diff --git a/.github/workflows/atlas-tier1.yml b/.github/workflows/atlas-tier1.yml new file mode 100644 index 000000000..50789cd65 --- /dev/null +++ b/.github/workflows/atlas-tier1.yml @@ -0,0 +1,161 @@ +name: Atlas DRC Tier 1 contract + +on: + workflow_dispatch: + pull_request: + paths: + - "tunnel/**" + - "bridge/common/**" + - "bridge/boston_dynamics/atlas_drc_bridge/**" + - "registry/vendors/boston-dynamics/atlas/**" + - ".github/workflows/atlas-tier1.yml" + push: + branches: [boston-dynamics-atlas-tier-1] + paths: + - "tunnel/**" + - "bridge/common/**" + - "bridge/boston_dynamics/atlas_drc_bridge/**" + - "registry/vendors/boston-dynamics/atlas/**" + - ".github/workflows/atlas-tier1.yml" + +jobs: + tunnel-and-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Build and test the real Go Tunnel + run: | + make build + make test + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install Atlas bridge dependencies + run: | + python -m pip install -r bridge/boston_dynamics/atlas_drc_bridge/requirements.txt + python bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py + - name: Run fail-closed contracts + env: + PYTHONPATH: ${{ github.workspace }}/bridge/boston_dynamics/atlas_drc_bridge + TUNNEL_BIN: ${{ github.workspace }}/bin/tunnel + LD_LIBRARY_PATH: ${{ github.workspace }}/.zenoh-c/lib + run: | + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_contract.py + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_registry_contract.py + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_bridge_contract.py + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_payment_gate.py + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_e2e_paid_action.py + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_x402_no_settlement.py + + atlas-mujoco: + name: Atlas DRC MuJoCo state-feedback proof + runs-on: ubuntu-latest + needs: tunnel-and-contract + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install dependencies and the pinned legacy Atlas source + run: | + python -m pip install --upgrade pip + python -m pip install -r bridge/boston_dynamics/atlas_drc_bridge/requirements.txt + python bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py + - name: Run actual MuJoCo wave + env: + PYTHONPATH: ${{ github.workspace }}/bridge/boston_dynamics/atlas_drc_bridge + run: | + python bridge/boston_dynamics/atlas_drc_bridge/tests/test_mujoco_runtime.py + python bridge/boston_dynamics/atlas_drc_bridge/run_paid_wave.py --json-output bridge/boston_dynamics/atlas_drc_bridge/artifacts/mujoco_result.json + - name: Upload MuJoCo evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: atlas-drc-mujoco-evidence + path: bridge/boston_dynamics/atlas_drc_bridge/artifacts/mujoco_result.json + retention-days: 90 + if-no-files-found: error + + atlas-sim2sim: + name: Atlas DRC Sim-to-Sim (MuJoCo + Webots) + runs-on: ubuntu-latest + needs: tunnel-and-contract + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install simulators and dependencies + run: | + sudo apt-get update + sudo apt-get install -y wget xvfb libgl1 libglu1-mesa libglib2.0-0 libgomp1 libfontconfig1 libxkbcommon0 libdbus-1-3 libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxtst6 libxi6 libxss1 libqt5core5a libqt5gui5 libqt5widgets5 libqt5network5 libqt5svg5 libqt5webengine5 libqt5webenginecore5 libqt5webenginewidgets5 libqt5multimedia5 libqt5printsupport5 libqt5concurrent5 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 + sudo apt-get install -y libsndio7.0 2>/dev/null || sudo apt-get install -y libsndio-dev 2>/dev/null || true + python -m pip install --upgrade pip + python -m pip install -r bridge/boston_dynamics/atlas_drc_bridge/requirements.txt + python bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py + wget -q https://github.com/cyberbotics/webots/releases/download/R2025a/webots-R2025a-x86-64.tar.bz2 + sudo tar xjf webots-R2025a-x86-64.tar.bz2 -C /usr/local + rm webots-R2025a-x86-64.tar.bz2 + - name: Run the same bounded policy in both simulators + env: + PYTHONPATH: ${{ github.workspace }}/bridge/boston_dynamics/atlas_drc_bridge + WEBOTS_EXE: /usr/local/webots/webots + QT_QPA_PLATFORM: offscreen + run: xvfb-run -a -s "-screen 0 1280x1024x24" python bridge/boston_dynamics/atlas_drc_bridge/run_sim2sim_validation.py --timeout 90 + - name: Upload Sim-to-Sim evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: atlas-drc-sim2sim-evidence + path: bridge/boston_dynamics/atlas_drc_bridge/artifacts/ + retention-days: 90 + if-no-files-found: error + + atlas-base-sepolia-e2e: + name: Atlas DRC Base Sepolia settlement evidence + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + concurrency: + group: atlas-drc-base-sepolia-${{ github.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + needs: [tunnel-and-contract, atlas-mujoco, atlas-sim2sim] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install dependencies, legacy model, and real Tunnel + run: | + python -m pip install --upgrade pip + python -m pip install -r bridge/boston_dynamics/atlas_drc_bridge/requirements.txt + python bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py + make build + - name: Start local Zenoh router + run: | + wget -q https://github.com/eclipse-zenoh/zenoh/releases/download/1.9.0/zenoh-1.9.0-x86_64-unknown-linux-gnu-standalone.zip + unzip -q zenoh-1.9.0-x86_64-unknown-linux-gnu-standalone.zip -d .zenoh-router + .zenoh-router/zenohd > zenohd.log 2>&1 & + sleep 2 + grep -q "zenohd" zenohd.log + - name: Run live Base Sepolia proof and generate evidence + env: + PRIVATE_KEY: ${{ secrets.BASE_SEPOLIA_PRIVATE_KEY }} + ROBO_PAYEE_ADDRESS: ${{ secrets.ROBO_PAYEE_ADDRESS }} + TUNNEL_BIN: ${{ github.workspace }}/bin/tunnel + LD_LIBRARY_PATH: ${{ github.workspace }}/.zenoh-c/lib + PROXY_WS_URL: wss://api.fabric.foundation/api/core/ws/robot + run: python bridge/boston_dynamics/atlas_drc_bridge/test_base_sepolia_tunnel_e2e.py + - name: Upload generated Base Sepolia evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: atlas-drc-base-sepolia-evidence + path: bridge/boston_dynamics/atlas_drc_bridge/artifacts/base_sepolia_result_*.json + retention-days: 90 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 15c39e757..410d2f005 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ go.work go.work.sum .env +robopay_idempotency.json .idea/ .vscode/ diff --git a/README.md b/README.md index 63e0fbb40..51655a94e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,26 @@ The robot-side `tunnel` receives the action request, runs x402 middleware, verif ![RoboPay action flow](docs/images/flow.png) +## Tier 1 simulator profile: Boston Dynamics Atlas DRC (legacy) + +This branch adds a **simulator-only** Tier 1 profile for the legacy DARPA-era +Atlas DRC/v4 model. It uses the same payment-gated Tunnel and Zenoh security +boundary as the Reachy Mini and Spot profiles, a bounded measured-state +right-arm wave policy in MuJoCo, and an independently supplied Webots R2025a +Atlas cross-check. It deliberately does **not** claim to model Boston +Dynamics' current electric Atlas product. + +Boston Dynamics publishes the electric product's high-level specification +(56 degrees of freedom and continuous joint range), but no public electric +Atlas URDF/USD or joint-level kinematic schema is available in its developer +documentation or NVIDIA's public Isaac Sim 5.1 robot-asset catalog. The pinned +DRC/v4 URDF has 30 movable one-degree-of-freedom joints, so this branch makes +no claim that its joint names, axes, limits, dynamics, or controller transfer +to the electric robot. + +Start with the [Atlas DRC bridge README](bridge/boston_dynamics/atlas_drc_bridge/README.md) +and the [robot profile](registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/robot.profile.yaml). + ## Repository layout ``` @@ -62,17 +82,63 @@ Package names are `isaac_sim_bridge_g1`, `isaac_sim_bridge_go2`, and `isaac_sim_ The tunnel (`tunnel/`) keeps an outbound WebSocket to the Fabric proxy, verifies x402 micropayments, and publishes accepted actions to the same Zenoh topic the bridge listens on. -Set the payee address (and any overrides) in `tunnel/config.json`: +`tunnel/config.json` is deliberately an inert checked-in example. Set the +stable robot identity and payee in an untracked `tunnel/.env` (or a deployment +secret manager) before starting the tunnel: ```json { "robot_id": "my-robot", "evm_payee_address": "0xYourAddress", - "price": "$0.002", + "price": "0.001", "network": "eip155:84532" } ``` +| Field | Required | Default | Description | +|--------------------------|---------------|-----------------|------------------------------------------------------------| +| `robot_id` | **Yes** | — | Stable robot identifier; generated IDs are rejected | +| `evm_payee_address` | **Yes** | — | Non-zero EVM address to receive x402 payments | +| `price` | No | `0.001` | Price per action, in whole token units | +| `network` | No | `eip155:84532` | CAIP-2 network ID (Base Sepolia in the checked-in example) | +| `token_address` | No | network default | ERC-20 the price is charged in | +| `token_name` | For `eip3009` | — | Token's `name()`, forms the EIP-712 domain the payer signs | +| `token_version` | No | `1` | Token version used in the EIP-712 domain | +| `token_decimals` | No | `6` | Token decimals, used to convert `price` to atomic units | +| `token_transfer_method` | No | `eip3009` | `eip3009` or `permit2` — how the payment settles | +| `token_supports_eip2612` | No | `false` | `permit2` only: payer signs a permit instead of approving | + +`price` is a decimal amount in whole units of the payment token, converted to atomic units using +`token_decimals` — with `token_decimals: 18`, `"1"` charges `1000000000000000000`. A leading `$` +is optional and carries no meaning; it only reads as dollars when the token is a stablecoin. + +### Custom payment token + +For well-known chains x402 already knows which stablecoin to use (USDC on Base, and so on), so +`token_address` can be omitted. On any other chain there is no default and requests fail with +`no default stablecoin configured for network ` — set `token_address` to register the +token as that network's default asset at startup. The checked-in Base Sepolia +template in [`tunnel/config.example.json`](tunnel/config.example.json) is +intentionally inert (zero payee); copy it to an untracked deployment config +and replace the robot ID and payee before starting the Tunnel. + +`token_transfer_method` decides how the facilitator moves the tokens: + +- **`eip3009`** (default) — the payer signs a `TransferWithAuthorization` message and the + facilitator calls `transferWithAuthorization` on the token. **Only works if the token actually + implements EIP-3009** (USDC and friends). Against a plain ERC-20 the signature is produced + happily and settlement then reverts. `token_name`/`token_version` must match the token's own + EIP-712 domain (its `name()`, not its symbol) or the signature will not verify. +- **`permit2`** — the payer signs a Permit2 witness and the facilitator settles through the x402 + exact Permit2 proxy. Works with **any** plain ERC-20, at the cost of a one-time + `approve(0x000000000022D473030F116dDEE9F6B43aC78BA3, …)` from each payer. The signed domain is + Permit2's own, so `token_name`/`token_version` are neither required nor advertised. Set + `token_supports_eip2612: true` only if the token has `permit()`, which lets the payer skip the + approval transaction. + +The facilitator has to support the chosen method too — it is the one that submits the settlement +transaction. + Build and run from the repo root (the `Makefile` operates inside `tunnel/`): ```bash @@ -89,11 +155,36 @@ Common environment overrides: | `FACILITATOR_URL` | `https://x402.org/facilitator` | x402 payment facilitator endpoint | | `GIN_MODE` | `release` | `debug` for verbose HTTP logs | +### Fail-closed paid action contract + +Every deployment supplies a robot-scoped skill catalog and an explicit +allowlist. The tunnel refuses all action requests until both are configured: + +```bash +ROBOT_ID=my-robot +ROBO_PAYEE_ADDRESS=0xYourAddress +SKILL_CATALOG_PATH=../registry/vendors////skill-catalog.json +ALLOWED_ACTIONS=registered_skill,stop +``` + +`POST /action` accepts only a registered skill whose parameters satisfy that +catalog. It returns `202 Accepted` with an `action_id` and `status_url`; poll +`GET /action//status` for the terminal result. The request is +durably idempotent (including across restart), and x402 settlement is deferred +until a simulator result matches the exact `action_id`, `robot_id`, `skill_id`, +parameter hash, and idempotency key. Simulator failure, timeout, or a +correlation mismatch never settles a payment. + +The current shared Fabric Tunnel/proxy protocol identifies a robot by its +configured ID but does **not yet** supply a signed robot-to-payee handshake. +That binding is an upstream protocol dependency. Robot profiles must not +invent a local EIP-signature handshake; they document the limitation and only +receive Tunnel-verified action events. + ## 4. Register the robot on BitAgent (Unibase AIP) — optional With `AIP_ENABLED=true`, the tunnel additionally registers the robot as an -A2A-compatible agent on the BitAgent network (Unibase AIP), so any AIP client -or agent can discover and call it. The integration is built on the +A2A-compatible discovery agent on the BitAgent network (Unibase AIP). The integration is built on the [Unibase AIP Go SDK](https://github.com/unibaseio/aip-go-sdk) — see `tunnel/internal/aipagent/agent.go`, which wraps the robot in a single `wrappers.ExposeAsA2A(...)` call. @@ -102,7 +193,7 @@ How AIP traffic flows: ``` AIP client → AIP gateway (/robots//…) → Fabric proxy (ws) → tunnel - → AIP handler → Zenoh topic robot/tunnel/action → bridge → /cmd_vel + → discovery metadata / rejected direct action ``` The tunnel serves the A2A contract endpoints (`/.well-known/agent-card.json`, @@ -120,7 +211,7 @@ cp tunnel/.env.example tunnel/.env | Variable | Required | Description | |----------------------|----------|----------------------------------------------------------| -| `AIP_ENABLED` | yes | Set `true` to enable BitAgent/AIP registration | +| `AIP_ENABLED` | no | Set `true` to enable BitAgent/AIP discovery registration | | `CHAIN` | no | Chain preset: `bsc-testnet`, `bsc-mainnet`, `base-sepolia` or `base-mainnet` — sets both the x402 payment network and the AIP registration chain | | `UNIBASE_PROXY_AUTH` | no* | Bearer token — your account is resolved from it (falls back to `PRIVY_TOKEN`) | | `AIP_USER_ID` | no* | Token-less fallback: wallet address to register under | @@ -152,6 +243,9 @@ registering robot as AIP agent robot_id= endpoint_url=…/robots/ ws connected to proxy robot_id= ``` -Actions received via AIP are published to the same Zenoh topic -(`robot/tunnel/action`) as paid x402 actions, so the bridge and robot-side -safety logic are identical for both paths. +Direct actions received through AIP are intentionally rejected and never +published to Zenoh: an AIP job input does not currently carry the +Tunnel-verified x402 payment context, exact correlation tuple, or durable +replay reservation. Use the paid Tunnel action endpoint for execution. AIP +execution can be enabled only when the shared gateway supplies that verified +envelope through the same contract. diff --git a/bridge/boston_dynamics/atlas_drc_bridge/.gitignore b/bridge/boston_dynamics/atlas_drc_bridge/.gitignore new file mode 100644 index 000000000..17879377d --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/.gitignore @@ -0,0 +1,5 @@ +models/atlas_v4/ +artifacts/ +scenes/*_result.json +__pycache__/ +*.pyc diff --git a/bridge/boston_dynamics/atlas_drc_bridge/README.md b/bridge/boston_dynamics/atlas_drc_bridge/README.md new file mode 100644 index 000000000..2b758208c --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/README.md @@ -0,0 +1,22 @@ +# Atlas DRC/v4 MuJoCo + Webots bridge + +This bridge implements the simulator-only Tier 1 profile +`boston-dynamics.atlas-drc.mujoco-webots-wave.v1`. It deliberately targets the +public DARPA-era hydraulic Atlas DRC/v4 model, not Boston Dynamics' current +electric Atlas product. + +The complete setup, action contract, payment safety, evidence procedure and +troubleshooting guide are documented in the profile +[README](../../../registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/README.md). + +Quick local model checks: + +```bash +python download_atlas_model.py +python run_paid_wave.py +python run_sim2sim_validation.py +``` + +On Windows, use `run_live_base_sepolia_visual.ps1` for the current-commit paid +recording flow. It pauses before payment and never stores or prints the payer +private key. diff --git a/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/__init__.py b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/__init__.py new file mode 100644 index 000000000..9c22574af --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/__init__.py @@ -0,0 +1,6 @@ +"""Payment-gated Boston Dynamics Atlas DRC legacy simulation bridge.""" + +from .contracts import ATLAS_ROBOT_ID, PROFILE_ID, WAVE_SKILL_ID +from .runtime import run_wave_episode + +__all__ = ["ATLAS_ROBOT_ID", "PROFILE_ID", "WAVE_SKILL_ID", "run_wave_episode"] diff --git a/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/bridge.py b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/bridge.py new file mode 100644 index 000000000..563d3c9d7 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/bridge.py @@ -0,0 +1,273 @@ +"""Zenoh bridge that executes only Tunnel-correlated Atlas actions.""" + +from __future__ import annotations + +import importlib.util +import json +import logging +import os +import sys +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .contracts import ( + ATLAS_ROBOT_ID, + PROFILE_ID, + STOP_SKILL_ID, + ActionContractError, + validate_action, +) +from .runtime import run_wave_episode + + +LOGGER = logging.getLogger("robopay.atlas_drc") +ACTION_TOPIC = "robot/tunnel/action" +RESULT_TOPIC = "robot/tunnel/result" +METRICS_TOPIC = "robot/boston_dynamics_atlas_drc/metrics" +READY_TOPIC = "robot/boston_dynamics_atlas_drc/ready" + + +@dataclass(frozen=True) +class BridgeSettings: + robot_id: str + zenoh_endpoint: str | None + zenoh_config_path: str | None + action_topic: str + result_topic: str + metrics_topic: str + + @classmethod + def from_env(cls) -> "BridgeSettings": + def configured(name: str, default: str) -> str: + return os.environ.get(name, default).strip() or default + + return cls( + robot_id=configured("ROBOT_ID", ATLAS_ROBOT_ID), + zenoh_endpoint=os.environ.get("ZENOH_ENDPOINT", "").strip() or None, + zenoh_config_path=os.environ.get("ZENOH_CONFIG", "").strip() or None, + action_topic=configured("ZENOH_ACTION_TOPIC", ACTION_TOPIC), + result_topic=configured("ZENOH_RESULT_TOPIC", RESULT_TOPIC), + metrics_topic=configured("ZENOH_METRICS_TOPIC", METRICS_TOPIC), + ) + + +def _open_zenoh_session(settings: BridgeSettings): + import zenoh + + if settings.zenoh_config_path: + return zenoh.open(zenoh.Config.from_file(settings.zenoh_config_path)) + if settings.zenoh_endpoint: + return zenoh.open( + zenoh.Config.from_json5( + json.dumps( + { + "mode": "client", + "connect": {"endpoints": [settings.zenoh_endpoint]}, + } + ) + ) + ) + raise RuntimeError( + "Refusing an implicit Zenoh session. Configure ZENOH_CONFIG for the " + "private Tunnel-to-bridge boundary (or ZENOH_ENDPOINT for a controlled local test)." + ) + + +def _load_event_parser(): + parser_path = ( + Path(__file__).resolve().parents[3] + / "common" + / "zenoh_bridge" + / "zenoh_bridge" + / "action_event.py" + ) + spec = importlib.util.spec_from_file_location("robopay_atlas_action_event", parser_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot load shared ActionEvent parser from {parser_path}") + module = importlib.util.module_from_spec(spec) + # ``action_event.py`` defines dataclasses. Registering its module before + # execution makes its annotations resolvable in the same way as a normal + # import (and avoids a test-only import-path difference). + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module.parse_action_event + + +class AtlasZenohBridge: + """A second authorization boundary before physics can receive an action.""" + + def __init__( + self, + model_dir: str | None = None, + settings: BridgeSettings | None = None, + episode_runner: Callable[..., dict] = run_wave_episode, + ): + self.settings = settings or BridgeSettings.from_env() + self.robot_id = self.settings.robot_id + self._model_dir = model_dir + self._episode_runner = episode_runner + self._parse_action_event = _load_event_parser() + self._session = _open_zenoh_session(self.settings) + self._result_publisher = self._session.declare_publisher(self.settings.result_topic) + self._metrics_publisher = self._session.declare_publisher(self.settings.metrics_topic) + self._stop_event = threading.Event() + self._stop_confirmed = threading.Event() + self._worker_lock = threading.Lock() + self._worker: threading.Thread | None = None + self._subscriber = self._session.declare_subscriber(self.settings.action_topic, self._on_action) + self._session.put( + READY_TOPIC, + json.dumps( + { + "status": "ready", + "robot_id": self.robot_id, + "action_topic": self.settings.action_topic, + }, + separators=(",", ":"), + ).encode("utf-8"), + ) + + def _publish(self, event, status: str, result: dict) -> None: + envelope = { + "action_id": event.action_id, + "robot_id": event.robot_id, + "skill_id": event.skill_id, + "params_hash": event.params_hash, + "idempotency_key": event.idempotency_key, + "profile_id": PROFILE_ID, + "status": status, + "result": result, + } + payload = json.dumps(envelope, separators=(",", ":")).encode("utf-8") + self._metrics_publisher.put(payload) + self._result_publisher.put(payload) + + def _execute_wave(self, event, params) -> None: + try: + runner_options = {} + if os.environ.get("ATLAS_MUJOCO_VIEWER", "").strip().lower() in {"1", "true", "yes"}: + try: + hold_seconds = max(0.0, float(os.environ.get("ATLAS_MUJOCO_VIEWER_HOLD_SECONDS", "10"))) + except ValueError: + hold_seconds = 10.0 + try: + start_hold_seconds = max( + 0.0, + float(os.environ.get("ATLAS_MUJOCO_VIEWER_START_HOLD_SECONDS", "3")), + ) + turn_hold_seconds = max( + 0.0, + float(os.environ.get("ATLAS_MUJOCO_VIEWER_TURN_HOLD_SECONDS", "0.45")), + ) + except ValueError: + start_hold_seconds = 3.0 + turn_hold_seconds = 0.45 + runner_options = { + "viewer": True, + "viewer_hold_seconds": hold_seconds, + "viewer_start_hold_seconds": start_hold_seconds, + "viewer_turn_hold_seconds": turn_hold_seconds, + } + result = self._episode_runner( + params, + model_dir=self._model_dir, + stop_requested=self._stop_event.is_set, + **runner_options, + ) + except Exception as error: + LOGGER.exception("Atlas simulator execution failed") + result = { + "success": False, + "error_code": "SIMULATOR_EXECUTION_ERROR", + "message": str(error), + } + if result.get("safe_stop_applied"): + self._stop_confirmed.set() + self._publish(event, "success" if result.get("success") else "failure", result) + + def _on_action(self, sample) -> None: # exercised by unit and real-Zenoh integration tests + event = self._parse_action_event(bytes(sample.payload.to_bytes())) + if event is None: + LOGGER.warning("Rejected malformed or uncorrelated ActionEvent before simulation") + return + if event.robot_id != self.robot_id: + return + if event.action != event.skill_id: + self._publish(event, "failure", {"success": False, "error_code": "ACTION_SKILL_MISMATCH"}) + return + try: + params = validate_action(event.action, event.params) + except ActionContractError as error: + self._publish( + event, + "failure", + {"success": False, "error_code": error.code, "message": str(error)}, + ) + return + + if event.action == STOP_SKILL_ID: + self._stop_event.set() + with self._worker_lock: + active = self._worker is not None and self._worker.is_alive() + confirmed = not active or self._stop_confirmed.wait(timeout=5.0) + self._publish( + event, + "success" if confirmed else "failure", + { + "success": confirmed, + "safe_stop_applied": confirmed, + "active_execution_interrupted": active, + "error_code": None if confirmed else "SAFE_STOP_TIMEOUT", + }, + ) + return + + with self._worker_lock: + if self._worker is not None and self._worker.is_alive(): + self._publish(event, "failure", {"success": False, "error_code": "ROBOT_BUSY"}) + return + self._stop_event.clear() + self._stop_confirmed.clear() + self._worker = threading.Thread( + target=self._execute_wave, + args=(event, params), + daemon=True, + name=f"atlas-wave-{event.action_id}", + ) + self._worker.start() + + def close(self) -> None: + self._stop_event.set() + with self._worker_lock: + worker = self._worker + if worker is not None: + worker.join(timeout=5) + self._subscriber.undeclare() + self._result_publisher.undeclare() + self._metrics_publisher.undeclare() + self._session.close() + + def spin(self) -> None: # pragma: no cover - process entry point + LOGGER.info( + "Atlas bridge %s subscribes %s and publishes %s", + self.robot_id, + self.settings.action_topic, + self.settings.result_topic, + ) + try: + while True: + time.sleep(0.1) + finally: + self.close() + + +def main() -> None: # pragma: no cover - process entry point + logging.basicConfig(level=logging.INFO) + AtlasZenohBridge().spin() + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/contracts.py b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/contracts.py new file mode 100644 index 000000000..debc2bed7 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/contracts.py @@ -0,0 +1,73 @@ +"""Fail-closed action contract for the Atlas DRC legacy profile.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + + +ATLAS_ROBOT_ID = "atlas-drc-mujoco-webots-sim-01" +PROFILE_ID = "boston-dynamics.atlas-drc.mujoco-webots-wave.v1" +WAVE_SKILL_ID = "wave_right_arm" +STOP_SKILL_ID = "stop" +ALLOWED_ACTIONS = {WAVE_SKILL_ID, STOP_SKILL_ID} + + +class ActionContractError(ValueError): + """A stable, safe-to-publish bridge rejection.""" + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class WaveParameters: + cycles: int + amplitude_rad: float + max_duration_sec: float + + +def _finite_number(value: object, name: str, minimum: float, maximum: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ActionContractError("INVALID_PARAMS", f"{name} must be a number.") + rendered = float(value) + if not math.isfinite(rendered) or not minimum <= rendered <= maximum: + raise ActionContractError( + "INVALID_PARAMS", f"{name} must be between {minimum} and {maximum}." + ) + return rendered + + +def validate_wave_params(params: object) -> WaveParameters: + """Validate the bounded state-feedback wave skill; unknown fields fail closed.""" + + if not isinstance(params, dict): + raise ActionContractError("INVALID_PARAMS", "params must be an object.") + allowed = {"cycles", "amplitudeRad", "maxDurationSec"} + unknown = sorted(set(params) - allowed) + if unknown: + raise ActionContractError("INVALID_PARAMS", f"Unknown parameter(s): {', '.join(unknown)}.") + + cycles = params.get("cycles", 2) + if isinstance(cycles, bool) or not isinstance(cycles, int) or not 1 <= cycles <= 3: + raise ActionContractError("INVALID_PARAMS", "cycles must be an integer from 1 to 3.") + amplitude = _finite_number(params.get("amplitudeRad", 0.30), "amplitudeRad", 0.15, 0.40) + # A fixed public lower bound keeps the Tunnel catalog and the bridge + # contract identical. Five seconds accommodates the maximum three-cycle + # request without accepting a paid request that the bridge would later + # reject only after Zenoh publication. + duration = _finite_number(params.get("maxDurationSec", 8.0), "maxDurationSec", 5.0, 15.0) + return WaveParameters(cycles=cycles, amplitude_rad=amplitude, max_duration_sec=duration) + + +def validate_action(action: object, params: object) -> WaveParameters | None: + """Return validated wave parameters, or ``None`` for a parameterless safe stop.""" + + if not isinstance(action, str) or action not in ALLOWED_ACTIONS: + raise ActionContractError("UNREGISTERED_ACTION", "Action is not registered for this Atlas profile.") + if action == STOP_SKILL_ID: + if params not in ({}, None): + raise ActionContractError("INVALID_PARAMS", "stop does not accept parameters.") + return None + return validate_wave_params(params) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/model.py b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/model.py new file mode 100644 index 000000000..f09330846 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/model.py @@ -0,0 +1,157 @@ +"""Resolve and load the pinned Atlas DRC v4 URDF for MuJoCo.""" + +from __future__ import annotations + +import os +import xml.etree.ElementTree as element_tree +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +LOCAL_MODEL_DIR = PACKAGE_ROOT / "models" / "atlas_v4" +URDF_RELATIVE_PATH = Path("urdf") / "atlas_v4_with_multisense.urdf" +PHYSICS_URDF_RELATIVE_PATH = Path("atlas_v4_with_multisense_physics.urdf") +VISUAL_URDF_RELATIVE_PATH = Path("atlas_v4_with_multisense_visual.urdf") + + +def resolve_model_dir(model_dir: str | Path | None = None) -> Path: + """Return a complete downloaded model directory, never a partial asset set.""" + + candidates: list[Path] = [] + if model_dir: + candidates.append(Path(model_dir)) + if os.environ.get("ATLAS_DRC_MODEL_DIR"): + candidates.append(Path(os.environ["ATLAS_DRC_MODEL_DIR"])) + candidates.append(LOCAL_MODEL_DIR) + for candidate in candidates: + candidate = candidate.expanduser().resolve() + if (candidate / URDF_RELATIVE_PATH).is_file(): + return candidate + raise FileNotFoundError( + "Atlas DRC v4 URDF was not found. Run " + "python bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py " + "or set ATLAS_DRC_MODEL_DIR to its atlas_v4 directory." + ) + + +def _source_mesh(model_dir: Path, package_reference: str) -> Path: + """Resolve an original package:// visual mesh without changing the URDF.""" + + if not package_reference.startswith("package://"): + raise ValueError(f"Expected package:// mesh reference, got {package_reference!r}") + relative = Path(package_reference.removeprefix("package://")) + if relative.parts[0] == "atlas_description": + return model_dir.joinpath(*relative.parts[1:]) + if relative.parts[0] == "multisense_sl_description": + return model_dir / relative + raise ValueError(f"Unsupported pinned Atlas mesh package: {package_reference}") + + +def _convert_dae_to_obj(source: Path, destination: Path) -> None: + """Convert an upstream visual DAE into an unmodified triangle OBJ mesh.""" + + import trimesh + + loaded = trimesh.load(source, force="scene") + if isinstance(loaded, trimesh.Scene): + mesh = loaded.dump(concatenate=True) + else: + mesh = loaded + if not isinstance(mesh, trimesh.Trimesh) or mesh.is_empty: + raise RuntimeError(f"Could not read visual mesh from {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + mesh.export(destination, file_type="obj") + + +def _name_unnamed_link_geometry(root: element_tree.Element) -> None: + """Give local import-only names to otherwise anonymous URDF geometry. + + The pinned source URDF intentionally leaves its ``visual`` and + ``collision`` elements unnamed. MuJoCo accepts that valid URDF but emits + a warning for every repeated empty name. Names carry no physics meaning, + so adding deterministic local names keeps the import auditable without + changing source geometry, joints, materials, or limits. + """ + + for link in root.findall("link"): + link_name = link.get("name", "unnamed_link") + for kind in ("visual", "collision"): + for index, element in enumerate(link.findall(kind)): + element.set("name", f"{link_name}_{kind}_{index}") + + +def prepare_physics_urdf(model_dir: str | Path | None = None) -> Path: + """Write a name-sanitized local import of the immutable source URDF.""" + + directory = resolve_model_dir(model_dir) + root = element_tree.parse(directory / URDF_RELATIVE_PATH).getroot() + _name_unnamed_link_geometry(root) + output_urdf = directory / PHYSICS_URDF_RELATIVE_PATH + element_tree.ElementTree(root).write(output_urdf, encoding="utf-8", xml_declaration=True) + return output_urdf + + +def prepare_visual_urdf(model_dir: str | Path | None = None) -> Path: + """Generate a local visual-only OBJ view of the pinned original URDF. + + MuJoCo accepts OBJ/STL meshes but not the source Atlas DAE visuals. This + leaves the checked-source URDF untouched, converts its original visual + triangles locally, and writes a sibling URDF used only when an operator + opts into the desktop viewer. + """ + + directory = resolve_model_dir(model_dir) + source_urdf = directory / URDF_RELATIVE_PATH + output_urdf = directory / VISUAL_URDF_RELATIVE_PATH + root = element_tree.parse(source_urdf).getroot() + mujoco_extension = root.find("mujoco") + if mujoco_extension is None: + mujoco_extension = element_tree.Element("mujoco") + root.insert(0, mujoco_extension) + compiler = mujoco_extension.find("compiler") + if compiler is None: + compiler = element_tree.SubElement(mujoco_extension, "compiler") + # MuJoCo's URDF compiler otherwise drops visual-only meshes when a link + # already has collision geometry. + compiler.set("discardvisual", "false") + _name_unnamed_link_geometry(root) + for mesh_element in root.iter("mesh"): + reference = mesh_element.get("filename") + if not reference or not reference.endswith(".dae"): + continue + source = _source_mesh(directory, reference) + if not source.is_file(): + raise FileNotFoundError(f"Pinned visual mesh is missing: {source}") + package_relative = Path(reference.removeprefix("package://")).with_suffix(".obj") + # MuJoCo's URDF importer resolves a mesh filename relative to the + # URDF and discards parent/subdirectory components. Keep generated + # display assets beside that URDF, with a collision-free flat name. + converted = output_urdf.parent / ( + "atlas_visual_" + "_".join(package_relative.parts) + ) + if not converted.is_file() or converted.stat().st_mtime < source.stat().st_mtime: + _convert_dae_to_obj(source, converted) + mesh_element.set("filename", converted.name) + output_urdf.parent.mkdir(parents=True, exist_ok=True) + element_tree.ElementTree(root).write(output_urdf, encoding="utf-8", xml_declaration=True) + return output_urdf + + +def load_mujoco_model(model_dir: str | Path | None = None, visual: bool = False): + """Load the pinned upstream URDF with MuJoCo's real physics parser. + + ``visual=True`` adds converted upstream display meshes, then marks those + meshes non-colliding so the viewer cannot alter the validated collision + physics used by the payment-gated controller. + """ + + import mujoco + + directory = resolve_model_dir(model_dir) + path = prepare_visual_urdf(directory) if visual else prepare_physics_urdf(directory) + model = mujoco.MjModel.from_xml_path(str(path)) + if visual: + mesh_geometries = model.geom_type == int(mujoco.mjtGeom.mjGEOM_MESH) + model.geom_contype[mesh_geometries] = 0 + model.geom_conaffinity[mesh_geometries] = 0 + return model diff --git a/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/runtime.py b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/runtime.py new file mode 100644 index 000000000..022eb33a0 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/runtime.py @@ -0,0 +1,263 @@ +"""Physics-backed, closed-loop Atlas DRC right-arm wave episode.""" + +from __future__ import annotations + +import math +import time +from contextlib import nullcontext +from dataclasses import dataclass +from typing import Callable + +import numpy as np + +from .contracts import WaveParameters +from .model import load_mujoco_model + + +# These are names from the pinned Atlas v4 URDF. The model has no actuator +# definitions, so this adapter applies bounded generalized torque through +# MuJoCo's real dynamics instead of writing joint positions directly. +WAVE_JOINT = "r_arm_shz" +POSTURE_TARGETS = { + "r_arm_shx": -0.25, + "r_arm_ely": 1.05, + "r_arm_elx": -0.75, + "r_arm_wry": 0.15, + "r_arm_wrx": 0.0, + "r_arm_wry2": 0.0, +} +KP = 70.0 +KD = 8.0 +MAX_TORQUE_NM = 75.0 +TARGET_TOLERANCE_RAD = 0.055 +VELOCITY_TOLERANCE_RAD_S = 0.65 +REQUIRED_SETTLED_STEPS = 6 +TURNING_POINT_FRACTION = 0.70 + + +@dataclass +class ArmWavePolicy: + """State-feedback, target-switching wave controller. + + This is intentionally not a replayed trajectory. It advances to the next + half-wave only after the measured shoulder crosses a bounded turning-point + threshold. The threshold is deliberately inside the requested amplitude: + it absorbs real model coupling and avoids treating a precomputed timer as + proof of a physical wave. + """ + + params: WaveParameters + phase_index: int = 0 + settled_steps: int = 0 + return_settled: bool = False + + @property + def complete(self) -> bool: + return self.return_settled + + def target(self) -> float: + if self.phase_index >= self.params.cycles * 2: + return 0.0 + return self.params.amplitude_rad if self.phase_index % 2 == 0 else -self.params.amplitude_rad + + def observe(self, position: float, velocity: float) -> None: + if self.complete: + return + if self.phase_index >= self.params.cycles * 2: + returned = ( + abs(position) <= TARGET_TOLERANCE_RAD + and abs(velocity) <= VELOCITY_TOLERANCE_RAD_S + ) + self.settled_steps = self.settled_steps + 1 if returned else 0 + if self.settled_steps >= REQUIRED_SETTLED_STEPS: + self.return_settled = True + self.settled_steps = 0 + return + threshold = abs(self.target()) * TURNING_POINT_FRACTION + reached = position >= threshold if self.target() > 0 else position <= -threshold + self.settled_steps = self.settled_steps + 1 if reached else 0 + if self.settled_steps >= REQUIRED_SETTLED_STEPS: + self.phase_index += 1 + self.settled_steps = 0 + + +def _joint_addresses(model) -> dict[str, tuple[int, int]]: + import mujoco + + addresses: dict[str, tuple[int, int]] = {} + required = {WAVE_JOINT, *POSTURE_TARGETS} + for joint_name in required: + joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, joint_name) + if joint_id < 0: + raise RuntimeError(f"Pinned Atlas URDF is missing required joint {joint_name!r}.") + addresses[joint_name] = (int(model.jnt_qposadr[joint_id]), int(model.jnt_dofadr[joint_id])) + return addresses + + +def _posture_hold_addresses(model) -> dict[str, tuple[int, int]]: + """Return all one-DoF joints that can be physically held during an arm task. + + The source URDF intentionally has no motor definitions. Holding the + non-commanded joints at their measured initial posture stops gravity and + whole-body coupling from masquerading as a right-arm-policy failure, while + leaving every degree of freedom in MuJoCo's normal forward dynamics. + """ + + import mujoco + + addresses: dict[str, tuple[int, int]] = {} + for joint_id in range(model.njnt): + if int(model.jnt_type[joint_id]) not in ( + int(mujoco.mjtJoint.mjJNT_HINGE), + int(mujoco.mjtJoint.mjJNT_SLIDE), + ): + continue + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, joint_id) + if name: + addresses[name] = (int(model.jnt_qposadr[joint_id]), int(model.jnt_dofadr[joint_id])) + return addresses + + +def _bounded_pd(target: float, position: float, velocity: float) -> float: + return float(np.clip(KP * (target - position) - KD * velocity, -MAX_TORQUE_NM, MAX_TORQUE_NM)) + + +def run_wave_episode( + params: WaveParameters, + model_dir: str | None = None, + stop_requested: Callable[[], bool] | None = None, + viewer: bool = False, + viewer_hold_seconds: float = 0.0, + viewer_start_hold_seconds: float = 0.0, + viewer_turn_hold_seconds: float = 0.0, +) -> dict: + """Run a measured-state closed-loop wave in real MuJoCo physics. + + The result contains joint-space state metrics that make a superficial + success impossible: a terminal success requires every requested half-wave + to reach its measured target while remaining finite and within torque + limits. + """ + + import mujoco + + model = load_mujoco_model(model_dir, visual=viewer) + data = mujoco.MjData(model) + mujoco.mj_forward(model, data) + addresses = _joint_addresses(model) + posture_addresses = _posture_hold_addresses(model) + policy = ArmWavePolicy(params) + stop = stop_requested or (lambda: False) + wave_qpos, wave_dof = addresses[WAVE_JOINT] + initial_angle = float(data.qpos[wave_qpos]) + hold_targets = { + name: float(data.qpos[qpos_address]) + for name, (qpos_address, _) in posture_addresses.items() + } + samples: list[float] = [initial_angle] + torque_peak = 0.0 + control_steps = 0 + safe_stopped = False + finite = True + + viewer_context = nullcontext(None) + if viewer: + import mujoco.viewer + + viewer_context = mujoco.viewer.launch_passive(model, data) + + with viewer_context as active_viewer: + if active_viewer is not None: + active_viewer.cam.lookat[:] = (0.0, 0.0, 0.85) + active_viewer.cam.distance = 4.2 + active_viewer.cam.azimuth = 135.0 + active_viewer.cam.elevation = -18.0 + + start_deadline = time.monotonic() + viewer_start_hold_seconds + while active_viewer.is_running() and time.monotonic() < start_deadline: + active_viewer.sync() + time.sleep(0.02) + + while data.time < params.max_duration_sec and not policy.complete: + if stop(): + data.qfrc_applied[:] = 0.0 + data.qvel[:] = 0.0 + mujoco.mj_forward(model, data) + safe_stopped = True + break + + data.qfrc_applied[:] = 0.0 + targets = dict(hold_targets) + targets.update({WAVE_JOINT: policy.target(), **POSTURE_TARGETS}) + for joint_name, target in targets.items(): + qpos_address, dof_address = posture_addresses[joint_name] + torque = _bounded_pd( + target, + float(data.qpos[qpos_address]), + float(data.qvel[dof_address]), + ) + data.qfrc_applied[dof_address] = torque + torque_peak = max(torque_peak, abs(torque)) + + mujoco.mj_step(model, data) + position = float(data.qpos[wave_qpos]) + velocity = float(data.qvel[wave_dof]) + finite = finite and math.isfinite(position) and math.isfinite(velocity) + if not finite: + break + samples.append(position) + previous_phase = policy.phase_index + policy.observe(position, velocity) + control_steps += 1 + if active_viewer is not None: + active_viewer.sync() + # The automated proof runs at full speed; the opt-in desktop + # view follows the physics clock so a human can inspect it. + time.sleep(float(model.opt.timestep)) + if policy.phase_index != previous_phase and viewer_turn_hold_seconds > 0: + turn_deadline = time.monotonic() + viewer_turn_hold_seconds + while active_viewer.is_running() and time.monotonic() < turn_deadline: + active_viewer.sync() + time.sleep(0.02) + + if active_viewer is not None and viewer_hold_seconds > 0: + deadline = time.monotonic() + viewer_hold_seconds + while active_viewer.is_running() and time.monotonic() < deadline: + active_viewer.sync() + time.sleep(0.02) + + measured_stroke = max(samples) - min(samples) + success = bool( + finite + and not safe_stopped + and policy.complete + and measured_stroke >= params.amplitude_rad * 1.35 + and torque_peak <= MAX_TORQUE_NM + 1e-9 + ) + return { + "simulator_engine": "MuJoCo", + "robot_model": "Boston Dynamics Atlas DRC v4 (legacy URDF)", + "task": "wave_right_arm", + "status": "success" if success else "failure", + "success": success, + "completion_reason": ( + "safe_stopped" if safe_stopped else "wave_complete" if policy.complete else "time_limit" + ), + "safe_stop_applied": safe_stopped, + "sim_duration_seconds": round(float(data.time), 3), + "control_steps": control_steps, + "controller": "state_feedback_turning_point_pd_torque", + "policy_id": "atlas-drc-right-arm-wave-v1", + "requested_cycles": params.cycles, + "completed_half_waves": policy.phase_index, + "requested_amplitude_rad": params.amplitude_rad, + "initial_wave_joint_rad": round(initial_angle, 5), + "final_wave_joint_rad": round(float(data.qpos[wave_qpos]), 5), + "min_wave_joint_rad": round(min(samples), 5), + "max_wave_joint_rad": round(max(samples), 5), + "final_wave_joint_velocity_rad_s": round(float(data.qvel[wave_dof]), 5), + "measured_wave_stroke_rad": round(measured_stroke, 5), + "peak_commanded_torque_nm": round(torque_peak, 5), + "finite_state": finite, + "viewer_enabled": viewer, + } diff --git a/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/webots.py b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/webots.py new file mode 100644 index 000000000..21ebb5f1b --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/atlas_drc_bridge/webots.py @@ -0,0 +1,92 @@ +"""Run the Atlas DRC legacy wave against Webots' built-in Atlas PROTO.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +SCENE = PACKAGE_ROOT / "scenes" / "atlas_paid_wave.wbt" +RESULT = PACKAGE_ROOT / "scenes" / "webots_wave_result.json" + + +def find_webots() -> Path | None: + override = os.environ.get("WEBOTS_EXE") + if override and Path(override).is_file(): + return Path(override) + for candidate in ("webots", "webots.exe"): + found = shutil.which(candidate) + if found: + return Path(found) + return None + + +def run_webots_validation(timeout_seconds: int = 60) -> dict: + """Run R2025a headlessly and return the controller-written state metrics.""" + + executable = find_webots() + if executable is None: + return { + "simulator_engine": "Webots", + "success": False, + "status": "failure", + "error": "Webots R2025a executable not found; set WEBOTS_EXE.", + } + RESULT.unlink(missing_ok=True) + environment = dict(os.environ) + environment["WEBOTS_CONTROLLER_PATH"] = str(PACKAGE_ROOT / "controllers") + capture_visual = bool( + environment.get("ATLAS_WEBOTS_RECORDING_PATH", "").strip() + or environment.get("ATLAS_WEBOTS_HOLD_SECONDS", "").strip() + ) + if sys.platform != "win32" and not capture_visual: + environment.setdefault("QT_QPA_PLATFORM", "offscreen") + elif sys.platform == "win32": + # The Windows distribution ships only the native Qt platform plugin; + # inheriting CI's offscreen setting prevents the GUI from starting. + environment.pop("QT_QPA_PLATFORM", None) + mode = "realtime" if capture_visual else "fast" + command = [str(executable), f"--mode={mode}", "--stdout", "--stderr", str(SCENE)] + if not capture_visual: + command[1:1] = ["--batch", "--no-rendering"] + process = subprocess.Popen( + command, + cwd=SCENE.parent, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, + ) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if RESULT.is_file(): + try: + hold_seconds = float(environment.get("ATLAS_WEBOTS_HOLD_SECONDS", "0") or 0) + process.wait(timeout=max(10.0, hold_seconds + 5.0)) + except subprocess.TimeoutExpired: + process.terminate() + result = json.loads(RESULT.read_text(encoding="utf-8")) + result["webots_return_code"] = process.poll() + return result + time.sleep(0.2) + process.terminate() + try: + stdout, stderr = process.communicate(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + return { + "simulator_engine": "Webots", + "success": False, + "status": "failure", + "error": f"Webots did not write a result within {timeout_seconds} seconds.", + "stdout": stdout[-1500:], + "stderr": stderr[-1500:], + } diff --git a/bridge/boston_dynamics/atlas_drc_bridge/controllers/atlas_wave_controller/atlas_wave_controller.py b/bridge/boston_dynamics/atlas_drc_bridge/controllers/atlas_wave_controller/atlas_wave_controller.py new file mode 100644 index 000000000..e17ddd640 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/controllers/atlas_wave_controller/atlas_wave_controller.py @@ -0,0 +1,143 @@ +"""Measured-state right-arm wave controller for Webots' built-in Atlas DRC.""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +from controller import Supervisor + + +TIME_STEP = 16 +CYCLES = 2 +AMPLITUDE_RAD = 0.30 +MAX_DURATION_SEC = 8.0 +TARGET_TOLERANCE_RAD = 0.06 +SETTLED_STEPS_REQUIRED = 6 +RESULT_PATH = Path(__file__).resolve().parents[2] / "scenes" / "webots_wave_result.json" + + +def _joint_position_field(atlas, definition: str): + """Read the actual HingeJoint position through the Supervisor API.""" + + # The joint DEFs are internal to Webots' Atlas PROTO. A world-level + # ``getFromDef`` cannot see them; the public PROTO-node API can. + joint = atlas.getFromProtoDef(definition) + if joint is None: + raise RuntimeError(f"Missing Webots Atlas joint DEF {definition}") + parameters = joint.getField("jointParameters").getSFNode() + if parameters is None: + raise RuntimeError(f"Missing HingeJointParameters for {definition}") + field = parameters.getField("position") + if field is None: + raise RuntimeError(f"Webots did not expose measured position for {definition}") + return field + + +def main() -> None: + robot = Supervisor() + atlas = robot.getFromDef("ATLAS_DRC") + if atlas is None: + raise RuntimeError("World must expose the Atlas instance as DEF ATLAS_DRC") + shoulder_position = _joint_position_field(atlas, "RArmUsy") + shoulder = robot.getDevice("RArmUsy") + shoulder_roll = robot.getDevice("RArmShx") + elbow = robot.getDevice("RArmEly") + elbow_roll = robot.getDevice("RArmElx") + wrist = robot.getDevice("RArmUwy") + for motor, target in ((shoulder_roll, -0.25), (elbow, 1.05), (elbow_roll, -0.75), (wrist, 0.15)): + motor.setPosition(target) + motor.setVelocity(2.0) + shoulder.setVelocity(1.5) + + recording = os.environ.get("ATLAS_WEBOTS_RECORDING_PATH", "").strip() + if recording: + Path(recording).parent.mkdir(parents=True, exist_ok=True) + # MPEG-4, high quality, real-time acceleration. This is opt-in so the + # normal CI validation remains fast and headless. + robot.movieStartRecording(recording, 1280, 720, 0, 100, 1, False) + + phase = 0 + settled_steps = 0 + values: list[float] = [] + root_heights: list[float] = [] + upright_cosines: list[float] = [] + start_time = robot.getTime() + while robot.step(TIME_STEP) != -1 and robot.getTime() - start_time < MAX_DURATION_SEC: + position = float(shoulder_position.getSFFloat()) + values.append(position) + root_heights.append(float(atlas.getPosition()[2])) + # Third column of the local-to-world rotation matrix is the Atlas + # torso's local +Z axis expressed in world coordinates. Its world-Z + # component is one while upright and approaches zero as it falls. + upright_cosines.append(float(atlas.getOrientation()[8])) + target = AMPLITUDE_RAD if phase % 2 == 0 else -AMPLITUDE_RAD + shoulder.setPosition(target) + if abs(position - target) <= TARGET_TOLERANCE_RAD: + settled_steps += 1 + else: + settled_steps = 0 + if settled_steps >= SETTLED_STEPS_REQUIRED: + phase += 1 + settled_steps = 0 + if phase >= CYCLES * 2: + break + + stroke = (max(values) - min(values)) if values else 0.0 + minimum_root_height = min(root_heights) if root_heights else 0.0 + minimum_upright_cosine = min(upright_cosines) if upright_cosines else -1.0 + recording_ready = True + if recording: + robot.movieStopRecording() + deadline = robot.getTime() + 15.0 + while not robot.movieIsReady() and not robot.movieFailed() and robot.getTime() < deadline: + if robot.step(TIME_STEP) == -1: + break + recording_ready = robot.movieIsReady() and not robot.movieFailed() and Path(recording).is_file() + + stable_base = minimum_root_height >= 0.85 and minimum_upright_cosine >= 0.90 + success = ( + phase >= CYCLES * 2 + and stroke >= AMPLITUDE_RAD * 1.35 + and stable_base + and recording_ready + ) + result = { + "simulator_engine": "Webots", + "robot_model": "Boston Dynamics Atlas DRC legacy (Webots R2025a built-in PROTO)", + "task": "wave_right_arm", + "status": "success" if success else "failure", + "success": success, + "controller": "state_feedback_target_switching_motor_controller", + "policy_id": "atlas-drc-right-arm-wave-v1", + "requested_cycles": CYCLES, + "completed_half_waves": phase, + "requested_amplitude_rad": AMPLITUDE_RAD, + "measured_wave_stroke_rad": round(stroke, 5), + "minimum_root_height_m": round(minimum_root_height, 5), + "minimum_upright_cosine": round(minimum_upright_cosine, 5), + "stable_base": stable_base, + "sim_duration_seconds": round(robot.getTime() - start_time, 3), + "state_authority": "RArmUsy HingeJointParameters.position via Supervisor", + } + if recording: + result["visual_recording"] = recording + result["visual_recording_ready"] = recording_ready + RESULT_PATH.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + + # Keep the completed pose visible only when an operator explicitly asks + # for a graphical preview or recording. Automated Sim-to-Sim runs leave + # this unset and still terminate immediately after producing the result. + hold_seconds = max(0.0, float(os.environ.get("ATLAS_WEBOTS_HOLD_SECONDS", "0"))) + hold_until = time.monotonic() + hold_seconds + while hold_seconds and time.monotonic() < hold_until: + if robot.step(TIME_STEP) == -1: + break + time.sleep(TIME_STEP / 1000.0) + robot.simulationQuit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py b/bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py new file mode 100644 index 000000000..86b3ee294 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py @@ -0,0 +1,98 @@ +"""Fetch the pinned, legacy Atlas v4 model used by the MuJoCo bridge. + +The current commercial Atlas does not have a public MuJoCo model. This +profile deliberately uses the older DRC/v4 Atlas model and says so everywhere +it presents evidence. The upstream model stays out of Git; this helper pins +its revision and verifies the downloaded URDF before it is used. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +LOCK_PATH = HERE / "models" / "model.lock.json" +DEFAULT_DESTINATION = HERE / "models" / "atlas_v4" + + +def _sha256(path: Path) -> str: + # Git for Windows may materialize these XML assets with CRLF despite a + # repository-level checkout setting. The locked blobs are LF canonical; + # normalize only line endings so the same signed source revision validates + # on Windows and Linux without weakening content verification. + return hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() + + +def download(destination: Path = DEFAULT_DESTINATION) -> Path: + """Download the exact model revision and return its local directory.""" + + lock = json.loads(LOCK_PATH.read_text(encoding="utf-8")) + required = destination / lock["urdf"] + extras = lock.get("extra_directories", []) + extras_valid = all( + (destination / extra["destination"] / extra["verify_path"]).is_file() + and _sha256(destination / extra["destination"] / extra["verify_path"]) == extra["sha256"] + for extra in extras + ) + if required.is_file() and _sha256(required) == lock["urdf_sha256"] and extras_valid: + return destination + + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="robopay-atlas-roboschool-") as temp_dir: + checkout = Path(temp_dir) / "roboschool" + subprocess.run( + [ + "git", + "-c", + "core.autocrlf=false", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + lock["source"], + str(checkout), + ], + check=True, + ) + subprocess.run( + ["git", "-C", str(checkout), "fetch", "--depth", "1", "origin", lock["commit"]], + check=True, + ) + subprocess.run( + ["git", "-C", str(checkout), "checkout", "--detach", lock["commit"]], + check=True, + ) + sparse_paths = [lock["directory"], *(extra["source"] for extra in extras)] + subprocess.run(["git", "-C", str(checkout), "sparse-checkout", "set", *sparse_paths], check=True) + source = checkout / lock["directory"] + source_urdf = source / lock["urdf"] + if not source_urdf.is_file(): + raise RuntimeError(f"Pinned Atlas URDF is missing: {source_urdf}") + if _sha256(source_urdf) != lock["urdf_sha256"]: + raise RuntimeError("Pinned Atlas URDF checksum did not match model.lock.json") + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(source, destination) + for extra in extras: + source_extra = checkout / extra["source"] + verified = source_extra / extra["verify_path"] + if not verified.is_file() or _sha256(verified) != extra["sha256"]: + raise RuntimeError(f"Pinned extra Atlas asset failed verification: {verified}") + shutil.copytree(source_extra, destination / extra["destination"]) + upstream_license = checkout / "LICENSE.md" + if upstream_license.is_file(): + shutil.copy2(upstream_license, destination / "LICENSE.openai-roboschool.md") + + return destination + + +if __name__ == "__main__": + model_dir = download() + print(f"Atlas DRC v4 model downloaded to: {model_dir}") diff --git a/bridge/boston_dynamics/atlas_drc_bridge/models/model.lock.json b/bridge/boston_dynamics/atlas_drc_bridge/models/model.lock.json new file mode 100644 index 000000000..98a3621c0 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/models/model.lock.json @@ -0,0 +1,18 @@ +{ + "robot": "Boston Dynamics Atlas DRC v4 (legacy)", + "source": "https://github.com/openai/roboschool.git", + "commit": "d32bcb2b35b94168b5ce27233ca62f3c8678886f", + "directory": "roboschool/models_robot/atlas_description", + "urdf": "urdf/atlas_v4_with_multisense.urdf", + "urdf_sha256": "75b7d6b9cb74cbb823895930a581e1745c754d78986554c78f4708a6305349d3", + "extra_directories": [ + { + "source": "roboschool/models_robot/multisense_sl_description", + "destination": "multisense_sl_description", + "verify_path": "meshes/head.dae", + "sha256": "2a14b5cdc9e1bba1f072010c412884da795a9769bcd5ce8b836e51d0e78796d4" + } + ], + "license": "MIT (OpenAI Roboschool repository; preserve upstream model notices)", + "limitations": "This is the legacy hydraulic/DARPA Atlas v4 model, not Boston Dynamics' current electric Atlas product. MuJoCo imports its collision geometry but does not load its DAE visual meshes." +} diff --git a/bridge/boston_dynamics/atlas_drc_bridge/requirements.txt b/bridge/boston_dynamics/atlas_drc_bridge/requirements.txt new file mode 100644 index 000000000..3740b25e7 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/requirements.txt @@ -0,0 +1,12 @@ +# Atlas DRC legacy bridge dependencies. The third-party Atlas model is fetched +# by download_atlas_model.py at a pinned revision; it is intentionally not +# vendored in this repository. +mujoco==3.3.0 +numpy==2.2.6 +pycollada==0.9.2 +trimesh==4.12.2 +eclipse-zenoh==1.9.0 +requests==2.33.0 +PyYAML==6.0.2 +x402[requests,evm]==2.16.0 +eth-account==0.13.7 diff --git a/bridge/boston_dynamics/atlas_drc_bridge/run_live_base_sepolia_visual.ps1 b/bridge/boston_dynamics/atlas_drc_bridge/run_live_base_sepolia_visual.ps1 new file mode 100644 index 000000000..988f35e4e --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/run_live_base_sepolia_visual.ps1 @@ -0,0 +1,160 @@ +[CmdletBinding()] +param( + [ValidateRange(0, 20)][int]$HoldSeconds = 5, + [ValidateRange(0, 20)][int]$StartHoldSeconds = 3, + [ValidateRange(0, 30)][int]$PreflightSeconds = 0, + [switch]$DryRun, + [switch]$NoOpenBaseScan, + [switch]$NoAutoLayout, + [switch]$PauseAfter, + [Parameter(Mandatory = $false)][string]$TunnelBin = $env:TUNNEL_BIN +) + +$ErrorActionPreference = 'Stop' + +$privateKey = if (-not [string]::IsNullOrWhiteSpace($env:PRIVATE_KEY)) { + $env:PRIVATE_KEY +} else { + $env:BASE_SEPOLIA_PRIVATE_KEY +} +$payee = if (-not [string]::IsNullOrWhiteSpace($env:ROBO_PAYEE_ADDRESS)) { + $env:ROBO_PAYEE_ADDRESS +} else { + $env:ROBOT_PAYEE_ADDRESS +} +if (-not $DryRun -and [string]::IsNullOrWhiteSpace($privateKey)) { + throw 'Missing PRIVATE_KEY or BASE_SEPOLIA_PRIVATE_KEY. The runner never stores or prints it.' +} +if (-not $DryRun -and $privateKey -notmatch '^(0x)?[0-9a-fA-F]{64}$') { + throw 'Invalid Base Sepolia private key: expected 64 hex characters, optionally prefixed with 0x.' +} +if ([string]::IsNullOrWhiteSpace($payee)) { + throw 'Missing ROBO_PAYEE_ADDRESS or ROBOT_PAYEE_ADDRESS.' +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path +$commitSha = (& git -C $repoRoot rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or $commitSha -notmatch '^[0-9a-f]{40}$') { + throw 'Unable to resolve the exact Git commit for the visual evidence run.' +} +if ([string]::IsNullOrWhiteSpace($TunnelBin)) { + $TunnelBin = Join-Path $repoRoot 'bin/tunnel' +} +if (-not (Test-Path -LiteralPath $TunnelBin)) { + throw "Tunnel binary not found: '$TunnelBin'. Build it in Ubuntu-22.04 with make build." +} + +& wsl.exe -d Ubuntu-22.04 -- true +if ($LASTEXITCODE -ne 0) { + throw 'Ubuntu-22.04 is unavailable in WSL; the Windows visual runner requires that distro.' +} +$staleZenohListener = Get-NetTCPConnection -LocalPort 7447 -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 +if ($staleZenohListener) { + $owner = Get-Process -Id $staleZenohListener.OwningProcess -ErrorAction SilentlyContinue + $description = if ($owner) { "$($owner.ProcessName) PID $($owner.Id)" } else { "PID $($staleZenohListener.OwningProcess)" } + throw "Zenoh port 7447 is already occupied by $description. Stop it before recording." +} + +$python = (Get-Command python -ErrorAction Stop).Source +$env:PRIVATE_KEY = $privateKey +$env:ROBO_PAYEE_ADDRESS = $payee +$env:TUNNEL_BIN = (Resolve-Path -LiteralPath $TunnelBin).Path +$env:PYTHONPATH = $PSScriptRoot +$env:ATLAS_MUJOCO_VIEWER_HOLD_SECONDS = [string]$HoldSeconds +$env:ATLAS_MUJOCO_VIEWER_START_HOLD_SECONDS = [string]$StartHoldSeconds +$env:ATLAS_MUJOCO_VIEWER_TURN_HOLD_SECONDS = '0.45' +$env:ROBO_PAY_COMMIT_SHA = $commitSha + +$layoutJob = $null +if (-not $NoAutoLayout) { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class AtlasEvidenceLayout { + [DllImport("user32.dll", SetLastError = true)] + public static extern bool MoveWindow(IntPtr hWnd, int x, int y, int width, int height, bool repaint); +} +"@ + $workArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea + $terminalWidth = [int]($workArea.Width * 0.45) + $viewerWidth = $workArea.Width - $terminalWidth + $viewerLeft = $workArea.Left + $terminalWidth + $terminalHandle = (Get-Process -Id $PID).MainWindowHandle + if ($terminalHandle -ne [IntPtr]::Zero) { + [void][AtlasEvidenceLayout]::MoveWindow( + $terminalHandle, $workArea.Left, $workArea.Top, + $terminalWidth, $workArea.Height, $true + ) + } + $layoutJob = Start-Job -ArgumentList @( + $viewerLeft, $workArea.Top, $viewerWidth, $workArea.Height + ) -ScriptBlock { + param($viewerLeft, $viewerTop, $viewerWidth, $viewerHeight) + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class AtlasViewerLayout { + [DllImport("user32.dll", SetLastError = true)] + public static extern bool MoveWindow(IntPtr hWnd, int x, int y, int width, int height, bool repaint); +} +"@ + $deadline = [DateTime]::UtcNow.AddMinutes(3) + while ([DateTime]::UtcNow -lt $deadline) { + $viewers = Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.MainWindowHandle -ne [IntPtr]::Zero -and $_.MainWindowTitle -like 'MuJoCo*' + } + foreach ($viewer in $viewers) { + [void][AtlasViewerLayout]::MoveWindow( + $viewer.MainWindowHandle, $viewerLeft, $viewerTop, + $viewerWidth, $viewerHeight, $true + ) + } + Start-Sleep -Milliseconds 250 + } + } +} + +Write-Host 'OBS sequence: bridge ready -> discovery -> unpaid 402 -> first paid 202 -> Atlas DRC wave -> correlated result -> settlement -> BaseScan' +Write-Host "Evidence commit: $commitSha" +Write-Host "Neutral pose hold: $StartHoldSeconds seconds; each measured turning point: 0.45 seconds; final pose: $HoldSeconds seconds." +Write-Host 'Automatic layout: terminal on the left; complete MuJoCo Atlas on the right.' +Write-Host 'The model is Atlas DRC/v4 hydraulic legacy, not the current electric Atlas.' +Write-Host 'Secrets are loaded from this process and will not be printed or written.' +Write-Host '' +Read-Host 'Start OBS, keep both windows visible, then press Enter to begin the current-head recording' +for ($remaining = $PreflightSeconds; $remaining -gt 0; $remaining--) { + Write-Host "Starting in $remaining..." + Start-Sleep -Seconds 1 +} + +$arguments = @( + (Join-Path $PSScriptRoot 'test_base_sepolia_tunnel_e2e.py'), + '--visual', + '--wsl-tunnel', + '--local-zenoh-router' +) +if (-not $NoOpenBaseScan) { + $arguments += '--open-basescan' +} +if ($DryRun) { + $arguments += '--dry-run' +} + +try { + & $python @arguments + $exitCode = $LASTEXITCODE +} finally { + if ($layoutJob -ne $null) { + Stop-Job -Job $layoutJob -ErrorAction SilentlyContinue + Remove-Job -Job $layoutJob -Force -ErrorAction SilentlyContinue + } + Remove-Item Env:PRIVATE_KEY -ErrorAction SilentlyContinue + Remove-Item Env:BASE_SEPOLIA_PRIVATE_KEY -ErrorAction SilentlyContinue + Remove-Item Env:ROBO_PAY_COMMIT_SHA -ErrorAction SilentlyContinue +} +if ($PauseAfter) { + [void](Read-Host 'Recording complete. Press Enter to close this window') +} +exit $exitCode diff --git a/bridge/boston_dynamics/atlas_drc_bridge/run_paid_wave.py b/bridge/boston_dynamics/atlas_drc_bridge/run_paid_wave.py new file mode 100644 index 000000000..a06fa2550 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/run_paid_wave.py @@ -0,0 +1,54 @@ +"""Run the bounded Atlas DRC MuJoCo wave and write reviewable metrics.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from atlas_drc_bridge.contracts import validate_wave_params +from atlas_drc_bridge.runtime import run_wave_episode + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run Boston Dynamics Atlas DRC v4 wave in MuJoCo.") + parser.add_argument("--cycles", type=int, default=2) + parser.add_argument("--amplitude-rad", type=float, default=0.30) + parser.add_argument("--max-duration", type=float, default=8.0) + parser.add_argument("--model-dir") + parser.add_argument("--json-output", type=Path) + parser.add_argument( + "--viewer", + action="store_true", + help="Open the real MuJoCo desktop viewer; intended for an operator recording a run.", + ) + parser.add_argument( + "--viewer-hold-seconds", + type=float, + default=3.0, + help="Keep the terminal MuJoCo pose visible after a --viewer run.", + ) + parser.add_argument("--viewer-start-hold-seconds", type=float, default=0.0) + parser.add_argument("--viewer-turn-hold-seconds", type=float, default=0.0) + args = parser.parse_args() + params = validate_wave_params( + {"cycles": args.cycles, "amplitudeRad": args.amplitude_rad, "maxDurationSec": args.max_duration} + ) + result = run_wave_episode( + params, + model_dir=args.model_dir, + viewer=args.viewer, + viewer_hold_seconds=max(0.0, args.viewer_hold_seconds), + viewer_start_hold_seconds=max(0.0, args.viewer_start_hold_seconds), + viewer_turn_hold_seconds=max(0.0, args.viewer_turn_hold_seconds), + ) + rendered = json.dumps(result, indent=2) + print(rendered) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered + "\n", encoding="utf-8") + raise SystemExit(0 if result["success"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_drc_bridge/run_sim2sim_validation.py b/bridge/boston_dynamics/atlas_drc_bridge/run_sim2sim_validation.py new file mode 100644 index 000000000..06a3efdac --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/run_sim2sim_validation.py @@ -0,0 +1,78 @@ +"""Compare the same Atlas DRC state-feedback contract in MuJoCo and Webots.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from atlas_drc_bridge.contracts import validate_wave_params +from atlas_drc_bridge.runtime import run_wave_episode +from atlas_drc_bridge.webots import run_webots_validation + + +PACKAGE_ROOT = Path(__file__).resolve().parent + + +def run_sim2sim_validation(timeout_seconds: int = 60) -> dict: + params = validate_wave_params({"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 8.0}) + mujoco_result = run_wave_episode(params) + webots_result = run_webots_validation(timeout_seconds) + shared = { + "policy_id": "atlas-drc-right-arm-wave-v1", + "skill_id": "wave_right_arm", + "cycles": params.cycles, + "amplitude_rad": params.amplitude_rad, + "max_duration_sec": params.max_duration_sec, + "state_authority": "measured right-shoulder joint position", + } + comparison = { + "both_engines_succeeded": bool(mujoco_result.get("success")) + and bool(webots_result.get("success")), + "policy_id_match": mujoco_result.get("policy_id") == webots_result.get("policy_id"), + "completed_half_waves_match": mujoco_result.get("completed_half_waves") + == webots_result.get("completed_half_waves") + == params.cycles * 2, + "measured_stroke_threshold_met": mujoco_result.get("measured_wave_stroke_rad", 0) + >= params.amplitude_rad * 1.35 + and webots_result.get("measured_wave_stroke_rad", 0) + >= params.amplitude_rad * 1.35, + "webots_base_stable": bool(webots_result.get("stable_base")), + } + sim_to_sim_score = sum(comparison.values()) / len(comparison) + success = sim_to_sim_score == 1.0 + return { + "task": "atlas_drc_right_arm_wave_sim2sim", + "status": "success" if success else "failure", + "success": success, + "shared_policy": shared, + "comparison": comparison, + "sim_to_sim_score": sim_to_sim_score, + "mujoco": mujoco_result, + "webots": webots_result, + "note": ( + "The simulators use independently supplied legacy Atlas DRC models. " + "This is cross-engine behavior validation, not a claim that either is the current electric Atlas." + ), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run Atlas DRC MuJoCo/Webots Sim-to-Sim validation.") + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument( + "--json-output", + type=Path, + default=PACKAGE_ROOT / "artifacts" / "sim2sim_result.json", + ) + args = parser.parse_args() + result = run_sim2sim_validation(args.timeout) + rendered = json.dumps(result, indent=2) + print(rendered) + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered + "\n", encoding="utf-8") + raise SystemExit(0 if result["success"] else 1) + + +if __name__ == "__main__": + main() diff --git a/bridge/boston_dynamics/atlas_drc_bridge/scenes/.atlas_paid_wave.wbproj b/bridge/boston_dynamics/atlas_drc_bridge/scenes/.atlas_paid_wave.wbproj new file mode 100644 index 000000000..6955f5fa7 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/scenes/.atlas_paid_wave.wbproj @@ -0,0 +1,9 @@ +Webots Project File version R2025a +perspectives: 000000ff00000000fd0000000200000001000000750000017afc0200000001fb0000001400540065007800740045006400690074006f007201000000150000017a0000003f00ffffff00000003000004f400000039fc0100000001fb0000001a0043006f006e0073006f006c00650041006c006c0041006c006c0100000000000004f40000006900ffffff0000047d0000017a00000001000000020000000100000008fc00000000 +simulationViewPerspectives: 000000ff000000010000000200000101000007e10100000002010000000100 +sceneTreePerspectives: 000000ff000000010000000300000395000000cb000000000100000002010000000200 +maximizedDockId: -1 +centralWidgetVisible: 1 +orthographicViewHeight: 1 +textFiles: -1 +consoles: Console:All:All diff --git a/bridge/boston_dynamics/atlas_drc_bridge/scenes/atlas_paid_wave.wbt b/bridge/boston_dynamics/atlas_drc_bridge/scenes/atlas_paid_wave.wbt new file mode 100644 index 000000000..afe3a974a --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/scenes/atlas_paid_wave.wbt @@ -0,0 +1,45 @@ +#VRML_SIM R2025a utf8 +# The Atlas PROTO is referenced from the installed Webots distribution. Do +# not vendor it: Cyberbotics licenses the asset specifically for Webots use. +# R2025a releases omit this licensed asset from some binary packages. Keep it +# external and pin the official source tag instead of vendoring the PROTO. +EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2025a/projects/robots/boston_dynamics/atlas/protos/Atlas.proto" + +WorldInfo { + CFM 1e-07 + ERP 0.8 + basicTimeStep 8 + lineScale 0.25 +} +Viewpoint { + # Official Atlas sample framing, kept so visual evidence shows the robot + # rather than a blank background in a headless recording. + orientation -0.12659710552975714 -0.22835578605467902 0.9653117671751635 5.240580919908894 + position -2.293920734012428 3.7668710396965643 2.01477919250191 +} +Background { + skyColor [ 0.08 0.12 0.18 ] +} +DirectionalLight { + direction -0.35 -0.45 -1 + intensity 2.4 + ambientIntensity 0.55 + castShadows FALSE +} +DEF FLOOR Solid { + translation 0 0 -0.05 + children [ + Shape { + appearance PBRAppearance { baseColor 0.16 0.21 0.25 roughness 0.8 } + geometry Box { size 8 8 0.1 } + } + ] + boundingObject Box { size 8 8 0.1 } +} +DEF ATLAS_DRC Atlas { + translation 0 0 1 + rotation 0 0 1 1.5708 + name "atlas-drc-mujoco-webots-sim-01" + controller "atlas_wave_controller" + supervisor TRUE +} diff --git a/bridge/boston_dynamics/atlas_drc_bridge/test_base_sepolia_tunnel_e2e.py b/bridge/boston_dynamics/atlas_drc_bridge/test_base_sepolia_tunnel_e2e.py new file mode 100644 index 000000000..f84df200b --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/test_base_sepolia_tunnel_e2e.py @@ -0,0 +1,462 @@ +"""Live Base Sepolia x402 -> Tunnel -> Atlas DRC MuJoCo settlement proof. + +Linux CI uses the default invocation. Windows operators add ``--visual +--wsl-tunnel --local-zenoh-router`` so MuJoCo stays native while the production +Tunnel runs in Ubuntu. The payer key never reaches the bridge or Tunnel. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import shlex +import subprocess +import sys +import tempfile +import threading +import time +import webbrowser +from pathlib import Path + +import requests +import zenoh +from eth_account import Account +from x402 import x402ClientSync +from x402.http.clients import x402_requests +from x402.mechanisms.evm.exact import register_exact_evm_client +from x402.mechanisms.evm.signers import EthAccountSigner + +from atlas_drc_bridge.bridge import READY_TOPIC + + +PACKAGE_ROOT = Path(__file__).resolve().parent +ROOT = PACKAGE_ROOT.parents[2] +SKILL_CATALOG = ( + ROOT + / "registry/vendors/boston-dynamics/atlas" + / "boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json" +) +TUNNEL_BINARY = Path(os.environ.get("TUNNEL_BIN", ROOT / "bin" / "tunnel")) +NETWORK = "eip155:84532" +FABRIC_API_BASE = os.environ.get( + "FABRIC_API_BASE_URL", "https://api.fabric.foundation/api/core" +).rstrip("/") +PROXY_WS_URL = os.environ.get( + "PROXY_WS_URL", "wss://api.fabric.foundation/api/core/ws/robot" +) +FACILITATOR_URL = os.environ.get("FACILITATOR_URL", "https://x402.org/facilitator") + + +def _required(name: str) -> str: + value = os.environ.get(name) + if not value: + raise SystemExit(f"Missing {name}; configure funded Base Sepolia credentials.") + return value + + +def _source_commit_sha() -> str: + configured = os.environ.get("ROBO_PAY_COMMIT_SHA", "").strip() + if configured: + return configured + completed = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def _decode_header(value: str | None) -> dict: + return json.loads(base64.b64decode(value).decode("utf-8")) if value else {} + + +def _wait_for_tunnel(tunnel: subprocess.Popen[str], log_path: Path) -> None: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if tunnel.poll() is not None: + raise RuntimeError(f"Tunnel exited early:\n{log_path.read_text(encoding='utf-8')}") + if "ws connected to proxy" in log_path.read_text( + encoding="utf-8", errors="replace" + ): + return + time.sleep(0.5) + raise RuntimeError( + f"Tunnel did not connect within 30 seconds:\n{log_path.read_text(encoding='utf-8')}" + ) + + +def _wait_for_bridge_ready(ready: threading.Event, bridge: subprocess.Popen[str]) -> None: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if ready.wait(0.2): + return + if bridge.poll() is not None: + raise RuntimeError( + "Atlas bridge exited before declaring its action subscription " + f"(exit={bridge.returncode})" + ) + raise RuntimeError("Atlas bridge never declared ready; refusing to send payment") + + +def _stream_tunnel_output( + tunnel: subprocess.Popen[str], log_path: Path, *, visual: bool +) -> threading.Thread: + if tunnel.stdout is None: + raise RuntimeError("Tunnel must be started with captured stdout") + log_path.touch() + + def copy_output() -> None: + with log_path.open("w", encoding="utf-8") as log: + for line in iter(tunnel.stdout.readline, ""): + log.write(line) + log.flush() + if visual and any( + marker in line + for marker in ( + "ws connected to proxy", + "action settled after successful execution", + "deferred settlement failed", + ) + ): + print(f"[tunnel] {line}", end="", flush=True) + + worker = threading.Thread(target=copy_output, name="atlas-tunnel-log", daemon=True) + worker.start() + return worker + + +def _wsl_path(path: Path) -> str: + if os.name != "nt": + raise RuntimeError("--wsl-tunnel is only available from Windows") + resolved = path.resolve() + drive = resolved.drive.rstrip(":") + if len(drive) != 1 or not drive.isalpha(): + raise RuntimeError(f"WSL requires a drive-backed path, got {resolved}") + native = resolved.as_posix() + return f"/mnt/{drive.lower()}{native[2:]}" + + +def _wsl_host_address() -> str: + completed = subprocess.run( + ["wsl.exe", "-d", "Ubuntu-22.04", "--", "ip", "route", "show", "default"], + check=True, + capture_output=True, + text=True, + ) + for line in completed.stdout.splitlines(): + fields = line.split() + if "via" in fields: + return fields[fields.index("via") + 1] + raise RuntimeError("Could not determine the Windows host address from WSL") + + +def _start_wsl_tunnel( + tunnel_config: Path, tunnel_env: dict[str, str], zenoh_config: Path +) -> subprocess.Popen[str]: + root_wsl = _wsl_path(ROOT) + translated = { + "PROXY_WS_URL": tunnel_env["PROXY_WS_URL"], + "FACILITATOR_URL": tunnel_env["FACILITATOR_URL"], + "AIP_ENABLED": tunnel_env["AIP_ENABLED"], + "ALLOWED_ACTIONS": tunnel_env["ALLOWED_ACTIONS"], + "EXECUTION_TIMEOUT_SECONDS": tunnel_env["EXECUTION_TIMEOUT_SECONDS"], + "SKILL_CATALOG_PATH": _wsl_path(SKILL_CATALOG), + "ZENOH_CONFIG": _wsl_path(zenoh_config), + "IDEMPOTENCY_STORE_PATH": _wsl_path(tunnel_config.parent / "idempotency.json"), + "LD_LIBRARY_PATH": f"{root_wsl}/.zenoh-c/lib", + } + environment = " ".join( + f"{key}={shlex.quote(value)}" for key, value in sorted(translated.items()) + ) + command = ( + f"exec env {environment} {shlex.quote(f'{root_wsl}/bin/tunnel')} " + f"--config {shlex.quote(_wsl_path(tunnel_config))}" + ) + return subprocess.Popen( + ["wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-lc", command], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--visual", action="store_true") + parser.add_argument("--open-basescan", action="store_true") + parser.add_argument("--wsl-tunnel", action="store_true") + parser.add_argument("--local-zenoh-router", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + + private_key = None if args.dry_run else _required("PRIVATE_KEY") + payee = _required("ROBO_PAYEE_ADDRESS") + commit_sha = _source_commit_sha() + if private_key and not private_key.startswith("0x"): + private_key = "0x" + private_key + if not TUNNEL_BINARY.is_file(): + raise SystemExit(f"Tunnel binary missing: {TUNNEL_BINARY}") + account = Account.from_key(private_key) if private_key else None + robot_id = os.environ.get("ROBOT_ID", f"atlas-drc-base-sepolia-{int(time.time())}") + action_id = f"atlas-wave-{int(time.time())}" + action_body = { + "action": "wave_right_arm", + "robot_id": robot_id, + "action_id": action_id, + "idempotency_key": action_id, + "params": {"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 8}, + } + print(f"Evidence commit: {commit_sha}", flush=True) + + with tempfile.TemporaryDirectory(prefix="robopay_atlas_base_sepolia_") as temp_dir: + temp = Path(temp_dir) + tunnel_config = temp / "tunnel.json" + tunnel_config.write_text( + json.dumps( + { + "robot_id": robot_id, + "evm_payee_address": payee, + "price": "$0.001", + "network": NETWORK, + } + ), + encoding="utf-8", + ) + zenoh_config = temp / "zenoh-client.json5" + zenoh_config.write_text( + '{"mode":"client","connect":{"endpoints":["tcp/127.0.0.1:7447"]}}', + encoding="utf-8", + ) + wsl_zenoh_config = zenoh_config + if args.wsl_tunnel: + wsl_zenoh_config = temp / "zenoh-wsl-client.json5" + wsl_zenoh_config.write_text( + json.dumps( + { + "mode": "client", + "connect": { + "endpoints": [f"tcp/{_wsl_host_address()}:7447"] + }, + } + ), + encoding="utf-8", + ) + + router_session = None + if args.local_zenoh_router: + router_session = zenoh.open( + zenoh.Config.from_json5( + '{"mode":"peer","scouting":{"multicast":{"enabled":false}},' + '"listen":{"endpoints":["tcp/0.0.0.0:7447"]}}' + ) + ) + ready = threading.Event() + ready_session = zenoh.open(zenoh.Config.from_file(str(zenoh_config))) + + def on_ready(sample) -> None: + try: + payload = json.loads(bytes(sample.payload.to_bytes())) + except (UnicodeDecodeError, json.JSONDecodeError): + return + if payload.get("status") == "ready" and payload.get("robot_id") == robot_id: + ready.set() + + ready_subscriber = ready_session.declare_subscriber(READY_TOPIC, on_ready) + tunnel_log_path = temp / "tunnel.log" + bridge_env = os.environ.copy() + for secret_name in ("PRIVATE_KEY", "EVM_PRIVATE_KEY"): + bridge_env.pop(secret_name, None) + bridge_env.update( + { + "PYTHONPATH": str(PACKAGE_ROOT) + + os.pathsep + + bridge_env.get("PYTHONPATH", ""), + "ZENOH_CONFIG": str(zenoh_config), + "ROBOT_ID": robot_id, + "ATLAS_MUJOCO_VIEWER": "true" if args.visual else "false", + "ATLAS_MUJOCO_VIEWER_HOLD_SECONDS": os.environ.get( + "ATLAS_MUJOCO_VIEWER_HOLD_SECONDS", "5" + ), + "ATLAS_MUJOCO_VIEWER_START_HOLD_SECONDS": os.environ.get( + "ATLAS_MUJOCO_VIEWER_START_HOLD_SECONDS", "3" + ), + "ATLAS_MUJOCO_VIEWER_TURN_HOLD_SECONDS": os.environ.get( + "ATLAS_MUJOCO_VIEWER_TURN_HOLD_SECONDS", "0.45" + ), + } + ) + bridge = subprocess.Popen( + [sys.executable, "-m", "atlas_drc_bridge.bridge"], + cwd=PACKAGE_ROOT, + env=bridge_env, + stdout=None if args.visual else subprocess.DEVNULL, + stderr=None if args.visual else subprocess.STDOUT, + text=True, + ) + tunnel_env = os.environ.copy() + for secret_name in ("PRIVATE_KEY", "EVM_PRIVATE_KEY"): + tunnel_env.pop(secret_name, None) + tunnel_env.update( + { + "PROXY_WS_URL": PROXY_WS_URL, + "FACILITATOR_URL": FACILITATOR_URL, + "AIP_ENABLED": "false", + "SKILL_CATALOG_PATH": str(SKILL_CATALOG), + "ALLOWED_ACTIONS": "wave_right_arm,stop", + "EXECUTION_TIMEOUT_SECONDS": "90", + "ZENOH_CONFIG": str(zenoh_config), + } + ) + tunnel = ( + _start_wsl_tunnel(tunnel_config, tunnel_env, wsl_zenoh_config) + if args.wsl_tunnel + else subprocess.Popen( + [str(TUNNEL_BINARY), "--config", str(tunnel_config)], + cwd=ROOT, + env=tunnel_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + ) + tunnel_log_thread = _stream_tunnel_output( + tunnel, tunnel_log_path, visual=args.visual + ) + try: + _wait_for_bridge_ready(ready, bridge) + print("Bridge ready: action subscription declared; no warm-up action used", flush=True) + _wait_for_tunnel(tunnel, tunnel_log_path) + print("Tunnel connected to Fabric", flush=True) + discovery_response = requests.get( + f"{FABRIC_API_BASE}/robots/{robot_id}/skills", timeout=45 + ) + if discovery_response.status_code != 200: + raise RuntimeError( + "Robot skill discovery failed: " + f"HTTP {discovery_response.status_code}: {discovery_response.text}" + ) + discovery = discovery_response.json() + if {item.get("skill_id") for item in discovery.get("skills", [])} != { + "wave_right_arm", + "stop", + }: + raise RuntimeError(f"Skill discovery drift: {discovery}") + if any(item.get("price_usdc") != "0.001" for item in discovery["skills"]): + raise RuntimeError(f"Skill price drift: {discovery}") + print("Robot discovery: Atlas DRC/v4 simulator-only", flush=True) + print("Skill discovery: wave_right_arm, stop @ 0.001 USDC", flush=True) + + action_url = f"{FABRIC_API_BASE}/robots/{robot_id}/action" + unpaid = requests.post(action_url, json=action_body, timeout=45) + if unpaid.status_code != 402: + raise RuntimeError( + f"Expected unpaid HTTP 402, got {unpaid.status_code}: {unpaid.text}" + ) + print("Unpaid action: HTTP 402", flush=True) + requirements = _decode_header( + unpaid.headers.get("PAYMENT-REQUIRED") + or unpaid.headers.get("Payment-Required") + ) + accepted = requirements.get("accepts", [{}])[0] + if accepted.get("payTo", "").lower() != payee.lower(): + raise RuntimeError(f"Unexpected payment recipient: {accepted.get('payTo')}") + if accepted.get("network") != NETWORK: + raise RuntimeError(f"Unexpected payment network: {accepted.get('network')}") + print( + f"Payment authorization window: {int(accepted.get('maxTimeoutSeconds') or 0)}s", + flush=True, + ) + if args.dry_run: + print("Dry run complete: no payment was signed or submitted", flush=True) + return 0 + + if account is None: + raise RuntimeError("paid run requires a Base Sepolia account") + client = x402ClientSync() + register_exact_evm_client(client, EthAccountSigner(account), networks=NETWORK) + print(f"Sending first paid action after clean start: {action_id}", flush=True) + paid = x402_requests(client).post(action_url, json=action_body, timeout=120) + if paid.status_code != 202 or paid.json().get("action_id") != action_id: + raise RuntimeError( + f"First paid action was not accepted: HTTP {paid.status_code}: {paid.text}" + ) + print(f"Paid action: HTTP 202 accepted ({action_id})", flush=True) + status_url = f"{FABRIC_API_BASE}/robots/{robot_id}/action/{action_id}/status" + terminal = None + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + response = requests.get(status_url, timeout=45) + if response.status_code == 200: + candidate = response.json() + if candidate.get("state") in { + "succeeded", + "failed", + "timeout", + "settlement_failed", + }: + terminal = candidate + break + time.sleep(2) + if terminal is None or terminal.get("state") != "succeeded" or not terminal.get("settled"): + raise RuntimeError(f"Atlas execution or settlement failed: {terminal}") + settlement = terminal.get("settlement") or {} + tx_hash = settlement.get("transaction") or settlement.get("txHash") + if not tx_hash: + raise RuntimeError(f"Successful Atlas action lacks transaction: {terminal}") + result = terminal.get("result") or {} + print( + "Correlated result: " + f"action_id={terminal.get('action_id')}, state={terminal.get('state')}, " + f"success={result.get('success')}, " + f"half_waves={result.get('completed_half_waves')}/4, " + f"stroke_rad={result.get('measured_wave_stroke_rad')}", + flush=True, + ) + print("Settlement settled: true", flush=True) + print(f"Settlement transaction: {tx_hash}", flush=True) + print(f"BaseScan: https://sepolia.basescan.org/tx/{tx_hash}", flush=True) + evidence = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "source_commit": commit_sha, + "network": NETWORK, + "payer": account.address, + "payee": payee, + "robot_id": robot_id, + "request_id": action_id, + "unpaid_http_status": unpaid.status_code, + "paid_http_status": paid.status_code, + "discovery": discovery, + "terminal_status": terminal, + "settlement": settlement, + "transaction_hash": tx_hash, + "basescan_url": f"https://sepolia.basescan.org/tx/{tx_hash}", + } + output = PACKAGE_ROOT / "artifacts" / f"base_sepolia_result_{int(time.time())}.json" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(f"Evidence: {output}", flush=True) + if args.open_basescan: + webbrowser.open(evidence["basescan_url"]) + return 0 + finally: + if tunnel.poll() is None: + tunnel.terminate() + tunnel.wait(timeout=15) + if bridge.poll() is None: + bridge.terminate() + bridge.wait(timeout=15) + ready_subscriber.undeclare() + ready_session.close() + if router_session is not None: + router_session.close() + tunnel_log_thread.join(timeout=2) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_bridge_contract.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_bridge_contract.py new file mode 100644 index 000000000..9d58d34aa --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_bridge_contract.py @@ -0,0 +1,162 @@ +"""Unit-level routing tests for the fail-closed Atlas Zenoh bridge. + +The real Tunnel/x402/Zenoh/MuJoCo proof lives in the integration tests. These +fast tests isolate the bridge's second authorization boundary so malformed or +misrouted events cannot regress unnoticed. +""" + +from __future__ import annotations + +import hashlib +import json +import sys +import threading +import time +import unittest +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGE_ROOT)) + +from atlas_drc_bridge.bridge import AtlasZenohBridge, _load_event_parser + + +class _Payload: + def __init__(self, value: bytes): + self._value = value + + def to_bytes(self) -> bytes: + return self._value + + +class _Sample: + def __init__(self, document: dict): + self.payload = _Payload(json.dumps(document).encode("utf-8")) + + +class _Publisher: + def __init__(self): + self.documents: list[dict] = [] + + def put(self, payload: bytes) -> None: + self.documents.append(json.loads(payload)) + + +def _event(action: str, params: dict | None = None, action_id: str = "action-1") -> dict: + action_params = params if params is not None else {} + canonical = json.dumps(action_params, separators=(",", ":"), sort_keys=True) + return { + "payload": {"skillId": action, "params": action_params}, + "action_id": action_id, + "robot_id": "atlas-bridge-contract-test", + "skill_id": action, + "idempotency_key": action_id, + "params_hash": "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest(), + "params_canonical": canonical, + "transaction_details": {"payment_requirements": {"network": "eip155:84532"}}, + } + + +def _bridge(runner) -> AtlasZenohBridge: + bridge = AtlasZenohBridge.__new__(AtlasZenohBridge) + bridge.robot_id = "atlas-bridge-contract-test" + bridge._model_dir = None + bridge._episode_runner = runner + bridge._parse_action_event = _load_event_parser() + bridge._result_publisher = _Publisher() + bridge._metrics_publisher = _Publisher() + bridge._stop_event = threading.Event() + bridge._stop_confirmed = threading.Event() + bridge._worker_lock = threading.Lock() + bridge._worker = None + return bridge + + +class AtlasBridgeContractTests(unittest.TestCase): + def test_parser_requires_skill_correlation_and_untampered_params(self) -> None: + parser = _load_event_parser() + parsed = parser( + json.dumps( + _event("wave_right_arm", {"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5}) + ).encode("utf-8") + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.action_id, "action-1") + + malformed = _event("wave_right_arm") + del malformed["idempotency_key"] + self.assertIsNone(parser(json.dumps(malformed).encode("utf-8"))) + tampered = _event("wave_right_arm", {"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5}) + tampered["payload"]["params"]["cycles"] = 3 + self.assertIsNone(parser(json.dumps(tampered).encode("utf-8"))) + + def test_success_and_failure_keep_tunnel_correlation(self) -> None: + for simulator_result, expected_status in ( + ({"success": True, "completion_reason": "wave_complete"}, "success"), + ({"success": False, "completion_reason": "time_limit"}, "failure"), + ): + with self.subTest(expected_status=expected_status): + bridge = _bridge(lambda *_args, **_kwargs: simulator_result) + bridge._on_action( + _Sample(_event("wave_right_arm", {"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5})) + ) + bridge._worker.join(timeout=2) + result = bridge._result_publisher.documents[-1] + self.assertEqual(result["action_id"], "action-1") + self.assertEqual(result["robot_id"], bridge.robot_id) + self.assertEqual(result["skill_id"], "wave_right_arm") + self.assertEqual(result["idempotency_key"], "action-1") + self.assertEqual(result["status"], expected_status) + + def test_unknown_invalid_and_wrong_robot_never_run_simulation(self) -> None: + calls: list[object] = [] + + def runner(*args, **kwargs): + calls.append((args, kwargs)) + return {"success": True} + + bridge = _bridge(runner) + bridge._on_action(_Sample(_event("object_tracking"))) + self.assertEqual(calls, []) + self.assertEqual(bridge._result_publisher.documents[-1]["result"]["error_code"], "UNREGISTERED_ACTION") + + bridge = _bridge(runner) + bridge._on_action(_Sample(_event("wave_right_arm", {"cycles": 1, "maxDurationSec": 5, "extra": True}))) + self.assertEqual(calls, []) + self.assertEqual(bridge._result_publisher.documents[-1]["result"]["error_code"], "INVALID_PARAMS") + + bridge = _bridge(runner) + wrong_robot = _event("wave_right_arm", {"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5}) + wrong_robot["robot_id"] = "different-robot" + bridge._on_action(_Sample(wrong_robot)) + self.assertEqual(calls, []) + self.assertEqual(bridge._result_publisher.documents, []) + + def test_stop_interrupts_running_wave_without_turning_into_wave(self) -> None: + started = threading.Event() + + def interrupted_runner(*_args, stop_requested, **_kwargs): + started.set() + deadline = time.monotonic() + 2 + while not stop_requested() and time.monotonic() < deadline: + time.sleep(0.005) + return {"success": False, "safe_stop_applied": stop_requested(), "completion_reason": "safe_stopped"} + + bridge = _bridge(interrupted_runner) + bridge._on_action( + _Sample(_event("wave_right_arm", {"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5}, "wave-1")) + ) + self.assertTrue(started.wait(timeout=1)) + bridge._on_action(_Sample(_event("stop", {}, "stop-1"))) + bridge._worker.join(timeout=2) + + by_action = {item["action_id"]: item for item in bridge._result_publisher.documents} + self.assertEqual(by_action["wave-1"]["status"], "failure") + self.assertTrue(by_action["wave-1"]["result"]["safe_stop_applied"]) + self.assertEqual(by_action["stop-1"]["status"], "success") + self.assertTrue(by_action["stop-1"]["result"]["safe_stop_applied"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_contract.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_contract.py new file mode 100644 index 000000000..a17d43bff --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_contract.py @@ -0,0 +1,63 @@ +"""Fast contract tests for Atlas action routing and movement bounds.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGE_ROOT)) + +from atlas_drc_bridge.contracts import ( + STOP_SKILL_ID, + WAVE_SKILL_ID, + ActionContractError, + validate_action, + validate_wave_params, +) +from atlas_drc_bridge.runtime import ArmWavePolicy + + +class AtlasContractTests(unittest.TestCase): + def test_bounded_wave_is_accepted(self) -> None: + params = validate_action( + WAVE_SKILL_ID, + {"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 8}, + ) + self.assertIsNotNone(params) + self.assertEqual(params.cycles, 2) + + def test_missing_unknown_and_oversized_values_fail_closed(self) -> None: + for payload in ( + {"cycles": 2, "amplitudeRad": 0.41, "maxDurationSec": 8}, + {"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 1}, + {"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 8, "target": "payer"}, + ): + with self.subTest(payload=payload), self.assertRaises(ActionContractError): + validate_wave_params(payload) + + def test_unknown_action_has_no_fallback(self) -> None: + with self.assertRaises(ActionContractError) as raised: + validate_action("object_tracking", {}) + self.assertEqual(raised.exception.code, "UNREGISTERED_ACTION") + + def test_stop_is_parameterless_and_does_not_turn_into_wave(self) -> None: + self.assertIsNone(validate_action(STOP_SKILL_ID, {})) + with self.assertRaises(ActionContractError): + validate_action(STOP_SKILL_ID, {"cycles": 2}) + + def test_policy_advances_only_from_measured_turning_point(self) -> None: + params = validate_wave_params({"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5}) + policy = ArmWavePolicy(params) + for _ in range(5): + policy.observe(0.21, 2.0) + self.assertEqual(policy.phase_index, 0) + policy.observe(0.21, 2.0) + self.assertEqual(policy.phase_index, 1) + self.assertFalse(policy.complete) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_e2e_paid_action.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_e2e_paid_action.py new file mode 100644 index 000000000..dbc13d9d8 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_e2e_paid_action.py @@ -0,0 +1,236 @@ +"""Positive first-action proof through the real Tunnel, Zenoh bridge, and MuJoCo. + +This test deliberately uses a local Fabric-protocol proxy and recording +facilitator so no wallet or public network is needed. Those are only protocol +doubles: the Go Tunnel/x402 middleware, Zenoh transport, Atlas bridge, and +real MuJoCo episode are the production implementations. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +import uuid +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +ROOT = PACKAGE_ROOT.parents[2] +sys.path.insert(0, str(PACKAGE_ROOT)) + +from atlas_drc_bridge.bridge import AtlasZenohBridge, BridgeSettings +from atlas_drc_bridge.model import resolve_model_dir +from x402_harness import ( + FacilitatorHandler, + LocalFabricProxy, + NETWORK, + PAYEE, + ZenohObserver, + find_tunnel_binary, + http_post, + payment_signature_from_402, + poll_action_status, + start_facilitator, +) + + +SKILL_CATALOG = ( + ROOT + / "registry/vendors/boston-dynamics/atlas" + / "boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json" +) +ROBOT_ID = "atlas_drc_positive_e2e" +ZENOH_PORT = int(os.environ.get("ATLAS_POSITIVE_E2E_ZENOH_PORT", "7447")) + + +class AtlasPositivePaidActionTests(unittest.TestCase): + def test_first_paid_action_runs_real_mujoco_then_paid_stop_settles(self) -> None: + """No warm-up action: the paid wave and its separately paid stop settle.""" + + tunnel_binary = find_tunnel_binary(ROOT) + if not tunnel_binary: + raise unittest.SkipTest("Build the real Go Tunnel first with make build") + try: + resolve_model_dir() + except FileNotFoundError as error: + raise unittest.SkipTest(str(error)) + + proxy = LocalFabricProxy() + facilitator, facilitator_thread = start_facilitator() + observer = bridge = tunnel = None + try: + observer = ZenohObserver(port=ZENOH_PORT) + bridge = AtlasZenohBridge( + settings=BridgeSettings( + robot_id=ROBOT_ID, + zenoh_endpoint=f"tcp/127.0.0.1:{ZENOH_PORT}", + zenoh_config_path=None, + action_topic="robot/tunnel/action", + result_topic="robot/tunnel/result", + metrics_topic="robot/boston_dynamics_atlas_drc/metrics", + ) + ) + proxy.start() + with tempfile.TemporaryDirectory(prefix="atlas_positive_e2e_") as temp_dir: + temp = Path(temp_dir) + config_path = temp / "tunnel.json" + config_path.write_text( + json.dumps( + { + "robot_id": ROBOT_ID, + "evm_payee_address": PAYEE, + "price": "$0.001", + "network": NETWORK, + } + ), + encoding="utf-8", + ) + zenoh_config = temp / "zenoh.json5" + zenoh_config.write_text( + json.dumps( + { + "mode": "peer", + "scouting": {"multicast": {"enabled": False}}, + "connect": {"endpoints": [f"tcp/127.0.0.1:{ZENOH_PORT}"]}, + } + ), + encoding="utf-8", + ) + environment = os.environ.copy() + environment.update( + { + "PROXY_WS_URL": f"ws://127.0.0.1:{proxy.port}/ws", + "FACILITATOR_URL": f"http://127.0.0.1:{facilitator.server_address[1]}", + "AIP_ENABLED": "false", + "ZENOH_CONFIG": str(zenoh_config), + "SKILL_CATALOG_PATH": str(SKILL_CATALOG), + "ALLOWED_ACTIONS": "wave_right_arm,stop", + "EXECUTION_TIMEOUT_SECONDS": "15", + } + ) + zenoh_library = ROOT / ".zenoh-c" / "lib" + if zenoh_library.is_dir(): + environment["LD_LIBRARY_PATH"] = ( + f"{zenoh_library}:{environment.get('LD_LIBRARY_PATH', '')}" + ) + tunnel = subprocess.Popen( + [tunnel_binary, "--config", str(config_path)], + cwd=ROOT, + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + ) + self.assertIsNotNone(proxy.wait_for_connection(15), "real Tunnel did not connect") + time.sleep(0.5) # allow explicit local Zenoh peers to discover one another + + action_url = f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action" + unpaid_status, unpaid_headers, _ = http_post(action_url, {"action": "wave_right_arm"}) + self.assertEqual(unpaid_status, 402) + + action_id = f"atlas-paid-{uuid.uuid4().hex}" + paid_status, _, paid_body = http_post( + action_url, + { + "action": "wave_right_arm", + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": action_id, + "params": {"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 5}, + }, + {"PAYMENT-SIGNATURE": payment_signature_from_402(unpaid_headers)}, + ) + accepted = json.loads(paid_body) + self.assertEqual(paid_status, 202, paid_body.decode("utf-8", errors="replace")) + self.assertEqual(accepted.get("state"), "pending") + self.assertEqual(accepted.get("action_id"), action_id) + + terminal = poll_action_status( + f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action/{action_id}/status", + {"succeeded", "failed", "timeout", "settlement_failed"}, + timeout=30, + ) + self.assertEqual(terminal["state"], "succeeded", terminal) + self.assertTrue(terminal["settled"], terminal) + self.assertTrue(terminal.get("settlement", {}).get("transaction"), terminal) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + _, results, metrics = observer.snapshot() + if results and metrics: + break + time.sleep(0.05) + matching_actions = [event for event in observer.actions if event.get("action_id") == action_id] + matching_results = [event for event in observer.results if event.get("action_id") == action_id] + matching_metrics = [event for event in observer.metrics if event.get("action_id") == action_id] + self.assertEqual(len(matching_actions), 1, observer.actions) + self.assertEqual(len(matching_results), 1, observer.results) + self.assertEqual(len(matching_metrics), 1, observer.metrics) + self.assertEqual(matching_results[0]["status"], "success") + self.assertTrue(matching_results[0]["result"]["success"]) + self.assertGreaterEqual(matching_results[0]["result"]["completed_half_waves"], 2) + self.assertGreater(matching_results[0]["result"]["measured_wave_stroke_rad"], 0.40) + self.assertEqual([call for call in FacilitatorHandler.calls if call[0] == "/verify"].__len__(), 1) + self.assertEqual([call for call in FacilitatorHandler.calls if call[0] == "/settle"].__len__(), 1) + + stop_probe_status, stop_probe_headers, _ = http_post(action_url, {"action": "stop"}) + self.assertEqual(stop_probe_status, 402) + stop_action_id = f"atlas-stop-{uuid.uuid4().hex}" + stop_status, _, stop_body = http_post( + action_url, + { + "action": "stop", + "robot_id": ROBOT_ID, + "action_id": stop_action_id, + "idempotency_key": stop_action_id, + "params": {}, + }, + {"PAYMENT-SIGNATURE": payment_signature_from_402(stop_probe_headers)}, + ) + self.assertEqual(stop_status, 202, stop_body.decode("utf-8", errors="replace")) + stop_terminal = poll_action_status( + f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action/{stop_action_id}/status", + {"succeeded", "failed", "timeout", "settlement_failed"}, + timeout=15, + ) + self.assertEqual(stop_terminal["state"], "succeeded", stop_terminal) + self.assertTrue(stop_terminal["settled"], stop_terminal) + stop_results = [ + event for event in observer.results if event.get("action_id") == stop_action_id + ] + self.assertEqual(len(stop_results), 1, observer.results) + self.assertEqual(stop_results[0]["status"], "success") + self.assertTrue(stop_results[0]["result"]["safe_stop_applied"]) + self.assertEqual( + len([call for call in FacilitatorHandler.calls if call[0] == "/verify"]), 2 + ) + self.assertEqual( + len([call for call in FacilitatorHandler.calls if call[0] == "/settle"]), 2 + ) + print( + "[ATLAS E2E] first paid action -> MuJoCo success + settlement; " + "paid stop -> correlated safe-stop success + settlement" + ) + finally: + if tunnel is not None and tunnel.poll() is None: + tunnel.terminate() + try: + tunnel.wait(timeout=5) + except subprocess.TimeoutExpired: + tunnel.kill() + if bridge is not None: + bridge.close() + if observer is not None: + observer.close() + proxy.close() + facilitator.shutdown() + facilitator.server_close() + facilitator_thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_mujoco_runtime.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_mujoco_runtime.py new file mode 100644 index 000000000..f49e22f38 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_mujoco_runtime.py @@ -0,0 +1,49 @@ +"""Physics regression for the pinned Atlas DRC legacy model.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGE_ROOT)) + +from atlas_drc_bridge.contracts import validate_wave_params +from atlas_drc_bridge.model import load_mujoco_model, resolve_model_dir +from atlas_drc_bridge.runtime import MAX_TORQUE_NM, run_wave_episode + + +class AtlasMuJoCoRuntimeTests(unittest.TestCase): + def test_original_visual_meshes_load_without_becoming_colliders(self) -> None: + try: + resolve_model_dir() + except FileNotFoundError as error: + self.skipTest(str(error)) + import mujoco + + model = load_mujoco_model(visual=True) + self.assertGreaterEqual(model.nmesh, 23) + mesh_geometries = model.geom_type == int(mujoco.mjtGeom.mjGEOM_MESH) + self.assertTrue(mesh_geometries.any()) + self.assertTrue((model.geom_contype[mesh_geometries] == 0).all()) + self.assertTrue((model.geom_conaffinity[mesh_geometries] == 0).all()) + + def test_closed_loop_wave_has_measured_state_change(self) -> None: + try: + resolve_model_dir() + except FileNotFoundError as error: + self.skipTest(str(error)) + result = run_wave_episode( + validate_wave_params({"cycles": 1, "amplitudeRad": 0.30, "maxDurationSec": 6}) + ) + self.assertTrue(result["finite_state"]) + self.assertTrue(result["success"], result) + self.assertGreaterEqual(result["completed_half_waves"], 2) + self.assertGreaterEqual(result["measured_wave_stroke_rad"], 0.405) + self.assertLessEqual(result["peak_commanded_torque_nm"], MAX_TORQUE_NM) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_payment_gate.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_payment_gate.py new file mode 100644 index 000000000..284470167 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_payment_gate.py @@ -0,0 +1,187 @@ +"""Mandatory real-Tunnel regression for facilitator-rejected Atlas payments.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +import unittest +import uuid +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +ROOT = PACKAGE_ROOT.parents[2] +sys.path.insert(0, str(PACKAGE_ROOT)) + +from atlas_drc_bridge.bridge import AtlasZenohBridge, BridgeSettings +from x402_harness import ( + FacilitatorHandler, + LocalFabricProxy, + PAYEE, + NETWORK, + ZenohObserver, + find_tunnel_binary, + http_post, + payment_signature_from_402, + start_facilitator, +) + + +SKILL_CATALOG = ( + ROOT + / "registry/vendors/boston-dynamics/atlas" + / "boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json" +) +ROBOT_ID = "atlas_drc_payment_gate" +ZENOH_PORT = int(os.environ.get("ATLAS_PAYMENT_GATE_ZENOH_PORT", "7447")) + + +def _frame(payload: bytes, opcode: int, final: bool) -> bytes: + header = bytes([(0x80 if final else 0) | opcode]) + if len(payload) < 126: + return header + bytes([len(payload)]) + payload + return header + bytes([126]) + len(payload).to_bytes(2, "big") + payload + + +class AtlasPaymentGateTests(unittest.TestCase): + def test_websocket_reader_reassembles_continuation_frames(self) -> None: + """The submitted local Fabric reader handles a fragmented first response.""" + + from x402_harness import TunnelConnection + + reader, writer = socket.socketpair() + try: + writer.sendall( + _frame(b'{"id":"paid-1",', opcode=1, final=False) + + _frame(b'"status":202}', opcode=0, final=True) + ) + opcode, payload = TunnelConnection(reader)._read_message() + self.assertEqual(opcode, 1) + self.assertEqual(json.loads(payload), {"id": "paid-1", "status": 202}) + finally: + reader.close() + writer.close() + + def test_is_valid_false_returns_402_before_action_or_simulator_boundary(self) -> None: + """Run the reviewer scenario through the real Go Tunnel and real Zenoh.""" + + tunnel_binary = find_tunnel_binary(ROOT) + if not tunnel_binary: + raise unittest.SkipTest("Build the real Go Tunnel first with make build") + proxy = LocalFabricProxy() + facilitator, facilitator_thread = start_facilitator( + {"isValid": False, "invalidReason": "reviewer-tampered-payment"} + ) + observer = bridge = tunnel = None + try: + observer = ZenohObserver(port=ZENOH_PORT) + bridge = AtlasZenohBridge( + settings=BridgeSettings( + robot_id=ROBOT_ID, + zenoh_endpoint=f"tcp/127.0.0.1:{ZENOH_PORT}", + zenoh_config_path=None, + action_topic="robot/tunnel/action", + result_topic="robot/tunnel/result", + metrics_topic="robot/boston_dynamics_atlas_drc/metrics", + ) + ) + proxy.start() + with tempfile.TemporaryDirectory(prefix="atlas_payment_gate_") as temp_dir: + temp = Path(temp_dir) + config_path = temp / "tunnel.json" + config_path.write_text( + json.dumps( + { + "robot_id": ROBOT_ID, + "evm_payee_address": PAYEE, + "price": "$0.001", + "network": NETWORK, + } + ), + encoding="utf-8", + ) + zenoh_config = temp / "zenoh.json5" + zenoh_config.write_text( + json.dumps( + { + "mode": "peer", + "scouting": {"multicast": {"enabled": False}}, + "connect": {"endpoints": [f"tcp/127.0.0.1:{ZENOH_PORT}"]}, + } + ), + encoding="utf-8", + ) + env = os.environ.copy() + zenoh_library = ROOT / ".zenoh-c" / "lib" + env.update( + { + "PROXY_WS_URL": f"ws://127.0.0.1:{proxy.port}/ws", + "FACILITATOR_URL": f"http://127.0.0.1:{facilitator.server_address[1]}", + "AIP_ENABLED": "false", + "ZENOH_CONFIG": str(zenoh_config), + "SKILL_CATALOG_PATH": str(SKILL_CATALOG), + "ALLOWED_ACTIONS": "wave_right_arm,stop", + } + ) + if zenoh_library.is_dir(): + env["LD_LIBRARY_PATH"] = f"{zenoh_library}:{env.get('LD_LIBRARY_PATH', '')}" + tunnel = subprocess.Popen( + [tunnel_binary, "--config", str(config_path)], + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + ) + self.assertIsNotNone(proxy.wait_for_connection(15), "real Tunnel did not connect") + action_url = f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action" + unpaid_status, unpaid_headers, _ = http_post(action_url, {"action": "wave_right_arm"}) + self.assertEqual(unpaid_status, 402) + + action_id = f"atlas-tampered-{uuid.uuid4().hex}" + status, _, body = http_post( + action_url, + { + "action": "wave_right_arm", + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": action_id, + "params": {"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 8}, + }, + {"PAYMENT-SIGNATURE": payment_signature_from_402(unpaid_headers)}, + ) + self.assertEqual(status, 402, body.decode("utf-8", errors="replace")) + time.sleep(2.0) + verify_calls = [item for item in FacilitatorHandler.calls if item[0] == "/verify"] + settle_calls = [item for item in FacilitatorHandler.calls if item[0] == "/settle"] + self.assertEqual(len(verify_calls), 1) + self.assertEqual(settle_calls, []) + self.assertEqual( + observer.snapshot(), + (0, 0, 0), + "invalid payment crossed into ActionEvent, bridge output, or simulator metrics", + ) + print("[ATLAS PAYMENT GATE] isValid:false -> 402, 0 actions, 0 simulator output, 0 settlements") + finally: + if tunnel is not None and tunnel.poll() is None: + tunnel.terminate() + try: + tunnel.wait(timeout=5) + except subprocess.TimeoutExpired: + tunnel.kill() + if bridge is not None: + bridge.close() + if observer is not None: + observer.close() + proxy.close() + facilitator.shutdown() + facilitator.server_close() + facilitator_thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_registry_contract.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_registry_contract.py new file mode 100644 index 000000000..448db7468 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_registry_contract.py @@ -0,0 +1,136 @@ +"""Keep the Atlas profile's registry documents and public catalog in lockstep.""" + +from __future__ import annotations + +import hashlib +import json +import sys +import unittest +from pathlib import Path + +import yaml + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +ROOT = PACKAGE_ROOT.parents[2] +PROFILE = ( + ROOT + / "registry/vendors/boston-dynamics/atlas" + / "boston-dynamics.atlas-drc.mujoco-webots-wave.v1" +) +REQUIRED = ( + "robot.profile.yaml", + "skills.yaml", + "functions.yaml", + "payment-policy.yaml", + "execution-mapping.yaml", + "skill-catalog.json", + "examples/action-envelope.wave_right_arm.json", + "examples/action-envelope.stop.json", + "tests/skill-contract.test.yaml", + "docs/README.md", + "docs/validation-report.md", + "docs/evidence/evidence-manifest.yaml", +) + + +def _yaml(name: str) -> dict: + document = yaml.safe_load((PROFILE / name).read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise AssertionError(f"{name} must contain a mapping") + return document + + +class AtlasRegistryContractTests(unittest.TestCase): + def test_profile_is_complete_and_cross_document_ids_match(self) -> None: + for name in REQUIRED: + self.assertTrue((PROFILE / name).is_file(), name) + profile = _yaml("robot.profile.yaml") + profile_id = profile["profileId"] + self.assertEqual(PROFILE.name, profile_id) + self.assertEqual(profile["simulation"]["scope"], "simulator-only") + self.assertEqual(profile["simulation"]["primaryEngine"], "MuJoCo 3.3.0") + self.assertEqual(profile["simulation"]["validationEngine"], "Webots R2025a") + self.assertIn("current electric Atlas", profile["simulation"]["model"]["limitation"]) + self.assertIn("30 movable one-DoF joints", profile["simulation"]["model"]["limitation"]) + self.assertIn("56 DoF", profile["simulation"]["model"]["limitation"]) + documents = { + name: _yaml(name) + for name in REQUIRED + if name.endswith(".yaml") and name.count("/") == 0 + } + for name, document in documents.items(): + if name != "payment-policy.yaml": + self.assertEqual(document.get("profileId"), profile_id, name) + + skills = documents["skills.yaml"]["skills"] + skill_ids = {item["skillId"] for item in skills} + catalog = json.loads((PROFILE / "skill-catalog.json").read_text(encoding="utf-8")) + self.assertEqual({item["skill_id"] for item in catalog}, skill_ids) + policies = documents["payment-policy.yaml"]["policies"] + self.assertEqual({item["skillId"] for item in policies}, skill_ids) + self.assertEqual(set(documents["execution-mapping.yaml"]["mappings"]), skill_ids) + + def test_transport_payment_and_wave_bounds_match_runtime_contract(self) -> None: + profile = _yaml("robot.profile.yaml") + mapping = _yaml("execution-mapping.yaml") + runtime = profile["runtime"] + transport = mapping["transport"] + self.assertEqual(runtime["transport"], transport["type"]) + for field in ("actionTopic", "resultTopic", "metricsTopic"): + self.assertEqual(runtime[field], transport[field]) + self.assertEqual(runtime["readyTopic"], "robot/boston_dynamics_atlas_drc/ready") + + skills = {item["skillId"]: item for item in _yaml("skills.yaml")["skills"]} + catalog = { + item["skill_id"]: item + for item in json.loads((PROFILE / "skill-catalog.json").read_text(encoding="utf-8")) + } + limits = mapping["mappings"]["wave_right_arm"]["limits"] + self.assertEqual(skills["wave_right_arm"]["params"]["maxDurationSec"]["min"], 5) + self.assertEqual(catalog["wave_right_arm"]["params"]["maxDurationSec"]["minimum"], 5) + self.assertEqual(limits["maxDurationSec"]["min"], 5) + self.assertTrue(all(item["required"] for item in _yaml("payment-policy.yaml")["policies"])) + settlement_rule = _yaml("payment-policy.yaml")["settlement"]["rule"] + self.assertIn("paid stop request", settlement_rule) + self.assertIn("correlated safe-stop success", settlement_rule) + + function_names = {item["name"] for item in _yaml("functions.yaml")["functions"]} + self.assertEqual( + function_names, + { + "get_robot_profile", + "list_robot_skills", + "request_robot_action", + "submit_paid_robot_action", + "get_action_status", + }, + ) + + def test_evidence_manifest_binds_captured_current_head_proof(self) -> None: + evidence = _yaml("docs/evidence/evidence-manifest.yaml") + self.assertEqual(evidence["profileId"], PROFILE.name) + self.assertIn("electric Atlas", evidence["claimBoundary"]["notClaimed"]) + self.assertIn("joint-level compatibility", evidence["claimBoundary"]["notClaimed"]) + by_id = {item["evidenceId"]: item for item in evidence["evidence"]} + live = by_id["ATLAS-DRC-LIVE-X402-CURRENT-HEAD"] + visual = by_id["ATLAS-DRC-CONTINUOUS-VISUAL-CURRENT-HEAD"] + self.assertEqual(live["status"], "captured-and-verified") + self.assertEqual(visual["status"], "captured-and-verified") + self.assertEqual(live["sourceCommit"], visual["sourceCommit"]) + self.assertEqual(live["actionId"], visual["actionId"]) + self.assertEqual(live["transactionHash"], visual["transactionHash"]) + self.assertEqual(live["artifactSha256"], visual["jsonArtifactSha256"]) + artifact_path = PROFILE / live["artifact"] + self.assertTrue(artifact_path.is_file()) + self.assertEqual( + hashlib.sha256(artifact_path.read_bytes()).hexdigest(), + live["artifactSha256"], + ) + self.assertTrue( + visual["artifact"].startswith("https://github.com/user-attachments/") + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/test_x402_no_settlement.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_x402_no_settlement.py new file mode 100644 index 000000000..089788316 --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/test_x402_no_settlement.py @@ -0,0 +1,252 @@ +"""Real Tunnel proof: Atlas failure, timeout, and replay never settle x402.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import uuid +from pathlib import Path + +import zenoh + +from x402_harness import ( + ACTION_TOPIC, + FacilitatorHandler, + LocalFabricProxy, + NETWORK, + PAYEE, + RESULT_TOPIC, + find_tunnel_binary, + http_post, + payment_signature_from_402, + poll_action_status, + start_facilitator, +) + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +ROOT = PACKAGE_ROOT.parents[2] +SKILL_CATALOG = ( + ROOT + / "registry/vendors/boston-dynamics/atlas" + / "boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json" +) +ROBOT_ID = "atlas_drc_no_settlement" +ZENOH_PORT = int(os.environ.get("ATLAS_NO_SETTLEMENT_ZENOH_PORT", "7447")) + + +class ControlledAtlasResultPeer: + """Inject terminal failure/silence at the *real* Zenoh result boundary. + + This intentionally does not replace the Tunnel or x402 middleware. It is a + deterministic simulator-fault injector used to prove settlement behavior + for failure and timeout paths that should never reach a live facilitator. + """ + + def __init__(self): + self.mode = "failure" + self.actions: list[dict] = [] + self.lock = threading.Lock() + self.session = zenoh.open( + zenoh.Config.from_json5( + '{"mode":"peer","scouting":{"multicast":{"enabled":false}},' + f'"listen":{{"endpoints":["tcp/127.0.0.1:{ZENOH_PORT}"]}}}}' + ) + ) + self.publisher = self.session.declare_publisher(RESULT_TOPIC) + self.subscriber = self.session.declare_subscriber(ACTION_TOPIC, self._on_action) + + def _on_action(self, sample) -> None: + event = json.loads(bytes(sample.payload.to_bytes())) + with self.lock: + self.actions.append(event) + mode = self.mode + if mode == "silent": + return + self.publisher.put( + json.dumps( + { + "action_id": event["action_id"], + "robot_id": event["robot_id"], + "skill_id": event["skill_id"], + "params_hash": event["params_hash"], + "idempotency_key": event["idempotency_key"], + "status": "failure", + "result": {"success": False, "error_code": "INJECTED_ATLAS_SIMULATOR_FAILURE"}, + } + ).encode() + ) + + def action_count(self) -> int: + with self.lock: + return len(self.actions) + + def close(self) -> None: + self.subscriber.undeclare() + self.publisher.undeclare() + self.session.close() + + +class AtlasNoSettlementTests(unittest.TestCase): + def test_failure_timeout_payment_replay_and_restart_replay_never_settle(self) -> None: + tunnel_binary = find_tunnel_binary(ROOT) + if not tunnel_binary: + raise unittest.SkipTest("Build the real Go Tunnel first with make build") + proxy = LocalFabricProxy() + facilitator, facilitator_thread = start_facilitator() + simulator = tunnel = None + try: + proxy.start() + simulator = ControlledAtlasResultPeer() + with tempfile.TemporaryDirectory(prefix="atlas_no_settlement_") as temp_dir: + temp = Path(temp_dir) + tunnel_config = temp / "tunnel.json" + tunnel_config.write_text( + json.dumps( + { + "robot_id": ROBOT_ID, + "evm_payee_address": PAYEE, + "price": "$0.001", + "network": NETWORK, + } + ), + encoding="utf-8", + ) + zenoh_config = temp / "zenoh.json5" + zenoh_config.write_text( + json.dumps( + { + "mode": "peer", + "scouting": {"multicast": {"enabled": False}}, + "connect": {"endpoints": [f"tcp/127.0.0.1:{ZENOH_PORT}"]}, + } + ), + encoding="utf-8", + ) + store = temp / "idempotency.json" + + def start_tunnel() -> subprocess.Popen: + env = os.environ.copy() + env.update( + { + "PROXY_WS_URL": f"ws://127.0.0.1:{proxy.port}/ws", + "FACILITATOR_URL": f"http://127.0.0.1:{facilitator.server_address[1]}", + "AIP_ENABLED": "false", + "ZENOH_CONFIG": str(zenoh_config), + "SKILL_CATALOG_PATH": str(SKILL_CATALOG), + "ALLOWED_ACTIONS": "wave_right_arm,stop", + "EXECUTION_TIMEOUT_SECONDS": "3", + "IDEMPOTENCY_STORE_PATH": str(store), + } + ) + zenoh_library = ROOT / ".zenoh-c" / "lib" + if zenoh_library.is_dir(): + env["LD_LIBRARY_PATH"] = f"{zenoh_library}:{env.get('LD_LIBRARY_PATH', '')}" + process = subprocess.Popen( + [tunnel_binary, "--config", str(tunnel_config)], + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + ) + self.assertIsNotNone(proxy.wait_for_connection(15), "real Tunnel did not connect") + return process + + tunnel = start_tunnel() + action_url = f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action" + unpaid_status, unpaid_headers, _ = http_post(action_url, {"action": "wave_right_arm"}) + self.assertEqual(unpaid_status, 402) + + def request_body(action_id: str) -> dict: + return { + "action": "wave_right_arm", + "robot_id": ROBOT_ID, + "action_id": action_id, + "idempotency_key": action_id, + "params": {"cycles": 2, "amplitudeRad": 0.30, "maxDurationSec": 8}, + } + + failed_id = f"atlas-failure-{uuid.uuid4().hex}" + signature = payment_signature_from_402(unpaid_headers) + status, _, _ = http_post(action_url, request_body(failed_id), {"PAYMENT-SIGNATURE": signature}) + self.assertEqual(status, 202) + failed = poll_action_status( + f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action/{failed_id}/status", + {"failed", "timeout"}, + ) + self.assertEqual(failed["state"], "failed") + self.assertFalse(failed["settled"]) + self.assertEqual([call for call in FacilitatorHandler.calls if call[0] == "/settle"], []) + self.assertEqual(simulator.action_count(), 1) + + replay_status, _, replay_body = http_post( + action_url, request_body(failed_id), {"PAYMENT-SIGNATURE": signature} + ) + self.assertEqual(replay_status, 409) + self.assertEqual(json.loads(replay_body)["error_code"], "REPLAY_DETECTED") + self.assertEqual(simulator.action_count(), 1) + + simulator.mode = "silent" + timeout_id = f"atlas-timeout-{uuid.uuid4().hex}" + timeout_signature = payment_signature_from_402(unpaid_headers) + timeout_status, _, _ = http_post( + action_url, + request_body(timeout_id), + {"PAYMENT-SIGNATURE": timeout_signature}, + ) + self.assertEqual(timeout_status, 202) + timed_out = poll_action_status( + f"http://127.0.0.1:{proxy.port}/robots/{ROBOT_ID}/action/{timeout_id}/status", + {"failed", "timeout"}, + ) + self.assertEqual(timed_out["state"], "timeout") + self.assertFalse(timed_out["settled"]) + self.assertEqual(simulator.action_count(), 2) + self.assertEqual([call for call in FacilitatorHandler.calls if call[0] == "/settle"], []) + + payment_replay_status, _, payment_replay_body = http_post( + action_url, + request_body(f"atlas-payment-replay-{uuid.uuid4().hex}"), + {"PAYMENT-SIGNATURE": timeout_signature}, + ) + self.assertEqual(payment_replay_status, 409) + self.assertEqual(json.loads(payment_replay_body)["error_code"], "PAYMENT_REPLAY_DETECTED") + self.assertEqual(simulator.action_count(), 2) + + stale_connection = proxy.wait_for_connection(1) + tunnel.terminate() + tunnel.wait(timeout=5) + if stale_connection is not None: + proxy.detach(stale_connection) + tunnel = start_tunnel() + restart_status, _, restart_body = http_post( + action_url, request_body(failed_id), {"PAYMENT-SIGNATURE": signature} + ) + self.assertEqual(restart_status, 409) + self.assertEqual(json.loads(restart_body)["error_code"], "REPLAY_DETECTED") + self.assertEqual(simulator.action_count(), 2) + self.assertEqual([call for call in FacilitatorHandler.calls if call[0] == "/settle"], []) + print("[ATLAS NO-SETTLE] failure, timeout, payment replay, restart replay: settle_calls=0") + finally: + if tunnel is not None and tunnel.poll() is None: + tunnel.terminate() + try: + tunnel.wait(timeout=5) + except subprocess.TimeoutExpired: + tunnel.kill() + if simulator is not None: + simulator.close() + proxy.close() + facilitator.shutdown() + facilitator.server_close() + facilitator_thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/bridge/boston_dynamics/atlas_drc_bridge/tests/x402_harness.py b/bridge/boston_dynamics/atlas_drc_bridge/tests/x402_harness.py new file mode 100644 index 000000000..324f9217d --- /dev/null +++ b/bridge/boston_dynamics/atlas_drc_bridge/tests/x402_harness.py @@ -0,0 +1,408 @@ +"""Protocol-accurate local Fabric/x402 harness for Atlas integration tests. + +The proxy and facilitator are recording test doubles. The Tunnel binary, +x402 middleware, Zenoh transport, and Atlas bridge remain the production code. +""" + +from __future__ import annotations + +import base64 +import hashlib +import http.server +import json +import os +import socketserver +import sys +import threading +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +import zenoh + + +NETWORK = "eip155:84532" +PAYEE = "0x0000000000000000000000000000000000000001" +ACTION_TOPIC = "robot/tunnel/action" +RESULT_TOPIC = "robot/tunnel/result" +METRICS_TOPIC = "robot/boston_dynamics_atlas_drc/metrics" + + +def find_tunnel_binary(root: Path) -> str | None: + configured = os.environ.get("TUNNEL_BIN") + candidates = [configured] if configured else [] + candidates += [str(root / "bin" / "tunnel"), str(root / "tunnel" / "tunnel_bin")] + for candidate in candidates: + if not candidate: + continue + if sys.platform == "win32" and not candidate.endswith(".exe"): + candidate += ".exe" + if Path(candidate).is_file(): + return candidate + return None + + +def _read_exact(sock, size: int) -> bytes: + chunks: list[bytes] = [] + while size: + chunk = sock.recv(size) + if not chunk: + raise ConnectionError("WebSocket closed while reading a frame") + chunks.append(chunk) + size -= len(chunk) + return b"".join(chunks) + + +def _read_frame(sock) -> tuple[bool, int, bytes]: + first, second = _read_exact(sock, 2) + final = bool(first & 0x80) + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + if length == 126: + length = int.from_bytes(_read_exact(sock, 2), "big") + elif length == 127: + length = int.from_bytes(_read_exact(sock, 8), "big") + mask = _read_exact(sock, 4) if masked else None + payload = _read_exact(sock, length) if length else b"" + if mask: + payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) + return final, opcode, payload + + +def _write_frame(sock, payload: bytes, opcode: int = 1) -> None: + header = bytes([0x80 | opcode]) + if len(payload) < 126: + header += bytes([len(payload)]) + elif len(payload) <= 0xFFFF: + header += bytes([126]) + len(payload).to_bytes(2, "big") + else: + header += bytes([127]) + len(payload).to_bytes(8, "big") + sock.sendall(header + payload) + + +class TunnelConnection: + def __init__(self, sock): + self.sock = sock + self.lock = threading.Lock() + + def _read_message(self) -> tuple[int, bytes]: + message_opcode: int | None = None + chunks: list[bytes] = [] + while True: + final, opcode, payload = _read_frame(self.sock) + if opcode == 9: + with self.lock: + _write_frame(self.sock, payload, opcode=10) + continue + if opcode == 8: + return opcode, payload + if opcode in {1, 2}: + if message_opcode is not None: + raise ConnectionError("new WebSocket message before continuation completed") + message_opcode = opcode + elif opcode == 0: + if message_opcode is None: + raise ConnectionError("unexpected WebSocket continuation") + else: + continue + chunks.append(payload) + if final: + return message_opcode, b"".join(chunks) + + def request(self, envelope: dict, timeout: float = 35.0) -> dict: + with self.lock: + _write_frame(self.sock, json.dumps(envelope, separators=(",", ":")).encode()) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + self.sock.settimeout(max(0.1, deadline - time.monotonic())) + opcode, raw = self._read_message() + if opcode == 8: + raise ConnectionError("Tunnel WebSocket closed before responding") + if opcode == 1: + response = json.loads(raw) + if response.get("id") == envelope["id"]: + return response + raise TimeoutError("Tunnel response timed out") + + +class _ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +class _ProxyHandler(http.server.BaseHTTPRequestHandler): + proxy = None + + def log_message(self, *_args) -> None: + pass + + def do_GET(self) -> None: + path = self.path.split("?", 1)[0] + if path == "/ws": + self._websocket() + elif path.endswith("/skills"): + self._forward("GET", "/skills", b"") + elif path.startswith("/robots/") and path.count("/") == 2: + self._forward("GET", "/robot", b"") + elif "/action/" in path and path.endswith("/status"): + self._forward("GET", path[path.index("/action/") :], b"") + else: + self.send_error(404) + + def do_POST(self) -> None: + if not self.path.endswith("/action"): + self.send_error(404) + return + length = int(self.headers.get("Content-Length", "0")) + self._forward("POST", "/action", self.rfile.read(length) if length else b"") + + def _websocket(self) -> None: + key = self.headers.get("Sec-WebSocket-Key") + if not key: + self.send_error(400, "missing Sec-WebSocket-Key") + return + accept = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + self.send_response(101, "Switching Protocols") + self.send_header("Upgrade", "websocket") + self.send_header("Connection", "Upgrade") + self.send_header("Sec-WebSocket-Accept", accept) + self.end_headers() + self.wfile.flush() + connection = TunnelConnection(self.connection) + self.proxy.attach(connection) + try: + self.proxy.stop_event.wait() + finally: + self.proxy.detach(connection) + + def _forward(self, method: str, path: str, body: bytes) -> None: + connection = self.proxy.wait_for_connection(10) + if connection is None: + self._respond(503, b'{"error":"Tunnel is not connected"}') + return + envelope = { + "type": "request", + "id": uuid.uuid4().hex, + "method": method, + "path": path, + "headers": {name: value for name, value in self.headers.items() if name != "Host"}, + "body": base64.b64encode(body).decode(), + } + try: + response = connection.request(envelope) + except Exception as error: + self._respond(502, json.dumps({"error": str(error)}).encode()) + return + response_body = base64.b64decode(response.get("body", "")) + self.send_response(int(response.get("status", 502))) + for name, value in (response.get("headers") or {}).items(): + if name.lower() not in {"connection", "content-length", "transfer-encoding"}: + self.send_header(name, value) + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + + def _respond(self, status: int, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class LocalFabricProxy: + def __init__(self): + self.server = _ThreadingHTTPServer(("127.0.0.1", 0), _ProxyHandler) + self.server.RequestHandlerClass.proxy = self + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.stop_event = threading.Event() + self.connection = None + self.condition = threading.Condition() + + @property + def port(self) -> int: + return self.server.server_address[1] + + def start(self) -> None: + self.thread.start() + + def attach(self, connection) -> None: + with self.condition: + self.connection = connection + self.condition.notify_all() + + def detach(self, connection) -> None: + with self.condition: + if self.connection is connection: + self.connection = None + self.condition.notify_all() + + def wait_for_connection(self, timeout: float): + deadline = time.monotonic() + timeout + with self.condition: + while self.connection is None and not self.stop_event.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + self.condition.wait(remaining) + return self.connection + + def close(self) -> None: + self.stop_event.set() + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + +class FacilitatorHandler(http.server.BaseHTTPRequestHandler): + calls: list[tuple[str, dict]] = [] + verify_response: dict = {"isValid": True, "payer": "0x1111111111111111111111111111111111111111"} + + def log_message(self, *_args) -> None: + pass + + def do_GET(self) -> None: + if self.path != "/supported": + self.send_error(404) + return + self._json({"kinds": [{"x402Version": 2, "scheme": "exact", "network": NETWORK}], "extensions": [], "signers": {}}) + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) if length else b"{}") + self.calls.append((self.path, payload)) + if self.path == "/verify": + self._json(self.verify_response) + elif self.path == "/settle": + self._json({"success": True, "transaction": "0x" + "e2" * 32, "network": NETWORK}) + else: + self.send_error(404) + + def _json(self, payload: dict) -> None: + raw = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +def start_facilitator(verify_response: dict | None = None): + FacilitatorHandler.calls = [] + FacilitatorHandler.verify_response = verify_response or { + "isValid": True, + "payer": "0x1111111111111111111111111111111111111111", + } + server = _ThreadingHTTPServer(("127.0.0.1", 0), FacilitatorHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +class ZenohObserver: + """Real Zenoh listener used to observe action/result/metrics boundaries.""" + + def __init__(self, port: int = 7447): + self.port = port + self.lock = threading.Lock() + self.actions: list[dict] = [] + self.results: list[dict] = [] + self.metrics: list[dict] = [] + self.action_received = threading.Event() + self.session = zenoh.open( + zenoh.Config.from_json5( + '{"mode":"peer","scouting":{"multicast":{"enabled":false}},' + f'"listen":{{"endpoints":["tcp/127.0.0.1:{port}"]}}}}' + ) + ) + self.action_sub = self.session.declare_subscriber(ACTION_TOPIC, self._record_action) + self.result_sub = self.session.declare_subscriber(RESULT_TOPIC, self._record_result) + self.metrics_sub = self.session.declare_subscriber(METRICS_TOPIC, self._record_metrics) + + def _record(self, target: list[dict], sample) -> None: + with self.lock: + target.append(json.loads(bytes(sample.payload.to_bytes()))) + + def _record_action(self, sample) -> None: + self._record(self.actions, sample) + self.action_received.set() + + def _record_result(self, sample) -> None: + self._record(self.results, sample) + + def _record_metrics(self, sample) -> None: + self._record(self.metrics, sample) + + def snapshot(self) -> tuple[int, int, int]: + with self.lock: + return len(self.actions), len(self.results), len(self.metrics) + + def close(self) -> None: + self.action_sub.undeclare() + self.result_sub.undeclare() + self.metrics_sub.undeclare() + self.session.close() + + +def http_post(url: str, payload: dict, headers: dict | None = None): + request = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", **(headers or {})}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=35) as response: + return response.status, dict(response.headers), response.read() + except urllib.error.HTTPError as error: + return error.code, dict(error.headers), error.read() + + +def http_get(url: str): + try: + with urllib.request.urlopen(urllib.request.Request(url, method="GET"), timeout=35) as response: + return response.status, dict(response.headers), response.read() + except urllib.error.HTTPError as error: + return error.code, dict(error.headers), error.read() + + +def payment_signature_from_402(headers: dict) -> str: + encoded = headers.get("PAYMENT-REQUIRED") or headers.get("Payment-Required") + if not encoded: + raise AssertionError("Tunnel 402 omitted PAYMENT-REQUIRED") + requirements = json.loads(base64.b64decode(encoded)) + accepted = requirements["accepts"][0] + payment = { + "x402Version": 2, + "accepted": accepted, + "payload": { + "signature": "0x" + "11" * 65, + "authorization": { + "from": "0x1111111111111111111111111111111111111111", + "to": accepted["payTo"], + "value": accepted["amount"], + "validAfter": "0", + "validBefore": str(int(time.time()) + 3600), + "nonce": "0x" + os.urandom(32).hex(), + }, + }, + } + return base64.b64encode(json.dumps(payment, separators=(",", ":")).encode()).decode() + + +def poll_action_status(url: str, terminal_states: set[str], timeout: float = 30) -> dict: + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + status, _, body = http_get(url) + if status == 200: + last = json.loads(body) + if last.get("state") in terminal_states: + return last + time.sleep(0.25) + raise AssertionError(f"status never reached {terminal_states}; last={last}") diff --git a/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py b/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py index 09ee7a9e7..69e6bfca3 100644 --- a/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py +++ b/bridge/common/zenoh_bridge/zenoh_bridge/action_event.py @@ -1,4 +1,6 @@ """Parse Fabric tunnel Action Event payloads.""" +import hashlib +import hmac import json from dataclasses import dataclass, field from typing import Any, Dict, Optional @@ -9,6 +11,13 @@ class ActionEvent: action: str params: Dict[str, Any] = field(default_factory=dict) timestamp: str = "" + action_id: str = "" + robot_id: str = "" + skill_id: str = "" + params_hash: str = "" + params_canonical: str = "" + idempotency_key: str = "" + transaction_details: Dict[str, Any] = field(default_factory=dict) def parse_action_event(raw: bytes) -> Optional[ActionEvent]: @@ -22,7 +31,9 @@ def parse_action_event(raw: bytes) -> Optional[ActionEvent]: "timestamp": "2026-01-01T00:00:00Z" } - Returns None on parse failure. + Returns None on parse failure or when the payload does not name an + action: there is no default action, so an unnamed request can never + actuate a robot (fail closed). """ try: event = json.loads(raw) @@ -33,8 +44,61 @@ def parse_action_event(raw: bytes) -> Optional[ActionEvent]: if not isinstance(payload, dict): return None + action = payload.get("action") or payload.get("skill_id") or payload.get("skillId") + correlation = { + "action_id": event.get("action_id"), + "robot_id": event.get("robot_id"), + "skill_id": event.get("skill_id") or event.get("skillId"), + "params_hash": event.get("params_hash") or event.get("paramsHash"), + "idempotency_key": event.get("idempotency_key") or event.get("idempotencyKey"), + } + if not isinstance(action, str) or not action.strip(): + return None + if any(not isinstance(value, str) or not value.strip() for value in correlation.values()): + # A bridge must never execute an event it cannot report back to the + # Tunnel as the exact same paid action. + return None + if action.strip() != correlation["skill_id"].strip(): + return None + + params = payload.get("params", {}) + if params is None: + params = {} + if not isinstance(params, dict): + return None + + # Go computes params_hash from encoding/json's exact canonical byte + # sequence. Carry that sequence alongside the structured payload so a + # Python bridge can prove integrity without trying to reproduce Go's float + # rendering rules. A local Zenoh publisher cannot alter valid parameters + # while retaining the Tunnel's paid correlation hash. + params_canonical = event.get("params_canonical") + if not isinstance(params_canonical, str): + return None + try: + canonical_params = json.loads(params_canonical) + except (TypeError, json.JSONDecodeError): + return None + expected_hash = "sha256:" + hashlib.sha256(params_canonical.encode("utf-8")).hexdigest() + if ( + not isinstance(canonical_params, dict) + or canonical_params != params + or not hmac.compare_digest(expected_hash, correlation["params_hash"].strip()) + ): + return None + transaction_details = event.get("transaction_details") or {} + if not isinstance(transaction_details, dict): + transaction_details = {} + return ActionEvent( - action=payload.get("action", "stop"), - params=payload.get("params") or {}, + action=action.strip(), + params=params, timestamp=event.get("timestamp", ""), + action_id=correlation["action_id"].strip(), + robot_id=correlation["robot_id"].strip(), + skill_id=correlation["skill_id"].strip(), + params_hash=correlation["params_hash"].strip(), + params_canonical=params_canonical, + idempotency_key=correlation["idempotency_key"].strip(), + transaction_details=transaction_details, ) diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/README.md b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/README.md new file mode 100644 index 000000000..2cff13a34 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/README.md @@ -0,0 +1,200 @@ +# Boston Dynamics Atlas DRC legacy — paid MuJoCo + Webots wave + +This is a **Tier 1, simulator-only** profile for the DARPA-era Atlas DRC/v4 +model. It is deliberately not described as Boston Dynamics' current electric +Atlas. The profile uses the same payment-gated Go Tunnel, x402, durable replay +state, and Zenoh result correlation boundary as the Reachy Mini and Spot +profiles. + +## Model provenance and visual fidelity + +The pinned source is OpenAI Roboschool commit `d32bcb2b35b94168b5ce27233ca62f3c8678886f`: +`atlas_v4_with_multisense.urdf`, its original Atlas DRC `.dae` meshes, and the +MultiSense head mesh. `download_atlas_model.py` verifies their locked SHA-256 +values before use. + +MuJoCo does not read DAE visual meshes directly. For an opt-in desktop view, +the bridge converts the **same checked-source visual triangles** locally to +OBJ, loads 23 display meshes, and disables collision on those display-only +geometries. Physics, joint names, limits, and the bounded torque controller +continue to come from the pinned original URDF. Generated source assets and +converted files are ignored by Git. + +Webots R2025a uses the official externally referenced Atlas PROTO at the +pinned `R2025a` source tag; it is not vendored because its asset license is +for Webots use. The world defines a floor, background, directional light, and +the official Atlas camera framing. + +## Electric Atlas compatibility boundary + +As checked on 2026-08-21, Boston Dynamics' public electric Atlas product page +and product announcement specify **56 degrees of freedom** and continuous or +fully rotational joints, but do not publish a URDF, USD, joint names, axes, +limits, inertias, or actuator model: + +- https://bostondynamics.com/products/atlas/ +- https://bostondynamics.com/blog/boston-dynamics-unveils-new-atlas-robot-to-revolutionize-industry/ +- https://bostondynamics.com/wp-content/uploads/2026/01/atlas-spec-sheet.pdf + +NVIDIA's public Isaac Sim 5.1 robot-asset catalog lists Spot and Spot with arm +under `BostonDynamics`, but contains no Atlas asset: + +- https://docs.isaacsim.omniverse.nvidia.com/5.1.0/assets/usd_assets_robots.html + +The pinned DRC/v4 URDF contains 30 movable, single-axis joints. Human-level +concepts such as shoulder, elbow, hip and knee exist on both generations, but +that is not enough to establish a kinematic mapping. The electric model's 56 +DoF, continuous range and unpublished topology mean DRC joint names, axes, +limits, torque gains and policies are **not treated as compatible or directly +transferable**. This profile is consequently named and scoped to +`atlas-drc-v4-legacy`; it is not a proxy claim for electric Atlas. + +## Clean setup + +```bash +make build +python -m pip install -r bridge/boston_dynamics/atlas_drc_bridge/requirements.txt +python bridge/boston_dynamics/atlas_drc_bridge/download_atlas_model.py +export PYTHONPATH="$PWD/bridge/boston_dynamics/atlas_drc_bridge" +``` + +On Windows PowerShell, replace `export` with `$env:PYTHONPATH = ...`. + +## Visual simulator runs + +Open the original-mesh MuJoCo view in a desktop session: + +```bash +python bridge/boston_dynamics/atlas_drc_bridge/run_paid_wave.py --viewer \ + --cycles 2 --amplitude-rad 0.30 --max-duration 8 --viewer-hold-seconds 20 +``` + +This is a local controller preview; it is useful for inspecting model geometry +and bounded state-feedback motion. The result still exposes measured joint +stroke, completed half-waves, finite state, and torque peak. + +Run the independently supplied, illuminated Webots scene: + +```bash +WEBOTS_EXE=/path/to/webots \ +python bridge/boston_dynamics/atlas_drc_bridge/run_sim2sim_validation.py +``` + +For an operator recording, set `ATLAS_WEBOTS_RECORDING_PATH` to an absolute +MP4 path and run Webots in a graphical desktop session. Do not treat a +headless/offscreen capture as visual evidence without inspecting it. + +## Tunnel, Zenoh, and action contract + +The bridge refuses an implicit Zenoh session. Configure a private, authenticated +or otherwise isolated Tunnel-to-bridge boundary with `ZENOH_CONFIG`; the test +only `ZENOH_ENDPOINT` mode is for a controlled local test router. The topics +are: + +| Direction | Topic | +| --- | --- | +| verified action from Tunnel | `robot/tunnel/action` | +| correlated terminal result | `robot/tunnel/result` | +| reviewable Atlas metrics | `robot/boston_dynamics_atlas_drc/metrics` | +| bridge readiness after subscription | `robot/boston_dynamics_atlas_drc/ready` | + +The Tunnel requires these deployment values: + +```bash +export SKILL_CATALOG_PATH="$PWD/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json" +export ALLOWED_ACTIONS="wave_right_arm,stop" +export IDEMPOTENCY_STORE_PATH=/secure/persistent/atlas-idempotency.json +export ZENOH_CONFIG=/secure/config/atlas-zenoh.json5 +``` + +`robot_id`, testnet payee, price, and network are deployment configuration, +not profile constants. Keep private keys only with the payer/live-evidence +runner; the Tunnel and bridge do not need or retain one. + +The Fabric gateway's `POST /robots/{robotId}/action` is forwarded to the +Tunnel's local `POST /action`. The Tunnel accepts only the registered tuple +`(robot_id, action == skill_id, params, action_id, idempotency_key)`. The wave +parameters are `cycles: 1..3`, `amplitudeRad: 0.15..0.40`, and +`maxDurationSec: 5..15`; unknown fields and actions fail closed before the +simulator. An accepted request returns immediate `202` with `action_id` and a +status URL. The same `action_id`, `robot_id`, `skill_id`, `params_hash`, and +`idempotency_key` are required on the terminal Zenoh result. + +The bridge executes a measured-state, turning-point right-arm policy in +MuJoCo. It switches only after measured shoulder state crosses the bounded +turning point, returns to neutral before success, and never writes `qpos` to +fake a result. The Webots controller observes the corresponding actual +`RArmUsy` HingeJoint position through the Supervisor API. + +## Payment and safety behavior + +- Missing, malformed, nil, or `isValid:false` x402 verification fails before + `PostAction`: HTTP `402`, zero ActionEvents, zero simulation output, and zero + settlement. +- A paid wave settles only after the exact correlated simulator success. +- Simulator failure, timeout, malformed result, payment replay, action replay, + and restart replay do not settle. +- `stop` has no parameters. It interrupts the active wave with zero torque and + zero simulated velocity; that interrupted wave reports failure and cannot + settle. A separately paid stop request can settle only for its **own** + correlated safe-stop success result. +- The current shared Tunnel/Gateway identity protocol identifies the configured + robot and payee but does not provide a signed robot-to-payee handshake. That + protocol binding remains an explicit upstream dependency; this profile does + not invent a local EIP signing scheme. + +For a live Base Sepolia recording, use the trusted-fork secret-backed workflow +or run `test_base_sepolia_tunnel_e2e.py` with a funded payer. Set +`ATLAS_MUJOCO_VIEWER=1` before that script to make the actual paid bridge open +the MuJoCo viewer during its first paid action in a desktop-capable session. +The script verifies unpaid `402`, skill discovery, first paid `202`, correlated +simulator success, settlement, and writes the real transaction hash to an +artifact. Never commit keys, raw local logs, or unverified recordings. + +On Windows, the reviewable split-screen runner keeps MuJoCo native and runs +the production Linux Tunnel in the `Ubuntu-22.04` WSL distribution: + +```powershell +$env:PRIVATE_KEY = '' +$env:ROBO_PAYEE_ADDRESS = '' +& bridge\boston_dynamics\atlas_drc_bridge\run_live_base_sepolia_visual.ps1 +``` + +The runner waits for an explicit bridge-ready event, shows the exact commit, +pauses at Enter before any paid request, then displays discovery, unpaid 402, +first paid 202/action ID, the complete measured wave, correlated result, +settlement and BaseScan. It writes the trusted JSON receipt under `artifacts/`; +copy only the verified receipt into `docs/evidence/` and bind its hash together +with the recording hash in `docs/evidence/evidence-manifest.yaml`. + +## Mandatory checks + +```bash +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_contract.py +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_registry_contract.py +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_bridge_contract.py +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_payment_gate.py +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_e2e_paid_action.py +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_x402_no_settlement.py +python bridge/boston_dynamics/atlas_drc_bridge/tests/test_mujoco_runtime.py +``` + +The GitHub workflow makes the first seven tests, MuJoCo proof, and Webots +Sim-to-Sim proof required. Its trusted-fork Base Sepolia job waits for all of +them and uploads the generated receipt/result JSON. + +## Troubleshooting + +- If the Atlas falls in Webots, confirm the submitted world still uses the + official `translation 0 0 1`, `CFM 1e-07`, `ERP 0.8`, and 8 ms basic time + step. The result must report `stable_base=true`; arm stroke alone is not a + passing result. +- If Webots fails on Windows with a Qt `offscreen` plugin error, do not export + `QT_QPA_PLATFORM=offscreen`; the launcher removes that CI-only setting on + Windows. +- If the visual runner reports port 7447 in use, stop the stale Zenoh router. + The runner intentionally refuses to pay into an unknown local session. +- If the Tunnel binary is missing, run `make build` inside `Ubuntu-22.04` WSL. +- A Base Sepolia `402 invalid_exact_evm_signature` is not a simulator failure; + verify that the payer key is funded, the payee matches discovery, and the + x402 Python/Go versions match the pinned requirements before retrying. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/evidence/base_sepolia_result_1787284145.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/evidence/base_sepolia_result_1787284145.json new file mode 100644 index 000000000..81e3a7d1f --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/evidence/base_sepolia_result_1787284145.json @@ -0,0 +1,99 @@ +{ + "timestamp": "2026-08-21T03:49:05Z", + "source_commit": "184c06aa6dd1ffff502563694e9b3cbaafc67263", + "network": "eip155:84532", + "payer": "0x39a315667d557B1425bb1e5D371DD66d300c98c1", + "payee": "0x39a315667d557B1425bb1e5D371DD66d300c98c1", + "robot_id": "atlas-drc-base-sepolia-1787284097", + "request_id": "atlas-wave-1787284097", + "unpaid_http_status": 402, + "paid_http_status": 202, + "discovery": { + "robot_id": "atlas-drc-base-sepolia-1787284097", + "skills": [ + { + "aliases": null, + "description": "Safely stop the active Atlas DRC simulation.", + "enabled": true, + "params": {}, + "payment_required": true, + "price_usdc": "0.001", + "skill_id": "stop" + }, + { + "aliases": null, + "description": "Run a bounded closed-loop right-arm wave in the Atlas DRC legacy simulator.", + "enabled": true, + "params": { + "amplitudeRad": { + "type": "number", + "minimum": 0.15, + "maximum": 0.4 + }, + "cycles": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "maxDurationSec": { + "type": "number", + "minimum": 5, + "maximum": 15 + } + }, + "payment_required": true, + "price_usdc": "0.001", + "skill_id": "wave_right_arm" + } + ] + }, + "terminal_status": { + "action_id": "atlas-wave-1787284097", + "idempotency_key": "atlas-wave-1787284097", + "params_hash": "sha256:478634bf802e465eb16705200670ecc712f3ce681dd4539a4034c2094dde4a06", + "result": { + "completed_half_waves": 4, + "completion_reason": "wave_complete", + "control_steps": 1782, + "controller": "state_feedback_turning_point_pd_torque", + "final_wave_joint_rad": -0.02711, + "final_wave_joint_velocity_rad_s": 0.26414, + "finite_state": true, + "initial_wave_joint_rad": 0, + "max_wave_joint_rad": 0.27421, + "measured_wave_stroke_rad": 0.65003, + "min_wave_joint_rad": -0.37581, + "peak_commanded_torque_nm": 75, + "policy_id": "atlas-drc-right-arm-wave-v1", + "requested_amplitude_rad": 0.3, + "requested_cycles": 2, + "robot_model": "Boston Dynamics Atlas DRC v4 (legacy URDF)", + "safe_stop_applied": false, + "sim_duration_seconds": 3.564, + "simulator_engine": "MuJoCo", + "status": "success", + "success": true, + "task": "wave_right_arm", + "viewer_enabled": true + }, + "robot_id": "atlas-drc-base-sepolia-1787284097", + "settled": true, + "settlement": { + "network": "eip155:84532", + "payer": "0x39a315667d557B1425bb1e5D371DD66d300c98c1", + "payment_response": "eyJzdWNjZXNzIjp0cnVlLCJwYXllciI6IjB4MzlhMzE1NjY3ZDU1N0IxNDI1YmIxZTVEMzcxREQ2NmQzMDBjOThjMSIsInRyYW5zYWN0aW9uIjoiMHgyNDM1MTI1YjYxYzVlMDAzNjcxMzE2MTExYTM0YWEwNjNjZjVlMmM0Njk0YzEzNWY1NWI5NjdlMDhlMjc3NzdjIiwibmV0d29yayI6ImVpcDE1NTo4NDUzMiJ9", + "transaction": "0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c" + }, + "skill_id": "wave_right_arm", + "state": "succeeded", + "updated_at": "2026-08-21T00:49:08-03:00" + }, + "settlement": { + "network": "eip155:84532", + "payer": "0x39a315667d557B1425bb1e5D371DD66d300c98c1", + "payment_response": "eyJzdWNjZXNzIjp0cnVlLCJwYXllciI6IjB4MzlhMzE1NjY3ZDU1N0IxNDI1YmIxZTVEMzcxREQ2NmQzMDBjOThjMSIsInRyYW5zYWN0aW9uIjoiMHgyNDM1MTI1YjYxYzVlMDAzNjcxMzE2MTExYTM0YWEwNjNjZjVlMmM0Njk0YzEzNWY1NWI5NjdlMDhlMjc3NzdjIiwibmV0d29yayI6ImVpcDE1NTo4NDUzMiJ9", + "transaction": "0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c" + }, + "transaction_hash": "0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c", + "basescan_url": "https://sepolia.basescan.org/tx/0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c" +} diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/evidence/evidence-manifest.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/evidence/evidence-manifest.yaml new file mode 100644 index 000000000..9e5058250 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/evidence/evidence-manifest.yaml @@ -0,0 +1,86 @@ +schemaVersion: evidence-manifest.v1 +profileId: boston-dynamics.atlas-drc.mujoco-webots-wave.v1 + +claimBoundary: + scope: simulator-only Tier 1 + claimed: >- + The shared Tunnel validates x402 evidence before publishing an ActionEvent; + the Atlas DRC/v4 MuJoCo bridge executes a measured-state wave and publishes + a correlated terminal result; settlement is deferred until that exact + result is successful. + notClaimed: >- + This profile does not represent the current electric Atlas and does not + claim physical Atlas execution. It uses public DARPA-era hydraulic Atlas + DRC/v4 assets in two independent simulators. It does not claim joint-level + compatibility or policy transfer to the 56-DoF electric Atlas. + +evidence: + - evidenceId: ATLAS-DRC-SIM2SIM-CI + artifact: bridge/boston_dynamics/atlas_drc_bridge/artifacts/sim2sim_result.json + generatedBy: bridge/boston_dynamics/atlas_drc_bridge/run_sim2sim_validation.py + requiredFields: + - shared_policy.policy_id + - mujoco.completed_half_waves + - mujoco.measured_wave_stroke_rad + - webots.completed_half_waves + - webots.measured_wave_stroke_rad + - webots.stable_base + - sim_to_sim_score + proves: >- + MuJoCo and Webots independently execute the same measured-state policy, + complete all four half-waves, exceed the stroke threshold, and keep the + Webots Atlas upright. + status: generated-by-required-ci + + - evidenceId: ATLAS-DRC-LIVE-X402-CURRENT-HEAD + artifact: docs/evidence/base_sepolia_result_1787284145.json + generatedBy: bridge/boston_dynamics/atlas_drc_bridge/run_live_base_sepolia_visual.ps1 + sourceCommit: 184c06aa6dd1ffff502563694e9b3cbaafc67263 + actionId: atlas-wave-1787284097 + transactionHash: 0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c + basescanUrl: https://sepolia.basescan.org/tx/0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c + artifactSha256: 459756fbc6d3fe7a2eab16af0782545db6915de5ffaf0c4ed3b3d0489aa7b249 + runnerArtifactSha256: 807e24bdf8d8171c2382ab5c7e2646a08e7bb372f86fdf46facaa2131e77176d + requiredFields: + - source_commit + - unpaid_http_status + - request_id + - paid_http_status + - terminal_status.action_id + - terminal_status.state + - terminal_status.settled + - transaction_hash + - basescan_url + proves: >- + One current-source run observes unpaid 402, first paid 202, correlated + Atlas simulator success, and the resulting Base Sepolia settlement. + status: captured-and-verified + + - evidenceId: ATLAS-DRC-CONTINUOUS-VISUAL-CURRENT-HEAD + artifact: https://github.com/user-attachments/assets/9a0b622f-e521-44f2-8101-071cde2fae28 + generatedBy: bridge/boston_dynamics/atlas_drc_bridge/run_live_base_sepolia_visual.ps1 + sourceCommit: 184c06aa6dd1ffff502563694e9b3cbaafc67263 + actionId: atlas-wave-1787284097 + transactionHash: 0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c + recordingSha256: 6f004724c9c48df34be62785c0ac7ed148464d9c379d09dd3097fea8f6b58f01 + jsonArtifact: docs/evidence/base_sepolia_result_1787284145.json + jsonArtifactSha256: 459756fbc6d3fe7a2eab16af0782545db6915de5ffaf0c4ed3b3d0489aa7b249 + runnerJsonArtifactSha256: 807e24bdf8d8171c2382ab5c7e2646a08e7bb372f86fdf46facaa2131e77176d + recording: + filename: 2026-08-21 00-48-13.mp4 + durationSeconds: 62.33 + resolution: 1280x720 + requiredVisibleSequence: + - exact source commit + - unpaid HTTP 402 with no actuation + - first paid HTTP 202 and action ID + - complete Atlas DRC wave in an unobstructed MuJoCo viewer + - correlated terminal result with the same action ID + - settlement and matching BaseScan transaction + requiredBindings: + - source_commit + - action_id + - transaction_hash + - recording_sha256 + - json_artifact_sha256 + status: captured-and-verified diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/validation-report.md b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/validation-report.md new file mode 100644 index 000000000..1fdca91ce --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/docs/validation-report.md @@ -0,0 +1,30 @@ +# Validation report — Atlas DRC/v4 legacy profile + +Validated locally on 2026-08-20 with the pinned Atlas DRC/v4 source and Webots +R2025a model. The live payment and continuous visual evidence were captured on +2026-08-21 from source commit +`184c06aa6dd1ffff502563694e9b3cbaafc67263`. +The follow-up commit changes only the versioned evidence package and its +registry binding assertion; it does not change the Tunnel, bridge, policy, +simulator, workflow, or model assets exercised by the recording. + +| Gate | Result | Measured evidence | +|---|---:|---| +| MuJoCo task | pass | State-feedback torque controller completed 4/4 half-waves; measured stroke `0.65003 rad`; finite state; peak bounded torque `75 Nm` | +| Webots task | pass | Measured-position controller completed 4/4 half-waves; stroke `0.55761 rad`; minimum root height `0.91602 m`; minimum upright cosine `0.99844`; `stable_base=true` | +| Sim-to-Sim | pass | Same policy ID, parameters and four measured turning points in both engines; all five comparison gates pass; score `1.0` | +| Go Tunnel | pass | Production Tunnel builds and all Go tests pass with x402 Go `v0.0.0-20260529172747-45d81d46e5bd` | +| Invalid payment | pass | Real Tunnel/router/Zenoh test receives facilitator HTTP 200 with `isValid:false`; returns HTTP 402, ActionEvents=0, simulator outputs=0, settlements=0 | +| Cold-start paid flow | pass | Bridge readiness is published after the action subscription; unpaid 402 is followed by the first paid 202 without a warm-up action; one real MuJoCo wave, one correlated result and one settlement | +| Failure/timeout/replay | pass | Simulator failure, silence timeout, payment replay and restart replay all leave settlement calls at zero | +| WebSocket fragmentation | pass | Continuation frames are assembled before decoding the first Fabric response | +| Registry drift | pass | Profile, skills, catalog, price, topics, examples and execution mapping validate together | +| Model identity | pass | Pinned DRC/v4 URDF has 30 movable one-DoF joints; electric Atlas is documented separately as 56 DoF with continuous range; no compatibility or electric-model claim | +| Current-HEAD Base Sepolia receipt | pass | Unpaid `402`; first paid `202`; correlated `atlas-wave-1787284097` success; settlement transaction [`0x2435125...777c`](https://sepolia.basescan.org/tx/0x2435125b61c5e003671316111a34aa063cf5e2c4694c135f55b967e08e27777c); committed receipt SHA-256 `459756fbc6d3fe7a2eab16af0782545db6915de5ffaf0c4ed3b3d0489aa7b249`; raw runner artifact SHA-256 `807e24bdf8d8171c2382ab5c7e2646a08e7bb372f86fdf46facaa2131e77176d` | +| Current-HEAD continuous visual evidence | pass | [Continuous split-screen recording](https://github.com/user-attachments/assets/9a0b622f-e521-44f2-8101-071cde2fae28) keeps terminal and MuJoCo visible through the complete wave and matching BaseScan success; recording SHA-256 `6f004724c9c48df34be62785c0ac7ed148464d9c379d09dd3097fea8f6b58f01` | + +The negative-payment suite uses a recording facilitator only to return a +deterministic invalid verdict. It does not mock the Go Tunnel, x402 middleware, +Zenoh publication boundary, durable replay store, or simulator-side action +boundary. Positive simulator success is covered separately with the real +MuJoCo and Webots runtimes. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/examples/action-envelope.stop.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/examples/action-envelope.stop.json new file mode 100644 index 000000000..6684edb30 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/examples/action-envelope.stop.json @@ -0,0 +1,7 @@ +{ + "action": "stop", + "robot_id": "atlas-drc-mujoco-webots-sim-01", + "action_id": "atlas-stop-example-001", + "idempotency_key": "atlas-stop-example-001", + "params": {} +} diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/examples/action-envelope.wave_right_arm.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/examples/action-envelope.wave_right_arm.json new file mode 100644 index 000000000..e4ff0484e --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/examples/action-envelope.wave_right_arm.json @@ -0,0 +1,11 @@ +{ + "action": "wave_right_arm", + "robot_id": "atlas-drc-mujoco-webots-sim-01", + "action_id": "atlas-wave-example-001", + "idempotency_key": "atlas-wave-example-001", + "params": { + "cycles": 2, + "amplitudeRad": 0.3, + "maxDurationSec": 8 + } +} diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/execution-mapping.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/execution-mapping.yaml new file mode 100644 index 000000000..f656cb65e --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/execution-mapping.yaml @@ -0,0 +1,34 @@ +schemaVersion: execution-mapping.v1 +profileId: boston-dynamics.atlas-drc.mujoco-webots-wave.v1 +transport: + type: zenoh + actionTopic: robot/tunnel/action + resultTopic: robot/tunnel/result + metricsTopic: robot/boston_dynamics_atlas_drc/metrics +paramsHashFormat: "sha256:" +mappings: + wave_right_arm: + task: humanoid_right_arm_wave + input: [measured_right_shoulder_position, measured_right_shoulder_velocity] + policy: atlas-drc-right-arm-wave-v1 + controller: >- + target-switching state-feedback PD controller; a half-wave advances only + after measured joint state crosses its bounded turning point, then the + controller returns to measured neutral before terminal success. + output: bounded MuJoCo generalized torque / Webots RotationalMotor target + metrics: + - completed_half_waves + - measured_wave_stroke_rad + - peak_commanded_torque_nm + - finite_state + - minimum_root_height_m + - minimum_upright_cosine + - stable_base + limits: + cycles: {min: 1, max: 3} + amplitudeRad: {min: 0.15, max: 0.40} + maxDurationSec: {min: 5, max: 15} + stop: + task: safe_stop + output: zero torque and zero simulated velocity + result: correlated terminal result; interrupted wave returns failure and cannot settle diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/functions.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/functions.yaml new file mode 100644 index 000000000..5f38675e6 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/functions.yaml @@ -0,0 +1,42 @@ +schemaVersion: agent-functions.v1 +profileId: boston-dynamics.atlas-drc.mujoco-webots-wave.v1 +functions: + - name: get_robot_profile + method: GET + url: /robot + - name: list_robot_skills + method: GET + url: /skills + - name: request_robot_action + method: POST + url: /action + body: + action: string + robot_id: string + action_id: string + idempotency_key: string + params: object + payment: + unpaidStatus: 402 + paymentRequiredHeader: PAYMENT-REQUIRED + - name: submit_paid_robot_action + method: POST + url: /action + headers: + PAYMENT-SIGNATURE: string + body: same as request_robot_action + returns: + "202": accepted/pending; terminal result is correlated by action_id + "400": invalid action parameters; no simulator actuation + "402": payment required or invalid payment evidence + "403": skill not allowed or wrong robot + "409": replay detected + "429": rate limited + "503": allowlist, replay store, or result channel unavailable + settlement: >- + Verification is fail-closed before action publication. Settlement occurs + only after a matching Atlas simulator result reports status success. + - name: get_action_status + method: GET + url: /action/{actionId}/status + resultCorrelation: [action_id, robot_id, skill_id, params_hash, idempotency_key] diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/payment-policy.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/payment-policy.yaml new file mode 100644 index 000000000..7ccd6ff26 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/payment-policy.yaml @@ -0,0 +1,26 @@ +schemaVersion: payment-policy.v1 +provider: x402 +network: eip155:84532 +asset: + symbol: USDC + address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + decimals: 6 +policies: + - skillId: wave_right_arm + required: true + priceUSDC: "0.001" + paymentHeader: PAYMENT-SIGNATURE + - skillId: stop + required: true + priceUSDC: "0.001" + paymentHeader: PAYMENT-SIGNATURE +payTo: "" +settlement: + facilitator: https://x402.org/facilitator + scheme: exact + settleOnStatus: success + rule: >- + Settlement is deferred until a matching robot/tunnel/result reports a + correlated success. Rejection, simulator failure, timeout, an interrupted + wave, or replay never settles. A separately paid stop request may settle + only after its own correlated safe-stop success result. diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/robot.profile.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/robot.profile.yaml new file mode 100644 index 000000000..df96ae902 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/robot.profile.yaml @@ -0,0 +1,33 @@ +schemaVersion: robot-profile.v1 +vendor: boston-dynamics +robotModel: atlas-drc-v4-legacy +robotType: humanoid +profileId: boston-dynamics.atlas-drc.mujoco-webots-wave.v1 +profileVersion: 1.0.0 +scope: simulator-only +runtime: + transport: zenoh + actionTopic: robot/tunnel/action + resultTopic: robot/tunnel/result + metricsTopic: robot/boston_dynamics_atlas_drc/metrics + readyTopic: robot/boston_dynamics_atlas_drc/ready + bridge: bridge/boston_dynamics/atlas_drc_bridge +simulation: + scope: simulator-only + primaryEngine: MuJoCo 3.3.0 + validationEngine: Webots R2025a + model: + label: Boston Dynamics Atlas DRC v4 legacy + source: https://github.com/openai/roboschool + commit: d32bcb2b35b94168b5ce27233ca62f3c8678886f + directory: roboschool/models_robot/atlas_description + license: MIT + limitation: >- + This profile does not represent the current electric Atlas product. It + uses public legacy/DARPA Atlas DRC assets in both simulators. The public + DRC/v4 URDF has 30 movable one-DoF joints, while Boston Dynamics reports + 56 DoF and continuous joint range for electric Atlas; no joint-level + compatibility is claimed. +maintainers: + - github: RobotDeveloper1 +status: experimental diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json new file mode 100644 index 000000000..bb0079b68 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skill-catalog.json @@ -0,0 +1,20 @@ +[ + { + "skill_id": "wave_right_arm", + "description": "Run a bounded closed-loop right-arm wave in the Atlas DRC legacy simulator.", + "payment_required": true, + "price_usdc": "0.001", + "params": { + "cycles": {"type": "integer", "minimum": 1, "maximum": 3}, + "amplitudeRad": {"type": "number", "minimum": 0.15, "maximum": 0.4}, + "maxDurationSec": {"type": "number", "minimum": 5, "maximum": 15} + } + }, + { + "skill_id": "stop", + "description": "Safely stop the active Atlas DRC simulation.", + "payment_required": true, + "price_usdc": "0.001", + "params": {} + } +] diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skills.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skills.yaml new file mode 100644 index 000000000..692991c96 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/skills.yaml @@ -0,0 +1,37 @@ +schemaVersion: robot-skills.v1 +profileId: boston-dynamics.atlas-drc.mujoco-webots-wave.v1 +skills: + - skillId: wave_right_arm + description: >- + Execute a bounded, measured-state right-arm wave using a target-switching + PD torque controller in MuJoCo and the corresponding Webots motor policy. + params: + cycles: + type: integer + min: 1 + max: 3 + default: 2 + amplitudeRad: + type: number + min: 0.15 + max: 0.40 + default: 0.30 + maxDurationSec: + type: number + min: 5 + max: 15 + default: 8 + paymentRequired: true + priceUSDC: "0.001" + movementLimits: + maxCycles: 3 + maxShoulderStrokeRad: 0.80 + maxCommandedTorqueNm: 75 + maxDurationSec: 15 + - skillId: stop + description: >- + Safely interrupt an active Atlas episode, clear commanded torque and + zero simulated joint velocities before issuing a correlated result. + params: {} + paymentRequired: true + priceUSDC: "0.001" diff --git a/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/tests/skill-contract.test.yaml b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/tests/skill-contract.test.yaml new file mode 100644 index 000000000..825283e12 --- /dev/null +++ b/registry/vendors/boston-dynamics/atlas/boston-dynamics.atlas-drc.mujoco-webots-wave.v1/tests/skill-contract.test.yaml @@ -0,0 +1,18 @@ +profileId: boston-dynamics.atlas-drc.mujoco-webots-wave.v1 +cases: + - name: bounded-wave + action: wave_right_arm + params: {cycles: 2, amplitudeRad: 0.30, maxDurationSec: 8} + expected: accepted + - name: reject-unregistered-action + action: object_tracking + params: {} + expected: rejected + - name: reject-oversized-amplitude + action: wave_right_arm + params: {cycles: 2, amplitudeRad: 0.41, maxDurationSec: 8} + expected: rejected + - name: safe-stop + action: stop + params: {} + expected: accepted diff --git a/tunnel/.env.example b/tunnel/.env.example index 110703d33..f485e6ba7 100644 --- a/tunnel/.env.example +++ b/tunnel/.env.example @@ -6,17 +6,34 @@ FACILITATOR_URL=https://x402.org/facilitator # gin log verbosity: "release" or "debug". GIN_MODE=debug +# Robot-scoped deployment values. Keep the checked-in config.json generic; +# set these in an untracked .env or your secret/config manager instead. +# ROBOT_ID=your-robot-id +# ROBO_PAYEE_ADDRESS=0xYourPayeeAddress +# ROBO_PRICE=0.001 +# ROBO_NETWORK=eip155:84532 + +# Required fail-closed action contract. This JSON file belongs to the robot +# profile and declares the skills, parameters, and limits that may be +# published to Zenoh. No actions are enabled when either setting is absent. +# SKILL_CATALOG_PATH=../registry/vendors/vendor/model/profile/skill-catalog.json +# ALLOWED_ACTIONS=skill_one,skill_two,stop +# IDEMPOTENCY_STORE_PATH=./artifacts/robopay_idempotency.json + # Chain preset: bsc-testnet | bsc-mainnet | base-sepolia | base-mainnet. # Sets both the x402 payment network and the AIP registration chain. When -# unset, payments use config.json "network" (default Base mainnet) and AIP +# unset, payments use config.json "network" (the checked-in example is Base +# Sepolia) and AIP # registration uses BSC testnet. Make sure FACILITATOR_URL supports the # selected network. CHAIN=base-sepolia # ── BitAgent / Unibase AIP registration ─────────────────────────────────── -# Master switch. When true the robot registers itself as an A2A agent on the -# AIP (BitAgent) network via the gateway. Requires the vars below. -AIP_ENABLED=true +# Master switch. When true the robot registers itself as an A2A discovery +# agent on the AIP (BitAgent) network via the gateway. Direct AIP actions are +# deliberately rejected: only the Tunnel's x402-verified action endpoint may +# publish to Zenoh. Requires the vars below. +AIP_ENABLED=false # Bearer token for registration — the platform resolves your account from # it. OPTIONAL: when unset, the tunnel opens an interactive browser diff --git a/tunnel/cmd/main.go b/tunnel/cmd/main.go index 76c962ef2..6071ecac6 100644 --- a/tunnel/cmd/main.go +++ b/tunnel/cmd/main.go @@ -4,8 +4,13 @@ import ( "context" "encoding/json" "flag" + "fmt" + "net/http" "os" "os/signal" + "strconv" + "strings" + "sync" "syscall" "time" @@ -18,7 +23,8 @@ import ( x402 "github.com/x402-foundation/x402/go" x402http "github.com/x402-foundation/x402/go/http" ginmw "github.com/x402-foundation/x402/go/http/gin" - evm "github.com/x402-foundation/x402/go/mechanisms/evm/exact/server" + evm "github.com/x402-foundation/x402/go/mechanisms/evm" + evmexact "github.com/x402-foundation/x402/go/mechanisms/evm/exact/server" "go.uber.org/zap" "github.com/fabricfoundation/tunnel/config" @@ -65,7 +71,7 @@ func main() { logger.Info("unibase authorization ready", zap.String("wallet", wallet)) } - session, err := zenoh.Open(zenoh.NewConfigDefault(), nil) + session, err := handlers.OpenZenohSession() if err != nil { logger.Fatal("failed to open zenoh session", zap.Error(err)) } @@ -84,30 +90,66 @@ func main() { sub, err := session.DeclareSubscriber(ke, zenoh.Closure[zenoh.Sample]{ Call: func(sample zenoh.Sample) { var partialCfg struct { - EVMPayeeAddress *string `json:"evm_payee_address"` - Price *string `json:"price"` - Network *string `json:"network"` + EVMPayeeAddress *string `json:"evm_payee_address"` + Price *string `json:"price"` + Network *string `json:"network"` + TokenAddress *string `json:"token_address"` + TokenName *string `json:"token_name"` + TokenVersion *string `json:"token_version"` + TokenDecimals *int `json:"token_decimals"` + TokenTransferMethod *string `json:"token_transfer_method"` + TokenSupportsEIP2612 *bool `json:"token_supports_eip2612"` } if err := json.Unmarshal(sample.Payload().Bytes(), &partialCfg); err != nil { logger.Warn("failed to parse config update", zap.Error(err)) return } + candidate := *cfg updated := false - if partialCfg.EVMPayeeAddress != nil && *partialCfg.EVMPayeeAddress != cfg.EVMPayeeAddress { - cfg.EVMPayeeAddress = *partialCfg.EVMPayeeAddress + if partialCfg.EVMPayeeAddress != nil && *partialCfg.EVMPayeeAddress != candidate.EVMPayeeAddress { + candidate.EVMPayeeAddress = *partialCfg.EVMPayeeAddress updated = true } - if partialCfg.Price != nil && *partialCfg.Price != cfg.Price { - cfg.Price = *partialCfg.Price + if partialCfg.Price != nil && *partialCfg.Price != candidate.Price { + candidate.Price = *partialCfg.Price updated = true } - if partialCfg.Network != nil && *partialCfg.Network != cfg.Network { - cfg.Network = *partialCfg.Network + if partialCfg.Network != nil && *partialCfg.Network != candidate.Network { + candidate.Network = *partialCfg.Network + updated = true + } + if partialCfg.TokenAddress != nil && *partialCfg.TokenAddress != candidate.TokenAddress { + candidate.TokenAddress = *partialCfg.TokenAddress + updated = true + } + if partialCfg.TokenName != nil && *partialCfg.TokenName != candidate.TokenName { + candidate.TokenName = *partialCfg.TokenName + updated = true + } + if partialCfg.TokenVersion != nil && *partialCfg.TokenVersion != candidate.TokenVersion { + candidate.TokenVersion = *partialCfg.TokenVersion + updated = true + } + if partialCfg.TokenDecimals != nil && *partialCfg.TokenDecimals != candidate.TokenDecimals { + candidate.TokenDecimals = *partialCfg.TokenDecimals + updated = true + } + if partialCfg.TokenTransferMethod != nil && *partialCfg.TokenTransferMethod != candidate.TokenTransferMethod { + candidate.TokenTransferMethod = *partialCfg.TokenTransferMethod + updated = true + } + if partialCfg.TokenSupportsEIP2612 != nil && *partialCfg.TokenSupportsEIP2612 != candidate.TokenSupportsEIP2612 { + candidate.TokenSupportsEIP2612 = *partialCfg.TokenSupportsEIP2612 updated = true } if updated { + if err := candidate.Validate(); err != nil { + logger.Warn("rejecting invalid config update", zap.Error(err)) + return + } + *cfg = candidate logger.Info("config updated via zenoh, signaling restart") select { case restartCh <- struct{}{}: @@ -128,7 +170,14 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer cancel() - aipSrv := aipagent.Build(cfg, handlers.PublishRobotAction, logger) + // AIP job input does not carry the Tunnel-verified x402 context, complete + // correlation tuple, or durable replay reservation. It must therefore + // never publish directly to Zenoh. Keep discovery/registration available + // but fail direct job execution closed until the shared gateway can forward + // a verified paid ActionEvent through the same PostAction contract. + aipSrv := aipagent.Build(cfg, func(_ []byte) error { + return fmt.Errorf("direct AIP action execution is disabled; use the paid Tunnel action endpoint") + }, logger) if aipSrv != nil { go func() { if err := aipSrv.Run(ctx); err != nil { @@ -140,6 +189,9 @@ func main() { for { router := setupRouter(cfg, aipSrv, logger) client := internal.NewClient(cfg.ProxyWSURL, cfg.RobotID, router, logger) + // The current shared protocol supplies the configured robot ID on this + // outbound connection. A signed robot-to-payee handshake is an upstream + // Gateway/Tunnel dependency; the simulator bridge never receives a key. clientCtx, clientCancel := context.WithCancel(ctx) @@ -162,8 +214,73 @@ func main() { } } +// pristineNetworkConfigs is x402's built-in asset table, captured before any +// deployment override. It makes hot reloads reversible when token_address is +// removed or the selected network changes. +var pristineNetworkConfigs = func() map[string]evm.NetworkConfig { + snapshot := make(map[string]evm.NetworkConfig, len(evm.NetworkConfigs)) + for network, cfg := range evm.NetworkConfigs { + snapshot[network] = cfg + } + return snapshot +}() + +var registeredNetwork string + +func restoreNetworkDefault(network string) { + if original, ok := pristineNetworkConfigs[network]; ok { + evm.NetworkConfigs[network] = original + return + } + delete(evm.NetworkConfigs, network) +} + +// registerTokenAsset preserves the custom-token support from main while +// allowing the execution-gated payment flow below to use the same config. +func registerTokenAsset(cfg *config.Config, logger *zap.Logger) { + if registeredNetwork != "" && registeredNetwork != cfg.Network { + restoreNetworkDefault(registeredNetwork) + registeredNetwork = "" + } + if cfg.TokenAddress == "" { + restoreNetworkDefault(cfg.Network) + registeredNetwork = "" + return + } + chainID, ok := cfg.ChainID() + if !ok { + logger.Warn("skipping token registration for non-eip155 network", zap.String("network", cfg.Network)) + return + } + asset := evm.AssetInfo{ + Address: cfg.TokenAddress, + Name: cfg.TokenName, + Version: cfg.TokenVersion, + Decimals: cfg.TokenDecimals, + } + if cfg.TokenTransferMethod == config.TransferMethodPermit2 { + asset.AssetTransferMethod = evm.AssetTransferMethodPermit2 + asset.SupportsEip2612 = cfg.TokenSupportsEIP2612 + } + evm.NetworkConfigs[cfg.Network] = evm.NetworkConfig{ + ChainID: chainID, + DefaultAsset: asset, + } + registeredNetwork = cfg.Network + logger.Info("registered payment token", + zap.String("network", cfg.Network), + zap.String("address", cfg.TokenAddress), + zap.String("name", cfg.TokenName), + zap.Int("decimals", cfg.TokenDecimals), + zap.String("transfer_method", cfg.TokenTransferMethod), + zap.Bool("supports_eip2612", cfg.TokenSupportsEIP2612), + ) +} + func setupRouter(cfg *config.Config, aipSrv *aipserver.Server, logger *zap.Logger) *gin.Engine { + registerTokenAsset(cfg, logger) router := gin.New() + router.Use(requestRateLimit()) router.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, @@ -180,7 +297,10 @@ func setupRouter(cfg *config.Config, aipSrv *aipserver.Server, logger *zap.Logge "PAYMENT-REQUIRED", "PAYMENT-RESPONSE", }, - AllowCredentials: true, + // Auth is carried by the PAYMENT-SIGNATURE header (x402), never by cookies. + // With a wildcard origin the CORS spec forbids credentialed requests, and + // enabling both is silently rejected by browsers — so we keep it disabled. + AllowCredentials: false, MaxAge: 12 * time.Hour, })) @@ -203,16 +323,47 @@ func setupRouter(cfg *config.Config, aipSrv *aipserver.Server, logger *zap.Logge }, } - router.Use(ginmw.X402Payment(ginmw.Config{ - Routes: routes, - Facilitator: facilitatorClient, - Schemes: []ginmw.SchemeConfig{ - {Network: x402.Network(cfg.Network), Server: evm.NewExactEvmScheme()}, - }, - Timeout: 30 * time.Second, - })) + // The stock gin middleware settles as soon as the handler returns < 400, + // which is incompatible with the immediate accepted/pending contract: a + // 202 would settle before the simulator ran. The gate below performs the + // same 402/verify handling synchronously but defers settlement to the + // handler's execution watcher, which settles only after simulator success. + paymentServer := x402http.Newx402HTTPResourceServer(routes, + x402.WithFacilitatorClient(facilitatorClient)) + paymentServer.Register(x402.Network(cfg.Network), evmexact.NewExactEvmScheme()) + { + initCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := paymentServer.Initialize(initCtx); err != nil { + logger.Warn("failed to initialize x402 payment server", zap.Error(err)) + } + cancel() + } + router.Use(deferredSettlementGate(paymentServer, logger)) - h := handlers.NewHandlers(logger) + h := handlers.NewHandlersForRobot(logger, cfg.RobotID) + catalog, catalogErr := handlers.LoadSkillCatalog(os.Getenv("SKILL_CATALOG_PATH"), cfg.Price) + if catalogErr != nil { + logger.Warn("skill catalog unavailable; refusing all paid actions", zap.Error(catalogErr)) + } else { + h.SkillCatalog = catalog + } + rawAllowedSkills, configured := os.LookupEnv("ALLOWED_ACTIONS") + h.AllowedSkills = allowedSkillsFromEnv(rawAllowedSkills, configured, h.KnownSkillIDs()) + if configured { + if len(h.AllowedSkills) == 0 { + logger.Warn("ALLOWED_ACTIONS is empty or contains no registered skills; refusing all actions") + } + } else { + // The public action route must not acquire an implicit capability merely + // because this binary knows about a profile. Without an explicit + // deployment allowlist, the handler returns ALLOWLIST_NOT_CONFIGURED. + logger.Warn("ALLOWED_ACTIONS not set; refusing all actions") + } + if raw := os.Getenv("MAX_ACTION_DURATION_SECONDS"); raw != "" { + if seconds, err := strconv.ParseFloat(raw, 64); err == nil && seconds > 0 { + h.MaxDurationSeconds = seconds + } + } RegisterAllRoutes(router, h) // Serve the AIP A2A contract (/.well-known/agent-card.json, /invoke, ...) @@ -224,7 +375,166 @@ func setupRouter(cfg *config.Config, aipSrv *aipserver.Server, logger *zap.Logge return router } +// allowedSkillsFromEnv preserves the distinction between an absent setting and +// an explicitly empty one. Both fail closed, but the nil result makes it clear +// that no deployment allowlist was provided at all. +func allowedSkillsFromEnv(raw string, configured bool, known map[string]struct{}) map[string]struct{} { + if !configured { + return nil + } + return parseAllowedSkills(raw, known) +} + +// parseAllowedSkills turns the deployment registration/allowlist into the +// exact set the handler enforces and advertises. Values not declared by the +// loaded robot-scoped catalog are discarded, so an environment typo cannot +// create a new actuator capability. +func parseAllowedSkills(raw string, known map[string]struct{}) map[string]struct{} { + allowed := make(map[string]struct{}) + for _, skill := range strings.Split(raw, ",") { + if skill = strings.TrimSpace(skill); skill != "" { + if _, registered := known[skill]; !registered { + continue + } + allowed[skill] = struct{}{} + } + } + return allowed +} + +type rateLimitEntry struct { + windowStart time.Time + count int +} + +var rateLimitState = struct { + sync.Mutex + clients map[string]rateLimitEntry + lastSweep time.Time +}{clients: make(map[string]rateLimitEntry)} + +func requestRateLimit() gin.HandlerFunc { + limit := 60 + if raw := os.Getenv("ACTION_RATE_LIMIT_RPM"); raw != "" { + if configured, err := strconv.Atoi(raw); err == nil && configured > 0 { + limit = configured + } + } + return func(c *gin.Context) { + client := c.ClientIP() + now := time.Now() + rateLimitState.Lock() + // Evict windows older than one minute at most once per minute so the + // client map cannot grow unbounded with one-off IPs. + if now.Sub(rateLimitState.lastSweep) >= time.Minute { + for ip, e := range rateLimitState.clients { + if now.Sub(e.windowStart) >= time.Minute { + delete(rateLimitState.clients, ip) + } + } + rateLimitState.lastSweep = now + } + entry := rateLimitState.clients[client] + if entry.windowStart.IsZero() || now.Sub(entry.windowStart) >= time.Minute { + entry = rateLimitEntry{windowStart: now} + } + entry.count++ + rateLimitState.clients[client] = entry + allowed := entry.count <= limit + rateLimitState.Unlock() + if !allowed { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "action rate limit exceeded", + "error_code": "RATE_LIMITED", + }) + return + } + c.Next() + } +} + // RegisterAllRoutes registers all real handlers on the router. func RegisterAllRoutes(router *gin.Engine, h *handlers.Handlers) { + router.GET("/robot", h.GetRobotProfile) + router.GET("/skills", h.GetSkills) router.POST("/action", h.PostAction) + router.GET("/action/:action_id/status", h.GetActionStatus) +} + +// deferredSettlementGate is the execution-gated replacement for the stock +// x402 gin middleware. It answers 402 for unpaid requests and verifies paid +// ones synchronously, but instead of settling on response it injects a +// handlers.SettleFunc into the context; the action handler invokes it only +// after the correlated simulator result reports success. +func deferredSettlementGate(server *x402http.HTTPServer, logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + reqCtx := x402http.HTTPRequestContext{ + Adapter: ginmw.NewGinAdapter(c), + Path: c.Request.URL.Path, + Method: c.Request.Method, + } + if !server.RequiresPayment(reqCtx) { + c.Next() + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + result := server.ProcessHTTPRequest(ctx, reqCtx, nil) + + switch result.Type { + case x402http.ResultNoPaymentRequired: + c.Next() + case x402http.ResultPaymentError: + for key, value := range result.Response.Headers { + c.Header(key, value) + } + if result.Response.IsHTML { + c.Data(result.Response.Status, "text/html; charset=utf-8", []byte(result.Response.Body.(string))) + } else { + c.JSON(result.Response.Status, result.Response.Body) + } + c.Abort() + case x402http.ResultPaymentVerified: + if result.PaymentPayload == nil || result.PaymentRequirements == nil { + logger.Warn("verified payment missing payload/requirements; refusing") + c.AbortWithStatusJSON(http.StatusPaymentRequired, gin.H{"error": "payment verification incomplete"}) + return + } + c.Set("x402_payload", *result.PaymentPayload) + c.Set("x402_requirements", *result.PaymentRequirements) + // Capture verified payment data by value: the settle callback + // runs after this request context is recycled by gin. + payload := *result.PaymentPayload + requirements := *result.PaymentRequirements + declared := result.DeclaredExtensions + var settle handlers.SettleFunc = func(settleCtx context.Context) (*handlers.SettlementRecord, error) { + settleResult := server.ProcessSettlement(settleCtx, payload, requirements, nil, nil, declared) + if settleResult == nil { + return nil, fmt.Errorf("settlement returned no result") + } + if !settleResult.Success { + reason := settleResult.ErrorReason + if reason == "" { + reason = "settlement failed" + } + return nil, fmt.Errorf("%s", reason) + } + record := &handlers.SettlementRecord{ + Transaction: settleResult.Transaction, + Network: string(settleResult.Network), + Payer: settleResult.Payer, + } + for key, value := range settleResult.Headers { + if strings.EqualFold(key, "PAYMENT-RESPONSE") { + record.PaymentResponse = value + } + } + return record, nil + } + c.Set("x402_settle", settle) + logger.Debug("payment verified; settlement deferred until simulator success") + c.Next() + } + } } diff --git a/tunnel/cmd/main_test.go b/tunnel/cmd/main_test.go new file mode 100644 index 000000000..8f5814b2d --- /dev/null +++ b/tunnel/cmd/main_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + x402 "github.com/x402-foundation/x402/go" + x402http "github.com/x402-foundation/x402/go/http" + "github.com/x402-foundation/x402/go/types" + "go.uber.org/zap" +) + +func TestRequestRateLimitReturns429(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("ACTION_RATE_LIMIT_RPM", "2") + + router := gin.New() + router.Use(requestRateLimit()) + router.GET("/action", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + for requestNumber := 1; requestNumber <= 3; requestNumber++ { + request := httptest.NewRequest(http.MethodGet, "/action", nil) + request.RemoteAddr = "198.51.100.10:12345" + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + expected := http.StatusOK + if requestNumber == 3 { + expected = http.StatusTooManyRequests + } + if response.Code != expected { + t.Fatalf("request %d: expected HTTP %d, got %d", requestNumber, expected, response.Code) + } + } +} + +func TestParseAllowedSkills(t *testing.T) { + known := map[string]struct{}{"navigate_obstacle_course": {}, "stop": {}} + allowed := parseAllowedSkills(" navigate_obstacle_course, stop, INVALID-SKILL!, navigate_obstacle_course, ", known) + + if len(allowed) != 2 { + t.Fatalf("expected two configured skills, got %d", len(allowed)) + } + for _, skill := range []string{"navigate_obstacle_course", "stop"} { + if _, ok := allowed[skill]; !ok { + t.Errorf("expected %q to be allowed", skill) + } + } +} + +func TestParseAllowedSkillsIsRobotAgnostic(t *testing.T) { + allowed := parseAllowedSkills("look_at_apple", map[string]struct{}{"look_at_apple": {}}) + if _, ok := allowed["look_at_apple"]; !ok { + t.Fatal("expected a robot profile skill to be registered without shared-code changes") + } +} + +func TestParseAllowedSkillsEmptyFailsClosed(t *testing.T) { + if allowed := parseAllowedSkills(" , ", map[string]struct{}{"stop": {}}); len(allowed) != 0 { + t.Fatalf("expected an empty allowlist, got %d skills", len(allowed)) + } +} + +func TestAllowedSkillsFromUnsetEnvFailsClosed(t *testing.T) { + if allowed := allowedSkillsFromEnv("", false, map[string]struct{}{"stop": {}}); allowed != nil { + t.Fatalf("expected no allowlist when ALLOWED_ACTIONS is unset, got %d skills", len(allowed)) + } +} + +// TestDeferredSettlementGateRejectsInvalidFacilitatorVerification proves the +// action boundary remains closed when a reachable facilitator returns either a +// nil response or HTTP-successful isValid:false verdict. The handler must +// never run and settlement must never be attempted. +func TestDeferredSettlementGateRejectsInvalidFacilitatorVerification(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + verification *x402.VerifyResponse + }{ + {name: "nil verification response", verification: nil}, + { + name: "facilitator rejects tampered signature", + verification: &x402.VerifyResponse{ + IsValid: false, + InvalidReason: "reviewer-tampered-payment", + InvalidMessage: "signature does not match authorization", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + facilitator := &invalidVerificationFacilitator{verification: test.verification} + paymentServer := x402http.Newx402HTTPResourceServer( + x402http.RoutesConfig{ + "POST /action": { + Accepts: x402http.PaymentOptions{{ + Scheme: "exact", + Price: "$0.001", + Network: x402.Network("eip155:84532"), + PayTo: "0x1111111111111111111111111111111111111111", + }}, + }, + }, + x402.WithFacilitatorClient(facilitator), + ) + paymentServer.Register(x402.Network("eip155:84532"), invalidVerificationTestScheme{}) + if err := paymentServer.Initialize(context.Background()); err != nil { + t.Fatalf("initialize payment server: %v", err) + } + + router := gin.New() + router.Use(deferredSettlementGate(paymentServer, zap.NewNop())) + handlerEntries := 0 + router.POST("/action", func(c *gin.Context) { + handlerEntries++ + c.Status(http.StatusAccepted) + }) + + payload := x402.PaymentPayload{ + X402Version: 2, + Payload: map[string]interface{}{"authorization": "tampered-signature"}, + Accepted: x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:84532", + Asset: "USDC", + Amount: "1000", + PayTo: "0x1111111111111111111111111111111111111111", + MaxTimeoutSeconds: 60, + }, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payment payload: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/action", nil) + request.Header.Set("PAYMENT-SIGNATURE", base64.StdEncoding.EncodeToString(payloadJSON)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusPaymentRequired { + t.Fatalf("expected HTTP %d, got %d", http.StatusPaymentRequired, response.Code) + } + if facilitator.verifyCalls != 1 { + t.Fatalf("expected exactly one verification call, got %d", facilitator.verifyCalls) + } + if handlerEntries != 0 { + t.Fatalf("invalid payment reached action handler %d time(s)", handlerEntries) + } + if facilitator.settleCalls != 0 { + t.Fatalf("invalid payment triggered %d settlement call(s)", facilitator.settleCalls) + } + }) + } +} + +type invalidVerificationTestScheme struct{} + +func (invalidVerificationTestScheme) Scheme() string { return "exact" } + +func (invalidVerificationTestScheme) ParsePrice(x402.Price, x402.Network) (x402.AssetAmount, error) { + return x402.AssetAmount{Asset: "USDC", Amount: "1000"}, nil +} + +func (invalidVerificationTestScheme) EnhancePaymentRequirements( + _ context.Context, + requirements types.PaymentRequirements, + _ types.SupportedKind, + _ []string, +) (types.PaymentRequirements, error) { + return requirements, nil +} + +type invalidVerificationFacilitator struct { + verification *x402.VerifyResponse + verifyCalls int + settleCalls int +} + +func (f *invalidVerificationFacilitator) Verify(_ context.Context, _, _ []byte) (*x402.VerifyResponse, error) { + f.verifyCalls++ + return f.verification, nil +} + +func (f *invalidVerificationFacilitator) Settle(_ context.Context, _, _ []byte) (*x402.SettleResponse, error) { + f.settleCalls++ + return &x402.SettleResponse{Success: true}, nil +} + +func (f *invalidVerificationFacilitator) GetSupported(context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{{ + X402Version: 2, + Scheme: "exact", + Network: "eip155:84532", + }}, + Signers: map[string][]string{}, + }, nil +} diff --git a/tunnel/config.example.json b/tunnel/config.example.json new file mode 100644 index 000000000..b2556c525 --- /dev/null +++ b/tunnel/config.example.json @@ -0,0 +1,6 @@ +{ + "robot_id": "example-robot", + "evm_payee_address": "0x0000000000000000000000000000000000000000", + "price": "0.001", + "network": "eip155:84532" +} diff --git a/tunnel/config.json b/tunnel/config.json index a3b7fedb4..b2556c525 100644 --- a/tunnel/config.json +++ b/tunnel/config.json @@ -1,6 +1,6 @@ { - "robot_id": "test-robot", - "evm_payee_address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "price": "$0.002", + "robot_id": "example-robot", + "evm_payee_address": "0x0000000000000000000000000000000000000000", + "price": "0.001", "network": "eip155:84532" } diff --git a/tunnel/config/config.go b/tunnel/config/config.go index be5450a38..e5e878dd0 100644 --- a/tunnel/config/config.go +++ b/tunnel/config/config.go @@ -3,12 +3,11 @@ package config import ( "encoding/json" "fmt" + "math/big" "os" "regexp" "strconv" "strings" - - "github.com/google/uuid" ) const ( @@ -19,6 +18,14 @@ const ( DefaultAIPGatewayURL = "https://gateway.aip.unibase.com" DefaultAIPChainID = 97 DefaultAIPLocalPort = 8000 + + EIP155Prefix = "eip155:" + DefaultTokenVersion = "1" + DefaultTokenDecimals = 6 + + TransferMethodEIP3009 = "eip3009" + TransferMethodPermit2 = "permit2" + zeroEVMAddress = "0x0000000000000000000000000000000000000000" ) func getEnvOrDefault(key, defaultVal string) string { @@ -29,12 +36,18 @@ func getEnvOrDefault(key, defaultVal string) string { } type Config struct { - RobotID string `json:"robot_id"` - EVMPayeeAddress string `json:"evm_payee_address"` - Price string `json:"price"` - Network string `json:"network"` - ProxyWSURL string `json:"-"` - FacilitatorURL string `json:"-"` + RobotID string `json:"robot_id"` + EVMPayeeAddress string `json:"evm_payee_address"` + Price string `json:"price"` + Network string `json:"network"` + TokenAddress string `json:"token_address"` + TokenName string `json:"token_name"` + TokenVersion string `json:"token_version"` + TokenDecimals int `json:"token_decimals"` + TokenTransferMethod string `json:"token_transfer_method"` + TokenSupportsEIP2612 bool `json:"token_supports_eip2612"` + ProxyWSURL string `json:"-"` + FacilitatorURL string `json:"-"` // aIP AIPEnabled bool `json:"-"` @@ -66,8 +79,9 @@ func (c *Config) AIPEndpointURL() string { } var ( - priceRegex = regexp.MustCompile(`^\$\d+(\.\d+)?$`) + priceRegex = regexp.MustCompile(`^\$?\d+(\.\d+)?$`) networkRegex = regexp.MustCompile(`^[a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$`) + addressRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) ) // chainPresets are the networks selectable via the CHAIN env var. A preset @@ -83,6 +97,99 @@ var chainPresets = map[string]struct { "base-mainnet": {"eip155:8453", 8453}, } +// ChainID returns the EIP-155 chain ID of the configured network. +// The second return value is false when the network is not an eip155 CAIP-2 ID. +func (c *Config) ChainID() (*big.Int, bool) { + if !strings.HasPrefix(c.Network, EIP155Prefix) { + return nil, false + } + return new(big.Int).SetString(strings.TrimPrefix(c.Network, EIP155Prefix), 10) +} + +// Validate checks the user-supplied fields and fills in defaults. It is safe to call on a +// candidate copy of a Config to vet a hot-reload update before committing it. +func (c *Config) Validate() error { + if strings.TrimSpace(c.RobotID) == "" { + // A generated ID breaks the robot-scoped action and payment binding on + // every restart. Deployments must provide a stable identity explicitly. + return fmt.Errorf("robot_id is required (set ROBOT_ID or config.json)") + } + + if c.Price == "" { + c.Price = "0.001" + } + if !priceRegex.MatchString(c.Price) { + return fmt.Errorf("invalid price format: %q, expected a decimal amount like 0.001 or $0.001", c.Price) + } + + if c.Network == "" { + c.Network = "eip155:8453" + } + if !networkRegex.MatchString(c.Network) { + return fmt.Errorf("invalid network format: %q, expected format like eip155:8453", c.Network) + } + + if c.EVMPayeeAddress == "" { + return fmt.Errorf("evm_payee_address is required") + } + if strings.EqualFold(c.EVMPayeeAddress, zeroEVMAddress) { + return fmt.Errorf("evm_payee_address must not be the zero address") + } + + return c.validateToken() +} + +// validateToken checks the optional token fields. They are only meaningful together: an empty +// token_address means "use whatever default asset x402 knows for this network". +func (c *Config) validateToken() error { + if c.TokenAddress == "" { + return nil + } + + if !addressRegex.MatchString(c.TokenAddress) { + return fmt.Errorf("invalid token_address format: %q, expected a 0x-prefixed 20-byte hex address", c.TokenAddress) + } + if _, ok := c.ChainID(); !ok { + return fmt.Errorf("token_address requires an eip155 network, got %q", c.Network) + } + if c.TokenDecimals < 0 || c.TokenDecimals > 36 { + return fmt.Errorf("invalid token_decimals: %d, expected 0-36", c.TokenDecimals) + } + + switch c.TokenTransferMethod { + case "": + c.TokenTransferMethod = TransferMethodEIP3009 + case TransferMethodEIP3009, TransferMethodPermit2: + default: + return fmt.Errorf("invalid token_transfer_method: %q, expected %q or %q", + c.TokenTransferMethod, TransferMethodEIP3009, TransferMethodPermit2) + } + + if c.TokenSupportsEIP2612 && c.TokenTransferMethod != TransferMethodPermit2 { + return fmt.Errorf("token_supports_eip2612 only applies when token_transfer_method is %q", TransferMethodPermit2) + } + + if c.NeedsEIP712Domain() && c.TokenName == "" { + return fmt.Errorf("token_name is required for %s transfers (it forms the EIP-712 domain the payer signs)", + c.TokenTransferMethod) + } + + if c.TokenVersion == "" { + c.TokenVersion = DefaultTokenVersion + } + if c.TokenDecimals == 0 { + c.TokenDecimals = DefaultTokenDecimals + } + + return nil +} + +// NeedsEIP712Domain reports whether the payer will sign against the token's own EIP-712 domain, +// which is what makes token_name and token_version load-bearing. +func (c *Config) NeedsEIP712Domain() bool { + return c.TokenTransferMethod != TransferMethodPermit2 || c.TokenSupportsEIP2612 +} + func LoadConfig(path string) (*Config, error) { file, err := os.ReadFile(path) if err != nil { @@ -93,21 +200,12 @@ func LoadConfig(path string) (*Config, error) { if err := json.Unmarshal(file, &cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } + applyDeploymentOverrides(&cfg) cfg.ProxyWSURL = getEnvOrDefault("PROXY_WS_URL", DefaultProxyWSURL) cfg.FacilitatorURL = getEnvOrDefault("FACILITATOR_URL", DefaultFacilitatorURL) - if cfg.RobotID == "" { - cfg.RobotID = uuid.NewString() - } - - if cfg.Price == "" { - cfg.Price = "$0.001" - } - if !priceRegex.MatchString(cfg.Price) { - return nil, fmt.Errorf("invalid price format: %q, expected format like $0.001", cfg.Price) - } - + // CHAIN overrides the configured network, so it has to be applied before validation. defaultChainID := DefaultAIPChainID if chain := os.Getenv("CHAIN"); chain != "" { preset, ok := chainPresets[strings.ToLower(chain)] @@ -117,15 +215,9 @@ func LoadConfig(path string) (*Config, error) { cfg.Network = preset.Network defaultChainID = preset.ChainID } - if cfg.Network == "" { - cfg.Network = "eip155:8453" // Base mainnet CAIP-2 ID - } - if !networkRegex.MatchString(cfg.Network) { - return nil, fmt.Errorf("invalid network format: %q, expected format like eip155:8453", cfg.Network) - } - if cfg.EVMPayeeAddress == "" { - return nil, fmt.Errorf("evm_payee_address is required") + if err := cfg.Validate(); err != nil { + return nil, err } if err := loadAIPConfig(&cfg, defaultChainID); err != nil { @@ -135,6 +227,24 @@ func LoadConfig(path string) (*Config, error) { return &cfg, nil } +// applyDeploymentOverrides keeps robot-specific values out of the checked-in +// example config. A deployment can select its identity, payee, price, and +// network without editing a tracked file. +func applyDeploymentOverrides(cfg *Config) { + if value := strings.TrimSpace(os.Getenv("ROBOT_ID")); value != "" { + cfg.RobotID = value + } + if value := strings.TrimSpace(os.Getenv("ROBO_PAYEE_ADDRESS")); value != "" { + cfg.EVMPayeeAddress = value + } + if value := strings.TrimSpace(os.Getenv("ROBO_PRICE")); value != "" { + cfg.Price = value + } + if value := strings.TrimSpace(os.Getenv("ROBO_NETWORK")); value != "" { + cfg.Network = value + } +} + func loadAIPConfig(cfg *Config, defaultChainID int) error { cfg.AIPEnabled = getBoolEnv("AIP_ENABLED", false) diff --git a/tunnel/config/config_test.go b/tunnel/config/config_test.go new file mode 100644 index 000000000..bea0d69d3 --- /dev/null +++ b/tunnel/config/config_test.go @@ -0,0 +1,34 @@ +package config + +import "testing" + +func TestValidateRequiresStableRobotIdentityAndPayee(t *testing.T) { + missingRobot := Config{ + EVMPayeeAddress: "0x1111111111111111111111111111111111111111", + Price: "0.001", + Network: "eip155:84532", + } + if err := missingRobot.Validate(); err == nil { + t.Fatal("expected missing robot_id to fail closed") + } + + zeroPayee := Config{ + RobotID: "robot-a", + EVMPayeeAddress: zeroEVMAddress, + Price: "0.001", + Network: "eip155:84532", + } + if err := zeroPayee.Validate(); err == nil { + t.Fatal("expected zero payee address to fail closed") + } + + valid := Config{ + RobotID: "robot-a", + EVMPayeeAddress: "0x1111111111111111111111111111111111111111", + Price: "0.001", + Network: "eip155:84532", + } + if err := valid.Validate(); err != nil { + t.Fatalf("expected explicit deployment identity to validate: %v", err) + } +} diff --git a/tunnel/go.mod b/tunnel/go.mod index f0d37f1eb..031bccc16 100644 --- a/tunnel/go.mod +++ b/tunnel/go.mod @@ -10,7 +10,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/unibaseio/aip-go-sdk v0.0.0-20260716210644-024763def441 - github.com/x402-foundation/x402/go v0.0.0-20260512144511-7c239c42f5dd + github.com/x402-foundation/x402/go v0.0.0-20260529172747-45d81d46e5bd go.uber.org/zap v1.28.0 ) diff --git a/tunnel/go.sum b/tunnel/go.sum index 35bca2abe..30ef90010 100644 --- a/tunnel/go.sum +++ b/tunnel/go.sum @@ -235,8 +235,8 @@ github.com/unibaseio/aip-go-sdk v0.0.0-20260716210644-024763def441 h1:xRzw8oeVES github.com/unibaseio/aip-go-sdk v0.0.0-20260716210644-024763def441/go.mod h1:o+rJGVpI8UEWayqFQ8YyIXo2aJsrdU7gkN881V/GVHg= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/x402-foundation/x402/go v0.0.0-20260512144511-7c239c42f5dd h1:rSGTqN02wCjWtbUVt+Xu+4F+MoxPTEB7jo5QR/XQxb4= -github.com/x402-foundation/x402/go v0.0.0-20260512144511-7c239c42f5dd/go.mod h1:58Cdk20g83eAI3QvxAiQJze7qWUgkjCj9uZlPb4M4HM= +github.com/x402-foundation/x402/go v0.0.0-20260529172747-45d81d46e5bd h1:Bb+VbLsDEQ7g69MZNfUkOva2qKuB5TCSgGXXOPTB0Qw= +github.com/x402-foundation/x402/go v0.0.0-20260529172747-45d81d46e5bd/go.mod h1:58Cdk20g83eAI3QvxAiQJze7qWUgkjCj9uZlPb4M4HM= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/tunnel/internal/aipagent/agent.go b/tunnel/internal/aipagent/agent.go index 562cccb23..8f93f42a4 100644 --- a/tunnel/internal/aipagent/agent.go +++ b/tunnel/internal/aipagent/agent.go @@ -48,20 +48,21 @@ func Build(cfg *config.Config, publish PublishFunc, logger *zap.Logger) *server. zap.String("endpoint_url", endpointURL), ) - // The job offering is what makes the robot purchasable on the BitAgent - // marketplace: without it the agent is discoverable but no job can be - // created against it. Jobs arrive through the gateway job queue - // (ViaGateway) and land in the handler above. + // AIP registration remains useful for discovery, but direct AIP job input + // is not a Tunnel-verified x402 ActionEvent. Keep this offering inactive + // until the shared gateway supplies that verified envelope; otherwise a + // marketplace job could bypass the paid-action contract and publish to + // Zenoh without allowlist, correlation, or durable replay protection. price := cfg.PriceAmount() jobOfferings := []types.AgentJobOffering{{ ID: "robot_action", Name: "robot_action", - Description: "Execute a single action on the robot (e.g. a motion command). The command is forwarded through the Fabric RoboPay tunnel to the robot's onboard controller; the robot-side safety layer always has the final say.", + Description: "Reserved for Tunnel-verified paid actions. Direct AIP action execution is disabled until the shared gateway forwards the verified action envelope.", Type: "JOB", Price: price, PriceV2: map[string]any{"type": "fixed", "amount": price, "currency": "USDC"}, - JobInput: `JSON action command, e.g. {"action":"move","direction":"forward","distance_m":1.0}`, - JobOutput: `{"status":"accepted"} once the action is on the robot's command bus`, + JobInput: `A Tunnel-verified paid action envelope (not currently accepted directly by AIP).`, + JobOutput: `{"status":"error","error":"use paid Tunnel action endpoint"}`, Requirement: map[string]any{ "type": "object", "required": []string{"action"}, @@ -77,7 +78,7 @@ func Build(cfg *config.Config, publish PublishFunc, logger *zap.Logger) *server. }, }, SLAMinutes: 1, - Active: true, + Active: false, }} return wrappers.ExposeAsA2A(wrappers.ExposeOptions{ @@ -95,7 +96,7 @@ func Build(cfg *config.Config, publish PublishFunc, logger *zap.Logger) *server. Skills: []types.AgentSkillCard{{ ID: cfg.RobotID + "_robot_action", Name: "robot_action", - Description: "Execute motion commands on the physical robot", + Description: "Robot discovery; execution is available only through the paid Tunnel action endpoint", InputModes: []string{"text/plain", "application/json"}, OutputModes: []string{"application/json"}, }}, diff --git a/tunnel/internal/handlers/handlers.go b/tunnel/internal/handlers/handlers.go index 4aac8ec66..8d0d03373 100644 --- a/tunnel/internal/handlers/handlers.go +++ b/tunnel/internal/handlers/handlers.go @@ -1,9 +1,18 @@ package handlers import ( + "context" + "crypto/sha256" "encoding/json" + "errors" + "fmt" "io" + "math" "net/http" + "os" + "sort" + "strconv" + "strings" "sync" "time" @@ -13,9 +22,258 @@ import ( ) const ( - RobotActionTopic = "robot/tunnel/action" + RobotActionTopic = "robot/tunnel/action" + RobotResultTopic = "robot/tunnel/result" + defaultExecutionTimeout = 90 * time.Second ) +func configuredTopic(envName, fallback string) string { + if value := strings.TrimSpace(os.Getenv(envName)); value != "" { + return value + } + return fallback +} + +func configuredActionTopic() string { + return configuredTopic("ZENOH_ACTION_TOPIC", RobotActionTopic) +} + +func configuredResultTopic() string { + return configuredTopic("ZENOH_RESULT_TOPIC", RobotResultTopic) +} + +// executionTimeout is how long the background execution watcher waits for +// the correlated simulator result before recording a timeout outcome. +// EXECUTION_TIMEOUT_SECONDS overrides the 90s default so integration tests +// can exercise the timeout no-settlement path quickly. +func executionTimeout() time.Duration { + if raw := os.Getenv("EXECUTION_TIMEOUT_SECONDS"); raw != "" { + if seconds, err := strconv.ParseFloat(raw, 64); err == nil && seconds > 0 { + return time.Duration(seconds * float64(time.Second)) + } + } + return defaultExecutionTimeout +} + +// SkillMetadata is the public, read-only discovery representation returned +// before a payer authorizes an action. +type SkillMetadata struct { + SkillID string `json:"skill_id"` + Aliases []string `json:"aliases,omitempty"` + Description string `json:"description"` + PaymentRequired bool `json:"payment_required"` + PriceUSDC string `json:"price_usdc"` + Params map[string]ParamSchema `json:"params"` +} + +// ParamSchema is the small, strict subset of the profile schema enforced by +// the Tunnel before a paid event can be published to Zenoh. The schema lives +// in a robot-scoped JSON catalog; the Tunnel deliberately contains no +// robot-specific action names or limits. +type ParamSchema struct { + Type string `json:"type"` + Required bool `json:"required,omitempty"` + Values []string `json:"values,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Items *ParamSchema `json:"items,omitempty"` + MinItems *int `json:"min_items,omitempty"` + MaxItems *int `json:"max_items,omitempty"` + UniqueItems bool `json:"unique_items,omitempty"` +} + +// LoadSkillCatalog reads a deployment-selected, robot-scoped JSON catalog. +// A missing, malformed, or unsafe catalog is an error; callers must fail +// closed rather than fall back to a built-in robot profile. +func LoadSkillCatalog(path, price string) ([]SkillMetadata, error) { + path = strings.TrimSpace(path) + if path == "" { + return nil, fmt.Errorf("SKILL_CATALOG_PATH is required") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read skill catalog: %w", err) + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + var catalog []SkillMetadata + if err := decoder.Decode(&catalog); err != nil { + return nil, fmt.Errorf("decode skill catalog: %w", err) + } + if len(catalog) == 0 { + return nil, fmt.Errorf("skill catalog is empty") + } + seen := make(map[string]struct{}) + price = strings.TrimPrefix(strings.TrimSpace(price), "$") + for index := range catalog { + skill := &catalog[index] + skill.SkillID = strings.TrimSpace(skill.SkillID) + if !validSkillID(skill.SkillID) { + return nil, fmt.Errorf("invalid skill_id %q", skill.SkillID) + } + if _, duplicate := seen[skill.SkillID]; duplicate { + return nil, fmt.Errorf("duplicate skill_id %q", skill.SkillID) + } + seen[skill.SkillID] = struct{}{} + for aliasIndex, alias := range skill.Aliases { + alias = strings.TrimSpace(alias) + if !validSkillID(alias) { + return nil, fmt.Errorf("invalid alias %q for %q", alias, skill.SkillID) + } + if _, duplicate := seen[alias]; duplicate { + return nil, fmt.Errorf("duplicate skill alias %q", alias) + } + seen[alias] = struct{}{} + skill.Aliases[aliasIndex] = alias + } + if skill.Params == nil { + skill.Params = map[string]ParamSchema{} + } + for name, schema := range skill.Params { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("empty parameter name for %q", skill.SkillID) + } + if err := validateSchemaDefinition(schema); err != nil { + return nil, fmt.Errorf("invalid schema for %s.%s: %w", skill.SkillID, name, err) + } + } + // Price is a deployment/payment setting, not profile prose. Returning + // it from the same value used by x402 prevents documentation drift. + skill.PriceUSDC = price + skill.PaymentRequired = true + } + sort.Slice(catalog, func(i, j int) bool { return catalog[i].SkillID < catalog[j].SkillID }) + return catalog, nil +} + +func validSkillID(value string) bool { + if len(value) == 0 || len(value) > 64 || value[0] < 'a' || value[0] > 'z' { + return false + } + for _, char := range value { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '_' { + return false + } + } + return true +} + +func validateSchemaDefinition(schema ParamSchema) error { + switch schema.Type { + case "string", "number", "integer", "boolean": + case "array": + if schema.Items == nil { + return errors.New("array requires items") + } + if err := validateSchemaDefinition(*schema.Items); err != nil { + return err + } + default: + return fmt.Errorf("unsupported type %q", schema.Type) + } + if schema.Minimum != nil && schema.Maximum != nil && *schema.Minimum > *schema.Maximum { + return errors.New("minimum exceeds maximum") + } + if schema.MinItems != nil && *schema.MinItems < 0 { + return errors.New("min_items must be non-negative") + } + if schema.MaxItems != nil && *schema.MaxItems < 0 { + return errors.New("max_items must be non-negative") + } + if schema.MinItems != nil && schema.MaxItems != nil && *schema.MinItems > *schema.MaxItems { + return errors.New("min_items exceeds max_items") + } + return nil +} + +// SettleFunc performs the deferred x402 settlement for an already-verified +// payment. The payment gate in main.go injects it under the "x402_settle" +// context key; PostAction invokes it only after the simulator reports +// success, so a failed or timed-out execution can never settle. +type SettleFunc func(ctx context.Context) (*SettlementRecord, error) + +type validationError struct { + status int + code string + message string +} + +func (e validationError) Error() string { + return fmt.Sprintf("%s: %s", e.code, e.message) +} + +type actionMetadata struct { + ActionID string + RobotID string + SkillID string + ParamsHash string + // ParamsCanonical is the exact JSON byte sequence that was hashed by Go. + // It travels with the event so bridges can verify the hash without making + // cross-language float-formatting assumptions. + ParamsCanonical string + IdempotencyKey string +} + +// executionResult is the terminal event emitted by a bridge. Every member of +// the correlation tuple is required in the production Zenoh path; a result +// that cannot be tied to the exact published action is ignored and therefore +// times out without settlement. +type executionResult struct { + ActionID string `json:"action_id"` + RobotID string `json:"robot_id"` + SkillID string `json:"skill_id"` + ParamsHash string `json:"params_hash"` + IdempotencyKey string `json:"idempotency_key"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +func (result executionResult) matches(metadata actionMetadata) bool { + return result.ActionID != "" && + result.RobotID != "" && + result.SkillID != "" && + result.ParamsHash != "" && + result.IdempotencyKey != "" && + result.ActionID == metadata.ActionID && + result.RobotID == metadata.RobotID && + result.SkillID == metadata.SkillID && + result.ParamsHash == metadata.ParamsHash && + result.IdempotencyKey == metadata.IdempotencyKey +} + +// zenohConfigFromEnvironment builds the session configuration used by both the +// action publisher and the tunnel's configuration subscriber. ZENOH_CONFIG is +// a complete JSON5 configuration and therefore takes precedence. For the +// common local-router case, ZENOH_ENDPOINT is a concise equivalent of setting +// connect/endpoints in that configuration. +func zenohConfigFromEnvironment() (zenoh.Config, error) { + if path := os.Getenv("ZENOH_CONFIG"); path != "" { + return zenoh.NewConfigFromFile(path) + } + + config := zenoh.NewConfigDefault() + if endpoint := strings.TrimSpace(os.Getenv("ZENOH_ENDPOINT")); endpoint != "" { + endpoints, err := json.Marshal([]string{endpoint}) + if err != nil { + return zenoh.Config{}, fmt.Errorf("marshal ZENOH_ENDPOINT: %w", err) + } + if err := config.InsertJson5(zenoh.ConfigConnectKey, string(endpoints)); err != nil { + return zenoh.Config{}, fmt.Errorf("configure ZENOH_ENDPOINT: %w", err) + } + } + return config, nil +} + +// OpenZenohSession opens the configured Zenoh session. +func OpenZenohSession() (zenoh.Session, error) { + config, err := zenohConfigFromEnvironment() + if err != nil { + return zenoh.Session{}, err + } + return zenoh.Open(config, nil) +} + type zenohPublisher interface { Publish(keyExpr string, payload []byte) error } @@ -40,7 +298,7 @@ var ( func getZenohPublisher() (zenohPublisher, error) { zenohOnce.Do(func() { - session, err := zenoh.Open(zenoh.NewConfigDefault(), nil) + session, err := OpenZenohSession() if err != nil { zenohInitError = err return @@ -55,22 +313,422 @@ func getZenohPublisher() (zenohPublisher, error) { return zenohPub, nil } -func PublishRobotAction(payload []byte) error { +type Handlers struct { + Logger *zap.Logger + RobotID string + Publisher zenohPublisher + ActionTopic string + ResultTopic string + AllowedSkills map[string]struct{} + SkillCatalog []SkillMetadata + MaxDurationSeconds float64 + // Replay is the durable, payment-bound idempotency store. Never nil. + Replay *ReplayStore + // WaitForResult is injectable for contract tests. Production uses the + // Zenoh result subscriber created below. + WaitForResult func(actionID string) (chan bool, func(), error) + // WaitForCorrelatedResult is the strict test hook. Unlike the legacy bool + // hook, it exercises the exact result-correlation contract. + WaitForCorrelatedResult func(actionMetadata) (chan executionResult, func(), error) + // watchers tracks the in-flight execution goroutines so tests (and a + // graceful shutdown) can wait for pending outcome/settlement writes. + watchers sync.WaitGroup +} + +// WaitForPendingExecutions blocks until every spawned execution watcher has +// recorded its terminal outcome. Used by tests to avoid racing the durable +// store writes against temp-dir cleanup. +func (h *Handlers) WaitForPendingExecutions() { + h.watchers.Wait() +} + +func NewHandlers(logger *zap.Logger) *Handlers { + return NewHandlersForRobot(logger, "") +} + +func NewHandlersForRobot(logger *zap.Logger, robotID string) *Handlers { + return &Handlers{ + Logger: logger, + RobotID: robotID, + ActionTopic: configuredActionTopic(), + ResultTopic: configuredResultTopic(), + Replay: NewReplayStoreFromEnv(), + MaxDurationSeconds: 30, + } +} + +// GetRobotProfile exposes the robot identity and discovery link before a paid +// action is selected. It does not disclose wallet credentials. +func (h *Handlers) GetRobotProfile(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "robot_id": h.RobotID, + "skills_url": "/skills", + }) +} + +// GetSkills returns the registered catalog and whether each skill is enabled +// by the deployment's fail-closed allowlist. +func (h *Handlers) GetSkills(c *gin.Context) { + skills := make([]gin.H, 0, len(h.SkillCatalog)) + for _, skill := range h.SkillCatalog { + _, enabled := h.AllowedSkills[skill.SkillID] + skills = append(skills, gin.H{ + "skill_id": skill.SkillID, + "aliases": skill.Aliases, + "description": skill.Description, + "payment_required": skill.PaymentRequired, + "price_usdc": skill.PriceUSDC, + "params": skill.Params, + "enabled": enabled, + }) + } + c.JSON(http.StatusOK, gin.H{"robot_id": h.RobotID, "skills": skills}) +} + +// KnownSkillIDs returns every primary skill and alias declared by the loaded +// profile catalog. It lets main filter ALLOWED_ACTIONS without embedding any +// robot profile in the shared Tunnel binary. +func (h *Handlers) KnownSkillIDs() map[string]struct{} { + known := make(map[string]struct{}) + for _, skill := range h.SkillCatalog { + known[skill.SkillID] = struct{}{} + for _, alias := range skill.Aliases { + known[alias] = struct{}{} + } + } + return known +} + +func (h *Handlers) skillForAction(action string) (SkillMetadata, bool) { + for _, skill := range h.SkillCatalog { + if skill.SkillID == action { + return skill, true + } + for _, alias := range skill.Aliases { + if alias == action { + return skill, true + } + } + } + return SkillMetadata{}, false +} + +func (h *Handlers) publish(payload []byte) error { + topic := h.ActionTopic + if topic == "" { + topic = configuredActionTopic() + } + if h.Publisher != nil { + return h.Publisher.Publish(topic, payload) + } pub, err := getZenohPublisher() if err != nil { return err } - return pub.Publish(RobotActionTopic, payload) + return pub.Publish(topic, payload) } -type Handlers struct { - Logger *zap.Logger +// prepareExecutionWait subscribes before the ActionEvent is published so a +// fast simulator cannot race past the result observer. The real x402 path +// uses this waiter; injected test publishers intentionally bypass it. +func (h *Handlers) prepareExecutionWait(metadata actionMetadata) (chan executionResult, func(), error) { + if h.WaitForCorrelatedResult != nil { + return h.WaitForCorrelatedResult(metadata) + } + if h.WaitForResult != nil { + legacy, cleanup, err := h.WaitForResult(metadata.ActionID) + if err != nil { + return nil, cleanup, err + } + result := make(chan executionResult, 1) + go func() { + if success, open := <-legacy; open { + status := "failure" + if success { + status = "success" + } + result <- executionResult{ + ActionID: metadata.ActionID, + RobotID: metadata.RobotID, + SkillID: metadata.SkillID, + ParamsHash: metadata.ParamsHash, + IdempotencyKey: metadata.IdempotencyKey, + Status: status, + } + } + }() + return result, cleanup, nil + } + if h.Publisher != nil || metadata.ActionID == "" { + return nil, func() {}, nil + } + + pub, err := getZenohPublisher() + if err != nil { + return nil, nil, err + } + zenohPub, ok := pub.(*zenohSessionPublisher) + if !ok { + return nil, nil, fmt.Errorf("zenoh publisher does not expose a session") + } + resultTopic := h.ResultTopic + if resultTopic == "" { + resultTopic = configuredResultTopic() + } + keyExpr, err := zenoh.NewKeyExpr(resultTopic) + if err != nil { + return nil, nil, err + } + result := make(chan executionResult, 1) + sub, err := zenohPub.session.DeclareSubscriber(keyExpr, zenoh.Closure[zenoh.Sample]{ + Call: func(sample zenoh.Sample) { + var envelope executionResult + if err := json.Unmarshal(sample.Payload().Bytes(), &envelope); err != nil || !envelope.matches(metadata) { + return + } + select { + case result <- envelope: + default: + } + }, + }, nil) + if err != nil { + return nil, nil, err + } + return result, func() { _ = sub.Undeclare() }, nil } -func NewHandlers(logger *zap.Logger) *Handlers { - return &Handlers{ - Logger: logger, +func stringField(object map[string]interface{}, names ...string) string { + for _, name := range names { + if value, ok := object[name].(string); ok { + return strings.TrimSpace(value) + } } + return "" +} + +func validatePayload(payload interface{}, expectedRobotID string) (actionMetadata, error) { + metadata := actionMetadata{} + object, ok := payload.(map[string]interface{}) + if !ok { + // Fail closed: a paid request that does not even carry a JSON object + // naming a skill must never reach the simulator. + return metadata, validationError{http.StatusBadRequest, "MISSING_ACTION", "request body must be a JSON object with a registered skill in \"action\""} + } + + actionField := "" + if rawAction, present := object["action"]; present { + action, valid := rawAction.(string) + if !valid || strings.TrimSpace(action) == "" { + return metadata, validationError{http.StatusBadRequest, "INVALID_ACTION", "action must be a non-empty string"} + } + actionField = strings.TrimSpace(action) + } + skillField := stringField(object, "skill_id", "skillId") + if actionField != "" && skillField != "" && actionField != skillField { + return metadata, validationError{http.StatusBadRequest, "INVALID_ACTION", "action and skill_id must match when both are supplied"} + } + metadata.SkillID = actionField + if metadata.SkillID == "" { + metadata.SkillID = skillField + } + if metadata.SkillID == "" { + // Fail closed: no action/skill means no actuation — there is no + // default skill and nothing is published to Zenoh. + return metadata, validationError{http.StatusBadRequest, "MISSING_ACTION", "a registered skill is required in \"action\" (or \"skill_id\")"} + } + + if rawParams, present := object["params"]; present && rawParams != nil { + if _, valid := rawParams.(map[string]interface{}); !valid { + return metadata, validationError{http.StatusBadRequest, "INVALID_PARAMS", "params must be a JSON object"} + } + } + + if suppliedRobotID := stringField(object, "robot_id", "robotId"); suppliedRobotID != "" { + if expectedRobotID != "" && suppliedRobotID != expectedRobotID { + return metadata, validationError{http.StatusForbidden, "WRONG_ROBOT", "action targets a different robot"} + } + metadata.RobotID = suppliedRobotID + } + if metadata.RobotID == "" { + metadata.RobotID = expectedRobotID + } + + params, _ := object["params"].(map[string]interface{}) + if params == nil { + params = map[string]interface{}{} + object["params"] = params + } + metadata.ActionID = stringField(object, "action_id", "actionId", "id", "request_id", "requestId") + metadata.IdempotencyKey = stringField(object, "idempotency_key", "idempotencyKey") + if metadata.IdempotencyKey == "" { + metadata.IdempotencyKey = metadata.ActionID + } + if metadata.ActionID == "" { + metadata.ActionID = fmt.Sprintf("action-%d", time.Now().UnixNano()) + } + if metadata.IdempotencyKey == "" { + metadata.IdempotencyKey = metadata.ActionID + } + + canonicalParams, err := json.Marshal(params) + if err != nil { + return metadata, validationError{http.StatusBadRequest, "INVALID_PARAMS", "params could not be canonicalized"} + } + hash := sha256.Sum256(canonicalParams) + metadata.ParamsHash = fmt.Sprintf("sha256:%x", hash[:]) + metadata.ParamsCanonical = string(canonicalParams) + return metadata, nil +} + +func (h *Handlers) validateExecutionPolicy(metadata actionMetadata, payload interface{}) error { + if len(h.AllowedSkills) == 0 { + // Fail closed: without an explicit deployment allowlist no skill is + // enabled and nothing may actuate. + return validationError{http.StatusServiceUnavailable, "ALLOWLIST_NOT_CONFIGURED", "no skill allowlist is configured; refusing all actions"} + } + if _, ok := h.AllowedSkills[metadata.SkillID]; !ok { + return validationError{http.StatusForbidden, "SKILL_NOT_ALLOWED", "action is not a registered skill for this robot"} + } + skill, found := h.skillForAction(metadata.SkillID) + if !found { + // A configured allowlist is not enough: it must be bound to a + // concrete robot-scoped schema before anything can reach Zenoh. + return validationError{http.StatusServiceUnavailable, "SKILL_CATALOG_NOT_CONFIGURED", "no schema is configured for the requested skill"} + } + object, _ := payload.(map[string]interface{}) + params, _ := object["params"].(map[string]interface{}) + if params == nil { + params = map[string]interface{}{} + } + if err := validateParameters(skill.Params, params); err != nil { + return validationError{http.StatusBadRequest, "INVALID_PARAMS", err.Error()} + } + if h.MaxDurationSeconds <= 0 { + return nil + } + if raw, ok := params["duration"]; ok { + duration, ok := raw.(float64) + if !ok || duration <= 0 || duration > h.MaxDurationSeconds { + return validationError{http.StatusBadRequest, "DURATION_LIMIT", fmt.Sprintf("duration must be between 0 and %.0f seconds", h.MaxDurationSeconds)} + } + } + return nil +} + +func validateParameters(schema map[string]ParamSchema, params map[string]interface{}) error { + for name := range params { + if _, known := schema[name]; !known { + return fmt.Errorf("unknown parameter %q", name) + } + } + for name, rule := range schema { + value, present := params[name] + if !present { + if rule.Required { + return fmt.Errorf("missing required parameter %q", name) + } + continue + } + if err := validateParameterValue(name, rule, value); err != nil { + return err + } + } + return nil +} + +func validateParameterValue(name string, schema ParamSchema, value interface{}) error { + switch schema.Type { + case "string": + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return fmt.Errorf("parameter %q must be a non-empty string", name) + } + if len(schema.Values) > 0 { + for _, allowed := range schema.Values { + if text == allowed { + return nil + } + } + return fmt.Errorf("parameter %q has an unsupported value", name) + } + case "number", "integer": + number, ok := value.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) { + return fmt.Errorf("parameter %q must be a finite number", name) + } + if schema.Type == "integer" && math.Trunc(number) != number { + return fmt.Errorf("parameter %q must be an integer", name) + } + if schema.Minimum != nil && number < *schema.Minimum { + return fmt.Errorf("parameter %q is below its minimum", name) + } + if schema.Maximum != nil && number > *schema.Maximum { + return fmt.Errorf("parameter %q exceeds its maximum", name) + } + case "boolean": + if _, ok := value.(bool); !ok { + return fmt.Errorf("parameter %q must be a boolean", name) + } + case "array": + items, ok := value.([]interface{}) + if !ok { + return fmt.Errorf("parameter %q must be an array", name) + } + if schema.MinItems != nil && len(items) < *schema.MinItems { + return fmt.Errorf("parameter %q has too few items", name) + } + if schema.MaxItems != nil && len(items) > *schema.MaxItems { + return fmt.Errorf("parameter %q has too many items", name) + } + seen := make(map[string]struct{}) + for index, item := range items { + if schema.Items == nil { + return fmt.Errorf("parameter %q has no item schema", name) + } + if err := validateParameterValue(fmt.Sprintf("%s[%d]", name, index), *schema.Items, item); err != nil { + return err + } + if schema.UniqueItems { + canonical, err := json.Marshal(item) + if err != nil { + return fmt.Errorf("parameter %q contains an invalid item", name) + } + key := string(canonical) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("parameter %q contains duplicate items", name) + } + seen[key] = struct{}{} + } + } + } + return nil +} + +// paymentFingerprint binds replay protection to the verified x402 payload, +// not its transport encoding. PAYMENT-SIGNATURE is base64 JSON, so hashing +// its raw header bytes would allow the same authorization to be replayed with +// different whitespace, key order, or padding. The payment middleware stores +// the parsed/verified payload in the Gin context; encoding/json then gives us +// a deterministic semantic representation (including sorted map keys). +// +// The header fallback exists only for handler-unit callers that deliberately +// omit the payment middleware. Every production paid request reaches this +// handler with x402_payload set by deferredSettlementGate. +func paymentFingerprint(c *gin.Context) (string, error) { + if payload, verified := c.Get("x402_payload"); verified { + canonical, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("canonicalize verified payment payload: %w", err) + } + sum := sha256.Sum256(canonical) + return fmt.Sprintf("sha256:%x", sum[:]), nil + } + if signature := c.GetHeader("PAYMENT-SIGNATURE"); signature != "" { + sum := sha256.Sum256([]byte(signature)) + return fmt.Sprintf("sha256:%x", sum[:]), nil + } + return "", nil } func (h *Handlers) PostAction(c *gin.Context) { @@ -92,6 +750,67 @@ func (h *Handlers) PostAction(c *gin.Context) { } } + metadata, err := validatePayload(payload, h.RobotID) + if err != nil { + if contractErr, ok := err.(validationError); ok { + h.Logger.Warn("invalid action contract", zap.Error(contractErr)) + c.JSON(contractErr.status, gin.H{ + "error": contractErr.message, + "error_code": contractErr.code, + }) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action contract", "error_code": "INVALID_CONTRACT"}) + return + } + if err := h.validateExecutionPolicy(metadata, payload); err != nil { + contractErr := err.(validationError) + h.Logger.Warn("action rejected by execution policy", zap.Error(contractErr)) + c.JSON(contractErr.status, gin.H{"error": contractErr.message, "error_code": contractErr.code}) + return + } + // Bind the reservation to the exact x402 payment payload so a replayed + // payment can never actuate twice, even with a fresh idempotency key. + paymentHash, err := paymentFingerprint(c) + if err != nil { + h.Logger.Warn("failed to fingerprint verified payment", zap.Error(err)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "payment fingerprint unavailable", "error_code": "PAYMENT_FINGERPRINT_UNAVAILABLE"}) + return + } + if err := h.Replay.Reserve(metadata.IdempotencyKey, paymentHash, metadata.ActionID); err != nil { + switch { + case errors.Is(err, ErrReplayDetected): + c.JSON(http.StatusConflict, gin.H{ + "error": "duplicate action", + "error_code": "REPLAY_DETECTED", + "action_id": metadata.ActionID, + }) + case errors.Is(err, ErrPaymentReplayed): + c.JSON(http.StatusConflict, gin.H{ + "error": "payment payload already used", + "error_code": "PAYMENT_REPLAY_DETECTED", + "action_id": metadata.ActionID, + }) + default: + h.Logger.Warn("idempotency store unavailable", zap.Error(err)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "idempotency store unavailable", "error_code": "IDEMPOTENCY_STORE_UNAVAILABLE"}) + } + return + } + if err := h.Replay.BindActionMetadata(metadata.IdempotencyKey, metadata); err != nil { + h.Replay.Release(metadata.IdempotencyKey) + h.Logger.Warn("failed to persist action metadata", zap.Error(err)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "idempotency store unavailable", "error_code": "IDEMPOTENCY_STORE_UNAVAILABLE"}) + return + } + waitResult, cleanupWait, err := h.prepareExecutionWait(metadata) + if err != nil { + h.Replay.Release(metadata.IdempotencyKey) + h.Logger.Warn("failed to subscribe for simulator result", zap.Error(err)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "result channel unavailable", "error_code": "RESULT_CHANNEL_UNAVAILABLE"}) + return + } + var paymentPayload interface{} if value, ok := c.Get("x402_payload"); ok { paymentPayload = value @@ -103,7 +822,13 @@ func (h *Handlers) PostAction(c *gin.Context) { } event := gin.H{ - "payload": payload, + "payload": payload, + "action_id": metadata.ActionID, + "robot_id": metadata.RobotID, + "skill_id": metadata.SkillID, + "params_hash": metadata.ParamsHash, + "params_canonical": metadata.ParamsCanonical, + "idempotency_key": metadata.IdempotencyKey, "transaction_details": gin.H{ "payment_payload": paymentPayload, "payment_requirements": paymentRequirements, @@ -114,17 +839,190 @@ func (h *Handlers) PostAction(c *gin.Context) { eventBytes, err := json.Marshal(event) if err != nil { h.Logger.Warn("failed to marshal action event", zap.Error(err)) - } else { - pub, err := getZenohPublisher() - if err != nil { - h.Logger.Warn("failed to initialize zenoh publisher", zap.Error(err)) - } else if err := pub.Publish(RobotActionTopic, eventBytes); err != nil { - h.Logger.Warn("failed to publish action event", zap.Error(err)) + // Nothing was published; the reservation can be safely released. + h.Replay.Release(metadata.IdempotencyKey) + cleanupWait() + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal action event"}) + return + } + if err := h.publish(eventBytes); err != nil { + h.Logger.Warn("failed to publish action event", zap.Error(err)) + // Nothing was published; the reservation can be safely released. + h.Replay.Release(metadata.IdempotencyKey) + cleanupWait() + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to publish action event"}) + return + } + // From this point the simulator may have actuated: the reservation is + // never released. Failure/timeout are recorded as terminal outcomes so a + // replay (same key or same payment) after restart still returns 409. + if err := h.Replay.MarkOutcome(metadata.IdempotencyKey, "published"); err != nil { + h.Logger.Warn("failed to persist published state", zap.Error(err)) + } + + // Deferred, execution-gated settlement: the payment gate verified the + // payment synchronously and injected the settle callback. It runs only + // inside the watcher below, strictly after a successful simulator result. + var settle SettleFunc + if value, ok := c.Get("x402_settle"); ok { + if fn, ok := value.(SettleFunc); ok { + settle = fn } } - c.JSON(http.StatusOK, gin.H{ - "status": "accepted", - "timestamp": time.Now().Format(time.RFC3339), + h.watchers.Add(1) + go func() { + defer h.watchers.Done() + h.watchExecution(metadata, waitResult, cleanupWait, settle) + }() + + // Immediate accepted/pending contract: the terminal outcome (and the + // settlement receipt) is exposed by GET /action/:action_id/status under + // the same action_id returned here. + c.JSON(http.StatusAccepted, gin.H{ + "status": "accepted", + "state": "pending", + "action_id": metadata.ActionID, + "robot_id": metadata.RobotID, + "skill_id": metadata.SkillID, + "settlement": "pending-execution-gated", + "status_url": "/action/" + metadata.ActionID + "/status", + "timestamp": time.Now().Format(time.RFC3339), }) } + +// watchExecution waits for the correlated simulator result in the background +// and records the terminal outcome durably. Settlement happens here and only +// here: after a successful result. Failure and timeout never settle, and the +// idempotency record is kept so replays return 409 even after a restart. +func (h *Handlers) watchExecution(metadata actionMetadata, waitResult chan executionResult, cleanupWait func(), settle SettleFunc) { + if cleanupWait != nil { + defer cleanupWait() + } + var terminal executionResult + success := true + if waitResult != nil { + select { + case result := <-waitResult: + terminal = result + if !result.matches(metadata) { + if err := h.Replay.MarkOutcomeDetails(metadata.IdempotencyKey, "failed", "SIMULATOR_RESULT_MISMATCH", nil); err != nil { + h.Logger.Warn("failed to persist mismatched-result outcome", zap.Error(err)) + } + h.Logger.Warn("simulator result did not match published action; payment not settled", + zap.String("action_id", metadata.ActionID)) + return + } + success = strings.EqualFold(result.Status, "success") + case <-time.After(executionTimeout()): + if err := h.Replay.MarkOutcomeDetails(metadata.IdempotencyKey, "timeout", "SIMULATOR_RESULT_TIMEOUT", nil); err != nil { + h.Logger.Warn("failed to persist timeout outcome", zap.Error(err)) + } + h.Logger.Warn("simulator result timeout — payment not settled", + zap.String("action_id", metadata.ActionID)) + return + } + } + if !success { + errorCode := terminal.ErrorCode + if errorCode == "" { + errorCode = "SIMULATOR_EXECUTION_FAILED" + } + if err := h.Replay.MarkOutcomeWithResult(metadata.IdempotencyKey, "failed", errorCode, nil, terminal.Result); err != nil { + h.Logger.Warn("failed to persist failure outcome", zap.Error(err)) + } + h.Logger.Warn("simulator execution failed — payment not settled", + zap.String("action_id", metadata.ActionID)) + return + } + + if settle == nil { + if err := h.Replay.MarkOutcomeWithResult(metadata.IdempotencyKey, "succeeded", "", nil, terminal.Result); err != nil { + h.Logger.Warn("failed to persist success outcome", zap.Error(err)) + } + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + receipt, err := settle(ctx) + if err != nil { + // Execution succeeded but settlement failed: never retried silently, + // surfaced via the status endpoint so the payer is not charged blind. + if markErr := h.Replay.MarkOutcomeDetails(metadata.IdempotencyKey, "settlement_failed", "SETTLEMENT_FAILED", nil); markErr != nil { + h.Logger.Warn("failed to persist settlement failure", zap.Error(markErr)) + } + h.Logger.Warn("deferred settlement failed", zap.Error(err), + zap.String("action_id", metadata.ActionID)) + return + } + if err := h.Replay.MarkOutcomeWithResult(metadata.IdempotencyKey, "succeeded", "", receipt, terminal.Result); err != nil { + h.Logger.Warn("failed to persist settled outcome", zap.Error(err)) + } + h.Logger.Info("action settled after successful execution", + zap.String("action_id", metadata.ActionID), + zap.String("transaction", receiptTransaction(receipt))) +} + +func receiptTransaction(receipt *SettlementRecord) string { + if receipt == nil { + return "" + } + return receipt.Transaction +} + +// GetActionStatus serves the terminal-result half of the accepted/pending +// contract: GET /action/:action_id/status returns the durable execution and +// settlement state for the action_id issued by POST /action. +func (h *Handlers) GetActionStatus(c *gin.Context) { + actionID := strings.TrimSpace(c.Param("action_id")) + status, found := h.Replay.StatusByActionID(actionID) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown action id", "error_code": "UNKNOWN_ACTION", "action_id": actionID}) + return + } + + state := status.Status + if state == "reserved" || state == "published" { + // A record stranded in a pre-terminal state (e.g. crash between + // publish and outcome) is reported as timeout once the execution + // window has passed; it stays unsettled either way. + if time.Since(status.UpdatedAt) > executionTimeout() { + state = "timeout" + if err := h.Replay.MarkOutcomeDetails(status.Key, "timeout", "SIMULATOR_RESULT_TIMEOUT", nil); err != nil { + h.Logger.Warn("failed to persist stale timeout", zap.Error(err)) + } + status.ErrorCode = "SIMULATOR_RESULT_TIMEOUT" + } else { + state = "pending" + } + } + + response := gin.H{ + "action_id": status.ActionID, + "robot_id": status.RobotID, + "skill_id": status.SkillID, + "params_hash": status.ParamsHash, + "idempotency_key": status.Key, + "state": state, + "settled": status.Settlement != nil, + "updated_at": status.UpdatedAt.Format(time.RFC3339), + } + if status.ErrorCode != "" { + response["error_code"] = status.ErrorCode + } + if status.Settlement != nil { + response["settlement"] = gin.H{ + "transaction": status.Settlement.Transaction, + "network": status.Settlement.Network, + "payer": status.Settlement.Payer, + "payment_response": status.Settlement.PaymentResponse, + } + } + if len(status.Result) > 0 && json.Valid(status.Result) { + var result interface{} + if err := json.Unmarshal(status.Result, &result); err == nil { + response["result"] = result + } + } + c.JSON(http.StatusOK, response) +} diff --git a/tunnel/internal/handlers/handlers_test.go b/tunnel/internal/handlers/handlers_test.go index 08cc7126a..131aae5f6 100644 --- a/tunnel/internal/handlers/handlers_test.go +++ b/tunnel/internal/handlers/handlers_test.go @@ -2,42 +2,852 @@ package handlers import ( "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" + "sync" "testing" + "time" + "github.com/eclipse-zenoh/zenoh-go/zenoh" "github.com/gin-gonic/gin" "go.uber.org/zap" ) -func TestPostAction_ValidJSON(t *testing.T) { - gin.SetMode(gin.TestMode) +type recordingPublisher struct { + payloads [][]byte + topics []string + err error +} + +func (p *recordingPublisher) Publish(topic string, payload []byte) error { + if p.err != nil { + return p.err + } + p.payloads = append(p.payloads, append([]byte(nil), payload...)) + p.topics = append(p.topics, topic) + return nil +} + +func TestConfiguredZenohTopics(t *testing.T) { + t.Setenv("ZENOH_ACTION_TOPIC", "robots/test/actions") + t.Setenv("ZENOH_RESULT_TOPIC", "robots/test/results") + h := NewHandlersForRobot(zap.NewNop(), "robot-test") + if h.ActionTopic != "robots/test/actions" || h.ResultTopic != "robots/test/results" { + t.Fatalf("unexpected configured topics: action=%q result=%q", h.ActionTopic, h.ResultTopic) + } + + publisher := &recordingPublisher{} + h.Publisher = publisher + if err := h.publish([]byte(`{"action":"test_action"}`)); err != nil { + t.Fatalf("publish failed: %v", err) + } + if len(publisher.topics) != 1 || publisher.topics[0] != "robots/test/actions" { + t.Fatalf("expected configured action topic, got %v", publisher.topics) + } +} + +func TestZenohConfigUsesEndpointWhenNoConfigFileIsSet(t *testing.T) { + t.Setenv("ZENOH_CONFIG", "") + t.Setenv("ZENOH_ENDPOINT", "tcp/127.0.0.1:7447") + + config, err := zenohConfigFromEnvironment() + if err != nil { + t.Fatalf("build Zenoh configuration: %v", err) + } + rawEndpoints, err := config.Get(zenoh.ConfigConnectKey) + if err != nil { + t.Fatalf("read configured endpoints: %v", err) + } + var endpoints []string + if err := json.Unmarshal([]byte(rawEndpoints), &endpoints); err != nil { + t.Fatalf("decode configured endpoints %q: %v", rawEndpoints, err) + } + if len(endpoints) != 1 || endpoints[0] != "tcp/127.0.0.1:7447" { + t.Fatalf("unexpected configured endpoints: %v", endpoints) + } +} + +// recordingSettler stands in for the deferred x402 settlement callback that +// main.go injects. Counting its calls is the settlement observation: any +// no-settlement assertion checks calls == 0. +type recordingSettler struct { + mu sync.Mutex + calls int + err error + receipt *SettlementRecord +} + +func (s *recordingSettler) settle(_ context.Context) (*SettlementRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.calls++ + if s.err != nil { + return nil, s.err + } + if s.receipt != nil { + return s.receipt, nil + } + return &SettlementRecord{Transaction: "0xtest", Network: "eip155:84532"}, nil +} + +func (s *recordingSettler) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +func buildRouter(h *Handlers, settle SettleFunc) *gin.Engine { router := gin.New() - h := NewHandlers(zap.NewNop()) + if settle != nil { + router.Use(func(c *gin.Context) { + c.Set("x402_settle", settle) + c.Next() + }) + } + router.GET("/robot", h.GetRobotProfile) + router.GET("/skills", h.GetSkills) router.POST("/action", h.PostAction) + router.GET("/action/:action_id/status", h.GetActionStatus) + return router +} + +func testRegisteredSkills() map[string]struct{} { + return map[string]struct{}{ + "navigate_obstacle_course": {}, + "stop": {}, + } +} - req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewBufferString(`{"command":"start"}`)) +func testSkillCatalog() []SkillMetadata { + return []SkillMetadata{ + { + SkillID: "navigate_obstacle_course", + Description: "test navigation", + PaymentRequired: true, + PriceUSDC: "0.001", + Params: map[string]ParamSchema{ + "target_object": { + Type: "string", + Values: []string{"apple", "croissant", "duck"}, + }, + "duration": { + Type: "number", + Minimum: numberPointer(0.1), + Maximum: numberPointer(30), + }, + }, + }, + {SkillID: "stop", Description: "test stop", PaymentRequired: true, PriceUSDC: "0.001", Params: map[string]ParamSchema{}}, + } +} + +func numberPointer(value float64) *float64 { return &value } + +// newTestHandlers builds handlers the way production main.go does: durable +// idempotency store (isolated per test) plus the registered-skill allowlist. +func newTestHandlers(t *testing.T, robotID string) (*Handlers, *recordingPublisher, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + t.Setenv("IDEMPOTENCY_STORE_PATH", filepath.Join(t.TempDir(), "replay.json")) + if robotID == "" { + robotID = "test-robot" + } + publisher := &recordingPublisher{} + h := NewHandlersForRobot(zap.NewNop(), robotID) + h.Publisher = publisher + h.AllowedSkills = testRegisteredSkills() + h.SkillCatalog = testSkillCatalog() + // Wait for in-flight watcher goroutines before t.TempDir cleanup removes + // the store directory, otherwise the durable write races the RemoveAll. + t.Cleanup(h.WaitForPendingExecutions) + return h, publisher, buildRouter(h, nil) +} + +func postAction(router *gin.Engine, body string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewBufferString(body)) + for key, value := range headers { + req.Header.Set(key, value) + } res := httptest.NewRecorder() + router.ServeHTTP(res, req) + return res +} +func getStatus(router *gin.Engine, actionID string) (*httptest.ResponseRecorder, map[string]interface{}) { + req := httptest.NewRequest(http.MethodGet, "/action/"+actionID+"/status", nil) + res := httptest.NewRecorder() router.ServeHTTP(res, req) + var payload map[string]interface{} + _ = json.Unmarshal(res.Body.Bytes(), &payload) + return res, payload +} + +// waitForState polls the status endpoint until the async execution watcher +// records the wanted terminal state (the accepted/pending contract's second +// half). Fails the test if the state is not reached in time. +func waitForState(t *testing.T, router *gin.Engine, actionID, want string) map[string]interface{} { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + var last map[string]interface{} + for time.Now().Before(deadline) { + res, payload := getStatus(router, actionID) + if res.Code == http.StatusOK { + last = payload + if payload["state"] == want { + return payload + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("action %s never reached state %q (last: %v)", actionID, want, last) + return nil +} + +func errorCode(t *testing.T, res *httptest.ResponseRecorder) string { + t.Helper() + var payload map[string]interface{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("response is not JSON: %v (%s)", err, res.Body.String()) + } + code, _ := payload["error_code"].(string) + return code +} + +func TestRobotAndSkillDiscovery(t *testing.T) { + _, _, router := newTestHandlers(t, "spot-discovery-test") + + robotRequest := httptest.NewRequest(http.MethodGet, "/robot", nil) + robotResponse := httptest.NewRecorder() + router.ServeHTTP(robotResponse, robotRequest) + if robotResponse.Code != http.StatusOK { + t.Fatalf("expected robot discovery 200, got %d: %s", robotResponse.Code, robotResponse.Body.String()) + } + + skillsRequest := httptest.NewRequest(http.MethodGet, "/skills", nil) + skillsResponse := httptest.NewRecorder() + router.ServeHTTP(skillsResponse, skillsRequest) + if skillsResponse.Code != http.StatusOK { + t.Fatalf("expected skill discovery 200, got %d: %s", skillsResponse.Code, skillsResponse.Body.String()) + } + var payload struct { + RobotID string `json:"robot_id"` + Skills []struct { + SkillID string `json:"skill_id"` + PriceUSDC string `json:"price_usdc"` + Enabled bool `json:"enabled"` + } `json:"skills"` + } + if err := json.Unmarshal(skillsResponse.Body.Bytes(), &payload); err != nil { + t.Fatalf("invalid discovery response: %v", err) + } + if payload.RobotID != "spot-discovery-test" || len(payload.Skills) != 2 { + t.Fatalf("unexpected discovery payload: %+v", payload) + } + for _, skill := range payload.Skills { + if skill.PriceUSDC != "0.001" || !skill.Enabled { + t.Fatalf("skill must expose price and enabled state: %+v", skill) + } + } +} + +// The reviewer's fail-open finding: {"command":"start"} used to be accepted +// with 200. It must now be rejected with 400 MISSING_ACTION and never +// published to Zenoh. +func TestPostAction_RejectsPayloadWithoutAction(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, `{"command":"start"}`, nil) - if res.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", res.Code) + if res.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", res.Code, res.Body.String()) + } + if code := errorCode(t, res); code != "MISSING_ACTION" { + t.Fatalf("expected MISSING_ACTION, got %q", code) + } + if len(publisher.payloads) != 0 { + t.Fatal("payload without a skill must not be published") + } +} + +func TestPostAction_RejectsEmptyBody(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, ``, nil) + + if res.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", res.Code, res.Body.String()) + } + if code := errorCode(t, res); code != "MISSING_ACTION" { + t.Fatalf("expected MISSING_ACTION, got %q", code) + } + if len(publisher.payloads) != 0 { + t.Fatal("empty body must not be published") } } func TestPostAction_InvalidJSON(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, `{"command":`, nil) + + if res.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", res.Code) + } + if len(publisher.payloads) != 0 { + t.Fatal("invalid JSON must not be published") + } +} + +func TestPostAction_FailsClosedWithoutAllowlist(t *testing.T) { + h, publisher, router := newTestHandlers(t, "") + h.AllowedSkills = nil // simulate a deployment without any allowlist + + res := postAction(router, `{"action":"navigate_obstacle_course","params":{}}`, nil) + + if res.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status 503, got %d: %s", res.Code, res.Body.String()) + } + if code := errorCode(t, res); code != "ALLOWLIST_NOT_CONFIGURED" { + t.Fatalf("expected ALLOWLIST_NOT_CONFIGURED, got %q", code) + } + if len(publisher.payloads) != 0 { + t.Fatal("nothing may be published when the allowlist is absent") + } +} + +// A damaged idempotency file must never be interpreted as an empty store: +// otherwise a restart after corruption would replay a paid action. +func TestPostAction_FailsClosedWithCorruptReplayStore(t *testing.T) { gin.SetMode(gin.TestMode) + storePath := filepath.Join(t.TempDir(), "replay.json") + if err := os.WriteFile(storePath, []byte(`{"unfinished":`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("IDEMPOTENCY_STORE_PATH", storePath) + publisher := &recordingPublisher{} + h := NewHandlersForRobot(zap.NewNop(), "test-robot") + h.Publisher = publisher + h.AllowedSkills = testRegisteredSkills() + h.SkillCatalog = testSkillCatalog() + router := buildRouter(h, nil) + + res := postAction(router, `{"action":"navigate_obstacle_course","idempotency_key":"corrupt-store","params":{"target_object":"apple"}}`, nil) + if res.Code != http.StatusServiceUnavailable { + t.Fatalf("expected corrupt replay store to fail closed with 503, got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 0 { + t.Fatal("corrupt replay state must not publish an action") + } +} + +func TestPostAction_RejectsUnknownSkill(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, `{"action":"move_forward","params":{}}`, nil) + + if res.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", res.Code, res.Body.String()) + } + if code := errorCode(t, res); code != "SKILL_NOT_ALLOWED" { + t.Fatalf("expected SKILL_NOT_ALLOWED, got %q", code) + } + if len(publisher.payloads) != 0 { + t.Fatal("unknown skill must not be published") + } +} + +// The immediate accepted/pending contract: POST answers 202 right away with +// the action_id, and the terminal result is later served by the status +// endpoint under the same action_id. +func TestPostAction_ImmediateAcceptedPendingContract(t *testing.T) { + _, publisher, router := newTestHandlers(t, "spot-mujoco-sim-01") + + res := postAction(router, `{"action":"navigate_obstacle_course","robot_id":"spot-mujoco-sim-01","action_id":"action-123","idempotency_key":"action-123","params":{"target_object":"apple"}}`, nil) + + if res.Code != http.StatusAccepted { + t.Fatalf("expected status 202, got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatalf("expected one publication, got %d", len(publisher.payloads)) + } + var event map[string]interface{} + if err := json.Unmarshal(publisher.payloads[0], &event); err != nil { + t.Fatalf("published invalid event: %v", err) + } + if event["action_id"] != "action-123" { + t.Fatalf("expected action_id action-123, got %v", event["action_id"]) + } + if event["robot_id"] != "spot-mujoco-sim-01" { + t.Fatalf("expected robot_id, got %v", event["robot_id"]) + } + if event["skill_id"] != "navigate_obstacle_course" { + t.Fatalf("expected skill_id, got %v", event["skill_id"]) + } + if event["params_hash"] == "" { + t.Fatal("expected params_hash") + } + canonical, ok := event["params_canonical"].(string) + if !ok || canonical == "" { + t.Fatalf("expected exact params_canonical string, got %T %v", event["params_canonical"], event["params_canonical"]) + } + hash := sha256.Sum256([]byte(canonical)) + if event["params_hash"] != fmt.Sprintf("sha256:%x", hash[:]) { + t.Fatalf("params_hash does not bind params_canonical: %v", event["params_hash"]) + } + var response map[string]interface{} + if err := json.Unmarshal(res.Body.Bytes(), &response); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if response["action_id"] != "action-123" { + t.Fatalf("202 response must echo action_id, got %v", response["action_id"]) + } + if response["status"] != "accepted" || response["state"] != "pending" { + t.Fatalf("expected accepted/pending, got %v/%v", response["status"], response["state"]) + } + if response["settlement"] != "pending-execution-gated" { + t.Fatalf("expected pending-execution-gated marker, got %v", response["settlement"]) + } + if response["status_url"] != "/action/action-123/status" { + t.Fatalf("expected status_url for the same actionId, got %v", response["status_url"]) + } + + // Terminal result carries the same actionId via the status endpoint. + status := waitForState(t, router, "action-123", "succeeded") + if status["action_id"] != "action-123" { + t.Fatalf("status must carry the same action_id, got %v", status["action_id"]) + } + if status["settled"] != false { + t.Fatal("no settle callback was injected, so settled must be false") + } +} + +func TestGetActionStatus_UnknownActionIs404(t *testing.T) { + _, _, router := newTestHandlers(t, "") + + res, _ := getStatus(router, "never-issued") + if res.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unknown action id, got %d", res.Code) + } + if code := errorCode(t, res); code != "UNKNOWN_ACTION" { + t.Fatalf("expected UNKNOWN_ACTION, got %q", code) + } +} + +func TestPostAction_InvalidParamsContract(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, `{"action":"navigate_obstacle_course","params":"not-an-object"}`, nil) + + if res.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", res.Code) + } + if len(publisher.payloads) != 0 { + t.Fatal("invalid params must not be published") + } +} + +func TestPostAction_RejectsUnknownParameterBeforePublish(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, `{"action":"navigate_obstacle_course","params":{"not_in_profile":true}}`, nil) + + if res.Code != http.StatusBadRequest || errorCode(t, res) != "INVALID_PARAMS" { + t.Fatalf("expected INVALID_PARAMS before publish, got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 0 { + t.Fatal("unknown parameter must not be published") + } +} + +func TestPostAction_RejectsDivergentActionAndSkill(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + + res := postAction(router, `{"action":"navigate_obstacle_course","skill_id":"stop","params":{}}`, nil) + + if res.Code != http.StatusBadRequest || errorCode(t, res) != "INVALID_ACTION" { + t.Fatalf("expected INVALID_ACTION before publish, got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 0 { + t.Fatal("divergent action and skill_id must not be published") + } +} + +func TestPostAction_WrongRobot(t *testing.T) { + _, publisher, router := newTestHandlers(t, "spot-mujoco-sim-01") + + res := postAction(router, `{"action":"navigate_obstacle_course","robot_id":"another-robot","params":{}}`, nil) + + if res.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", res.Code) + } + if len(publisher.payloads) != 0 { + t.Fatal("wrong-robot action must not be published") + } +} + +func TestPostAction_RejectsReplay(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + body := `{"action":"navigate_obstacle_course","action_id":"same-action","idempotency_key":"same-action","params":{"target_object":"apple"}}` + + first := postAction(router, body, nil) + second := postAction(router, body, nil) + + if first.Code != http.StatusAccepted { + t.Fatalf("expected first request 202, got %d", first.Code) + } + if second.Code != http.StatusConflict { + t.Fatalf("expected replay status 409, got %d", second.Code) + } + if code := errorCode(t, second); code != "REPLAY_DETECTED" { + t.Fatalf("expected REPLAY_DETECTED, got %q", code) + } + if len(publisher.payloads) != 1 { + t.Fatalf("expected one publication, got %d", len(publisher.payloads)) + } +} + +// Replay protection must survive a process restart: the durable store is +// reloaded from disk and the same idempotency key still gets 409 with zero +// new publications (reviewer: "restart/retry can produce another actuation"). +func TestPostAction_ReplayRejectedAfterRestart(t *testing.T) { + gin.SetMode(gin.TestMode) + storePath := filepath.Join(t.TempDir(), "replay.json") + t.Setenv("IDEMPOTENCY_STORE_PATH", storePath) + body := `{"action":"navigate_obstacle_course","action_id":"restart-action","idempotency_key":"restart-action","params":{}}` + + firstPublisher := &recordingPublisher{} + firstHandlers := NewHandlersForRobot(zap.NewNop(), "") + firstHandlers.Publisher = firstPublisher + firstHandlers.AllowedSkills = testRegisteredSkills() + firstHandlers.SkillCatalog = testSkillCatalog() + t.Cleanup(firstHandlers.WaitForPendingExecutions) + firstRouter := buildRouter(firstHandlers, nil) + if res := postAction(firstRouter, body, nil); res.Code != http.StatusAccepted { + t.Fatalf("expected first request 202, got %d: %s", res.Code, res.Body.String()) + } + // Let the async watcher reach the terminal state before "restarting". + waitForState(t, firstRouter, "restart-action", "succeeded") + + // Simulate a tunnel restart: brand-new handlers reload the same file. + secondPublisher := &recordingPublisher{} + secondHandlers := NewHandlersForRobot(zap.NewNop(), "") + secondHandlers.Publisher = secondPublisher + secondHandlers.AllowedSkills = testRegisteredSkills() + secondHandlers.SkillCatalog = testSkillCatalog() + t.Cleanup(secondHandlers.WaitForPendingExecutions) + secondRouter := buildRouter(secondHandlers, nil) + + res := postAction(secondRouter, body, nil) + if res.Code != http.StatusConflict { + t.Fatalf("expected 409 after restart, got %d: %s", res.Code, res.Body.String()) + } + if len(secondPublisher.payloads) != 0 { + t.Fatal("replay after restart must not actuate the simulator") + } + + // The status endpoint also survives the restart under the same actionId. + statusRes, status := getStatus(secondRouter, "restart-action") + if statusRes.Code != http.StatusOK || status["state"] != "succeeded" { + t.Fatalf("expected persisted succeeded state after restart, got %d %v", statusRes.Code, status) + } +} + +// The same x402 payment payload must never actuate twice, even when the +// caller invents a fresh idempotency key for the retry. +func TestPostAction_RejectsPaymentReplayWithFreshKey(t *testing.T) { + _, publisher, router := newTestHandlers(t, "") + headers := map[string]string{"PAYMENT-SIGNATURE": "signed-payment-payload"} + + first := postAction(router, `{"action":"navigate_obstacle_course","action_id":"pay-1","idempotency_key":"pay-1","params":{}}`, headers) + second := postAction(router, `{"action":"navigate_obstacle_course","action_id":"pay-2","idempotency_key":"pay-2","params":{}}`, headers) + + if first.Code != http.StatusAccepted { + t.Fatalf("expected first request 202, got %d: %s", first.Code, first.Body.String()) + } + if second.Code != http.StatusConflict { + t.Fatalf("expected 409 for replayed payment, got %d: %s", second.Code, second.Body.String()) + } + if code := errorCode(t, second); code != "PAYMENT_REPLAY_DETECTED" { + t.Fatalf("expected PAYMENT_REPLAY_DETECTED, got %q", code) + } + if len(publisher.payloads) != 1 { + t.Fatalf("expected exactly one actuation, got %d", len(publisher.payloads)) + } +} + +// The replay key must be derived from the parsed/verified payment, not the +// base64 header bytes. Two serializations of the same authorization must +// still produce one publication. +func TestPostAction_RejectsSemanticallyEquivalentVerifiedPaymentReplay(t *testing.T) { + h, publisher, _ := newTestHandlers(t, "") + verifiedPayment := map[string]interface{}{ + "x402Version": float64(2), + "payload": map[string]interface{}{ + "signature": "0xsame-signature", + "authorization": map[string]interface{}{ + "from": "0x1111111111111111111111111111111111111111", + "nonce": "0xsame-nonce", + }, + }, + } router := gin.New() - h := NewHandlers(zap.NewNop()) + router.Use(func(c *gin.Context) { + c.Set("x402_payload", verifiedPayment) + c.Next() + }) router.POST("/action", h.PostAction) - req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewBufferString(`{"command":`)) - res := httptest.NewRecorder() + first := postAction(router, + `{"action":"navigate_obstacle_course","action_id":"semantic-1","idempotency_key":"semantic-1","params":{}}`, + map[string]string{"PAYMENT-SIGNATURE": "eyJwYXlsb2FkIjp7ImEiOjF9fQ=="}, + ) + second := postAction(router, + `{"action":"navigate_obstacle_course","action_id":"semantic-2","idempotency_key":"semantic-2","params":{}}`, + // Same JSON authorization can legitimately be transported with a + // different base64 padding/layout; the verified object above is equal. + map[string]string{"PAYMENT-SIGNATURE": "eyJwYXlsb2FkIjp7ICJhIiA6IDEgfX0"}, + ) + if first.Code != http.StatusAccepted { + t.Fatalf("expected first request 202, got %d: %s", first.Code, first.Body.String()) + } + if second.Code != http.StatusConflict || errorCode(t, second) != "PAYMENT_REPLAY_DETECTED" { + t.Fatalf("expected semantic payment replay 409, got %d: %s", second.Code, second.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatalf("semantically identical verified payment must actuate once, got %d", len(publisher.payloads)) + } +} - router.ServeHTTP(res, req) +// Settlement is deferred and execution-gated: the settle callback runs +// exactly once, only after the simulator reports success, and the receipt is +// exposed by the status endpoint. +func TestPostAction_SettlesOnlyAfterSimulatorSuccess(t *testing.T) { + h, _, _ := newTestHandlers(t, "") + h.WaitForResult = func(_ string) (chan bool, func(), error) { + result := make(chan bool, 1) + result <- true + return result, func() {}, nil + } + settler := &recordingSettler{receipt: &SettlementRecord{Transaction: "0xabc", Network: "eip155:84532", Payer: "0xpayer"}} + router := buildRouter(h, settler.settle) + body := `{"action":"navigate_obstacle_course","action_id":"settle-action","idempotency_key":"settle-action","params":{}}` + res := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-1"}) + if res.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", res.Code, res.Body.String()) + } + + status := waitForState(t, router, "settle-action", "succeeded") + if status["settled"] != true { + t.Fatalf("expected settled=true after success, got %v", status["settled"]) + } + settlement, _ := status["settlement"].(map[string]interface{}) + if settlement == nil || settlement["transaction"] != "0xabc" { + t.Fatalf("expected settlement receipt with transaction, got %v", status["settlement"]) + } + if settler.callCount() != 1 { + t.Fatalf("expected exactly one settle call, got %d", settler.callCount()) + } +} + +func TestPostAction_DoesNotSettleOnSimulatorFailure(t *testing.T) { + h, publisher, _ := newTestHandlers(t, "") + h.WaitForResult = func(_ string) (chan bool, func(), error) { + result := make(chan bool, 1) + result <- false + return result, func() {}, nil + } + settler := &recordingSettler{} + router := buildRouter(h, settler.settle) + body := `{"action":"navigate_obstacle_course","action_id":"failed-action","idempotency_key":"failed-action","params":{}}` + + res := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-fail"}) + if res.Code != http.StatusAccepted { + t.Fatalf("expected 202 (accepted/pending), got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatalf("expected action publication, got %d", len(publisher.payloads)) + } + + status := waitForState(t, router, "failed-action", "failed") + if status["error_code"] != "SIMULATOR_EXECUTION_FAILED" { + t.Fatalf("expected SIMULATOR_EXECUTION_FAILED, got %v", status["error_code"]) + } + if status["settled"] != false { + t.Fatal("failure must never settle") + } + if settler.callCount() != 0 { + t.Fatalf("expected ZERO settle calls on failure, got %d", settler.callCount()) + } + + // The failed reservation is kept (not deleted): a retry of the same key + // after failure is 409 and produces zero additional actuations. + retry := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-fail-2"}) + if retry.Code != http.StatusConflict { + t.Fatalf("expected 409 replay after failure, got %d: %s", retry.Code, retry.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatal("retry after failure must not actuate again") + } + if settler.callCount() != 0 { + t.Fatal("retry after failure must not settle either") + } +} + +func TestPostAction_DoesNotSettleForMismatchedResult(t *testing.T) { + h, publisher, _ := newTestHandlers(t, "robot-a") + h.WaitForCorrelatedResult = func(metadata actionMetadata) (chan executionResult, func(), error) { + result := make(chan executionResult, 1) + result <- executionResult{ + ActionID: metadata.ActionID, + RobotID: metadata.RobotID, + SkillID: "stop", // wrong action for this published request + ParamsHash: metadata.ParamsHash, + IdempotencyKey: metadata.IdempotencyKey, + Status: "success", + } + return result, func() {}, nil + } + settler := &recordingSettler{} + router := buildRouter(h, settler.settle) + body := `{"action":"navigate_obstacle_course","robot_id":"robot-a","action_id":"mismatch-action","idempotency_key":"mismatch-action","params":{}}` + + res := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-mismatch"}) + if res.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatalf("expected exactly one publication, got %d", len(publisher.payloads)) + } + status := waitForState(t, router, "mismatch-action", "failed") + if status["error_code"] != "SIMULATOR_RESULT_MISMATCH" { + t.Fatalf("expected mismatched result to be rejected, got %v", status["error_code"]) + } + if settler.callCount() != 0 { + t.Fatalf("mismatched result must make zero settlement calls, got %d", settler.callCount()) + } +} + +func TestPostAction_PersistsStructuredResult(t *testing.T) { + h, _, _ := newTestHandlers(t, "robot-result") + h.WaitForCorrelatedResult = func(metadata actionMetadata) (chan executionResult, func(), error) { + result := make(chan executionResult, 1) + result <- executionResult{ + ActionID: metadata.ActionID, + RobotID: metadata.RobotID, + SkillID: metadata.SkillID, + ParamsHash: metadata.ParamsHash, + IdempotencyKey: metadata.IdempotencyKey, + Status: "success", + Result: json.RawMessage(`{"metric":1,"policy":"closed-loop"}`), + } + return result, func() {}, nil + } + router := buildRouter(h, nil) + body := `{"action":"navigate_obstacle_course","robot_id":"robot-result","action_id":"result-action","idempotency_key":"result-action","params":{}}` + if res := postAction(router, body, nil); res.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", res.Code, res.Body.String()) + } + status := waitForState(t, router, "result-action", "succeeded") + result, ok := status["result"].(map[string]interface{}) + if !ok || result["policy"] != "closed-loop" { + t.Fatalf("expected structured bridge result in status, got %v", status["result"]) + } +} + +func TestPostAction_TimesOutWithoutSettlementAndKeepsReservation(t *testing.T) { + h, publisher, _ := newTestHandlers(t, "") + t.Setenv("EXECUTION_TIMEOUT_SECONDS", "0.05") + h.WaitForResult = func(_ string) (chan bool, func(), error) { + return make(chan bool), func() {}, nil // no result ever arrives + } + settler := &recordingSettler{} + router := buildRouter(h, settler.settle) + body := `{"action":"navigate_obstacle_course","action_id":"timeout-action","idempotency_key":"timeout-action","params":{}}` + + res := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-timeout"}) + if res.Code != http.StatusAccepted { + t.Fatalf("expected 202 (accepted/pending), got %d: %s", res.Code, res.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatalf("expected one publication, got %d", len(publisher.payloads)) + } + + status := waitForState(t, router, "timeout-action", "timeout") + if status["error_code"] != "SIMULATOR_RESULT_TIMEOUT" { + t.Fatalf("expected SIMULATOR_RESULT_TIMEOUT, got %v", status["error_code"]) + } + if status["settled"] != false { + t.Fatal("timeout must never settle") + } + if settler.callCount() != 0 { + t.Fatalf("expected ZERO settle calls on timeout, got %d", settler.callCount()) + } + + retry := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-timeout-2"}) + if retry.Code != http.StatusConflict { + t.Fatalf("expected 409 replay after timeout, got %d: %s", retry.Code, retry.Body.String()) + } + if len(publisher.payloads) != 1 { + t.Fatal("retry after timeout must not actuate again") + } +} + +// If execution succeeded but the deferred settlement errors, the status must +// say so instead of silently pretending the payment went through. +func TestPostAction_SettlementFailureIsSurfaced(t *testing.T) { + h, _, _ := newTestHandlers(t, "") + h.WaitForResult = func(_ string) (chan bool, func(), error) { + result := make(chan bool, 1) + result <- true + return result, func() {}, nil + } + settler := &recordingSettler{err: errors.New("facilitator unavailable")} + router := buildRouter(h, settler.settle) + body := `{"action":"navigate_obstacle_course","action_id":"settle-fail","idempotency_key":"settle-fail","params":{}}` + + res := postAction(router, body, map[string]string{"PAYMENT-SIGNATURE": "payment-x"}) + if res.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d", res.Code) + } + status := waitForState(t, router, "settle-fail", "settlement_failed") + if status["settled"] != false { + t.Fatal("failed settlement must report settled=false") + } + if status["error_code"] != "SETTLEMENT_FAILED" { + t.Fatalf("expected SETTLEMENT_FAILED, got %v", status["error_code"]) + } +} + +func TestPostAction_RejectsSkillOutsideAllowlist(t *testing.T) { + h, publisher, router := newTestHandlers(t, "") + h.AllowedSkills = map[string]struct{}{"navigate_obstacle_course": {}} + + res := postAction(router, `{"action":"move_forward","params":{}}`, nil) + if res.Code != http.StatusForbidden { + t.Fatalf("expected 403 for disallowed skill, got %d", res.Code) + } + if len(publisher.payloads) != 0 { + t.Fatal("disallowed skill must not be published") + } +} + +func TestPostAction_RejectsDurationAboveLimit(t *testing.T) { + h, publisher, router := newTestHandlers(t, "") + h.MaxDurationSeconds = 5 + + res := postAction(router, `{"action":"navigate_obstacle_course","params":{"duration":6}}`, nil) if res.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", res.Code) + t.Fatalf("expected 400 for excessive duration, got %d", res.Code) + } + if len(publisher.payloads) != 0 { + t.Fatal("excessive duration must not be published") } } diff --git a/tunnel/internal/handlers/idempotency.go b/tunnel/internal/handlers/idempotency.go new file mode 100644 index 000000000..84a9cfa39 --- /dev/null +++ b/tunnel/internal/handlers/idempotency.go @@ -0,0 +1,293 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// replayRetention is how long terminal replay records stay on disk. It is +// intentionally much longer than the old in-memory 10-minute TTL so that a +// tunnel restart cannot be used to re-run an already-actuated payment. +const replayRetention = 24 * time.Hour + +var ( + // ErrReplayDetected is returned when an idempotency key was already used. + ErrReplayDetected = errors.New("duplicate idempotency key") + // ErrPaymentReplayed is returned when the exact same x402 payment payload + // was already bound to a previous action, regardless of idempotency key. + ErrPaymentReplayed = errors.New("payment payload already used for a previous action") +) + +type replayRecord struct { + Key string `json:"key"` + PaymentHash string `json:"payment_hash,omitempty"` + ActionID string `json:"action_id,omitempty"` + RobotID string `json:"robot_id,omitempty"` + SkillID string `json:"skill_id,omitempty"` + ParamsHash string `json:"params_hash,omitempty"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + // Settlement is recorded only after a successful deferred x402 settlement + // so GET /action/:id/status can serve the receipt across restarts. + Settlement *SettlementRecord `json:"settlement,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SettlementRecord is the durable x402 settlement receipt for an action. +type SettlementRecord struct { + Transaction string `json:"transaction,omitempty"` + Network string `json:"network,omitempty"` + Payer string `json:"payer,omitempty"` + PaymentResponse string `json:"payment_response,omitempty"` +} + +// ActionStatus is the queryable view of a record for the status endpoint. +type ActionStatus struct { + Key string + ActionID string + RobotID string + SkillID string + ParamsHash string + Status string + ErrorCode string + Result json.RawMessage + Settlement *SettlementRecord + UpdatedAt time.Time +} + +// ReplayStore is a durable, payment-bound idempotency store. Every record is +// persisted to disk before the action is allowed to proceed, so a process +// restart (or crash between publish and response) cannot re-actuate the +// simulator for the same idempotency key or the same x402 payment payload. +type ReplayStore struct { + mu sync.Mutex + path string + records map[string]replayRecord + // loadErr is sticky: accepting an action after an unreadable or corrupt + // durable store would turn a restart into a replay bypass. Reserve and + // all state mutations reject while it is set, so payment safety fails + // closed until an operator restores the store deliberately. + loadErr error +} + +// NewReplayStore loads (or lazily creates) the store backing file at path. +func NewReplayStore(path string) *ReplayStore { + store := &ReplayStore{path: path, records: make(map[string]replayRecord)} + raw, err := os.ReadFile(path) + switch { + case err == nil: + var loaded map[string]replayRecord + if err := json.Unmarshal(raw, &loaded); err != nil || loaded == nil { + if err == nil { + err = errors.New("idempotency store must contain a JSON object") + } + store.loadErr = fmt.Errorf("load idempotency store: %w", err) + return store + } + store.records = loaded + case errors.Is(err, os.ErrNotExist): + // A first deployment has no state yet. It becomes durable before the + // first publication in Reserve. + default: + store.loadErr = fmt.Errorf("read idempotency store: %w", err) + return store + } + store.pruneLocked(time.Now()) + return store +} + +// NewReplayStoreFromEnv builds the store from IDEMPOTENCY_STORE_PATH, falling +// back to a file in the working directory so durability is on by default. +func NewReplayStoreFromEnv() *ReplayStore { + path := os.Getenv("IDEMPOTENCY_STORE_PATH") + if path == "" { + path = "robopay_idempotency.json" + } + return NewReplayStore(path) +} + +func (s *ReplayStore) pruneLocked(now time.Time) { + for key, record := range s.records { + if now.Sub(record.UpdatedAt) > replayRetention { + delete(s.records, key) + } + } +} + +// Reserve durably claims key (and, when present, the payment payload hash) +// before anything is published to the robot. The write is persisted before +// returning nil; a persistence failure rejects the action (fail closed). +func (s *ReplayStore) Reserve(key, paymentHash, actionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.loadErr != nil { + return s.loadErr + } + now := time.Now() + s.pruneLocked(now) + + if key != "" { + if _, exists := s.records[key]; exists { + return ErrReplayDetected + } + } + if paymentHash != "" { + for _, record := range s.records { + if record.PaymentHash == paymentHash { + return ErrPaymentReplayed + } + } + } + if key == "" && paymentHash == "" { + return nil + } + storageKey := key + if storageKey == "" { + storageKey = "payment:" + paymentHash + } + s.records[storageKey] = replayRecord{ + Key: storageKey, + PaymentHash: paymentHash, + ActionID: actionID, + Status: "reserved", + UpdatedAt: now, + } + if err := s.persistLocked(); err != nil { + delete(s.records, storageKey) + return err + } + return nil +} + +// MarkOutcome records the terminal state of a reserved key. Records are kept +// (not deleted) on failure/timeout so a replay after failure still gets 409. +func (s *ReplayStore) MarkOutcome(key, status string) error { + return s.MarkOutcomeDetails(key, status, "", nil) +} + +// MarkOutcomeDetails records the terminal state together with the error code +// and, on settled success, the x402 settlement receipt. Like MarkOutcome the +// record is persisted and never deleted before the retention window ends. +func (s *ReplayStore) MarkOutcomeDetails(key, status, errorCode string, settlement *SettlementRecord) error { + return s.MarkOutcomeWithResult(key, status, errorCode, settlement, nil) +} + +// MarkOutcomeWithResult persists the bridge's structured terminal result next +// to the settlement state so GET /action/:id/status remains useful after a +// restart and cannot be confused with an unrelated action. +func (s *ReplayStore) MarkOutcomeWithResult(key, status, errorCode string, settlement *SettlementRecord, result json.RawMessage) error { + if key == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.loadErr != nil { + return s.loadErr + } + record, exists := s.records[key] + if !exists { + return nil + } + record.Status = status + record.ErrorCode = errorCode + if settlement != nil { + record.Settlement = settlement + } + if result != nil { + record.Result = append(json.RawMessage(nil), result...) + } + record.UpdatedAt = time.Now() + s.records[key] = record + return s.persistLocked() +} + +// BindActionMetadata makes the durable record carry the complete correlation +// tuple before publication. A persistence failure keeps the action fail-closed. +func (s *ReplayStore) BindActionMetadata(key string, metadata actionMetadata) error { + if key == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.loadErr != nil { + return s.loadErr + } + record, exists := s.records[key] + if !exists { + return errors.New("idempotency reservation not found") + } + record.ActionID = metadata.ActionID + record.RobotID = metadata.RobotID + record.SkillID = metadata.SkillID + record.ParamsHash = metadata.ParamsHash + record.UpdatedAt = time.Now() + s.records[key] = record + return s.persistLocked() +} + +// StatusByActionID returns the durable execution/settlement state for the +// status endpoint. The lookup scans records because the store is keyed by +// idempotency key; sizes are small (24h retention). +func (s *ReplayStore) StatusByActionID(actionID string) (ActionStatus, bool) { + if actionID == "" { + return ActionStatus{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.loadErr != nil { + return ActionStatus{}, false + } + for _, record := range s.records { + if record.ActionID == actionID { + return ActionStatus{ + Key: record.Key, + ActionID: record.ActionID, + RobotID: record.RobotID, + SkillID: record.SkillID, + ParamsHash: record.ParamsHash, + Status: record.Status, + ErrorCode: record.ErrorCode, + Result: append(json.RawMessage(nil), record.Result...), + Settlement: record.Settlement, + UpdatedAt: record.UpdatedAt, + }, true + } + } + return ActionStatus{}, false +} + +// Release drops a reservation. Only valid before anything was published to +// the robot (e.g. marshal or publish failure); once an action may have +// actuated, the record must be kept via MarkOutcome instead. +func (s *ReplayStore) Release(key string) { + if key == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.records, key) + _ = s.persistLocked() +} + +func (s *ReplayStore) persistLocked() error { + raw, err := json.Marshal(s.records) + if err != nil { + return err + } + if dir := filepath.Dir(s.path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + temp := s.path + ".tmp" + if err := os.WriteFile(temp, raw, 0o600); err != nil { + return err + } + return os.Rename(temp, s.path) +}