acecalisto3 commited on
Commit
8848e5c
·
verified ·
1 Parent(s): cdc8948

Submesh 0.5 - header carry + shared submesh freq + op_count + Scan UI + exo-structure docs

Browse files
Files changed (2) hide show
  1. PAPER.md +69 -0
  2. core/submesh.py +400 -67
PAPER.md CHANGED
@@ -340,3 +340,72 @@ assignment as the natural next evolution.
340
  ---
341
 
342
  *Submitted for review. Benchmark code and implementation: https://github.com/Ig0tU/SignalMesh*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  ---
341
 
342
  *Submitted for review. Benchmark code and implementation: https://github.com/Ig0tU/SignalMesh*
343
+
344
+
345
+ ---
346
+
347
+ ## Addendum: Runtime Without The Runtime
348
+
349
+ ### Exo-Structure
350
+
351
+ The Submesh Protocol wraps any origin — a URL, a hosted web app, later a local
352
+ binary or repo — from the OUTSIDE. Nothing is embedded, hosted, or shipped. The
353
+ target service is untouched; only its outside gets a handle. The mesh points
354
+ AT the runtime, wearing it like a glove.
355
+
356
+ We call this pattern **exo-structure**: an external skeleton around software.
357
+ It gives you coord-addressable handles into a service's behavior without
358
+ containing the service itself. Docker, K8s, Lambda, and WASM all EMBED runtime —
359
+ you host, ship, permission, and update the thing. Submesh does the opposite:
360
+ it points AT the thing from outside, at zero cost to the target. GitHub
361
+ doesn't know it's wrapped. Hyperagent won't either.
362
+
363
+ ### State plane, Execution plane, Auth plane — all exo
364
+
365
+ SignalMesh already inverted STATE — context without a context server,
366
+ broadcast/tune without a message broker, coordinates without a service
367
+ registry. That was exo-structure for state.
368
+
369
+ The Submesh extends the inversion to EXECUTION. A runtime's behavior is
370
+ available without ever running it: `POST /api/submesh/wrap` reads the target's
371
+ `/openapi.json` (or accepts a user-provided manifest) and emits one coord per
372
+ operation. `POST /api/submesh/call` piggybacks that coord to invoke the real
373
+ service and streams the result envelope back onto the mesh — where any tuned
374
+ agent receives it without polling.
375
+
376
+ Auth is exo too. `POST /api/submesh/session` attaches cookies AND/OR headers
377
+ scoped to an origin. On `/call`, the mesh injects them into the outbound
378
+ request — same trust boundary as an autofilled Submit Payment: user-triggered,
379
+ local-side, and the credential the target already accepted IS the credential.
380
+ Values live in memory only; only 8-char hashes are retained for provenance.
381
+
382
+ ### Fractal
383
+
384
+ Every wrapped origin is itself reachable through the mesh, and can in turn
385
+ host its own submesh, which can be wrapped, ad infinitum. The self-wrap demo
386
+ in Phase 0 proved this: the SignalMesh Space wrapped itself, generated 54
387
+ coord-handles into its own routes, and called back into itself through those
388
+ coords. Exo on exo. No new infrastructure at any layer.
389
+
390
+ ### Contract
391
+
392
+ - Discovery: `POST /api/submesh/wrap { origin, cookies?, headers?, manifest? }` returns coord list.
393
+ - Session attach: `POST /api/submesh/session { origin, cookies?, headers? }` scopes credentials to origin.
394
+ - Invoke: `POST /api/submesh/call { coord, input, cookies?, headers? }` executes a real HTTP round-trip and broadcasts the result envelope.
395
+ - Ambient reception: `GET /api/submesh/stream` (SSE) or `POST /api/tune_in { keywords: ["submesh"] }`.
396
+ - Introspection: `GET /api/submesh/nodes`, `GET /api/submesh/health`.
397
+ - Scan UI: `GET /api/submesh/ui` — paste-based session sync page.
398
+
399
+ All under the existing `X-SignalMesh-Key` gate. Free tier: `smesh-free-demo`
400
+ (200 calls / 24h). Paid: personal key, unlimited.
401
+
402
+ ### What this unlocks
403
+
404
+ - Any hosted service becomes agent-callable through the SAME code path as any
405
+ other — no per-service adapter written by hand.
406
+ - A user's authenticated browsing surface becomes an agent's callable surface:
407
+ same threads, same repos, same private views, no re-auth ritual.
408
+ - Fleet-scale ambient sharing of every call result: one agent calls, all tuned
409
+ agents hear the envelope, no polling.
410
+ - The service-integration long tail collapses to a Scan click and a POST.
411
+
core/submesh.py CHANGED
@@ -1,15 +1,42 @@
1
  """
2
- SignalMesh — Submesh Protocol (Phase 0)
3
-
4
- Wraps any origin (URL / web-app; later: local binary / repo via nicoli) into
5
- coordinate-addressable tools on the mesh. Browser-session cookies for the
6
- origin are stored scoped to that origin and passed through as request headers
7
- on `/api/submesh/call`same trust model as an autofilled Submit Payment.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  Additive only: no changes to existing broadcast/ingest/manifest handlers.
10
  Mounted from signalmesh_api.py via `include_router(make_router(...))`.
11
 
12
- Author: Ig0tU (SignalMesh) — Submesh Protocol Phase 0
 
 
 
 
 
 
 
 
 
13
  """
