From 1a1c7dfb8ede4039f99b3b83a86923533e5a51b6 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 31 Aug 2026 21:52:03 -0500 Subject: [PATCH 1/2] harness/ui-chat: drive a two-node chat through the real UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three containers — a canonical and two nodes — each node running a real ciris-server AND the real Compose Desktop client, driven through the app's TestAutomationServer (java.awt.Robot at screen coordinates). No stage is performed over the node's HTTP API; HTTP is read-only, for evidence, and never makes a step succeed. mesh-repro's `chat` scenario proves the same delivery over HTTP. This proves it through the surface a person uses: the contact is added in the UI, consent given in the UI, the message typed and sent in the UI, and the assertion reads node B's on-screen transcript. A CONTAINER PER NODE, because the client resolves federation-crypto calls to a hardcoded `LOCAL_NODE_URL` of 127.0.0.1:4243 (CIRISClient#26). Three nodes on one host would send those calls to whichever node answers 4243 and mint the owner's identity on the wrong one. Separate netns makes each node genuinely 127.0.0.1:4243, so the constant is correct rather than tolerated — no client patch, no node moved off the default ports, and closer to a real install than a port-juggled host. PASSING: first_run x2 (wizard, auto-claiming from /claim_pin), restart-for-opt-in, login x2. BLOCKED at discover:a_sees_b_fedid, on CIRISEdge#552. Both nodes discover each other's NODE keys and the canonical; neither obtains the other owner's FED-ID, so add_contact refuses contacts.unknown_fed_id. The canonical's own directory holds both node keys and neither owner. `Key` already serves public on the advertise axis, but the RECEIVE axis was subject-only — "un-addressable, not undisclosed", since Pull is the only by-name read and a node cannot compute the hash of a record it has never held. CIRISEdge#556 widens it; this waits on a release carrying that (CIRISServer#522). SIX DEFECTS FOUND GETTING HERE, each now prevented rather than remembered: default-jre-headless has no X11/AWT (dies HeadlessException, which reads as a missing display and is a missing toolkit); the automation server binds loopback only, so it answers `docker exec curl` and not the host; all five trust-root vars are required, and the classical two boot a node that roots then dies "PQC signature without pubkey"; a tmpfs home makes the restart wipe the identity the restart exists to apply; a stale /tmp/.X99-lock survives a restart and fails the second boot only; and clicking `toggle_announce_ownership` to "opt in" turned it OFF, because SetupState.announceOwnership already defaults true — a click is a toggle, never an assertion. TWO MORE IN THE DRIVER'S OWN ASSERTIONS, both checks that could not fail: `contact:a_adds_b` passed on the absence of a refusal banner while listContacts said "0 contact(s) of 0"; and a navigation stage left the UI on ManageNodes so the next stage reported "the contacts button never appeared" — true, and pointing at the wrong thing. Both now assert the positive. The contact is a FED-ID, never a node key: bound_nodes_of resolves the person through nodes_stewarded_by and filters to identity_type == node. An earlier revision fell back to the node key when the fed-id was missing, which made the stage pass while testing node-to-node delivery wearing a person-to-person label. The wheel and uber-jar are gitignored — 104 MB of build inputs run.sh stages and tells you how to build. Refs CIRISEdge#552/#556, CIRISServer#522, CIRISClient#25/#26, CIRISServer#520. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017gxnxsTWS8FkN3CT2JEz86 --- harness/ui-chat/.gitignore | 6 + harness/ui-chat/Dockerfile | 64 ++++ harness/ui-chat/README.md | 101 ++++++ harness/ui-chat/docker-compose.yml | 104 ++++++ harness/ui-chat/drive.py | 539 +++++++++++++++++++++++++++++ harness/ui-chat/entrypoint.sh | 101 ++++++ harness/ui-chat/run.sh | 79 +++++ 7 files changed, 994 insertions(+) create mode 100644 harness/ui-chat/.gitignore create mode 100644 harness/ui-chat/Dockerfile create mode 100644 harness/ui-chat/README.md create mode 100644 harness/ui-chat/docker-compose.yml create mode 100755 harness/ui-chat/drive.py create mode 100755 harness/ui-chat/entrypoint.sh create mode 100755 harness/ui-chat/run.sh diff --git a/harness/ui-chat/.gitignore b/harness/ui-chat/.gitignore new file mode 100644 index 00000000..4d46a0d1 --- /dev/null +++ b/harness/ui-chat/.gitignore @@ -0,0 +1,6 @@ +# Build inputs staged by run.sh, never committed: a 34 MB wheel and a 70 MB +# uber-jar are exactly the "distribute via releases and fetch on install" case +# the repo-size rule excludes. run.sh copies them in and tells you how to build +# either one if it cannot find it. +wheels/ +jars/ diff --git a/harness/ui-chat/Dockerfile b/harness/ui-chat/Dockerfile new file mode 100644 index 00000000..e3c61ff4 --- /dev/null +++ b/harness/ui-chat/Dockerfile @@ -0,0 +1,64 @@ +# ui-chat harness image — a node AND the desktop UI that drives it, in one +# container. +# +# WHY THE UI LIVES WITH THE NODE. The client resolves federation-crypto calls to +# `CIRISApiClient.LOCAL_NODE_URL`, a hardcoded `http://127.0.0.1:4243` +# (CIRISClient#26). Pointing one client at a non-default port makes those calls +# target whatever else answers :4243 — on a host running three nodes, that is a +# different node, and the owner's federation identity gets minted on the wrong +# one. +# +# A container per node dissolves that instead of working around it: each has its +# own netns, so its node genuinely IS 127.0.0.1:4243 and the constant is correct. +# Nothing needs patching, no port juggling, and `net.listen_addr` stays at its +# default — which is also how a real install looks, so the harness stops being a +# special case. +FROM python:3.12-slim + +# The node. Same test-anchor wheel the mesh-repro harness installs: it carries +# the SW single-key trust root, so a fresh mesh roots with no operator YubiKeys. +COPY wheels/ /opt/harness/wheels/ +RUN pip install --no-cache-dir /opt/harness/wheels/ciris_server-*.whl + +# The UI. `default-jre`, deliberately NOT `-headless`: the headless variant ships +# without X11/AWT, and this app's automation server clicks through +# `java.awt.Robot`. Installing it fails as +# `java.awt.HeadlessException: No X11 DISPLAY variable was set` — which reads +# like a missing DISPLAY and is actually a missing toolkit, so it sends you to +# debug Xvfb instead of the package name. +# +# `default-jre` rather than a pinned openjdk-N: python:3.12-slim is Debian +# trixie, which carries no openjdk-17 at all, and the uber-jar is built under 21 +# anyway. +# +# A JRE for the Compose Desktop uber-jar, and Xvfb because the desktop +# TestAutomationServer clicks through java.awt.Robot at SCREEN coordinates — +# there is no headless path, the click is a real pointer event. +# +# `xauth` and `x11-utils` are not decoration: `xdpyinfo` is how the entrypoint +# waits for the display to be REAL rather than sleeping a guessed interval, and +# Robot fails opaquely against a half-started server. +RUN apt-get update && apt-get install -y --no-install-recommends \ + default-jre xvfb xauth x11-utils curl procps socat \ + && rm -rf /var/lib/apt/lists/* + +# socat, because the TestAutomationServer binds LOOPBACK ONLY +# (127.0.0.1:9091). Inside a container that is unreachable from the host: a +# published port DNATs to the container's eth0 address, which the server is not +# listening on. The symptom is a UI that answers perfectly to `docker exec curl` +# and not at all to the harness, which reads like a broken app rather than a +# broken address. Forwarding rather than patching the client keeps this harness +# honest — it drives the shipped binary, unmodified. +COPY jars/ /opt/ui/ +COPY entrypoint.sh /opt/ui/entrypoint.sh +RUN chmod +x /opt/ui/entrypoint.sh + +# 4242 edge / 4243 read API — the DEFAULTS, deliberately. 9091 is the UI's +# TestAutomationServer. +EXPOSE 4242 4243 9091 +ENV DISPLAY=:99 \ + CIRIS_TEST_MODE=true \ + CIRIS_TEST_PORT=9091 \ + CIRIS_HOME=/var/lib/ciris + +ENTRYPOINT ["/opt/ui/entrypoint.sh"] diff --git a/harness/ui-chat/README.md b/harness/ui-chat/README.md new file mode 100644 index 00000000..334624c4 --- /dev/null +++ b/harness/ui-chat/README.md @@ -0,0 +1,101 @@ +# ui-chat — a chat driven through the interface a person actually uses + +Three containers: a canonical and two nodes, each node running a real +`ciris-server` **and** the real Compose Desktop client, driven through the app's +`TestAutomationServer` (`/click`, `/input`, `/tree` — `java.awt.Robot` at screen +coordinates). No step is performed over the node's HTTP API; HTTP is read-only +and used for evidence, never to make a stage succeed. + +`harness/mesh-repro/scenarios/chat.sh` proves the same delivery over HTTP. This +proves it through the surface a person uses: the contact is added in the UI, +consent is given in the UI, the message is typed and sent in the UI, and the +assertion reads node B's on-screen transcript. + +``` +./run.sh # build what is missing, run, tear down +KEEP=1 ./run.sh # leave the containers up +SKIP_BUILD=1 ./run.sh # reuse the image +``` + +## Why a container per node + +The client resolves federation-crypto calls to +`CIRISApiClient.LOCAL_NODE_URL`, a hardcoded `http://127.0.0.1:4243` +(CIRISClient#26). Three nodes on one host would make those calls target whichever +node answers 4243 — a different node — and mint the owner's identity in the wrong +place. A container per node gives each its own netns, so its node genuinely *is* +`127.0.0.1:4243` and the constant is correct rather than merely tolerated. Nothing +needs patching and no node moves off the default ports, which is also how a real +install looks. + +## Status: BLOCKED at `discover:a_sees_b_fedid` + +Passing: `first_run` ×2 (wizard, auto-claim from `/claim_pin`), +`restart:for_announce_optin`, `login` ×2. + +Blocked: both nodes discover each other's **node** keys and the canonical; +neither ever obtains the other owner's **fed-id**, so `add_contact` refuses +`contacts.unknown_fed_id`. The canonical's own directory shows both node keys and +neither owner. + +That is CIRISEdge#552's cell: `Key` already serves `public` on the advertise axis +but the RECEIVE axis was `subject_pull:data_subject; subject-only`, which made it +*"un-addressable, not undisclosed"* — `Pull` is the only by-name read, and a node +cannot compute the hash of a record it has never held. CIRISEdge#556 widens it to +`data_subject+any_attributed`. **This harness is waiting on an edge release +carrying that** (CIRISServer#522, the `SERVE_ADVERTISE_POLICY_HASH` re-pin). + +## What it found on the way + +Each of these cost a run, and each is now prevented rather than remembered: + +1. **`default-jre-headless` has no X11/AWT.** Dies `HeadlessException: No X11 + DISPLAY variable was set` — reads as a missing display, is a missing toolkit. +2. **The TestAutomationServer binds loopback only.** Answers `docker exec curl` + perfectly and the host not at all; needs the `socat` forwarder in + `entrypoint.sh`. Any containerised UI platform hits this. +3. **All five trust-root vars are required.** The classical two boot a node that + roots, arms its gates, then dies `PQC signature without pubkey`. +4. **`tmpfs` home + a restart = a wiped node.** The wizard's opt-in is + boot-structural, so the harness restarts to apply it — and a tmpfs home makes + that restart destroy the identity the restart existed to apply. +5. **A stale `/tmp/.X99-lock` survives a restart.** Second boot only, so it reads + like a flake. +6. **A click is a toggle, never an assertion.** The driver clicked + `toggle_announce_ownership` to "opt in" and turned it OFF — + `SetupState.announceOwnership` already defaults `true`. The run then failed + four minutes later in an unrelated stage. + +Two more in the driver's own assertions, both the same shape — a check that +cannot fail: + +- `contact:a_adds_b` passed on the ABSENCE of a refusal banner while + `listContacts` said `0 contact(s) of 0`. It now requires the contact to appear. +- A navigation stage left the UI on `ManageNodes` and the next stage reported + "the contacts button never appeared" — true, and pointing at the wrong thing. + `goto()` now confirms the landing screen. + +## Design notes + +- **Readiness is `/tree` returning elements, never the port answering.** On + Android the automation server came up 200 ms before the process died + (CIRISClient#25); a port check calls that green. +- **The arrival assertion keys on the sender's exact typed text**, which B cannot + produce for itself — the same reasoning `chat.sh` uses when it keys on the + sender's `attestation_id`. +- **The contact is a fed-id, never a node key.** `peer::bound_nodes_of` resolves + the person through `nodes_stewarded_by` and filters to `identity_type == "node"`, + because *"only NODE-role keys are what edge's consent send-set matches links + against"*. An earlier revision fell back to the peer's node key when the fed-id + was missing, which made the stage pass while testing node-to-node delivery + wearing a person-to-person label. The fed-id being absent is the DEFECT. +- **The canonical runs no UI**, so a transcript can only come from a node someone + actually drove. + +## Not yet aligned with CIRISServer#520 + +#520 rules that identity promotion is opt-out, that opting out means the agent +does not run, and that it is recorded as a `consent_state: granted` row with +specific wizard copy. The driver currently just walks the wizard's defaults. When +#520 lands, the first-run stage should assert that shape rather than accept +whatever the wizard does. diff --git a/harness/ui-chat/docker-compose.yml b/harness/ui-chat/docker-compose.yml new file mode 100644 index 00000000..faee1b1a --- /dev/null +++ b/harness/ui-chat/docker-compose.yml @@ -0,0 +1,104 @@ +# ui-chat — a message typed by a person in the UI on node A must ARRIVE in the +# UI transcript on node B. +# +# The mesh-repro `chat` scenario proves the same delivery over HTTP. This proves +# it through the SURFACE A PERSON USES: the contact is added in the UI, consent +# is given in the UI, the message is typed and sent in the UI, and the assertion +# reads node B's on-screen transcript. Everything between is the real substrate. +# +# THREE CONTAINERS, THREE NETNS. Each node is 127.0.0.1:4243 to its own UI, so +# the client's hardcoded LOCAL_NODE_URL (CIRISClient#26) is correct rather than +# merely tolerated, and no node needs its ports moved off the defaults. + +x-test-anchor-env: &test-anchor-env + CIRIS_TESTING_MODE: "true" + # 15s announce instead of the 300s production cycle — rooting converges in + # ~30s. test-anchor-fenced in compose.rs, so it cannot leak into a prod node. + CIRIS_TEST_ANNOUNCE_SECS: "15" + # ALL FIVE. Setting only the classical half boots a node that roots, arms its + # gates, and then dies adopting its own blessed record: + # "adopt the test-root-blessed self record: scrub-signature verification + # failed: registration hybrid-verify: PQC signature without pubkey" + # The hybrid record carries a PQC signature, so the PQC pubkey is not optional + # — the optional-half class, and the error names it precisely. + CIRIS_TEST_TRUST_ROOT: "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=" + CIRIS_TEST_TRUST_ROOT_PQC: "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" + CIRIS_TEST_TRUST_ROOT_SCRUB: "1LzFAFfuS+7JtBJ/q8bFcRn6L/fN0NZ+QarCsMOyVZ04niuwhgtbQOl+qJMsRLkGmf52hEYXVaWKRY+mwDCfDg==" + CIRIS_TEST_TRUST_ROOT_SCRUB_PQC: "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" + CIRIS_TEST_TRUST_ROOT_SEED: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + CIRIS_TEST_CANONICAL_DIAL: "172.29.78.10:4242" + +services: + canonical: + build: . + image: ciris-uichat:latest + environment: + <<: *test-anchor-env + UI_ROLE: "canonical" + CIRIS_KEY_ID: "ciris-canonical-1" + # The only node blessed as one. node-a/node-b are ordinary owner-claimed + # nodes, which is what makes the delivery claim non-trivial. + CIRIS_TEST_BLESS_CANONICAL: "true" + RUST_LOG: "info,ciris_edge=debug" + networks: + mesh: + ipv4_address: 172.29.78.10 + # A VOLUME, not tmpfs. The wizard's opt-in is boot-structural, so the harness + # restarts the node to apply it — and a tmpfs home makes that restart wipe + # the identity, the claim and the config the restart existed to apply. The + # run then loops: every boot is a first boot. + volumes: [canonical-home:/var/lib/ciris] + + node-a: + build: . + image: ciris-uichat:latest + depends_on: [canonical] + environment: + <<: *test-anchor-env + UI_ROLE: "node" + CIRIS_KEY_ID: "node-a" + # The synthetic canonical, injected as this node's dial set. Without it + # `/v1/accord/canonical/servers` stays empty and nothing discovers anything. + CIRIS_NODE_BOOTSTRAP_PEERS: '["172.29.78.10:4242"]' + CIRIS_TEST_BLESS_CANONICAL: "false" + RUST_LOG: "info,ciris_edge=debug" + ports: + - "9101:9092" # UI automation (via socat; the server itself is loopback-only) + - "14243:4243" # read API, for out-of-band assertions + networks: + mesh: + ipv4_address: 172.29.78.11 + volumes: [node-a-home:/var/lib/ciris] + + node-b: + build: . + image: ciris-uichat:latest + depends_on: [canonical] + environment: + <<: *test-anchor-env + UI_ROLE: "node" + CIRIS_KEY_ID: "node-b" + # The synthetic canonical, injected as this node's dial set. Without it + # `/v1/accord/canonical/servers` stays empty and nothing discovers anything. + CIRIS_NODE_BOOTSTRAP_PEERS: '["172.29.78.10:4242"]' + CIRIS_TEST_BLESS_CANONICAL: "false" + RUST_LOG: "info,ciris_edge=debug" + ports: + - "9102:9092" + - "24243:4243" + networks: + mesh: + ipv4_address: 172.29.78.12 + volumes: [node-b-home:/var/lib/ciris] + +volumes: + canonical-home: + node-a-home: + node-b-home: + +networks: + mesh: + driver: bridge + ipam: + config: + - subnet: 172.29.78.0/24 diff --git a/harness/ui-chat/drive.py b/harness/ui-chat/drive.py new file mode 100755 index 00000000..aeb828bf --- /dev/null +++ b/harness/ui-chat/drive.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +"""Drive the CIRIS desktop UI through a two-node chat, entirely via the UI. + +The claim under test is the one the HTTP `chat` scenario cannot make: that a +person, using the surface a person uses, can add a contact, consent to them, and +have their typed message arrive in the other person's transcript. Every step +below is a real click or keystroke through the app's TestAutomationServer +(java.awt.Robot at screen coordinates); nothing here calls the node's API to +make a step succeed. + +Read-only HTTP is used for EVIDENCE only — to learn a node's own key id, which +the UI has no reason to display, and to say what a node believes when the UI +disagrees. It is never used to perform a step. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request + +# The wizard has more steps than any one deployment shows: age band, federation +# identity, consent toggles, and whichever the node's capabilities enable. So the +# driver fills what it FINDS rather than assuming a fixed sequence — a harness +# that hardcodes step 3 breaks the first time a step is added, and reports it as +# a chat failure. +WIZARD_TEXT_FIELDS = { + "input_username": "username", + "input_password": "password", + "input_password_confirm": "password", + "input_device_name": "device_name", + "input_fedid_label": "username", +} +WIZARD_CHOICES = ("age_band_adult",) + +# THE OPT-IN THAT MAKES A FED-ID DISCOVERABLE IS ALREADY ON. DO NOT CLICK IT. +# +# `net.announce_ownership` defaults FALSE on the SERVER, and while it is false +# "the transport still brings up + announces its raw destination hash, but with +# NO identity attestation -> rooting peers drop it (fail-honest) -> the node is +# not federation-identity-discoverable". That is the state that makes a peer's +# fed-id never arrive, so `add_contact` refuses `contacts.unknown_fed_id`. +# +# But the WIZARD opts in for you: `SetupState.announceOwnership` defaults TRUE, +# and completing setup performs the promote. An earlier revision of this driver +# clicked `toggle_announce_ownership` to "opt in" and thereby turned it OFF — +# the tree reports element positions, not checked state, so a click is a +# TOGGLE and never an assertion. The run then failed four minutes later, in a +# different stage, for a reason that looked like a replication problem. +# +# Nothing to press. Listed here so the next person does not add it back. +WIZARD_TOGGLES_ONCE: tuple[str, ...] = () + + +START = time.time() + + +def log(scope: str, msg: str) -> None: + """One line per thing that happened, stamped with elapsed time. + + Every failure in this harness so far was diagnosed from a state dump taken + AFTER the fact — which screen, which tags, which directory rows. Printing + that as it happens is the difference between "the contacts button never + appeared" and "we were on ManageNodes because the previous stage navigated + away". The log IS the diagnosis; the exception is only where it stopped. + """ + print(f" [{time.time() - START:6.1f}s] {scope:<14} {msg}", flush=True) + + +def directory_of(read_api_port: int) -> list[str]: + """Key ids this node's federation directory holds — the precondition every + contact stage depends on and none of them could previously show.""" + try: + url = f"http://127.0.0.1:{read_api_port}/v1/federation/peers" + with urllib.request.urlopen(url, timeout=8) as r: + return [p.get("key_id") for p in json.loads(r.read().decode()).get("peers", [])] + except Exception as exc: # noqa: BLE001 — diagnosis must never mask the real failure + return [f""] + + +class UiError(RuntimeError): + pass + + +class Ui: + """One app instance, addressed through its TestAutomationServer.""" + + def __init__(self, name: str, port: int, timeout: float = 10.0, api_port: int | None = None): + self.name = name + self.base = f"http://127.0.0.1:{port}" + self.timeout = timeout + # The node behind this UI, for showing preconditions. Never used to + # PERFORM a step — only to say why one could not. + self.api_port = api_port + + # ── transport ──────────────────────────────────────────────────────────── + def _get(self, path: str): + with urllib.request.urlopen(f"{self.base}{path}", timeout=self.timeout) as r: + return json.loads(r.read().decode()) + + def _post(self, path: str, body: dict): + req = urllib.request.Request( + f"{self.base}{path}", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout) as r: + return json.loads(r.read().decode()) + + # ── reading ────────────────────────────────────────────────────────────── + def screen(self) -> str: + return self._get("/screen").get("screen", "?") + + def tree(self) -> dict: + return self._get("/tree") + + def tags(self) -> list[str]: + return [e.get("testTag") for e in self.tree().get("elements", [])] + + def wait_ready(self, secs: int = 180) -> None: + """Up AND rendering. The port answering is not the app being alive: on + Android the automation server came up 200ms before the process died, and + a check that stops at the port reports green on a corpse.""" + deadline = time.time() + secs + while time.time() < deadline: + try: + if self.tree().get("count", 0) > 0: + return + except (urllib.error.URLError, OSError, json.JSONDecodeError): + pass + time.sleep(2) + raise UiError(f"{self.name}: no rendered UI within {secs}s") + + def wait_for_tag(self, tag: str, secs: int = 90) -> None: + deadline = time.time() + secs + seen: list[str] = [] + while time.time() < deadline: + seen = self.tags() + if tag in seen: + return + time.sleep(2) + raise UiError( + f"{self.name}: {tag!r} never appeared within {secs}s " + f"(screen={self.screen()!r}, present={sorted(t for t in seen if t)})" + ) + + # ── acting ─────────────────────────────────────────────────────────────── + def click(self, tag: str) -> dict: + log(self.name, f"click {tag}") + res = self._post("/click", {"testTag": tag}) + if not res.get("success"): + raise UiError(f"{self.name}: click {tag!r} failed: {res}") + return res + + def type_into(self, tag: str, text: str) -> dict: + shown = text if "password" not in tag else "*" * len(text) + log(self.name, f"type {tag} = {shown!r}") + res = self._post("/input", {"testTag": tag, "text": text}) + if not res.get("success"): + raise UiError(f"{self.name}: input {tag!r} failed: {res}") + return res + + def click_if_present(self, tag: str) -> bool: + if tag in self.tags(): + self.click(tag) + return True + return False + + +def wait_for_key(ui: Ui, api_port: int, key_id: str, secs: int = 240) -> None: + """Wait for a peer's FED-ID to reach this node's federation directory. + + It arrives on the Reticulum announce, but only once the owner has opted in: + `net.announce_ownership` defaults FALSE, and while it is false the transport + "announces its raw destination hash, but with NO identity attestation -> + rooting peers drop it (fail-honest)". The wizard's + `toggle_announce_ownership` is that opt-in, and the setting is + boot-structural — it takes effect on the restart the wizard performs when it + completes, which is also why the app asks you to sign in again. + + So this is a real wait on a real cadence (15s announces in the harness), not + a sleep hiding a race. + """ + deadline = time.time() + secs + last = 0 + while time.time() < deadline: + held = directory_of(api_port) + if key_id in held: + log(ui.name, f"directory now holds {key_id} ({len(held)} keys)") + return + if len(held) != last: + log(ui.name, f"directory has {len(held)} keys, still no {key_id}") + last = len(held) + time.sleep(8) + raise UiError( + f"{ui.name}: {key_id!r} never reached the federation directory in {secs}s. " + f"Holding: {directory_of(api_port)}. A fed-id is only discoverable when its " + f"owner opted in (net.announce_ownership) AND the node has restarted since." + ) + + +def goto(ui: Ui, nav_tag: str, expect_screen: str, secs: int = 60) -> None: + """Navigate and CONFIRM the landing. + + Every stage assumes a starting screen, and a stage that reads its own + prerequisites as failures is the least useful kind: `owner_fed_id` left the + UI on ManageNodes once, and the next stage reported "the contacts button + never appeared" — true, and pointing at the wrong thing entirely. + """ + if ui.screen() == expect_screen: + return + log(ui.name, f"nav {ui.screen()} -> {expect_screen} via {nav_tag}") + ui.click_if_present(nav_tag) + deadline = time.time() + secs + while time.time() < deadline: + if ui.screen() == expect_screen: + log(ui.name, f"nav arrived at {expect_screen}") + return + time.sleep(2) + raise UiError( + f"{ui.name}: {nav_tag} did not reach {expect_screen!r} " + f"(still on {ui.screen()!r})" + ) + + +def owner_fed_id(ui: Ui, secs: int = 90) -> str: + """This node's OWNER fed-id, read off the UI's own node graph. + + A contact is a PERSON, not a node. `contacts_chat::add_contact` takes a + federation key id and `peer::bound_nodes_of` resolves it through + `nodes_stewarded_by`, filtering to `identity_type == "node"` — because "only + NODE-role keys are what edge's consent send-set matches links against". + Typing a node key into the contact form skips that resolution entirely and + asks the wire to route to a person, which is the unroutable-subject defect + that function exists to avoid. + + Read from the UI rather than an API on purpose: it is the value a person + would copy from their own screen, and it keeps the harness honest about what + is reachable through the interface. + """ + ui.click_if_present("nav_epistemic_nodes") + deadline = time.time() + secs + while time.time() < deadline: + for tag in ui.tags(): + if tag and tag.startswith("node_graph_node_qaowner"): + fed = tag.replace("node_graph_node_", "") + print(f" [{ui.name}] owner fed-id: {fed}") + goto(ui, "nav_epistemic_contacts", "Contacts") + return fed + time.sleep(3) + raise UiError( + f"{ui.name}: no owner fed-id on the node graph within {secs}s " + f"(screen={ui.screen()!r}) — the wizard may not have minted one" + ) + + +def node_key_id(read_api_port: int) -> str: + """A node's own key id, for the contact the OTHER side must type.""" + url = f"http://127.0.0.1:{read_api_port}/v1/identity" + with urllib.request.urlopen(url, timeout=10) as r: + return json.loads(r.read().decode())["key_id"] + + +def first_run(ui: Ui, username: str, password: str, device_name: str) -> None: + """Log in locally and walk the wizard to completion. + + The claim itself is NOT performed here. The node writes its one-time PIN to + `/claim_pin` (0600, never over HTTP) and the app reads it from the path + the NODE declares — local-FS access to the node's home being operator-level + access already. So the wizard claims the node on its own, and the driver's + job is only to answer the questions it asks. + """ + ui.wait_ready() + values = {"username": username, "password": password, "device_name": device_name} + pressed: set[str] = set() + + if "btn_local_login" in ui.tags(): + ui.click("btn_local_login") + time.sleep(3) + + # Walk forward until the wizard hands over. Bounded, so a wizard that loops + # on a validation error fails as a wizard failure rather than hanging. + for step in range(1, 13): + screen = ui.screen() + if screen != "Setup": + print(f" [{ui.name}] wizard complete at step {step} (screen={screen})") + return + tags = set(ui.tags()) + log(ui.name, f"wizard step {step}: {len(tags)} elements on {screen}") + for tag, key in WIZARD_TEXT_FIELDS.items(): + if tag in tags: + ui.type_into(tag, values[key]) + for tag in WIZARD_CHOICES: + if tag in tags: + ui.click(tag) + for tag in WIZARD_TOGGLES_ONCE: + if tag in tags and tag not in pressed: + ui.click(tag) + pressed.add(tag) + log(ui.name, f"opted in: {tag}") + if "btn_next" not in tags: + raise UiError( + f"{ui.name}: wizard step {step} has no btn_next " + f"(screen={screen}, tags={sorted(tags)})" + ) + ui.click("btn_next") + time.sleep(6) + + raise UiError(f"{ui.name}: wizard did not finish in 12 steps (screen={ui.screen()})") + + +def login(ui: Ui, username: str, password: str) -> None: + """Sign in after setup. + + The wizard does NOT leave you signed in: it completes, returns to Login, and + says so with `banner_setup_complete_relogin`. Skipping this step lands every + later stage on the Login screen and reports "the contacts button never + appeared", which is true and useless — the button is on a screen you were + never going to reach. + """ + ui.wait_for_tag("btn_local_login", secs=90) + ui.click("btn_local_login") + ui.wait_for_tag("input_username", secs=30) + ui.type_into("input_username", username) + ui.type_into("input_password", password) + ui.click("btn_login_submit") + # Sign-in lands on Contacts directly. + deadline = time.time() + 90 + while time.time() < deadline: + if ui.screen() != "Login": + print(f" [{ui.name}] signed in (screen={ui.screen()})") + return + time.sleep(3) + raise UiError(f"{ui.name}: still on Login 90s after submit (tags={sorted(t for t in ui.tags() if t)})") + + +def add_contact(ui: Ui, key_id: str) -> None: + """Add the peer as a contact — through the form, not the API.""" + goto(ui, "nav_epistemic_contacts", "Contacts") + log(ui.name, f"adding contact {key_id}") + if ui.api_port: + held = directory_of(ui.api_port) + present = key_id in held + log(ui.name, f"directory holds {len(held)} keys; contact present={present}") + if not present: + log(ui.name, f" directory: {held}") + log(ui.name, " NOTE: add_contact refuses contacts.unknown_fed_id for a key " + "the directory does not hold — the peer must be ADMITTED first " + "(POST /v1/federation/peering with the peer's blessed self record). " + "Discovery admits NODE keys; owner fed-ids are self-plane " + "(Projection::SelfOwn) and are advertised by nobody.") + ui.wait_for_tag("btn_contacts_add_open", secs=120) + ui.click("btn_contacts_add_open") + ui.wait_for_tag("input_contacts_add_key") + ui.type_into("input_contacts_add_key", key_id) + ui.click("btn_contacts_add_submit") + time.sleep(6) + # A refusal is a RESULT, not an exception: reporting it with its own text is + # the difference between "consent was refused" and "the harness broke". + if "contacts_add_refusal" in ui.tags(): + raise UiError(f"{ui.name}: contact add refused (see contacts_add_refusal)") + + # AND THE CONTACT MUST APPEAR. The first version of this stopped at the line + # above and reported PASS while `listContacts` said "0 contact(s) of 0" — it + # asserted the absence of a complaint, which is not the presence of a + # contact, and every later stage then failed somewhere unrelated. + deadline = time.time() + 90 + while time.time() < deadline: + ui.click_if_present("btn_contacts_refresh") + blob = json.dumps(ui.tree()) + if key_id in blob or "contacts_list" in blob: + print(f" [{ui.name}] contact {key_id} is listed") + return + time.sleep(5) + raise UiError( + f"{ui.name}: contact {key_id!r} never appeared in the list. The node " + f"admits a contact only if the key is already in its federation " + f"directory (contacts.unknown_fed_id otherwise) — check that discovery " + f"put the peer there." + ) + + +def consent(ui: Ui) -> None: + """Grant the replication consent the contact rides on.""" + for tag in ("btn_consent_peering", "btn_consent"): + if ui.click_if_present(tag): + time.sleep(5) + return + print(f" [{ui.name}] no explicit consent control — contact add carried it") + + +def send_chat(ui: Ui, text: str) -> None: + ui.click_if_present("btn_contacts_add_open_chat") + ui.wait_for_tag("input_chat_body", secs=90) + ui.type_into("input_chat_body", text) + ui.click("btn_chat_send") + time.sleep(5) + if "chat_refusal" in ui.tags(): + raise UiError(f"{ui.name}: send refused (see chat_refusal)") + + +def transcript_has(ui: Ui, text: str, secs: int = 180) -> bool: + """Poll node B's ON-SCREEN transcript for the message. + + Keyed on the sender's exact text, which B could not produce for itself. The + HTTP scenario keys on the sender's attestation_id for the same reason: a + green transcript that B derived locally would prove nothing about delivery. + """ + deadline = time.time() + secs + while time.time() < deadline: + ui.click_if_present("btn_chat_refresh") + try: + blob = json.dumps(ui.tree()) + except (urllib.error.URLError, OSError): + blob = "" + if text in blob: + return True + time.sleep(6) + return False + + +def restart_nodes(compose_dir: str = ".", services: tuple[str, ...] = ("node-a", "node-b")) -> None: + """Restart the node containers so a boot-structural opt-in takes effect.""" + log("restart", f"restarting {', '.join(services)} to apply net.announce_ownership") + res = subprocess.run( + ["docker", "compose", "restart", *services], + cwd=compose_dir, capture_output=True, text=True, timeout=300, + ) + if res.returncode != 0: + raise UiError(f"restart failed: {res.stderr.strip()[:300]}") + # Wait for the UI to come back, not for the command to return: the container + # is up long before the app is rendering, and every stage after this one + # assumes a live UI. + for ui, port in (("node-a", 9101), ("node-b", 9102)): + probe = Ui(ui, port) + probe.wait_ready(secs=240) + log("restart", f"{ui} UI back up") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--a-ui", type=int, default=9101) + ap.add_argument("--b-ui", type=int, default=9102) + ap.add_argument("--a-api", type=int, default=14243) + ap.add_argument("--b-api", type=int, default=24243) + ap.add_argument("--message", default="ui-chat harness: A to B, typed by hand") + args = ap.parse_args() + + a = Ui("node-a", args.a_ui, api_port=args.a_api) + b = Ui("node-b", args.b_ui, api_port=args.b_api) + stages: list[tuple[str, bool, str]] = [] + + def stage(name, fn): + log("stage", f"BEGIN {name}") + try: + fn() + stages.append((name, True, "")) + print(f" ✓ {name}") + except Exception as exc: # noqa: BLE001 — every stage reports, none aborts silently + stages.append((name, False, str(exc))) + print(f" ✗ {name}: {exc}") + raise + + try: + print("── first run, both nodes (the wizard claims each node itself) ──") + stage("first_run:a", lambda: first_run(a, "qaowner-a", "QaHarness!2026", "node-a-desktop")) + stage("first_run:b", lambda: first_run(b, "qaowner-b", "QaHarness!2026", "node-b-desktop")) + + # THE RESTART THE WIZARD WOULD HAVE DONE ITSELF. + # + # `net.announce_ownership` is boot-structural: the opt-in is written by + # the wizard, and the node wires its federation signer into the announce + # on the NEXT boot. On a desktop install the app OWNS the node process + # (`PythonRuntime` launches it and reads its stdout), so completing setup + # restarts it and the operator simply signs in again. + # + # This harness starts the node from the container entrypoint instead, so + # the app has nothing to restart and the opt-in would never take effect — + # the fed-id stays undiscoverable and every later stage fails at the + # contact. Restarting here is not a workaround for a product bug; it is + # the harness standing in for the lifecycle a real install already has. + stage("restart:for_announce_optin", restart_nodes) + + print("── sign in (the wizard returns to Login on purpose) ──") + stage("login:a", lambda: login(a, "qaowner-a", "QaHarness!2026")) + stage("login:b", lambda: login(b, "qaowner-b", "QaHarness!2026")) + + print("── identities ──") + a_owner, b_owner = owner_fed_id(a), owner_fed_id(b) + a_node, b_node = node_key_id(args.a_api), node_key_id(args.b_api) + log("identity", f"A owner={a_owner} node={a_node}") + log("identity", f"B owner={b_owner} node={b_node}") + + # THE CONTACT IS THE FED-ID. Not the node key. + # + # A contact is a PERSON. `peer::bound_nodes_of` resolves that fed-id + # through `nodes_stewarded_by` and filters to `identity_type == "node"`, + # because "only NODE-role keys are what edge's consent send-set matches + # links against" — so the person is what you name and their nodes are + # what the wire routes to. Both the fed-id AND its bound nodes have to be + # in the directory: the first to name the contact, the second to route to + # them, and the owner-binding between them to validate the relationship. + # + # An earlier revision fell back to the peer's node key when the fed-id + # was missing. That made the stage pass while testing something else + # entirely — node-to-node delivery wearing a person-to-person label. The + # fed-id being absent is the DEFECT, so it is waited for and then + # reported, never substituted. + stage("discover:a_sees_b_fedid", lambda: wait_for_key(a, args.a_api, b_owner)) + stage("discover:b_sees_a_fedid", lambda: wait_for_key(b, args.b_api, a_owner)) + a_types, b_types = b_owner, a_owner + + print("── contact + consent, through the UI ──") + stage("contact:a_adds_b", lambda: add_contact(a, a_types)) + stage("consent:a", lambda: consent(a)) + stage("contact:b_adds_a", lambda: add_contact(b, b_types)) + stage("consent:b", lambda: consent(b)) + + print("── send from A, read on B ──") + stage("send:a", lambda: send_chat(a, args.message)) + arrived = transcript_has(b, args.message) + stages.append(("arrived:b", arrived, "" if arrived else "message never appeared in B's transcript")) + print((" ✓ " if arrived else " ✗ ") + "arrived:b") + except Exception: + pass + + print("\n── verdict ──") + for name, ok, why in stages: + print(f" {'PASS' if ok else 'FAIL'} {name}{(' — ' + why) if why else ''}") + failed = [s for s in stages if not s[1]] + return 1 if failed or not stages else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/ui-chat/entrypoint.sh b/harness/ui-chat/entrypoint.sh new file mode 100755 index 00000000..ff7e186c --- /dev/null +++ b/harness/ui-chat/entrypoint.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# One container: node, display, UI — started in that order, each waited for +# rather than slept past. +set -euo pipefail + +HOME_DIR="${CIRIS_HOME:-/var/lib/ciris}" +KEY_ID="${CIRIS_KEY_ID:-node}" +ROLE="${UI_ROLE:-node}" # node | canonical +mkdir -p "$HOME_DIR" + +log() { printf '[entrypoint %s] %s\n' "$(date -u +%H:%M:%S)" "$*"; } + +log "role=$ROLE key_id=$KEY_ID home=$HOME_DIR" + +# ── 0. the dial set ────────────────────────────────────────────────────────── +# THE CANONICAL HAS TO BE INJECTED. A node's federation directory starts with +# the baked accord holders and nothing else — `/v1/accord/canonical/servers` +# answers `{"servers":[]}` — so nothing advertises, nothing discovers, and every +# later stage fails somewhere far away from the cause: `authorFederationConsent` +# reports "no canonical server in {servers:[]}", and adding a contact refuses +# `contacts.unknown_fed_id` because the peer's key was never admitted. +# +# `net.bootstrap_peers` is boot-STRUCTURAL: a signed config:* CEG object read +# once, before the edge is built. Setting it over HTTP after boot would not be +# read until the next restart, so it is set here — the same two-boot pattern +# mesh-repro's node_boot.sh uses. `config set` opens the engine, mints the +# keystore, writes the row and exits; the server then boots on that home. +if [ -n "${CIRIS_NODE_BOOTSTRAP_PEERS:-}" ]; then + log "config set net.bootstrap_peers ${CIRIS_NODE_BOOTSTRAP_PEERS}" + if ciris-server config set net.bootstrap_peers "$CIRIS_NODE_BOOTSTRAP_PEERS" \ + --home "$HOME_DIR" --key-id "$KEY_ID" \ + --reason "ui-chat harness: inject the synthetic canonical" 2>&1 | sed 's/^/ /'; then + log "dial set written" + else + # Not fatal: a node with no dial set still boots and still answers HTTP. The + # drive stages report the consequence, which is more useful than a container + # that dies here and reads as "the stack failed to come up". + log "WARN: config set failed — this node will not find the canonical" + fi +else + log "no CIRIS_NODE_BOOTSTRAP_PEERS (expected for the canonical itself)" +fi + +# ── 1. the node ────────────────────────────────────────────────────────────── +log "starting ciris-server" +ciris-server --home "$HOME_DIR" --key-id "$KEY_ID" > /var/log/node.log 2>&1 & +NODE_PID=$! + +# Wait for the READ API, not for the process: a node that exits at second 3 +# leaves a pid that was briefly real, and every later step then fails against a +# dead port with an error that names the wrong thing. +for i in $(seq 1 120); do + if curl -sf --max-time 2 http://127.0.0.1:4243/v1/health >/dev/null 2>&1; then + log "node healthy after ${i}s"; break + fi + if ! kill -0 "$NODE_PID" 2>/dev/null; then + log "FATAL: node exited during boot. Last lines:" >&2 + tail -25 /var/log/node.log >&2 + exit 1 + fi + sleep 1 +done + +# The canonical is the dial target and trust anchor only — it is not a party to +# the chat, so it runs no UI. Keeping it UI-less also keeps the evidence honest: +# a transcript can only come from a node someone actually drove. +if [ "$ROLE" = "canonical" ]; then + log "canonical: no UI, holding the node in foreground" + log "canonical servers it will advertise:" + curl -sf --max-time 4 http://127.0.0.1:4243/v1/accord/canonical/servers 2>/dev/null | head -c 300 | sed 's/^/ /'; echo + wait "$NODE_PID" +fi + +# ── 2. the display ─────────────────────────────────────────────────────────── +# Clear a stale lock first. `docker compose restart` keeps the container's +# filesystem, so /tmp/.X99-lock survives from the previous run and Xvfb refuses +# the display — the entrypoint then dies "FATAL: no display" on the SECOND boot +# only, which reads like a flake and is a leftover file. +rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true +Xvfb :99 -screen 0 1600x1200x24 -nolisten tcp > /var/log/xvfb.log 2>&1 & +for i in $(seq 1 30); do + if xdpyinfo -display :99 >/dev/null 2>&1; then + log "display :99 up after ${i}s"; break + fi + sleep 1 +done +xdpyinfo -display :99 >/dev/null 2>&1 || { log "FATAL: no display"; exit 1; } + +# ── 3. the UI ──────────────────────────────────────────────────────────────── +# CIRIS_NODE_URL is set to the default on purpose: it must agree with +# LOCAL_NODE_URL, and in this netns both are this container's own node. +# Expose the loopback-bound automation server on a routable address so a +# published port can reach it. 9092 is the outside face of 9091. +socat TCP-LISTEN:9092,fork,reuseaddr TCP:127.0.0.1:9091 > /var/log/socat.log 2>&1 & +log "socat 0.0.0.0:9092 -> 127.0.0.1:9091" + +JAR=$(ls /opt/ui/*.jar | head -1) +log "node sees canonical servers:" +curl -sf --max-time 4 http://127.0.0.1:4243/v1/accord/canonical/servers 2>/dev/null | head -c 300 | sed 's/^/ /'; echo +log "launching UI: $JAR" +exec java -jar "$JAR" diff --git a/harness/ui-chat/run.sh b/harness/ui-chat/run.sh new file mode 100755 index 00000000..02974178 --- /dev/null +++ b/harness/ui-chat/run.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ui-chat — stand up canonical + two nodes, each with its own UI, and drive a +# chat from A to B entirely through the interface. +# +# ./run.sh build what is missing, run, tear down +# KEEP=1 ./run.sh leave the containers up for inspection +# SKIP_BUILD=1 ./run.sh reuse the existing image +set -euo pipefail +cd "$(dirname "$0")" + +REPO_ROOT="$(cd ../.. && pwd)" +JAR_SRC_DIR="${CIRIS_CLIENT_JARS:-$HOME/CIRISClient/client/desktopApp/build/compose/jars}" + +say() { printf '\n\033[1m── %s ──\033[0m\n' "$*"; } + +# ── the node wheel ─────────────────────────────────────────────────────────── +# test-anchor, because the SW single-key trust root is what lets a fresh mesh +# root with no operator YubiKeys. Its absence is the wall between this harness +# and a production node, and that is deliberate. +say "node wheel" +mkdir -p wheels +if ! ls wheels/ciris_server-*.whl >/dev/null 2>&1; then + echo "no wheel in wheels/ — build one first:" + echo " cd $REPO_ROOT && cargo build --release --features extension-module,test-anchor" + echo " python3 harness/mesh-repro/pack_wheel.py # writes the wheel" + echo "then copy it into harness/ui-chat/wheels/" + exit 1 +fi +ls -la wheels/*.whl | sed 's/^/ /' + +# ── the client uber-jar ────────────────────────────────────────────────────── +say "desktop uber-jar" +mkdir -p jars +if ! ls jars/*.jar >/dev/null 2>&1; then + latest=$(ls -t "$JAR_SRC_DIR"/CIRIS-linux-x64-*.jar 2>/dev/null | head -1 || true) + if [ -z "$latest" ]; then + echo "no uber-jar found in $JAR_SRC_DIR — build one:" + echo " cd \$CIRISCLIENT/client && ./gradlew :desktopApp:packageUberJarForCurrentOS" + exit 1 + fi + cp "$latest" jars/ +fi +ls -la jars/*.jar | sed 's/^/ /' + +# ── up ─────────────────────────────────────────────────────────────────────── +say "compose up" +[ "${SKIP_BUILD:-0}" = "1" ] || docker compose build +docker compose up -d +trap '[ "${KEEP:-0}" = "1" ] || { echo; echo "── tearing down ──"; docker compose down -v >/dev/null 2>&1 || true; }' EXIT + +say "waiting for both UIs to render" +for port in 9101 9102; do + ok=0 + for i in $(seq 1 90); do + n=$(curl -sf --max-time 3 "http://127.0.0.1:$port/tree" 2>/dev/null \ + | python3 -c 'import json,sys;print(json.load(sys.stdin).get("count",0))' 2>/dev/null || echo 0) + if [ "${n:-0}" -gt 0 ]; then echo " :$port rendering after ~$((i*4))s"; ok=1; break; fi + sleep 4 + done + if [ "$ok" != 1 ]; then + echo " ✗ :$port never rendered. Container logs:" + docker compose logs --tail 40 + exit 1 + fi +done + +# ── drive ──────────────────────────────────────────────────────────────────── +say "driving the UI" +set +e +python3 drive.py --a-ui 9101 --b-ui 9102 --a-api 14243 --b-api 24243 +rc=$? +set -e + +say "evidence" +for svc in canonical node-a node-b; do + echo " [$svc] $(docker compose logs --tail 3 "$svc" 2>/dev/null | tr '\n' ' ' | cut -c1-150)" +done + +exit $rc From efef1d9d84e24bb9acfdb43ca470410da327fb85 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 31 Aug 2026 23:04:58 -0500 Subject: [PATCH 2/2] ui-chat: stranger contact starts from a node code, not a directory lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver had a `discover:a_sees_b_fedid` stage that waited for node A to learn node B's owner fed-id by discovery, and reported it as blocked on CIRISEdge#552. That was a misreading of the boundary. CIRISServer#524 §6.3: "Stranger contact is meant to start from a nodecode, not a directory lookup. You hand out an identifier out-of-band, the peer dials that specific node (which serves its own record), and consent follows. Building 'search the federation for a person' on top of `discover` will work only for people you already have a consented relationship with — that is the boundary, not a gap to route around." So the stage was not waiting for a fix. It asked the substrate for an address-book lookup it refuses by design, because answering a third-party probe "would make a body-holding server an address-book oracle for records it never advertised" (§6.1). Left as it was, the harness would have re-run against v18.12.1, failed identically, and blamed the release. The flow now matches the product: each side reads its own node code, the harness carries it across — that carrying IS the out-of-band channel, exactly as a person handing over a code is — `add_peer_by_code` admits the peer through the UI's NetworkPeers sheet, and only then is the owner behind that node resolvable. The receive-axis widening still matters: it is what makes the by-name Pull legal once a code names the peer. It was never going to be sufficient alone, which is the part I had wrong. Stages after the hand-off need a wheel built against edge v18.12.1 to run end to end; the pins move in CIRISServer#525. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017gxnxsTWS8FkN3CT2JEz86 --- harness/ui-chat/README.md | 42 +++++++++++++++++-------- harness/ui-chat/drive.py | 65 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/harness/ui-chat/README.md b/harness/ui-chat/README.md index 334624c4..b09ef93d 100644 --- a/harness/ui-chat/README.md +++ b/harness/ui-chat/README.md @@ -28,22 +28,40 @@ place. A container per node gives each its own netns, so its node genuinely *is* needs patching and no node moves off the default ports, which is also how a real install looks. -## Status: BLOCKED at `discover:a_sees_b_fedid` +## Status Passing: `first_run` ×2 (wizard, auto-claim from `/claim_pin`), `restart:for_announce_optin`, `login` ×2. -Blocked: both nodes discover each other's **node** keys and the canonical; -neither ever obtains the other owner's **fed-id**, so `add_contact` refuses -`contacts.unknown_fed_id`. The canonical's own directory shows both node keys and -neither owner. - -That is CIRISEdge#552's cell: `Key` already serves `public` on the advertise axis -but the RECEIVE axis was `subject_pull:data_subject; subject-only`, which made it -*"un-addressable, not undisclosed"* — `Pull` is the only by-name read, and a node -cannot compute the hash of a record it has never held. CIRISEdge#556 widens it to -`data_subject+any_attributed`. **This harness is waiting on an edge release -carrying that** (CIRISServer#522, the `SERVE_ADVERTISE_POLICY_HASH` re-pin). +Not yet run end to end: the stages after the node-code hand-off need a wheel +built against edge **v18.12.1**, whose receive-axis widening (CIRISEdge#556) is +what lets a by-name `Pull` fetch the owner record behind an admitted node. + +### The stage that was wrong, and what replaced it + +An earlier revision had a `discover:a_sees_b_fedid` stage that waited for node A +to learn node B's owner fed-id by discovery, and reported it as blocked on +CIRISEdge#552. That was a misreading. CIRISServer#524 §6.3: + +> Stranger contact is meant to start from a nodecode, not a directory lookup. +> You hand out an identifier out-of-band, the peer dials that specific node +> (which serves its own record), and consent follows. Building "search the +> federation for a person" on top of `discover` will work only for people you +> already have a consented relationship with — **that is the boundary, not a gap +> to route around.** + +So the stage was not waiting on a fix; it was asking the substrate for an +address-book lookup it refuses on purpose — §6.1: answering a third-party probe +"would make a body-holding server an address-book oracle for records it never +advertised". + +The flow now matches the product: each side reads its own **node code**, the +harness carries it across (that carrying IS the out-of-band channel, exactly as +a person handing over a code is), `add_peer_by_code` admits it, and only then is +the owner behind that node resolvable. The widening still matters — it is what +makes the by-name fetch legal once a code names the peer — but it was never +going to be sufficient on its own. + ## What it found on the way diff --git a/harness/ui-chat/drive.py b/harness/ui-chat/drive.py index aeb828bf..0f7f5579 100755 --- a/harness/ui-chat/drive.py +++ b/harness/ui-chat/drive.py @@ -171,6 +171,55 @@ def click_if_present(self, tag: str) -> bool: return False +def node_code(read_api_port: int) -> str: + """One side's node code — the identifier a person hands over out of band. + + Read over HTTP here, which is what a person does by reading it off their own + screen. The out-of-band part is not the transport; it is that the OTHER side + learns it from the person rather than from the federation. That distinction + is the design, and CIRISServer#524 §6.3 rules it: + + "Stranger contact is meant to start from a nodecode, not a directory + lookup. You hand out an identifier out-of-band, the peer dials that + specific node (which serves its own record), and consent follows. + Building 'search the federation for a person' on top of `discover` will + work only for people you already have a consented relationship with — + that is the boundary, not a gap to route around." + + An earlier revision of this driver waited for discovery to deliver the peer's + fed-id and called that wait a blocked stage. It was not blocked: it asked the + substrate for an address-book lookup it refuses by design, because answering + a third-party probe "would make a body-holding server an address-book oracle + for records it never advertised" (§6.1). + """ + url = f"http://127.0.0.1:{read_api_port}/v1/federation/node-code" + with urllib.request.urlopen(url, timeout=10) as r: + payload = json.loads(r.read().decode()) + code = payload.get("code") or "" + if not code: + raise UiError(f"no node code at :{read_api_port} — payload keys {sorted(payload)}") + return code + + +def add_peer_by_code(ui: Ui, code: str) -> None: + """Admit a peer from the code they handed you — the stranger-contact entry. + + This is what makes the peer's own record fetchable: you dial THAT node and it + serves its own record. Everything downstream — learning the owner behind it, + adding the contact, consent — hangs off having done this first. + """ + ui.click_if_present("nav_epistemic_network_ops") + ui.wait_for_tag("btn_add_peer", secs=90) + ui.click("btn_add_peer") + ui.wait_for_tag("input_add_peer_code", secs=30) + ui.type_into("input_add_peer_code", code) + ui.click("btn_add_peer_submit") + time.sleep(8) + if "text_add_peer_error" in ui.tags(): + raise UiError(f"{ui.name}: peer add refused (see text_add_peer_error)") + log(ui.name, "peer admitted from the handed-over code") + + def wait_for_key(ui: Ui, api_port: int, key_id: str, secs: int = 240) -> None: """Wait for a peer's FED-ID to reach this node's federation directory. @@ -510,8 +559,20 @@ def stage(name, fn): # entirely — node-to-node delivery wearing a person-to-person label. The # fed-id being absent is the DEFECT, so it is waited for and then # reported, never substituted. - stage("discover:a_sees_b_fedid", lambda: wait_for_key(a, args.a_api, b_owner)) - stage("discover:b_sees_a_fedid", lambda: wait_for_key(b, args.b_api, a_owner)) + # THE OUT-OF-BAND HAND-OFF. Each side gets the other's node code the way a + # person would — handed over, not discovered. The harness carrying the + # string between the two UIs IS the out-of-band channel. + print("── exchange node codes (the stranger-contact entry) ──") + a_code, b_code = node_code(args.a_api), node_code(args.b_api) + log("handoff", f"A code {a_code[:24]}… B code {b_code[:24]}…") + stage("peer:a_admits_b", lambda: add_peer_by_code(a, b_code)) + stage("peer:b_admits_a", lambda: add_peer_by_code(b, a_code)) + + # Only now is the peer's record reachable — and only now can the by-name + # Pull that CIRISEdge#556 widened fetch the owner behind it. + print("── the owner behind each admitted node ──") + stage("resolve:a_sees_b_fedid", lambda: wait_for_key(a, args.a_api, b_owner)) + stage("resolve:b_sees_a_fedid", lambda: wait_for_key(b, args.b_api, a_owner)) a_types, b_types = b_owner, a_owner print("── contact + consent, through the UI ──")