From 9ab6e634db7d331c298e51b73a079b22a7473d6f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 05:18:32 +0000 Subject: [PATCH 1/4] feat(kits): add experimental Codex gateway kit --- codex/README.md | 57 +++ codex/docs/decisions/0001-session-bridge.md | 33 ++ codex/docs/verification.md | 29 ++ codex/files/home/.local/bin/codex | 3 + codex/files/home/codex-kit/bridge.py | 391 ++++++++++++++++++++ codex/files/home/codex-kit/catalog.py | 35 ++ codex/files/home/codex-kit/install.py | 81 ++++ codex/files/home/codex-kit/launch.py | 114 ++++++ codex/files/home/codex-kit/process.py | 56 +++ codex/files/home/codex-kit/provider.py | 104 ++++++ codex/scripts/test | 9 + codex/spec.yaml | 31 ++ codex/tests/test_codex.py | 225 +++++++++++ codex/tests/test_process.py | 49 +++ 14 files changed, 1217 insertions(+) create mode 100644 codex/README.md create mode 100644 codex/docs/decisions/0001-session-bridge.md create mode 100644 codex/docs/verification.md create mode 100755 codex/files/home/.local/bin/codex create mode 100644 codex/files/home/codex-kit/bridge.py create mode 100644 codex/files/home/codex-kit/catalog.py create mode 100755 codex/files/home/codex-kit/install.py create mode 100755 codex/files/home/codex-kit/launch.py create mode 100644 codex/files/home/codex-kit/process.py create mode 100644 codex/files/home/codex-kit/provider.py create mode 100755 codex/scripts/test create mode 100644 codex/spec.yaml create mode 100644 codex/tests/test_codex.py create mode 100644 codex/tests/test_process.py diff --git a/codex/README.md b/codex/README.md new file mode 100644 index 0000000..fd90a46 --- /dev/null +++ b/codex/README.md @@ -0,0 +1,57 @@ +--- +title: Codex gateway kit +description: Codex CLI over a configured Chat Completions provider +status: draft +tier: 3 +--- + +# Codex gateway kit + +**Experimental; not ready for deployment.** Installs OpenAI Codex 0.166.0 on +Linux ARM64 or x64. Consumes acq's configured OpenAI-shaped model provider, +specifically its **Chat Completions** API. Codex requires Responses, so each +invocation owns a private authenticated loopback compatibility bridge. + +## Use + +```bash +export ACQ_EXTRA_KIT_SOURCES="github.com/mogul/" +acq run codex /path/to/project --kit '' --kit 'git+https://github.com/mogul/agentic-coding-kits.git#ref=&dir=codex' +``` + +Provider facts and credentials must already be materialized by acq. With more +than one provider set `ACQ_CODEX_PROVIDER`; `ACQ_CODEX_MODEL` chooses an exact +model ID. No OpenAI subscription login or second Paseo catalog writer is needed. +Only installation hosts are added by this kit; the provider kit owns inference +network access. Corporate CA trust remains a separate kit. + +## Boundaries + +- Text and ordinary function tools only. Native web search, images, encrypted + reasoning, custom tools and server-side Responses state are rejected. +- Upstream responses are buffered, then emitted as SSE. This is not live token + streaming. Token usage is unknown, not fabricated. +- Catalog metadata is deliberately conservative; matching a deployment name + does not establish native OpenAI capabilities. The kit supplies a minimal + generic coding instruction template because Codex requires catalog instructions. +- CLI routing/config overrides are rejected. Permissions are not weakened. + Terminal suspension/job control (Ctrl-Z, shell `fg`/`bg`) is not supported + by this wrapper; use interruption or exit instead. +- Codex state persists under `~/.local/state/acq/codex`; routing/catalog files + are per invocation. No credentials are written to disk. The model credential + stays in the wrapper process, not the CLI environment. +- A same-UID guest process can inspect process state. Loopback authentication is + not isolation from another process owned by the same user. + +## Verification + +Run `python3 scripts/test` for offline tests. Real CLI execution is opt-in; see +[verification](docs/verification.md). Real ARM64 CLI file-tool turns and isolated +Paseo sessions through a synthetic provider were exercised here; live gateway and x64 remain +unverified. See [design](docs/decisions/0001-session-bridge.md). + +## License + +Kit code follows this repository's MIT license. Codex is a separately downloaded +Apache-2.0 upstream product; this repository does not redistribute its binary. +Official source and release: . diff --git a/codex/docs/decisions/0001-session-bridge.md b/codex/docs/decisions/0001-session-bridge.md new file mode 100644 index 0000000..5dfe4bf --- /dev/null +++ b/codex/docs/decisions/0001-session-bridge.md @@ -0,0 +1,33 @@ +--- +title: Use an invocation-owned Responses bridge +description: Preserve acq provider ownership while supporting Codex +status: draft +tier: 2 +date: 2026-10-31 +--- + +# Use an invocation-owned Responses bridge + +## Context + +The existing provider contract describes Chat Completions, whereas Codex +0.166.0 accepts only Responses. Provider metadata must not select arbitrary +transports or supply credentials to unrelated hosts. + +## Options + +1. Require a Responses provider: incompatible with the current neutral contract. +2. Add a large translation dependency: more installation/supply-chain burden. +3. Implement a bounded standard-library text/function facade. + +## Decision + +Prototype option 3. Each invocation owns an authenticated loopback server; +the configured HTTPS endpoint is the only upstream route, with redirects denied. +Native model identifiers survive unchanged. No daemon or provider kit changes. + +## Consequences + +No extra runtime dependencies, but the supported protocol subset is limited. +Responses are buffered. Full streaming, media, reasoning and server-side state +are not claimed. Human review and real CLI/Paseo validation are release gates. diff --git a/codex/docs/verification.md b/codex/docs/verification.md new file mode 100644 index 0000000..282827f --- /dev/null +++ b/codex/docs/verification.md @@ -0,0 +1,29 @@ +--- +title: Codex verification +description: Observed checks and remaining release gates +status: draft +tier: 3 +--- + +# Verification + +The official 0.166.0 ARM64 archive matched its published SHA-256 and the binary +ran on Linux ARM64. A real `codex exec` using a generated catalog and local +synthetic Chat Completions transport returned `CODEX_PROBE_OK`, exit 0. +This verifies the request path, not live upstream model access. + +`python3 scripts/verify-native-cli.py codex --binary ` passed: +exact custom ID, function call, real file write, tool result and final response. +The offline suite and process-group SIGTERM/SIGHUP tests passed. A separate +Paseo 0.12.0 daemon with an isolated HOME and ephemeral loopback port discovered +only `exact.test_model` and completed a real Codex session returning +`PASEO_KIT_OK` through the staged wrapper and a local HTTPS synthetic provider. + +Final `make check` passed: lint/format, ShellCheck, six kit contracts, all kit +tests, 28 shared-tooling tests, dependency audits and secret scans. Codex's +14 tests passed on Python 3.12 and 3.14. Final adversarial review found no +blockers within the experimental text/function scope. The native verifier and +isolated Paseo session were rerun with the final code and passed. + +Remaining gates: remote GitHub CI, interactive PTY, native x64 and live gateway. +No real model credential was sent during these probes. diff --git a/codex/files/home/.local/bin/codex b/codex/files/home/.local/bin/codex new file mode 100755 index 0000000..db8413c --- /dev/null +++ b/codex/files/home/.local/bin/codex @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec python3 "$HOME/codex-kit/launch.py" "$@" diff --git a/codex/files/home/codex-kit/bridge.py b/codex/files/home/codex-kit/bridge.py new file mode 100644 index 0000000..2480d33 --- /dev/null +++ b/codex/files/home/codex-kit/bridge.py @@ -0,0 +1,391 @@ +"""Bounded, authenticated Responses/Gemini facade over Chat Completions. + +Upstream responses are buffered. SSE is generated only after validation; this is +not token-by-token streaming. No SDK, callbacks, remote tool execution, or URLs +from request bodies are passed to the upstream transport. +""" + +import hmac +import json +import secrets +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +from provider import strict_json + +MAX_BODY = 8 * 1024 * 1024 + + +class BoundedServer(ThreadingHTTPServer): + """Limit open worker threads before request parsing/authentication.""" + + daemon_threads = True + + def __init__(self, *args): + self.slots = threading.BoundedSemaphore(8) + super().__init__(*args) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + +def text(parts): + if isinstance(parts, str): + return parts + if not isinstance(parts, list): + raise ValueError("invalid text content") + result = [] + for part in parts: + if not isinstance(part, dict) or part.get("type") not in ("input_text", "output_text", "text"): + raise ValueError("only text content is supported") + if not isinstance(part.get("text"), str): + raise ValueError("invalid text content") + result.append(part["text"]) + return "\n".join(result) + + +def responses_request(data): + allowed = { + "model", + "input", + "instructions", + "tools", + "tool_choice", + "parallel_tool_calls", + "stream", + "store", + "client_metadata", + "include", + "prompt_cache_key", + "reasoning", + "text", + } + if set(data) - allowed: + raise ValueError("unsupported Responses fields") + if "stream" in data and type(data["stream"]) is not bool: + raise ValueError("stream must be boolean") + if "parallel_tool_calls" in data and type(data["parallel_tool_calls"]) is not bool: + raise ValueError("parallel_tool_calls must be boolean") + if data.get("tool_choice", "auto") not in ("auto", "none", "required"): + raise ValueError("unsupported tool selection") + if data.get("reasoning", {}).get("effort") not in (None, "none"): + raise ValueError("reasoning effort is unsupported") + if data.get("reasoning", {}).get("summary") not in (None, "none", "auto"): + raise ValueError("reasoning summaries are unsupported") + if data.get("text") not in (None, {}, {"format": {"type": "text"}}): + raise ValueError("structured output is unsupported") + messages = [] + if data.get("instructions"): + messages.append({"role": "system", "content": text(data["instructions"])}) + items = data.get("input", []) + if isinstance(items, str): + items = [{"role": "user", "content": items}] + if not isinstance(items, list): + raise ValueError("input must be an array or text") + for item in items: + if not isinstance(item, dict): + raise ValueError("invalid input item") + kind = item.get("type", "message") + if kind == "message": + role = item.get("role") + if role not in ("system", "developer", "user", "assistant"): + raise ValueError("unsupported message role") + messages.append( + {"role": "system" if role == "developer" else role, "content": text(item.get("content", []))} + ) + elif kind == "function_call": + name = item.get("name", "") + if item.get("namespace"): + name = item["namespace"] + "__" + name + call = { + "id": item["call_id"], + "type": "function", + "function": {"name": name, "arguments": item["arguments"]}, + } + if messages and messages[-1].get("tool_calls") and messages[-1]["role"] == "assistant": + messages[-1]["tool_calls"].append(call) + else: + messages.append({"role": "assistant", "content": None, "tool_calls": [call]}) + elif kind == "function_call_output": + messages.append({"role": "tool", "tool_call_id": item["call_id"], "content": text(item["output"])}) + elif kind == "reasoning" and not item.get("encrypted_content"): + continue + else: + raise ValueError("unsupported Responses input item") + tools, names = [], {} + for tool in data.get("tools", []): + if tool.get("type") == "namespace": + namespace, nested = tool["name"], tool["tools"] + else: + namespace, nested = None, [tool] + for definition in nested: + if definition.get("type") != "function": + raise ValueError("only ordinary function tools are supported") + original = definition["name"] + name = namespace + "__" + original if namespace else original + if name in names: + raise ValueError("duplicate function tool") + names[name] = (original, namespace) + tools.append( + { + "type": "function", + "function": { + "name": name, + "description": definition.get("description", ""), + "parameters": definition.get("parameters", {"type": "object", "properties": {}}), + }, + } + ) + if "strict" in definition: + if type(definition["strict"]) is not bool: + raise ValueError("strict must be boolean") + tools[-1]["function"]["strict"] = definition["strict"] + pending, used = set(), set() + for message in messages: + if message["role"] == "tool": + identity = message["tool_call_id"] + if identity not in pending: + raise ValueError("unpaired function result") + pending.remove(identity) + else: + if pending: + raise ValueError("function results must immediately follow their call batch") + for call in message.get("tool_calls", []): + identity = call["id"] + if not isinstance(identity, str) or identity in used: + raise ValueError("invalid or duplicate function ID") + used.add(identity) + pending.add(identity) + if pending: + raise ValueError("missing function results") + return messages, tools, names + + +def response_output(message, names): + output = [] + if message.get("content"): + output.append( + { + "id": "msg_" + secrets.token_hex(12), + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text(message["content"]), "annotations": []}], + } + ) + for call in message.get("tool_calls", []): + function = call["function"] + name, namespace = names[function["name"]] + arguments = function["arguments"] + if not isinstance(strict_json(arguments), dict): + raise ValueError("invalid upstream function arguments") + item = { + "id": "fc_" + secrets.token_hex(12), + "type": "function_call", + "status": "completed", + "call_id": call["id"], + "name": name, + "arguments": arguments, + } + if namespace: + item["namespace"] = namespace + output.append(item) + if not output: + raise ValueError("empty upstream response") + return output + + +def events(response): + sequence = 0 + + def event(kind, **fields): + nonlocal sequence + sequence += 1 + return ( + "event: " + + kind + + "\ndata: " + + json.dumps({"type": kind, "sequence_number": sequence - 1, **fields}) + + "\n\n" + ) + + pending = {**response, "status": "in_progress", "output": []} + yield event("response.created", response=pending) + yield event("response.in_progress", response=pending) + for index, item in enumerate(response["output"]): + initial = {**item, "status": "in_progress"} + if item["type"] == "message": + initial["content"] = [] + else: + initial["arguments"] = "" + yield event("response.output_item.added", output_index=index, item=initial) + if item["type"] == "message": + part = item["content"][0] + yield event( + "response.content_part.added", + item_id=item["id"], + output_index=index, + content_index=0, + part={**part, "text": ""}, + ) + yield event( + "response.output_text.delta", + item_id=item["id"], + output_index=index, + content_index=0, + delta=part["text"], + ) + yield event( + "response.output_text.done", item_id=item["id"], output_index=index, content_index=0, text=part["text"] + ) + yield event( + "response.content_part.done", item_id=item["id"], output_index=index, content_index=0, part=part + ) + else: + yield event( + "response.function_call_arguments.delta", + item_id=item["id"], + output_index=index, + delta=item["arguments"], + ) + yield event( + "response.function_call_arguments.done", + item_id=item["id"], + output_index=index, + arguments=item["arguments"], + ) + yield event("response.output_item.done", output_index=index, item=item) + yield event("response.completed", response=response) + + +def start(facts, ids, key, token, *, transport=None): + correlation = secrets.token_hex(16) + + def upstream(data): + request = urllib.request.Request( # noqa: S310 -- validated HTTPS facts, never request-body URLs + facts["baseUrl"].rstrip("/") + "/chat/completions", + data=json.dumps(data).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer " + key, + "User-Agent": "acq-cli-bridge/1.0", + "x-opencode-session": correlation, + }, + ) + + # No redirects: credentials must never follow a different endpoint. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + return None + + with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as result: # noqa: S310 -- validated HTTPS facts + raw = result.read(MAX_BODY + 1) + if len(raw) > MAX_BODY: + raise ValueError("upstream response too large") + return strict_json(raw) + + send = transport or upstream + gate = threading.BoundedSemaphore(4) + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + return + + def setup(self): + super().setup() + self.connection.settimeout(10) + + def reply(self, status, payload, content_type="application/json"): + body = payload.encode() if isinstance(payload, str) else json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + self.close_connection = True + + def do_POST(self): + auth = self.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or not hmac.compare_digest(auth[7:], token): + self.reply(401, {"error": {"message": "Bridge authentication required"}}) + return + if not gate.acquire(blocking=False): + self.reply(429, {"error": {"message": "Bridge concurrency limit reached"}}) + return + try: + if self.headers.get("Transfer-Encoding"): + raise ValueError("chunked requests unsupported") + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= MAX_BODY: + raise ValueError("invalid request size") + raw = self.rfile.read(length) + if len(raw) != length: + raise ValueError("truncated body") + data = strict_json(raw) + if not isinstance(data, dict): + raise ValueError("request must be an object") + path = urlsplit(self.path).path + if path == "/v1/responses": + model = data.get("model") + messages, tools, names = responses_request(data) + else: + self.reply(404, {"error": {"message": "Unsupported bridge route"}}) + return + if not isinstance(model, str) or model not in ids: + raise ValueError("model is not configured") + params = {"model": model, "messages": messages, "stream": False} + if tools: + params["tools"] = tools + params["tool_choice"] = data.get("tool_choice", "auto") + params["parallel_tool_calls"] = data.get("parallel_tool_calls", False) + upstream_result = send(params) + message = upstream_result["choices"][0]["message"] + output = response_output(message, names) + result = { + "id": "resp_" + secrets.token_hex(12), + "object": "response", + "status": "completed", + "model": model, + "output": output, + "usage": None, + } + if data.get("stream"): + self.reply(200, "".join(events(result)), "text/event-stream") + else: + self.reply(200, result) + except (ValueError, KeyError, TypeError, IndexError): + self.reply( + 400, + { + "error": { + "message": "Unsupported request or model; use a configured model and text/function tools" + } + }, + ) + except Exception: + self.reply(502, {"error": {"message": "Upstream request failed"}}) + finally: + gate.release() + + server = BoundedServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + return server diff --git a/codex/files/home/codex-kit/catalog.py b/codex/files/home/codex-kit/catalog.py new file mode 100644 index 0000000..400104c --- /dev/null +++ b/codex/files/home/codex-kit/catalog.py @@ -0,0 +1,35 @@ +"""Codex-owned catalog: exact provider IDs, conservative function-tool metadata.""" + + +def codex_catalog(models): + return { + "models": [ + { + "slug": model["id"], + "display_name": model["id"], + "description": "Configured Chat Completions deployment", + "supported_reasoning_levels": [], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": True, + "priority": index, + "availability_nux": None, + "upgrade": None, + "support_verbosity": False, + "default_verbosity": None, + "apply_patch_tool_type": None, + "truncation_policy": {"mode": "bytes", "limit": 10000}, + "experimental_supported_tools": [], + "input_modalities": ["text"], + "supports_reasoning_summary_parameter": False, + "node_repl_disabled": True, + "include_apps_usage_instructions": False, + "base_instructions": ( + "You are a coding assistant. Follow the user's task and workspace instructions. " + "Use the supplied function tools when needed, respect permission decisions, " + "verify changes, and report failures honestly." + ), + } + for index, model in enumerate(models) + ] + } diff --git a/codex/files/home/codex-kit/install.py b/codex/files/home/codex-kit/install.py new file mode 100755 index 0000000..2707864 --- /dev/null +++ b/codex/files/home/codex-kit/install.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Install a checksum-pinned official Codex archive without package scripts.""" + +import argparse +import hashlib +import os +import platform +import shutil +import tarfile +import tempfile +import urllib.request +from pathlib import Path +from urllib.parse import urlsplit + +VERSION = "0.166.0" +MAX_ARCHIVE = 500_000_000 +PINS = { + "aarch64": ("aarch64", "94eb707605ec2646b2e05b7edf7c4e7b3ccf02b16237c34ae9beb8cfb7f810a9"), + "x86_64": ("x86_64", "a0bf572d2f93f056c6712a6da348e96078dc84f3885fd7be67807ef389cb3978"), +} + + +def extract(archive, digest, member_name, destination, algorithm="sha256"): + with archive.open("rb") as source: + if hashlib.file_digest(source, algorithm).hexdigest() != digest: + raise ValueError("CLI archive checksum mismatch") + with tarfile.open(archive, "r:gz") as bundle: + members = [m for m in bundle.getmembers() if m.name == member_name] + if len(members) != 1 or not members[0].isreg() or not 1 <= members[0].size <= 500_000_000: + raise ValueError("archive must contain one bounded regular CLI binary") + with bundle.extractfile(members[0]) as source, destination.open("xb") as target: + shutil.copyfileobj(source, target) + destination.chmod(0o755) + + +def install(): + if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() not in PINS: + raise ValueError("install as the agent on Linux ARM64 or x64") + arch, digest = PINS[platform.machine()] + prefix = Path.home() / ".local/share/acq/codex" + prefix.mkdir(parents=True, exist_ok=True) + marker = prefix / "installed" + if marker.is_file() and marker.read_text() == VERSION + ":" + digest and (prefix / "codex").is_file(): + return + url = f"https://github.com/openai/codex/releases/download/rust-v{VERSION}/codex-{arch}-unknown-linux-musl.tar.gz" + with tempfile.TemporaryDirectory(prefix="install-", dir=prefix) as directory: + stage = Path(directory) + + # GitHub release redirects are expected; allow only its HTTPS asset host. + class Redirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + resolved = urlsplit(newurl) + if resolved.scheme != "https" or resolved.hostname not in ( + "github.com", + "release-assets.githubusercontent.com", + ): + raise ValueError("unexpected CLI download redirect") + return super().redirect_request(request, fp, code, msg, headers, newurl) + + with ( + urllib.request.build_opener(Redirect).open(url, timeout=120) as response, + (stage / "cli.tgz").open("xb") as target, + ): # noqa: S310 -- fixed official URL with pre-follow redirect validation + total = 0 + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > MAX_ARCHIVE: + raise ValueError("CLI archive exceeds size limit") + target.write(chunk) + extract(stage / "cli.tgz", digest, f"codex-{arch}-unknown-linux-musl", stage / "codex") + os.replace(stage / "codex", prefix / "codex") + marker.write_text(VERSION + ":" + digest) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true") + if parser.parse_args().apply: + install() + else: + print(f"Would install Codex {VERSION} in the agent home") diff --git a/codex/files/home/codex-kit/launch.py b/codex/files/home/codex-kit/launch.py new file mode 100755 index 0000000..221fcb5 --- /dev/null +++ b/codex/files/home/codex-kit/launch.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Launch pinned Codex with per-invocation routing and persistent kit-owned state.""" + +import json +import os +import secrets +import subprocess +import sys +import tempfile +from pathlib import Path + +from bridge import start +from catalog import codex_catalog +from process import run as run_process +from provider import load + + +def environment(keys, state, token): + env = os.environ.copy() + for name in (*keys, "GITHUB_TOKEN", "USAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CODEX_API_KEY"): + env.pop(name, None) + # Prevent an inherited remote daemon or existing user's provider config + # from bypassing the kit's explicit route. + for name in list(env): + if name.startswith("CODEX_") or name in ("OPENAI_BASE_URL", "PASEO_HOST"): + env.pop(name) + env.update(CODEX_HOME=str(state), ACQ_BRIDGE_TOKEN=token) + return env + + +def check_args(args, ids): + for index, argument in enumerate(args): + if argument == "--": + break + if argument in ( + "--oss", + "--local-provider", + "--remote", + "--remote-auth-token-env", + "--profile", + "-p", + "--search", + ): + raise ValueError("alternate routing and native search are unsupported") + if ( + argument.startswith(("--remote=", "--profile=", "--local-provider=", "--config=", "-c")) + or argument == "--config" + ): + raise ValueError("raw configuration overrides are unsupported") + if argument in ("-m", "--model") and (index + 1 == len(args) or args[index + 1] not in ids): + raise ValueError("choose an exact configured model ID") + if argument.startswith("--model=") and argument.split("=", 1)[1] not in ids: + raise ValueError("choose an exact configured model ID") + if argument.startswith("-m") and argument != "-m" and argument[2:] not in ids: + raise ValueError("choose an exact configured model ID") + + +def run(binary, args): + facts, models, primary, _, keys = load("CODEX") + ids = [m["id"] for m in models] + check_args(args, ids) + state = Path.home() / ".local/state/acq/codex" + state.mkdir(parents=True, exist_ok=True, mode=0o700) + token = secrets.token_urlsafe(32) + server = start(facts, ids, os.environ[facts["keyEnv"]], token) + try: + with tempfile.TemporaryDirectory(prefix="catalog-", dir=state) as directory: + catalog = Path(directory) / "models.json" + catalog.write_text(json.dumps(codex_catalog(models))) + route = { + "model": primary, + "model_provider": "acq", + "model_catalog_json": str(catalog), + "model_providers.acq.name": "Configured acq provider", + "model_providers.acq.base_url": f"http://127.0.0.1:{server.server_port}/v1", + "model_providers.acq.env_key": "ACQ_BRIDGE_TOKEN", + "model_providers.acq.wire_api": "responses", + "model_providers.acq.request_max_retries": 0, + "model_providers.acq.stream_max_retries": 0, + "model_providers.acq.supports_websockets": False, + "features.responses_websockets": False, + "features.responses_websockets_v2": False, + "features.apps": False, + "features.code_mode": False, + "analytics.enabled": False, + "feedback.enabled": False, + "web_search": "disabled", + } + config_args = [value for key, setting in route.items() for value in ("-c", key + "=" + json.dumps(setting))] + return run_process([str(binary), *config_args, *args], environment(keys, state, token)) + finally: + server.shutdown() + server.server_close() + + +def main(): + binary = Path.home() / ".local/share/acq/codex/codex" + args = sys.argv[1:] + if not binary.is_file(): + print("codex-kit: installation not ready", file=sys.stderr) + return 1 + if args in (["--help"], ["-h"], ["--version"], ["-V"]): + return subprocess.call([str(binary), *args]) # noqa: S603 -- fixed binary, flags allowlist + try: + return run(binary, args) + except (ValueError, OSError): + print("codex-kit: verify provider input, credentials and supported CLI arguments", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/codex/files/home/codex-kit/process.py b/codex/files/home/codex-kit/process.py new file mode 100644 index 0000000..d369f99 --- /dev/null +++ b/codex/files/home/codex-kit/process.py @@ -0,0 +1,56 @@ +"""Bounded ownership of one CLI process group and its terminal.""" + +import os +import signal +import subprocess +import sys +from contextlib import suppress + + +def foreground(group): + previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + try: + os.tcsetpgrp(sys.stdin.fileno(), group) + finally: + signal.signal(signal.SIGTTOU, previous) + + +def claim_terminal(): + foreground(os.getpgrp()) + + +def run(argv, env): + terminal = sys.stdin.isatty() + previous_group = os.tcgetpgrp(sys.stdin.fileno()) if terminal else None + child = subprocess.Popen([sys.executable, __file__, *argv], env=env, process_group=0) # noqa: S603 -- fixed helper, trusted CLI argv + + def interrupted(signum, _): + raise InterruptedError(signum) + + handlers = {s: signal.signal(s, interrupted) for s in (signal.SIGTERM, signal.SIGHUP)} + try: + return child.wait() + except InterruptedError as error: + return 128 + error.args[0] + except KeyboardInterrupt: + return 130 + finally: + # Cleanup cannot be interrupted by repeated supervisor/terminal signals. + protected = {s: signal.signal(s, signal.SIG_IGN) for s in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)} + with suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGTERM) + with suppress(subprocess.TimeoutExpired): + child.wait(3) + with suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGKILL) + child.wait() + if previous_group is not None: + foreground(previous_group) + for signum in protected: + signal.signal(signum, handlers.get(signum, protected[signum])) + + +if __name__ == "__main__": + if sys.stdin.isatty(): + claim_terminal() + os.execvpe(sys.argv[1], sys.argv[1:], os.environ) # noqa: S606 -- pinned CLI, argv only diff --git a/codex/files/home/codex-kit/provider.py b/codex/files/home/codex-kit/provider.py new file mode 100644 index 0000000..c2329d4 --- /dev/null +++ b/codex/files/home/codex-kit/provider.py @@ -0,0 +1,104 @@ +"""Read acq's backend-owned provider generation, never an agent-owned catalog.""" + +import json +import math +import os +import re +from pathlib import Path +from urllib.parse import urlsplit + + +def strict_json(raw): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON property") + result[key] = value + return result + + def invalid(_): + raise ValueError("non-finite JSON value") + + def finite(raw): + value = float(raw) + if not math.isfinite(value): + raise ValueError("non-finite JSON value") + return value + + return json.loads(raw, object_pairs_hook=unique, parse_constant=invalid, parse_float=finite) + + +def read(path): + if path.is_symlink() or not path.is_file() or path.stat().st_size > 2_000_000: + raise ValueError("provider input must be a bounded regular file") + value = strict_json(path.read_bytes()) + if not isinstance(value, dict): + raise ValueError("provider input must be an object") + return value + + +def load(kind, env=None): + env = os.environ if env is None else env + root = Path(env.get("ACQ_PROVIDER_INPUT_ROOT", "")) + if not root.is_absolute(): + raise ValueError("provider input root must be absolute") + for path in (root, root / "models", root / "models/providers"): + if path.is_symlink() or not path.is_dir(): + raise ValueError("provider input is not ready") + directories = list((root / "models/providers").iterdir()) + if any(p.is_symlink() or not p.is_dir() for p in directories): + raise ValueError("invalid provider directory") + selected = env.get(f"ACQ_{kind}_PROVIDER") + if selected is None and len(directories) == 1: + selected = directories[0].name + if not isinstance(selected, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", selected): + raise ValueError("select an unambiguous configured provider") + facts_by_id = {p.name: read(p / "facts.json") for p in directories} + for identity, facts in facts_by_id.items(): + url = urlsplit(facts.get("baseUrl", "")) + key = facts.get("keyEnv", "") + if ( + facts.get("schemaVersion") != "acq-provider-facts/v1" + or facts.get("providerId") != identity + or facts.get("apiProtocol") != "openai-compatible" + or url.scheme != "https" + or not url.hostname + or url.hostname != facts.get("host") + or url.username + or url.password + or url.query + or url.fragment + or not isinstance(key, str) + or not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) + or key == "GITHUB_TOKEN" + ): + raise ValueError("invalid provider facts") + if selected not in facts_by_id: + raise ValueError("selected provider is absent") + facts = facts_by_id[selected] + catalog = read(root / "models/providers" / selected / "snapshot.json") + if catalog.get("schemaVersion") != "acq-neutral-model-catalog/v1" or catalog.get("providerId") != selected: + raise ValueError("catalog identity mismatch") + models = catalog.get("models") + if not isinstance(models, list) or not 1 <= len(models) <= 500: + raise ValueError("invalid model list") + ids = [] + for model in models: + value = model.get("id") if isinstance(model, dict) else None + if ( + not isinstance(value, str) + or not 1 <= len(value) <= 128 + or re.search(r"[\x00-\x1f\x7f\u2028\u2029]", value) + or value in ids + ): + raise ValueError("invalid or duplicate model ID") + ids.append(value) + recommendations = catalog.get("recommendations", {}) + if not isinstance(recommendations, dict) or any(v not in ids for v in recommendations.values()): + raise ValueError("invalid recommendations") + primary = env.get(f"ACQ_{kind}_MODEL") or recommendations.get("primary") or (ids[0] if len(ids) == 1 else None) + small = env.get(f"ACQ_{kind}_SMALL_MODEL") or recommendations.get("small") or primary + if primary not in ids or small not in ids or not env.get(facts["keyEnv"]): + raise ValueError("select a configured model and inject its credential") + return facts, models, primary, small, {f["keyEnv"] for f in facts_by_id.values()} diff --git a/codex/scripts/test b/codex/scripts/test new file mode 100755 index 0000000..85563fe --- /dev/null +++ b/codex/scripts/test @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Run the dependency-free kit suite; real CLI checks require explicit opt-in.""" + +import subprocess +import sys +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +sys.exit(subprocess.call([sys.executable, "-m", "unittest", "discover", "-s", str(root / "tests"), "-v"], cwd=root)) diff --git a/codex/spec.yaml b/codex/spec.yaml new file mode 100644 index 0000000..803fb53 --- /dev/null +++ b/codex/spec.yaml @@ -0,0 +1,31 @@ +schemaVersion: hybrid/v1 +kind: mixin +name: codex +displayName: Codex (Chat Completions gateway) +description: Pinned Codex CLI with an invocation-owned Responses-to-Chat-Completions bridge. +agent: + name: codex + entrypoint: codex +caps: + network: + allow: [github.com, release-assets.githubusercontent.com] +files: + - {path: /home/agent/codex-kit/install.py, mode: "0755", source: files/home/codex-kit/install.py} + - {path: /home/agent/codex-kit/launch.py, mode: "0755", source: files/home/codex-kit/launch.py} + - {path: /home/agent/codex-kit/provider.py, mode: "0644", source: files/home/codex-kit/provider.py} + - {path: /home/agent/codex-kit/bridge.py, mode: "0644", source: files/home/codex-kit/bridge.py} + - {path: /home/agent/codex-kit/catalog.py, mode: "0644", source: files/home/codex-kit/catalog.py} + - {path: /home/agent/codex-kit/process.py, mode: "0644", source: files/home/codex-kit/process.py} + - {path: /home/agent/.local/bin/codex, mode: "0755", source: files/home/.local/bin/codex} +commands: + - phase: startup + user: "1000" + description: Install checksum-pinned Codex as the agent user + command: [python3, /home/agent/codex-kit/install.py, --apply] +agentContext: | + Run codex in the guest terminal. The kit consumes ACQ_PROVIDER_INPUT_ROOT + and acq-injected credentials; set ACQ_CODEX_PROVIDER if ambiguous and + ACQ_CODEX_MODEL to an exact configured deployment ID if no default exists. + The bridge supports text and ordinary function tools, not native search, + encrypted reasoning, images, or server-side Responses state. SSE is buffered. + No proxy port is published. Native Codex permissions remain active. diff --git a/codex/tests/test_codex.py b/codex/tests/test_codex.py new file mode 100644 index 0000000..6222fa6 --- /dev/null +++ b/codex/tests/test_codex.py @@ -0,0 +1,225 @@ +import hashlib +import importlib +import io +import json +import os +import sys +import tarfile +import tempfile +import unittest +import urllib.error +import urllib.request +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "files/home/codex-kit")) +bridge = importlib.import_module("bridge") +provider = importlib.import_module("provider") +installer = importlib.import_module("install") +launch = importlib.import_module("launch") +catalog = importlib.import_module("catalog") + + +class KitTests(unittest.TestCase): + def test_archive_integrity_and_member(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "cli.tgz" + with tarfile.open(archive, "w:gz") as bundle: + member = tarfile.TarInfo("cli") + member.size = 3 + bundle.addfile(member, io.BytesIO(b"cli")) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + installer.extract(archive, digest, "cli", root / "out") + self.assertEqual((root / "out").stat().st_mode & 0o777, 0o755) + with self.assertRaises(ValueError): + installer.extract(archive, "0" * 64, "cli", root / "bad") + with self.assertRaises(ValueError): + installer.extract(archive, digest, "absent", root / "bad") + + def test_pin_lengths(self): + self.assertEqual(set(installer.PINS), {"x86_64", "aarch64"}) + self.assertTrue(all(len(pin[1]) == 64 for pin in installer.PINS.values())) + + def test_strict_json(self): + for raw in ['{"a":1,"a":2}', '{"a":NaN}', '{"a":1e999}']: + with self.assertRaises(ValueError): + provider.strict_json(raw) + + def test_provider_validation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "models/providers/test" + path.mkdir(parents=True) + facts = { + "schemaVersion": "acq-provider-facts/v1", + "providerId": "test", + "apiProtocol": "openai-compatible", + "host": "example.invalid", + "baseUrl": "https://example.invalid/v1", + "keyEnv": "TEST_MODEL_KEY", + } + snapshot = { + "schemaVersion": "acq-neutral-model-catalog/v1", + "providerId": "test", + "models": [{"id": "exact.model_id"}], + "recommendations": {"primary": "exact.model_id"}, + } + (path / "facts.json").write_text(json.dumps(facts)) + (path / "snapshot.json").write_text(json.dumps(snapshot)) + env = {"ACQ_PROVIDER_INPUT_ROOT": str(root), "TEST_MODEL_KEY": "test-only"} + self.assertEqual(provider.load("CODEX", env)[2], "exact.model_id") + for field, value in [ + ("host", "other.invalid"), + ("baseUrl", "http://example.invalid"), + ("keyEnv", "GITHUB_TOKEN"), + ]: + (path / "facts.json").write_text(json.dumps({**facts, field: value})) + with self.assertRaises(ValueError): + provider.load("CODEX", env) + (path / "facts.json").write_text(json.dumps(facts)) + with self.assertRaises(ValueError): + provider.load("CODEX", {**env, "ACQ_CODEX_MODEL": "unknown"}) + with self.assertRaises(ValueError): + provider.load("CODEX", {"ACQ_PROVIDER_INPUT_ROOT": str(root)}) + + def test_argument_boundary_and_routing(self): + launch.check_args(["exec", "--", "--config=secret", "--model", "unknown"], ["exact"]) + for args in [ + ["--model", "unknown"], + ["--model=unknown"], + ["-c", "route=x"], + ["--remote", "ws://example"], + ["--search"], + ]: + with self.assertRaises(ValueError): + launch.check_args(args, ["exact"]) + + def test_environment_removes_credentials(self): + with patch.dict( + os.environ, {"TEST_MODEL_KEY": "sensitive", "GITHUB_TOKEN": "sensitive", "USAI_API_KEY": "sensitive"} + ): + result = launch.environment({"TEST_MODEL_KEY"}, Path("/state"), "local-token") + self.assertNotIn("TEST_MODEL_KEY", result) + self.assertNotIn("GITHUB_TOKEN", result) + self.assertNotIn("USAI_API_KEY", result) + self.assertEqual(result["ACQ_BRIDGE_TOKEN"], "local-token") + + def test_catalog_exact_ids(self): + result = catalog.codex_catalog([{"id": "exact.model_id"}]) + self.assertEqual(result["models"][0]["slug"], "exact.model_id") + self.assertEqual(result["models"][0]["supported_reasoning_levels"], []) + + def test_function_roundtrip(self): + messages, tools, names = bridge.responses_request( + { + "input": [ + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "task"}]}, + { + "type": "function_call", + "namespace": "ns", + "name": "write", + "arguments": "{}", + "call_id": "call_1", + }, + {"type": "function_call_output", "call_id": "call_1", "output": "done"}, + ], + "tools": [{"type": "namespace", "name": "ns", "tools": [{"type": "function", "name": "write"}]}], + } + ) + self.assertEqual(messages[0]["role"], "system") + self.assertEqual(tools[0]["function"]["name"], "ns__write") + output = bridge.response_output( + {"tool_calls": [{"id": "call_2", "function": {"name": "ns__write", "arguments": "{}"}}]}, names + ) + self.assertEqual(output[0]["namespace"], "ns") + self.assertEqual(output[0]["name"], "write") + self.assertIn( + "response.function_call_arguments.done", "".join(bridge.events({"status": "completed", "output": output})) + ) + + def test_unsupported_shapes(self): + for data in [ + {"input": [{"type": "compaction"}]}, + {"tools": [{"type": "web_search"}]}, + {"previous_response_id": "resp_other"}, + {"input": [{"role": "user", "content": [{"type": "input_image"}]}]}, + ]: + with self.assertRaises(ValueError): + bridge.responses_request(data) + + def test_sse_initial_items_are_empty(self): + item = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "ONCE", "annotations": []}], + } + frames = [json.loads(frame.split("data: ")[1]) for frame in bridge.events({"output": [item]})] + added = next(frame for frame in frames if frame["type"] == "response.output_item.added") + self.assertEqual(added["item"]["content"], []) + self.assertEqual([f["sequence_number"] for f in frames], list(range(len(frames)))) + + def test_rejects_behavior_constraints(self): + for data in [ + {"tool_choice": "made-up"}, + {"text": {"format": {"type": "json_schema"}}}, + {"reasoning": {"effort": "high"}}, + {"stream": "true"}, + ]: + with self.assertRaises(ValueError): + bridge.responses_request(data) + + def test_parallel_calls_are_one_batch_and_strict_is_preserved(self): + data = { + "input": [ + {"type": "function_call", "name": "f", "call_id": "a", "arguments": "{}"}, + {"type": "function_call", "name": "f", "call_id": "b", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "b", "output": "B"}, + {"type": "function_call_output", "call_id": "a", "output": "A"}, + ], + "tools": [{"type": "function", "name": "f", "strict": True}], + } + messages, tools, _ = bridge.responses_request(data) + self.assertEqual(len(messages), 3) + self.assertEqual(len(messages[0]["tool_calls"]), 2) + self.assertTrue(tools[0]["function"]["strict"]) + with self.assertRaises(ValueError): + bridge.responses_request({"input": data["input"][:2]}) + + def test_http_auth_model_and_fixed_errors(self): + seen = [] + + def transport(data): + seen.append(data) + return {"choices": [{"message": {"content": "OK"}}]} + + server = bridge.start( + {"baseUrl": "https://example.invalid/v1"}, ["exact"], "upstream-only", "local", transport=transport + ) + try: + url = f"http://127.0.0.1:{server.server_port}/v1/responses" + for token, model, expected in [("wrong", "exact", 401), ("local", "missing", 400), ("local", "exact", 200)]: + request = urllib.request.Request( + url, + data=json.dumps({"model": model, "input": "task", "stream": True}).encode(), + headers={"Authorization": "Bearer " + token}, + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + self.assertEqual(response.status, expected) + self.assertIn(b"response.completed", response.read()) + except urllib.error.HTTPError as error: + self.assertEqual(error.code, expected) + self.assertNotIn(b"upstream-only", error.read()) + error.close() + self.assertEqual(len(seen), 1) + self.assertEqual(set(seen[0]), {"model", "messages", "stream"}) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/codex/tests/test_process.py b/codex/tests/test_process.py new file mode 100644 index 0000000..0123bae --- /dev/null +++ b/codex/tests/test_process.py @@ -0,0 +1,49 @@ +import json +import signal +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +class LifecycleTests(unittest.TestCase): + def test_wrapper_only_signals_kill_uncooperative_descendants(self): + helper = Path(__file__).resolve().parents[1] / "files/home/codex-kit/process.py" + for signum in (signal.SIGTERM, signal.SIGHUP): + with self.subTest(signal=signum), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + child_code = "import signal,time;signal.signal(signal.SIGTERM,signal.SIG_IGN);time.sleep(60)" + parent_code = ( + "import subprocess,signal,time,os,json;from pathlib import Path;" + "signal.signal(signal.SIGTERM,signal.SIG_IGN);" + f"child=subprocess.Popen([{sys.executable!r},'-c',{child_code!r}]);" + f"Path({str(root / 'pids')!r}).write_text(json.dumps([os.getpid(),child.pid]));time.sleep(60)" + ) + code = ( + "import importlib.util,os,sys;" + f"s=importlib.util.spec_from_file_location('owned',{str(helper)!r});" + "m=importlib.util.module_from_spec(s);s.loader.exec_module(m);" + f"sys.exit(m.run([{sys.executable!r},'-c',{parent_code!r}],os.environ.copy()))" + ) + wrapper = subprocess.Popen([sys.executable, "-c", code], stdin=subprocess.DEVNULL) + try: + import time + + deadline = time.monotonic() + 10 + while not (root / "pids").exists(): + if time.monotonic() > deadline: + self.fail("child did not initialize") + time.sleep(0.02) + pids = json.loads((root / "pids").read_text()) + wrapper.send_signal(signum) + time.sleep(0.1) + wrapper.send_signal(signum) + self.assertEqual(wrapper.wait(8), 128 + signum) + for pid in pids: + status = Path(f"/proc/{pid}/stat") + self.assertTrue(not status.exists() or status.read_text().split()[2] == "Z") + finally: + if wrapper.poll() is None: + wrapper.kill() + wrapper.wait() From c518a9e27a439640c6c1cf866e06f7041cb628bc Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 05:18:36 +0000 Subject: [PATCH 2/4] feat(kits): add experimental Antigravity gateway kit --- antigravity/README.md | 63 +++++ .../docs/decisions/0001-session-bridge.md | 29 ++ antigravity/docs/verification.md | 33 +++ antigravity/files/home/.local/bin/agy | 3 + .../files/home/antigravity-kit/bridge.py | 267 ++++++++++++++++++ .../files/home/antigravity-kit/install.py | 79 ++++++ .../files/home/antigravity-kit/launch.py | 78 +++++ .../files/home/antigravity-kit/process.py | 56 ++++ .../files/home/antigravity-kit/provider.py | 105 +++++++ antigravity/scripts/test | 9 + antigravity/spec.yaml | 32 +++ antigravity/tests/test_antigravity.py | 131 +++++++++ antigravity/tests/test_process.py | 20 ++ 13 files changed, 905 insertions(+) create mode 100644 antigravity/README.md create mode 100644 antigravity/docs/decisions/0001-session-bridge.md create mode 100644 antigravity/docs/verification.md create mode 100755 antigravity/files/home/.local/bin/agy create mode 100644 antigravity/files/home/antigravity-kit/bridge.py create mode 100755 antigravity/files/home/antigravity-kit/install.py create mode 100755 antigravity/files/home/antigravity-kit/launch.py create mode 100644 antigravity/files/home/antigravity-kit/process.py create mode 100644 antigravity/files/home/antigravity-kit/provider.py create mode 100755 antigravity/scripts/test create mode 100644 antigravity/spec.yaml create mode 100644 antigravity/tests/test_antigravity.py create mode 100644 antigravity/tests/test_process.py diff --git a/antigravity/README.md b/antigravity/README.md new file mode 100644 index 0000000..17ec8a0 --- /dev/null +++ b/antigravity/README.md @@ -0,0 +1,63 @@ +--- +title: Antigravity gateway kit +description: Google agy CLI over a configured Chat Completions provider +status: draft +tier: 3 +--- + +# Antigravity gateway kit + +**Experimental; not ready for deployment.** Installs Google's `agy` CLI 1.4.12 +on Linux ARM64 or x64—not the Antigravity editor launcher. Paseo supports it via +its Antigravity provider plugin. Each invocation translates Gemini requests to +the configured OpenAI-shaped model provider's **Chat Completions** endpoint. + +## Use + +```bash +export ACQ_EXTRA_KIT_SOURCES="github.com/mogul/" +acq run antigravity /path/to/project --kit '' --kit 'git+https://github.com/mogul/agentic-coding-kits.git#ref=&dir=antigravity' +``` + +Provider facts and credentials must be materialized by acq. With multiple +providers set `ACQ_ANTIGRAVITY_PROVIDER`. `ACQ_ANTIGRAVITY_MODEL` and +`ACQ_ANTIGRAVITY_SMALL_MODEL` select exact configured IDs. The wrapper's +`agy models` reports those IDs for Paseo discovery. No global Paseo configuration +or Google login is written. CA trust is supplied separately. + +## Limitations + +- The native terminal picker may show more models than the configured + OpenAI-shaped model provider supports. Choose an exact configured ID; + unsupported selections fail rather than route to a similarly named model. +- `customModelsConfig` selects exact IDs, but the CLI still speaks Gemini. + Internal `gemini-3.1-flash-lite-preview` housekeeping requests with no tools + explicitly route to the configured small/default model. This is separate + from user model selection. +- Text and ordinary function tools only; media, native Gemini search and + thinking features are not supported. Upstream responses are buffered. + agy 1.4.12 always requests automatic thought output for custom models; the + kit explicitly ignores that automatic envelope in its text/function mode. + No thought output or reasoning-effort guarantee is provided. Explicit effort + flags and non-default thinking settings are rejected. +- **Paseo's plugin launches agy with full access and no permission prompts.** + The kit does not add that flag for terminal use. An acq microVM remains the + external boundary; do not assume a CLI plan mode is a filesystem sandbox. +- Persistent conversations use `~/.local/state/acq/antigravity` as their HOME. + Terminal suspension/job control (Ctrl-Z, shell `fg`/`bg`) is not supported + by the wrapper; use interruption or exit instead. + This intentionally separates Google credentials and state from other agents, + but user-global skills/configuration outside that HOME are not inherited. +- No credentials are persisted. The wrapper holds the upstream key; agy gets + only a random per-invocation loopback token. Same-UID processes are not isolated. + +## Verification and license + +Run `python3 scripts/test`. See [verification](docs/verification.md) and +[design](docs/decisions/0001-session-bridge.md). Real ARM64 CLI custom-ID file-tool +turns and isolated Paseo sessions passed against a synthetic provider. Live +gateway, interactive PTY and x64 execution remain unverified. + +Kit code is MIT. Antigravity is a separately downloaded proprietary Google +product; installation does not grant rights beyond its upstream terms. +Official product: . diff --git a/antigravity/docs/decisions/0001-session-bridge.md b/antigravity/docs/decisions/0001-session-bridge.md new file mode 100644 index 0000000..925f8c4 --- /dev/null +++ b/antigravity/docs/decisions/0001-session-bridge.md @@ -0,0 +1,29 @@ +--- +title: Use an invocation-owned Gemini bridge +description: Route agy custom models to acq Chat Completions providers +status: draft +tier: 2 +date: 2026-10-31 +--- + +# Use an invocation-owned Gemini bridge + +## Context and options + +agy 1.4.12 rejects modelProvider=openai, but accepts session-only Gemini +settings and customModelsConfig.customModels. Considered native OpenAI routing +(not supported in the live probe), global Google settings (collides with other +agents), and an invocation-owned bridge with a dedicated persistent HOME. + +## Decision + +Prototype the bridge. Keep exact custom deployment IDs and expose those through +the wrapper's model discovery. Explicitly route internal lightweight housekeeping +to the configured small model. Use standard-library HTTPS with redirects denied. + +## Consequences + +No runtime dependency install, Google login or global Paseo writer. This loses +global CLI configuration/skill inheritance and requires a limited Gemini mapping. +Media/reasoning/native search are unsupported. Paseo's upstream plugin uses full +access; external workspace isolation and human review remain essential. diff --git a/antigravity/docs/verification.md b/antigravity/docs/verification.md new file mode 100644 index 0000000..f82f821 --- /dev/null +++ b/antigravity/docs/verification.md @@ -0,0 +1,33 @@ +--- +title: Antigravity verification +description: Observed native routing and remaining release gates +status: draft +tier: 3 +--- + +# Verification + +The official 1.4.12 ARM64 archive matched the versioned Google manifest's +SHA-512. `agy --help` ran. A synthetic HTTP probe observed a request to +`/v1beta/models/test-model:streamGenerateContent` for an exact custom ID, +plus a separate lightweight housekeeping request. + +The real CLI subsequently completed a text turn and a file-tool turn against a +synthetic transport. `python3 scripts/verify-native-cli.py antigravity --binary +` repeats the exact-ID/function/file/result path without credentials. +The native default headless mode denied a write; explicit full access in the +isolated fixture allowed it. Kit terminal defaults were not weakened. + +A separate Paseo 0.12.0 daemon, isolated HOME and ephemeral loopback port +discovered only `exact.test_model` and completed a real Antigravity session +returning `PASEO_KIT_OK` through the staged wrapper and a local HTTPS synthetic +provider. Offline checks and all six repository contracts passed. + +Final `make check` passed: lint/format, ShellCheck, all six contracts and all +kit/shared tests, dependency audits and secret scans. The nine Antigravity +tests passed on Python 3.12 and 3.14. Final adversarial review found no blockers +within the experimental text/function scope. The native verifier and isolated +Paseo session were rerun with final code and passed. + +Remaining gates: remote GitHub CI, interactive PTY, x64 and live gateway. +These synthetic tests prove wiring, not remote model behavior or quality. diff --git a/antigravity/files/home/.local/bin/agy b/antigravity/files/home/.local/bin/agy new file mode 100755 index 0000000..7cf38c4 --- /dev/null +++ b/antigravity/files/home/.local/bin/agy @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec python3 "$HOME/antigravity-kit/launch.py" "$@" diff --git a/antigravity/files/home/antigravity-kit/bridge.py b/antigravity/files/home/antigravity-kit/bridge.py new file mode 100644 index 0000000..44cb8cb --- /dev/null +++ b/antigravity/files/home/antigravity-kit/bridge.py @@ -0,0 +1,267 @@ +"""Authenticated, text/function-only Gemini facade over Chat Completions.""" + +import hmac +import json +import secrets +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import unquote, urlsplit + +from provider import strict_json + +MAX_BODY = 8 * 1024 * 1024 + + +class BoundedServer(ThreadingHTTPServer): + """Limit open worker threads before request parsing/authentication.""" + + daemon_threads = True + + def __init__(self, *args): + self.slots = threading.BoundedSemaphore(8) + super().__init__(*args) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + +def translate(data): + if set(data) - {"contents", "systemInstruction", "tools", "generationConfig", "toolConfig"}: + raise ValueError("unsupported Gemini fields") + config = data.get("generationConfig", {}) + if set(config) - {"temperature", "topP", "topK", "maxOutputTokens", "thinkingConfig"}: + raise ValueError("unsupported generation settings") + thinking = config.get("thinkingConfig", {}) + # agy 1.4.12 emits this automatic envelope for ALL custom models. The kit + # explicitly operates in text/function mode, not Gemini reasoning mode. + if thinking not in ({}, {"includeThoughts": True, "thinkingBudget": -1}, {"thinkingBudget": 0}): + raise ValueError("thinking is unsupported") + if data.get("toolConfig") not in (None, {}, {"functionCallingConfig": {"mode": "AUTO"}}): + raise ValueError("unsupported tool selection") + messages, tools = [], [] + pending = {} + call_number = 0 + used = { + part["functionCall"]["id"] + for content in data.get("contents", []) + for part in content.get("parts", []) + if "functionCall" in part and part["functionCall"].get("id") + } + explicit = set() + if data.get("systemInstruction"): + messages.append({"role": "system", "content": "\n".join(p["text"] for p in data["systemInstruction"]["parts"])}) + for content in data.get("contents", []): + if content.get("role") not in ("model", "user"): + raise ValueError("invalid Gemini content role") + role = "assistant" if content["role"] == "model" else "user" + texts, calls, outputs = [], [], [] + for part in content["parts"]: + if "text" in part: + texts.append(part["text"]) + elif "functionCall" in part: + call = part["functionCall"] + call_number += 1 + identity = call.get("id") + if identity: + if identity in explicit: + raise ValueError("duplicate function ID") + explicit.add(identity) + else: + while f"call_{call_number}" in used: + call_number += 1 + identity = f"call_{call_number}" + used.add(identity) + pending.setdefault(call["name"], []).append(identity) + calls.append( + { + "id": identity, + "type": "function", + "function": { + "name": call["name"], + "arguments": json.dumps(call.get("args", {})), + }, + } + ) + elif "functionResponse" in part: + response = part["functionResponse"] + if response.get("parts"): + raise ValueError("media tool results are unsupported") + identities = pending.get(response["name"], []) + identity = response.get("id") + if identity is None: + if len(identities) != 1: + raise ValueError("ambiguous function response") + identity = identities[0] + if identity not in identities: + raise ValueError("unpaired function response") + identities.remove(identity) + outputs.append( + { + "role": "tool", + "tool_call_id": identity, + "content": json.dumps(response["response"]), + } + ) + else: + raise ValueError("only text/function parts are supported") + if texts or calls: + message = {"role": role, "content": "\n".join(texts) or None} + if calls: + message["tool_calls"] = calls + messages.append(message) + messages.extend(outputs) + for tool in data.get("tools", []): + if set(tool) != {"functionDeclarations"}: + raise ValueError("only ordinary function tools are supported") + for function in tool["functionDeclarations"]: + tools.append( + { + "type": "function", + "function": { + "name": function["name"], + "description": function.get("description", ""), + "parameters": function.get( + "parametersJsonSchema", function.get("parameters", {"type": "object"}) + ), + }, + } + ) + return messages, tools + + +def output(message, names): + parts = [] + if message.get("content"): + if not isinstance(message["content"], str): + raise ValueError("invalid upstream text") + parts.append({"text": message["content"]}) + for call in message.get("tool_calls", []): + function = call["function"] + arguments = strict_json(function["arguments"]) + if function["name"] not in names or not isinstance(arguments, dict): + raise ValueError("invalid upstream function call") + parts.append({"functionCall": {"id": call["id"], "name": function["name"], "args": arguments}}) + if not parts: + raise ValueError("empty upstream response") + return {"candidates": [{"content": {"role": "model", "parts": parts}, "finishReason": "STOP"}]} + + +def start(facts, ids, key, token, small, transport=None): + correlation = secrets.token_hex(16) + + def upstream(data): + request = urllib.request.Request( # noqa: S310 -- validated HTTPS facts, never request-body URLs + facts["baseUrl"].rstrip("/") + "/chat/completions", + data=json.dumps(data).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer " + key, + "User-Agent": "acq-antigravity-bridge/1.0", + "x-opencode-session": correlation, + }, + ) + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + return None + + with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as result: # noqa: S310 -- validated HTTPS facts + raw = result.read(MAX_BODY + 1) + if len(raw) > MAX_BODY: + raise ValueError("upstream response too large") + return strict_json(raw) + + send = transport or upstream + gate = threading.BoundedSemaphore(4) + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + return + + def setup(self): + super().setup() + self.connection.settimeout(10) + + def reply(self, status, payload, content_type="application/json"): + raw = payload.encode() if isinstance(payload, str) else json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_POST(self): + if not hmac.compare_digest(self.headers.get("x-goog-api-key", ""), token): + self.reply(401, {"error": {"code": 401, "message": "Bridge authentication required"}}) + return + if not gate.acquire(blocking=False): + self.reply(429, {"error": {"code": 429, "message": "Bridge concurrency limit reached"}}) + return + try: + if self.headers.get("Transfer-Encoding"): + raise ValueError("chunked requests unsupported") + size = int(self.headers.get("Content-Length", "0")) + if not 0 < size <= MAX_BODY: + raise ValueError("invalid request size") + raw = self.rfile.read(size) + if len(raw) != size: + raise ValueError("truncated body") + data = strict_json(raw) + if not isinstance(data, dict): + raise ValueError("request must be an object") + path = urlsplit(self.path).path + if not path.startswith("/v1beta/models/") or not path.endswith( + (":generateContent", ":streamGenerateContent") + ): + self.reply(404, {"error": {"code": 404, "message": "Unsupported bridge route"}}) + return + model = unquote(path[len("/v1beta/models/") :].rsplit(":", 1)[0]) + if model == "gemini-3.1-flash-lite-preview" and not data.get("tools"): + model = small + if model not in ids: + raise ValueError("model is not configured") + messages, tools = translate(data) + params = {"model": model, "messages": messages, "stream": False} + config = data.get("generationConfig", {}) + for source, target in [ + ("temperature", "temperature"), + ("topP", "top_p"), + ("maxOutputTokens", "max_tokens"), + ]: + if source in config: + params[target] = config[source] + if "topK" in config: + raise ValueError("topK is unsupported by Chat Completions") + if tools: + params["tools"] = tools + result = send(params) + payload = output(result["choices"][0]["message"], {t["function"]["name"] for t in tools}) + if path.endswith(":streamGenerateContent"): + self.reply(200, "data: " + json.dumps(payload) + "\n\n", "text/event-stream") + else: + self.reply(200, payload) + except (ValueError, TypeError, KeyError, IndexError): + self.reply(400, {"error": {"code": 400, "message": "Unsupported request or configured model"}}) + except Exception: + self.reply(502, {"error": {"code": 502, "message": "Upstream request failed"}}) + finally: + gate.release() + + server = BoundedServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + return server diff --git a/antigravity/files/home/antigravity-kit/install.py b/antigravity/files/home/antigravity-kit/install.py new file mode 100755 index 0000000..3cad4c2 --- /dev/null +++ b/antigravity/files/home/antigravity-kit/install.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Install a checksum-pinned official Antigravity binary, without shell setup.""" + +import argparse +import hashlib +import os +import platform +import shutil +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +VERSION = "1.4.12" +BUILD = "4768735748292608" +PINS = { + "aarch64": ( + "linux-arm/cli_linux_arm64.tar.gz", + "7cae317fb6a02eb387982f06bfb234b366879cf9a80ad8969b571e5a55d1e37a6a5a49cd6b703c0443bb417937cf5e3b14a8c80cb26917ad79eef723a9f374c7", + ), + "x86_64": ( + "linux-x64/cli_linux_x64.tar.gz", + "ea01c73b772187bca928852cf96235f0d846d6da5ebdac188b9ae719597465737f325540d483ee5f3f62d151029001713344972a187ddfb98cc7b97f7204b7f4", + ), +} + + +def extract(archive, digest, destination): + with archive.open("rb") as source: + if hashlib.file_digest(source, "sha512").hexdigest() != digest: + raise ValueError("Antigravity archive checksum mismatch") + with tarfile.open(archive, "r:gz") as bundle: + members = [m for m in bundle.getmembers() if m.name == "antigravity"] + if len(members) != 1 or not members[0].isreg() or not 1 <= members[0].size <= 500_000_000: + raise ValueError("archive must contain one bounded regular CLI binary") + with bundle.extractfile(members[0]) as source, destination.open("xb") as target: + shutil.copyfileobj(source, target) + destination.chmod(0o755) + + +def install(): + if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() not in PINS: + raise ValueError("install as the agent on Linux ARM64 or x64") + platform_path, digest = PINS[platform.machine()] + prefix = Path.home() / ".local/share/acq/antigravity" + prefix.mkdir(parents=True, exist_ok=True) + marker = prefix / "installed" + if marker.is_file() and marker.read_text() == VERSION + ":" + digest and (prefix / "agy").is_file(): + return + url = f"https://storage.googleapis.com/antigravity-public/antigravity-cli/{VERSION}-{BUILD}/{platform_path}" + with tempfile.TemporaryDirectory(prefix="install-", dir=prefix) as directory: + stage = Path(directory) + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + raise ValueError("unexpected Antigravity download redirect") + + with ( + urllib.request.build_opener(NoRedirect).open(url, timeout=120) as response, + (stage / "cli.tgz").open("xb") as target, + ): # noqa: S310 -- fixed official URL, redirects denied + total = 0 + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > 500_000_000: + raise ValueError("CLI archive exceeds size limit") + target.write(chunk) + extract(stage / "cli.tgz", digest, stage / "agy") + os.replace(stage / "agy", prefix / "agy") + marker.write_text(VERSION + ":" + digest) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true") + if parser.parse_args().apply: + install() + else: + print(f"Would install Antigravity {VERSION} in the agent home") diff --git a/antigravity/files/home/antigravity-kit/launch.py b/antigravity/files/home/antigravity-kit/launch.py new file mode 100755 index 0000000..78fefad --- /dev/null +++ b/antigravity/files/home/antigravity-kit/launch.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Run agy against an invocation-owned bridge and kit-owned persistent home.""" + +import json +import os +import secrets +import subprocess +import sys +from pathlib import Path + +from bridge import start +from process import run as run_process +from provider import load + + +def run(binary, args): + facts, models, primary, small, keys = load() + ids = [m["id"] for m in models] + if args == ["models"]: + for model in models: + print(model["id"] + "\t" + model["id"]) + return 0 + for index, argument in enumerate(args): + if argument == "--": + break + if argument in ("--settings", "--remote-control", "--effort") or argument.startswith( + ("--settings=", "--effort=") + ): + raise ValueError("alternate settings or remote control are unsupported") + if argument == "--model" and (index + 1 == len(args) or args[index + 1] not in ids): + raise ValueError("choose an exact configured model ID") + if argument.startswith("--model=") and argument.split("=", 1)[1] not in ids: + raise ValueError("choose an exact configured model ID") + state = Path.home() / ".local/state/acq/antigravity" + state.mkdir(parents=True, exist_ok=True, mode=0o700) + token = secrets.token_urlsafe(32) + server = start(facts, ids, os.environ[facts["keyEnv"]], token, small) + env = os.environ.copy() + for name in (*keys, "GITHUB_TOKEN", "USAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"): + env.pop(name, None) + env.update( + HOME=str(state), + XDG_CONFIG_HOME=str(state / ".config"), + XDG_CACHE_HOME=str(state / ".cache"), + XDG_DATA_HOME=str(state / ".local/share"), + GEMINI_API_KEY=token, + GOOGLE_GEMINI_BASE_URL=f"http://127.0.0.1:{server.server_port}", + ) + settings = { + "modelProvider": "gemini", + "customModelsConfig": {"customModels": {value: {"modelName": value, "apiProvider": "gemini"} for value in ids}}, + } + try: + return run_process([str(binary), "--settings", json.dumps(settings), "--model", primary, *args], env) + finally: + server.shutdown() + server.server_close() + + +def main(): + binary = Path.home() / ".local/share/acq/antigravity/agy" + args = sys.argv[1:] + if not binary.is_file(): + print("antigravity-kit: installation not ready", file=sys.stderr) + return 1 + if args in (["--help"], ["-h"], ["--version"]): + return subprocess.call([str(binary), *args]) # noqa: S603 -- pinned binary, flags allowlist + try: + return run(binary, args) + except (ValueError, OSError): + print("antigravity-kit: verify provider input, credentials and supported arguments", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/antigravity/files/home/antigravity-kit/process.py b/antigravity/files/home/antigravity-kit/process.py new file mode 100644 index 0000000..d369f99 --- /dev/null +++ b/antigravity/files/home/antigravity-kit/process.py @@ -0,0 +1,56 @@ +"""Bounded ownership of one CLI process group and its terminal.""" + +import os +import signal +import subprocess +import sys +from contextlib import suppress + + +def foreground(group): + previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + try: + os.tcsetpgrp(sys.stdin.fileno(), group) + finally: + signal.signal(signal.SIGTTOU, previous) + + +def claim_terminal(): + foreground(os.getpgrp()) + + +def run(argv, env): + terminal = sys.stdin.isatty() + previous_group = os.tcgetpgrp(sys.stdin.fileno()) if terminal else None + child = subprocess.Popen([sys.executable, __file__, *argv], env=env, process_group=0) # noqa: S603 -- fixed helper, trusted CLI argv + + def interrupted(signum, _): + raise InterruptedError(signum) + + handlers = {s: signal.signal(s, interrupted) for s in (signal.SIGTERM, signal.SIGHUP)} + try: + return child.wait() + except InterruptedError as error: + return 128 + error.args[0] + except KeyboardInterrupt: + return 130 + finally: + # Cleanup cannot be interrupted by repeated supervisor/terminal signals. + protected = {s: signal.signal(s, signal.SIG_IGN) for s in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)} + with suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGTERM) + with suppress(subprocess.TimeoutExpired): + child.wait(3) + with suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGKILL) + child.wait() + if previous_group is not None: + foreground(previous_group) + for signum in protected: + signal.signal(signum, handlers.get(signum, protected[signum])) + + +if __name__ == "__main__": + if sys.stdin.isatty(): + claim_terminal() + os.execvpe(sys.argv[1], sys.argv[1:], os.environ) # noqa: S606 -- pinned CLI, argv only diff --git a/antigravity/files/home/antigravity-kit/provider.py b/antigravity/files/home/antigravity-kit/provider.py new file mode 100644 index 0000000..32cfa52 --- /dev/null +++ b/antigravity/files/home/antigravity-kit/provider.py @@ -0,0 +1,105 @@ +"""Read validated, backend-owned acq provider facts and exact model IDs.""" + +import json +import math +import os +import re +from pathlib import Path +from urllib.parse import urlsplit + + +def strict_json(raw): + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON property") + result[key] = value + return result + + def invalid(_): + raise ValueError("non-finite JSON value") + + def finite(raw): + value = float(raw) + if not math.isfinite(value): + raise ValueError("non-finite JSON value") + return value + + return json.loads(raw, object_pairs_hook=unique, parse_constant=invalid, parse_float=finite) + + +def load(env=None): + env = os.environ if env is None else env + root = Path(env.get("ACQ_PROVIDER_INPUT_ROOT", "")) + if not root.is_absolute(): + raise ValueError("provider root must be absolute") + for path in (root, root / "models", root / "models/providers"): + if path.is_symlink() or not path.is_dir(): + raise ValueError("provider generation is not ready") + directories = list((root / "models/providers").iterdir()) + if any(p.is_symlink() or not p.is_dir() for p in directories): + raise ValueError("invalid provider directory") + selected = env.get("ACQ_ANTIGRAVITY_PROVIDER") or (directories[0].name if len(directories) == 1 else None) + if not isinstance(selected, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", selected): + raise ValueError("select an unambiguous configured provider") + keys, facts = set(), None + for directory in directories: + candidate = read(directory / "facts.json") + url = urlsplit(candidate.get("baseUrl", "")) + key = candidate.get("keyEnv", "") + if ( + candidate.get("schemaVersion") != "acq-provider-facts/v1" + or candidate.get("providerId") != directory.name + or candidate.get("apiProtocol") != "openai-compatible" + or url.scheme != "https" + or not url.hostname + or url.hostname != candidate.get("host") + or url.username + or url.password + or url.query + or url.fragment + or not isinstance(key, str) + or not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) + or key == "GITHUB_TOKEN" + ): + raise ValueError("invalid provider facts") + keys.add(key) + if directory.name == selected: + facts = candidate + if facts is None: + raise ValueError("selected provider is absent") + catalog = read(root / "models/providers" / selected / "snapshot.json") + if catalog.get("schemaVersion") != "acq-neutral-model-catalog/v1" or catalog.get("providerId") != selected: + raise ValueError("catalog identity mismatch") + models = catalog.get("models") + if not isinstance(models, list) or not 1 <= len(models) <= 500: + raise ValueError("invalid model list") + ids = [] + for model in models: + value = model.get("id") if isinstance(model, dict) else None + if ( + not isinstance(value, str) + or not 1 <= len(value) <= 128 + or re.search(r"[\x00-\x1f\x7f\u2028\u2029]", value) + or value in ids + ): + raise ValueError("invalid or duplicate model ID") + ids.append(value) + recommendations = catalog.get("recommendations", {}) + if not isinstance(recommendations, dict) or any(v not in ids for v in recommendations.values()): + raise ValueError("invalid recommendations") + primary = env.get("ACQ_ANTIGRAVITY_MODEL") or recommendations.get("primary") or (ids[0] if len(ids) == 1 else None) + small = env.get("ACQ_ANTIGRAVITY_SMALL_MODEL") or recommendations.get("small") or primary + if primary not in ids or small not in ids or not env.get(facts["keyEnv"]): + raise ValueError("select a configured model and inject its credential") + return facts, models, primary, small, keys + + +def read(path): + if path.is_symlink() or not path.is_file() or path.stat().st_size > 2_000_000: + raise ValueError("provider input must be a bounded regular file") + value = strict_json(path.read_bytes()) + if not isinstance(value, dict): + raise ValueError("provider input must be an object") + return value diff --git a/antigravity/scripts/test b/antigravity/scripts/test new file mode 100755 index 0000000..85563fe --- /dev/null +++ b/antigravity/scripts/test @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Run the dependency-free kit suite; real CLI checks require explicit opt-in.""" + +import subprocess +import sys +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +sys.exit(subprocess.call([sys.executable, "-m", "unittest", "discover", "-s", str(root / "tests"), "-v"], cwd=root)) diff --git a/antigravity/spec.yaml b/antigravity/spec.yaml new file mode 100644 index 0000000..94896ac --- /dev/null +++ b/antigravity/spec.yaml @@ -0,0 +1,32 @@ +schemaVersion: hybrid/v1 +kind: mixin +name: antigravity +displayName: Antigravity (Chat Completions gateway) +description: Pinned agy CLI with an invocation-owned Gemini-to-Chat-Completions bridge. +agent: + name: antigravity + entrypoint: agy +caps: + network: + allow: [storage.googleapis.com] +files: + - {path: /home/agent/antigravity-kit/install.py, mode: "0755", source: files/home/antigravity-kit/install.py} + - {path: /home/agent/antigravity-kit/launch.py, mode: "0755", source: files/home/antigravity-kit/launch.py} + - {path: /home/agent/antigravity-kit/provider.py, mode: "0644", source: files/home/antigravity-kit/provider.py} + - {path: /home/agent/antigravity-kit/bridge.py, mode: "0644", source: files/home/antigravity-kit/bridge.py} + - {path: /home/agent/antigravity-kit/process.py, mode: "0644", source: files/home/antigravity-kit/process.py} + - {path: /home/agent/.local/bin/agy, mode: "0755", source: files/home/.local/bin/agy} +commands: + - phase: startup + user: "1000" + description: Install checksum-pinned Antigravity as the agent user + command: [python3, /home/agent/antigravity-kit/install.py, --apply] +agentContext: | + Run agy in the guest terminal. Set ACQ_ANTIGRAVITY_PROVIDER if the + materialized acq provider generation is ambiguous; ACQ_ANTIGRAVITY_MODEL + and ACQ_ANTIGRAVITY_SMALL_MODEL select exact configured deployment IDs. + Text and ordinary function tools are supported; media and native Gemini + features are not. Responses are buffered. No proxy port is published. + Paseo's Antigravity plugin uses full access without permission prompts; + only use it inside an appropriately isolated workspace. The kit does not + enable that flag for terminal use. diff --git a/antigravity/tests/test_antigravity.py b/antigravity/tests/test_antigravity.py new file mode 100644 index 0000000..d9e0c44 --- /dev/null +++ b/antigravity/tests/test_antigravity.py @@ -0,0 +1,131 @@ +import importlib +import json +import sys +import unittest +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "files/home/antigravity-kit")) +bridge = importlib.import_module("bridge") +provider = importlib.import_module("provider") +installer = importlib.import_module("install") + + +class KitTests(unittest.TestCase): + def test_pins(self): + self.assertEqual(set(installer.PINS), {"aarch64", "x86_64"}) + self.assertTrue(all(len(pin[1]) == 128 for pin in installer.PINS.values())) + + def test_json_rejects_duplicates_and_constants(self): + for raw in ['{"key":1,"key":2}', '{"key":Infinity}', '{"key":1e999}']: + with self.assertRaises(ValueError): + provider.strict_json(raw) + + def test_function_translation(self): + messages, tools = bridge.translate( + { + "contents": [ + { + "role": "model", + "parts": [{"functionCall": {"id": "call", "name": "write", "args": {"path": "file"}}}], + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"id": "call", "name": "write", "response": {"result": "done"}}} + ], + }, + ], + "tools": [{"functionDeclarations": [{"name": "write", "parametersJsonSchema": {"type": "object"}}]}], + } + ) + self.assertEqual(messages[1]["tool_call_id"], "call") + self.assertEqual(tools[0]["function"]["name"], "write") + result = bridge.output( + {"tool_calls": [{"id": "call", "function": {"name": "write", "arguments": "{}"}}]}, {"write"} + ) + self.assertEqual(result["candidates"][0]["content"]["parts"][0]["functionCall"]["id"], "call") + + def test_rejects_media_and_native_tools(self): + for data in [{"contents": [{"parts": [{"inlineData": {"data": "x"}}]}]}, {"tools": [{"googleSearch": {}}]}]: + with self.assertRaises(ValueError): + bridge.translate(data) + with self.assertRaises(ValueError): + bridge.output({"tool_calls": [{"id": "x", "function": {"name": "undeclared", "arguments": "{}"}}]}, set()) + + def test_rejects_invalid_role_and_constraints(self): + for data in [ + {"contents": [{"role": "admin", "parts": []}]}, + {"generationConfig": {"responseMimeType": "application/json"}}, + {"toolConfig": {"functionCallingConfig": {"mode": "NONE"}}}, + {"generationConfig": {"thinkingConfig": {"thinkingBudget": 100}}}, + ]: + with self.assertRaises(ValueError): + bridge.translate(data) + + def test_parallel_missing_ids_are_distinct_but_ambiguous_results_fail(self): + content = { + "role": "model", + "parts": [{"functionCall": {"name": "read", "args": {}}}, {"functionCall": {"name": "read", "args": {}}}], + } + messages, _ = bridge.translate({"contents": [content]}) + self.assertEqual(len({c["id"] for c in messages[0]["tool_calls"]}), 2) + response = {"role": "user", "parts": [{"functionResponse": {"name": "read", "response": {}}}]} + with self.assertRaises(ValueError): + bridge.translate({"contents": [content, response]}) + + def test_generated_ids_do_not_collide_with_explicit_ids(self): + content = { + "role": "model", + "parts": [ + {"functionCall": {"name": "read", "id": "call_2", "args": {}}}, + {"functionCall": {"name": "read", "args": {}}}, + ], + } + messages, _ = bridge.translate({"contents": [content]}) + self.assertEqual(len({c["id"] for c in messages[0]["tool_calls"]}), 2) + + def test_http_exact_id_and_housekeeping(self): + seen = [] + + def transport(data): + seen.append(data) + return {"choices": [{"message": {"content": "OK"}}]} + + server = bridge.start( + {"baseUrl": "https://example.invalid/v1"}, + ["exact.model_id", "small"], + "upstream", + "local", + "small", + transport, + ) + try: + for model, token, expected in [ + ("exact.model_id", "local", 200), + ("gemini-3.1-flash-lite-preview", "local", 200), + ("native", "local", 400), + ("exact.model_id", "wrong", 401), + ]: + url = f"http://127.0.0.1:{server.server_port}/v1beta/models/{model}:streamGenerateContent?alt=sse" + request = urllib.request.Request( + url, + data=json.dumps({"contents": [{"role": "user", "parts": [{"text": "task"}]}]}).encode(), + headers={"x-goog-api-key": token}, + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + self.assertEqual(response.status, expected) + self.assertIn(b"OK", response.read()) + except urllib.error.HTTPError as error: + self.assertEqual(error.code, expected) + error.close() + self.assertEqual([d["model"] for d in seen], ["exact.model_id", "small"]) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/antigravity/tests/test_process.py b/antigravity/tests/test_process.py new file mode 100644 index 0000000..9286f3d --- /dev/null +++ b/antigravity/tests/test_process.py @@ -0,0 +1,20 @@ +import subprocess +import sys +import unittest +from pathlib import Path + + +class LifecycleTests(unittest.TestCase): + def test_stdin_stdout_forwarding(self): + helper = Path(__file__).resolve().parents[1] / "files/home/antigravity-kit/process.py" + code = ( + "import importlib.util,os,sys;" + f"s=importlib.util.spec_from_file_location('owned',{str(helper)!r});" + "m=importlib.util.module_from_spec(s);s.loader.exec_module(m);" + "sys.exit(m.run([sys.executable,'-c','import sys;print(sys.stdin.read())'],os.environ.copy()))" + ) + result = subprocess.run( + [sys.executable, "-c", code], input="FORWARDED", text=True, capture_output=True, timeout=10 + ) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), "FORWARDED") From d5769ac9a05621720f1da23832c0053e0112fd20 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 05:18:41 +0000 Subject: [PATCH 3/4] test(kits): extend matrix and add real CLI verification --- .github/codex-antigravity-pr-draft.md | 72 +++++++++++++ .github/workflows/ci.yml | 6 +- Makefile | 5 + README.md | 17 +++ pyproject.toml | 4 + scripts/run-kit-tests.py | 2 +- scripts/tests/test_tooling.py | 4 +- scripts/validate-kits.py | 2 +- scripts/verify-native-cli.py | 146 ++++++++++++++++++++++++++ 9 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 .github/codex-antigravity-pr-draft.md create mode 100755 scripts/verify-native-cli.py diff --git a/.github/codex-antigravity-pr-draft.md b/.github/codex-antigravity-pr-draft.md new file mode 100644 index 0000000..69606ec --- /dev/null +++ b/.github/codex-antigravity-pr-draft.md @@ -0,0 +1,72 @@ +## Summary + +Add independently installable experimental Codex and Google Antigravity (`agy`) +agent kits consuming acq's neutral configured Chat Completions provider input. +No USAi-specific endpoints, model catalogs or credential values are embedded. + +- Codex 0.166.0: per-invocation authenticated Responses facade, exact-ID native catalog. +- Antigravity 1.4.12: per-invocation authenticated Gemini facade, custom-model settings. +- Standard-library runtime; no new third-party runtime dependencies. +- Six-kit CI matrix, Python 3.12 coverage and repeatable credential-free real-CLI verifier. +- No changes to existing kits' runtime behavior or Paseo's configuration/catalog. + +## Context and plan + +Existing provider kits publish Chat Completions-compatible facts/catalogs. +Neither agent's native wire API matches that contract. Keep adaptation in each +agent kit, preserve exact IDs, scope translation to text/function tools and +reject unsupported behavior. Design records live with each kit. + +This branch is based on the Claude-kit branch so it reuses its shared validation +and executable-index guard. It must target that branch until the prerequisite +is merged, or be rebased onto main afterward. Do not include unrelated base work +as a new change. + +## Verification transcript + +- `make check PATTERNS= GITLEAKS=`: PASS. + Includes Ruff, formatting, ShellCheck, all six contracts, all offline suites, + 28 shared tests, dependency audits, unsafe-shell and secret scanning. +- Codex: 14 tests, PASS on Python 3.12 and 3.14. +- Antigravity: 9 tests, PASS on Python 3.12 and 3.14. +- `python3 scripts/verify-native-cli.py codex --binary `: PASS. +- Equivalent Antigravity command: PASS. + Both execute real CLI file tools and verify exact IDs, tool results and final replies. +- Separate Paseo 0.12.0 daemon, isolated HOME/port and local HTTPS synthetic provider: + both catalogs contain only `exact.test_model`; both real sessions complete with + `PASEO_KIT_OK`. Final code rerun PASS. Active user daemon was untouched. +- Independent adversarial reviews: concrete findings fixed and regressions added; + final review reports no blockers within experimental text/function scope. + +These are real CLI/protocol/Paseo tests against synthetic model output, not live +gateway/model-quality verification. Remote CI, native x64 and interactive PTY +remain unverified. Terminal suspension/job control is unsupported. + +## Security impact + +New installation hosts and private loopback services. No published model ports, +persisted keys, disabled TLS verification or global provider config writes. +Upstream redirects are denied; installer redirects checked before following. +Local auth token is separate from outbound correlation metadata. Tool/field +translation uses a limited allowlist. Same-UID guest processes are not isolated. +Paseo's Antigravity plugin uses full access; the kit's terminal wrapper does not +enable that flag. The synthetic file fixture explicitly opts into it. + +## Limitations + +Buffered responses, text/function tools only, no media/native search/encrypted +reasoning/server-side Responses state. Antigravity's fixed automatic thought +envelope is explicitly ignored by the text/function policy; effort flags reject. +CLI state uses kit-owned persistent directories; normal user-global config is +not inherited. Generic Codex catalog instructions may affect model performance. + +## Rollback + +Revert this PR's commits. Remove the two kit references from future sandbox +creation. Existing sandboxes are not modified automatically; do not delete +conversation directories without operator consent. + +## AI assistance and review + +Implementation and verification were AI-assisted. Human review is required; +this is not production approval or a compliance assertion. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad93e01..62e4dbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,15 @@ jobs: strategy: fail-fast: false matrix: - kit: [opencode-go-provider, pi-coding-agent, first-mate-skill, claude-code] + kit: [opencode-go-provider, pi-coding-agent, first-mate-skill, claude-code, codex, antigravity] python: ["3.14"] include: - kit: claude-code python: "3.12" + - kit: codex + python: "3.12" + - kit: antigravity + python: "3.12" steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/Makefile b/Makefile index 06d04ec..3bce94f 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ VENV ?= .tmp/ci-venv PY := $(VENV)/bin/python PATTERNS ?= .patterns LINT_PATHS := scripts/validate-kits.py scripts/run-kit-tests.py scripts/tests claude-code/files claude-code/tests claude-code/scripts/test claude-code/scripts/verify +LINT_PATHS += codex/files codex/tests codex/scripts antigravity/files antigravity/tests antigravity/scripts scripts/verify-native-cli.py GITLEAKS ?= gitleaks .PHONY: setup check lint test validate security @@ -18,6 +19,7 @@ lint: $(PY) -m ruff check $(LINT_PATHS) $(PY) -m ruff format --check $(LINT_PATHS) shellcheck claude-code/files/home/.local/bin/claude + shellcheck codex/files/home/.local/bin/codex antigravity/files/home/.local/bin/agy validate: $(PY) scripts/validate-kits.py --patterns $(PATTERNS) @@ -32,4 +34,7 @@ security: $(PY) $(PATTERNS)/scripts/scan_unsafe_shell.py claude-code $(GITLEAKS) dir --redact --no-banner --log-level warn claude-code $(GITLEAKS) dir --redact --no-banner --log-level warn scripts + $(PY) $(PATTERNS)/scripts/scan_unsafe_shell.py codex antigravity + $(GITLEAKS) dir --redact --no-banner --log-level warn codex + $(GITLEAKS) dir --redact --no-banner --log-level warn antigravity $(GITLEAKS) git --redact --no-banner --log-level warn . diff --git a/README.md b/README.md index e56694c..12268aa 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ An agent kit is a `hybrid/v1` mixin that: | Kit | Kind | Provides | Status | | --- | --- | --- | --- | | [`claude-code/`](claude-code/) | Agent | Pinned Claude Code + private Messages-to-Chat-Completions proxy for a configured OpenAI-shaped model provider | **Experimental** — operator-verified on ARM64 MSB; x64 execution pending; browser model picker overstates availability | +| [`codex/`](codex/) | Agent | Pinned Codex + private Responses-to-Chat-Completions bridge | **Experimental** — real ARM64 CLI and isolated Paseo verified against synthetic provider; live gateway and x64 pending | +| [`antigravity/`](antigravity/) | Agent | Pinned Google agy + private Gemini-to-Chat-Completions bridge | **Experimental** — real ARM64 CLI and isolated Paseo verified against synthetic provider; live gateway and x64 pending | | [`opencode-go-provider/`](opencode-go-provider/) | Provider | OpenCode Go (`openai-compatible`) facts and a 19-model neutral catalog | **Preview** — blocked on the acq credential-binding addition | | [`pi-coding-agent/`](pi-coding-agent/) | Agent | Pinned pi CLI + `~/.pi/agent/models.json` renderer | **Preview** — live verification pending acq PR 611 | | [`first-mate-skill/`](first-mate-skill/) | Skill | The `paseo-first-mate` Agent Skill and two helper scripts, dropped into `~/.agents/skills` | Offline-verified; no live sandbox run | @@ -87,6 +89,21 @@ models in the Go docs endpoint table plus one active free/experimental alias Each kit directory has its own README with specific details and a TROUBLESHOOTING guide. +Codex and Antigravity consume the same neutral provider contract as the other +agent kits. Their native APIs differ, so each owns a text/function compatibility +bridge. They do not claim that Chat Completions, Responses and Gemini APIs are +interchangeable. Neither writes a second Paseo model catalog. + +For repeatable real-CLI protocol/tool tests without credentials: + +```bash +python3 scripts/verify-native-cli.py codex --binary /path/to/pinned/codex +python3 scripts/verify-native-cli.py antigravity --binary /path/to/pinned/agy +``` + +These tests use isolated synthetic transports and actually execute a fixture +file tool. They do not prove remote model quality or acq sandbox creation. + ## Layout ```text diff --git a/pyproject.toml b/pyproject.toml index 7d2e576..061d210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ select = ["E", "F", "I", "UP", "B", "SIM", "S"] "claude-code/tests/*" = ["S101", "S603", "S607"] "claude-code/scripts/test" = ["S603", "S607"] "claude-code/scripts/verify" = ["S603", "S607"] +"codex/tests/*" = ["S101", "S603", "S607", "S310"] +"antigravity/tests/*" = ["S101", "S603", "S607", "S310"] +"codex/scripts/test" = ["S603"] +"antigravity/scripts/test" = ["S603"] [tool.pytest.ini_options] testpaths = ["scripts/tests"] diff --git a/scripts/run-kit-tests.py b/scripts/run-kit-tests.py index 6b49330..8aec635 100755 --- a/scripts/run-kit-tests.py +++ b/scripts/run-kit-tests.py @@ -10,7 +10,7 @@ import sys from pathlib import Path -KIT_NAMES = ("opencode-go-provider", "pi-coding-agent", "first-mate-skill", "claude-code") +KIT_NAMES = ("opencode-go-provider", "pi-coding-agent", "first-mate-skill", "claude-code", "codex", "antigravity") def test_commands(root: Path, name: str, *, install: bool = False) -> tuple[Path, list[list[str]]]: diff --git a/scripts/tests/test_tooling.py b/scripts/tests/test_tooling.py index 9216e79..103df5b 100644 --- a/scripts/tests/test_tooling.py +++ b/scripts/tests/test_tooling.py @@ -92,7 +92,7 @@ def test_dispatch_commands(kits, name): assert cwd == kits / name if name in ("opencode-go-provider", "pi-coding-agent"): assert commands[:2] == [["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], ["npm", "test"]] - elif name == "claude-code": + elif name in ("claude-code", "codex", "antigravity"): assert commands == [[sys.executable, "scripts/test"]] else: assert commands == [["bash", "scripts/verify"]] @@ -105,6 +105,8 @@ def test_ci_matrix_and_dispatch_cover_every_repository_kit(): assert set(matrix["kit"]) == set(runner.KIT_NAMES) == actual assert actual == validator.REQUIRED_KITS assert {"kit": "claude-code", "python": "3.12"} in matrix["include"] + assert {"kit": "codex", "python": "3.12"} in matrix["include"] + assert {"kit": "antigravity", "python": "3.12"} in matrix["include"] @pytest.mark.parametrize("name", ["../claude-code", "/outside-project", "unknown", "claude-code;echo bad"]) diff --git a/scripts/validate-kits.py b/scripts/validate-kits.py index 7293618..fcc6db1 100755 --- a/scripts/validate-kits.py +++ b/scripts/validate-kits.py @@ -19,7 +19,7 @@ import yaml PATTERNS_REF = "a290d5d80737fc739e7ec139da0eeaf438631631" -REQUIRED_KITS = {"opencode-go-provider", "pi-coding-agent", "first-mate-skill", "claude-code"} +REQUIRED_KITS = {"opencode-go-provider", "pi-coding-agent", "first-mate-skill", "claude-code", "codex", "antigravity"} def require_file(path: Path) -> Path: diff --git a/scripts/verify-native-cli.py b/scripts/verify-native-cli.py new file mode 100755 index 0000000..c62f4da --- /dev/null +++ b/scripts/verify-native-cli.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Exercise a real CLI against a credential-free synthetic provider. + +This proves protocol wiring and tool execution, not model quality, remote API +compatibility, or acq microVM creation. Run separately for each installed CLI. +""" + +import argparse +import importlib +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def verify(kind, binary): + root = Path(__file__).resolve().parents[1] + folder = "codex-kit" if kind == "codex" else "antigravity-kit" + sys.path.insert(0, str(root / kind / "files/home" / folder)) + bridge = importlib.import_module("bridge") + seen = [] + with tempfile.TemporaryDirectory(prefix="native-cli-") as temporary: + workspace = Path(temporary) + output = workspace / "output.txt" + + def transport(data): + seen.append(data) + tools = {t["function"]["name"] for t in data.get("tools", [])} + results = [m for m in data["messages"] if m.get("role") == "tool"] + if not tools: + return {"choices": [{"message": {"content": "Synthetic file task"}}]} + if results: + if not output.is_file() or output.read_text() != "FILE_MARKER": + raise ValueError("real tool did not write the expected marker") + return {"choices": [{"message": {"content": "NATIVE_CLI_OK"}}]} + if kind == "codex": + name = "exec_command" + args = {"cmd": "printf FILE_MARKER > output.txt", "max_output_tokens": 1000} + else: + name = "write_file" + args = {"Path": str(output), "Content": "FILE_MARKER", "Overwrite": True} + return { + "choices": [ + { + "message": { + "content": None, + "tool_calls": [ + { + "id": "call_fixture", + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + } + ], + } + } + ] + } + + facts = {"baseUrl": "https://example.invalid/v1"} + if kind == "codex": + server = bridge.start(facts, ["exact.test_model"], "synthetic", "local", transport=transport) + else: + server = bridge.start(facts, ["exact.test_model"], "synthetic", "local", "exact.test_model", transport) + # No inherited credential values or daemon selectors enter the test CLI. + env = { + k: v + for k, v in os.environ.items() + if not any(s in k for s in ("KEY", "TOKEN", "CODEX", "PASEO", "GOOGLE", "GEMINI")) + } + env.update(HOME=str(workspace), NO_PROXY="localhost,127.0.0.1") + if kind == "codex": + catalog = workspace / "models.json" + catalog.write_text( + json.dumps(importlib.import_module("catalog").codex_catalog([{"id": "exact.test_model"}])) + ) + env.update(CODEX_HOME=str(workspace), ACQ_BRIDGE_TOKEN="local") # noqa: S106 -- synthetic credential-free fixture + config = { + "model_provider": "test", + "model_providers.test.name": "Synthetic provider", + "model_providers.test.base_url": f"http://127.0.0.1:{server.server_port}/v1", + "model_providers.test.env_key": "ACQ_BRIDGE_TOKEN", + "model_catalog_json": str(catalog), + "features.code_mode": False, + "web_search": "disabled", + } + args = [value for key, setting in config.items() for value in ("-c", key + "=" + json.dumps(setting))] + args += [ + "exec", + "--skip-git-repo-check", + "--sandbox", + "workspace-write", + "-m", + "exact.test_model", + "Write output.txt containing FILE_MARKER, then report NATIVE_CLI_OK", + ] + else: + env.update(GEMINI_API_KEY="local", GOOGLE_GEMINI_BASE_URL=f"http://127.0.0.1:{server.server_port}") + config = { + "modelProvider": "gemini", + "customModelsConfig": { + "customModels": { + "exact.test_model": {"modelName": "exact.test_model", "apiProvider": "gemini"}, + } + }, + } + args = [ + "--settings", + json.dumps(config), + "--model", + "exact.test_model", + "--add-dir", + str(workspace), + "--dangerously-skip-permissions", + "-p", + "Write output.txt then report NATIVE_CLI_OK", + "--print-timeout", + "30s", + ] + try: + result = subprocess.run( # noqa: S603 -- operator-selected installed CLI, argv only + [str(binary), *args], cwd=workspace, env=env, capture_output=True, text=True, timeout=45 + ) # noqa: S603 -- operator-selected installed CLI, argv only + if ( + result.returncode != 0 + or "NATIVE_CLI_OK" not in result.stdout + or not output.exists() + or output.read_text() != "FILE_MARKER" + ): + raise ValueError(f"native {kind} verification failed (exit {result.returncode}); inspect CLI locally") + print( + f"PASS: real {kind} exact-ID selection, function call, file write, tool result " + f"and final response; {len(seen)} requests" + ) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kit", choices=("codex", "antigravity")) + parser.add_argument("--binary", required=True, type=Path) + args = parser.parse_args() + verify(args.kit, args.binary.resolve(strict=True)) From 752e1869104f7b56349f5d58fbeeafd8584e49be Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 31 Oct 2026 05:25:58 +0000 Subject: [PATCH 4/4] docs: target merged main for CLI kits PR --- .github/codex-antigravity-pr-draft.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/codex-antigravity-pr-draft.md b/.github/codex-antigravity-pr-draft.md index 69606ec..df611b0 100644 --- a/.github/codex-antigravity-pr-draft.md +++ b/.github/codex-antigravity-pr-draft.md @@ -17,10 +17,9 @@ Neither agent's native wire API matches that contract. Keep adaptation in each agent kit, preserve exact IDs, scope translation to text/function tools and reject unsupported behavior. Design records live with each kit. -This branch is based on the Claude-kit branch so it reuses its shared validation -and executable-index guard. It must target that branch until the prerequisite -is merged, or be rebased onto main afterward. Do not include unrelated base work -as a new change. +This branch is based on main and reuses the merged Claude kit's shared validation +and executable-index guard. The PR adds only the two new kits and their shared +test/CI wiring. ## Verification transcript