diff --git a/.github/workflows/npm-packages.yml b/.github/workflows/npm-packages.yml index e495ea471..2b066e1d5 100644 --- a/.github/workflows/npm-packages.yml +++ b/.github/workflows/npm-packages.yml @@ -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 diff --git a/.github/workflows/ruview-npm-release.yml b/.github/workflows/ruview-npm-release.yml index 431643248..2970e9a7c 100644 --- a/.github/workflows/ruview-npm-release.yml +++ b/.github/workflows/ruview-npm-release.yml @@ -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). diff --git a/docs/adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md b/docs/adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md new file mode 100644 index 000000000..d621b57ed --- /dev/null +++ b/docs/adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md @@ -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 `, or the secret-path form `/mcp/`. + 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 867659732..ac02de326 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 6d788c4b7..ec9e71a74 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -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": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index f724e6c8c..e25b3f65c 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -36990fd5c552e61b410ac8771d6a55769e8fcc9f695a1710b107a57e409a27de manifest.json +6e4a541fc2ca74b2c19de18ed6b7a5b1206d012257e264c3521a80b4379388c4 manifest.json diff --git a/harness/ruview/README.md b/harness/ruview/README.md index 46a646148..144194f0f 100644 --- a/harness/ruview/README.md +++ b/harness/ruview/README.md @@ -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 ` 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:///mcp/` 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, diff --git a/harness/ruview/bin/cli.js b/harness/ruview/bin/cli.js index e8c3f3cdf..6a01437d3 100644 --- a/harness/ruview/bin/cli.js +++ b/harness/ruview/bin/cli.js @@ -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} [options] - -Operator tools: - onboard [--path docker-demo|repo-build|live-esp32] pick a setup path - verify [--repo ] run the deterministic proof (VERDICT: PASS) - claim-check --text "..." | --file 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

--bundle

[--variant s3-8mb|s3-4mb|c6] verify + print the plan - flash --port

--bundle

[--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

[--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout - lidar --source rplidar --port

[--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 [--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 print a skill playbook - mcp start run the ruview.* MCP server (stdio) - install --host project the harness config into the current repo - agent run --host claude-code|codex --prompt "..." [--repo

] - 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 (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 ]'); 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 .'); 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 [--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). diff --git a/harness/ruview/package.json b/harness/ruview/package.json index 74c59c4cb..876bf03f1 100644 --- a/harness/ruview/package.json +++ b/harness/ruview/package.json @@ -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", diff --git a/harness/ruview/src/cli-ui.js b/harness/ruview/src/cli-ui.js new file mode 100644 index 000000000..a6e31ef77 --- /dev/null +++ b/harness/ruview/src/cli-ui.js @@ -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'); } +} diff --git a/harness/ruview/src/cli.js b/harness/ruview/src/cli.js new file mode 100644 index 000000000..9d0351eac --- /dev/null +++ b/harness/ruview/src/cli.js @@ -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 ', + ' ChatGPT (no custom headers): expose the port over HTTPS (e.g. a tunnel) and add the connector URL', + ` https:///mcp/${generated ? srv.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} [options] + +Operator tools: + onboard [--path docker-demo|repo-build|live-esp32] pick a setup path + verify [--repo ] run the deterministic proof (VERDICT: PASS) + claim-check --text "..." | --file 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

--bundle

[--variant s3-8mb|s3-4mb|c6] verify + print the plan + flash --port

--bundle

[--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

[--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout + lidar --source rplidar --port

[--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 [--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 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 project the harness config into the current repo + agent run --host claude-code|codex --prompt "..." [--repo

] + 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 (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 ]'); 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 .'); 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 [--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; + } +} diff --git a/harness/ruview/src/devices/esp32.js b/harness/ruview/src/devices/esp32.js index 8b8e8583b..ed49a5b56 100644 --- a/harness/ruview/src/devices/esp32.js +++ b/harness/ruview/src/devices/esp32.js @@ -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

--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.' }; } diff --git a/harness/ruview/src/mcp-http.js b/harness/ruview/src/mcp-http.js new file mode 100644 index 000000000..565e9898d --- /dev/null +++ b/harness/ruview/src/mcp-http.js @@ -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 ` or the +// secret-path form `/mcp/` (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()); }), + }); + }); + }); +} diff --git a/harness/ruview/src/mcp-server.js b/harness/ruview/src/mcp-server.js index 57beaa697..392805753 100644 --- a/harness/ruview/src/mcp-server.js +++ b/harness/ruview/src/mcp-server.js @@ -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(); - }); + })); }); } diff --git a/harness/ruview/src/tools.js b/harness/ruview/src/tools.js index 9b9a08a92..48fd42418 100644 --- a/harness/ruview/src/tools.js +++ b/harness/ruview/src/tools.js @@ -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 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() } : {}), })); } diff --git a/harness/ruview/src/ui/console-widget.js b/harness/ruview/src/ui/console-widget.js new file mode 100644 index 000000000..f49508a15 --- /dev/null +++ b/harness/ruview/src/ui/console-widget.js @@ -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 = ` + +RuView console + +

Waiting for a RuView result…

+`; + +/** 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', + }; +} diff --git a/harness/ruview/test/cli-ui.test.mjs b/harness/ruview/test/cli-ui.test.mjs new file mode 100644 index 000000000..114b8eae6 --- /dev/null +++ b/harness/ruview/test/cli-ui.test.mjs @@ -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 }); + } +}); diff --git a/harness/ruview/test/mcp-apps.test.mjs b/harness/ruview/test/mcp-apps.test.mjs new file mode 100644 index 000000000..dcd036823 --- /dev/null +++ b/harness/ruview/test/mcp-apps.test.mjs @@ -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/); +}); diff --git a/harness/ruview/test/mcp.test.mjs b/harness/ruview/test/mcp.test.mjs index b6ec5caac..924bcb420 100644 --- a/harness/ruview/test/mcp.test.mjs +++ b/harness/ruview/test/mcp.test.mjs @@ -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: [] });