14
  from __future__ import annotations
15
 
@@ -23,13 +50,16 @@ from urllib.parse import urlparse
23
 
24
  import httpx
25
  from fastapi import APIRouter, Header, HTTPException
26
- from fastapi.responses import StreamingResponse
27
  from pydantic import BaseModel
28
 
 
 
 
29
  # ── In-memory Phase-0 stores (SQLite promotion planned for Phase 1) ──────────
30
  _NODES: Dict[str, dict] = {} # origin_slug -> node record
31
  _COORDS: Dict[str, dict] = {} # coord -> {origin_slug, op}
32
- _SESSIONS: Dict[str, dict] = {} # origin_slug -> {cookies, expires, hashes}
33
  _LOG: List[dict] = [] # rolling event log for /stream
34
  _LOG_MAX = 500
35
 
@@ -39,6 +69,7 @@ class WrapReq(BaseModel):
39
  origin: str
40
  kind: str = "url" # "url" | "manifest" | "nicoli" (Phase 0.5)
41
  cookies: Any = None # list[dict] | dict | JSON str | Netscape text
 
42
  manifest: Optional[dict] = None
43
  scan_openapi: bool = True # try /openapi.json when kind=url
44
 
@@ -47,11 +78,13 @@ class CallReq(BaseModel):
47
  coord: str
48
  input: dict = {}
49
  cookies: Any = None # per-call override; else pulls from session store
 
50
 
51
 
52
  class SessionReq(BaseModel):
53
  origin: str
54
- cookies: Any
 
55
  ttl_ms: int = 24 * 3600 * 1000 # default 24h
56
 
57
  # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -100,10 +133,43 @@ def _normalize_cookies(raw: Any) -> List[dict]:
100
  return []
101
 
102
 
103
- def _cookie_hashes(cookies: List[dict]) -> List[str]:
104
- """Provenance only cookie VALUES never leave memory / never logged."""
105
- return [hashlib.sha256(f"{c.get('name','')}:{c.get('value','')}".encode()).hexdigest()[:8]
106
- for c in cookies]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
 
109
  async def _fetch_openapi(base: str) -> Optional[dict]:
@@ -175,6 +241,252 @@ def _default_url_ops(slug: str, base: str) -> List[dict]:
175
  "requires_body": False,
176
  }]
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  # ── Router factory ────────────────────────────────────────────────────────────
179
 
180
  def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
@@ -196,6 +508,11 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
196
  "events": len(_LOG),
197
  }
198
 
 
 
 
 
 
199
  @router.post("/wrap", summary="Wrap an origin into coordinate-addressable tools")
200
  async def wrap(req: WrapReq, x_signalmesh_key: Optional[str] = Header(default=None)):
201
  check_key(x_signalmesh_key)
@@ -230,48 +547,51 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
230
  ops = _default_url_ops(slug, base)
231
  source = "default"
232
 
233
- # Wrap-time cookies (optional; can also be sent later via /session)
234
  cookies = _normalize_cookies(req.cookies)
235
- if cookies:
236
- _SESSIONS[slug] = {
237
- "cookies": cookies,
238
- "expires": time.time() + 86400,
239
- "hashes": _cookie_hashes(cookies),
240
- }
241
 
242
  _NODES[slug] = {
243
  "origin": origin,
244
  "base": base,
245
  "slug": slug,
246
  "ops": ops,
 
247
  "source": source,
248
- "authed": bool(cookies),
249
  "wrapped_at": time.time(),
250
  }
251
  for op in ops:
252
  _COORDS[op["coord"]] = {"origin_slug": slug, "op": op}
253
 
254
- # Broadcast node registration onto the mesh — tuned agents pick it up
255
- try:
256
- signal_registry.broadcast(
257
- name=f"submesh.node.{slug}",
258
- source_type="submesh_wrap",
259
- data={"origin": origin, "coords": [o["coord"] for o in ops], "authed": bool(cookies)},
260
- metadata={"source": source, "op_count": len(ops)},
261
- )
262
- except Exception:
263
- pass
 
 
 
 
264
 
265
  _log({"event": "wrap", "slug": slug, "source": source,
266
- "op_count": len(ops), "authed": bool(cookies)})
267
 
