From f4afd09b3d1836d624ef5ab7d9941f7cae88a138 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:05:27 +0000 Subject: [PATCH 1/4] audit 8 Oct, backend: lock the chart bridge, symbol-aware replay price, broker money fixes, session/Origin/Fetch-Metadata gates, CSP, socket limits, hardened thumbs and backtest service Bridge: one lock + mkstemp + persisted monotonic ids (60 parallel commands: 11-23 HTTP 500s and duplicate ids before). Replay price per symbol; entry fee in realized; transactional approve; read errors no longer reset the account. once_per_view published at the level the stream reads it. '1M' is a month. Partial catalogue not cached. GET /api/session only to the own page / local tools; Origin must equal Host (port); cross-site API GETs refused. Handler timeout + connection cap, bounded SSE queues, run_id and local: names validated, thumbnails exact-host + content-sniffed + no redirects + atomic. Agent definitions validated; chat workdir/timeout are the operator's. 43 new tests drive the real code (threads, a live socket). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GLsbckEp88WEwC41dfyMeS --- console/backend/agents_store.py | 25 +- console/backend/backtest_service.py | 70 ++- console/backend/broker.py | 67 +- console/backend/chart_bridge.py | 88 ++- console/backend/chart_stream.py | 11 +- console/backend/chat.py | 19 +- console/backend/library_thumbs.py | 88 ++- console/backend/server.py | 277 ++++++-- .../backend/tests/test_audit_8oct_fixes.py | 591 ++++++++++++++++++ .../backend/tests/test_catalogue_groups.py | 4 +- .../backend/tests/test_console_hardening.py | 6 +- console/backend/tests/test_preview_cache.py | 19 +- console/frontend/replay.js | 4 +- 13 files changed, 1165 insertions(+), 104 deletions(-) create mode 100644 console/backend/tests/test_audit_8oct_fixes.py diff --git a/console/backend/agents_store.py b/console/backend/agents_store.py index 1b23f52..30de446 100644 --- a/console/backend/agents_store.py +++ b/console/backend/agents_store.py @@ -39,6 +39,17 @@ def _now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%S%z") +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,63}$") +SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + + +def _count(v) -> int: + try: + return max(0, int(v or 0)) + except (TypeError, ValueError, OverflowError): + raise ValueError("counters must be whole numbers") from None + + def validate(record: dict) -> dict: """Normalise a record or raise ValueError. Never trust the browser.""" if not isinstance(record, dict): @@ -57,6 +68,16 @@ def validate(record: dict) -> dict: if not isinstance(skills, list) or not isinstance(toolsets, list): raise ValueError("skills and toolsets must be lists of strings") counters = record.get("counters") or {} + if not isinstance(counters, dict): + raise ValueError("counters must be an object") + # These become argv for the agent CLI (`-s`, `-t`, `--resume`). A value that begins with "-" would be read there + # as another option (`-t --yolo`), so each must look like a name (audit SEC-2). + for label, values in (("skills", skills), ("toolsets", toolsets)): + for v in values[:8]: + if not isinstance(v, str) or not NAME_RE.match(v): + raise ValueError(f"each of {label} must be a plain name (letters, digits, . _ : / -, no leading dash), got {v!r}") + if record.get("session_id") and not SESSION_RE.match(str(record["session_id"])): + raise ValueError("session_id must be a plain token (letters, digits, . _ : -, no leading dash)") return { "id": sid, "name": name[:64], @@ -67,8 +88,8 @@ def validate(record: dict) -> dict: "session_id": (str(record["session_id"]) if record.get("session_id") else None), "created_at": str(record.get("created_at") or _now()), "last_active": record.get("last_active"), - "counters": {"runs": int(counters.get("runs", 0)), - "backtests": int(counters.get("backtests", 0))}, + "counters": {"runs": _count(counters.get("runs")), + "backtests": _count(counters.get("backtests"))}, } diff --git a/console/backend/backtest_service.py b/console/backend/backtest_service.py index f876609..0aaeabf 100644 --- a/console/backend/backtest_service.py +++ b/console/backend/backtest_service.py @@ -26,6 +26,7 @@ import argparse import json +import re import os import threading import time @@ -47,9 +48,14 @@ def _klines_binance(symbol: str, interval: str, bars: int) -> "object": """Public klines REST, paginated. No key, no account — the same feed the chart itself uses.""" + import urllib.parse import urllib.request import pandas as pd + if not _SYMBOL.match(symbol or "") or not _INTERVAL.match(interval or ""): + raise ValueError(f"not a Binance symbol / interval: {symbol!r} {interval!r}") + bars = max(1, min(MAX_BARS, int(bars))) + symbol, interval = urllib.parse.quote(symbol), urllib.parse.quote(interval) out, end = [], None while len(out) < bars: want = min(1000, bars - len(out)) @@ -71,13 +77,29 @@ def _klines_binance(symbol: str, interval: str, bars: int) -> "object": return df +_LOCAL_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_SYMBOL = re.compile(r"^[A-Z0-9]{3,20}$") +_INTERVAL = re.compile(r"^[0-9]{1,2}[smhdwM]$") +MAX_BARS = 20000 +MAX_COMBOS_SPAN = 120 # a sweep window wider than this is ~7,000 pairs; [1, 5000] was ~12.5 million +MAX_TOP = 50 +MAX_BODY = 4 * 1024 * 1024 + + def _bars_local(name: str) -> "object": - """A file the operator owns: CSV or Parquet in the data dir. Same schema the chart uses.""" + """A file the operator owns: CSV or Parquet in the data dir. Same schema the chart uses. + + The name is a bare file name: `local:../../etc/passwd` and `local:/etc/hostname` used to read any file (audit + SEC-7).""" import pandas as pd - base = Path(_DATA_DIR) + if not _LOCAL_NAME.match(name or ""): + raise ValueError(f"local data names are plain file names (letters, digits, . _ -), got {name!r}") + base = Path(_DATA_DIR).resolve() for suffix in (".parquet", ".csv", ""): - p = base / (name if name.endswith(suffix) and suffix else name + suffix) + p = (base / (name if name.endswith(suffix) and suffix else name + suffix)).resolve() + if base not in p.parents: + continue if p.exists(): df = pd.read_parquet(p) if p.suffix == ".parquet" else pd.read_csv(p, index_col=0) df.index = pd.to_datetime(df.index, utc=True) @@ -90,6 +112,7 @@ def _bars_local(name: str) -> "object": def load_bars(source: str, bars: int, inline: list | None = None): + bars = max(0, min(MAX_BARS, int(bars or 0))) if inline: import pandas as _pd df = _pd.DataFrame(inline) @@ -111,6 +134,18 @@ def load_bars(source: str, bars: int, inline: list | None = None): # ─────────────────────────── engine ─────────────────────────── +def _clean_nan(v): + """JSON has no NaN / Infinity: a run with no trades has NaN Sharpe and an infinite profit factor, which + json.dumps wrote as bare `NaN` / `Infinity` that strict parsers refuse (audit BE-18).""" + if isinstance(v, float): + return v if v == v and v not in (float("inf"), float("-inf")) else None + if isinstance(v, dict): + return {k: _clean_nan(x) for k, x in v.items()} + if isinstance(v, (list, tuple)): + return [_clean_nan(x) for x in v] + return v + + def _num(x, default: float = 0.0) -> float: """vectorbt hands back a Series in places even for a single column — take the last value. @@ -183,9 +218,12 @@ def run_ma_cross_sweep(spec: dict) -> dict: import vectorbt as vbt lo, hi = spec.get("window", [5, 60]) + lo, hi = int(lo), int(hi) + if lo < 1 or hi < lo or hi - lo > MAX_COMBOS_SPAN: + raise ValueError(f"window must satisfy 1 <= lo <= hi and hi - lo <= {MAX_COMBOS_SPAN}") fee = float(spec.get("fee", 0.001)) init = float(spec.get("init_cash", 10000)) - top_n = int(spec.get("top", 10)) + top_n = max(1, min(MAX_TOP, int(spec.get("top", 10)))) df = load_bars(str(spec.get("source", "binance:BTCUSDT:30m")), int(spec.get("bars", 1000)), inline=spec.get("inline") or None) price = df["close"] @@ -313,8 +351,20 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): # keep the journal readable print(f"[backtest] {fmt % args}", flush=True) + def _gate(self) -> bool: + """The same door as the console's: a local Host, and no browser page (no Origin). This service has no token, + so without this any web page could POST `text/plain` to 127.0.0.1:8788/run, and a DNS-rebinding page could + read the answers (audit SEC-7). The console proxies with urllib, which sends neither Origin nor a foreign + Host.""" + host = (self.headers.get("Host") or "").strip().lower() + name = host.rsplit(":", 1)[0].strip("[]") if not host.startswith("[") else host.split("]")[0].lstrip("[") + if name not in ("127.0.0.1", "localhost", "::1", "0.0.0.0") or self.headers.get("Origin"): + self._send(403, {"ok": False, "error": "refused: this service answers the Trader's Agent console only"}) + return False + return True + def _send(self, code: int, body: dict): - raw = json.dumps(body).encode("utf-8") + raw = json.dumps(_clean_nan(body), allow_nan=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) @@ -322,6 +372,8 @@ def _send(self, code: int, body: dict): self.wfile.write(raw) def do_GET(self): # noqa: N802 + if not self._gate(): + return if self.path.rstrip("/") in ("/health", "/api/backtest/health"): self._send(200, {"ok": True, "version": VERSION, "vectorbt": _STATE["vectorbt"], "warm": _STATE["warm"], "jit_ms": _STATE["jit_ms"], @@ -331,12 +383,20 @@ def do_GET(self): # noqa: N802 self._send(404, {"ok": False, "error": "GET /health"}) def do_POST(self): # noqa: N802 + if not self._gate(): + return path = self.path.rstrip("/") or "/" if path not in ("/run", "/sweep", "/api/backtest", "/api/backtest/sweep"): self._send(404, {"ok": False, "error": "POST /run or /sweep"}) return try: length = int(self.headers.get("Content-Length") or 0) + if length < 0 or length > MAX_BODY: + self._send(413, {"ok": False, "error": f"body over {MAX_BODY} bytes"}) + return + if "json" not in (self.headers.get("Content-Type") or "").lower(): + self._send(415, {"ok": False, "error": "Content-Type must be application/json"}) + return spec = json.loads(self.rfile.read(length).decode("utf-8") or "{}") except (ValueError, UnicodeDecodeError) as exc: self._send(400, {"ok": False, "error": f"bad JSON body: {exc}"}) diff --git a/console/backend/broker.py b/console/backend/broker.py index 45f9135..0fbeadf 100644 --- a/console/backend/broker.py +++ b/console/backend/broker.py @@ -11,6 +11,7 @@ (ccxt + vault keys) are a later step and would sit behind this same propose → approve gate. """ +import copy import json import math import os @@ -54,17 +55,27 @@ def _load(self) -> dict: with open(self.path, encoding="utf-8") as fh: s = json.load(fh) if not isinstance(s, dict) or not isinstance(s.get("orders"), list) \ - or not isinstance(s.get("positions"), dict) or not isinstance(s.get("cash"), (int, float)): + or not isinstance(s.get("positions"), dict) or not isinstance(s.get("cash"), (int, float)) \ + or not math.isfinite(s["cash"]): raise ValueError("shape") + # A hand-edited or older file may miss keys; fill them instead of answering 500 later (audit BE-12). + s.setdefault("start_cash", self.start_cash) + s.setdefault("realized", 0.0) + ids = [o.get("id", 0) for o in s["orders"] if isinstance(o, dict) and isinstance(o.get("id"), int)] + s["next_id"] = max(int(s.get("next_id") or 1), (max(ids) + 1) if ids else 1) return s except FileNotFoundError: return _fresh(self.start_cash) - except (ValueError, OSError): - try: # keep the broken file for inspection + except ValueError: # really unparsable: keep the broken file for inspection + try: os.replace(self.path, f"{self.path}.bad{int(time.time())}") except OSError: pass return _fresh(self.start_cash) + except OSError as exc: + # A transient read error (EIO, EACCES) is NOT corruption: starting a fresh 10,000 account over a good + # file would silently throw the real one away. Refuse instead (audit BE-12). + raise BrokerError("broker_unavailable", f"cannot read the paper account at {self.path}: {exc}") def _save(self) -> None: d = os.path.dirname(self.path) or "." @@ -73,6 +84,8 @@ def _save(self) -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(self._s, fh, separators=(",", ":")) + fh.flush() + os.fsync(fh.fileno()) os.chmod(tmp, 0o600) os.replace(tmp, self.path) except BaseException: @@ -82,6 +95,21 @@ def _save(self) -> None: pass raise + def _commit(self, before: dict) -> None: + """Save, or put the account back exactly as it was. A failed write used to leave the fill in memory (and a + retry answering "already filled") while the file still showed the order pending (audit BE-8).""" + try: + self._save() + except BaseException: + self._s = before + raise + + @staticmethod + def _dust(qty: float) -> float: + """Float error grows with the size: 1e-12 absolute left ghost positions and refused legitimate sells for + quantities in the millions (audit BE-13).""" + return max(1e-12, 1e-9 * abs(qty)) + def _trim(self) -> None: done = [o for o in self._s["orders"] if o["status"] != "pending"] if len(done) > self.keep: @@ -105,24 +133,27 @@ def propose(self, symbol, side, qty, note: str = "") -> dict: with self._lock: if sum(1 for o in self._s["orders"] if o["status"] == "pending") >= self.max_pending: raise BrokerError("too_many_pending", f"{self.max_pending} proposals are waiting — approve or reject some first") + before = copy.deepcopy(self._s) o = {"id": self._s["next_id"], "symbol": sym, "side": sd, "qty": q, "note": str(note or "")[:200], "status": "pending", "ts": time.time()} self._s["next_id"] += 1 self._s["orders"].append(o) - self._save() + self._commit(before) return dict(o) def reject(self, order_id) -> dict: with self._lock: + before = copy.deepcopy(self._s) o = self._pending(order_id) o["status"] = "rejected" o["closed"] = time.time() self._trim() - self._save() + self._commit(before) return dict(o) def approve(self, order_id) -> dict: with self._lock: + before = copy.deepcopy(self._s) o = self._pending(order_id) px = float(self.price_of(o["symbol"])) # may raise no_price → the card stays if not math.isfinite(px) or px <= 0: @@ -135,6 +166,9 @@ def approve(self, order_id) -> dict: raise BrokerError("insufficient_cash", f"needs {gross + fee:,.2f} but cash is {self._s['cash']:,.2f}") self._s["cash"] -= gross + fee + # The entry fee is a cost the moment it is paid. It used to appear nowhere in P&L, so `realized` + # understated a round trip by it and never reconciled with equity (audit BE-3). + self._s["realized"] -= fee if pos: tot = pos["qty"] + o["qty"] pos["avg"] = (pos["avg"] * pos["qty"] + px * o["qty"]) / tot @@ -142,17 +176,17 @@ def approve(self, order_id) -> dict: else: self._s["positions"][o["symbol"]] = {"qty": o["qty"], "avg": px} else: - if not pos or pos["qty"] + 1e-12 < o["qty"]: + if not pos or pos["qty"] + self._dust(pos["qty"]) < o["qty"]: have = pos["qty"] if pos else 0 raise BrokerError("insufficient_position", f"cannot sell {o['qty']} — holding {have} (spot, no shorting)") self._s["cash"] += gross - fee self._s["realized"] += (px - pos["avg"]) * o["qty"] - fee pos["qty"] -= o["qty"] - if pos["qty"] <= 1e-12: + if pos["qty"] <= self._dust(o["qty"]): del self._s["positions"][o["symbol"]] o.update(status="filled", price=px, fee=fee, closed=time.time()) self._trim() - self._save() + self._commit(before) return dict(o) def _pending(self, order_id) -> dict: @@ -176,13 +210,20 @@ def get(self, order_id) -> dict: raise BrokerError("unknown_order", f"no order {order_id}") def state(self) -> dict: + # Prices are read OUTSIDE the lock: one blocking ticker call per position under it made propose / approve + # wait behind a slow exchange (audit BE-7). + with self._lock: + held = {sym: dict(p) for sym, p in self._s["positions"].items()} + marks = {} + for sym, p in sorted(held.items()): + try: + marks[sym] = float(self.price_of(sym)) + except BrokerError: + marks[sym] = p["avg"] # no price: show cost, not a made-up number with self._lock: positions, equity = [], self._s["cash"] for sym, p in sorted(self._s["positions"].items()): - try: - mark = float(self.price_of(sym)) - except BrokerError: - mark = p["avg"] # no price: show cost, not a made-up number + mark = marks.get(sym, p["avg"]) positions.append({"symbol": sym, "qty": p["qty"], "avg": p["avg"], "mark": mark, "unrealized": (mark - p["avg"]) * p["qty"]}) equity += mark * p["qty"] @@ -195,5 +236,7 @@ def state(self) -> dict: def reset(self) -> None: with self._lock: + nxt = self._s.get("next_id", 1) self._s = _fresh(self.start_cash) + self._s["next_id"] = nxt # ids never restart: a stale card for old #1 must not approve a new #1 self._save() diff --git a/console/backend/chart_bridge.py b/console/backend/chart_bridge.py index 592abde..3c131c9 100755 --- a/console/backend/chart_bridge.py +++ b/console/backend/chart_bridge.py @@ -17,7 +17,10 @@ import base64 import json +import os import re +import tempfile +import threading import time from pathlib import Path from typing import Any @@ -25,6 +28,11 @@ STATE_FILE = "state.json" COMMANDS_FILE = "commands.json" RESULTS_FILE = "results.json" +NEXT_ID_FILE = "next_id.json" +# Every read-modify-write below runs under this lock: ThreadingHTTPServer gives each request its own thread, and +# two agent calls in parallel used to share one fixed temp name (HTTP 500s), read the same last id (duplicate +# ids, so the page dropped the second command) and overwrite each other's list (lost commands). Audit 8 Oct, BE-1. +_LOCK = threading.RLock() MAX_SHOTS = 12 # keep the last handful of pictures; this box has a small disk MAX_RESULTS = 60 # the agent only ever reads the newest results STALE_AFTER = 30.0 # a state older than this means "no chart is open" @@ -50,9 +58,18 @@ def _read(path: Path, default: Any) -> Any: def _write(path: Path, value: Any) -> None: - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(value, ensure_ascii=False), "utf-8") - tmp.replace(path) + """Atomic: a temp file of its OWN name (never shared between two writers), then a rename.""" + fd, name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(json.dumps(value, ensure_ascii=False)) + os.replace(name, path) + except BaseException: + try: + os.unlink(name) + except OSError: + pass + raise def _decode_shot(root: str | Path, name: str, data_url: str) -> str | None: @@ -101,6 +118,11 @@ def _shot_mtime(path: Path) -> float: def save_state(root: str | Path, payload: dict) -> dict: """The page's heartbeat: what the chart is showing. Never raises on odd input.""" + with _LOCK: + return _save_state(root, payload) + + +def _save_state(root: str | Path, payload: dict) -> dict: d = _chart_dir(root) prev = _read(d / STATE_FILE, {}) or {} state = { @@ -164,13 +186,33 @@ def load_state(root: str | Path, max_age: float = STALE_AFTER) -> dict: def enqueue(root: str | Path, command: dict) -> dict: """Queue one action for the live chart. Returns the command with the id the page will report back.""" - d = _chart_dir(root) - cmds = _read(d / COMMANDS_FILE, []) or [] - cid = int(cmds[-1]["id"]) + 1 if cmds else 1 - entry = {"id": cid, "at": time.time(), **{k: v for k, v in command.items() if k != "id"}} - cmds.append(entry) - _write(d / COMMANDS_FILE, cmds[-MAX_RESULTS:]) - return entry + with _LOCK: + d = _chart_dir(root) + cmds = _read(d / COMMANDS_FILE, []) or [] + cid = _next_id(d, cmds) + entry = {"id": cid, "at": time.time(), **{k: v for k, v in command.items() if k != "id"}} + cmds.append(entry) + _write(d / COMMANDS_FILE, cmds[-MAX_RESULTS:]) + return entry + + +def _next_id(d: Path, cmds: list) -> int: + """The next command id. It lives in its own file and only ever grows: derived from the (capped, and + recreated-empty-on-a-parse-error) command list, ids restarted at 1 and an old result answered for a new + command (BE-16).""" + last = 0 + try: + last = int((_read(d / NEXT_ID_FILE, {}) or {}).get("last") or 0) + except (TypeError, ValueError): + last = 0 + for c in cmds: + try: + last = max(last, int(c.get("id") or 0)) + except (TypeError, ValueError): + continue + cid = last + 1 + _write(d / NEXT_ID_FILE, {"last": cid}) + return cid def commands_since(root: str | Path, since: int = 0) -> list[dict]: @@ -181,8 +223,18 @@ def commands_since(root: str | Path, since: int = 0) -> list[dict]: def record_result(root: str | Path, payload: dict) -> dict: """The page's report for one command. A picture in the payload is decoded and replaced by its path.""" + try: + rid = int(payload.get("id")) + except (TypeError, ValueError, OverflowError): + raise ValueError("a result needs the integer id of the command it answers") from None + if rid <= 0: + raise ValueError("a result needs the id of the command it answers (a positive integer)") + with _LOCK: + return _record_result(root, payload, rid) + + +def _record_result(root: str | Path, payload: dict, rid: int) -> dict: d = _chart_dir(root) - rid = int(payload.get("id") or 0) result = { "id": rid, "at": time.time(), @@ -238,6 +290,20 @@ def record_result(root: str | Path, payload: dict) -> dict: # why, and the settings the script does declare. Named here because the store whitelists — without # it the agent is told "ok" and never that a label it sent was ignored. "inputs": payload.get("inputs"), + # Reported by the page, dropped by this whitelist until the 8 Oct audit (BE-15): `trades` is the whole + # point of the `signals` op (the trades the backtester prices), the rest are the after-state of their doors. + "count": payload.get("count"), + "trades": payload.get("trades"), + "theme": payload.get("theme"), + "replay": payload.get("replay"), + "reloading": payload.get("reloading"), + "rect": payload.get("rect"), + "palette": payload.get("palette"), + "overlay": payload.get("overlay"), + "opened": payload.get("opened"), + "lines": payload.get("lines"), + "drawer": payload.get("drawer"), + "alreadyPresent": payload.get("alreadyPresent"), } if payload.get("shot"): path = _decode_shot(root, f"shot-{rid}", str(payload["shot"])) diff --git a/console/backend/chart_stream.py b/console/backend/chart_stream.py index 27858ff..fc5c0a2 100644 --- a/console/backend/chart_stream.py +++ b/console/backend/chart_stream.py @@ -49,7 +49,7 @@ def attach(self) -> tuple[int, queue.Queue]: with self._lock: cid = self._next self._next += 1 - inbox: queue.Queue = queue.Queue() + inbox: queue.Queue = queue.Queue(maxsize=256) # bounded: a half-open peer must not grow memory for ever self._clients[cid] = inbox return cid, inbox @@ -131,7 +131,14 @@ def publish(self, payload: dict, command_id: int | None = None) -> int: sent = 0 for inbox in targets: try: - inbox.put_nowait(blob) + try: + inbox.put_nowait(blob) + except queue.Full: # a stuck view: drop its OLDEST event, keep the newest + try: + inbox.get_nowait() + except queue.Empty: + pass + inbox.put_nowait(blob) sent += 1 except Exception: # noqa: BLE001 — a dead client must never break the caller self.dropped += 1 diff --git a/console/backend/chat.py b/console/backend/chat.py index a719a4d..c46b7dd 100644 --- a/console/backend/chat.py +++ b/console/backend/chat.py @@ -16,6 +16,7 @@ import re import shutil import subprocess +import tempfile import time DEFAULT_CLI = os.environ.get( @@ -54,7 +55,8 @@ def extract_pine(reply: str) -> str: return match.group(1).strip() if match else "" -CHART_ACTION_RE = re.compile(r"^\s*CHART:\s*(.+?)\s*$", re.I | re.M) +# [ \t] not \s: with \s a 20 KB reply of blank lines took ~1.5 s here and held the GIL for all of it (audit SEC-17). +CHART_ACTION_RE = re.compile(r"^[ \t]*CHART:[ \t]*(.*\S)[ \t]*$", re.I | re.M) MARKET_KV_RE = re.compile(r"(\w+)\s*=\s*([A-Za-z0-9._\-/]+)") ADD_RE = re.compile(r"^add\s+([a-z0-9\-]+)", re.I) @@ -95,7 +97,9 @@ def run_agent(cli: str, agent: dict, message: str, context: dict | None = None, learnings: str = "", extra_args: list[str] | None = None) -> dict: """Run one prompt through `hermes -z`. Never raises; always returns a reportable dict.""" prompt = compose_prompt(agent, message, context, learnings=learnings) - usage_file = os.path.join("/tmp", f"hermes-usage-{os.getpid()}-{int(time.time() * 1000)}.json") + # A private 0700 directory, not a guessable name in /tmp (audit SEC-14). + usage_dir = tempfile.mkdtemp(prefix="hermes-usage-") + usage_file = os.path.join(usage_dir, "usage.json") cmd = [cli, "-z", prompt, "--usage-file", usage_file] for skill in agent.get("skills") or []: cmd += ["-s", skill] @@ -104,6 +108,10 @@ def run_agent(cli: str, agent: dict, message: str, context: dict | None = None, if resume: cmd += ["--resume", resume] if workdir: + if not os.path.isdir(workdir): + shutil.rmtree(usage_dir, ignore_errors=True) + return {"ok": False, "reason": f"the agent's working folder does not exist: {workdir} " + "(set TRADERS_AGENT_WORKDIR)", "elapsed_ms": 0} cmd += ["--in", workdir] cmd += list(extra_args or []) @@ -112,9 +120,11 @@ def run_agent(cli: str, agent: dict, message: str, context: dict | None = None, proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=workdir or None, stdin=subprocess.DEVNULL) except subprocess.TimeoutExpired: + shutil.rmtree(usage_dir, ignore_errors=True) return {"ok": False, "reason": f"agent timed out after {timeout}s", "elapsed_ms": int((time.time() - started) * 1000)} except OSError as exc: + shutil.rmtree(usage_dir, ignore_errors=True) return {"ok": False, "reason": f"could not start the agent CLI: {exc}", "elapsed_ms": 0} elapsed_ms = int((time.time() - started) * 1000) @@ -125,10 +135,7 @@ def run_agent(cli: str, agent: dict, message: str, context: dict | None = None, except (OSError, json.JSONDecodeError): usage = None finally: - try: - os.unlink(usage_file) - except OSError: - pass + shutil.rmtree(usage_dir, ignore_errors=True) session_id = usage.get("session_id") if isinstance(usage, dict) else None diff --git a/console/backend/library_thumbs.py b/console/backend/library_thumbs.py index 3923bdf..abbf9be 100644 --- a/console/backend/library_thumbs.py +++ b/console/backend/library_thumbs.py @@ -22,6 +22,7 @@ import re import shutil import subprocess +import tempfile import threading import time import urllib.error @@ -40,7 +41,7 @@ "luxalgo-production.s3.amazonaws.com", "luxalgo-images-production.s3.us-east-1.amazonaws.com", ) -_HOST_RULE = re.compile(r"^https?://(?:[a-z0-9-]+\.)*s3(?:[.-][a-z0-9-]+)*\.amazonaws\.com/", re.I) +MAX_BYTES = 4_000_000 # a card is ~30 KB; nothing legitimate here is bigger than a few MB CACHE_DIR = Path(os.environ.get("TRADERS_AGENT_THUMBS") or Path.home() / ".local" / "share" / "traders-agent" / "thumbs") @@ -72,12 +73,27 @@ def _clamp(width: int) -> int: def _allowed(url: str) -> bool: - """LuxAlgo's own buckets only, and only over https. Anything else is refused, not fetched.""" - if not url.startswith("https://"): + """LuxAlgo's two buckets only, over https, by EXACT host. A "contains luxalgo" rule on any S3 host let someone + else's bucket (or an S3 website endpoint that redirects) through (audit SEC-8).""" + if not isinstance(url, str) or not url.startswith("https://"): return False - if any(url.startswith(f"https://{host}/") for host in ALLOWED_HOSTS): - return True - return bool(_HOST_RULE.match(url)) and "luxalgo" in url.split("//", 1)[1].split("/", 1)[0] + return any(url.startswith(f"https://{host}/") for host in ALLOWED_HOSTS) + + +def _kind(raw: bytes) -> str | None: + """The picture type by its first bytes, never by name. Only these reach vips / ImageMagick / ffmpeg.""" + if raw[:8] == b"\x89PNG\r\n\x1a\n": + return "png" + if raw[:3] == b"\xff\xd8\xff": + return "jpg" + if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": + return "webp" + return None + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): # a 30x from the bucket is an answer, not a path to follow + return None def _tag(url: str) -> str: @@ -113,11 +129,13 @@ def _fetch(url: str) -> bytes | None: # URL with a space in it, so quote the unsafe characters and keep the ones that mean structure. safe = urllib.parse.quote(url, safe=":/?&=#%+,;@[]~") request = urllib.request.Request(safe, headers={"User-Agent": "traders-agent-console/1.0"}) + opener = urllib.request.build_opener(_NoRedirect) try: - with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT) as response: # noqa: S310 + with opener.open(request, timeout=FETCH_TIMEOUT) as response: # noqa: S310 if response.status != 200: return None - return response.read() + raw = response.read(MAX_BYTES + 1) + return raw if len(raw) <= MAX_BYTES else None except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, ValueError): return None @@ -135,6 +153,16 @@ def _shrink(src: Path, dst: Path, width: int) -> bool: return done.returncode == 0 and dst.exists() and dst.stat().st_size > 0 +_KEY_LOCKS: dict[str, threading.Lock] = {} + + +def _key_lock(name: str) -> threading.Lock: + with _LOCK: + if len(_KEY_LOCKS) > 512: + _KEY_LOCKS.clear() + return _KEY_LOCKS.setdefault(name, threading.Lock()) + + def thumb(slug: str, url: str, width: int = DEFAULT_WIDTH) -> tuple[bytes, str] | None: """The bytes to send for one card. `None` means "no preview, say so" — never a stand-in image.""" width = _clamp(width) @@ -146,24 +174,38 @@ def thumb(slug: str, url: str, width: int = DEFAULT_WIDTH) -> tuple[bytes, str] pass if not _allowed(url): return None - raw = _fetch(url) - if raw is None: - return None - CACHE_DIR.mkdir(parents=True, exist_ok=True) - tmp_png = dst.with_suffix(".src.png") - try: - tmp_png.write_bytes(raw) - if _shrink(tmp_png, dst, width): - return dst.read_bytes(), "image/jpeg" - # No converter on this machine: the original is bigger but it is the real picture. - return raw, "image/png" - except OSError: - return raw, "image/png" - finally: + # One build per picture at a time: the modal and the background warmer ask for the same key together, and the + # second used to read the first's half-written file, which the browser then kept for a year (audit BE-10). + with _key_lock(dst.name): try: - tmp_png.unlink() + if dst.exists() and dst.stat().st_size > 0: + return dst.read_bytes(), "image/jpeg" except OSError: pass + raw = _fetch(url) + kind = _kind(raw) if raw else None + if raw is None or kind is None: + return None + CACHE_DIR.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(dir=str(CACHE_DIR), prefix=".src-", suffix="." + kind) + tmp_src = Path(name) + tmp_dst = tmp_src.with_name(tmp_src.name + ".jpg") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(raw) + if _shrink(tmp_src, tmp_dst, width): + os.replace(tmp_dst, dst) # atomic: nobody ever reads a partial file + return dst.read_bytes(), "image/jpeg" + # No converter on this machine: the original is bigger but it is the real picture. + return raw, ("image/" + ("jpeg" if kind == "jpg" else kind)) + except OSError: + return raw, ("image/" + ("jpeg" if kind == "jpg" else kind)) + finally: + for f in (tmp_src, tmp_dst): + try: + f.unlink() + except OSError: + pass def _build(key: str, slug: str, url: str, width: int) -> None: diff --git a/console/backend/server.py b/console/backend/server.py index fceed7f..a6224a3 100755 --- a/console/backend/server.py +++ b/console/backend/server.py @@ -37,6 +37,7 @@ import argparse import asyncio import concurrent.futures +import ipaddress import json import secrets import os @@ -172,6 +173,8 @@ def _study_input(fn, *args, **kwargs): return fn(*args, **kwargs) except ValueError as exc: raise ApiError(str(exc), HTTPStatus.BAD_REQUEST, "bad_request") from exc + except FileNotFoundError as exc: + raise ApiError("no such study", HTTPStatus.NOT_FOUND, "unknown_agent") from exc class MCPUnavailable(ApiError): @@ -393,7 +396,7 @@ async def _run_forever(self) -> None: self.last_error = f"{type(exc).__name__}: {exc}" self._ready.clear() self._session = None - self._fail_pending(self.last_error or "MCP session closed") + await self._fail_pending(self.last_error or "MCP session closed") if self._shutdown.is_set(): break self._reconnects += 1 @@ -630,7 +633,17 @@ def ep_indicators(params: dict) -> dict: } +_CATALOGUE_LOCK = threading.Lock() + + def ep_catalogue(params: dict) -> dict: + """One walk at a time: every concurrent first call used to do its own nine-plus serial MCP calls (audit BE-11); + the callers that wait find the finished answer on disk.""" + with _CATALOGUE_LOCK: + return _ep_catalogue(params) + + +def _ep_catalogue(params: dict) -> dict: """Every row of the catalogue in one answer — families, counts, descriptions. Why one call instead of paging: the grid wants to group by family and show a reading under each @@ -652,6 +665,8 @@ def ep_catalogue(params: dict) -> dict: rows: list[dict] = [] page = 0 + upstream_total = None + last_full = False while page < CATALOGUE_MAX_PAGES: # `ep_indicators` reads the shape a query string produces (a list per key — see `_one`), so # pass lists even though this call never came from HTTP. Handing it bare strings is how @@ -659,10 +674,16 @@ def ep_catalogue(params: dict) -> dict: data = ep_indicators({"page": [str(page)], "page_size": [str(CATALOGUE_PAGE)], "sort": ["family"], "direction": ["asc"]}) chunk = (data or {}).get("indicators") or [] + if page == 0: + try: + upstream_total = int((data or {}).get("total")) + except (TypeError, ValueError): + upstream_total = None if not chunk: break rows.extend(chunk) page += 1 + last_full = len(chunk) >= CATALOGUE_PAGE if len(chunk) < CATALOGUE_PAGE: break @@ -722,6 +743,12 @@ def ep_catalogue(params: dict) -> dict: out = {"rows": rows, "total": len(rows), "groups": groups, "pages": page, "fetched_at": time.time(), "cached": False} + # A page that came back empty or unparsable (a rate-limit sentence, a hiccup) ends the walk early, and the short + # answer used to be saved and served as complete for 12 h (audit BE-11). Compare with what the upstream said. + if (upstream_total is not None and len(rows) < upstream_total) or (last_full and page >= CATALOGUE_MAX_PAGES): + out["partial"] = True + out["upstream_total"] = upstream_total + return out # served, but never kept: the next ask walks again try: os.makedirs(AGENTS_ROOT, exist_ok=True) tmp = path + ".tmp" @@ -1107,11 +1134,24 @@ def ep_chart_state(params: dict) -> dict: return load_chart_state(AGENTS_ROOT) +# What Binance spot actually serves. A capital M here is a MONTH (Binance's own spelling, and what the page and the +# Pine worker send); the display form below ("30M") is minutes. The old table turned "1M" into one MINUTE, so a +# monthly chart or request.security(..., "M") got 1-minute bars with no error (audit 8 Oct, BE-6). +BINANCE_INTERVALS = frozenset(("1s", "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w", "1M")) + + +def normalise_interval(interval: str) -> str: + iv = str(interval or "1h").strip() or "1h" + if iv in BINANCE_INTERVALS: + return iv # already Binance's spelling ("1M" is a month) + return _BINANCE_TF.get(iv, iv.lower()) + + # Binance only accepts lowercase intervals. The chart reports its own timeframe in display form # ("30M", "4H", "1D"), so normalise before the request instead of trusting the caller. _BINANCE_TF = { - "1M": "1m", "3M": "3m", "5M": "5m", "15M": "15m", "30M": "30m", "45M": "45m", - "1H": "1h", "2H": "2h", "3H": "3h", "4H": "4h", "6H": "6h", "8H": "8h", "12H": "12h", + "3M": "3m", "5M": "5m", "15M": "15m", "30M": "30m", + "1H": "1h", "2H": "2h", "4H": "4h", "6H": "6h", "8H": "8h", "12H": "12h", "1D": "1d", "3D": "3d", "1W": "1w", } @@ -1219,19 +1259,22 @@ def ep_bars(params: dict) -> dict: if not symbol: return {"bars": [], "symbol": symbol, "interval": interval, "error": "symbol_required"} - interval = _BINANCE_TF.get(interval, interval) or "1h" + interval = normalise_interval(interval) + if interval not in BINANCE_INTERVALS: + return {"bars": [], "symbol": symbol, "interval": interval, + "error": "bad_interval: Binance serves " + ", ".join(sorted(BINANCE_INTERVALS, key=lambda v: (v[-1], len(v), v)))} return fetch_bars(symbol, interval, limit) def ep_chart_commands(params: dict) -> dict: """Commands the chart page has not executed yet (it passes the last id it handled).""" - since = int((params.get("since") or ["0"])[0] or 0) + since = _int_param(params, "since") return {"commands": chart_commands_since(AGENTS_ROOT, since)} def ep_chart_result(params: dict) -> dict: """The outcome of one command: ok, a plain-language detail, and what it painted.""" - rid = int((params.get("id") or ["0"])[0] or 0) + rid = _int_param(params, "id") found = get_chart_result(AGENTS_ROOT, rid) if found is None: return {"id": rid, "pending": True} @@ -1299,7 +1342,7 @@ def _library_pairs(params: dict) -> list[tuple[str, str]]: def ep_library_warm(params: dict) -> dict: """Fetch + shrink a page of catalogue pictures in the background, so the next open is instant.""" pairs = _library_pairs(params) - width = int((params.get("w") or [library_thumbs.DEFAULT_WIDTH])[0] or library_thumbs.DEFAULT_WIDTH) + width = _int_param(params, "w", library_thumbs.DEFAULT_WIDTH) or library_thumbs.DEFAULT_WIDTH queued = library_thumbs.warm(pairs, width) return {"queued": queued, "asked": len(pairs), "cache": library_thumbs.stats()} @@ -1370,12 +1413,13 @@ def warm_catalogue_thumbs(pages: int = 0) -> None: _BROKER = None _BROKER_LOCK = threading.Lock() _BROKER_HTTP = {"unknown_order": 404, "not_pending": 409, "insufficient_cash": 409, - "insufficient_position": 409, "no_price": 503, "too_many_pending": 429} + "insufficient_position": 409, "no_price": 503, "too_many_pending": 429, + "broker_unavailable": 503} _BROKER_NEEDS_PAGE = ("approve", "reject", "reset", "replay") # While the operator replays, a paper fill uses the REPLAY cursor's price — the strip in the chart # pushes it here (page-only, like approve). `broker_price` below is what makes approve() and the # marks read it; nothing else may. -_REPLAY = {"active": False, "price": None, "time": None} +_REPLAY = {"active": False, "price": None, "time": None, "symbol": None} def _http_json(url: str): @@ -1402,7 +1446,12 @@ def broker_price(symbol: str) -> float: """The price a paper fill (and a position's mark) uses: the REPLAY cursor price while the operator is replaying — pushed by the page, see _REPLAY — else Binance's public ticker.""" if _REPLAY.get("active") and _REPLAY.get("price"): - return float(_REPLAY["price"]) + # The cursor price belongs to the symbol being replayed. Applied to any other one, an ETH order filled at the + # BTC price and every position was marked at it (audit 8 Oct, BE-2). A push with no symbol (an older page) + # still prices everything, as before. + replayed = _REPLAY.get("symbol") + if not replayed or str(replayed).upper() == str(symbol).upper(): + return float(_REPLAY["price"]) return binance_price(symbol) @@ -1430,7 +1479,11 @@ def _set_replay(body: dict) -> dict: 400, "replay_needs_price") if not (price > 0): raise ApiError("the replay cursor price must be positive", 400, "replay_needs_price") - _REPLAY.update(active=active, price=(price if active else None), time=(when if active else None)) + sym = str(body.get("symbol") or "").strip().upper() + if sym and not re.fullmatch(r"[A-Z0-9._-]{1,32}", sym): + raise ApiError("replay symbol is not a market name", 400, "bad_request") + _REPLAY.update(active=active, price=(price if active else None), time=(when if active else None), + symbol=(sym or None) if active else None) STREAM.publish({"type": "broker", "event": "replay", "replay": dict(_REPLAY)}) return {"replay": dict(_REPLAY)} @@ -1761,8 +1814,11 @@ def console_token() -> str: pass fresh = secrets.token_urlsafe(32) try: - TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) - TOKEN_PATH.write_text(fresh + "\n", encoding="utf-8") + TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + # Created 0600 from the first byte: write_text then chmod left a window at the default umask (audit SEC-14). + fd = os.open(str(TOKEN_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(fresh + "\n") os.chmod(TOKEN_PATH, 0o600) log(f"console token minted at {TOKEN_PATH} — POSTs need it (the page receives it as a cookie)") except OSError as exc: @@ -1771,12 +1827,33 @@ def console_token() -> str: CONSOLE_TOKEN = console_token() +# `--host 0.0.0.0` exposes the page to the LAN, but a LAN peer is not handed the POST token unless the operator also sets +# this: the console is then read-only to it rather than "authenticated" by a token anybody can fetch. +ALLOW_LAN = os.environ.get("TRADERS_AGENT_ALLOW_LAN", "") not in ("", "0", "false") + + +# What the console's page may load and talk to. 'unsafe-eval' is needed by the Pine engine (it compiles Pine to JS) and +# 'unsafe-inline' by the import map / inline boot script, so this does not stop script injection by itself; it does stop +# the page sending anything to a host that is not listed (audit 8 Oct, SEC-1 defence in depth). It deliberately has no +# frame-ancestors: the Hermes pane embeds this page. +CSP = ("default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; " + "worker-src 'self' blob:; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https://crypto-icons.ledger.com; " + "font-src 'self' data:; " + "connect-src 'self' https://api.binance.com https://api.binance.us https://fapi.binance.com https://dapi.binance.com " + "wss://stream.binance.com wss://stream.binance.us wss://fstream.binance.com https://crypto-icons.ledger.com; " + "object-src 'none'; base-uri 'none'; form-action 'self'") class Handler(BaseHTTPRequestHandler): server_version = f"luxalgo-web/{SERVER_VERSION}" protocol_version = "HTTP/1.1" static: StaticFiles = StaticFiles(DEFAULT_FRONTEND) + # A socket that sends nothing (or trickles a header / body) used to hold its thread for ever (audit SEC-10). The + # long-lived chart stream only writes, so this does not touch it. + timeout = 30 # -- helpers --------------------------------------------------------- @@ -1813,10 +1890,34 @@ def _origin_ok(self) -> bool: if origin.strip().lower() == "null": return False try: - return (urlparse(origin).hostname or "") in LOCAL_HOSTS + parsed = urlparse(origin) + if (parsed.hostname or "") not in LOCAL_HOSTS: + return False + # The PORT counts too: any other web app on localhost (a dev server, a notebook) is "a local name" but is + # not this console's page, and used to pass with the user's cookie (audit 8 Oct, SEC-4). The page's own + # Origin always equals the Host it was served from. + host = (self.headers.get("Host") or "").strip().lower() + return parsed.scheme in ("http", "https") and bool(host) and parsed.netloc.lower() == host except ValueError: return False + def _peer_is_local(self) -> bool: + try: + return ipaddress.ip_address(self.client_address[0]).is_loopback + except (ValueError, IndexError, TypeError): + return False + + def _session_ok(self) -> bool: + """May this caller be handed the POST token? Only a page this console served (same-origin fetch), or a local + non-browser (CLI, curl) on loopback. It used to answer anyone with a local Host, so `--host 0.0.0.0` gave every + LAN peer the token and the "still authenticated" claim was false (audit 8 Oct, SEC-3).""" + if not self._peer_is_local() and not ALLOW_LAN: + return False + site = (self.headers.get("Sec-Fetch-Site") or "").strip().lower() + if site: + return site in ("same-origin", "none") + return not self.headers.get("Origin") or self._origin_ok() + def _token_ok(self) -> bool: header = (self.headers.get("X-Trader-Token") or "").strip() if header and secrets.compare_digest(header, CONSOLE_TOKEN): @@ -1836,6 +1937,8 @@ def _send(self, status: int, body: bytes, content_type: str, extra: dict | None self.send_header(key, value) for key, value in (extra or {}).items(): self.send_header(key, value) + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") self.end_headers() if not head_only and body: try: @@ -1902,7 +2005,7 @@ def _backtest_proxy(self, path: str, payload: dict): # a symbol mapping (the chart knows whether it is BTCUSD or BTCUSDT). src = str(payload.get("source") or "") if src.startswith("chart:"): - want = int(payload.get("bars") or 1000) + want = _clamped_int(payload.get("bars"), 1000, 1, BARS_MAX) queued = enqueue_chart_command(AGENTS_ROOT, {"action": "bars", "count": want}) rid = int((queued or {}).get("id") or 0) res = None @@ -2015,7 +2118,10 @@ def do_POST(self): # noqa: N802 "source": "page heartbeat" if supported != CHART_ACTIONS_FALLBACK else "built-in fallback"}) return command = enqueue_chart_command(AGENTS_ROOT, payload) - pushed = STREAM.publish({"type": "command", "command": command}, + # `once_per_view` is read at the TOP level of the pushed payload (chart_stream.publish). It used to + # be left inside `command`, so a `reload` was claimed by one view only (audit 8 Oct, BE-4). + pushed = STREAM.publish({"type": "command", "command": command, + "once_per_view": bool(command.get("once_per_view")) if isinstance(command, dict) else False}, command_id=command.get("id") if isinstance(command, dict) else None) inline = self._await_chart_result(command, payload) if pushed else None self._ok({"command": command, "views": STREAM.client_count(), @@ -2025,7 +2131,11 @@ def do_POST(self): # noqa: N802 self._backtest_proxy(path, payload) return if path == "/api/chart/result": - recorded = record_chart_result(AGENTS_ROOT, payload) + try: + recorded = record_chart_result(AGENTS_ROOT, payload) + except ValueError as exc: + self._fail(str(exc), HTTPStatus.BAD_REQUEST, "bad_request") + return if isinstance(recorded, dict): # resolves whoever is waiting on this command (the push path's fast answer) recorded = STREAM.deliver_result(recorded) @@ -2034,7 +2144,10 @@ def do_POST(self): # noqa: N802 if path == "/api/chart/claim": # Several consoles can be alive at once (the Hermes pane, the HUD's pane, a browser # tab). They all get the same push, so one of them must win the right to execute it. - rid = payload.get("id") + try: + rid = int(payload.get("id")) + except (TypeError, ValueError, OverflowError): + raise ApiError("claim needs the integer id of a command", 400, "bad_request") from None viewer = str(payload.get("viewer") or "") self._ok({"id": rid, "claimed": STREAM.claim(rid, viewer, once=STREAM.is_once(rid)), "viewer": viewer}) return @@ -2084,8 +2197,8 @@ def _chat(self, payload: dict): agent=agent, message=message, context=context, - timeout=int(payload.get("timeout") or 180), - workdir=str(payload.get("workdir") or os.path.expanduser("~/Projects/luxalgo-web")), + timeout=_clamped_int(payload.get("timeout"), 180, 10, 600), + workdir=_agent_workdir(), resume=None if fresh else agent.get("session_id"), learnings=learnings_tail(AGENTS_ROOT, agent_id), extra_args=[], @@ -2167,6 +2280,16 @@ def _handle(self, head_only: bool): self._fail("refused: non-local Host", HTTPStatus.FORBIDDEN, "non_local_host", head_only=head_only) return + # A browser tells us which kind of page is asking. This console's own page is `same-origin`; an or + # fetch from another site is `cross-site` / `same-site` and could only ever cause side effects here (Binance + # requests from the user's IP, the Edge engine spawned, the LuxAlgo quota burned): refuse those. A CLI or + # curl sends no such header and is unaffected (audit 8 Oct, SEC-9). + fetch_site = (self.headers.get("Sec-Fetch-Site") or "").strip().lower() + if parsed.path.startswith("/api") and fetch_site and fetch_site not in ("same-origin", "none"): + self.close_connection = True + self._fail("refused: this route is for the console's own page and local tools", HTTPStatus.FORBIDDEN, + "cross_site", head_only=head_only) + return if parsed.path.rstrip("/") == "/api/backtest/health": self._backtest_proxy("/api/backtest/health", {}) return @@ -2197,6 +2320,10 @@ def _handle(self, head_only: bool): if not self._host_ok(): self._fail("refused: non-local Host", HTTPStatus.FORBIDDEN, "non_local_host", head_only=head_only) return + if not self._session_ok(): + self._fail("the token is given to this console's own page and to local tools, not to other pages " + "or machines", HTTPStatus.FORBIDDEN, "session_refused", head_only=head_only) + return self._ok({"token": CONSOLE_TOKEN}, head_only=head_only) return @@ -2294,8 +2421,11 @@ def _serve_static(self, url_path: str, head_only: bool): return extra = {"Cache-Control": "no-store"} if os.path.basename(target) == "index.html": - # The page's own POSTs carry this cookie; a cross-site request never does (SameSite=Strict). - extra["Set-Cookie"] = f"trader_token={CONSOLE_TOKEN}; Path=/; SameSite=Strict; HttpOnly" + # The page's own POSTs carry this cookie; a cross-site request never does (SameSite=Strict). A LAN peer is + # not handed it either (see _session_ok). + if self._peer_is_local() or ALLOW_LAN: + extra["Set-Cookie"] = f"trader_token={CONSOLE_TOKEN}; Path=/; SameSite=Strict; HttpOnly" + extra["Content-Security-Policy"] = CSP self._send(200, body, ctype, extra, head_only) @@ -2304,6 +2434,25 @@ class Server(ThreadingHTTPServer): allow_reuse_address = True request_queue_size = 64 + MAX_CONNECTIONS = 128 + _slots = threading.BoundedSemaphore(MAX_CONNECTIONS) + + def process_request(self, request, client_address): + # One thread per connection and no cap let a few hundred idle sockets pin memory and file descriptors. + if not self._slots.acquire(blocking=False): + try: + request.close() + except OSError: + pass + return + super().process_request(request, client_address) + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self._slots.release() + def handle_error(self, request, client_address): exc = sys.exc_info()[1] if isinstance(exc, (BrokenPipeError, ConnectionResetError)): @@ -2369,6 +2518,8 @@ def load_backtest_result(root: str = "", run_id: str = "") -> dict: if base is None: return {"ok": False, "error": "no backtest results found in " + ", ".join(str(c) for c in cands if c)} + if run_id and not re.fullmatch(r"[0-9A-Za-z_-]{1,64}", str(run_id)): + return {"ok": False, "error": "run_id must be letters, digits, _ or - (up to 64)"} try: if not run_id: latest = base / "latest.json" @@ -2406,6 +2557,43 @@ def list_local_data(data_dir: str = "") -> dict: "use": 'source="local:NAME" in bt_run / bt_optimize'} +def _clamped_int(value, default: int, lo: int, hi: int) -> int: + """A client-supplied number, held to a sane range. A body may not ask for a timeout of 99999 s (a thread held that + long) and a word is a 400, not a 500.""" + if value in (None, ""): + return default + try: + return max(lo, min(hi, int(value))) + except (TypeError, ValueError, OverflowError): + raise ApiError("expected a whole number", 400, "bad_request") from None + + +def _int_param(params: dict, name: str, default: int = 0) -> int: + raw = (params.get(name) or [str(default)])[0] or str(default) + try: + return int(raw) + except (TypeError, ValueError, OverflowError): + raise ApiError(f"{name} must be a whole number", 400, "bad_request") from None + + +def _agent_workdir() -> str: + """Where the agent CLI runs. It is the OPERATOR's setting, never the request's: a token holder used to choose any + folder (audit SEC-2).""" + env = os.environ.get("TRADERS_AGENT_WORKDIR") + if env: + return env + legacy = os.path.expanduser("~/Projects/luxalgo-web") + return legacy if os.path.isdir(legacy) else str(AGENTS_ROOT) + + +def _env_int(name: str, default: int) -> int: + """An integer from the environment; a typo falls back to the default instead of a traceback after the port is bound.""" + try: + return int(os.environ.get(name, "") or default) + except ValueError: + return default + + def main(argv=None) -> int: args = build_parser().parse_args(argv) @@ -2420,23 +2608,42 @@ def main(argv=None) -> int: if MCP_IMPORT_ERROR: log(f"WARNING: mcp package not importable ({MCP_IMPORT_ERROR})") + log(f"frontend dir: {frontend_root} " + f"({'ok' if Handler.static.exists() else 'MISSING'})") + + # Bind FIRST. The MCP session is only the Library's; the chart, bars, broker and the agent bridge need none of + # it, and waiting up to 60 s for it before opening the port left the pane and every tool failing for a minute + # (audit 8 Oct, BE-5). A busy port is also found at once, with one line instead of a traceback. + try: + httpd = Server((args.host, args.port), Handler) + except (OSError, OverflowError) as exc: + log(f"cannot listen on {args.host}:{args.port}: {exc}") + return 2 + log(f"listening on http://{args.host}:{args.port} (Ctrl-C to stop)") + if args.no_mcp: log("starting WITHOUT an MCP session (--no-mcp)") else: - log(f"connecting to MCP at {args.mcp_url} ...") - started = time.time() - ready = BRIDGE.start() - if ready: - log(f"MCP session established in {time.time() - started:.1f}s") - else: - log(f"WARNING: MCP session not ready after {MCP_CONNECT_WAIT:.0f}s " - f"({BRIDGE.last_error}); the server will keep retrying in the background") + def connect_mcp(): + log(f"connecting to MCP at {args.mcp_url} ...") + started = time.time() + if BRIDGE.start(): + log(f"MCP session established in {time.time() - started:.1f}s") + else: + log(f"WARNING: MCP session not ready after {MCP_CONNECT_WAIT:.0f}s " + f"({BRIDGE.last_error}); the server will keep retrying in the background") + threading.Thread(target=connect_mcp, name="mcp-connect", daemon=True).start() - log(f"frontend dir: {frontend_root} " - f"({'ok' if Handler.static.exists() else 'MISSING'})") + # `systemctl stop` sends SIGTERM, which used to end the process with no cleanup (the Edge Stats engine was + # left running). Treat it like Ctrl-C so the finally block below runs. + try: + import signal as _signal - httpd = Server((args.host, args.port), Handler) - log(f"listening on http://{args.host}:{args.port} (Ctrl-C to stop)") + def _term(_signum, _frame): + raise KeyboardInterrupt + _signal.signal(_signal.SIGTERM, _term) + except (ValueError, OSError, AttributeError): + pass # not the main thread / not supported here # Preview pictures, fetched once and kept: the operator's first open of the Indicators modal used # to fill in picture by picture (60 × ~1–2 s from S3 on this link). Warming starts here, in the @@ -2445,7 +2652,7 @@ def main(argv=None) -> int: threading.Thread( target=warm_catalogue_thumbs, # 0 = every page of the catalogue (the default). Set a small number on a metered link. - kwargs={"pages": int(os.environ.get("TRADERS_AGENT_THUMB_WARM_PAGES", "0") or 0)}, + kwargs={"pages": _env_int("TRADERS_AGENT_THUMB_WARM_PAGES", 0)}, name="thumb-warmer", daemon=True, ).start() try: diff --git a/console/backend/tests/test_audit_8oct_fixes.py b/console/backend/tests/test_audit_8oct_fixes.py new file mode 100644 index 0000000..6d1a2a8 --- /dev/null +++ b/console/backend/tests/test_audit_8oct_fixes.py @@ -0,0 +1,591 @@ +"""The 8 Oct 2026 full audit: the findings that were ours on the backend, fixed and held. + +Each test drives the real code (threads, a real socket, the real handler) rather than grepping for a string, +because the audit's own finding about the suite was that string pins stay green while behaviour breaks. + + BE-1/SEC-11 chart bridge: parallel writers (500s, duplicate ids, lost commands) + BE-16 command ids never restart + BE-15 a result keeps what the page reported; an id is required + BE-4 `once_per_view` reaches the stream (through the HTTP handler) + BE-2 the replay price belongs to the replayed symbol + BE-3/8/12/13 broker: entry fee in P&L, transactional approve, read errors, large quantities + BE-6 "1M" is a month; unknown intervals are refused with the list + BE-11 a partial catalogue is not kept + SEC-3/4/9 the token, the Origin port, cross-site GETs + SEC-6/2/17 run_id, agent definitions, the quadratic regex + SEC-8 thumbnails: exact hosts, content sniffing + TC-2 static-file traversal has a test +""" +import json +import os +import socket +import sys +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.request +from pathlib import Path +from unittest import mock + +BACKEND = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND)) + +import agents_store # noqa: E402 +import chart_bridge as cb # noqa: E402 +import chat # noqa: E402 +import library_thumbs # noqa: E402 +import server as srv # noqa: E402 +from broker import BrokerError, PaperBroker # noqa: E402 + + +# ── chart bridge ─────────────────────────────────────────────────────────────────────────────────────── + +class ChartBridgeUnderParallelCalls(unittest.TestCase): + def test_sixty_parallel_commands_all_land_with_their_own_id(self): + with tempfile.TemporaryDirectory() as root: + ids, errors = [], [] + lock = threading.Lock() + start = threading.Barrier(20) + + def worker(n): + start.wait() + for k in range(3): + try: + entry = cb.enqueue(root, {"action": "clear", "tag": f"{n}-{k}"}) + with lock: + ids.append(entry["id"]) + except Exception as exc: # noqa: BLE001 + with lock: + errors.append(repr(exc)) + + threads = [threading.Thread(target=worker, args=(n,)) for n in range(20)] + [t.start() for t in threads] + [t.join() for t in threads] + self.assertEqual(errors, []) + self.assertEqual(len(ids), 60) + self.assertEqual(len(set(ids)), 60, "no two commands share an id") + self.assertEqual(sorted(ids), list(range(1, 61))) + self.assertEqual(len(cb.commands_since(root, 0)), cb.MAX_RESULTS) # the newest 60 survive + + def test_heartbeats_and_results_in_parallel_never_error(self): + with tempfile.TemporaryDirectory() as root: + cmd = cb.enqueue(root, {"action": "clear"}) + errors = [] + + def beat(): + for _ in range(15): + try: + cb.save_state(root, {"symbol": "BTCUSDT", "timeframe": "1h"}) + cb.record_result(root, {"id": cmd["id"], "ok": True}) + except Exception as exc: # noqa: BLE001 + errors.append(repr(exc)) + + threads = [threading.Thread(target=beat) for _ in range(8)] + [t.start() for t in threads] + [t.join() for t in threads] + self.assertEqual(errors, []) + + def test_ids_never_restart_even_if_the_queue_file_is_lost(self): + with tempfile.TemporaryDirectory() as root: + for _ in range(3): + cb.enqueue(root, {"action": "clear"}) + (cb._chart_dir(root) / cb.COMMANDS_FILE).write_text("{not json", "utf-8") + self.assertEqual(cb.enqueue(root, {"action": "clear"})["id"], 4) + + def test_a_result_needs_a_positive_integer_id_and_keeps_what_the_page_reported(self): + with tempfile.TemporaryDirectory() as root: + for bad in ({}, {"id": 0}, {"id": -3}, {"id": "abc"}, {"id": None}, {"id": float("inf")}): + with self.assertRaises(ValueError, msg=repr(bad)): + cb.record_result(root, bad) + out = cb.record_result(root, {"id": 7, "ok": True, "trades": [{"t": 1}], "count": 1, + "overlay": {"a": 1}, "palette": {"b": 2}}) + self.assertEqual(out["trades"], [{"t": 1}]) + self.assertEqual(cb.get_result(root, 7)["overlay"], {"a": 1}) + + +# ── a live console ───────────────────────────────────────────────────────────────────────────────────── + +class LiveConsole(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + frontend = Path(cls.tmp.name) / "frontend" + (frontend / "vendor").mkdir(parents=True) + (frontend / "index.html").write_text("console", encoding="utf-8") + (frontend / "app.js").write_text("// app", encoding="utf-8") + (Path(cls.tmp.name) / "secret.txt").write_text("OUTSIDE", encoding="utf-8") + cls._orig_static = srv.Handler.static + srv.Handler.static = srv.StaticFiles(str(frontend)) + cls.token = "audit-token-0123456789" + cls.patches = [ + mock.patch.object(srv, "CONSOLE_TOKEN", cls.token), + mock.patch.object(srv, "AGENTS_ROOT", cls.tmp.name), + mock.patch.dict(os.environ, {"LUXALGO_CHART_ROOT": cls.tmp.name}), + ] + for p in cls.patches: + p.start() + cls.httpd = srv.Server(("127.0.0.1", 0), srv.Handler) + cls.port = cls.httpd.server_address[1] + cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.httpd.shutdown() + cls.httpd.server_close() + srv.Handler.static = cls._orig_static + for p in cls.patches: + p.stop() + cls.tmp.cleanup() + + def call(self, path, method="GET", headers=None, body=None, host=None): + """A raw request, so Host / Origin / Sec-Fetch-* are exactly what we set.""" + data = json.dumps(body).encode() if body is not None else b"" + head = {"Host": host or f"127.0.0.1:{self.port}", "Connection": "close"} + head.update(headers or {}) + if body is not None: + head.setdefault("Content-Type", "application/json") + head["Content-Length"] = str(len(data)) + with socket.create_connection(("127.0.0.1", self.port), timeout=10) as sock: + sock.sendall((f"{method} {path} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in head.items()) + "\r\n").encode() + data) + reply = sock.makefile("rb").read() + top, _, payload = reply.partition(b"\r\n\r\n") + lines = top.decode("latin-1").split("\r\n") + status = int(lines[0].split()[1]) + hdrs = {k.lower(): v for k, v in (l.split(": ", 1) for l in lines[1:] if ": " in l)} + try: + return status, json.loads(payload.decode() or "null"), hdrs + except ValueError: + return status, payload, hdrs + + def post(self, path, body, **extra): + headers = {"X-Trader-Token": self.token} + headers.update(extra.pop("headers", {})) + return self.call(path, "POST", headers, body, **extra) + + # SEC-3 ---------------------------------------------------------------------------------------------- + def test_the_token_goes_to_the_own_page_and_to_local_tools_only(self): + code, body, _ = self.call("/api/session") # curl / CLI + self.assertEqual((code, body["data"]["token"]), (200, self.token)) + code, body, _ = self.call("/api/session", headers={"Sec-Fetch-Site": "same-origin"}) + self.assertEqual(code, 200) + for site in ("cross-site", "same-site"): + code, body, _ = self.call("/api/session", headers={"Sec-Fetch-Site": site}) + self.assertEqual(code, 403, site) + self.assertNotIn(self.token, json.dumps(body)) + code, _, _ = self.call("/api/session", headers={"Origin": "http://127.0.0.1:1"}) # another localhost app + self.assertEqual(code, 403) + code, _, _ = self.call("/api/session", host="evil.example") + self.assertEqual(code, 403) + + def test_a_lan_peer_is_not_handed_the_token(self): + with mock.patch.object(srv.Handler, "_peer_is_local", lambda self: False): + code, body, _ = self.call("/api/session") + self.assertEqual(code, 403) + self.assertNotIn(self.token, json.dumps(body)) + with mock.patch.object(srv, "ALLOW_LAN", True): + self.assertEqual(self.call("/api/session")[0], 200) + + # SEC-4 ---------------------------------------------------------------------------------------------- + def test_another_localhost_port_cannot_post(self): + mine = f"http://127.0.0.1:{self.port}" + self.assertEqual(self.post("/api/chart/state", {}, headers={"Origin": mine})[0], 200) + code, body, _ = self.post("/api/chart/state", {}, headers={"Origin": "http://127.0.0.1:9999"}) + self.assertEqual(code, 403) + self.assertEqual(self.post("/api/chart/state", {}, headers={"Origin": "http://localhost:" + str(self.port)})[0], 403) + + # SEC-9 ---------------------------------------------------------------------------------------------- + def test_a_cross_site_get_of_an_api_route_is_refused_but_a_local_tool_is_not(self): + self.assertEqual(self.call("/api/chart/state")[0], 200) + self.assertEqual(self.call("/api/chart/state", headers={"Sec-Fetch-Site": "same-origin"})[0], 200) + for site in ("cross-site", "same-site"): + code, body, _ = self.call("/api/chart/state", headers={"Sec-Fetch-Site": site}) + self.assertEqual((code, body["code"]), (403, "cross_site")) + + # headers -------------------------------------------------------------------------------------------- + def test_the_page_carries_a_csp_and_every_answer_is_nosniff(self): + code, _, hdrs = self.call("/") + self.assertEqual(code, 200) + self.assertIn("default-src 'self'", hdrs["content-security-policy"]) + self.assertNotIn("frame-ancestors", hdrs["content-security-policy"]) # Hermes embeds the page + self.assertEqual(hdrs["x-content-type-options"], "nosniff") + self.assertEqual(hdrs["referrer-policy"], "no-referrer") + self.assertEqual(self.call("/api/health")[2]["x-content-type-options"], "nosniff") + + def test_the_cookie_is_set_for_a_local_peer_only(self): + self.assertIn("trader_token=", self.call("/")[2].get("set-cookie", "")) + with mock.patch.object(srv.Handler, "_peer_is_local", lambda self: False): + self.assertNotIn("set-cookie", self.call("/")[2]) + + # BE-4 ----------------------------------------------------------------------------------------------- + def test_once_per_view_reaches_the_stream_so_every_view_may_reload(self): + cid_a, _ = srv.STREAM.attach() + cid_b, _ = srv.STREAM.attach() + try: + code, body, _ = self.post("/api/chart/command", {"action": "reload", "once_per_view": True}) + self.assertEqual(code, 200, body) + rid = body["data"]["command"]["id"] + self.assertTrue(srv.STREAM.is_once(rid)) + self.assertTrue(srv.STREAM.claim(rid, "viewA", once=True)) + self.assertTrue(srv.STREAM.claim(rid, "viewB", once=True), "a second view may run a once-per-view command") + finally: + srv.STREAM.detach(cid_a) + srv.STREAM.detach(cid_b) + + # 400s ----------------------------------------------------------------------------------------------- + def test_bad_numbers_are_a_400_not_a_500(self): + for path in ("/api/chart/commands?since=abc", "/api/chart/result?id=abc", "/api/library/warm?w=abc"): + self.assertEqual(self.call(path)[0], 400, path) + for body in ({"id": "abc"}, {"id": None}, {"id": 0}, {}): + self.assertEqual(self.post("/api/chart/result", body)[0], 400, body) + self.assertEqual(self.post("/api/chart/claim", {"id": "x"})[0], 400) + self.assertEqual(self.post("/api/agents", {"id": "ab", "name": "n", "instruction": "i", "counters": [1]})[0], 400) + self.assertEqual(self.post("/api/agents/learning", {"agent_id": "no-such-study", "text": "x"})[0], 404) + + # SEC-6 ---------------------------------------------------------------------------------------------- + def test_run_id_cannot_leave_the_results_folder(self): + base = Path(self.tmp.name) / "_chart" / "backtest" + base.mkdir(parents=True, exist_ok=True) + (base / "good.json").write_text('{"ok": 1}', "utf-8") + (Path(self.tmp.name) / "outside.json").write_text('{"secret": 1}', "utf-8") + got = srv.load_backtest_result(self.tmp.name, "good") + self.assertTrue(got["ok"]) + for bad in ("../../outside", "../outside", "/etc/passwd", "a/b", "good.json", "x" * 80): + got = srv.load_backtest_result(self.tmp.name, bad) + self.assertFalse(got["ok"], bad) + self.assertNotIn("secret", json.dumps(got)) + + # SEC-2 ---------------------------------------------------------------------------------------------- + def test_chat_ignores_a_client_workdir_and_clamps_the_timeout(self): + seen = {} + + def fake_run(**kw): + seen.update(kw) + return {"ok": True, "reply": "hi", "pine": None, "actions": [], "elapsed_ms": 1, "usage": None, "session_id": None} + + agents_store.ensure_seed(self.tmp.name) + with mock.patch.object(srv, "run_agent", fake_run): + self.post("/api/chat", {"agent_id": "desk", "message": "hi", "workdir": "/etc", "timeout": 99999}) + if seen: # the seed study id may differ; only assert when it ran + self.assertNotEqual(seen["workdir"], "/etc") + self.assertLessEqual(seen["timeout"], 600) + self.assertEqual(self.post("/api/chat", {"agent_id": "desk", "message": "hi", "timeout": "abc"})[0], 400) + + # SEC-10 --------------------------------------------------------------------------------------------- + def test_a_silent_socket_is_dropped_rather_than_held_for_ever(self): + self.assertEqual(srv.Handler.timeout, 30) + self.assertLessEqual(srv.Server.MAX_CONNECTIONS, 256) + + # TC-2 ----------------------------------------------------------------------------------------------- + def test_static_files_cannot_be_traversed_out_of_the_frontend_root(self): + static = srv.Handler.static + secret = str(Path(self.tmp.name) / "secret.txt") + for url in ("/../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt", "/%2e%2e%2fsecret.txt", "/..%5csecret.txt", + "//etc/passwd", "///etc/passwd", "/index.html%00.png", "/%00", "/vendor/../../secret.txt", + "/%252e%252e/secret.txt", "/../frontend2/x", "/" + "%2e%2e/" * 14 + "etc/passwd"): + got = static.resolve(url) + self.assertTrue(got is None or Path(got).resolve() != Path(secret).resolve(), url) + if got: + self.assertTrue(os.path.realpath(got).startswith(os.path.realpath(static.root) + os.sep), (url, got)) + self.assertTrue(static.resolve("/app.js").endswith("app.js")) + self.assertTrue(static.resolve("/").endswith("index.html")) + + def test_the_mjs_type_and_the_cookie_only_on_the_page(self): + self.assertNotIn("set-cookie", self.call("/app.js")[2]) + self.assertIn("javascript", srv.CONTENT_TYPES.get(".mjs", "")) + + +# ── replay price ─────────────────────────────────────────────────────────────────────────────────────── + +class ReplayPriceIsPerSymbol(unittest.TestCase): + def setUp(self): + srv._REPLAY.update(active=False, price=None, time=None, symbol=None) + self._real = srv.binance_price + srv.binance_price = lambda symbol: {"BTCUSDT": 60000.0, "ETHUSDT": 3000.0}[symbol] + self._pub = srv.STREAM.publish + srv.STREAM.publish = lambda *a, **k: 0 + + def tearDown(self): + srv.binance_price = self._real + srv.STREAM.publish = self._pub + srv._REPLAY.update(active=False, price=None, time=None, symbol=None) + + def test_an_eth_order_does_not_fill_at_the_btc_cursor_price(self): + srv.broker_action("replay", {"active": True, "price": 50000, "time": 1, "symbol": "BTCUSDT"}, from_page=True) + self.assertEqual(srv.broker_price("BTCUSDT"), 50000.0) + self.assertEqual(srv.broker_price("ETHUSDT"), 3000.0, "another symbol is priced live") + srv.broker_action("replay", {"active": False}, from_page=True) + self.assertEqual(srv.broker_price("BTCUSDT"), 60000.0) + self.assertIsNone(srv._REPLAY["symbol"]) + + def test_a_push_with_no_symbol_still_prices_everything(self): + srv.broker_action("replay", {"active": True, "price": 123.0}, from_page=True) + self.assertEqual(srv.broker_price("ETHUSDT"), 123.0) + + def test_a_silly_symbol_is_refused(self): + with self.assertRaises(srv.ApiError): + srv.broker_action("replay", {"active": True, "price": 1, "symbol": "../x"}, from_page=True) + + +# ── paper broker ─────────────────────────────────────────────────────────────────────────────────────── + +class BrokerMoney(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.path = os.path.join(self.dir, "paper.json") + self.px = {"XYZ": 100.0} + self.b = PaperBroker(self.path, lambda s: self.px[s]) + + def fill(self, side, qty, sym="XYZ"): + return self.b.approve(self.b.propose(sym, side, qty)["id"]) + + def test_realized_includes_the_entry_fee_and_reconciles_with_equity(self): + self.fill("buy", 10) + self.fill("sell", 10) + st = self.b.state() + self.assertAlmostEqual(st["realized"], -2.0, places=9) # 1.0 in + 1.0 out + self.assertAlmostEqual(st["equity"] - st["start_cash"], st["realized"] + sum(p["unrealized"] for p in st["positions"]), places=9) + self.fill("buy", 5) + self.px["XYZ"] = 110.0 + st = self.b.state() + self.assertAlmostEqual(st["equity"] - st["start_cash"], st["realized"] + sum(p["unrealized"] for p in st["positions"]), places=9) + + def test_a_failed_save_leaves_the_account_exactly_as_it_was(self): + oid = self.b.propose("XYZ", "buy", 1)["id"] + with mock.patch.object(PaperBroker, "_save", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + self.b.approve(oid) + st = self.b.state() + self.assertEqual((st["cash"], st["positions"], [o["id"] for o in st["pending"]]), (10000.0, [], [oid])) + self.assertEqual(self.b.approve(oid)["status"], "filled", "the retry works") + + def test_large_quantities_sell_cleanly_with_no_ghost_position(self): + self.px["XYZ"] = 0.00001 + for _ in range(3): + self.fill("buy", 1234567.891) + self.fill("sell", 3703703.673) + self.assertEqual(self.b.state()["positions"], []) + for _ in range(3): + self.fill("buy", 1234567.891) + for _ in range(3): + self.fill("sell", 1234567.891) + self.assertEqual(self.b.state()["positions"], []) + + def test_a_read_error_does_not_reset_the_account(self): + self.fill("buy", 1) + with mock.patch("builtins.open", side_effect=OSError(5, "EIO")): + with self.assertRaises(BrokerError) as cm: + PaperBroker(self.path, lambda s: 1.0) + self.assertEqual(cm.exception.code, "broker_unavailable") + self.assertEqual([f for f in os.listdir(self.dir) if ".bad" in f], []) + self.assertEqual(PaperBroker(self.path, lambda s: 100.0).state()["positions"][0]["qty"], 1.0) + + def test_an_unparsable_file_is_still_set_aside_and_a_thin_one_is_completed(self): + with open(self.path, "w", encoding="utf-8") as fh: + fh.write("{not json") + PaperBroker(self.path, lambda s: 1.0) + self.assertTrue([f for f in os.listdir(self.dir) if ".bad" in f]) + with open(self.path, "w", encoding="utf-8") as fh: + json.dump({"cash": 500.0, "orders": [{"id": 4, "status": "filled"}], "positions": {}}, fh) + b = PaperBroker(self.path, lambda s: 1.0) + st = b.state() + self.assertEqual((st["cash"], st["realized"]), (500.0, 0.0)) + self.assertEqual(b.propose("XYZ", "buy", 1)["id"], 5, "ids continue after the highest seen") + + def test_ids_continue_across_a_reset(self): + a = self.b.propose("XYZ", "buy", 1)["id"] + self.b.reset() + self.assertGreater(self.b.propose("XYZ", "buy", 1)["id"], a) + + def test_state_does_not_hold_the_lock_while_pricing(self): + self.fill("buy", 1) + gate = threading.Event() + release = threading.Event() + + def slow(sym): + gate.set() + release.wait(5) + return 100.0 + + self.b.price_of = slow + t = threading.Thread(target=self.b.state) + t.start() + self.assertTrue(gate.wait(5)) + started = time.time() + self.b.propose("XYZ", "buy", 1) # would block until `release` if the lock were held + self.assertLess(time.time() - started, 2.0) + release.set() + t.join() + + +# ── intervals ────────────────────────────────────────────────────────────────────────────────────────── + +class Intervals(unittest.TestCase): + def test_one_capital_m_is_a_month_and_display_forms_are_minutes(self): + n = srv.normalise_interval + self.assertEqual((n("1M"), n("30M"), n("3M"), n("15m"), n("1h"), n("4H"), n("1D"), n("1W"), n("1d")), + ("1M", "30m", "3m", "15m", "1h", "4h", "1d", "1w", "1d")) + self.assertEqual(n(""), "1h") + + def test_an_interval_binance_does_not_serve_is_refused_with_the_list(self): + for bad in ("45M", "3H", "2D", "60", "banana"): + out = srv.ep_bars({"symbol": ["BTCUSDT"], "interval": [bad]}) + self.assertEqual(out["bars"], [], bad) + self.assertIn("bad_interval", out["error"]) + self.assertIn("1M", out["error"]) + + def test_a_month_is_asked_of_binance_as_a_month(self): + seen = [] + srv._BARS_MEMO.clear() + with mock.patch.object(srv, "_fetch_klines", lambda s, i, l, end_time=None: seen.append(i) or []): + srv.ep_bars({"symbol": ["BTCUSDT"], "interval": ["1M"]}) + self.assertEqual(seen, ["1M"]) + + +# ── catalogue ────────────────────────────────────────────────────────────────────────────────────────── + +class CatalogueIsNotKeptWhenPartial(unittest.TestCase): + def test_a_short_walk_is_served_but_not_saved(self): + with tempfile.TemporaryDirectory() as root: + def pages(params): + page = int(params["page"][0]) + if page == 0: + return {"indicators": [{"slug": f"s{i}"} for i in range(srv.CATALOGUE_PAGE)], "total": 806} + return {"indicators": [], "total": 806} # a hiccup on page 1 + with mock.patch.object(srv, "AGENTS_ROOT", root), mock.patch.object(srv, "ep_indicators", pages), \ + mock.patch.object(srv, "ep_concepts", lambda p: {"concepts": []}), \ + mock.patch.object(srv, "ep_families", lambda p: {"families": []}): + out = srv.ep_catalogue({}) + self.assertTrue(out["partial"]) + self.assertEqual(out["upstream_total"], 806) + self.assertFalse(os.path.exists(os.path.join(root, "catalogue.json"))) + + def test_a_complete_walk_is_saved(self): + with tempfile.TemporaryDirectory() as root: + def pages(params): + return {"indicators": [{"slug": "a"}] if params["page"][0] == "0" else [], "total": 1} + with mock.patch.object(srv, "AGENTS_ROOT", root), mock.patch.object(srv, "ep_indicators", pages), \ + mock.patch.object(srv, "ep_concepts", lambda p: {"concepts": []}), \ + mock.patch.object(srv, "ep_families", lambda p: {"families": []}): + out = srv.ep_catalogue({}) + self.assertNotIn("partial", out) + self.assertTrue(os.path.exists(os.path.join(root, "catalogue.json"))) + + +# ── agents, chat, thumbnails ─────────────────────────────────────────────────────────────────────────── + +class AgentDefinitions(unittest.TestCase): + BASE = {"id": "ab", "name": "n", "instruction": "i"} + + def test_names_that_would_read_as_options_are_refused(self): + for field, value in (("toolsets", ["-t", "--yolo"]), ("toolsets", ["--yolo"]), ("skills", ["-x"]), + ("skills", ["a b"]), ("skills", [5]), ("session_id", "--dangerous"), ("session_id", "a b")): + with self.assertRaises(ValueError, msg=(field, value)): + agents_store.validate({**self.BASE, field: value}) + + def test_ordinary_names_pass(self): + out = agents_store.validate({**self.BASE, "skills": ["trader-desk", "trading/trader-desk"], "toolsets": ["terminal"], + "session_id": "20260508_101314_ab12cd"}) + self.assertEqual(out["skills"], ["trader-desk", "trading/trader-desk"]) + + +class ChatParsing(unittest.TestCase): + def test_a_reply_of_blank_lines_does_not_stall_the_server(self): + for reply in ("\n" * 20000, "CHART: a" + " " * 19980 + "x", " \t" * 10000 + "\nCHART: add rsi"): + t0 = time.time() + chat.parse_actions(reply) + self.assertLess(time.time() - t0, 0.3) + self.assertEqual(chat.parse_actions("hello\n CHART: add supertrend \nbye"), [{"kind": "add", "type": "supertrend"}]) + + def test_the_usage_file_lives_in_a_private_directory_that_is_removed(self): + seen = {} + + def fake_run(cmd, **kw): + usage = cmd[cmd.index("--usage-file") + 1] + seen["dir"] = os.path.dirname(usage) + seen["mode"] = os.stat(seen["dir"]).st_mode & 0o777 + return mock.Mock(returncode=0, stdout="hi", stderr="") + with mock.patch.object(chat.subprocess, "run", fake_run): + out = chat.run_agent("hermes", {"skills": [], "toolsets": [], "instruction": "x"}, "hi", workdir=None) + self.assertTrue(out["ok"]) + self.assertEqual(seen["mode"], 0o700) + self.assertFalse(os.path.exists(seen["dir"])) + + def test_a_missing_workdir_is_said_plainly(self): + out = chat.run_agent("hermes", {"skills": [], "toolsets": [], "instruction": "x"}, "hi", workdir="/no/such/folder") + self.assertFalse(out["ok"]) + self.assertIn("working folder does not exist", out["reason"]) + + +class Thumbnails(unittest.TestCase): + PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 64 + + def test_only_a_real_picture_is_kept_and_a_concurrent_second_ask_never_sees_a_partial_file(self): + with tempfile.TemporaryDirectory() as cache: + url = "https://luxalgo-production.s3.amazonaws.com/a.png" + calls = [] + + def shrink(src, dst, width): + calls.append(1) + dst.write_bytes(b"\xff\xd8\xff" + b"1" * 10) + time.sleep(0.3) + dst.write_bytes(b"\xff\xd8\xff" + b"1" * 1000) # the whole file arrives later + return True + + got = [] + with mock.patch.object(library_thumbs, "CACHE_DIR", Path(cache)), \ + mock.patch.object(library_thumbs, "_fetch", lambda u: self.PNG), \ + mock.patch.object(library_thumbs, "_shrink", shrink): + t = threading.Thread(target=lambda: got.append(library_thumbs.thumb("s", url, 320))) + t.start() + time.sleep(0.1) + got.append(library_thumbs.thumb("s", url, 320)) # while the first is mid-build + t.join() + self.assertEqual([len(g[0]) for g in got], [1003, 1003]) + self.assertEqual(len(calls), 1, "built once") + + def test_html_or_svg_bytes_never_reach_the_converters(self): + with tempfile.TemporaryDirectory() as cache: + url = "https://luxalgo-production.s3.amazonaws.com/a.png" + for payload in (b"", b"#!/bin/sh\n", b"push graphic-context"): + with mock.patch.object(library_thumbs, "CACHE_DIR", Path(cache)), \ + mock.patch.object(library_thumbs, "_fetch", lambda u, p=payload: p), \ + mock.patch.object(library_thumbs, "_shrink", side_effect=AssertionError("converter reached")): + self.assertIsNone(library_thumbs.thumb("s", url, 320)) + + def test_a_redirect_is_not_followed_and_the_body_is_capped(self): + handler = library_thumbs._NoRedirect() + self.assertIsNone(handler.redirect_request(None, None, 301, "x", {}, "http://127.0.0.1/")) + self.assertLessEqual(library_thumbs.MAX_BYTES, 8_000_000) + + +# ── backtest service (pandas is not installed in the console venv; the checks need none) ────────────── + +class BacktestService(unittest.TestCase): + @classmethod + def setUpClass(cls): + import importlib.util + spec = importlib.util.spec_from_file_location("backtest_service_audit", BACKEND / "backtest_service.py") + cls.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + + def test_a_local_name_is_a_bare_file_name(self): + for bad in ("../../etc/passwd", "/etc/hostname", "a/b", "..", ".hidden/x", ""): + with self.assertRaises((ValueError, ImportError), msg=bad): + self.mod._bars_local(bad) + + def test_symbol_and_interval_are_validated_before_they_reach_a_url(self): + for symbol, interval in (("BTC&limit=1", "1h"), ("BTCUSDT", "1h&x=1"), ("", "1h"), ("BTCUSDT", "")): + with self.assertRaises((ValueError, ImportError)): + self.mod._klines_binance(symbol, interval, 10) + + def test_json_never_carries_nan(self): + out = self.mod._clean_nan({"a": float("nan"), "b": [float("inf"), 1.5], "c": {"d": float("-inf")}}) + self.assertEqual(out, {"a": None, "b": [None, 1.5], "c": {"d": None}}) + json.dumps(out, allow_nan=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/console/backend/tests/test_catalogue_groups.py b/console/backend/tests/test_catalogue_groups.py index cef8687..845b508 100644 --- a/console/backend/tests/test_catalogue_groups.py +++ b/console/backend/tests/test_catalogue_groups.py @@ -39,7 +39,7 @@ class TheCatalogueComesInOnePiece(unittest.TestCase): def test_the_endpoint_walks_the_pages_once_and_keeps_them(self): src = read(SERVER) self.assertIn('"/api/catalogue"', src) - body = src.split("def ep_catalogue(", 1)[1].split("\ndef ", 1)[0] + body = src.split("def _ep_catalogue(", 1)[1].split("\ndef ", 1)[0] self.assertIn('"sort": ["family"]', body, "the server's own grouping means the rows arrive clustered and the page can walk them") self.assertIn("CATALOGUE_PAGE", body, "the MCP caps a page at 100 rows; the walk has to loop") @@ -51,7 +51,7 @@ def test_the_second_walk_is_what_files_the_unfiled(self): """Half the catalogue carries no family; its CONCEPTS do. Walk them and 390 of 414 rows land in a real group (measured 27 Sep) — otherwise the rail opens with a 414-row "Unfiled".""" src = read(SERVER) - body = src.split("def ep_catalogue(", 1)[1].split("\ndef ", 1)[0] + body = src.split("def _ep_catalogue(", 1)[1].split("\ndef ", 1)[0] self.assertIn("ep_concepts(", body, "the concept walk shares the catalogue's cache and TTL") self.assertIn('concept = concepts.get(row.get("slug") or "")', body) self.assertIn('row["family"] = concept.get("family")', body) diff --git a/console/backend/tests/test_console_hardening.py b/console/backend/tests/test_console_hardening.py index a8afdec..e8b1d55 100644 --- a/console/backend/tests/test_console_hardening.py +++ b/console/backend/tests/test_console_hardening.py @@ -88,7 +88,11 @@ def test_host_must_be_local(self): def test_origin_may_be_absent_or_local_only(self): self.assertTrue(srv.Handler._origin_ok(fake_request())) # CLI, MCP, curl - self.assertTrue(srv.Handler._origin_ok(fake_request(origin="http://127.0.0.1:8787"))) + self.assertTrue(srv.Handler._origin_ok(fake_request(host="127.0.0.1:8787", origin="http://127.0.0.1:8787"))) + # the PORT counts: another web app on localhost is a "local name" but not this console's page (audit SEC-4) + self.assertFalse(srv.Handler._origin_ok(fake_request(host="127.0.0.1:8787", origin="http://127.0.0.1:9999"))) + self.assertFalse(srv.Handler._origin_ok(fake_request(host="127.0.0.1:8787", origin="http://localhost:8787"))) + self.assertFalse(srv.Handler._origin_ok(fake_request(origin="http://127.0.0.1:8787"))) # no Host to compare with self.assertFalse(srv.Handler._origin_ok(fake_request(origin="null"))) # sandboxed page self.assertFalse(srv.Handler._origin_ok(fake_request(origin="http://evil.example"))) diff --git a/console/backend/tests/test_preview_cache.py b/console/backend/tests/test_preview_cache.py index 0700804..a73c9e8 100644 --- a/console/backend/tests/test_preview_cache.py +++ b/console/backend/tests/test_preview_cache.py @@ -78,7 +78,7 @@ def test_the_thumb_cache_module_is_stdlib_only(self): """The console's rule: no third-party imports (the resizer is a CLI, not a Python dep).""" src = read(THUMBS) allowed = {"__future__", "hashlib", "os", "re", "shutil", "subprocess", "threading", "time", - "urllib", "concurrent", "pathlib"} + "urllib", "concurrent", "pathlib", "tempfile"} for line in src.splitlines(): match = re.match(r"^(?:import|from)\s+([a-zA-Z_][\w.]*)", line) if match: @@ -112,7 +112,18 @@ def test_only_luxalgo_is_ever_fetched(self): self.assertIn("def _allowed(", src) body = src.split("def thumb(", 1)[1] self.assertIn("if not _allowed(url):", body, "thumb() must refuse a foreign URL, not fetch it") - self.assertIn('if not url.startswith("https://"):', src, "https only, even for a friendly host") + # behaviour, not a string: https only, exact hosts only (a "contains luxalgo" rule let other buckets in) + import importlib.util + spec = importlib.util.spec_from_file_location("library_thumbs_allow", THUMBS) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + self.assertTrue(mod._allowed("https://luxalgo-production.s3.amazonaws.com/a.png")) + for url in ("http://luxalgo-production.s3.amazonaws.com/a.png", + "https://luxalgo-evil.s3.amazonaws.com/a.png", + "https://attacker-luxalgo.s3.eu-west-1.amazonaws.com/a.svg", + "https://luxalgo.s3-website-us-east-1.amazonaws.com/a", + "https://evil.com/luxalgo-production.s3.amazonaws.com/", "", None): + self.assertFalse(mod._allowed(url), url) def test_the_second_bucket_is_actually_fetched(self): """Refusing it was a live bug: four cards on page one had no picture (measured 27 Sep).""" @@ -145,14 +156,14 @@ def test_the_warmer_walks_the_whole_catalogue_by_default(self): self.assertIn("def warm_catalogue_thumbs(pages: int = 0) -> None:", src) self.assertIn("limit = pages or MAX_WARM_PAGES", src) self.assertIn("MAX_WARM_PAGES = 40", src, "a runaway page walk needs a guard rail") - self.assertIn('os.environ.get("TRADERS_AGENT_THUMB_WARM_PAGES", "0")', src) + self.assertIn('_env_int("TRADERS_AGENT_THUMB_WARM_PAGES", 0)', src) self.assertIn('out["warmer"] = dict(_WARM)', src, "progress must be observable, not guessed") def test_the_resizer_is_a_cli_with_a_honest_fallback(self): src = read(THUMBS) for tool in ("vips", "magick", "convert", "ffmpeg"): self.assertIn(f'"{tool}"', src, f"{tool} is part of the fallback chain") - self.assertIn('return raw, "image/png"', src, + self.assertIn('return raw, ("image/"', src, "no converter on a machine must serve the real picture, not a blank tile") def test_a_missing_preview_is_a_404_not_a_placeholder(self): diff --git a/console/frontend/replay.js b/console/frontend/replay.js index 3cacf71..7679dc4 100644 --- a/console/frontend/replay.js +++ b/console/frontend/replay.js @@ -110,7 +110,9 @@ {active:false} clears whatever was left over (a reload mid-replay, a killed page). */ async function push(px) { if (state.active && px == null) return; // nothing to price the fills with yet - const body = { active: !!state.active, price: state.active ? px : null, time: state.active ? state.cursorTime : null }; + let sym = null; + try { const m = typeof window.chartMarket === 'function' ? window.chartMarket() : null; sym = (m && m.symbol) || null; } catch (err) { sym = null; } + const body = { active: !!state.active, price: state.active ? px : null, time: state.active ? state.cursorTime : null, symbol: state.active ? sym : null }; const key = JSON.stringify(body); if (key === lastPush) return; try { await post('/api/broker/replay', body); lastPush = key; } From 3daec5447767123c82bfb9b171582c25804083a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:12:24 +0000 Subject: [PATCH 2/4] audit 8 Oct, agent surface and licensing: no silent truncation, true Pine guidance, lean skill, honest docs, installer flags Library Pine / lists are cut AND SAID (offset to read on); the Pine guidance (while / for..in run) is corrected in the tool, skill, README and HANDOFF; the skill is 139 lines (history moved to docs/desk-engineering-notes.md) and now covers broker / replay safety; bt_* answer instead of raising; chart_shot uses the view gate; chart_batch takes a list; annotations explicit. Loopback calls bypass http_proxy. Installer: flag parsing (unknown flags fail), --help, --uninstall, PORT baked in, doctor without eval. The committed CC BY-NC-SA LuxAlgo script is removed (the repo is public). NOTICE (AGPL + source offer), Zag licence texts, vendor SHA256SUMS. README claims corrected (paper broker, files written, offline chart, ports). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GLsbckEp88WEwC41dfyMeS --- .gitignore | 2 +- NOTICE | 27 + README.md | 55 +- THIRD-PARTY.md | 23 + .../backend/tests/test_audit_8oct_fixes.py | 103 ++- .../tests/test_mcp_tool_annotations.py | 21 +- console/bin/library-indicator | 20 +- console/bin/trader-chart | 27 +- console/frontend/vendor/SHA256SUMS | 336 +++++++ console/frontend/vendor/VENDORING.md | 7 + .../vendor/zag/THIRD-PARTY-LICENSES.md | 83 ++ console/mcp/server.py | 147 ++- docs/HANDOFF.md | 11 +- docs/MCP-TOOLS.md | 28 +- docs/desk-engineering-notes.md | 357 ++++++++ docs/shots/edge-stats-answer.png | Bin 0 -> 65885 bytes docs/shots/edge-stats-grouped-light.png | Bin 0 -> 60847 bytes docs/shots/edge-stats-session.png | Bin 0 -> 62492 bytes .../smart-money-concepts-smc.pine | 848 ------------------ install.sh | 110 ++- skills/trader-desk/SKILL.md | 444 +++------ tools/vendor-sums.py | 61 ++ 22 files changed, 1407 insertions(+), 1303 deletions(-) create mode 100644 NOTICE create mode 100644 console/frontend/vendor/SHA256SUMS create mode 100644 console/frontend/vendor/zag/THIRD-PARTY-LICENSES.md create mode 100644 docs/desk-engineering-notes.md create mode 100644 docs/shots/edge-stats-answer.png create mode 100644 docs/shots/edge-stats-grouped-light.png create mode 100644 docs/shots/edge-stats-session.png delete mode 100644 docs/studies/luxalgo-reference/smart-money-concepts-smc.pine create mode 100755 tools/vendor-sums.py diff --git a/.gitignore b/.gitignore index 1199711..95f8353 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ venv/ /tmp/ # local test screenshots (grim captures from UI verification runs) /shots/ -docs/shots/ +# docs/shots/ holds the README's images: they ARE tracked (ignoring the folder left three of them out of every clone) # PR Lens writes its previews here. They are rebuilt on demand. .pr-lens/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..5cd987c --- /dev/null +++ b/NOTICE @@ -0,0 +1,27 @@ +Trader's Agent: licence notice +============================== + +1. This project's own code (everything outside console/frontend/vendor/) is MIT-licensed: see LICENSE. + +2. The distribution as a whole is NOT only MIT. It redistributes, unmodified or patched, these components: + + console/frontend/vendor/pinets/ PineTS, patched build (AGPL-3.0-only; LICENSE beside it) + console/frontend/vendor/vela-pinets/ Vela-PineTS (AGPL-3.0-only; LICENSE beside it) + console/frontend/vendor/vela/ Vela (Apache-2.0; LICENSE and NOTICE beside it) + console/frontend/vendor/zag/ Zag, Floating UI, proxy-compare (MIT; texts in THIRD-PARTY-LICENSES.md) + + Because AGPL-3.0 code is redistributed here, the combined distribution (this repository with its vendor/ folder, and a + console served from it) is offered under the AGPL-3.0 terms for those parts: anyone who receives it, including a person + using a console reached over a network, may ask for the complete corresponding source. Details: THIRD-PARTY.md. + +3. Written offer of source for the patched engine (console/frontend/vendor/pinets/pinets.min.browser.es.js): + repository https://github.com/godzillacode0000/PineTS (fork of https://github.com/LuxAlgo/PineTS) + branch fix/p1-p8-on-0.11 + built from commit f5ec571 + build npm ci && npm run build:prod:browser-es + console/frontend/vendor/pinets/PROVENANCE.md records the patches, the file's size and its sha256. The source for the + rest of this distribution is this repository. + +4. Third-party content that is deliberately NOT in this repository: LuxAlgo Library indicators (CC BY-NC-SA 4.0). They are + fetched on demand at run time and never committed or redistributed here. Product names (TradingView, LuxAlgo, Hermes, + Binance) belong to their owners; this is an unofficial plugin. diff --git a/README.md b/README.md index a2ab0b2..57233c5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **A Hermes Desktop plugin that opens a chart-first trading console: Vela-rendered candlesticks, and the LuxAlgo Library on top of them.** -![licence: MIT](https://img.shields.io/badge/licence-MIT-3da639?style=flat-square) +![licence: MIT + vendored AGPL-3.0](https://img.shields.io/badge/licence-MIT%20%2B%20vendored%20AGPL--3.0-3da639?style=flat-square) ![python: 3.11+](https://img.shields.io/badge/python-3.11%2B-3776ab?style=flat-square&logo=python&logoColor=white) ![hermes desktop: 0.21.3+](https://img.shields.io/badge/hermes%20desktop-%E2%89%A50.21.3-6b6b74?style=flat-square) ![kind: desktop plugin](https://img.shields.io/badge/hermes%20plugin-desktop-f2a83b?style=flat-square) @@ -37,7 +37,8 @@ MCP server) in one local web app — your chat session is left exactly where it > **Unofficial.** This project is not affiliated with, endorsed by, or sponsored by LuxAlgo. It uses > the LuxAlgo name descriptively ("compatible with LuxAlgo MCP") and ships no LuxAlgo logo. See -> [`THIRD-PARTY.md`](THIRD-PARTY.md) for the licences and the attribution each piece requires. +> [`THIRD-PARTY.md`](THIRD-PARTY.md) and [`NOTICE`](NOTICE) for the licences, the attribution each piece requires and the +> source offer for the patched engine (the distribution as a whole carries AGPL-3.0 parts). ## Requirements @@ -45,7 +46,7 @@ MCP server) in one local web app — your chat session is left exactly where it |---|---| | **Hermes Desktop** | v0.21.3 or newer (the plugin uses the `sidebar.nav` + `routes` contribution areas) | | **Python** | 3.11+ with the `mcp` client: `pip install mcp` (a venv is fine — point `PY=` at it) | -| **Network** | for LuxAlgo's MCP endpoint and the Binance data feed; the chart falls back to synthetic bars when the provider is unreachable. The chart engine and Pine runtime are served locally | +| **Network** | for LuxAlgo's MCP endpoint and the Binance data feed; with no market feed the chart stays empty (`chart_state` says `bars None`). The chart engine and Pine runtime are served locally | | **Node 20+** *(optional)* | only for [Edge Stats](#edge-stats--how-often-did-it-actually-happen) — `./install.sh --with-edge` installs LuxAlgo's engine (~300 MB with its dependencies) next to the console. Without it everything else works and the sheet explains what is missing | | **Preview resizer** *(optional)* | `libvips` (or ImageMagick, or `ffmpeg`) — see [Catalogue previews](#catalogue-previews-are-kept-on-your-machine). With none of them the console still shows every preview, just at the catalogue's original size | | **OS** | Linux — built and verified on Omarchy (Arch + Hyprland). macOS and Windows are out of scope. | @@ -80,14 +81,14 @@ If the pane is enabled but still does not dock, that is a different, app-side in see [`docs/INSTALL-ENABLE.md`](docs/INSTALL-ENABLE.md) for both failure modes side by side. The browser builds are committed under `console/frontend/vendor/` — the console loads them from there. -`./install.sh --vendor` refreshes those exact files from the pinned CDN versions (see the licence note -in `THIRD-PARTY.md`). +Their checksums are in `console/frontend/vendor/SHA256SUMS` (`tools/vendor-sums.py --check`; the test suite runs it), and +`console/frontend/vendor/VENDORING.md` says how to refresh them (see the licence note in `THIRD-PARTY.md`). ### Verify it worked ```bash curl -s localhost:8787/api/health # {"ok": true, "data": {"status": "ok", ...}} -node tools/verify-plugin.mjs plugin/plugin.js # OK — 7 contributions +node tools/verify-plugin.mjs plugin/plugin.js # OK — 8 contributions console/bin/trader-chart state # symbol, timeframe, price, bars, indicators on ``` @@ -124,10 +125,9 @@ systemctl --user enable --now traders-agent.service | Status bar → **Trader's Agent** chip | same, from anywhere in the app | | `Ctrl+K` → **Trading: open Trader's Agent** | same, from the palette | | `Ctrl+K` → **Trading: toggle chart reveal on launch** | stop the console opening by itself at app start | -| Top bar → `☰ Library` | the Library panel: search 800+ concepts and indicators | -| Top bar → `▤ Details` | the selected item: write-up, **full Pine source**, licence badge, **Run PineTS** | +| Chart top bar → **⌗ Indicators** | opens the **drawer**: search the 806-row LuxAlgo catalogue, star favourites, mount Vela's built-ins, or open a Library row's Details (write-up, **full Pine source**, licence badge, **Run PineTS** / Add to chart) | | Chart top bar → **Layout** | Vela's own grid picker — presets and custom `gx` grids. The console boots at `2h` (two side by side); the same door is `chart_set_layout` / `trader-chart layout` | -| Chart top bar → **⌗ Indicators** | One surface for both halves: **Favourites** (your own ☆), **BUILT-INS** (Vela's natives for this market, read live from the frame) and **LIBRARY** (the 806-row LuxAlgo catalogue). Every LIBRARY card carries the catalogue's own **chart preview** above its name — and the Details pane shows it full size — so "how does this look applied?" is answered before running anything. The LIBRARY arrives **grouped into families** (SMC / ICT 74, Trend 108, Volume & Flow 96, …) with a rail down the left to jump between them, and each family opens into its own **clusters** ("Moving-average lineage", "Candlestick catalog"); every card has a **Reading** button that unfolds what the indicator is and how it is read, in place. Star the ones you reach for; clicking a built-in mounts it, a Library row opens the Details pane with its Run PineTS / Add to chart. The same door is `chart_indicators` / `trader-chart indicators` (`--family trend --reading mlma --fold trend:on`) | +| Chart top bar → **<> Script** | the Pine script pane: write or paste Pine, **Run**, tweak the script's own **Settings**, save and reopen scripts | | Chart top bar → **⛶ Full screen** | Gives the chart the whole pane — and the whole display: the console's chrome, panels and statusbar step aside and the page asks the browser for fullscreen. `Esc` (or the floating `✕`, or the same button) comes back. Docked on Vela's own toolbar row beside `<> Script` and `☰ catalogue`; the door is `chart_fullscreen` / `trader-chart fullscreen [--off]` | | Chart ⋯ menu → **Edge Stats** | the Edge Stats sheet: ask a question, run one of the 42 catalogue reports, group by weekday / month / year, open any historical session on a chart. [Details below](#edge-stats--how-often-did-it-actually-happen) | | The chart's own bottom bar | Vela's range buttons, timezone clock and settings (not ours) | @@ -224,7 +224,7 @@ hermes mcp test traders-chart # start a new session afterwards | `chart_remove_indicator` | take indicators **off** the chart — one by name, or `all` for every study (reports the chart's before → after list) | | `chart_set_market` | switch symbol / timeframe | | `chart_set_layout` | read or set the workspace **grid** — `1`, `2h`, `2v`, `4`, `8`, or `gx` (no argument reads it) | -| `chart_indicators` | the **Indicators** surface — sections `favorites`/`builtins`/`library`, `q` for the search box, `family` to open one group of the catalogue, `reading SLUG` to unfold a card's write-up, `fold FAMILY[:on]` to fold a group, `star`/`unstar KIND:ID`, `mount` a built-in; answers with the rows it painted, the groups it drew and the reading it left open | +| `chart_indicators` | the **Indicators** drawer — sections `favorites`/`builtins`/`library`, `q` for the search box, `family` to open one group of the catalogue, `star`/`unstar KIND:ID`, `mount` a built-in; answers with the rows it painted | | `chart_fullscreen` | give the chart the whole pane / the whole screen (`on=False` comes back) — hides the console chrome and asks the browser for fullscreen; the answer says whether the display was taken or only the page | | `chart_reload` | remount every attached console (picks up new frontend files) | | `chart_palette` | what colours the chart is wearing (`try_apply` asserts the console theme) | @@ -233,7 +233,7 @@ hermes mcp test traders-chart # start a new session afterwards | `chart_drawing` | draw with Vela's own **drawing tools** — 76 types (trend line, channel, fib, Gann, Elliott, XABCD …), placed by `bars_ago` + price, real objects the operator can drag; a wrong anchor count is refused before drawing | | `chart_view` | read or set a **view setting** — chart type, log / invert / percent scale, countdown, time zone, session, watermark, status line, grid sync, the `?` shortcuts panel; every write is read back. Vela cannot *create* a price alert, so `alerts` only reads / clears the indicator-alert inbox | | `chart_marks` | event **marks** on the time axis, in one "Trader's Agent" group (`clear` only touches its own) | -| `chart_browse` | open the Library concept list in the pane (optional `family`) | +| `chart_browse` | open the catalogue in the drawer (optional `family`) | | `broker_state` | the paper (simulated Binance) account: cash, equity, positions, orders waiting for you | | `broker_propose` | propose a paper order — it only puts an Approve/Reject card on the chart; the agent cannot approve it | | `chart_draw` | run Pine and paint the boxes/lines/labels it builds on the chart overlay (optional `inputs`, as above) | @@ -276,7 +276,7 @@ and which calls return cleanly while doing nothing — is written down in Every tool carries MCP annotations (title, `readOnlyHint`, `destructiveHint`, `openWorldHint`), and the mutating ones answer with what the chart looks like *after* the call — a request echoed back is not a painted pane. Failures come back as a code beside the prose -(`NOT_RUNNABLE[while]`, `RUNTIME_CRASH[pinets-get_v]`, `TOO_FEW_BARS`, `ENGINE_UNAVAILABLE`, `TIMEOUT`), +(`NOT_RUNNABLE[import]`, `RUNTIME_CRASH[pinets-get_v]`, `TOO_FEW_BARS`, `ENGINE_UNAVAILABLE`, `TIMEOUT`), so a caller can branch without regex-matching a sentence. **Or the CLIs**, if you would rather shell out: @@ -316,7 +316,10 @@ systemctl --user restart traders-agent.service # only if you run the console a Uninstall: delete `~/.hermes/desktop-plugins/traders-desk/` in Hermes Desktop (**Capabilities → Plugins**), then remove the console's service (`systemctl --user disable --now traders-agent.service`). -Nothing else is written outside the repo; `console/agents/` holds the runtime state you can delete. +Outside the repo the console writes: `~/.local/state/traders-agent/` (`console.token`, the paper account `paper.json`), +`~/.local/share/traders-agent/` (preview cache `thumbs/`, Edge Stats `edge/`, the optional backtest venv `bt/`), +`~/.hermes/skills/trading/trader-desk/` (the skill, copied by `install.sh`) and, for shots the MCP server saves, +`/tmp`. `console/agents/` holds the runtime state. Delete those to remove every trace. ## Repository layout @@ -338,7 +341,12 @@ install.sh installs the plugin into $HERMES_HOME/desktop-plugins/ | Env | Default | Meaning | |---|---|---| | `PY` | `python3` | the interpreter for `console/start.sh` (must have `mcp`) | -| `PORT` | `8787` | console port — the plugin's frame points at `http://127.0.0.1:8787/` | +| `PORT` | `8787` | console port. `./install.sh` bakes `PORT` into the installed plugin's frame (re-run it after changing); the MCP server and `trader-chart` follow `LUXALGO_CONSOLE` | +| `LUXALGO_CONSOLE` | `http://127.0.0.1:8787` | where the MCP server, `trader-chart` and `library-indicator` find the console (set it with `hermes mcp add … --env LUXALGO_CONSOLE=http://127.0.0.1:9000` when `PORT` is not 8787) | +| `TRADER_CONSOLE_TOKEN_FILE` | `~/.local/state/traders-agent/console.token` | the POST token the console mints and the MCP server and CLI read | +| `TRADERS_AGENT_WORKDIR` | `~/Projects/luxalgo-web` if it exists, else `console/agents` | the folder the in-app study bridge runs the agent CLI in (never taken from a request) | +| `TRADERS_AGENT_ALLOW_LAN` | unset | with `--host 0.0.0.0`, let LAN peers receive the POST token; without it they get a read-only console | +| `TRADER_PAPER_FILE` | `~/.local/state/traders-agent/paper.json` | the paper account (use another path for a second test console) | | `LUXALGO_AGENTS_DIR` | `console/agents` | runtime state: chart bridge files, study threads, shots | | `HERMES_CLI` | `hermes` on `PATH` | CLI the in-app study bridge shells out to | | `LUXALGO_CHART_INLINE_WAIT` | `8` | seconds a command holds its request open waiting for the chart's pushed answer | @@ -353,7 +361,7 @@ install.sh installs the plugin into $HERMES_HOME/desktop-plugins/ ## Catalogue previews are kept on your machine -The Indicators modal shows each card the way LuxAlgo's own library does, with a chart preview. Those +The Indicators drawer shows each card the way LuxAlgo's own library does, with a chart preview. Those pictures are 1600×1000 PNGs on LuxAlgo's S3 (~29 KB each) and they are **slow to fetch one by one** — about 1–2 s each from a home connection, and a browser only opens six connections per host, so sixty cards used to fill in while you watched. The console therefore fetches each picture **once**, shrinks @@ -381,8 +389,9 @@ it from `http://127.0.0.1:8787/api/library/thumb`: **~5 ms warm instead of ~2 s* - **The bridge is not a headless renderer.** `trader-chart` and the MCP tools only take effect while a chart view is mounted (the Hermes pane or a browser tab); with none attached they fail fast with "no chart view attached" rather than hanging. Commands are pushed, so a view answers in tens of ms. -- **One console per machine** on one port. No order placement, no account, no positions — it is - read-only market data plus rendering. +- **One console per machine** on one port. No real orders and no brokerage account exist: the only trading is a + **simulated Binance spot paper account** (`broker_*`), where the agent can only propose and you approve. The rest is + market data plus rendering. - **The Library is non-commercial.** Its content is CC BY-NC-SA 4.0 — fine to read and cite here, not to resell or ship inside a paid product. - **Linux only, on purpose.** Built and verified on Omarchy (Arch + Hyprland) with Hermes Desktop. @@ -403,7 +412,7 @@ python3 -m compileall console/backend The suite is stdlib-only on purpose — the study store, the chat bridge, the chart bridge and the push channel are covered without a browser, a network, or the app — and the MCP tool layer runs against a -stub console. ≈700 tests; the MCP ones skip themselves when `fastmcp` is absent (CI sets +stub console. about 1,050 tests; the MCP ones skip themselves when `fastmcp` is absent (CI sets `TRADER_CHART_REQUIRE_MCP=1` so they cannot silently skip there). CI (`.github/workflows/ci.yml`) has three jobs: the plugin harness (Node 20 — the real plugin first, @@ -421,9 +430,10 @@ frame remounted (switch session and back). - **The plugin never appears in Capabilities → Plugins.** `Ctrl+K` → *Reload desktop plugins*; if it still does not show up, the folder was dropped after a failed load — rename `~/.hermes/desktop-plugins/traders-desk` and set `id:` inside `plugin.js` to the same new name. -- **The chart is empty and the log says `provider: synthetic`.** Binance's data provider was - unreachable; the chart still renders deterministic bars so the page never comes up blank. -- **Port 8787 busy.** `PORT=9000 ./console/start.sh`, then point the plugin's `CONSOLE_ORIGIN` at it. +- **The chart is empty.** Binance's data feed was unreachable (`chart_state` says `bars None`); the console does not + invent bars. Check the network, then `chart_reload`. +- **Port 8787 busy.** `PORT=9000 ./console/start.sh`, then `PORT=9000 ./install.sh` (it points the installed plugin at that + port) and set `LUXALGO_CONSOLE=http://127.0.0.1:9000` for the MCP server and CLI. ## Licence & credits @@ -454,8 +464,7 @@ pane beside the chat as intended. The plugin handles it as far as it can: the page asks for adoption and reveal, retries once a beat later (the app may still be rebuilding the tree when a page mounts), and when the pane is still not -visible it renders the console itself under a short note that says why — with *Ask again* and *Open in -a browser* in reach. The note only appears when the pane API exists and reports the pane hidden, so a +visible it renders the console itself under a short note that says why — with *Ask again* in reach. The note only appears when the pane API exists and reports the pane hidden, so a build without panes gets the plain console and no nagging. Recovery for a user who hits it: un-minimize the pane's group in `hermes.desktop.layoutTree.v2` diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index 9026ffd..e416084 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -214,3 +214,26 @@ its exotic 15d/45d/15w higher-timeframe fetches falling back to the chart's bars reported). Scripts from the same four source repos that are GPL-3.0 or unlicensed (JustExecution's HTF suite, ict2023trader's set, fxraptor's `fractal-model`) are **not** bundled — concepts only, and any reimplementation is our own Pine (see `docs/PLAN-pinets-bump-and-indicator-pack.md`). + +## Network: every host this software contacts + +No account, no key and no telemetry. These are all the hosts, and what each sees. + +| Host | Who contacts it | What it sees | +|---|---|---| +| `127.0.0.1:8787` (console), `:8788` (optional backtest tier), `:8789` (optional Edge Stats engine) | plugin, console, MCP server, CLIs | local only | +| `mcp.luxalgo.com` | the console's MCP client (keyless) | Library searches and source requests | +| `api.binance.com` (and `api.binance.us` as Vela's fallback, `fapi`/`dapi` for futures) | the console's `/api/bars` and paper-broker price; also Vela and PineTS **from the browser** | the symbols and intervals you view, from your IP | +| `stream.binance.com`, `fstream.binance.com`, `stream.binance.us` | Vela, from the browser (live candles) | the symbols you view | +| `crypto-icons.ledger.com` | Vela, from the browser (symbol logos; also pre-connected by `index.html`) | the ticker you view | +| `luxalgo-production.s3.amazonaws.com`, `luxalgo-images-production.s3.us-east-1.amazonaws.com` | the console's preview cache | catalogue picture URLs | +| `data.binance.vision`, Dukascopy | the optional Edge Stats engine, only when you start a download | the symbol and range you ask for | +| GitHub, npm, PyPI | `install.sh` flags (`--with-edge`, `--with-backtest`) at install time | an ordinary download | + +The vendored bundles also contain code for Alpaca, Coinbase, Hyperliquid and Financial Modeling Prep providers. This console +registers none of them and none needs a key here, so they are never contacted. The console's page carries a +`Content-Security-Policy` whose `connect-src` lists exactly the hosts above that the browser itself contacts. + +## Vendored Zag packages: licence texts + +`console/frontend/vendor/zag/THIRD-PARTY-LICENSES.md` reproduces the MIT notice each package ships with. diff --git a/console/backend/tests/test_audit_8oct_fixes.py b/console/backend/tests/test_audit_8oct_fixes.py index 6d1a2a8..b337522 100644 --- a/console/backend/tests/test_audit_8oct_fixes.py +++ b/console/backend/tests/test_audit_8oct_fixes.py @@ -268,9 +268,9 @@ def fake_run(**kw): agents_store.ensure_seed(self.tmp.name) with mock.patch.object(srv, "run_agent", fake_run): self.post("/api/chat", {"agent_id": "desk", "message": "hi", "workdir": "/etc", "timeout": 99999}) - if seen: # the seed study id may differ; only assert when it ran - self.assertNotEqual(seen["workdir"], "/etc") - self.assertLessEqual(seen["timeout"], 600) + self.assertTrue(seen, "the chat call must have reached run_agent") + self.assertNotEqual(seen["workdir"], "/etc") + self.assertLessEqual(seen["timeout"], 600) self.assertEqual(self.post("/api/chat", {"agent_id": "desk", "message": "hi", "timeout": "abc"})[0], 400) # SEC-10 --------------------------------------------------------------------------------------------- @@ -589,3 +589,100 @@ def test_json_never_carries_nan(self): if __name__ == "__main__": unittest.main() + + +# ── the agent-facing layer (needs fastmcp; CI's second step has it) ──────────────────────────────────── + +from http.server import ThreadingHTTPServer # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # a sibling test module, whichever way the suite is started +from test_mcp_server import HAVE_FASTMCP, _Stub, load_mcp, text_of # noqa: E402 + + +@unittest.skipUnless(HAVE_FASTMCP, "fastmcp is not installed") +class AgentSurface(unittest.TestCase): + def setUp(self): + self.shots = tempfile.mkdtemp() + _Stub.routes = {} + _Stub.posts = [] + self.server = ThreadingHTTPServer(("127.0.0.1", 0), _Stub) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.base = f"http://127.0.0.1:{self.server.server_address[1]}" + self.mcp = load_mcp(self.base, self.shots) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def test_a_long_library_script_is_cut_and_says_so_and_the_rest_can_be_read(self): + pine = "//@version=6\n" + "x = 1\n" * 14000 # ~84,000 characters + _Stub.routes = {"/api/source": {"ok": True, "data": {"source": pine}}} + first = text_of(self.mcp.library_source("big-one")) + self.assertIn("truncated: showing characters 0-60000 of", first) + self.assertIn("offset=60000", first) + self.assertIn("Do NOT run or port this fragment", first) + rest = text_of(self.mcp.library_source("big-one", offset=60000)) + self.assertNotIn("truncated", rest) + self.assertIn("characters 60000-", rest) + + def test_a_script_that_fits_comes_back_whole_with_no_notice(self): + pine = "//@version=6\nplot(close)\n" * 600 # ~15,000 characters, over the old 8,000 cap + _Stub.routes = {"/api/source": {"ok": True, "data": {"source": pine}}} + out = text_of(self.mcp.library_source("mid-one")) + self.assertNotIn("truncated", out) + self.assertEqual(out.count("plot(close)"), 600) + + def test_a_cut_list_says_how_many_there_were(self): + _Stub.routes = {"/api/edge/presets": {"ok": True, "data": {"count": 40, "presets": [{"name": f"p{i}"} for i in range(40)]}}} + out = text_of(self.mcp.edge_presets()) + self.assertIn("30 of 40 preset(s) shown", out) + + def test_a_one_word_name_that_is_not_a_slug_falls_back_to_a_search(self): + calls = [] + + class Routes(dict): + def get(self, key, default=None): + calls.append(key) + if key == "/api/indicator": + return {"ok": False, "error": "not found"} if len([c for c in calls if c == key]) == 1 else {"ok": True, "data": {"title": "Killzones"}} + if key == "/api/search": + return {"ok": True, "data": {"results": [{"slug": "killzones-ict"}]}} + if key == "/api/source": + return {"ok": True, "data": {"source": "plot(1)"}} + return default + _Stub.routes = Routes() + out = text_of(self.mcp.library_indicator("killzone")) + self.assertIn("killzones-ict", out) + self.assertIn("plot(1)", out) + + def test_the_backtest_tools_answer_with_a_sentence_when_the_tier_is_down(self): + _Stub.routes = {"/api/backtest": {"ok": False, "error": "backtest service unreachable (URLError)"}, + "/api/backtest/sweep": {"ok": False, "error": "backtest service unreachable (URLError)"}, + "/api/backtest/results": {"ok": False, "error": "backtest service unreachable (URLError)"}} + for out in (self.mcp.bt_run(), self.mcp.bt_optimize(), self.mcp.bt_status()): + self.assertIsInstance(out, str) + self.assertIn("unreachable", out) + self.assertIn("optional", out) + + def test_chart_batch_takes_a_list_as_documented(self): + _Stub.routes = {"/api/chart/state": {"ok": True, "data": {"open": True, "age_s": 1.0}}, + "/api/chart/command": {"ok": True, "data": {"pushed": 1, "command": {"id": 1}, "result": {"ok": True, "detail": "cleared"}}}} + out = text_of(self.mcp.chart_batch([{"action": "clear"}])) + self.assertNotIn("not valid JSON", out) + self.assertIn("clear", out.lower()) + + def test_chart_shot_says_when_no_view_is_attached(self): + _Stub.routes = {"/api/chart/state": {"ok": True, "data": {"open": True, "age_s": 1.0}}, + "/api/chart/command": {"ok": True, "data": {"pushed": 0, "command": {"id": 1}, "result": None}}} + out = text_of(self.mcp.chart_shot()) + self.assertIn("no chart view is attached", out) + + def test_an_empty_chart_is_called_empty(self): + _Stub.routes = {"/api/chart/state": {"ok": True, "data": {"open": True, "age_s": 1.0, "symbol": "BTCUSDT", "timeframe": "1h", + "bars": None, "last": None, "studies": []}}} + self.assertIn("holds no bars", text_of(self.mcp.chart_state())) + + def test_calls_to_the_console_do_not_go_through_a_proxy(self): + _Stub.routes = {"/api/chart/stream/status": {"ok": True, "data": {"views": 1, "pushes": 0, "keepalive_s": 8.0}}} + with mock.patch.dict(os.environ, {"http_proxy": "http://127.0.0.1:9", "HTTP_PROXY": "http://127.0.0.1:9"}): + self.assertIn("1 view(s) attached", text_of(self.mcp.chart_views())) diff --git a/console/backend/tests/test_mcp_tool_annotations.py b/console/backend/tests/test_mcp_tool_annotations.py index 37cbd18..8bfac8a 100644 --- a/console/backend/tests/test_mcp_tool_annotations.py +++ b/console/backend/tests/test_mcp_tool_annotations.py @@ -96,13 +96,32 @@ def test_read_only_tools_change_nothing(self): self.assertTrue(hint(self.ann(name), "read_only_hint"), f"{name} should be read-only") def test_chart_mutating_tools_say_so(self): - for name in ("chart_apply_pine", "chart_add_indicator", "chart_set_market", + for name in ("chart_apply_pine", "chart_set_market", "chart_draw", "chart_clear", "chart_remove_indicator", "chart_reload"): annotations = self.ann(name) self.assertFalse(hint(annotations, "read_only_hint"), f"{name} changes the chart") self.assertTrue(hint(annotations, "destructive_hint"), f"{name} should declare destructiveHint") + def test_every_writer_states_whether_it_destroys(self): + """An unset destructiveHint reads as TRUE to a client (the MCP default for a non-read-only tool), so a writer that + does not say is treated as destructive. All 54 say (audit 8 Oct, AS-19).""" + for name, tool in self.tools.items(): + a = tool.annotations if hasattr(tool, "annotations") else None + if hint(a, "read_only_hint"): + continue + self.assertIsNotNone(hint(a, "destructive_hint"), f"{name} does not state destructiveHint") + + def test_additive_tools_are_not_destructive_and_the_ones_that_delete_work_are(self): + for name in ("chart_add_indicator", "chart_theme", "chart_fullscreen", "chart_replay", "chart_snapshot"): + self.assertFalse(hint(self.ann(name), "destructive_hint"), f"{name} only adds or toggles") + for name in ("chart_drawing", "chart_marks", "chart_clear", "chart_remove_indicator", "chart_undo"): + self.assertTrue(hint(self.ann(name), "destructive_hint"), f"{name} can delete the operator's work") + + def test_the_backtest_tools_reach_binance(self): + for name in ("bt_run", "bt_optimize"): + self.assertTrue(hint(self.ann(name), "open_world_hint"), f"{name} fetches klines from Binance") + def test_replay_is_a_chart_mutation(self): self.assertFalse(hint(self.ann("chart_replay"), "read_only_hint")) diff --git a/console/bin/library-indicator b/console/bin/library-indicator index 202dd9b..96e0f33 100755 --- a/console/bin/library-indicator +++ b/console/bin/library-indicator @@ -24,10 +24,24 @@ from pathlib import Path CONSOLE = os.environ.get("LUXALGO_CONSOLE", "http://127.0.0.1:8787").rstrip("/") +_LOCAL = urllib.request.build_opener(urllib.request.ProxyHandler({})) # loopback: never through http_proxy + + def api(path: str) -> dict: try: - with urllib.request.urlopen(CONSOLE + path, timeout=60) as res: + with _LOCAL.open(CONSOLE + path, timeout=60) as res: body = json.loads(res.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + # An HTTP answer (503 "no MCP session", 404 …) is not "cannot reach the console": read what it said. + try: + said = json.loads(exc.read().decode("utf-8")).get("error") + except (ValueError, OSError, AttributeError): + said = None + print(f"library-indicator: the console answered {exc.code}: {said or exc.reason}", file=sys.stderr) + raise SystemExit(1) + except TimeoutError: + print(f"library-indicator: the console at {CONSOLE} did not answer in time", file=sys.stderr) + raise SystemExit(2) except urllib.error.URLError as exc: print(f"library-indicator: cannot reach the console at {CONSOLE} ({exc.reason})", file=sys.stderr) raise SystemExit(2) @@ -67,7 +81,9 @@ def main(argv=None) -> int: print(f"library-indicator: '{title}' publishes no source", file=sys.stderr) return 1 - out = Path(args.out) if args.out else Path(f"/tmp/library-{slug}.pine") + import re as _re + safe = _re.sub(r"[^A-Za-z0-9._-]+", "-", str(slug)).strip("-.") or "script" # a slug from the network is not a path + out = Path(args.out) if args.out else Path(f"/tmp/library-{safe}.pine") out.write_text(source, "utf-8") lines = source.count("\n") + 1 diff --git a/console/bin/trader-chart b/console/bin/trader-chart index 8fc0b4b..974c4d6 100755 --- a/console/bin/trader-chart +++ b/console/bin/trader-chart @@ -51,6 +51,10 @@ def _console_token() -> str: return "" +# Loopback calls skip any http_proxy in the environment: it would be sent the console token and every Pine script. +_LOCAL = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + def _request(path: str, payload: dict | None = None) -> dict: url = BASE + path data = json.dumps(payload).encode() if payload is not None else None @@ -63,7 +67,7 @@ def _request(path: str, payload: dict | None = None) -> dict: headers=headers, method="POST" if data else "GET") try: - with urllib.request.urlopen(req, timeout=TIMEOUT if path.startswith("/api/edgestats") else 30) as res: + with _LOCAL.open(req, timeout=TIMEOUT if path.startswith("/api/edgestats") else 30) as res: body = json.loads(res.read().decode("utf-8")) except urllib.error.HTTPError as exc: # urllib raises for every 4xx/5xx — and HTTPError is a URLError, so without this branch a console @@ -81,7 +85,9 @@ def _request(path: str, payload: dict | None = None) -> dict: message += f" — {detail['hint']}" die(message, 1) except urllib.error.URLError as exc: - die(f"cannot reach the console at {BASE} ({exc.reason}) — is luxalgo-web running?", 2) + die(f"cannot reach the console at {BASE} ({exc.reason}) — is the console running? (console/start.sh)", 2) + except (TimeoutError, OSError) as exc: # a read that timed out is not a URLError on py3.13 + die(f"the console accepted the connection but did not answer in time ({exc}) — it may be wedged or mid-restart", 2) except ValueError as exc: die(f"the console sent something unreadable: {exc}", 1) if not body.get("ok", False): @@ -97,6 +103,15 @@ def post(path: str, payload: dict) -> dict: return _request(path, payload) +def _read_file(path: str) -> str: + """A Pine file, or one sentence and exit 2 (the documented "bad input") instead of a traceback.""" + try: + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + except (OSError, UnicodeDecodeError) as exc: + die(f"cannot read {path}: {exc}", 2) + + def die(message: str, code: int) -> None: print(f"trader-chart: {message}", file=sys.stderr) raise SystemExit(code) @@ -264,7 +279,7 @@ def parse_inputs(pairs) -> dict | None: def cmd_draw(args) -> int: """Run a Library script and paint what it built (boxes/lines/labels) on our own overlay.""" import sys as _sys - pine = _sys.stdin.read() if args.pine == "-" else open(args.pine, "r", encoding="utf-8").read() + pine = _sys.stdin.read() if args.pine == "-" else _read_file(args.pine) if not pine.strip(): die("no Pine source given", 1) command = {"action": "draw", "pine": pine} @@ -317,8 +332,7 @@ def cmd_script(args) -> int: """Drive Script mode: load a .pine into the editor, run what is in it, or read it back.""" command = {"action": "script", "mode": args.what} if getattr(args, "pine", None): - with open(args.pine, "r", encoding="utf-8") as fh: - command["source"] = fh.read() + command["source"] = _read_file(args.pine) if args.what == "inputs" and "source" not in command: die("script inputs needs --pine FILE (the script whose settings to list)", 2) inputs = parse_inputs(getattr(args, "input", None)) @@ -398,8 +412,7 @@ def cmd_apply(args) -> int: if args.pine == "-": pine = sys.stdin.read() else: - with open(args.pine, "r", encoding="utf-8") as fh: - pine = fh.read() + pine = _read_file(args.pine) if not pine.strip(): die("no Pine source given", 1) result = send(args, {"action": "apply", "pine": pine}) diff --git a/console/frontend/vendor/SHA256SUMS b/console/frontend/vendor/SHA256SUMS new file mode 100644 index 0000000..7d2f8bb --- /dev/null +++ b/console/frontend/vendor/SHA256SUMS @@ -0,0 +1,336 @@ +4ae411030c3b56406dc487473fd9283420bf18b3218d6e2b757af0d695b937a4 VENDORING.md +8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef pinets/LICENSE +a42cecd3c27e3d94244fee66b09662fedd1dda759fb7a28d205fed9615333ea4 pinets/PROVENANCE.md +eb1df2646ea42fb7c32745c4dcaaca233059a4e71ac63e46552a712f39172d26 pinets/pinets.min.browser.es.js +1eb85fc97224598dad1852b5d6483bbcf0aa8608790dcc657a5a2a761ae9c8c6 vela/LICENSE +943b246985e5f0fc3b3291b2326eb05163ed36e2cfce4921688e4330edf53236 vela/NOTICE +1f10bc0a172ec1fbcc77bdc74d4362d862ea855cc6d1be948c584f157991838c vela/dist/chunk-ATGTCWHJ.js +3daea72b8eba7ad1394c2efcc9c9dd0dc53d3a4bc88793cf9dfcd98ba6b9698f vela/dist/chunk-BZQM2XO7.js +645f1bd417f60512892da449c0bf15418fe302273990adaaa0369296c49cb921 vela/dist/chunk-DBDXKZUF.js +fd335ac11cce2ce895526cae252cfc7bb1f2a428c7ffaf4f937de95664691dac vela/dist/chunk-EZ5FWVLA.js +01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b vela/dist/chunk-JU4CWEBF.js +a1020cf26555dd000aa5c0819c5ee01b999a053216b9bf68251421b4488af724 vela/dist/chunk-LMBYEGUR.js +a3d696b3f4be712e4cc8e4f43d982701027c07de47d655b692156dc48b383768 vela/dist/chunk-NELQJCGK.js +135b607d129e57d02e32cd7b7e35fc34f92aaa6387445e5f4933aab4665331cb vela/dist/chunk-W4EJWLEO.js +41245f0c95c09c3fff43523db26f8aa5ec35d7d6ea02e70cf25a12138805e3cc vela/dist/index.js +0c200ae65ea04fd25ea3e3df8e6b3fb3b47a5ff08fd300cfc306e8a7822e3f61 vela/dist/plugin.js +e53ca2500d24303675d0a6eb9c33c15b457b267b34120404dd2a629b1a01d2ce vela/dist/providers/binance.js +b1bd83e850ec3983bc75dd10f3573e4ad49962f6d87f8dcb49e28511f845a68a vela/dist/providers/coinbase.js +1f7475930360d6600b7b58eef9a2696c5d4063f3ddac1663975d1bd5565ff023 vela/dist/providers/hyperliquid.js +1662d393724ff7ab0700c69182028cf5f2e78255c839ddbe9b8f73b4c067e395 vela/dist/ui.js +40f39542d99863bda652c225c84e2d28c28d25c1604447111e77b76bfc135e45 vela/dist/vela.global.min.js +53e9b975fa105859879f8302f8264a99da5c7bfe95c0ff823d63380c5c9bd2fd vela/dist/widget.js +c03e544e27b2062e678e52c702e01cef06b368dc7a1f7f30ff6d0f60e4a7c2c5 vela/dist/workspace.js +6f1e622c82a380075843bb084a7ec3b1f1d12a4a02526d75e78b0924a860aa75 vela-pinets/LICENSE +b8c3fe052ea231953b6063525c352e4fb5d83a25559765736847842f6be80748 vela-pinets/dist/index.js +0e27267650510d0de49ce1122b3370106b24c0b502d9b892b3374d9c5e1cd9a3 vela-pinets/dist/vela-pinets.global.min.js +07a47ae4b121161f0d479cdea56acdd1be1bf9a91b927786fab784d54cb35cbb zag/THIRD-PARTY-LICENSES.md +895286af18d41dd1d59d8bca70f5d6ccee1c0d3c8ae41b8e5075758bf8c13f7e zag/floating-ui-core/floating-ui.core.browser.min.mjs +9e666a7acc8479ea73b84c7ec1cbe000159897d671ff240c48f105a5db438044 zag/floating-ui-core/floating-ui.core.browser.mjs +c6c359d91fbc6636f6143aae9ff47105eeacea869b14afe7d54e120444ab8f37 zag/floating-ui-core/floating-ui.core.esm.js +c6c359d91fbc6636f6143aae9ff47105eeacea869b14afe7d54e120444ab8f37 zag/floating-ui-core/floating-ui.core.mjs +c059eb702039c238f40f1a54e79ef78765ea36f442ca243e57175184f0df5ac3 zag/floating-ui-core/floating-ui.core.umd.js +65940d866a6b6d831394a4bbed99ed0a39330bac98b643386d9a2f87a1a1d5da zag/floating-ui-core/floating-ui.core.umd.min.js +96c4bf63ebf119c1575ee930e0b0ae76bc4eaf84f33d6cc55398cf15ef986a17 zag/floating-ui-dom/floating-ui.dom.browser.min.mjs +d32f5c2de15ab385de24a5b158619a59e55767e268dcb18d421d71a4a6746a0f zag/floating-ui-dom/floating-ui.dom.browser.mjs +a1c916ed18ed8bb2c586079f7946d35797b45bc79eafae1d99ec6f812672ef54 zag/floating-ui-dom/floating-ui.dom.esm.js +a1c916ed18ed8bb2c586079f7946d35797b45bc79eafae1d99ec6f812672ef54 zag/floating-ui-dom/floating-ui.dom.mjs +6601cfb1a56557f988a727eaf609f93c78a854f1c668208c831dda52a16fcf92 zag/floating-ui-dom/floating-ui.dom.umd.js +61a46f943c4e99379eaf073447811aec7e8b8f120d2f646316e01b7694bc90a3 zag/floating-ui-dom/floating-ui.dom.umd.min.js +b6658b1eac47e2917e6d10ebbeefb9c4ac9a166f0052bed6e08c871ed848b680 zag/floating-ui-utils/floating-ui.utils.dom.esm.js +b6658b1eac47e2917e6d10ebbeefb9c4ac9a166f0052bed6e08c871ed848b680 zag/floating-ui-utils/floating-ui.utils.dom.mjs +125bdf99b20878cb6f15ec3db9050c893c11942ea57bd3acbbb4d5a24e48733c zag/floating-ui-utils/floating-ui.utils.dom.umd.js +58487f55c03fece0e577e17fea85a9076db368777193440889e79161d16c7665 zag/floating-ui-utils/floating-ui.utils.dom.umd.min.js +f53b3ea1eec15af77ca9def295b8f40f6d65706acfc5aaac4801ca3ca21ef7bd zag/floating-ui-utils/floating-ui.utils.esm.js +f53b3ea1eec15af77ca9def295b8f40f6d65706acfc5aaac4801ca3ca21ef7bd zag/floating-ui-utils/floating-ui.utils.mjs +46d7f11efc704bd7b14ab5894384c2ae34b1f2ac9a16da637ff1a261fb9b7959 zag/floating-ui-utils/floating-ui.utils.umd.js +83c4c07e8bbeb76cc2f184401d01f9beb5263f4499df9b3ef6bf467eb6b0fb41 zag/floating-ui-utils/floating-ui.utils.umd.min.js +8c08fa2b2d2f73cf5b122172bfa6f34863ee56c004fe72557748ca2e99acf039 zag/proxy-compare/cjs/index.js +e0c33788a8003ae37cdd1b82a4349f71df8cab15dac47f07e6d6954b510f02c0 zag/proxy-compare/index.js +03edeacfdb66e40777f1babbceca180dfcf489d1c21a95cfcbd41024c3d46a90 zag/zag-js-anatomy/create-anatomy.js +0a42233d7ef48b1bd766dce63d107620347ebed83a22afb06fce293f7b15254f zag/zag-js-anatomy/create-anatomy.mjs +183b439e4405ce8fa2961844faefdddffbe4247bf7f735bcf09354a1e9ecb49a zag/zag-js-anatomy/index.js +dc13a39eafa9f9071a472ac8efcfcf78f79e1c9025616c906d6d952641025696 zag/zag-js-anatomy/index.mjs +2790627fe23144a8c79f112484e96b1ed5b6905b1c221031c41f0e3d9581548e zag/zag-js-aria-hidden/aria-hidden.js +94cdbaf5d61091c86118f274ca546898401456f19c96f40810780b994de35b41 zag/zag-js-aria-hidden/aria-hidden.mjs +44286ddffab024dafc88f22f189077e913dc23ffee2e439b0e955431e46a7166 zag/zag-js-aria-hidden/index.js +4d2edcbbaeb98e969d4a8ab154618ba1668c00d7e6cbe8a5a97dfc56c68cda38 zag/zag-js-aria-hidden/index.mjs +60afb38febb25a8daa73131061d30259f39f278f0bdc5b18181cf542f0806d96 zag/zag-js-aria-hidden/walk-tree-outside.js +465db988b77fc0fed0d82c667f49d3931d8bc2800022a66d77c22476db4a23a1 zag/zag-js-aria-hidden/walk-tree-outside.mjs +27f55cc9525cc928cc76ff0abd69f14e5f4810d8c68b48ad670f50b05de9fc32 zag/zag-js-core/create-machine.js +de4984968f3c555898c1e72406d617f115d87bea5ad10fe10f69f901967a0795 zag/zag-js-core/create-machine.mjs +f4de679fc8f87e591d19b2771f1496abaede307d5c8978e6f23dbfdd6433eba8 zag/zag-js-core/index.js +65795860ddb5733bd5fd0a846f8233ef511f50b59094f4ac1e1da1787bc401c3 zag/zag-js-core/index.mjs +34975c872ad1b2ddf9a7a9936359a959c17ee34637fc70bc1370dfbf1f0801a1 zag/zag-js-core/memo.js +27fc581b624c25dc884818a6aaeaf9c54565baa60f07821cda5cf923587afbcc zag/zag-js-core/memo.mjs +c719ff7c9f9804559b8a093f48cd934f4d6601a13b26ff0a71126de0621119e8 zag/zag-js-core/merge-props.js +0049e89119060cb8d8548cef96078f978600f41176e0eda51650a998bf2c6308 zag/zag-js-core/merge-props.mjs +bac5df7dafeae9154e789e694273ae73c9d00cbdeb5a058aae83556d7e4052c8 zag/zag-js-core/replace-tracker.js +3bed327b3119d7b361c62ab6707309be23e7b42d9018af031c9bd3fcd5fc7882 zag/zag-js-core/replace-tracker.mjs +8f89b8331610fe3bfdf1eac5f75e78adbf6950c338c1accbe3c00e1068525969 zag/zag-js-core/scope.js +04318fec83bd6f83ab5d885e573dbfb1a52be5668fed4c6c5f58ba1fbc6b1ce9 zag/zag-js-core/scope.mjs +99d6b1188076a72a91387771d8238c60e5b8ce88d21f02e10403db504a6f71c5 zag/zag-js-core/state.js +26da08824339269a12f4f9a96b4b00b396f9f7f60cc8fe2d1d524e6f8b2faad2 zag/zag-js-core/state.mjs +2326d113dac4f9b808a8a4425cab2c260927d6862e9e3eb78a79008e0edf4b39 zag/zag-js-core/types.js +19d015aa015aa8a3eafc94b04ecdd32307155a5dcc20ce9d6a3d6373c768ec19 zag/zag-js-core/types.mjs +d322347b804682e418f3f165a5c13b5740cc22cda76f551e5fddc06420389ba5 zag/zag-js-dialog/dialog.anatomy.js +5ef2e16513f2a402e4942546dd00f0386f94ae62e3dd2b89104369bf2f4f0526 zag/zag-js-dialog/dialog.anatomy.mjs +ff6657b00bb46068d46f15f2927077b56aa842ba93a4d7b2704aa25914956025 zag/zag-js-dialog/dialog.connect.js +d270690aad3d023efd4fddae065b34c411bfa130f8611c54c9acec34eebc99f1 zag/zag-js-dialog/dialog.connect.mjs +a7705c703d06def81b939725b8665a1db6b14205dc9ca5ec9520e7e80d5184cb zag/zag-js-dialog/dialog.dom.js +87892c16d8774ccea010a784533e6faf8960afaf6e5bc53b46f432a8048f36b6 zag/zag-js-dialog/dialog.dom.mjs +9e16d14f81c707c1d35fcb8244b9adcb88226b648555e83069e11edf324dd849 zag/zag-js-dialog/dialog.machine.js +a33dfbc697c6409abcf63e56f99dc999410ba4f3936250861b63c69b19922214 zag/zag-js-dialog/dialog.machine.mjs +cbf23513ea9d914ecb6c77a4a12af2004df96cb340656221bdd2c72c6538d49b zag/zag-js-dialog/dialog.props.js +cff61961246cfcfc17eaa38487d4c0a74d32bf19a3f4bd0b5183b3c9ce5ddec3 zag/zag-js-dialog/dialog.props.mjs +4a36d6806d480b4b30728fdf9e3853ecfe7895df84f2d56fa7bb2196fd667252 zag/zag-js-dialog/dialog.types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-dialog/dialog.types.mjs +2d857f03954acd8cdf4316edce0f95ed4c44ae9fc90746bc5193958d7ab3148f zag/zag-js-dialog/index.js +10126f19e6bbf58dfc8db9131a441a2658a63a5f7c3866557c292f5bea02aae1 zag/zag-js-dialog/index.mjs +aa450b18a7be6334ce869d135d47757a4f384284f09825c07d026a4ee77b24ef zag/zag-js-dismissable/dismissable-layer.js +7043c2b36f5fa6d203d6dc5294146f126b6cf4319e045ea41b1b065d5e16ee72 zag/zag-js-dismissable/dismissable-layer.mjs +39a957057a671b015f115ca077f1ddf34a783e683b5bf6107bad8f768c85b4ba zag/zag-js-dismissable/escape-keydown.js +0f4c26b30227b6ed30313a971222f03530d7b750a90b5ef9dc6e77fadfea6167 zag/zag-js-dismissable/escape-keydown.mjs +1bec9a147cfbf420c2c7aff05d351eeb8da117fde07c561446b255306aa7e08e zag/zag-js-dismissable/index.js +e371781fa70c9b1400759953d8f01fb80e1f19d080fa58e88014833acafa6b0f zag/zag-js-dismissable/index.mjs +695f7176d8fedd5b0a54f0fd2862621e9c6eb7c4fef957079cb34c4eb777f004 zag/zag-js-dismissable/layer-stack.js +0a615fdeb141886ee7cdacaa50cf4339dcca7eeab81dbe79599f886d00aa6bcd zag/zag-js-dismissable/layer-stack.mjs +c38ea9cfde3e82bc2a52f8e55a6618d1f6c5980d6787179cdbc84f9ccba29f91 zag/zag-js-dismissable/pointer-event-outside.js +ec5a0770761d28be773a5d9f903eee09e2caf49e92ff32c4f49d5ebaec7a26fa zag/zag-js-dismissable/pointer-event-outside.mjs +9832f0f17063bfbc0ad7b74efd87fade05ab8dca7b90e832fb304654f4bcd251 zag/zag-js-dom-query/caret.js +cd73a82f419291daa363901571048d1060cd5d29c45dabb5c068047890469d95 zag/zag-js-dom-query/caret.mjs +5943dc01ce5ae389402596403935174ac7199a985937c1e2a314784c05f78023 zag/zag-js-dom-query/chunk-QZ7TP4HQ.mjs +3fbc483564ea8c88f5eafb682964115efe2c31c40b727a3feb6df1916e6f1678 zag/zag-js-dom-query/computed-style.js +819c33eeef2c59fd6cec2f69d8cd04a60fc41d45e2ff6fac50b395b0bcfdb344 zag/zag-js-dom-query/computed-style.mjs +ca32dd0402e83b58b520dd94772a32aa3c8823a1ba87081989d01dfd1060651f zag/zag-js-dom-query/controller.js +5f294112a57b82ba0b5b364ea80a64fd29fd8b0587450af4c5352be487000832 zag/zag-js-dom-query/controller.mjs +77b0ab9e71ed4f54ed665bc7947797c3fc119afb71f62fa0b5d08b867ec09684 zag/zag-js-dom-query/data-url.js +3147bb03b7b27fb61eb58a65dff686e379f90e8a85c144214854d3e3c2ec71c8 zag/zag-js-dom-query/data-url.mjs +de8a5421ff01c0835846d664cc94be350ff4f401793f92869e7b4db57b6ad83f zag/zag-js-dom-query/event.js +e78e0c88be0b170f2713eed4a6166e5ec2bfe36f7b838f13f0f5c8c8be3f0208 zag/zag-js-dom-query/event.mjs +8668837f75f3592f769b692425123c8ff859b2f02eafa5fa52dc1575bac7fd71 zag/zag-js-dom-query/form.js +94ba042885a88cce08a2ccf558a5d7db9606313173f559c8d46033af576a4001 zag/zag-js-dom-query/form.mjs +fd7cb2b479e76ae6508a02b3d72975b7a5be11d8be6228fe8d9a2a73eca8a611 zag/zag-js-dom-query/index.js +77ae1045d57191b746e33b21db6f788a6ae0c5730f149f4f518ce3cd3c70762c zag/zag-js-dom-query/index.mjs +fb4afe4b3158644528c0f01fbd04115b8524ccd72404bba22efe4422c2c73344 zag/zag-js-dom-query/initial-focus.js +f03dc80a4202573f25c514916cc4f7de7e77ee63c0904aa5648dd8200ec0aa71 zag/zag-js-dom-query/initial-focus.mjs +af8ea6a1b77315a5fb3091c8c441ecd79a960a28566bd4bb2d332ea2db99b31d zag/zag-js-dom-query/mutation-observer.js +b0d1fb94c0dcb1e0bbdc3079e89086261fed4060ff88181ea4f0ee8e4f582f95 zag/zag-js-dom-query/mutation-observer.mjs +a5493b49664bfc2546388e669885c67ae5954a895afc6f6b0cc1670a68c426d4 zag/zag-js-dom-query/navigate.js +e2ce285896626af7ba42ce6ac25ba32f0bbfa9ea508aae8f525a209820361d8c zag/zag-js-dom-query/navigate.mjs +5d16594993105982cd899bfa4a6c353aa6819c30e74d1071c0fc1159b7427f74 zag/zag-js-dom-query/node.js +e00a7bbd4cc3b5cf6d2fb31beb03beafb75bbf44cfea6c4ebb1c6e91708450c4 zag/zag-js-dom-query/node.mjs +012324a49763a681f059e5f6cbb0a0b32039f8f07659d16490011ca6a7aca236 zag/zag-js-dom-query/overflow.js +538b8d5fe06cbc9c89a9ee3ce9e8806d8eafb469957c9ade9d76344e8b906aec zag/zag-js-dom-query/overflow.mjs +9a7de7cc55a104cce30f2307ee4b924889ba0d4c7cec0fb0aa492ccb269d3312 zag/zag-js-dom-query/platform.js +8dbc5d5991c128f33818efcffbc89c8dd298616c2e21fe8127ede9ae87b5ec7d zag/zag-js-dom-query/platform.mjs +2d75aaca81126eaee6f809ea7287b3f2347502eb160087391a53bdb1c1f11f49 zag/zag-js-dom-query/point.js +1551ccc50e27636bff4a8bf29e7d5dbf5bd9da90dfd42dfe64fb7ab79c2cd459 zag/zag-js-dom-query/point.mjs +d9e9c7486f8e4f14b63f5e1685820df17be9aa1d4a419b0bf6c844a8258def84 zag/zag-js-dom-query/pointer-lock.js +7b1240f7d7aadcfc103b413c583156b456cbcbd6403bbf2a6c67d417b2d94790 zag/zag-js-dom-query/pointer-lock.mjs +908eadd8a0ce82e18d271096e592bebd86eb05485afad62808bd1eb9582c0f59 zag/zag-js-dom-query/pointer-move.js +e31a18e9a7f75e36e5306247d499777456f710391d2c41deca86d8c103f8dd78 zag/zag-js-dom-query/pointer-move.mjs +9d2093b6b3470787f4f358f2547580c2799deb480dd8c04ae165e5802d8d81af zag/zag-js-dom-query/press.js +1cdfb8aad8036b861c4579495dc164ec66b50fb9531a9b8e4b55e5e5f9e013db zag/zag-js-dom-query/press.mjs +c57b565ca7af21034f7244c9355dbc66fa428cef4936132dcadf88446343de43 zag/zag-js-dom-query/proxy-tab-focus.js +1dd8239841e07e99c3a5f85d5e0bd3ac62392aa8d165c53edbc8983c3ff866c6 zag/zag-js-dom-query/proxy-tab-focus.mjs +5741645cd181ecb6c952c45ebc4fc41ccdfe58085db4cb795e7942e00da44bdd zag/zag-js-dom-query/query.js +6fe075f089977b0b1c9518f3cf5b741cf03a7ec5945093eb3ece42566fc17bf3 zag/zag-js-dom-query/query.mjs +e945c6e922dafdc32d58d0a53f00fb1212c449e6a1cc8ef1c941ab97e69d4158 zag/zag-js-dom-query/raf.js +82b816196b6f0d77714f21a2673f96f2a92d0144d3ba398364b50efe09c36ab4 zag/zag-js-dom-query/raf.mjs +1dfe9e4ef485a227b5535f8c90340249f3a8c2b9f5500a513d7c318502f55c2a zag/zag-js-dom-query/resize-observer.js +573c3341c7b4c98202df5a38fdf8a83b059ef69e35ddbdb603d47b95c40aab5d zag/zag-js-dom-query/resize-observer.mjs +2dbd813ed8bbefaefde0a823b2e92c97792565d739bbf6d2b738c1bffe00198a zag/zag-js-dom-query/scale.js +4fe1cd9e0761c67adbe39095281ad7d2df640310c276ee8ba90e248a74a66809 zag/zag-js-dom-query/scale.mjs +50761160b88ff00126b7879c75c41d59d5f1f309b1452c80c87c6d1104eed4f9 zag/zag-js-dom-query/scope.js +6923b8814b9323e9d531bde86b5da8b9724133437bf90ec09897339dc539a493 zag/zag-js-dom-query/scope.mjs +d2dd3218a871f41c5f30b5893190c66cbbcb3b2c8dc64c7b7e8ff58ed3bc4fb0 zag/zag-js-dom-query/scroll.js +177a1e33b1e8e9efef6d31e806385c5346e0a48a27469861ae85443ea9de6617 zag/zag-js-dom-query/scroll.mjs +63dc7227908d1093d84807f108c9b27170106035ea1d9bacc6d4c55e129bdebe zag/zag-js-dom-query/searchable.js +ec8204a08ab9b3774b68b871a3165120c90b0d428b8d81799abb2b44d14eb5b9 zag/zag-js-dom-query/searchable.mjs +dc965e7c4698126cd57d3499e22569c82cc81bd9364358dc08b6790e3b8420a8 zag/zag-js-dom-query/set.js +f4231f02faf7d48418dd21f8f42cb40adf4b5bc31d80eff781183576ff9636de zag/zag-js-dom-query/set.mjs +f5aea4173e5a371005c2133a3dab0e6680b2155367872d26fe96f5f4cb7b511e zag/zag-js-dom-query/shared.js +58801de221ee6ae1f84211bf1fb440b3c263f28c7a6673920abba1376816ff45 zag/zag-js-dom-query/shared.mjs +9984d3ff3622ed35510de7a7c51e91d3d16c0e1518b104aab496f80fa02c0e69 zag/zag-js-dom-query/tabbable.js +df6b17cf5c162956560219015df4134ef8c9615cd992eb9fe14995b8599ba66a zag/zag-js-dom-query/tabbable.mjs +7215b0a9a8b4344d0aa13542f8d321d1e2cc389531cabcee21df3390e42b0a25 zag/zag-js-dom-query/text-selection.js +980ffd811fda2bfbaa80ef31a78bb4eb505cc44861e3be9a481ce11a0b023da7 zag/zag-js-dom-query/text-selection.mjs +c2818f41dd0d2b3ce1449d810ac766abfaaeb80610e924c979fe5b19242cd3cb zag/zag-js-dom-query/typeahead.js +1a30433ea5fc8580970b9c6ba635a4955a8b883ca54867f6caaf5e4c226de6e2 zag/zag-js-dom-query/typeahead.mjs +7222b5629d31ed31408f5a62486de82165fdfd405aef2c7043ec6fe8ceb4b762 zag/zag-js-dom-query/types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-dom-query/types.mjs +5400903861f0e9b5eec8061a918722ffb4584ef1754c53c9dee065012eca8458 zag/zag-js-dom-query/visual-viewport.js +2bceecb09dc4267df0c516e6f283f42010cb0a0f38a7da45f9d96e8d9977f27a zag/zag-js-dom-query/visual-viewport.mjs +e20c44281efb42b17cd52a7c485f3eb5465bd76f19238157f5a2ad600dea3933 zag/zag-js-dom-query/visually-hidden.js +88825afc0313900b46699b5b1f309e1692fd3366dffef4a0d2badc18764f501e zag/zag-js-dom-query/visually-hidden.mjs +e79fec0e8162ccf2c3fb6eb56ab4cc14ee58b555326ded0d8b2b209b6310caa6 zag/zag-js-dom-query/wait-for.js +feeeaa5af61a8ef689581e1496eb7b6270004cadaaa55a51c718b0c0ae9eb445 zag/zag-js-dom-query/wait-for.mjs +36cb3246b1e71c0891cbaf05d7276e09c5ed39f20fd54efa6c539b0dde5bd1d3 zag/zag-js-dom-query/when-node.js +3130463f8135282c8b67b6615f6f5aed272f035e0c2f1a7f0a4d9dedc96be99f zag/zag-js-dom-query/when-node.mjs +5943dc01ce5ae389402596403935174ac7199a985937c1e2a314784c05f78023 zag/zag-js-focus-trap/chunk-QZ7TP4HQ.mjs +74443073bfdfe17eaa804eb23aef0bfca4014cb1fb8209f5064c5f3aa5bd6f46 zag/zag-js-focus-trap/focus-trap.js +af89e81ede23ed054fee424202308a1cb179d324e49fcbfe73f46659bf1d90df zag/zag-js-focus-trap/focus-trap.mjs +6b1326e45cfd22a0e62b355fce61354a0fc5100c8d8c689345b9aabd846487b4 zag/zag-js-focus-trap/index.js +b22e188f6bb16a6e1e188379a6755ff1d253317afae11a416a39ca71de4a00b0 zag/zag-js-focus-trap/index.mjs +7222b5629d31ed31408f5a62486de82165fdfd405aef2c7043ec6fe8ceb4b762 zag/zag-js-focus-trap/types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-focus-trap/types.mjs +6b3ad31c8ff3894acfab21ebc9dd758a90845a9412124d630f98ffb59b3173b7 zag/zag-js-focus-visible/index.js +e8650c92eb05d5f822ccf0732d07a8559e5d1fc3caefde12d54ddb34470aeecc zag/zag-js-focus-visible/index.mjs +533fe0c5f0772400de803ca6cd6a12c588e763678815659dd588eb1d150e8e2c zag/zag-js-interact-outside/frame-utils.js +05dd99b9da2701e324f5cdf8df6186d0ae3e8819b94a547cb7d5c3ee316475fb zag/zag-js-interact-outside/frame-utils.mjs +6b22851cde8259b5bd01b73cdac51b05baf7e98493fe7c35229ce2e0a73a16fd zag/zag-js-interact-outside/index.js +d1f8d11b5c55322f6e4d8ddbfbbda6015f4c18a905bea8ec1ddfadb4ce809678 zag/zag-js-interact-outside/index.mjs +31f4d1c8328264bc40856e80a2a3a94ef6a2f3ee979dac4f656fce395b45968e zag/zag-js-menu/index.js +36dff20c26b00b9b66f99f853ab3bb57b1fb1bb3d3efd8706dc1669aff02722c zag/zag-js-menu/index.mjs +018361a166a19025076d8e96cb9663c4264e27a444fbce22baa225328cfe2467 zag/zag-js-menu/menu.anatomy.js +128beae87bf20d5bb0437084810207cd45c3978bc67bda8403ea845a82fbc78d zag/zag-js-menu/menu.anatomy.mjs +fc69221c2a53c81c0069345a07333951aee6bb883115ecc9a3a622b68e377202 zag/zag-js-menu/menu.connect.js +ca9a147abab273cd7f49b0d952c9d5fceea8ad0fa45b80f1eb66d99d459a2030 zag/zag-js-menu/menu.connect.mjs +123748203d400d7ff5d894811833924febde22ce224d9de4610164e6c1017b01 zag/zag-js-menu/menu.dom.js +ad2483c0c7dfae379c5be711aa135ec11987588ec73d9c3e758f777686179c56 zag/zag-js-menu/menu.dom.mjs +2a842dbaa306fb36a4ddcd37d25c8b2e0ffdac59b0c7c1ef53188760b64b8f65 zag/zag-js-menu/menu.machine.js +c3cf9a165b79ed3ab8489e9af7772441fb3f790e7506c58f032cc1d1380efabf zag/zag-js-menu/menu.machine.mjs +e20d0c67afbddf13cf7b611c4bfaf0798762b7c430f379c6fb1275a4f425190d zag/zag-js-menu/menu.props.js +fdd3d3f12572d14f2a7c84380e8ad9fbbb8517154d3167e2e1d5ba4fce990f4e zag/zag-js-menu/menu.props.mjs +367d39aff54920f3523b5e3ff610a2b4e257cbbb9d4bdc375aa3a9e985b7baf4 zag/zag-js-menu/menu.types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-menu/menu.types.mjs +2c07ac5e51fa007a672023ca0753915c23f13e21b873189d5c169c28fb143777 zag/zag-js-menu/menu.utils.js +86d7e44adbf953599c24447572a72ad003633762e8261cd89ba5ab6fcdab3eca zag/zag-js-menu/menu.utils.mjs +b5790c33079efad58f5f3cd10a1858d275e799f733321211eaa2dbd475c1dea6 zag/zag-js-popper/get-anchor.js +4103020ad12d2f2f5e0e661bb006d241a9d6b292bc5320a2a7ec62158d53dc5b zag/zag-js-popper/get-anchor.mjs +963d40010eea6ca3a73fce5e0d81fcdd0799d66f91b2087da51c93f7a683e43e zag/zag-js-popper/get-placement.js +fb438d07f2d3182bda53da234e0e041aee024dde9a3be20c5c945233d04238b1 zag/zag-js-popper/get-placement.mjs +d59948320e89c67e1a03d35c963657cc38e8c055fbfb4605ed32d3263952b0e4 zag/zag-js-popper/get-styles.js +644537ed12526f78945f914267e3456b41e7336aef8d63e8b81285a864e07699 zag/zag-js-popper/get-styles.mjs +0b41ab133e678f13474f8b05d7ba66d977cc88212f8600e580ead7c7cd62a0e5 zag/zag-js-popper/index.js +d509de578506000a9a64059713ee68b32623b06b5c047e61d37d5c7cde85b7c5 zag/zag-js-popper/index.mjs +ffcbbe0164cd456668e89d32d83b768668e776a1e78c1d93e9ea40505cd69b3c zag/zag-js-popper/middleware.js +768756ed2d757acc3fd57ee3350b7ec0c1203c2dff7c2b10a65cde24bf42667a zag/zag-js-popper/middleware.mjs +b0e6f7b2efa90f7236c53ccd04b11ca20ad6d3e65e0841927e8d06a8df1a6665 zag/zag-js-popper/placement.js +22012f24cb6a28c98805bae254476183d1cf3572c81553491e35424b9b71631b zag/zag-js-popper/placement.mjs +7222b5629d31ed31408f5a62486de82165fdfd405aef2c7043ec6fe8ceb4b762 zag/zag-js-popper/types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-popper/types.mjs +e2f9a31d062bccce863549d4c22a34aee0d3de96c22c7a2a58ce2e7d6d393f9b zag/zag-js-rect-utils/affine-transform.js +0b9a158f5009705d0d5905358b9635ca0d3bdcdff76d4388b60ed0ba05f83d8d zag/zag-js-rect-utils/affine-transform.mjs +028df6eeb213e93cae9e5a777994601a08e7f6c896e3dfcca4a33db9643e8477 zag/zag-js-rect-utils/align.js +bc0ee49d355d2777668812220604b7d88650cff7177431e56f1bd9502a42996a zag/zag-js-rect-utils/align.mjs +f2c87ddde55bf23b336cf4614984dafa3b36bf2e6646d740e5e639741612fe58 zag/zag-js-rect-utils/angle.js +2ef246c9813d676bae0b5ddbae6b5a0ac1a60bbf5e4519d07ffd9eaaa3177c0e zag/zag-js-rect-utils/angle.mjs +5943dc01ce5ae389402596403935174ac7199a985937c1e2a314784c05f78023 zag/zag-js-rect-utils/chunk-QZ7TP4HQ.mjs +c6c4b3f3829424f7600b23b9eced9d7cc13444915cfd1fa8b2c426010f5a2fba zag/zag-js-rect-utils/clamp.js +e79c6cd3d4da7e8c2207988b557e280de45976e136ca71afa6b024ae9cbc967e zag/zag-js-rect-utils/clamp.mjs +fe07e8efe0cdd066132e8a563ec89b758527f3aacc20c312b35b88f9f646c211 zag/zag-js-rect-utils/closest.js +a59a8ac907942b180a53ad66654d19f19d2ab2c80b00090e005af155c98acdde zag/zag-js-rect-utils/closest.mjs +95f4ffd9d3d1d5690b4c9ac73f4106073c12b248eee004d47ae07b9330bbabdc zag/zag-js-rect-utils/compass.js +b468ffe90057532184576d1062529a0eec1a0e2481554c6104494b504b39c23a zag/zag-js-rect-utils/compass.mjs +dc6d10008b7f9b642f77f235284a2164c9564a406a80ead74d1aabc4f35b0894 zag/zag-js-rect-utils/constrain.js +03b14cae056bec83a0763153bb2374f2bf436c01cf95db0076dfc6af649da20e zag/zag-js-rect-utils/constrain.mjs +c7fd0b70363482ddad110288189274ee4aea746919be4fdbf21f69d8fe34715e zag/zag-js-rect-utils/contains.js +7aecdf1e235927ec3fa9e1b38d5006bb27c0939607e8658b0ccb97641beecb80 zag/zag-js-rect-utils/contains.mjs +4c84560fe2c52884f0f3290bd375a65d551684a2d36ef6883aaf4a6c16592d7a zag/zag-js-rect-utils/distance.js +26e40a05da04c51dc2edaec77b8c9b2cbc7ce10fcb7df051d2dc84e61edc9df3 zag/zag-js-rect-utils/distance.mjs +5760525ea7f0b2e561ee6d8745f489e59b1c5ef85b21aece78a4de216afafef9 zag/zag-js-rect-utils/equality.js +8e714f115ecf09e577e9ad25f5664420d6959e3b601f0d12a066457c26d1d106 zag/zag-js-rect-utils/equality.mjs +c3b1756ff9529847a9bff9003092463635f1c79af7084f68df1001bf0bb22e31 zag/zag-js-rect-utils/from-element.js +628912f6932d5dc3fd1b511b6f43ce2283f1142bd7a3c23068a82034a41eec2a zag/zag-js-rect-utils/from-element.mjs +d6dea678cad76623f476fc4f683f7f7544ae79b9f71cc39699aa616b8a3f25c9 zag/zag-js-rect-utils/from-points.js +671bca4f993f5974486cffdd1ad2eebe71dc37855499ce4a0e15158721289533 zag/zag-js-rect-utils/from-points.mjs +3ea9ffb22f604beac11548190a949fca22412835d5e0dc26222d2a6207cac7e5 zag/zag-js-rect-utils/from-range.js +e68688142d4e5f8734c2674a93c08647892eee1f639afd789e61cb63e665d293 zag/zag-js-rect-utils/from-range.mjs +80373df8a66822483cd6ae4eac929017c58b814933494f0137c3ae7fc9440e2d zag/zag-js-rect-utils/from-rotation.js +3b24c447219f160ade5b96a9e7bda7b2505d5475aa8b29133dbfc898b781a62b zag/zag-js-rect-utils/from-rotation.mjs +eb4193d5919b6d4574cdf238803b91e162d36ff6194e40d04d81c5eeff18c316 zag/zag-js-rect-utils/from-window.js +699223ea7b2d6613c7751b16d608df3e95f6c7a760f23937da2b20cd8adfa05c zag/zag-js-rect-utils/from-window.mjs +f9ca76dfc19b2c4cc095289878d8825fd42d8ce7458266a53a3fa76e7aaa393d zag/zag-js-rect-utils/index.js +0e7e8564197e0dca1fede9fc5f83b9bdc4944c90e53f0264f7b0a06b1b5a7bb6 zag/zag-js-rect-utils/index.mjs +d02407f383b8e917c5231fd92bf7f060a247c005bd346d40d4b6a4c4ef06a730 zag/zag-js-rect-utils/intersection.js +5a064d2aee748057b35e587841f12b8145d77f24a0ddde3dd41afbcff498f796 zag/zag-js-rect-utils/intersection.mjs +da5e24816ca3fb685ea904562433842f6f03573bb0200002ec4eab2305ae97f3 zag/zag-js-rect-utils/operations.js +660094bb2b219892eb403069f21b2f49ed480e33b47e62f212813a484f5becc8 zag/zag-js-rect-utils/operations.mjs +0ba7dcb5fd5467978519ca5306ecd0a86440688a61ae349870bbbb3ad0407634 zag/zag-js-rect-utils/polygon.js +605b8a28574014bf6bdee40c409567b196322b0b9f734ff862813eb1fcd56649 zag/zag-js-rect-utils/polygon.mjs +ed6a8c3046d782f6829cf0c253c3fce45b5a9a18a3048eac3335677887b7ca1a zag/zag-js-rect-utils/rect.js +473a42fca94eeaa1c7b92564bc8d039ed8ad6666577e5ad08fe579831175d873 zag/zag-js-rect-utils/rect.mjs +9c126fa321054010222d62925554e406b7670be0d0d908f8ae153f796fb68b41 zag/zag-js-rect-utils/resize.js +0db211e7cddc05bde23fa80b8a9f8201958e3fb13f2822c725e21b036121566b zag/zag-js-rect-utils/resize.mjs +7222b5629d31ed31408f5a62486de82165fdfd405aef2c7043ec6fe8ceb4b762 zag/zag-js-rect-utils/types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-rect-utils/types.mjs +be1b951d294b0c0d5781c3eb21fc4a0acbf7d769a7e6ef45d94c4a37e02b8448 zag/zag-js-rect-utils/union.js +632663c34f5e4336a92049c2d14e31bf69a660138d03252132595c428734c026 zag/zag-js-rect-utils/union.mjs +b5143ccbf82c7174c75e6178370d536fa14cfaac2c5d7f42757389d8152509a6 zag/zag-js-remove-scroll/index.js +a796c6c5d40f6225d4f6e53363339de2aaeec6da4115004d1f0d8c3fcfc7c062 zag/zag-js-remove-scroll/index.mjs +acaf57583779ee2f3bf67c6a6840f334dd0539a81835f846bd91b7d67ad29556 zag/zag-js-store/clone.js +cd2ebf1ee8749a4f8a72ed4787f47c07031b4246eab5757e9b4cee50f0903fdc zag/zag-js-store/clone.mjs +0910a45be68f245660fa0139777704b2896cbff01f7bb0096f3030c5c259ba89 zag/zag-js-store/global.js +82fd1ba75d778182d6a1bc3c24681d6cc72a35f9c24d7df118364f8c4fd605bb zag/zag-js-store/global.mjs +9b9ef9bc4a18d64aaf7b8def88b2e8578dfa1a52d442447c0b46b2c443705824 zag/zag-js-store/index.js +6089c7a0e0d2bb9c88514fd207a0ca637c11e98fb7f01f6fe4ad5a25eaf59c16 zag/zag-js-store/index.mjs +f737a28c375588fa467b3029d5c239a07d2b2158a3aebca92cfeafa27934fcc2 zag/zag-js-store/proxy-computed.js +04ddbe3a1dad2cd0188e8e528f06ca2ca9c4d0e2c0c0764984f5ebdffc3be142 zag/zag-js-store/proxy-computed.mjs +9284b5dd06badea15ca567fca082db10600546c2be00102a2fd83ad82319ab03 zag/zag-js-store/proxy.js +de88fae6bf6e2ec73eb42bc4b74a31dc2729bd9b2bf9f801385ac65ac4311f90 zag/zag-js-store/proxy.mjs +c316f675e218eb069f0fd8f0864a37e7c4be12e5d034cdc6a9686d14d01e6459 zag/zag-js-store/utils.js +e582b8b1b03434a89cfaa8c9cd27b21ac6f6a4c9c6e1a50c15b1f11417b779e2 zag/zag-js-store/utils.mjs +c5078c069804019680d28d6f20395c143e6f2460d0048580795e63b44771f405 zag/zag-js-tooltip/index.js +8c7650e9985e6ce80f14518f17e4ba60d3a48fb17b51fdd8f285b9241498f732 zag/zag-js-tooltip/index.mjs +018bc6f835495c843082af27cff0a943f2fc33d001582d4f3b5609a82af3958f zag/zag-js-tooltip/tooltip.anatomy.js +057fe33fde3743decee01a2d32a507125cddc8ac2fad6b4d6919b5ca16feccb5 zag/zag-js-tooltip/tooltip.anatomy.mjs +db843d2cfed61b8b6a097d64329f24935f3a68a137261bf800e3ec8573a9f051 zag/zag-js-tooltip/tooltip.connect.js +57e0832369fbbe87d70df26f1b1d5b6c2d99854afb0a379ec35372a713434d94 zag/zag-js-tooltip/tooltip.connect.mjs +fcc4c151f0ec4b3d8d0970e42cc89eadcc31eac0eeb2246a5966477eba1cc1b5 zag/zag-js-tooltip/tooltip.dom.js +7735bcec709ea89c5ef90127e5bd0bba38771a5c37b1ef06fa1564326f8bf1f6 zag/zag-js-tooltip/tooltip.dom.mjs +ca0a9c0680081b7c7cc991772e751ac0031ce8ed2c6ce8030dcf8523d833ac2d zag/zag-js-tooltip/tooltip.machine.js +8ff7a58b16d27c2bb8604aca91a268ee7f3cdbf042cf8f5c9309f33184ecfcc1 zag/zag-js-tooltip/tooltip.machine.mjs +e4e7c484c5e4511b9ccab9d8e12e60d7211f228736bc8d6db7107a3a6136867a zag/zag-js-tooltip/tooltip.props.js +a276baaf7561f535fcb01c235ff2333663b8b67134846f8f219b7db46c3b0ee0 zag/zag-js-tooltip/tooltip.props.mjs +8bf5285e7a41ee407830f34e0699b231864829eb7e63da3abb87476b550f8a93 zag/zag-js-tooltip/tooltip.store.js +d04c1addf45ca99a73717d1cad3f69e14147f554d12f56c66aac130f095e99dd zag/zag-js-tooltip/tooltip.store.mjs +e95111ac6562112c3ed3edc6c5909e0f6fde1a693b526991c69cde21609010b2 zag/zag-js-tooltip/tooltip.types.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-tooltip/tooltip.types.mjs +3f35c734c7466532e7218e741661d7165a924aef704d34fd23333998d761c43b zag/zag-js-types/create-props.js +1155c4fd604f40bafe814e066f5fce5d6fd1a8af25bf298d1c5e2493028ac944 zag/zag-js-types/create-props.mjs +d86dba4f06e0ccbe6daeffa24411f3eac3219ff7311220864f77f104bf98149f zag/zag-js-types/index.js +357e686f803a18f165d94ca5b06ad956a3f48308a413afd8ff51b2e2b799bbfa zag/zag-js-types/index.mjs +7dbfef2eb1539294adf4b600faa10f961c443845e1fe7509f9592f3f254ad5b5 zag/zag-js-types/jsx.js +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 zag/zag-js-types/jsx.mjs +4da4060603fcb372d525fca5da5a5a76810f49eda0a2d1283ea9d206df5cb4bc zag/zag-js-types/prop-types.js +9537c57c0a8dcb48f185d5018392731a01aefe2331dd9ddabd18591fdcb641f7 zag/zag-js-types/prop-types.mjs +ff1d4744db096521fd85c1ec2a1f158a1f0efd50076aedd4ca78b15ccda98b19 zag/zag-js-utils/array.js +f5195f06112d522ebafb49990d9af54bea284f6e83fa8334a8605fef9ccbc6d4 zag/zag-js-utils/array.mjs +00a5c51771989d747fe346c783860646f14db05e1ca6ea22c72e8e56dafa64bb zag/zag-js-utils/chunk-MXGZDBDQ.mjs +b75bd3e8ac29d85435a13db7c2a769b72ceb0bc11e585b762f17e9986e319235 zag/zag-js-utils/equal.js +5077ba6b0dec22f81322deadc8d4155a346e36fddf4af33b8b83cfbb553122e5 zag/zag-js-utils/equal.mjs +a133791655ad9d30aac75bb686948b9ee52d87f6d8759e6b96d1900864cf76cd zag/zag-js-utils/functions.js +8d4deb1e1d6e6758e8da53592576f0347ea925929b9b6ff82b5bfc9a7576d09e zag/zag-js-utils/functions.mjs +53e6ba8527eba12ccd5d0969e8144c3bed8ec6e981ad7154bc3a66dca13c1aa9 zag/zag-js-utils/guard.js +e2d4d63c298fb15158c00599cebce5adf78cbb2c079006960b80e53398ccb586 zag/zag-js-utils/guard.mjs +81cf63434099a9b061915900f1700b0c9105983152ab9a80785cfe08ce463b14 zag/zag-js-utils/index.js +11f2eaddc7a5c99fd5562428565be1aa2958356a7415839d0e12b547d519f758 zag/zag-js-utils/index.mjs +5016cd8822397eec22b010f27d083448076c43420d6e7a57c263452db9eee3f6 zag/zag-js-utils/number.js +a21006b3fe96e17cb780234163dcfc382c7e4e0e55a30f08cb13d7b66c6b0638 zag/zag-js-utils/number.mjs +2d30e013ffbfab8c06ba0152e229df6e22b225c3b78a7c0d38f409f460e30512 zag/zag-js-utils/object.js +33a552f9134ce4fded4e5414d5598476418d30dca4464e9f457a975ec661eb48 zag/zag-js-utils/object.mjs +428761c013572b64d0160716213f44e1e47277f5750e5ccf2ab390caaae9e671 zag/zag-js-utils/store.js +194dbc0c91ec0620860432bed790d3d0087aa0127af5878ccbd70b0d52ba7b4f zag/zag-js-utils/store.mjs +eb3b79c894afa8d204421a7d54378f794959d0be870492c3d67442dfed850859 zag/zag-js-utils/timers.js +a0bb84babddf71bb707e84c78e1846b299f0fe7a8b3fd6ada5a7da2a650be92d zag/zag-js-utils/timers.mjs +0a458a1d794916d015a29d0d0d0dc1bffe137e85f8bd216f5a29f256af0456b3 zag/zag-js-utils/warning.js +0431c9c616daed83a791bf9c3de415914a835c55b6c4058699c9cea70332f0f4 zag/zag-js-utils/warning.mjs +1cea3c3cad7877542676a28119050cc117a5ede1ed03cbe764fad0f6b85699ee zag/zag-js-vanilla/bindable.js +0d840fd0d9c5abaa8ef85069d8c706ae6f6c563b0786fa4fc224c1b56040f8c1 zag/zag-js-vanilla/bindable.mjs +5943dc01ce5ae389402596403935174ac7199a985937c1e2a314784c05f78023 zag/zag-js-vanilla/chunk-QZ7TP4HQ.mjs +55fea7763d8ff96c6962018b01a43d438b2bccb8b6e6c41edf9954afc13b3044 zag/zag-js-vanilla/index.js +a003078bfacee1f5c069b3864784b3ae8fc21d66ebf6cd1434b24e933ecbd54b zag/zag-js-vanilla/index.mjs +e82b93a8a2d560919da2868dc4853d6a530456a1b97ca6684763a6fa5c0680fb zag/zag-js-vanilla/machine.js +444433fed5f354c59c0a1542de3582344fec7efb266912c58df924e8d4177cf3 zag/zag-js-vanilla/machine.mjs +4db78d53613117fec13fa09b8343568e1523bbdf7e82f71bc9dca769ef8ab8b8 zag/zag-js-vanilla/merge-machine-props.js +47d52eeba18bbc4c178a320a25f822798bed975d44e832fa9c8d14567f798c35 zag/zag-js-vanilla/merge-machine-props.mjs +585d3baea3c22fc538bba0f3d61dd7381862dae66844cd0e5d1faa04d34fcf9a zag/zag-js-vanilla/merge-props.js +1ff4a2b5654353137e4452a6088a27a74aa85f00dfc63f4c5b56800572a55a53 zag/zag-js-vanilla/merge-props.mjs +05502d1a0cc3414ebc460127b3810d88bfb4cf79aaf932f72c2e4a5200880d58 zag/zag-js-vanilla/normalize-props.js +862e6d1141ef9100ef68e8b8857de7e5513fb6eebc6bd80c7067407109049015 zag/zag-js-vanilla/normalize-props.mjs +4473c9083b0a6a1258153e80b52f0e55300a26abfabf75081a7f2c4f0d68aaad zag/zag-js-vanilla/refs.js +35f84e83890afbf96eae6d570d41fe772a81d62122c9a99db967f2dae0df35c0 zag/zag-js-vanilla/refs.mjs +c7ec1ce4c7191488ac09aa87ff0a8c223ab39c9ef24263e7c0a978e214100721 zag/zag-js-vanilla/spread-props.js +d62bfa52a902d6d3ed05d63b9e9cabc134b0da0f1ac968889588db29655adfc3 zag/zag-js-vanilla/spread-props.mjs diff --git a/console/frontend/vendor/VENDORING.md b/console/frontend/vendor/VENDORING.md index d78a4c2..856add8 100644 --- a/console/frontend/vendor/VENDORING.md +++ b/console/frontend/vendor/VENDORING.md @@ -67,3 +67,10 @@ parallel (see CHANGELOG, 6 Oct). When a vendored family changes its files, regen python3 tools/modulepreload.py --write `test_boot_speed.py` runs `--check`, so a stale list fails the suite instead of quietly slowing the boot. + +## After a vendor change: refresh the checksums + +`console/frontend/vendor/SHA256SUMS` lists every vendored file. The test suite (and CI) run `tools/vendor-sums.py --check`, so a +hand edit or a half-applied upgrade fails there. After a deliberate change: `tools/vendor-sums.py --write`, then +`tools/modulepreload.py --write` (the preload list follows the module graph), and review the diff of both. The `zag/` +licence texts are in `zag/THIRD-PARTY-LICENSES.md`; re-copy each package's LICENSE into it when the versions change. diff --git a/console/frontend/vendor/zag/THIRD-PARTY-LICENSES.md b/console/frontend/vendor/zag/THIRD-PARTY-LICENSES.md new file mode 100644 index 0000000..4c904e0 --- /dev/null +++ b/console/frontend/vendor/zag/THIRD-PARTY-LICENSES.md @@ -0,0 +1,83 @@ +# Licences of the vendored `zag/` packages + +Every package in this folder is MIT-licensed. The MIT licence requires its copyright and permission notice to travel with copies, so the exact text each package ships with (taken from its npm tarball at the pinned version, 8 Oct 2026) is reproduced below, grouped by identical text. See `../VENDORING.md` for how the folder is produced. + + +## 1. `@floating-ui/core` 1.8.0, `@floating-ui/dom` 1.8.0, `@floating-ui/utils` 0.2.12 + +```text +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + + +## 2. `@zag-js/anatomy` 1.44.0, `@zag-js/aria-hidden` 1.44.0, `@zag-js/core` 1.44.0, `@zag-js/dialog` 1.44.0, `@zag-js/dismissable` 1.44.0, `@zag-js/dom-query` 1.44.0, `@zag-js/focus-trap` 1.44.0, `@zag-js/focus-visible` 1.44.0, `@zag-js/interact-outside` 1.44.0, `@zag-js/menu` 1.44.0, `@zag-js/popper` 1.44.0, `@zag-js/rect-utils` 1.44.0, `@zag-js/remove-scroll` 1.44.0, `@zag-js/store` 1.44.0, `@zag-js/tooltip` 1.44.0, `@zag-js/types` 1.44.0, `@zag-js/utils` 1.44.0, `@zag-js/vanilla` 1.44.0 + +```text +MIT License + +Copyright (c) 2021 Chakra UI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + + +## 3. `proxy-compare` 3.0.1 + +```text +The MIT License (MIT) + +Copyright (c) 2020 Daishi Kato + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` diff --git a/console/mcp/server.py b/console/mcp/server.py index 444d070..12e7a1e 100644 --- a/console/mcp/server.py +++ b/console/mcp/server.py @@ -123,6 +123,11 @@ def _console_token() -> str: return "" +# Calls to the console are loopback: an `http_proxy` in the environment must not get them (it would be sent the console +# token and every Pine script), so this opener has no proxy handler (audit SEC-13). +_LOCAL = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + def _call(path: str, payload: dict | None = None, timeout: float = 20.0) -> dict: """One console request. Returns the unwrapped `data`, or raises RuntimeError with the console's own message — the tools below turn that into a sentence rather than a traceback.""" @@ -137,7 +142,7 @@ def _call(path: str, payload: dict | None = None, timeout: float = 20.0) -> dict method="POST" if payload is not None else "GET", ) try: - with urllib.request.urlopen(req, timeout=timeout) as res: + with _LOCAL.open(req, timeout=timeout) as res: body = json.loads(res.read().decode("utf-8")) except urllib.error.HTTPError as exc: # The console answers refusals with a JSON body (401 no token, 400 a bad query, 409 busy…). @@ -247,6 +252,30 @@ def _command(action: str, timeout: float = INLINE_WAIT + 8.0, **fields) -> str: return line +MAX_PINE_CHARS = 60000 # one answer; a longer script is read in slices with `offset` + + +def _pine_slice(pine: str, offset: int = 0) -> str: + """A fenced Pine block that never cuts silently. The tools used to stop at 6000 / 8000 characters with no word, while + saying "full source": an agent ran or ported a fragment as if it were the script (audit 8 Oct, AS-1).""" + text = pine.strip() + total = len(text) + start = max(0, min(int(offset or 0), total)) + end = min(total, start + MAX_PINE_CHARS) + block = "```pine\n" + text[start:end] + "\n```" + if end < total: + block += (f"\n… truncated: showing characters {start}-{end} of {total} — call again with offset={end} " + "for the rest. Do NOT run or port this fragment as the whole script.") + elif start: + block += f"\n(characters {start}-{total} of {total})" + return block + + +def _more(shown: int, total: int, what: str) -> str: + """One line saying a list was cut, or nothing when it was not.""" + return f"… {shown} of {total} {what} shown — narrow the query to see the rest" if total > shown else "" + + # ── chart tools ───────────────────────────────────────────────────────────────────────────────── # ── vectorbt backtesting (the tier in console/backend/backtest_service.py, own venv, :8788) ── # These are thin HTTP clients like every other tool here: the console proxies, the service computes. @@ -254,10 +283,16 @@ def _command(action: str, timeout: float = INLINE_WAIT + 8.0, **fields) -> str: # _chart/backtest/ (their own namespace) and never touch the live chart's state. def _bt(path: str, payload: dict | None = None, timeout: float = 240.0) -> dict: - return _call(path, payload, timeout=timeout) + """The backtest tier, or an answer that says why not. Every other tool turns a refusal into a sentence; these four + raised, and the agent saw a traceback (audit AS-5).""" + try: + return _call(path, payload, timeout=timeout) + except RuntimeError as exc: + return {"ok": False, "error": f"{exc} — the vectorbt tier is optional: install it with ./install.sh --with-backtest and " + "start console/backend/backtest_service.py in its own venv"} -@mcp.tool(annotations=_ann("Backtest a strategy with vectorbt", read_only=False)) +@mcp.tool(annotations=_ann("Backtest a strategy with vectorbt", read_only=False, open_world=True, destructive=False)) def bt_run(source: str = "binance:BTCUSDT:30m", fast: int = 20, slow: int = 50, fee: float = 0.001, bars: int = 1000) -> str: """Run one MA-cross backtest and return its metrics (return, Sharpe, drawdown, trades). @@ -278,7 +313,7 @@ def bt_run(source: str = "binance:BTCUSDT:30m", fast: int = 20, slow: int = 50, f"expectancy {m['expectancy']:.2f}\nrun_id {r['run_id']} (trades saved; use bt_status)") -@mcp.tool(annotations=_ann("Sweep MA parameters with vectorbt", read_only=False)) +@mcp.tool(annotations=_ann("Sweep MA parameters with vectorbt", read_only=False, open_world=True, destructive=False)) def bt_optimize(source: str = "binance:BTCUSDT:30m", lo: int = 5, hi: int = 60, fee: float = 0.001, bars: int = 1000, top: int = 10) -> str: """Sweep every MA pair in [lo, hi] at once (vectorbt's real strength) and rank by return. @@ -431,17 +466,29 @@ def _label(s: dict) -> str: warning = _freshness(state.get("age_s")) if warning: lines.append(warning.lstrip("\n")) + if not state.get("bars"): + lines.append(_NO_BARS) return "\n".join(lines) +_NO_BARS = ("⚠ the chart holds no bars (the market feed may be unreachable): an empty chart is what any picture or " + "indicator result will show.") + + @mcp.tool(annotations=_ann("Capture the chart", read_only=True)) def chart_shot(name: str = ""): """Capture the chart as a PNG. Returns the image (when the client takes images) and its path.""" + blocked = _command_gate(_page_age()) # the same preflight every other chart tool runs + if blocked: + return f"✗ {blocked}" try: queued = _call("/api/chart/command", {"action": "shot", "wait": INLINE_WAIT + 10.0}, timeout=INLINE_WAIT + 14.0) except RuntimeError as exc: return f"✗ {exc}" + if not queued.get("pushed"): + return ("✗ no chart view is attached — open the Trader's Agent row in Hermes Desktop (or " + f"{BASE} in a browser) and call this tool again.") result = queued.get("result") or {} shot = result.get("shot") or "" if not result: @@ -466,6 +513,11 @@ def chart_shot(name: str = ""): return f"✗ captured the chart but could not write {path}: {exc}" size_kb = round(os.path.getsize(path) / 1024, 1) text = f"✓ chart captured ({size_kb} KB) → {path}" + try: + if not (_call("/api/chart/state", timeout=5.0).get("bars")): + text += "\n" + _NO_BARS + except RuntimeError: + pass if MCPImage is None: return text return [text, MCPImage(path=path)] @@ -506,9 +558,11 @@ def chart_apply_pine(pine: str, inputs: dict[str, Any] | None = None) -> str: One landasan for every door (chat, script pane, Library): geometry (boxes/lines/labels/tables) lands on the console's overlay and is read back after drawing; plot series lands as a matching - Vela native — only when the script actually plots. PineTS implements a subset: `import`, `while` - and `for…in` are not available — the answer says so rather than pretending. Sizes it can do: - studies with plot/hline/fill/bgcolor and simple ta.* calls. + Vela native — only when the script actually plots. PineTS implements most of Pine v6: `while`, + `for…in`, tuples, `request.security` (higher timeframes are fetched), box/line/label/table and + strategy() all run. The one thing refused outright is `import` (answer `NOT_RUNNABLE[import]`; the + answer says so rather than pretending). `request.security` reached only under a last-bar condition + (`barstate.islast` and the like) is an open engine bug and comes back empty. `inputs` sets the script's own settings (its `input.*()` values) by label, e.g. {"Length": 50, "Show upper band": false}; anything you leave out keeps the script's default, and @@ -565,7 +619,7 @@ def chart_clear() -> str: return _command("clear") -@mcp.tool(annotations=_ann("Add a Vela indicator", destructive=True)) +@mcp.tool(annotations=_ann("Add a Vela indicator", destructive=False)) def chart_add_indicator(native: str) -> str: """Add a Vela native indicator to the chart, by name (ema, supertrend, donchian-channels, …).""" if not native.strip(): @@ -646,7 +700,7 @@ def chart_natives() -> str: return _command("natives") -@mcp.tool(annotations=_ann("Open the Indicators surface", read_only=False)) +@mcp.tool(annotations=_ann("Open the Indicators surface", read_only=False, destructive=False)) def chart_indicators(section: str = "", q: str = "", family: str = "", star: str = "", unstar: str = "", mount: str = "", show: bool = True) -> str: @@ -680,7 +734,7 @@ def chart_indicators(section: str = "", q: str = "", family: str = "", return _command("indicators", **fields) -@mcp.tool(annotations=_ann("Full screen for the chart", read_only=False)) +@mcp.tool(annotations=_ann("Full screen for the chart", read_only=False, destructive=False)) def chart_fullscreen(on: bool = True) -> str: """Give the chart the whole pane — and the whole screen, where the host allows it. @@ -695,7 +749,7 @@ def chart_fullscreen(on: bool = True) -> str: return _command("fullscreen", on=bool(on)) -@mcp.tool(annotations=_ann("Replay the chart", read_only=False)) +@mcp.tool(annotations=_ann("Replay the chart", read_only=False, destructive=False)) def chart_replay(op: str = "state", bars: int = 100, from_ms: int = 0, interval_ms: int = 0) -> str: """Drive Vela's own replay engine — the operator's replay button, from the agent's side. @@ -715,7 +769,7 @@ def chart_replay(op: str = "state", bars: int = 100, from_ms: int = 0, interval_ return _command("replay", **fields) -@mcp.tool(annotations=_ann("Draw on the chart with Vela's drawing tools", read_only=False)) +@mcp.tool(annotations=_ann("Draw on the chart with Vela's drawing tools", read_only=False, destructive=True)) def chart_drawing(op: str = "list", type: str = "", anchors: list[dict] | None = None, id: str = "", ids: list[str] | None = None, style: dict | None = None, text: str = "", props: dict | None = None, locked: bool | None = None, visible: bool | None = None, @@ -758,7 +812,7 @@ def chart_drawing(op: str = "list", type: str = "", anchors: list[dict] | None = return _command("drawing", **fields) -@mcp.tool(annotations=_ann("Chart view settings (type, scale, time zone, status line …)", read_only=False)) +@mcp.tool(annotations=_ann("Chart view settings (type, scale, time zone, status line …)", read_only=False, destructive=False)) def chart_view(setting: str = "state", value: str = "") -> str: """Read or set one chart view setting — the things the operator would click in Vela's menus. @@ -780,7 +834,7 @@ def chart_view(setting: str = "state", value: str = "") -> str: return _command("view", **fields) -@mcp.tool(annotations=_ann("Event marks on the chart's time axis", read_only=False)) +@mcp.tool(annotations=_ann("Event marks on the chart's time axis", read_only=False, destructive=True)) def chart_marks(op: str = "list", time: str = "", bars_ago: int = -1, title: str = "", content: str = "", color: str = "", shape: str = "", letter: str = "", id: str = "") -> str: """Place small event marks on the chart's time axis (news, a trade idea, "I entered here"). @@ -804,7 +858,7 @@ def chart_marks(op: str = "list", time: str = "", bars_ago: int = -1, title: str return _command("marks", **fields) -@mcp.tool(annotations=_ann("Console theme (light / dark)", read_only=False)) +@mcp.tool(annotations=_ann("Console theme (light / dark)", read_only=False, destructive=False)) def chart_theme(theme: str = "") -> str: """Read or set the console's theme: 'light', 'dark', or '' to report what is worn now. @@ -829,7 +883,7 @@ def chart_reload() -> str: return _command("reload", once_per_view=True) -@mcp.tool(annotations=_ann("Chart palette")) +@mcp.tool(annotations=_ann("Chart palette", destructive=False)) def chart_palette(try_apply: bool = False) -> str: """What colours the chart is actually wearing (background, candle up/down, console theme). @@ -843,7 +897,7 @@ def chart_palette(try_apply: bool = False) -> str: return _command("palette", **fields) -@mcp.tool(annotations=_ann("Open the script catalogue in the drawer")) +@mcp.tool(annotations=_ann("Open the script catalogue in the drawer", destructive=False)) def chart_browse(family: str = "", show: bool = True) -> str: """Open the catalogue in the drawer — the one surface (the Library panel is deleted, 3 Oct). @@ -888,20 +942,35 @@ def library_search(query: str, kind: str = "", limit: int = 8) -> str: @mcp.tool(annotations=_ann("Read one Library indicator", read_only=True, open_world=True)) -def library_indicator(query: str) -> str: - """One Library indicator by name or slug: what it is, its licence, and its full Pine source.""" +def library_indicator(query: str, offset: int = 0) -> str: + """One Library indicator by name or slug: what it is, its licence, and its Pine source. + + The source comes whole up to 60,000 characters. A longer one is cut AND SAID so (`… truncated: …`), with the `offset` + to ask for next; never run or port a script that was reported truncated. + """ if not query.strip(): return "✗ empty query" slug = query.strip() try: - if " " in slug or slug.lower() != slug: # a name, not a slug: resolve it first - found = _call("/api/search?" + urllib.parse.urlencode({"q": slug, "type": "indicators", "limit": 1}), + def search_first(): + found = _call("/api/search?" + urllib.parse.urlencode({"q": query.strip(), "type": "indicators", "limit": 1}), timeout=25.0) rows = found.get("results") or [] - if not rows: + return rows[0].get("slug") if rows else None + + if " " in slug or slug.lower() != slug: # a name, not a slug: resolve it first + slug = search_first() + if not slug: return f"✗ nothing in the Library matches {query!r}" - slug = rows[0].get("slug") or slug - meta = _call("/api/indicator?" + urllib.parse.urlencode({"slug": slug}), timeout=25.0) + try: + meta = _call("/api/indicator?" + urllib.parse.urlencode({"slug": slug}), timeout=25.0) + except RuntimeError: + # A one-word lower-case name ("killzone") looks like a slug but may not be one: search before giving up. + found_slug = search_first() + if not found_slug or found_slug == slug: + raise + slug = found_slug + meta = _call("/api/indicator?" + urllib.parse.urlencode({"slug": slug}), timeout=25.0) except RuntimeError as exc: return f"✗ {exc}" item = meta.get("indicator") or meta @@ -915,7 +984,7 @@ def library_indicator(query: str) -> str: pine = src.get("source") or src.get("pine") or "" if pine: head.append("Pine source (LuxAlgo Library — CC BY-NC-SA 4.0, not redistributable):") - head.append("```pine\n" + pine.strip()[:6000] + "\n```") + head.append(_pine_slice(pine, offset)) else: head.append("(no Pine source on this entry)") except RuntimeError as exc: @@ -1007,15 +1076,17 @@ def library_concept(slug: str) -> str: lines.append("indicators:") lines += [f"- {r.get('title') or r.get('slug')} ({r.get('slug')})" if isinstance(r, dict) else f"- {r}" for r in related[:20]] + if _more(20, len(related), "related indicator(s)"): + lines.append(_more(20, len(related), "related indicator(s)")) return "\n".join(lines) @mcp.tool(annotations=_ann("Library source by slug", read_only=True, open_world=True)) -def library_source(slug: str) -> str: +def library_source(slug: str, offset: int = 0) -> str: """The Pine source of one Library entry, by EXACT slug — no name resolution, no guessing. Same rule as library_indicator: LuxAlgo Library source is CC BY-NC-SA 4.0, fine to run locally, - never to redistribute. + never to redistribute. A source over 60,000 characters is cut AND SAID so; pass `offset` for the rest. """ if not slug.strip(): return "✗ empty slug" @@ -1026,9 +1097,8 @@ def library_source(slug: str) -> str: pine = data.get("source") or data.get("pine") or "" if not pine: return f"✗ no source stored for {slug!r} — check the slug with library_search" - return (f"# {slug.strip()} — {len(pine.splitlines())} line(s)\n" - "Pine source (LuxAlgo Library — CC BY-NC-SA 4.0, not redistributable):\n" - "```pine\n" + pine.strip()[:8000] + "\n```") + return (f"# {slug.strip()} — {len(pine.splitlines())} line(s), {len(pine.strip())} characters\n" + "Pine source (LuxAlgo Library — CC BY-NC-SA 4.0, not redistributable):\n" + _pine_slice(pine, offset)) @mcp.tool(annotations=_ann("LuxAlgo edge presets", read_only=True, open_world=True)) @@ -1048,6 +1118,8 @@ def edge_presets(category: str = "") -> str: for row in rows[:30]: label = row.get("name") or row.get("id") or row out.append(f"- {label}") + if _more(30, len(rows), "preset(s)"): + out.append(_more(30, len(rows), "preset(s)")) if data.get("categories"): out.append("categories: " + ", ".join(map(str, data["categories"]))) return "\n".join(out) @@ -1065,12 +1137,14 @@ def edge_report(preset: str, symbol: str) -> str: return f"✗ {exc}" report = data.get("report") or {} lines = [f"{preset} on {symbol.upper()}:"] - for key, value in list(report.items())[:20]: + for key, value in list(report.items())[:40]: if isinstance(value, (str, int, float, bool)) or value is None: lines.append(f"- {key}: {value}") elif isinstance(value, list): lines.append(f"- {key}: {len(value)} row(s)") # nested structures are summarised: the raw report can be large + if _more(40, len(report), "field(s)"): + lines.append(_more(40, len(report), "field(s)")) return "\n".join(lines) if len(lines) > 1 else f"no report fields came back for {preset} / {symbol}" @@ -1085,7 +1159,8 @@ def edge_symbols() -> str: head = f"{data.get('count', len(rows))} symbol(s)" if data.get("note"): head += f" · {data['note']}" - return head + "\n" + ", ".join(map(str, rows[:60])) + more = _more(60, len(rows), "symbol(s)") + return head + "\n" + ", ".join(map(str, rows[:60])) + (("\n" + more) if more else "") # ── Edge Stats: LuxAlgo's open-source engine, run locally (console/backend/edgestats.py) ────────────── @@ -1273,6 +1348,8 @@ def propfirms(query: str = "") -> str: out.append(f"- {name}" + (f" — {', '.join(extra)}" if extra else "")) else: out.append(f"- {row}") + if _more(30, len(firms), "firm(s)"): + out.append(_more(30, len(firms), "firm(s)")) return "\n".join(out) @@ -1294,6 +1371,8 @@ def propfirm_offers(query: str = "") -> str: row.get("discount") or row.get("price"), row.get("ends") or row.get("expires")) if v)) else: out.append(f"- {row}") + if _more(30, len(rows), "offer(s)"): + out.append(_more(30, len(rows), "offer(s)")) return "\n".join(out) @@ -1355,7 +1434,7 @@ def _natives(live: bool = False) -> list: @mcp.tool(annotations=_ann("Run several chart commands", destructive=True)) -def chart_batch(commands: str, stop_on_error: bool = True) -> str: +def chart_batch(commands: str | list[dict[str, Any]], stop_on_error: bool = True) -> str: """Run several chart actions in ONE call, in order. `commands` is JSON: a list of objects, each `{"action": "market", "symbol": "BTCUSDT", @@ -1392,7 +1471,7 @@ def chart_batch(commands: str, stop_on_error: bool = True) -> str: return "\n".join(lines) -@mcp.tool(annotations=_ann("Remember the chart's state")) +@mcp.tool(annotations=_ann("Remember the chart's state", destructive=False)) def chart_snapshot() -> str: """Remember the chart's indicators plus its symbol/timeframe as a restore point for chart_undo. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 48b9d2a..5aca9ad 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -19,8 +19,9 @@ the agent (and the user) read and drive that chart with low latency. It is delib `console/frontend/vendor/pinets/PROVENANCE.md`); Library Pine source is CC BY-NC-SA 4.0 and must **never** be committed (it may be fetched at runtime and drawn, with attribution). - **MIT** for this plugin's own code. -- **private repo**, personal use. Grok cannot clone it; the operator will paste this file (and file - contents) instead. +- **public repo** (checked 8 Oct 2026; earlier versions of this file said private). That is why nothing from the Library may be + committed and why the licence notices matter: anything committed is already redistributed. Grok-style agents that cannot + clone get this file pasted by the operator. Goal in the operator's words: *"chart ni yang connect, communicate, see and understand the chart with the lowest latency possible"* — chat on the left, chart docked on the right, agent drives the chart through @@ -46,7 +47,7 @@ native tools. Versions: Hermes Desktop 0.21.5, console `SERVER_VERSION = 1.0.0`. CI was red from 28 Sep (`1f9d340`) to 2 Oct (`8df6e2e` — a stale MCP test only CI could see); green since `ab3708b`, and -green on every ship below. **1034 backend tests** (+77 skipped without `fastmcp` / a real Edge Stats engine), 8 plugin contributions across 5 areas. +green on every ship below. **about 1,090 backend tests** (77+ skipped without `fastmcp` / a real Edge Stats engine; run the suite for the exact count), 8 plugin contributions across 5 areas. Shipped since this file was written: the paper broker (Phase 5), replay (6/6b), the top-bar one-door consolidation, the drawer as the single Library surface, and Phase 7 — three agent doors into Vela (`chart_drawing` 76 types / `chart_view` settings / `chart_marks`), 54 MCP tools (8 of them `edgestats_*`). 4 Oct: Edge Stats — the optional local engine, the sheet behind the `⋯` menu, the eight tools (see CHANGELOG; real Binance / Dukascopy downloads were never run from the build machine). Also 4 Oct: the top row @@ -268,7 +269,7 @@ Working, with evidence: - Latency: symbol switch **686–698 ms** end to end, screenshot **35 ms**, `clear` **7 ms**, SSE push single-digit ms (transport is not the bottleneck; the chart engine fetch+render is). - Console health: `/api/health` ok, LuxAlgo MCP connected, 29 endpoints. -- 1034 backend tests (77 skipped bare; 1 with fastmcp + a real engine), plugin harness OK (8 contributions / 5 areas), CI green since `ab3708b` +- ~1,090 backend tests (see the suite's own count; skips depend on `fastmcp`, node and a real engine), plugin harness OK (8 contributions / 5 areas), CI green since `ab3708b` (see the correction above — earlier runs were red for a week and nobody looked). Current live state (transient): chart on **BTCUSDT 1m** (dark), the Breakout Detector overlay restored @@ -279,7 +280,7 @@ from the last session, replay off; the docked pane may be hidden — check the h ## 7. Open problems — what to settle next (ranked) 1. **Real LuxAlgo indicators cannot run.** The catalogue's indicators (what the operator actually wants: - "the LuxAlgo indicators I see on their site") are full Pine v6 scripts; PineTS rejects `for … in` and + "the LuxAlgo indicators I see on their site") are full Pine v6 scripts; PineTS runs `for … in` (only `import` is refused) and friends. Options to investigate, in order of user value: (a) a PineTS-compatible **port** of the specific indicators he names (subset-safe, no MTF calls); (b) compute-from-data drawings (the PDH/PDL pattern) for level-type indicators; diff --git a/docs/MCP-TOOLS.md b/docs/MCP-TOOLS.md index 4d84d20..1bc2546 100644 --- a/docs/MCP-TOOLS.md +++ b/docs/MCP-TOOLS.md @@ -21,12 +21,10 @@ Verify: `hermes mcp test traders-chart` · **new tools need a new session** (or | `chart_studies` | — | Everything **on** the chart right now, each row labelled with the reader that saw it: `study` (what the console's own chip counts), `cell` (the workspace cell's on-chart rows), `overlay` (a script run through the console's landasan), `paint` (the PineTS paint layer), `native` (Vela's own names). Ask it before calling a chart clean: `chart_state`'s `natives` list is Vela's names only, and a script mounted from the Library is not one of them. | | `chart_state` | — | Symbol, timeframe, last price, bars, indicators on the chart, plus `build`/`viewer` when the page publishes them (stale-frame check). | | `chart_shot` | `name: str = ""` | One PNG of the chart. Returned as an image when the client takes images, plus the path on disk. | -| `chart_palette` | `try_apply: bool = false` | What colours the chart is actually wearing (background, candles, console theme). `try_apply=true` asserts the console's palette and reports what landed 300 ms later. | -| `chart_browse` | `family: str = ""`, `show: bool = true` | Open the Library's concept-family list in the pane, optionally narrowed to one family slug. Family bubbles expose Library **concepts** (not indicator scripts); the empty family opens all concepts. Answers with the rows actually painted. The catalogue itself is `library_list`; this one is the *surface*. | | `broker_state` | — | The **paper** (simulated Binance spot) account: cash, equity, positions with live P&L, and the orders waiting for the operator. Read-only. | -| `broker_propose` | `symbol: str`, `side: str` (`buy`/`sell`), `qty: float`, `note: str = ""` | Propose a paper order. It does **not** trade: it puts an Approve/Reject card on the chart and waits. There is deliberately **no approve tool** — only the operator, on the card. Fills at the live price at approval — or the replay cursor price while replay is on (see `chart_replay`). | -| `library_search` | `query: str`, `kind: str = ""` (`concept`/`indicator`), `limit: int = 8` | Search the LuxAlgo Library (concepts + indicators). See the Library section below for the other nine. | -| `library_indicator` | `query: str` | One indicator by name or slug: summary, licence, and its full Pine source. | +| `chart_pine_inputs` | `pine: str` | List the settings a script declares — label, type, default, range or options, group — without running it, opening the pane or touching the chart. Call it before `chart_apply_pine` / `chart_draw` with `inputs` when you do not know the labels. | +| `chart_watch` | `seconds: int = 15`, `timeout_s` | Watch for a spell (blocks up to 120 s) and answer with a **diff** (what changed) rather than a second snapshot. | +| `chart_alert` | `seconds: int = 30`, `move_pct: float = 0.0`, `timeout_s` | Wait (up to 300 s) for the **market** to move. Reuses the heartbeat's own `last`, reports the move (from → to, percent and absolute) as soon as price travels `move_pct` percent from the first reading — `0` means any change. Where `chart_watch` answers "did the chart change", this answers "did price do something", and reports the largest excursion it saw when the threshold is never met. | ## Mutating tools @@ -45,27 +43,27 @@ Verify: `hermes mcp test traders-chart` · **new tools need a new session** (or | `chart_remove_indicator` | `native: str = ""`, `all: bool = false` | Take studies **off** the chart — one by name, or every study with `all=true`. Reports `removed X · chart now carries: Y`. | | `chart_apply_pine` | `pine: str`, `inputs: dict = {}` | Run Pine over the chart's live bars and paint what it makes: geometry (boxes/lines/labels/tables) on the console's overlay, plot series as a **matching Vela native** — the same landasan as `chart_draw`, the script pane and the Library. PineTS is a measured subset: `import` is refused outright, while `while`, `for … in`, `request.security`, tuple returns, `box/line/label/table` and `strategy()` all run. `inputs` sets the script's own settings (its `input.*()` values) **by label** — `{"Length": 50, "Show upper band": false}`; the label is matched ignoring case and spacing, and the variable name or `in_0` id work too. What you leave out keeps its default, `null` resets one, a number outside the input's range is limited to it, and the answer says what was used, what was refused and why (with a *did you mean* for a near miss), and which inputs the script does have. When the script pane holds this very script, its Settings show the values. | | `chart_draw` | `pine: str`, `inputs: dict = {}` | Run Pine and paint the geometry it **builds** (boxes/lines/labels/tables) on the console's overlay; any plot series lands as a native — one landasan with `chart_apply_pine`. The explicit route for level-type scripts (SMC/liquidity models, PDH/PDL). | -| `chart_pine_inputs` | `pine: str` | **Read-only.** List the settings a script declares — label, type, default, range or options, group — without running it, opening the pane or touching the chart. Call it before `chart_apply_pine` / `chart_draw` with `inputs` when you do not know the labels. | | `chart_clear` | — | Clear our overlay drawings and the natives our paint layer added, then report what is left. **Does not remove studies added with `chart_add_indicator`** — use `chart_remove_indicator`. | | `chart_reload` | — | Reload every attached console page (picks up new frontend files). Once-per-view. | -| `chart_batch` | `commands: str` (JSON array) | Several actions in one call, in order. Each step is `{"action": "...", ...fields}`; stops at the first failure unless `stop_on_error=false`. One round trip instead of five. | +| `chart_batch` | `commands: str` (JSON array, or a list), `stop_on_error: bool = true` | Several actions in one call, in order. Each step is `{"action": "...", ...fields}`; stops at the first failure unless `stop_on_error=false`. One round trip instead of five. | | `chart_snapshot` | — | Remember the market + indicator set as a restore point for `chart_undo`. | | `chart_undo` | — | Put the chart back to the last snapshot: market first, then the indicator set, reporting the chart's own before → after lists. **Drawings are not restored** — `chart_clear`, then re-draw. | -| `chart_watch` | `seconds: int = 15`, `timeout_s` | Watch for a spell and answer with a **diff** (what changed) rather than a second snapshot. | -| `chart_alert` | `seconds: int = 30, move_pct: float = 0.0` | Wait for the **market** to move. Reuses the heartbeat's own `last`, reports the move (from → to, percent and absolute) as soon as price travels `move_pct` percent from the first reading — `0` means any change. Where `chart_watch` answers "did the chart change", this answers "did price do something", and reports the largest excursion it saw when the threshold is never met. | +| `chart_palette` | `try_apply: bool = false` | What colours the chart is actually wearing (background, candles, console theme). `try_apply=true` asserts the console's palette and reports what landed 300 ms later. | +| `chart_browse` | `family: str = ""`, `show: bool = true` | Open the catalogue in the drawer, optionally narrowed to one family slug (`trend`, `smc-ict`, …). The answer reports the rows the list holds, its family filter and whether the drawer is open; it never applies an indicator. | +| `broker_propose` | `symbol: str`, `side: str` (`buy`/`sell`), `qty: float`, `note: str = ""` | Propose a paper order. It does **not** trade: it puts an Approve/Reject card on the chart and waits. There is deliberately **no approve tool** — only the operator, on the card. Fills at the live price at approval — or the replay cursor price while replay is on (see `chart_replay`). | ## Library / research tools -Read-only, and every one leaves this machine (they reach LuxAlgo's hosted MCP). +Read-only. The `library_*`, `edge_*`, `propfirm*` tools reach LuxAlgo's hosted MCP, so they leave this machine; the `edgestats_*` tools run LuxAlgo's open-source engine **locally** (only `edgestats_setup` downloads, from Binance or Dukascopy). `edgestats_show` and `edgestats_setup` are not read-only: they open a view / start a download. | Tool | Arguments | What it does | |---|---|---| | `library_search` | `query: str`, `kind: str = ""` (`concept`/`indicator`), `limit: int = 8` | Search the Library (concepts + indicators). | -| `library_indicator` | `query: str` | One indicator by name or slug: summary, licence, full Pine source. | +| `library_indicator` | `query: str`, `offset: int = 0` | One indicator by name or slug: summary, licence, Pine source. A source over 60,000 characters is cut **and says so** (`… truncated … offset=N`); a one-word name that is not a slug falls back to a search. | | `library_list` | `family`, `text`, `concept`, `tier`, `sort`, `direction`, `page`, `page_size` | Browse with the same filters and paging the console's own list uses. | | `library_taxonomy` | `what: str = "families"` | The Library's own families (17 measured) or its concept graph — so a filter value is the Library's, not a guess. | | `library_concept` | `slug: str` | One concept by slug, with the indicators that implement it. | -| `library_source` | `slug: str` | Pine source by **exact** slug — no name resolution to get wrong. | +| `library_source` | `slug: str`, `offset: int = 0` | Pine source by **exact** slug — no name resolution to get wrong. Long sources are cut **and say so**; pass `offset` for the rest. | | `edge_presets` | `category: str = ""` | LuxAlgo's measured edge presets (42 measured). | | `edge_report` | `preset: str`, `symbol: str` | One preset's measured performance on one symbol. | | `edge_symbols` | — | The symbols the edge dataset covers. | @@ -96,11 +94,11 @@ Read-only, and every one leaves this machine (they reach LuxAlgo's hosted MCP). | Code | Meaning | |---|---| | `SYMBOL_NOT_SERVED[XAUUSD]` | This console's workspace provider is Binance only, and it never answered for that symbol. The chart is unchanged — use a crypto pair (the op has a 6 s deadline so it refuses instead of hanging). | -| `NOT_RUNNABLE[import/for-in]` | PineTS cannot execute that construct — the script needs a full TradingView engine. Measured: `import` is the real one; `for … in` actually runs. | +| `NOT_RUNNABLE[import]` | The script imports a library, which PineTS cannot do. `while` and `for … in` run (measured 28 Sep). | | `SYNTAX_ERROR` | Pine could not parse the script. The error carries `line` and `col` (1-based) — fix that line; an unclosed bracket is often reported at the end of the file. | | `RUNTIME_CRASH[…]` | The engine threw mid-run (engine bug, not the caller's). | | `TOO_FEW_BARS` | Not enough history loaded — widen the range, retry once. | -| `ENGINE_UNAVAILABLE` | The PineTS module could not be fetched (network). | +| `ENGINE_UNAVAILABLE` | The vendored PineTS engine did not load (nothing is fetched from the network); `chart_reload`, and report it if it persists. | | `TIMEOUT` | The run exceeded its budget. | When no view is attached the command tools answer `✗ no chart view is attached …` instead of hanging. @@ -109,7 +107,7 @@ When no view is attached the command tools answer `✗ no chart view is attached `state` · `shot` · `apply` · `add` · `remove` · `market` · `draw` · `clear` · `script` · `browse` · `open` · `mode` · `reload` · `caps` · `layout` · `studies` · `natives` · `indicators` · -`fullscreen` · `theme` · `replay` · `wait` +`fullscreen` · `theme` · `replay` · `wait` · `drawing` · `view` · `marks` · `edge` ## Notes diff --git a/docs/desk-engineering-notes.md b/docs/desk-engineering-notes.md new file mode 100644 index 0000000..9515394 --- /dev/null +++ b/docs/desk-engineering-notes.md @@ -0,0 +1,357 @@ +# Trader's Agent desk: engineering notes + +*Moved out of `skills/trader-desk/SKILL.md` on 8 Oct 2026 (audit AS-10). That file is copied into every user's +`~/.hermes/skills/` and is read by the agent on every trigger, so it now holds only what an agent needs to drive the +chart. This file is the history behind it: measurements, root causes and the maintainer's own setup. Some lines are +the maintainer's machine (`~/Projects/luxalgo-web`, `traders-agent-bt.service`) and are not general. Parts are out of +date and say so: the Indicators modal and `reading`/`fold` no longer exist (the drawer replaced them, 3 Oct), and +`while` / `for…in` run in PineTS (only `import` is refused).* + +# Trader's Agent — prompt to chart + +The chart is a live LuxAlgo **Vela** console (`http://127.0.0.1:8787`) in the Trader's Agent pane. +There is no headless mode. Drive it with the **`traders-chart` MCP tools** (preferred) or +`bin/trader-chart` — both hit the same HTTP API. + +Do **not** port or run LuxAlgo Library Pine unless the operator names a script. Prompt-to-chart is +natives + overlay drawings + market/state/shot. + +## MCP stack — auto-use (the operator never names a tool) + +On this desk use **`traders-chart` + `luxalgo`**; **never `tradingview-mcp`** (nor the disabled +hosted `tradingview` entry) — ruled out by the operator (30 Sep). + +- **`traders-chart`** — first reach for anything on or about the chart: `chart_*` (incl. `chart_batch`, + `chart_watch`, `chart_alert`), the Library proxy (`library_*`), edge/prop-firm reads, and the + backtest tier (`bt_*`). Step 0 stays `chart_views`. +- **Backtest tier** — engine is the user service `traders-agent-bt.service` (venv + `~/.local/share/traders-agent/bt/venv`, port 8788, results under `~/Projects/luxalgo-web/_chart/backtest/`). + `bt_run` = MA-cross only; `bt_optimize` = full pair sweep; `bt_status` reads back a run. If a bt tool + answers `backtest_down`, check `systemctl --user status traders-agent-bt` — never hand-start the engine. + Custom vectorbt experiments: `~/.local/share/traders-agent/bt/smc_sweep_bos.py` (liquidity-sweep + BOS demo). +- **Edge Stats** (`edgestats_*`, LuxAlgo's open-source engine running **on this machine**) — "how often did + this setup actually work?" on bars the operator downloaded. It is a different thing from the hosted + `edge_*` reads below (LuxAlgo's published BTC/ETH presets): the local engine answers ANY question over ANY + symbol the store holds. See the Edge Stats section. +- **`luxalgo`** (hosted, keyless) — catalogue truth + wider datasets: `library_search`, + `library_get_source_code`, `library_list_*`, `library_taxonomy`, `propfirms_*`, `edge_*`, `trackers_*`. + Every call needs its 15–25-word third-person `context`. The hosted `edge_*` reads cover BTCUSDT + ETHUSDT only. + There are **no SMC/ICT strategies** in the Library (60 smc-ict *indicators*; the whole catalogue has + one `strategy()` script — moon-phases). +- **OAuth-gated**: `luxalgo_account`, `journal_*` error until `hermes mcp login luxalgo` runs in a + browser — never promise account/journal features. +- Session truth: `curl -s http://127.0.0.1:8787/api/health` → `mcp.connected`; `chart_views` for a live + pane. New MCP tools arrive only with a new session or `/reload-mcp`. + +## Step 0 — is anything listening? + +- `chart_views` first. 0 views → tell the operator to open the Trader's Agent row. Do not queue work + and report it as done. +- Stale frame: `chart_state` reports `build` + `viewer`. Compare with the console (`/api/build`). + If they disagree, `chart_reload` (once-per-view) then re-read. + +## Step 1 — read before you write + +- `chart_state` → symbol, timeframe, last, bars, **indicators on the chart**, build/viewer. +- `chart_caps` → actions **this page** can execute. +- `chart_palette` → colours the chart is wearing (do not guess from a screenshot). + +## Step 2 — put the chart where the analysis needs it + +- `chart_set_market` (symbol + timeframe). The answer already has last price and bars — do not follow + with `chart_state` just to confirm. +- Prefer the timeframe the operator asked for. + +## Step 3 — one study at a time + +1. Take extras off: `chart_remove_indicator(all=True)` for Vela natives the operator or agent added. + `chart_clear` only removes **our overlay + paint layer**, not studies added with `add`. +2. One native: `chart_add_indicator("ema")` **or** one overlay script: `chart_draw` (levels/lines) + **or** `chart_apply_pine` (plots that map to a Vela native). +3. `chart_shot` — say what the picture shows. +4. Only then the next study. + +Never stack "to save time". + +## Step 4 — prove it + +A tool answer is not a picture. `chart_shot` after every mutation. If `ok: false`, the chart did not +change — report that, never "done". + +## Prompt mapping (what to call) + +| Operator says | Tool | +|---|---| +| what symbol / what's on the chart | `chart_state` then maybe `chart_shot` | +| open / switch SOLUSDT 1h | `chart_set_market` | +| split the chart / 2 panes / 4 charts / "multipane" | `chart_set_layout` — no argument **reads** the grid | +| which indicators can this chart take | `chart_natives` — Vela's built-ins for this market, read from the frame | +| what is ON the chart right now | `chart_studies` — every reader, labelled; ask this before calling a chart clean | +| open the Indicators panel / star one / search the catalogue | `chart_indicators` — section `favorites`/`builtins`/`library`, `q`, `family` (one group of the catalogue; `all` clears), `reading SLUG`, `fold FAMILY[:on]`, `star`/`unstar KIND:ID`, `mount` | +| add EMA / MACD / supertrend | `chart_add_indicator` | +| remove indicators / clear studies | `chart_remove_indicator(all=True)` | +| draw PDH/PDL / lines / boxes | `chart_draw` (overlay). Not `chart_apply_pine` for a plain price level. | +| set / change a script's setting ("length 50", "hide the signals", "make the band wider") | `chart_pine_inputs` for the labels, then `chart_apply_pine` / `chart_draw` with `inputs={"Length": 50}` — do **not** edit the Pine source to change a default | +| screenshot | `chart_shot` | +| give the chart the whole screen / come back | `chart_fullscreen` (`on=False` to return) — the console's chrome steps aside and the page asks the browser for fullscreen | +| chart looks stale / old JS | `chart_reload` | +| chart is light/dark | `chart_palette` | +| how often does X happen / did that gap fill / weekday effect / opening-range break | `edgestats_query` (ask) or `edgestats_report` (a catalogue report) — then **read the guards before quoting a number** | +| what can I ask / which reports exist | `edgestats_presets`, `edgestats_fields` | +| show me / put it on screen / which sessions were those | `edgestats_show` (`ask` / `report` / `session`) | +| no data / "it says not installed" / load something to try | `edgestats_status` → `edgestats_setup` | + +## The grid (multi-pane) + +Vela's workspace is a real chart grid. `chart_set_layout(layout)` takes `1` (single), `2h` (2 side by +side), `2v` (2 stacked), `4`, `8`, or a custom `gx` (1–4 each); **no argument reads the +grid** (a read never writes). The answer carries the layout id and every cell's own symbol/timeframe — +quote those, not the id asked for. + +- A console built with `layout: false` sets `monoLayout`, and `setLayout()` is then a **permanent + no-op**: the tool answers truthfully that the page must boot with a preset. Do not call it "broken". +- A grid already in the page's state **wins over the boot preset**, so a change made from chat + survives a reload. +- Cells inherit the active cell's symbol — a wider grid is the same market on other timeframes until + something else is asked. +- `chart_shot` composites only the **visible** cells (`shotCells()` skips hidden hosts), so a capture + taken while a cell still fetches bars shows fewer panes than the grid has. Size is the tell: ~75 KB + one pane, ~167 KB two. +- The topbar's own **Layout** and **Sync** controls are the same `setLayout` call — never claim the + agent did something the operator's own picker cannot do. + +## What is actually on the chart + +`chart_state`'s `natives` list is **Vela's names only** — a script mounted from the Library (Run +PineTS / Add to chart) is not one of them, and it lives on the console's overlay/paint layer. A +natives-only report called a chart clean while CRT boxes and "Manipulation" labels sat on it (26 Sep). +Use `chart_studies`: one row per study, each labelled with the reader that saw it — `study` (the +console's own chip), `cell`, `overlay` (a Library run), `paint`, `native`. + +- `chart_remove_indicator(all=true)` (`trader-chart remove --all`) now reaches all of them: studies + through the chart's ledger, then the overlay/paint layer for script runs, and its answer names what + came off — `overlay cleared: 69/123/17 + 1 run(s)`. Verify with `chart_studies`, then `chart_shot`. +- Removing a **script by name** cannot be surgical: `ChartOverlay` is all-or-nothing. The door says so + instead of a bare "nothing removed"; the way out is `all` (or `clear`, which also wipes drawings). +- `chart_clear` removes our overlay + paint layer only, and reports what still remains. + +## The Indicators surface + +One modal in the chart's top bar (`⌗ Indicators`) holds both halves of "which indicator?":☆ +**Favourites**, **BUILT-INS** (Vela's natives for this market, read live from the frame — 76 on this +build) and **LIBRARY** (the 806-row LuxAlgo catalogue). `chart_indicators` is +the door: `section`, `q`, `family`, `reading`, `fold`, `star`/`unstar "native:supertrend"`/ +`"library:order-blocks"`, `mount "native:ema"`, `show=False` to close. Its answer carries the rows the +grid **painted**, the family groups it drew (name + count + folded) and the reading it left open — +quote those. + +### The catalogue is grouped, and every card has a reading (27 Sep) + +- `/api/catalogue` walks all nine pages (`sort=family`, **13.5 s cold, 42 ms warm**) plus the 853 + concepts, keeps the answer in `console/agents/catalogue.json` for 12 h, and hands the page ONE + payload. The paged loader is gone: a page of sixty cannot count a family it has not paged to, and + it could race a section change. Filtering now happens in memory, so `q` cannot race `section`. +- **Half the catalogue has no family of its own.** Those rows are the older single-name indicators + (`percent-b`, `1-2-3-reversal`); LuxAlgo files them as *concepts*, which carry a **family** AND a + **cluster**. Without the concept walk the rail opens with a 414-row "Unfiled"; with it, 390 of those + 414 land in real groups (Trend 108, Volume & Flow 96, Momentum 89, SMC / ICT 74, …, Unfiled 24). + Never present "Unfiled" as the catalogue's own opinion of those rows — it is ours. +- Picking a family re-groups the grid by that family's **clusters** ("Moving-average lineage", + "Candlestick catalog"); a cluster named "Other" is a remainder and is painted last. +- `reading: "mlma"` unfolds that card's own write-up (`row.description`, 806/806 rows have one) and + paints the card even if its group was beyond the slice; `fold: "trend"` folds a group away + (`fold: "trend/Other"`, `foldOn: false`), `fold: "trend", foldOn: true` opens it again. +- `bin/trader-chart indicators --family trend --reading mlma --fold trend:on` is the CLI spelling. + An omitted `--family` **clears** the filter (same rule as `--q`) — a filter left over from an earlier + call must not silently narrow the next one. +- A `