mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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>
This commit is contained in:
1 parent
6df9fc2221
commit
54cc522e86
99 files changed
+4275
-192
No files matched your search
@@ -330,7 +330,7 @@ jobs:
|
||||
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
|
||||
- name: Mark the restored Runner release crate current
|
||||
if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }}
|
||||
@@ -843,7 +843,7 @@ jobs:
|
||||
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
|
||||
- name: Mark the restored Runner release crate current
|
||||
if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }}
|
||||
@@ -1060,7 +1060,7 @@ jobs:
|
||||
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
|
||||
- name: Mark the restored Runner release crate current
|
||||
if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }}
|
||||
@@ -1418,7 +1418,7 @@ jobs:
|
||||
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
|
||||
- name: Mark the restored Runner release crate current
|
||||
if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }}
|
||||
|
||||
@@ -471,7 +471,7 @@ jobs:
|
||||
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
|
||||
- name: Build the Runner release crate for its cache
|
||||
if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha && steps.runner_release_crate.outputs.cache-hit != 'true' }}
|
||||
@@ -492,7 +492,7 @@ jobs:
|
||||
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
|
||||
|
||||
build:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
|
||||
+3
-2
@@ -122,10 +122,11 @@ WORKDIR /app/packages/paperclip-runner
|
||||
COPY packages/paperclip-runner/rust-toolchain.toml ./
|
||||
COPY packages/paperclip-runner/runner ./runner
|
||||
COPY packages/paperclip-runner/protocol ./protocol
|
||||
COPY packages/paperclip-runner/acpx-profiles.json packages/paperclip-runner/antigravity-distributions.json ./
|
||||
# Cargo fingerprints source mtimes. Normalize them here and after the full
|
||||
# source copy below so a fresh checkout cannot invalidate unchanged inputs.
|
||||
RUN find runner protocol -type f -exec touch -d @0 {} + \
|
||||
&& touch -d @0 rust-toolchain.toml \
|
||||
&& touch -d @0 rust-toolchain.toml acpx-profiles.json antigravity-distributions.json \
|
||||
&& cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd
|
||||
|
||||
FROM runner-build AS build
|
||||
@@ -133,7 +134,7 @@ WORKDIR /app
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN find packages/paperclip-runner/runner packages/paperclip-runner/protocol -type f -exec touch -d @0 {} + \
|
||||
&& touch -d @0 packages/paperclip-runner/rust-toolchain.toml
|
||||
&& touch -d @0 packages/paperclip-runner/rust-toolchain.toml packages/paperclip-runner/acpx-profiles.json packages/paperclip-runner/antigravity-distributions.json
|
||||
# Both the browser bundle and server stamp need the source commit. Declare it
|
||||
# after the stable dependency layers, before either application build.
|
||||
ARG PAPERCLIP_BUILD_COMMIT=""
|
||||
|
||||
@@ -9,6 +9,7 @@ WORKDIR /workspace/packages/paperclip-runner/runner
|
||||
COPY packages/paperclip-runner/runner/Cargo.toml packages/paperclip-runner/runner/Cargo.lock ./
|
||||
COPY packages/paperclip-runner/runner/crates ./crates
|
||||
COPY packages/paperclip-runner/protocol ../protocol
|
||||
COPY packages/paperclip-runner/acpx-profiles.json packages/paperclip-runner/antigravity-distributions.json ../
|
||||
RUN cargo build --locked --release -j 2 -p paperclip-runner-core --bin paperclip-runnerd \
|
||||
&& strip /workspace/packages/paperclip-runner/runner/target/release/paperclip-runnerd
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
|
||||
{ value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: true, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "pi", label: "Pi", qualified: true, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] },
|
||||
{ value: "antigravity", label: "Google Antigravity", qualified: false, credentialEnvironment: ["GEMINI_API_KEY"] },
|
||||
] as const);
|
||||
|
||||
/** Exact Pi native thinking levels; aliases that silently clamp are not configuration. */
|
||||
|
||||
@@ -4,22 +4,32 @@
|
||||
"driverKind": "acpx_runtime",
|
||||
"protocolVersion": 1,
|
||||
"profiles": {
|
||||
"antigravity": {
|
||||
"agentProfileVersion": 1,
|
||||
"agentServerPackage": "native:antigravity-acp",
|
||||
"agentServerVersion": "1.4.3",
|
||||
"agentRuntimePackage": "native:antigravity-localharness",
|
||||
"agentRuntimeVersion": "1.4.3",
|
||||
"commandDigest": "sha256:acf5f574b6b9ec4388ff9d081890caa330e66bf1ae3fe3b08e7f0fb0e851b9ce",
|
||||
"requiresProviderPolicy": true,
|
||||
"qualificationStatus": "pending"
|
||||
},
|
||||
"grok": {
|
||||
"agentProfileVersion": 12,
|
||||
"agentProfileVersion": 13,
|
||||
"agentServerPackage": "builtin:grok-acp",
|
||||
"agentServerVersion": "1",
|
||||
"agentRuntimePackage": "native:grok",
|
||||
"agentRuntimeVersion": "1.0.13",
|
||||
"commandDigest": "sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a",
|
||||
"commandDigest": "sha256:e311f8111dd12f0c49bf12a80345cfe5f31a1222e0189209adeabf2f9bd0e1b0",
|
||||
"requiresProviderPolicy": false
|
||||
},
|
||||
"pi": {
|
||||
"agentProfileVersion": 34,
|
||||
"agentProfileVersion": 35,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"commandDigest": "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f",
|
||||
"commandDigest": "sha256:9473de6ef4baf2bd82fe8b6757239bdc160960b0a78ae6f47fa9e0996544226c",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"cursor": {
|
||||
@@ -32,12 +42,12 @@
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 81,
|
||||
"agentProfileVersion": 84,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c",
|
||||
"commandDigest": "sha256:a95f1d8e4d0a18d0c2cbc3bffdf998a2c5b8a582bdf51a72085b01d948d0a04e",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"claude": {
|
||||
|
||||
@@ -53,6 +53,16 @@
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "command": { "enum": ["initialize", "session.open"] } } },
|
||||
"then": { "properties": { "params": {
|
||||
"type": "object",
|
||||
"properties": { "candidateAdmission": { "$ref": "#/$defs/candidateAdmission" } },
|
||||
"if": { "properties": { "candidateAdmission": {} }, "required": ["candidateAdmission"] },
|
||||
"then": { "required": ["agent", "model"], "properties": { "agent": { "const": "antigravity" }, "model": { "type": "string" } } }
|
||||
} } },
|
||||
"else": { "properties": { "params": { "type": "object", "properties": { "candidateAdmission": false } } } }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "command": { "const": "turn.steer" } } },
|
||||
"then": { "properties": { "params": { "$ref": "#/$defs/turnControl" } } }
|
||||
@@ -63,6 +73,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidateAdmission": {
|
||||
"description": "The same closed record is accepted only from trusted runner startup CLI and repeated on sidecar initialize/open. Company authority is validated by the server; the runtime additionally checks compiled pins, actual run/session identity, native host, and expiry.",
|
||||
"type": "object",
|
||||
"required": ["schema", "companyId", "agent", "model", "profileDigest", "nativeClosureDigest", "platform", "architecture", "expiresAt", "runId", "normalizedSessionId"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.acpx-candidate-admission.v1" },
|
||||
"companyId": { "type": "string", "minLength": 1, "maxLength": 240, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" },
|
||||
"agent": { "const": "antigravity" },
|
||||
"model": { "type": "string", "minLength": 1, "maxLength": 512, "pattern": "^[^\\u0000-\\u001f\\u007f]+$" },
|
||||
"profileDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
||||
"nativeClosureDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
||||
"platform": { "const": "darwin" },
|
||||
"architecture": { "const": "arm64" },
|
||||
"expiresAt": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 },
|
||||
"runId": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" },
|
||||
"normalizedSessionId": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"piThinkingLevel": { "enum": ["off", "low", "high", "max"] },
|
||||
"sessionOpenThinkingLevel": {
|
||||
"type": "object",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::generated_acpx_profiles::acpx_release_profile;
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS};
|
||||
|
||||
const SCHEMA: &str = "paperclip.acpx-candidate-admission.v1";
|
||||
const MAX_LIFETIME_MS: u64 = 24 * 60 * 60 * 1_000;
|
||||
const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
const DISTRIBUTIONS: &str = include_str!("../../../../antigravity-distributions.json");
|
||||
|
||||
/// Operator authority supplied only through trusted process startup. Company
|
||||
/// membership is authorized by the server; an agent descriptor cannot mint it.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AcpxCandidateAdmission {
|
||||
pub schema: String,
|
||||
pub company_id: String,
|
||||
pub agent: String,
|
||||
pub model: String,
|
||||
pub profile_digest: String,
|
||||
pub native_closure_digest: String,
|
||||
pub platform: String,
|
||||
pub architecture: String,
|
||||
pub expires_at: u64,
|
||||
pub run_id: String,
|
||||
pub normalized_session_id: String,
|
||||
}
|
||||
|
||||
fn invalid() -> LocalRunnerError {
|
||||
LocalRunnerError::invalid(
|
||||
"ACPX candidate admission is missing, expired, or does not match the exact authorized execution",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn current_time_ms() -> Result<u64, LocalRunnerError> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|duration| u64::try_from(duration.as_millis()).ok())
|
||||
.ok_or_else(invalid)
|
||||
}
|
||||
|
||||
impl AcpxCandidateAdmission {
|
||||
pub fn parse_json(encoded: &str) -> Result<Self, LocalRunnerError> {
|
||||
if encoded.len() > 4_096 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let admission: Self = serde_json::from_str(encoded).map_err(|_| invalid())?;
|
||||
admission.validate()?;
|
||||
Ok(admission)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), LocalRunnerError> {
|
||||
self.validate_at(
|
||||
current_time_ms()?,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_at(&self, now: u64, os: &str, architecture: &str) -> Result<(), LocalRunnerError> {
|
||||
let profile = acpx_release_profile("antigravity").ok_or_else(invalid)?;
|
||||
let manifest: Value = serde_json::from_str(DISTRIBUTIONS).map_err(|_| invalid())?;
|
||||
let closure = manifest
|
||||
.pointer("/platforms/darwin-arm64/closureSha256")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(invalid)?;
|
||||
if self.schema != SCHEMA
|
||||
|| !is_stable_id(&self.company_id, DURABLE_STABLE_ID_CHARS)
|
||||
|| self.agent != "antigravity"
|
||||
|| self.model.trim().is_empty()
|
||||
|| self.model != self.model.trim()
|
||||
|| self.model.len() > 512
|
||||
|| self.model.chars().any(char::is_control)
|
||||
|| matches!(self.model.to_ascii_lowercase().as_str(), "auto" | "default")
|
||||
|| self.profile_digest != profile.command_digest
|
||||
|| self.native_closure_digest != format!("sha256:{closure}")
|
||||
|| self.platform != "darwin"
|
||||
|| self.architecture != "arm64"
|
||||
|| os != "macos"
|
||||
|| architecture != "aarch64"
|
||||
|| self.expires_at > MAX_JSON_SAFE_INTEGER
|
||||
|| self.expires_at <= now
|
||||
|| self.expires_at > now.saturating_add(MAX_LIFETIME_MS)
|
||||
|| !is_stable_id(&self.run_id, SHORT_STABLE_ID_CHARS)
|
||||
|| !is_stable_id(&self.normalized_session_id, SHORT_STABLE_ID_CHARS)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_binding(
|
||||
&self,
|
||||
agent: &str,
|
||||
model: &str,
|
||||
run_id: &str,
|
||||
normalized_session_id: &str,
|
||||
) -> Result<(), LocalRunnerError> {
|
||||
self.validate()?;
|
||||
if self.agent != agent
|
||||
|| self.model != model
|
||||
|| self.run_id != run_id
|
||||
|| self.normalized_session_id != normalized_session_id
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_admission(run_id: &str, session_id: &str) -> AcpxCandidateAdmission {
|
||||
let manifest: Value = serde_json::from_str(DISTRIBUTIONS).unwrap();
|
||||
AcpxCandidateAdmission {
|
||||
schema: SCHEMA.to_owned(),
|
||||
company_id: "company-1".to_owned(),
|
||||
agent: "antigravity".to_owned(),
|
||||
model: "explicit-model".to_owned(),
|
||||
profile_digest: acpx_release_profile("antigravity")
|
||||
.unwrap()
|
||||
.command_digest
|
||||
.to_owned(),
|
||||
native_closure_digest: format!(
|
||||
"sha256:{}",
|
||||
manifest["platforms"]["darwin-arm64"]["closureSha256"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
),
|
||||
platform: "darwin".to_owned(),
|
||||
architecture: "arm64".to_owned(),
|
||||
expires_at: current_time_ms().unwrap() + 60_000,
|
||||
run_id: run_id.to_owned(),
|
||||
normalized_session_id: session_id.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn candidate_record_is_strict_and_pinned_to_native_host_and_time() {
|
||||
let candidate = test_admission("run-1", "session-1");
|
||||
let now = candidate.expires_at - 1;
|
||||
candidate.validate_at(now, "macos", "aarch64").unwrap();
|
||||
for (os, architecture) in [("linux", "aarch64"), ("macos", "x86_64")] {
|
||||
assert!(candidate.validate_at(now, os, architecture).is_err());
|
||||
}
|
||||
assert!(candidate
|
||||
.validate_at(candidate.expires_at, "macos", "aarch64")
|
||||
.is_err());
|
||||
assert!(candidate
|
||||
.validate_at(
|
||||
candidate.expires_at - MAX_LIFETIME_MS - 1,
|
||||
"macos",
|
||||
"aarch64"
|
||||
)
|
||||
.is_err());
|
||||
for (field, value) in [
|
||||
("schema", json!("unknown")),
|
||||
("companyId", json!("bad company")),
|
||||
("agent", json!("copilot")),
|
||||
("model", json!("auto")),
|
||||
("profileDigest", json!("sha256:wrong")),
|
||||
("nativeClosureDigest", json!("sha256:wrong")),
|
||||
("platform", json!("linux")),
|
||||
("architecture", json!("x64")),
|
||||
("runId", json!("bad/run")),
|
||||
("normalizedSessionId", json!("bad/session")),
|
||||
] {
|
||||
let mut changed = serde_json::to_value(&candidate).unwrap();
|
||||
changed[field] = value;
|
||||
let changed: AcpxCandidateAdmission = serde_json::from_value(changed).unwrap();
|
||||
assert!(
|
||||
changed.validate_at(now, "macos", "aarch64").is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let mut value = serde_json::to_value(&candidate).unwrap();
|
||||
value["extra"] = json!(true);
|
||||
assert!(serde_json::from_value::<AcpxCandidateAdmission>(value).is_err());
|
||||
assert!(AcpxCandidateAdmission::parse_json(&"x".repeat(4_097)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_binds_actual_launch_and_does_not_echo_bad_input() {
|
||||
let candidate = test_admission("run-1", "session-1");
|
||||
candidate
|
||||
.validate_binding("antigravity", "explicit-model", "run-1", "session-1")
|
||||
.unwrap();
|
||||
for expected in [
|
||||
("copilot", "explicit-model", "run-1", "session-1"),
|
||||
("antigravity", "another-model", "run-1", "session-1"),
|
||||
("antigravity", "explicit-model", "run-2", "session-1"),
|
||||
("antigravity", "explicit-model", "run-1", "session-2"),
|
||||
] {
|
||||
assert!(candidate
|
||||
.validate_binding(expected.0, expected.1, expected.2, expected.3)
|
||||
.is_err());
|
||||
}
|
||||
let error = AcpxCandidateAdmission::parse_json("private malformed input").unwrap_err();
|
||||
assert!(!error.to_string().contains("private"));
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,18 @@ impl AcpxProviderDescriptor {
|
||||
expected_identity: Option<AcpxProviderSessionIdentity>,
|
||||
launch_profile: Option<&AcpxLaunchProfile>,
|
||||
) -> Result<AcpxProviderSessionConfig, DurableRunnerError> {
|
||||
let candidate_admission =
|
||||
launch_profile.and_then(|profile| profile.candidate_admission.clone());
|
||||
if let Some(admission) = &candidate_admission {
|
||||
admission
|
||||
.validate_binding(
|
||||
&self.agent,
|
||||
&self.model,
|
||||
&self.run_id,
|
||||
&self.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
}
|
||||
secure_directory(&self.runtime_directory, "ACPX runtime")?;
|
||||
let transport = self.verified_transport(launch_profile)?;
|
||||
// Keep the controller's full authority catalog durable so terminal
|
||||
@@ -375,6 +387,7 @@ impl AcpxProviderDescriptor {
|
||||
operations: provider_operations,
|
||||
};
|
||||
Ok(AcpxProviderSessionConfig {
|
||||
candidate_admission,
|
||||
transport,
|
||||
agent: self.agent.clone(),
|
||||
model: self.model.clone(),
|
||||
@@ -873,6 +886,14 @@ impl AcpxCommandExecutor {
|
||||
self.state_dir.join(ACPX_PROVIDER_STATE_FILE)
|
||||
}
|
||||
|
||||
fn candidate_admission(
|
||||
&self,
|
||||
) -> Option<&crate::acpx_candidate_admission::AcpxCandidateAdmission> {
|
||||
self.launch_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.candidate_admission.as_ref())
|
||||
}
|
||||
|
||||
fn launch_profile_digest(&self) -> Result<String, DurableRunnerError> {
|
||||
self.launch_profile
|
||||
.as_ref()
|
||||
@@ -915,6 +936,11 @@ impl AcpxCommandExecutor {
|
||||
)))
|
||||
}
|
||||
};
|
||||
if self.candidate_admission().is_some() {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot adopt a prior durable runner activation",
|
||||
));
|
||||
}
|
||||
let length = file
|
||||
.metadata()
|
||||
.map_err(|error| {
|
||||
@@ -1131,6 +1157,16 @@ impl AcpxCommandExecutor {
|
||||
DurableRunnerError::invalid(format!("run.prepare ACPX provider is invalid: {error}"))
|
||||
})?;
|
||||
descriptor.validate(&self.context)?;
|
||||
if let Some(admission) = self.candidate_admission() {
|
||||
admission
|
||||
.validate_binding(
|
||||
&descriptor.agent,
|
||||
&descriptor.model,
|
||||
&descriptor.run_id,
|
||||
&descriptor.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
}
|
||||
let tool_set = authorized_tool_set(payload)?;
|
||||
let launch_profile_digest = self.launch_profile_digest()?;
|
||||
if let Some(state) = self.state.as_ref() {
|
||||
@@ -1160,6 +1196,11 @@ impl AcpxCommandExecutor {
|
||||
}
|
||||
|
||||
fn attach_run(&mut self, payload: &Value) -> Result<(), DurableRunnerError> {
|
||||
if self.candidate_admission().is_some() {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot rotate or adopt a live runner authority",
|
||||
));
|
||||
}
|
||||
let descriptor: AcpxProviderDescriptor = serde_json::from_value(
|
||||
payload
|
||||
.get("provider")
|
||||
@@ -1954,6 +1995,9 @@ impl AcpxCommandExecutor {
|
||||
}
|
||||
|
||||
fn suspend(&mut self) -> Result<CommandExecution, DurableRunnerError> {
|
||||
if self.candidate_admission().is_some() {
|
||||
return self.close_session("candidate runner.suspend retires the owned process");
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.as_ref()
|
||||
@@ -2189,6 +2233,14 @@ fn goal_terminal_disposition<'a>(
|
||||
impl CommandExecutor for AcpxCommandExecutor {
|
||||
fn execute(&mut self, command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
self.restore()?;
|
||||
if self.candidate_admission().is_some()
|
||||
&& command.command_type == "run.attach"
|
||||
&& command.payload.get("paperclipNextAuthority").is_some()
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot rotate a live runner authority",
|
||||
));
|
||||
}
|
||||
if command.command_type != "run.attach"
|
||||
&& self
|
||||
.state
|
||||
@@ -2788,6 +2840,7 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command_path.clone(),
|
||||
args: Vec::new(),
|
||||
@@ -3246,7 +3299,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pi_profile_matches_published_identity_and_rejects_prior_profiles() {
|
||||
let published: Value = serde_json::from_str(include_str!(
|
||||
"../../../../test-fixtures/pi-acp/profile-v34-identity.json"
|
||||
"../../../../test-fixtures/pi-acp/profile-v35-identity.json"
|
||||
))
|
||||
.unwrap();
|
||||
let mut value = descriptor("codex");
|
||||
@@ -3263,6 +3316,7 @@ mod tests {
|
||||
current.validate(&context()).unwrap();
|
||||
|
||||
for prior in [
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v34-identity.json"),
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v33-identity.json"),
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v32-identity.json"),
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v31-identity.json"),
|
||||
@@ -3314,6 +3368,7 @@ mod tests {
|
||||
write_artifact(&sidecar, b"qualified sidecar", false);
|
||||
let args = vec![sidecar.to_string_lossy().into_owned()];
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
@@ -3344,6 +3399,36 @@ mod tests {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_authority_cannot_come_from_descriptor_or_recover_a_prior_runner() {
|
||||
let directory = temporary_directory("candidate-authority");
|
||||
let mut admission = crate::acpx_candidate_admission::test_admission("run-1", "session-1");
|
||||
admission.model = "explicit-test-model".to_owned();
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: Some(admission.clone()),
|
||||
authority_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
command: PathBuf::from("/qualified/node"),
|
||||
args: vec!["/qualified/acpx-sidecar.js".to_owned()],
|
||||
artifacts: Vec::new(),
|
||||
};
|
||||
let mut injected = descriptor("antigravity");
|
||||
injected["candidateAdmission"] = json!(admission);
|
||||
assert!(serde_json::from_value::<AcpxProviderDescriptor>(injected).is_err());
|
||||
let config = test_config(&directory, Some(profile));
|
||||
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
let mut wrong = descriptor("antigravity");
|
||||
wrong["model"] = json!("another-model");
|
||||
assert!(executor.prepare(&json!({"provider":wrong})).is_err());
|
||||
assert!(!executor.state_path().exists());
|
||||
assert!(executor.attach_run(&json!({})).is_err());
|
||||
write_artifact(&executor.state_path(), b"{}", false);
|
||||
let error = executor.restore().unwrap_err().to_string();
|
||||
assert!(error.contains("cannot adopt a prior durable runner"));
|
||||
assert!(executor.session.is_none());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod darwin_named_transport_cleanup {
|
||||
use super::*;
|
||||
@@ -3363,6 +3448,7 @@ mod tests {
|
||||
write_artifact(&sidecar, b"qualified sidecar", false);
|
||||
let args = vec![sidecar.to_string_lossy().into_owned()];
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
@@ -3484,6 +3570,7 @@ mod tests {
|
||||
write_artifact(&command, b"qualified node", true);
|
||||
symlink(&command, &command_link).unwrap();
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command_link.clone(),
|
||||
args: Vec::new(),
|
||||
@@ -3542,6 +3629,7 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: Vec::new(),
|
||||
@@ -3824,6 +3912,7 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: Vec::new(),
|
||||
@@ -4147,6 +4236,7 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: Vec::new(),
|
||||
@@ -4422,6 +4512,7 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: Vec::new(),
|
||||
@@ -4590,6 +4681,7 @@ mod tests {
|
||||
let sidecar = directory.join("sidecar");
|
||||
write_artifact(&sidecar, b"qualified sidecar", true);
|
||||
let launch_profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: sidecar.clone(),
|
||||
args: Vec::new(),
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct AcpxProviderRuntimePolicy {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcpxProviderSessionConfig {
|
||||
pub candidate_admission: Option<crate::acpx_candidate_admission::AcpxCandidateAdmission>,
|
||||
pub transport: AcpxSidecarTransportConfig,
|
||||
pub agent: String,
|
||||
pub model: String,
|
||||
@@ -117,6 +118,18 @@ impl AcpxProviderSessionConfig {
|
||||
let profile = acpx_release_profile(&self.agent).ok_or_else(|| {
|
||||
LocalRunnerError::invalid("ACPX agent must name a known immutable profile")
|
||||
})?;
|
||||
if let Some(admission) = &self.candidate_admission {
|
||||
admission.validate_binding(
|
||||
&self.agent,
|
||||
&self.model,
|
||||
&self.run_id,
|
||||
&self.normalized_session_id,
|
||||
)?;
|
||||
} else if profile.qualification_pending {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX provider qualification is pending; this build cannot launch the provider",
|
||||
));
|
||||
}
|
||||
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
|
||||
if self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
self.agent != "claude"
|
||||
@@ -475,6 +488,14 @@ impl AcpxProviderSession {
|
||||
"ACPX provider session still has unsettled semantic tools",
|
||||
));
|
||||
}
|
||||
if let Some(admission) = &self.config.candidate_admission {
|
||||
admission.validate_binding(
|
||||
&self.config.agent,
|
||||
&self.config.model,
|
||||
&self.config.run_id,
|
||||
&self.config.normalized_session_id,
|
||||
)?;
|
||||
}
|
||||
let rotate_turn_identity_ledger = self.state.settled_turn_identity_capacity_reached();
|
||||
let identity_validation = if rotate_turn_identity_ledger {
|
||||
self.state
|
||||
@@ -1367,6 +1388,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu
|
||||
"tools": &sidecar_tools,
|
||||
"expectedIdentity": config.expected_identity,
|
||||
});
|
||||
if let Some(admission) = &config.candidate_admission {
|
||||
params["candidateAdmission"] = json!(admission);
|
||||
}
|
||||
if let Some(effort) = &config.claude_effort {
|
||||
params["claudeEffort"] = json!(effort);
|
||||
}
|
||||
@@ -1385,6 +1409,14 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu
|
||||
params
|
||||
}
|
||||
|
||||
fn initialize_params(config: &AcpxProviderSessionConfig) -> Value {
|
||||
let mut params = json!({"agent": config.agent, "model": config.model});
|
||||
if let Some(admission) = &config.candidate_admission {
|
||||
params["candidateAdmission"] = json!(admission);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn bootstrap(
|
||||
transport: &mut AcpxSidecarTransport,
|
||||
config: &AcpxProviderSessionConfig,
|
||||
@@ -1399,7 +1431,7 @@ fn bootstrap(
|
||||
let sidecar_tools = sidecar_run_tool_operations(&config.tool_set);
|
||||
let initialized = transport.request(
|
||||
GeneratedAcpxSidecarCommand::Initialize,
|
||||
json!({"agent": config.agent, "model": config.model}),
|
||||
initialize_params(config),
|
||||
)?;
|
||||
verify_initialize_response(&initialized, transport.process_id())?;
|
||||
|
||||
@@ -1813,6 +1845,7 @@ mod mode_tests {
|
||||
fn config() -> AcpxProviderSessionConfig {
|
||||
let operations = Vec::new();
|
||||
AcpxProviderSessionConfig {
|
||||
candidate_admission: None,
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: std::env::current_exe().unwrap(),
|
||||
args: Vec::new(),
|
||||
@@ -1866,6 +1899,62 @@ mod mode_tests {
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn pending_profile_cannot_launch_even_with_explicit_policy() {
|
||||
let mut config = config();
|
||||
config.agent = "antigravity".to_owned();
|
||||
config.pi_thinking_level = None;
|
||||
assert!(config
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("qualification is pending"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_admission_is_bound_and_carried_through_both_bootstrap_requests() {
|
||||
let mut cfg = config();
|
||||
assert!(initialize_params(&cfg).get("candidateAdmission").is_none());
|
||||
assert!(session_open_params(&cfg, &[])
|
||||
.get("candidateAdmission")
|
||||
.is_none());
|
||||
cfg.agent = "antigravity".to_owned();
|
||||
cfg.mode = None;
|
||||
let admission = crate::acpx_candidate_admission::test_admission(
|
||||
&cfg.run_id,
|
||||
&cfg.normalized_session_id,
|
||||
);
|
||||
cfg.candidate_admission = Some(admission.clone());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(
|
||||
initialize_params(&cfg)["candidateAdmission"],
|
||||
json!(admission)
|
||||
);
|
||||
assert_eq!(
|
||||
session_open_params(&cfg, &[])["candidateAdmission"],
|
||||
json!(admission)
|
||||
);
|
||||
for (field, value) in [
|
||||
("run", "different-run"),
|
||||
("session", "different-session"),
|
||||
("model", "different-model"),
|
||||
("agent", "codex"),
|
||||
] {
|
||||
let mut invalid = cfg.clone();
|
||||
match field {
|
||||
"run" => invalid.run_id = value.to_owned(),
|
||||
"session" => invalid.normalized_session_id = value.to_owned(),
|
||||
"model" => invalid.model = value.to_owned(),
|
||||
"agent" => invalid.agent = value.to_owned(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(invalid.validate().is_err(), "{field}");
|
||||
}
|
||||
cfg.candidate_admission.as_mut().unwrap().expires_at = 0;
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_explicit_and_provider_verified_for_every_agent() {
|
||||
let mut config = config();
|
||||
|
||||
@@ -216,6 +216,10 @@ impl AcpxSidecarTransport {
|
||||
"pi" => &[],
|
||||
"cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
"copilot" => &["COPILOT_GITHUB_TOKEN"],
|
||||
"antigravity" => &[
|
||||
"GEMINI_API_KEY",
|
||||
"PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET",
|
||||
],
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX sidecar credentials require a known agent profile",
|
||||
@@ -250,7 +254,7 @@ impl AcpxSidecarTransport {
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
|
||||
];
|
||||
if matches!(agent, "pi" | "cursor" | "copilot") {
|
||||
if matches!(agent, "pi" | "cursor" | "copilot" | "antigravity") {
|
||||
// Credential values alone are not proof of an explicit task binding.
|
||||
// The sidecar checks this controller-minted provider/session marker.
|
||||
keys.push("PAPERCLIP_ACPX_CREDENTIAL_BINDING");
|
||||
|
||||
@@ -240,7 +240,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"hasToken": std::env::var("PAPERCLIP_NATIVE_MCP_TOKEN").is_ok(),
|
||||
"hasUnrelatedSecret": std::env::var("UNRELATED_EVAL_SECRET").is_ok(),
|
||||
"credentialBinding": std::env::var("PAPERCLIP_ACPX_CREDENTIAL_BINDING").ok(),
|
||||
"credentialKeys": (["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "MY_PI_SERVICE_KEY", "LD_API_KEY", "DYLD_API_KEY", "PAPERCLIP_PI_PROVIDERS", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"].into_iter().filter(|key| std::env::var(key).is_ok()).collect::<Vec<_>>()),
|
||||
"credentialKeys": (["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET", "MY_PI_SERVICE_KEY", "LD_API_KEY", "DYLD_API_KEY", "PAPERCLIP_PI_PROVIDERS", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"].into_iter().filter(|key| std::env::var(key).is_ok()).collect::<Vec<_>>()),
|
||||
"ambientAntigravityKeys": (["GEMINI_HOME", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_API_KEY", "GOOGLE_CLOUD_PROJECT", "AGY_ACP_FORCE_FILE_STORAGE", "PYTHONPATH", "PYTHONHOME"].into_iter().filter(|key| std::env::var(key).is_ok()).collect::<Vec<_>>()),
|
||||
}
|
||||
}),
|
||||
)?;
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
|
||||
use paperclip_runner_core::acpx_candidate_admission::AcpxCandidateAdmission;
|
||||
use paperclip_runner_core::durable::{
|
||||
capture_bootstrap_ticket, redact_diagnostic_text, run_durable_runner, AcpxLaunchProfile,
|
||||
DurableRunnerConfig, OpenCodeLaunchProfile, QualifiedLaunchArtifact,
|
||||
@@ -249,6 +250,9 @@ fn optional_u64(args: &[String], name: &str) -> Result<Option<u64>, LocalRunnerE
|
||||
}
|
||||
|
||||
fn acpx_launch_profile(args: &[String]) -> Result<Option<AcpxLaunchProfile>, LocalRunnerError> {
|
||||
let candidate_admission = optional_value(args, "--acpx-candidate-admission-json")?
|
||||
.map(|encoded| AcpxCandidateAdmission::parse_json(&encoded))
|
||||
.transpose()?;
|
||||
let authority_digest = optional_value(args, "--acpx-launch-authority-digest")?;
|
||||
let command = optional_value(args, "--acpx-sidecar-command")?;
|
||||
let command_sha256 = optional_value(args, "--acpx-sidecar-command-sha256")?;
|
||||
@@ -261,7 +265,7 @@ fn acpx_launch_profile(args: &[String]) -> Result<Option<AcpxLaunchProfile>, Loc
|
||||
sidecar,
|
||||
sidecar_sha256,
|
||||
) {
|
||||
(None, None, None, None, None) => Ok(None),
|
||||
(None, None, None, None, None) if candidate_admission.is_none() => Ok(None),
|
||||
(
|
||||
Some(authority_digest),
|
||||
Some(command),
|
||||
@@ -272,6 +276,7 @@ fn acpx_launch_profile(args: &[String]) -> Result<Option<AcpxLaunchProfile>, Loc
|
||||
let command = PathBuf::from(command);
|
||||
let sidecar = PathBuf::from(sidecar);
|
||||
Ok(Some(AcpxLaunchProfile {
|
||||
candidate_admission,
|
||||
authority_digest,
|
||||
command: command.clone(),
|
||||
args: vec![sidecar.to_string_lossy().into_owned()],
|
||||
@@ -473,6 +478,14 @@ fn run(args: &[String]) -> Result<(), LocalRunnerError> {
|
||||
{
|
||||
return run_durable(args);
|
||||
}
|
||||
if args
|
||||
.iter()
|
||||
.any(|argument| argument == "--acpx-candidate-admission-json")
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX candidate admission requires trusted durable runner startup",
|
||||
));
|
||||
}
|
||||
run_local_runner(RunnerConfig {
|
||||
run_id: value(args, "--run-id")?,
|
||||
normalized_session_id: value(args, "--session-id")?,
|
||||
@@ -573,6 +586,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_startup_requires_a_complete_trusted_launch_profile() {
|
||||
let profiles: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../../../acpx-profiles.json")).unwrap();
|
||||
let distributions: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../../../antigravity-distributions.json"
|
||||
))
|
||||
.unwrap();
|
||||
let encoded = json!({
|
||||
"schema":"paperclip.acpx-candidate-admission.v1", "companyId":"company-1", "agent":"antigravity", "model":"exact-model",
|
||||
"profileDigest":profiles["profiles"]["antigravity"]["commandDigest"],
|
||||
"nativeClosureDigest":format!("sha256:{}",distributions["platforms"]["darwin-arm64"]["closureSha256"].as_str().unwrap()),
|
||||
"platform":"darwin", "architecture":"arm64", "expiresAt":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64 + 60_000,
|
||||
"runId":"run-1", "normalizedSessionId":"session-1"
|
||||
}).to_string();
|
||||
let mut args = vec!["--acpx-candidate-admission-json".to_owned(), encoded];
|
||||
assert!(acpx_launch_profile(&args).is_err());
|
||||
assert!(run(&args).is_err());
|
||||
args.extend([
|
||||
"--acpx-launch-authority-digest".to_owned(),
|
||||
format!("sha256:{}", "a".repeat(64)),
|
||||
"--acpx-sidecar-command".to_owned(),
|
||||
"/qualified/node".to_owned(),
|
||||
"--acpx-sidecar-command-sha256".to_owned(),
|
||||
format!("sha256:{}", "b".repeat(64)),
|
||||
"--acpx-sidecar-script".to_owned(),
|
||||
"/qualified/sidecar.cjs".to_owned(),
|
||||
"--acpx-sidecar-script-sha256".to_owned(),
|
||||
format!("sha256:{}", "c".repeat(64)),
|
||||
]);
|
||||
let parsed = acpx_launch_profile(&args).unwrap().unwrap();
|
||||
assert_eq!(parsed.candidate_admission.unwrap().run_id, "run-1");
|
||||
assert!(acpx_launch_profile(&[]).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_inventory_rejects_task_owned_roots_and_writable_aliases() {
|
||||
let directory =
|
||||
|
||||
@@ -118,6 +118,7 @@ pub struct AcpxLaunchProfile {
|
||||
pub command: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub artifacts: Vec<QualifiedLaunchArtifact>,
|
||||
pub candidate_admission: Option<crate::acpx_candidate_admission::AcpxCandidateAdmission>,
|
||||
}
|
||||
|
||||
impl AcpxLaunchProfile {
|
||||
@@ -163,6 +164,17 @@ impl AcpxLaunchProfile {
|
||||
);
|
||||
update(&mut digest, artifact.sha256.as_bytes());
|
||||
}
|
||||
if let Some(admission) = &self.candidate_admission {
|
||||
update(&mut digest, b"paperclip.acpx-candidate-admission.v1");
|
||||
update(
|
||||
&mut digest,
|
||||
&serde_json::to_vec(admission).map_err(|_| {
|
||||
DurableRunnerError::invalid(
|
||||
"ACPX candidate admission could not be bound to launch identity",
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(format!("sha256:{:x}", digest.finalize()))
|
||||
}
|
||||
}
|
||||
@@ -257,6 +269,16 @@ impl DurableRunnerConfig {
|
||||
));
|
||||
}
|
||||
if let Some(profile) = self.acpx_launch_profile.as_ref() {
|
||||
if let Some(admission) = &profile.candidate_admission {
|
||||
admission
|
||||
.validate_binding(
|
||||
&admission.agent,
|
||||
&admission.model,
|
||||
&self.run_id,
|
||||
&self.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
}
|
||||
if profile.authority_digest.len() != 71
|
||||
|| !profile.authority_digest.starts_with("sha256:")
|
||||
|| !profile.authority_digest[7..]
|
||||
@@ -349,6 +371,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_launch_digest_binds_authority_and_config_binds_actual_identity() {
|
||||
let mut profile = AcpxLaunchProfile {
|
||||
authority_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
command: PathBuf::from("/qualified/node"),
|
||||
args: vec!["/qualified/sidecar.cjs".to_owned()],
|
||||
artifacts: vec![QualifiedLaunchArtifact {
|
||||
path: PathBuf::from("/qualified/node"),
|
||||
sha256: format!("sha256:{}", "b".repeat(64)),
|
||||
}],
|
||||
candidate_admission: None,
|
||||
};
|
||||
let ordinary = profile.canonical_digest().unwrap();
|
||||
assert_eq!(
|
||||
ordinary,
|
||||
"sha256:652c2eb4b49d3a38b028ea5a9803a91188c14a283f1bb478d823681b95be69c6"
|
||||
);
|
||||
profile.candidate_admission = Some(crate::acpx_candidate_admission::test_admission(
|
||||
"run-1",
|
||||
"session-1",
|
||||
));
|
||||
let candidate = profile.canonical_digest().unwrap();
|
||||
assert_ne!(ordinary, candidate);
|
||||
let mut config = config();
|
||||
config.acpx_launch_profile = Some(profile.clone());
|
||||
config.validate().unwrap();
|
||||
config.run_id = "wrong-run".to_owned();
|
||||
assert!(config.validate().is_err());
|
||||
profile.candidate_admission.as_mut().unwrap().company_id = "company-2".to_owned();
|
||||
assert_ne!(candidate, profile.canonical_digest().unwrap());
|
||||
profile.candidate_admission = None;
|
||||
assert_eq!(ordinary, profile.canonical_digest().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_durable_event_identity_boundaries() {
|
||||
let mut boundary = config();
|
||||
|
||||
@@ -394,7 +394,25 @@ pub fn run_durable_runner<E: CommandExecutor>(
|
||||
bootstrap_ticket: BootstrapTicket,
|
||||
mut executor: E,
|
||||
) -> Result<(), DurableRunnerError> {
|
||||
let started = Instant::now();
|
||||
config.validate()?;
|
||||
if let Some(admission) = config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.candidate_admission.as_ref())
|
||||
{
|
||||
let remaining =
|
||||
Duration::from_millis(admission.expires_at.saturating_sub(current_unix_ms()?));
|
||||
// Candidate authority is absolute. Include startup/state work in this
|
||||
// process deadline, and never let a caller's unlimited runtime widen it.
|
||||
config.max_runtime = if config.max_runtime.is_zero() {
|
||||
remaining.max(Duration::from_nanos(1))
|
||||
} else {
|
||||
config
|
||||
.max_runtime
|
||||
.min(remaining.max(Duration::from_nanos(1)))
|
||||
};
|
||||
}
|
||||
let store = DurableStateStore::new(&config.state_dir)?;
|
||||
let (mut state, recovered) = store.load_or_create(&config)?;
|
||||
if state.lifecycle == "revoked"
|
||||
@@ -422,7 +440,6 @@ pub fn run_durable_runner<E: CommandExecutor>(
|
||||
// reconnects retain the same validated addresses so DNS cannot redirect a
|
||||
// retry after the trust decision.
|
||||
let mut endpoint = RunnerTransportEndpoint::new(&config.connect_url, &config.run_id)?;
|
||||
let started = Instant::now();
|
||||
let mut bootstrap_ticket = Some(bootstrap_ticket);
|
||||
let mut lease: Option<LeaseCredential> = None;
|
||||
let mut authenticated_once = false;
|
||||
|
||||
@@ -10,18 +10,30 @@ pub(crate) struct AcpxReleaseProfile {
|
||||
pub agent_runtime_version: Option<&'static str>,
|
||||
pub command_digest: &'static str,
|
||||
pub requires_provider_policy: bool,
|
||||
pub qualification_pending: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
Some(match agent {
|
||||
"antigravity" => AcpxReleaseProfile {
|
||||
agent_server_package: "native:antigravity-acp",
|
||||
agent_server_version: "1.4.3",
|
||||
agent_runtime_package: Some("native:antigravity-localharness"),
|
||||
agent_runtime_version: Some("1.4.3"),
|
||||
command_digest:
|
||||
"sha256:acf5f574b6b9ec4388ff9d081890caa330e66bf1ae3fe3b08e7f0fb0e851b9ce",
|
||||
requires_provider_policy: true,
|
||||
qualification_pending: true,
|
||||
},
|
||||
"grok" => AcpxReleaseProfile {
|
||||
agent_server_package: "builtin:grok-acp",
|
||||
agent_server_version: "1",
|
||||
agent_runtime_package: Some("native:grok"),
|
||||
agent_runtime_version: Some("1.0.13"),
|
||||
command_digest:
|
||||
"sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a",
|
||||
"sha256:e311f8111dd12f0c49bf12a80345cfe5f31a1222e0189209adeabf2f9bd0e1b0",
|
||||
requires_provider_policy: false,
|
||||
qualification_pending: false,
|
||||
},
|
||||
"pi" => AcpxReleaseProfile {
|
||||
agent_server_package: "pi-acp",
|
||||
@@ -29,8 +41,9 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: Some("@earendil-works/pi-coding-agent"),
|
||||
agent_runtime_version: Some("1.0.0"),
|
||||
command_digest:
|
||||
"sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f",
|
||||
"sha256:9473de6ef4baf2bd82fe8b6757239bdc160960b0a78ae6f47fa9e0996544226c",
|
||||
requires_provider_policy: true,
|
||||
qualification_pending: false,
|
||||
},
|
||||
"cursor" => AcpxReleaseProfile {
|
||||
agent_server_package: "cursor-agent",
|
||||
@@ -40,6 +53,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
command_digest:
|
||||
"sha256:759e7415fea8ed2b6b0e6f362b57652b8768835f9d1fdae4a94c1b5eb3669301",
|
||||
requires_provider_policy: true,
|
||||
qualification_pending: false,
|
||||
},
|
||||
"copilot" => AcpxReleaseProfile {
|
||||
agent_server_package: "@github/copilot",
|
||||
@@ -47,8 +61,9 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c",
|
||||
"sha256:a95f1d8e4d0a18d0c2cbc3bffdf998a2c5b8a582bdf51a72085b01d948d0a04e",
|
||||
requires_provider_policy: true,
|
||||
qualification_pending: false,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
agent_server_package: "@agentclientprotocol/claude-agent-acp",
|
||||
@@ -58,6 +73,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
command_digest:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
requires_provider_policy: false,
|
||||
qualification_pending: false,
|
||||
},
|
||||
"codex" => AcpxReleaseProfile {
|
||||
agent_server_package: "@agentclientprotocol/codex-acp",
|
||||
@@ -67,6 +83,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
command_digest:
|
||||
"sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc",
|
||||
requires_provider_policy: false,
|
||||
qualification_pending: false,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod acpx_candidate_admission;
|
||||
pub mod acpx_event_payload;
|
||||
pub mod acpx_event_scope;
|
||||
pub mod acpx_provider_backend;
|
||||
|
||||
@@ -133,6 +133,27 @@ impl NativeProviderCommandExecutor {
|
||||
}
|
||||
|
||||
fn select_recovery(&mut self) -> Result<(), DurableRunnerError> {
|
||||
if !self.recovery_checked
|
||||
&& self
|
||||
.config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.is_some_and(|profile| profile.candidate_admission.is_some())
|
||||
&& [
|
||||
CODEX_PROVIDER_STATE_FILE,
|
||||
ACPX_PROVIDER_STATE_FILE,
|
||||
MANAGED_PROVIDER_STATE_FILE,
|
||||
DOT_PROVIDER_STATE_FILE,
|
||||
MUSE_PROVIDER_STATE_FILE,
|
||||
"credential-observer-state.json",
|
||||
]
|
||||
.iter()
|
||||
.any(|file| self.state_dir.join(file).exists())
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot adopt a prior durable runner activation",
|
||||
));
|
||||
}
|
||||
self.credentials.restore()?;
|
||||
if self.recovery_checked {
|
||||
return Ok(());
|
||||
@@ -190,6 +211,32 @@ impl NativeProviderCommandExecutor {
|
||||
"run.prepare requires a supported provider kind before runner execution",
|
||||
)
|
||||
})?;
|
||||
if let Some(admission) = self
|
||||
.config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.candidate_admission.as_ref())
|
||||
{
|
||||
if kind != "acpx" {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot authorize another provider kind",
|
||||
));
|
||||
}
|
||||
admission
|
||||
.validate_binding(
|
||||
payload
|
||||
.pointer("/provider/agent")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(""),
|
||||
payload
|
||||
.pointer("/provider/model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(""),
|
||||
&self.config.run_id,
|
||||
&self.config.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
}
|
||||
self.selected = Some(match kind {
|
||||
"muse" => SelectedExecutor::Dot(ExternalCommandExecutor::with_codec(
|
||||
&self.state_dir,
|
||||
|
||||
@@ -35,6 +35,7 @@ fn temporary_directory(label: &str) -> PathBuf {
|
||||
fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
|
||||
let operations = Vec::new();
|
||||
AcpxProviderSessionConfig {
|
||||
candidate_admission: None,
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".to_owned(), "suspend".to_owned()],
|
||||
|
||||
@@ -34,6 +34,7 @@ fn tool_set() -> AuthorizedToolSet {
|
||||
|
||||
fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
AcpxProviderSessionConfig {
|
||||
candidate_admission: None,
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".to_owned(), mode.to_owned()],
|
||||
|
||||
@@ -30,6 +30,7 @@ fn tool_set() -> AuthorizedToolSet {
|
||||
|
||||
fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
AcpxProviderSessionConfig {
|
||||
candidate_admission: None,
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".to_owned(), mode.to_owned()],
|
||||
|
||||
@@ -10,6 +10,7 @@ use paperclip_runner_core::provider_bridge::{authorized_tool_catalog_digest, Aut
|
||||
fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
let operations = Vec::new();
|
||||
AcpxProviderSessionConfig {
|
||||
candidate_admission: None,
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".to_owned(), mode.to_owned()],
|
||||
|
||||
@@ -29,6 +29,7 @@ fn tool_set() -> AuthorizedToolSet {
|
||||
|
||||
fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
AcpxProviderSessionConfig {
|
||||
candidate_admission: None,
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".to_owned(), mode.to_owned()],
|
||||
|
||||
@@ -392,6 +392,78 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn antigravity_candidate_forwards_only_selected_credentials_and_binding() {
|
||||
const CHILD: &str = "PAPERCLIP_TEST_ANTIGRAVITY_ENV_CHILD";
|
||||
let selected = match std::env::var(CHILD) {
|
||||
Ok(selected) => selected,
|
||||
Err(_) => {
|
||||
for selected in [
|
||||
"GEMINI_API_KEY",
|
||||
"PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET",
|
||||
] {
|
||||
let status = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"antigravity_candidate_forwards_only_selected_credentials_and_binding",
|
||||
"--nocapture",
|
||||
])
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
|
||||
.env(CHILD, selected)
|
||||
.env(selected, "fixture-selected-credential")
|
||||
.env(
|
||||
"PAPERCLIP_ACPX_CREDENTIAL_BINDING",
|
||||
"fixture-controller-binding",
|
||||
)
|
||||
.envs(
|
||||
[
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"UNRELATED_EVAL_SECRET",
|
||||
"GEMINI_HOME",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"AGY_ACP_FORCE_FILE_STORAGE",
|
||||
"PYTHONPATH",
|
||||
"PYTHONHOME",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| (key, "fixture-ambient-must-not-cross")),
|
||||
)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(
|
||||
status.success(),
|
||||
"isolated Antigravity environment test failed"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut sidecar = AcpxSidecarTransport::start_for_agent(
|
||||
&AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".into(), "mcp-environment".into()],
|
||||
verified_launch: None,
|
||||
request_timeout: Duration::from_secs(2),
|
||||
shutdown_grace: Duration::from_millis(50),
|
||||
},
|
||||
"antigravity",
|
||||
)
|
||||
.unwrap();
|
||||
let response = sidecar
|
||||
.request(GeneratedAcpxSidecarCommand::Initialize, json!({}))
|
||||
.unwrap();
|
||||
assert_eq!(response["credentialKeys"], json!([selected]));
|
||||
assert_eq!(response["credentialBinding"], "fixture-controller-binding");
|
||||
assert_eq!(response["hasUnrelatedSecret"], false);
|
||||
assert_eq!(response["ambientAntigravityKeys"], json!([]));
|
||||
sidecar.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_ordinary_request_timeout_still_retires_the_sidecar() {
|
||||
let mut sidecar = AcpxSidecarTransport::start_for_agent(
|
||||
|
||||
@@ -62,6 +62,7 @@ fn checkpoints_the_sidecar_and_rebinds_consecutive_warm_runs_before_accepting_wo
|
||||
runner_version: "0.0.0".to_owned(),
|
||||
runner_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
acpx_launch_profile: Some(AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: sidecar.clone(),
|
||||
args: args.clone(),
|
||||
|
||||
+112
-1
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f";
|
||||
"sha256:9473de6ef4baf2bd82fe8b6757239bdc160960b0a78ae6f47fa9e0996544226c";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
@@ -142,6 +142,7 @@ fn acpx_config(state_dir: &Path, mode: &str) -> DurableRunnerConfig {
|
||||
let mut config = config(state_dir);
|
||||
let command = PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar"));
|
||||
config.acpx_launch_profile = Some(AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: vec![
|
||||
@@ -158,6 +159,116 @@ fn acpx_config(state_dir: &Path, mode: &str) -> DurableRunnerConfig {
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_fixture(directory: &Path) -> (DurableRunnerConfig, Value) {
|
||||
use paperclip_runner_core::acpx_candidate_admission::AcpxCandidateAdmission;
|
||||
let profiles: Value =
|
||||
serde_json::from_str(include_str!("../../../../acpx-profiles.json")).unwrap();
|
||||
let distributions: Value =
|
||||
serde_json::from_str(include_str!("../../../../antigravity-distributions.json")).unwrap();
|
||||
let profile = &profiles["profiles"]["antigravity"];
|
||||
let admission = AcpxCandidateAdmission::parse_json(&json!({
|
||||
"schema":"paperclip.acpx-candidate-admission.v1", "companyId":"company-1",
|
||||
"agent":"antigravity", "model":"explicit-test-model", "profileDigest":profile["commandDigest"],
|
||||
"nativeClosureDigest":format!("sha256:{}",distributions["platforms"]["darwin-arm64"]["closureSha256"].as_str().unwrap()),
|
||||
"platform":"darwin", "architecture":"arm64", "expiresAt":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 + 60_000,
|
||||
"runId":"run-1", "normalizedSessionId":"session-1"
|
||||
}).to_string()).unwrap();
|
||||
let mut config = acpx_config(directory, "turns-reserved-result-terminal");
|
||||
let launch = config.acpx_launch_profile.as_mut().unwrap();
|
||||
*launch.args.last_mut().unwrap() = admission.profile_digest.clone();
|
||||
launch.candidate_admission = Some(admission);
|
||||
let mut payload = prepare_payload(directory, "antigravity");
|
||||
let provider = &mut payload["provider"];
|
||||
for field in [
|
||||
"agentServerPackage",
|
||||
"agentServerVersion",
|
||||
"agentRuntimePackage",
|
||||
"agentRuntimeVersion",
|
||||
"commandDigest",
|
||||
] {
|
||||
provider[field] = profile[field].clone();
|
||||
}
|
||||
provider["sidecarArgs"] = json!(launch.args);
|
||||
provider["providerPolicy"] = json!({"readOnly":true});
|
||||
(config, payload)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_cannot_change_provider_kind_or_adopt_prior_activation() {
|
||||
let directory = temporary_directory("candidate-provider-authority");
|
||||
let (config, mut payload) = candidate_fixture(&directory);
|
||||
for kind in [
|
||||
"codex",
|
||||
"opencode",
|
||||
"openai_dot",
|
||||
"grok_bot",
|
||||
"muse",
|
||||
"claude_managed",
|
||||
"aws_agentcore",
|
||||
] {
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
let error = executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
json!({"provider":{"kind":kind}}),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("cannot authorize another provider kind"),
|
||||
"{kind}: {error}"
|
||||
);
|
||||
assert!(!directory.join("acpx-runtime").exists());
|
||||
}
|
||||
payload["paperclipNextAuthority"] = json!({});
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(executor
|
||||
.execute(&command(1, "run.attach", payload))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot rotate a live runner authority"));
|
||||
assert!(!directory.join("acpx-provider-state.json").exists());
|
||||
fs::write(directory.join("codex-provider-state.json"), b"{}").unwrap();
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(recovered
|
||||
.poll_events()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot adopt a prior durable runner activation"));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
fn candidate_verified_cold_session_retires_on_suspend_instead_of_warm_adoption() {
|
||||
let directory = temporary_directory("candidate-cold-session");
|
||||
let (config, payload) = candidate_fixture(&directory);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor
|
||||
.execute(&command(1, "run.prepare", payload))
|
||||
.unwrap();
|
||||
let opened = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(opened.result["status"], "started");
|
||||
let closed = executor
|
||||
.execute(&command(3, "runner.suspend", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(closed.result["status"], "closed");
|
||||
let state: Value =
|
||||
serde_json::from_slice(&fs::read(directory.join("acpx-provider-state.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(state["lifecycle"], "closed");
|
||||
executor.shutdown().unwrap();
|
||||
let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
assert!(recovered.poll_events().is_err());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
fn pi_acpx_config(state_dir: &Path, mode: &str) -> DurableRunnerConfig {
|
||||
let mut config = acpx_config(state_dir, mode);
|
||||
*config
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
// restart whenever the resulting identity changes. Historical identities remain.
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const fixture = resolve(root, "test/fixtures/copilot-profile-v81-identity.json");
|
||||
const fixture = resolve(root, "test/fixtures/copilot-profile-v84-identity.json");
|
||||
const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8"));
|
||||
const declaration = { ...historical.declaration, agentProfileVersion: 81 };
|
||||
const declaration = { ...historical.declaration, agentProfileVersion: 84 };
|
||||
delete declaration.permissionIdentitySourceSha256;
|
||||
const sources = {
|
||||
displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts",
|
||||
@@ -85,6 +85,9 @@ for (const [field, path] of Object.entries(sources)) {
|
||||
const sorted = Object.fromEntries(Object.entries(declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
const commandDigest = `sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`;
|
||||
const identity = `${JSON.stringify({ commandDigest, declaration }, null, 2)}\n`;
|
||||
if (!process.argv.includes("--check") && existsSync(fixture) && readFileSync(fixture, "utf8") !== identity) {
|
||||
throw new Error("Copilot profile identity is immutable; advance its version before changing bound source");
|
||||
}
|
||||
// The shared manifest now generates both TypeScript and Rust admission. Update
|
||||
// only Copilot; other providers retain their upstream release attestations.
|
||||
const manifestPath = resolve(root, "acpx-profiles.json");
|
||||
|
||||
@@ -8,6 +8,7 @@ const root = new URL("../", import.meta.url);
|
||||
const readJson = async path => JSON.parse(await readFile(new URL(path, root), "utf8"));
|
||||
const manifest = await readJson("acpx-profiles.json");
|
||||
const distributions = await readJson("cursor-distributions.json");
|
||||
const antigravityDistributions = await readJson("antigravity-distributions.json");
|
||||
const contract = await readJson("cursor-contract.json");
|
||||
const pkg = await readJson("package.json");
|
||||
assert.equal(manifest.schema, "paperclip.acpx-profiles.v1");
|
||||
@@ -41,6 +42,8 @@ const canonicalJson = value => value && typeof value === "object"
|
||||
? Array.isArray(value) ? `[${value.map(canonicalJson).join(",")}]`
|
||||
: `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
assert.equal(manifest.profiles.antigravity.agentServerVersion, antigravityDistributions.version);
|
||||
assert.equal(manifest.profiles.antigravity.commandDigest, `sha256:${createHash("sha256").update(canonicalJson(antigravityDistributions)).digest("hex")}`, "Antigravity distribution identity is stale");
|
||||
const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
@@ -74,6 +77,8 @@ export const ACPX_DRIVER_PROTOCOL_VERSION = ${manifest.protocolVersion} as const
|
||||
export const QUALIFIED_ACPX_PROFILE_DATA = ${JSON.stringify(profiles, null, 2)} as const;
|
||||
|
||||
export const CURSOR_DISTRIBUTION_PINS = ${JSON.stringify(Object.fromEntries(Object.entries(distributions.platforms).map(([platform, { closureSha256, executable, entrypoint }]) => [platform, { closureSha256, executable, entrypoint }])), null, 2)} as const;
|
||||
|
||||
export const ANTIGRAVITY_DISTRIBUTION_PINS = ${JSON.stringify(Object.fromEntries(Object.entries(antigravityDistributions.platforms).filter(([, pin]) => pin.provisioning === "verified-closure").map(([platform, { closureSha256, executable, args }]) => [platform, { closureSha256, executable, args }])), null, 2)} as const;
|
||||
`;
|
||||
const option = value => value === null ? "None" : `Some(${quote(value)})`;
|
||||
const rust = `// ${header}
|
||||
@@ -88,6 +93,7 @@ pub(crate) struct AcpxReleaseProfile {
|
||||
pub agent_runtime_version: Option<&'static str>,
|
||||
pub command_digest: &'static str,
|
||||
pub requires_provider_policy: bool,
|
||||
pub qualification_pending: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
@@ -100,6 +106,7 @@ ${entries.map(([agent, p]) => ` ${quote(agent)} => AcpxReleaseProfile {
|
||||
command_digest:
|
||||
${quote(p.commandDigest)},
|
||||
requires_provider_policy: ${p.requiresProviderPolicy},
|
||||
qualification_pending: ${p.qualificationStatus === "pending"},
|
||||
},`).join("\n")}
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repositoryRoot = resolve(packageRoot, "../..");
|
||||
// Versions 1–11 remain decodable historical identities. Versioned fixtures are
|
||||
// Versions 1–12 remain decodable historical identities. Versioned fixtures are
|
||||
// immutable; advance this version before changing a versioned declaration.
|
||||
export const GROK_PROFILE_VERSION = 12;
|
||||
export const GROK_PROFILE_VERSION = 13;
|
||||
// Contract surfaces: ACPX wire patch; sidecar parsing and error translation;
|
||||
// native effort/search/credential admission; host/sandbox/tool-bridge execution;
|
||||
// launcher integrity and platform pins; Rust descriptor writes, session state,
|
||||
|
||||
@@ -47,7 +47,7 @@ test("the generator cannot overwrite a versioned identity after source changes",
|
||||
const launcher = `${prefix}src/providers/grok/launcher.cjs`;
|
||||
try {
|
||||
for (const path of new Set([...GROK_PROFILE_SOURCE_FILES, ...[
|
||||
"acpx-profiles.json", "cursor-distributions.json", "cursor-contract.json", "package.json",
|
||||
"acpx-profiles.json", "cursor-distributions.json", "cursor-contract.json", "antigravity-distributions.json", "package.json",
|
||||
"scripts/grok-profile-identity.mjs", "scripts/generate-acpx-profiles.mjs",
|
||||
`test/fixtures/copilot-profile-v${manifest.profiles.copilot.agentProfileVersion}-identity.json`,
|
||||
`test-fixtures/pi-acp/profile-v${manifest.profiles.pi.agentProfileVersion}-identity.json`,
|
||||
|
||||
@@ -50,7 +50,7 @@ test("public server tar layout carries a self-contained host provisioner and exa
|
||||
assert.deepEqual(await readdir(join(installedRunner, "provider-assets/pi")), [`${process.platform}-${process.arch}`]);
|
||||
});
|
||||
|
||||
test("explicit CLI setup passes only network settings to its real child and enables Node proxy handling", async t => {
|
||||
for (const provider of ["pi", "antigravity"]) test(`${provider} CLI setup passes only network settings to its real child and enables Node proxy handling`, async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-setup-environment-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const server = join(root, "node_modules/@paperclipai/server");
|
||||
@@ -59,7 +59,7 @@ test("explicit CLI setup passes only network settings to its real child and enab
|
||||
await writeFile(join(server, "package.json"), JSON.stringify({ name: "@paperclipai/server", type: "module", exports: "./dist/index.js" }));
|
||||
await writeFile(join(server, "dist/index.js"), "throw Error('server must not start')");
|
||||
// A capture child models the public layout only. It cannot download or launch Pi.
|
||||
await writeFile(join(cli, "provision-pi.cjs"), `const assert=require('node:assert/strict');
|
||||
await writeFile(join(cli, `provision-${provider}.cjs`), `const assert=require('node:assert/strict');
|
||||
assert.deepEqual(process.execArgv,['--use-env-proxy']);
|
||||
assert.equal(process.env.HTTP_PROXY,'http://127.0.0.1:9');
|
||||
assert.equal(process.env.HTTPS_PROXY,'http://127.0.0.1:9');
|
||||
@@ -71,7 +71,8 @@ test("explicit CLI setup passes only network settings to its real child and enab
|
||||
console.log('SETUP_NETWORK_BOUNDARY_PASS');`);
|
||||
const modulePath = join(root, "runtime.mjs");
|
||||
await build({ entryPoints: [resolve(dirname(new URL(import.meta.url).pathname), "../../../cli/src/commands/runtime.ts")], outfile: modulePath, bundle: true, platform: "node", format: "esm", target: "node24", logLevel: "silent" });
|
||||
const result = spawnSync(process.execPath, ["--input-type=module", "-e", "const runtime=await import(process.argv[1]);await runtime.setupPiRuntime();", modulePath], {
|
||||
const setupFunction = provider === "pi" ? "setupPiRuntime" : "setupAntigravityRuntime";
|
||||
const result = spawnSync(process.execPath, ["--input-type=module", "-e", `const runtime=await import(process.argv[1]);await runtime.${setupFunction}();`, modulePath], {
|
||||
encoding: "utf8", timeout: 10_000, env: { PATH: "/usr/bin:/bin", HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", NO_PROXY: "localhost", http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", no_proxy: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/dev/null", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" },
|
||||
});
|
||||
assert.ifError(result.error); assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { bindAcpxCandidateAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "../drivers/acpx/generated-profiles.js";
|
||||
import { canonicalNativeRuntimeContextDigest } from "../contracts/runtime-context.js";
|
||||
import { acpxProfileActivity } from "../drivers/acpx/profile-activity.js";
|
||||
import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js";
|
||||
@@ -1269,6 +1271,30 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
expect(ACPX_CAPABILITY_PROFILES[agent].qualification).toBe(agent === "cursor" ? "qualified" : "pending");
|
||||
});
|
||||
|
||||
it("refuses pending Antigravity bootstrap before any native process opens", async () => {
|
||||
const sidecar = startSidecar();
|
||||
sidecar.write(initializeRequest(1, "antigravity", "caller-exact-model"));
|
||||
expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ id: 1, ok: false,
|
||||
error: { message: expect.stringContaining("qualification is pending"), retryable: false } });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("admits only matching candidate startup and rejects session replay before provider launch", async () => {
|
||||
const sidecar = startSidecar();
|
||||
const model = "gemini-3.8-flash-low";
|
||||
const candidateAdmission = bindAcpxCandidateAdmission({ schema: "paperclip.acpx-candidate-admission.v1", companyId: "company-candidate", agent: "antigravity", model,
|
||||
profileDigest: resolveQualifiedAcpxProfile("antigravity", model).commandDigest,
|
||||
nativeClosureDigest: `sha256:${ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256}`,
|
||||
platform: "darwin", architecture: "arm64", expiresAt: Date.now() + 60_000 }, { runId: "run-candidate", normalizedSessionId: "session-candidate" });
|
||||
const request = initializeRequest(1, "antigravity", model);
|
||||
sidecar.write({ ...request, params: { ...request.params, candidateAdmission } });
|
||||
expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true, result: { profile: { agent: "antigravity", qualificationStatus: "pending", reportedModelId: model } } });
|
||||
sidecar.write({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open", params: {
|
||||
runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "other-session", agent: "antigravity", model,
|
||||
candidateAdmission, permissionMode: "deny-all", permissionModePinned: true, providerPolicy: { readOnly: true }, systemInstructions: "", tools: [],
|
||||
} });
|
||||
expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({ ok: false, error: { message: expect.stringContaining("candidate admission") } });
|
||||
});
|
||||
|
||||
it("fails closed after an unsupported provider bootstrap", async () => {
|
||||
const sidecar = startSidecar();
|
||||
sidecar.write(
|
||||
@@ -1286,7 +1312,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "acpx_sidecar_command_failed",
|
||||
message: "ACPX agent must be claude, codex, grok, cursor, copilot, or pi",
|
||||
message: "ACPX agent must be claude, codex, grok, cursor, copilot, pi, or antigravity",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js
|
||||
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { resolveGrokEffort } from "../drivers/acpx/grok-effort.js";
|
||||
import { parseProviderMode } from "../contracts/provider-mode.js";
|
||||
import { assertAcpxExecutionAdmission, parseAcpxCandidateStartupAdmission, type AcpxCandidateStartupAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js";
|
||||
|
||||
import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
@@ -45,6 +46,7 @@ import { normalizeAcpxPermission, type NormalizedAcpxPermission } from "../drive
|
||||
import { acpxGoalProjection } from "../drivers/acpx/session-goals.js";
|
||||
import { acpxProviderSessionIdentity } from "../drivers/acpx/recovery-identity.js";
|
||||
import {
|
||||
assertAcpxProfileReleased,
|
||||
resolveQualifiedAcpxProfile,
|
||||
type QualifiedAcpxAgent,
|
||||
} from "../drivers/acpx/qualified-profiles.js";
|
||||
@@ -169,6 +171,7 @@ let pendingInput = Promise.resolve();
|
||||
let bootstrapFailure: Error | null = null;
|
||||
let initializedAgent: QualifiedAcpxAgent | null = null;
|
||||
let initializedModel: string | null = null;
|
||||
let initializedCandidateAdmission: AcpxCandidateStartupAdmission | undefined;
|
||||
let inputClosed = false;
|
||||
const tools = new Map<string, PendingTool>();
|
||||
const normalizingTools = new Set<string>();
|
||||
@@ -256,11 +259,14 @@ async function dispatch(
|
||||
if (request.command === "initialize") {
|
||||
if (initializedModel)
|
||||
throw new Error("ACPX sidecar is already initialized");
|
||||
const agent = requireQualifiedAgent(request.params.agent);
|
||||
const model = requiredText(request.params.model, "model");
|
||||
const candidateAdmission = request.params.candidateAdmission === undefined ? undefined
|
||||
: parseAcpxCandidateStartupAdmission(request.params.candidateAdmission, { agent: String(request.params.agent), model });
|
||||
const agent = requireQualifiedAgent(request.params.agent, model, candidateAdmission);
|
||||
const profile = resolveQualifiedAcpxProfile(agent, model);
|
||||
initializedAgent = agent;
|
||||
initializedModel = model;
|
||||
initializedCandidateAdmission = candidateAdmission;
|
||||
return {
|
||||
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
sidecarPid: process.pid,
|
||||
@@ -289,11 +295,13 @@ async function dispatch(
|
||||
if (
|
||||
params.agent !== initializedAgent ||
|
||||
params.model !== initializedModel
|
||||
|| canonicalJson(params.candidateAdmission ?? null) !== canonicalJson(initializedCandidateAdmission ?? null)
|
||||
) {
|
||||
throw new Error("ACPX session profile differs from its initialization");
|
||||
}
|
||||
const openedHost = await AcpxRuntimeHost.open(
|
||||
{
|
||||
candidateAdmission: params.candidateAdmission,
|
||||
runtimeDirectory: params.runtimeDirectory,
|
||||
normalizedSessionId: params.normalizedSessionId,
|
||||
workingDirectory: params.workingDirectory,
|
||||
@@ -375,6 +383,7 @@ async function dispatch(
|
||||
throw new Error("ACPX run tool catalog differs from the opened session");
|
||||
}
|
||||
const attachment = parseAcpxRunAttachment(request.params);
|
||||
if (initializedCandidateAdmission) parseAcpxCandidateStartupAdmission(initializedCandidateAdmission, { agent: initializedAgent!, model: initializedModel!, runId: attachment.runId, normalizedSessionId: openParams!.normalizedSessionId });
|
||||
runId = attachment.runId;
|
||||
return {
|
||||
runId: attachment.runId,
|
||||
@@ -385,6 +394,7 @@ async function dispatch(
|
||||
const activeHost = requireHost();
|
||||
if (!runId) throw new Error("attach a run before starting an ACPX turn");
|
||||
if (turnId) throw new Error("ACPX sidecar already has an active turn");
|
||||
if (initializedCandidateAdmission) parseAcpxCandidateStartupAdmission(initializedCandidateAdmission, { agent: initializedAgent!, model: initializedModel!, runId, normalizedSessionId: openParams!.normalizedSessionId });
|
||||
const currentTurnId = boundedIdentity(request.params.turnId, "turnId");
|
||||
const activeAgent = openParams!.agent;
|
||||
turnId = currentTurnId;
|
||||
@@ -1241,10 +1251,12 @@ function safeOutput(value: unknown): Record<string, unknown> {
|
||||
function parseOpenParams(
|
||||
value: Record<string, unknown>,
|
||||
): AcpxSidecarOpenParams {
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "grokEffort", "grokDisableWebSearch", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "grokEffort", "grokDisableWebSearch", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity", "candidateAdmission"]);
|
||||
if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field");
|
||||
const agent = requireQualifiedAgent(value.agent);
|
||||
const model = requiredText(value.model, "model");
|
||||
const candidateAdmission = value.candidateAdmission === undefined ? undefined
|
||||
: parseAcpxCandidateStartupAdmission(value.candidateAdmission, { agent: String(value.agent), model, normalizedSessionId: String(value.normalizedSessionId) });
|
||||
const agent = requireQualifiedAgent(value.agent, model, candidateAdmission);
|
||||
if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor");
|
||||
const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort(agent, value.claudeEffort);
|
||||
@@ -1260,6 +1272,7 @@ function parseOpenParams(
|
||||
);
|
||||
}
|
||||
return {
|
||||
...(candidateAdmission ? { candidateAdmission } : {}),
|
||||
runtimeDirectory: requiredText(value.runtimeDirectory, "runtimeDirectory"),
|
||||
normalizedSessionId: boundedIdentity(
|
||||
value.normalizedSessionId,
|
||||
@@ -1453,10 +1466,12 @@ function requireHost(
|
||||
return requireSidecarCommandHost(host, activeHostCleanup, options);
|
||||
}
|
||||
|
||||
function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent {
|
||||
if (value !== "grok" && value !== "codex" && value !== "claude" && value !== "pi" && value !== "cursor" && value !== "copilot") {
|
||||
throw new Error("ACPX agent must be claude, codex, grok, cursor, copilot, or pi");
|
||||
function requireQualifiedAgent(value: unknown, model?: string, candidateAdmission?: AcpxCandidateStartupAdmission): QualifiedAcpxAgent {
|
||||
if (value !== "grok" && value !== "codex" && value !== "claude" && value !== "pi" && value !== "cursor" && value !== "copilot" && value !== "antigravity") {
|
||||
throw new Error("ACPX agent must be claude, codex, grok, cursor, copilot, pi, or antigravity");
|
||||
}
|
||||
if (candidateAdmission) assertAcpxExecutionAdmission(resolveQualifiedAcpxProfile(value, model ?? ""), candidateAdmission);
|
||||
else assertAcpxProfileReleased(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface NativeAwsAgentCoreProfileSnapshot {
|
||||
eventExpiryDays: 90;
|
||||
}
|
||||
|
||||
export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot";
|
||||
export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot" | "antigravity";
|
||||
export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted";
|
||||
export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny";
|
||||
export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
@@ -709,8 +709,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for acpx");
|
||||
}
|
||||
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex" && provider.agent !== "grok" && provider.agent !== "cursor" && provider.agent !== "copilot") {
|
||||
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, codex, grok, cursor, or copilot");
|
||||
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex" && provider.agent !== "grok" && provider.agent !== "cursor" && provider.agent !== "copilot" && provider.agent !== "antigravity") {
|
||||
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, codex, grok, cursor, copilot, or antigravity");
|
||||
}
|
||||
if (isV4) {
|
||||
if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
@@ -7,10 +7,12 @@ import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { beforeAll, expect, it, vi } from "vitest";
|
||||
import { DurablePrpControlPlane, decodeDurablePrpCheckpoint, type DurablePrpCheckpoint } from "./durable-prp-control-plane.js";
|
||||
import { DurablePrpControlPlane, decodeDurablePrpCheckpoint, spawnRunner, type DurablePrpCheckpoint } from "./durable-prp-control-plane.js";
|
||||
import { copyOwnedNodeRuntime } from "../test-support/owned-node-runtime.js";
|
||||
import { GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/generated-sidecar-contract.js";
|
||||
import { createCapabilityRunnerdCodexTransport } from "../live/runnerd-codex-transport.js";
|
||||
import { bindAcpxCandidateAdmission, type AcpxCandidateStartupAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "../drivers/acpx/generated-profiles.js";
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
const binary = process.env.PAPERCLIP_ACPX_REPRO_BINARY ?? resolve(packageRoot, "runner/target/debug/paperclip-runnerd");
|
||||
@@ -30,11 +32,11 @@ beforeAll(() => {
|
||||
expectedRunnerDigest = digest(binary);
|
||||
}, 150_000);
|
||||
|
||||
function sidecarSource(root: string) {
|
||||
function sidecarSource(root: string, selectedProfile = profile, agent = "claude") {
|
||||
return [
|
||||
"const fs = require('node:fs'); const net = require('node:net'); const rl = require('node:readline'); const {randomInt}=require('node:crypto');",
|
||||
"const root = " + JSON.stringify(root) + ";",
|
||||
"const profileDigest = " + JSON.stringify(profile.commandDigest) + ";",
|
||||
"const profileDigest = " + JSON.stringify(selectedProfile.commandDigest) + ";",
|
||||
"const protocolVersion = " + GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION + ";",
|
||||
"const send = value => process.stdout.write(JSON.stringify(value) + '\\n');",
|
||||
"let identity, runId, turnId = null, sequence = 0, lastAction = '';",
|
||||
@@ -48,7 +50,7 @@ function sidecarSource(root: string) {
|
||||
" const lines=rl.createInterface({input:process.stdin}); lines.on('close',()=>process.exit(0)); lines.on('line', line => {",
|
||||
" const r=JSON.parse(line), p=r.params || {};",
|
||||
" switch(r.command) {",
|
||||
" case 'initialize': reply(r,{protocolVersion,sidecarPid:process.pid,profile:{agent:'claude'},capabilities:{persistentSessions:true,exactModelVerification:true,permissions:'runner_policy',semanticTools:'runner_bridge',structuredInput:'paperclip.question_set.v1'}});break;",
|
||||
" case 'initialize': reply(r,{protocolVersion,sidecarPid:process.pid,profile:{agent:" + JSON.stringify(agent) + "},capabilities:{persistentSessions:true,exactModelVerification:true,permissions:'runner_policy',semanticTools:'runner_bridge',structuredInput:'paperclip.question_set.v1'}});break;",
|
||||
" case 'session.open': identity={kind:'acpx',normalizedSessionId:p.normalizedSessionId,acpxRecordId:'record-1',backendSessionId:'backend-1',agentSessionId:'agent-1',profileDigest,workspaceDigest:'sha256:'+'2'.repeat(64),requestedModel:p.model,effectiveModel:p.model,permissionMode:p.permissionMode,mode:p.mode,piThinkingLevel:p.piThinkingLevel,claudeEffort:p.claudeEffort,grokEffort:p.grokEffort,grokDisableWebSearch:p.grokDisableWebSearch,providerLifetimeFenceCandidates:ports};reply(r,{sidecarPid:process.pid,identity,status:{},turnControls:{steering:false,queuedFollowUp:false}});break;",
|
||||
" case 'run.attach': runId=p.runId; reply(r,{runId,catalogRevision:p.catalogRevision});break;",
|
||||
" case 'session.goal.get': reply(r,{schema:'paperclip.session_goal.snapshot.v1',goal:null,workingNow:false,sessionGoals:{availability:'unsupported',actions:[],autonomousUpdates:false,persistentAcrossResume:false,maxObjectiveChars:4000,tokenBudgetControl:false,usageReporting:false}});break;",
|
||||
@@ -65,20 +67,22 @@ function sidecarSource(root: string) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
async function fixture(maxLifetimeMs?: number, candidateAdmission?: AcpxCandidateStartupAdmission, beforeLaunchDelayMs = 0) {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "acpx-controller-recovery-"));
|
||||
const { executable: nodeCommand } = await copyOwnedNodeRuntime(resolve(root, "node"));
|
||||
const stateDirectory = resolve(root, "control-plane");
|
||||
const script = resolve(root, "sidecar.cjs");
|
||||
writeFileSync(script, sidecarSource(root), { mode: 0o700 });
|
||||
const agent = candidateAdmission ? "antigravity" : "claude", selectedProfile = profiles.profiles[agent];
|
||||
writeFileSync(script, sidecarSource(root, selectedProfile, agent), { mode: 0o700 });
|
||||
const runtime = resolve(root, "runtime");
|
||||
const descriptor = { kind: "acpx", provider: "acpx", driver: "acpx_runtime", providerVersion: profiles.acpxVersion,
|
||||
agent: "claude", model: "scripted-model", acpxVersion: profiles.acpxVersion,
|
||||
agentServerPackage: profile.agentServerPackage, agentServerVersion: profile.agentServerVersion,
|
||||
agentRuntimePackage: profile.agentRuntimePackage, agentRuntimeVersion: profile.agentRuntimeVersion,
|
||||
commandDigest: profile.commandDigest, sidecarCommand: nodeCommand, sidecarArgs: [script],
|
||||
agent, model: candidateAdmission?.model ?? "scripted-model", acpxVersion: profiles.acpxVersion,
|
||||
agentServerPackage: selectedProfile.agentServerPackage, agentServerVersion: selectedProfile.agentServerVersion,
|
||||
agentRuntimePackage: selectedProfile.agentRuntimePackage, agentRuntimeVersion: selectedProfile.agentRuntimeVersion,
|
||||
commandDigest: selectedProfile.commandDigest, sidecarCommand: nodeCommand, sidecarArgs: [script],
|
||||
runtimeDirectory: runtime, normalizedSessionId: identity.normalizedSessionId, runId: identity.runId,
|
||||
cwd: root, instructions: "Do the supplied synthetic task.", permissionMode: "approve-reads", permissionModePinned: true,
|
||||
...(selectedProfile.requiresProviderPolicy ? { providerPolicy: { readOnly: true } } : {}),
|
||||
runtimeContext: null };
|
||||
let checkpoint!: DurablePrpCheckpoint;
|
||||
const create = () => {
|
||||
@@ -89,8 +93,12 @@ async function fixture() {
|
||||
let controller = create();
|
||||
await controller.start();
|
||||
const port = Number(new URL(controller.connectUrl).port);
|
||||
const ticket = controller.issueBootstrapTicket();
|
||||
// Establish one absolute fixture grant before issuing its one-use ticket.
|
||||
// Bootstrap preparation and the actual daemon share this deadline.
|
||||
const deadlineAt = candidateAdmission?.expiresAt ?? (maxLifetimeMs === undefined ? undefined : Date.now() + maxLifetimeMs);
|
||||
const ticket = controller.issueBootstrapTicket(deadlineAt === undefined ? 5_000 : deadlineAt - Date.now());
|
||||
await controller.flushCheckpoint();
|
||||
if (beforeLaunchDelayMs) await new Promise(resolveDelay => setTimeout(resolveDelay, beforeLaunchDelayMs));
|
||||
let diagnostics = "";
|
||||
const args = ["--connect-url",controller.connectUrl,"--state-dir",resolve(root,"runner"),
|
||||
"--runner-id",identity.runnerInstanceId,"--environment-lease-id",identity.environmentLeaseId,
|
||||
@@ -98,24 +106,46 @@ async function fixture() {
|
||||
"--runner-version",expectedRunnerVersion,"--runner-digest",expectedRunnerDigest,"--max-runtime-ms","0",
|
||||
"--reconnect-grace-ms","15000","--reconnect-delay-ms","25","--acpx-launch-authority-digest","sha256:"+"d".repeat(64),
|
||||
"--acpx-sidecar-command",nodeCommand,"--acpx-sidecar-command-sha256",digest(nodeCommand),
|
||||
"--acpx-sidecar-script",script,"--acpx-sidecar-script-sha256",digest(script)];
|
||||
"--acpx-sidecar-script",script,"--acpx-sidecar-script-sha256",digest(script),
|
||||
...(candidateAdmission ? ["--acpx-candidate-admission-json", JSON.stringify(candidateAdmission)] : [])];
|
||||
const startedAt = new Date().toISOString();
|
||||
const child = spawn(binary,args,{env:{PATH:process.env.PATH,PAPERCLIP_RUNNER_BOOTSTRAP_TICKET:ticket},stdio:["ignore","ignore","pipe"],detached:true});
|
||||
// Exercise daemon authority independently of the TypeScript TTL clamp.
|
||||
const bounded = maxLifetimeMs === undefined ? undefined : spawnRunner({
|
||||
connectUrl: controller.connectUrl, stateDirectory: resolve(root, "runner"), identity, ticket,
|
||||
maxOutboxBytes: 16 * 1024 * 1024, p0ReserveBytes: 1024 * 1024,
|
||||
maxRuntimeMs: 0, maxLifetimeMs: deadlineAt! - Date.now(), reconnectGraceMs: 15_000,
|
||||
runnerBinaryPath: binary, runnerVersion: expectedRunnerVersion, runnerDigest: expectedRunnerDigest,
|
||||
acpxLaunchProfile: { ...(candidateAdmission ? { candidateAdmission } : {}), authorityDigest: `sha256:${"d".repeat(64)}`, command: nodeCommand, commandSha256: digest(nodeCommand),
|
||||
sidecarScript: script, sidecarScriptSha256: digest(script) },
|
||||
environment: { PATH: process.env.PATH },
|
||||
});
|
||||
const child = bounded?.child as ChildProcess | undefined
|
||||
?? spawn(binary,args,{env:{PATH:process.env.PATH,PAPERCLIP_RUNNER_BOOTSTRAP_TICKET:ticket},stdio:["ignore","ignore","pipe"],detached:true});
|
||||
const exit = once(child,"exit");
|
||||
child.stderr!.on("data",bytes=>{diagnostics=(diagnostics+String(bytes)).slice(-4096);});
|
||||
let commandNumber=0;
|
||||
async function command(type:string,payload:Record<string,unknown>={}) {
|
||||
const id="command-"+(++commandNumber);
|
||||
controller.queueCommand(type,payload,id,true);
|
||||
await vi.waitFor(()=>{
|
||||
expect(child.exitCode,"runner exit; "+diagnostics).toBeNull();
|
||||
expect(child.signalCode,"runner signal; "+diagnostics).toBeNull();
|
||||
expect(["completed","failed"]).toContain(controller.getCommand(id)?.status);
|
||||
},{timeout:8000});
|
||||
try {
|
||||
await vi.waitFor(()=>{
|
||||
expect(child.exitCode,"runner exit; "+diagnostics).toBeNull();
|
||||
expect(child.signalCode,"runner signal; "+diagnostics).toBeNull();
|
||||
expect(["completed","failed"]).toContain(controller.getCommand(id)?.status);
|
||||
},{timeout:8000});
|
||||
} catch (error) {
|
||||
let runnerDiagnostics: unknown;
|
||||
try { runnerDiagnostics = JSON.parse(readFileSync(resolve(root, "runner/runner-state.json"), "utf8")).diagnostics?.slice(-8); } catch { /* Startup may not have created state. */ }
|
||||
throw new Error("Hermetic Runner command did not settle: " + JSON.stringify({ id, type,
|
||||
authenticatedConnections: controller.activeRunnerConnectionCount(), command: controller.getCommand(id),
|
||||
remainingLifetimeMs: deadlineAt === undefined ? null : deadlineAt - Date.now(),
|
||||
exitCode: child.exitCode, signalCode: child.signalCode, stderr: diagnostics, runnerDiagnostics }).slice(-8192), { cause: error });
|
||||
}
|
||||
return controller.getCommand(id)!;
|
||||
}
|
||||
const ready = async () => {
|
||||
expect(await command("run.prepare",{provider:descriptor})).toMatchObject({status:"completed"});
|
||||
const prepared = await command("run.prepare",{provider:descriptor});
|
||||
expect(prepared, JSON.stringify(prepared)).toMatchObject({status:"completed"});
|
||||
const opened = await command("session.open");
|
||||
expect(opened, JSON.stringify(opened)).toMatchObject({status:"completed"});
|
||||
};
|
||||
@@ -126,7 +156,7 @@ async function fixture() {
|
||||
rmSync(stateDirectory,{recursive:true,force:true});
|
||||
decodeDurablePrpCheckpoint(checkpoint.records).restoreSessionDirectory(root);
|
||||
};
|
||||
return {root,child,exit,command,ready,startedAt,
|
||||
return {root,child,exit,command,ready,startedAt,deadlineAt,processGroupId: bounded?.processGroupId ?? child.pid,
|
||||
controller:()=>controller,
|
||||
stopController:stopped,
|
||||
restoreCheckpoint,
|
||||
@@ -162,6 +192,49 @@ async function fixture() {
|
||||
};
|
||||
}
|
||||
|
||||
async function assertActiveExpiry(candidate = false) {
|
||||
// One fixed grant covers the six-second cold delay, four existing eight-
|
||||
// second command budgets, and seven seconds of active-turn headroom. Loaded
|
||||
// CI exhausted the earlier fifteen-second grant before session.open settled.
|
||||
// Never renew this authority after setup or the active-turn witness.
|
||||
const lifetimeMs = 45_000;
|
||||
const candidateAdmission = candidate ? bindAcpxCandidateAdmission({ schema: "paperclip.acpx-candidate-admission.v1", companyId: "company-expiry",
|
||||
agent: "antigravity", model: "gemini-3.8-flash-low", profileDigest: profiles.profiles.antigravity.commandDigest,
|
||||
nativeClosureDigest: `sha256:${ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256}`,
|
||||
platform: "darwin", architecture: "arm64", expiresAt: Date.now() + lifetimeMs }, identity) : undefined;
|
||||
// A controlled cold start outlasts the default five-second bootstrap ticket.
|
||||
// It must still activate inside the original fixed absolute grant.
|
||||
const run = await fixture(candidate ? undefined : lifetimeMs, candidateAdmission, candidate ? 0 : 6_000);
|
||||
const deadlineAt = run.deadlineAt!;
|
||||
try {
|
||||
await run.ready();
|
||||
expect(await run.command("turn.start", { turnId: "provider-expiring-turn", text: "Keep this hermetic turn active" })).toMatchObject({ status: "completed" });
|
||||
const launch = run.launches()[0];
|
||||
expect(launch.pid).toBeGreaterThan(1);
|
||||
expect(() => process.kill(launch.pid, 0)).not.toThrow();
|
||||
expect(await run.command("session.snapshot")).toMatchObject({ result: { result: { activeProviderTurnId: "provider-expiring-turn" } } });
|
||||
expect(Date.now()).toBeLessThan(deadlineAt);
|
||||
// No stop/cancel request is sent. The real daemon must enforce the launch
|
||||
// deadline while the provider is active, then finish physical cleanup.
|
||||
await vi.waitFor(() => expect(run.child.exitCode ?? run.child.signalCode).not.toBeNull(), { timeout: Math.max(1, deadlineAt - Date.now()) + 4_000 });
|
||||
expect(run.child.exitCode).not.toBe(0);
|
||||
expect(() => process.kill(launch.pid, 0)).toThrow();
|
||||
expect(run.processGroupId).toBeGreaterThan(1);
|
||||
expect(() => process.kill(-run.processGroupId!, 0)).toThrow();
|
||||
const fences = await Promise.all((launch.ports as number[]).map(port => new Promise<ReturnType<typeof createNetServer>>((resolveFree, reject) => {
|
||||
const probe = createNetServer(); probe.once("error", reject); probe.listen(port, "127.0.0.1", () => resolveFree(probe));
|
||||
})));
|
||||
await Promise.all(fences.map(server => new Promise<void>((resolveFree, reject) => server.close(error => error ? reject(error) : resolveFree()))));
|
||||
const state = JSON.parse(readFileSync(resolve(run.root, "runner/runner-state.json"), "utf8"));
|
||||
expect(state.recoverableFailure).toBe("transport_reconnect_deadline_exceeded");
|
||||
} finally { await run.cleanup(); }
|
||||
}
|
||||
|
||||
it("expires an active provider turn after a cold bootstrap delay and physically retires its owned processes", () => assertActiveExpiry(), 60_000);
|
||||
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")(
|
||||
"retires an active exact Antigravity candidate at absolute operator expiry with no caller runtime limit",
|
||||
() => assertActiveExpiry(true), 60_000);
|
||||
|
||||
it("keeps the original native ACPX process and active turn through controller replacement",async()=>{
|
||||
const run=await fixture();
|
||||
try{
|
||||
|
||||
@@ -3914,6 +3914,7 @@ const runnerExplicitProviderEnvironmentKeys = [
|
||||
"CODEX_API_KEY",
|
||||
"PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET",
|
||||
"PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET",
|
||||
"PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET",
|
||||
"XAI_API_KEY",
|
||||
"AWS_REGION",
|
||||
"AWS_DEFAULT_REGION",
|
||||
@@ -4016,6 +4017,7 @@ export function spawnRunner(options: {
|
||||
runnerVersion: string;
|
||||
runnerDigest: string;
|
||||
acpxLaunchProfile?: {
|
||||
candidateAdmission?: import("../drivers/acpx/candidate-admission.js").AcpxCandidateStartupAdmission;
|
||||
authorityDigest: string;
|
||||
command: string;
|
||||
commandSha256: string;
|
||||
@@ -4034,6 +4036,23 @@ export function spawnRunner(options: {
|
||||
processLauncher?: (spec: RunnerProcessLaunchSpec) => RunnerProcessHandle;
|
||||
diagnosticsDirectory?: string;
|
||||
}): RunnerProcessHandle {
|
||||
for (const value of [options.maxRuntimeMs, options.maxLifetimeMs]) {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) throw new Error("runner_process_lifetime_invalid");
|
||||
}
|
||||
const launchedAt = Date.now();
|
||||
const candidateExpiresAt = options.acpxLaunchProfile?.candidateAdmission?.expiresAt;
|
||||
const lifetimeDeadlines = [
|
||||
...(options.maxLifetimeMs && options.maxLifetimeMs > 0 ? [launchedAt + options.maxLifetimeMs] : []),
|
||||
...(candidateExpiresAt === undefined ? [] : [candidateExpiresAt]),
|
||||
];
|
||||
const lifetimeDeadline = lifetimeDeadlines.length ? Math.min(...lifetimeDeadlines) : undefined;
|
||||
if (lifetimeDeadline !== undefined && lifetimeDeadline <= launchedAt) throw new Error("runner_process_lifetime_expired");
|
||||
const runtimeBounds = [
|
||||
...(options.maxRuntimeMs && options.maxRuntimeMs > 0 ? [options.maxRuntimeMs] : []),
|
||||
...(lifetimeDeadline === undefined ? [] : [lifetimeDeadline - launchedAt]),
|
||||
];
|
||||
const maxRuntimeMs = runtimeBounds.length ? Math.min(...runtimeBounds)
|
||||
: options.maxRuntimeMs ?? (options.maxLifetimeMs === undefined ? undefined : 0);
|
||||
const connection =
|
||||
options.connection ??
|
||||
(options.connectUrl
|
||||
@@ -4091,6 +4110,7 @@ export function spawnRunner(options: {
|
||||
options.acpxLaunchProfile.sidecarScript,
|
||||
"--acpx-sidecar-script-sha256",
|
||||
options.acpxLaunchProfile.sidecarScriptSha256,
|
||||
...(options.acpxLaunchProfile.candidateAdmission ? ["--acpx-candidate-admission-json", JSON.stringify(options.acpxLaunchProfile.candidateAdmission)] : []),
|
||||
]
|
||||
: []),
|
||||
...(options.opencodeLaunchProfile
|
||||
@@ -4120,11 +4140,9 @@ export function spawnRunner(options: {
|
||||
"--reconnect-delay-ms",
|
||||
"250",
|
||||
];
|
||||
if (options.maxLifetimeMs !== undefined) {
|
||||
args.push("--max-lifetime-ms", String(options.maxLifetimeMs));
|
||||
} else if (options.maxRuntimeMs !== undefined) {
|
||||
args.push("--max-runtime-ms", String(options.maxRuntimeMs));
|
||||
}
|
||||
// The daemon enforces its total process deadline through max-runtime-ms.
|
||||
// Keep the same bounded value in structured launches for external owners.
|
||||
if (maxRuntimeMs !== undefined) args.push("--max-runtime-ms", String(maxRuntimeMs));
|
||||
if (options.reconnectGraceMs !== undefined) {
|
||||
args.push("--reconnect-grace-ms", String(options.reconnectGraceMs));
|
||||
}
|
||||
@@ -4151,7 +4169,9 @@ export function spawnRunner(options: {
|
||||
if (handle.isExitConfirmed?.() === false) {
|
||||
throw new Error("runner_process_termination_unconfirmed");
|
||||
}
|
||||
return spawnRunner({ ...options, ticket });
|
||||
if (lifetimeDeadline !== undefined && lifetimeDeadline <= Date.now()) throw new Error("runner_process_lifetime_expired");
|
||||
return spawnRunner({ ...options, ticket,
|
||||
...(lifetimeDeadline === undefined ? {} : { maxLifetimeMs: lifetimeDeadline - Date.now() }) });
|
||||
},
|
||||
});
|
||||
if (options.processLauncher !== undefined) {
|
||||
@@ -4161,7 +4181,7 @@ export function spawnRunner(options: {
|
||||
identity: options.identity, ticket: options.ticket, connection, stateDirectory: options.stateDirectory,
|
||||
runnerVersion: options.runnerVersion, runnerDigest: options.runnerDigest,
|
||||
maxOutboxBytes: options.maxOutboxBytes, p0ReserveBytes: options.p0ReserveBytes,
|
||||
maxRuntimeMs: options.maxRuntimeMs, maxLifetimeMs: options.maxLifetimeMs,
|
||||
maxRuntimeMs, maxLifetimeMs: lifetimeDeadline === undefined ? options.maxLifetimeMs : maxRuntimeMs,
|
||||
reconnectGraceMs: options.reconnectGraceMs, lifecyclePolicy: options.lifecyclePolicy,
|
||||
acpxLaunchProfile: options.acpxLaunchProfile, opencodeLaunchProfile: options.opencodeLaunchProfile,
|
||||
diagnosticsDirectory: options.diagnosticsDirectory,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createAcpRuntime } from "acpx/runtime";
|
||||
import { createAntigravityAdmission } from "./antigravity-admission.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
|
||||
|
||||
/** The real installed manager/stdio transport, with only the remote provider replaced. */
|
||||
async function fixture(behavior: "accept" | "missing" | "mismatch" | "question" | "family" | "family-change" = "accept") {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "antigravity-acpx-contract-"));
|
||||
const requests: Array<Record<string, any>> = [];
|
||||
const nativeResponses: Array<Record<string, any>> = [];
|
||||
const sdkResponses: Array<Record<string, any>> = [];
|
||||
const children: ChildProcess[] = [];
|
||||
const records = new Map();
|
||||
const runtimes: Array<{ runtime: ReturnType<typeof createAcpRuntime>; handle: any }> = [];
|
||||
const content = " Exact 🧷 governance\n\n";
|
||||
const family = behavior.startsWith("family");
|
||||
const model = family ? "gemini-3.8-flash-low" : "caller-model-absent-from-catalog";
|
||||
const admission = createAntigravityAdmission(content, model);
|
||||
cleanups.push(async () => {
|
||||
try {
|
||||
for (const owner of runtimes) if (owner.handle) await owner.runtime.close({ handle: owner.handle, reason: "test cleanup" }).catch(() => {});
|
||||
} finally {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const closed = new Promise<void>(resolve => child.once("close", () => resolve()));
|
||||
child.kill("SIGKILL"); await closed;
|
||||
}
|
||||
}
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const options = {
|
||||
cwd, timeoutMs: 2000, permissionMode: "approve-all" as const, elicitationModes: ["form"] as ["form"],
|
||||
sessionStore: { async load(id: string) { return records.get(id); }, async save(record: any) { records.set(record.acpxRecordId, record); } },
|
||||
agentRegistry: { resolve: () => "verified-antigravity-test-peer", list: () => ["antigravity"] },
|
||||
protocolGuardFactory: () => {
|
||||
const guard = admission.createGuard();
|
||||
return (direction: "inbound" | "outbound", message: unknown) => {
|
||||
if (direction === "inbound") nativeResponses.push(structuredClone(message) as Record<string, any>);
|
||||
guard(direction, message);
|
||||
if (direction === "inbound") sdkResponses.push(structuredClone(message) as Record<string, any>);
|
||||
if (direction === "outbound") requests.push(structuredClone(message) as Record<string, any>);
|
||||
};
|
||||
},
|
||||
spawnAgent: () => {
|
||||
const code = `
|
||||
const readline = require('node:readline'); let promptId;
|
||||
const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\\n');
|
||||
const family=${JSON.stringify(family)};
|
||||
const nativeOptions=()=>family?[
|
||||
{id:'model',name:'Model',type:'select',category:'model',currentValue:'gemini-3.8-flash',options:[{value:'gemini-3.8-flash',name:'Gemini 3.8 Flash'}]},
|
||||
{id:'thinking_level',name:'Thinking level',type:'select',category:'thought_level',currentValue:'low',options:[{value:'high',name:'High'},{value:'medium',name:'Medium'},{value:'low',name:'Low'}]},
|
||||
{id:'mode',name:'Mode',type:'select',category:'mode',currentValue:'default',options:[{value:'default',name:'Default'},{value:'auto_edit',name:'Auto edit'},{value:'yolo',name:'YOLO'}]}
|
||||
]:null;
|
||||
readline.createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line);
|
||||
if(m.method==='initialize') send({id:m.id,result:{protocolVersion:1,agentInfo:{name:'antigravity-acp',version:'1.4.3'},
|
||||
agentCapabilities:{loadSession:true,_meta:{antigravity:{paperclipAdmission:1}}},authMethods:[]}});
|
||||
else if(['session/new','session/load','session/resume','session/set_config_option','session/set_model'].includes(m.method)) {
|
||||
const a=m.params._meta.antigravity.paperclipAdmission;
|
||||
const model=${JSON.stringify(behavior)}==='mismatch'?'fallback':a.model;
|
||||
send({id:m.id,result:{...(!family||m.method==='session/new'?{sessionId:'native-session'}:{}),...(!family||['session/new','session/load','session/resume'].includes(m.method)?{modes:{currentModeId:'default'}}:{}),
|
||||
...(!family||['session/new','session/load','session/resume'].includes(m.method)?{models:{currentModelId:model,availableModels:family?[{modelId:'gemini-3.8-flash-high',name:'Gemini 3.8 Flash (High)'},{modelId:'gemini-3.8-flash-medium',name:'Gemini 3.8 Flash (Medium)'},{modelId:'gemini-3.8-flash-low',name:'Gemini 3.8 Flash (Low)'}]:[{modelId:'advertised-only',name:'Advertised'}]}}:{}),
|
||||
configOptions:nativeOptions()??[{id:'model',name:'Model',type:'select',category:'model',currentValue:model,options:[{value:'advertised-only',name:'Advertised'}]}],
|
||||
...(${JSON.stringify(behavior)}==='missing'?{}:{_meta:{paperclipAntigravityAdmission:{...a,model,sessionId:'native-session',connectionId:'owned-native-connection'}}})}});
|
||||
if(family&&m.method==='session/set_config_option')send({method:'session/update',params:{sessionId:'native-session',update:{sessionUpdate:'config_option_update',configOptions:nativeOptions()}}});
|
||||
} else if(m.method==='session/prompt') {
|
||||
promptId=m.id;
|
||||
if(${JSON.stringify(behavior)}==='family-change') {
|
||||
const changed=nativeOptions();changed.find(o=>o.id==='thinking_level').currentValue='high';
|
||||
send({method:'session/update',params:{sessionId:'native-session',update:{sessionUpdate:'config_option_update',configOptions:changed}}});
|
||||
}
|
||||
if(${JSON.stringify(behavior)}==='question') send({id:0,method:'session/request_permission',params:{sessionId:'native-session',
|
||||
toolCall:{toolCallId:'interaction_choices',title:'Pick one',status:'pending',rawInput:{}},
|
||||
options:[{optionId:'yes',name:'Yes',kind:'allow_once'},{optionId:'deny',name:'No',kind:'reject_once'}]}});
|
||||
else send({id:m.id,result:{stopReason:'end_turn'}});
|
||||
} else if(m.id===0 && m.result?.outcome) {
|
||||
if(m.result.outcome.outcome!=='selected'||m.result.outcome.optionId!=='deny') process.exit(7);
|
||||
send({id:promptId,result:{stopReason:'end_turn'}});
|
||||
} else if(m.id!==undefined) send({id:m.id,result:{}});
|
||||
});`;
|
||||
const child = spawn(process.execPath, ["-e", code], { cwd, env: {}, stdio: ["pipe", "pipe", "pipe"] });
|
||||
children.push(child); return child;
|
||||
},
|
||||
};
|
||||
return {
|
||||
requests, nativeResponses, sdkResponses, content, model,
|
||||
async open() {
|
||||
const owner = { runtime: createAcpRuntime(options), handle: null as any };
|
||||
runtimes.push(owner);
|
||||
owner.handle = await owner.runtime.ensureSession({ sessionKey: "antigravity-contract", agent: "antigravity", mode: "persistent", cwd,
|
||||
sessionOptions: { model, systemPrompt: { append: content } } });
|
||||
return owner;
|
||||
},
|
||||
async turn(owner: { runtime: ReturnType<typeof createAcpRuntime>; handle: any }, onQuestion?: (request: any) => void) {
|
||||
const turn = owner.runtime.startTurn({ handle: owner.handle, text: "Do the task", mode: "prompt", requestId: "turn-contract",
|
||||
onElicitation: async request => { onQuestion?.(request); return { action: "accept", content: { answer: "deny" } }; } });
|
||||
const drained = (async () => { for await (const _event of turn.events) {} })();
|
||||
const result = await turn.result; await drained; return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("passes exact unlisted model and instruction bytes through installed ACPX on create and load", async () => {
|
||||
const f = await fixture(); const first = await f.open();
|
||||
expect((await f.turn(first)).status).toBe("completed");
|
||||
await first.runtime.close({ handle: first.handle, reason: "reload" });
|
||||
const second = await f.open(); expect((await f.turn(second)).status).toBe("completed");
|
||||
const opens = f.requests.filter(message => ["session/new", "session/load"].includes(message.method));
|
||||
expect(opens.some(message => message.method === "session/load")).toBe(true);
|
||||
for (const message of opens) {
|
||||
expect(message.params._meta.antigravity.systemPrompt.append).toBe(f.content);
|
||||
expect(message.params._meta.antigravity.paperclipAdmission.model).toBe(f.model);
|
||||
}
|
||||
expect(new Set(opens.map(message => message.params._meta.antigravity.paperclipAdmission.nonce)).size).toBe(opens.length);
|
||||
});
|
||||
|
||||
it.each(["missing", "mismatch"] as const)("blocks prompts when the actual installed manager receives %s native admission", async behavior => {
|
||||
const f = await fixture(behavior);
|
||||
await expect(f.open()).rejects.toThrow(/admission/i);
|
||||
expect(f.requests.some(message => message.method === "session/prompt")).toBe(false);
|
||||
});
|
||||
|
||||
it("delivers a native deny answer through the form path even with approve-all permissions", async () => {
|
||||
const f = await fixture("question"); const owner = await f.open(); const seen: unknown[] = [];
|
||||
expect((await f.turn(owner, request => seen.push(request))).status).toBe("completed");
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({ mode: "form", requestedSchema: { required: ["answer"] } });
|
||||
expect(f.requests.find(message => message.id === 0 && message.result)).toMatchObject({ result: { outcome: { outcome: "selected", optionId: "deny" } } });
|
||||
});
|
||||
|
||||
it("admits the concrete model through installed ACPX when native new/load/setter metadata groups it by family and thinking level", async () => {
|
||||
const f = await fixture("family");
|
||||
const first = await f.open();
|
||||
expect((await f.turn(first)).status).toBe("completed");
|
||||
await first.runtime.setConfigOption({ handle: first.handle, key: "model", value: f.model });
|
||||
expect((await f.turn(first)).status).toBe("completed");
|
||||
await first.runtime.close({ handle: first.handle, reason: "native family reload" });
|
||||
const loaded = await f.open();
|
||||
expect((await f.turn(loaded)).status).toBe("completed");
|
||||
expect(f.requests.some(message => message.method === "session/load")).toBe(true);
|
||||
expect(f.requests.some(message => message.method === "session/set_config_option")).toBe(true);
|
||||
const native = f.nativeResponses.filter(message => message.result?.configOptions);
|
||||
expect(native.length).toBeGreaterThanOrEqual(3);
|
||||
const setter = native.find(message => !message.result.models);
|
||||
expect(setter).toBeDefined();
|
||||
expect(setter!.result.modes).toBeUndefined();
|
||||
expect(setter!.result._meta.paperclipAntigravityAdmission.model).toBe(f.model);
|
||||
for (const message of native) {
|
||||
expect(message.result.configOptions.find((option: any) => option.id === "model").currentValue).toBe("gemini-3.8-flash");
|
||||
expect(message.result.configOptions.find((option: any) => option.id === "thinking_level").currentValue).toBe("low");
|
||||
}
|
||||
for (const message of f.sdkResponses.filter(message => message.result?.configOptions)) {
|
||||
expect(message.result.configOptions.find((option: any) => option.id === "model").currentValue).toBe(f.model);
|
||||
}
|
||||
const updates = f.sdkResponses.filter(message => message.params?.update?.sessionUpdate === "config_option_update");
|
||||
expect(updates.length).toBeGreaterThan(0);
|
||||
for (const message of updates) expect(message.params.update.configOptions.find((option: any) => option.id === "model").currentValue).toBe(f.model);
|
||||
});
|
||||
|
||||
it("rejects an unacknowledged thinking-level change after a native family model was admitted", async () => {
|
||||
const f = await fixture("family-change"), owner = await f.open();
|
||||
const result = await f.turn(owner);
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.message).toMatch(/admission/i);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ANTIGRAVITY_ADMISSION_SCHEMA, createAntigravityAdmission } from "./antigravity-admission.js";
|
||||
|
||||
const content = " Exact governance 🧷\n\n";
|
||||
const model = "caller/model-not-in-catalog";
|
||||
const initialize = { agentInfo: { name: "antigravity-acp", version: "1.4.3" }, agentCapabilities: { _meta: { antigravity: { paperclipAdmission: 1 } } } };
|
||||
function fixture() {
|
||||
const admission = createAntigravityAdmission(content, model);
|
||||
const guard = admission.createGuard();
|
||||
guard("outbound", { id: "init", method: "initialize", params: {} });
|
||||
guard("inbound", { id: "init", result: structuredClone(initialize) });
|
||||
return { admission, guard };
|
||||
}
|
||||
function request(f: ReturnType<typeof fixture>, method = "session/new", id: string | number = 0) {
|
||||
const message = { id, method, params: { ...(method === "session/new" ? {} : { sessionId: "native-session" }) } as Record<string, unknown> };
|
||||
f.guard("outbound", message);
|
||||
return message;
|
||||
}
|
||||
function response(message: ReturnType<typeof request>, changes: Record<string, unknown> = {}) {
|
||||
const meta = message.params._meta as { antigravity: { paperclipAdmission: Record<string, unknown> } };
|
||||
return { id: message.id, result: {
|
||||
sessionId: "native-session", models: { currentModelId: model }, modes: { currentModeId: "default" },
|
||||
_meta: { paperclipAntigravityAdmission: {
|
||||
...meta.antigravity.paperclipAdmission, sessionId: "native-session", connectionId: "native-connection", ...changes,
|
||||
} },
|
||||
} };
|
||||
}
|
||||
const prompt = () => ({ id: "turn", method: "session/prompt", params: { sessionId: "native-session", prompt: [{ type: "text", text: "Do the task" }] } });
|
||||
|
||||
describe("Antigravity exact native admission", () => {
|
||||
it("preserves whitespace/Unicode and binds a native receipt to request ID zero", () => {
|
||||
const f = fixture(); const sent = request(f);
|
||||
const meta = sent.params._meta as { antigravity: Record<string, unknown> };
|
||||
expect(meta.antigravity.systemPrompt).toEqual({ append: content });
|
||||
expect(meta.antigravity.paperclipAdmission).toMatchObject({ schema: ANTIGRAVITY_ADMISSION_SCHEMA, model, byteLength: Buffer.byteLength(content) });
|
||||
f.guard("inbound", response(sent));
|
||||
expect(() => f.admission.assertReady()).not.toThrow();
|
||||
expect(() => f.guard("outbound", prompt())).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(["session/load", "session/resume"])("requires a new exact receipt on %s", method => {
|
||||
const f = fixture(); const first = request(f); f.guard("inbound", response(first));
|
||||
const load = request(f, method, "reloaded");
|
||||
expect(() => f.admission.assertReady()).toThrow("receipt is missing");
|
||||
const ack = response(load);
|
||||
expect(ack.result._meta.paperclipAntigravityAdmission.nonce).not.toBe(response(first).result._meta.paperclipAntigravityAdmission.nonce);
|
||||
f.guard("inbound", ack);
|
||||
expect(() => f.guard("outbound", prompt())).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ digest: "sha256:wrong" }, { byteLength: 1 }, { nonce: "stale-request" },
|
||||
{ sessionId: "other-session" }, { model: "fallback" }, { connectionId: "" }, { schema: "wrong" },
|
||||
])("rejects a mismatched native receipt %j before prompting", mutation => {
|
||||
const f = fixture(); const sent = request(f);
|
||||
expect(() => f.guard("inbound", response(sent, mutation))).toThrow("receipt");
|
||||
expect(() => f.guard("outbound", prompt())).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an ordinary successful ACP response without native content proof", () => {
|
||||
const f = fixture(); const sent = request(f);
|
||||
expect(() => f.guard("inbound", { id: sent.id, result: { sessionId: "native-session" } })).toThrow("receipt");
|
||||
});
|
||||
|
||||
it("does not inherit authority from a previous stdio connection", () => {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent));
|
||||
const next = f.admission.createGuard();
|
||||
expect(() => f.guard("outbound", prompt())).toThrow("replaced");
|
||||
expect(() => next("outbound", prompt())).toThrow("unacknowledged");
|
||||
});
|
||||
|
||||
it("preserves the provider's original session error and never admits a prompt", () => {
|
||||
const f = fixture(); const sent = request(f);
|
||||
expect(() => f.guard("inbound", { id: sent.id, error: { code: -32000, message: "Authentication required" } })).not.toThrow();
|
||||
expect(() => f.admission.assertReady()).toThrow("receipt is missing");
|
||||
expect(() => f.guard("outbound", prompt())).toThrow();
|
||||
});
|
||||
|
||||
it("rejects native model replacement and permission-mode changes", () => {
|
||||
for (const update of [
|
||||
{ sessionUpdate: "config_option_update", models: { currentModelId: "fallback" } },
|
||||
{ sessionUpdate: "current_mode_update", currentModeId: "yolo" },
|
||||
]) {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent));
|
||||
expect(() => f.guard("inbound", { method: "session/update", params: { sessionId: "native-session", update } })).toThrow();
|
||||
expect(() => f.guard("outbound", prompt())).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a fresh configuration receipt after exact-model control", () => {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent));
|
||||
const control = { id: "model-control", method: "session/set_config_option", params: { sessionId: "native-session", configId: "model", value: model } as Record<string, unknown> };
|
||||
f.guard("outbound", control);
|
||||
expect(() => f.admission.assertReady()).toThrow();
|
||||
f.guard("inbound", response(control));
|
||||
expect(() => f.admission.assertReady()).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects native slash commands, non-text prompts, and arbitrary configuration", () => {
|
||||
for (const message of [
|
||||
{ ...prompt(), params: { ...prompt().params, prompt: [{ type: "text", text: " /logout" }] } },
|
||||
{ ...prompt(), params: { ...prompt().params, prompt: [{ type: "image", data: "unverified" }] } },
|
||||
{ id: "control", method: "session/set_config_option", params: { sessionId: "native-session", configId: "mode", value: "yolo" } },
|
||||
]) {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent));
|
||||
expect(() => f.guard("outbound", message)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["Do the task\n/logout", "Do the task\r\n /model replace", "Do the task\r /login"])("rejects a native slash command on any prompt line: %j", text => {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent));
|
||||
expect(() => f.guard("outbound", { ...prompt(), params: { ...prompt().params, prompt: [{ type: "text", text }] } })).toThrow("slash commands");
|
||||
});
|
||||
|
||||
it("rejects a native slash command in a later text block", () => {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent));
|
||||
expect(() => f.guard("outbound", { ...prompt(), params: { ...prompt().params, prompt: [{ type: "text", text: "Do the task" }, { type: "text", text: "/logout" }] } })).toThrow("slash commands");
|
||||
});
|
||||
|
||||
it("rejects unpatched native runtimes before creating a session", () => {
|
||||
const f = createAntigravityAdmission("", model); const guard = f.createGuard();
|
||||
guard("outbound", { id: 0, method: "initialize", params: {} });
|
||||
expect(() => guard("inbound", { id: 0, result: { agentInfo: initialize.agentInfo, agentCapabilities: {} } })).toThrow("patch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Antigravity native questions", () => {
|
||||
function admitted() {
|
||||
const f = fixture(); const sent = request(f); f.guard("inbound", response(sent)); return f;
|
||||
}
|
||||
const question = () => ({ id: 0, method: "session/request_permission", params: {
|
||||
sessionId: "native-session", toolCall: { toolCallId: "interaction_123", title: "Choose a deployment" },
|
||||
options: [{ optionId: "allow/weird:α", name: "Deploy", kind: "allow_once" }, { optionId: "deny", name: "Keep it local", kind: "reject_once" }],
|
||||
} });
|
||||
|
||||
it("converts permission-shaped choices into a form and preserves a deny choice as an answer", () => {
|
||||
const f = admitted(); const q = question(); f.guard("inbound", q);
|
||||
expect(q.method).toBe("elicitation/create");
|
||||
expect(q.params).toMatchObject({ mode: "form", requestedSchema: { required: ["answer"], properties: {
|
||||
answer: { oneOf: [{ const: "allow/weird:α", title: "Deploy" }, { const: "deny", title: "Keep it local" }] },
|
||||
} } });
|
||||
const answer = { id: 0, result: { action: "accept", content: { answer: "deny" } } as Record<string, unknown> };
|
||||
f.guard("outbound", answer);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "deny" } });
|
||||
});
|
||||
|
||||
it("passes ordinary tool permissions through unchanged", () => {
|
||||
const f = admitted(); const q = question(); q.params.toolCall.toolCallId = "command_123";
|
||||
const before = structuredClone(q); f.guard("inbound", q); expect(q).toEqual(before);
|
||||
});
|
||||
|
||||
it.each(["forged", ["deny", "allow/weird:α"]])("rejects forged or multiple answers %j", answer => {
|
||||
const f = admitted(); const q = question(); f.guard("inbound", q);
|
||||
expect(() => f.guard("outbound", { id: 0, result: { action: "accept", content: { answer } } })).toThrow("offered choice");
|
||||
});
|
||||
|
||||
it("maps explicit cancellation and rejects duplicate native request identities", () => {
|
||||
const f = admitted(); f.guard("inbound", question());
|
||||
const answer = { id: 0, result: { action: "cancel" } as Record<string, unknown> };
|
||||
f.guard("outbound", answer); expect(answer.result).toEqual({ outcome: { outcome: "cancelled" } });
|
||||
f.guard("inbound", question());
|
||||
expect(() => f.guard("inbound", question())).toThrow("identity");
|
||||
});
|
||||
|
||||
it("retires old questions when a new native session replaces their authority", () => {
|
||||
const f = admitted(); f.guard("inbound", question());
|
||||
const sent = request(f, "session/new", "new-session-request"), ack = response(sent, { sessionId: "new-native-session" });
|
||||
ack.result.sessionId = "new-native-session"; f.guard("inbound", ack);
|
||||
const late = { id: 0, result: { action: "accept", content: { answer: "deny" } } };
|
||||
expect(() => f.guard("outbound", late)).not.toThrow();
|
||||
expect(late.result).toEqual({ action: "accept", content: { answer: "deny" } });
|
||||
const next = question(); next.params.sessionId = "new-native-session";
|
||||
expect(() => f.guard("inbound", next)).not.toThrow();
|
||||
expect(next.method).toBe("elicitation/create");
|
||||
expect(() => f.admission.assertReady()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createAntigravityQuestionBridge } from "./antigravity-questions.js";
|
||||
|
||||
export const ANTIGRAVITY_ADMISSION_SCHEMA = "paperclip.antigravity.admission.v1";
|
||||
const SESSION_METHODS = new Set(["session/new", "session/load", "session/resume"]);
|
||||
|
||||
export class AntigravityAdmissionError extends Error {
|
||||
readonly code = "ANTIGRAVITY_ADMISSION_FAILED";
|
||||
readonly retryable = false;
|
||||
constructor(detail: string) { super(`Antigravity admission failed: ${detail}`); this.name = "AntigravityAdmissionError"; }
|
||||
}
|
||||
|
||||
export function antigravityInstructionBinding(content: string) {
|
||||
if (typeof content !== "string" || content.includes("\0") || Buffer.byteLength(content) > 256 * 1024) {
|
||||
throw new AntigravityAdmissionError("instructions exceed their size limit or contain NUL");
|
||||
}
|
||||
return { digest: `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`, byteLength: Buffer.byteLength(content) };
|
||||
}
|
||||
|
||||
export function assertAntigravityPromptPolicy(content: string): void {
|
||||
// Native slash commands can alter account/configuration before inference.
|
||||
if (content.split(/\r\n|\r|\n/u).some(line => line.trimStart().startsWith("/"))) throw new AntigravityAdmissionError("native slash commands are unavailable in a managed task");
|
||||
}
|
||||
|
||||
/** No saved ACPX record can substitute for this actual connection's receipt. */
|
||||
export function createAntigravityAdmission(content: string, model: string) {
|
||||
const binding = antigravityInstructionBinding(content);
|
||||
if (typeof model !== "string" || !model.trim() || model.includes("\0") || model.length > 512) {
|
||||
throw new AntigravityAdmissionError("an explicit model is required");
|
||||
}
|
||||
let current: { sessionId: string | null; error: Error | null } | null = null;
|
||||
const assertReady = () => {
|
||||
if (current?.error) throw current.error;
|
||||
if (!current?.sessionId) throw new AntigravityAdmissionError("the native configuration receipt is missing");
|
||||
};
|
||||
return {
|
||||
assertReady,
|
||||
createGuard() {
|
||||
const state = { sessionId: null as string | null, error: null as Error | null };
|
||||
current = state;
|
||||
let initialized = false;
|
||||
const sessions = new Map<string, string>();
|
||||
let nativeSelection = new Map<string, string>();
|
||||
const pending = new Map<string | number, { method: string; nonce?: string; sessionId?: string }>();
|
||||
const questions = createAntigravityQuestionBridge(id => typeof id === "string" && sessions.has(id));
|
||||
const fail = (detail: string): never => { throw new AntigravityAdmissionError(detail); };
|
||||
return (direction: "inbound" | "outbound", value: unknown): void => {
|
||||
if (current !== state) throw new AntigravityAdmissionError("connection authority was replaced");
|
||||
if (state.error) throw state.error;
|
||||
const message = record(value);
|
||||
const params = record(message.params);
|
||||
const method = message.method;
|
||||
const id = message.id;
|
||||
try {
|
||||
if (direction === "outbound" && typeof method === "string") {
|
||||
const isSession = SESSION_METHODS.has(method);
|
||||
const isModel = method === "session/set_model" || method === "session/set_config_option";
|
||||
if (method.startsWith("session/") && !isSession) {
|
||||
if (typeof params.sessionId !== "string" || !sessions.has(params.sessionId)) fail("operation belongs to an unacknowledged session");
|
||||
}
|
||||
if (isSession || isModel) {
|
||||
if (!initialized) fail("the runtime does not advertise the required native admission patch");
|
||||
if (isModel && !(method === "session/set_model" ? params.modelId === model : params.configId === "model" && params.value === model)) {
|
||||
fail("only the exact selected model can be configured");
|
||||
}
|
||||
if (typeof id !== "string" && typeof id !== "number") fail("native admission requires a request identity");
|
||||
if ([...pending.values()].some(request => request.nonce)) fail("a native configuration request is already pending");
|
||||
const nonce = randomBytes(24).toString("hex");
|
||||
const meta = record(params._meta);
|
||||
params._meta = { ...meta, antigravity: {
|
||||
...record(meta.antigravity), systemPrompt: { append: content },
|
||||
paperclipAdmission: { schema: ANTIGRAVITY_ADMISSION_SCHEMA, nonce, model, ...binding },
|
||||
} };
|
||||
message.params = params;
|
||||
state.sessionId = null;
|
||||
sessions.clear();
|
||||
nativeSelection.clear();
|
||||
questions.clear();
|
||||
if (pending.has(id as string | number) || pending.size >= 128) fail("duplicate or excessive native admission requests");
|
||||
pending.set(id as string | number, { method, nonce, ...(typeof params.sessionId === "string" ? { sessionId: params.sessionId } : {}) });
|
||||
} else if (method === "session/prompt") {
|
||||
assertReady();
|
||||
if (params.sessionId !== state.sessionId || !Array.isArray(params.prompt)
|
||||
|| params.prompt.some(value => record(value).type !== "text" || typeof record(value).text !== "string")) fail("prompt is outside the admitted text session");
|
||||
assertAntigravityPromptPolicy((params.prompt as unknown[]).map(value => record(value).text).join("\n"));
|
||||
} else if (!["initialize", "session/cancel", "session/close"].includes(method)) {
|
||||
fail("native control is unavailable in a managed task");
|
||||
}
|
||||
if (method === "initialize" && (typeof id === "string" || typeof id === "number")) {
|
||||
if (pending.has(id)) fail("duplicate initialize identity");
|
||||
pending.set(id, { method });
|
||||
}
|
||||
if (method === "session/close" && typeof params.sessionId === "string") {
|
||||
sessions.delete(params.sessionId); state.sessionId = null; nativeSelection.clear(); questions.clear();
|
||||
}
|
||||
}
|
||||
if (direction === "inbound" && method === undefined && (typeof id === "string" || typeof id === "number")) {
|
||||
const request = pending.get(id);
|
||||
if (request) {
|
||||
pending.delete(id);
|
||||
if (message.error !== undefined) return; // Keep the provider's typed error.
|
||||
const result = record(message.result);
|
||||
if (request.method === "initialize") {
|
||||
const info = record(result.agentInfo);
|
||||
const native = record(record(record(result.agentCapabilities)._meta).antigravity);
|
||||
if (info.name !== "antigravity-acp" || info.version !== "1.4.3" || native.paperclipAdmission !== 1) fail("runtime identity or native admission patch is unavailable");
|
||||
initialized = true;
|
||||
} else if (request.nonce) {
|
||||
const ack = record(record(result._meta).paperclipAntigravityAdmission);
|
||||
const sessionId = request.sessionId ?? result.sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId || ack.schema !== ANTIGRAVITY_ADMISSION_SCHEMA
|
||||
|| ack.nonce !== request.nonce || ack.sessionId !== sessionId || ack.model !== model
|
||||
|| ack.digest !== binding.digest || ack.byteLength !== binding.byteLength
|
||||
|| typeof ack.connectionId !== "string" || !ack.connectionId || ack.connectionId.length > 256) fail("native configuration receipt is missing, stale, or mismatched");
|
||||
const selected = selectedModel(result);
|
||||
if (selected !== undefined && selected !== model) fail("provider acknowledged a different model");
|
||||
const mode = record(result.modes).currentModeId;
|
||||
if (mode !== undefined && mode !== "default") fail("native permissions are outside the admitted default mode");
|
||||
nativeSelection = selectionValues(result);
|
||||
projectConcreteModel(result, model);
|
||||
sessions.set(sessionId as string, ack.connectionId as string);
|
||||
state.sessionId = sessionId as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (direction === "inbound" && method === "session/update") {
|
||||
const update = record(params.update);
|
||||
if (update.sessionUpdate === "config_option_update" && sessions.has(String(params.sessionId))
|
||||
&& selectedModel(update) !== undefined && selectedModel(update) !== model) fail("provider changed the admitted model");
|
||||
if (update.sessionUpdate === "config_option_update" && sessions.has(String(params.sessionId))) {
|
||||
for (const [id, value] of selectionValues(update)) {
|
||||
if (nativeSelection.get(id) !== value) fail("provider changed the admitted model, thinking level, or permission mode");
|
||||
}
|
||||
projectConcreteModel(update, model);
|
||||
}
|
||||
if (update.sessionUpdate === "current_mode_update" && update.currentModeId !== "default") fail("provider changed the admitted permission mode");
|
||||
}
|
||||
questions.guard(direction, message);
|
||||
} catch (error) {
|
||||
state.error = error instanceof AntigravityAdmissionError ? error : new AntigravityAdmissionError(error instanceof Error ? error.message : "invalid native protocol");
|
||||
state.sessionId = null; sessions.clear(); nativeSelection.clear(); pending.clear(); questions.clear();
|
||||
throw state.error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function selectionValues(value: Record<string, unknown>): Map<string, string> {
|
||||
const selected = new Map<string, string>();
|
||||
if (!Array.isArray(value.configOptions)) return selected;
|
||||
for (const entry of value.configOptions) {
|
||||
const option = record(entry);
|
||||
if (!["model", "thinking_level", "mode"].includes(String(option.id))) continue;
|
||||
if (typeof option.currentValue !== "string" || selected.has(String(option.id))) {
|
||||
throw new AntigravityAdmissionError("invalid native selection state");
|
||||
}
|
||||
selected.set(String(option.id), option.currentValue);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/** Native model options group variants into a family plus thinking level.
|
||||
* ACPX treats currentValue as a concrete model ID. Project only after the
|
||||
* native receipt binds the actual initialized variant; keep the native values
|
||||
* above so subsequent family/thinking changes cannot inherit that authority.
|
||||
*/
|
||||
function projectConcreteModel(value: Record<string, unknown>, model: string): void {
|
||||
if (!Array.isArray(value.configOptions)) return;
|
||||
for (const entry of value.configOptions) {
|
||||
const option = record(entry);
|
||||
if (option.id === "model") option.currentValue = model;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedModel(value: Record<string, unknown>): unknown {
|
||||
// The native model selector can group concrete variants under a display
|
||||
// family. The exact initialized model is bound by the native receipt;
|
||||
// only models.currentModelId is an additional concrete-model assertion.
|
||||
return record(value.models).currentModelId;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setup = vi.hoisted(() => ({ spawn: vi.fn(), close: vi.fn(async () => undefined), verify: vi.fn() }));
|
||||
vi.mock("./antigravity-installation.js", () => ({ antigravityNativeDistributionSpec: () => ({ pinned: true }) }));
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: setup.verify }));
|
||||
import { sanitizeAntigravityCredential, startAntigravityBrowserLogin, validateAntigravityAuthorizationUrl, verifyAntigravityCredential } from "./antigravity-auth.js";
|
||||
|
||||
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const credential = { client_id: CLIENT_ID, client_secret: "fixture-client-secret", refresh_token: "fixture-refresh-token", token_uri: "https://oauth2.googleapis.com/token",
|
||||
scopes: ["https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/aicode"], project_id: "fixture-project" };
|
||||
function authorizationUrl() {
|
||||
return "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ client_id: CLIENT_ID, response_type: "code", redirect_uri: "http://127.0.0.1:18973/",
|
||||
scope: credential.scopes.join(" "), state: "fixture_state_0123456789", code_challenge: "a".repeat(43), code_challenge_method: "S256", access_type: "offline", prompt: "consent" });
|
||||
}
|
||||
const roots: string[] = [], children: ChildProcess[] = [];
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const child of children.splice(0)) if (child.exitCode === null && child.signalCode === null) { try { process.kill(-child.pid!, "SIGKILL"); } catch {} }
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
||||
vi.unstubAllGlobals(); vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function fixture(mode = "success") {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-antigravity-login-test-"))); roots.push(home);
|
||||
const peer = join(home, "peer.cjs");
|
||||
await writeFile(peer, `
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const readline = require('node:readline');
|
||||
const home = process.argv[2], mode = process.argv[3];
|
||||
const requests = [];
|
||||
if(mode==='stubborn') {
|
||||
process.on('SIGTERM',()=>{});
|
||||
const child=require('node:child_process').spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"],{stdio:'ignore'});
|
||||
fs.writeFileSync(path.join(home,'descendant.pid'),String(child.pid));
|
||||
}
|
||||
const reader = readline.createInterface({input:process.stdin});
|
||||
reader.on('line', line => {
|
||||
const request=JSON.parse(line); requests.push(request);
|
||||
fs.writeFileSync(path.join(home,'requests.json'),JSON.stringify(requests));
|
||||
if(request.method==='initialize') {
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:1,agentInfo:{name:'antigravity-acp',version:'1.4.3'},agentCapabilities:{_meta:{antigravity:{paperclipAdmission:1}}},authMethods:[{id:'oauth-personal'}]}})+'\\n');
|
||||
} else if(request.method==='authenticate') {
|
||||
const url = mode==='bad-url' ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
const message='Open the following link to authenticate the ACP server: '+url+'\\n';
|
||||
process.stderr.write(message.slice(0,42));process.stderr.write(message.slice(42));
|
||||
if(mode==='blocked' || mode==='bad-url' || mode==='stubborn' || mode==='retained') return;
|
||||
if(mode==='error') {process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,error:{code:-32000,message:'do not expose fixture-refresh-token'}})+'\\n');return;}
|
||||
fs.mkdirSync(path.join(home,'antigravity-acp'),{mode:0o700});
|
||||
fs.writeFileSync(path.join(home,'antigravity-acp/acp_token.json'),${JSON.stringify(JSON.stringify(credential))},{mode:0o600});
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{}})+'\\n');
|
||||
} else {throw new Error('inference/session request forbidden');}
|
||||
});
|
||||
reader.on('close',()=>mode==='stubborn'||mode==='retained'?setInterval(()=>{},1000):process.exit(0));
|
||||
`);
|
||||
setup.verify.mockResolvedValue({ openCommand: async () => ({ spawn: setup.spawn, close: setup.close }) });
|
||||
setup.spawn.mockImplementation((_args, options) => {
|
||||
const child = spawn(process.execPath, [peer, home, mode], { ...options, detached: true }); children.push(child); return child;
|
||||
});
|
||||
return home;
|
||||
}
|
||||
|
||||
function googleIdentity(emailVerified = true) {
|
||||
return vi.fn().mockImplementation(async (url: string, options: RequestInit) => {
|
||||
if (url === "https://oauth2.googleapis.com/token") {
|
||||
expect(options.redirect).toBe("error");
|
||||
expect(String(options.body)).toContain("grant_type=refresh_token");
|
||||
return new Response(JSON.stringify({ access_token: "fixture-access-token", refresh_token: "fixture-rotated-refresh", token_type: "Bearer" }));
|
||||
}
|
||||
expect(url).toBe("https://www.googleapis.com/oauth2/v3/userinfo");
|
||||
expect(options.headers).toEqual({ Authorization: "Bearer fixture-access-token" });
|
||||
return new Response(JSON.stringify({ email: "person@example.test", email_verified: emailVerified }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Antigravity setup-only Google authentication", () => {
|
||||
it.each(["network", "unverified"])("preserves an OAuth rotation when userinfo %s verification fails", async failure => {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-antigravity-rotation-test-"))); roots.push(home);
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(join(home, "antigravity-acp"), { mode: 0o700 });
|
||||
const token = join(home, "antigravity-acp", "acp_token.json");
|
||||
await writeFile(token, JSON.stringify(credential), { mode: 0o600 });
|
||||
const fetch = vi.fn().mockResolvedValueOnce(Response.json({ access_token: "fixture-access-token", refresh_token: "fixture-newest-rotation", token_type: "Bearer" }));
|
||||
if (failure === "network") fetch.mockRejectedValueOnce(new Error("fixture userinfo unavailable"));
|
||||
else fetch.mockResolvedValueOnce(Response.json({ email: "person@example.test", email_verified: false }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await expect(verifyAntigravityCredential(home)).rejects.toThrow();
|
||||
expect(JSON.parse(await readFile(token, "utf8")).refresh_token).toBe("fixture-newest-rotation");
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("performs native authenticate, retires it, verifies identity, and preserves refresh rotation without repeated status network calls", async () => {
|
||||
const home = await fixture(), fetch = googleIdentity(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5_000 });
|
||||
expect(login.authorizationUrl).toBe(authorizationUrl());
|
||||
expect(JSON.parse(await readFile(join(home, "requests.json"), "utf8")).map((request: { method: string }) => request.method)).toEqual(["initialize", "authenticate"]);
|
||||
expect(setup.spawn.mock.calls[0]![1].env).toMatchObject({ GEMINI_HOME: home, AGY_ACP_FORCE_FILE_STORAGE: "1", BROWSER: "/usr/bin/true" });
|
||||
expect(Object.keys(setup.spawn.mock.calls[0]![1].env).some(key => /^(?:GOOGLE_|GEMINI_API|ANTIGRAVITY_|PYTHON|NODE_OPTIONS|HTTP_PROXY)/.test(key))).toBe(false);
|
||||
expect(setup.close).toHaveBeenCalledOnce();
|
||||
expect(children[0]!.exitCode).toBe(0);
|
||||
const verified = await verifyAntigravityCredential(home);
|
||||
expect(verified.accountEmail).toBe("person@example.test");
|
||||
expect(JSON.parse(verified.credential).refresh_token).toBe("fixture-rotated-refresh");
|
||||
expect(JSON.parse(await readFile(join(home, "antigravity-acp/acp_token.json"), "utf8")).refresh_token).toBe("fixture-rotated-refresh");
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
await login.abort();
|
||||
// Runtime verification outside a live browser attempt must not retain a
|
||||
// credential indefinitely in the browser status cache.
|
||||
await verifyAntigravityCredential(home);
|
||||
await verifyAntigravityCredential(home);
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it.each(["error", "bad-url"])("rejects %s without leaking provider text or calling Google", async mode => {
|
||||
const home = await fixture(mode), fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5_000 });
|
||||
await vi.waitFor(() => expect(setup.close).toHaveBeenCalledOnce(), { timeout: 5_000 });
|
||||
expect(login.error).not.toContain("fixture");
|
||||
if (mode === "bad-url") expect(login.authorizationUrl).toBeUndefined();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("abort retires a waiting native flow without session requests", async () => {
|
||||
const home = await fixture("blocked"), fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.authorizationUrl).toBeDefined());
|
||||
login.abort();
|
||||
await vi.waitFor(() => expect(setup.close).toHaveBeenCalledOnce(), { timeout: 5_000 });
|
||||
expect(login.outcome).toBe("failure"); expect(fetch).not.toHaveBeenCalled();
|
||||
expect(children[0]!.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("abort waits for SIGKILL retirement of a login and inherited descendant that ignore EOF and SIGTERM", async () => {
|
||||
const home = await fixture("stubborn"); vi.stubGlobal("fetch", vi.fn());
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.authorizationUrl).toBeDefined());
|
||||
const descendant = (await readFile(join(home, "descendant.pid"), "utf8")).trim();
|
||||
await login.abort();
|
||||
expect(children[0]!.signalCode).toBe("SIGKILL");
|
||||
expect(setup.close).toHaveBeenCalledOnce();
|
||||
let state = "";
|
||||
try { state = execFileSync("/bin/ps", ["-o", "stat=", "-p", descendant], { encoding: "utf8" }).trim(); } catch {}
|
||||
expect(state === "" || state.startsWith("Z")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps abort, the command lease and the private home owned through uncertain retirement until the actual child exits", async () => {
|
||||
const home = await fixture("retained"); vi.stubGlobal("fetch", vi.fn());
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.authorizationUrl).toBeDefined());
|
||||
const child = children[0]!, realKill = process.kill.bind(process);
|
||||
let blockProof = true;
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (blockProof && pid === -child.pid! && signal === 0) throw Object.assign(new Error("controlled process-state uncertainty"), { code: "EACCES" });
|
||||
return realKill(pid, signal);
|
||||
});
|
||||
const stopped = login.abort(), releaseHome = vi.fn(() => rm(home, { recursive: true, force: true }));
|
||||
const cleaned = stopped.then(releaseHome);
|
||||
await vi.waitFor(() => expect(login.error).toContain("still stopping"), { timeout: 5_000 });
|
||||
expect(child.exitCode).toBeNull(); expect(child.signalCode).toBeNull();
|
||||
expect(setup.close).not.toHaveBeenCalled(); expect(releaseHome).not.toHaveBeenCalled();
|
||||
await expect(access(home)).resolves.toBeUndefined();
|
||||
blockProof = false;
|
||||
realKill(-child.pid!, "SIGKILL");
|
||||
await cleaned;
|
||||
expect(child.signalCode).toBe("SIGKILL");
|
||||
expect(setup.close).toHaveBeenCalledOnce(); expect(releaseHome).toHaveBeenCalledOnce();
|
||||
await expect(access(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("does not accept an unverified Google account after native authentication", async () => {
|
||||
const home = await fixture(); vi.stubGlobal("fetch", googleIdentity(false));
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5_000 });
|
||||
expect(setup.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires the pinned OAuth client and token destination and strips unrelated native fields", () => {
|
||||
expect(JSON.parse(sanitizeAntigravityCredential(JSON.stringify({ ...credential, endpoint: "https://example.invalid", access_token: "discard" })))).toEqual(credential);
|
||||
expect(sanitizeAntigravityCredential(JSON.stringify({ ...credential, scopes: [...credential.scopes].reverse() }))).toBe(sanitizeAntigravityCredential(JSON.stringify(credential)));
|
||||
expect(() => sanitizeAntigravityCredential(JSON.stringify({ ...credential, token_uri: "https://example.invalid/token" }))).toThrow("Invalid Antigravity credential");
|
||||
expect(() => sanitizeAntigravityCredential(JSON.stringify({ ...credential, client_id: "another-client" }))).toThrow("Invalid Antigravity credential");
|
||||
expect(() => sanitizeAntigravityCredential(JSON.stringify({ ...credential, scopes: ["openid"] }))).toThrow("Invalid Antigravity credential");
|
||||
});
|
||||
|
||||
it("requires Google PKCE and a numeric loopback redirect", () => {
|
||||
expect(validateAntigravityAuthorizationUrl(authorizationUrl())).toBe(authorizationUrl());
|
||||
for (const url of [authorizationUrl().replace("accounts.google.com", "example.invalid"), authorizationUrl().replace("S256", "plain"), authorizationUrl().replace("127.0.0.1", "localhost"), authorizationUrl() + "&state=duplicate"]) {
|
||||
expect(() => validateAntigravityAuthorizationUrl(url)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, rename, rm } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { antigravityPrivateEnvironment, checkAntigravityPrivateHome, retireAntigravityProcess } from "./antigravity-process.js";
|
||||
import { antigravityNativeDistributionSpec } from "./antigravity-installation.js";
|
||||
import { verifyNativeAcpxInstallation, type VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const TOKEN_URI = "https://oauth2.googleapis.com/token";
|
||||
const SCOPES = ["https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/aicode"];
|
||||
const AUTH_PREFIX = "Open the following link to authenticate the ACP server: ";
|
||||
const MAX_BYTES = 64 * 1024;
|
||||
const LOGIN_ERROR = "Google sign-in did not complete. Start sign-in again.";
|
||||
const record = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const verifiedCredentials = new Map<string, { credential: string; accountEmail: string }>();
|
||||
const activeBrowserHomes = new Set<string>();
|
||||
|
||||
export type AntigravityBrowserLoginState = {
|
||||
authorizationUrl?: string;
|
||||
outcome?: "success" | "failure";
|
||||
error?: string;
|
||||
/** Resolves only after physical process retirement and lease release. */
|
||||
abort(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Pure validation for the native credential projection and refresh writeback. */
|
||||
export function sanitizeAntigravityCredential(serialized: string): string {
|
||||
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error("Invalid Antigravity credential");
|
||||
let value: Record<string, unknown>;
|
||||
try { value = record(JSON.parse(serialized)); } catch { throw new Error("Invalid Antigravity credential"); }
|
||||
const text = (entry: unknown, max: number): entry is string => typeof entry === "string" && entry.length > 0 && entry.length <= max && !/[\x00-\x20\x7f]/.test(entry);
|
||||
if (value.client_id !== CLIENT_ID || value.token_uri !== TOKEN_URI
|
||||
|| !text(value.client_secret, 1024) || !text(value.refresh_token, 16_384)
|
||||
|| !Array.isArray(value.scopes) || value.scopes.length > 4
|
||||
|| !value.scopes.every(scope => typeof scope === "string" && [...SCOPES, "openid"].includes(scope))
|
||||
|| !SCOPES.every(scope => (value.scopes as unknown[]).includes(scope))
|
||||
|| new Set(value.scopes).size !== value.scopes.length
|
||||
|| (value.project_id !== undefined && !text(value.project_id, 1024))) throw new Error("Invalid Antigravity credential");
|
||||
return JSON.stringify({ client_id: CLIENT_ID, client_secret: value.client_secret, refresh_token: value.refresh_token,
|
||||
token_uri: TOKEN_URI, scopes: [...SCOPES, ...((value.scopes as unknown[]).includes("openid") ? ["openid"] : [])], ...(value.project_id !== undefined ? { project_id: value.project_id } : {}) });
|
||||
}
|
||||
|
||||
/** Only the official PKCE loopback authorization URL may reach the browser. */
|
||||
export function validateAntigravityAuthorizationUrl(raw: string): string {
|
||||
if (raw.length > 16_384 || /[\x00-\x20\x7f]/.test(raw)) throw new Error(LOGIN_ERROR);
|
||||
const url = new URL(raw), query = url.searchParams;
|
||||
const keys = ["client_id", "response_type", "redirect_uri", "scope", "state", "code_challenge", "code_challenge_method", "access_type", "prompt"];
|
||||
if (url.origin !== "https://accounts.google.com" || url.pathname !== "/o/oauth2/v2/auth" || url.hash || url.username || url.password
|
||||
|| [...query.keys()].some(key => !keys.includes(key) || query.getAll(key).length !== 1)
|
||||
|| query.get("client_id") !== CLIENT_ID || query.get("response_type") !== "code"
|
||||
|| query.get("access_type") !== "offline" || query.get("prompt") !== "consent"
|
||||
|| query.get("code_challenge_method") !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(query.get("code_challenge") ?? "")
|
||||
|| !/^[A-Za-z0-9._~-]{16,1024}$/.test(query.get("state") ?? "")) throw new Error(LOGIN_ERROR);
|
||||
const redirect = new URL(query.get("redirect_uri") ?? "");
|
||||
if (redirect.protocol !== "http:" || redirect.hostname !== "127.0.0.1" || !redirect.port || redirect.pathname !== "/"
|
||||
|| redirect.username || redirect.password || redirect.search || redirect.hash) throw new Error(LOGIN_ERROR);
|
||||
const scopes = (query.get("scope") ?? "").split(" ");
|
||||
if (scopes.length > 4 || !SCOPES.every(scope => scopes.includes(scope)) || scopes.some(scope => ![...SCOPES, "openid"].includes(scope))) throw new Error(LOGIN_ERROR);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function credentialFile(home: string): Promise<string> {
|
||||
await checkAntigravityPrivateHome(home);
|
||||
const parent = join(home, "antigravity-acp"), stat = await lstat(parent);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) throw new Error("Invalid Antigravity credential");
|
||||
// Darwin's O_NOFOLLOW_ANY rejects symlinks in every component, including a
|
||||
// concurrently replaced parent. This candidate is macOS ARM64 only.
|
||||
const noFollow = process.platform === "darwin" ? 0x20000000 : constants.O_NOFOLLOW;
|
||||
const file = await open(join(parent, "acp_token.json"), constants.O_RDONLY | noFollow | constants.O_NONBLOCK);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600 || info.size > MAX_BYTES) throw new Error("Invalid Antigravity credential");
|
||||
const bytes = Buffer.alloc(MAX_BYTES + 1);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
const { bytesRead } = await file.read(bytes, length, bytes.length - length, length);
|
||||
if (!bytesRead) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > MAX_BYTES) throw new Error("Invalid Antigravity credential");
|
||||
return sanitizeAntigravityCredential(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)));
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
async function googleJson(url: string, options: RequestInit): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(url, { ...options, redirect: "error", signal: options.signal ?? AbortSignal.timeout(10_000) });
|
||||
if (!response.ok || !response.body) throw new Error(LOGIN_ERROR);
|
||||
const reader = response.body.getReader(), parts: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
length += next.value.length;
|
||||
if (length > MAX_BYTES) throw new Error(LOGIN_ERROR);
|
||||
parts.push(next.value);
|
||||
}
|
||||
return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts))));
|
||||
} finally { await reader.cancel().catch(() => undefined); }
|
||||
}
|
||||
|
||||
/** Refresh and check Google identity privately; this never calls a model API.
|
||||
* Callers must also require the setup process's successful authenticate result.
|
||||
*/
|
||||
export async function verifyAntigravityCredential(home: string, options: { signal?: AbortSignal } = {}): Promise<{ credential: string; accountEmail: string }> {
|
||||
options.signal?.throwIfAborted();
|
||||
const credential = await credentialFile(home), cached = verifiedCredentials.get(home);
|
||||
if (cached?.credential === credential) return { ...cached };
|
||||
const value = record(JSON.parse(credential));
|
||||
const token = await googleJson(TOKEN_URI, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: value.refresh_token as string, client_id: CLIENT_ID, client_secret: value.client_secret as string }) });
|
||||
if (typeof token.access_token !== "string" || token.access_token.length > 16_384 || /[\x00-\x20\x7f]/.test(token.access_token) || !token.access_token
|
||||
|| (token.token_type !== undefined && String(token.token_type).toLowerCase() !== "bearer")) throw new Error(LOGIN_ERROR);
|
||||
let next = credential;
|
||||
if (token.refresh_token !== undefined && token.refresh_token !== value.refresh_token) {
|
||||
next = sanitizeAntigravityCredential(JSON.stringify({ ...value, refresh_token: token.refresh_token }));
|
||||
// Preserve a consumed rotation before another request can fail. This is a
|
||||
// private, unverified receipt until userinfo succeeds; the connection owner
|
||||
// still performs identity and generation-checked encrypted writeback.
|
||||
await checkAntigravityPrivateHome(home);
|
||||
const target = join(home, "antigravity-acp", "acp_token.json"), temporary = `${target}.${randomUUID()}.tmp`;
|
||||
const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
await file.writeFile(next);
|
||||
await file.sync();
|
||||
} finally { await file.close(); }
|
||||
try {
|
||||
await rename(temporary, target);
|
||||
const directory = await open(join(home, "antigravity-acp"), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await directory.sync(); } finally { await directory.close(); }
|
||||
} finally { await rm(temporary, { force: true }); }
|
||||
}
|
||||
const identity = await googleJson("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${token.access_token}` },
|
||||
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000) });
|
||||
if (identity.email_verified !== true || typeof identity.email !== "string" || identity.email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(identity.email)) throw new Error("Google could not verify the signed-in account.");
|
||||
const result = { credential: next, accountEmail: identity.email };
|
||||
// Runtime refresh checks use short-lived prepared homes. Retain credentials
|
||||
// only for an active, bounded browser attempt, whose abort owns eviction.
|
||||
if (activeBrowserHomes.has(home)) verifiedCredentials.set(home, result);
|
||||
return { ...result };
|
||||
}
|
||||
|
||||
/** A setup-only native ACP client. Its complete outgoing method set is
|
||||
* initialize/authenticate; pending provider qualification remains enforced for
|
||||
* every session and prompt by the ordinary runtime installation path.
|
||||
*/
|
||||
export function startAntigravityBrowserLogin(home: string): AntigravityBrowserLoginState {
|
||||
const controller = new AbortController();
|
||||
activeBrowserHomes.add(home);
|
||||
let resolveStopped: () => void;
|
||||
const stopped = new Promise<void>(resolve => { resolveStopped = resolve; });
|
||||
const state: AntigravityBrowserLoginState = { abort() { controller.abort(); activeBrowserHomes.delete(home); verifiedCredentials.delete(home); return stopped; } };
|
||||
void (async () => {
|
||||
let lease: VerifiedAcpxCommandLease | undefined;
|
||||
let child: ChildProcessWithoutNullStreams | undefined, closed: Promise<void> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined, failPending: ((error: Error) => void) | undefined;
|
||||
try {
|
||||
await checkAntigravityPrivateHome(home);
|
||||
const installation = await verifyNativeAcpxInstallation(antigravityNativeDistributionSpec());
|
||||
lease = await installation.openCommand({ signal: controller.signal });
|
||||
const environment = await antigravityPrivateEnvironment(home);
|
||||
if (controller.signal.aborted) throw new Error(LOGIN_ERROR);
|
||||
child = lease.spawn([], { cwd: home, stdio: "pipe", detached: process.platform !== "win32", env: environment }) as ChildProcessWithoutNullStreams;
|
||||
closed = new Promise(resolve => child!.once("close", () => resolve()));
|
||||
const peer = child;
|
||||
let phase = 1, stdout = "", stderr = "", total = 0, failure: Error | undefined;
|
||||
const outputDecoder = new StringDecoder("utf8"), errorDecoder = new StringDecoder("utf8");
|
||||
const authenticated = new Promise<void>((resolve, reject) => {
|
||||
const fail = (error = new Error(LOGIN_ERROR)) => { failure ??= error; reject(failure); };
|
||||
failPending = fail;
|
||||
const send = (id: 1 | 2) => peer.stdin.write(`${JSON.stringify(id === 1
|
||||
? { jsonrpc: "2.0", id, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "paperclip-runner", version: "1" }, clientCapabilities: {} } }
|
||||
: { jsonrpc: "2.0", id, method: "authenticate", params: { methodId: "oauth-personal" } })}\n`);
|
||||
peer.stdout.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > 2 * 1024 * 1024) { fail(); return; }
|
||||
stdout += outputDecoder.write(chunk);
|
||||
while (stdout.includes("\n") && !failure) {
|
||||
const end = stdout.indexOf("\n"), line = stdout.slice(0, end); stdout = stdout.slice(end + 1);
|
||||
if (!line.trim()) continue;
|
||||
let message: Record<string, unknown>;
|
||||
try { message = record(JSON.parse(line)); } catch { fail(); return; }
|
||||
if (phase > 2 || message.jsonrpc !== "2.0" || message.id !== phase || "method" in message || "error" in message || !Object.hasOwn(message, "result")) { fail(); return; }
|
||||
if (phase === 1) {
|
||||
const initialized = record(message.result), info = record(initialized.agentInfo);
|
||||
if (initialized.protocolVersion !== 1 || info.name !== "antigravity-acp" || info.version !== "1.4.3"
|
||||
|| record(record(record(initialized.agentCapabilities)._meta).antigravity).paperclipAdmission !== 1
|
||||
|| !Array.isArray(initialized.authMethods) || !initialized.authMethods.some(method => record(method).id === "oauth-personal")) { fail(); return; }
|
||||
phase = 2; send(2);
|
||||
} else { phase = 3; resolve(); }
|
||||
}
|
||||
});
|
||||
peer.stderr.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > 2 * 1024 * 1024) { fail(); return; }
|
||||
stderr += errorDecoder.write(chunk);
|
||||
while (stderr.includes("\n") && !failure) {
|
||||
const end = stderr.indexOf("\n"), line = stderr.slice(0, end); stderr = stderr.slice(end + 1);
|
||||
if (!line.startsWith(AUTH_PREFIX)) continue;
|
||||
try {
|
||||
const url = validateAntigravityAuthorizationUrl(line.slice(AUTH_PREFIX.length));
|
||||
if (phase !== 2 || state.authorizationUrl !== undefined) throw new Error(LOGIN_ERROR);
|
||||
state.authorizationUrl = url;
|
||||
} catch { fail(); }
|
||||
}
|
||||
if (stderr.length > MAX_BYTES) fail();
|
||||
});
|
||||
peer.once("error", () => fail());
|
||||
peer.once("close", () => { if (phase !== 3) fail(); });
|
||||
peer.stdin.on("error", () => { if (phase !== 3) fail(); });
|
||||
timeout = setTimeout(() => fail(), 360_000);
|
||||
send(1);
|
||||
});
|
||||
const abort = () => failPending?.(new Error(LOGIN_ERROR));
|
||||
controller.signal.addEventListener("abort", abort, { once: true });
|
||||
try { if (controller.signal.aborted) abort(); await authenticated; }
|
||||
finally { controller.signal.removeEventListener("abort", abort); }
|
||||
if (timeout) clearTimeout(timeout);
|
||||
await retireAntigravityProcess(peer, closed); child = undefined;
|
||||
await lease.close(); lease = undefined;
|
||||
if (failure || controller.signal.aborted) throw new Error(LOGIN_ERROR);
|
||||
await verifyAntigravityCredential(home, { signal: controller.signal });
|
||||
if (controller.signal.aborted) throw new Error(LOGIN_ERROR);
|
||||
state.outcome = "success";
|
||||
} catch {
|
||||
state.outcome = "failure"; state.error = LOGIN_ERROR;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
let observedClose = child === undefined;
|
||||
let cleanupWasUncertain = false;
|
||||
void closed?.then(() => { observedClose = true; });
|
||||
// The active login owns these references until cleanup is proven. Only
|
||||
// this unresolved obligation schedules retries; physical close wakes it
|
||||
// early. Callers keep the private home while awaiting abort's completion.
|
||||
while (true) {
|
||||
try {
|
||||
if (child && closed) await retireAntigravityProcess(child, closed);
|
||||
await lease?.close();
|
||||
break;
|
||||
} catch {
|
||||
cleanupWasUncertain = true;
|
||||
state.outcome = "failure";
|
||||
state.error = "Google sign-in is still stopping. Wait before trying again.";
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const retry = new Promise<void>(resolve => { retryTimer = setTimeout(resolve, 1_000); });
|
||||
try { await (observedClose || !closed ? retry : Promise.race([closed, retry])); }
|
||||
finally { if (retryTimer) clearTimeout(retryTimer); }
|
||||
}
|
||||
}
|
||||
if (cleanupWasUncertain) state.error = LOGIN_ERROR;
|
||||
if (state.outcome !== "success") { activeBrowserHomes.delete(home); verifiedCredentials.delete(home); }
|
||||
resolveStopped!();
|
||||
}
|
||||
})();
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { ANTIGRAVITY_AUTH_ENV, stageManagedAntigravityCredential } from "./antigravity-credentials.js";
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js";
|
||||
import { configuredEnvironmentProjection } from "../../configured-environment.js";
|
||||
import * as codexCredentials from "./codex-credentials.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const credential = JSON.stringify({ client_id: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
client_secret: "fixture-client", refresh_token: "fixture-refresh", token_uri: "https://oauth2.googleapis.com/token",
|
||||
scopes: ["https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/aicode"] });
|
||||
async function home() { const value = await realpath(await mkdtemp(join(tmpdir(), "antigravity-credential-test-"))); roots.push(value); return value; }
|
||||
afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
|
||||
it.each([{}, { [ANTIGRAVITY_AUTH_ENV]: "{}" }, { [ANTIGRAVITY_AUTH_ENV]: credential, GEMINI_API_KEY: "fixture-key" }])("rejects absent, malformed or ambiguous credentials", async environment => {
|
||||
await expect(stageManagedAntigravityCredential({ agentHomeDirectory: await home(), environment })).rejects.toThrow(/credential/);
|
||||
});
|
||||
|
||||
it("keeps OAuth bound to its sidecar session and out of the actual native environment", () => {
|
||||
const source = { [ANTIGRAVITY_AUTH_ENV]: credential };
|
||||
const env = { ...source, [ACPX_CREDENTIAL_BINDING_ENV]: createAcpxCredentialBinding(source, "antigravity", "one") };
|
||||
expect(createAcpxSidecarHostEnvironment(env, "antigravity", "one")[ANTIGRAVITY_AUTH_ENV]).toBe(credential);
|
||||
expect(() => createAcpxSidecarHostEnvironment(env, "antigravity", "other")).toThrow(/binding/);
|
||||
expect(() => createAcpxSidecarHostEnvironment(source, "antigravity", "one")).toThrow(/binding/);
|
||||
expect(createSanitizedAcpxSpawnInput(source, "antigravity").env[ANTIGRAVITY_AUTH_ENV]).toBeUndefined();
|
||||
vi.stubEnv(ANTIGRAVITY_AUTH_ENV, credential);
|
||||
expect(createSanitizedAcpxSpawnInput(undefined, "antigravity").env[ANTIGRAVITY_AUTH_ENV]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fences refresh owners, preserves native rotation, scrubs live files and removes stale handoffs on reuse", async () => {
|
||||
const root = await home(), directory = join(root, "antigravity-acp");
|
||||
const input = { agentHomeDirectory: root, environment: { [ANTIGRAVITY_AUTH_ENV]: credential } };
|
||||
const first = await stageManagedAntigravityCredential(input);
|
||||
try {
|
||||
expect((await stat(join(directory, "acp_token.json"))).mode & 0o777).toBe(0o600);
|
||||
await expect(stageManagedAntigravityCredential(input)).rejects.toThrow(/active lease|owned/);
|
||||
const rotated = credential.replace("fixture-refresh", "rotated-refresh");
|
||||
await writeFile(join(directory, "acp_token.json"), rotated, { mode: 0o600 });
|
||||
await Promise.all([first.close(), first.close()]);
|
||||
expect(await readFile(join(directory, "auth-refresh.json"), "utf8")).toBe(rotated);
|
||||
for (const name of ["auth.json", "acp_token.json"]) await expect(stat(join(directory, name))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const second = await stageManagedAntigravityCredential(input);
|
||||
try { await expect(stat(join(directory, "auth-refresh.json"))).rejects.toMatchObject({ code: "ENOENT" }); }
|
||||
finally { await second.close(); }
|
||||
} finally { await first.close(); }
|
||||
});
|
||||
|
||||
it("retains ownership when a token is replaced by a symlink, and never reads its target", async () => {
|
||||
const root = await home(), token = join(root, "antigravity-acp", "acp_token.json");
|
||||
const input = { agentHomeDirectory: root, environment: { [ANTIGRAVITY_AUTH_ENV]: credential } };
|
||||
const lease = await stageManagedAntigravityCredential(input), target = join(await home(), "unrelated");
|
||||
await writeFile(target, "unrelated", { mode: 0o600 });
|
||||
await rm(token); await symlink(target, token);
|
||||
await expect(lease.close()).rejects.toThrow();
|
||||
await expect(stageManagedAntigravityCredential(input)).rejects.toThrow(/active lease|owned/);
|
||||
expect(await readFile(target, "utf8")).toBe("unrelated");
|
||||
await rm(token); await writeFile(token, credential, { mode: 0o600 }); await lease.close();
|
||||
});
|
||||
|
||||
it("preserves a native OAuth rotation when startup admission never succeeds", async () => {
|
||||
const root = await home(), directory = join(root, "antigravity-acp");
|
||||
const lease = await stageManagedAntigravityCredential({ agentHomeDirectory: root,
|
||||
environment: { [ANTIGRAVITY_AUTH_ENV]: credential }, retainRefresh: () => false });
|
||||
const rotated = credential.replace("fixture-refresh", "failed-startup-rotation");
|
||||
await writeFile(join(directory, "acp_token.json"), rotated, { mode: 0o600 });
|
||||
await lease.close();
|
||||
expect(await readFile(join(directory, "auth-refresh.json"), "utf8")).toBe(rotated);
|
||||
expect((await stat(join(directory, "auth-refresh.json"))).mode & 0o777).toBe(0o600);
|
||||
await expect(stat(join(directory, "acp_token.json"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("retries the original lifetime lease after its first close fails without losing the rotated handoff", async () => {
|
||||
const stage = codexCredentials.stageManagedCodexCredential;
|
||||
let attempts = 0;
|
||||
vi.spyOn(codexCredentials, "stageManagedCodexCredential").mockImplementation(async input => {
|
||||
const actual = await stage(input);
|
||||
return { ...actual, close: async () => {
|
||||
if (++attempts === 1) throw new Error("injected lifetime close failure");
|
||||
await actual.close();
|
||||
} };
|
||||
});
|
||||
const root = await home(), directory = join(root, "antigravity-acp");
|
||||
const input = { agentHomeDirectory: root, environment: { [ANTIGRAVITY_AUTH_ENV]: credential } };
|
||||
const lease = await stageManagedAntigravityCredential(input);
|
||||
const rotated = credential.replace("fixture-refresh", "retry-close-rotation");
|
||||
await writeFile(join(directory, "acp_token.json"), rotated, { mode: 0o600 });
|
||||
try {
|
||||
await expect(lease.close()).rejects.toThrow("injected lifetime close failure");
|
||||
await expect(lease.close()).resolves.toBeUndefined();
|
||||
expect(attempts).toBe(2);
|
||||
expect(await readFile(join(directory, "auth-refresh.json"), "utf8")).toBe(rotated);
|
||||
const next = await stageManagedAntigravityCredential(input); await next.close();
|
||||
} finally { await lease.close(); }
|
||||
});
|
||||
|
||||
it("leaves no unchanged OAuth handoff after failed admission or an API-key session", async () => {
|
||||
for (const environment of [{ [ANTIGRAVITY_AUTH_ENV]: credential }, { GEMINI_API_KEY: "fixture-key" }]) {
|
||||
const root = await home();
|
||||
const lease = await stageManagedAntigravityCredential({ agentHomeDirectory: root, environment, retainRefresh: () => false });
|
||||
await lease.close();
|
||||
for (const name of ["auth.json", "acp_token.json", "auth-refresh.json"]) await expect(stat(join(root, "antigravity-acp", name))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the selected key while removing conflicting explicitly projected Google auth and runtime controls", () => {
|
||||
const source = configuredEnvironmentProjection({
|
||||
GEMINI_API_KEY: "selected-key", GOOGLE_API_KEY: "other-key", GOOGLE_APPLICATION_CREDENTIALS: "/foreign/adc.json",
|
||||
GOOGLE_GENAI_USE_VERTEXAI: "true", GOOGLE_CLOUD_PROJECT: "foreign-project", GEMINI_HOME: "/foreign/home",
|
||||
AGY_LLM_GATEWAY_URL: "https://foreign.invalid", ANTIGRAVITY_HARNESS_PATH: "/foreign/helper",
|
||||
CLOUDSDK_CONFIG: "/foreign/cloud", AGY_ACP_FORCE_FILE_STORAGE: "0", TASK_INPUT: "retained",
|
||||
});
|
||||
const env = createSanitizedAcpxSpawnInput(source, "antigravity").env;
|
||||
expect(env.GEMINI_API_KEY).toBe("selected-key");
|
||||
expect(env.TASK_INPUT).toBe("retained");
|
||||
for (const name of Object.keys(source)) {
|
||||
if (/^(?:GOOGLE_|GEMINI_|AGY_|ANTIGRAVITY_|CLOUDSDK_)/.test(name) && name !== "GEMINI_API_KEY") expect(env[name]).toBeUndefined();
|
||||
}
|
||||
expect(JSON.parse(env.PAPERCLIP_CONFIGURED_ENV_KEYS!)).toEqual(["GEMINI_API_KEY", "TASK_INPUT"]);
|
||||
});
|
||||
|
||||
it("does not borrow an ambient Gemini key", () => {
|
||||
vi.stubEnv("GEMINI_API_KEY", "ambient-key");
|
||||
expect(createSanitizedAcpxSpawnInput(undefined, "antigravity").env.GEMINI_API_KEY).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, open, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { sanitizeAntigravityCredential } from "./antigravity-auth.js";
|
||||
import { stageManagedCodexCredential, type AcpxProviderLifetimeLease } from "./codex-credentials.js";
|
||||
|
||||
export const ANTIGRAVITY_AUTH_ENV = "PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET";
|
||||
|
||||
/** The controller's selected credential crosses stdio admission once, then lives
|
||||
* only in a private, fenced native home. It never enters provider environment.
|
||||
*/
|
||||
export async function stageManagedAntigravityCredential(input: {
|
||||
agentHomeDirectory: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
/** Unchanged copies may be omitted; consumed refresh rotations always survive. */
|
||||
retainRefresh?: () => boolean;
|
||||
}): Promise<AcpxProviderLifetimeLease> {
|
||||
const source = input.environment ?? {};
|
||||
const inline = source[ANTIGRAVITY_AUTH_ENV];
|
||||
const key = source.GEMINI_API_KEY?.trim();
|
||||
if (Boolean(inline) === Boolean(key)) throw new Error("Antigravity requires exactly one selected runtime credential");
|
||||
const canonical = inline ? sanitizeAntigravityCredential(inline) : undefined;
|
||||
const directory = join(input.agentHomeDirectory, "antigravity-acp");
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const metadata = await lstat(directory);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0
|
||||
|| metadata.uid !== process.getuid?.() || await realpath(directory) !== directory) {
|
||||
throw new Error("Antigravity credential home is unsafe");
|
||||
}
|
||||
// Reuse the established crash scrub, cleanup intent, and inherited quorum.
|
||||
// The extra native token filename is scrubbed while that quorum is held.
|
||||
const lease = await stageManagedCodexCredential({ agentHomeDirectory: directory,
|
||||
environment: canonical ? { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: canonical } : { OPENAI_API_KEY: key } });
|
||||
const token = join(directory, "acp_token.json");
|
||||
const refresh = join(directory, "auth-refresh.json");
|
||||
try {
|
||||
for (const filename of [token, refresh, `${refresh}.tmp`]) await rm(filename, { force: true });
|
||||
if (canonical) await writeFile(token, canonical, { flag: "wx", mode: 0o600 });
|
||||
} catch (error) {
|
||||
await rm(token, { force: true });
|
||||
await lease.close();
|
||||
throw error;
|
||||
}
|
||||
let closed = false;
|
||||
let tokenProcessed = false;
|
||||
let closing: Promise<void> | undefined;
|
||||
return {
|
||||
lifetimeFenceCandidates: lease.lifetimeFenceCandidates,
|
||||
lifetimeFenceFds: lease.lifetimeFenceFds,
|
||||
activateLifetimeOwner: pid => lease.activateLifetimeOwner(pid),
|
||||
close() {
|
||||
if (closed) return Promise.resolve();
|
||||
return closing ??= (async () => {
|
||||
if (canonical && !tokenProcessed) {
|
||||
const handle = await open(token, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.()
|
||||
|| (stat.mode & 0o777) !== 0o600 || stat.size > 65_536) throw new Error("Antigravity refreshed credential is unsafe");
|
||||
const buffer = Buffer.alloc(65_537);
|
||||
try {
|
||||
let size = 0;
|
||||
while (size < buffer.length) {
|
||||
const read = await handle.read(buffer, size, buffer.length - size, size);
|
||||
if (!read.bytesRead) break;
|
||||
size += read.bytesRead;
|
||||
}
|
||||
if (size > 65_536) throw new Error("Antigravity refreshed credential exceeds its bound");
|
||||
const refreshed = sanitizeAntigravityCredential(buffer.subarray(0, size).toString("utf8"));
|
||||
// Failed/cancelled startup can still consume a refresh token.
|
||||
// Admission controls retaining an unchanged copy, never rotation.
|
||||
if (refreshed !== canonical || (input.retainRefresh?.() ?? true)) {
|
||||
await rm(`${refresh}.tmp`, { force: true });
|
||||
const handoff = await open(`${refresh}.tmp`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
try { await handoff.writeFile(refreshed); await handoff.sync(); } finally { await handoff.close(); }
|
||||
await rename(`${refresh}.tmp`, refresh);
|
||||
const parent = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await parent.sync(); } finally { await parent.close(); }
|
||||
}
|
||||
} finally { buffer.fill(0); await rm(`${refresh}.tmp`, { force: true }); }
|
||||
} finally { await handle.close(); }
|
||||
// A failed underlying lease close must retry that same lease without
|
||||
// reopening the token removed only after this durable handoff.
|
||||
tokenProcessed = true;
|
||||
}
|
||||
await rm(token, { force: true });
|
||||
await rm(join(directory, "logs"), { recursive: true, force: true });
|
||||
await lease.close();
|
||||
closed = true;
|
||||
})().finally(() => { closing = undefined; });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironmentKeys } from "../../configured-environment.js";
|
||||
|
||||
/** The selected Gemini key owns authentication; task variables cannot reroute it. */
|
||||
export function sanitizeAntigravityEnvironment(environment: NodeJS.ProcessEnv): void {
|
||||
const removed = new Set<string>();
|
||||
const names = configuredEnvironmentKeys(environment);
|
||||
for (const name of Object.keys(environment)) {
|
||||
if (name === "GEMINI_API_KEY") continue;
|
||||
if (/^(?:GOOGLE_|GEMINI_|AGY_|ANTIGRAVITY_|GCLOUD_|CLOUDSDK_|PYTHON)/i.test(name)) {
|
||||
delete environment[name]; removed.add(name);
|
||||
}
|
||||
}
|
||||
if (environment[CONFIGURED_ENVIRONMENT_KEYS] !== undefined) {
|
||||
environment[CONFIGURED_ENVIRONMENT_KEYS] = JSON.stringify(names.filter(name => !removed.has(name)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { assertAcpxExecutionAdmission, type AcpxCandidateStartupAdmission } from "./candidate-admission.js";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { userInfo } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "./generated-profiles.js";
|
||||
import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
import type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
|
||||
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
|
||||
/** Owned cache resolution is independent of the isolated provider's HOME. */
|
||||
export function antigravityRuntimeCachePath(closure: string, platform: string, architecture: string, home = userInfo().homedir): string {
|
||||
if (!/^[a-f0-9]{64}$/.test(closure) || !Object.hasOwn(ANTIGRAVITY_DISTRIBUTION_PINS, `${platform}-${architecture}`)
|
||||
|| !isAbsolute(home) || home.includes("\0")) throw new Error("Invalid Antigravity runtime cache identity");
|
||||
let directory = home;
|
||||
for (const part of [".paperclip", "runtimes", "antigravity", `${platform}-${architecture}`, closure]) {
|
||||
directory = join(directory, part);
|
||||
try {
|
||||
const stat = lstatSync(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Antigravity runtime cache must contain real directories");
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
export function antigravityNativeDistributionSpec(platform: NodeJS.Platform = process.platform, architecture: string = process.arch): NativeAcpxDistributionInput {
|
||||
const key = `${platform}-${architecture}`;
|
||||
if (!Object.hasOwn(ANTIGRAVITY_DISTRIBUTION_PINS, key)) throw new Error(`Antigravity has no admitted complete runtime closure for ${key}`);
|
||||
const pin = ANTIGRAVITY_DISTRIBUTION_PINS[key as keyof typeof ANTIGRAVITY_DISTRIBUTION_PINS];
|
||||
const packaged = join(resolveRunnerProviderAssetsRoot(import.meta.url, "antigravity"), key);
|
||||
let distributionRoot = packaged;
|
||||
try {
|
||||
const stat = lstatSync(packaged);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Antigravity packaged runtime must be a real directory");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
distributionRoot = antigravityRuntimeCachePath(pin.closureSha256, platform, architecture);
|
||||
}
|
||||
return { distributionRoot, manifestPath: join(distributionRoot, ".paperclip-antigravity-closure.json"), expectedClosureSha256: pin.closureSha256,
|
||||
executable: pin.executable, fixedArguments: [...pin.args] };
|
||||
}
|
||||
|
||||
export async function verifyAntigravityInstallation(profile: QualifiedAcpxProfile, candidateAdmission?: AcpxCandidateStartupAdmission): Promise<VerifiedAcpxInstallation> {
|
||||
assertAcpxExecutionAdmission(profile, candidateAdmission);
|
||||
const trusted = QUALIFIED_ACPX_PROFILES.antigravity;
|
||||
const modelFields = new Set(["qualificationModel", "reportedModelId"]);
|
||||
if (Object.keys(profile).some(key => !modelFields.has(key) && !Object.hasOwn(trusted, key))
|
||||
|| Object.entries(trusted).some(([key, value]) => profile[key as keyof QualifiedAcpxProfile] !== value)
|
||||
|| !profile.qualificationModel?.trim() || profile.reportedModelId !== profile.qualificationModel) {
|
||||
throw new Error("Antigravity installation requires its exact pinned profile and an explicit model");
|
||||
}
|
||||
return { ...await verifyNativeAcpxInstallation(antigravityNativeDistributionSpec()), commandDigest: trusted.commandDigest };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodexAcpxDriver } from "./codex-acpx-driver.js";
|
||||
import { acpxCapabilities, acpxDriverDescriptor, validateAcpxDriverConfig } from "./driver-profile.js";
|
||||
import { verifyQualifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
import { verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
import { assertAcpxProfileReleased, resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
|
||||
const setup = vi.hoisted(() => ({ spawn: vi.fn(), close: vi.fn(async () => undefined), verify: vi.fn(), uncertainRetirement: false }));
|
||||
vi.mock("./antigravity-installation.js", () => ({ antigravityNativeDistributionSpec: () => ({ pinned: true }) }));
|
||||
vi.mock("./installation-integrity.js", async original => ({
|
||||
...await original<typeof import("./installation-integrity.js")>(), verifyNativeAcpxInstallation: setup.verify,
|
||||
}));
|
||||
vi.mock("./antigravity-process.js", async original => {
|
||||
const actual = await original<typeof import("./antigravity-process.js")>();
|
||||
return { ...actual, retireAntigravityProcess: (...args: Parameters<typeof actual.retireAntigravityProcess>) => setup.uncertainRetirement
|
||||
? Promise.reject(new Error("controlled retirement uncertainty")) : actual.retireAntigravityProcess(...args) };
|
||||
});
|
||||
import { AntigravityModelDiscoveryCleanupError, discoverAntigravityModels } from "./antigravity-models.js";
|
||||
|
||||
const roots: string[] = [], children: ChildProcess[] = [];
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) { try { process.kill(-child.pid!, "SIGKILL"); } catch {} }
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
||||
setup.uncertainRetirement = false;
|
||||
vi.unstubAllGlobals(); vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function fixture(mode = "success") {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-antigravity-models-test-"))); roots.push(home);
|
||||
const peer = join(home, "peer.cjs");
|
||||
await writeFile(peer, `
|
||||
const fs=require('node:fs'),path=require('node:path'),readline=require('node:readline');
|
||||
const home=process.argv[2],mode=process.argv[3],requests=[];
|
||||
if(mode==='stubborn') {
|
||||
process.on('SIGTERM',()=>{});
|
||||
const child=require('node:child_process').spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"],{stdio:'ignore'});
|
||||
fs.writeFileSync(path.join(home,'descendant.pid'),String(child.pid));
|
||||
}
|
||||
const reader=readline.createInterface({input:process.stdin});
|
||||
const reply=(id,result)=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',id,result})+'\\n');
|
||||
reader.on('line',line=>{
|
||||
const request=JSON.parse(line);requests.push(request);fs.writeFileSync(path.join(home,'requests.json'),JSON.stringify(requests));
|
||||
if(request.method==='initialize') {
|
||||
reply(request.id,{protocolVersion:1,agentInfo:{name:'antigravity-acp',version:'1.4.3'},agentCapabilities:{_meta:{antigravity:{paperclipAdmission:1,paperclipModels:mode==='old-patch'?0:1}}},authMethods:[{id:'oauth-personal'}]});
|
||||
} else if(request.method==='_paperclip/models') {
|
||||
if(mode==='blocked'||mode==='stubborn') return;
|
||||
if(mode==='error') {process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,error:{code:-32000,message:'private-token-do-not-expose'}})+'\\n');return;}
|
||||
if(mode==='bad-utf8') {process.stdout.write(Buffer.from([255,10]));return;}
|
||||
if(mode==='overflow') {process.stdout.write('x'.repeat(1048577));return;}
|
||||
const result={schema:request.params.schema,nonce:request.params.nonce,authMethod:'oauth-personal',models:[{id:'gemini-pro-agent',label:'Gemini Pro (High)'},{id:'gemini-3.1-pro-low',label:'Gemini Pro (Low)'}],defaultModelId:'gemini-pro-agent',ignoredPrivateData:'private-token-do-not-expose'};
|
||||
if(mode==='wrong-nonce') result.nonce='stale-nonce';
|
||||
if(mode==='wrong-auth') result.authMethod='gemini-api-key';
|
||||
if(mode==='wrong-schema') result.schema='another-schema';
|
||||
if(mode==='duplicate') result.models.push(result.models[0]);
|
||||
if(mode==='unknown-default') result.defaultModelId='unavailable-model';
|
||||
if(mode==='bad-label') result.models[0].label='bad\\u0000label';
|
||||
if(mode==='auto-model') result.models[0].id='auto';
|
||||
if(mode==='many-models') result.models=Array.from({length:201},(_,i)=>({id:'gemini-'+i,label:'Gemini '+i}));
|
||||
reply(mode==='wrong-id'?91:request.id,result);
|
||||
} else throw new Error('setup sent forbidden authentication/session/prompt method');
|
||||
});
|
||||
reader.on('close',()=>mode==='stubborn'||mode==='blocked'?setInterval(()=>{},1000):process.exit(0));
|
||||
`);
|
||||
setup.verify.mockResolvedValue({ openCommand: async () => ({ spawn: setup.spawn, close: setup.close }) });
|
||||
setup.spawn.mockImplementation((_args, options) => {
|
||||
const child = spawn(process.execPath, [peer, home, mode], { ...options, detached: true }); children.push(child); return child;
|
||||
});
|
||||
return home;
|
||||
}
|
||||
|
||||
describe("Antigravity session-free account model discovery", () => {
|
||||
it("returns only nonce-bound account models after process retirement with no ambient auth or direct account requests", async () => {
|
||||
const home = await fixture(), fetch = vi.fn(() => { throw new Error("direct account requests forbidden"); }); vi.stubGlobal("fetch", fetch);
|
||||
const result = await discoverAntigravityModels(home);
|
||||
expect(result).toEqual({ models: [{ id: "gemini-pro-agent", label: "Gemini Pro (High)" }, { id: "gemini-3.1-pro-low", label: "Gemini Pro (Low)" }], defaultModelId: "gemini-pro-agent" });
|
||||
const requests = JSON.parse(await readFile(join(home, "requests.json"), "utf8"));
|
||||
expect(requests.map((value: { method: string }) => value.method)).toEqual(["initialize", "_paperclip/models"]);
|
||||
expect(requests[1].params).toEqual({ schema: "paperclip.antigravity.models.v1", nonce: expect.any(String), authMethod: "oauth-personal" });
|
||||
expect(setup.spawn.mock.calls[0]![1]).toMatchObject({ detached: true, env: { GEMINI_HOME: home, AGY_ACP_FORCE_FILE_STORAGE: "1", BROWSER: "/usr/bin/true" } });
|
||||
expect(Object.keys(setup.spawn.mock.calls[0]![1].env).some(key => /^(?:GOOGLE_|GEMINI_API|ANTIGRAVITY_|PYTHON|NODE_OPTIONS|HTTP_PROXY)/.test(key))).toBe(false);
|
||||
expect(children[0]!.exitCode).toBe(0); expect(setup.close).toHaveBeenCalledOnce(); expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["old-patch", "wrong-nonce", "wrong-auth", "wrong-schema", "wrong-id", "duplicate", "unknown-default", "bad-label", "auto-model", "many-models", "bad-utf8", "overflow", "error"])("rejects %s and retires the process without exposing native text", async mode => {
|
||||
const home = await fixture(mode);
|
||||
await expect(discoverAntigravityModels(home)).rejects.toThrow("Could not load models for this Google account");
|
||||
expect(children[0]!.exitCode).toBe(0); expect(setup.close).toHaveBeenCalledOnce();
|
||||
if (mode === "old-patch") expect(JSON.parse(await readFile(join(home, "requests.json"), "utf8"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancellation retires a blocked catalog process and inherited descendant before rejecting", async () => {
|
||||
const home = await fixture("stubborn"), controller = new AbortController();
|
||||
const result = discoverAntigravityModels(home, { signal: controller.signal });
|
||||
void result.catch(() => undefined);
|
||||
await vi.waitFor(async () => expect(JSON.parse(await readFile(join(home, "requests.json"), "utf8"))).toHaveLength(2));
|
||||
const descendant = (await readFile(join(home, "descendant.pid"), "utf8")).trim();
|
||||
controller.abort();
|
||||
await expect(result).rejects.toThrow("Could not load models for this Google account");
|
||||
expect(children[0]!.signalCode).toBe("SIGKILL"); expect(setup.close).toHaveBeenCalledOnce();
|
||||
let state = "";
|
||||
try { state = execFileSync("/bin/ps", ["-o", "stat=", "-p", descendant], { encoding: "utf8" }).trim(); } catch {}
|
||||
expect(state === "" || state.startsWith("Z")).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the actual child, lease and caller home through cleanup uncertainty, then releases exactly once after physical exit", async () => {
|
||||
const home = await fixture("error"); setup.uncertainRetirement = true;
|
||||
const error = await discoverAntigravityModels(home).catch(error => error);
|
||||
expect(error).toBeInstanceOf(AntigravityModelDiscoveryCleanupError);
|
||||
expect(error.operationError.message).toContain("Could not load models");
|
||||
expect(error.cleanupError.message).toBe("controlled retirement uncertainty");
|
||||
expect(error.message).not.toContain("private-token");
|
||||
expect(children[0]!.exitCode).toBeNull(); expect(children[0]!.signalCode).toBeNull();
|
||||
expect(setup.close).not.toHaveBeenCalled();
|
||||
const releaseHome = vi.fn(() => rm(home, { recursive: true, force: true }));
|
||||
const cleaned = error.retired.then(releaseHome);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(releaseHome).not.toHaveBeenCalled(); await expect(access(home)).resolves.toBeUndefined();
|
||||
setup.uncertainRetirement = false;
|
||||
process.kill(-children[0]!.pid!, "SIGTERM");
|
||||
await cleaned;
|
||||
expect(setup.close).toHaveBeenCalledOnce(); expect(releaseHome).toHaveBeenCalledOnce();
|
||||
expect(children[0]!.signalCode).toBe("SIGTERM");
|
||||
await expect(access(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("enforces its real 30-second overall deadline and physically retires a blocked metadata call", async () => {
|
||||
const home = await fixture("blocked"), start = Date.now();
|
||||
await expect(discoverAntigravityModels(home)).rejects.toThrow("Could not load models for this Google account");
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(29_500);
|
||||
expect(Date.now() - start).toBeLessThan(35_000);
|
||||
expect(children[0]!.signalCode).toBe("SIGTERM"); expect(setup.close).toHaveBeenCalledOnce();
|
||||
expect(JSON.parse(await readFile(join(home, "requests.json"), "utf8")).map((value: { method: string }) => value.method)).toEqual(["initialize", "_paperclip/models"]);
|
||||
}, 40_000);
|
||||
});
|
||||
|
||||
describe("Antigravity pending release boundaries", () => {
|
||||
it("retains release admission for existing providers, including Copilot's separate capability status", () => {
|
||||
for (const agent of ["claude", "codex", "grok", "cursor", "pi", "copilot"] as const) {
|
||||
expect(() => assertAcpxProfileReleased(agent)).not.toThrow();
|
||||
}
|
||||
expect(acpxCapabilities("copilot")).toMatchObject({ typedEvents: true, dynamicTools: true });
|
||||
});
|
||||
|
||||
it("preserves the caller's exact model separately from release admission", () => {
|
||||
expect(resolveQualifiedAcpxProfile("antigravity", "caller-exact-model")).toMatchObject({
|
||||
agent: "antigravity", qualificationStatus: "pending", qualificationModel: "caller-exact-model", reportedModelId: "caller-exact-model",
|
||||
});
|
||||
expect(validateAcpxDriverConfig({ agent: "antigravity", model: "caller-exact-model" }))
|
||||
.toMatchObject({ ok: false, issues: [{ path: "agent", code: "qualification_pending" }] });
|
||||
expect(() => assertAcpxProfileReleased("antigravity")).toThrow(expect.objectContaining({ code: "ACPX_PROFILE_QUALIFICATION_PENDING" }));
|
||||
});
|
||||
|
||||
it("advertises no accepted runtime or context capabilities", () => {
|
||||
expect(acpxDriverDescriptor("antigravity")).toMatchObject({ displayName: "Google Antigravity via ACPX",
|
||||
runtimeContextCapabilities: { instructions: "unsupported", skills: "unsupported", mcp: "unsupported" } });
|
||||
for (const value of Object.values(acpxCapabilities("antigravity"))) {
|
||||
if (typeof value === "boolean") expect(value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects direct construction before opening a runtime host", () => {
|
||||
const openHost = vi.fn();
|
||||
expect(() => new CodexAcpxDriver({ agent: "antigravity", model: "caller-exact-model", runtimeDirectory: "/unused",
|
||||
providerPolicy: { readOnly: false } }, { openHost }))
|
||||
.toThrow(expect.objectContaining({ code: "ACPX_PROFILE_QUALIFICATION_PENDING" }));
|
||||
expect(openHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses raw installation lookup even after a caller strips the pending marker", async () => {
|
||||
const profile = { ...resolveQualifiedAcpxProfile("antigravity", "caller-exact-model"), qualificationStatus: undefined };
|
||||
const resolvePackage = vi.fn();
|
||||
await expect(verifyQualifiedAcpxInstallation(profile, resolvePackage))
|
||||
.rejects.toMatchObject({ code: "ACPX_PROFILE_QUALIFICATION_PENDING" });
|
||||
await expect(verifyAcpxProfileInstallation(profile))
|
||||
.rejects.toMatchObject({ code: "ACPX_PROFILE_QUALIFICATION_PENDING" });
|
||||
expect(resolvePackage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { antigravityNativeDistributionSpec } from "./antigravity-installation.js";
|
||||
import { antigravityPrivateEnvironment, checkAntigravityPrivateHome, retireAntigravityProcess } from "./antigravity-process.js";
|
||||
import { verifyNativeAcpxInstallation, type VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
const SCHEMA = "paperclip.antigravity.models.v1";
|
||||
const ERROR = "Could not load models for this Google account. Retry or sign in again.";
|
||||
const MAX_WIRE_BYTES = 1024 * 1024;
|
||||
const record = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
|
||||
export type AntigravityModelCatalog = { models: Array<{ id: string; label: string }>; defaultModelId: string };
|
||||
|
||||
/** The caller must keep its credential home until retired resolves. The owned
|
||||
* cleanup operation retains the child and command lease throughout uncertainty.
|
||||
*/
|
||||
export class AntigravityModelDiscoveryCleanupError extends Error {
|
||||
readonly code = "ANTIGRAVITY_MODELS_CLEANUP_PENDING";
|
||||
constructor(
|
||||
readonly retired: Promise<void>,
|
||||
readonly operationError: Error | undefined,
|
||||
readonly cleanupError: Error,
|
||||
) {
|
||||
super("Antigravity model discovery failed while its process was stopping.");
|
||||
this.name = "AntigravityModelDiscoveryCleanupError";
|
||||
}
|
||||
}
|
||||
|
||||
function retainCatalogCleanup(child: ChildProcessWithoutNullStreams | undefined, closed: Promise<void> | undefined, lease: VerifiedAcpxCommandLease): Promise<void> {
|
||||
let observedClose = child === undefined;
|
||||
void closed?.then(() => { observedClose = true; });
|
||||
return (async () => {
|
||||
// This retry belongs only to an unresolved, active process/lease cleanup.
|
||||
// Close wakes it immediately; successful cleanup leaves no timer or work.
|
||||
while (true) {
|
||||
try {
|
||||
if (child && closed) await retireAntigravityProcess(child, closed);
|
||||
await lease.close();
|
||||
return;
|
||||
} catch {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const retry = new Promise<void>(resolve => { timer = setTimeout(resolve, 1_000); });
|
||||
try { await (observedClose || !closed ? retry : Promise.race([closed, retry])); }
|
||||
finally { if (timer) clearTimeout(timer); }
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function catalog(value: unknown, nonce: string): AntigravityModelCatalog {
|
||||
const response = record(value);
|
||||
if (response.schema !== SCHEMA || response.nonce !== nonce || response.authMethod !== "oauth-personal"
|
||||
|| !Array.isArray(response.models) || response.models.length < 1 || response.models.length > 200) throw new Error(ERROR);
|
||||
const ids = new Set<string>(), models: AntigravityModelCatalog["models"] = [];
|
||||
for (const value of response.models) {
|
||||
const model = record(value);
|
||||
if (typeof model.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$/.test(model.id)
|
||||
|| ["auto", "default"].includes(model.id.toLowerCase()) || ids.has(model.id)
|
||||
|| typeof model.label !== "string" || !model.label.trim() || model.label.length > 200 || /[\x00-\x1f\x7f]/.test(model.label)) throw new Error(ERROR);
|
||||
ids.add(model.id); models.push({ id: model.id, label: model.label });
|
||||
}
|
||||
if (typeof response.defaultModelId !== "string" || !ids.has(response.defaultModelId)) throw new Error(ERROR);
|
||||
return { models, defaultModelId: response.defaultModelId };
|
||||
}
|
||||
|
||||
/** Read-only account catalog discovery through the pinned native implementation.
|
||||
* The caller owns the private projected credential home and any refreshed-secret
|
||||
* writeback. Execution retires before ordinary results/errors. A cleanup error
|
||||
* transfers the home-retention obligation through its retired completion.
|
||||
*/
|
||||
export async function discoverAntigravityModels(home: string, options: { signal?: AbortSignal } = {}): Promise<AntigravityModelCatalog> {
|
||||
const deadline = new AbortController(), timer = setTimeout(() => deadline.abort(), 30_000);
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal;
|
||||
let lease: VerifiedAcpxCommandLease | undefined, child: ChildProcessWithoutNullStreams | undefined, closed: Promise<void> | undefined;
|
||||
let result: AntigravityModelCatalog | undefined, failure: Error | undefined;
|
||||
let cleanupError: AntigravityModelDiscoveryCleanupError | undefined;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
await checkAntigravityPrivateHome(home);
|
||||
signal.throwIfAborted();
|
||||
const installation = await verifyNativeAcpxInstallation(antigravityNativeDistributionSpec());
|
||||
signal.throwIfAborted();
|
||||
lease = await installation.openCommand({ signal });
|
||||
const environment = await antigravityPrivateEnvironment(home);
|
||||
signal.throwIfAborted();
|
||||
child = lease.spawn([], { cwd: home, stdio: "pipe", detached: process.platform !== "win32", env: environment }) as ChildProcessWithoutNullStreams;
|
||||
const peer = child;
|
||||
closed = new Promise(resolve => peer.once("close", () => resolve()));
|
||||
const nonce = randomUUID();
|
||||
result = await new Promise<AntigravityModelCatalog>((resolve, reject) => {
|
||||
let phase = 1, stdout = "", outputBytes = 0, errorBytes = 0;
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const fail = () => { failure ??= new Error(ERROR); reject(failure); };
|
||||
const abort = () => fail();
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
const send = (id: 1 | 2) => peer.stdin.write(`${JSON.stringify(id === 1
|
||||
? { jsonrpc: "2.0", id, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "paperclip-runner", version: "1" }, clientCapabilities: {} } }
|
||||
: { jsonrpc: "2.0", id, method: "_paperclip/models", params: { schema: SCHEMA, nonce, authMethod: "oauth-personal" } })}\n`);
|
||||
peer.stdout.on("data", (chunk: Buffer) => {
|
||||
outputBytes += chunk.length;
|
||||
if (outputBytes > MAX_WIRE_BYTES) { fail(); return; }
|
||||
try { stdout += decoder.decode(chunk, { stream: true }); } catch { fail(); return; }
|
||||
while (stdout.includes("\n") && !failure) {
|
||||
const end = stdout.indexOf("\n"), line = stdout.slice(0, end); stdout = stdout.slice(end + 1);
|
||||
if (!line.trim()) continue;
|
||||
let message: Record<string, unknown>;
|
||||
try { message = record(JSON.parse(line)); } catch { fail(); return; }
|
||||
if (phase > 2 || message.jsonrpc !== "2.0" || message.id !== phase || "method" in message || "error" in message || !Object.hasOwn(message, "result")) { fail(); return; }
|
||||
if (phase === 1) {
|
||||
const initialized = record(message.result), info = record(initialized.agentInfo), capabilities = record(record(record(initialized.agentCapabilities)._meta).antigravity);
|
||||
if (initialized.protocolVersion !== 1 || info.name !== "antigravity-acp" || info.version !== "1.4.3"
|
||||
|| capabilities.paperclipAdmission !== 1 || capabilities.paperclipModels !== 1
|
||||
|| !Array.isArray(initialized.authMethods) || !initialized.authMethods.some(value => record(value).id === "oauth-personal")) { fail(); return; }
|
||||
phase = 2; send(2);
|
||||
} else {
|
||||
try { const models = catalog(message.result, nonce); phase = 3; resolve(models); } catch { fail(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
peer.stdout.once("end", () => {
|
||||
try { stdout += decoder.decode(); if (stdout.trim()) fail(); } catch { fail(); }
|
||||
});
|
||||
// Native error text may contain private account data. Count and discard.
|
||||
peer.stderr.on("data", (chunk: Buffer) => { errorBytes += chunk.length; if (errorBytes > MAX_WIRE_BYTES) fail(); });
|
||||
peer.once("error", fail);
|
||||
peer.once("close", () => { signal.removeEventListener("abort", abort); if (phase !== 3) fail(); });
|
||||
peer.stdin.on("error", () => { if (phase !== 3) fail(); });
|
||||
if (signal.aborted) fail(); else send(1);
|
||||
});
|
||||
} catch { failure ??= new Error(ERROR); }
|
||||
finally {
|
||||
try {
|
||||
if (child && closed) await retireAntigravityProcess(child, closed);
|
||||
await lease?.close();
|
||||
} catch (error) {
|
||||
if (lease) cleanupError = new AntigravityModelDiscoveryCleanupError(
|
||||
retainCatalogCleanup(child, closed, lease), failure,
|
||||
error instanceof Error ? error : new Error("Antigravity process retirement or command lease release could not be confirmed"),
|
||||
);
|
||||
else failure ??= new Error(ERROR);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (cleanupError) throw cleanupError;
|
||||
if (signal.aborted || failure || result === undefined) throw failure ?? new Error(ERROR);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { acquireAcpxProviderLifetimeLease } from "./codex-credentials.js";
|
||||
import { resolveAcpxRuntimeRoot } from "./recovery-identity.js";
|
||||
import { confirmAntigravityProbeNativeRetirement } from "./antigravity-probe-retirement.js";
|
||||
|
||||
it.skipIf(process.platform === "win32")("retains native cleanup until the real descendant releases its inherited kernel quorum", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "antigravity-probe-quorum-"));
|
||||
const home = join(await resolveAcpxRuntimeRoot(root, "probe-session"), "antigravity-home", "antigravity-acp");
|
||||
await mkdir(home, { recursive: true, mode: 0o700 });
|
||||
const lease = await acquireAcpxProviderLifetimeLease({ agentHomeDirectory: home });
|
||||
const child = spawn(process.execPath, ["-e", "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)"], {
|
||||
detached: true, env: { PATH: "/usr/bin:/bin" }, stdio: ["ignore", "pipe", "ignore", ...lease.lifetimeFenceFds],
|
||||
});
|
||||
const closed = new Promise<void>(resolve => child.once("exit", () => resolve()));
|
||||
let retirement: Promise<void> | undefined;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => { child.stdout!.once("data", () => resolve()); child.once("error", reject); });
|
||||
await lease.close();
|
||||
let retired = false;
|
||||
retirement = confirmAntigravityProbeNativeRetirement(root, "probe-session").then(() => { retired = true; });
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
expect(retired).toBe(false);
|
||||
expect(child.exitCode).toBeNull();
|
||||
expect((await stat(home)).isDirectory()).toBe(true);
|
||||
child.kill("SIGKILL");
|
||||
await closed;
|
||||
await retirement;
|
||||
expect(retired).toBe(true);
|
||||
const successor = await acquireAcpxProviderLifetimeLease({ agentHomeDirectory: home });
|
||||
await successor.close();
|
||||
} finally {
|
||||
child.kill("SIGKILL");
|
||||
await closed;
|
||||
await lease.close();
|
||||
await retirement;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { acquireAcpxProviderLifetimeLease, type AcpxProviderLifetimeLease } from "./codex-credentials.js";
|
||||
import { resolveAcpxRuntimeRoot } from "./recovery-identity.js";
|
||||
|
||||
/** After all original startup work and the sidecar have retired, acquiring the
|
||||
* same kernel quorum proves the guarded native lifetime no longer owns this
|
||||
* credential home. No token bytes are read, changed, or used for authentication.
|
||||
*/
|
||||
export async function confirmAntigravityProbeNativeRetirement(runtimeDirectory: string, normalizedSessionId: string): Promise<void> {
|
||||
let directory: string;
|
||||
try {
|
||||
directory = join(await resolveAcpxRuntimeRoot(runtimeDirectory, normalizedSessionId), "antigravity-home", "antigravity-acp");
|
||||
await lstat(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
let lease: AcpxProviderLifetimeLease | undefined;
|
||||
// This owner retries only a concrete outstanding native lifetime or release.
|
||||
// It leaves no recurring work once the original native fence is free.
|
||||
while (true) {
|
||||
try {
|
||||
lease ??= await acquireAcpxProviderLifetimeLease({ agentHomeDirectory: directory });
|
||||
await lease.close();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!lease && (!(error instanceof Error) || error.message !== "Managed Codex credential home already has an active lease")) throw error;
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
export async function checkAntigravityPrivateHome(home: string): Promise<void> {
|
||||
if (!isAbsolute(home) || home.includes("\0") || resolve(home) !== home || await realpath(home) !== home) throw new Error("Invalid Antigravity private home");
|
||||
const stat = await lstat(home);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) throw new Error("Invalid Antigravity private home");
|
||||
}
|
||||
|
||||
/** Owned setup environment; never inherits credentials, endpoints, hooks,
|
||||
* Python startup options, proxy routing, or user configuration from the host. */
|
||||
export async function antigravityPrivateEnvironment(home: string): Promise<NodeJS.ProcessEnv> {
|
||||
await checkAntigravityPrivateHome(home);
|
||||
for (const name of ["host-home", "config", "data", "cache", "tmp"]) {
|
||||
const directory = join(home, name);
|
||||
try { await mkdir(directory, { mode: 0o700 }); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
||||
const stat = await lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) throw new Error("Invalid Antigravity private home");
|
||||
}
|
||||
return {
|
||||
PATH: "/usr/bin:/bin", HOME: join(home, "host-home"), GEMINI_HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, "config"), XDG_DATA_HOME: join(home, "data"), XDG_CACHE_HOME: join(home, "cache"), TMPDIR: join(home, "tmp"),
|
||||
// Success prevents Python webbrowser from trying its next OS controller.
|
||||
BROWSER: "/usr/bin/true", AGY_ACP_FORCE_FILE_STORAGE: "1", NO_COLOR: "1", LANG: "en_US.UTF-8",
|
||||
};
|
||||
}
|
||||
|
||||
export async function retireAntigravityProcess(child: ChildProcessWithoutNullStreams, closed: Promise<void>): Promise<void> {
|
||||
let retired = false;
|
||||
void closed.then(() => { retired = true; });
|
||||
const signal = (value: NodeJS.Signals) => {
|
||||
try { if (child.pid !== undefined && process.platform !== "win32") process.kill(-child.pid, value); else if (!retired) child.kill(value); }
|
||||
catch (error) { if (!["ESRCH", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; }
|
||||
};
|
||||
const alive = async () => {
|
||||
let groupAlive = !retired;
|
||||
if (process.platform !== "win32" && child.pid !== undefined) {
|
||||
try { process.kill(-child.pid, 0); groupAlive = true; }
|
||||
catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ESRCH") groupAlive = false;
|
||||
else if (code === "EPERM" && process.platform === "darwin") {
|
||||
// Darwin reports EPERM for some groups containing only orphaned
|
||||
// zombies. Read numeric state only; never process arguments/env.
|
||||
const output = await new Promise<string>((resolve, reject) => execFile("/bin/ps", ["-axo", "pid=,pgid=,stat="], {
|
||||
encoding: "utf8", timeout: 1_000, maxBuffer: 1024 * 1024, env: { PATH: "/usr/bin:/bin", LANG: "C" },
|
||||
}, (error, stdout) => error ? reject(error) : resolve(stdout)));
|
||||
const rows = output.trim().split("\n").filter(Boolean).map(line => line.trim().split(/\s+/));
|
||||
if (rows.some(row => row.length !== 3 || !/^\d+$/.test(row[0]!) || !/^\d+$/.test(row[1]!) || !/^[A-Za-z+<>NLsWX-]+$/.test(row[2]!))) throw new Error("Antigravity process retirement could not be confirmed");
|
||||
groupAlive = rows.some(row => Number(row[1]) === child.pid && !row[2]!.startsWith("Z"));
|
||||
} else throw error;
|
||||
}
|
||||
}
|
||||
return groupAlive;
|
||||
};
|
||||
child.stdin.end();
|
||||
for (const [value, wait] of [[null, 500], ["SIGTERM", 500], ["SIGKILL", 1_000]] as const) {
|
||||
if (value) signal(value);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = Date.now() + wait;
|
||||
await Promise.race([closed, new Promise<void>(resolve => { timer = setTimeout(resolve, wait); })]);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!await alive()) return;
|
||||
// A descendant can keep running after the launcher exits. Observe the
|
||||
// remainder of this stage's deadline before escalating the owned group.
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
if (!await alive()) return;
|
||||
}
|
||||
throw new Error("Antigravity process retirement could not be confirmed");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Antigravity 1.4.3 encodes fixed-choice questions as permission callbacks. */
|
||||
export function createAntigravityQuestionBridge(ownsSession: (sessionId: unknown) => boolean) {
|
||||
const pending = new Map<string | number, { sessionId: string; optionIds: Set<string> }>();
|
||||
return {
|
||||
clear() { pending.clear(); },
|
||||
guard(direction: "inbound" | "outbound", value: unknown): void {
|
||||
const message = record(value);
|
||||
const id = message.id;
|
||||
if (direction === "inbound" && message.method === "session/request_permission") {
|
||||
const params = record(message.params);
|
||||
const tool = record(params.toolCall);
|
||||
if (typeof tool.toolCallId !== "string" || !tool.toolCallId.startsWith("interaction_")) return;
|
||||
if ((typeof id !== "string" && typeof id !== "number") || pending.has(id) || pending.size >= 16
|
||||
|| !ownsSession(params.sessionId) || typeof params.sessionId !== "string"
|
||||
|| typeof tool.title !== "string" || !tool.title.trim()
|
||||
|| !Array.isArray(params.options) || params.options.length < 1 || params.options.length > 128) {
|
||||
throw new Error("Antigravity question has invalid identity or incomplete choices");
|
||||
}
|
||||
const optionIds = new Set<string>();
|
||||
const choices = params.options.map(value => {
|
||||
const option = record(value);
|
||||
if (typeof option.optionId !== "string" || !option.optionId || option.optionId.length > 1024
|
||||
|| typeof option.name !== "string" || !option.name.trim() || optionIds.has(option.optionId)) {
|
||||
throw new Error("Antigravity question has invalid or duplicate choice identities");
|
||||
}
|
||||
optionIds.add(option.optionId);
|
||||
// kind is deliberately ignored: deny/block can be an answer choice.
|
||||
return { const: option.optionId, title: option.name };
|
||||
});
|
||||
const form = {
|
||||
sessionId: params.sessionId, mode: "form", message: tool.title,
|
||||
requestedSchema: {
|
||||
type: "object", title: tool.title, required: ["answer"], additionalProperties: false,
|
||||
properties: { answer: { type: "string", title: "Answer", oneOf: choices } },
|
||||
},
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(form)) > 196 * 1024) throw new Error("Antigravity question exceeds its complete-content limit");
|
||||
pending.set(id, { sessionId: params.sessionId, optionIds });
|
||||
// The installed ACPX form path owns durable input and delivery. Convert
|
||||
// before its permission policy can interpret a choice as an approval.
|
||||
message.method = "elicitation/create";
|
||||
message.params = form;
|
||||
} else if (direction === "outbound" && message.method === undefined
|
||||
&& (typeof id === "string" || typeof id === "number") && pending.has(id)) {
|
||||
const question = pending.get(id)!;
|
||||
if (!ownsSession(question.sessionId)) throw new Error("Antigravity answer belongs to a stale session");
|
||||
if (message.error !== undefined) { pending.delete(id); return; }
|
||||
const result = record(message.result);
|
||||
if (result.action === "accept") {
|
||||
const content = record(result.content);
|
||||
if (Object.keys(content).length !== 1 || typeof content.answer !== "string" || !question.optionIds.has(content.answer)) {
|
||||
throw new Error("Antigravity answer must select exactly one offered choice");
|
||||
}
|
||||
message.result = { outcome: { outcome: "selected", optionId: content.answer } };
|
||||
} else if (result.action === "decline" || result.action === "cancel") {
|
||||
message.result = { outcome: { outcome: "cancelled" } };
|
||||
} else throw new Error("Antigravity question response is invalid");
|
||||
pending.delete(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "./generated-profiles.js";
|
||||
import { assertAcpxProfileReleased, resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { assertAcpxExecutionAdmission, bindAcpxCandidateAdmission, parseAcpxCandidateAdmission, parseAcpxCandidateStartupAdmission, type AcpxCandidateAdmission } from "./candidate-admission.js";
|
||||
import { verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
import { AcpxRuntimeHost } from "./runtime-host.js";
|
||||
import * as integrity from "./installation-integrity.js";
|
||||
|
||||
vi.mock("./installation-integrity.js", async importOriginal => ({
|
||||
...await importOriginal<typeof import("./installation-integrity.js")>(),
|
||||
verifyNativeAcpxInstallation: vi.fn(),
|
||||
}));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
const expected = { companyId: "company-candidate", agent: "antigravity", model: "gemini-3.8-flash-low" };
|
||||
const candidate = (): AcpxCandidateAdmission => ({ schema: "paperclip.acpx-candidate-admission.v1", ...expected, agent: "antigravity",
|
||||
profileDigest: resolveQualifiedAcpxProfile("antigravity", expected.model).commandDigest,
|
||||
nativeClosureDigest: `sha256:${ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256}`,
|
||||
platform: "darwin", architecture: "arm64", expiresAt: now + 60_000 });
|
||||
|
||||
describe("operator candidate execution admission", () => {
|
||||
it("binds an exact company/model/profile/native closure without changing release admission", () => {
|
||||
const source = candidate();
|
||||
const value = parseAcpxCandidateAdmission(source, expected, now);
|
||||
expect(value).toEqual(source);
|
||||
expect(value).not.toBe(source);
|
||||
expect(() => assertAcpxProfileReleased("antigravity")).toThrow("qualification is pending");
|
||||
expect(() => assertAcpxExecutionAdmission(resolveQualifiedAcpxProfile("antigravity", expected.model))).toThrow("qualification is pending");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ companyId: "another-company" }, { agent: "codex" }, { model: "gemini-3.8-flash-high" },
|
||||
{ profileDigest: `sha256:${"a".repeat(64)}` }, { nativeClosureDigest: `sha256:${"b".repeat(64)}` },
|
||||
{ platform: "linux" }, { architecture: "x64" }, { expiresAt: now }, { expiresAt: now + 86_400_001 },
|
||||
{ expiresAt: "1800000060000" }, { unknown: true }, { runId: "injected-run" }, { model: "auto" },
|
||||
])("rejects mismatched or broadened authority %j", change => {
|
||||
expect(() => parseAcpxCandidateAdmission({ ...candidate(), ...change }, expected, now)).toThrow(expect.objectContaining({ code: "ACPX_CANDIDATE_ADMISSION_INVALID" }));
|
||||
});
|
||||
|
||||
it("binds actual startup run/session and refuses replay against another identity", () => {
|
||||
const current = { ...candidate(), expiresAt: Date.now() + 60_000 };
|
||||
const startup = bindAcpxCandidateAdmission(current, { runId: "actual-run", normalizedSessionId: "actual-session" });
|
||||
expect(parseAcpxCandidateStartupAdmission(startup, { ...expected, runId: "actual-run", normalizedSessionId: "actual-session" })).toEqual(startup);
|
||||
for (const identity of [{ runId: "other-run" }, { normalizedSessionId: "other-session" }, { model: "other-model" }]) {
|
||||
expect(() => parseAcpxCandidateStartupAdmission(startup, { ...expected, ...identity })).toThrow();
|
||||
}
|
||||
expect(() => parseAcpxCandidateStartupAdmission({ ...startup, trusted: true }, expected)).toThrow();
|
||||
expect(() => parseAcpxCandidateStartupAdmission(startup, expected, startup.expiresAt)).toThrow();
|
||||
});
|
||||
|
||||
it("permits only the pinned native candidate at the execution exception", () => {
|
||||
const startup = bindAcpxCandidateAdmission({ ...candidate(), expiresAt: Date.now() + 60_000 }, { runId: "actual-run", normalizedSessionId: "actual-session" });
|
||||
const profile = resolveQualifiedAcpxProfile("antigravity", expected.model);
|
||||
if (process.platform === "darwin" && process.arch === "arm64") expect(() => assertAcpxExecutionAdmission(profile, startup)).not.toThrow();
|
||||
else expect(() => assertAcpxExecutionAdmission(profile, startup)).toThrow();
|
||||
expect(() => assertAcpxExecutionAdmission(resolveQualifiedAcpxProfile("codex", expected.model), startup)).toThrow();
|
||||
expect(() => assertAcpxExecutionAdmission({ ...profile, commandDigest: `sha256:${"c".repeat(64)}` }, startup)).toThrow();
|
||||
for (const agent of ["codex", "claude", "grok", "cursor", "pi", "copilot"] as const) {
|
||||
expect(() => assertAcpxExecutionAdmission(resolveQualifiedAcpxProfile(agent, "caller-selected-model"))).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate installation remains cryptographically verified", () => {
|
||||
const profile = resolveQualifiedAcpxProfile("antigravity", "gemini-3.8-flash-low");
|
||||
const admission = () => bindAcpxCandidateAdmission({ schema: "paperclip.acpx-candidate-admission.v1", companyId: "company-candidate", agent: "antigravity", model: profile.qualificationModel,
|
||||
profileDigest: profile.commandDigest, nativeClosureDigest: `sha256:${ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256}`,
|
||||
platform: "darwin", architecture: "arm64", expiresAt: Date.now() + 60_000 }, { runId: "run-candidate", normalizedSessionId: "session-candidate" });
|
||||
|
||||
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("delegates the admitted exception to the exact complete native closure verifier", async () => {
|
||||
const native = { commandDigest: "native-closure-verifier-result" } as integrity.VerifiedAcpxInstallation;
|
||||
vi.mocked(integrity.verifyNativeAcpxInstallation).mockResolvedValue(native);
|
||||
const result = await verifyAcpxProfileInstallation(profile, admission());
|
||||
expect(result.commandDigest).toBe(profile.commandDigest);
|
||||
expect(integrity.verifyNativeAcpxInstallation).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
expectedClosureSha256: ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256,
|
||||
executable: "agy_acp_server.par", fixedArguments: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it("denies absent, altered, or other-provider authority before any native files are opened", async () => {
|
||||
vi.mocked(integrity.verifyNativeAcpxInstallation).mockClear();
|
||||
await expect(verifyAcpxProfileInstallation(profile)).rejects.toMatchObject({ code: "ACPX_PROFILE_QUALIFICATION_PENDING" });
|
||||
await expect(verifyAcpxProfileInstallation(profile, { ...admission(), model: "gemini-3.8-flash-high" })).rejects.toMatchObject({ code: "ACPX_CANDIDATE_ADMISSION_INVALID" });
|
||||
await expect(verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("codex", profile.qualificationModel), admission())).rejects.toMatchObject({ code: "ACPX_CANDIDATE_ADMISSION_INVALID" });
|
||||
expect(integrity.verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a candidate session mismatch before installation, credentials, or runtime acquisition", async () => {
|
||||
const verifyInstallation = vi.fn(), openRuntime = vi.fn();
|
||||
await expect(AcpxRuntimeHost.open({ candidateAdmission: admission(), runtimeDirectory: "/unused", normalizedSessionId: "another-session", workingDirectory: "/unused", agent: "antigravity", model: profile.qualificationModel,
|
||||
permissionMode: "deny-all", providerPolicy: { readOnly: true } }, { verifyInstallation, openRuntime, retainAdmissionCleanup: () => {}, reportRetainedCleanupFailure: () => {} })).rejects.toMatchObject({ code: "ACPX_CANDIDATE_ADMISSION_INVALID" });
|
||||
expect(verifyInstallation).not.toHaveBeenCalled();
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "./generated-profiles.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, assertAcpxProfileReleased, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
|
||||
/** Operator authority travels only through trusted Runner startup, never agent configuration. */
|
||||
export interface AcpxCandidateAdmission {
|
||||
schema: "paperclip.acpx-candidate-admission.v1";
|
||||
companyId: string;
|
||||
agent: "antigravity";
|
||||
model: string;
|
||||
profileDigest: string;
|
||||
nativeClosureDigest: string;
|
||||
platform: "darwin";
|
||||
architecture: "arm64";
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Runner binds operator authority to its actual launch identity. */
|
||||
export interface AcpxCandidateStartupAdmission extends AcpxCandidateAdmission {
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
}
|
||||
|
||||
const BASE_FIELDS = ["schema", "companyId", "agent", "model", "profileDigest", "nativeClosureDigest", "platform", "architecture", "expiresAt"];
|
||||
const stableId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(value);
|
||||
const invalid = (): never => { throw Object.assign(new Error("ACPX candidate admission is missing, expired, or does not match the exact authorized execution"), { code: "ACPX_CANDIDATE_ADMISSION_INVALID", retryable: false }); };
|
||||
|
||||
export function parseAcpxCandidateAdmission(
|
||||
value: unknown,
|
||||
expected: { companyId: string; agent: string; model: string },
|
||||
now = Date.now(),
|
||||
): AcpxCandidateAdmission {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return invalid();
|
||||
const entry = value as Record<string, unknown>;
|
||||
if (!Number.isSafeInteger(now) || now < 0 || Object.keys(entry).length !== BASE_FIELDS.length || Object.keys(entry).some(key => !BASE_FIELDS.includes(key))
|
||||
|| entry.schema !== "paperclip.acpx-candidate-admission.v1"
|
||||
|| !stableId(entry.companyId) || entry.companyId !== expected.companyId
|
||||
|| entry.agent !== "antigravity" || entry.agent !== expected.agent
|
||||
|| typeof entry.model !== "string" || entry.model !== expected.model || !entry.model.trim()
|
||||
|| entry.model !== entry.model.trim() || Buffer.byteLength(entry.model) > 512 || /[\u0000-\u001f\u007f]/.test(entry.model)
|
||||
|| ["auto", "default"].includes(entry.model.toLowerCase())
|
||||
|| entry.profileDigest !== QUALIFIED_ACPX_PROFILES.antigravity.commandDigest
|
||||
|| entry.nativeClosureDigest !== `sha256:${ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256}`
|
||||
|| entry.platform !== "darwin" || entry.architecture !== "arm64"
|
||||
|| !Number.isSafeInteger(entry.expiresAt) || Number(entry.expiresAt) <= now
|
||||
|| Number(entry.expiresAt) > now + 24 * 60 * 60 * 1000) return invalid();
|
||||
return { ...entry } as unknown as AcpxCandidateAdmission;
|
||||
}
|
||||
|
||||
export function bindAcpxCandidateAdmission(
|
||||
admission: AcpxCandidateAdmission,
|
||||
identity: { runId: string; normalizedSessionId: string },
|
||||
): AcpxCandidateStartupAdmission {
|
||||
if (!stableId(identity.runId) || identity.runId.length > 160 || !stableId(identity.normalizedSessionId) || identity.normalizedSessionId.length > 160) return invalid();
|
||||
return { ...parseAcpxCandidateAdmission(admission, admission), runId: identity.runId, normalizedSessionId: identity.normalizedSessionId };
|
||||
}
|
||||
|
||||
/** The company field here is only a receipt of upstream authorization. This
|
||||
* parser is called on the trusted runner/sidecar startup channel; agent or
|
||||
* provider configuration must never be promoted into that channel. */
|
||||
export function parseAcpxCandidateStartupAdmission(
|
||||
value: unknown,
|
||||
expected: { agent: string; model: string; runId?: string; normalizedSessionId?: string },
|
||||
now = Date.now(),
|
||||
): AcpxCandidateStartupAdmission {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return invalid();
|
||||
const { runId, normalizedSessionId, ...base } = value as Record<string, unknown>;
|
||||
if (!stableId(runId) || runId.length > 160 || !stableId(normalizedSessionId) || normalizedSessionId.length > 160
|
||||
|| expected.runId !== undefined && expected.runId !== runId
|
||||
|| expected.normalizedSessionId !== undefined && expected.normalizedSessionId !== normalizedSessionId) return invalid();
|
||||
const admission = parseAcpxCandidateAdmission(base, { ...expected, companyId: String(base.companyId) }, now);
|
||||
return { ...admission, runId, normalizedSessionId };
|
||||
}
|
||||
|
||||
/** This exception is deliberately absent from direct driver/config admission. */
|
||||
export function assertAcpxExecutionAdmission(profile: QualifiedAcpxProfile, candidate?: AcpxCandidateStartupAdmission): void {
|
||||
if (candidate === undefined) return assertAcpxProfileReleased(profile.agent);
|
||||
if (process.platform !== "darwin" || process.arch !== "arm64") return invalid();
|
||||
parseAcpxCandidateStartupAdmission(candidate, { agent: profile.agent, model: profile.qualificationModel });
|
||||
if (profile.commandDigest !== candidate.profileDigest) return invalid();
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
export interface AcpxCapabilityProfile {
|
||||
readonly displayName: string;
|
||||
readonly qualification: "qualified" | "pending";
|
||||
readonly models: "explicit-provider-verified";
|
||||
readonly permissions: "runner-policy" | "interactive";
|
||||
readonly models: "explicit-provider-verified" | "unverified";
|
||||
readonly permissions: "runner-policy" | "interactive" | "unverified";
|
||||
readonly questions: "form" | "cursor-extension" | "semantic-only" | "not-exposed";
|
||||
readonly plans: "native" | "cursor-decision" | "semantic-only";
|
||||
readonly tools: "authenticated-mcp" | "owned-extension";
|
||||
readonly tools: "authenticated-mcp" | "owned-extension" | "unverified";
|
||||
readonly recovery: "session-load" | "unverified";
|
||||
readonly toolRefreshOnResume?: boolean;
|
||||
readonly usage: "reported" | "unverified";
|
||||
@@ -20,6 +20,12 @@ export interface AcpxCapabilityProfile {
|
||||
|
||||
/** These are runner integration claims, not a proxy for everything a harness can do. */
|
||||
export const ACPX_CAPABILITY_PROFILES: Readonly<Record<QualifiedAcpxAgent, AcpxCapabilityProfile>> = {
|
||||
antigravity: {
|
||||
displayName: "Google Antigravity", qualification: "pending", models: "unverified",
|
||||
permissions: "unverified", questions: "not-exposed", plans: "semantic-only", tools: "unverified",
|
||||
recovery: "unverified", usage: "unverified", steering: "unsupported", followUp: "controller-queue",
|
||||
artifacts: "policy_disabled", extensionRequests: [], extensionNotifications: [],
|
||||
},
|
||||
claude: {
|
||||
toolRefreshOnResume: true,
|
||||
displayName: "Claude", qualification: "qualified", models: "explicit-provider-verified",
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
validateAcpxDriverConfig,
|
||||
} from "./driver-profile.js";
|
||||
import {
|
||||
assertAcpxProfileReleased,
|
||||
resolveQualifiedAcpxProfile,
|
||||
type QualifiedAcpxAgent,
|
||||
} from "./qualified-profiles.js";
|
||||
@@ -288,6 +289,7 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
options: CodexAcpxDriverOptions,
|
||||
dependencies: CodexAcpxDriverDependencies = {},
|
||||
) {
|
||||
assertAcpxProfileReleased(options.agent ?? "codex");
|
||||
if (["pi", "cursor", "copilot"].includes(options.agent ?? "codex") && typeof options.providerPolicy?.readOnly !== "boolean") {
|
||||
throw new Error("ACP candidate requires an explicit provider read-only policy");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
import type { AcpxModelStatus } from "./model-verification.js";
|
||||
import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
|
||||
import { admitCursorInstructions, createCursorInstructionAdmission } from "./cursor-instructions.js";
|
||||
import { createCursorInstructionAdmission } from "./cursor-instructions.js";
|
||||
import { createAntigravityAdmission } from "./antigravity-admission.js";
|
||||
import { admitAcpxSessionContext } from "./session-context-admission.js";
|
||||
import { createAcpxModeBinding } from "./provider-mode.js";
|
||||
import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js";
|
||||
|
||||
@@ -327,6 +329,10 @@ export async function openQualifiedAcpxRuntime(
|
||||
const cursorInstructions = options.profile.agent === "cursor"
|
||||
? createCursorInstructionAdmission(options.systemInstructions)
|
||||
: null;
|
||||
const antigravityAdmission = options.profile.agent === "antigravity"
|
||||
? createAntigravityAdmission(options.systemInstructions, options.profile.reportedModelId)
|
||||
: null;
|
||||
const contextAdmission = cursorInstructions ?? antigravityAdmission;
|
||||
const runtimeOptions: GoalAwareAcpRuntimeOptions = {
|
||||
cwd: options.cwd,
|
||||
...(cursorInstructions || modeBinding ? { protocolGuardFactory: () => {
|
||||
@@ -336,6 +342,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
} } : {}),
|
||||
...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}),
|
||||
...(effortAdmission ? { protocolGuardFactory: () => effortAdmission.createGuard() } : {}),
|
||||
...(antigravityAdmission ? { protocolGuardFactory: () => antigravityAdmission.createGuard() } : {}),
|
||||
sessionStore,
|
||||
agentRegistry: createRegistry({
|
||||
// Preserve Claude's ACP capability identity. This is metadata only: the
|
||||
@@ -514,18 +521,18 @@ export async function openQualifiedAcpxRuntime(
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handshake = (cursorInstructions || modeBinding
|
||||
const handshake = (contextAdmission || modeBinding
|
||||
? ensuredSession.then(async (ensuredHandle) => {
|
||||
handle = ensuredHandle;
|
||||
options.signal?.throwIfAborted();
|
||||
if (cursorInstructions) await admitCursorInstructions(cursorInstructions, {
|
||||
if (contextAdmission) await admitAcpxSessionContext(contextAdmission, {
|
||||
providerSpawned: commandLaunches.count > 0,
|
||||
load: async () => {
|
||||
// A fresh ACPX manager may reuse a saved record without spawning.
|
||||
// Its supported exact-model control forces session/load and the
|
||||
// current instruction acknowledgement before host admission.
|
||||
if (!runtime.setConfigOption) {
|
||||
throw new Error("Cursor cold admission requires exact model configuration");
|
||||
throw new Error("ACP context admission requires exact model configuration");
|
||||
}
|
||||
await runtime.setConfigOption({
|
||||
handle: ensuredHandle, key: "model", value: options.profile.reportedModelId,
|
||||
@@ -542,7 +549,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
if (modeBinding && !modeBinding.isReady()) {
|
||||
if (!runtime.setConfigOption) throw new Error("Provider mode admission requires native mode configuration");
|
||||
await runtime.setConfigOption({ handle: ensuredHandle, key: modeBinding.configKey, value: modeBinding.selectedMode });
|
||||
cursorInstructions?.assertReady();
|
||||
contextAdmission?.assertReady();
|
||||
modeBinding.assertReady();
|
||||
await children.verifyLifetimeOwnership();
|
||||
options.signal?.throwIfAborted();
|
||||
@@ -597,7 +604,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
handle = options.signal
|
||||
? await raceRuntimeHandshakeWithAbort(boundedHandshake, options.signal)
|
||||
: await boundedHandshake;
|
||||
cursorInstructions?.assertReady();
|
||||
contextAdmission?.assertReady();
|
||||
modeBinding?.assertReady();
|
||||
piThinking?.assertReady();
|
||||
effortAdmission?.assertReady();
|
||||
|
||||
@@ -87,9 +87,9 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current profile with environment launch preparation", () => {
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(81);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(84);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined();
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c");
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:a95f1d8e4d0a18d0c2cbc3bffdf998a2c5b8a582bdf51a72085b01d948d0a04e");
|
||||
});
|
||||
|
||||
it("preserves published v65 while binding retained-maintenance waits in v66", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { admitAcpxSessionContext } from "./session-context-admission.js";
|
||||
|
||||
const SCHEMA = "paperclip.cursor.instructions.v1";
|
||||
const MAX_BYTES = 32 * 1024;
|
||||
@@ -77,11 +78,5 @@ export async function admitCursorInstructions(
|
||||
admission: ReturnType<typeof createCursorInstructionAdmission>,
|
||||
input: { providerSpawned: boolean; load: () => Promise<void>; refreshCommand: () => Promise<void> },
|
||||
): Promise<void> {
|
||||
if (!input.providerSpawned) {
|
||||
await input.load();
|
||||
admission.assertReady();
|
||||
// ACPX control calls close their temporary native connection. A subsequent
|
||||
// prompt needs a fresh verified command lease, never a consumed snapshot.
|
||||
await input.refreshCommand();
|
||||
} else admission.assertReady();
|
||||
await admitAcpxSessionContext(admission, input);
|
||||
}
|
||||
@@ -9,13 +9,14 @@ import { providerFamilyCapabilities } from "../../provider-events.js";
|
||||
import {
|
||||
ACPX_DRIVER_KIND,
|
||||
QUALIFIED_ACPX_VERSION,
|
||||
QUALIFIED_ACPX_PROFILES,
|
||||
resolveQualifiedAcpxProfile,
|
||||
type QualifiedAcpxAgent,
|
||||
} from "./qualified-profiles.js";
|
||||
|
||||
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
|
||||
|
||||
const ACPX_AGENTS = ["claude", "codex", "grok", "pi", "cursor", "copilot"] as const;
|
||||
const ACPX_AGENTS = ["claude", "codex", "grok", "pi", "cursor", "copilot", "antigravity"] as const;
|
||||
const ACPX_PERMISSION_MODES = [
|
||||
"approve-all",
|
||||
"approve-paperclip",
|
||||
@@ -37,6 +38,12 @@ export function acpxCapabilities(
|
||||
): NativeSessionCapabilities {
|
||||
const controls = agent === "pi" ? negotiated : null;
|
||||
const profile = ACPX_CAPABILITY_PROFILES[agent];
|
||||
if (QUALIFIED_ACPX_PROFILES[agent].qualificationStatus === "pending") return {
|
||||
resume: false, toolRefreshOnResume: false, typedEvents: false, steering: false, queuedFollowUp: false,
|
||||
interruption: false, structuredResult: false, read: false, reconciliation: false, usage: false,
|
||||
dynamicTools: false, runtimeRequestResolution: false, runtimeRequestHandoff: false, goals: false, threadLineage: false,
|
||||
unsupported: ["qualification_pending"],
|
||||
};
|
||||
return {
|
||||
resume: profile.recovery === "session-load",
|
||||
toolRefreshOnResume: profile.recovery === "session-load" && profile.toolRefreshOnResume === true,
|
||||
@@ -68,15 +75,16 @@ export function acpxCapabilities(
|
||||
export function acpxDriverDescriptor(
|
||||
agent: QualifiedAcpxAgent,
|
||||
): HarnessDriverDescriptor {
|
||||
const pending = QUALIFIED_ACPX_PROFILES[agent].qualificationStatus === "pending";
|
||||
return {
|
||||
kind: ACPX_DRIVER_KIND,
|
||||
displayName: `${displayAgent(agent)} via ACPX`,
|
||||
version: QUALIFIED_ACPX_VERSION,
|
||||
protocolVersion: "acp/v1",
|
||||
runtimeContextCapabilities: {
|
||||
instructions: "native",
|
||||
skills: "native",
|
||||
mcp: "native",
|
||||
instructions: pending ? "unsupported" : "native",
|
||||
skills: pending ? "unsupported" : "native",
|
||||
mcp: pending ? "unsupported" : "native",
|
||||
},
|
||||
capabilities: acpxCapabilities(agent),
|
||||
};
|
||||
@@ -105,11 +113,11 @@ export function validateAcpxDriverConfig(
|
||||
return invalid(
|
||||
"agent",
|
||||
"invalid_agent",
|
||||
"ACPX agent must be claude, codex, grok, cursor, copilot, or pi.",
|
||||
"ACPX agent must be claude, codex, grok, cursor, copilot, pi, or antigravity.",
|
||||
);
|
||||
}
|
||||
if (ACPX_CAPABILITY_PROFILES[agent].qualification !== "qualified") {
|
||||
return invalid("agent", "qualification_pending", `${ACPX_CAPABILITY_PROFILES[agent].displayName} requires local and Daytona qualification before use.`);
|
||||
return invalid("agent", "qualification_pending", `${ACPX_CAPABILITY_PROFILES[agent].displayName} requires native Runner acceptance before use.`);
|
||||
}
|
||||
const model = text(config.model);
|
||||
try {
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("ACPX launch environment", () => {
|
||||
|
||||
it.each([
|
||||
["pi", "OPENROUTER_API_KEY"], ["cursor", "CURSOR_API_KEY"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"], ["antigravity", "GEMINI_API_KEY"],
|
||||
] as const)("requires an explicit %s credential binding for %s", (agent, name) => {
|
||||
vi.stubEnv(name, "ambient-secret");
|
||||
expect(createSanitizedAcpxSpawnInput(undefined, agent).env[name]).toBeUndefined();
|
||||
@@ -137,7 +137,7 @@ describe("ACPX launch environment", () => {
|
||||
|
||||
it.each([
|
||||
["pi", "OPENROUTER_API_KEY"], ["cursor", "CURSOR_API_KEY"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"], ["antigravity", "GEMINI_API_KEY"],
|
||||
] as const)("admits only session-bound %s sidecar credentials for %s", (agent, name) => {
|
||||
const source = { [name]: "task-secret" };
|
||||
const binding = createAcpxCredentialBinding(source, agent, "session-1");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isOpenRouterUrl, openRouterClaudeHeaders } from "../openrouter-attribution.js";
|
||||
import { sanitizeAntigravityEnvironment } from "./antigravity-environment.js";
|
||||
import { providerObservabilityEnvironment } from "../agent-observability.js";
|
||||
import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment, configuredEnvironmentKeys } from "../../configured-environment.js";
|
||||
import { PI_CREDENTIAL_NAMES, PI_GENERAL_IAM_CREDENTIAL_NAMES, piCredentialNames } from "./pi-provider-config.js";
|
||||
@@ -7,6 +8,7 @@ import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING";
|
||||
export const ACPX_CREDENTIAL_NAMES: Readonly<Record<QualifiedAcpxAgent, readonly string[]>> = {
|
||||
antigravity: ["GEMINI_API_KEY", "PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET"],
|
||||
grok: ["XAI_API_KEY"],
|
||||
pi: PI_CREDENTIAL_NAMES,
|
||||
cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
@@ -25,7 +27,7 @@ function removePiIamCredentials(environment: NodeJS.ProcessEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
const isCandidate = (agent: QualifiedAcpxAgent) => agent === "pi" || agent === "cursor" || agent === "copilot";
|
||||
const isCandidate = (agent: QualifiedAcpxAgent) => agent === "pi" || agent === "cursor" || agent === "copilot" || agent === "antigravity";
|
||||
|
||||
/** Mint only at the controller's explicit task-environment boundary, never by copying a marker. */
|
||||
export function createAcpxCredentialBinding(
|
||||
@@ -77,6 +79,7 @@ export function createAcpxSidecarHostEnvironment(
|
||||
}
|
||||
}
|
||||
if (agent === "pi") removePiIamCredentials(result);
|
||||
if (agent === "antigravity") sanitizeAntigravityEnvironment(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -158,6 +161,10 @@ export function createSanitizedAcpxSpawnInput(
|
||||
result[key] = value;
|
||||
}
|
||||
if (agent === "pi") removePiIamCredentials(result);
|
||||
if (agent === "antigravity") sanitizeAntigravityEnvironment(result);
|
||||
// The controller-to-sidecar document is staged under a lifetime fence. The
|
||||
// native child receives the private token file, never its launch document.
|
||||
if (agent === "antigravity") delete result.PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET;
|
||||
if (agent === "claude" && isOpenRouterUrl(result.ANTHROPIC_BASE_URL)) {
|
||||
result.ANTHROPIC_CUSTOM_HEADERS = openRouterClaudeHeaders(result.ANTHROPIC_CUSTOM_HEADERS);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,31 @@ export const ACPX_DRIVER_KIND = "acpx_runtime" as const;
|
||||
export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"antigravity": {
|
||||
"driverKind": "acpx_runtime",
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "antigravity",
|
||||
"agentProfileVersion": 1,
|
||||
"agentServerPackage": "native:antigravity-acp",
|
||||
"agentServerVersion": "1.4.3",
|
||||
"agentRuntimePackage": "native:antigravity-localharness",
|
||||
"agentRuntimeVersion": "1.4.3",
|
||||
"commandDigest": "sha256:acf5f574b6b9ec4388ff9d081890caa330e66bf1ae3fe3b08e7f0fb0e851b9ce",
|
||||
"qualificationStatus": "pending",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"grok": {
|
||||
"driverKind": "acpx_runtime",
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "grok",
|
||||
"agentProfileVersion": 12,
|
||||
"agentProfileVersion": 13,
|
||||
"agentServerPackage": "builtin:grok-acp",
|
||||
"agentServerVersion": "1",
|
||||
"agentRuntimePackage": "native:grok",
|
||||
"agentRuntimeVersion": "1.0.13",
|
||||
"commandDigest": "sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a",
|
||||
"commandDigest": "sha256:e311f8111dd12f0c49bf12a80345cfe5f31a1222e0189209adeabf2f9bd0e1b0",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"pi": {
|
||||
@@ -22,12 +36,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 34,
|
||||
"agentProfileVersion": 35,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"commandDigest": "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f",
|
||||
"commandDigest": "sha256:9473de6ef4baf2bd82fe8b6757239bdc160960b0a78ae6f47fa9e0996544226c",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"cursor": {
|
||||
@@ -48,12 +62,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 81,
|
||||
"agentProfileVersion": 84,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c",
|
||||
"commandDigest": "sha256:a95f1d8e4d0a18d0c2cbc3bffdf998a2c5b8a582bdf51a72085b01d948d0a04e",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"claude": {
|
||||
@@ -101,3 +115,11 @@ export const CURSOR_DISTRIBUTION_PINS = {
|
||||
"entrypoint": "index.js"
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ANTIGRAVITY_DISTRIBUTION_PINS = {
|
||||
"darwin-arm64": {
|
||||
"closureSha256": "044792d793e203bc8b4524a418f1816c4d34db67bb41fa4bae5947a050c49b4a",
|
||||
"executable": "agy_acp_server.par",
|
||||
"args": []
|
||||
}
|
||||
} as const;
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "node:path";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import { resolveQualifiedAcpxProfile, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { assertAcpxProfileReleased, resolveQualifiedAcpxProfile, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { resolveInstalledProviderAuthority } from "./installed-provider-authority.js";
|
||||
import {
|
||||
VERIFIED_RUNTIME_EXECUTABLE_ENV,
|
||||
@@ -651,6 +651,7 @@ export async function verifyQualifiedAcpxInstallation(
|
||||
profile: QualifiedAcpxProfile,
|
||||
resolvePackageJson: AcpxPackageJsonResolver = defaultPackageJsonResolver,
|
||||
): Promise<VerifiedAcpxInstallation> {
|
||||
assertAcpxProfileReleased(profile.agent);
|
||||
// Keep the selected package authority available when its bundled provider
|
||||
// wrapper delegates platform installation to the published server manifest.
|
||||
if ((profile.agent === "codex" || profile.agent === "claude") && resolvePackageJson === defaultPackageJsonResolver) {
|
||||
|
||||
@@ -218,21 +218,21 @@ describe("Pi installation factory", () => {
|
||||
|
||||
it("rejects legacy profiles and tampered executable identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 34");
|
||||
for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 34");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 35");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 35");
|
||||
for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 35");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { isSupportedAcpxProfileVersion } from "./profile-compatibility.js";
|
||||
|
||||
describe("historical ACPX profile decoding", () => {
|
||||
it("retains each provider's existing revision boundary", () => {
|
||||
expect(isSupportedAcpxProfileVersion("grok", 7)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("pi", 30)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 71)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 17)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("pi", 17)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("pi", 18)).toBe(true);
|
||||
|
||||
@@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js";
|
||||
|
||||
/** Decodable historical profile revisions, not permission to launch them.
|
||||
* Exact current profile/package/digest admission is checked separately. */
|
||||
type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80;
|
||||
type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83;
|
||||
type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"];
|
||||
export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion;
|
||||
|
||||
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80],
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83],
|
||||
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { assertAcpxExecutionAdmission, type AcpxCandidateStartupAdmission } from "./candidate-admission.js";
|
||||
import { assertCopilotCredentials, classifyCopilotFailure } from "./copilot-profile.js";
|
||||
import { verifyCopilotInstallation } from "./copilot-installation.js";
|
||||
import { verifyCursorInstallation } from "./cursor-installation.js";
|
||||
import { verifyAntigravityInstallation } from "./antigravity-installation.js";
|
||||
import { assertCursorWorkspacePolicy } from "./cursor-launch-policy.js";
|
||||
import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { verifyPiInstallation } from "./pi-installation.js";
|
||||
import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
|
||||
/** Closed build-owned registry. Provider branches add their pinned installations here. */
|
||||
export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
|
||||
export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile, candidateAdmission?: AcpxCandidateStartupAdmission): Promise<VerifiedAcpxInstallation> {
|
||||
assertAcpxExecutionAdmission(profile, candidateAdmission);
|
||||
if (profile.agent === "antigravity") return verifyAntigravityInstallation(profile, candidateAdmission);
|
||||
if (profile.agent === "pi") return verifyPiInstallation(profile);
|
||||
if (profile.agent === "copilot") return verifyCopilotInstallation(profile);
|
||||
if (profile.agent === "cursor") {
|
||||
@@ -31,6 +35,9 @@ export async function assertAcpxProfileWorkspace(agent: QualifiedAcpxAgent, work
|
||||
/** Called with the sanitized launch environment, never ambient process.env. */
|
||||
export function assertAcpxProfileEnvironment(agent: QualifiedAcpxAgent, environment: Readonly<NodeJS.ProcessEnv>): void {
|
||||
if (agent === "copilot") assertCopilotCredentials(environment);
|
||||
if (agent === "antigravity" && !environment.GEMINI_API_KEY?.trim() && !environment.PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET?.trim()) {
|
||||
throw Object.assign(new Error("Google Antigravity requires an explicitly bound Google runtime connection. Connect a Google account or Gemini API key before starting a task."), { code: "ANTIGRAVITY_CREDENTIALS_MISSING", retryable: false });
|
||||
}
|
||||
if (agent === "cursor" && !environment.CURSOR_API_KEY?.trim() && !environment.CURSOR_AUTH_TOKEN?.trim()) {
|
||||
throw Object.assign(new Error("Cursor credentials are missing. Bind a company secret to CURSOR_API_KEY or CURSOR_AUTH_TOKEN in the agent environment."), { code: "CURSOR_CREDENTIALS_MISSING", retryable: false });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, rea
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type NativeProvider = "cursor" | "copilot" | "pi";
|
||||
type NativeProvider = "cursor" | "copilot" | "pi" | "antigravity";
|
||||
const RUNNER_PACKAGE_NAME = "@paperclipai/paperclip-runner";
|
||||
const SERVER_PACKAGE_NAME = "@paperclipai/server";
|
||||
function assertServerVendorLayout(root: string): void {
|
||||
@@ -17,7 +17,7 @@ function assertServerVendorLayout(root: string): void {
|
||||
|
||||
/** Resolve only runner-owned package assets, including descriptor-loaded sidecars. */
|
||||
export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: NativeProvider): string {
|
||||
if (!["cursor", "copilot", "pi"].includes(provider)) throw new Error("Unknown native ACP provider assets");
|
||||
if (!["cursor", "copilot", "pi", "antigravity"].includes(provider)) throw new Error("Unknown native ACP provider assets");
|
||||
const boundRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT;
|
||||
const boundManifest = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST;
|
||||
let packageRoot: string;
|
||||
|
||||
@@ -37,6 +37,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
Record<QualifiedAcpxAgent, AcpxReleaseProfile>
|
||||
> = deepFreeze(QUALIFIED_ACPX_PROFILE_DATA);
|
||||
|
||||
/** Release admission uses canonical build metadata, never a caller's snapshot. */
|
||||
export function assertAcpxProfileReleased(agent: QualifiedAcpxAgent): void {
|
||||
if (QUALIFIED_ACPX_PROFILES[agent].qualificationStatus === "pending") {
|
||||
throw Object.assign(new Error(`ACPX ${agent} qualification is pending; this build cannot launch the provider.`),
|
||||
{ code: "ACPX_PROFILE_QUALIFICATION_PENDING", retryable: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveQualifiedAcpxProfile(
|
||||
agent: QualifiedAcpxAgent,
|
||||
requestedModel: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAcpxExecutionAdmission, parseAcpxCandidateStartupAdmission, type AcpxCandidateStartupAdmission } from "./candidate-admission.js";
|
||||
import { resolveGrokDisableWebSearch } from "./grok-web-search.js";
|
||||
import { recordAcpxAdmissionFailure, type ACPX_ADMISSION_CODES } from "./admission-diagnostics.js";
|
||||
import { composeCopilotSystemInstructions } from "./copilot-profile.js";
|
||||
@@ -13,6 +14,7 @@ import { bindAcpxAgentFiles } from "./agent-files-binding.js";
|
||||
import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js";
|
||||
import { stageManagedGrokCredential } from "./grok-credentials.js";
|
||||
import { stageManagedAntigravityCredential } from "./antigravity-credentials.js";
|
||||
import { claudeNativeSkillPrompt } from "./native-skill-prompt.js";
|
||||
import { prepareStdioMcpBindings } from "../stdio-mcp.js";
|
||||
import { nativeMcpLaunchBinding } from "../native-mcp.js";
|
||||
@@ -238,6 +240,8 @@ export interface AcpxRuntimeHostDependencies {
|
||||
}
|
||||
|
||||
export interface OpenAcpxRuntimeHostOptions {
|
||||
/** Trusted startup authority from runnerd; never provider session data. */
|
||||
candidateAdmission?: AcpxCandidateStartupAdmission;
|
||||
/** Explicit task execution policy; never inferred from auto-approval. Required for Pi. */
|
||||
providerPolicy?: AcpxProviderRuntimePolicy;
|
||||
/** Never persisted as session options or recovery identity. */
|
||||
@@ -309,6 +313,7 @@ export class AcpxRuntimeHost {
|
||||
readonly #toolBridge: RunnerToolBridge | null;
|
||||
readonly #claudeSkillNames: readonly string[];
|
||||
readonly #assertAgentFilesHeld: (() => void) | undefined;
|
||||
readonly #candidateAdmission: AcpxCandidateStartupAdmission | undefined;
|
||||
#activeTurn: AcpxRuntimeTurn | null = null;
|
||||
#activeTurnInterruption: AcpxRuntimeTurn["cancel"] | null = null;
|
||||
#closingStarted = false;
|
||||
@@ -325,6 +330,7 @@ export class AcpxRuntimeHost {
|
||||
toolBridge: RunnerToolBridge | null;
|
||||
claudeSkillNames: readonly string[];
|
||||
assertAgentFilesHeld?: () => void;
|
||||
candidateAdmission?: AcpxCandidateStartupAdmission;
|
||||
}) {
|
||||
this.#runtime = input.runtime;
|
||||
this.#binding = input.binding;
|
||||
@@ -335,6 +341,7 @@ export class AcpxRuntimeHost {
|
||||
this.#toolBridge = input.toolBridge;
|
||||
this.#claudeSkillNames = [...input.claudeSkillNames];
|
||||
this.#assertAgentFilesHeld = input.assertAgentFilesHeld;
|
||||
this.#candidateAdmission = input.candidateAdmission ? structuredClone(input.candidateAdmission) : undefined;
|
||||
}
|
||||
|
||||
static async open(
|
||||
@@ -365,6 +372,11 @@ export class AcpxRuntimeHost {
|
||||
throw new Error("assigned native MCP launch binding is unavailable");
|
||||
}
|
||||
const profile = resolveQualifiedAcpxProfile(options.agent, options.model);
|
||||
if (options.candidateAdmission) {
|
||||
assertAcpxExecutionAdmission(profile, options.candidateAdmission);
|
||||
options = { ...options, candidateAdmission: parseAcpxCandidateStartupAdmission(options.candidateAdmission, { agent: options.agent, model: options.model, normalizedSessionId: options.normalizedSessionId }) };
|
||||
if (preconfigured || typeof options.providerPolicy?.readOnly !== "boolean") throw new Error("ACPX candidate requires a fresh isolated session and explicit task policy");
|
||||
}
|
||||
const binding = await runAbortableAdmissionStage(
|
||||
options.signal,
|
||||
() =>
|
||||
@@ -403,9 +415,9 @@ export class AcpxRuntimeHost {
|
||||
const installation = await runAbortableAdmissionStage(
|
||||
options.signal,
|
||||
() =>
|
||||
(dependencies.verifyInstallation ?? verifyAcpxProfileInstallation)(
|
||||
profile,
|
||||
),
|
||||
dependencies.verifyInstallation
|
||||
? dependencies.verifyInstallation(profile)
|
||||
: verifyAcpxProfileInstallation(profile, options.candidateAdmission),
|
||||
dependencies.retainAdmissionCleanup,
|
||||
).catch(error => { throw recordAcpxAdmissionFailure(error, "installation"); });
|
||||
if (installation.commandDigest !== profile.commandDigest) {
|
||||
@@ -477,6 +489,7 @@ export class AcpxRuntimeHost {
|
||||
dependencies.retainAdmissionCleanup,
|
||||
);
|
||||
admissionStage = "credential";
|
||||
if (options.agent === "antigravity") assertAcpxProfileEnvironment(options.agent, options.environment ?? {});
|
||||
if (options.agent === "codex" && !preconfigured) {
|
||||
credential = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
@@ -491,6 +504,14 @@ export class AcpxRuntimeHost {
|
||||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
} else if (options.agent === "antigravity") {
|
||||
credential = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () => stageManagedAntigravityCredential({ agentHomeDirectory: sandbox.agentHomeDirectory,
|
||||
environment: options.environment, retainRefresh: () => admissionSucceeded }),
|
||||
resource: "credential", releaseLate: late => late.close(),
|
||||
reportFailure: failure => dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
} else if (options.agent === "grok") {
|
||||
credential = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
@@ -528,7 +549,9 @@ export class AcpxRuntimeHost {
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
admissionStage = "credential";
|
||||
assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment);
|
||||
// Antigravity's selected OAuth document has already been validated and
|
||||
// staged; it is intentionally absent from the native launch environment.
|
||||
if (options.agent !== "antigravity") assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment);
|
||||
admissionStage = "configuration";
|
||||
let launchEnvironment = sandbox.launchEnvironment;
|
||||
// This copy is registered by the authenticated controller for its company,
|
||||
@@ -630,6 +653,7 @@ export class AcpxRuntimeHost {
|
||||
options.assertWorkspaceHeld?.();
|
||||
agentFiles?.assertHeld();
|
||||
await assertAcpxProfileWorkspace(options.agent, binding.workspacePath);
|
||||
if (options.candidateAdmission) assertAcpxExecutionAdmission(profile, options.candidateAdmission);
|
||||
return dependencies.openRuntime({
|
||||
...(options.clientCapabilities === undefined ? {} : {
|
||||
clientCapabilities: structuredClone(options.clientCapabilities),
|
||||
@@ -740,6 +764,7 @@ export class AcpxRuntimeHost {
|
||||
command,
|
||||
toolBridge,
|
||||
assertAgentFilesHeld: agentFiles?.assertHeld,
|
||||
candidateAdmission: options.candidateAdmission,
|
||||
claudeSkillNames: options.agent === "claude"
|
||||
? (options.runtimeContext?.skills.map((skill) => skill.runtimeName) ?? [])
|
||||
: [],
|
||||
@@ -864,6 +889,7 @@ export class AcpxRuntimeHost {
|
||||
}
|
||||
|
||||
startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn {
|
||||
if (this.#candidateAdmission) parseAcpxCandidateStartupAdmission(this.#candidateAdmission, { agent: this.#candidateAdmission.agent, model: this.#binding.requestedModel, normalizedSessionId: this.#binding.normalizedSessionId });
|
||||
this.#assertAgentFilesHeld?.();
|
||||
if (this.#closed || this.#closingStarted) {
|
||||
throw new Error("ACPX runtime host is closing");
|
||||
|
||||
@@ -167,6 +167,20 @@ describe("ACPX runtime sandbox", () => {
|
||||
for (const value of ["provider-secret", "configured-secret", "scoped-run-token", "private-identity"]) expect(config).not.toContain(value);
|
||||
});
|
||||
|
||||
it("seeds an isolated Antigravity API-key profile without inherited login/configuration", async () => {
|
||||
const fixture = await sandboxFixture("antigravity");
|
||||
const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "antigravity",
|
||||
environment: { GEMINI_API_KEY: "bound-key", GEMINI_HOME: "/foreign/profile", GOOGLE_APPLICATION_CREDENTIALS: "/foreign/adc.json" } });
|
||||
await sandbox.initializeOwnedConfiguration?.();
|
||||
expect(sandbox.launchEnvironment.GEMINI_HOME).toBe(sandbox.agentHomeDirectory);
|
||||
expect(sandbox.launchEnvironment.AGY_ACP_FORCE_FILE_STORAGE).toBe("1");
|
||||
expect(sandbox.launchEnvironment.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
|
||||
const settings = join(sandbox.agentHomeDirectory, "antigravity-acp/settings.json");
|
||||
expect(JSON.parse(await readFile(settings, "utf8"))).toEqual({ auth: { type: "gemini-api-key" } });
|
||||
expect((await stat(settings)).mode & 0o777).toBe(0o600);
|
||||
expect(JSON.stringify(sandbox.persistedEnvironment)).not.toContain("bound-key");
|
||||
});
|
||||
|
||||
it.each(["claude-sonnet-5", "sonnet", "custom-deployment-id"])(
|
||||
"preserves the requested Claude model %s in its isolated settings",
|
||||
async (model) => {
|
||||
@@ -551,7 +565,7 @@ it("refreshes Copilot native instructions privately on every admission, includin
|
||||
expect(await readFile(path, "utf8")).toBe(composed(""));
|
||||
});
|
||||
|
||||
async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") {
|
||||
async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot" | "antigravity") {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-sandbox-"));
|
||||
temporaryDirectories.push(root);
|
||||
const workspace = join(root, "workspace");
|
||||
@@ -564,6 +578,7 @@ async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "curso
|
||||
codex: "gpt-5.6-sol",
|
||||
cursor: "provider-model-cursor",
|
||||
copilot: "provider-model-copilot",
|
||||
antigravity: "provider-model-antigravity",
|
||||
} as const;
|
||||
const binding = await createAcpxRecoveryBinding({
|
||||
runtimeDirectory,
|
||||
|
||||
@@ -440,6 +440,10 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
: {}),
|
||||
})}\n`,
|
||||
);
|
||||
} : input.agent === "antigravity" ? async () => {
|
||||
const profile = await ensurePrivateDirectory(join(agentHomeDirectory, "antigravity-acp"), agentHomeDirectory);
|
||||
const method = input.environment?.PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET ? "oauth-personal" : "gemini-api-key";
|
||||
await writePrivateFile(join(profile, "settings.json"), `${JSON.stringify({ auth: { type: method } })}\n`);
|
||||
} : undefined;
|
||||
if (input.agent === "grok") {
|
||||
// Native project rules merge with global rules. Ask rules win over project
|
||||
@@ -529,6 +533,7 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
XDG_CACHE_HOME: cacheDirectory,
|
||||
PAPERCLIP_ACPX_PROFILE: input.agent,
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1",
|
||||
...(input.agent === "antigravity" ? { GEMINI_HOME: agentHomeDirectory, AGY_ACP_FORCE_FILE_STORAGE: "1" } : {}),
|
||||
...(input.agent === "grok" ? { GROK_HOME: agentHomeDirectory, NO_BROWSER: "1", GROK_DISABLE_AUTOUPDATER: "1" } : {}),
|
||||
...(input.agent === "pi"
|
||||
? {
|
||||
@@ -764,6 +769,7 @@ function isPersistableEnvironmentName(name: string): boolean {
|
||||
/^(?:PI_CODING_AGENT_DIR|PI_SKIP_VERSION_CHECK|PI_TELEMETRY)$/.test(name) ||
|
||||
/^(?:CURSOR_CONFIG_DIR|CURSOR_DATA_DIR|AGENT_CLI_CREDENTIAL_STORE|NO_OPEN_BROWSER|NODE_DISABLE_COMPILE_CACHE)$/.test(name) ||
|
||||
/^(?:COPILOT_HOME|COPILOT_CACHE_HOME|COPILOT_AUTO_UPDATE|COPILOT_ALLOW_ALL|COPILOT_DISABLE_TERMINAL_TITLE|NO_COLOR)$/.test(name) ||
|
||||
/^(?:GEMINI_HOME|AGY_ACP_FORCE_FILE_STORAGE)$/.test(name) ||
|
||||
/^(?:CLAUDE_CONFIG_DIR|CODEX_HOME|NO_BROWSER|DEFAULT_AUTH_REQUEST|PAPERCLIP_RUNNER_PREPARED_HARNESS_HOME)$/.test(
|
||||
name,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** A disk record is not proof that this connection received the session context. */
|
||||
export async function admitAcpxSessionContext(
|
||||
admission: { assertReady(): void },
|
||||
input: { providerSpawned: boolean; load: () => Promise<void>; refreshCommand: () => Promise<void> },
|
||||
): Promise<void> {
|
||||
if (!input.providerSpawned) {
|
||||
await input.load();
|
||||
admission.assertReady();
|
||||
// ACPX control calls close their temporary connection. Its consumed native
|
||||
// command lease cannot be reused for the subsequent prompt connection.
|
||||
await input.refreshCommand();
|
||||
} else admission.assertReady();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -39,6 +40,7 @@ export interface AcpxSidecarEvent {
|
||||
}
|
||||
|
||||
export interface AcpxSidecarOpenParams {
|
||||
candidateAdmission?: AcpxCandidateStartupAdmission;
|
||||
runtimeDirectory: string;
|
||||
normalizedSessionId: string;
|
||||
workingDirectory: string;
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface CodexAppServerTransport {
|
||||
notify(method: string, params?: Record<string, unknown>): void;
|
||||
notifications(): AsyncIterable<CodexRpcNotification>;
|
||||
setServerRequestHandler(handler: CodexServerRequestHandler): void;
|
||||
/** Establish a fresh owned provider lifetime before reading a retained conversation. */
|
||||
prepareConversationResume?(params: Record<string, unknown>): Promise<void>;
|
||||
/** One-shot requests authenticated by an adopted live provider, never a saved checkpoint alone. */
|
||||
takeRestoredRuntimeRequests?(): CodexRpcServerRequest[];
|
||||
/**
|
||||
|
||||
@@ -406,6 +406,14 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
try {
|
||||
await cancellation.wait(this.#persistProcessOwnership(transport));
|
||||
const initialize = await cancellation.wait(this.#initialize(transport));
|
||||
if (transport.prepareConversationResume) {
|
||||
const resumeDirectory = snapshot.workingDirectory
|
||||
? validateWorkingDirectory(snapshot.workingDirectory, this.#options.environment, this.#options.workingDirectoryAuthority)
|
||||
: undefined;
|
||||
await cancellation.wait(transport.prepareConversationResume(resumeDirectory
|
||||
? this.#resumeThreadParams(snapshot.driverSessionId, resumeDirectory)
|
||||
: { threadId: snapshot.driverSessionId }));
|
||||
}
|
||||
const existing = await cancellation.wait(
|
||||
readCodexThreadState(transport, snapshot.driverSessionId),
|
||||
);
|
||||
@@ -436,23 +444,7 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
};
|
||||
}
|
||||
const response = await cancellation.wait(
|
||||
transport.request("thread/resume", {
|
||||
excludeTurns: true,
|
||||
threadId: snapshot.driverSessionId,
|
||||
...createSecuredCodexThreadParams(
|
||||
workingDirectory,
|
||||
this.#options.requestedCollaborationMode ?? "default",
|
||||
this.#options.includeCollaborationModeInstructions ?? true,
|
||||
this.#options.includeSkillInstructions ?? false,
|
||||
this.#options.environment,
|
||||
),
|
||||
...this.#instructionParams(this.#direct() ? "" : this.#baseInstructions()),
|
||||
approvalPolicy: this.#options.approvalPolicy ?? "never",
|
||||
...(this.#options.model ? { model: this.#options.model } : {}),
|
||||
dynamicTools: this.#providerDynamicTools(),
|
||||
...(this.#prepared() ? { conversationMode: "prepared" } : {}),
|
||||
persistExtendedHistory: false,
|
||||
}),
|
||||
transport.request("thread/resume", this.#resumeThreadParams(snapshot.driverSessionId, workingDirectory)),
|
||||
);
|
||||
await cancellation.wait(this.#persistProcessOwnership(transport));
|
||||
const collaborationMode = await cancellation.wait(
|
||||
@@ -686,6 +678,22 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
}
|
||||
}
|
||||
|
||||
#resumeThreadParams(threadId: string, workingDirectory: string): Record<string, unknown> {
|
||||
return {
|
||||
excludeTurns: true, threadId,
|
||||
...createSecuredCodexThreadParams(workingDirectory,
|
||||
this.#options.requestedCollaborationMode ?? "default",
|
||||
this.#options.includeCollaborationModeInstructions ?? true,
|
||||
this.#options.includeSkillInstructions ?? false, this.#options.environment),
|
||||
...this.#instructionParams(this.#direct() ? "" : this.#baseInstructions()),
|
||||
approvalPolicy: this.#options.approvalPolicy ?? "never",
|
||||
...(this.#options.model ? { model: this.#options.model } : {}),
|
||||
dynamicTools: this.#providerDynamicTools(),
|
||||
...(this.#prepared() ? { conversationMode: "prepared" } : {}),
|
||||
persistExtendedHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
#transport(context?: {
|
||||
workingDirectory?: string;
|
||||
providerRecoveryPolicy?: PersistedHarnessSession["providerRecoveryPolicy"];
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { CodexAppServerDriver, FakeCodexTransport, WORKSPACE, collectUntilTerminal, envelope, expect, it, vi, type CodexAppServerTransport } from "./codex-app-server-driver.test-support.js";
|
||||
|
||||
const model = "gemini-3.8-flash-low";
|
||||
const identity = { kind: "acpx", normalizedSessionId: "native-conversation", acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "native-1",
|
||||
profileDigest: `sha256:${"a".repeat(64)}`, workspaceDigest: `sha256:${"b".repeat(64)}`, requestedModel: model, effectiveModel: model,
|
||||
permissionMode: "approve-all", providerLifetimeFenceCandidates: [60001, 60002, 60003] };
|
||||
|
||||
function peer(nativeIdentity = identity) {
|
||||
const transport = new FakeCodexTransport("driver-1", "native-1", nativeIdentity) as FakeCodexTransport & Pick<CodexAppServerTransport, "prepareConversationResume">;
|
||||
const request = transport.request.bind(transport);
|
||||
transport.request = async (method, params) => {
|
||||
const result = await request(method, params);
|
||||
if (method === "thread/start" || method === "thread/resume") result.model = model;
|
||||
return result;
|
||||
};
|
||||
transport.readResponse = { thread: { id: "driver-1", sessionId: "native-1", cwd: WORKSPACE, turns: [] } };
|
||||
return transport;
|
||||
}
|
||||
|
||||
it.each(["loaded", "missing-conversation", "different-thread", "different-native-identity"])("normal driver continuation prepares a fresh lifetime before authoritative reads (%s)", async mode => {
|
||||
const first = peer(), next = peer(mode === "different-native-identity" ? { ...identity, agentSessionId: "replaced-native-session" } : identity);
|
||||
const instructions = "Current assigned instruction bytes.\n";
|
||||
const prepare = vi.fn(async (params: Record<string, unknown>) => {
|
||||
next.calls.push({ method: "owned-conversation-bootstrap", params: structuredClone(params) });
|
||||
expect(next.calls.some(call => call.method === "thread/read")).toBe(false);
|
||||
if (mode === "missing-conversation") throw new Error("retained native conversation is unavailable");
|
||||
if (mode === "different-thread") next.readResponse = { thread: { id: "different-driver", sessionId: "different-native", cwd: WORKSPACE, turns: [] } };
|
||||
});
|
||||
next.prepareConversationResume = prepare;
|
||||
let index = 0;
|
||||
const driver = new CodexAppServerDriver({ taskEnvelope: envelope, model, baseInstructions: instructions,
|
||||
requireProviderSessionIdentity: true, driverIdentity: { kind: "acpx", displayName: "Antigravity", version: "test" }, requestedCollaborationMode: "default", environment: {}, transportFactory: () => [first, next][index++]! });
|
||||
const original = await driver.openSession({ runId: "first-run", normalizedSessionId: "native-conversation", workingDirectory: WORKSPACE });
|
||||
await original.startTurn({ message: { role: "user", text: "Finish the first turn." } });
|
||||
first.push("turn/completed", { threadId: "driver-1", turn: { id: "turn-1", status: "completed", items: [] } });
|
||||
await collectUntilTerminal(original.events());
|
||||
const snapshot = await original.snapshot();
|
||||
expect(snapshot.activeTurnId).toBeNull();
|
||||
await original.close({ reason: "first per-turn lifetime retired" });
|
||||
snapshot.runId = "current-run";
|
||||
snapshot.providerRecoveryPolicy = "same_session_only";
|
||||
next.turnStartResponse = Promise.resolve({ turn: { id: "turn-2", status: "inProgress", items: [] } });
|
||||
const recovered = await driver.recoverSession(snapshot);
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(prepare).toHaveBeenCalledWith(expect.objectContaining({ threadId: "driver-1", model, cwd: WORKSPACE, approvalPolicy: "never", permissions: "paperclip-runner-workspace-only", dynamicTools: expect.any(Array) }));
|
||||
expect(prepare.mock.calls[0]![0].baseInstructions).toContain(instructions.trim());
|
||||
if (mode === "loaded") {
|
||||
expect(recovered.recovered).toBe(true);
|
||||
expect(next.calls.find(call => call.method === "thread/resume")!.params).toEqual(prepare.mock.calls[0]![0]);
|
||||
expect((await recovered.session!.snapshot()).providerIdentity).toEqual(identity);
|
||||
await recovered.session!.startTurn({ message: { role: "user", text: "Continue in the retained conversation." } });
|
||||
expect(next.calls.filter(call => call.method === "turn/start")).toHaveLength(1);
|
||||
await recovered.session!.close({ reason: "current per-turn lifetime retired" });
|
||||
} else {
|
||||
expect(recovered.recovered).toBe(false);
|
||||
expect(next.calls.some(call => call.method === "turn/start" || call.method === "thread/start")).toBe(false);
|
||||
if (mode === "missing-conversation") expect(next.calls.some(call => call.method === "thread/read")).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -56,6 +56,9 @@ export { runnerBinaryTarget } from "./live/runner-binary.js";
|
||||
export * from "./drivers/opencode/opencode-server-driver.js";
|
||||
export * from "./drivers/opencode/mcp-bridge.js";
|
||||
export * from "./drivers/acpx/qualified-profiles.js";
|
||||
export { parseAcpxCandidateAdmission, type AcpxCandidateAdmission } from "./drivers/acpx/candidate-admission.js";
|
||||
export { startAntigravityBrowserLogin, verifyAntigravityCredential, sanitizeAntigravityCredential } from "./drivers/acpx/antigravity-auth.js";
|
||||
export { AntigravityModelDiscoveryCleanupError, discoverAntigravityModels, type AntigravityModelCatalog } from "./drivers/acpx/antigravity-models.js";
|
||||
export { acpxRuntimeSessionDirectoryName, resolveAcpxRuntimeRoot } from "./drivers/acpx/recovery-identity.js";
|
||||
export {
|
||||
probeQualifiedAcpxEnvironment,
|
||||
@@ -69,6 +72,7 @@ export * from "./drivers/runner-tool-bridge.js";
|
||||
export {
|
||||
createRunnerdCodexTransport,
|
||||
probeNativeRunnerEnvironment,
|
||||
NativeEnvironmentProbeCleanupError,
|
||||
runnerCodexDynamicToolsFit,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
readRunnerdArtifactBinding,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { spawnRunner, type RunnerProcessLaunchSpec } from "../control-plane/durable-prp-control-plane.js";
|
||||
import { bindAcpxCandidateAdmission, type AcpxCandidateAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "../drivers/acpx/generated-profiles.js";
|
||||
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { createRunnerdCodexTransport, runnerdLaunchProfileInternals } from "./runnerd-codex-transport.js";
|
||||
|
||||
const identity = { runnerInstanceId: "runner-candidate", environmentLeaseId: "lease-candidate", runId: "run-candidate", normalizedSessionId: "session-candidate", turnId: "turn-candidate", itemId: "item-candidate" };
|
||||
const candidate = (): AcpxCandidateAdmission => ({ schema: "paperclip.acpx-candidate-admission.v1", companyId: "company-candidate", agent: "antigravity", model: "gemini-3.8-flash-low",
|
||||
profileDigest: resolveQualifiedAcpxProfile("antigravity", "gemini-3.8-flash-low").commandDigest,
|
||||
nativeClosureDigest: `sha256:${ANTIGRAVITY_DISTRIBUTION_PINS["darwin-arm64"].closureSha256}`,
|
||||
platform: "darwin", architecture: "arm64", expiresAt: Date.now() + 60_000 });
|
||||
const supported = process.platform === "darwin" && process.arch === "arm64";
|
||||
|
||||
describe("Antigravity candidate trusted startup", () => {
|
||||
it("bounds argv and structured launches by the absolute candidate expiry across replacement", () => {
|
||||
let now = Date.now();
|
||||
const clock = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
try {
|
||||
const admission = bindAcpxCandidateAdmission({ ...candidate(), expiresAt: now + 2_000 }, identity);
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
const handle = spawnRunner({ connectUrl: "ws://127.0.0.1:43127", stateDirectory: "/unused/candidate", identity,
|
||||
ticket: "inert-ticket", maxOutboxBytes: 262144, p0ReserveBytes: 65536, maxRuntimeMs: 0, maxLifetimeMs: 10_000,
|
||||
runnerVersion: "fixture", runnerDigest: `sha256:${"f".repeat(64)}`,
|
||||
acpxLaunchProfile: { candidateAdmission: admission, authorityDigest: `sha256:${"d".repeat(64)}`, command: "/owned/node", commandSha256: `sha256:${"b".repeat(64)}`,
|
||||
sidecarScript: "/owned/sidecar.cjs", sidecarScriptSha256: `sha256:${"c".repeat(64)}` },
|
||||
processLauncher: spec => { launches.push(spec); return { child: { pid: 42, exitCode: 0, signalCode: null, kill: () => true },
|
||||
completion: Promise.resolve({ code: 0, signal: null, stdout: "", stderr: "" }) }; },
|
||||
});
|
||||
expect(launches[0]!.args).not.toContain("--max-lifetime-ms");
|
||||
expect(launches[0]!.args.slice(-2)).toEqual(["--max-runtime-ms", "2000"]);
|
||||
expect(launches[0]!.nativeRunner).toMatchObject({ maxRuntimeMs: 2000, maxLifetimeMs: 2000 });
|
||||
now += 1_500;
|
||||
handle.restart!("replacement-ticket");
|
||||
expect(launches[1]!.args.slice(-2)).toEqual(["--max-runtime-ms", "500"]);
|
||||
expect(launches[1]!.nativeRunner).toMatchObject({ maxRuntimeMs: 500, maxLifetimeMs: 500 });
|
||||
now += 501;
|
||||
expect(() => handle.restart!("expired-ticket")).toThrow("runner_process_lifetime_expired");
|
||||
expect(launches).toHaveLength(2);
|
||||
} finally { clock.mockRestore(); }
|
||||
});
|
||||
|
||||
it.skipIf(!supported)("allows an explicit cold local owner while rejecting absent and broadened authority", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "antigravity-candidate-startup-"));
|
||||
try {
|
||||
const options = { provider: "acpx" as const, acpxAgent: "antigravity" as const, stateDirectory: root, acpxCandidateAdmission: candidate() };
|
||||
expect(() => createRunnerdCodexTransport({ ...options, acpxCandidateAdmission: undefined })).toThrow("evaluation opt-in");
|
||||
const owner = createRunnerdCodexTransport(options);
|
||||
await owner.transport.close();
|
||||
for (const change of [{ lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 1000 } }, { runnerFilesystemRoot: "/guest" },
|
||||
{ homeMode: "preconfigured" as const }, { resumeProviderSession: { driverSessionId: "old-session" } },
|
||||
{ provider: "codex" as const },
|
||||
{ acpxCandidateAdmission: { ...candidate(), nativeClosureDigest: `sha256:${"a".repeat(64)}` } }]) {
|
||||
expect(() => createRunnerdCodexTransport({ ...options, ...change })).toThrow();
|
||||
}
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.skipIf(!supported)("loads a settled native conversation only through a fresh current-run owner", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "antigravity-candidate-continuation-"));
|
||||
const admission = candidate();
|
||||
const retained = { driverSessionId: "driver-retained", providerSessionId: "native-retained", providerIdentity: {
|
||||
kind: "acpx", normalizedSessionId: identity.normalizedSessionId,
|
||||
// The recovery binding digest is distinct from the release command digest.
|
||||
profileDigest: `sha256:${"a".repeat(64)}`, requestedModel: admission.model, effectiveModel: admission.model,
|
||||
} };
|
||||
try {
|
||||
const options = { provider: "acpx" as const, acpxAgent: "antigravity" as const, stateDirectory: root,
|
||||
acpxCandidateAdmission: admission, prpIdentity: identity, resumeWorkingDirectory: root,
|
||||
resumeProviderSession: retained, providerRecoveryPolicy: "allow_replacement_after_governed_wait" as const };
|
||||
const owner = createRunnerdCodexTransport(options);
|
||||
expect(owner.transport.prepareConversationResume).toBeTypeOf("function");
|
||||
await expect(owner.transport.prepareConversationResume!({ threadId: retained.driverSessionId, model: "wrong-model", cwd: root })).rejects.toThrow("exact current conversation");
|
||||
await expect(owner.transport.prepareConversationResume!({ threadId: retained.driverSessionId, model: admission.model, cwd: "/wrong-workspace" })).rejects.toThrow("exact current conversation");
|
||||
await owner.transport.close();
|
||||
expect(() => createRunnerdCodexTransport({ ...options, resumeActiveTurnId: "stale-active-turn" })).toThrow("fresh local isolated");
|
||||
expect(() => createRunnerdCodexTransport({ ...options, resumeProviderSession: { ...retained,
|
||||
providerIdentity: { ...retained.providerIdentity, effectiveModel: "wrong-model" } } })).toThrow("exact retained native conversation");
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("binds the startup authority to the actual Runner identity and keeps it out of provider configuration", () => {
|
||||
const admission = bindAcpxCandidateAdmission(candidate(), identity);
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
spawnRunner({ connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" }, stateDirectory: "/unused/candidate", identity,
|
||||
ticket: "inert-ticket", maxOutboxBytes: 262144, p0ReserveBytes: 65536, runnerVersion: "fixture", runnerDigest: `sha256:${"f".repeat(64)}`,
|
||||
acpxLaunchProfile: { candidateAdmission: admission, authorityDigest: `sha256:${"d".repeat(64)}`, command: "/owned/node", commandSha256: `sha256:${"b".repeat(64)}`, sidecarScript: "/owned/sidecar.cjs", sidecarScriptSha256: `sha256:${"c".repeat(64)}` },
|
||||
environment: {}, processLauncher: spec => { launches.push(spec); return { child: { pid: 42, exitCode: 0, signalCode: null, kill: () => true }, completion: Promise.resolve({ code: 0, signal: null, stdout: "", stderr: "" }) }; },
|
||||
});
|
||||
expect(launches).toHaveLength(1);
|
||||
const launch = launches[0]!;
|
||||
const flag = launch.args.indexOf("--acpx-candidate-admission-json");
|
||||
expect(flag).toBeGreaterThan(0);
|
||||
expect(JSON.parse(launch.args[flag + 1]!)).toEqual({ ...candidate(), ...admission });
|
||||
expect(launch.nativeRunner?.acpxLaunchProfile?.candidateAdmission).toEqual(admission);
|
||||
expect(JSON.stringify(launch.environment)).not.toContain(admission.schema);
|
||||
expect(runnerdLaunchProfileInternals.openedThreadModelProvider("acpx", "antigravity", admission.model)).toBe("google");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseAcpxCandidateAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import { normalizeProviderNotice } from "../drivers/provider-notices.js";
|
||||
@@ -907,9 +908,13 @@ export class CapabilityLiveSessionService {
|
||||
|
||||
async create(input: CreateCapabilityLiveSessionInput = {}): Promise<CapabilityLiveSession> {
|
||||
resolvePiThinkingLevel(input.provider === "acpx" ? input.acpxAgent ?? "codex" : "", input.piThinkingLevel);
|
||||
if (this.#transportOptions.acpxCandidateAdmission) {
|
||||
parseAcpxCandidateAdmission(this.#transportOptions.acpxCandidateAdmission, { companyId: input.companyId ?? "", agent: input.acpxAgent ?? "", model: input.requestedModel ?? "" });
|
||||
}
|
||||
if (input.provider === "acpx" && input.acpxAgent !== undefined
|
||||
&& ACPX_CAPABILITY_PROFILES[input.acpxAgent].qualification === "pending"
|
||||
&& this.#transportOptions.acpxCandidateProfile !== input.acpxAgent) {
|
||||
&& this.#transportOptions.acpxCandidateProfile !== input.acpxAgent
|
||||
&& !(input.acpxAgent === "antigravity" && this.#transportOptions.acpxCandidateAdmission)) {
|
||||
throw new Error("The candidate ACPX profile requires explicit evaluation opt-in");
|
||||
}
|
||||
if (input.provider === "claude_managed" && !input.managedProfile) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { NativeEnvironmentProbeCleanupError, probeNativeRunnerEnvironment } from "./runnerd-codex-transport.js";
|
||||
|
||||
it.each(["eventual-exit", "bounded-retry", "windows-unconfirmed"] as const)("retains a timed-out probe home until physical proof and runs cleanup once (%s)", async mode => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-probe-retained-"));
|
||||
const home = join(root, "selected-home");
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
let started!: () => void;
|
||||
const launched = new Promise<void>(resolve => { started = resolve; });
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
let exited!: Promise<void>;
|
||||
const copyBack = vi.fn(async () => {
|
||||
expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true);
|
||||
expect((await stat(home)).isDirectory()).toBe(true);
|
||||
});
|
||||
const cleanup = vi.fn(async () => {
|
||||
expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
const probe = probeNativeRunnerEnvironment({ runtimeDirectory: root, provider: "codex", model: "fixture-model", admissionOnly: true,
|
||||
environment: {}, timeoutMs: 1000, ...(mode !== "eventual-exit" ? { cleanupTimeoutMs: 100 } : {}), onCleanupConfirmed: cleanup, onCodexCredentialRefresh: copyBack,
|
||||
transportOptions: { sourceCodexHome: "", runnerBinary: resolve("runner/target/debug/paperclip-runnerd"), codexCommand: resolve("runner/target/debug/fake-codex-app-server"),
|
||||
runnerProcessLauncher: () => {
|
||||
child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore", env: { PATH: "/usr/bin:/bin" } });
|
||||
exited = new Promise<void>(resolve => child!.once("exit", () => resolve()));
|
||||
started();
|
||||
return {
|
||||
// A controlled process owner reports an unresolved signal dispatch.
|
||||
// The real child remains alive until this test's original owner stops it.
|
||||
child: { pid: child.pid, exitCode: null, signalCode: null, kill: () => true },
|
||||
processGroupId: null,
|
||||
isExitConfirmed: () => child!.exitCode !== null || child!.signalCode !== null,
|
||||
completion: exited.then(() => ({ code: child!.exitCode, signal: child!.signalCode, stdout: "", stderr: "" })),
|
||||
};
|
||||
},
|
||||
},
|
||||
}).catch(error => error);
|
||||
try {
|
||||
await launched;
|
||||
const failure = await probe;
|
||||
expect(failure).toBeInstanceOf(NativeEnvironmentProbeCleanupError);
|
||||
expect(failure.operationError).toBeInstanceOf(Error);
|
||||
expect(failure.cleanupErrors.length).toBeGreaterThan(0);
|
||||
let retired = false, retirementError: unknown;
|
||||
void failure.retired.then(() => { retired = true; }, (error: unknown) => { retirementError = error; });
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
if (mode !== "eventual-exit") {
|
||||
await vi.waitFor(() => expect(retirementError).toBeInstanceOf(Error), { timeout: 500 });
|
||||
expect(failure.retryCleanup).toBeTypeOf("function");
|
||||
}
|
||||
expect(retired).toBe(false);
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
expect(copyBack).not.toHaveBeenCalled();
|
||||
expect((await stat(home)).isDirectory()).toBe(true);
|
||||
expect(child!.exitCode).toBeNull();
|
||||
child!.kill("SIGKILL");
|
||||
await exited;
|
||||
if (mode === "windows-unconfirmed") {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||
Object.defineProperty(process, "platform", { ...platform, value: "win32" });
|
||||
try {
|
||||
await expect(failure.retryCleanup()).rejects.toThrow("platform process retirement remains unconfirmed");
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
expect(copyBack).not.toHaveBeenCalled();
|
||||
expect((await stat(home)).isDirectory()).toBe(true);
|
||||
} finally { Object.defineProperty(process, "platform", platform); }
|
||||
}
|
||||
if (mode !== "eventual-exit") await failure.retryCleanup();
|
||||
else { await failure.retired; expect(retired).toBe(true); }
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(copyBack).toHaveBeenCalledTimes(1);
|
||||
await expect(stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
child?.kill("SIGKILL");
|
||||
await exited;
|
||||
await probe;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
@@ -77,6 +77,7 @@ import { RUNNERD_CANONICAL_ITEM } from "../drivers/codex/codex-driver-values.js"
|
||||
|
||||
import {
|
||||
probeNativeRunnerEnvironment,
|
||||
NativeEnvironmentProbeCleanupError,
|
||||
parseAcpxTurnControlCapabilities,
|
||||
authorizedToolSetForProvider,
|
||||
createCapabilityRunnerdCodexTransport,
|
||||
@@ -123,6 +124,7 @@ it("attributes opened candidate sessions to the harness biller instead of the mo
|
||||
expect(provider("acpx", "pi", "openrouter/deepseek/deepseek-v4-flash-0731")).toBe("openrouter");
|
||||
expect(provider("acpx", "claude", "claude-sonnet-5")).toBe("anthropic");
|
||||
expect(provider("acpx", "codex", "gpt-5.6-sol")).toBe("openai");
|
||||
expect(provider("acpx", "antigravity", "gemini-3.8-flash-low")).toBe("google");
|
||||
expect(provider("opencode", "codex", "openrouter/openai/gpt-6-astra")).toBe("openrouter");
|
||||
expect(provider("claude_managed", "codex", "claude-sonnet-5")).toBe("anthropic");
|
||||
expect(provider("aws_agentcore", "codex", "global.anthropic.claude-sonnet-4-6")).toBe("aws");
|
||||
@@ -1996,14 +1998,16 @@ it.each([false, true])("binds a vendored source server to its pnpm dependency ro
|
||||
expect(runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
|
||||
join(server, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"),
|
||||
)).toEqual({ root: selfRooted ? server : workspace, manifest: join(server, "package.json") });
|
||||
expect(runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
|
||||
join(server, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"), workspace, "pi",
|
||||
)).toEqual({ root: server, manifest: join(server, "package.json") });
|
||||
for (const agent of ["pi", "antigravity"] as const) {
|
||||
expect(runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
|
||||
join(server, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"), workspace, agent,
|
||||
)).toEqual({ root: server, manifest: join(server, "package.json") });
|
||||
}
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it.each((["copilot", "pi"] as const).flatMap(agent =>
|
||||
it.each((["copilot", "pi", "antigravity"] as const).flatMap(agent =>
|
||||
["source", "scoped-npm", "deployment"].map(layout => ({ agent, layout }))))(
|
||||
"admits the controller-selected $agent package authority in a $layout layout",
|
||||
async ({ agent, layout }) => {
|
||||
@@ -2597,8 +2601,8 @@ it.each(["reuse", "large-instructions", "rotated-recovery", "removed-context", "
|
||||
const checkpoint = vi.fn();
|
||||
const releaseRoute = vi.fn();
|
||||
let currentRun = () => "";
|
||||
const create = (closeGraceMs = 5_000, runId = "run-prepared") => createCapabilityRunnerdCodexTransport({ provider: "codex", runnerArtifact: artifact,
|
||||
closeGraceMs,
|
||||
const create = (closeGraceMs?: number, runId = "run-prepared") => createCapabilityRunnerdCodexTransport({ provider: "codex", runnerArtifact: artifact,
|
||||
...(closeGraceMs === undefined ? {} : { closeGraceMs }),
|
||||
stateDirectory: root, runtimeContext: runId === "run-prepared" ? context
|
||||
: mode === "removed-context" ? null : mode === "omitted-context" ? undefined : context,
|
||||
...(mode === "rotated-recovery" || mode === "removed-context" || mode === "omitted-context" ? {
|
||||
@@ -3489,9 +3493,10 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
|
||||
let finishRelease!: () => void;
|
||||
const releasing = new Promise<void>(resolve => { finishRelease = resolve; });
|
||||
const releaseError = new Error("setup registration release failed");
|
||||
let releaseFails = mode === "late-release-error";
|
||||
const release = vi.fn(async () => {
|
||||
if (mode === "stuck-release") await releasing;
|
||||
if (mode === "late-release-error") throw releaseError;
|
||||
if (releaseFails) throw releaseError;
|
||||
});
|
||||
const registered = {
|
||||
connection: { mode: "listen" as const, listenAddress: "127.0.0.1", listenPort: 43127, listenPath: "/setup-probe" },
|
||||
@@ -3554,9 +3559,24 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper
|
||||
finishRelease();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
expect(failure).toBeInstanceOf(NativeEnvironmentProbeCleanupError);
|
||||
if (mode === "late-release-error") {
|
||||
await expect((failure as NativeEnvironmentProbeCleanupError).retired).rejects.toBe(releaseError);
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
expect((await stat(runtimeDirectory)).isDirectory()).toBe(true);
|
||||
releaseFails = false;
|
||||
await (failure as NativeEnvironmentProbeCleanupError).retryCleanup!();
|
||||
expect(release).toHaveBeenCalledTimes(2);
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
await expect(stat(runtimeDirectory)).rejects.toThrow();
|
||||
} else {
|
||||
await (failure as NativeEnvironmentProbeCleanupError).retired;
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
await expect(stat(runtimeDirectory)).rejects.toThrow();
|
||||
}
|
||||
} else {
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
if (failure instanceof NativeEnvironmentProbeCleanupError) await failure.retired;
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
await expect(stat(runtimeDirectory)).rejects.toThrow();
|
||||
}
|
||||
@@ -3725,7 +3745,7 @@ it("does not certify probe cleanup while owned credential preparation is unconfi
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refresh-error", "model-cleanup-error", "missing-model", "missing-runtime", "missing-completion", "late-bootstrap", "remote-complete", "remote-mismatched-model", "late-remote-preparation", "late-remote-release-error"] as const)("native Codex setup requires its daemon/app-server hello (%s)", async mode => {
|
||||
it.each(["admission-only", "admission-only-mismatched-model", "complete", "api-key", "default-model", "mismatched-model", "model-refresh-error", "model-cleanup-error", "missing-model", "missing-runtime", "missing-completion", "late-bootstrap", "remote-complete", "remote-mismatched-model", "late-remote-preparation", "late-remote-release-error"] as const)("native Codex setup requires its daemon/app-server hello (%s)", async mode => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-native-setup-"));
|
||||
const runtimeDirectory = join(root, "native-state");
|
||||
await mkdir(runtimeDirectory, { mode: 0o700 });
|
||||
@@ -3744,6 +3764,7 @@ it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refr
|
||||
try {
|
||||
const probe = probeNativeRunnerEnvironment({
|
||||
runtimeDirectory, provider: "codex", model: mode === "default-model" ? null : "gpt-6.1-sol", reasoningEffort: "low",
|
||||
...(mode.startsWith("admission-only") ? { admissionOnly: true } : {}),
|
||||
environment: { PATH: process.env.PATH, OPENAI_API_KEY: mode === "api-key" ? "selected-account" : "", HOME: "/unrelated/home" },
|
||||
timeoutMs: mode.startsWith("late-remote-") ? 800 : mode === "late-bootstrap" ? 100 : mode === "missing-completion" ? 1_000 : 10_000,
|
||||
...(remote ? { workingDirectory: remoteWorkspace } : {}),
|
||||
@@ -3764,7 +3785,7 @@ it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refr
|
||||
runnerBinary: mode === "missing-runtime" ? join(root, "missing-runner") : resolve("runner/target/debug/paperclip-runnerd"),
|
||||
codexCommand: fakeCodex, codexArgs: fakeCodexArgs(root, "--request-log", calls,
|
||||
...(mode === "missing-completion" ? ["--hold-turn"] : []),
|
||||
...(mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" ? ["--returned-model", "another-model"] : []),
|
||||
...(mode === "admission-only-mismatched-model" || mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" ? ["--returned-model", "another-model"] : []),
|
||||
...(mode === "missing-model" ? ["--returned-model", ""] : []),
|
||||
...(mode === "late-bootstrap" ? ["--thread-start-delay-ms", "1000"] : [])),
|
||||
sourceCodexHome: sourceHome,
|
||||
@@ -3787,18 +3808,19 @@ it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refr
|
||||
await rm(runtimeDirectory, { recursive: true, force: true });
|
||||
},
|
||||
});
|
||||
if (mode === "complete" || mode === "api-key" || mode === "default-model" || mode === "remote-complete") {
|
||||
if (mode === "admission-only" || mode === "complete" || mode === "api-key" || mode === "default-model" || mode === "remote-complete") {
|
||||
const observedModel = mode === "default-model" ? "gpt-test" : "gpt-6.1-sol";
|
||||
await expect(probe).resolves.toEqual({ provider: "codex", providerDriver: "codex_app_server", effectiveModel: observedModel, helloProbePassed: true });
|
||||
await expect(probe).resolves.toEqual({ provider: "codex", providerDriver: "codex_app_server", effectiveModel: observedModel, ...(mode === "admission-only" ? { helloProbePassed: false, admissionOnly: true, sessionAdmissionPassed: true } : { helloProbePassed: true }) });
|
||||
expect(refreshed).toBe(true);
|
||||
if (remote) expect(registrationReleased).toBe(true);
|
||||
expect(cleanupConfirmed).toBe(true);
|
||||
await expect(stat(runtimeDirectory)).rejects.toThrow();
|
||||
const requests = (await readFile(calls, "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
||||
expect(requests.find(value => value.method === "thread/start").params).toMatchObject({ ...(remote ? { cwd: remoteWorkspace } : {}), permissions: "paperclip-runner-workspace-read-only", dynamicTools: [] });
|
||||
expect(requests.find(value => value.method === "turn/start").params).toMatchObject({ effort: "low", permissions: "paperclip-runner-workspace-read-only" });
|
||||
if (mode === "admission-only") expect(requests.some(value => value.method === "turn/start")).toBe(false);
|
||||
else expect(requests.find(value => value.method === "turn/start").params).toMatchObject({ effort: "low", permissions: "paperclip-runner-workspace-read-only" });
|
||||
} else {
|
||||
await expect(probe).rejects.toThrow(mode === "missing-runtime" ? /ENOENT|unavailable|cleanup/i : mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" ? /observed model|mismatched provider model/i : /timed out|cleanup|suspension/i);
|
||||
await expect(probe).rejects.toThrow(mode === "missing-runtime" ? /ENOENT|unavailable|cleanup/i : mode === "admission-only-mismatched-model" || mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" ? /observed model|mismatched provider model/i : /timed out|cleanup|suspension/i);
|
||||
if (mode === "late-remote-release-error") {
|
||||
expect(registrationReleased).toBe(true); expect(cleanupConfirmed).toBe(false);
|
||||
expect((await stat(runtimeDirectory)).isDirectory()).toBe(true);
|
||||
@@ -3812,11 +3834,11 @@ it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refr
|
||||
expect(cleanupConfirmed).toBe(true);
|
||||
expect((await stat(runtimeDirectory)).isDirectory()).toBe(true);
|
||||
}
|
||||
if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "missing-model") {
|
||||
if (mode === "admission-only-mismatched-model" || mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "missing-model") {
|
||||
expect(cleanupConfirmed).toBe(true);
|
||||
await expect(stat(runtimeDirectory)).rejects.toThrow();
|
||||
}
|
||||
if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" || mode === "late-bootstrap" || mode.startsWith("late-remote-")) {
|
||||
if (mode === "admission-only-mismatched-model" || mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" || mode === "late-bootstrap" || mode.startsWith("late-remote-")) {
|
||||
const requests = (await readFile(calls, "utf8").catch(() => "")).trim().split("\n").filter(Boolean).map(line => JSON.parse(line));
|
||||
expect(requests.some(value => value.method === "turn/start")).toBe(false);
|
||||
}
|
||||
@@ -9418,6 +9440,25 @@ describe("cold process admission budget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the authenticated command failure without implicitly logging its structured result", async () => {
|
||||
const result = { commandId: "command-2", commandType: "session.open", status: "failed",
|
||||
result: { code: "command_execution_failed", message: "closed runtime failure" } };
|
||||
let failure: unknown;
|
||||
try {
|
||||
await waitForRunnerCommand({ type: "session.open", deadline: Date.now() + 1000, abortOnClose: true,
|
||||
isClosed: () => false, throwIfFailed: () => {}, command: () => ({ status: "failed", result }),
|
||||
runnerHasExited: async () => false, failureCode: () => "startup_failed" });
|
||||
} catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure).toHaveProperty("message", `PRP command session.open failed: ${JSON.stringify(result)}`);
|
||||
const structured = (failure as Error & { runnerCommandResult: unknown }).runnerCommandResult;
|
||||
expect(structured).toEqual(result);
|
||||
expect(structured).not.toBe(result);
|
||||
expect(JSON.stringify(failure)).not.toContain("command_execution_failed");
|
||||
result.result.message = "mutated after receipt";
|
||||
expect(structured).toHaveProperty("result.message", "closed runtime failure");
|
||||
});
|
||||
|
||||
|
||||
it("shares Pi's absolute cold deadline across recovery barriers and rejects a late ACK", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmAntigravityProbeNativeRetirement } from "../drivers/acpx/antigravity-probe-retirement.js";
|
||||
import { resolveGrokDisableWebSearch, admittedGrokWebSearchPolicy } from "../drivers/acpx/grok-web-search.js";
|
||||
import { PRP_COMPLETION_TOOL_NAME, PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA } from "../contracts/completion-result.js";
|
||||
import { RunnerLaunchPreparationStore } from "./runner-launch-preparation-store.js";
|
||||
@@ -7,6 +8,7 @@ export { createRunnerdCodexAppServerArgs, resolveRunnerdAcpxPermissionMode, trus
|
||||
import { applyProviderObservabilityEnvironment, redactObservabilityDiagnostic } from "../drivers/agent-observability.js";
|
||||
import type { RunnerExecutionDriver } from "../control-plane/runner-execution-driver.js";
|
||||
import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js";
|
||||
import { bindAcpxCandidateAdmission, parseAcpxCandidateAdmission, type AcpxCandidateAdmission, type AcpxCandidateStartupAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolveClaudeEffort, admittedClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { resolveGrokEffort, admittedGrokEffort } from "../drivers/acpx/grok-effort.js";
|
||||
@@ -1243,6 +1245,8 @@ export interface CapabilityRunnerdCodexTransportOptions extends RunnerExecutionD
|
||||
acpxAgent?: QualifiedAcpxAgent;
|
||||
/** Explicit evaluation-only candidate selection, never derived from persisted session input. */
|
||||
acpxCandidateProfile?: "pi" | "cursor" | "copilot";
|
||||
/** Server/operator authority; never derived from persisted agent or provider input. */
|
||||
acpxCandidateAdmission?: AcpxCandidateAdmission;
|
||||
acpxPermissionMode?: NativeAcpxPermissionMode;
|
||||
acpxMode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
@@ -1390,6 +1394,8 @@ export interface CapabilityRunnerdCodexTransport {
|
||||
evidence(): Readonly<CapabilityRunnerdProcessEvidence>;
|
||||
/** Relinquish controller authority without stopping the durable runner. */
|
||||
detachControllerForRestart(): Promise<void>;
|
||||
/** Setup-only retained cleanup; joins original work and proves physical retirement. */
|
||||
retireProbeResources?(timeoutMs?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export type RunnerdCodexTransport = CapabilityRunnerdCodexTransport;
|
||||
@@ -3061,7 +3067,7 @@ function acpxProviderPackageAuthority(
|
||||
const workspaceRoot = dirname(serverRoot);
|
||||
// Source/Docker builds share the workspace's pnpm store. Published npm
|
||||
// bundles and self-rooted pnpm deployments retain their server authority.
|
||||
if (agent !== "pi" && basename(serverRoot) === "server" &&
|
||||
if (agent !== "pi" && agent !== "antigravity" && basename(serverRoot) === "server" &&
|
||||
!existsSync(resolve(serverRoot, "node_modules/.pnpm")) &&
|
||||
existsSync(resolve(workspaceRoot, "node_modules/.pnpm")) &&
|
||||
existsSync(resolve(workspaceRoot, "pnpm-workspace.yaml")) &&
|
||||
@@ -3084,10 +3090,10 @@ function acpxProviderPackageAuthority(
|
||||
);
|
||||
}
|
||||
const sidecarPackageRoot = resolve(cliDirectory, "../..");
|
||||
// Native Pi and Copilot closures belong to the runner package itself. Their asset
|
||||
// Native provider closures belong to the runner package itself. Their asset
|
||||
// guard requires the bound manifest directly inside that same package root;
|
||||
// the workspace dependency root used by JS providers is a different owner.
|
||||
if (agent === "copilot" || agent === "pi") {
|
||||
if (agent === "copilot" || agent === "pi" || agent === "antigravity") {
|
||||
return {
|
||||
root: sidecarPackageRoot,
|
||||
manifest: resolve(sidecarPackageRoot, "package.json"),
|
||||
@@ -3123,7 +3129,9 @@ function acpxRunnerLaunchProfile(
|
||||
options: CapabilityRunnerdCodexTransportOptions,
|
||||
command: string,
|
||||
sidecarScript: string,
|
||||
identity?: DurableRecoveryIdentity,
|
||||
): {
|
||||
candidateAdmission?: AcpxCandidateStartupAdmission;
|
||||
authorityDigest: string;
|
||||
command: string;
|
||||
commandSha256: string;
|
||||
@@ -3132,6 +3140,11 @@ function acpxRunnerLaunchProfile(
|
||||
} {
|
||||
const localDigest = (path: string) =>
|
||||
`sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
|
||||
let candidateAdmission: AcpxCandidateStartupAdmission | undefined;
|
||||
if (options.acpxCandidateAdmission) {
|
||||
if (!identity || options.runnerFilesystemRoot) throw new Error("ACPX candidate requires an actual local startup identity");
|
||||
candidateAdmission = bindAcpxCandidateAdmission(options.acpxCandidateAdmission, identity);
|
||||
}
|
||||
if (!options.runnerFilesystemRoot) {
|
||||
const buildCommand = process.execPath;
|
||||
const buildSidecarCandidates = buildOwnedCliArtifactCandidates(
|
||||
@@ -3162,6 +3175,7 @@ function acpxRunnerLaunchProfile(
|
||||
const commandSha256 = localDigest(buildCommand);
|
||||
const sidecarScriptSha256 = localDigest(buildSidecar);
|
||||
return {
|
||||
...(candidateAdmission ? { candidateAdmission } : {}),
|
||||
authorityDigest: commandDigest({
|
||||
schema: "paperclip.runner.local-acpx-authority.v1",
|
||||
commandSha256,
|
||||
@@ -3364,6 +3378,8 @@ function createCapabilityRunnerdProviderEnvironmentBase(input: {
|
||||
...(credentialBinding === undefined ? {} : { [ACPX_CREDENTIAL_BINDING_ENV]: credentialBinding }),
|
||||
...(input.options.acpxAgent === "grok" && input.options.environment?.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET
|
||||
? { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: input.options.environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET } : {}),
|
||||
...(input.options.acpxAgent === "antigravity" && input.options.environment?.PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET
|
||||
? { PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET: input.options.environment.PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET } : {}),
|
||||
...commonIdentity,
|
||||
// The verified sidecar bundle cannot use import.meta.url while Node
|
||||
// executes it through /proc/self/fd. Anchor its closed provider package
|
||||
@@ -3372,7 +3388,7 @@ function createCapabilityRunnerdProviderEnvironmentBase(input: {
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: providerPackageAuthority.root,
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
|
||||
providerPackageAuthority.manifest,
|
||||
...(input.options.providerRecoveryPolicy ===
|
||||
...(!input.options.acpxCandidateAdmission && input.options.providerRecoveryPolicy ===
|
||||
"allow_replacement_after_governed_wait"
|
||||
? {
|
||||
PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY:
|
||||
@@ -3600,6 +3616,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
readonly #closedSignal: Promise<void>;
|
||||
#resolveClosedSignal!: () => void;
|
||||
#closePromise: Promise<void> | null = null;
|
||||
#probeRetirement: Promise<void> | null = null;
|
||||
readonly prepareConversationResume?: CodexAppServerTransport["prepareConversationResume"];
|
||||
#detachPromise: Promise<void> | null = null;
|
||||
#startupOperation: Promise<unknown> | null = null;
|
||||
#attachmentOperation: Promise<void> | null = null;
|
||||
@@ -3661,9 +3679,33 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
if (options.adoptExistingRunner && !options.stateDirectory?.trim()) {
|
||||
throw new Error("native_adopted_runner_state_directory_required");
|
||||
}
|
||||
if (options.acpxCandidateAdmission !== undefined) {
|
||||
const candidate = options.acpxCandidateAdmission;
|
||||
const admitted = parseAcpxCandidateAdmission(candidate, { companyId: candidate.companyId, agent: options.acpxAgent ?? "", model: candidate.model });
|
||||
this.options = { ...this.options, acpxCandidateAdmission: Object.freeze(admitted), providerRecoveryPolicy: "same_session_only" };
|
||||
this.prepareConversationResume = params => this.#prepareCandidateConversationResume(params);
|
||||
if (options.provider !== "acpx" || process.platform !== "darwin" || process.arch !== "arm64"
|
||||
|| options.runnerFilesystemRoot || options.prepareLaunch || options.runnerProcessLauncher
|
||||
|| options.homeMode === "preconfigured" || options.lifecyclePolicy?.mode === "warm"
|
||||
|| options.adoptExistingRunner || options.resumeActiveTurnId
|
||||
) {
|
||||
throw new Error("ACPX candidate admission supports only a fresh local isolated per-turn execution");
|
||||
}
|
||||
}
|
||||
if (options.acpxCandidateAdmission && options.resumeProviderSession) {
|
||||
const prior = options.resumeProviderSession;
|
||||
const identity = record(prior.providerIdentity);
|
||||
if (!prior.driverSessionId?.trim() || !prior.providerSessionId?.trim()
|
||||
|| identity.kind !== "acpx" || identity.normalizedSessionId !== options.prpIdentity?.normalizedSessionId
|
||||
|| identity.requestedModel !== options.acpxCandidateAdmission.model || identity.effectiveModel !== options.acpxCandidateAdmission.model
|
||||
|| typeof identity.profileDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(identity.profileDigest)) {
|
||||
throw new Error("ACPX candidate continuation requires the exact retained native conversation identity");
|
||||
}
|
||||
}
|
||||
if (options.provider === "acpx" && options.acpxAgent !== undefined
|
||||
&& ACPX_CAPABILITY_PROFILES[options.acpxAgent].qualification === "pending"
|
||||
&& options.acpxCandidateProfile !== options.acpxAgent) {
|
||||
&& options.acpxCandidateProfile !== options.acpxAgent
|
||||
&& !(options.acpxAgent === "antigravity" && options.acpxCandidateAdmission)) {
|
||||
throw new Error("The candidate ACPX profile requires explicit evaluation opt-in");
|
||||
}
|
||||
this.#failureSignal = new Promise<never>((_resolve, reject) => {
|
||||
@@ -4522,6 +4564,42 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
retireProbeResources(timeoutMs?: number): Promise<void> {
|
||||
if (this.#probeRetirement) return this.#probeRetirement;
|
||||
const operation = (async () => {
|
||||
const deadline = Date.now() + probeCleanupBudget(timeoutMs);
|
||||
// Cancellation/timeouts revoke admission but cannot cancel an in-flight
|
||||
// filesystem request, registration, or child spawn. Join those originals.
|
||||
await awaitProbeCleanup(Promise.allSettled([this.#startupOperation, this.#attachmentOperation]), deadline, "joining the original launch");
|
||||
if (this.#closePromise) await awaitProbeCleanup(this.#closePromise.catch(() => undefined), deadline, "joining the original close");
|
||||
const handle = this.#handle;
|
||||
if (handle) {
|
||||
// Close already exercised the original owner's signal authority. Proof
|
||||
// must never dispatch another signal using a possibly reused PID/group.
|
||||
try { await awaitProbeCleanup(handle.completion, deadline, "waiting for the original child exit"); }
|
||||
catch (error) { if (handle.child.pid !== undefined) throw error; }
|
||||
await awaitProbeProof(() => handle.isExitConfirmed?.() !== false, deadline, "confirming the original owner exit");
|
||||
}
|
||||
// A remote owner must explicitly prove its own process retirement. An
|
||||
// absent local handle or a dispatched remote signal is not that proof.
|
||||
if (this.options.prepareLaunch || this.options.runnerFilesystemRoot || this.options.runnerProcessLauncher || this.options.adoptExistingRunner) {
|
||||
if (handle?.isExitConfirmed?.() !== true && this.#evidence.runnerPid !== null) throw new Error("Native probe remote retirement remains unconfirmed");
|
||||
}
|
||||
this.#pumpEventsSafely();
|
||||
const pids = [...new Set([this.#evidence.runnerPid, this.#evidence.sidecarPid, this.#evidence.agentPid, this.#evidence.providerPid])].filter((pid): pid is number => pid !== null);
|
||||
if (pids.length && process.platform === "win32") throw new Error("Native probe platform process retirement remains unconfirmed");
|
||||
await awaitProbeProof(() => pids.every(maintenanceProcessAbsent), deadline, "confirming recorded process absence");
|
||||
if (this.options.provider === "acpx" && this.options.acpxAgent === "antigravity") {
|
||||
const sessionId = this.#eventIdentity?.normalizedSessionId ?? this.options.prpIdentity?.normalizedSessionId;
|
||||
if (sessionId) await awaitProbeCleanup(confirmAntigravityProbeNativeRetirement(this.options.acpxRuntimeDirectory ?? resolve(this.#root, "acpx"), sessionId), deadline, "confirming the original Google owner retirement");
|
||||
else if (handle?.child.pid !== undefined) throw new Error("Native probe credential lifetime is unidentified");
|
||||
}
|
||||
})();
|
||||
this.#probeRetirement = operation;
|
||||
void operation.catch(() => { if (this.#probeRetirement === operation) this.#probeRetirement = null; });
|
||||
return operation;
|
||||
}
|
||||
|
||||
detachControllerForRestart(): Promise<void> {
|
||||
this.#detachPromise ??= this.#detachControllerOnce();
|
||||
return this.#detachPromise;
|
||||
@@ -4898,6 +4976,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
params: Record<string, unknown>, identity: DurableRecoveryIdentity,
|
||||
dynamicTools: Record<string, unknown>[],
|
||||
) {
|
||||
if (this.options.acpxCandidateAdmission) {
|
||||
const candidate = this.options.acpxCandidateAdmission;
|
||||
parseAcpxCandidateAdmission(candidate, { companyId: candidate.companyId, agent: this.options.acpxAgent ?? "", model: String(params.model ?? "") });
|
||||
}
|
||||
if (this.options.prepareLaunch) {
|
||||
const provider = this.options.provider ?? "codex";
|
||||
this.#authorizedTools = authorizedToolSetForProvider(provider, dynamicTools);
|
||||
@@ -4991,6 +5073,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
this.options,
|
||||
providerNodeCommand,
|
||||
acpxSidecarPath,
|
||||
identity,
|
||||
)
|
||||
: undefined;
|
||||
const runnerOpenCodeLaunchProfile =
|
||||
@@ -5184,6 +5267,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
maxOutboxBytes: RUNNERD_MAX_OUTBOX_BYTES,
|
||||
p0ReserveBytes: RUNNERD_P0_RESERVE_BYTES,
|
||||
maxRuntimeMs: 0,
|
||||
...(this.options.acpxCandidateAdmission ? { maxLifetimeMs: Math.max(1, this.options.acpxCandidateAdmission.expiresAt - Date.now()) } : {}),
|
||||
reconnectGraceMs: runnerProcessReconnectGraceMs(this.options),
|
||||
lifecyclePolicy: this.options.lifecyclePolicy,
|
||||
runnerBinaryPath,
|
||||
@@ -5240,7 +5324,27 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
};
|
||||
}
|
||||
|
||||
async #prepareCandidateConversationResume(params: Record<string, unknown>): Promise<void> {
|
||||
const candidate = this.options.acpxCandidateAdmission;
|
||||
const prior = this.options.resumeProviderSession;
|
||||
if (!candidate || !prior || this.#core || this.#startupOperation
|
||||
|| params.threadId !== prior.driverSessionId || params.model !== candidate.model
|
||||
|| typeof params.cwd !== "string" || params.cwd !== this.options.resumeWorkingDirectory
|
||||
|| existsSync(resolve(this.#root, "control-plane", "control-plane-state.json"))
|
||||
|| existsSync(resolve(this.#root, "runner", "runner-state.json"))) {
|
||||
throw new Error("ACPX candidate continuation requires a fresh daemon and the exact current conversation selection");
|
||||
}
|
||||
parseAcpxCandidateAdmission(candidate, { companyId: candidate.companyId, agent: "antigravity", model: String(params.model) });
|
||||
this.#checkpointProviderIdentityExpectation = {
|
||||
driverSessionId: prior.driverSessionId, providerSessionId: prior.providerSessionId!,
|
||||
providerIdentity: structuredClone(record(prior.providerIdentity)),
|
||||
};
|
||||
await this.#trackStartup(() => this.#start(params));
|
||||
if (!this.#checkpointProviderIdentityConfirmed) throw new Error("ACPX candidate continuation has no verified native conversation receipt");
|
||||
}
|
||||
|
||||
async #resume(): Promise<void> {
|
||||
if (this.options.acpxCandidateAdmission) throw new Error("ACPX candidate durable runner recovery is not admitted");
|
||||
const desiredIdentity = this.options.prpIdentity;
|
||||
if (desiredIdentity === undefined) {
|
||||
throw new Error("PRP provider resume state is unavailable");
|
||||
@@ -5531,6 +5635,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
this.options,
|
||||
providerNodeCommand,
|
||||
acpxSidecarPath,
|
||||
identity,
|
||||
)
|
||||
: undefined;
|
||||
const runnerOpenCodeLaunchProfile =
|
||||
@@ -6875,7 +6980,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
descriptor.agent === "claude" ||
|
||||
descriptor.agent === "codex" ||
|
||||
descriptor.agent === "cursor" ||
|
||||
descriptor.agent === "copilot"
|
||||
descriptor.agent === "copilot" ||
|
||||
descriptor.agent === "antigravity"
|
||||
)
|
||||
this.#evidence.acpxAgent = descriptor.agent;
|
||||
if (typeof descriptor.agentServerVersion === "string")
|
||||
@@ -7344,19 +7450,76 @@ export function createCapabilityRunnerdCodexTransport(
|
||||
transport,
|
||||
evidence: () => transport.evidence(),
|
||||
detachControllerForRestart: () => transport.detachControllerForRestart(),
|
||||
retireProbeResources: timeoutMs => transport.retireProbeResources(timeoutMs),
|
||||
};
|
||||
}
|
||||
|
||||
export const createRunnerdCodexTransport =
|
||||
createCapabilityRunnerdCodexTransport;
|
||||
|
||||
const retainedNativeProbeCleanups = new Set<{ run: () => Promise<void> }>();
|
||||
|
||||
function probeCleanupBudget(timeoutMs = 10_000): number {
|
||||
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, 10_000) : 10_000;
|
||||
}
|
||||
async function awaitProbeCleanup<T>(pending: Promise<T>, deadline: number, detail: string): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([pending, new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`Native probe retirement timed out while ${detail}; recovery state retained.`)), Math.max(0, deadline - Date.now()));
|
||||
})]);
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
async function awaitProbeProof(proven: () => boolean, deadline: number, detail: string): Promise<void> {
|
||||
while (!proven()) {
|
||||
if (Date.now() >= deadline) throw new Error(`Native probe retirement remains unconfirmed while ${detail}; recovery state retained.`);
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(1, deadline - Date.now()))));
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeEnvironmentProbeCleanupError extends AggregateError {
|
||||
readonly code = "NATIVE_ENVIRONMENT_PROBE_CLEANUP_PENDING";
|
||||
constructor(
|
||||
readonly retired: Promise<void>,
|
||||
readonly operationError: unknown,
|
||||
readonly cleanupErrors: readonly unknown[],
|
||||
readonly retryCleanup?: () => Promise<void>,
|
||||
) {
|
||||
const errors = [...(operationError === undefined ? [] : [operationError]), ...cleanupErrors];
|
||||
super(errors, errors.map(error => error instanceof Error ? error.message : "Native probe cleanup failed.").join("; "), { cause: operationError ?? cleanupErrors[0] });
|
||||
this.name = "NativeEnvironmentProbeCleanupError";
|
||||
}
|
||||
}
|
||||
|
||||
function retainNativeProbeCleanup(cleanup: (retry: boolean) => Promise<void>) {
|
||||
let operation: Promise<void> | undefined;
|
||||
let attempts = 0;
|
||||
const owner = { run: (): Promise<void> => {
|
||||
if (operation) return operation;
|
||||
const retry = attempts++ > 0;
|
||||
operation = Promise.resolve().then(() => cleanup(retry)).then(() => {
|
||||
retainedNativeProbeCleanups.delete(owner);
|
||||
}, error => { operation = undefined; throw error; });
|
||||
// The HTTP caller may retain only the error, without awaiting retirement.
|
||||
// Keep awaited failures observable while preventing detached rejections.
|
||||
void operation.catch(() => undefined);
|
||||
return operation;
|
||||
} };
|
||||
retainedNativeProbeCleanups.add(owner);
|
||||
return { retired: owner.run(), retryCleanup: owner.run };
|
||||
}
|
||||
|
||||
export async function probeNativeRunnerEnvironment(options: {
|
||||
runtimeDirectory: string;
|
||||
provider: "codex" | "opencode" | "acpx";
|
||||
model: string | null;
|
||||
reasoningEffort?: string;
|
||||
/** Verify native session identity/model and cleanup without a billable turn. */
|
||||
admissionOnly?: boolean;
|
||||
environment: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
/** A finite cleanup budget, capped at the ordinary ten-second boundary. */
|
||||
cleanupTimeoutMs?: number;
|
||||
workingDirectory?: string;
|
||||
transportOptions?: CapabilityRunnerdCodexTransportOptions;
|
||||
onCodexCredentialRefresh?: (filename: string) => Promise<void>;
|
||||
@@ -7365,7 +7528,9 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
provider: "codex" | "opencode" | "acpx";
|
||||
providerDriver: string;
|
||||
effectiveModel: string;
|
||||
helloProbePassed: true;
|
||||
helloProbePassed: boolean;
|
||||
admissionOnly?: true;
|
||||
sessionAdmissionPassed?: true;
|
||||
}> {
|
||||
const environment = { ...options.environment };
|
||||
const remoteFilesystemRoot = options.transportOptions?.runnerFilesystemRoot;
|
||||
@@ -7376,7 +7541,21 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
const controller = new AbortController();
|
||||
let registrationTeardownError: unknown;
|
||||
let registrationOperation: Promise<unknown> | undefined;
|
||||
let registrationReleaseOperation: Promise<void> | undefined;
|
||||
let registrationReleaseFailed = false;
|
||||
let releaseRegistration: (() => Promise<void>) | undefined;
|
||||
let cleanupCallbackOperation: Promise<void> | undefined;
|
||||
let cleanupCallbackFailed = false;
|
||||
let credentialRefreshOperation: Promise<void> | undefined;
|
||||
let credentialRefreshFailed = false;
|
||||
const preserveCredentialRefresh = (retry = false) => {
|
||||
if (retry && credentialRefreshFailed) { credentialRefreshOperation = undefined; credentialRefreshFailed = false; }
|
||||
return credentialRefreshOperation ??= Promise.resolve()
|
||||
.then(() => options.onCodexCredentialRefresh?.(resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home", "auth.json")))
|
||||
.catch(error => { credentialRefreshFailed = true; throw error; });
|
||||
};
|
||||
let registrationCleanupConfirmed = true;
|
||||
const cleanupBudget = probeCleanupBudget(options.cleanupTimeoutMs);
|
||||
const sourceCodexHome = ownsPreparation ? undefined : options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment);
|
||||
delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX;
|
||||
if (!ownsPreparation) {
|
||||
@@ -7392,13 +7571,14 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
controller.signal.throwIfAborted();
|
||||
registrationCleanupConfirmed = false;
|
||||
const registration = await options.transportOptions!.controlPlaneRegistration!(authority, identity, controller.signal);
|
||||
let releaseOperation: Promise<void> | undefined;
|
||||
const release = () => releaseOperation ??= Promise.resolve()
|
||||
const release = () => registrationReleaseOperation ??= Promise.resolve()
|
||||
.then(() => registration.release())
|
||||
.then(() => { registrationCleanupConfirmed = true; }, error => {
|
||||
registrationReleaseFailed = true;
|
||||
registrationTeardownError = error;
|
||||
throw error;
|
||||
});
|
||||
releaseRegistration = release;
|
||||
if (controller.signal.aborted) {
|
||||
await release();
|
||||
controller.signal.throwIfAborted();
|
||||
@@ -7419,7 +7599,7 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
externallySandboxed: options.transportOptions?.externallySandboxed === true,
|
||||
disableCodexProjectInstructions: true,
|
||||
disableCodexWebSearch: options.provider === "codex",
|
||||
closeGraceMs: 10_000,
|
||||
closeGraceMs: cleanupBudget,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
});
|
||||
const driver = new CodexAppServerDriver({
|
||||
@@ -7453,7 +7633,7 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
let effectiveModel: string | undefined;
|
||||
let failure: unknown;
|
||||
const operation = (async () => {
|
||||
session = await driver.openSession({ runId: "environment-probe", normalizedSessionId: "environment-probe",
|
||||
session = await driver.openSession({ runId: options.transportOptions?.prpIdentity?.runId ?? "environment-probe", normalizedSessionId: options.transportOptions?.prpIdentity?.normalizedSessionId ?? "environment-probe",
|
||||
workingDirectory, signal: controller.signal });
|
||||
controller.signal.throwIfAborted();
|
||||
const ids = session.ids();
|
||||
@@ -7472,6 +7652,7 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
if (!effectiveModel || (options.model !== null && effectiveModel !== options.model)) throw new Error("The selected native runtime returned a missing or mismatched provider model.");
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
if (options.admissionOnly) { completed = true; return; }
|
||||
const { turnId } = await session.startTurn({ message: { role: "user", text: "Respond only with hello. Do not use tools or inspect files." } });
|
||||
let responseObserved = false;
|
||||
while (true) {
|
||||
@@ -7503,7 +7684,7 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
clearTimeout(timer);
|
||||
controller.abort(failure ?? new Error("Native provider hello probe complete."));
|
||||
const teardownErrors: unknown[] = [];
|
||||
const teardownDeadline = Date.now() + 10_000;
|
||||
const teardownDeadline = Date.now() + cleanupBudget;
|
||||
const awaitTeardown = async (pending: Promise<unknown>, detail: string): Promise<void> => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
@@ -7515,8 +7696,9 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
]);
|
||||
} finally { clearTimeout(timeout); }
|
||||
};
|
||||
const closingOperation = session ? session.close({ reason: "environment probe complete" }) : bundle.transport.close("environment probe complete");
|
||||
try {
|
||||
await awaitTeardown(session ? session.close({ reason: "environment probe complete" }) : bundle.transport.close("environment probe complete"), "closing the selected runtime");
|
||||
await awaitTeardown(closingOperation, "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); }
|
||||
try { await awaitTeardown(operation.catch(() => undefined), "draining native setup"); }
|
||||
@@ -7528,21 +7710,41 @@ export async function probeNativeRunnerEnvironment(options: {
|
||||
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")); }
|
||||
try {
|
||||
await awaitTeardown(preserveCredentialRefresh(), "preserving credential refresh");
|
||||
}
|
||||
catch (error) { teardownErrors.push(error); }
|
||||
}
|
||||
const confirmCleanup = (retry = false) => {
|
||||
if (retry && cleanupCallbackFailed) { cleanupCallbackOperation = undefined; cleanupCallbackFailed = false; }
|
||||
return cleanupCallbackOperation ??= Promise.resolve().then(() => options.onCleanupConfirmed?.())
|
||||
.catch(error => { cleanupCallbackFailed = true; throw error; });
|
||||
};
|
||||
if (!teardownErrors.length && options.onCleanupConfirmed) {
|
||||
try { await options.onCleanupConfirmed(); }
|
||||
try { await awaitTeardown(confirmCleanup(), "confirming credential handoff"); }
|
||||
catch (error) { teardownErrors.push(error); }
|
||||
}
|
||||
if (teardownErrors.length) {
|
||||
const errors = [...(failure === undefined ? [] : [failure]), ...teardownErrors];
|
||||
throw new AggregateError(errors, errors.map(error => error instanceof Error ? error.message : "Native probe cleanup failed.").join("; "), { cause: failure ?? teardownErrors[0] });
|
||||
const retained = retainNativeProbeCleanup(async retry => {
|
||||
const deadline = Date.now() + cleanupBudget;
|
||||
await awaitProbeCleanup(Promise.allSettled([operation, closingOperation, registrationOperation]), deadline, "joining original probe operations");
|
||||
if (!bundle.retireProbeResources) throw new Error("Native probe physical retirement proof is unavailable");
|
||||
await awaitProbeCleanup(bundle.retireProbeResources(Math.max(1, deadline - Date.now())), deadline, "confirming physical retirement");
|
||||
if (retry && registrationReleaseFailed) { registrationReleaseOperation = undefined; registrationReleaseFailed = false; }
|
||||
if (!registrationCleanupConfirmed && releaseRegistration) await awaitProbeCleanup(releaseRegistration(), deadline, "releasing original probe authority");
|
||||
if (bundle.evidence().runnerPid !== null && options.provider === "codex" && options.onCodexCredentialRefresh) {
|
||||
await awaitProbeCleanup(preserveCredentialRefresh(retry), deadline, "preserving credential refresh");
|
||||
}
|
||||
if (!registrationCleanupConfirmed) throw new Error("Native probe authority release remains unconfirmed");
|
||||
await awaitProbeCleanup(confirmCleanup(retry), deadline, "confirming credential handoff");
|
||||
});
|
||||
throw new NativeEnvironmentProbeCleanupError(retained.retired, failure, teardownErrors, retained.retryCleanup);
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
if (!completed) throw new Error("The native provider hello probe did not complete.");
|
||||
return Object.freeze({ provider: options.provider, providerDriver: bundle.evidence().providerDriver!, effectiveModel: effectiveModel!, helloProbePassed: true });
|
||||
return Object.freeze({ provider: options.provider, providerDriver: bundle.evidence().providerDriver!, effectiveModel: effectiveModel!,
|
||||
...(options.admissionOnly ? { helloProbePassed: false, admissionOnly: true as const, sessionAdmissionPassed: true as const } : { helloProbePassed: true }) });
|
||||
}
|
||||
|
||||
/** The transport provider identifies the biller, independently of model family. */
|
||||
@@ -7560,6 +7762,7 @@ function openedThreadModelProvider(
|
||||
if (acpxAgent === "claude") return "anthropic";
|
||||
if (acpxAgent === "cursor") return "cursor";
|
||||
if (acpxAgent === "copilot") return "github";
|
||||
if (acpxAgent === "antigravity") return "google";
|
||||
}
|
||||
return "openai";
|
||||
}
|
||||
@@ -7631,6 +7834,11 @@ export function coldAdmissionTimeoutMs(provider: string | undefined, agent: stri
|
||||
return coldProcess && provider === "acpx" && agent === "pi" ? 60_000 : 30_000;
|
||||
}
|
||||
|
||||
export interface RunnerCommandFailure extends Error {
|
||||
/** Private diagnostic value copied from the authenticated command result. */
|
||||
readonly runnerCommandResult: unknown;
|
||||
}
|
||||
|
||||
/** Shared polling path keeps startup cancellation separate from owned cleanup commands. */
|
||||
export async function waitForRunnerCommand(input: {
|
||||
type: string;
|
||||
@@ -7650,7 +7858,9 @@ export async function waitForRunnerCommand(input: {
|
||||
const command = input.command();
|
||||
if (command?.status === "completed") return;
|
||||
if (command !== undefined && command.status !== "pending") {
|
||||
throw new Error(`PRP command ${input.type} ${command.status}: ${JSON.stringify(command.result)}`);
|
||||
const error = new Error(`PRP command ${input.type} ${command.status}: ${JSON.stringify(command.result)}`) as RunnerCommandFailure;
|
||||
Object.defineProperty(error, "runnerCommandResult", { value: structuredClone(command.result), enumerable: false });
|
||||
throw error;
|
||||
}
|
||||
if (await input.runnerHasExited()) {
|
||||
throw new Error(`runnerd exited while waiting for ${input.type}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Grok admission source identity: sha256:79188d089f3f6b575726af77fe055a58449144a373a0745dcad4be414cba8cd0
|
||||
// Grok admission source identity: sha256:9d778ec8c5987e304d3121787806f546c7c9f545cf5e0628ce0035c59a559907
|
||||
// This launcher is admitted by digest and receives a verified native executable.
|
||||
// Never resolve an executable through PATH or accept command-line overrides.
|
||||
const { isAbsolute, join } = require("node:path");
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"commandDigest": "sha256:9473de6ef4baf2bd82fe8b6757239bdc160960b0a78ae6f47fa9e0996544226c",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 35,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"nodeVersion": "24.21.0",
|
||||
"closure": {
|
||||
"darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf",
|
||||
"darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e",
|
||||
"linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410"
|
||||
},
|
||||
"wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36",
|
||||
"helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d",
|
||||
"extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"nativeQuestions": "select-confirm-input-editor-v4-root-field-types",
|
||||
"pathPolicy": "sdk-normalization-v1",
|
||||
"mcpToolNames": "provider-safe-exact-source-v1",
|
||||
"acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2",
|
||||
"nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts",
|
||||
"nativeNotices": "session-extension-v2-bounded-runtime-failure",
|
||||
"messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd",
|
||||
"noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"nativeInputDisposition": "queued-only-v1",
|
||||
"cacheWarming": "disabled-owned-decision-v1",
|
||||
"thinkingLevel": {
|
||||
"supported": [
|
||||
"off",
|
||||
"low",
|
||||
"high",
|
||||
"max"
|
||||
],
|
||||
"newConfigurationDefault": "low",
|
||||
"admission": "explicit-native-effective-before-prompt-v1",
|
||||
"recovery": "mode-bound-v1"
|
||||
},
|
||||
"modelSelection": "caller-selected-native-verified-v1",
|
||||
"providerConfigurationSourceSha256": "75a9a13f13f224dc092bd8ebf49e8747162172943761e44476c8eb18d80c52d4",
|
||||
"credentialEnvironmentSourceSha256": "2ed68b9aed9756fdd682721833eb2aeaa58f30f1dc8825c88886cc8e7ee92abe",
|
||||
"runtimeSandboxSourceSha256": "b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875",
|
||||
"recoveryIdentitySourceSha256": "f74d061cc07b0c7ca04df62ca991d940a733d9ef6594b3362451769fc10030b8",
|
||||
"nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1",
|
||||
"dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814",
|
||||
"openRouterAttributionSourceSha256": "1211191529d6073b733b311c3d69cada4973eede0f3e540c46e261f78e0b90f9",
|
||||
"preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e"
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,25 @@ test("the ACPX sidecar schema accepts each versioned message family", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("candidate startup and sidecar admission share one strict identity-bound JSON record", () => {
|
||||
const candidateAdmission = { schema: "paperclip.acpx-candidate-admission.v1", companyId: "company-1", agent: "antigravity", model: "gemini-3.8-flash-low",
|
||||
profileDigest: `sha256:${"a".repeat(64)}`, nativeClosureDigest: `sha256:${"b".repeat(64)}`, platform: "darwin", architecture: "arm64",
|
||||
expiresAt: 1800000060000, runId: "run-1", normalizedSessionId: "session-1" };
|
||||
const startup = new Ajv2020({ allErrors: true, strict: true }).compile({ ...schema, oneOf: undefined, $ref: "#/$defs/candidateAdmission" });
|
||||
assert.equal(startup(candidateAdmission), true, JSON.stringify(startup.errors));
|
||||
for (const command of ["initialize", "session.open"]) {
|
||||
const request = { protocolVersion, id: 1, command, params: { agent: "antigravity", model: candidateAdmission.model, candidateAdmission } };
|
||||
assert.equal(validate(request), true, JSON.stringify(validate.errors));
|
||||
for (const change of [{ unknown: true }, { runId: undefined }, { normalizedSessionId: "wrong session" }, { platform: "linux" }, { expiresAt: "1800000060000" }]) {
|
||||
const altered = JSON.parse(JSON.stringify({ ...request, params: { ...request.params, candidateAdmission: { ...candidateAdmission, ...change } } }));
|
||||
assert.equal(validate(altered), false);
|
||||
assert.equal(startup(altered.params.candidateAdmission), false);
|
||||
}
|
||||
assert.equal(validate({ ...request, params: { ...request.params, agent: "codex" } }), false);
|
||||
}
|
||||
assert.equal(validate({ protocolVersion, id: 1, command: "turn.start", params: { candidateAdmission } }), false);
|
||||
});
|
||||
|
||||
test("Pi session admission requires an explicit supported thinking level including recovery", () => {
|
||||
const open = (params) => ({ protocolVersion, id: 1, command: "session.open", params });
|
||||
for (const piThinkingLevel of ["off", "low", "high", "max"]) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"commandDigest": "sha256:99d3a7f3853bc3f518c1579cbe165f38fd4764c020a688814d3adfd7e3d7b1a8",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 82,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b",
|
||||
"permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
|
||||
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "abe3e6c718e8e0fed154526affc94e34b46d96f7bdaed5d88e2b00a21a5b84db",
|
||||
"semanticDirectDriverSourceSha256": "4ec65aa1d4e905a1e732f2d043bbd39de1ab92f380276ede37d6a72efc19c64d",
|
||||
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "f49398b682a5623812f5d3c33d6bb9a749008e47cc0ed969072041615f66df59",
|
||||
"semanticValidatorsSourceSha256": "b20ec8ee6e7165117233ce4421a975bb9d3f276220ce31655c0b9a717cb2d9cb",
|
||||
"semanticSchemaBundleSourceSha256": "81ef98ca7b34896a0f76390ac7af3dd8ef7609fe6f38d54bd6a69373d50fe40b",
|
||||
"semanticSidecarProtocolSourceSha256": "26b6b21dad582810d00f46e947e682a9615ee9761c487f6315970d6d014c05ab",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
|
||||
"displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877",
|
||||
"displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e",
|
||||
"configuredEnvironmentSourceSha256": "ecef0ada320a762e0a3d91c38c72d4e19e2c8e8be8c060938a47e7f1a029058a",
|
||||
"configuredEnvironmentRustSourceSha256": "1671cb2735b3fa119d32f08d4a12b21b9f78a0e4d7285eb63c35df79ce17bd3c",
|
||||
"profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282",
|
||||
"profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571",
|
||||
"profileInstallationSourceSha256": "58841b2d3b47ab9956db20608802edcd0e55cf5856002dc7c51e05e75bdcb8f6",
|
||||
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
|
||||
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
|
||||
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
|
||||
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
|
||||
"nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1",
|
||||
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
|
||||
"harnessSessionContractSourceSha256": "baba1a210b863518f01e0c875606ed37f9467c433222251438578bd999bc43b5",
|
||||
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
|
||||
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
|
||||
"transportContractSourceSha256": "06f06c53ecae304f72034ffb2f659ff087c82a70ed4994daf21a9d2edaf56e4e",
|
||||
"nativeBackendSourceSha256": "7495d9898d22dca1ebf6336361d332bafefc4498f6e49f1135025fad846c8586",
|
||||
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
|
||||
"nativeInstructionsSourceSha256": "82ac6e5f7275b4ef79fb166c7330efc504e80cab3d7e9081e4e40a8b570bc50b",
|
||||
"providerLaunchSourceSha256": "c744235b3067ea75973523f78f1e13399337bc155f1775af694e34782d5c9bef",
|
||||
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
|
||||
"runtimeRequestBridgeSourceSha256": "8442088daf04fc7f09bca2fbb32bf5e9b550be6a4606f8c2ff3a23a268fa0564",
|
||||
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
|
||||
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
|
||||
"guardedInstallationSourceSha256": "64b137e3ef695e7d03013ea2b6c8830171e0a73e0bf2e73d55e9ced57a47bcf6",
|
||||
"nativeExecutionSourceSha256": "116c348e232848a80c5556494c109314baafb20fb5c97133d9656c7732ee54be",
|
||||
"profileCompatibilitySourceSha256": "d3bd56d7c2d924c0ed623e4b92aea9b858020a3e4a6245e20c91476079e1bbf8",
|
||||
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
|
||||
"assetOwnershipSourceSha256": "e5bb6bc58b3f0e957a64b2cd99c710337ffcd9c0e1fadb341f48e033b64b99f5",
|
||||
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
|
||||
"credentialBindingSourceSha256": "2ed68b9aed9756fdd682721833eb2aeaa58f30f1dc8825c88886cc8e7ee92abe",
|
||||
"admissionDiagnosticsSourceSha256": "df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af",
|
||||
"grokAdmissionSourceSha256": "f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777",
|
||||
"sidecarErrorSourceSha256": "17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c",
|
||||
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
|
||||
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
|
||||
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticContinuationSourceSha256": "c9cc0fd516b3df92a6655f56ee9bef37615a889c371e2e137d8ac5c1176de811",
|
||||
"preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e",
|
||||
"runtimeHostSourceSha256": "26b2e115b3f4ae7c20e9d963d117e0286c958af81386deade83397f12abf9662",
|
||||
"runtimeAdapterSourceSha256": "fab1e278e0f2aed55b35e57d14329eb431b79a08040fa079aa2403842d16085e",
|
||||
"runtimeSandboxSourceSha256": "b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875",
|
||||
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
|
||||
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
|
||||
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"commandDigest": "sha256:56361a790c38c59633ad4a5adad2d3b4a9098974f15264e4b506d69807902bea",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 83,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b",
|
||||
"permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
|
||||
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "abe3e6c718e8e0fed154526affc94e34b46d96f7bdaed5d88e2b00a21a5b84db",
|
||||
"semanticDirectDriverSourceSha256": "4ec65aa1d4e905a1e732f2d043bbd39de1ab92f380276ede37d6a72efc19c64d",
|
||||
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "f49398b682a5623812f5d3c33d6bb9a749008e47cc0ed969072041615f66df59",
|
||||
"semanticValidatorsSourceSha256": "b20ec8ee6e7165117233ce4421a975bb9d3f276220ce31655c0b9a717cb2d9cb",
|
||||
"semanticSchemaBundleSourceSha256": "81ef98ca7b34896a0f76390ac7af3dd8ef7609fe6f38d54bd6a69373d50fe40b",
|
||||
"semanticSidecarProtocolSourceSha256": "26b6b21dad582810d00f46e947e682a9615ee9761c487f6315970d6d014c05ab",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
|
||||
"displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877",
|
||||
"displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e",
|
||||
"configuredEnvironmentSourceSha256": "ecef0ada320a762e0a3d91c38c72d4e19e2c8e8be8c060938a47e7f1a029058a",
|
||||
"configuredEnvironmentRustSourceSha256": "1671cb2735b3fa119d32f08d4a12b21b9f78a0e4d7285eb63c35df79ce17bd3c",
|
||||
"profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282",
|
||||
"profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571",
|
||||
"profileInstallationSourceSha256": "58841b2d3b47ab9956db20608802edcd0e55cf5856002dc7c51e05e75bdcb8f6",
|
||||
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
|
||||
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
|
||||
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
|
||||
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
|
||||
"nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1",
|
||||
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
|
||||
"harnessSessionContractSourceSha256": "baba1a210b863518f01e0c875606ed37f9467c433222251438578bd999bc43b5",
|
||||
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
|
||||
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
|
||||
"transportContractSourceSha256": "06f06c53ecae304f72034ffb2f659ff087c82a70ed4994daf21a9d2edaf56e4e",
|
||||
"nativeBackendSourceSha256": "7495d9898d22dca1ebf6336361d332bafefc4498f6e49f1135025fad846c8586",
|
||||
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
|
||||
"nativeInstructionsSourceSha256": "82ac6e5f7275b4ef79fb166c7330efc504e80cab3d7e9081e4e40a8b570bc50b",
|
||||
"providerLaunchSourceSha256": "c744235b3067ea75973523f78f1e13399337bc155f1775af694e34782d5c9bef",
|
||||
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
|
||||
"runtimeRequestBridgeSourceSha256": "f9b5a400a449f36de123bb511b21c4cb4564b50081f9958b92e6b7f0c6891d47",
|
||||
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
|
||||
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
|
||||
"guardedInstallationSourceSha256": "64b137e3ef695e7d03013ea2b6c8830171e0a73e0bf2e73d55e9ced57a47bcf6",
|
||||
"nativeExecutionSourceSha256": "116c348e232848a80c5556494c109314baafb20fb5c97133d9656c7732ee54be",
|
||||
"profileCompatibilitySourceSha256": "d3bd56d7c2d924c0ed623e4b92aea9b858020a3e4a6245e20c91476079e1bbf8",
|
||||
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
|
||||
"assetOwnershipSourceSha256": "e5bb6bc58b3f0e957a64b2cd99c710337ffcd9c0e1fadb341f48e033b64b99f5",
|
||||
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
|
||||
"credentialBindingSourceSha256": "2ed68b9aed9756fdd682721833eb2aeaa58f30f1dc8825c88886cc8e7ee92abe",
|
||||
"admissionDiagnosticsSourceSha256": "df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af",
|
||||
"grokAdmissionSourceSha256": "f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777",
|
||||
"sidecarErrorSourceSha256": "17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c",
|
||||
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
|
||||
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
|
||||
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticContinuationSourceSha256": "c9cc0fd516b3df92a6655f56ee9bef37615a889c371e2e137d8ac5c1176de811",
|
||||
"preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e",
|
||||
"runtimeHostSourceSha256": "26b2e115b3f4ae7c20e9d963d117e0286c958af81386deade83397f12abf9662",
|
||||
"runtimeAdapterSourceSha256": "fab1e278e0f2aed55b35e57d14329eb431b79a08040fa079aa2403842d16085e",
|
||||
"runtimeSandboxSourceSha256": "b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875",
|
||||
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
|
||||
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
|
||||
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"commandDigest": "sha256:a95f1d8e4d0a18d0c2cbc3bffdf998a2c5b8a582bdf51a72085b01d948d0a04e",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 84,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b",
|
||||
"permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
|
||||
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "abe3e6c718e8e0fed154526affc94e34b46d96f7bdaed5d88e2b00a21a5b84db",
|
||||
"semanticDirectDriverSourceSha256": "4ec65aa1d4e905a1e732f2d043bbd39de1ab92f380276ede37d6a72efc19c64d",
|
||||
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "f49398b682a5623812f5d3c33d6bb9a749008e47cc0ed969072041615f66df59",
|
||||
"semanticValidatorsSourceSha256": "b20ec8ee6e7165117233ce4421a975bb9d3f276220ce31655c0b9a717cb2d9cb",
|
||||
"semanticSchemaBundleSourceSha256": "81ef98ca7b34896a0f76390ac7af3dd8ef7609fe6f38d54bd6a69373d50fe40b",
|
||||
"semanticSidecarProtocolSourceSha256": "26b6b21dad582810d00f46e947e682a9615ee9761c487f6315970d6d014c05ab",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
|
||||
"displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877",
|
||||
"displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e",
|
||||
"configuredEnvironmentSourceSha256": "ecef0ada320a762e0a3d91c38c72d4e19e2c8e8be8c060938a47e7f1a029058a",
|
||||
"configuredEnvironmentRustSourceSha256": "1671cb2735b3fa119d32f08d4a12b21b9f78a0e4d7285eb63c35df79ce17bd3c",
|
||||
"profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282",
|
||||
"profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571",
|
||||
"profileInstallationSourceSha256": "58841b2d3b47ab9956db20608802edcd0e55cf5856002dc7c51e05e75bdcb8f6",
|
||||
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
|
||||
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
|
||||
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
|
||||
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
|
||||
"nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1",
|
||||
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
|
||||
"harnessSessionContractSourceSha256": "baba1a210b863518f01e0c875606ed37f9467c433222251438578bd999bc43b5",
|
||||
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
|
||||
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
|
||||
"transportContractSourceSha256": "06f06c53ecae304f72034ffb2f659ff087c82a70ed4994daf21a9d2edaf56e4e",
|
||||
"nativeBackendSourceSha256": "7495d9898d22dca1ebf6336361d332bafefc4498f6e49f1135025fad846c8586",
|
||||
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
|
||||
"nativeInstructionsSourceSha256": "82ac6e5f7275b4ef79fb166c7330efc504e80cab3d7e9081e4e40a8b570bc50b",
|
||||
"providerLaunchSourceSha256": "c744235b3067ea75973523f78f1e13399337bc155f1775af694e34782d5c9bef",
|
||||
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
|
||||
"runtimeRequestBridgeSourceSha256": "f9b5a400a449f36de123bb511b21c4cb4564b50081f9958b92e6b7f0c6891d47",
|
||||
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
|
||||
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
|
||||
"guardedInstallationSourceSha256": "64b137e3ef695e7d03013ea2b6c8830171e0a73e0bf2e73d55e9ced57a47bcf6",
|
||||
"nativeExecutionSourceSha256": "116c348e232848a80c5556494c109314baafb20fb5c97133d9656c7732ee54be",
|
||||
"profileCompatibilitySourceSha256": "eecf2c4e42b451df811e08c1ea25d37619eaf67ad255c5dd742e9232aada35ac",
|
||||
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
|
||||
"assetOwnershipSourceSha256": "e5bb6bc58b3f0e957a64b2cd99c710337ffcd9c0e1fadb341f48e033b64b99f5",
|
||||
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
|
||||
"credentialBindingSourceSha256": "2ed68b9aed9756fdd682721833eb2aeaa58f30f1dc8825c88886cc8e7ee92abe",
|
||||
"admissionDiagnosticsSourceSha256": "df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af",
|
||||
"grokAdmissionSourceSha256": "f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777",
|
||||
"sidecarErrorSourceSha256": "17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c",
|
||||
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
|
||||
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
|
||||
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticContinuationSourceSha256": "c9cc0fd516b3df92a6655f56ee9bef37615a889c371e2e137d8ac5c1176de811",
|
||||
"preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e",
|
||||
"runtimeHostSourceSha256": "26b2e115b3f4ae7c20e9d963d117e0286c958af81386deade83397f12abf9662",
|
||||
"runtimeAdapterSourceSha256": "fab1e278e0f2aed55b35e57d14329eb431b79a08040fa079aa2403842d16085e",
|
||||
"runtimeSandboxSourceSha256": "b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875",
|
||||
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
|
||||
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
|
||||
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"commandDigest": "sha256:e311f8111dd12f0c49bf12a80345cfe5f31a1222e0189209adeabf2f9bd0e1b0",
|
||||
"sourceDigest": "sha256:9d778ec8c5987e304d3121787806f546c7c9f545cf5e0628ce0035c59a559907",
|
||||
"declaration": {
|
||||
"schema": "paperclip.grok-profile-identity.v1",
|
||||
"agent": "grok",
|
||||
"agentProfileVersion": 13,
|
||||
"agentServerPackage": "builtin:grok-acp",
|
||||
"agentServerVersion": "1",
|
||||
"agentRuntimePackage": "native:grok",
|
||||
"agentRuntimeVersion": "1.0.13",
|
||||
"requiresProviderPolicy": false,
|
||||
"acpxVersion": "0.13.1",
|
||||
"sources": {
|
||||
"patches/acpx@0.13.1.patch": "sha256:1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2",
|
||||
"packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts": "sha256:abe3e6c718e8e0fed154526affc94e34b46d96f7bdaed5d88e2b00a21a5b84db",
|
||||
"packages/paperclip-runner/src/cli/acpx-sidecar-input.ts": "sha256:17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c",
|
||||
"packages/paperclip-runner/src/drivers/acpx/admission-diagnostics.ts": "sha256:df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af",
|
||||
"packages/paperclip-runner/src/drivers/acpx/grok-effort.ts": "sha256:f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777",
|
||||
"packages/paperclip-runner/src/drivers/acpx/grok-web-search.ts": "sha256:754b5ca2345c045421f6007f34999508d996e1cea035f0f1952b3d5f00072b9c",
|
||||
"packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts": "sha256:1c4ff0be6da798b6fcc604d20a9198dcc8523a3806d563b5b5345b7260549f73",
|
||||
"packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts": "sha256:fab1e278e0f2aed55b35e57d14329eb431b79a08040fa079aa2403842d16085e",
|
||||
"packages/paperclip-runner/src/drivers/acpx/runtime-host.ts": "sha256:26b2e115b3f4ae7c20e9d963d117e0286c958af81386deade83397f12abf9662",
|
||||
"packages/paperclip-runner/src/drivers/preconfigured-home.ts": "sha256:bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e",
|
||||
"packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "sha256:b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875",
|
||||
"packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts": "sha256:26b6b21dad582810d00f46e947e682a9615ee9761c487f6315970d6d014c05ab",
|
||||
"packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts": "sha256:aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b",
|
||||
"packages/paperclip-runner/src/providers/grok/launcher.cjs": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
"packages/paperclip-runner/src/providers/grok/platforms.json": "sha256:931eed3abbf2bf041e6c72e6e9486d976f4352613cf069b216ddf010c9a8e704",
|
||||
"packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs": "sha256:a1002d0d7fa8ce6b8287ad0397f7b9d93fe123aecc3dcbe5981b4efb41685736",
|
||||
"packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs": "sha256:09be94877a11ff526f4351b36471ed9685828a3ee4f8e6d329031e88c713afcb",
|
||||
"packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs": "sha256:839cb1d2ac19462f2bcef94d014e528e56beb7a1a1cbf6c3b10906d0ab7eb10c"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,10 @@ export const AI_CONNECTION_CAPABILITIES: Record<
|
||||
name: "GitHub Copilot",
|
||||
methods: { api_key: { adapters: ["copilot_runtime"], envKey: "COPILOT_GITHUB_TOKEN" } },
|
||||
},
|
||||
google: { name: "Google", methods: { api_key: { adapters: ["gemini_local", "pi_local"], envKey: "GEMINI_API_KEY" } } },
|
||||
google: { name: "Google", methods: {
|
||||
api_key: { adapters: ["gemini_local", "pi_local", "antigravity_runtime"], envKey: "GEMINI_API_KEY" },
|
||||
subscription: { adapters: ["antigravity_runtime"], envKey: "GEMINI_HOME" },
|
||||
} },
|
||||
anthropic: {
|
||||
name: "Claude",
|
||||
methods: {
|
||||
@@ -124,6 +127,11 @@ export function isAiConnectionCompatible(
|
||||
acpxAgent?: unknown,
|
||||
): boolean {
|
||||
adapterType = aiRoutingHarness(adapterType, runnerProvider, acpxAgent);
|
||||
// Antigravity uses its own Google runtime sign-in or a Gemini API key.
|
||||
// Tool OAuth and API-compatible endpoints cannot authenticate this harness.
|
||||
if (adapterType === "antigravity_runtime") return requirement.provider === "google"
|
||||
&& (requirement.method === "api_key" || requirement.method === "subscription")
|
||||
&& !("routing" in requirement && requirement.routing);
|
||||
if ("routing" in requirement && requirement.routing) return requirement.method === "api_key" && isAiRoutingCompatible(requirement.routing, adapterType);
|
||||
// A fixed binding contains identity only. The service checks authoritative
|
||||
// connection metadata before resolving credentials or running the harness.
|
||||
@@ -229,7 +237,7 @@ export type CreateAiConnection = z.infer<typeof createAiConnectionSchema>;
|
||||
|
||||
export const aiConnectionLoginIntentSchema = z
|
||||
.object({
|
||||
provider: z.enum(["anthropic", "openai", "xai"]),
|
||||
provider: z.enum(["anthropic", "openai", "xai", "google"]),
|
||||
method: z.literal("subscription"),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
ownership: z.enum(["personal", "shared"]),
|
||||
@@ -251,6 +259,7 @@ export interface LocalAiLoginStatus {
|
||||
authorizationUrl?: string;
|
||||
code?: string;
|
||||
error?: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
export interface LocalAiLoginAttempt {
|
||||
sessionId: string;
|
||||
@@ -263,6 +272,6 @@ export interface LocalAiLoginAttempt {
|
||||
export function aiSubscriptionNeedsIsolatedLogin(config: Record<string, unknown> | undefined): boolean {
|
||||
const metadata = aiConnectionMetadataSchema.safeParse(config?.ai);
|
||||
return metadata.success && metadata.data.method === "subscription" &&
|
||||
(metadata.data.provider === "openai" || metadata.data.provider === "xai") &&
|
||||
(["openai", "xai", "google"].includes(metadata.data.provider)) &&
|
||||
config?.aiIsolatedSubscription !== true;
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export function aiRoutingHarness(
|
||||
): string {
|
||||
if (adapter !== "paperclip_runner") return adapter;
|
||||
if (provider === "acpx" && acpxAgent === "copilot") return "copilot_runtime";
|
||||
if (provider === "acpx" && acpxAgent === "antigravity") return "antigravity_runtime";
|
||||
const runner =
|
||||
provider === "acpx" && ["claude", "grok", "pi"].includes(String(acpxAgent))
|
||||
? acpxAgent
|
||||
|
||||
@@ -80,6 +80,7 @@ test("the script's path list and key inputs cover what the release build of the
|
||||
assert.deepEqual(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/**`,
|
||||
@@ -280,6 +281,51 @@ test("every file the crate includes by path sits under a hashed input, so an edi
|
||||
assert.deepEqual([...forms].sort(), ["concat-manifest-dir", "concat-metavariable", "literal"]);
|
||||
});
|
||||
|
||||
test("isolated Docker Rust stages preserve the layout of embedded schema and identity inputs", () => {
|
||||
const crateRoot = path.join(repoRoot, RUNNER_PACKAGE, "runner/crates");
|
||||
const sources = [];
|
||||
(function walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith(".rs")) sources.push(full);
|
||||
}
|
||||
})(crateRoot);
|
||||
for (const [dockerfile, stage] of [["Dockerfile", "runner-build"], ["docker/daytona-runner/Dockerfile", "runnerd-build"]]) {
|
||||
const docker = readFileSync(path.join(repoRoot, dockerfile), "utf8");
|
||||
const body = docker.split(/^FROM .+ AS /m).find(part => part.startsWith(`${stage}\n`));
|
||||
assert.ok(body, `${dockerfile}: missing ${stage}`);
|
||||
const workdir = /^WORKDIR (.+)$/m.exec(body)?.[1];
|
||||
assert.ok(workdir?.startsWith("/"), `${dockerfile}: an absolute stage working directory is required`);
|
||||
const copies = [...body.matchAll(/^COPY (?!-)(.+)$/gm)].flatMap(([, instruction]) => {
|
||||
const args = instruction.trim().split(/\s+/);
|
||||
const destination = path.posix.resolve(workdir, args.pop());
|
||||
return args.map(source => {
|
||||
const absolute = path.join(repoRoot, source);
|
||||
return { source: absolute, destination: statSync(absolute).isDirectory() ? destination : path.posix.join(destination, path.basename(source)) };
|
||||
});
|
||||
});
|
||||
const copiedPath = source => {
|
||||
const copy = copies.find(copy => source === copy.source || source.startsWith(`${copy.source}${path.sep}`));
|
||||
return copy ? path.posix.join(copy.destination, path.relative(copy.source, source)) : null;
|
||||
};
|
||||
let checked = 0;
|
||||
for (const source of sources) {
|
||||
for (const { resolved } of crateIncludes(source, readFileSync(source, "utf8"))) {
|
||||
// Cargo test-only fixture trees are intentionally absent from production
|
||||
// Docker stages. Their cache coverage is checked by the preceding test.
|
||||
if (/\/(?:test-fixtures|test\/fixtures|tests\/runner-e2e\/fixtures)(?:\/|$)/.test(resolved)) continue;
|
||||
const stagedSource = copiedPath(source);
|
||||
assert.ok(stagedSource, `${dockerfile}: crate source was not copied: ${path.relative(repoRoot, source)}`);
|
||||
const expected = path.posix.resolve(path.posix.dirname(stagedSource), path.relative(path.dirname(source), resolved));
|
||||
assert.equal(copiedPath(resolved), expected, `${dockerfile}: include input ${path.relative(repoRoot, resolved)} must retain its crate-relative path`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 0, `${dockerfile}: the guard must see real Rust include paths`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every PR reader restores the crate after the dependency cache and marks it before building", () => {
|
||||
for (const [name, buildCommand] of READERS) {
|
||||
const body = job(prWorkflow, name);
|
||||
|
||||
@@ -74,6 +74,9 @@ export function paperclipRunnerBinaryNeedsBuild({
|
||||
|
||||
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"),
|
||||
|
||||
@@ -52,8 +52,8 @@ assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).na
|
||||
const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js')));
|
||||
const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js')));
|
||||
const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js')));
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 34);
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f');
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 35);
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:9473de6ef4baf2bd82fe8b6757239bdc160960b0a78ae6f47fa9e0996544226c');
|
||||
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false);
|
||||
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false);
|
||||
const daemon = resolvePaperclipRunnerBinary();
|
||||
|
||||
@@ -54,6 +54,7 @@ export const CACHED_PATHS = [
|
||||
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/**`,
|
||||
|
||||
@@ -138,6 +138,23 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"antigravity-distributions.json", "rust-toolchain.toml", "protocol/provider-schemas/acpx-sidecar.schema.json",
|
||||
])("rebuilds an existing native binary after changing embedded input %s", input => {
|
||||
const checkout = createRunnerCheckout();
|
||||
const dependency = path.join(checkout.root, "packages/paperclip-runner", input);
|
||||
fs.mkdirSync(path.dirname(dependency), { recursive: true });
|
||||
fs.writeFileSync(dependency, "fixture compile input\n");
|
||||
const now = Date.now(), old = new Date(now - 2_000), built = new Date(now + 2_000), changed = new Date(now + 4_000);
|
||||
fs.utimesSync(dependency, old, old);
|
||||
fs.utimesSync(checkout.binary, built, built);
|
||||
const options = { repoRoot: checkout.root, nativeRunnerRequired: true };
|
||||
expect(paperclipRunnerBinaryNeedsBuild(options)).toBe(false);
|
||||
fs.utimesSync(dependency, changed, changed);
|
||||
expect(paperclipRunnerBinaryNeedsBuild(options)).toBe(true);
|
||||
expect(paperclipRunnerBinaryNeedsBuild({ ...options, nativeRunnerRequired: false })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["arm64", "x64"])("uses the staged Mac %s target before the flat build output", architecture => {
|
||||
const checkout = createRunnerCheckout();
|
||||
const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { aiConnectionMetadataSchema, aiRoutingHarness, isAiConnectionCompatible } from "@paperclipai/shared";
|
||||
import { aiBindingForAuthRecovery, isAiAuthenticationFailure } from "./ai-auth-failure.js";
|
||||
|
||||
describe("AI authentication failure recovery", () => {
|
||||
@@ -26,3 +27,30 @@ describe("AI authentication failure recovery", () => {
|
||||
expect(aiBindingForAuthRecovery("gemini_local", {})).toEqual({ provider: "google", method: "api_key", mode: "responsible_user" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("native Antigravity account compatibility", () => {
|
||||
const compatible = (connection: Parameters<typeof isAiConnectionCompatible>[0]) =>
|
||||
isAiConnectionCompatible(connection, "paperclip_runner", "gemini-3.8-flash-low", "acpx", "antigravity");
|
||||
|
||||
it.each(["api_key", "subscription"] as const)("accepts the selected Google %s account", method => {
|
||||
expect(aiRoutingHarness("paperclip_runner", "acpx", "antigravity")).toBe("antigravity_runtime");
|
||||
expect(compatible({ provider: "google", method })).toBe(true);
|
||||
expect(compatible({ provider: "google", method, mode: "responsible_user" })).toBe(true);
|
||||
expect(compatible({ provider: "google", method, mode: "delegated",
|
||||
connectionId: "11111111-1111-4111-8111-111111111111", grantId: "22222222-2222-4222-8222-222222222222" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other providers, tool OAuth and API-compatible endpoints", () => {
|
||||
for (const provider of ["openai", "anthropic", "openrouter", "github", "xai"] as const) {
|
||||
expect(compatible({ provider, method: "api_key" })).toBe(false);
|
||||
expect(compatible({ provider, method: "api_key", mode: "shared",
|
||||
connectionId: "11111111-1111-4111-8111-111111111111", grantId: "22222222-2222-4222-8222-222222222222" })).toBe(false);
|
||||
}
|
||||
expect(aiConnectionMetadataSchema.safeParse({ provider: "google", method: "oauth" }).success).toBe(false);
|
||||
expect(compatible({ provider: "google", method: "api_key", routing: {
|
||||
kind: "openrouter", protocol: "chat", auth: "bearer", models: [],
|
||||
} })).toBe(false);
|
||||
expect(isAiConnectionCompatible({ provider: "google", method: "subscription" }, "gemini_local")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -65,8 +65,14 @@ export function aiBindingForAuthRecovery(
|
||||
const missingProvider = failure ? missingPersonalAiCredentialProvider(failure) : undefined;
|
||||
for (const provider of AI_PROVIDERS) {
|
||||
if (missingProvider && missingProvider !== provider) continue;
|
||||
const binding = { provider, method: AI_CONNECTION_CAPABILITIES[provider].methods.subscription ? "subscription" : "api_key", mode: "responsible_user" } as const;
|
||||
if (isAiConnectionCompatible(binding, adapterType, config.model, config.provider, config.acpxAgent)) return binding;
|
||||
for (const method of ["subscription", "api_key"] as const) {
|
||||
if (!AI_CONNECTION_CAPABILITIES[provider].methods[method]) continue;
|
||||
// Recovery presents a concrete sign-in method. A responsible-user binding
|
||||
// normally follows any compatible default, so check exact metadata first.
|
||||
if (isAiConnectionCompatible({ provider, method }, adapterType, config.model, config.provider, config.acpxAgent)) {
|
||||
return { provider, method, mode: "responsible_user" };
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in new issue
Block a user