Skip to content

Commit 0fd77fa

Browse files
Zawwarsami16claude
andcommitted
phase 7.0: capability-only WebSocket — devices untethered from any one AI
new endpoint WS /ws/expose. devices register WITHOUT a publisher's api_key — hub mints a dx_... device key on first registration, stores in new sqlite table `exposures(exposure_id, name, manifest_json, device_key_hash, first_seen, last_seen)`. re-registration with the same device_key restores the same exposure_id (persistence). new SDK function zhub.expose(name, capabilities, public=True) mirrors connect()'s shape but is AI-untethered. handlers receive invoke-request envelopes the same way connect() handlers do. new HTTP surfaces: GET /exposures public-flagged exposures only, no auth, returns name + caps + uptime for discovery. POST /exposures/<exposure_id>/invoke auth: any registered publisher's zk_ bearer key. body: {capability, args}. args validated against the exposure's declared json schema (same plumbing as /v1/invoke). same envelope unwrap as the connection-side invoke path. zhub now has three peer roles: publisher (the AI), connection (paired client), exposure (untethered device). exposures are the "USB peripherals" of the hub — register once, available to every AI on it. tests: 11 new (5 persistence + 6 e2e via real WS+httpx). 141/141 pytest now. spec at docs/superpowers/specs/2026-05-10-zhub-phase-7.0-capability-only-ws-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ab89b0 commit 0fd77fa

9 files changed

