mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Native Runner providers need verified executables before they can run. > - Google Antigravity ships a native ACP server and a helper executable. > - An archive hash alone does not verify the installed runtime or its child process cleanup. > - This pull request adds a pinned installer and a small, verified compatibility patch. > - Later pull requests add Runner execution, account setup, and the recorded user journey eval. ## Linked Issues or Issue Description Refs: #12503, #14938. Those pull requests use the legacy CLI adapter. This stack adds native Runner ACP support. **Agent or provider** Google Antigravity ACP 1.4.3. **Why this adapter is useful** Users need a reproducible runtime before they can select Antigravity for a Paperclip agent. **How the agent is invoked** The installer prepares the native ACP server and helper. This pull request does not enable task execution. Default execution remains closed pending qualification. ## What Changed - Pin the complete macOS ARM64 runtime closure. Keep other platforms as inventory only. - Verify archive entries, installed bytes, ownership, and cache layout. - Patch exact upstream code anchors for model admission, instruction loading, native process ownership, and per-call file tool completion. - Package a self-contained installer and expose `paperclipai runtime setup antigravity`. - Record candidate limitations and historical acceptance evidence. ## Verification - Runtime provisioning, packaging, metadata, and patch support tests: 62 passed. The separate exact pinned native patch run passed all 5 tests with no skips and no Google authentication. - CLI runtime setup tests: 5 passed. - Map contract check passed. Staged diff whitespace check passed. - The first test command ran before dependency installation and failed on missing esbuild. The same command passed after installation. - Current master has a stale lockfile. Dependencies were resolved locally with scripts disabled. The generated lockfile is excluded from this pull request. - Final head: required `ci / verify` and `ci / e2e` passed, and Greptile is 5/5 with both review threads resolved. The optional external Paperclip Review remains queued. Two original timing failures are retained; one failed-job rerun passed without source or deadline changes. ## Risks - The patch depends on exact upstream native code. A new upstream release must get a new verified closure. - Only macOS ARM64 has a complete verified runtime. Installation does not qualify authentication, task execution, or other platforms. - The installer downloads official Google assets when the verified cache is absent. ## Model Used OpenAI Codex (GPT-6). The host did not expose the exact model revision or context limit. The model used tools for code edits, test execution, and review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
612 lines
36 KiB
JavaScript
612 lines
36 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { test } from "node:test";
|
|
import { pathToFileURL } from "node:url";
|
|
import { antigravityParSection, patchAntigravityServerBytes, replaceAntigravitySource } from "./patch-antigravity-runtime.mjs";
|
|
|
|
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
const source = process.env.PAPERCLIP_ANTIGRAVITY_NATIVE_TEST_SOURCE;
|
|
|
|
function runNative(executable, home, requests) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = execFile(executable, [], {
|
|
cwd: dirname(executable),
|
|
env: { PATH: process.env.PATH, HOME: home, GEMINI_HOME: join(home, ".gemini"), BROWSER: "/usr/bin/true", AGY_ACP_FORCE_FILE_STORAGE: "1", ANTIGRAVITY_HARNESS_PATH: join(executable, "..", "localharness_external") },
|
|
timeout: 30_000, maxBuffer: 2 * 1024 * 1024,
|
|
}, (error, stdout, stderr) => error ? reject(Object.assign(error, { stdout, stderr })) : resolve({ events: stdout.trim().split("\n").filter(Boolean).map(line => JSON.parse(line)), stderr }));
|
|
child.stdin.end(requests.map(request => JSON.stringify(request)).join("\n") + "\n");
|
|
});
|
|
}
|
|
|
|
function initialize() {
|
|
return { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "paperclip-runner", version: "test" }, clientCapabilities: {} } };
|
|
}
|
|
|
|
function admission(content) {
|
|
return { schema: "paperclip.antigravity.admission.v1", nonce: "credential-free-regression", model: "gemini-3.1-flash-lite", digest: "sha256:" + hash(content), byteLength: Buffer.byteLength(content) };
|
|
}
|
|
|
|
// This fixture runs inside the real vendor hermetic Python interpreter. It
|
|
// tests native protobuf boundaries without creating a session or using auth.
|
|
const nativeBoundaryTests = `
|
|
|
|
def _paperclip_native_boundary_regressions():
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from google3.learning.genai.api.localharness import localharness_pb2 as pb
|
|
def rejected(fn):
|
|
try:
|
|
fn()
|
|
except exceptions.RequestError:
|
|
return
|
|
raise AssertionError("mismatched native admission was accepted")
|
|
for content in (" exact café 🧷\\n", ""):
|
|
raw = content.encode("utf-8")
|
|
request = {"schema": SCHEMA, "nonce": "native-regression", "model": "literal-model", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw)}
|
|
metadata = {"antigravity": {"paperclipAdmission": request, "systemPrompt": {"append": content}}}
|
|
expected = _read_request(metadata)
|
|
assert expected["content"] == content
|
|
bad = {"antigravity": {"paperclipAdmission": {**request, "byteLength": len(raw) + 1}, "systemPrompt": {"append": content}}}
|
|
rejected(lambda: _read_request(bad))
|
|
config = pb.HarnessConfig(cascade_id="native-session", models=[pb.ModelConfig(name="literal-model", types=[1])])
|
|
if content:
|
|
config.system_instructions.appended.appended_sections.add(content=content)
|
|
token = _CONTEXT.set(expected)
|
|
try:
|
|
strategy = SimpleNamespace(_binary_path="/untrusted/cached/helper", _env={"ANTIGRAVITY_HARNESS_PATH": "/untrusted/env/helper"})
|
|
prepared = prepare_native_config(config, strategy)
|
|
assert strategy._binary_path == os.environ["ANTIGRAVITY_HARNESS_PATH"]
|
|
assert strategy._env["ANTIGRAVITY_HARNESS_PATH"] == strategy._binary_path
|
|
native_ack = pb.OutputEvent(initialize_conversation_response=pb.InitializeConversationResponse(cascade_id="native-session"))
|
|
receipt = acknowledge_native_config(prepared, native_ack, config)
|
|
assert prepare_native_config(config, strategy)["connectionId"] != prepared["connectionId"]
|
|
assert receipt["digest"] == request["digest"] and receipt["byteLength"] == len(raw)
|
|
assert receipt["sessionId"] == "native-session" and receipt["connectionId"]
|
|
rejected(lambda: acknowledge_native_config(prepared, pb.OutputEvent(), config))
|
|
wrong = pb.OutputEvent(initialize_conversation_response=pb.InitializeConversationResponse(cascade_id="another-session"))
|
|
rejected(lambda: acknowledge_native_config(prepared, wrong, config))
|
|
config.models[0].name = "substituted-model"
|
|
rejected(lambda: prepare_native_config(config, strategy))
|
|
rejected(lambda: acknowledge_native_config(prepared, native_ack, config))
|
|
config.models[0].name = "literal-model"
|
|
config.system_instructions.appended.appended_sections.add(content="unexpected instruction")
|
|
rejected(lambda: prepare_native_config(config, strategy))
|
|
del config.system_instructions.appended.appended_sections[:]
|
|
if content:
|
|
config.system_instructions.appended.appended_sections.add(content=content)
|
|
config.harness_side_tools.subagents.enabled = True
|
|
rejected(lambda: prepare_native_config(config, strategy))
|
|
config.harness_side_tools.subagents.enabled = False
|
|
config.harness_side_tools.run_workflow.enabled = True
|
|
rejected(lambda: prepare_native_config(config, strategy))
|
|
config.harness_side_tools.run_workflow.enabled = False
|
|
config.harness_side_tools.schedule.enabled = True
|
|
rejected(lambda: prepare_native_config(config, strategy))
|
|
config.harness_side_tools.schedule.enabled = False
|
|
config.custom_subagents.add(name="unpromised-browser-agent")
|
|
rejected(lambda: prepare_native_config(config, strategy))
|
|
finally:
|
|
_CONTEXT.reset(token)
|
|
sys.stderr.write("paperclip-native-boundary-regressions: passed\\n")
|
|
|
|
_paperclip_native_boundary_regressions()
|
|
`;
|
|
|
|
// Calls the real SDK subprocess/stdio/WebSocket startup. The native helper has
|
|
// not received InitializeConversationEvent, so no auth or inference is used.
|
|
const nativeHelperIsolationTests = `
|
|
|
|
def _paperclip_native_helper_isolation_regression():
|
|
import asyncio
|
|
import subprocess
|
|
import sys
|
|
from google.antigravity import types as sdk_types
|
|
from google.antigravity.tools.tool_runner import ToolRunner
|
|
from google.antigravity.connections.local.local_connection_config import LocalAgentConfig
|
|
async def run():
|
|
home = os.environ["HOME"]
|
|
os.makedirs(home, exist_ok=True)
|
|
sentinel = os.path.join(home, "untrusted-cached-helper")
|
|
marker = os.path.join(home, "untrusted-helper-launched")
|
|
with open(sentinel, "w") as output:
|
|
output.write("#!/bin/sh\\n: > " + marker + "\\nexit 0\\n")
|
|
os.chmod(sentinel, 0o700)
|
|
config = LocalAgentConfig(conversation_id="helper-isolation-session-1234567890123456", system_instructions="exact helper isolation", capabilities=sdk_types.CapabilitiesConfig(enable_subagents=False, disabled_tools=[sdk_types.BuiltinTools.SCHEDULE]), models=[sdk_types.ModelTarget(name="literal-model", types=[sdk_types.ModelType.TEXT], endpoint=sdk_types.GeminiAPIEndpoint())], env={"ANTIGRAVITY_HARNESS_PATH": sentinel})
|
|
strategy = config.create_strategy(tool_runner=None, hook_runner=None)
|
|
assert strategy._binary_path == sentinel, "test must begin with an already-cached override"
|
|
raw = b"exact helper isolation"
|
|
admission = {"schema": SCHEMA, "nonce": "helper-isolation", "model": "literal-model", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw), "content": raw.decode()}
|
|
token = _CONTEXT.set(admission)
|
|
owned = []
|
|
launched_paths = []
|
|
ws = None
|
|
original_popen = subprocess.Popen
|
|
def tracked_popen(*args, **kwargs):
|
|
process = original_popen(*args, **kwargs)
|
|
owned.append(process)
|
|
launched_paths.append(args[0][0])
|
|
return process
|
|
try:
|
|
native = strategy._build_harness_config()
|
|
prepare_native_config(native, strategy)
|
|
subprocess.Popen = tracked_popen
|
|
process, ws, _url = await asyncio.wait_for(strategy._spawn_harness_and_connect_ws(), timeout=10)
|
|
assert process.poll() is None, "the actual pinned native helper must be running"
|
|
assert len(launched_paths) == 1 and launched_paths[0] == strategy._binary_path
|
|
assert launched_paths[0] != sentinel
|
|
assert strategy._env["ANTIGRAVITY_HARNESS_PATH"] == launched_paths[0]
|
|
assert not os.path.exists(marker), "the cached override must never execute"
|
|
finally:
|
|
subprocess.Popen = original_popen
|
|
_CONTEXT.reset(token)
|
|
if ws is not None:
|
|
await ws.close()
|
|
for process in owned:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
for stream in (process.stdin, process.stdout, process.stderr):
|
|
if stream is not None:
|
|
stream.close()
|
|
assert owned and all(process.poll() is not None for process in owned)
|
|
asyncio.run(run())
|
|
sys.stderr.write("paperclip-native-helper-isolation-regression: passed\\n")
|
|
|
|
_paperclip_native_helper_isolation_regression()
|
|
`;
|
|
|
|
const nativeManagedSurfaceTests = `
|
|
|
|
def _paperclip_managed_surface_regressions():
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from google.antigravity import types as sdk_types
|
|
from google.antigravity.tools.tool_runner import ToolRunner
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server import browser_subagent
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server.server import AgyAdapter, AuthState
|
|
install(AgyAdapter)
|
|
patched_create_config = AgyAdapter._create_agent_config
|
|
install(AgyAdapter)
|
|
assert AgyAdapter._create_agent_config is patched_create_config
|
|
class MemoryStore:
|
|
def read(self): return None
|
|
def write(self, value): pass
|
|
def clear(self): pass
|
|
class FixtureCredentials(OAuthCredentialManager):
|
|
def __init__(self):
|
|
super().__init__(client_id="fixture", client_secret="fixture", scopes=[], token_path=os.path.join(os.environ["HOME"], "unused-credentials.json"), keychain_service="fixture", keychain_account="fixture", store=MemoryStore())
|
|
self.interactive_calls = 0
|
|
self.tier_info = SimpleNamespace(is_empty=True)
|
|
def _authenticate_interactively(self):
|
|
self.interactive_calls += 1
|
|
return SimpleNamespace(valid=True, token="controlled-fixture-token", refresh_token=None)
|
|
def _save_credentials(self, credentials): pass
|
|
def _resolve_backend(self, token, interactive_login=False): pass
|
|
async def run():
|
|
workspace = os.path.join(os.environ["HOME"], "managed-surface-workspace")
|
|
os.makedirs(workspace, exist_ok=True)
|
|
hooks_path = acp_hooks.get_global_hooks_path()
|
|
os.makedirs(os.path.dirname(hooks_path), exist_ok=True)
|
|
hooks = {"fixture": {"enabled": True, "PreInvocation": [{"hooks": [{"type": "command", "command": "false"}]}]}}
|
|
with open(hooks_path, "w") as output: json.dump(hooks, output)
|
|
for workspace_hooks_path in acp_hooks.get_workspace_hooks_paths(workspace):
|
|
os.makedirs(os.path.dirname(workspace_hooks_path), exist_ok=True)
|
|
with open(workspace_hooks_path, "w") as output: json.dump(hooks, output)
|
|
assert acp_hooks.has_workspace_hooks(workspace)
|
|
assert acp_hooks.load_hooks_config(workspace), "unmanaged source loader must remain functional"
|
|
content = " Exact managed source config 🧷\\n"
|
|
raw = content.encode()
|
|
admission = {"schema": SCHEMA, "nonce": "managed-source-policy", "model": "gemini-3.1-flash-lite", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw), "content": content}
|
|
adapter = AgyAdapter()
|
|
adapter._auth_state = AuthState(method_id="gemini-api-key", api_key="controlled-fixture-key-not-used")
|
|
original_preflight = browser_subagent.preflight_launcher
|
|
def forbidden_preflight(): raise AssertionError("managed browser launcher must never be probed")
|
|
token = _CONTEXT.set(admission)
|
|
try:
|
|
assert not acp_hooks.has_workspace_hooks(workspace)
|
|
assert acp_hooks.load_hooks_config(workspace) == {}
|
|
assert await acp_hooks.load_hooks_config_async(workspace) == {}
|
|
browser_subagent.preflight_launcher = forbidden_preflight
|
|
config, teardown = await adapter._create_agent_config(cwd=workspace, session_id="managed-source-policy-1234567890123456", gemini_home=os.environ["GEMINI_HOME"], model_id=admission["model"], system_instructions=content)
|
|
assert teardown is None
|
|
assert not config.capabilities.enable_subagents and not config.subagents
|
|
assert all(isinstance(hook, acp_hooks.ExternalOnInteractionHook) for hook in config.hooks)
|
|
strategy = config.create_strategy(tool_runner=ToolRunner(tools=config._get_all_custom_tools()), hook_runner=None)
|
|
native = strategy._build_harness_config()
|
|
assert not native.harness_side_tools.subagents.enabled
|
|
assert not native.harness_side_tools.run_workflow.enabled and not native.custom_subagents
|
|
assert not native.harness_side_tools.schedule.enabled
|
|
prepare_native_config(native, strategy)
|
|
managed_manager = FixtureCredentials()
|
|
adapter._oauth_mgr = managed_manager
|
|
assert adapter._ensure_oauth_mgr() is managed_manager
|
|
finally:
|
|
browser_subagent.preflight_launcher = original_preflight
|
|
_CONTEXT.reset(token)
|
|
try:
|
|
managed_manager.get_access_token()
|
|
except ValueError as error:
|
|
assert "renewed through Paperclip" in str(error)
|
|
else:
|
|
raise AssertionError("managed missing/expired credentials launched interactive auth")
|
|
assert managed_manager.interactive_calls == 0, "managed policy must survive outside its request context"
|
|
standalone = AgyAdapter()
|
|
interactive_manager = FixtureCredentials()
|
|
standalone._oauth_mgr = interactive_manager
|
|
await standalone.authenticate("oauth-personal")
|
|
assert interactive_manager.interactive_calls == 1, "explicit standalone authenticate must retain its interactive path"
|
|
assert not getattr(interactive_manager, "_paperclip_noninteractive", False)
|
|
assert acp_hooks.has_workspace_hooks(workspace)
|
|
assert acp_hooks.load_hooks_config(workspace), "unmanaged hooks policy must be unchanged"
|
|
asyncio.run(run())
|
|
sys.stderr.write("paperclip-managed-surface-regressions: passed\\n")
|
|
|
|
_paperclip_managed_surface_regressions()
|
|
`;
|
|
|
|
// Exercises the shipped authentication assertion and CCPA model parser. Only
|
|
// the metadata response and in-memory credentials are controlled. Session,
|
|
// native helper, browser, network, and enterprise-poller factories must not run.
|
|
const nativeModelCatalogTests = `
|
|
|
|
def _paperclip_model_catalog_regressions():
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import urllib.request
|
|
import webbrowser
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
from google.auth import exceptions as auth_exceptions
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server import client_info
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server import server as native_server
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server import settings_writer
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server.ccpa_connection import ccpa_client
|
|
AgyAdapter = native_server.AgyAdapter
|
|
AuthState = native_server.AuthState
|
|
install(AgyAdapter)
|
|
calls = []
|
|
main_thread = threading.get_ident()
|
|
response = {
|
|
"models": {
|
|
"gemini-exact-low-effort": {"displayName": " Exact café Model 🧷 ", "description": "private description must not escape"},
|
|
"gemini-exact-high-effort": {"displayName": "Native Default"},
|
|
"other-provider": {"displayName": "Filtered for this client"},
|
|
"gemini-disabled": {"displayName": "Not entitled", "disabled": True},
|
|
"gemini-unlisted": {"displayName": "Not advertised in sorts"},
|
|
},
|
|
"agentModelSorts": [{"groups": [{"modelIds": ["gemini-exact-low-effort", "other-provider", "gemini-disabled", "gemini-exact-low-effort", "gemini-exact-high-effort"]}]}],
|
|
"defaultAgentModelId": "gemini-exact-high-effort",
|
|
}
|
|
class MemoryStore:
|
|
def read(self): return None
|
|
def write(self, value): pass
|
|
def clear(self): pass
|
|
class Credentials(OAuthCredentialManager):
|
|
def __init__(self):
|
|
super().__init__(client_id="fixture", client_secret="fixture", scopes=[], token_path=os.path.join(os.environ["HOME"], "unused-model-credentials.json"), keychain_service="fixture", keychain_account="fixture", store=MemoryStore())
|
|
self._creds = SimpleNamespace(valid=True, token="controlled-catalog-token", refresh_token=None)
|
|
self.project_id = "controlled-project"
|
|
self.endpoint_url = "https://catalog.invalid"
|
|
self.interactive_calls = 0
|
|
self.resolutions = 0
|
|
def _authenticate_interactively(self):
|
|
self.interactive_calls += 1
|
|
raise AssertionError("catalog must never authenticate interactively")
|
|
def _save_credentials(self, credentials): pass
|
|
def _resolve_backend(self, token, interactive_login=False):
|
|
assert getattr(self, "_paperclip_noninteractive", False), "noninteractive policy must precede native authentication"
|
|
assert not interactive_login
|
|
self.resolutions += 1
|
|
def forbidden(*args, **kwargs):
|
|
raise AssertionError("session-free model discovery reached a forbidden factory")
|
|
async def rejected(adapter, params, message=None):
|
|
try:
|
|
await adapter.ext_method("_paperclip/models", params)
|
|
except exceptions.RequestError as error:
|
|
text = str(error)
|
|
assert "controlled-secret" not in text
|
|
assert "private description" not in text
|
|
if message is not None: assert message in text, text
|
|
return error
|
|
raise AssertionError("invalid model catalog operation succeeded")
|
|
request = {"schema": MODELS_SCHEMA, "nonce": "catalog-nonce 🧷", "authMethod": "oauth-personal"}
|
|
def adapter():
|
|
value = AgyAdapter(sdk_agent_factory=forbidden)
|
|
value._client_info = client_info.parse(SimpleNamespace(name="paperclip-runner", title="Paperclip Runner", version="test"))
|
|
value._oauth_mgr = Credentials()
|
|
return value
|
|
def fetch(token, endpoint, project, user_agent):
|
|
assert threading.get_ident() != main_thread, "catalog must use the bounded caller's worker thread"
|
|
assert token == "controlled-catalog-token" and endpoint == "https://catalog.invalid" and project == "controlled-project"
|
|
calls.append(user_agent)
|
|
return response
|
|
async def run():
|
|
settings_writer.save_auth_type("oauth-personal")
|
|
value = adapter()
|
|
assert value._auth_state is None, "saved settings must select the actual native auth method"
|
|
result = await value.ext_method("_paperclip/models", request)
|
|
assert result == {"schema": MODELS_SCHEMA, "nonce": request["nonce"], "authMethod": "oauth-personal", "models": [{"id": "gemini-exact-low-effort", "label": " Exact café Model 🧷 "}, {"id": "gemini-exact-high-effort", "label": "Native Default"}], "defaultModelId": "gemini-exact-high-effort"}
|
|
assert value._auth_state.method_id == "oauth-personal"
|
|
assert len(calls) == 1 and value._oauth_mgr.resolutions == 2
|
|
assert value._oauth_mgr._paperclip_noninteractive and value._oauth_mgr.interactive_calls == 0
|
|
assert value._sessions == {} and value._background_tasks == {}
|
|
assert value._proxy_server is None and value._baic_proxy_server is None and value._admin_controls_mgr is None
|
|
assert "private description" not in json.dumps(result)
|
|
for invalid in (None, {}, {**request, "schema": "other"}, {**request, "authMethod": "oauth-business"}, {**request, "authMethod": "gemini-api-key"}, {**request, "nonce": ""}, {**request, "nonce": 3}, {**request, "nonce": "x" * 513}, {**request, "extra": True}):
|
|
await rejected(value, invalid, "invalid model catalog request")
|
|
assert len(calls) == 1
|
|
for method in ("gemini-api-key", "oauth-business", "agent-platform", "gateway"):
|
|
wrong = adapter()
|
|
wrong._auth_state = AuthState(method_id=method)
|
|
await rejected(wrong, request, "sign-in must be renewed")
|
|
assert not getattr(wrong._oauth_mgr, "_paperclip_noninteractive", False)
|
|
logged_out = adapter()
|
|
logged_out._auth_state = AuthState(method_id="oauth-personal")
|
|
logged_out._logged_out = True
|
|
await rejected(logged_out, request, "sign-in must be renewed")
|
|
assert not getattr(logged_out._oauth_mgr, "_paperclip_noninteractive", False)
|
|
settings_writer.clear_auth_type()
|
|
await rejected(adapter(), request, "sign-in must be renewed")
|
|
settings_writer.save_auth_type("oauth-personal")
|
|
active = adapter()
|
|
active._sessions["not-created"] = object()
|
|
await rejected(active, request, "session-free")
|
|
active._sessions.clear()
|
|
active._paperclip_managed_sessions = {"not-created": {}}
|
|
await rejected(active, request, "session-free")
|
|
token = _CONTEXT.set({"model": "must-not-resolve"})
|
|
try: await rejected(adapter(), request, "session-free")
|
|
finally: _CONTEXT.reset(token)
|
|
assert len(calls) == 1
|
|
# The persistent manager policy must still hold after this request returns.
|
|
manager = value._oauth_mgr
|
|
manager._creds = None
|
|
try: manager.get_access_token()
|
|
except ValueError: pass
|
|
else: raise AssertionError("catalog manager lost its noninteractive policy")
|
|
await rejected(value, request, "sign-in must be renewed")
|
|
assert manager.interactive_calls == 0 and len(calls) == 1
|
|
expired = adapter()
|
|
class Expired:
|
|
valid = False
|
|
refresh_token = "controlled-refresh"
|
|
def refresh(self, request): raise auth_exceptions.RefreshError("controlled-secret refresh rejected")
|
|
expired._oauth_mgr._creds = Expired()
|
|
await rejected(expired, request, "sign-in must be renewed")
|
|
assert len(calls) == 1
|
|
# Provider failures and empty/malformed catalogs must not produce custom or
|
|
# hardcoded model fallbacks, or expose backend/credential response text.
|
|
with mock.patch.object(ccpa_client, "fetch_available_models", side_effect=RuntimeError("controlled-secret backend detail")):
|
|
await rejected(adapter(), request, "could not list models")
|
|
for bad in ({}, {"models": {}, "defaultAgentModelId": "unoffered"}, {**response, "models": {**response["models"], "gemini-exact-low-effort": {"displayName": {"controlled-secret": True}}}}):
|
|
with mock.patch.object(ccpa_client, "fetch_available_models", return_value=bad):
|
|
await rejected(adapter(), request, "could not list models")
|
|
with contextlib.ExitStack() as stack:
|
|
for owner, name in ((AgyAdapter, "_create_agent_config"), (AgyAdapter, "_ensure_business_auth_mgr"), (AgyAdapter, "_ensure_admin_controls_mgr"), (AgyAdapter, "_init_admin_controls_for_session"), (native_server.session_store, "trajectory_path"), (native_server.browser_subagent, "preflight_launcher"), (native_server.LocalAgentConfig, "create_strategy"), (subprocess, "Popen"), (asyncio, "create_subprocess_exec"), (socket, "create_connection"), (urllib.request, "urlopen"), (webbrowser, "open"), (webbrowser, "open_new_tab")):
|
|
stack.enter_context(mock.patch.object(owner, name, side_effect=forbidden))
|
|
stack.enter_context(mock.patch.object(ccpa_client, "fetch_available_models", side_effect=fetch))
|
|
stack.enter_context(mock.patch.dict(os.environ, {"AGY_ACP_DEFAULT_MODEL": "unoffered-custom-fallback"}))
|
|
asyncio.run(run())
|
|
sys.stderr.write("paperclip-model-catalog-regressions: passed\\n")
|
|
|
|
_paperclip_model_catalog_regressions()
|
|
`;
|
|
|
|
// These state tests use the shipped SDK config builder, ACP response classes,
|
|
// and SessionInfo. The native acknowledgment is controlled; no agent is entered
|
|
// and no authentication or network request occurs.
|
|
const nativeStateTests = `
|
|
|
|
def _paperclip_managed_state_regressions():
|
|
import asyncio
|
|
import contextlib
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from acp import schema
|
|
from google.antigravity import types as sdk_types
|
|
from google.antigravity.connections.local.local_connection_config import LocalAgentConfig
|
|
from google3.learning.genai.api.localharness import localharness_pb2 as pb
|
|
from google3.cloud.developer_experience.antigravity_extensions.acp_server.server import SessionInfo
|
|
sid = "native-managed-session-12345678901234567890"
|
|
def metadata(content, nonce):
|
|
raw = content.encode("utf-8")
|
|
return {"antigravity": {"paperclipAdmission": {"schema": SCHEMA, "nonce": nonce, "model": "literal-model", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw)}, "systemPrompt": {"append": content}}}
|
|
async def rejected(fn):
|
|
try:
|
|
await fn()
|
|
except exceptions.RequestError:
|
|
return
|
|
raise AssertionError("managed state mismatch was accepted")
|
|
class FixtureAdapter:
|
|
def __init__(self):
|
|
self._sessions = {}
|
|
self.prompts = 0
|
|
self.replacements = 0
|
|
async def initialize(self, **kwargs):
|
|
return schema.InitializeResponse(protocol_version=1, agent_capabilities=schema.AgentCapabilities(), auth_methods=[])
|
|
def _list_available_models(self, model):
|
|
return schema.SessionModelState(current_model_id=model, available_models=[])
|
|
async def _create_agent_config(self, **kwargs):
|
|
disabled = kwargs["tool_filter"].resolve_disabled_builtins()
|
|
assert sdk_types.BuiltinTools.INVOKE_SUBAGENT in disabled and sdk_types.BuiltinTools.RUN_WORKFLOW in disabled
|
|
assert sdk_types.BuiltinTools.SCHEDULE in disabled
|
|
return LocalAgentConfig(conversation_id=kwargs["session_id"], system_instructions=kwargs["system_instructions"], capabilities=sdk_types.CapabilitiesConfig(enable_subagents=False, disabled_tools=list(disabled)), models=[sdk_types.ModelTarget(name=kwargs["model_id"], types=[sdk_types.ModelType.TEXT], endpoint=sdk_types.GeminiAPIEndpoint())])
|
|
async def _initialize_fixture(self, session_id):
|
|
expected = _CONTEXT.get()
|
|
model = self._list_available_models("native-default").current_model_id
|
|
config = await self._create_agent_config(session_id=session_id, model_id=model, system_instructions=expected["content"])
|
|
strategy = config.create_strategy(tool_runner=None, hook_runner=None)
|
|
native = strategy._build_harness_config()
|
|
prepared = prepare_native_config(native, strategy)
|
|
ack = pb.OutputEvent(initialize_conversation_response=pb.InitializeConversationResponse(cascade_id=session_id))
|
|
strategy._paperclip_native_admission = acknowledge_native_config(prepared, ack, native)
|
|
agent = SimpleNamespace(_strategy=strategy, conversation=SimpleNamespace(is_idle=True))
|
|
self._sessions[session_id] = SessionInfo(agent=agent, stack=contextlib.AsyncExitStack(), current_model_id=model, system_prompt=expected["content"])
|
|
async def new_session(self, cwd, **kwargs):
|
|
await self._initialize_fixture(sid)
|
|
return schema.NewSessionResponse(session_id=sid)
|
|
async def load_session(self, session_id, cwd, **kwargs):
|
|
await self._initialize_fixture(session_id)
|
|
return schema.LoadSessionResponse()
|
|
async def resume_session(self, session_id, cwd, **kwargs):
|
|
await self._initialize_fixture(session_id)
|
|
return schema.ResumeSessionResponse()
|
|
async def _rebuild_session_agent(self, session_id):
|
|
await self._initialize_fixture(session_id)
|
|
async def _maybe_replace_unavailable_model(self, session_id):
|
|
self.replacements += 1
|
|
async def prompt(self, prompt, session_id, **kwargs):
|
|
self.prompts += 1
|
|
return schema.PromptResponse(stop_reason="end_turn")
|
|
async def set_session_model(self, model_id, session_id, **kwargs):
|
|
self._sessions[session_id].current_model_id = model_id
|
|
return schema.SetSessionModelResponse()
|
|
async def set_config_option(self, config_id, session_id, value, **kwargs):
|
|
self._sessions[session_id].current_model_id = value
|
|
return schema.SetSessionConfigOptionResponse(config_options=[])
|
|
async def cleanup(self, session_id=None):
|
|
if session_id is None:
|
|
self._sessions.clear()
|
|
else:
|
|
self._sessions.pop(session_id, None)
|
|
async def run():
|
|
adapter = FixtureAdapter()
|
|
await adapter.initialize()
|
|
first = await adapter.new_session("/fixture", **metadata(" exact café 🧷\\n", "open-nonce"))
|
|
receipt = first.field_meta["paperclipAntigravityAdmission"]
|
|
assert receipt["sessionId"] == sid and receipt["nonce"] == "open-nonce"
|
|
status = await adapter.ext_method("paperclip/admission", {"sessionId": sid, "nonce": "open-nonce"})
|
|
assert status["paperclipAntigravityAdmission"] == receipt
|
|
session = adapter._sessions[sid]
|
|
session.mode_id = "yolo"
|
|
await rejected(lambda: adapter.prompt([], sid))
|
|
assert adapter.prompts == 0
|
|
session.mode_id = "default"
|
|
await adapter._maybe_replace_unavailable_model(sid)
|
|
assert adapter.replacements == 0
|
|
changed = await adapter.set_config_option("model", sid, "literal-model", **metadata("", "setter-nonce"))
|
|
changed_receipt = changed.field_meta["paperclipAntigravityAdmission"]
|
|
assert changed_receipt["nonce"] == "setter-nonce" and changed_receipt["byteLength"] == 0
|
|
assert changed_receipt["connectionId"] != receipt["connectionId"]
|
|
await rejected(lambda: adapter.ext_method("paperclip/admission", {"sessionId": sid, "nonce": "open-nonce"}))
|
|
await rejected(lambda: adapter.load_session(sid, "/fixture"))
|
|
loaded = await adapter.load_session(sid, "/fixture", **metadata(" exact reload ", "load-nonce"))
|
|
assert loaded.field_meta["paperclipAntigravityAdmission"]["nonce"] == "load-nonce"
|
|
resumed = await adapter.resume_session(sid, "/fixture", **metadata(" exact resume ", "resume-nonce"))
|
|
assert resumed.field_meta["paperclipAntigravityAdmission"]["nonce"] == "resume-nonce"
|
|
await adapter.cleanup(sid)
|
|
assert not adapter._paperclip_managed_sessions
|
|
original_extract = system_prompt.extract_system_prompt
|
|
try:
|
|
install(FixtureAdapter)
|
|
asyncio.run(run())
|
|
finally:
|
|
system_prompt.extract_system_prompt = original_extract
|
|
sys.stderr.write("paperclip-managed-state-regressions: passed\\n")
|
|
|
|
_paperclip_managed_state_regressions()
|
|
`;
|
|
|
|
test("native patch rejects an unpinned executable before decoding", () => {
|
|
assert.throws(() => patchAntigravityServerBytes(Buffer.from("ambient executable"), Buffer.from("module")), /SHA-256 mismatch/);
|
|
});
|
|
|
|
test("source changes require a single exact pinned anchor", () => {
|
|
assert.throws(() => replaceAntigravitySource("changed", "original", "patch"), /anchor changed/);
|
|
assert.throws(() => replaceAntigravitySource("original original", "original", "patch"), /anchor changed/);
|
|
});
|
|
|
|
test("Mach-O section decoding rejects invalid architecture and out-of-bounds native archives", () => {
|
|
assert.throws(() => antigravityParSection(Buffer.alloc(64)), /ARM64 Mach-O/);
|
|
const bytes = Buffer.alloc(32 + 72 + 80);
|
|
bytes.writeUInt32LE(0xfeedfacf);
|
|
bytes.writeUInt32LE(0x0100000c, 4);
|
|
bytes.writeUInt32LE(1, 16);
|
|
bytes.writeUInt32LE(0x19, 32);
|
|
bytes.writeUInt32LE(152, 36);
|
|
bytes.writeUInt32LE(1, 96);
|
|
bytes.write(".par_data", 104);
|
|
bytes.writeBigUInt64LE(1000n, 144);
|
|
bytes.writeUInt32LE(10, 152);
|
|
assert.throws(() => antigravityParSection(bytes), /archive bounds/);
|
|
});
|
|
|
|
test("pinned native closure reproducibly loads exact admission and rejects requests before authentication", {
|
|
skip: !source || process.platform !== "darwin" || process.arch !== "arm64",
|
|
timeout: 180_000,
|
|
}, async t => {
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-antigravity-native-patch-test-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const originalHash = hash(await readFile(join(source, "agy_acp_server.par")));
|
|
const { patchAntigravityRuntime } = await import("./patch-antigravity-runtime.mjs");
|
|
const first = await patchAntigravityRuntime({ source, destination: join(root, "first") });
|
|
const second = await patchAntigravityRuntime({ source, destination: join(root, "second") });
|
|
assert.deepEqual(second, first, "the complete signed patch must be reproducible");
|
|
assert.equal(hash(await readFile(join(source, "agy_acp_server.par"))), originalHash, "vendor source remains immutable");
|
|
const content = " exact café 🧷\n";
|
|
const valid = admission(content);
|
|
const create = (id, value) => ({ jsonrpc: "2.0", id, method: "session/new", params: { cwd: root, mcpServers: [], _meta: { antigravity: { paperclipAdmission: value, systemPrompt: { append: content } } } } });
|
|
const models = { schema: "paperclip.antigravity.models.v1", nonce: "native-models-nonce", authMethod: "oauth-personal" };
|
|
const result = await runNative(join(root, "first/agy_acp_server.par"), join(root, "home"), [initialize(), create(2, { ...valid, digest: "sha256:" + "0".repeat(64) }), create(3, valid), { jsonrpc: "2.0", id: 4, method: "_paperclip/admission", params: { sessionId: "missing", nonce: valid.nonce } }, { jsonrpc: "2.0", id: 5, method: "_paperclip/models", params: models }, { jsonrpc: "2.0", id: 6, method: "_paperclip/models", params: { ...models, extra: true } }]);
|
|
const byId = new Map(result.events.filter(event => event.id !== undefined).map(event => [event.id, event]));
|
|
assert.equal(byId.get(1).result.agentInfo.name, "antigravity-acp");
|
|
assert.equal(byId.get(1).result.agentCapabilities._meta.antigravity.paperclipAdmission, 1);
|
|
assert.equal(byId.get(1).result.agentCapabilities._meta.antigravity.paperclipModels, 1);
|
|
assert.match(byId.get(2).error.message, /digest mismatch/);
|
|
assert.equal(byId.get(3).error.code, -32000, "valid metadata reaches the native authentication gate");
|
|
assert.match(byId.get(4).error.message, /unknown admission session or nonce/, "native status extension fails closed without a session");
|
|
assert.equal(byId.get(5).error.code, -32000, "session-free model discovery requires saved personal sign-in");
|
|
assert.match(byId.get(5).error.message, /sign-in must be renewed through Paperclip/);
|
|
assert.equal(byId.get(6).error.code, -32602, "the native model request contract rejects extra fields");
|
|
assert(!result.events.some(event => event.result?._meta?.paperclipAntigravityAdmission), "no native init means no fabricated receipt");
|
|
|
|
const fixtureScripts = join(root, "fixture-scripts");
|
|
const { mkdir } = await import("node:fs/promises");
|
|
await mkdir(fixtureScripts);
|
|
await copyFile(new URL("./patch-antigravity-runtime.mjs", import.meta.url), join(fixtureScripts, "patch-antigravity-runtime.mjs"));
|
|
const module = await readFile(new URL("./patch-antigravity-runtime.admission.py", import.meta.url), "utf8");
|
|
await writeFile(join(fixtureScripts, "patch-antigravity-runtime.admission.py"), module + nativeBoundaryTests + nativeHelperIsolationTests + nativeManagedSurfaceTests + nativeModelCatalogTests + nativeStateTests);
|
|
const fixture = await import(pathToFileURL(join(fixtureScripts, "patch-antigravity-runtime.mjs")));
|
|
await fixture.patchAntigravityRuntime({ source, destination: join(root, "boundary-fixture") });
|
|
const tested = await runNative(join(root, "boundary-fixture/agy_acp_server.par"), join(root, "fixture-home"), [initialize()]);
|
|
assert.match(tested.stderr, /paperclip-native-boundary-regressions: passed/);
|
|
assert.match(tested.stderr, /paperclip-native-helper-isolation-regression: passed/);
|
|
assert.match(tested.stderr, /paperclip-managed-surface-regressions: passed/);
|
|
assert.match(tested.stderr, /paperclip-model-catalog-regressions: passed/);
|
|
assert.match(tested.stderr, /paperclip-managed-state-regressions: passed/);
|
|
assert.equal(tested.events[0].result.agentCapabilities._meta.antigravity.paperclipAdmission, 1);
|
|
assert.equal(tested.events[0].result.agentCapabilities._meta.antigravity.paperclipModels, 1);
|
|
});
|
|
|
|
|
|
test("native client file callbacks settle exact tool identity without broadcasting errors", {
|
|
skip: !source || process.platform !== "darwin" || process.arch !== "arm64",
|
|
timeout: 90_000,
|
|
}, async t => {
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-antigravity-tool-lifecycle-test-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const fixtureScripts = join(root, "scripts");
|
|
const { mkdir } = await import("node:fs/promises");
|
|
await mkdir(fixtureScripts);
|
|
await copyFile(new URL("./patch-antigravity-runtime.mjs", import.meta.url), join(fixtureScripts, "patch-antigravity-runtime.mjs"));
|
|
const module = await readFile(new URL("./patch-antigravity-runtime.admission.py", import.meta.url), "utf8");
|
|
const regression = await readFile(new URL("./patch-antigravity-runtime.tool-lifecycle.test.py", import.meta.url), "utf8");
|
|
await writeFile(join(fixtureScripts, "patch-antigravity-runtime.admission.py"), module + "\n" + regression);
|
|
const fixture = await import(pathToFileURL(join(fixtureScripts, "patch-antigravity-runtime.mjs")));
|
|
await fixture.patchAntigravityRuntime({ source, destination: join(root, "runtime") });
|
|
const tested = await runNative(join(root, "runtime/agy_acp_server.par"), join(root, "home"), [initialize()]);
|
|
assert.match(tested.stderr, /paperclip-tool-lifecycle-regressions: passed/);
|
|
assert.equal(tested.events[0].result.agentCapabilities._meta.antigravity.paperclipAdmission, 1);
|
|
});
|