268
  return {
269
  "wrapped": True,
270
  "origin": origin,
271
  "slug": slug,
272
  "source": source,
273
- "authed": bool(cookies),
274
- "cookie_hashes": _cookie_hashes(cookies) if cookies else [],
 
275
  "coords": [o["coord"] for o in ops],
276
  "op_count": len(ops),
277
  }
@@ -281,29 +601,31 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
281
  check_key(x_signalmesh_key)
282
  return {"nodes": list(_NODES.values()), "count": len(_NODES)}
283
 
284
- @router.post("/session", summary="Attach browser-session cookies to an origin")
 
285
  def session(req: SessionReq, x_signalmesh_key: Optional[str] = Header(default=None)):
286
  check_key(x_signalmesh_key)
287
  cookies = _normalize_cookies(req.cookies)
288
- if not cookies:
289
- raise HTTPException(400, "no valid cookies parsed from payload")
 
290
  slug = _slug(req.origin)
291
- _SESSIONS[slug] = {
292
- "cookies": cookies,
293
- "expires": time.time() + req.ttl_ms / 1000.0,
294
- "hashes": _cookie_hashes(cookies),
295
- }
296
  if slug in _NODES:
297
  _NODES[slug]["authed"] = True
298
- _log({"event": "session_attached", "slug": slug, "cookie_count": len(cookies)})
 
299
  return {
300
  "attached": True,
301
  "slug": slug,
302
  "cookie_count": len(cookies),
303
- "cookie_hashes": _cookie_hashes(cookies),
 
 
 
304
  }
305
 
306
- @router.post("/call", summary="Invoke a submesh coord with optional cookie carry")
307
  async def call(req: CallReq, x_signalmesh_key: Optional[str] = Header(default=None)):
308
  check_key(x_signalmesh_key)
309
  entry = _COORDS.get(req.coord)
@@ -325,12 +647,15 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
325
  if not safe:
326
  raise HTTPException(400, f"SEC-Ω blocked: {reason}")
327
 
328
- # Resolve cookies: per-call override > stored session > none
329
  cookies = _normalize_cookies(req.cookies) if req.cookies else []
330
- if not cookies:
331
- sess = _SESSIONS.get(slug)
332
- if sess and sess["expires"] > time.time():
333
- cookies = sess["cookies"]
 
 
 
334
 
335
  method = op["method"]
336
  url = op["url"]
@@ -354,10 +679,17 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
354
  except Exception:
355
  continue
356
 
 
 
 
 
 
 
 
357
  started = time.time()
358
  try:
359
- async with httpx.AsyncClient(follow_redirects=True, timeout=20.0, cookies=jar,
360
- headers={"User-Agent": "SignalMesh-Submesh/0"}) as client:
361
  r = await client.request(method, url, params=params, json=body)
362
  ct = r.headers.get("content-type", "")
363
  if "application/json" in ct:
@@ -368,8 +700,9 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
368
  "coord": req.coord,
369
  "origin_slug": slug,
370
  "status": r.status_code,
371
- "authed": bool(cookies),
372
- "cookie_hashes": _cookie_hashes(cookies),
 
373
  "payload_type": payload_type,
374
  "payload": payload,
375
  "elapsed_ms": int((time.time() - started) * 1000),
@@ -381,23 +714,23 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
381
  "origin_slug": slug,
382
  "status": 0,
383
  "error": str(e),
384
- "authed": bool(cookies),
385
  "elapsed_ms": int((time.time() - started) * 1000),
386
  }
387
 
388
- # Broadcast the result envelope tuned agents receive without polling
389
- try:
390
- signal_registry.broadcast(
391
- name=f"submesh.call.{slug}",
392
- source_type="submesh_call",
393
- data=envelope,
394
- metadata={"coord": req.coord, "authed": bool(cookies)},
395
- )
396
- except Exception:
397
- pass
398
 
399
  _log({"event": "call", "coord": req.coord,
400
- "status": envelope.get("status"), "authed": bool(cookies)})
 
401
 
402
  return envelope
