diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 1d4c4ee908a..954756ceaa7 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -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' }} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index befc71e6bad..7daaf98d13b 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -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 }} diff --git a/Dockerfile b/Dockerfile index 36a9c707cf2..5360d08396a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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="" diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index cb791d6acac..69a8fe305ed 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -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 diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 7abe19ed0a5..bed9078b502 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -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. */ diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 26d239cd09b..7444d913f41 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -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": { diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json index ee358cbab29..2287515faec 100644 --- a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -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", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_candidate_admission.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_candidate_admission.rs new file mode 100644 index 00000000000..c9eb0de9ecb --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_candidate_admission.rs @@ -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 { + 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 { + 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::(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")); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 391f6463abf..912e9e26531 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -350,6 +350,18 @@ impl AcpxProviderDescriptor { expected_identity: Option, launch_profile: Option<&AcpxLaunchProfile>, ) -> Result { + 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 { 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 { + 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 { 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::(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(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 8265121050f..270036a3514 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -89,6 +89,7 @@ pub struct AcpxProviderRuntimePolicy { #[derive(Clone, Debug)] pub struct AcpxProviderSessionConfig { + pub candidate_admission: Option, 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(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 5dfd897614c..73bcdf9459e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -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"); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index 563855b6e9c..c5329db13e5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -240,7 +240,8 @@ fn run() -> Result<(), Box> { "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::>()), + "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::>()), + "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::>()), } }), )?; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index 55341216d3e..089a85f73dd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -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, LocalRunnerE } fn acpx_launch_profile(args: &[String]) -> Result, 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, 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, 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 = diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs index 4aa92852466..7c72e5e372c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs @@ -118,6 +118,7 @@ pub struct AcpxLaunchProfile { pub command: PathBuf, pub args: Vec, pub artifacts: Vec, + pub candidate_admission: Option, } 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(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index 2a8eb5f6067..ac5838d2f17 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -394,7 +394,25 @@ pub fn run_durable_runner( 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( // 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 = None; let mut authenticated_once = false; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 58a1aecb61d..9e76995b3e3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { command_digest: "sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc", requires_provider_policy: false, + qualification_pending: false, }, _ => return None, }) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 0e8e9881ae0..5c1d58d335f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -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; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs index 02153514160..a3ef35a7aa3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs @@ -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, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs index 28d279725f4..e31736dd7ad 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs @@ -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()], diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs index 6d6027fb329..23105613a6a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs @@ -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()], diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index c080b933370..cb28c8c3f72 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -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()], diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs index 4b4a4986905..f09b9724a91 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs @@ -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()], diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs index dda6cfad4f1..e4485a9f7bb 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs @@ -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()], diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs index 62cda02e06e..9ee17452633 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs @@ -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( diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs index 4cfb4113898..b0bbf0901c1 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs @@ -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(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 16bd6ba02c5..71395ec5e72 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -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 diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index e72fba2aa18..4a80c6fbd76 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -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"); diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index a5588a38325..c84b0941688 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -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 { @@ -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, }) diff --git a/packages/paperclip-runner/scripts/grok-profile-identity.mjs b/packages/paperclip-runner/scripts/grok-profile-identity.mjs index 3ea0bb086f6..bf43ad20a48 100644 --- a/packages/paperclip-runner/scripts/grok-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/grok-profile-identity.mjs @@ -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, diff --git a/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs b/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs index 375be1550a6..ad877be3165 100644 --- a/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs +++ b/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs @@ -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`, diff --git a/packages/paperclip-runner/scripts/provision-pi-package.test.mjs b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs index 7577d016120..ab78a40ee35 100644 --- a/packages/paperclip-runner/scripts/provision-pi-package.test.mjs +++ b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs @@ -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); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index f1cb9aeee47..b35ca3dae1a 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -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, }, }); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index db18659d3db..56933071772 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -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(); const normalizingTools = new Set(); @@ -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 { function parseOpenParams( value: Record, ): 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; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 246674b89b9..a73b03383e7 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -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") { diff --git a/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts b/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts index 0f63269c5ee..b147497b092 100644 --- a/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts +++ b/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts @@ -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={}) { 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>((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((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{ diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 89dd0138fbd..eb67e6d9849 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -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, diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-acpx-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-acpx-runtime.test.ts new file mode 100644 index 00000000000..93ce3d63825 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-acpx-runtime.test.ts @@ -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> = []; +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> = []; + const nativeResponses: Array> = []; + const sdkResponses: Array> = []; + const children: ChildProcess[] = []; + const records = new Map(); + const runtimes: Array<{ runtime: ReturnType; 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(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); + guard(direction, message); + if (direction === "inbound") sdkResponses.push(structuredClone(message) as Record); + if (direction === "outbound") requests.push(structuredClone(message) as Record); + }; + }, + 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; 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); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.test.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.test.ts new file mode 100644 index 00000000000..728b1fd2a32 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.test.ts @@ -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, method = "session/new", id: string | number = 0) { + const message = { id, method, params: { ...(method === "session/new" ? {} : { sessionId: "native-session" }) } as Record }; + f.guard("outbound", message); + return message; +} +function response(message: ReturnType, changes: Record = {}) { + const meta = message.params._meta as { antigravity: { paperclipAdmission: Record } }; + 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 }; + 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 }; + 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 }; + 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 }; + 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(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts new file mode 100644 index 00000000000..11cee9481d1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts @@ -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(); + let nativeSelection = new Map(); + const pending = new Map(); + 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): Map { + const selected = new Map(); + 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, 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): 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 { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-auth.test.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-auth.test.ts new file mode 100644 index 00000000000..5037ca9c587 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-auth.test.ts @@ -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(); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-auth.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-auth.ts new file mode 100644 index 00000000000..2921fd141db --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-auth.ts @@ -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 => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const verifiedCredentials = new Map(); +const activeBrowserHomes = new Set(); + +export type AntigravityBrowserLoginState = { + authorizationUrl?: string; + outcome?: "success" | "failure"; + error?: string; + /** Resolves only after physical process retirement and lease release. */ + abort(): Promise; +}; + +/** 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; + 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 { + 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> { + 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(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 | undefined; + let timeout: ReturnType | 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((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; + 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 | undefined; + const retry = new Promise(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; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-credentials.test.ts new file mode 100644 index 00000000000..3e3f11ede1f --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-credentials.test.ts @@ -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(); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-credentials.ts new file mode 100644 index 00000000000..62bbe5bf671 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-credentials.ts @@ -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 { + 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 | 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; }); + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-environment.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-environment.ts new file mode 100644 index 00000000000..7cd13354b7c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-environment.ts @@ -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(); + 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))); + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-installation.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-installation.ts new file mode 100644 index 00000000000..d94f3fbf166 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-installation.ts @@ -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 { + 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 }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-models.test.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-models.test.ts new file mode 100644 index 00000000000..aad213e1f37 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-models.test.ts @@ -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(), verifyNativeAcpxInstallation: setup.verify, +})); +vi.mock("./antigravity-process.js", async original => { + const actual = await original(); + return { ...actual, retireAntigravityProcess: (...args: Parameters) => 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(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-models.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-models.ts new file mode 100644 index 00000000000..ea5227a20f0 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-models.ts @@ -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 => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + +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, + 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 | undefined, lease: VerifiedAcpxCommandLease): Promise { + 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 | undefined; + const retry = new Promise(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(), 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 { + 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 | 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((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; + 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; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-probe-retirement.test.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-probe-retirement.test.ts new file mode 100644 index 00000000000..b970105f78d --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-probe-retirement.test.ts @@ -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(resolve => child.once("exit", () => resolve())); + let retirement: Promise | undefined; + try { + await new Promise((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); diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-probe-retirement.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-probe-retirement.ts new file mode 100644 index 00000000000..6dd4bf0123e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-probe-retirement.ts @@ -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 { + 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)); + } + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-process.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-process.ts new file mode 100644 index 00000000000..3039be5e7a3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-process.ts @@ -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 { + 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 { + 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): Promise { + 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((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 | undefined; + const deadline = Date.now() + wait; + await Promise.race([closed, new Promise(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"); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-questions.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-questions.ts new file mode 100644 index 00000000000..b93653951a0 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-questions.ts @@ -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 }>(); + 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(); + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/candidate-admission.test.ts b/packages/paperclip-runner/src/drivers/acpx/candidate-admission.test.ts new file mode 100644 index 00000000000..803e28250e1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/candidate-admission.test.ts @@ -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(), + 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(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/candidate-admission.ts b/packages/paperclip-runner/src/drivers/acpx/candidate-admission.ts new file mode 100644 index 00000000000..67752d25cfc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/candidate-admission.ts @@ -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; + 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; + 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(); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index fce35cf840e..cc2d1eff0d6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts @@ -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> = { + 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", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 239ef5c37e1..8d573038487 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -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"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index ac4fb23f740..0f74e8812bd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -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(); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 0acaed242c9..707120eb163 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -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 () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts index 84497d44bbb..d16afbb9c0b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts @@ -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, input: { providerSpawned: boolean; load: () => Promise; refreshCommand: () => Promise }, ): Promise { - 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); } diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index c629c2fb7b5..854c5568c7d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -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 { diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index 2d1615a651e..42d19992879 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -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"); diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 8f393b4b599..40eb872e16e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -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> = { + 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); } diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 9601bea35f3..0ffc9fbcc25 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -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; diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index d38b69645fe..fbf39064e18 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -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 { + 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) { diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index e5aa088a3ee..5704ac6df2b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -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"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts index 57b35807402..ce32dcba841 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 05954b8f78c..41707246f27 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -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> = { - 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], }; diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index 5c07c21aab6..2e21564352c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -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 { +export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile, candidateAdmission?: AcpxCandidateStartupAdmission): Promise { + 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): 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 }); } diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts index 5f4929963b8..3dee493616e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -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; diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 1a3012ff463..371167e743d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -37,6 +37,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< Record > = 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, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index ff7d3b0d348..51814ff481d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -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"); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index 2552c76d4cc..a508285096d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -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, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 5a3906ba605..fec3c663280 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -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, ) diff --git a/packages/paperclip-runner/src/drivers/acpx/session-context-admission.ts b/packages/paperclip-runner/src/drivers/acpx/session-context-admission.ts new file mode 100644 index 00000000000..4254d21ba95 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/session-context-admission.ts @@ -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; refreshCommand: () => Promise }, +): Promise { + 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(); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index efc7ea9f63b..26f9367d34b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -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; diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index c54663b1b28..e06d5ca1263 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -46,6 +46,8 @@ export interface CodexAppServerTransport { notify(method: string, params?: Record): void; notifications(): AsyncIterable; setServerRequestHandler(handler: CodexServerRequestHandler): void; + /** Establish a fresh owned provider lifetime before reading a retained conversation. */ + prepareConversationResume?(params: Record): Promise; /** One-shot requests authenticated by an adopted live provider, never a saved checkpoint alone. */ takeRestoredRuntimeRequests?(): CodexRpcServerRequest[]; /** diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 7103c137c48..18b5cff263c 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -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 { + 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"]; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.candidate-continuation.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.candidate-continuation.test.ts new file mode 100644 index 00000000000..1e53e044902 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.candidate-continuation.test.ts @@ -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; + 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) => { + 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); + } +}); diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index cb7996e7172..0a1744f0173 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -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, diff --git a/packages/paperclip-runner/src/live/antigravity-candidate-startup.test.ts b/packages/paperclip-runner/src/live/antigravity-candidate-startup.test.ts new file mode 100644 index 00000000000..80389f03b96 --- /dev/null +++ b/packages/paperclip-runner/src/live/antigravity-candidate-startup.test.ts @@ -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"); + }); +}); diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index c36b8036346..28e23ce4073 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -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 { 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) { diff --git a/packages/paperclip-runner/src/live/native-probe-cleanup.test.ts b/packages/paperclip-runner/src/live/native-probe-cleanup.test.ts new file mode 100644 index 00000000000..9625b09cdb9 --- /dev/null +++ b/packages/paperclip-runner/src/live/native-probe-cleanup.test.ts @@ -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(resolve => { started = resolve; }); + let child: ReturnType | undefined; + let exited!: Promise; + 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(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); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 855c41492a3..c09a2c50ea0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -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(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(); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index beeb607a8f0..07ac0d5696f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -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; /** Relinquish controller authority without stopping the durable runner. */ detachControllerForRestart(): Promise; + /** Setup-only retained cleanup; joins original work and proves physical retirement. */ + retireProbeResources?(timeoutMs?: number): Promise; } 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; #resolveClosedSignal!: () => void; #closePromise: Promise | null = null; + #probeRetirement: Promise | null = null; + readonly prepareConversationResume?: CodexAppServerTransport["prepareConversationResume"]; #detachPromise: Promise | null = null; #startupOperation: Promise | null = null; #attachmentOperation: Promise | 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((_resolve, reject) => { @@ -4522,6 +4564,42 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { return this.#closePromise; } + retireProbeResources(timeoutMs?: number): Promise { + 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 { this.#detachPromise ??= this.#detachControllerOnce(); return this.#detachPromise; @@ -4898,6 +4976,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { params: Record, identity: DurableRecoveryIdentity, dynamicTools: Record[], ) { + 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): Promise { + 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 { + 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 }>(); + +function probeCleanupBudget(timeoutMs = 10_000): number { + return Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, 10_000) : 10_000; +} +async function awaitProbeCleanup(pending: Promise, deadline: number, detail: string): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([pending, new Promise((_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 { + 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, + readonly operationError: unknown, + readonly cleanupErrors: readonly unknown[], + readonly retryCleanup?: () => Promise, + ) { + 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) { + let operation: Promise | undefined; + let attempts = 0; + const owner = { run: (): Promise => { + 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; @@ -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 | undefined; + let registrationReleaseOperation: Promise | undefined; + let registrationReleaseFailed = false; + let releaseRegistration: (() => Promise) | undefined; + let cleanupCallbackOperation: Promise | undefined; + let cleanupCallbackFailed = false; + let credentialRefreshOperation: Promise | 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 | 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, detail: string): Promise => { let timeout: ReturnType | 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}`); diff --git a/packages/paperclip-runner/src/providers/grok/launcher.cjs b/packages/paperclip-runner/src/providers/grok/launcher.cjs index 6f1f9d7b095..e9e24f46bff 100755 --- a/packages/paperclip-runner/src/providers/grok/launcher.cjs +++ b/packages/paperclip-runner/src/providers/grok/launcher.cjs @@ -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"); diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v35-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v35-identity.json new file mode 100644 index 00000000000..4c21b13859b --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v35-identity.json @@ -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" + } +} diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index 8adbe3b46f7..b841a224e2d 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -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"]) { diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v82-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v82-identity.json new file mode 100644 index 00000000000..99ff69ea406 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v82-identity.json @@ -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" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v83-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v83-identity.json new file mode 100644 index 00000000000..8a3faad53b1 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v83-identity.json @@ -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" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v84-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v84-identity.json new file mode 100644 index 00000000000..5a612af2798 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v84-identity.json @@ -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" + } +} diff --git a/packages/paperclip-runner/test/fixtures/grok-profile-v13-identity.json b/packages/paperclip-runner/test/fixtures/grok-profile-v13-identity.json new file mode 100644 index 00000000000..62e144b5bdb --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/grok-profile-v13-identity.json @@ -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" + } + } +} diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index 250eece6212..f9771a64b2b 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -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; 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 | 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; } diff --git a/packages/shared/src/ai-provider-routing.ts b/packages/shared/src/ai-provider-routing.ts index 7095664442f..519c93bd489 100644 --- a/packages/shared/src/ai-provider-routing.ts +++ b/packages/shared/src/ai-provider-routing.ts @@ -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 diff --git a/scripts/__tests__/runner-release-crate-cache.test.mjs b/scripts/__tests__/runner-release-crate-cache.test.mjs index 7a2627788f2..c19872b38c2 100644 --- a/scripts/__tests__/runner-release-crate-cache.test.mjs +++ b/scripts/__tests__/runner-release-crate-cache.test.mjs @@ -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); diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index 1df5eab04b3..274c4c920d8 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -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"), diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 95d24a35638..f3b26cf97c9 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -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(); diff --git a/scripts/runner-release-crate-cache.mjs b/scripts/runner-release-crate-cache.mjs index f0de40d6034..941bcf80824 100644 --- a/scripts/runner-release-crate-cache.mjs +++ b/scripts/runner-release-crate-cache.mjs @@ -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/**`, diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 6b0e7695b19..cfe8ba50d6b 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -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"); diff --git a/server/src/services/ai-auth-failure.test.ts b/server/src/services/ai-auth-failure.test.ts index 2edcfb807ed..f29273adc8d 100644 --- a/server/src/services/ai-auth-failure.test.ts +++ b/server/src/services/ai-auth-failure.test.ts @@ -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[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); + }); +}); diff --git a/server/src/services/ai-auth-failure.ts b/server/src/services/ai-auth-failure.ts index c2ff39ba715..45ded369115 100644 --- a/server/src/services/ai-auth-failure.ts +++ b/server/src/services/ai-auth-failure.ts @@ -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; }