Lines changed: 767 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ Tests: `pytest -v`. The e2e tests spin up the hub in-process and run the full pu
9292
- **Phase 5.2** ✅ — Entity expansion: added `## install`, `## up`, `## paths` sections to `entity.md`. Header notes the file ships with the package so an AI installing zhub can read it locally before any hub is running.
9393
- **Phase 5.3** ✅ — `AnthropicAdapter` brain (5th adapter). Anthropic Messages API has its own SSE shape (`event:`/`data:` pairs, `content_block_delta` with `text_delta`); adapter normalizes to `ChatChunk` like the others. Registered in REGISTRY after Cerebras.
9494
- **Phase 6.0** ✅ — Production-readiness pack: structured access logs at `zhub.access` logger (one line per request: status + method + path + latency_ms + ai_name when applicable); per-AI latency tracking surfaced in `/metrics` (`request_count`, `total_latency_ms`, `max_latency_ms`, `avg_latency_ms`); `python -m zhub up --tunnel-name <name>` for cloudflared *named* tunnels (stable URL across restarts); README gets a mermaid arch diagram; new `docs/DEPLOY.md` walkthrough for a $5 VPS deployment with systemd unit files for hub + named tunnel.
95+
- **Phase 7.0** ✅ — Capability-only WebSocket (`/ws/expose`). Devices register tools without pairing to any specific AI; hub mints `dx_` device key + `ex_` exposure id, persists across restarts. `GET /exposures` (public discovery), `POST /exposures/<id>/invoke` (any registered publisher's `zk_` key authorizes; same JSON-Schema validation as `/v1/invoke`). New SDK function `zhub.expose(name, capabilities, public=True)` mirrors `connect()` shape but is AI-untethered. The "USB peripherals for AIs on the hub" primitive — register once, available to all.
9596

96-
**Next (not started):** true tool_call delta streaming through SSE (4.2b), multi-tier API keys, capability-only WS connections (a "tool provider" usable by any AI on the hub, not tied to one publisher), MCP resources/prompts surface.
97+
**Next (not started):** true tool_call delta streaming through SSE (4.2b), multi-tier API keys, MCP resources/prompts surface, more brain adapters (Cohere/Mistral/Together/Bedrock/Vertex).
9798

9899
## 6. File layout (what's where)
99100

tests/test_exposures.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""Phase 7.0 — capability-only exposures end-to-end.
2+
3+
A device registers via /ws/expose without being paired to any AI.
4+
Any registered publisher's bearer key can invoke its capabilities.
5+
"""
6+
7+
import asyncio
8+
import socket
9+
import threading
10+
import time
11+
12+
import pytest
13+
14+
try:
15+
import fastapi # noqa
16+
import uvicorn # noqa
17+
import httpx # noqa
18+
DEPS_AVAILABLE = True
19+
except ImportError:
20+
DEPS_AVAILABLE = False
21+
22+
if DEPS_AVAILABLE:
23+
from zhub.server import create_app
24+
from zhub import publish, expose
25+
26+
27+
def _free_port() -> int:
28+
with socket.socket() as s:
29+
s.bind(("", 0))
30+
return s.getsockname()[1]
31+
32+
33+
@pytest.fixture
34+
def hub(tmp_path):
35+
if not DEPS_AVAILABLE:
36+
pytest.skip("fastapi/uvicorn/httpx not installed")
37+
port = _free_port()
38+
db_path = str(tmp_path / "exp.db")
39+
app = create_app(db_path=db_path)
40+
41+
def run():
42+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
43+
log_level="warning")
44+
asyncio.run(uvicorn.Server(config).serve())
45+
46+
threading.Thread(target=run, daemon=True).start()
47+
for _ in range(30):
48+
try:
49+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
50+
break
51+
except OSError:
52+
time.sleep(0.1)
53+
yield port
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_register_exposure_returns_id_and_key(hub):
58+
"""expose() registers the device and the hub returns an exposure_id
59+
+ dx_ device key."""
60+
exp = expose(
61+
name="weather-sensor",
62+
capabilities={
63+
"weather_lookup": (
64+
{"type": "object",
65+
"properties": {"city": {"type": "string"}},
66+
"required": ["city"]},
67+
lambda args: {"temp": 22, "city": args["city"]},
68+
),
69+
},
70+
hub_url=f"ws://127.0.0.1:{hub}",
71+
public=True,
72+
)
73+
for _ in range(50):
74+
if exp.exposure_id and exp.device_key:
75+
break
76+
await asyncio.sleep(0.1)
77+
assert exp.exposure_id.startswith("ex_"), f"got {exp.exposure_id!r}"
78+
assert exp.device_key.startswith("dx_"), f"got {exp.device_key!r}"
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_get_exposures_lists_public_only(hub):
83+
e_pub = expose(
84+
name="weather-public",
85+
capabilities={"weather_lookup": ({"type": "object"}, lambda a: {})},
86+
hub_url=f"ws://127.0.0.1:{hub}",
87+
public=True,
88+
)
89+
e_priv = expose(
90+
name="secret-camera",
91+
capabilities={"take_photo": ({"type": "object"}, lambda a: {})},
92+
hub_url=f"ws://127.0.0.1:{hub}",
93+
public=False,
94+
)
95+
for _ in range(50):
96+
if e_pub.exposure_id and e_priv.exposure_id:
97+
break
98+
await asyncio.sleep(0.1)
99+
100+
async with httpx.AsyncClient(timeout=5.0) as c:
101+
listing = (await c.get(f"http://127.0.0.1:{hub}/exposures")).json()
102+
103+
names = {e["name"] for e in listing}
104+
assert "weather-public" in names
105+
assert "secret-camera" not in names
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_invoke_exposure_via_publisher_key(hub):
110+
"""A registered publisher's zk_ key authorizes invoking any exposure."""
111+
captured: dict = {}
112+
e = expose(
113+
name="weather",
114+
capabilities={
115+
"weather_lookup": (
116+
{"type": "object", "required": ["city"],
117+
"properties": {"city": {"type": "string"}}},
118+
lambda a: {"city": a["city"], "temp": 18,
119+
"_captured": captured.update(a) or True},
120+
),
121+
},
122+
hub_url=f"ws://127.0.0.1:{hub}",
123+
public=True,
124+
)
125+
for _ in range(50):
126+
if e.exposure_id:
127+
break
128+
await asyncio.sleep(0.1)
129+
130+
pub = publish(
131+
name="some-ai",
132+
description="any AI",
133+
chat_handler=lambda m, o: "ok",
134+
hub_url=f"ws://127.0.0.1:{hub}",
135+
)
136+
for _ in range(50):
137+
if pub.api_key:
138+
break
139+
await asyncio.sleep(0.1)
140+
141+
async with httpx.AsyncClient(timeout=5.0) as c:
142+
resp = await c.post(
143+
f"http://127.0.0.1:{hub}/exposures/{e.exposure_id}/invoke",
144+
json={"capability": "weather_lookup", "args": {"city": "Mississauga"}},
145+
headers={"Authorization": f"Bearer {pub.api_key}"},
146+
)
147+
assert resp.status_code == 200, resp.text
148+
body = resp.json()
149+
assert body["ok"] is True
150+
assert body["result"]["city"] == "Mississauga"
151+
assert body["result"]["temp"] == 18
152+
assert captured == {"city": "Mississauga"}
153+
154+
155+
@pytest.mark.asyncio
156+
async def test_invoke_rejects_without_publisher_key(hub):
157+
e = expose(
158+
name="x",
159+
capabilities={"do_x": ({"type": "object"}, lambda a: {"ok": True})},
160+
hub_url=f"ws://127.0.0.1:{hub}",
161+
public=True,
162+
)
163+
for _ in range(50):
164+
if e.exposure_id:
165+
break
166+
await asyncio.sleep(0.1)
167+
168+
async with httpx.AsyncClient(timeout=5.0) as c:
169+
# No bearer
170+
r = await c.post(
171+
f"http://127.0.0.1:{hub}/exposures/{e.exposure_id}/invoke",
172+
json={"capability": "do_x", "args": {}},
173+
)
174+
assert r.status_code == 401
175+
176+
177+
@pytest.mark.asyncio
178+
async def test_invoke_404_for_unknown_exposure(hub):
179+
pub = publish(
180+
name="auth-source",
181+
description="x",
182+
chat_handler=lambda m, o: "ok",
183+
hub_url=f"ws://127.0.0.1:{hub}",
184+
)
185+
for _ in range(50):
186+
if pub.api_key:
187+
break
188+
await asyncio.sleep(0.1)
189+
190+
async with httpx.AsyncClient(timeout=5.0) as c:
191+
r = await c.post(
192+
f"http://127.0.0.1:{hub}/exposures/ex_nope/invoke",
193+
json={"capability": "x", "args": {}},
194+
headers={"Authorization": f"Bearer {pub.api_key}"},
195+
)
196+
assert r.status_code == 404
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_invoke_validates_args_against_schema(hub):
201+
e = expose(
202+
name="strict",
203+
capabilities={
204+
"strict_op": (
205+
{"type": "object", "required": ["needed"],
206+
"properties": {"needed": {"type": "string"}}},
207+
lambda a: {"got": a},
208+
),
209+
},
210+
hub_url=f"ws://127.0.0.1:{hub}",
211+
public=True,
212+
)
213+
for _ in range(50):
214+
if e.exposure_id:
215+
break
216+
await asyncio.sleep(0.1)
217+
218+
pub = publish(
219+
name="caller",
220+
description="x",
221+
chat_handler=lambda m, o: "ok",
222+
hub_url=f"ws://127.0.0.1:{hub}",
223+
)
224+
for _ in range(50):
225+
if pub.api_key:
226+
break
227+
await asyncio.sleep(0.1)
228+
229+
async with httpx.AsyncClient(timeout=5.0) as c:
230+
r = await c.post(
231+
f"http://127.0.0.1:{hub}/exposures/{e.exposure_id}/invoke",
232+
json={"capability": "strict_op", "args": {}},
233+
headers={"Authorization": f"Bearer {pub.api_key}"},
234+
)
235+
assert r.status_code == 400
236+
assert "needed" in r.text.lower()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Phase 7.0 — SQLite storage for capability-only exposures."""
2+
3+
import pytest
4+
5+
from zhub.persistence import Storage, hash_key
6+
7+
8+
@pytest.fixture
9+
def store(tmp_path):
10+
return Storage(tmp_path / "test.db")
11+
12+
13+
def test_add_exposure_returns_id_and_persists(store):
14+
eid = store.add_exposure(
15+
name="weather-sensor",
16+
manifest={"capabilities": [{"name": "weather_lookup"}], "public": True},
17+
device_key_hash=hash_key("dx_secret"),
18+
)
19+
assert eid.startswith("ex_")
20+
rows = store.all_exposures()
21+
assert len(rows) == 1
22+
e = rows[0]
23+
assert e["exposure_id"] == eid
24+
assert e["name"] == "weather-sensor"
25+
assert e["manifest"]["public"] is True
26+
assert e["device_key_hash"] == hash_key("dx_secret")
27+
28+
29+
def test_lookup_exposure_by_id(store):
30+
eid = store.add_exposure("x", {}, hash_key("dx_a"))
31+
found = store.lookup_exposure(eid)
32+
assert found is not None
33+
assert found["exposure_id"] == eid
34+
35+
36+
def test_lookup_exposure_by_device_key(store):
37+
eid = store.add_exposure("x", {}, hash_key("dx_unique"))
38+
found = store.lookup_exposure_by_key_hash(hash_key("dx_unique"))
39+
assert found is not None
40+
assert found["exposure_id"] == eid
41+
42+
43+
def test_remove_exposure(store):
44+
eid = store.add_exposure("x", {}, hash_key("dx_y"))
45+
store.remove_exposure(eid)
46+
assert store.lookup_exposure(eid) is None
47+
assert store.all_exposures() == []
48+
49+
50+
def test_exposures_persist_across_reopen(tmp_path):
51+
db = tmp_path / "p.db"
52+
s1 = Storage(db)
53+
eid = s1.add_exposure("x", {}, hash_key("dx_z"))
54+
s1.close()
55+
s2 = Storage(db)
56+
assert s2.lookup_exposure(eid) is not None

zhub/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
conn.run_forever()
4242
"""
4343

44-
from .client import publish, connect, ZhubPublication, ZhubConnection
44+
from .client import (
45+
publish, connect, expose, ZhubPublication, ZhubConnection, ZhubExposure,
46+
)
4547
from .manifest import Manifest, Capability
4648
from .errors import ZhubError, AuthError, ConnectionError as ZhubConnectionError
4749

@@ -58,8 +60,10 @@
5860
__all__ = [
5961
"publish",
6062
"connect",
63+
"expose",
6164
"ZhubPublication",
6265
"ZhubConnection",
66+
"ZhubExposure",
6367
"Manifest",
6468
"Capability",
6569
"ZhubError",

0 commit comments

Comments
 (0)