403
 
 
1
  """
2
+ SignalMesh — Submesh Protocol
3
+ Exo-structure: a runtime's behavior without ever running it.
4
+
5
+ The Submesh wraps any origin (URL / web-app; later: local binary / repo via
6
+ nicoli) into coordinate-addressable tools on the mesh. Nothing is embedded,
7
+ hosted, or shipped the target service is untouched; only its outside gets
8
+ a handle. The mesh points AT the runtime from outside, wearing it like a
9
+ glove.
10
+
11
+ Auth carry (cookies + headers) is how the exo-structure inherits the user's
12
+ reach — its skin is the browser's skin. Same trust model as an autofilled
13
+ Submit Payment: user-triggered, local-side, and the credential the target
14
+ already accepted IS the credential.
15
+
16
+ SignalMesh already inverted STATE (context without a context server,
17
+ broadcast/tune without a message broker, coordinates without a service
18
+ registry). The Submesh extends the same inversion to EXECUTION — you get
19
+ a runtime's behavior without ever running it. State plane exo, execution
20
+ plane exo, auth plane exo. Full skeleton, no viscera.
21
+
22
+ Contrast: Docker / K8s / Lambda / WASM all EMBED runtime — you host, ship,
23
+ permission, and update the thing. Submesh points AT the thing from outside,
24
+ at zero cost to the target. GitHub doesn't know it's wrapped. Hyperagent
25
+ won't either.
26
 
27
  Additive only: no changes to existing broadcast/ingest/manifest handlers.
28
  Mounted from signalmesh_api.py via `include_router(make_router(...))`.
29
 
30
+ Endpoints:
31
+ GET /api/submesh/health — liveness + counts
32
+ GET /api/submesh/ui — paste-based Scan UI (attach session)
33
+ POST /api/submesh/wrap — wrap an origin, emit coord list
34
+ GET /api/submesh/nodes — list wrapped nodes
35
+ POST /api/submesh/session — attach cookies AND/OR headers to an origin
36
+ POST /api/submesh/call — invoke a coord with session carry
37
+ GET /api/submesh/stream — SSE stream of events
38
+
39
+ Author: Ig0tU (SignalMesh) — Submesh Protocol
40
  """
41
  from __future__ import annotations
42
 
 
50
 
51
  import httpx
52
  from fastapi import APIRouter, Header, HTTPException
53
+ from fastapi.responses import HTMLResponse, StreamingResponse
54
  from pydantic import BaseModel
55
 
56
+ # ── Shared broadcast frequency for keyword tune-in ────────────────────────────
57
+ _SUBMESH_FREQ = "submesh"
58
+
59
  # ── In-memory Phase-0 stores (SQLite promotion planned for Phase 1) ──────────
60
  _NODES: Dict[str, dict] = {} # origin_slug -> node record
61
  _COORDS: Dict[str, dict] = {} # coord -> {origin_slug, op}
62
+ _SESSIONS: Dict[str, dict] = {} # origin_slug -> {cookies, headers, expires, ...}
63
  _LOG: List[dict] = [] # rolling event log for /stream
64
  _LOG_MAX = 500
65
 
 
69
  origin: str
70
  kind: str = "url" # "url" | "manifest" | "nicoli" (Phase 0.5)
71
  cookies: Any = None # list[dict] | dict | JSON str | Netscape text
72
+ headers: Any = None # dict | list[{name,value}] | JSON str | "H: v" lines
73
  manifest: Optional[dict] = None
74
  scan_openapi: bool = True # try /openapi.json when kind=url
75
 
 
78
  coord: str
79
  input: dict = {}
80
  cookies: Any = None # per-call override; else pulls from session store
81
+ headers: Any = None # per-call override; else pulls from session store
82
 
83
 
84
  class SessionReq(BaseModel):
85
  origin: str
86
+ cookies: Any = None
87
+ headers: Any = None
88
  ttl_ms: int = 24 * 3600 * 1000 # default 24h
89
 
90
  # ── Helpers ───────────────────────────────────────────────────────────────────
 
133
  return []
134
 
135
 
136
+ def _normalize_headers(raw: Any) -> List[dict]:
137
+ """Accept dict | list[{name,value}] | JSON str | 'H: v' lines. Return normalized list."""
138
+ if raw is None:
139
+ return []
140
+ if isinstance(raw, dict):
141
+ return [{"name": str(k), "value": str(v)} for k, v in raw.items()]
142
+ if isinstance(raw, list):
143
+ out: List[dict] = []
144
+ for h in raw:
145
+ if isinstance(h, dict) and "name" in h and "value" in h:
146
+ out.append({"name": str(h["name"]), "value": str(h["value"])})
147
+ return out
148
+ if isinstance(raw, str):
149
+ s = raw.strip()
150
+ if s.startswith("{") or s.startswith("["):
151
+ try:
152
+ return _normalize_headers(json.loads(s))
153
+ except Exception:
154
+ pass
155
+ out: List[dict] = []
156
+ for line in s.splitlines():
157
+ line = line.strip()
158
+ if not line or line.startswith("#"):
159
+ continue
160
+ if ":" in line:
161
+ name, _, value = line.partition(":")
162
+ name, value = name.strip(), value.strip()
163
+ if name and value:
164
+ out.append({"name": name, "value": value})
165
+ return out
166
+ return []
167
+
168
+
169
+ def _pair_hashes(pairs: List[dict]) -> List[str]:
170
+ """Provenance only — cookie/header VALUES never leave memory / never logged."""
171
+ return [hashlib.sha256(f"{p.get('name','')}:{p.get('value','')}".encode()).hexdigest()[:8]
172
+ for p in pairs]
173
 
174
 
