mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
feat(ruview): MCP Apps console widget, HTTP transport, terminal UI (ADR-375)
- ui://ruview/console-v1.html: one self-contained widget for MCP Apps hosts
and ChatGPT (ui.resourceUri + openai/outputTemplate). Renders node
streams, device scans and doctor results; Refresh re-runs the tool via
window.openai.callTool or an MCP Apps tools/call. Empty CSP allowlist, no
network, textContent only, parent-frame messages only.
- MCP: protocol negotiation (2025-06-18/2025-03-26/2024-11-05),
structuredContent + compact JSON text (17-33% smaller, MEASURED),
resources/list|read, tool titles, canonical tool name in result _meta.
Transport-neutral handleRpc + shared FIFO dispatcher.
- `mcp start --http`: Streamable HTTP for ChatGPT/remote clients. Bearer or
secret-path token (constant-time), loopback default, Origin allowlist,
256 KiB cap, no batches, write grants never honoured over HTTP.
- CLI: formatted TTY output (tables, rate bars, alerts first, NO_COLOR),
JSON for pipes/--json, capture countdown, `esp32 --watch` with per-node
sparklines, lazy module loading (--help ~20% faster, MEASURED).
- monitor detects Ameba CSI report-buffer starvation ("lack of csi buf"),
the root cause of the Realtek heartbeat-only stall seen live.
- Size budget 320 -> 384 KiB (package ~339 kB).
Verified: 160 tests + 83 security tests; headless-Chrome e2e of the MCP
Apps flow against live boards (refresh round trip 108 -> 105 packets);
HTTP security probes; clean tarball smoke (stdio + HTTP).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
1 parent
3cd360f9e2
commit
45796e4e3e
19 files changed
+1497
-422
No files matched your search
@@ -43,8 +43,9 @@ jobs:
|
||||
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
|
||||
# still runtime-dependency-free. ADR-369..374 add the SDK, firmware
|
||||
# flashing, training runner/gate, doctor, device access, and SSH
|
||||
# hosts: 320 KiB reviewed ceiling.
|
||||
unpacked_budget: 327680
|
||||
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
|
||||
# Streamable HTTP transport and the terminal UI: 384 KiB reviewed ceiling.
|
||||
unpacked_budget: 393216
|
||||
- dir: harness/homecore
|
||||
build: false
|
||||
publishable: true
|
||||
|
||||
@@ -105,8 +105,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
case "${{ inputs.package }}" in
|
||||
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
|
||||
# flashing, training, doctor, device access, SSH hosts; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=327680 ;;
|
||||
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
|
||||
# widget, HTTP transport, terminal UI; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=393216 ;;
|
||||
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
|
||||
harness/homecore) export UNPACKED_BUDGET=180000 ;;
|
||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, mcp, mcp-apps, chatgpt, ui, cli, http, least-authority, performance
|
||||
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution and size
|
||||
budget), ADR-369 (operator surface and SDK), ADR-373 (device access),
|
||||
ADR-374 (remote hosts)
|
||||
|
||||
## Context
|
||||
|
||||
The harness exposes one tool registry through the CLI, an MCP stdio server and
|
||||
an SDK (ADR-369). Hardware testing of the device tools (ADR-373 Amendment 2)
|
||||
showed three gaps in how results reach people and models:
|
||||
|
||||
- **MCP results were text only.** Every tool returned pretty-printed JSON in a
|
||||
text block, with no `structuredContent`. The server declared protocol
|
||||
`2024-11-05`, and `resources/list` was always empty, so MCP Apps hosts and
|
||||
ChatGPT had nothing to render.
|
||||
- **ChatGPT could not connect.** ChatGPT connectors reach MCP servers over
|
||||
HTTPS. The harness spoke stdio only.
|
||||
- **The CLI printed raw JSON to humans.** A live capture of two nodes was a
|
||||
1.2 kB JSON object in the terminal, with alerts such as heartbeat-only
|
||||
senders easy to miss.
|
||||
|
||||
Reference implementations for the target hosts (the `web-based-chatgpt-mcp-starter`
|
||||
and `signal-to-swarm` sites) declare a `ui://` HTML resource and point UI tools
|
||||
at it through both the MCP Apps key (`_meta.ui.resourceUri`) and the ChatGPT
|
||||
keys (`openai/outputTemplate`, `openai/widgetAccessible`).
|
||||
|
||||
The package must stay free of runtime dependencies (ADR-263) and inside a
|
||||
reviewed size budget (ADR-265).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One console widget, two host families
|
||||
|
||||
`src/ui/console-widget.js` holds one self-contained HTML resource,
|
||||
`ui://ruview/console-v1.html`, with mimeType `text/html;profile=mcp-app`.
|
||||
|
||||
- **Tools.** `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor`
|
||||
carry `_meta.ui.resourceUri`, `ui/resourceUri`, `openai/outputTemplate` and
|
||||
`openai/widgetAccessible`.
|
||||
- **Data intake.** The widget reads results from either host family:
|
||||
- MCP Apps: a `ui/initialize` handshake over `postMessage`, then
|
||||
`ui/notifications/tool-input` and `ui/notifications/tool-result`;
|
||||
- ChatGPT: `window.openai.toolOutput` and the `openai:set_globals` event.
|
||||
- **Refresh.** A Refresh button re-runs the same tool with the same
|
||||
arguments, through `window.openai.callTool` or an MCP Apps `tools/call`
|
||||
request.
|
||||
- **Safety.**
|
||||
- The resource declares an empty CSP allowlist and makes no network
|
||||
requests.
|
||||
- Every value reaches the DOM through `textContent`; there is no
|
||||
`innerHTML`.
|
||||
- Messages are accepted only from the parent frame.
|
||||
- A test pins all of this.
|
||||
- **Design.** The visual language follows the reference sites: dark ground,
|
||||
`#d0ff72` accent, monospace labels, and plain wording about what each value
|
||||
is. Device vitals are labelled "device-reported (not validated)". Kernel
|
||||
outputs carry the "no reference measurement" note, and SYNTHETIC frames
|
||||
raise a warning.
|
||||
|
||||
### 2. Structured results and protocol negotiation
|
||||
|
||||
- **Protocol negotiation.** `initialize` negotiates `2025-06-18`,
|
||||
`2025-03-26` or `2024-11-05`, and advertises `resources`.
|
||||
- **Result shape.** `tools/call` returns `structuredContent` (always an
|
||||
object), a text block with the same JSON in compact form, and
|
||||
`_meta['ruview/tool']` set to the canonical tool name (so an aliased call
|
||||
still refreshes correctly).
|
||||
- **Compact text.** Compact rather than pretty-printed JSON cuts each result
|
||||
by 17–33% (MEASURED on this host: guidance 17.5%, device scan 20.7%, live
|
||||
capture 33.4%). Clients that read only `content` still receive the
|
||||
complete result.
|
||||
- **Titles.** Tools gain `title` and `annotations.title`.
|
||||
|
||||
`handleRpc` is transport-neutral, and `createDispatcher` holds the FIFO tool
|
||||
chain both transports share (ADR-263 O2).
|
||||
|
||||
### 3. Streamable HTTP transport
|
||||
|
||||
`ruview mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]`
|
||||
starts `src/mcp-http.js`.
|
||||
|
||||
- **Authentication on every request.** A request must carry either
|
||||
`Authorization: Bearer <token>`, or the secret-path form `/mcp/<token>`.
|
||||
The second exists because ChatGPT connectors cannot send a static header.
|
||||
- Tokens are compared in constant time and must be at least 16 characters.
|
||||
- The token comes from `RUVIEW_MCP_TOKEN`, never argv. If unset, one is
|
||||
generated and printed once to stderr.
|
||||
- **Least authority.**
|
||||
- It binds to loopback by default.
|
||||
- Browser `Origin`s are refused unless allowlisted (DNS-rebinding defence).
|
||||
Server-to-server clients send no Origin.
|
||||
- `workspace-write` and `hardware-write` are never honoured over HTTP, even
|
||||
when granted in `RUVIEW_MCP_GRANTS`. Flashing and calibration stay
|
||||
stdio/CLI-only.
|
||||
- **Protocol surface.**
|
||||
- POST only; single JSON responses with no SSE stream and no sessions.
|
||||
- Batches and unsupported `MCP-Protocol-Version` values are refused.
|
||||
- Bodies are capped at 256 KiB.
|
||||
- Notifications return 202.
|
||||
- `/healthz` reports only the name and version.
|
||||
|
||||
Exposing the port to ChatGPT (a tunnel or reverse proxy with TLS) is the
|
||||
operator's choice. The startup banner says so and warns on a non-loopback
|
||||
bind.
|
||||
|
||||
### 4. Terminal UI
|
||||
|
||||
`src/cli-ui.js` renders results for a TTY:
|
||||
- aligned tables, with unicode rate bars;
|
||||
- colour that respects `NO_COLOR` and `TERM=dumb`;
|
||||
- alerts and fixes ahead of the data;
|
||||
- a stderr countdown for timed captures.
|
||||
|
||||
Pipes and `--json` keep receiving exact JSON. `ruview call` always prints
|
||||
JSON, because remote hosts parse it.
|
||||
|
||||
`ruview esp32 --watch [--seconds 3]` redraws repeated capture windows with a
|
||||
per-node rate sparkline.
|
||||
|
||||
`ruview monitor` now flags Ameba CSI report-buffer starvation (`lack of csi
|
||||
buf` / `csi buf not enough`). This is the root cause of the Realtek
|
||||
heartbeat-only stall, observed live: 399 starvation lines in 8 s, 0 CSI.
|
||||
|
||||
### 5. Startup
|
||||
|
||||
- **Launcher split.** `bin/cli.js` is a thin launcher, and `src/cli.js` loads
|
||||
the tool registry, doctor, brain, host adapters and transports on demand.
|
||||
- **Measured effect.**
|
||||
- `--help`/`--version`/`skills` skip the registry: about 20% faster, median
|
||||
of 9 interleaved runs against the previous build on this host (MEASURED).
|
||||
- The MCP path needs the whole registry to list tools, so its start time is
|
||||
unchanged within noise.
|
||||
- **Compile cache rejected.** Node's on-disk compile cache
|
||||
(`module.enableCompileCache`) was evaluated: module load was 27.7 ms with
|
||||
it and 28.5 ms without (25 interleaved runs, MEASURED). That is within
|
||||
noise, so it was not adopted.
|
||||
|
||||
### 6. Size budget
|
||||
|
||||
The additions cost about 38 kB:
|
||||
- widget, 12.6 kB;
|
||||
- HTTP transport;
|
||||
- terminal UI;
|
||||
- CLI split.
|
||||
|
||||
The `@ruvnet/ruview` unpacked budget rises from 320 KiB to 384 KiB in
|
||||
`npm-packages.yml` and `ruview-npm-release.yml`. The package is about 338 kB.
|
||||
|
||||
## Evidence
|
||||
|
||||
All on Windows 11 x64, Node 24, with an ESP32-C6 (CP210x, COM16) and an
|
||||
RTL8721Dx (PL2303GC, COM10) attached. All results MEASURED unless noted.
|
||||
|
||||
**Browser e2e.** A real MCP Apps host flow in headless Chrome:
|
||||
- The host lists tools and reads the `ui://` resource over HTTP MCP, then
|
||||
loads it in a sandboxed iframe (`allow-scripts` only).
|
||||
- It answers `ui/initialize` and delivers a live `ruview_esp32_capture`
|
||||
result.
|
||||
- The widget's Refresh button triggered a second real capture through the
|
||||
host (108 → 105 packets).
|
||||
- The devices and doctor views rendered, in dark and light themes. The only
|
||||
console error was the test host's own missing favicon.
|
||||
|
||||
**Security probes** against the live HTTP server:
|
||||
- no token: 401;
|
||||
- wrong token: 401;
|
||||
- foreign Origin: 403;
|
||||
- GET: 405;
|
||||
- `ruview_node_flash` with `hardware-write` granted in the environment:
|
||||
`authority_denied`.
|
||||
|
||||
**Tests.**
|
||||
- `npm test`: 160 pass, 1 skip.
|
||||
- `test:security`: 83 pass, 1 skip.
|
||||
- New suites: `mcp-apps.test.mjs` (protocol, metadata, widget safety, HTTP
|
||||
auth/origin/size/write denial) and `cli-ui.test.mjs` (renderers, colour
|
||||
rules, the starvation detector run against a stand-in serial module).
|
||||
|
||||
**Tarball smoke** from a clean install:
|
||||
- CLI and SDK work;
|
||||
- stdio MCP negotiates `2025-06-18` and serves the widget and structured
|
||||
results;
|
||||
- HTTP MCP lists 22 tools, 3 with `ui://` metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
- MCP Apps hosts and ChatGPT render node streams, device scans and
|
||||
diagnostics instead of raw JSON, and can refresh them in place.
|
||||
- Remote MCP clients can use the read tools over authenticated HTTP. Write
|
||||
tools remain local by construction.
|
||||
- Each tool result costs fewer model tokens.
|
||||
- The widget is a second place where result shapes are interpreted. Changes
|
||||
to the capture, devices or doctor result shapes must update it, and its
|
||||
version suffix (`console-v1`) changes when the contract does.
|
||||
- Host behaviour for MCP Apps and ChatGPT keys is followed as documented by
|
||||
the reference implementations. A live ChatGPT connector session (public
|
||||
HTTPS URL) has not been run yet: CLAIMED, pending.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
|
||||
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
|
||||
- [ADR-373: Host device access layer — ESP32, mmWave, LiDAR](ADR-373-host-device-access-layer.md)
|
||||
- [ADR-372: Structured debugging doctor](ADR-372-structured-debugging-doctor.md)
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
|
||||
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
|
||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
||||
"README.md": "1b09064f3e1a7082b40078351df8758cc1bcdb3ea587eb0305692271791c7d7e",
|
||||
"bin/cli.js": "95cbb40d5780f53f73772b1106c3dbb23d6e194035d342900b193a7bf35ba462",
|
||||
"README.md": "cdce362447f71e79667d8293569ba63ec251e15010aa3d01958f66fbe3bb54ec",
|
||||
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
|
||||
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
|
||||
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
|
||||
@@ -32,7 +32,7 @@
|
||||
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
|
||||
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
|
||||
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
|
||||
"package.json": "5093f21e692d9f8e4ca4ba83099ee0d12bc2ae875177671bb38c1a9d88c2e6e7",
|
||||
"package.json": "121c380a8e61ffe35dd3fad3d10b74c388de605eb64c1f1195979a84a2975a9d",
|
||||
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
|
||||
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
|
||||
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
|
||||
@@ -44,7 +44,9 @@
|
||||
"skills/train-pose.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
|
||||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
|
||||
"src/devices/esp32.js": "59c445b9d36188405d075953941a0abf261ea3ee8c0523ffe71ac1d00fcccbff",
|
||||
"src/cli-ui.js": "42d9c616d5363cb2dffc8ac00dd11fef2b3e9db548dc60851dbc827ee0b7f22d",
|
||||
"src/cli.js": "ad331b361200ebf56bdf9e9aa17e1ddb4ccb01e99eecd30e409364cf22d9f5f5",
|
||||
"src/devices/esp32.js": "ae389193430fc85601b46aef9281ad91631a1cb9bf4d2f247dfb02e2a4491c4d",
|
||||
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
|
||||
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
|
||||
"src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be",
|
||||
@@ -58,7 +60,8 @@
|
||||
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
|
||||
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
|
||||
"src/kernel.js": "f2d7cf59cc26a499764ed09b7af391e5cd37566ada5c2be45c4586ecba8b128b",
|
||||
"src/mcp-server.js": "8b2ee4b939b25c1b1f507b295a43a2ebad852b8bac9d31af9bf7fb39b181c12e",
|
||||
"src/mcp-http.js": "b3dc3ed53a48e69a39e2aa48382251b8ca2a0635c7c25858c2f0b7e7a5314fb4",
|
||||
"src/mcp-server.js": "0e8817a919a3cde13bc57afa190498dfb655f29217b186b4ba500790c41a220c",
|
||||
"src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2",
|
||||
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
|
||||
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
|
||||
@@ -67,10 +70,11 @@
|
||||
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
|
||||
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
|
||||
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
|
||||
"src/tools.js": "cd72ac87e85e0bfbef7af11c98d03c4cb63a5bdd5da760460e9df8de9468bc34",
|
||||
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956"
|
||||
"src/tools.js": "9207389f5ddcbc2889cd4ac1518239a59a94fd3d2c3598b3adece48afcd260e6",
|
||||
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
|
||||
"src/ui/console-widget.js": "68cb7a93f22a5a6a0a7640984b86b69ee0b9c722e39f5c8533de79ba6a0b15ba"
|
||||
},
|
||||
"filesDigest": "b8c8a261e647535ccfafc54783fc25d7de961eab584b3365c9457f9cf9cfe46f",
|
||||
"filesDigest": "f6f1cec7894b73529ce589696e24033f0e69e0a90b11bbcc0847e23af2d014b6",
|
||||
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
|
||||
"developmentPins": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
36990fd5c552e61b410ac8771d6a55769e8fcc9f695a1710b107a57e409a27de manifest.json
|
||||
6e4a541fc2ca74b2c19de18ed6b7a5b1206d012257e264c3521a80b4379388c4 manifest.json
|
||||
@@ -242,6 +242,28 @@ The bundled `.claude/settings.json` registers the `ruview` MCP server
|
||||
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
|
||||
`npx @ruvnet/ruview install --host claude-code`.
|
||||
|
||||
## ChatGPT and MCP Apps (ADR-375)
|
||||
|
||||
`ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor` render in a
|
||||
self-contained console widget (`ui://ruview/console-v1.html`) in ChatGPT and
|
||||
other MCP Apps hosts, with a Refresh button that re-runs the tool. Results also
|
||||
carry `structuredContent`.
|
||||
|
||||
```bash
|
||||
# MCP over HTTP: loopback, token-authenticated, read tools only
|
||||
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
|
||||
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview mcp start --http --port 8790
|
||||
```
|
||||
|
||||
Clients that can send headers use `Authorization: Bearer <token>` on
|
||||
`http://127.0.0.1:8790/mcp`. ChatGPT connectors cannot, so expose the port over
|
||||
HTTPS (a tunnel or reverse proxy) and add `https://<host>/mcp/<token>` as the
|
||||
connector URL. Write grants (`hardware-write`, `workspace-write`) are never
|
||||
honoured over HTTP: flashing and calibration stay on the CLI and stdio.
|
||||
|
||||
In a terminal, commands print a formatted view; pipes and `--json` print JSON.
|
||||
`ruview esp32 --watch` redraws live capture windows with per-node trends.
|
||||
|
||||
## Hosts
|
||||
|
||||
Claude Code and Codex are implemented directly and tested with the local,
|
||||
|
||||
+5
-313
@@ -1,322 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
|
||||
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
|
||||
//
|
||||
// The `ruview.*` tools (onboard/verify/claim-check/…) and local host adapters are
|
||||
// pure Node and run with zero runtime dependencies.
|
||||
// `npx ruview` launcher. The logic lives in src/cli.js, which loads modules on
|
||||
// demand (ADR-375); `run` is re-exported for tests and embedders.
|
||||
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { argv } from 'node:process';
|
||||
import { runTool, listTools, findRepoRoot, OPERATOR_DEPS, isRemoteReadOnlyTool, resolveToolName } from '../src/tools.js';
|
||||
import { saveHost } from '../src/remote.js';
|
||||
import { formatDoctor, runDoctor, DOCTOR_GROUPS } from '../src/doctor.js';
|
||||
import { getHost } from '../src/hosts/index.js';
|
||||
import { makeProposal, searchBrain, verifyBrain } from '../src/brain.js';
|
||||
import { run } from '../src/cli.js';
|
||||
|
||||
const NAME = 'ruview';
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const SKILLS_DIR = join(ROOT, 'skills');
|
||||
|
||||
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
|
||||
const VERB_TO_TOOL = {
|
||||
onboard: 'ruview_onboard',
|
||||
verify: 'ruview_verify',
|
||||
'claim-check': 'ruview_claim_check',
|
||||
calibrate: 'ruview_calibrate',
|
||||
monitor: 'ruview_node_monitor',
|
||||
flash: 'ruview_node_flash',
|
||||
guidance: 'ruview_guidance',
|
||||
spaces: 'ruview_spaces_list',
|
||||
kernel: 'ruview_kernel_selftest',
|
||||
'flash-plan': 'ruview_firmware_plan',
|
||||
ports: 'ruview_firmware_ports',
|
||||
train: 'ruview_train',
|
||||
'train-plan': 'ruview_train_plan',
|
||||
'train-gate': 'ruview_train_gate',
|
||||
devices: 'ruview_devices_scan',
|
||||
esp32: 'ruview_esp32_capture',
|
||||
mmwave: 'ruview_mmwave_read',
|
||||
lidar: 'ruview_lidar_read',
|
||||
};
|
||||
|
||||
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
|
||||
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
|
||||
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames']);
|
||||
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze']);
|
||||
|
||||
function toSchemaArgs(flags) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
const k = key.replaceAll('-', '_');
|
||||
if (NUMERIC_FLAGS.has(k)) out[k] = Number(value);
|
||||
else if (BOOLEAN_FLAGS.has(k)) out[k] = value === true || value === 'true';
|
||||
else if (k === 'train_subjects' || k === 'test_subjects') out[k] = String(value).split(',').filter(Boolean);
|
||||
else out[k] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
|
||||
|
||||
function listSkills() {
|
||||
if (!existsSync(SKILLS_DIR)) return [];
|
||||
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
|
||||
}
|
||||
|
||||
async function doctor(flags) {
|
||||
const groups = typeof flags.group === 'string' ? flags.group.split(',').map((g) => g.trim()).filter(Boolean) : undefined;
|
||||
const unknown = (groups || []).filter((g) => !DOCTOR_GROUPS.includes(g));
|
||||
if (unknown.length) { console.error(`doctor: unknown group(s) ${unknown.join(', ')}; valid: ${DOCTOR_GROUPS.join(', ')}`); return 2; }
|
||||
const report = await runDoctor({
|
||||
groups,
|
||||
port: typeof flags.port === 'string' ? flags.port : undefined,
|
||||
probe_port: flags.probe === true,
|
||||
sensing_url: typeof flags.url === 'string' ? flags.url : undefined,
|
||||
}, OPERATOR_DEPS);
|
||||
if (flags.json === true) pjson(report);
|
||||
else console.log(formatDoctor(report));
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
function help() {
|
||||
console.log(`Usage: ${NAME} <command> [options]
|
||||
|
||||
Operator tools:
|
||||
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
|
||||
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
|
||||
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
|
||||
calibrate --step baseline|enroll|train-room|room-watch
|
||||
monitor --port COM8 [--seconds 12] [--baud 1500000] assert CSI is flowing on a node (no reset on open)
|
||||
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
|
||||
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
|
||||
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
|
||||
kernel [--backend wasm|napi|auto] [--seconds 60] SYNTHETIC self-test of @ruvnet/ruview-kernel (optional)
|
||||
|
||||
Firmware (ADR-370, cross-platform esptool):
|
||||
ports list serial ports (pyserial)
|
||||
flash-plan --port <p> --bundle <dir> [--variant s3-8mb|s3-4mb|c6] verify + print the plan
|
||||
flash --port <p> --bundle <dir> [--variant ...] [--baud 460800] --confirm
|
||||
[--boot-log-seconds 15] [--allow-unverified] write flash, then capture boot evidence
|
||||
|
||||
Devices (ADR-373) — run on the machine the hardware is attached to:
|
||||
devices classify USB serial devices (ESP32, mmWave, RPLIDAR)
|
||||
esp32 [--udp-port 5005] [--seconds 10] [--bind 0.0.0.0] receive + summarize ESP32 / Realtek RAC1 node UDP streams
|
||||
[--analyze [--node-id N] [--backend wasm|napi|auto]] run live CSI through @ruvnet/ruview-kernel
|
||||
mmwave --port <p> [--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout
|
||||
lidar --source rplidar --port <p> [--baud 115200] RPLIDAR scan summary
|
||||
lidar --source iphone --url ws://HOST:8787/ws/lidar iPhone LiDAR relay (token: RUVIEW_LIDAR_TOKEN)
|
||||
|
||||
Remote hosts (ADR-374) — SSH, read-only tools only:
|
||||
hosts add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z]
|
||||
hosts list
|
||||
hosts run --host pi --tool ruview_devices_scan [--args-json '{"seconds":10}']
|
||||
call <tool> [--read-only] [--args-json '{...}'] generic schema-validated tool call
|
||||
|
||||
Training (ADR-371):
|
||||
train-plan [--mode pose-smoke|pose|room] [...] resolve the command, run nothing
|
||||
train --mode pose-smoke|pose|room [--config f] [--data-dir d] [--checkpoint-dir d]
|
||||
[--enrollment f] [--output f] [--samples 64] [--cuda] --confirm
|
||||
train-gate --file report.json | --model-score .59 --baseline-score .50 --split chronological ...
|
||||
|
||||
Harness:
|
||||
doctor [--json] [--group a,b] [--port P [--probe]] [--url http://host:port]
|
||||
structured diagnostics with fixes (ADR-372)
|
||||
skills list bundled skills
|
||||
skill <name> print a skill playbook
|
||||
mcp start run the ruview.* MCP server (stdio)
|
||||
install --host <h> project the harness config into the current repo
|
||||
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
|
||||
brain search --query "..." | verify | propose
|
||||
--version | --help
|
||||
|
||||
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
|
||||
function parseFlags(rest) {
|
||||
const f = {};
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (a.startsWith('--')) {
|
||||
const eq = a.indexOf('=');
|
||||
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
|
||||
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
|
||||
else { f[a.slice(2)] = true; }
|
||||
}
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
export async function run(args) {
|
||||
const cmd = args[0] ?? 'onboard';
|
||||
const rest = args.slice(1);
|
||||
const flags = parseFlags(rest);
|
||||
|
||||
// Direct tool verbs.
|
||||
if (VERB_TO_TOOL[cmd]) {
|
||||
const toolArgs = { ...flags };
|
||||
if (cmd === 'claim-check') {
|
||||
if (flags.file) {
|
||||
toolArgs.text = readFileSync(flags.file, 'utf8');
|
||||
delete toolArgs.file;
|
||||
}
|
||||
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
|
||||
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
|
||||
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
|
||||
return 2;
|
||||
}
|
||||
const res = await runTool('ruview_claim_check', toolArgs);
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
|
||||
if (cmd === 'monitor' && flags.baud) toolArgs.baud = Number(flags.baud);
|
||||
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
|
||||
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
|
||||
if (cmd === 'kernel' && flags.seconds !== undefined) toolArgs.seconds = Number(flags.seconds);
|
||||
if (SNAKE_VERBS.has(cmd)) {
|
||||
let snake = toSchemaArgs(flags);
|
||||
if (cmd === 'train-gate' && typeof flags.file === 'string') {
|
||||
snake = JSON.parse(readFileSync(flags.file, 'utf8'));
|
||||
}
|
||||
const res = await runTool(VERB_TO_TOOL[cmd], snake, { source: 'cli' });
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'spaces') {
|
||||
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
|
||||
delete toolArgs['credentials-path'];
|
||||
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
|
||||
}
|
||||
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' });
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'doctor': return doctor(flags);
|
||||
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
|
||||
case 'skill': {
|
||||
const n = rest[0];
|
||||
const p = n && join(SKILLS_DIR, `${n}.md`);
|
||||
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
|
||||
console.log(readFileSync(p, 'utf8'));
|
||||
return 0;
|
||||
}
|
||||
case 'mcp': {
|
||||
if (rest[0] === 'start' || rest[0] === undefined) {
|
||||
const { startMcpServer } = await import('../src/mcp-server.js');
|
||||
startMcpServer();
|
||||
return new Promise(() => {}); // run until stdin closes
|
||||
}
|
||||
console.error('Usage: ruview mcp start'); return 2;
|
||||
}
|
||||
case 'agent': {
|
||||
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
|
||||
const hostName = String(flags.host || 'codex');
|
||||
const prompt = String(flags.prompt || '');
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
|
||||
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
|
||||
const allowWrite = flags['allow-write'] === true;
|
||||
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
|
||||
try {
|
||||
const result = await getHost(hostName).run({
|
||||
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
|
||||
});
|
||||
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
|
||||
return 0;
|
||||
} catch (error) {
|
||||
pjson({ ok: false, host: hostName, error: error.message });
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'brain': {
|
||||
const action = rest[0] || 'search';
|
||||
if (action === 'search') {
|
||||
const query = String(flags.query || '');
|
||||
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
|
||||
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
|
||||
}
|
||||
if (action === 'verify') {
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
|
||||
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
if (action === 'propose') {
|
||||
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview brain search|verify|propose'); return 2;
|
||||
}
|
||||
case 'install': {
|
||||
const host = flags.host || 'claude-code';
|
||||
if (!['claude-code', 'codex'].includes(host)) {
|
||||
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
const adapter = getHost(host);
|
||||
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
|
||||
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
|
||||
console.log('Skills:', listSkills().join(', '));
|
||||
return 0;
|
||||
} catch {
|
||||
console.error(`Host adapter "${host}" is unavailable.`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'tools': pjson(listTools()); return 0;
|
||||
case 'call': {
|
||||
// Generic, schema-validated tool call (ADR-374). `--read-only` is what
|
||||
// remote hosts receive: it refuses anything but read-only tools.
|
||||
const name = resolveToolName(rest[0] || '');
|
||||
if (!name) { console.error('Usage: ruview call <tool> [--read-only] [--args-json \'{...}\']'); return 2; }
|
||||
if (flags['read-only'] === true && !isRemoteReadOnlyTool(name)) {
|
||||
pjson({ ok: false, reason: 'remote_tool_not_allowed', name }); return 1;
|
||||
}
|
||||
let toolArgs = {};
|
||||
if (typeof flags['args-json'] === 'string') {
|
||||
try { toolArgs = JSON.parse(flags['args-json']); } catch { pjson({ ok: false, reason: 'invalid_arguments', errors: ['--args-json is not valid JSON'] }); return 2; }
|
||||
}
|
||||
const res = await runTool(name, toolArgs, { source: 'cli' });
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
case 'hosts': {
|
||||
const action = rest[0] || 'list';
|
||||
if (action === 'list') { const res = await runTool('ruview_host_list', {}, { source: 'cli' }); pjson(res); return res.ok ? 0 : 1; }
|
||||
if (action === 'add') {
|
||||
try {
|
||||
const out = saveHost({ name: flags.name, ssh: flags.ssh, port: flags.port ? Number(flags.port) : undefined, version: flags.version, description: flags.description });
|
||||
pjson({ ok: true, ...out, next: `Verify the host key once: ssh ${out.host.ssh} true` }); return 0;
|
||||
} catch (error) { pjson({ ok: false, reason: error.reason || 'invalid_host', detail: error.message }); return 2; }
|
||||
}
|
||||
if (action === 'run') {
|
||||
let toolArgs = {};
|
||||
if (typeof flags['args-json'] === 'string') {
|
||||
try { toolArgs = JSON.parse(flags['args-json']); } catch { console.error('--args-json is not valid JSON'); return 2; }
|
||||
}
|
||||
const tool = resolveToolName(String(flags.tool || '')) || String(flags.tool || '');
|
||||
const res = await runTool('ruview_host_run', { host: String(flags.host || ''), tool, args: toolArgs }, { source: 'cli' });
|
||||
pjson(res); return res.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview hosts list | add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z] | run --host pi --tool ruview_devices_scan [--args-json {...}]');
|
||||
return 2;
|
||||
}
|
||||
case '--version': case '-v': {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
console.log(pkg.version); return 0;
|
||||
}
|
||||
case '--help': case '-h': return help();
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
export { run };
|
||||
|
||||
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
|
||||
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs test/kernel.test.mjs test/firmware.test.mjs test/training.test.mjs test/devices.test.mjs test/csi-nodes.test.mjs test/remote.test.mjs",
|
||||
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs test/kernel.test.mjs test/firmware.test.mjs test/training.test.mjs test/devices.test.mjs test/csi-nodes.test.mjs test/remote.test.mjs test/mcp-apps.test.mjs test/cli-ui.test.mjs",
|
||||
"doctor": "node ./bin/cli.js doctor",
|
||||
"mcp": "node ./bin/cli.js mcp start",
|
||||
"brain:verify": "node ./bin/cli.js brain verify",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Terminal rendering for the CLI (ADR-375). Used only when stdout is a TTY and
|
||||
// `--json` is absent; pipes and scripts keep receiving exact JSON. Respects
|
||||
// NO_COLOR and TERM=dumb. Pure functions of (tool, result) so tests can pin
|
||||
// the output byte-for-byte.
|
||||
|
||||
const ESC = '\x1b[';
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
export function colorEnabled(stream = process.stdout, env = process.env) {
|
||||
return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
|
||||
}
|
||||
|
||||
export function createStyle(on) {
|
||||
const wrap = (code) => (s) => (on ? `${ESC}${code}m${s}${ESC}0m` : String(s));
|
||||
return { acid: wrap('38;5;155'), muted: wrap('38;5;245'), warn: wrap('38;5;221'), bad: wrap('38;5;203'), bold: wrap('1') };
|
||||
}
|
||||
|
||||
const visible = (s) => String(s).replace(ANSI_RE, '').length;
|
||||
const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - visible(s)));
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(v > 0 && v < 0.01 ? 2 : 1)}%` : '—');
|
||||
const BARS = '▁▂▃▄▅▆▇█';
|
||||
|
||||
/** Unicode bar of `width` cells for a 0..1 fraction. */
|
||||
export function bar(frac, width = 10) {
|
||||
const f = Math.max(0, Math.min(1, Number(frac) || 0));
|
||||
const full = Math.floor(f * width);
|
||||
return '█'.repeat(full) + (full < width ? (f * width - full >= 0.5 ? '▌' : ' ') : '') + ' '.repeat(Math.max(0, width - full - 1));
|
||||
}
|
||||
|
||||
/** Sparkline of a numeric series (min..max scaled). */
|
||||
export function sparkline(values) {
|
||||
const v = values.filter((x) => typeof x === 'number' && Number.isFinite(x));
|
||||
if (!v.length) return '';
|
||||
const lo = Math.min(...v); const hi = Math.max(...v);
|
||||
return v.map((x) => BARS[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
|
||||
}
|
||||
|
||||
/** Align rows (arrays of cells) into columns. */
|
||||
export function table(rows, style, gap = 2) {
|
||||
const widths = [];
|
||||
for (const row of rows) row.forEach((c, i) => { widths[i] = Math.max(widths[i] || 0, visible(c)); });
|
||||
return rows.map((row, r) => {
|
||||
const line = row.map((c, i) => (i === row.length - 1 ? String(c) : pad(c, widths[i] + gap))).join('').trimEnd();
|
||||
return r === 0 ? style.muted(line) : line;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function head(view, r, s) {
|
||||
const measured = r.evidence && /^MEASURED/.test(r.evidence);
|
||||
const status = r.ok === false
|
||||
? s.bad(`✖ ${String(r.reason || 'failed').replace(/_/g, ' ')}`)
|
||||
: measured ? s.acid('● MEASURED') : s.acid('✔ ok');
|
||||
return `${s.muted(`RUVIEW / ${view}`)} ${status}`;
|
||||
}
|
||||
|
||||
function failureLines(r, s) {
|
||||
if (r.ok !== false) return [];
|
||||
const out = [];
|
||||
for (const k of ['detail', 'remedy', 'hint']) if (r[k]) out.push(`${s.muted(k === 'remedy' ? 'fix' : k)} ${r[k]}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderCapture(r, s, history) {
|
||||
const lines = [head('NODE STREAM', r, s), ...failureLines(r, s)];
|
||||
lines.push(s.muted(`packets ${r.packets ?? '—'} · decoded ${r.decodedPackets ?? '—'} · csi nodes ${r.csiNodes ?? '—'} · ${r.seconds ?? '—'} s · ${r.listen ?? ''}`));
|
||||
for (const hb of r.heartbeatOnlySenders || []) lines.push(s.warn(`! heartbeat-only: ${hb.address} (${hb.heartbeats} heartbeats, no CSI)`));
|
||||
const nodes = r.nodes || [];
|
||||
if (nodes.length) {
|
||||
const maxHz = Math.max(...nodes.map((n) => n.csiRateHz || 0), 1);
|
||||
const rows = [['SOURCE', 'NODE', 'CSI RATE', '', 'LOSS', 'RSSI', 'SHAPE', 'SEQ', history ? 'TREND' : '']];
|
||||
for (const n of nodes) {
|
||||
const key = `${n.source}:${n.nodeId}`;
|
||||
const csi = n.csi || {};
|
||||
const seq = [n.seqReordered && `${n.seqReordered}r`, n.seqStrays && `${n.seqStrays}s`, n.seqResyncs && `${n.seqResyncs}R`].filter(Boolean).join(' ') || '—';
|
||||
const loss = n.csiLossFraction;
|
||||
rows.push([
|
||||
n.source === 'realtek' ? 'realtek' : 'esp32', String(n.nodeId),
|
||||
`${num(n.csiRateHz, 1)} Hz`, s.acid(bar((n.csiRateHz || 0) / maxHz, 8)),
|
||||
loss == null ? '—' : loss > 0.05 ? s.warn(pct(loss)) : pct(loss),
|
||||
`${num(n.rssiMean, 1)} dBm`,
|
||||
`${csi.shape || '—'}${n.csiShapes ? ` +${n.csiShapes.length - 1}` : ''}${csi.synthetic ? s.warn(' SYNTHETIC') : ''}`,
|
||||
seq, history ? s.acid(sparkline(history.get(key) || [])) : '',
|
||||
]);
|
||||
}
|
||||
lines.push('', table(rows, s));
|
||||
}
|
||||
const a = r.analysis;
|
||||
if (a) {
|
||||
lines.push('');
|
||||
if (a.ok === false) lines.push(s.warn(`kernel analysis: ${a.reason}${a.detail ? ` — ${a.detail}` : ''}`));
|
||||
else {
|
||||
const last = a.summary?.last || {}; const hr = last.heart || {}; const rr = last.respiratory || {};
|
||||
lines.push(`${s.bold('kernel analysis')} ${s.muted(`${a.backend} · ${a.integrity} · ${a.input?.frames} frames @ ${num(a.input?.sampleRateHz, 1)} Hz`)}`);
|
||||
lines.push(` heart ${num(hr.bpm, 1)} bpm (${hr.status || '—'}) breathing ${rr.status === 'unavailable' ? 'unavailable' : `${num(rr.bpm, 1)} bpm (${rr.status || '—'})`}`);
|
||||
lines.push(s.muted(` ${a.note || 'estimates without a reference measurement'}`));
|
||||
}
|
||||
}
|
||||
if (r.unknownPackets) lines.push(s.muted(`unknown packets ${r.unknownPackets}: ${Object.keys(r.unknownMagics || {}).join(', ')}`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderDevices(r, s) {
|
||||
const lines = [head('DEVICES', r, s), ...failureLines(r, s)];
|
||||
const rows = [['PORT', 'BRIDGE', 'LIKELY', 'CONFIRM WITH']];
|
||||
for (const d of (r.devices || []).filter((x) => !x.builtin)) {
|
||||
rows.push([d.port, d.bridge || d.usb || 'unknown', (d.likelyRoles || []).join('/') || '—', (d.confirmWith || [])[0] || '—']);
|
||||
}
|
||||
if (rows.length > 1) lines.push('', table(rows, s));
|
||||
if (r.note) lines.push('', s.muted(r.note));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderMonitor(r, s) {
|
||||
const lines = [head('SERIAL MONITOR', r, s), ...failureLines(r, s)];
|
||||
if (r.ok !== false || r.lines !== undefined) lines.push(`csi log lines ${r.csi_callbacks ?? 0} · console lines ${r.lines ?? '—'} · ${r.baud ?? '—'} baud · reset on open: ${r.reset_on_open === false ? 'no' : 'unknown'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFlash(r, s) {
|
||||
const lines = [head(r.dryRun ? 'FLASH PLAN' : 'FLASH', r, s), ...failureLines(r, s)];
|
||||
const p = r.plan;
|
||||
if (p) {
|
||||
lines.push(s.muted(`${p.chip} · ${p.flashSize} · ${p.port} · checksums ${p.checksums || 'none'}`));
|
||||
lines.push('', table([['OFFSET', 'ROLE', 'BYTES', 'INTEGRITY'], ...(p.images || []).map((i) => [i.offset, i.role, String(i.bytes), i.integrity === 'verified' ? s.acid(i.integrity) : s.warn(i.integrity)])], s));
|
||||
}
|
||||
if (r.bootLog) lines.push('', `boot log: ${r.bootLog.lines ?? 0} lines · CSI callbacks ${r.bootLog.csiCallbacks ?? 0} · panic ${r.bootLog.panic ? s.bad('yes') : 'no'}`);
|
||||
if (r.evidence) lines.push(s.acid(r.evidence));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderGeneric(tool, r, s) {
|
||||
const view = String(tool || 'result').replace(/^ruview_/, '').replace(/_/g, ' ').toUpperCase();
|
||||
const lines = [head(view, r, s), ...failureLines(r, s)];
|
||||
const scalars = Object.entries(r).filter(([k, v]) => !['ok', 'reason', 'detail', 'remedy', 'hint'].includes(k) && (v === null || ['string', 'number', 'boolean'].includes(typeof v)));
|
||||
for (const [k, v] of scalars.slice(0, 16)) lines.push(`${s.muted(pad(k, 18))}${String(v).length > 140 ? `${String(v).slice(0, 137)}…` : v}`);
|
||||
const nested = Object.keys(r).filter((k) => r[k] && typeof r[k] === 'object');
|
||||
if (nested.length) lines.push(s.muted(`… ${nested.join(', ')} (use --json for the full result)`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool result for a terminal. `history` (Map of node key → rates)
|
||||
* adds trend sparklines in watch mode.
|
||||
*/
|
||||
export function renderResult(tool, r, { color = false, history } = {}) {
|
||||
const s = createStyle(color);
|
||||
if (!r || typeof r !== 'object') return String(r);
|
||||
if (tool === 'ruview_esp32_capture' || r.nodes) return renderCapture(r, s, history);
|
||||
if (tool === 'ruview_devices_scan') return renderDevices(r, s);
|
||||
if (tool === 'ruview_node_monitor') return renderMonitor(r, s);
|
||||
if (tool === 'ruview_node_flash' || tool === 'ruview_firmware_plan') return renderFlash(r, s);
|
||||
return renderGeneric(tool, r, s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a countdown on stderr while `fn` runs (TTY only). Returns fn's result.
|
||||
*/
|
||||
export async function withProgress(label, seconds, fn, stream = process.stderr) {
|
||||
if (!stream.isTTY || !seconds) return fn();
|
||||
const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
||||
const start = Date.now();
|
||||
let i = 0;
|
||||
const draw = () => {
|
||||
const left = Math.max(0, seconds - (Date.now() - start) / 1000);
|
||||
stream.write(`\r${frames[i++ % frames.length]} ${label} · ${left.toFixed(0)}s left `);
|
||||
};
|
||||
draw();
|
||||
const timer = setInterval(draw, 120);
|
||||
try { return await fn(); } finally { clearInterval(timer); stream.write('\r\x1b[2K'); }
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
|
||||
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
|
||||
//
|
||||
// The `ruview.*` tools (onboard/verify/claim-check/…) and local host adapters are
|
||||
// pure Node and run with zero runtime dependencies. Modules load on demand so
|
||||
// `--help`/`--version`/`skills` never pay for the tool registry (ADR-375).
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { colorEnabled, renderResult, withProgress } from './cli-ui.js';
|
||||
|
||||
const NAME = 'ruview';
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const SKILLS_DIR = join(ROOT, 'skills');
|
||||
const tools = () => import('./tools.js');
|
||||
|
||||
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
|
||||
const VERB_TO_TOOL = {
|
||||
onboard: 'ruview_onboard',
|
||||
verify: 'ruview_verify',
|
||||
'claim-check': 'ruview_claim_check',
|
||||
calibrate: 'ruview_calibrate',
|
||||
monitor: 'ruview_node_monitor',
|
||||
flash: 'ruview_node_flash',
|
||||
guidance: 'ruview_guidance',
|
||||
spaces: 'ruview_spaces_list',
|
||||
kernel: 'ruview_kernel_selftest',
|
||||
'flash-plan': 'ruview_firmware_plan',
|
||||
ports: 'ruview_firmware_ports',
|
||||
train: 'ruview_train',
|
||||
'train-plan': 'ruview_train_plan',
|
||||
'train-gate': 'ruview_train_gate',
|
||||
devices: 'ruview_devices_scan',
|
||||
esp32: 'ruview_esp32_capture',
|
||||
mmwave: 'ruview_mmwave_read',
|
||||
lidar: 'ruview_lidar_read',
|
||||
};
|
||||
|
||||
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
|
||||
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
|
||||
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames']);
|
||||
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze']);
|
||||
// Presentation-only flags never reach a tool schema.
|
||||
const UI_FLAGS = new Set(['json', 'watch', 'interval']);
|
||||
|
||||
function toSchemaArgs(flags) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
const k = key.replaceAll('-', '_');
|
||||
if (UI_FLAGS.has(k)) continue;
|
||||
if (NUMERIC_FLAGS.has(k)) out[k] = Number(value);
|
||||
else if (BOOLEAN_FLAGS.has(k)) out[k] = value === true || value === 'true';
|
||||
else if (k === 'train_subjects' || k === 'test_subjects') out[k] = String(value).split(',').filter(Boolean);
|
||||
else out[k] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
|
||||
|
||||
/** JSON for pipes and --json; the terminal UI for a human at a TTY. */
|
||||
function emit(tool, res, flags) {
|
||||
if (flags.json === true || !process.stdout.isTTY) pjson(res);
|
||||
else console.log(renderResult(tool, res, { color: colorEnabled() }));
|
||||
}
|
||||
|
||||
/** Tools that hold the terminal for a known window get a countdown (TTY only). */
|
||||
function progressLabel(tool, args) {
|
||||
if (tool === 'ruview_esp32_capture') return [`Listening on UDP ${args.bind || '0.0.0.0'}:${args.udp_port || 5005}`, args.seconds ?? 10];
|
||||
if (tool === 'ruview_node_monitor') return [`Reading ${args.port} at ${args.baud || 115200} baud`, args.seconds ?? 12];
|
||||
if (tool === 'ruview_mmwave_read' || tool === 'ruview_lidar_read') return [`Reading ${args.port || args.url || 'device'}`, args.seconds ?? 10];
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
async function callTool(tool, args) {
|
||||
const { runTool } = await tools();
|
||||
const [label, seconds] = progressLabel(tool, args);
|
||||
return label ? withProgress(label, seconds, () => runTool(tool, args, { source: 'cli' })) : runTool(tool, args, { source: 'cli' });
|
||||
}
|
||||
|
||||
/** `esp32 --watch`: repeated short captures, redrawn in place, with per-node trend lines. */
|
||||
async function watchCapture(args, flags) {
|
||||
const { runTool } = await tools();
|
||||
const window = { ...args, seconds: args.seconds ?? 3 };
|
||||
const history = new Map();
|
||||
let stop = false;
|
||||
process.once('SIGINT', () => { stop = true; });
|
||||
const tty = process.stdout.isTTY && flags.json !== true;
|
||||
while (!stop) {
|
||||
const res = await runTool('ruview_esp32_capture', window, { source: 'cli' });
|
||||
for (const n of res.nodes || []) {
|
||||
const key = `${n.source}:${n.nodeId}`;
|
||||
history.set(key, [...(history.get(key) || []), n.csiRateHz].slice(-30));
|
||||
}
|
||||
if (!tty) { console.log(JSON.stringify(res)); continue; }
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
console.log(renderResult('ruview_esp32_capture', res, { color: colorEnabled(), history }));
|
||||
console.log(`\n${new Date().toLocaleTimeString()} · ${window.seconds}s windows · Ctrl+C to stop`);
|
||||
if (res.reason === 'port_in_use' || res.reason === 'invalid_port' || res.reason === 'invalid_bind') return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listSkills() {
|
||||
if (!existsSync(SKILLS_DIR)) return [];
|
||||
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
|
||||
}
|
||||
|
||||
async function doctor(flags) {
|
||||
const [{ formatDoctor, runDoctor, DOCTOR_GROUPS }, { OPERATOR_DEPS }] = await Promise.all([import('./doctor.js'), tools()]);
|
||||
const groups = typeof flags.group === 'string' ? flags.group.split(',').map((g) => g.trim()).filter(Boolean) : undefined;
|
||||
const unknown = (groups || []).filter((g) => !DOCTOR_GROUPS.includes(g));
|
||||
if (unknown.length) { console.error(`doctor: unknown group(s) ${unknown.join(', ')}; valid: ${DOCTOR_GROUPS.join(', ')}`); return 2; }
|
||||
const report = await runDoctor({
|
||||
groups,
|
||||
port: typeof flags.port === 'string' ? flags.port : undefined,
|
||||
probe_port: flags.probe === true,
|
||||
sensing_url: typeof flags.url === 'string' ? flags.url : undefined,
|
||||
}, OPERATOR_DEPS);
|
||||
if (flags.json === true) pjson(report);
|
||||
else console.log(formatDoctor(report));
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
async function mcp(rest, flags) {
|
||||
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
|
||||
if (flags.http !== true) {
|
||||
const { startMcpServer } = await import('./mcp-server.js');
|
||||
startMcpServer();
|
||||
return new Promise(() => {}); // run until stdin closes
|
||||
}
|
||||
const { startMcpHttp, DEFAULT_HTTP_PORT } = await import('./mcp-http.js');
|
||||
const host = typeof flags.host === 'string' ? flags.host : '127.0.0.1';
|
||||
const port = flags.port === undefined ? DEFAULT_HTTP_PORT : Number(flags.port);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error('mcp start: --port must be 0..65535'); return 2; }
|
||||
const allowOrigins = typeof flags['allow-origin'] === 'string' ? flags['allow-origin'].split(',').map((o) => o.trim()).filter(Boolean) : [];
|
||||
// The token is read from the environment, never from argv (process lists are visible to other users).
|
||||
const srv = await startMcpHttp({ host, port, token: process.env.RUVIEW_MCP_TOKEN, allowOrigins });
|
||||
const generated = !process.env.RUVIEW_MCP_TOKEN;
|
||||
process.stderr.write([
|
||||
`ruview MCP over HTTP on ${srv.url}${srv.loopback ? '' : ' (non-loopback bind: anyone who can reach this port and holds the token can call read tools)'}`,
|
||||
` grants: ${srv.grants.join(', ') || 'none (set RUVIEW_MCP_GRANTS=device-access for hardware reads)'}`,
|
||||
` token: ${generated ? `${srv.token} (generated; set RUVIEW_MCP_TOKEN to keep it stable)` : 'from RUVIEW_MCP_TOKEN'}`,
|
||||
' clients with headers: Authorization: Bearer <token>',
|
||||
' ChatGPT (no custom headers): expose the port over HTTPS (e.g. a tunnel) and add the connector URL',
|
||||
` https://<public-host>/mcp/${generated ? srv.token : '<token>'}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
const shutdown = () => srv.close().then(() => process.exit(0));
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
function help() {
|
||||
console.log(`Usage: ${NAME} <command> [options]
|
||||
|
||||
Operator tools:
|
||||
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
|
||||
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
|
||||
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
|
||||
calibrate --step baseline|enroll|train-room|room-watch
|
||||
monitor --port COM8 [--seconds 12] [--baud 1500000] assert CSI is flowing on a node (no reset on open)
|
||||
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
|
||||
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
|
||||
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
|
||||
kernel [--backend wasm|napi|auto] [--seconds 60] SYNTHETIC self-test of @ruvnet/ruview-kernel (optional)
|
||||
|
||||
Firmware (ADR-370, cross-platform esptool):
|
||||
ports list serial ports (pyserial)
|
||||
flash-plan --port <p> --bundle <dir> [--variant s3-8mb|s3-4mb|c6] verify + print the plan
|
||||
flash --port <p> --bundle <dir> [--variant ...] [--baud 460800] --confirm
|
||||
[--boot-log-seconds 15] [--allow-unverified] write flash, then capture boot evidence
|
||||
|
||||
Devices (ADR-373) — run on the machine the hardware is attached to:
|
||||
devices classify USB serial devices (ESP32, Realtek, mmWave, RPLIDAR)
|
||||
esp32 [--udp-port 5005] [--seconds 10] [--bind 0.0.0.0] receive + summarize ESP32 / Realtek RAC1 node UDP streams
|
||||
[--analyze [--node-id N] [--backend wasm|napi|auto]] run live CSI through @ruvnet/ruview-kernel
|
||||
[--watch [--seconds 3]] live view: repeated windows with per-node trends
|
||||
mmwave --port <p> [--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout
|
||||
lidar --source rplidar --port <p> [--baud 115200] RPLIDAR scan summary
|
||||
lidar --source iphone --url ws://HOST:8787/ws/lidar iPhone LiDAR relay (token: RUVIEW_LIDAR_TOKEN)
|
||||
|
||||
Remote hosts (ADR-374) — SSH, read-only tools only:
|
||||
hosts add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z]
|
||||
hosts list
|
||||
hosts run --host pi --tool ruview_devices_scan [--args-json '{"seconds":10}']
|
||||
call <tool> [--read-only] [--args-json '{...}'] generic schema-validated tool call
|
||||
|
||||
Training (ADR-371):
|
||||
train-plan [--mode pose-smoke|pose|room] [...] resolve the command, run nothing
|
||||
train --mode pose-smoke|pose|room [--config f] [--data-dir d] [--checkpoint-dir d]
|
||||
[--enrollment f] [--output f] [--samples 64] [--cuda] --confirm
|
||||
train-gate --file report.json | --model-score .59 --baseline-score .50 --split chronological ...
|
||||
|
||||
Harness:
|
||||
doctor [--json] [--group a,b] [--port P [--probe]] [--url http://host:port]
|
||||
structured diagnostics with fixes (ADR-372)
|
||||
skills list bundled skills
|
||||
skill <name> print a skill playbook
|
||||
mcp start run the ruview.* MCP server (stdio)
|
||||
mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]
|
||||
MCP over HTTP for ChatGPT and remote clients (ADR-375);
|
||||
token: RUVIEW_MCP_TOKEN (generated if unset), grants: RUVIEW_MCP_GRANTS
|
||||
install --host <h> project the harness config into the current repo
|
||||
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
|
||||
brain search --query "..." | verify | propose
|
||||
--version | --help
|
||||
|
||||
Output: a terminal gets a formatted view; pipes and --json get JSON.
|
||||
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
|
||||
function parseFlags(rest) {
|
||||
const f = {};
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (a.startsWith('--')) {
|
||||
const eq = a.indexOf('=');
|
||||
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
|
||||
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
|
||||
else { f[a.slice(2)] = true; }
|
||||
}
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
export async function run(args) {
|
||||
const cmd = args[0] ?? 'onboard';
|
||||
const rest = args.slice(1);
|
||||
const flags = parseFlags(rest);
|
||||
|
||||
// Direct tool verbs.
|
||||
if (VERB_TO_TOOL[cmd]) {
|
||||
const tool = VERB_TO_TOOL[cmd];
|
||||
const toolArgs = { ...flags };
|
||||
for (const k of UI_FLAGS) delete toolArgs[k];
|
||||
if (cmd === 'claim-check') {
|
||||
if (flags.file) {
|
||||
toolArgs.text = readFileSync(flags.file, 'utf8');
|
||||
delete toolArgs.file;
|
||||
}
|
||||
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
|
||||
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
|
||||
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
|
||||
return 2;
|
||||
}
|
||||
const res = await callTool(tool, toolArgs);
|
||||
emit(tool, res, flags);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
|
||||
if (cmd === 'monitor' && flags.baud) toolArgs.baud = Number(flags.baud);
|
||||
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
|
||||
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
|
||||
if (cmd === 'kernel' && flags.seconds !== undefined) toolArgs.seconds = Number(flags.seconds);
|
||||
if (SNAKE_VERBS.has(cmd)) {
|
||||
let snake = toSchemaArgs(flags);
|
||||
if (cmd === 'train-gate' && typeof flags.file === 'string') {
|
||||
snake = JSON.parse(readFileSync(flags.file, 'utf8'));
|
||||
}
|
||||
if (cmd === 'esp32' && flags.watch === true) return watchCapture(snake, flags);
|
||||
const res = await callTool(tool, snake);
|
||||
emit(tool, res, flags);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'spaces') {
|
||||
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
|
||||
delete toolArgs['credentials-path'];
|
||||
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
|
||||
}
|
||||
const res = await callTool(tool, toolArgs);
|
||||
emit(tool, res, flags);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'doctor': return doctor(flags);
|
||||
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
|
||||
case 'skill': {
|
||||
const n = rest[0];
|
||||
const p = n && join(SKILLS_DIR, `${n}.md`);
|
||||
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
|
||||
console.log(readFileSync(p, 'utf8'));
|
||||
return 0;
|
||||
}
|
||||
case 'mcp': return mcp(rest, flags);
|
||||
case 'agent': {
|
||||
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
|
||||
const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]);
|
||||
const hostName = String(flags.host || 'codex');
|
||||
const prompt = String(flags.prompt || '');
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
|
||||
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
|
||||
const allowWrite = flags['allow-write'] === true;
|
||||
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
|
||||
try {
|
||||
const result = await getHost(hostName).run({
|
||||
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
|
||||
});
|
||||
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
|
||||
return 0;
|
||||
} catch (error) {
|
||||
pjson({ ok: false, host: hostName, error: error.message });
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'brain': {
|
||||
const { makeProposal, searchBrain, verifyBrain } = await import('./brain.js');
|
||||
const action = rest[0] || 'search';
|
||||
if (action === 'search') {
|
||||
const query = String(flags.query || '');
|
||||
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
|
||||
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
|
||||
}
|
||||
if (action === 'verify') {
|
||||
const { findRepoRoot } = await tools();
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
|
||||
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
if (action === 'propose') {
|
||||
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview brain search|verify|propose'); return 2;
|
||||
}
|
||||
case 'install': {
|
||||
const host = flags.host || 'claude-code';
|
||||
if (!['claude-code', 'codex'].includes(host)) {
|
||||
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
const { getHost } = await import('./hosts/index.js');
|
||||
const adapter = getHost(host);
|
||||
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
|
||||
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
|
||||
console.log('Skills:', listSkills().join(', '));
|
||||
return 0;
|
||||
} catch {
|
||||
console.error(`Host adapter "${host}" is unavailable.`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'tools': { const { listTools } = await tools(); pjson(listTools()); return 0; }
|
||||
case 'call': {
|
||||
// Generic, schema-validated tool call (ADR-374). `--read-only` is what
|
||||
// remote hosts receive: it refuses anything but read-only tools.
|
||||
const { isRemoteReadOnlyTool, resolveToolName } = await tools();
|
||||
const name = resolveToolName(rest[0] || '');
|
||||
if (!name) { console.error('Usage: ruview call <tool> [--read-only] [--args-json \'{...}\']'); return 2; }
|
||||
if (flags['read-only'] === true && !isRemoteReadOnlyTool(name)) {
|
||||
pjson({ ok: false, reason: 'remote_tool_not_allowed', name }); return 1;
|
||||
}
|
||||
let toolArgs = {};
|
||||
if (typeof flags['args-json'] === 'string') {
|
||||
try { toolArgs = JSON.parse(flags['args-json']); } catch { pjson({ ok: false, reason: 'invalid_arguments', errors: ['--args-json is not valid JSON'] }); return 2; }
|
||||
}
|
||||
const res = await callTool(name, toolArgs);
|
||||
// Remote hosts parse this stream: `call` always prints JSON.
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
case 'hosts': {
|
||||
const action = rest[0] || 'list';
|
||||
if (action === 'list') { const res = await callTool('ruview_host_list', {}); pjson(res); return res.ok ? 0 : 1; }
|
||||
if (action === 'add') {
|
||||
const { saveHost } = await import('./remote.js');
|
||||
try {
|
||||
const out = saveHost({ name: flags.name, ssh: flags.ssh, port: flags.port ? Number(flags.port) : undefined, version: flags.version, description: flags.description });
|
||||
pjson({ ok: true, ...out, next: `Verify the host key once: ssh ${out.host.ssh} true` }); return 0;
|
||||
} catch (error) { pjson({ ok: false, reason: error.reason || 'invalid_host', detail: error.message }); return 2; }
|
||||
}
|
||||
if (action === 'run') {
|
||||
const { resolveToolName } = await tools();
|
||||
let toolArgs = {};
|
||||
if (typeof flags['args-json'] === 'string') {
|
||||
try { toolArgs = JSON.parse(flags['args-json']); } catch { console.error('--args-json is not valid JSON'); return 2; }
|
||||
}
|
||||
const tool = resolveToolName(String(flags.tool || '')) || String(flags.tool || '');
|
||||
const res = await callTool('ruview_host_run', { host: String(flags.host || ''), tool, args: toolArgs });
|
||||
pjson(res); return res.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview hosts list | add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z] | run --host pi --tool ruview_devices_scan [--args-json {...}]');
|
||||
return 2;
|
||||
}
|
||||
case '--version': case '-v': {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
console.log(pkg.version); return 0;
|
||||
}
|
||||
case '--help': case '-h': return help();
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ export function captureEsp32(args = {}, deps = {}) {
|
||||
if (packets === 0) {
|
||||
failure = { reason: 'no_packets', remedy: `No node packets reached ${bind}:${port}. Check the node's target IP/port (provision.py --target-ip/--target-port), that this host is on the same network, and the firewall (UDP ${port}).` };
|
||||
} else if (NODE_PACKETS(summary) === 0 && !summary.meshMessages && summary.heartbeats > 0) {
|
||||
failure = { reason: 'heartbeat_only', remedy: 'Realtek nodes are alive (RHB1 heartbeats) but no RAC1 CSI arrived. Reset the board; if it persists, check its serial log at 1500000 baud for csi_sequence and reduce channel contention (ADR-323).' };
|
||||
failure = { reason: 'heartbeat_only', remedy: 'Realtek nodes are alive (RHB1 heartbeats) but no RAC1 CSI arrived. Run `ruview monitor --port <p> --baud 1500000`: "lack of csi buf" lines mean CSI report-buffer starvation in the firmware (reset the board); otherwise check csi_sequence and channel contention (ADR-323).' };
|
||||
} else if (decoded === 0) {
|
||||
failure = { reason: 'no_decodable_packets', remedy: 'Packets arrived but none matched a known RuView format (see unknownMagics). Check the sender and firmware version.' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// MCP over Streamable HTTP (ADR-375) — what ChatGPT and other remote MCP
|
||||
// clients connect to. Same handler and FIFO tool chain as stdio, plus:
|
||||
// - authentication on every request: `Authorization: Bearer <token>` or the
|
||||
// secret-path form `/mcp/<token>` (ChatGPT connectors cannot send a static
|
||||
// bearer header), compared in constant time;
|
||||
// - loopback bind by default; a non-loopback bind is the operator's choice;
|
||||
// - Origin allowlist (DNS-rebinding defence): browser origins are refused
|
||||
// unless listed; server-to-server clients send no Origin;
|
||||
// - write grants (workspace-write, hardware-write) are never honoured over
|
||||
// HTTP, so flashing and calibration stay stdio/CLI-only;
|
||||
// - single JSON responses (no SSE stream), bounded bodies, no sessions.
|
||||
|
||||
import http from 'node:http';
|
||||
import { randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||
import { createDispatcher, MAX_REQUEST_BYTES, parseGrants, SERVER_INFO, SUPPORTED_PROTOCOLS } from './mcp-server.js';
|
||||
|
||||
export const DEFAULT_HTTP_PORT = 8790;
|
||||
export const HTTP_WRITE_GRANTS = Object.freeze(['workspace-write', 'hardware-write']);
|
||||
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
||||
|
||||
const digest = (s) => createHash('sha256').update(String(s)).digest();
|
||||
function sameSecret(a, b) {
|
||||
return typeof a === 'string' && a.length > 0 && timingSafeEqual(digest(a), digest(b));
|
||||
}
|
||||
|
||||
export function generateToken() {
|
||||
return randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
/** Grants usable over HTTP: everything the operator granted except writes. */
|
||||
export function httpGrants(grants) {
|
||||
return grants.filter((g) => !HTTP_WRITE_GRANTS.includes(g));
|
||||
}
|
||||
|
||||
function send(res, status, body, headers = {}) {
|
||||
const payload = body === undefined ? '' : JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}),
|
||||
...headers,
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP transport. opts: { host='127.0.0.1', port=8790, token,
|
||||
* allowOrigins=[], grants=env RUVIEW_MCP_GRANTS, log }. Resolves to
|
||||
* { server, url, secretUrl, token, grants, close() }.
|
||||
*/
|
||||
export function startMcpHttp(opts = {}) {
|
||||
const host = opts.host || '127.0.0.1';
|
||||
const port = opts.port ?? DEFAULT_HTTP_PORT;
|
||||
const token = opts.token || generateToken();
|
||||
if (token.length < 16) return Promise.reject(new Error('token must be at least 16 characters'));
|
||||
const allowOrigins = new Set(opts.allowOrigins || []);
|
||||
const requested = opts.grants || parseGrants();
|
||||
const grants = httpGrants(requested);
|
||||
const log = opts.log || ((...a) => process.stderr.write('[ruview-mcp-http] ' + a.join(' ') + '\n'));
|
||||
const dropped = requested.filter((g) => !grants.includes(g));
|
||||
if (dropped.length) log(`write grants are never honoured over HTTP; ignoring: ${dropped.join(', ')}`);
|
||||
const { dispatch } = createDispatcher({ source: 'mcp', transport: 'http', grants });
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') return send(res, 200, { ok: true, name: SERVER_INFO.name, version: SERVER_INFO.version });
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
if (segments[0] !== 'mcp' || segments.length > 2) return send(res, 404, { error: 'not_found' });
|
||||
const auth = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || '');
|
||||
if (!sameSecret(segments[1] || (auth && auth[1].trim()), token)) {
|
||||
return send(res, 401, { error: 'unauthorized' }, { 'www-authenticate': 'Bearer realm="ruview"' });
|
||||
}
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !allowOrigins.has(origin)) return send(res, 403, { error: 'origin_not_allowed' });
|
||||
if (req.method !== 'POST') return send(res, 405, { error: 'method_not_allowed' }, { allow: 'POST' });
|
||||
const version = req.headers['mcp-protocol-version'];
|
||||
if (version && !SUPPORTED_PROTOCOLS.includes(version)) return send(res, 400, { error: 'unsupported_protocol_version', supported: SUPPORTED_PROTOCOLS });
|
||||
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
let aborted = false;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_REQUEST_BYTES) { aborted = true; send(res, 413, { error: 'request_too_large' }); req.destroy(); return; }
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', async () => {
|
||||
if (aborted) return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
|
||||
return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
||||
}
|
||||
if (Array.isArray(msg)) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Batches are not supported' } });
|
||||
const reply = await dispatch(msg);
|
||||
if (!reply) { res.writeHead(202, { 'cache-control': 'no-store' }); return res.end(); }
|
||||
send(res, 200, reply);
|
||||
});
|
||||
});
|
||||
// Tool runs (captures, analysis) can take minutes; headers must still arrive fast.
|
||||
server.headersTimeout = 10_000;
|
||||
server.requestTimeout = 15 * 60_000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', reject);
|
||||
const address = server.address();
|
||||
const shownHost = address.family === 'IPv6' ? `[${address.address}]` : address.address;
|
||||
const base = `http://${shownHost}:${address.port}/mcp`;
|
||||
resolve({
|
||||
server, token, grants, loopback: LOOPBACK.has(host),
|
||||
url: base, secretUrl: `${base}/${token}`,
|
||||
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,148 +1,176 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
|
||||
// RuView harness — dependency-free MCP server core (JSON-RPC 2.0).
|
||||
//
|
||||
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
|
||||
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
|
||||
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
|
||||
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
|
||||
// stdout is the JSON-RPC channel and must stay clean.
|
||||
// pulling the full MCP SDK. `handleRpc` is transport-neutral; stdio (this file)
|
||||
// and Streamable HTTP (mcp-http.js, ADR-375) both drive it. Logs go to stderr
|
||||
// ONLY — on stdio, stdout is the JSON-RPC channel and must stay clean.
|
||||
//
|
||||
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
|
||||
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
|
||||
// mid-run see a live server. Responses may therefore arrive out of request
|
||||
// order, which JSON-RPC permits (ids correlate them).
|
||||
//
|
||||
// ADR-375: results carry `structuredContent` (2025-06-18) next to a compact
|
||||
// JSON text block, and the UI tools point at a self-contained `ui://` widget
|
||||
// for MCP Apps hosts and ChatGPT.
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { listTools, runTool } from './tools.js';
|
||||
import { listTools, resolveToolName, runTool } from './tools.js';
|
||||
import { CONSOLE_HTML, CONSOLE_RESOURCE, CONSOLE_RESOURCE_META } from './ui/console-widget.js';
|
||||
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
const MAX_REQUEST_BYTES = 256 * 1024;
|
||||
/** Newest first; an unknown client version gets the newest we speak. */
|
||||
export const SUPPORTED_PROTOCOLS = Object.freeze(['2025-06-18', '2025-03-26', '2024-11-05']);
|
||||
export const MAX_REQUEST_BYTES = 256 * 1024;
|
||||
const MAX_QUEUED_TOOL_CALLS = 20;
|
||||
// Single-source the version from package.json (ADR-263 O6).
|
||||
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
const SERVER_INFO = { name: 'ruview', version: PKG.version };
|
||||
export const SERVER_INFO = Object.freeze({ name: 'ruview', title: 'RuView', version: PKG.version });
|
||||
|
||||
function send(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
||||
const INSTRUCTIONS = 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check. Credentialed external reads are denied without an operator grant; ruview_spaces_list requires credential-use. Node captures, device scans and doctor results render in the ruview console widget.';
|
||||
|
||||
export function negotiateProtocol(requested) {
|
||||
return SUPPORTED_PROTOCOLS.includes(requested) ? requested : SUPPORTED_PROTOCOLS[0];
|
||||
}
|
||||
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
|
||||
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
|
||||
|
||||
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
||||
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
|
||||
|
||||
async function handle(msg, context = {}) {
|
||||
/**
|
||||
* Shape a tool result as an MCP CallToolResult: structured content for hosts
|
||||
* and widgets, plus compact JSON text for clients that read only `content`
|
||||
* (compact, not pretty-printed: the same data in fewer model tokens).
|
||||
*/
|
||||
export function toCallToolResult(name, out) {
|
||||
const structured = out && typeof out === 'object' && !Array.isArray(out) ? out : { value: out };
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(structured) }],
|
||||
structuredContent: structured,
|
||||
isError: structured.ok === false,
|
||||
_meta: { 'ruview/tool': name },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one JSON-RPC message. Returns the response object, or null for a
|
||||
* notification (no response is sent).
|
||||
*/
|
||||
export async function handleRpc(msg, context = {}) {
|
||||
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
||||
return fail(msg && typeof msg === 'object' && 'id' in msg ? msg.id : null, -32600, 'Invalid Request');
|
||||
}
|
||||
const { id, method, params } = msg;
|
||||
const isNotification = id === undefined;
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return result(id, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
return ok(id, {
|
||||
protocolVersion: negotiateProtocol(params?.protocolVersion),
|
||||
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
|
||||
serverInfo: SERVER_INFO,
|
||||
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check. Credentialed external reads are denied without an operator grant; ruview_spaces_list requires credential-use.',
|
||||
instructions: INSTRUCTIONS,
|
||||
});
|
||||
case 'notifications/initialized':
|
||||
case 'initialized':
|
||||
return; // notifications — no response
|
||||
return null;
|
||||
case 'notifications/cancelled':
|
||||
if (context.queuedIds?.has(params?.requestId)) context.cancelled?.add(params.requestId);
|
||||
return; // queued requests are cancelled before execution
|
||||
return null;
|
||||
case 'ping':
|
||||
return result(id, {});
|
||||
return ok(id, {});
|
||||
case 'tools/list':
|
||||
return result(id, { tools: listTools() });
|
||||
return ok(id, { tools: listTools() });
|
||||
case 'resources/list':
|
||||
return result(id, { resources: [] });
|
||||
return ok(id, { resources: [{ ...CONSOLE_RESOURCE, _meta: CONSOLE_RESOURCE_META }] });
|
||||
case 'resources/templates/list':
|
||||
return ok(id, { resourceTemplates: [] });
|
||||
case 'resources/read': {
|
||||
if (params?.uri !== CONSOLE_RESOURCE.uri) return fail(id, -32002, `Resource not found: ${String(params?.uri).slice(0, 200)}`);
|
||||
return ok(id, { contents: [{ uri: CONSOLE_RESOURCE.uri, mimeType: CONSOLE_RESOURCE.mimeType, text: CONSOLE_HTML, _meta: CONSOLE_RESOURCE_META }] });
|
||||
}
|
||||
case 'prompts/list':
|
||||
return result(id, { prompts: [] });
|
||||
return ok(id, { prompts: [] });
|
||||
case 'tools/call': {
|
||||
const name = params?.name;
|
||||
const args = params?.arguments || {};
|
||||
log('audit', JSON.stringify({ event: 'tools/call', id, name }));
|
||||
log('audit', JSON.stringify({ event: 'tools/call', id, name, transport: context.transport || 'stdio' }));
|
||||
const out = await runTool(name, args, context);
|
||||
// MCP content envelope: text block with the JSON, isError reflects ok=false.
|
||||
return result(id, {
|
||||
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
|
||||
isError: out && out.ok === false,
|
||||
});
|
||||
return ok(id, toCallToolResult(resolveToolName(name) || name, out));
|
||||
}
|
||||
default:
|
||||
if (id !== undefined) error(id, -32601, `Method not found: ${method}`);
|
||||
return isNotification ? null : fail(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startMcpServer() {
|
||||
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
|
||||
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
|
||||
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
|
||||
// answer during a long tool run). `toolChain` also lets stdin-close drain the
|
||||
// in-flight call so its response is flushed instead of dropped by process.exit.
|
||||
let toolChain = Promise.resolve();
|
||||
let queuedToolCalls = 0;
|
||||
/**
|
||||
* Serialize tools/call through one FIFO chain: hardware/mutating tools
|
||||
* (calibrate, serial monitor, flash) must never overlap. Everything else
|
||||
* answers immediately (ADR-263 O2). Shared by the stdio and HTTP transports.
|
||||
*/
|
||||
export function createDispatcher(baseContext) {
|
||||
let chain = Promise.resolve();
|
||||
let queued = 0;
|
||||
const cancelled = new Set();
|
||||
const queuedIds = new Set();
|
||||
|
||||
const grants = String(process.env.RUVIEW_MCP_GRANTS || '').split(',').map((v) => v.trim()).filter(Boolean);
|
||||
const dispatch = (msg) => handle(msg, { source: 'mcp', grants, cancelled, queuedIds }).catch((err) => {
|
||||
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
|
||||
const context = { ...baseContext, cancelled, queuedIds };
|
||||
const run = (msg) => handleRpc(msg, context).catch((err) => {
|
||||
log('handler error:', String(err));
|
||||
return msg && msg.id !== undefined ? fail(msg.id, -32603, String(err && err.message || err)) : null;
|
||||
});
|
||||
|
||||
function dispatch(msg) {
|
||||
if (!msg || msg.method !== 'tools/call') return run(msg);
|
||||
const validId = typeof msg.id === 'string' || (typeof msg.id === 'number' && Number.isFinite(msg.id));
|
||||
if (!validId) return Promise.resolve(fail(msg?.id ?? null, -32600, 'tools/call requires a finite string or number id'));
|
||||
if (queuedIds.has(msg.id)) return Promise.resolve(fail(msg.id, -32600, 'Duplicate in-flight request id'));
|
||||
if (queued >= MAX_QUEUED_TOOL_CALLS) { log('tool queue full:', String(msg.id)); return Promise.resolve(fail(msg.id, -32000, 'Tool queue is full')); }
|
||||
queued += 1;
|
||||
queuedIds.add(msg.id);
|
||||
const result = chain.then(async () => {
|
||||
try {
|
||||
if (cancelled.delete(msg.id)) return fail(msg.id, -32800, 'Request cancelled');
|
||||
return await run(msg);
|
||||
} finally {
|
||||
cancelled.delete(msg.id);
|
||||
queuedIds.delete(msg.id);
|
||||
queued -= 1;
|
||||
}
|
||||
});
|
||||
chain = result.then(() => {}, () => {});
|
||||
return result;
|
||||
}
|
||||
return { dispatch, idle: () => chain };
|
||||
}
|
||||
|
||||
export function parseGrants(env = process.env) {
|
||||
return String(env.RUVIEW_MCP_GRANTS || '').split(',').map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function startMcpServer() {
|
||||
log(`starting v${SERVER_INFO.version} (protocol ${SUPPORTED_PROTOCOLS[0]}, ${listTools().length} tools, stdio)`);
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
const { dispatch, idle } = createDispatcher({ source: 'mcp', transport: 'stdio', grants: parseGrants() });
|
||||
const send = (res) => { if (res) process.stdout.write(JSON.stringify(res) + '\n'); };
|
||||
|
||||
rl.on('line', (line) => {
|
||||
if (Buffer.byteLength(line, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
log('oversized JSON-RPC line dropped');
|
||||
return;
|
||||
}
|
||||
if (Buffer.byteLength(line, 'utf8') > MAX_REQUEST_BYTES) { log('oversized JSON-RPC line dropped'); return; }
|
||||
const s = line.trim();
|
||||
if (!s) return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
|
||||
if (msg && msg.method === 'tools/call') {
|
||||
const validId = typeof msg.id === 'string' || (typeof msg.id === 'number' && Number.isFinite(msg.id));
|
||||
if (!validId) {
|
||||
error(msg?.id ?? null, -32600, 'tools/call requires a finite string or number id');
|
||||
return;
|
||||
}
|
||||
if (queuedIds.has(msg.id)) {
|
||||
error(msg.id, -32600, 'Duplicate in-flight request id');
|
||||
return;
|
||||
}
|
||||
if (queuedToolCalls >= MAX_QUEUED_TOOL_CALLS) {
|
||||
if (msg.id !== undefined) error(msg.id, -32000, 'Tool queue is full');
|
||||
log('tool queue full:', String(msg.id));
|
||||
return;
|
||||
}
|
||||
queuedToolCalls += 1;
|
||||
queuedIds.add(msg.id);
|
||||
toolChain = toolChain.then(async () => {
|
||||
try {
|
||||
if (cancelled.delete(msg.id)) {
|
||||
if (msg.id !== undefined) error(msg.id, -32800, 'Request cancelled');
|
||||
return;
|
||||
}
|
||||
await dispatch(msg);
|
||||
} finally {
|
||||
cancelled.delete(msg.id);
|
||||
queuedIds.delete(msg.id);
|
||||
queuedToolCalls -= 1;
|
||||
}
|
||||
}); // one tool at a time
|
||||
} else {
|
||||
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
|
||||
}
|
||||
try { msg = JSON.parse(s); } catch { log('bad JSON line dropped'); return; }
|
||||
dispatch(msg).then(send);
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
// Wait for any queued/in-flight tool call to settle (its response written)
|
||||
// before exiting — fire-and-forget used to race this and drop the response.
|
||||
toolChain.then(() => {
|
||||
idle().then(() => setImmediate(() => {
|
||||
log('stdin closed — exiting');
|
||||
const done = () => process.exit(0);
|
||||
// Pipe writes are async; flush buffered stdout before exit.
|
||||
if (process.stdout.writableLength) process.stdout.once('drain', done);
|
||||
else done();
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { execTool } from './exec.js';
|
||||
import { BAUD_RATES, FIRMWARE_VARIANTS, flashFirmware, listSerialPorts } from './firmware.js';
|
||||
import { SPLITS, TRAIN_MODES, runTraining, trainingGate } from './training.js';
|
||||
import { DOCTOR_GROUPS, runDoctor } from './doctor.js';
|
||||
import { UI_TOOLS, uiToolMeta } from './ui/console-widget.js';
|
||||
import { scanDevices } from './devices/registry.js';
|
||||
import { captureEsp32 } from './devices/esp32.js';
|
||||
import { MMWAVE_MODELS, readMmwave } from './devices/mmwave.js';
|
||||
@@ -146,7 +147,7 @@ const ONBOARD_PATHS = {
|
||||
|
||||
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
|
||||
// never spliced into interpreter source).
|
||||
const MONITOR_SCRIPT = [
|
||||
export const MONITOR_SCRIPT = [
|
||||
'import sys,time',
|
||||
'try:',
|
||||
' import serial',
|
||||
@@ -161,7 +162,7 @@ const MONITOR_SCRIPT = [
|
||||
'ser.port=port; ser.baudrate=baud; ser.timeout=1',
|
||||
'ser.dtr=False; ser.rts=False',
|
||||
'ser.open()',
|
||||
'csi=0; n=0; t=time.time()',
|
||||
'csi=0; n=0; starved=0; t=time.time()',
|
||||
'while time.time()-t<dur:',
|
||||
' ln=ser.readline()',
|
||||
' if not ln: continue',
|
||||
@@ -169,8 +170,9 @@ const MONITOR_SCRIPT = [
|
||||
' n+=1',
|
||||
" if 'CSI cb' in s or 'csi_collector' in s or 'RUVIEW_CSI: frame #' in s: csi+=1",
|
||||
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
|
||||
" if 'lack of csi buf' in s or 'csi buf not enough' in s: starved+=1",
|
||||
'ser.close()',
|
||||
"print(f'LINES={n} CSI={csi}')",
|
||||
"print(f'LINES={n} CSI={csi} STARVED={starved}')",
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
@@ -263,9 +265,14 @@ export const TOOLS = {
|
||||
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
|
||||
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
|
||||
const lines = Number((r.stdout.match(/LINES=(\d+)/) || [])[1] || 0);
|
||||
const starved = Number((r.stdout.match(/STARVED=(\d+)/) || [])[1] || 0);
|
||||
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
|
||||
const out = { ok: csi > 0, csi_callbacks: csi, lines, baud, mgmt_data_upgrade: upgraded, reset_on_open: false, raw: r.stdout.trim() };
|
||||
if (csi === 0) {
|
||||
const out = { ok: csi > 0 && starved === 0, csi_callbacks: csi, lines, baud, mgmt_data_upgrade: upgraded, reset_on_open: false, raw: r.stdout.trim() };
|
||||
if (starved) {
|
||||
out.csi_buffer_starved = starved;
|
||||
out.reason = 'csi_buffer_starvation';
|
||||
out.hint = `${starved} 'lack of csi buf' lines: the radio is generating CSI reports but the firmware is not returning report buffers, so CSI stops while heartbeats continue (heartbeat-only on UDP). Reset the node now; the lasting fix is in the firmware's CSI report handling (buffer release/drain rate vs. CSI_REPORT_BUF_NUM).`;
|
||||
} else if (csi === 0) {
|
||||
out.hint = lines === 0
|
||||
? 'No console output on this port. ESP32-C6/S3 builds with CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG log on the native USB port (303A:1001), not the UART bridge: attach that port, or confirm CSI over the network with `ruview esp32 --seconds 10`. Realtek Ameba boards log at --baud 1500000.'
|
||||
: 'Console output but no CSI log lines in the window. CSI logs are rate-limited (ESP32: first 3, then every 100th callback); lengthen --seconds or confirm over the network with `ruview esp32 --seconds 10`.';
|
||||
@@ -652,9 +659,11 @@ export async function runTool(name, args, context = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
|
||||
/** MCP-shaped tool list: name, title, schema, annotations, and ui:// metadata for UI tools (ADR-375). */
|
||||
export function listTools() {
|
||||
return Object.entries(TOOLS).map(([name, t]) => ({
|
||||
name, description: t.description, inputSchema: t.inputSchema, annotations: mcpAnnotations(name),
|
||||
name, ...(t.title ? { title: t.title } : {}), description: t.description, inputSchema: t.inputSchema,
|
||||
annotations: { ...(t.title ? { title: t.title } : {}), ...mcpAnnotations(name) },
|
||||
...(UI_TOOLS.includes(name) ? { _meta: uiToolMeta() } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// RuView console widget (ADR-375): one self-contained HTML resource rendered by
|
||||
// MCP Apps hosts (`_meta.ui.resourceUri`, mimeType text/html;profile=mcp-app)
|
||||
// and ChatGPT (`openai/outputTemplate`). No external requests, no innerHTML:
|
||||
// every value from a tool result reaches the DOM through textContent only.
|
||||
|
||||
export const CONSOLE_URI = 'ui://ruview/console-v1.html';
|
||||
export const UI_MIME = 'text/html;profile=mcp-app';
|
||||
|
||||
export const CONSOLE_HTML = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>RuView console</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#090b0d;--panel:#0f1312;--text:#eef0e9;--muted:#9ca49e;--line:#29302d;--acid:#d0ff72;--warn:#ffcf5c;--bad:#ff7a6b;--mono:ui-monospace,Consolas,Menlo,monospace}
|
||||
:root[data-theme=light]{color-scheme:light;--bg:#f7f8f4;--panel:#fff;--text:#121512;--muted:#5b625d;--line:#dde2da;--acid:#4a7a00;--warn:#8a5d00;--bad:#b42318}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,Arial,sans-serif}
|
||||
main{padding:16px;max-width:880px;margin:0 auto}
|
||||
.top{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.eyebrow,.mono,.badge,.chip,th{font-family:var(--mono);font-size:11px;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.cmd{font-family:var(--mono);font-size:12px;overflow-wrap:anywhere}.id{font-family:var(--mono);font-size:12px;white-space:nowrap}
|
||||
.eyebrow{color:var(--muted);flex:1;min-width:0}
|
||||
.badge{border:1px solid var(--line);border-radius:3px;padding:2px 8px;color:var(--muted)}
|
||||
.badge[data-s=ok]{color:var(--acid);border-color:var(--acid)}.badge[data-s=warn]{color:var(--warn);border-color:var(--warn)}.badge[data-s=bad]{color:var(--bad);border-color:var(--bad)}
|
||||
button{font:inherit;font-size:12px;background:none;color:var(--text);border:1px solid var(--line);border-radius:3px;padding:5px 11px;cursor:pointer}
|
||||
button:focus-visible{outline:2px solid var(--acid);outline-offset:2px}button:disabled{opacity:.5;cursor:wait}
|
||||
.alert{border:1px solid var(--bad);border-left-width:4px;border-radius:4px;padding:10px 12px;margin:0 0 12px}
|
||||
.alert.warn{border-color:var(--warn)}.alert b{display:block}.alert p{margin:4px 0 0;color:var(--muted)}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-bottom:12px}
|
||||
.stat,.card{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px}
|
||||
.stat .v{font-size:20px;font-weight:600}.stat .k,.k{color:var(--muted);font-size:12px}
|
||||
.cards{display:grid;gap:10px}.card h3{margin:0 0 8px;font-size:15px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}
|
||||
.meter{height:4px;background:var(--line);border-radius:2px;margin-top:4px;overflow:hidden}.meter i{display:block;height:100%;background:var(--acid)}
|
||||
.chip{display:inline-block;border:1px solid var(--line);border-radius:3px;padding:1px 6px;margin:2px 4px 0 0;color:var(--muted)}
|
||||
.note{color:var(--muted);font-size:12px;margin:8px 0 0}
|
||||
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-weight:400}
|
||||
pre{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;overflow:auto;font:12px/1.45 var(--mono);max-height:340px}
|
||||
.muted{color:var(--muted)}
|
||||
</style></head>
|
||||
<body><main id="app" aria-live="polite"><p class="muted">Waiting for a RuView result…</p></main>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
var app=document.getElementById('app');
|
||||
var state={tool:null,args:null,result:null};
|
||||
var pending={},rpcId=0;
|
||||
|
||||
function h(tag,props){
|
||||
var el=document.createElement(tag);
|
||||
if(props)Object.keys(props).forEach(function(k){
|
||||
var v=props[k];if(v==null||v===false)return;
|
||||
if(k==='class')el.className=v;else if(k.slice(0,2)==='on')el.addEventListener(k.slice(2),v);else el.setAttribute(k,v===true?'':String(v));
|
||||
});
|
||||
for(var i=2;i<arguments.length;i++)add(el,arguments[i]);
|
||||
return el;
|
||||
}
|
||||
function add(el,c){if(c==null||c===false)return;if(Array.isArray(c)){c.forEach(function(x){add(el,x)});return}el.append(c instanceof Node?c:document.createTextNode(String(c)))}
|
||||
function num(v,d){return typeof v==='number'&&isFinite(v)?v.toFixed(d==null?0:d):'—'}
|
||||
function pct(v){return typeof v==='number'?(v*100).toFixed(v<0.01&&v>0?2:1)+'%':'—'}
|
||||
function stat(k,v){return h('div',{class:'stat'},h('div',{class:'v'},v),h('div',{class:'k'},k))}
|
||||
function meter(frac){var w=Math.max(0,Math.min(1,frac||0));return h('div',{class:'meter'},h('i',{style:'width:'+(w*100).toFixed(1)+'%'}))}
|
||||
function metric(k,v,frac){return h('div',null,h('div',{class:'k'},k),h('div',null,v),frac==null?null:meter(frac))}
|
||||
function alertBox(title,body,warn){return h('div',{class:'alert'+(warn?' warn':''),role:'alert'},h('b',null,title),body?h('p',null,body):null)}
|
||||
|
||||
function post(msg){if(window.parent&&window.parent!==window)window.parent.postMessage(msg,'*')}
|
||||
function request(method,params){
|
||||
var id=++rpcId;post({jsonrpc:'2.0',id:id,method:method,params:params||{}});
|
||||
return new Promise(function(res,rej){pending[id]={res:res,rej:rej};setTimeout(function(){if(pending[id]){delete pending[id];rej(new Error('host did not answer '+method))}},20000)});
|
||||
}
|
||||
function applyTheme(t){if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t)}
|
||||
function toolFrom(meta){return meta&&typeof meta['ruview/tool']==='string'?meta['ruview/tool']:null}
|
||||
function setResult(r,tool){if(!r||typeof r!=='object')return;state.result=r;if(tool)state.tool=tool;render()}
|
||||
|
||||
function canRerun(){return !!(state.tool&&(window.openai&&window.openai.callTool||window.parent!==window))}
|
||||
function rerun(btn){
|
||||
btn.disabled=true;btn.textContent='Running…';
|
||||
var call=window.openai&&window.openai.callTool?window.openai.callTool(state.tool,state.args||{}):request('tools/call',{name:state.tool,arguments:state.args||{}});
|
||||
Promise.resolve(call).then(function(r){setResult(r&&(r.structuredContent||r),state.tool)}).catch(function(e){
|
||||
btn.disabled=false;btn.textContent='Retry';app.prepend(alertBox('Refresh failed',e&&e.message||String(e),true));
|
||||
});
|
||||
}
|
||||
|
||||
function header(view,r){
|
||||
var s=r.ok===false?'bad':(r.evidence&&/^SYNTHETIC/.test(r.evidence))?'warn':'ok';
|
||||
var label=r.ok===false?(r.reason||'failed').replace(/_/g,' '):(r.evidence?r.evidence.split(':')[0]:'OK');
|
||||
return h('div',{class:'top'},
|
||||
h('div',{class:'eyebrow'},'RUVIEW / '+view),
|
||||
h('span',{class:'badge','data-s':s},label),
|
||||
canRerun()?h('button',{type:'button',onclick:function(e){rerun(e.currentTarget)}},'Refresh'):null);
|
||||
}
|
||||
function failure(r){return r.ok===false?alertBox((r.reason||'failed').replace(/_/g,' '),r.remedy||r.detail||r.hint||null):null}
|
||||
|
||||
function nodeCard(n,maxHz){
|
||||
var src=n.source==='realtek'?'Realtek RTL8721Dx':'ESP32';
|
||||
var csi=n.csi||{};
|
||||
var shapes=(n.csiShapes||[]).map(function(s){return h('span',{class:'chip'},s.shape+' · '+s.frames)});
|
||||
var seq=[];if(n.seqReordered)seq.push(n.seqReordered+' reordered');if(n.seqStrays)seq.push(n.seqStrays+' stray');if(n.seqResyncs)seq.push(n.seqResyncs+' resync');
|
||||
var v=n.lastVitals;
|
||||
return h('div',{class:'card'},
|
||||
h('h3',null,src+' · node '+n.nodeId,h('span',{class:'chip'},Object.keys(n.packets||{}).map(function(k){return k+' '+n.packets[k]}).join(' · '))),
|
||||
h('div',{class:'grid'},
|
||||
metric('CSI rate',num(n.csiRateHz,1)+' Hz',maxHz?n.csiRateHz/maxHz:0),
|
||||
h('div',null,h('div',{class:'k'},'Loss'),h('div',{style:n.csiLossFraction>0.05?'color:var(--warn)':null},pct(n.csiLossFraction))),
|
||||
metric('RSSI',num(n.rssiMean,1)+' dBm',n.rssiMean==null?null:(n.rssiMean+95)/65),
|
||||
metric('CSI shape',csi.shape?csi.shape+(csi.channel?' · ch '+csi.channel:csi.freqMhz?' · '+csi.freqMhz+' MHz':''):'—')),
|
||||
shapes.length>1?h('div',{class:'note'},'Shapes: ',shapes):null,
|
||||
seq.length?h('div',{class:'note'},'Sequence: '+seq.join(' · ')):null,
|
||||
csi.synthetic?alertBox('SYNTHETIC frames','This node set the simulator flag; these are not live measurements.',true):null,
|
||||
v?h('div',{class:'note'},'Device-reported (not validated): presence '+(v.presence?'yes':'no')+' · breathing '+num(v.breathingBpm,1)+' bpm · heart '+num(v.heartBpm,1)+' bpm'):null);
|
||||
}
|
||||
function analysis(a){
|
||||
if(!a)return null;
|
||||
if(a.ok===false)return alertBox('Kernel analysis: '+String(a.reason||'failed').replace(/_/g,' '),a.detail||a.remedy||null,true);
|
||||
var s=a.summary||{},last=s.last||{},hr=last.heart||{},rr=last.respiratory||{},inp=a.input||{};
|
||||
return h('div',{class:'card'},
|
||||
h('h3',null,'Kernel analysis',h('span',{class:'chip'},(a.backend||'?')+' · '+(a.integrity||'?'))),
|
||||
h('div',{class:'grid'},
|
||||
metric('Input',(inp.frames||0)+' frames · '+num(inp.sampleRateHz,1)+' Hz'),
|
||||
metric('Heart (est.)',num(hr.bpm,1)+' bpm · '+(hr.status||'—')),
|
||||
metric('Breathing (est.)',rr.status==='unavailable'?'unavailable':num(rr.bpm,1)+' bpm · '+(rr.status||'—')),
|
||||
metric('Signal quality',num(last.signal_quality,2))),
|
||||
h('p',{class:'note'},a.note||'Estimates without a reference measurement.'));
|
||||
}
|
||||
function viewCapture(r){
|
||||
var nodes=r.nodes||[],maxHz=nodes.reduce(function(m,n){return Math.max(m,n.csiRateHz||0)},0);
|
||||
return [
|
||||
failure(r),
|
||||
(r.heartbeatOnlySenders||[]).length?alertBox('Heartbeat-only senders','Alive but no CSI: '+r.heartbeatOnlySenders.map(function(s){return s.address+' ('+s.heartbeats+')'}).join(', '),true):null,
|
||||
h('div',{class:'stats'},stat('Packets',r.packets!=null?r.packets:'—'),stat('Decoded',r.decodedPackets!=null?r.decodedPackets:'—'),stat('CSI nodes',r.csiNodes!=null?r.csiNodes:'—'),stat('Window',(r.seconds||'—')+' s')),
|
||||
h('div',{class:'cards'},nodes.map(function(n){return nodeCard(n,maxHz)}),analysis(r.analysis)),
|
||||
r.unknownPackets?h('p',{class:'note'},'Unknown packets: '+r.unknownPackets+(r.unknownMagics?' ('+Object.keys(r.unknownMagics).join(', ')+')':'')):null,
|
||||
r.listen?h('p',{class:'note cmd'},'listen '+r.listen):null];
|
||||
}
|
||||
function viewDevices(r){
|
||||
return [failure(r),h('table',null,h('thead',null,h('tr',null,h('th',null,'Port'),h('th',null,'Bridge'),h('th',null,'Likely'),h('th',null,'Confirm with'))),
|
||||
h('tbody',null,(r.devices||[]).filter(function(d){return !d.builtin}).map(function(d){
|
||||
return h('tr',null,h('td',{class:'id'},d.port),h('td',null,d.bridge||d.usb||'unknown'),h('td',null,(d.likelyRoles||[]).map(function(x){return h('span',{class:'chip'},x)})),h('td',{class:'cmd'},(d.confirmWith||[])[0]||'—'))}))),
|
||||
h('p',{class:'note'},r.note||'')];
|
||||
}
|
||||
function viewDoctor(r){
|
||||
var s=r.summary||{};var rows=(r.checks||[]).map(function(c){
|
||||
var st=c.status==='pass'?'ok':c.status==='fail'?'bad':c.status==='warn'?'warn':'';
|
||||
return h('tr',null,h('td',null,h('span',{class:'badge','data-s':st},c.status)),h('td',{class:'id'},c.group+'/'+c.id),h('td',null,c.detail,c.remedy&&c.status!=='pass'?h('div',{class:'note'},'fix: '+c.remedy):null))});
|
||||
return [h('div',{class:'stats'},stat('Pass',s.pass||0),stat('Warn',s.warn||0),stat('Fail',s.fail||0),stat('Skip',s.skip||0)),h('table',null,h('tbody',null,rows))];
|
||||
}
|
||||
function viewGeneric(r){return [failure(r),h('pre',null,JSON.stringify(r,null,2))]}
|
||||
|
||||
function render(){
|
||||
var r=state.result;if(!r)return;
|
||||
var t=state.tool||'';
|
||||
var view=r.nodes||t==='ruview_esp32_capture'?['NODE STREAM',viewCapture]:r.devices?['DEVICES',viewDevices]:r.checks&&r.summary?['DOCTOR',viewDoctor]:[(t.replace(/^ruview_/,'').replace(/_/g,' ')||'RESULT').toUpperCase(),viewGeneric];
|
||||
app.replaceChildren(header(view[0],r));add(app,view[1](r));
|
||||
notifySize();
|
||||
}
|
||||
function notifySize(){var hgt=Math.ceil(document.documentElement.scrollHeight);if(window.openai&&typeof window.openai.notifyIntrinsicHeight==='function')window.openai.notifyIntrinsicHeight(hgt);else post({jsonrpc:'2.0',method:'ui/notifications/size-changed',params:{height:hgt}})}
|
||||
|
||||
window.addEventListener('message',function(e){
|
||||
if(e.source!==window.parent)return;var m=e.data;if(!m||m.jsonrpc!=='2.0')return;
|
||||
if(m.id!=null&&!m.method&&pending[m.id]){var p=pending[m.id];delete pending[m.id];if(m.error)p.rej(new Error(m.error.message||'host error'));else p.res(m.result);return}
|
||||
var params=m.params||{};
|
||||
if(m.method==='ui/notifications/tool-input')state.args=params.arguments||null;
|
||||
else if(m.method==='ui/notifications/tool-result')setResult(params.structuredContent,toolFrom(params._meta));
|
||||
else if(m.method==='ui/notifications/host-context-changed')applyTheme(params.theme);
|
||||
});
|
||||
function fromOpenAI(){var o=window.openai;if(!o)return;applyTheme(o.theme);if(o.toolInput)state.args=o.toolInput;if(o.toolOutput)setResult(o.toolOutput,toolFrom(o.toolResponseMetadata))}
|
||||
window.addEventListener('openai:set_globals',fromOpenAI);
|
||||
if(window.openai)fromOpenAI();
|
||||
else request('ui/initialize',{protocolVersion:'2025-06-18',appInfo:{name:'ruview-console',version:'1'},appCapabilities:{}}).then(function(res){
|
||||
applyTheme(res&&res.hostContext&&res.hostContext.theme);post({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}});
|
||||
}).catch(function(){});
|
||||
})();
|
||||
</script></body></html>`;
|
||||
|
||||
/** The resource descriptor advertised by resources/list. */
|
||||
export const CONSOLE_RESOURCE = Object.freeze({
|
||||
uri: CONSOLE_URI,
|
||||
name: 'ruview-console',
|
||||
title: 'RuView console',
|
||||
description: 'Interactive view of RuView node captures, device scans and diagnostics. Self-contained; makes no network requests.',
|
||||
mimeType: UI_MIME,
|
||||
});
|
||||
|
||||
/** Resource-level metadata: an empty CSP allowlist (no external origins) for both host families. */
|
||||
export const CONSOLE_RESOURCE_META = Object.freeze({
|
||||
ui: { csp: { connectDomains: [], resourceDomains: [] }, prefersBorder: true },
|
||||
'openai/widgetDescription': 'Shows a RuView tool result: per-node CSI rate, loss and RSSI, device scans, or doctor checks, with a refresh button.',
|
||||
'openai/widgetPrefersBorder': true,
|
||||
'openai/widgetCSP': { connect_domains: [], resource_domains: [] },
|
||||
});
|
||||
|
||||
/** Tools whose results render in the console widget. */
|
||||
export const UI_TOOLS = Object.freeze(['ruview_esp32_capture', 'ruview_devices_scan', 'ruview_doctor']);
|
||||
|
||||
/** Tool-level metadata pointing a UI tool at the widget (MCP Apps + ChatGPT keys). */
|
||||
export function uiToolMeta() {
|
||||
return {
|
||||
ui: { resourceUri: CONSOLE_URI },
|
||||
'ui/resourceUri': CONSOLE_URI,
|
||||
'openai/outputTemplate': CONSOLE_URI,
|
||||
'openai/widgetAccessible': true,
|
||||
'openai/toolInvocation/invoking': 'Reading RuView…',
|
||||
'openai/toolInvocation/invoked': 'RuView result ready',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// ADR-375: terminal rendering. Pure functions, pinned without color; JSON stays
|
||||
// the contract for pipes. Monitor starvation detection runs the real monitor
|
||||
// script against a stand-in serial module.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { bar, colorEnabled, renderResult, sparkline, table, createStyle } from '../src/cli-ui.js';
|
||||
import { MONITOR_SCRIPT } from '../src/tools.js';
|
||||
|
||||
const capture = {
|
||||
ok: true, listen: '0.0.0.0:5005', seconds: 15, packets: 5199, decodedPackets: 5199, csiNodes: 2,
|
||||
evidence: 'MEASURED: live UDP packets received and decoded on this host',
|
||||
heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 50 }],
|
||||
nodes: [
|
||||
{ source: 'esp32', nodeId: 42, csiRateHz: 4.73, csiLossFraction: 0, rssiMean: -63.1, csi: { shape: '1x64' }, csiShapes: [{}, {}], packets: { csi: 71 } },
|
||||
{ source: 'realtek', nodeId: 3, csiRateHz: 324.67, csiLossFraction: 0.1, rssiMean: -39.3, csi: { shape: '1x52', synthetic: false }, seqReordered: 37, seqStrays: 7, packets: { csi: 4870 } },
|
||||
],
|
||||
analysis: { ok: true, backend: 'wasm', integrity: 'verified', input: { frames: 12549, sampleRateHz: 313.7 }, summary: { last: { heart: { bpm: 99.06, status: 'unreliable' }, respiratory: { status: 'unavailable' } } }, note: 'no reference measurement' },
|
||||
};
|
||||
|
||||
test('capture renders nodes, alerts and analysis without color codes', () => {
|
||||
const out = renderResult('ruview_esp32_capture', capture, { color: false });
|
||||
assert.doesNotMatch(out, /\x1b\[/);
|
||||
assert.match(out, /^RUVIEW \/ NODE STREAM {3}● MEASURED/);
|
||||
assert.match(out, /heartbeat-only: 192\.168\.1\.67 \(50 heartbeats, no CSI\)/);
|
||||
assert.match(out, /esp32 +42 +4\.7 Hz/);
|
||||
assert.match(out, /realtek +3 +324\.7 Hz +████████/);
|
||||
assert.match(out, /10\.0%/);
|
||||
assert.match(out, /1x64 \+1/);
|
||||
assert.match(out, /37r 7s/);
|
||||
assert.match(out, /heart 99\.1 bpm \(unreliable\) {3}breathing unavailable/);
|
||||
const lines = out.split('\n');
|
||||
const header = lines.find((l) => l.startsWith('SOURCE'));
|
||||
const row = lines.find((l) => l.startsWith('realtek'));
|
||||
assert.equal(row.indexOf('324.7'), header.indexOf('CSI RATE'), 'columns align');
|
||||
});
|
||||
|
||||
test('failures lead with the reason and the fix; color is opt-in', () => {
|
||||
const out = renderResult('ruview_esp32_capture', { ok: false, reason: 'heartbeat_only', remedy: 'reset the board', packets: 3 }, { color: false });
|
||||
assert.match(out, /✖ heartbeat only/);
|
||||
assert.match(out, /fix {2}reset the board/);
|
||||
assert.match(renderResult('ruview_esp32_capture', capture, { color: true }), /\x1b\[38;5;155m/);
|
||||
assert.equal(colorEnabled({ isTTY: true }, {}), true);
|
||||
assert.equal(colorEnabled({ isTTY: true }, { NO_COLOR: '' }), false);
|
||||
assert.equal(colorEnabled({ isTTY: true }, { TERM: 'dumb' }), false);
|
||||
assert.equal(colorEnabled({ isTTY: false }, {}), false);
|
||||
});
|
||||
|
||||
test('devices, monitor, flash and generic views', () => {
|
||||
const dev = renderResult('ruview_devices_scan', { ok: true, devices: [{ port: 'COM10', bridge: 'Prolific PL2303GC', likelyRoles: ['realtek'], confirmWith: ['ruview monitor --port COM10 --baud 1500000'] }, { port: 'COM3', builtin: true }], note: 'hints' }, { color: false });
|
||||
assert.match(dev, /COM10 +Prolific PL2303GC +realtek +ruview monitor --port COM10 --baud 1500000/);
|
||||
assert.doesNotMatch(dev, /COM3/, 'built-in ports hidden');
|
||||
const mon = renderResult('ruview_node_monitor', { ok: false, reason: 'csi_buffer_starvation', hint: 'reset', csi_callbacks: 0, lines: 629, baud: 1500000, reset_on_open: false }, { color: false });
|
||||
assert.match(mon, /✖ csi buffer starvation/);
|
||||
assert.match(mon, /reset on open: no/);
|
||||
const flash = renderResult('ruview_firmware_plan', { ok: true, dryRun: true, plan: { chip: 'ESP32-C6', flashSize: '4MB', port: 'COM16', checksums: 'SHA256SUMS.txt', images: [{ offset: '0x0', role: 'bootloader', bytes: 19568, integrity: 'verified' }] } }, { color: false });
|
||||
assert.match(flash, /RUVIEW \/ FLASH PLAN/);
|
||||
assert.match(flash, /0x0 +bootloader +19568 +verified/);
|
||||
const gen = renderResult('ruview_claim_check', { ok: true, verdict: 'PASS', findings: [] }, { color: false });
|
||||
assert.match(gen, /RUVIEW \/ CLAIM CHECK/);
|
||||
assert.match(gen, /verdict +PASS/);
|
||||
assert.match(gen, /use --json/);
|
||||
});
|
||||
|
||||
test('bar, sparkline and table primitives', () => {
|
||||
assert.equal(bar(1, 4), '████');
|
||||
assert.equal(bar(0, 4), ' ');
|
||||
assert.equal(bar(0.5, 4).length, 4);
|
||||
assert.equal(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), '▁▂▃▄▅▆▇█');
|
||||
assert.equal(sparkline([5, 5]), '▄▄');
|
||||
assert.equal(sparkline([]), '');
|
||||
assert.equal(table([['A', 'B'], ['xx', 'y']], createStyle(false)), 'A B\nxx y');
|
||||
});
|
||||
|
||||
test('monitor script flags Ameba CSI buffer starvation', (t) => {
|
||||
const py = ['python3', 'python'].find((p) => spawnSync(p, ['--version'], { stdio: 'ignore' }).status === 0);
|
||||
if (!py) { t.skip('python not available'); return; }
|
||||
const fake = mkdtempSync(join(tmpdir(), 'ruview-fakeserial-'));
|
||||
try {
|
||||
writeFileSync(join(fake, 'serial.py'), [
|
||||
'class Serial:',
|
||||
' def __init__(self, *a, **k): self.lines = [b"[WLAN-W] lack of csi buf!\\n", b"[WLAN-W] csi buf not enough\\n", b"RUVIEW_CSI: heartbeat tick=9\\n"]',
|
||||
' def open(self): pass',
|
||||
' def readline(self): return self.lines.pop(0) if self.lines else b""',
|
||||
' def close(self): pass',
|
||||
].join('\n'));
|
||||
const r = spawnSync(py, ['-c', MONITOR_SCRIPT, 'COM1', '0.4', '1500000'], { env: { PATH: process.env.PATH, SYSTEMROOT: process.env.SYSTEMROOT || '', PYTHONPATH: fake }, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /LINES=3 CSI=0 STARVED=2/);
|
||||
} finally {
|
||||
rmSync(fake, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// ADR-375: MCP Apps / ChatGPT widget, structured results, protocol
|
||||
// negotiation, and the authenticated Streamable HTTP transport.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { handleRpc, negotiateProtocol, SUPPORTED_PROTOCOLS, toCallToolResult } from '../src/mcp-server.js';
|
||||
import { generateToken, httpGrants, startMcpHttp } from '../src/mcp-http.js';
|
||||
import { CONSOLE_HTML, CONSOLE_URI, UI_MIME, UI_TOOLS } from '../src/ui/console-widget.js';
|
||||
|
||||
const rpc = (method, params, id = 1) => handleRpc({ jsonrpc: '2.0', id, method, params });
|
||||
|
||||
test('initialize negotiates the protocol and advertises resources', async () => {
|
||||
for (const v of SUPPORTED_PROTOCOLS) assert.equal((await rpc('initialize', { protocolVersion: v })).result.protocolVersion, v);
|
||||
assert.equal(negotiateProtocol('1999-01-01'), SUPPORTED_PROTOCOLS[0]);
|
||||
const init = (await rpc('initialize', {})).result;
|
||||
assert.equal(init.protocolVersion, '2025-06-18');
|
||||
assert.deepEqual(Object.keys(init.capabilities).sort(), ['resources', 'tools']);
|
||||
assert.equal(init.serverInfo.title, 'RuView');
|
||||
});
|
||||
|
||||
test('UI tools point at the console widget with MCP Apps and ChatGPT keys', async () => {
|
||||
const tools = (await rpc('tools/list')).result.tools;
|
||||
for (const name of UI_TOOLS) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
assert.ok(t, name);
|
||||
assert.equal(t._meta.ui.resourceUri, CONSOLE_URI);
|
||||
assert.equal(t._meta['openai/outputTemplate'], CONSOLE_URI);
|
||||
assert.equal(t._meta['openai/widgetAccessible'], true);
|
||||
assert.ok(t.title, `${name} has a title`);
|
||||
}
|
||||
const plain = tools.find((x) => x.name === 'ruview_claim_check');
|
||||
assert.equal(plain._meta, undefined, 'non-UI tools carry no widget metadata');
|
||||
assert.equal(tools.filter((t) => t._meta).length, UI_TOOLS.length);
|
||||
});
|
||||
|
||||
test('resources list and read the self-contained widget; unknown URIs fail', async () => {
|
||||
const [res] = (await rpc('resources/list')).result.resources;
|
||||
assert.deepEqual([res.uri, res.mimeType], [CONSOLE_URI, UI_MIME]);
|
||||
assert.deepEqual(res._meta.ui.csp, { connectDomains: [], resourceDomains: [] });
|
||||
const read = (await rpc('resources/read', { uri: CONSOLE_URI })).result.contents[0];
|
||||
assert.equal(read.text, CONSOLE_HTML);
|
||||
assert.equal(read.mimeType, UI_MIME);
|
||||
const missing = await rpc('resources/read', { uri: 'ui://ruview/nope.html' });
|
||||
assert.equal(missing.error.code, -32002);
|
||||
assert.deepEqual((await rpc('resources/templates/list')).result, { resourceTemplates: [] });
|
||||
});
|
||||
|
||||
test('widget is self-contained and never writes HTML from data', () => {
|
||||
assert.ok(Buffer.byteLength(CONSOLE_HTML) < 16 * 1024, 'widget stays under 16 KiB');
|
||||
assert.doesNotMatch(CONSOLE_HTML, /https?:\/\//, 'no external origins');
|
||||
assert.doesNotMatch(CONSOLE_HTML, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|eval\(|new Function/);
|
||||
assert.doesNotMatch(CONSOLE_HTML, /<(link|iframe|img|object|embed)\b/i);
|
||||
assert.match(CONSOLE_HTML, /e\.source!==window\.parent/, 'only the host frame may post messages');
|
||||
assert.match(CONSOLE_HTML, /ui\/initialize/);
|
||||
assert.match(CONSOLE_HTML, /openai:set_globals/);
|
||||
});
|
||||
|
||||
test('tools/call returns structuredContent, compact text, and the canonical tool name', async () => {
|
||||
const r = (await rpc('tools/call', { name: 'ruview.claim_check', arguments: { text: 'PCK 0.9 MEASURED with baseline' } })).result;
|
||||
assert.equal(typeof r.structuredContent, 'object');
|
||||
assert.equal(r.content[0].text, JSON.stringify(r.structuredContent), 'text is the compact serialization');
|
||||
assert.equal(r._meta['ruview/tool'], 'ruview_claim_check');
|
||||
assert.equal(r.isError, r.structuredContent.ok === false);
|
||||
assert.deepEqual(toCallToolResult('x', [1, 2]).structuredContent, { value: [1, 2] }, 'structuredContent is always an object');
|
||||
});
|
||||
|
||||
test('invalid messages and unknown methods get JSON-RPC errors; notifications get none', async () => {
|
||||
assert.equal((await handleRpc({ id: 1, method: 'ping' })).error.code, -32600, 'jsonrpc field required');
|
||||
assert.equal((await handleRpc([])).error.code, -32600);
|
||||
assert.equal((await rpc('nope/method')).error.code, -32601);
|
||||
assert.equal(await handleRpc({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);
|
||||
assert.equal(await handleRpc({ jsonrpc: '2.0', method: 'notifications/whatever' }), null);
|
||||
});
|
||||
|
||||
test('HTTP grants never include writes', () => {
|
||||
assert.deepEqual(httpGrants(['device-access', 'hardware-write', 'workspace-write', 'credential-use']), ['device-access', 'credential-use']);
|
||||
assert.ok(generateToken().length >= 32);
|
||||
});
|
||||
|
||||
test('HTTP transport: auth, origin, method, size, protocol header, notifications, write denial', async (t) => {
|
||||
const token = generateToken();
|
||||
const logs = [];
|
||||
const srv = await startMcpHttp({ port: 0, token, grants: ['device-access', 'hardware-write'], allowOrigins: ['https://ok.example'], log: (m) => logs.push(m) });
|
||||
t.after(() => srv.close());
|
||||
const post = (path, body, headers = {}) => fetch(`${srv.url.replace(/\/mcp$/, '')}${path}`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
});
|
||||
const init = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } };
|
||||
|
||||
assert.deepEqual(srv.grants, ['device-access']);
|
||||
assert.match(logs.join('\n'), /never honoured over HTTP; ignoring: hardware-write/);
|
||||
assert.equal((await post('/mcp', init)).status, 401);
|
||||
assert.equal((await post('/mcp', init, { authorization: 'Bearer wrong-token-xxxxxxxxxxxx' })).status, 401);
|
||||
assert.equal((await post('/mcp/wrong-token-xxxxxxxxxxxx', init)).status, 401);
|
||||
const bearer = await post('/mcp', init, { authorization: `Bearer ${token}` });
|
||||
assert.equal(bearer.status, 200);
|
||||
assert.equal((await bearer.json()).result.protocolVersion, '2025-06-18');
|
||||
const secretPath = await post(`/mcp/${token}`, init);
|
||||
assert.equal(secretPath.status, 200, 'secret-path form for clients without custom headers');
|
||||
|
||||
assert.equal((await post(`/mcp/${token}`, init, { origin: 'https://evil.example' })).status, 403);
|
||||
assert.equal((await post(`/mcp/${token}`, init, { origin: 'https://ok.example' })).status, 200);
|
||||
assert.equal((await fetch(`${srv.url}/${token}`)).status, 405);
|
||||
assert.equal((await post(`/mcp/${token}`, init, { 'mcp-protocol-version': '1999-01-01' })).status, 400);
|
||||
assert.equal((await post(`/mcp/${token}`, { jsonrpc: '2.0', method: 'notifications/initialized' })).status, 202);
|
||||
assert.equal((await post(`/mcp/${token}`, 'not json')).status, 400);
|
||||
assert.equal((await post(`/mcp/${token}`, [init])).status, 400, 'batches refused');
|
||||
assert.equal((await post(`/mcp/${token}`, 'x'.repeat(300 * 1024))).status, 413);
|
||||
assert.equal((await post('/elsewhere', init)).status, 404);
|
||||
assert.equal((await fetch(srv.url.replace(/\/mcp$/, '/healthz'))).status, 200);
|
||||
|
||||
// Flash is a hardware-write tool: denied over HTTP even though the operator granted it.
|
||||
const flash = await post(`/mcp/${token}`, { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'ruview_node_flash', arguments: { port: 'COM9', bundle: '.', variant: 'c6', confirm: true } } });
|
||||
const sc = (await flash.json()).result.structuredContent;
|
||||
assert.deepEqual([sc.ok, sc.reason, sc.requiredGrant], [false, 'authority_denied', 'hardware-write']);
|
||||
const deny = await post(`/mcp/${token}`, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_calibrate', arguments: { step: 'baseline', confirm: true } } });
|
||||
assert.equal((await deny.json()).result.structuredContent.reason, 'authority_denied');
|
||||
});
|
||||
|
||||
test('HTTP transport refuses short tokens', async () => {
|
||||
await assert.rejects(startMcpHttp({ port: 0, token: 'short' }), /at least 16/);
|
||||
});
|
||||
@@ -64,7 +64,8 @@ test('MCP handshake: initialize reports the package.json version; list endpoints
|
||||
assert.equal(spaces.annotations.openWorldHint, true);
|
||||
|
||||
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
|
||||
assert.deepEqual((await s.next(3)).result, { resources: [] });
|
||||
const resources = (await s.next(3)).result.resources;
|
||||
assert.deepEqual(resources.map((r) => [r.uri, r.mimeType]), [['ui://ruview/console-v1.html', 'text/html;profile=mcp-app']]);
|
||||
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
|
||||
assert.deepEqual((await s.next(4)).result, { prompts: [] });
|
||||
|
||||
|
||||
Reference in new issue
Block a user