From 9cf55bf6c618039b3064d384779ead050208f9cc Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 1 Jul 2026 11:53:17 -0400 Subject: [PATCH 1/3] Rework MCP safety and pricing outputs Ensure hosted MCP subprocess calls fail fast without inherited stdin, gate wallet creation behind explicit confirmation, and report pricing around MCP/inference modes instead of hosted-agent runtime assumptions. Co-authored-by: Cursor --- cli/mcp_server.py | 22 +++++++++++++-- scripts/pricing_aggregate.py | 44 +++++++++++++++++++++++------ tests/test_mcp_money_tools.py | 41 +++++++++++++++++++++++++++ tests/test_pricing_aggregate.py | 49 +++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 tests/test_pricing_aggregate.py diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 875b328..67ea6f4 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -15,7 +15,13 @@ def _run_hl(*args: str, timeout: int = 30) -> str: """Run an hl CLI command via subprocess and return stdout.""" cmd = [sys.executable, "-m", "cli.main", *args] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + result = subprocess.run( + cmd, + capture_output=True, + stdin=subprocess.DEVNULL, + text=True, + timeout=timeout, + ) output = result.stdout.strip() if result.returncode != 0 and result.stderr: output = output + "\n" + result.stderr.strip() if output else result.stderr.strip() @@ -26,7 +32,13 @@ def _run_script(script_name: str, *args: str, timeout: int = 300) -> str: """Run a repository script via subprocess and return stdout/stderr.""" script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name cmd = [sys.executable, str(script_path), *args] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + result = subprocess.run( + cmd, + capture_output=True, + stdin=subprocess.DEVNULL, + text=True, + timeout=timeout, + ) output = result.stdout.strip() if result.returncode != 0 and result.stderr: output = output + "\n" + result.stderr.strip() if output else result.stderr.strip() @@ -92,17 +104,21 @@ def wallet_list() -> str: return json.dumps(keystores, indent=2) if keystores else "No keystores found." @mcp.tool() - def wallet_auto(save_env: bool = True) -> str: + def wallet_auto(save_env: bool = True, confirm: bool = False) -> str: """Create a new wallet non-interactively (agent-friendly). Args: save_env: Save credentials to ~/.hl-agent/env for auto-detection (default: True) + confirm: Must be true because this creates a new private key and writes credentials. """ import secrets from pathlib import Path from eth_account import Account from cli.keystore import create_keystore + if not confirm: + return "Refusing to create a wallet without confirm=true." + password = secrets.token_urlsafe(32) account = Account.create() ks_path = create_keystore(account.key.hex(), password) diff --git a/scripts/pricing_aggregate.py b/scripts/pricing_aggregate.py index 3b1e216..ef7b83c 100644 --- a/scripts/pricing_aggregate.py +++ b/scripts/pricing_aggregate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Aggregate hosted-agent pricing ledgers into COGS and launch pricing.""" +"""Aggregate MCP/inference pricing ledgers into COGS and launch pricing.""" from __future__ import annotations import argparse @@ -217,18 +217,22 @@ def _render_markdown(input_dir: Path, rows: List[dict], incidents: List[dict], a generated = time.strftime("%Y-%m-%d") lines = [ "---", - "title: Hosted Agent Pricing Results", + "title: MCP and Inference Pricing Results", f"date: {generated}", - "tags: [pricing, hosted-agents, cost-experiment, agent-cli]", + "tags: [pricing, mcp, inference, cost-experiment, agent-cli]", "---", "", - "# Hosted Agent Pricing Results", + "# MCP and Inference Pricing Results", "", - "**Source:** [[2026-06-25-hosted-agent-pricing-qualification-loop]]", + "**Source:** [[2026-07-01-mcp-subscription-rework]]", "", f"Input directory: `{input_dir}`", f"Target margin: {args.target_margin}%", "", + "## Mode-Specific Pricing Inputs", + "", + *_render_mode_summary(rows, args), + "", "## Executive Recommendation", "", ] @@ -282,19 +286,40 @@ def _render_markdown(input_dir: Path, rows: List[dict], incidents: List[dict], a "", "## Assumptions", "", - f"- Infra allocation: {_money(rows[0]['infra_hourly']) if rows else '$0.0000'}/agent-hour ({rows[0]['infra_source'] if rows else 'unknown'}).", + f"- Hosted MCP `C_seat` uses `--hosted-mcp-seats={args.hosted_mcp_seats}` and amortizes shared tools runtime/observability over seats; it is not a per-user autonomous hosted-agent cost.", + f"- Infra allocation input: {_money(rows[0]['infra_hourly']) if rows else '$0.0000'}/runtime-hour ({rows[0]['infra_source'] if rows else 'unknown'}).", f"- Railway assumption: {args.railway_vcpu_per_agent} vCPU, {args.railway_ram_gb_per_agent} GB RAM, " - f"{args.railway_volume_gb_per_agent} GB volume, {args.railway_egress_gb_per_agent_month} GB monthly egress per agent.", - f"- Observability allocation: ${args.observability_usd_per_agent_hour}/agent-hour.", + f"{args.railway_volume_gb_per_agent} GB volume, {args.railway_egress_gb_per_agent_month} GB monthly egress for the measured runtime allocation.", + f"- Observability allocation: ${args.observability_usd_per_agent_hour}/runtime-hour.", "- Cache savings are reported only when provider usage metadata includes a savings value; otherwise cached tokens and hit rate are shown without assumed dollar savings.", + "- OpenRouter anchors from current experiments: openrouter/auto ~= $0.0036-$0.00375 per heartbeat; gpt-4.1-mini ~= $0.0002-$0.000222; Fusion capped ~= $0.033 and is premium-only until cheaper routing is proven.", "- Pricing uses p95 monthly COGS rather than average COGS.", "- Testnet measurements still need mainnet fee and production infra validation before final launch pricing.", ]) return "\n".join(lines) + "\n" +def _render_mode_summary(rows: List[dict], args: argparse.Namespace) -> List[str]: + if not rows: + return ["No mode-specific rows available yet."] + seats = Decimal(max(1, int(args.hosted_mcp_seats))) + mode_2_inference = sum((row["llm_total"] for row in rows), Decimal("0")) + mode_3_builder = sum((row["fees_total"] for row in rows), Decimal("0")) + p95_runtime = sum((row["p95_monthly_cogs"] - row["llm_total"] - row["fees_total"] for row in rows), Decimal("0")) + c_seat = p95_runtime / seats if seats > 0 else Decimal("0") + return [ + "| Mode | What Nunchi Pays | Measured Input | Pricing Note |", + "| --- | --- | ---: | --- |", + f"| `mode_1_hosted_mcp_tools` | Shared Railway tools runtime, gateway, audit/control plane | `C_seat` ~= {_money(c_seat)} / seat-month at {int(seats)} seats | User pays their own inference; paid tools and call volume decide final tier. |", + f"| `mode_2_hosted_mcp_tools_inference` | Mode 1 plus Nunchi/OpenRouter budget | `C_inference` observed {_money(mode_2_inference)} in input ledgers | Cheap model default; auto/Fusion are premium cost centers. |", + f"| `mode_3_clone_local` | No hosted tools/runtime unless user opts in | Builder-fee metadata observed {_money(mode_3_builder)} | Economics are builder-code capture plus optional paid controls. |", + "", + "**OpenRouter anchor reminders:** `gpt-4.1-mini` ~= $0.0002/heartbeat, `openrouter/auto` ~= $0.0037/heartbeat, Fusion ~= $0.033/heartbeat in prior capped runs.", + ] + + def main() -> int: - parser = argparse.ArgumentParser(description="Aggregate hosted-agent pricing ledgers") + parser = argparse.ArgumentParser(description="Aggregate MCP/inference pricing ledgers") parser.add_argument("--input-dir", required=True) parser.add_argument("--output") parser.add_argument("--infra-usd-per-agent-hour", type=float, default=None) @@ -303,6 +328,7 @@ def main() -> int: parser.add_argument("--railway-volume-gb-per-agent", type=float, default=0.0) parser.add_argument("--railway-egress-gb-per-agent-month", type=float, default=0.0) parser.add_argument("--observability-usd-per-agent-hour", type=float, default=0.0) + parser.add_argument("--hosted-mcp-seats", type=int, default=5, help="Seats used to amortize shared hosted MCP runtime cost") parser.add_argument("--target-margin", choices=["70", "80", "85", "90"], default="80") args = parser.parse_args() return aggregate(args) diff --git a/tests/test_mcp_money_tools.py b/tests/test_mcp_money_tools.py index 9cb67c7..8c414b6 100644 --- a/tests/test_mcp_money_tools.py +++ b/tests/test_mcp_money_tools.py @@ -3,6 +3,7 @@ import sys import types +import subprocess class FakeFastMCP: @@ -142,6 +143,46 @@ def test_mcp_trade_confirm_builds_safe_subprocess(monkeypatch): ] +def test_mcp_subprocess_helpers_do_not_inherit_stdin(monkeypatch): + import cli.mcp_server as mcp_server + + calls = [] + + class Result: + returncode = 0 + stdout = "ok\n" + stderr = "" + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return Result() + + monkeypatch.setattr(mcp_server.subprocess, "run", fake_run) + + assert mcp_server._run_hl("trade", "ETH-PERP") == "ok" + assert mcp_server._run_script("test_hedge_agent.py") == "ok" + assert calls[0][1]["stdin"] == subprocess.DEVNULL + assert calls[1][1]["stdin"] == subprocess.DEVNULL + + +def test_mcp_wallet_auto_requires_confirm(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + + assert server.tools["wallet_auto"]() == "Refusing to create a wallet without confirm=true." + + def test_mcp_hedge_smoke_test_builds_script_call(monkeypatch): fastmcp_module = types.ModuleType("mcp.server.fastmcp") fastmcp_module.FastMCP = FakeFastMCP diff --git a/tests/test_pricing_aggregate.py b/tests/test_pricing_aggregate.py new file mode 100644 index 0000000..924c775 --- /dev/null +++ b/tests/test_pricing_aggregate.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import argparse +import json + +from scripts import pricing_aggregate + + +def test_pricing_aggregate_outputs_mode_specific_summary(tmp_path): + (tmp_path / "cost_ledger.jsonl").write_text( + json.dumps({ + "job_type": "heartbeat", + "agent_id": "agent-1", + "user_id": "user-1", + "account_id": "acct-1", + "subscription_id": "sub-1", + "usd_cost": "0.01", + "input_tokens": 100, + "cached_tokens": 80, + "ts": 1_000, + }) + "\n" + ) + (tmp_path / "agent_runtime_ledger.jsonl").write_text( + json.dumps({"job_type": "heartbeat", "agent_id": "agent-1", "event_type": "heartbeat", "ts": 1_000}) + "\n" + + json.dumps({"job_type": "heartbeat", "agent_id": "agent-1", "event_type": "heartbeat", "ts": 3_601_000}) + "\n" + ) + output = tmp_path / "report.md" + args = argparse.Namespace( + input_dir=str(tmp_path), + output=str(output), + infra_usd_per_agent_hour=0.01, + railway_vcpu_per_agent=1.0, + railway_ram_gb_per_agent=1.0, + railway_volume_gb_per_agent=0.0, + railway_egress_gb_per_agent_month=0.0, + observability_usd_per_agent_hour=0.0, + hosted_mcp_seats=5, + target_margin="80", + ) + + assert pricing_aggregate.aggregate(args) == 0 + + report = output.read_text() + assert "## Mode-Specific Pricing Inputs" in report + assert "`mode_1_hosted_mcp_tools`" in report + assert "`C_seat`" in report + assert "`mode_2_hosted_mcp_tools_inference`" in report + assert "`mode_3_clone_local`" in report + assert "gpt-4.1-mini" in report From 79d41ce68aaf4bf150442a1a34db15d9ebe94e20 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 1 Jul 2026 12:17:53 -0400 Subject: [PATCH 2/3] Default Railway runtime to hosted MCP tools Updates Railway defaults and docs so the subscription path runs the shared MCP tools runtime instead of a per-user autonomous agent, with tests for the mcp default. Co-authored-by: Cursor --- README.md | 36 +++++++++++++++++++++--------------- docs/RUNBOOK.md | 23 +++++++++++++++-------- railway.toml | 2 +- scripts/entrypoint.py | 8 ++++---- tests/test_entrypoint.py | 10 +++++++++- 5 files changed, 50 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f4466e4..6b3106a 100644 --- a/README.md +++ b/README.md @@ -511,29 +511,34 @@ Every deployed agent also exposes an HTTP REST API and SSE real-time feed for da ## Deploy on Railway -Two deployment options: **headless** (APEX runs strategies directly) or **OpenClaw agent** (conversational AI trading assistant with Telegram). +The subscription product uses Railway as a **shared MCP tools runtime**, not as +a per-user Hermes/OpenClaw/autonomous-agent host. The top-level Railway template +defaults to `RUN_MODE=mcp`; Nunchi operates this runner pool behind +`mcp-gateway`, while users run their own Cursor, Claude, Codex, or local +`agent-cli` clients. -### Option A: Headless APEX (Deterministic) +### Shared MCP Tools Runtime -One-click deploy to run APEX autonomously. No AI model needed — pure deterministic strategy execution. - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli&envs=HL_PRIVATE_KEY,HL_TESTNET,RUN_MODE,APEX_PRESET&HL_TESTNETDefault=true&RUN_MODEDefault=apex&APEX_PRESETDefault=default) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli&envs=HL_TESTNET,RUN_MODE,DATA_DIR&HL_TESTNETDefault=true&RUN_MODEDefault=mcp&DATA_DIRDefault=/data) | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `HL_PRIVATE_KEY` | Yes | — | Your Hyperliquid private key | | `HL_TESTNET` | No | `true` | `true` for testnet, `false` for mainnet | -| `RUN_MODE` | No | `apex` | `apex`, `wolf` (alias), `strategy`, or `mcp` | -| `APEX_PRESET` | No | `default` | `conservative`, `default`, or `aggressive` | +| `RUN_MODE` | No | `mcp` | `mcp` for the hosted tools runtime; `apex`/`strategy` are local or legacy operator modes | +| `DATA_DIR` | No | `/data` | Persistent ledgers, wallet state, and metering data | +| `NUNCHI_METERING_URL` | No | - | Generic web-auth metering upload endpoint when the runner reports usage | +| `NUNCHI_METERING_TOKEN` | No | - | Metering bearer token issued by web-auth/gateway config | **Run modes:** -- **apex** (default) — APEX multi-slot orchestrator with autonomous entry, exit, Guard trailing stops, and REFLECT self-improvement loop -- **strategy** — Single strategy loop (set `STRATEGY=engine_mm`, `avellaneda_mm`, etc.) -- **mcp** — MCP server for AI agent integration (SSE transport) +- **mcp** (default) - SSE MCP server for the shared Railway tools runtime. +- **apex** / **strategy** - direct autonomous loops for local/self-hosted operators, not the Nunchi subscription product path. -### Option B: OpenClaw Agent (Conversational AI) +### Legacy OpenClaw Agent Template -One-click deploy of a full OpenClaw agent that uses our CLI as the tool backend. Talk to your trading bot via Telegram — it scans markets, enters trades, manages risk, and learns from its mistakes. +`deploy/openclaw-railway` is retained as a legacy/reference self-host template. +It is not part of the new hosted MCP subscription architecture because it +provisions a user-facing conversational/autonomous agent. Do not expose it as +the paid Nunchi product path. [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template?template=https://github.com/Nunchi-trade/agent-cli/tree/main/deploy/openclaw-railway&envs=HL_PRIVATE_KEY,AI_PROVIDER,AI_API_KEY,TELEGRAM_BOT_TOKEN,TELEGRAM_USERNAME,HL_TESTNET&HL_TESTNETDefault=true) @@ -546,7 +551,7 @@ One-click deploy of a full OpenClaw agent that uses our CLI as the tool backend. | `TELEGRAM_USERNAME` | Yes | — | Your Telegram @username | | `HL_TESTNET` | No | `true` | `true` for testnet, `false` for mainnet | -**What you get:** +**Legacy behavior:** - OpenClaw gateway with web UI at `/openclaw` - Telegram integration — chat with your bot to start/stop trading, run scans, check status - Our 13 MCP trading tools as the agent's primary capabilities @@ -561,7 +566,8 @@ One-click deploy of a full OpenClaw agent that uses our CLI as the tool backend. 4. Ask "how did we do?" → it runs REFLECT and reports performance metrics 5. The agent reads workspace files (AGENTS.md, SOUL.md) that define its trading behavior -Both options persist state via Railway volume at `/data` — APEX state, REFLECT reports, Radar history, and agent memory survive redeploys. +Both templates persist state via Railway volume at `/data`, but only the +top-level `RUN_MODE=mcp` deployment reflects the shared hosted tools runtime. --- diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 08f1d5d..8d63b7f 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -1,4 +1,10 @@ -# APEX Operational Runbook +# Agent CLI Railway Runbook + +The default Railway deployment is the shared MCP/tools runtime used behind +`mcp-gateway`. It should run `RUN_MODE=mcp` and expose tools only; it must not +run a per-user Hermes/OpenClaw/autonomous-agent loop for the subscription +product. The APEX sections below apply only when an operator explicitly opts +into self-hosted autonomous modes. ## Starting / Stopping @@ -7,7 +13,7 @@ # Deploy via Railway dashboard or CLI railway up ``` -The entrypoint starts a health server on `$PORT` (default 8080) then launches the configured `RUN_MODE`. +The entrypoint starts a health server on `$PORT` (default 8080) then launches the configured `RUN_MODE`. For the hosted MCP product, leave `RUN_MODE=mcp`. ### Start (Local) ```bash @@ -113,7 +119,7 @@ railway logs | jq '.level, .message' | Variable | Default | Purpose | |----------|---------|---------| -| `RUN_MODE` | `apex` | `apex`, `strategy`, `mcp` | +| `RUN_MODE` | `mcp` | `mcp` for shared hosted tools; `apex`/`strategy` only for self-hosted operators | | `APEX_PRESET` | `default` | `conservative`, `default`, `aggressive` | | `APEX_BUDGET` | auto | Total trading capital | | `APEX_SLOTS` | `3` | Max concurrent positions | @@ -126,11 +132,12 @@ railway logs | jq '.level, .message' ## Railway Deployment Checklist -- [ ] `HL_PRIVATE_KEY` or keystore credentials configured -- [ ] `HL_TESTNET=false` for mainnet -- [ ] `APEX_BUDGET` set to desired capital +- [ ] `RUN_MODE=mcp` for Nunchi-hosted shared tools runtime +- [ ] `HL_TESTNET=true` unless the runner is explicitly approved for mainnet +- [ ] Do not deploy Hermes/OpenClaw as a Nunchi subscription product surface +- [ ] Configure generic metering upload when reporting usage: `NUNCHI_METERING_URL`, `NUNCHI_METERING_TOKEN` - [ ] `API_AUTH_TOKEN` set for control endpoint security - [ ] Persistent volume mounted at `/data` - [ ] Health check endpoint `/health` responds with 200 -- [ ] Run `hl apex reconcile` after first deploy to verify clean state -- [ ] Monitor `/metrics` endpoint for tick latency and error counts +- [ ] Gateway points at this runner with `NUNCHI_MCP_TOOLS_RUNNER_URL` +- [ ] For self-hosted APEX/strategy modes only: run `hl apex reconcile` after first deploy and monitor `/metrics` diff --git a/railway.toml b/railway.toml index ef3cb55..64d10e2 100644 --- a/railway.toml +++ b/railway.toml @@ -13,6 +13,6 @@ mountPath = "/data" PORT = "8080" HL_TESTNET = "true" APEX_PRESET = "default" -RUN_MODE = "apex" +RUN_MODE = "mcp" INSTRUMENT = "ETH-PERP" DATA_DIR = "/data" diff --git a/scripts/entrypoint.py b/scripts/entrypoint.py index dff2eb7..6131310 100644 --- a/scripts/entrypoint.py +++ b/scripts/entrypoint.py @@ -69,7 +69,7 @@ def _pricing_snapshot(data_dir: str, limit: int = 20) -> dict: "trades": base / "trades.jsonl", } return { - "mode": os.environ.get("RUN_MODE", "apex"), + "mode": os.environ.get("RUN_MODE", "mcp"), "strategy": os.environ.get("STRATEGY"), "ai_provider": os.environ.get("AI_PROVIDER"), "ai_model": os.environ.get("AI_MODEL"), @@ -98,7 +98,7 @@ def do_GET(self): if self.path == "/health": body = json.dumps({ "status": "ok", - "mode": os.environ.get("RUN_MODE", "apex"), + "mode": os.environ.get("RUN_MODE", "mcp"), "strategy": os.environ.get("STRATEGY"), "ai_provider": os.environ.get("AI_PROVIDER"), "ai_model": os.environ.get("AI_MODEL"), @@ -360,7 +360,7 @@ def log_message(self, format, *args): def build_command() -> list[str]: """Build the CLI command from environment variables.""" - mode = os.environ.get("RUN_MODE", "apex").lower() + mode = os.environ.get("RUN_MODE", "mcp").lower() py = [sys.executable, "-m", "cli.main"] if mode in ("apex", "wolf"): @@ -479,7 +479,7 @@ def main(): # Build and run main command cmd = build_command() - mode = os.environ.get("RUN_MODE", "apex") + mode = os.environ.get("RUN_MODE", "mcp") safe_cmd = _SECRET_RE.sub("0x[REDACTED]", ' '.join(cmd)) log.info("Starting %s mode: %s", mode, safe_cmd) diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index f12cd12..4f94884 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -21,6 +21,13 @@ # --------------------------------------------------------------------------- class TestBuildCommand: + def test_default_mode_is_mcp_tools_runtime(self, monkeypatch): + monkeypatch.delenv("RUN_MODE", raising=False) + + cmd = build_command() + + assert cmd == [sys.executable, "-m", "cli.main", "mcp", "serve", "--transport", "sse"] + def test_apex_mode_default(self, monkeypatch): monkeypatch.setenv("RUN_MODE", "apex") monkeypatch.delenv("APEX_PRESET", raising=False) @@ -166,7 +173,8 @@ def test_no_token_configured(self, monkeypatch): ep.AUTH_TOKEN = None try: handler = self._make_handler() - assert handler._check_auth() is True + assert handler._check_auth() is False + handler.send_response.assert_called_with(503) finally: ep.AUTH_TOKEN = original From b81e56040c769c901815d8e2be7bc504ded5d148 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Wed, 1 Jul 2026 12:33:09 -0400 Subject: [PATCH 3/3] Enforce hosted MCP entitlement policy in agent-cli. Add opt-in hosted entitlement consumption, builder-code broadcast validation, identity registration metadata, and dry-run pricing evidence so local/BYO mode stays ungated while hosted paths fail closed where required. Co-authored-by: Cursor --- README.md | 47 +++++ cli/builder_fee.py | 25 ++- cli/commands/pair.py | 65 ++++++ cli/commands/trade.py | 17 ++ cli/hl_adapter.py | 18 +- cli/mcp_entitlements.py | 278 ++++++++++++++++++++++++++ cli/mcp_server.py | 62 ++++++ cli/web_auth.py | 77 +++++++ scripts/funded_btcswp_combined_run.py | 66 ++++++ scripts/pricing_aggregate.py | 10 +- scripts/pricing_experiment_suite.py | 39 +++- tests/test_builder_fee.py | 15 ++ tests/test_hl_adapter.py | 11 + tests/test_mcp_entitlements.py | 83 ++++++++ tests/test_pricing_aggregate.py | 2 + 15 files changed, 807 insertions(+), 8 deletions(-) create mode 100644 cli/mcp_entitlements.py create mode 100644 tests/test_mcp_entitlements.py diff --git a/README.md b/README.md index 6b3106a..59d7f6a 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,53 @@ MCP tools: `funding_hedge_propose`, `funding_hedge_backtest` --- +## Hosted MCP Entitlements + +Fully local/BYO MCP mode stays ungated by default. Hosted Nunchi MCP/tools or +Nunchi inference enforcement activates only when one of these is configured: + +- `NUNCHI_MCP_ENTITLEMENT_JSON`: inline web-auth `GET /api/entitlements/mcp` + response. +- `NUNCHI_MCP_ENTITLEMENT_FILE`: path to the entitlement JSON. +- `NUNCHI_CONNECTION_MODE=hosted-mcp-tools` or + `NUNCHI_CONNECTION_MODE=hosted-mcp-tools-inference`: fetch entitlement from + web-auth with the stored pair token. +- `NUNCHI_MCP_REQUIRE_ENTITLEMENT=true`: fail closed if no entitlement can be + fetched. + +When active, agent-cli enforces web-auth `allowedTools`, free/paid/safety tool +buckets, local free-call accounting, and model policy (`openrouter/auto` and +Fusion stay blocked unless the entitlement allows them). Safety-gated tools +still require explicit `confirm=true`. + +Register local agent identity with web-auth: + +```bash +hl pair register --agent-id local-mm-1 --agent-name "Local MM 1" --connection-mode clone-local +``` + +## Builder-Code Validation + +All direct Hyperliquid order broadcasts flow through `DirectHLProxy.place_order`. +That adapter fails closed before calling `exchange.order` unless valid Nunchi +builder-fee metadata is present. `hl trade --dry-run` prints the builder-code +status and does not submit. Live trade ledgers include builder-code metadata so +Mode 3 economics can be measured without implying Nunchi hosts the agent. + +## Pricing Dry Runs + +Use dry-run mode when OpenRouter keys or funded wallets are unavailable: + +```bash +python scripts/pricing_experiment_suite.py --dry-run-only +``` + +The suite writes `experiment_manifest.json` with blocked live measurements +instead of faking results. Fill-level validation still requires funded maker and +taker wallet env vars. + +--- + ## Strategies 14 built-in strategies across four categories. Every strategy extends `BaseStrategy` with a single `on_tick()` method — no shared state, no hidden coupling between strategies. diff --git a/cli/builder_fee.py b/cli/builder_fee.py index 1e9ae4f..3f3e563 100644 --- a/cli/builder_fee.py +++ b/cli/builder_fee.py @@ -10,12 +10,14 @@ from dataclasses import dataclass from typing import Any, Dict, Optional +NUNCHI_BUILDER_ADDRESS = "0x0D1DB1C800184A203915757BbbC0ee3A8E12FfB0" + @dataclass class BuilderFeeConfig: """Builder fee settings. Loaded from env vars or YAML config.""" - builder_address: str = "0x0D1DB1C800184A203915757BbbC0ee3A8E12FfB0" # Nunchi fee wallet + builder_address: str = NUNCHI_BUILDER_ADDRESS fee_rate_tenths_bps: int = 100 # 10 bps (0.1%) @property @@ -39,6 +41,27 @@ def to_builder_info(self) -> Optional[Dict[str, Any]]: return None return {"b": self.builder_address, "f": self.fee_rate_tenths_bps} + def metadata(self) -> Dict[str, Any]: + """Return ledger-safe builder-code metadata.""" + return { + "builder_code_required": True, + "builder_address": self.builder_address or None, + "builder_fee_tenths_bps": self.fee_rate_tenths_bps, + "builder_fee_bps": self.fee_bps, + "builder_fee_enabled": self.enabled, + } + + def validate_for_broadcast(self) -> None: + """Fail closed before a live order can leave agent-cli.""" + if not self.enabled: + raise RuntimeError( + "builder-code validation failed: BUILDER_ADDRESS and BUILDER_FEE_TENTHS_BPS must configure a positive Nunchi builder fee" + ) + if not isinstance(self.builder_address, str) or not self.builder_address.startswith("0x") or len(self.builder_address) != 42: + raise RuntimeError("builder-code validation failed: builder address must be a 20-byte hex address") + if self.fee_rate_tenths_bps <= 0: + raise RuntimeError("builder-code validation failed: builder fee must be positive") + @classmethod def from_env(cls) -> "BuilderFeeConfig": """Load from env vars, falling back to hardcoded defaults.""" diff --git a/cli/commands/pair.py b/cli/commands/pair.py index 88dabe2..d6369e9 100644 --- a/cli/commands/pair.py +++ b/cli/commands/pair.py @@ -37,6 +37,13 @@ def pair_connect( no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."), timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for browser approval."), app_name: str = typer.Option("HL Agent CLI", "--app-name", help="Display name shown on the authorize page."), + agent_id: str = typer.Option("", "--agent-id", help="Stable local agent id to include in pairing metadata."), + agent_name: str = typer.Option("", "--agent-name", help="Human-readable local agent name."), + connection_mode: str = typer.Option( + "clone-local", + "--connection-mode", + help="clone-local, hosted-mcp-tools, or hosted-mcp-tools-inference.", + ), ) -> None: _ensure_path() from cli.web_auth import PairingTimedOutError, get_stored_pairing, start_pairing @@ -69,6 +76,9 @@ def _on_polling() -> None: try: result = start_pairing( app_name=app_name, + agent_id=agent_id or None, + agent_name=agent_name or app_name, + connection_mode=connection_mode, no_browser=no_browser, on_url=_on_url, on_polling=_on_polling, @@ -125,6 +135,9 @@ def pair_status() -> None: typer.echo(f" paired: {_humanize_age(pairing.paired_at_ms)}") if pairing.account_id: typer.echo(f" account: {pairing.account_id}") + if pairing.agent_id: + typer.echo(f" agent: {pairing.agent_name or pairing.agent_id} ({pairing.agent_id})") + typer.echo(f" runtime: {pairing.runtime_location or 'local'} / {pairing.connection_mode or 'clone-local'}") if pairing.master_address: typer.echo(f" master: {pairing.master_address}") typer.echo(f" addresses ({len(pairing.addresses)}):") @@ -152,6 +165,10 @@ def pair_list() -> None: "ok": True, "label": pairing.label, "accountId": pairing.account_id, + "agentId": pairing.agent_id, + "agentName": pairing.agent_name, + "runtimeLocation": pairing.runtime_location, + "connectionMode": pairing.connection_mode, "masterAddress": pairing.master_address, "selectedAddress": pairing.selected_or_master_address, "wallets": [ @@ -190,6 +207,11 @@ def pair_open( account_id: str = typer.Option("", "--account-id", help="Optional account id for agent-wallet binding view."), agent_id: str = typer.Option("", "--agent-id", help="Optional agent id for agent-wallet binding view."), agent_name: str = typer.Option("", "--agent-name", help="Optional display name for the agent-wallet binding view."), + connection_mode: str = typer.Option( + "clone-local", + "--connection-mode", + help="clone-local, hosted-mcp-tools, or hosted-mcp-tools-inference.", + ), include_pair_token: bool = typer.Option( False, "--include-pair-token", @@ -204,6 +226,8 @@ def pair_open( account_id=account_id or None, agent_id=agent_id or None, agent_name=agent_name or None, + runtime_location="local", + connection_mode=connection_mode, include_pair_token=include_pair_token, ) typer.echo(f"web-auth: {url}") @@ -215,6 +239,7 @@ def pair_bind_role( account_id: str = typer.Option("", "--account-id", help="web-auth account id for the binding. Defaults to the paired account."), agent_id: str = typer.Option("", "--agent-id", help="Override agent id. Defaults to agent-cli-cost-e2e-."), agent_name: str = typer.Option("", "--agent-name", help="Override display name in web-auth."), + connection_mode: str = typer.Option("clone-local", "--connection-mode", help="Connection mode to tag in web-auth."), timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for the web-auth selection."), no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."), ) -> None: @@ -247,6 +272,8 @@ def pair_bind_role( account_id=resolved_account_id, agent_id=resolved_agent_id, agent_name=resolved_agent_name, + runtime_location="local", + connection_mode=connection_mode, include_pair_token=True, ) except PairingMissingError as exc: @@ -283,6 +310,44 @@ def _on_polling() -> None: typer.echo(f" agentId: {resolved_agent_id}") +@pair_app.command("register", help="Register or update this local agent identity in web-auth") +def pair_register( + agent_id: str = typer.Option(..., "--agent-id", help="Stable local agent id."), + agent_name: str = typer.Option("", "--agent-name", help="Human-readable agent name."), + account_id: str = typer.Option("", "--account-id", help="web-auth account id. Defaults to paired account."), + connection_mode: str = typer.Option( + "clone-local", + "--connection-mode", + help="clone-local, hosted-mcp-tools, or hosted-mcp-tools-inference.", + ), + json_output: bool = typer.Option(False, "--json", help="Print raw JSON response."), +) -> None: + _ensure_path() + from cli.web_auth import PairingInvalidError, PairingMissingError, register_agent + + try: + result = register_agent( + account_id=account_id or None, + agent_id=agent_id, + agent_name=agent_name or agent_id, + connection_mode=connection_mode, + ) + except (PairingMissingError, PairingInvalidError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + except Exception as exc: + typer.echo(f"Agent register failed: {exc}", err=True) + raise typer.Exit(1) + + if json_output: + typer.echo(json.dumps(result, indent=2)) + return + agent = result.get("agent") or {} + typer.echo(f"Registered agent: {agent.get('agentName') or agent_name or agent_id}") + typer.echo(f" agentId: {agent.get('agentId') or agent.get('agent_id') or agent_id}") + typer.echo(f" runtime: {agent.get('runtimeLocation') or 'local'} / {agent.get('connectionMode') or connection_mode}") + + @pair_app.command("roles", help="Show maker/taker wallet-role selections stored for this pairing") def pair_roles() -> None: _ensure_path() diff --git a/cli/commands/trade.py b/cli/commands/trade.py index 3da4df1..f6fb093 100644 --- a/cli/commands/trade.py +++ b/cli/commands/trade.py @@ -130,10 +130,25 @@ def trade_cmd( f"Placing {side.upper()} {size} {instrument} @ {price} ({tif}) on {network} " f"(notional=${notional_usd:.2f}, max=${notional_cap:.2f})" ) + builder_cfg = cfg.get_builder_config() + try: + builder_cfg.validate_for_broadcast() + builder_validation = "ok" + except RuntimeError as exc: + builder_validation = str(exc) + builder_info = builder_cfg.to_builder_info() + typer.echo( + "Builder-code: " + f"{builder_validation}; address={builder_cfg.builder_address or '-'}; " + f"fee_tenths_bps={builder_cfg.fee_rate_tenths_bps}" + ) if dry_run: typer.echo("Dry run: order not submitted.") return + if builder_validation != "ok": + typer.echo(builder_validation, err=True) + raise typer.Exit(1) if hl is None: private_key = cfg.get_private_key() @@ -148,6 +163,7 @@ def trade_cmd( size=size, price=price, tif=tif, + builder=builder_info, ) if fill: @@ -180,6 +196,7 @@ def trade_cmd( "strategy": "manual_trade", "route": "cli.trade", "network": network, + **builder_cfg.metadata(), }) else: typer.echo("No fill (order may have been rejected or not matched)") diff --git a/cli/hl_adapter.py b/cli/hl_adapter.py index 7a8498b..215e595 100644 --- a/cli/hl_adapter.py +++ b/cli/hl_adapter.py @@ -36,10 +36,24 @@ class APICircuitBreakerOpen(Exception): def _default_builder() -> Optional[dict]: """Return the default Nunchi builder fee. Always active unless overridden.""" from cli.builder_fee import BuilderFeeConfig - return BuilderFeeConfig().to_builder_info() + cfg = BuilderFeeConfig() + cfg.validate_for_broadcast() + return cfg.to_builder_info() ZERO = Decimal("0") +def _validate_builder_info(builder: Optional[dict]) -> dict: + if not isinstance(builder, dict): + raise RuntimeError("builder-code validation failed: missing builder fee metadata") + address = builder.get("b") + fee = builder.get("f") + if not isinstance(address, str) or not address.startswith("0x") or len(address) != 42: + raise RuntimeError("builder-code validation failed: invalid builder address") + if not isinstance(fee, int) or fee <= 0: + raise RuntimeError("builder-code validation failed: builder fee must be a positive integer") + return builder + + def _to_hl_coin(instrument: str) -> str: """Map instrument name to HL coin for API calls. @@ -297,6 +311,7 @@ def place_order( # This is the sole enforcement point — all order paths flow through here. if builder is None: builder = _default_builder() + builder = _validate_builder_info(builder) coin = _to_hl_coin(instrument) is_buy = side.lower() == "buy" @@ -474,6 +489,7 @@ def place_trigger_order(self, instrument: str, side: str, size: float, trigger_p """ if builder is None: builder = _default_builder() + builder = _validate_builder_info(builder) coin = self._to_coin(instrument) is_buy = side.lower() == "buy" sz = self._round_size(coin, size) diff --git a/cli/mcp_entitlements.py b/cli/mcp_entitlements.py new file mode 100644 index 0000000..6076aea --- /dev/null +++ b/cli/mcp_entitlements.py @@ -0,0 +1,278 @@ +"""Hosted MCP entitlement policy for the local MCP server. + +The policy is intentionally inactive unless Nunchi entitlement context is +configured. That keeps fully local/BYO `agent-cli` MCP usage ungated while +allowing hosted MCP/tools and Nunchi-inference modes to consume the same +entitlement JSON returned by web-auth. +""" +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Optional + +import requests + +from cli.web_auth import PAIR_API_BASE, get_stored_pairing + +FREE_TOOLS = { + "strategies", + "builder_status", + "wallet_list", + "setup_check", + "pair_status", + "account", + "status", + "funding_hedge_propose", + "funding_hedge_backtest", + "apex_status", + "agent_memory", + "trade_journal", + "judge_report", + "obsidian_context", + "money_bridge_status", +} +PAID_COMPUTE_TOOLS = {"run_strategy", "radar_run", "apex_run", "reflect_run", "hedge_agent_smoke_test"} +SAFETY_GATED_TOOLS = { + "trade", + "money_withdraw", + "money_transfer_usd", + "money_deposit", + "approve_agent", + "wallet_auto", +} +DEFAULT_TOOL_BUCKETS = { + "free": sorted(FREE_TOOLS), + "paidCompute": sorted(PAID_COMPUTE_TOOLS), + "safetyGated": sorted(SAFETY_GATED_TOOLS), +} + +STATE_PATH = Path(os.environ.get("NUNCHI_MCP_ENTITLEMENT_STATE", "~/.hl-agent/mcp-entitlement-state.json")).expanduser() + + +@dataclass +class EntitlementDecision: + allowed: bool + reason: str = "" + + def as_message(self, tool_name: str) -> str: + return f"Refusing MCP tool `{tool_name}`: {self.reason}" + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _read_json_file(path: Path) -> Optional[dict[str, Any]]: + try: + parsed = json.loads(path.read_text("utf-8")) + except (OSError, json.JSONDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _configured_mode_requires_entitlement() -> bool: + mode = os.environ.get("NUNCHI_CONNECTION_MODE") or os.environ.get("NUNCHI_MCP_CONNECTION_MODE") + if mode in {"hosted-mcp-tools", "hosted-mcp-tools-inference"}: + return True + return _truthy(os.environ.get("NUNCHI_MCP_REQUIRE_ENTITLEMENT")) + + +def load_entitlement() -> Optional[dict[str, Any]]: + """Load entitlement JSON from env, file, or web-auth pair token. + + Returning None means local/BYO mode: do not apply hosted MCP gating. + """ + inline = os.environ.get("NUNCHI_MCP_ENTITLEMENT_JSON") + if inline: + try: + parsed = json.loads(inline) + except json.JSONDecodeError: + return {"ok": False, "error": "invalid_NUNCHI_MCP_ENTITLEMENT_JSON"} + return parsed if isinstance(parsed, dict) else {"ok": False, "error": "entitlement_json_not_object"} + + file_path = os.environ.get("NUNCHI_MCP_ENTITLEMENT_FILE") + if file_path: + return _read_json_file(Path(file_path).expanduser()) or {"ok": False, "error": "entitlement_file_unreadable"} + + if not _configured_mode_requires_entitlement(): + return None + + pairing = get_stored_pairing() + if pairing is None: + return {"ok": False, "error": "hosted_mcp_entitlement_required_but_no_pairing"} + try: + resp = requests.get( + f"{PAIR_API_BASE}/api/entitlements/mcp", + headers={"Authorization": f"Bearer {pairing.token}", "Accept": "application/json"}, + timeout=10, + ) + except requests.RequestException as exc: + return {"ok": False, "error": f"entitlement_fetch_failed:{exc.__class__.__name__}"} + if not resp.ok: + return {"ok": False, "error": f"entitlement_fetch_http_{resp.status_code}"} + try: + parsed = resp.json() + except ValueError: + return {"ok": False, "error": "entitlement_fetch_invalid_json"} + return parsed if isinstance(parsed, dict) else {"ok": False, "error": "entitlement_fetch_not_object"} + + +def _normalise_buckets(entitlement: Mapping[str, Any]) -> dict[str, set[str]]: + raw = entitlement.get("toolBuckets") + if not isinstance(raw, Mapping): + raw = DEFAULT_TOOL_BUCKETS + return { + "free": set(str(item) for item in raw.get("free", []) if item), + "paidCompute": set(str(item) for item in raw.get("paidCompute", []) if item), + "safetyGated": set(str(item) for item in raw.get("safetyGated", []) if item), + } + + +def _tool_bucket(tool_name: str, entitlement: Mapping[str, Any]) -> str: + buckets = _normalise_buckets(entitlement) + if tool_name in buckets["safetyGated"]: + return "safetyGated" + if tool_name in buckets["paidCompute"]: + return "paidCompute" + return "free" + + +def _allowed_tools(entitlement: Mapping[str, Any]) -> set[str]: + raw = entitlement.get("allowedTools") + if isinstance(raw, list): + return {str(item) for item in raw if item} + buckets = _normalise_buckets(entitlement) + return set().union(*buckets.values()) + + +def _policy_key(entitlement: Mapping[str, Any]) -> str: + stable = { + "tier": entitlement.get("tier"), + "planId": entitlement.get("planId"), + "subscription": (entitlement.get("subscription") or {}).get("subscriptionId") + if isinstance(entitlement.get("subscription"), Mapping) + else None, + } + return hashlib.sha256(json.dumps(stable, sort_keys=True).encode("utf-8")).hexdigest()[:20] + + +def _read_state() -> dict[str, Any]: + return _read_json_file(STATE_PATH) or {} + + +def _write_state(state: Mapping[str, Any]) -> None: + try: + STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + STATE_PATH.write_text(json.dumps(state, indent=2) + "\n", "utf-8") + STATE_PATH.chmod(0o600) + except OSError: + pass + + +def _period_key() -> str: + return time.strftime("%Y-%m") + + +def _local_calls_used(entitlement: Mapping[str, Any]) -> int: + state = _read_state() + scoped = state.get(_policy_key(entitlement), {}) + period = scoped.get(_period_key(), {}) + try: + return int(period.get("mcpCalls", 0)) + except (TypeError, ValueError): + return 0 + + +def _record_allowed_call(entitlement: Mapping[str, Any], tool_name: str) -> None: + state = _read_state() + key = _policy_key(entitlement) + period = _period_key() + scoped = state.setdefault(key, {}) + bucket = scoped.setdefault(period, {}) + bucket["mcpCalls"] = int(bucket.get("mcpCalls", 0)) + 1 + bucket["updatedAt"] = int(time.time() * 1000) + bucket["lastTool"] = tool_name + _write_state(state) + + +def _model_policy_allows(model: str, entitlement: Mapping[str, Any]) -> EntitlementDecision: + policy = entitlement.get("modelPolicy") + if not isinstance(policy, Mapping): + return EntitlementDecision(True) + normalized = model.lower() + if normalized == "openrouter/auto" and not bool(policy.get("allowAuto", False)): + return EntitlementDecision(False, "model policy blocks openrouter/auto; use the tier default or upgrade") + if "fusion" in normalized and not bool(policy.get("allowFusion", False)): + return EntitlementDecision(False, "model policy blocks Fusion routes; use the tier default or upgrade") + return EntitlementDecision(True) + + +def check_tool_call( + tool_name: str, + *, + entitlement: Optional[Mapping[str, Any]] = None, + confirm: bool = False, + model: Optional[str] = None, + record: bool = True, +) -> EntitlementDecision: + entitlement = entitlement if entitlement is not None else load_entitlement() + if entitlement is None: + return EntitlementDecision(True) + if entitlement.get("ok") is False: + return EntitlementDecision(False, str(entitlement.get("error") or "invalid entitlement")) + + allowed = _allowed_tools(entitlement) + if allowed and tool_name not in allowed: + return EntitlementDecision(False, f"tool is outside allowedTools for tier {entitlement.get('tier') or 'unknown'}") + + bucket = _tool_bucket(tool_name, entitlement) + if bucket == "safetyGated" and not confirm: + return EntitlementDecision(False, "safety-gated tool requires confirm=true") + + if model: + model_decision = _model_policy_allows(model, entitlement) + if not model_decision.allowed: + return model_decision + + limit = entitlement.get("mcpCallLimit") + try: + limit_int = int(limit) + except (TypeError, ValueError): + limit_int = 0 + used = int(entitlement.get("mcpCallsUsed") or 0) + _local_calls_used(entitlement) + if limit_int > 0 and used >= limit_int: + return EntitlementDecision(False, f"MCP call limit exceeded ({used}/{limit_int}); upgrade or refresh entitlement") + + if record: + _record_allowed_call(entitlement, tool_name) + return EntitlementDecision(True) + + +def current_model_for_tool(tool_name: str) -> Optional[str]: + if tool_name == "reflect_run": + return os.environ.get("NUNCHI_REFLECT_MODEL") or os.environ.get("AI_MODEL") + if tool_name in PAID_COMPUTE_TOOLS: + return os.environ.get("AI_MODEL") or os.environ.get("OPENROUTER_MODEL") + return None + + +def entitlement_summary() -> dict[str, Any]: + entitlement = load_entitlement() + if entitlement is None: + return {"mode": "local_byo", "enforced": False} + return { + "mode": "configured", + "enforced": True, + "tier": entitlement.get("tier"), + "planId": entitlement.get("planId"), + "status": entitlement.get("status"), + "mcpCallLimit": entitlement.get("mcpCallLimit"), + "mcpCallsUsed": entitlement.get("mcpCallsUsed"), + "localCallsUsed": _local_calls_used(entitlement), + "modelPolicy": entitlement.get("modelPolicy"), + } diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 67ea6f4..8aee8e5 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -11,6 +11,15 @@ from pathlib import Path from typing import Optional +from cli.mcp_entitlements import check_tool_call, current_model_for_tool, entitlement_summary + + +def _policy_denial(tool_name: str, *, confirm: bool = False, model: Optional[str] = None) -> Optional[str]: + decision = check_tool_call(tool_name, confirm=confirm, model=model) + if decision.allowed: + return None + return decision.as_message(tool_name) + def _run_hl(*args: str, timeout: int = 30) -> str: """Run an hl CLI command via subprocess and return stdout.""" @@ -64,6 +73,8 @@ def create_mcp_server(): @mcp.tool() def strategies() -> str: """List all available trading strategies with descriptions and default parameters.""" + if denial := _policy_denial("strategies"): + return denial from cli.strategy_registry import STRATEGY_REGISTRY, YEX_MARKETS result = {"strategies": {}, "yex_markets": {}} @@ -83,6 +94,8 @@ def strategies() -> str: @mcp.tool() def builder_status() -> str: """Get builder fee configuration status.""" + if denial := _policy_denial("builder_status"): + return denial from cli.config import TradingConfig cfg = TradingConfig() @@ -98,6 +111,8 @@ def builder_status() -> str: @mcp.tool() def wallet_list() -> str: """List saved encrypted keystores.""" + if denial := _policy_denial("wallet_list"): + return denial from cli.keystore import list_keystores keystores = list_keystores() @@ -118,6 +133,8 @@ def wallet_auto(save_env: bool = True, confirm: bool = False) -> str: if not confirm: return "Refusing to create a wallet without confirm=true." + if denial := _policy_denial("wallet_auto", confirm=confirm): + return denial password = secrets.token_urlsafe(32) account = Account.create() @@ -141,6 +158,8 @@ def wallet_auto(save_env: bool = True, confirm: bool = False) -> str: @mcp.tool() def setup_check() -> str: """Validate environment — SDK, keys, network, builder fee.""" + if denial := _policy_denial("setup_check"): + return denial import os from cli.keystore import list_keystores from cli.config import TradingConfig @@ -192,12 +211,15 @@ def setup_check() -> str: "ok": ok_items, "warnings": warnings, "issues": issues, + "mcpEntitlement": entitlement_summary(), "passed": len(issues) == 0, }, indent=2) @mcp.tool() def pair_status() -> str: """Show web-auth paired wallet status.""" + if denial := _policy_denial("pair_status"): + return denial return _run_hl("pair", "status") @mcp.tool() @@ -219,6 +241,8 @@ def funding_hedge_propose( funding_rate_8h: 8h funding rate as a decimal, used if funding_apr is omitted. vol_multiplier: BTCSWP hedge multiplier. Default 15 means 1/15 notional. """ + if denial := _policy_denial("funding_hedge_propose"): + return denial from modules.funding_hedge import propose_funding_hedge try: @@ -255,6 +279,8 @@ def funding_hedge_backtest( perp_notional_usd: Absolute perp notional in USD. vol_multiplier: BTCSWP hedge multiplier. Default 15 means 1/15 notional. """ + if denial := _policy_denial("funding_hedge_backtest"): + return denial from modules.funding_hedge import backtest_funding_hedge_csv try: @@ -272,6 +298,8 @@ def funding_hedge_backtest( @mcp.tool() def account(mainnet: bool = False) -> str: """Get Hyperliquid account state (balances, positions).""" + if denial := _policy_denial("account"): + return denial # Account requires live HL connection — use subprocess for isolation args = ["account"] if mainnet: @@ -281,6 +309,8 @@ def account(mainnet: bool = False) -> str: @mcp.tool() def status() -> str: """Show current positions, PnL, and risk state.""" + if denial := _policy_denial("status"): + return denial return _run_hl("status") # ------------------------------------------------------------------ @@ -320,6 +350,8 @@ def trade( """ if not confirm and not dry_run: return "Refusing to trade without confirm=true or dry_run=true." + if denial := _policy_denial("trade", confirm=confirm or dry_run): + return denial args = [ "trade", instrument, @@ -356,6 +388,8 @@ def approve_agent(confirm: bool = False, mainnet: bool = False) -> str: """ if not confirm: return "Refusing to approve agent without confirm=true." + if denial := _policy_denial("approve_agent", confirm=confirm): + return denial args = ["pair", "approve-agent", "--yes"] if mainnet: args.append("--mainnet") @@ -373,6 +407,8 @@ def money_withdraw(amount: str, destination: str, confirm: bool = False, mainnet """ if not confirm: return "Refusing to move funds without confirm=true." + if denial := _policy_denial("money_withdraw", confirm=confirm): + return denial args = ["money", "withdraw", amount, destination, "--yes"] if mainnet: args.append("--mainnet") @@ -390,6 +426,8 @@ def money_transfer_usd(amount: str, destination: str, confirm: bool = False, mai """ if not confirm: return "Refusing to move funds without confirm=true." + if denial := _policy_denial("money_transfer_usd", confirm=confirm): + return denial args = ["money", "transfer", "usd", amount, destination, "--yes"] if mainnet: args.append("--mainnet") @@ -406,6 +444,8 @@ def money_deposit(amount: str, confirm: bool = False, mainnet: bool = False) -> """ if not confirm: return "Refusing to move funds without confirm=true." + if denial := _policy_denial("money_deposit", confirm=confirm): + return denial args = ["money", "deposit", amount, "--yes"] if mainnet: args.append("--mainnet") @@ -414,6 +454,8 @@ def money_deposit(amount: str, confirm: bool = False, mainnet: bool = False) -> @mcp.tool() def money_bridge_status() -> str: """Explain why cross-chain bridge support is not enabled yet.""" + if denial := _policy_denial("money_bridge_status"): + return denial return _run_hl("money", "bridge") @mcp.tool() @@ -437,6 +479,8 @@ def run_strategy( dry_run: Log decisions without placing orders mainnet: Use mainnet instead of testnet """ + if denial := _policy_denial("run_strategy", model=current_model_for_tool("run_strategy")): + return denial args = ["run", strategy, "-i", instrument, "-t", str(tick)] if max_ticks is not None: args.extend(["--max-ticks", str(max_ticks)]) @@ -486,6 +530,8 @@ def hedge_agent_smoke_test( return "Refusing to move testnet USDC without confirm_send_testnet_usdc=true." if send_testnet_usdc and not sam_address: return "Refusing to move testnet USDC without sam_address." + if denial := _policy_denial("hedge_agent_smoke_test", model=current_model_for_tool("hedge_agent_smoke_test")): + return denial args = [ "--instrument", instrument, @@ -508,6 +554,8 @@ def hedge_agent_smoke_test( @mcp.tool() def radar_run(mock: bool = False) -> str: """Run opportunity radar — screen HL perps for trading setups.""" + if denial := _policy_denial("radar_run", model=current_model_for_tool("radar_run")): + return denial args = ["radar", "once"] if mock: args.append("--mock") @@ -516,6 +564,8 @@ def radar_run(mock: bool = False) -> str: @mcp.tool() def apex_status() -> str: """Get APEX orchestrator status (slots, positions, daily PnL).""" + if denial := _policy_denial("apex_status"): + return denial return _run_hl("apex", "status") @mcp.tool() @@ -533,6 +583,8 @@ def apex_run( preset: Strategy preset (default, conservative, aggressive) mainnet: Use mainnet """ + if denial := _policy_denial("apex_run", model=current_model_for_tool("apex_run")): + return denial args = ["apex", "run", "--preset", preset] if mock: args.append("--mock") @@ -549,6 +601,8 @@ def reflect_run(since: Optional[str] = None) -> str: Args: since: Start date for analysis (YYYY-MM-DD). Default: since last report. """ + if denial := _policy_denial("reflect_run", model=current_model_for_tool("reflect_run")): + return denial args = ["reflect", "run"] if since: args.extend(["--since", since]) @@ -567,6 +621,8 @@ def agent_memory(query_type: str = "recent", limit: int = 20, event_type: Option limit: Max events to return (default 20) event_type: Filter by type (param_change, reflect_review, notable_trade, judge_finding, session_start, session_end) """ + if denial := _policy_denial("agent_memory"): + return denial from modules.memory_guard import MemoryGuard guard = MemoryGuard() @@ -585,6 +641,8 @@ def trade_journal(date: Optional[str] = None, limit: int = 20) -> str: date: Filter by date (YYYY-MM-DD). Default: all dates. limit: Max entries to return (default 20) """ + if denial := _policy_denial("trade_journal"): + return denial from modules.journal_guard import JournalGuard guard = JournalGuard() @@ -594,6 +652,8 @@ def trade_journal(date: Optional[str] = None, limit: int = 20) -> str: @mcp.tool() def judge_report() -> str: """Get latest Judge evaluation — signal quality, false positive rates, recommendations.""" + if denial := _policy_denial("judge_report"): + return denial from modules.judge_guard import JudgeGuard guard = JudgeGuard() @@ -605,6 +665,8 @@ def judge_report() -> str: @mcp.tool() def obsidian_context() -> str: """Read trading context from Obsidian vault — watchlists, market theses, risk preferences.""" + if denial := _policy_denial("obsidian_context"): + return denial from modules.obsidian_reader import ObsidianReader reader = ObsidianReader() diff --git a/cli/web_auth.py b/cli/web_auth.py index 5f0a257..e750cd4 100644 --- a/cli/web_auth.py +++ b/cli/web_auth.py @@ -55,6 +55,10 @@ class PairingResult: active_session: Optional[dict[str, Any]] = None agent_wallet_binding: Optional[dict[str, Any]] = None role_addresses: Optional[dict[str, str]] = None + agent_id: Optional[str] = None + agent_name: Optional[str] = None + runtime_location: str = "local" + connection_mode: str = "clone-local" def to_json(self) -> dict[str, Any]: return asdict(self) @@ -72,6 +76,10 @@ def from_json(cls, raw: dict[str, Any]) -> "PairingResult": active_session=raw.get("active_session") or raw.get("activeSession"), agent_wallet_binding=raw.get("agent_wallet_binding") or raw.get("agentWalletBinding"), role_addresses=raw.get("role_addresses") or raw.get("roleAddresses") or {}, + agent_id=raw.get("agent_id") or raw.get("agentId"), + agent_name=raw.get("agent_name") or raw.get("agentName"), + runtime_location=raw.get("runtime_location") or raw.get("runtimeLocation") or "local", + connection_mode=raw.get("connection_mode") or raw.get("connectionMode") or "clone-local", ) @property @@ -212,12 +220,20 @@ def _auth_headers(pairing: PairingResult) -> dict[str, str]: return {"Authorization": f"Bearer {pairing.token}", "Accept": "application/json"} +def normalize_connection_mode(value: Optional[str]) -> str: + if value in {"clone-local", "hosted-mcp-tools", "hosted-mcp-tools-inference"}: + return value + return "clone-local" + + def open_wallet_ui( *, no_browser: bool = False, account_id: Optional[str] = None, agent_id: Optional[str] = None, agent_name: Optional[str] = None, + runtime_location: str = "local", + connection_mode: str = "clone-local", include_pair_token: bool = False, ) -> str: pairing = get_stored_pairing() @@ -232,6 +248,8 @@ def open_wallet_ui( params.append(("agentId", agent_id)) if agent_name: params.append(("agentName", agent_name)) + params.append(("runtimeLocation", runtime_location or "local")) + params.append(("connectionMode", normalize_connection_mode(connection_mode))) if include_pair_token and pairing is not None: params.append(("pairToken", pairing.token)) separator = "&" if "?" in WALLET_AUTH_URL else "?" @@ -331,6 +349,9 @@ def _open_browser(url: str, no_browser: bool = False) -> None: def start_pairing( app_name: str = "HL Agent CLI", deep_link: Optional[str] = None, + agent_id: Optional[str] = None, + agent_name: Optional[str] = None, + connection_mode: str = "clone-local", on_polling: Optional[Callable[[], None]] = None, no_browser: bool = False, on_url: Optional[Callable[[str], None]] = None, @@ -341,6 +362,12 @@ def start_pairing( params: list[tuple[str, str]] = [("code", code), ("app", app_name)] if deep_link: params.append(("redirect", deep_link)) + if agent_id: + params.append(("agentId", agent_id)) + if agent_name: + params.append(("agentName", agent_name)) + params.append(("runtimeLocation", "local")) + params.append(("connectionMode", normalize_connection_mode(connection_mode))) url = f"{AUTHORIZE_URL}?{urlencode(params)}" if on_url: @@ -381,6 +408,10 @@ def start_pairing( master_address=body.get("masterAddress"), active_session=body.get("activeSession"), agent_wallet_binding=body.get("agentWalletBinding"), + agent_id=body.get("agentId") or agent_id, + agent_name=body.get("agentName") or agent_name or app_name, + runtime_location=body.get("runtimeLocation") or "local", + connection_mode=body.get("connectionMode") or normalize_connection_mode(connection_mode), ) _persist(result) return result @@ -408,6 +439,52 @@ def verify_pairing() -> Optional[dict[str, Any]]: return resp.json() +def register_agent( + *, + account_id: Optional[str] = None, + agent_id: str, + agent_name: Optional[str] = None, + connection_mode: str = "clone-local", + extra: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + pairing = require_pairing() + resolved_account_id = account_id or pairing.account_id + if not resolved_account_id: + raise PairingInvalidError("pairing does not include an account id") + mode = normalize_connection_mode(connection_mode) + name = agent_name or pairing.agent_name or agent_id + record: dict[str, Any] = { + "agentId": agent_id, + "agent_id": agent_id, + "agentName": name, + "name": name, + "runtimeLocation": "local", + "runtime_location": "local", + "connectionMode": mode, + "connection_mode": mode, + "accountId": resolved_account_id, + "account_id": resolved_account_id, + } + if extra: + record.update(extra) + resp = requests.post( + f"{PAIR_API_BASE}/api/agents/register", + headers={**_auth_headers(pairing), "Content-Type": "application/json"}, + json={"accountId": resolved_account_id, "agentId": agent_id, "agent": record}, + timeout=15, + ) + if resp.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not resp.ok: + raise RuntimeError(f"/api/agents/register returned {resp.status_code}: {resp.text[:200]}") + pairing.agent_id = agent_id + pairing.agent_name = name + pairing.runtime_location = "local" + pairing.connection_mode = mode + _persist(pairing) + return resp.json() + + def sign_with_pair( typed_data: dict[str, Any], summary: str = "", diff --git a/scripts/funded_btcswp_combined_run.py b/scripts/funded_btcswp_combined_run.py index 4974b9a..24a6e17 100644 --- a/scripts/funded_btcswp_combined_run.py +++ b/scripts/funded_btcswp_combined_run.py @@ -22,8 +22,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from cli.builder_fee import BuilderFeeConfig # noqa: E402 from modules.cost_metering import CostMeter, ExperimentContext # noqa: E402 from modules.openrouter_usage import extract_cache_metrics, usage_cost, usage_value # noqa: E402 +from parent.store import JSONLStore # noqa: E402 def _hyperliquid_info(payload: dict, *, testnet: bool = True) -> Any: @@ -186,6 +188,43 @@ def _run_trade( return result +def _record_dry_run_trade( + *, + args: argparse.Namespace, + context: ExperimentContext, + decision_call_id: str, + generation_id: Optional[str], + side: str, + tif: str, +) -> None: + trade_log = JSONLStore(os.environ.get("NUNCHI_TRADE_LEDGER_PATH") or str(Path(args.data_dir) / "trades.jsonl")) + builder_cfg = BuilderFeeConfig.from_env() + trade_log.append({ + **context.ledger_fields(), + "ts": int(time.time() * 1000), + "tick": args.tick_index, + "tick_index": args.tick_index, + "decision_call_id": decision_call_id, + "generation_id": generation_id, + "oid": None, + "cloid": None, + "instrument": args.instrument, + "side": side, + "price": str(args.price), + "quantity": str(args.size), + "notional_usd": str(abs(args.price * args.size)), + "timestamp_ms": int(time.time() * 1000), + "fee": "0", + "strategy": "funded_btcswp_combined_run", + "route": "scripts.funded_btcswp_combined_run", + "network": "mainnet" if args.mainnet else "testnet", + "tif": tif, + "dry_run": True, + "fill_status": "dry_run_no_submission", + **builder_cfg.metadata(), + }) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Funded BTCSWP combined cost/fill smoke run") parser.add_argument("--instrument", default="osrs:BTCSWP") @@ -235,6 +274,15 @@ def main() -> int: generation_id=generation_id, tif="Alo", ) + if args.dry_run or not args.confirm: + _record_dry_run_trade( + args=args, + context=context, + decision_call_id=decision_call_id, + generation_id=generation_id, + side=maker_side, + tif="Alo", + ) _run_trade( args=args, side=args.side, @@ -243,6 +291,15 @@ def main() -> int: generation_id=generation_id, tif="Ioc", ) + if args.dry_run or not args.confirm: + _record_dry_run_trade( + args=args, + context=context, + decision_call_id=decision_call_id, + generation_id=generation_id, + side=args.side, + tif="Ioc", + ) validate_address = os.environ.get(args.taker_address_env, "") else: _run_trade( @@ -253,6 +310,15 @@ def main() -> int: generation_id=generation_id, tif="Ioc", ) + if args.dry_run or not args.confirm: + _record_dry_run_trade( + args=args, + context=context, + decision_call_id=decision_call_id, + generation_id=generation_id, + side=args.side, + tif="Ioc", + ) validate_address = os.environ.get("HL_ADDRESS", "") if args.validate_fills and args.confirm and validate_address: diff --git a/scripts/pricing_aggregate.py b/scripts/pricing_aggregate.py index ef7b83c..0d1f93e 100644 --- a/scripts/pricing_aggregate.py +++ b/scripts/pricing_aggregate.py @@ -115,7 +115,10 @@ def aggregate(args: argparse.Namespace) -> int: cost_by_decision[str(decision_call_id)] += _decimal(row.get("usd_cost")) linked_trade_cost = Decimal("0") linked_trade_count = 0 + dry_run_count = len([r for r in trades if r.get("dry_run")]) for row in trades: + if row.get("dry_run"): + continue decision_call_id = row.get("decision_call_id") if decision_call_id and str(decision_call_id) in cost_by_decision: linked_trade_count += 1 @@ -185,6 +188,7 @@ def aggregate(args: argparse.Namespace) -> int: "cache_hit_rate": cache_hit_rate, "cache_savings_total": cache_savings_total, "linked_trade_count": linked_trade_count, + "dry_run_count": dry_run_count, "avg_llm_per_linked_fill": avg_llm_per_linked_fill, "observability_total": observability_total, "total": total, @@ -247,15 +251,15 @@ def _render_markdown(input_dir: Path, rows: List[dict], incidents: List[dict], a "", "## Cost By Job Type", "", - "| Job Type | Users | Accounts | Agents | Subs | Hours | Heartbeats | Linked Fills | Avg LLM/Linked Fill | Cache Hit | Cached Tokens | Cache Savings | LLM | Infra | Fees | Total | USD/Heartbeat | USD/Month | p95 Monthly COGS | Recommended |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + "| Job Type | Users | Accounts | Agents | Subs | Hours | Heartbeats | Linked Live Fills | Dry Runs | Avg LLM/Linked Fill | Cache Hit | Cached Tokens | Cache Savings | LLM | Infra | Fees | Total | USD/Heartbeat | USD/Month | p95 Monthly COGS | Recommended |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ]) for row in rows: lines.append( f"| `{row['job_type']}` | {row['user_count']} | {row['account_count']} | {row['agent_count']} | " f"{row['subscription_count']} | {float(row['duration_hours']):.2f} | " - f"{row['heartbeat_count']} | {row['linked_trade_count']} | {_money(row['avg_llm_per_linked_fill'])} | " + f"{row['heartbeat_count']} | {row['linked_trade_count']} | {row['dry_run_count']} | {_money(row['avg_llm_per_linked_fill'])} | " f"{float(row['cache_hit_rate']) * 100:.1f}% | {int(row['cached_token_total'])} | {_money(row['cache_savings_total'])} | " f"{_money(row['llm_total'])} | {_money(row['infra_total'])} | " f"{_money(row['fees_total'])} | {_money(row['total'])} | {_money(row['usd_per_heartbeat'])} | " diff --git a/scripts/pricing_experiment_suite.py b/scripts/pricing_experiment_suite.py index 219d899..38fe839 100644 --- a/scripts/pricing_experiment_suite.py +++ b/scripts/pricing_experiment_suite.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Run bounded pricing experiments and emit aggregate reports.""" +"""Run bounded MCP/inference pricing experiments and emit aggregate reports.""" from __future__ import annotations import argparse +import json import os import subprocess import sys @@ -21,7 +22,7 @@ def _run(cmd: list[str], *, env: dict | None = None) -> int: def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run hosted-agent pricing experiment suite") + parser = argparse.ArgumentParser(description="Run mode-specific MCP/inference pricing experiment suite") parser.add_argument( "--suite", choices=["cache", "monitoring", "hedge_heartbeat", "combined_dry_run", "all"], @@ -30,17 +31,45 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--experiment-id", default=f"pricing-suite-{time.strftime('%Y%m%d')}-{uuid.uuid4().hex[:6]}") parser.add_argument("--data-root", default="data/pricing_suite") parser.add_argument("--skip-live", action="store_true", help="Skip OpenRouter live calls") + parser.add_argument("--dry-run-only", action="store_true", help="Run only non-funded/non-secret dry-run measurements") parser.add_argument("--combined-price", type=float, default=24000.0) return parser.parse_args() +def _write_manifest(root: Path, args: argparse.Namespace, blocked: list[str]) -> None: + manifest = { + "experiment_id": args.experiment_id, + "suite": args.suite, + "skip_live": bool(args.skip_live), + "dry_run_only": bool(args.dry_run_only), + "generated_at_ms": int(time.time() * 1000), + "modes": { + "mode_1_hosted_mcp_tools": "dry-run/runtime allocation only unless Railway runner metrics are supplied", + "mode_2_hosted_mcp_tools_inference": "OpenRouter measurements skipped when --skip-live or --dry-run-only is set", + "mode_3_clone_local": "builder-fee economics reported from dry-run/ledger metadata; fill validation needs funded wallets", + }, + "blocked_live_measurements": blocked, + } + (root / "experiment_manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", "utf-8") + + def main() -> int: args = parse_args() + if args.dry_run_only: + args.skip_live = True + if args.suite == "all": + args.suite = "combined_dry_run" root = Path(args.data_root) / args.experiment_id root.mkdir(parents=True, exist_ok=True) env = os.environ.copy() env.setdefault("HL_TESTNET", "true") exit_code = 0 + blocked = [] + if args.skip_live: + blocked.append("OpenRouter anchor measurements skipped because --skip-live/--dry-run-only was set.") + if not (os.environ.get("HL_TESTNET_MAKER_PRIVATE_KEY") and os.environ.get("HL_TESTNET_TAKER_PRIVATE_KEY")): + blocked.append("Fill-level maker/taker validation blocked: funded HL_TESTNET_MAKER_PRIVATE_KEY and HL_TESTNET_TAKER_PRIVATE_KEY are not configured.") + _write_manifest(root, args, blocked) if args.suite in {"cache", "all"} and not args.skip_live: cache_dir = root / "cache" @@ -137,9 +166,13 @@ def main() -> int: exit_code = exit_code or code if not args.skip_live: _run([sys.executable, "scripts/validate_combined_ledger.py", "--input-dir", str(combined_dir)]) - _run([sys.executable, "scripts/pricing_aggregate.py", "--input-dir", str(combined_dir)]) + _run([sys.executable, "scripts/pricing_aggregate.py", "--input-dir", str(combined_dir)]) print(f"Experiment suite complete under {root}") + if blocked: + print("Blocked live measurements:") + for item in blocked: + print(f"- {item}") return exit_code diff --git a/tests/test_builder_fee.py b/tests/test_builder_fee.py index d001a39..5ecfa1f 100644 --- a/tests/test_builder_fee.py +++ b/tests/test_builder_fee.py @@ -82,6 +82,21 @@ def test_fee_bps_fractional(self): assert cfg.fee_bps == 0.5 assert cfg.max_fee_rate_str == "0.005%" + def test_validate_for_broadcast_rejects_disabled(self): + cfg = BuilderFeeConfig(builder_address="", fee_rate_tenths_bps=0) + with pytest.raises(RuntimeError, match="builder-code validation failed"): + cfg.validate_for_broadcast() + + def test_metadata_marks_builder_required(self): + cfg = BuilderFeeConfig(builder_address="0x0000000000000000000000000000000000000001", fee_rate_tenths_bps=10) + assert cfg.metadata() == { + "builder_code_required": True, + "builder_address": "0x0000000000000000000000000000000000000001", + "builder_fee_tenths_bps": 10, + "builder_fee_bps": 1.0, + "builder_fee_enabled": True, + } + # --------------------------------------------------------------------------- # TradingConfig integration diff --git a/tests/test_hl_adapter.py b/tests/test_hl_adapter.py index 24d5176..7e5ada8 100644 --- a/tests/test_hl_adapter.py +++ b/tests/test_hl_adapter.py @@ -16,6 +16,7 @@ CIRCUIT_BREAKER_THRESHOLD, MAX_RATE_LIMIT_RETRIES, _to_hl_coin, + _validate_builder_info, ) @@ -194,6 +195,16 @@ def test_empty_statuses_returns_none(self): fill = proxy.place_order("ETH-PERP", "buy", 1.0, 2500.0) assert fill is None + def test_invalid_builder_metadata_fails_before_exchange_order(self): + proxy = _make_proxy() + with pytest.raises(RuntimeError, match="builder-code validation failed"): + proxy.place_order("ETH-PERP", "buy", 1.0, 2500.0, builder={"b": "", "f": 0}) + proxy._exchange.order.assert_not_called() + + def test_validate_builder_info_accepts_hl_builder_shape(self): + builder = {"b": "0x0000000000000000000000000000000000000001", "f": 10} + assert _validate_builder_info(builder) == builder + class TestALOFallback: def test_alo_rejection_falls_back_to_gtc(self): diff --git a/tests/test_mcp_entitlements.py b/tests/test_mcp_entitlements.py new file mode 100644 index 0000000..c01dd97 --- /dev/null +++ b/tests/test_mcp_entitlements.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json + +from cli import mcp_entitlements + + +def _entitlement(**overrides): + base = { + "ok": True, + "tier": "hosted-mcp-tools-inference", + "planId": "hosted-mcp-inference-starter", + "entitled": True, + "allowedTools": ["status", "run_strategy", "trade"], + "toolBuckets": { + "free": ["status"], + "paidCompute": ["run_strategy"], + "safetyGated": ["trade"], + }, + "mcpCallLimit": 20, + "mcpCallsUsed": 0, + "modelPolicy": {"defaultModel": "openai/gpt-4.1-mini", "allowAuto": False, "allowFusion": False}, + } + base.update(overrides) + return base + + +def test_no_entitlement_keeps_local_byo_ungated(monkeypatch): + monkeypatch.delenv("NUNCHI_MCP_ENTITLEMENT_JSON", raising=False) + monkeypatch.delenv("NUNCHI_MCP_ENTITLEMENT_FILE", raising=False) + monkeypatch.delenv("NUNCHI_MCP_REQUIRE_ENTITLEMENT", raising=False) + monkeypatch.delenv("NUNCHI_CONNECTION_MODE", raising=False) + + assert mcp_entitlements.check_tool_call("run_strategy").allowed + + +def test_entitlement_blocks_tools_outside_allowlist(tmp_path, monkeypatch): + monkeypatch.setattr(mcp_entitlements, "STATE_PATH", tmp_path / "state.json") + + decision = mcp_entitlements.check_tool_call("radar_run", entitlement=_entitlement()) + + assert not decision.allowed + assert "outside allowedTools" in decision.reason + + +def test_safety_gated_tools_require_confirm(tmp_path, monkeypatch): + monkeypatch.setattr(mcp_entitlements, "STATE_PATH", tmp_path / "state.json") + + denied = mcp_entitlements.check_tool_call("trade", entitlement=_entitlement()) + allowed = mcp_entitlements.check_tool_call("trade", entitlement=_entitlement(), confirm=True) + + assert not denied.allowed + assert "confirm=true" in denied.reason + assert allowed.allowed + + +def test_free_call_limit_counts_local_usage(tmp_path, monkeypatch): + monkeypatch.setattr(mcp_entitlements, "STATE_PATH", tmp_path / "state.json") + ent = _entitlement(mcpCallLimit=1) + + assert mcp_entitlements.check_tool_call("status", entitlement=ent).allowed + denied = mcp_entitlements.check_tool_call("status", entitlement=ent) + + assert not denied.allowed + assert "MCP call limit exceeded" in denied.reason + + +def test_model_policy_blocks_auto_and_fusion(tmp_path, monkeypatch): + monkeypatch.setattr(mcp_entitlements, "STATE_PATH", tmp_path / "state.json") + auto = mcp_entitlements.check_tool_call("run_strategy", entitlement=_entitlement(), model="openrouter/auto") + fusion = mcp_entitlements.check_tool_call("run_strategy", entitlement=_entitlement(), model="nunchi/fusion") + + assert not auto.allowed + assert not fusion.allowed + + +def test_inline_entitlement_json_loads_from_env(monkeypatch): + monkeypatch.setenv("NUNCHI_MCP_ENTITLEMENT_JSON", json.dumps(_entitlement())) + + loaded = mcp_entitlements.load_entitlement() + + assert loaded is not None + assert loaded["tier"] == "hosted-mcp-tools-inference" diff --git a/tests/test_pricing_aggregate.py b/tests/test_pricing_aggregate.py index 924c775..500c3ae 100644 --- a/tests/test_pricing_aggregate.py +++ b/tests/test_pricing_aggregate.py @@ -47,3 +47,5 @@ def test_pricing_aggregate_outputs_mode_specific_summary(tmp_path): assert "`mode_2_hosted_mcp_tools_inference`" in report assert "`mode_3_clone_local`" in report assert "gpt-4.1-mini" in report + assert "Linked Live Fills" in report + assert "Dry Runs" in report