175
  async def _fetch_openapi(base: str) -> Optional[dict]:
 
241
  "requires_body": False,
242
  }]
243
 
244
+
245
+ def _broadcast_dual(signal_registry, per_slug_freq: str, source_type: str,
246
+ data: dict, metadata: dict) -> None:
247
+ """
248
+ Emit each event twice: once on the per-origin frequency (submesh.node.<slug>
249
+ or submesh.call.<slug>) for scoped tuning, once on the shared _SUBMESH_FREQ
250
+ ("submesh") for global keyword tune-in. Best-effort; failures swallowed.
251
+ """
252
+ for freq in (per_slug_freq, _SUBMESH_FREQ):
253
+ try:
254
+ signal_registry.broadcast(
255
+ name=freq, source_type=source_type,
256
+ data=data, metadata=metadata,
257
+ )
258
+ except Exception:
259
+ pass
260
+
261
+
262
+ def _build_session_record(cookies: List[dict], headers: List[dict], ttl_s: float) -> dict:
263
+ return {
264
+ "cookies": cookies,
265
+ "headers": headers,
266
+ "expires": time.time() + ttl_s,
267
+ "cookie_hashes": _pair_hashes(cookies),
268
+ "header_hashes": _pair_hashes(headers),
269
+ }
270
+
271
+ # ── Scan UI (paste-based session sync page) ──────────────────────────────────
272
+ # Neutral chrome / restrained palette — no generic AI gradient look.
273
+
274
+ _SCAN_UI = """<!DOCTYPE html>
275
+ <html lang="en">
276
+ <head>
277
+ <meta charset="UTF-8">
278
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
279
+ <title>SignalMesh · Submesh Scan</title>
280
+ <style>
281
+ * { margin:0; padding:0; box-sizing:border-box }
282
+ :root {
283
+ --bg:#0c0e11; --panel:#14181d; --panel2:#1a1f26; --panel3:#22282f;
284
+ --line:#242b33; --line2:#2f3742;
285
+ --text:#e7e5df; --muted:#8b929c; --dim:#5b626c;
286
+ --accent:#46d39a; --accent-dim:#2f8865;
287
+ --warn:#e0a848; --error:#d15656;
288
+ --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
289
+ --font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
290
+ }
291
+ html, body { height:100% }
292
+ body {
293
+ background:var(--bg); color:var(--text); font-family:var(--font);
294
+ min-height:100vh; padding:32px 24px;
295
+ }
296
+ .wrap { max-width:820px; margin:0 auto }
297
+ header { border-bottom:1px solid var(--line); padding-bottom:20px; margin-bottom:28px }
298
+ h1 { font-size:1.6rem; font-weight:600; letter-spacing:.2px; margin-bottom:6px }
299
+ h1 .a { color:var(--accent) }
300
+ .sub { color:var(--muted); font-size:.9rem; line-height:1.55; max-width:640px }
301
+ h2 { font-size:1rem; font-weight:600; margin:24px 0 10px; letter-spacing:.3px }
302
+ .card {
303
+ background:var(--panel); border:1px solid var(--line); border-radius:8px;
304
+ padding:20px; margin-bottom:16px;
305
+ }
306
+ label { display:block; font-size:.72rem; color:var(--muted); margin-bottom:6px;
307
+ letter-spacing:.4px; text-transform:uppercase }
308
+ input[type=text], input[type=password], textarea, select {
309
+ width:100%; background:var(--panel2); border:1px solid var(--line2); color:var(--text);
310
+ padding:10px 12px; border-radius:6px; font-family:var(--mono); font-size:.85rem;
311
+ outline:none; transition:border .15s;
312
+ }
313
+ input:focus, textarea:focus, select:focus { border-color:var(--accent) }
314
+ textarea { resize:vertical; min-height:110px; line-height:1.5 }
315
+ .hint { color:var(--dim); font-size:.75rem; margin-top:6px; line-height:1.5 }
316
+ .row { margin-bottom:16px }
317
+ .btn-row { display:flex; gap:10px; align-items:center; margin-top:6px; flex-wrap:wrap }
318
+ button {
319
+ background:var(--accent); color:#0c0e11; border:0; padding:10px 22px; border-radius:6px;
320
+ font-weight:600; font-size:.9rem; cursor:pointer; transition:background .15s;
321
+ font-family:var(--font);
322
+ }
323
+ button:hover:not(:disabled) { background:var(--accent-dim); color:var(--text) }
324
+ button:disabled { opacity:.45; cursor:not-allowed }
325
+ button.ghost { background:transparent; color:var(--muted); border:1px solid var(--line2) }
326
+ button.ghost:hover:not(:disabled) { background:var(--panel2); color:var(--text) }
327
+ #out {
328
+ background:var(--panel3); border:1px solid var(--line2); border-radius:6px;
329
+ padding:14px; font-family:var(--mono); font-size:.78rem; color:var(--muted);
330
+ white-space:pre-wrap; word-break:break-word; max-height:400px; overflow:auto;
331
+ margin-top:16px; display:none;
332
+ }
333
+ #out.ok { border-color:var(--accent-dim); color:var(--text) }
334
+ #out.err { border-color:var(--error); color:var(--warn) }
335
+ .wordmark { font-family:var(--mono); font-size:.72rem; color:var(--dim); letter-spacing:.5px }
336
+ a { color:var(--accent); text-decoration:none }
337
+ a:hover { text-decoration:underline }
338
+ kbd {
339
+ font-family:var(--mono); font-size:.72rem; background:var(--panel3);
340
+ padding:2px 6px; border-radius:3px; border:1px solid var(--line2);
341
+ }
342
+ .meta { color:var(--dim); font-size:.75rem; margin-top:8px; font-family:var(--mono) }
343
+ </style>
344
+ </head>
345
+ <body>
346
+ <div class="wrap">
347
+
348
+ <header>
349
+ <div class="wordmark">SIGNALMESH · SUBMESH</div>
350
+ <h1>Scan <span class="a">&rarr;</span> attach your session to an origin</h1>
351
+ <p class="sub">
352
+ Paste cookies (Netscape / JSON) and/or headers (KV pairs or JSON) that your browser
353
+ already carries for a target origin. The submesh stores them scoped to that origin
354
+ and pipes them through on every <kbd>POST /api/submesh/call</kbd> to matching coords.
355
+ Values live in memory only; never logged, only 8-char hashes retained for provenance.
356
+ Same trust boundary as an autofilled <em>Submit Payment</em>: user-triggered, local-side,
357
+ and the credential the target already accepted IS the credential.
358
+ </p>
359
+ </header>
360
+
361
+ <div class="card">
362
+ <h2>1. Auth key</h2>
363
+ <div class="row">
364
+ <label for="key">X-SignalMesh-Key</label>
365
+ <input type="password" id="key" placeholder="smesh-free-demo" value="smesh-free-demo"/>
366
+ <div class="hint">Your submesh API key. Defaults to the free demo tier.</div>
367
+ </div>
368
+ </div>
369
+
370
+ <div class="card">
371
+ <h2>2. Target origin</h2>
372
+ <div class="row">
373
+ <label for="origin">Origin URL</label>
374
+ <input type="text" id="origin" placeholder="https://github.com"/>
375
+ <div class="hint">The service you want the mesh to reach AS YOU. Include scheme.</div>
376
+ </div>
377
+ </div>
378
+
379
+ <div class="card">
380
+ <h2>3. Session material (cookies + / OR headers)</h2>
381
+ <div class="row">
382
+ <label for="cookies">Cookies</label>
383
+ <textarea id="cookies" placeholder='JSON: [{"name":"user_session","value":"..."},{"name":"logged_in","value":"yes"}]
384
+ Or Netscape cookies.txt: .github.com TRUE / TRUE 0 user_session abc123...
385
+ Or object: {"user_session":"abc123","logged_in":"yes"}'></textarea>
386
+ <div class="hint">
387
+ Fastest capture in Chrome/Brave DevTools: <kbd>Application &rarr; Storage &rarr; Cookies &rarr;</kbd>
388
+ your origin, copy the rows you need. Values are never logged.
389
+ </div>
390
+ </div>
391
+ <div class="row">
392
+ <label for="headers">Headers</label>
393
+ <textarea id="headers" placeholder='JSON: {"Authorization":"Bearer ghp_...","X-GitHub-Api-Version":"2022-11-28"}
394
+ Or lines: Authorization: Bearer ghp_...
395
+ X-API-Key: your_key'></textarea>
396
+ <div class="hint">
397
+ For header-based auth (GitHub Bearer, HF X-SignalMesh-Key, etc.). Same in-memory scoping.
398
+ </div>
399
+ </div>
400
+ <div class="row">
401
+ <label for="ttl">TTL (hours)</label>
402
+ <input type="text" id="ttl" placeholder="24" value="24"/>
403
+ <div class="hint">Session drops from memory after this window.</div>
404
+ </div>
405
+ </div>
406
+
407
+ <div class="btn-row">
408
+ <button id="attach">Attach session</button>
409
+ <button id="wrap" class="ghost">Wrap + attach</button>
410
+ <button id="clear" class="ghost">Clear</button>
411
+ </div>
412
+ <div class="meta" id="meta"></div>
413
+
414
+ <pre id="out"></pre>
415
+
416
+ </div>
417
+
418
+ <script>
419
+ const $ = (id) => document.getElementById(id);
420
+ const out = $('out');
421
+
422
+ function show(res, ok) {
423
+ out.style.display = 'block';
424
+ out.className = ok ? 'ok' : 'err';
425
+ try { out.textContent = JSON.stringify(res, null, 2); }
426
+ catch { out.textContent = String(res); }
427
+ }
428
+
429
+ function parseMaybeJSON(s) {
430
+ s = (s || '').trim();
431
+ if (!s) return null;
432
+ if (s.startsWith('{') || s.startsWith('[')) {
433
+ try { return JSON.parse(s); } catch { return s; }
434
+ }
435
+ return s;
436
+ }
437
+
438
+ async function post(path, body) {
439
+ const key = $('key').value.trim() || 'smesh-free-demo';
440
+ const r = await fetch(path, {
441
+ method: 'POST',
442
+ headers: {'Content-Type': 'application/json', 'X-SignalMesh-Key': key},
443
+ body: JSON.stringify(body),
444
+ });
445
+ const data = await r.json().catch(() => ({error: 'non-JSON response', status: r.status}));
446
+ return {status: r.status, body: data};
447
+ }
448
+
449
+ $('attach').addEventListener('click', async () => {
450
+ const origin = $('origin').value.trim();
451
+ if (!origin) { show({error: 'origin required'}, false); return; }
452
+ const ttl_ms = (parseFloat($('ttl').value) || 24) * 3600 * 1000;
453
+ const cookies = parseMaybeJSON($('cookies').value);
454
+ const headers = parseMaybeJSON($('headers').value);
455
+ if (!cookies && !headers) { show({error: 'paste cookies or headers'}, false); return; }
456
+ const {status, body} = await post('/api/submesh/session', {origin, cookies, headers, ttl_ms});
457
+ show(body, status < 400);
458
+ });
459
+
460
+ $('wrap').addEventListener('click', async () => {
461
+ const origin = $('origin').value.trim();
462
+ if (!origin) { show({error: 'origin required'}, false); return; }
463
+ const cookies = parseMaybeJSON($('cookies').value);
464
+ const headers = parseMaybeJSON($('headers').value);
465
+ const {status, body} = await post('/api/submesh/wrap', {origin, kind: 'url', cookies, headers});
466
+ show(body, status < 400);
467
+ });
468
+
469
+ $('clear').addEventListener('click', () => {
470
+ $('cookies').value = ''; $('headers').value = '';
471
+ out.style.display = 'none';
472
+ });
473
+
474
+ (async () => {
475
+ try {
476
+ const r = await fetch('/api/submesh/nodes',
477
+ {headers: {'X-SignalMesh-Key': 'smesh-free-demo'}});
478
+ if (r.ok) {
479
+ const d = await r.json();
480
+ $('meta').textContent = `mesh has ${d.count || 0} wrapped node(s) right now`;
481
+ }
482
+ } catch {}
483
+ })();
484
+ </script>
485
+
486
+ </body>
487
+ </html>
488
+ """
489
+
490
  # ── Router factory ────────────────────────────────────────────────────────────
