From 5eed470e2b2297749c530304c071053ef2bd9f56 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 7 Oct 2026 21:39:56 -0500 Subject: [PATCH 1/3] fix(qa): point the wallet at a local stub RPC so QA never calls mainnet.base.org The v2.14.0 release build (run 37649288177) failed Staged QA (all_2) on sqlite with 463/463 tests passing: the incidents gate found two "[ChainClient] Failed to get USDC/ETH balance: Server error '503 Service Unavailable' for url 'https://mainnet.base.org'" ERRORs. The wallet adapter auto-loads for base-mainnet and its context-enrichment tool wallet:get_statement queries balances during context gathering, so QA was gated on a third party's uptime. The QA runner now starts a stub JSON-RPC server (tools/qa_runner/ mock_chain_rpc.py, same lifecycle as the mock logshipper, OS-assigned port) and sets the adapter's existing WALLET_X402_RPC_URL env var to it. An operator-exported WALLET_X402_RPC_URL still wins. The stub answers the read calls ChainClient issues with deterministic zero balances and refuses eth_sendRawTransaction. The ChainClient error is deliberately NOT added to EXPECTED_QA_INCIDENT_PATTERNS - that would hide real wallet failures. Tests include two negative checks: without the override the wallet targets https://mainnet.base.org, and a stub forced to answer 503 reproduces the exact ChainClient ERROR lines from the failed run. black also normalised pre-existing formatting in server.py (a mis-indented block and a long signature); no behaviour change there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F4gtPV4CQ22A8B4mTUBfdu --- tests/tools/qa_runner/test_wallet_stub_rpc.py | 221 +++++++++++++++++ tools/qa_runner/mock_chain_rpc.py | 233 ++++++++++++++++++ tools/qa_runner/server.py | 82 ++++-- 3 files changed, 511 insertions(+), 25 deletions(-) create mode 100644 tests/tools/qa_runner/test_wallet_stub_rpc.py create mode 100644 tools/qa_runner/mock_chain_rpc.py diff --git a/tests/tools/qa_runner/test_wallet_stub_rpc.py b/tests/tools/qa_runner/test_wallet_stub_rpc.py new file mode 100644 index 000000000..217872529 --- /dev/null +++ b/tests/tools/qa_runner/test_wallet_stub_rpc.py @@ -0,0 +1,221 @@ +"""QA must never talk to the public Base RPC. + +v2.14.0 release build (run 37649288177), Staged QA (all_2) sqlite: 463/463 +tests passed and the job still failed, because the incidents gate found + + [ChainClient] Failed to get USDC balance: Server error '503 Service + Unavailable' for url 'https://mainnet.base.org' + +(and the ETH twin). The wallet adapter auto-loads for base-mainnet and its +context-enrichment tool queries balances during context gathering, so QA was +gated on a third party's uptime. The fix is a local stub RPC the runner +points the wallet at via WALLET_X402_RPC_URL -- NOT an incidents-pattern +allow-list entry, which would hide real wallet failures. +""" + +from __future__ import annotations + +import logging +import pathlib +from decimal import Decimal +from typing import Iterator + +import httpx +import pytest + +from ciris_adapters.wallet.providers.chain_client import CHAIN_CONFIG, ChainClient +from tools.qa_runner.mock_chain_rpc import ( + BASE_MAINNET_CHAIN_ID, + WALLET_RPC_URL_ENV, + ZERO_WORD, + MockChainRPCServer, + apply_wallet_rpc_stub_env, +) + +REPO = pathlib.Path(__file__).resolve().parents[3] +TEST_ADDRESS = "0x" + "ab" * 20 + + +@pytest.fixture +def stub() -> Iterator[MockChainRPCServer]: + server = MockChainRPCServer() + assert server.start() + yield server + server.stop() + + +def _rpc(url: str, method: str, params: list[object]) -> dict[str, object]: + resp = httpx.post(url, json={"jsonrpc": "2.0", "id": 7, "method": method, "params": params}) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, dict) + return body + + +# -------------------------------------------------------------------------- +# The stub itself +# -------------------------------------------------------------------------- + + +def test_stub_binds_a_free_local_port(stub: MockChainRPCServer) -> None: + assert stub.port != 0 + assert stub.endpoint_url.startswith("http://127.0.0.1:") + + +def test_stub_answers_read_calls_deterministically(stub: MockChainRPCServer) -> None: + url = stub.endpoint_url + assert _rpc(url, "eth_chainId", [])["result"] == hex(BASE_MAINNET_CHAIN_ID) + assert _rpc(url, "eth_getBalance", [TEST_ADDRESS, "latest"])["result"] == "0x0" + call = _rpc(url, "eth_call", [{"to": CHAIN_CONFIG["base-mainnet"]["usdc_address"], "data": "0x70a08231"}, "latest"]) + assert call["result"] == ZERO_WORD + assert call["id"] == 7 + assert "error" not in call + assert int(str(_rpc(url, "eth_blockNumber", [])["result"]), 16) >= 0 + assert _rpc(url, "eth_getTransactionReceipt", ["0x" + "00" * 32])["result"] is None + block = _rpc(url, "eth_getBlockByNumber", ["latest", False])["result"] + assert isinstance(block, dict) and "baseFeePerGas" in block + + +def test_stub_refuses_transaction_submission(stub: MockChainRPCServer) -> None: + body = _rpc(stub.endpoint_url, "eth_sendRawTransaction", ["0xdead"]) + assert "result" not in body + assert isinstance(body["error"], dict) + + +def test_stub_rejects_unknown_methods(stub: MockChainRPCServer) -> None: + body = _rpc(stub.endpoint_url, "debug_traceTransaction", []) + assert isinstance(body["error"], dict) and body["error"]["code"] == -32601 + + +def test_stub_records_calls(stub: MockChainRPCServer) -> None: + _rpc(stub.endpoint_url, "eth_chainId", []) + _rpc(stub.endpoint_url, "eth_getBalance", [TEST_ADDRESS, "latest"]) + assert [c.method for c in stub.get_calls()] == ["eth_chainId", "eth_getBalance"] + + +async def test_chain_client_against_stub_logs_no_errors( + stub: MockChainRPCServer, caplog: pytest.LogCaptureFixture +) -> None: + """Positive: the exact calls that failed in v2.14.0 succeed against the stub.""" + client = ChainClient(network="base-mainnet", rpc_url=stub.endpoint_url) + with caplog.at_level(logging.DEBUG, logger="ciris_adapters.wallet.providers.chain_client"): + assert await client.get_usdc_balance(TEST_ADDRESS) == Decimal("0") + assert await client.get_eth_balance(TEST_ADDRESS) == Decimal("0") + assert await client.get_block_number() == 1 + errors = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert not errors, [r.getMessage() for r in errors] + assert {c.method for c in stub.get_calls()} == {"eth_call", "eth_getBalance", "eth_blockNumber"} + + +# -------------------------------------------------------------------------- +# Negative checks -- the defect is reproducible without the fix +# -------------------------------------------------------------------------- + + +def test_without_override_wallet_targets_public_mainnet(monkeypatch: pytest.MonkeyPatch) -> None: + """NEGATIVE: no WALLET_X402_RPC_URL -> the ChainClient aims at mainnet.base.org.""" + from ciris_adapters.wallet.adapter import WalletAdapter + + monkeypatch.delenv(WALLET_RPC_URL_ENV, raising=False) + monkeypatch.delenv("WALLET_X402_ENABLED", raising=False) + cfg = WalletAdapter._load_config_from_env(None) # type: ignore[arg-type] # method never touches self + x402 = cfg.provider_configs["x402"] + assert x402.rpc_url is None + assert ChainClient(network=x402.network, rpc_url=x402.rpc_url).rpc_url == "https://mainnet.base.org" + + +def test_override_env_reaches_the_chain_client(monkeypatch: pytest.MonkeyPatch, stub: MockChainRPCServer) -> None: + """The existing env var flows adapter config -> X402 config -> ChainClient.""" + from ciris_adapters.wallet.adapter import WalletAdapter + + env: dict[str, str] = {} + assert apply_wallet_rpc_stub_env(env, stub, operator_env={}) + monkeypatch.setenv(WALLET_RPC_URL_ENV, env[WALLET_RPC_URL_ENV]) + cfg = WalletAdapter._load_config_from_env(None) # type: ignore[arg-type] # method never touches self + x402 = cfg.provider_configs["x402"] + assert ChainClient(network=x402.network, rpc_url=x402.rpc_url).rpc_url == stub.endpoint_url + + +async def test_upstream_503_is_logged_as_chain_client_error(caplog: pytest.LogCaptureFixture) -> None: + """NEGATIVE: an RPC answering 503 reproduces the exact v2.14.0 incident lines. + + This is why the incidents gate went red, and why the fix is to stop + talking to a live RPC rather than to allow-list the message. + """ + outage = MockChainRPCServer(forced_status=503) + assert outage.start() + try: + client = ChainClient(network="base-mainnet", rpc_url=outage.endpoint_url) + with caplog.at_level(logging.ERROR, logger="ciris_adapters.wallet.providers.chain_client"): + assert await client.get_usdc_balance(TEST_ADDRESS) == Decimal("0") + assert await client.get_eth_balance(TEST_ADDRESS) == Decimal("0") + finally: + outage.stop() + messages = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR] + assert any("[ChainClient] Failed to get USDC balance" in m and "503" in m for m in messages), messages + assert any("[ChainClient] Failed to get ETH balance" in m and "503" in m for m in messages), messages + + +def test_chain_client_error_is_not_allow_listed() -> None: + """The fix must not be an incidents-gate allow-list entry.""" + from tools.qa_runner.runner import EXPECTED_QA_INCIDENT_PATTERNS + + joined = " ".join(str(p) for p in EXPECTED_QA_INCIDENT_PATTERNS) + assert "ChainClient" not in joined + assert "mainnet.base.org" not in joined + + +# -------------------------------------------------------------------------- +# Runner wiring +# -------------------------------------------------------------------------- + + +def test_apply_env_sets_stub_url(stub: MockChainRPCServer) -> None: + env: dict[str, str] = {} + assert apply_wallet_rpc_stub_env(env, stub, operator_env={}) + assert env[WALLET_RPC_URL_ENV] == stub.endpoint_url + + +def test_operator_export_wins(stub: MockChainRPCServer) -> None: + env = {WALLET_RPC_URL_ENV: "https://sepolia.base.org"} + assert not apply_wallet_rpc_stub_env(env, stub, operator_env=dict(env)) + assert env[WALLET_RPC_URL_ENV] == "https://sepolia.base.org" + + +def test_no_stub_no_change() -> None: + env: dict[str, str] = {} + assert not apply_wallet_rpc_stub_env(env, None, operator_env={}) + assert WALLET_RPC_URL_ENV not in env + + +def test_server_manager_starts_stub_and_wires_wallet() -> None: + """APIServerManager.start() must start the stub and apply it to the agent env; stop() must stop it.""" + src = (REPO / "tools" / "qa_runner" / "server.py").read_text(encoding="utf-8") + start_body = src[src.index(' def start(self) -> bool:\n """Start the API server') :] + assert "self.mock_chain_rpc = MockChainRPCServer()" in start_body + assert "apply_wallet_rpc_stub_env(env, self.mock_chain_rpc, os.environ)" in start_body + assert "self.mock_chain_rpc.stop()" in src + + +async def test_x402_provider_from_env_fetches_balance_via_stub( + monkeypatch: pytest.MonkeyPatch, stub: MockChainRPCServer, caplog: pytest.LogCaptureFixture +) -> None: + """End to end inside the wallet: env -> adapter config -> X402Provider -> stub. + + _fetch_balance_from_chain is the path wallet:get_statement takes during + context enrichment -- the one that hit mainnet in v2.14.0. + """ + from ciris_adapters.wallet.adapter import WalletAdapter + from ciris_adapters.wallet.providers.x402_provider import X402Provider + + env: dict[str, str] = {} + apply_wallet_rpc_stub_env(env, stub, operator_env={}) + monkeypatch.setenv(WALLET_RPC_URL_ENV, env[WALLET_RPC_URL_ENV]) + cfg = WalletAdapter._load_config_from_env(None) # type: ignore[arg-type] # method never touches self + provider = X402Provider(cfg.provider_configs["x402"], evm_address=TEST_ADDRESS) + + with caplog.at_level(logging.ERROR): + balance = await provider._fetch_balance_from_chain() + assert balance.available == Decimal("0") + assert not [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR] + assert {c.method for c in stub.get_calls()} == {"eth_call", "eth_getBalance"} diff --git a/tools/qa_runner/mock_chain_rpc.py b/tools/qa_runner/mock_chain_rpc.py new file mode 100644 index 000000000..144e5e968 --- /dev/null +++ b/tools/qa_runner/mock_chain_rpc.py @@ -0,0 +1,233 @@ +""" +Local stub JSON-RPC server for the wallet adapter during QA. + +The wallet adapter auto-loads at startup (bootstrap_helpers) with x402 on +base-mainnet, and its context-enrichment tool ``wallet:get_statement`` calls +the chain during context gathering. Without an override every QA run queries +the public ``https://mainnet.base.org`` endpoint, so an outage of that third +party turns the QA incidents gate red (v2.14.0, run 37649288177: 503s logged +as ``[ChainClient] Failed to get USDC/ETH balance`` ERRORs). + +This stub answers the read-only calls ``ChainClient`` issues with +deterministic values. The QA runner points the wallet at it through the +adapter's existing ``WALLET_X402_RPC_URL`` env var, so QA never talks to +mainnet. It refuses ``eth_sendRawTransaction``: QA must never submit a +transaction, and a submission attempt should be loud. + +Same lifecycle shape as ``MockLogshipperServer`` in ``server.py``. +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Dict, List, Mapping, MutableMapping, Optional, Union + +from pydantic import BaseModel, Field, JsonValue + +# Env var the wallet adapter already reads for the x402 ChainClient RPC URL +# (ciris_adapters/wallet/adapter.py::_load_config_from_env). +WALLET_RPC_URL_ENV = "WALLET_X402_RPC_URL" + +# Base mainnet chain id - the network the wallet initialises for in QA. +BASE_MAINNET_CHAIN_ID = 8453 + +# Deterministic answers. Balances are zero (a fresh QA wallet holds nothing), +# so the stub cannot make a test pass by inventing funds. +ZERO_WORD = "0x" + "0" * 64 +STUB_BLOCK_NUMBER = "0x1" +STUB_GAS_PRICE = "0x3b9aca00" # 1 gwei +STUB_GAS_ESTIMATE = "0x5208" # 21000 +STUB_BASE_FEE = "0x0" + +JSONRPC_METHOD_NOT_FOUND = -32601 +JSONRPC_INVALID_REQUEST = -32600 +JSONRPC_SERVER_ERROR = -32000 + + +class JsonRpcError(BaseModel): + """JSON-RPC 2.0 error object.""" + + code: int + message: str + + +class JsonRpcResponse(BaseModel): + """JSON-RPC 2.0 response envelope.""" + + jsonrpc: str = "2.0" + id: Optional[Union[int, str]] = None + result: JsonValue = None + error: Optional[JsonRpcError] = None + + def to_wire(self) -> bytes: + body = self.model_dump(exclude_none=False) + # Per spec a response carries exactly one of result / error. + if self.error is not None: + body.pop("result", None) + else: + body.pop("error", None) + return json.dumps(body).encode("utf-8") + + +class RecordedCall(BaseModel): + """One RPC call the stub received (for assertions and the stop report).""" + + method: str + params: List[JsonValue] = Field(default_factory=list) + + +def answer_rpc(method: str, chain_id: int) -> JsonRpcResponse: + """Deterministic answer for one JSON-RPC method (id filled in by caller).""" + static: Dict[str, JsonValue] = { + "eth_chainId": hex(chain_id), + "net_version": str(chain_id), + "eth_getBalance": "0x0", + # ERC-20 balanceOf / allowance and EntryPoint getNonce all decode a + # single uint256 word - zero for each. + "eth_call": ZERO_WORD, + "eth_blockNumber": STUB_BLOCK_NUMBER, + "eth_getTransactionCount": "0x0", + "eth_gasPrice": STUB_GAS_PRICE, + "eth_estimateGas": STUB_GAS_ESTIMATE, + "eth_getTransactionReceipt": None, + "eth_getBlockByNumber": { + "number": STUB_BLOCK_NUMBER, + "baseFeePerGas": STUB_BASE_FEE, + "timestamp": "0x0", + "transactions": [], + }, + } + if method in static: + return JsonRpcResponse(result=static[method]) + if method == "eth_sendRawTransaction": + return JsonRpcResponse( + error=JsonRpcError( + code=JSONRPC_SERVER_ERROR, + message="QA stub RPC refuses transaction submission", + ) + ) + return JsonRpcResponse( + error=JsonRpcError(code=JSONRPC_METHOD_NOT_FOUND, message=f"QA stub RPC: method not found: {method}") + ) + + +class _MockChainRPCHTTPServer(HTTPServer): + """HTTPServer holding per-instance state (see _MockLogshipperHTTPServer).""" + + chain_id: int + forced_status: Optional[int] + calls: List[RecordedCall] + calls_lock: threading.Lock + + +class MockChainRPCHandler(BaseHTTPRequestHandler): + """Answers JSON-RPC POSTs on any path.""" + + server: _MockChainRPCHTTPServer + + def log_message(self, format: str, *args: object) -> None: + """Suppress default logging.""" + + def _send(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) + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + + if self.server.forced_status is not None: + # Test hook: emulate an upstream outage (e.g. 503). + self._send(self.server.forced_status, b'{"error": "forced by QA stub"}') + return + + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + payload = None + if not isinstance(payload, dict) or not isinstance(payload.get("method"), str): + invalid = JsonRpcResponse(error=JsonRpcError(code=JSONRPC_INVALID_REQUEST, message="Invalid Request")) + self._send(200, invalid.to_wire()) + return + + method: str = payload["method"] + params = payload.get("params") + with self.server.calls_lock: + self.server.calls.append(RecordedCall(method=method, params=params if isinstance(params, list) else [])) + + response = answer_rpc(method, self.server.chain_id) + request_id = payload.get("id") + response.id = request_id if isinstance(request_id, (int, str)) else None + self._send(200, response.to_wire()) + + +class MockChainRPCServer: + """Local stub Base RPC for QA, run in a background thread. + + port=0 (the default) binds an OS-assigned free port, so concurrent + backends under --parallel-backends never collide. + """ + + def __init__( + self, + port: int = 0, + chain_id: int = BASE_MAINNET_CHAIN_ID, + forced_status: Optional[int] = None, + ) -> None: + self.port = port + self.chain_id = chain_id + self.forced_status = forced_status + self.server: Optional[_MockChainRPCHTTPServer] = None + self.thread: Optional[threading.Thread] = None + + def start(self) -> bool: + """Start the stub in a background thread.""" + try: + server = _MockChainRPCHTTPServer(("127.0.0.1", self.port), MockChainRPCHandler) + except OSError: + return False + server.chain_id = self.chain_id + server.forced_status = self.forced_status + server.calls = [] + server.calls_lock = threading.Lock() + self.port = server.server_address[1] + self.server = server + self.thread = threading.Thread(target=server.serve_forever, daemon=True) + self.thread.start() + return True + + def stop(self) -> None: + """Stop the stub.""" + if self.server: + self.server.shutdown() + self.server.server_close() + self.server = None + + def get_calls(self) -> List[RecordedCall]: + """RPC calls received so far.""" + if self.server is None: + return [] + with self.server.calls_lock: + return list(self.server.calls) + + @property + def endpoint_url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + +def apply_wallet_rpc_stub_env( + env: MutableMapping[str, str], stub: Optional[MockChainRPCServer], operator_env: Mapping[str, str] +) -> bool: + """Point the agent's wallet at the stub RPC. + + An operator-exported WALLET_X402_RPC_URL wins (same precedence as + apply_module_server_env), so a deliberate testnet/mainnet run is still + possible. Returns True when the stub URL was applied. + """ + if stub is None or WALLET_RPC_URL_ENV in operator_env: + return False + env[WALLET_RPC_URL_ENV] = stub.endpoint_url + return True diff --git a/tools/qa_runner/server.py b/tools/qa_runner/server.py index 1bd2dfd28..0dedcc2ce 100644 --- a/tools/qa_runner/server.py +++ b/tools/qa_runner/server.py @@ -45,6 +45,7 @@ from rich.console import Console from .config import QAConfig +from .mock_chain_rpc import MockChainRPCServer, apply_wallet_rpc_stub_env logger = logging.getLogger(__name__) @@ -59,8 +60,9 @@ NODE_HTTP_PORT = 4243 - -def apply_module_server_env(env: Dict[str, str], module_env: Mapping[str, str], operator_env: Mapping[str, str]) -> None: +def apply_module_server_env( + env: Dict[str, str], module_env: Mapping[str, str], operator_env: Mapping[str, str] +) -> None: """Merge a module's SERVER_ENV into the agent process env. Precedence, highest first: @@ -81,6 +83,7 @@ def apply_module_server_env(env: Dict[str, str], module_env: Mapping[str, str], continue env[key] = value + class _MockLogshipperHTTPServer(HTTPServer): """HTTPServer subclass that holds per-instance state. @@ -601,6 +604,9 @@ def __init__(self, config: QAConfig, database_backend: str = "sqlite", modules: self.process: Optional[subprocess.Popen] = None self.pid: Optional[int] = None self.mock_logshipper: Optional[MockLogshipperServer] = None + # Local stub Base RPC for the wallet adapter, so QA never depends on + # the uptime of the public mainnet.base.org endpoint. + self.mock_chain_rpc: Optional[MockChainRPCServer] = None self._extracted_password: Optional[str] = None # Dynamically extracted from server output # Per-backend output namespace. In parallel-backend mode both SQLITE @@ -941,6 +947,20 @@ def start(self) -> bool: self.console.print("[yellow][WARN] Could not start mock logshipper[/yellow]") self.mock_logshipper = None + # Start the stub chain RPC. The wallet adapter auto-loads for + # base-mainnet and its context-enrichment tool (wallet:get_statement) + # queries balances during context gathering; without this the agent + # calls https://mainnet.base.org and a third-party 503 surfaces as a + # ChainClient ERROR that fails the incidents gate (v2.14.0 all_2). + # Reused across a restart (start() without stop()) so the URL is stable. + if self.mock_chain_rpc is None: + self.mock_chain_rpc = MockChainRPCServer() + if self.mock_chain_rpc.start(): + self.console.print(f"[cyan]⛓ Stub chain RPC started at {self.mock_chain_rpc.endpoint_url}[/cyan]") + else: + self.console.print("[yellow][WARN] Could not start stub chain RPC[/yellow]") + self.mock_chain_rpc = None + self.console.print("[cyan] Starting API server...[/cyan]") # Build command. When --from-staged is set, the server under test is @@ -1019,27 +1039,27 @@ def start(self) -> bool: # Auto-detect provider from base_url if not explicitly set provider = self.config.live_provider if not provider and self.config.live_base_url: - # Match on the parsed HOSTNAME, not a substring of the whole - # URL. `"groq.com" in url` also matches - # `https://evil.example/groq.com/v1` and - # `https://groq.com.attacker.net` — the live API key would go - # to the wrong host while the log says "groq" - # (CodeQL py/incomplete-url-substring-sanitization). Host - # suffix matching, with the leading dot, cannot be spoofed by - # a path segment or a longer registrable domain. - host = (urlparse(self.config.live_base_url).hostname or "").lower() - - def _is_host(domain: str) -> bool: - return host == domain or host.endswith("." + domain) - - if _is_host("openrouter.ai"): - provider = "openrouter" - elif _is_host("groq.com"): - provider = "groq" - elif _is_host("together.ai") or _is_host("together.xyz"): - provider = "together" - else: - provider = "openai_compatible" + # Match on the parsed HOSTNAME, not a substring of the whole + # URL. `"groq.com" in url` also matches + # `https://evil.example/groq.com/v1` and + # `https://groq.com.attacker.net` — the live API key would go + # to the wrong host while the log says "groq" + # (CodeQL py/incomplete-url-substring-sanitization). Host + # suffix matching, with the leading dot, cannot be spoofed by + # a path segment or a longer registrable domain. + host = (urlparse(self.config.live_base_url).hostname or "").lower() + + def _is_host(domain: str) -> bool: + return host == domain or host.endswith("." + domain) + + if _is_host("openrouter.ai"): + provider = "openrouter" + elif _is_host("groq.com"): + provider = "groq" + elif _is_host("together.ai") or _is_host("together.xyz"): + provider = "together" + else: + provider = "openai_compatible" provider = provider or "openai" env["CIRIS_LLM_PROVIDER"] = provider @@ -1066,6 +1086,11 @@ def _is_host(domain: str) -> bool: if self.mock_logshipper: env["CIRIS_ACCORD_METRICS_ENDPOINT"] = self.mock_logshipper.endpoint_url + # Point the wallet's ChainClient at the stub RPC (operator export of + # WALLET_X402_RPC_URL still wins). + if apply_wallet_rpc_stub_env(env, self.mock_chain_rpc, os.environ): + self.console.print(f"[dim]WALLET_X402_RPC_URL={env['WALLET_X402_RPC_URL']} (stub chain RPC)[/dim]") + # When running in --live mode, opt the agent into location sharing in # accord traces so lens dashboards can correlate by region. Defaults # to the dev's home base (Schaumburg, IL) and can be overridden by @@ -1295,7 +1320,6 @@ def _is_host(domain: str) -> bool: # are different bugs with different owners. self._probe_canonical_reachability(env) - if accord_metrics_enabled: # Load base accord_metrics adapter alongside the main adapter if "ciris_accord_metrics" not in env.get("CIRIS_ADAPTER", ""): @@ -1551,6 +1575,15 @@ def stop(self): self.console.print(f" • {trace['task_name']}: {trace['filepath']}") self.mock_logshipper = None + # Stop the stub chain RPC and report what the wallet asked it + if self.mock_chain_rpc: + calls = self.mock_chain_rpc.get_calls() + self.mock_chain_rpc.stop() + if calls: + methods = sorted({c.method for c in calls}) + self.console.print(f"[green] Stub chain RPC served {len(calls)} calls: {', '.join(methods)}[/green]") + self.mock_chain_rpc = None + # Skip port cleanup - it's causing hangs # JOIN THE LOG READER. It is a daemon thread teeing the server's stdout; # once the process is gone its readline returns EOF and it exits -- but @@ -1566,7 +1599,6 @@ def stop(self): if t.is_alive(): self.console.print("[yellow] log reader thread did not finish within 5s[/yellow]") - def _is_server_running(self) -> bool: """Check if server is running on the configured port.""" try: From e5d23b47b6f26ec3f31112c256ffdd590fdf04b5 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 7 Oct 2026 22:13:18 -0500 Subject: [PATCH 2/3] fix(qa): ASCII marker for the stub-RPC start line The start line printed a chain-link emoji, which test_no_emoji_in_runtime_output rejects: on a Windows cp1252 console an emoji raises UnicodeEncodeError and kills the process. Use [STUB]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F4gtPV4CQ22A8B4mTUBfdu --- tools/qa_runner/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/qa_runner/server.py b/tools/qa_runner/server.py index 0dedcc2ce..a065aa6ee 100644 --- a/tools/qa_runner/server.py +++ b/tools/qa_runner/server.py @@ -956,7 +956,7 @@ def start(self) -> bool: if self.mock_chain_rpc is None: self.mock_chain_rpc = MockChainRPCServer() if self.mock_chain_rpc.start(): - self.console.print(f"[cyan]⛓ Stub chain RPC started at {self.mock_chain_rpc.endpoint_url}[/cyan]") + self.console.print(f"[cyan][STUB] Stub chain RPC started at {self.mock_chain_rpc.endpoint_url}[/cyan]") else: self.console.print("[yellow][WARN] Could not start stub chain RPC[/yellow]") self.mock_chain_rpc = None From e93aa2a4cc9c2603b10801229f4f90fe65cc9177 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 8 Oct 2026 15:00:03 -0500 Subject: [PATCH 3/3] chore(version): 2.14.0 -> 2.15.0 Releases the token single-count fix (#1241), resource pressure that acts (#1242: RECLAIM / THROTTLE / SHED / DRAIN, no token budget by default, config-graph token budgets, device-class memory budgets) and the hermetic staged-QA wallet stub (#1237). Breaking (#1242): - resource_signal_defer_total, resource_signal_reject_total and resource_signal_shutdown_total are removed; - the /v1/system/resources "action" values are renamed; - SHED answers new inbound work with 503 + Retry-After. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F4gtPV4CQ22A8B4mTUBfdu --- apps/android/build.gradle | 4 ++-- apps/android/src/main/python/version.py | 2 +- apps/ios/iosApp/Info.plist | 4 ++-- ciris_engine/constants.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/android/build.gradle b/apps/android/build.gradle index 95ce4417b..2125a7de7 100644 --- a/apps/android/build.gradle +++ b/apps/android/build.gradle @@ -25,8 +25,8 @@ android { applicationId "ai.ciris.mobile" minSdk 24 targetSdk 36 - versionCode 201 - versionName "2.14.0" + versionCode 202 + versionName "2.15.0" ndk { // Include x86_64 for emulator testing (Chaquopy reads abiFilters at config time) diff --git a/apps/android/src/main/python/version.py b/apps/android/src/main/python/version.py index 79e05f63f..bca17fefe 100644 --- a/apps/android/src/main/python/version.py +++ b/apps/android/src/main/python/version.py @@ -7,7 +7,7 @@ # Static version - updated at build time by the Android build process # This avoids file-system hashing logic that doesn't work in the Android package -__version__ = "android-2.14.0" +__version__ = "android-2.15.0" def get_version() -> str: diff --git a/apps/ios/iosApp/Info.plist b/apps/ios/iosApp/Info.plist index bce9bc50a..91770e37e 100644 --- a/apps/ios/iosApp/Info.plist +++ b/apps/ios/iosApp/Info.plist @@ -21,9 +21,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.14.0 + 2.15.0 CFBundleVersion - 363 + 364 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/ciris_engine/constants.py b/ciris_engine/constants.py index 71562ced3..38419ea43 100644 --- a/ciris_engine/constants.py +++ b/ciris_engine/constants.py @@ -6,10 +6,10 @@ from ciris_engine.schemas.runtime.canonical_peer import CanonicalBootstrapPeer # Version information -CIRIS_VERSION = "2.14.0-stable" +CIRIS_VERSION = "2.15.0-stable" ACCORD_VERSION = "1.2-Beta" CIRIS_VERSION_MAJOR = 2 -CIRIS_VERSION_MINOR = 14 +CIRIS_VERSION_MINOR = 15 CIRIS_VERSION_PATCH = 0 CIRIS_VERSION_BUILD = 0 CIRIS_VERSION_STAGE = "stable"