491
 
492
  def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
 
508
  "events": len(_LOG),
509
  }
510
 
511
+ @router.get("/ui", response_class=HTMLResponse, include_in_schema=False)
512
+ def scan_ui():
513
+ """Paste-based Scan UI. Public HTML; POSTs from JS supply the key."""
514
+ return HTMLResponse(_SCAN_UI)
515
+
516
  @router.post("/wrap", summary="Wrap an origin into coordinate-addressable tools")
517
  async def wrap(req: WrapReq, x_signalmesh_key: Optional[str] = Header(default=None)):
518
  check_key(x_signalmesh_key)
 
547
  ops = _default_url_ops(slug, base)
548
  source = "default"
549
 
550
+ # Wrap-time session material (optional; can also be sent later via /session)
551
  cookies = _normalize_cookies(req.cookies)
552
+ headers = _normalize_headers(req.headers)
553
+ if cookies or headers:
554
+ _SESSIONS[slug] = _build_session_record(cookies, headers, 86400.0)
 
 
 
555
 
556
  _NODES[slug] = {
557
  "origin": origin,
558
  "base": base,
559
  "slug": slug,
560
  "ops": ops,
561
+ "op_count": len(ops),
562
  "source": source,
563
+ "authed": bool(cookies or headers),
564
  "wrapped_at": time.time(),
565
  }
566
  for op in ops:
567
  _COORDS[op["coord"]] = {"origin_slug": slug, "op": op}
568
 
569
+ # Broadcast node registration: per-slug + shared "submesh"
570
+ _broadcast_dual(
571
+ signal_registry,
572
+ per_slug_freq=f"submesh.node.{slug}",
573
+ source_type="submesh_wrap",
574
+ data={
575
+ "origin": origin,
576
+ "coords": [o["coord"] for o in ops],
577
+ "authed": bool(cookies or headers),
578
+ "op_count": len(ops),
579
+ "source": source,
580
+ },
581
+ metadata={"source": source, "op_count": len(ops), "slug": slug},
582
+ )
583
 
584
  _log({"event": "wrap", "slug": slug, "source": source,
585
+ "op_count": len(ops), "authed": bool(cookies or headers)})
586
 
587
  return {
588
  "wrapped": True,
589
  "origin": origin,
590
  "slug": slug,
591
  "source": source,
592
+ "authed": bool(cookies or headers),
593
+ "cookie_hashes": _pair_hashes(cookies) if cookies else [],
594
+ "header_hashes": _pair_hashes(headers) if headers else [],
595
  "coords": [o["coord"] for o in ops],
596
  "op_count": len(ops),
597
  }
 
601
  check_key(x_signalmesh_key)
602
  return {"nodes": list(_NODES.values()), "count": len(_NODES)}
603
 
604
+ @router.post("/session",
605
+ summary="Attach browser-session cookies AND/OR auth headers to an origin")
606
  def session(req: SessionReq, x_signalmesh_key: Optional[str] = Header(default=None)):
607
  check_key(x_signalmesh_key)
608
  cookies = _normalize_cookies(req.cookies)
609
+ headers = _normalize_headers(req.headers)
610
+ if not cookies and not headers:
611
+ raise HTTPException(400, "provide at least one of cookies or headers")
612
  slug = _slug(req.origin)
613
+ _SESSIONS[slug] = _build_session_record(cookies, headers, req.ttl_ms / 1000.0)
 
 
 
 
614
  if slug in _NODES:
615
  _NODES[slug]["authed"] = True
616
+ _log({"event": "session_attached", "slug": slug,
617
+ "cookie_count": len(cookies), "header_count": len(headers)})
618
  return {
619
  "attached": True,
620
  "slug": slug,
621
  "cookie_count": len(cookies),
622
+ "header_count": len(headers),
623
+ "cookie_hashes": _pair_hashes(cookies),
624
+ "header_hashes": _pair_hashes(headers),
625
+ "expires_in_s": req.ttl_ms / 1000.0,
626
  }
627
 
628
+ @router.post("/call", summary="Invoke a submesh coord with cookie + header carry")
629
  async def call(req: CallReq, x_signalmesh_key: Optional[str] = Header(default=None)):
630
  check_key(x_signalmesh_key)
631
  entry = _COORDS.get(req.coord)
 
647
  if not safe:
648
  raise HTTPException(400, f"SEC-Ω blocked: {reason}")
649
 
650
+ # Resolve cookies + headers: per-call override > stored session > none
651
  cookies = _normalize_cookies(req.cookies) if req.cookies else []
652
+ headers = _normalize_headers(req.headers) if req.headers else []
653
+ sess = _SESSIONS.get(slug)
654
+ if sess and sess["expires"] > time.time():
655
+ if not cookies:
656
+ cookies = sess.get("cookies", [])
657
+ if not headers:
658
+ headers = sess.get("headers", [])
659
 
660
  method = op["method"]
661
  url = op["url"]
 
679
  except Exception:
680
  continue
681
 
682
+ hdrs = {"User-Agent": "SignalMesh-Submesh/0"}
683
+ for h in headers:
684
+ try:
685
+ hdrs[str(h["name"])] = str(h["value"])
686
+ except Exception:
687
+ continue
688
+
689
  started = time.time()
690
  try:
691
+ async with httpx.AsyncClient(follow_redirects=True, timeout=20.0,
692
+ cookies=jar, headers=hdrs) as client:
693
  r = await client.request(method, url, params=params, json=body)
694
  ct = r.headers.get("content-type", "")
695
  if "application/json" in ct:
 
700
  "coord": req.coord,
701
  "origin_slug": slug,
702
  "status": r.status_code,
703
+ "authed": bool(cookies or headers),
704
+ "cookie_hashes": _pair_hashes(cookies),
705
+ "header_hashes": _pair_hashes(headers),
706
  "payload_type": payload_type,
707
  "payload": payload,
708
  "elapsed_ms": int((time.time() - started) * 1000),
 
714
  "origin_slug": slug,
715
  "status": 0,
716
  "error": str(e),
717
+ "authed": bool(cookies or headers),
718
  "elapsed_ms": int((time.time() - started) * 1000),
719
  }
720
 
721
+ # Broadcast result: per-slug + shared "submesh" frequency
722
+ _broadcast_dual(
723
+ signal_registry,
724
+ per_slug_freq=f"submesh.call.{slug}",
725
+ source_type="submesh_call",
726
+ data=envelope,
727
+ metadata={"coord": req.coord, "status": envelope.get("status"),
728
+ "authed": bool(cookies or headers), "slug": slug},
729
+ )
 
730
 
731
  _log({"event": "call", "coord": req.coord,
732
+ "status": envelope.get("status"),
733
+ "authed": bool(cookies or headers)})
734
 
735
  return envelope
736