diff --git a/CREDSTORE.md b/CREDSTORE.md index 8e74868..bbc807b 100644 --- a/CREDSTORE.md +++ b/CREDSTORE.md @@ -10,23 +10,83 @@ sudo meta-creds.sh list muse # list account IDs (no secrets) sudo meta-creds.sh get muse # output JSON (never log this) sudo meta-creds.sh add muse # interactive prompts ``` +## Naming + +Store ID == `ACCOUNTS.md` `agent` name (e.g. `646`, `pip`, `muse`). The +secret store and the secret-free registry join on this ID — same account, +different jobs (secrets vs. state). + ## Schema ```json { "muse": { "": { - "email": "...", "phone": "...", + "email": "...", + "phone": "...", + "via_meta_account": "", "age_verified": "true", "instagram_linked": "", "verified_by": "human", "verified_at": "2026-10-03T...", "notes": "..." } }, - "instagram": {"": {"username": "...", "password": "...", "email": "..."}}, - "facebook": {"": {"email": "...", "password": "..."}} + "instagram": { + "": { + "username": "...", "password": "...", + "email": "...", "phone": "...", + "accounts_center": "", + "linked_to": ["", "..."], + "login_methods": ["password", "phone_otp"] + } + }, + "facebook": { + "": { + "email": "...", "password": "...", "phone": "...", + "accounts_center": "", + "linked_to": ["", "..."], + "login_methods": ["password", "phone_otp"] + } + } } ``` + +### Field notes + +- `phone`: mobile number for login/2FA. **May be stored here** (encrypted); + one phone can map to multiple accounts (observed: 646 + piparada share + a number) — never treat it as a unique key. +- `via_meta_account` (muse): when muse.ai auth runs through a Meta + account (phone OTP → Meta account → muse.ai), points at the + `facebook`/`instagram` entry. This is the 646/pip intersection. +- `accounts_center`: local alias for the Accounts Center (e.g. `ac-646`). + Post early-2026 this is the login blast-radius boundary — every account + in one Center logs into every other by default. +- `linked_to`: other store IDs in the same Accounts Center. Cached from + the Meta API's `list-linked`; **the API is ground truth** — when they + disagree, the API wins and the store gets updated. +- `login_methods`: how the account can be authenticated. Drives which + flow the automation attempts. + +## Intersections + +- **Phone ↔ accounts (1:many):** the store holds the number (encrypted); + the human is no longer the sole holder, but the number still never + appears in logs, chat, memory, or the secret-free registry. +- **Meta credential → muse.ai session:** a `facebook`/`instagram` entry + can be the auth path for a `muse` login. Follow `via_meta_account`. +- **Store ↔ ACCOUNTS.md:** joined on ID. Store = secrets, registry = state. +- **Store ↔ Meta Accounts Center API** (`docs/META-ACCOUNTS-API.md`): + the API reads linkage ground truth; the store caches it in `linked_to` / + `accounts_center`. + ## Rules + - Operators only. Developers never get access (prevents board leaks). - Decrypt transiently, never log values, never put in chat/memory. -- Human validates Instagram linking; operators automate after. +- Phone numbers and PII **may** live in `store.age` (age-encrypted, 600 + root, VM only). They must never appear in plaintext anywhere else: + no logs, no chat, no memory, no registry, no board. +- Human validates Instagram linking and Meta account ownership; + operators automate after. +- When adding an account, fill `accounts_center` / `linked_to` from the + Meta API (`list-linked`), not from memory. diff --git a/README.md b/README.md index eb66cd9..c3ace3a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,15 @@ veth IPs aren't routable off the host and Warp forwards no inbound traffic. - NODES.md — the network registry: profile/node -> netns -> Warp identity -> veth IP -> CDP port -> egress IP. - ACCOUNTS.md — the secret-free login registry: login -> profile/node -> purpose -> auth state (no credentials, ever). - `bin/netvm-accounts.sh` — operator view: registry joined with live node state. +- `bin/meta-ac-snapshot.py` — Meta Accounts Center change detector: CDP snapshot + (redirect chain + DOM markers) diffed against `snapshots/meta-ac/baseline.json`; + outcomes PASS/CHANGED/FAIL, `--promote` after human review. +- docs/META-ACCOUNTS-API.md — separate API for accountscenter.meta.com + (linkage/security surface; credential-isolated from phone-OTP). +- docs/PHONE-OTP.md — phone-number OTP login flow for muse.ai (proven on bl). +- `bin/accounts-health.py` — per-account CDP session probe (runs inside the netns). +- `bin/accounts-health.sh` — aggregates account vitality from ACCOUNTS.md, + signs + POSTs to the board health ingest (systemd timer, every 15 min). ## Verification checklist diff --git a/bin/__pycache__/dm-listener.cpython-314.pyc b/bin/__pycache__/dm-listener.cpython-314.pyc new file mode 100644 index 0000000..7925790 Binary files /dev/null and b/bin/__pycache__/dm-listener.cpython-314.pyc differ diff --git a/bin/__pycache__/dm.cpython-314.pyc b/bin/__pycache__/dm.cpython-314.pyc new file mode 100644 index 0000000..5b1137b Binary files /dev/null and b/bin/__pycache__/dm.cpython-314.pyc differ diff --git a/bin/__pycache__/muse-chat-api.cpython-314.pyc b/bin/__pycache__/muse-chat-api.cpython-314.pyc new file mode 100644 index 0000000..edbd32a Binary files /dev/null and b/bin/__pycache__/muse-chat-api.cpython-314.pyc differ diff --git a/bin/__pycache__/opp-dm.cpython-314.pyc b/bin/__pycache__/opp-dm.cpython-314.pyc new file mode 100644 index 0000000..79099ee Binary files /dev/null and b/bin/__pycache__/opp-dm.cpython-314.pyc differ diff --git a/bin/__pycache__/p2p-relay.cpython-314.pyc b/bin/__pycache__/p2p-relay.cpython-314.pyc new file mode 100644 index 0000000..f9c4205 Binary files /dev/null and b/bin/__pycache__/p2p-relay.cpython-314.pyc differ diff --git a/bin/__pycache__/thread-listener.cpython-314.pyc b/bin/__pycache__/thread-listener.cpython-314.pyc new file mode 100644 index 0000000..051987c Binary files /dev/null and b/bin/__pycache__/thread-listener.cpython-314.pyc differ diff --git a/bin/accounts-health.py b/bin/accounts-health.py new file mode 100644 index 0000000..abbf93f --- /dev/null +++ b/bin/accounts-health.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Per-account session vitality check (runs INSIDE the node's netns). + +Usage: sudo ip netns exec warp- python3 accounts-health.py + +Probes the account's browser via CDP: + - browser reachable + - muse.ai tab present + - login markers (heuristic: "Log in" button vs user content) + +Prints JSON to stdout. Exit 0 on success, 1 if the browser is unreachable. +""" +import json, sys, urllib.request + +CDP_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 9410 + +def http(path, timeout=5): + with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}{path}", + timeout=timeout) as r: + return json.loads(r.read()) + +result = {"browser_up": False, "muse_tab": False, + "session_alive": None, "title": "", "url": "", "detail": ""} + +try: + http("/json/version") + result["browser_up"] = True +except Exception as e: + result["detail"] = f"CDP unreachable: {e}" + print(json.dumps(result)); sys.exit(1) + +try: + tabs = http("/json/list") +except Exception as e: + result["detail"] = f"tab list failed: {e}" + print(json.dumps(result)); sys.exit(0) + +muse_tab = None +for t in tabs: + url = t.get("url", "") + if "muse.ai" in url and t.get("type") == "page": + muse_tab = t + break + +if not muse_tab: + result["detail"] = "no muse.ai tab open" + print(json.dumps(result)); sys.exit(0) + +result["muse_tab"] = True +result["url"] = muse_tab.get("url", "") +result["title"] = muse_tab.get("title", "") + +# DOM heuristic via the tab's debugger socket +try: + import websocket + ws = websocket.create_connection(muse_tab["webSocketDebuggerUrl"], timeout=10) + js = """JSON.stringify({ + loginButtons: [...document.querySelectorAll('button')].filter( + b => /^\\s*log\\s*in\\s*$/i.test(b.innerText)).map(b => b.innerText.trim()), + hasAvatar: !!document.querySelector( + 'img[alt*="avatar" i], [data-testid*="avatar" i], [aria-label*="profile" i]'), + title: document.title, + url: location.href + })""" + ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", + "params": {"expression": js, "returnByValue": True}})) + resp = json.loads(ws.recv()) + ws.close() + dom = json.loads(resp["result"]["result"]["value"]) + # Heuristic: login buttons present + no avatar => logged out. + # No login buttons (or avatar present) => likely logged in. + if dom["loginButtons"] and not dom["hasAvatar"]: + result["session_alive"] = False + result["detail"] = f"login wall visible: {dom['loginButtons'][:2]}" + else: + result["session_alive"] = True + result["detail"] = "no login wall detected" +except Exception as e: + result["detail"] = f"DOM check failed: {e}" + +print(json.dumps(result)) diff --git a/bin/accounts-health.sh b/bin/accounts-health.sh new file mode 100755 index 0000000..9f7f073 --- /dev/null +++ b/bin/accounts-health.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# accounts-health.sh — account session vitality reporter for the front-door network. +# +# Reads ACCOUNTS.md, probes each account's browser via CDP (inside its NetVM +# netns) for session liveness, and emits a JSON report. Optionally signs and +# POSTs it to the board health ingest, following the health-report.sh +# convention: payload is \n\n, namespace "health". +# +# Usage: accounts-health.sh [--no-post] +# +# Cron (on bl, every 15 min): +# */15 * * * * ~/Projects/NetVM/bin/accounts-health.sh >/dev/null 2>&1 +# +# Health key setup (once, on bl): +# ssh-keygen -t ed25519 -N "" -f ~/.ssh/muse-health +# # operator registers the pubkey on the VM: +# echo "bl $(cat ~/.ssh/muse-health.pub)" \ +# | ssh super@34.139.37.135 "sudo tee -a /srv/board/health_signers" +set -u + +NETVM_DIR="${NETVM_DIR:-$HOME/Projects/NetVM}" +ACCOUNTS="$NETVM_DIR/ACCOUNTS.md" +CHECKER="$NETVM_DIR/bin/accounts-health.py" +MACHINE="${MUSE_MACHINE:-bl}" +KEY="${HEALTH_KEY:-$HOME/.ssh/muse-health}" +ENDPOINT="${HEALTH_ENDPOINT:-https://board.muse-dev.online/api/health/report}" +POST=1 +[ "${1:-}" = "--no-post" ] && POST=0 + +[ -f "$ACCOUNTS" ] || { echo "accounts-health: $ACCOUNTS missing" >&2; exit 1; } +[ -f "$CHECKER" ] || { echo "accounts-health: $CHECKER missing" >&2; exit 1; } + +TS="$(date +%s)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# Parse ACCOUNTS.md pipe table, probe each account inside its netns +NETVM_DIR="$NETVM_DIR" python3 - > "$TMP/facts.json" <<'PYEOF' +import json, os, subprocess, time +netvm = os.environ["NETVM_DIR"] +rows = [] +for line in open(os.path.join(netvm, "ACCOUNTS.md")): + line = line.strip() + if not line.startswith("|"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + if len(cells) < 13 or cells[0] in ("agent", "-------", ""): + continue + if cells[10] not in ("", "-"): + rows.append({"agent": cells[0], "node": cells[1], + "status": cells[8], "cdp_port": cells[10]}) +checker = os.path.join(netvm, "bin", "accounts-health.py") +out = {} +for a in rows: + try: + r = subprocess.run( + ["sudo", "-n", "ip", "netns", "exec", f"warp-{a['node']}", + "python3", checker, a["cdp_port"]], + capture_output=True, text=True, timeout=60) + res = json.loads(r.stdout.strip().splitlines()[-1]) + res["registry_status"] = a["status"] + out[a["agent"]] = res + except Exception as e: + out[a["agent"]] = {"browser_up": False, "session_alive": None, + "detail": f"probe failed: {e}", + "registry_status": a["status"]} +print(json.dumps({"accounts": out, "checked_at": int(time.time())}, indent=2)) +PYEOF + +if [ "$POST" -eq 0 ]; then + cat "$TMP/facts.json" + exit 0 +fi + +if [ ! -f "$KEY" ]; then + echo "accounts-health: $KEY missing — printing JSON, not posting (see header for key setup)" >&2 + cat "$TMP/facts.json" + exit 0 +fi + +printf '%s\n%s\n' "$MACHINE" "$TS" > "$TMP/payload" +FACTS_JSON="$(cat "$TMP/facts.json")" +printf '%s' "$FACTS_JSON" >> "$TMP/payload" +ssh-keygen -Y sign -f "$KEY" -n health "$TMP/payload" >/dev/null 2>&1 +SIG="$(cat "$TMP/payload.sig")" +python3 - "$MACHINE" "$TS" "$FACTS_JSON" "$SIG" <<'PYEOF' > "$TMP/body.json" +import json, sys +machine, ts, facts_json, sig = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4] +body = {"machine": machine, "ts": ts, + "facts": json.loads(facts_json), "facts_json": facts_json, + "signature": sig} +print(json.dumps(body)) +PYEOF + +curl -s -X POST "$ENDPOINT" -H 'Content-Type: application/json' \ + --data @"$TMP/body.json" | head -c 300 +echo diff --git a/bin/dev-dm.py b/bin/dev-dm.py deleted file mode 100755 index caacce9..0000000 --- a/bin/dev-dm.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -""" -dev-dm.py: Developer Direct Messages - -For operator-to-developer communication (muse, pip task agents). -Developers are browser-only, no SSH. Uses headless via dm.py backend. - -Distinct from opp-dm.py (for operators: 646, operator-main). - -Usage: - dev-dm.py send --agent muse --target main "message" - dev-dm.py read --agent muse --target main -""" -import argparse -import subprocess -import sys - -DM_PY = "/home/super/Projects/NetVM/bin/dm.py" - -def run(cmd, timeout=90): - result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) - return result.stdout.strip() - -def dev_send(agent, target, message): - """Send a dev DM via headless (dm.py backend).""" - if agent not in ["muse", "pip"]: - print(f"ERROR: dev-dm only for muse, pip (not {agent})", file=sys.stderr) - sys.exit(1) - # Delegate to dm.py - safe = message.replace('"', '\\"')[:1000] - cmd = f"{DM_PY} send --agent {agent} --target {target} \"{safe}\"" - # Run on bl - bl_cmd = f"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes super@100.123.153.75 '{cmd}'" - vm_cmd = f"ssh -i ~/.ssh/vm_to_gcp -o ProxyCommand=\"$HOME/workspace/bin/ssh-via-proxy %h %p\" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes super@34.139.37.135 \"{bl_cmd}\"" - result = run(vm_cmd) - print(f"DEV-DM sent to {agent}/{target}") - return result - -def dev_read(agent, target, n=5): - """Read dev DMs via headless.""" - if agent not in ["muse", "pip"]: - print(f"ERROR: dev-dm only for muse, pip", file=sys.stderr) - sys.exit(1) - cmd = f"{DM_PY} read --agent {agent} --target {target} --n {n}" - bl_cmd = f"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes super@100.123.153.75 '{cmd}'" - vm_cmd = f"ssh -i ~/.ssh/vm_to_gcp -o ProxyCommand=\"$HOME/workspace/bin/ssh-via-proxy %h %p\" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes super@34.139.37.135 \"{bl_cmd}\"" - result = run(vm_cmd) - print(result) - return result - -def main(): - p = argparse.ArgumentParser(description="DEV-DM: Developer Direct Messages") - sub = p.add_subparsers(dest='cmd', required=True) - - ps = sub.add_parser('send', help='Send dev DM') - ps.add_argument('--agent', required=True, choices=['muse', 'pip']) - ps.add_argument('--target', required=True, help='main or chat_id') - ps.add_argument('message', help='Message') - ps.set_defaults(func=lambda a: dev_send(a.agent, a.target, a.message)) - - pr = sub.add_parser('read', help='Read dev DMs') - pr.add_argument('--agent', required=True, choices=['muse', 'pip']) - pr.add_argument('--target', required=True) - pr.add_argument('--n', type=int, default=5) - pr.set_defaults(func=lambda a: dev_read(a.agent, a.target, a.n)) - - args = p.parse_args() - args.func(args) - -if __name__ == '__main__': - main() diff --git a/bin/meta-ac-snapshot.py b/bin/meta-ac-snapshot.py new file mode 100755 index 0000000..1188b0c --- /dev/null +++ b/bin/meta-ac-snapshot.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Meta Accounts Center change-detection harness. + +Captures a structural snapshot of the accountscenter.meta.com auth flow +via CDP inside a NetVM netns, diffs against the stored baseline. + +Outcomes: + PASS - matches baseline (or first run establishes it) + CHANGED - structural diff detected; needs human review, baseline untouched + FAIL - automation itself broke (browser/CDP/network error) + +Usage: + meta-ac-snapshot.py [--node NAME] [--promote] [--snapshot-dir DIR] + + --node NetVM node to run in (default: phone) + --promote after human review, promote the latest snapshot to baseline + --snapshot-dir where snapshots live (default: ~/Projects/NetVM/snapshots/meta-ac) +""" +import argparse, base64, datetime, json, os, subprocess, sys, time +import urllib.parse, urllib.request + +CDP_PORT = 19744 + +def log(*a): + print(*a, flush=True) + +def ns_exec(node, cmd): + return subprocess.run( + ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}"] + cmd, + capture_output=True, text=True) + +def norm_url(u): + """Strip query/fragment — nonces change every visit.""" + p = urllib.parse.urlparse(u) + return f"{p.scheme}://{p.host}{p.path}" if hasattr(p, 'host') else f"{p.scheme}://{p.hostname}{p.path}" + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--node", default="phone") + ap.add_argument("--promote", action="store_true") + ap.add_argument("--snapshot-dir", default=os.path.expanduser( + "~/Projects/NetVM/snapshots/meta-ac")) + args = ap.parse_args() + os.makedirs(args.snapshot_dir, exist_ok=True) + baseline_path = os.path.join(args.snapshot_dir, "baseline.json") + + if args.promote: + snaps = sorted(f for f in os.listdir(args.snapshot_dir) + if f.startswith("snap-") and f.endswith(".json")) + if not snaps: + log("no snapshots to promote"); return 2 + latest = os.path.join(args.snapshot_dir, snaps[-1]) + data = json.load(open(latest)) + data["promoted_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + json.dump(data, open(baseline_path, "w"), indent=2) + log(f"promoted {snaps[-1]} -> baseline.json") + return 0 + + profile_dir = "/tmp/meta-ac-snap-profile" + subprocess.run(["rm", "-rf", profile_dir]) + os.makedirs(profile_dir, exist_ok=True) + + def http(path): + with urllib.request.urlopen( + f"http://127.0.0.1:{CDP_PORT}{path}", timeout=5) as r: + return json.loads(r.read()) + + # launch chromium inside the netns via a wrapper script + wrapper = "/tmp/meta-ac-snap-run.py" + open(wrapper, "w").write(WRAPPER_SRC) + log(f"launching chromium in warp-{args.node} (CDP {CDP_PORT})...") + proc = subprocess.Popen( + ["sudo", "-n", "ip", "netns", "exec", f"warp-{args.node}", + "python3", wrapper, str(CDP_PORT), profile_dir], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + try: + out, _ = proc.communicate(timeout=120) + except subprocess.TimeoutExpired: + proc.kill(); log("FAIL: harness timed out"); return 1 + print(out) + # wrapper prints SNAPSHOT_JSON= on success + snap = None + for line in out.splitlines(): + if line.startswith("SNAPSHOT_JSON="): + snap = json.loads(line[len("SNAPSHOT_JSON="):]) + if not snap: + log("FAIL: no snapshot captured"); return 1 + + snap["node"] = args.node + snap["captured_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + # egress ip for context + try: + r = ns_exec(args.node, ["curl", "-s", "--max-time", "8", + "https://api.ipify.org"]) + snap["egress_ip"] = r.stdout.strip() + except Exception: + snap["egress_ip"] = "unknown" + + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S") + snap_path = os.path.join(args.snapshot_dir, f"snap-{ts}.json") + json.dump(snap, open(snap_path, "w"), indent=2) + log(f"snapshot saved: {snap_path}") + + if not os.path.exists(baseline_path): + json.dump(snap, open(baseline_path, "w"), indent=2) + log("PASS: baseline established (first run)") + return 0 + + baseline = json.load(open(baseline_path)) + diffs = diff_snapshots(baseline, snap) + if not diffs: + log("PASS: matches baseline") + return 0 + log("CHANGED: structural diff detected (baseline untouched):") + for d in diffs: + log(f" - {d}") + log("review with: diff baseline.json snap-.json") + log("promote after review with: --promote") + return 3 + +def diff_snapshots(base, snap): + diffs = [] + b_chain = [norm_url(u) for u in base.get("redirect_chain", [])] + s_chain = [norm_url(u) for u in snap.get("redirect_chain", [])] + if b_chain != s_chain: + diffs.append(f"redirect_chain changed: {b_chain} -> {s_chain}") + for key in ("forms", "inputs", "buttons"): + b = sorted(base.get("dom_markers", {}).get(key, [])) + s = sorted(snap.get("dom_markers", {}).get(key, [])) + if b != s: + added = [x for x in s if x not in b] + removed = [x for x in b if x not in s] + diffs.append(f"dom_markers.{key}: added={added} removed={removed}") + if base.get("final_title") != snap.get("final_title"): + diffs.append(f"final_title: {base.get('final_title')!r} -> {snap.get('final_title')!r}") + return diffs + +WRAPPER_SRC = ''' +import json, subprocess, sys, time, os, urllib.request, base64 +CDP_PORT = int(sys.argv[1]) +PROFILE_DIR = sys.argv[2] +def http(path): + with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}{path}", timeout=5) as r: + return json.loads(r.read()) +logf = open("/tmp/meta-ac-snap-chrome.log", "w") +proc = subprocess.Popen(["chromium", "--headless=new", "--disable-gpu", "--no-sandbox", + "--disable-dev-shm-usage", f"--user-data-dir={PROFILE_DIR}", + f"--remote-debugging-port={CDP_PORT}", "--remote-allow-origins=*", "about:blank"], + stdout=logf, stderr=subprocess.STDOUT) +try: + for i in range(30): + try: + ver = http("/json/version") + if "webSocketDebuggerUrl" in ver: break + except Exception: pass + time.sleep(1) + else: + print("FAIL: CDP never came up"); sys.exit(1) + import websocket + bws = websocket.create_connection(ver["webSocketDebuggerUrl"], timeout=20) + bws.send(json.dumps({"id": 1, "method": "Target.createTarget", + "params": {"url": "https://accountscenter.meta.com"}})) + target_id = json.loads(bws.recv())["result"]["targetId"] + bws.close() + # redirect chain: seed with the navigation target (we always start + # there), then poll for where Meta sends us. Seeding fixes the race + # where a fast redirect is missed by the poll interval. + START_URL = "https://accountscenter.meta.com/" + chain, seen = [START_URL], {START_URL} + for _ in range(24): + time.sleep(2) + for t in http("/json/list"): + if t.get("id") == target_id or "meta.com" in t.get("url", ""): + u = t["url"] + if u not in seen: + seen.add(u); chain.append(u) + title = t.get("title", "") + break + # dom markers from the final tab + tab_ws = None + for t in http("/json/list"): + if t.get("id") == target_id or "meta.com" in t.get("url", ""): + tab_ws = t["webSocketDebuggerUrl"]; final_url = t["url"]; break + ws = websocket.create_connection(tab_ws, timeout=20) + js = """JSON.stringify({ + forms: [...document.forms].map(f => f.id || f.name || '(anon)'), + inputs: [...document.querySelectorAll('input')].map(i => i.name || i.type || '(anon)'), + buttons: [...document.querySelectorAll('button, [role=button]')].map(b => (b.innerText||'').trim()).filter(Boolean) + })""" + ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", + "params": {"expression": js, "returnByValue": True}})) + markers = json.loads(json.loads(ws.recv())["result"]["result"]["value"]) + # dedupe buttons, keep order + markers["buttons"] = list(dict.fromkeys(markers["buttons"])) + ws.close() + snap = {"redirect_chain": chain, "final_url": final_url, + "final_title": title, "dom_markers": markers} + print("SNAPSHOT_JSON=" + json.dumps(snap)) +finally: + proc.terminate() +''' + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/netvm-docs-server.py b/bin/netvm-docs-server.py new file mode 100755 index 0000000..fd44f6f --- /dev/null +++ b/bin/netvm-docs-server.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +NetVM Docs HTTP Server (bl) + +Serves markdown documentation for agents via HTTPS (tailnet). +- Central repo: ~/Projects/NetVM/ +- Serves: *.md, docs/*.png +- Read-only, no auth (tailnet is the auth boundary) + +GOLDEN PATH: container -> VM (34.139.37.135) -> bl (100.123.153.75) -> this server + +Usage: + python3 netvm-docs-server.py [--port 8080] + +Agents fetch via: + curl http://0.0.0.0:8080/ACCOUNTS.md +""" +import http.server +import socketserver +import os +import argparse +from pathlib import Path + +class DocsHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + # Serve from NetVM repo root + self.base_dir = Path.home() / "Projects" / "NetVM" + super().__init__(*args, directory=str(self.base_dir), **kwargs) + + def log_message(self, format, *args): + # Quiet logging + pass + + def end_headers(self): + # Allow CORS for browser agents + self.send_header('Access-Control-Allow-Origin', '*') + super().end_headers() + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--port', type=int, default=8080) + args = p.parse_args() + + # Only bind to tailnet interface (not public) + # 100.123.153.75 is bl's tailnet IP + with socketserver.TCPServer(("0.0.0.0", args.port), DocsHandler) as httpd: + print(f"Serving NetVM docs on http://0.0.0.0:{args.port}/") + print(f"Directory: {Path.home()}/Projects/NetVM/") + httpd.serve_forever() + +if __name__ == '__main__': + main() diff --git a/bin/onboard-driver.py b/bin/onboard-driver.py new file mode 100755 index 0000000..ec869a4 --- /dev/null +++ b/bin/onboard-driver.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""onboard-driver.py — bl-side OTP onboarding driver. + +Runs INSIDE the node's netns (via netvm-exec.sh). Reads the identifier +(line 1) and OTP code (line 2, submit step only) from stdin — never argv. + + onboard-driver.py --node muse --service muse --id-type email --step initiate [--dry-run] + onboard-driver.py --node muse --service muse --id-type email --step submit + +Exit codes: 0 = step done, 2 = APPROVAL_NEEDED (code sent, awaiting OTP), +1 = failed. The identifier/code are passed to the local signin script as +argv (transient, same trust domain — bl is operator infrastructure); +they never cross a network boundary except inside the already-encrypted +VM->bl SSH stdin pipe. + +Part of the cred onboarding module (front-door repo, docs/CRED-MODULE.md). +""" +import argparse +import json +import subprocess +import sys +import urllib.request + +SIGNIN = "/home/super/Projects/NetVM/bin/muse-signin.py" +CDP_PORTS = {"muse": "9410", "pip": "9420"} + + +def cdp_ok(port): + try: + ts = json.load(urllib.request.urlopen( + "http://127.0.0.1:%s/json/list" % port, timeout=5)) + return any(t.get("type") == "page" for t in ts) + except Exception: + return False + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--node", required=True) + p.add_argument("--service", required=True) + p.add_argument("--id-type", required=True) + p.add_argument("--step", required=True, choices=["initiate", "submit"]) + p.add_argument("--dry-run", action="store_true") + args = p.parse_args() + + lines = sys.stdin.read().splitlines() + identifier = lines[0].strip() if lines else "" + code = lines[1].strip() if len(lines) > 1 else "" + + if args.service != "muse" or args.id_type != "email": + print("ERROR: unsupported service/id_type " + "(muse+email only for now)", file=sys.stderr) + return 1 + port = CDP_PORTS.get(args.node) + if not port: + print("ERROR: unknown node", file=sys.stderr) + return 1 + + if args.dry_run: + # Walk the chain without sending anything: netns + CDP + page. + if cdp_ok(port): + print("dry-run ok: node=%s cdp=%s reachable, page present" + % (args.node, port)) + return 0 + print("ERROR: CDP unreachable on %s" % port, file=sys.stderr) + return 1 + + if not identifier: + print("ERROR: no identifier on stdin", file=sys.stderr) + return 1 + + cmd = [sys.executable, SIGNIN, "--email", identifier] + if args.step == "submit": + if not code: + print("ERROR: no code on stdin", file=sys.stderr) + return 1 + cmd += ["--otp", code] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=220) + # Propagate the signin script's contract: 2 = OTP prompt reached. + sys.stdout.write(r.stdout) + sys.stderr.write(r.stderr) + return r.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/opp-dm.py b/bin/opp-dm.py deleted file mode 100755 index 9896ddb..0000000 --- a/bin/opp-dm.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -""" -opp-dm.py: Operator Direct Messages (fixed) - -Works from container OR from bl directly. -Detects environment and acts accordingly. -Verifies writes. - -For operator-to-operator: 646, operator-main. -""" -import argparse -import subprocess -import sys -import os -import datetime -from pathlib import Path - -# Detect if we're on bl (has /home/super/Projects/NetVM) -ON_BL = Path("/home/super/Projects/NetVM").exists() -BL_MSG_DIR = Path("/home/super/Projects/NetVM/opp-dms") - -def bl_run_container(cmd): - """Run on bl via SSH chain (from container).""" - full = f"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes super@100.123.153.75 '{cmd}'" - vm_cmd = f"ssh -i ~/.ssh/vm_to_gcp -o ProxyCommand=\"$HOME/workspace/bin/ssh-via-proxy %h %p\" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes super@34.139.37.135 \"{full}\"" - result = subprocess.run(vm_cmd, shell=True, capture_output=True, text=True, timeout=30) - return result.stdout.strip(), result.returncode - -def opp_send(to, message, from_who="operator-main"): - """Send operator DM. Works from container or bl.""" - if to not in ["646", "operator-main"]: - print(f"ERROR: Unknown operator {to}", file=sys.stderr) - sys.exit(1) - - ts = datetime.datetime.now().isoformat() - safe = message.replace("'", "'\"'\"'")[:1000] - line = f"{ts} [{from_who}]: {safe}" - - if ON_BL: - # Direct write (we're on bl) - BL_MSG_DIR.mkdir(parents=True, exist_ok=True) - logfile = BL_MSG_DIR / f"{to}.log" - with open(logfile, "a") as f: - f.write(line + "\n") - # Verify - if logfile.exists(): - print(f"OPP-DM sent to {to} (verified)") - return True - else: - print(f"ERROR: Write failed", file=sys.stderr) - sys.exit(1) - else: - # Via SSH (from container) - cmd = f"mkdir -p {BL_MSG_DIR} && echo '{line}' >> {BL_MSG_DIR}/{to}.log && test -f {BL_MSG_DIR}/{to}.log && echo OK" - out, rc = bl_run_container(cmd) - if rc == 0 and "OK" in out: - print(f"OPP-DM sent to {to} (verified)") - return True - else: - print(f"ERROR: Send failed: {out}", file=sys.stderr) - sys.exit(1) - -def opp_read(who="operator-main", n=5): - """Read operator DMs. Works from container or bl.""" - if who not in ["646", "operator-main"]: - print(f"ERROR: Unknown operator {who}", file=sys.stderr) - sys.exit(1) - - if ON_BL: - logfile = BL_MSG_DIR / f"{who}.log" - if not logfile.exists(): - print("no messages") - return - with open(logfile) as f: - lines = f.readlines() - for l in lines[-n:]: - print(l.strip()) - else: - cmd = f"tail -n {n} {BL_MSG_DIR}/{who}.log 2>/dev/null || echo 'no messages'" - out, rc = bl_run_container(cmd) - print(out) - -def main(): - p = argparse.ArgumentParser(description="OPP-DM: Operator DMs (fixed)") - sub = p.add_subparsers(dest='cmd', required=True) - - ps = sub.add_parser('send', help='Send operator DM') - ps.add_argument('--to', required=True, choices=['646', 'operator-main']) - ps.add_argument('--from', dest='from_who', default='operator-main') - ps.add_argument('message', help='Message') - ps.set_defaults(func=lambda a: opp_send(a.to, a.message, a.from_who)) - - pr = sub.add_parser('read', help='Read operator DMs') - pr.add_argument('--who', default='operator-main', choices=['646', 'operator-main']) - pr.add_argument('--n', type=int, default=5) - pr.set_defaults(func=lambda a: opp_read(a.who, a.n)) - - args = p.parse_args() - args.func(args) - -if __name__ == '__main__': - main() diff --git a/bridge/dm-listener-watermark.json b/bridge/dm-listener-watermark.json new file mode 100644 index 0000000..6795280 --- /dev/null +++ b/bridge/dm-listener-watermark.json @@ -0,0 +1,6 @@ +{ + "processed": [ + "muse:Test from 646, DM automation is live.", + "pip:Hello from 646. Testing DM to your main " + ] +} \ No newline at end of file diff --git a/bridge/thread-watermark.json b/bridge/thread-watermark.json new file mode 100644 index 0000000..c5db502 --- /dev/null +++ b/bridge/thread-watermark.json @@ -0,0 +1,3 @@ +{ + "last_processed": "muse:6db43088:You're idle and the P2P bridge" +} \ No newline at end of file diff --git a/docs/PHONE-OTP.md b/docs/PHONE-OTP.md new file mode 100644 index 0000000..cdb9e7e --- /dev/null +++ b/docs/PHONE-OTP.md @@ -0,0 +1,73 @@ +# Phone-OTP Login Flow + +**Status:** proven on bl (2026-10-03) +Alternative to the email-OTP path for muse.ai login; same form, same OTP +mechanics, different delivery channel (SMS instead of email). + +## When + +Fresh chrome-box profile + NetVM node, no session yet. Used when the +account's primary identifier is a mobile number rather than an email. + +## Prerequisites + +- chrome-box profile + NetVM node provisioned on bl (1:1: profile == node + == Warp identity). See `README.md` "Provisioning a node". +- A real mobile number that can receive SMS — **human-provided per login + attempt, never stored, never written to any registry, log, or memory.** + The registry records only `phone_otp: yes` as the route. + +## Flow + +1. Launch headless Chromium in the node's netns: + `netvm-chrome.sh --headless https://muse.ai` + (CDP on by default for automation.) +2. Via CDP: open muse.ai → "Log in" → inline form "Mobile number or email". +3. Enter the human-provided mobile number (E.164) and Continue. +4. If the number maps to **multiple Meta accounts**, an account-selection + UI appears (observed 2026-10-03: "Meta Account" vs "piparada" with + Instagram avatar — see `docs/meta-account-selection.png`). Select the + intended account. +5. muse.ai sends a 6-digit SMS OTP. +6. Human reads the SMS and relays the OTP (board/chat handoff, or direct). +7. Enter the OTP via CDP and submit. Session established, cached in the + profile. +8. Verify: revisit muse.ai via CDP, confirm logged-in markers, no login + wall. Flip the `ACCOUNTS.md` row to `active`. + +## Operator/human split + +- **Agent:** browser launch, CDP automation, form entry, OTP submission, + verification. Never sees the phone number beyond the single form entry. +- **Human:** provides the number per attempt, receives the SMS, relays the + OTP. The only party that ever holds the number. +- **Operator:** provisions the node, coordinates, maintains the registry. + +## PII rules + +- The phone number is PII. It goes into the login form and nowhere else — + not the registry, not logs, not chat history, not memory. +- OTP codes are relayed, used once, never stored. +- If the number is linked to other accounts (e.g. Facebook), that linkage + is managed via the separate Meta Accounts Center API + (`docs/META-ACCOUNTS-API.md`) — never conflated with the OTP flow. + +## Failure modes + +- **OTP expired:** re-request from the form (note rate limits if hit). +- **SMS delayed:** wait 60s, retry once, then escalate (possible carrier + filtering). +- **Session lost on browser restart:** observed 2026-10-03 (`pip` node) — + re-auth from step 1. If recurrent, investigate session persistence. +- **Wrong number entered:** restart from step 1. +- **Account-selection ambiguity:** confirm with the human which account + before proceeding; never guess. + +## Provenance + +- 2026-10-03: `646` — phone OTP login completed (first Meta Account + option), bl. +- 2026-10-03: `pip` (piparada) — phone OTP login completed; session lost + on browser restart, needs re-auth. +- 3 OTP codes used 2026-10-03 without rate-limit issues. +- Egress: bl Warp (104.28.195.181) accepted without bot-detection friction. diff --git a/snapshots/meta-ac/baseline.json b/snapshots/meta-ac/baseline.json new file mode 100644 index 0000000..137e623 --- /dev/null +++ b/snapshots/meta-ac/baseline.json @@ -0,0 +1,21 @@ +{ + "redirect_chain": [ + "https://accountscenter.meta.com/", + "https://auth.meta.com/?waterfall_id=d468891e-29bd-426d-9516-997753deb786&redirect_uri=https%3A%2F%2Fauth.meta.com%2Foidc%2F%3Fapp_id%3D633385687760560%26nonce%3DAdTU08Ikjcm9mTIgoaGrOVdc2Hw%26redirect_uri%3Dhttps%253A%252F%252Faccountscenter.meta.com%252Foidc%252Fcallback%252F%26response_type%3Dcode%26scope%3Dopenid%26state%3DATp-FrkXUmikeLjBdUcvg88XWZqfg28_w4CfZo0pU1jvEZhZDxHUizuDNqY3DshXdxThHi2x3FCqCt83Iu33y6UbRDBm7gK1XsydiOLSex3CeO8NHJ1vpzdAp_pc5Jiks73LDbvyaxt5sFFzmJNXZdndeOnFC--DIn_PJ2jkG1rkG7lIZc0ltpTX2PZG3wdcmPHD1BQed-tioVY%26waterfall_id%3Dd468891e-29bd-426d-9516-997753deb786&source_app_id=633385687760560&force_reauth=0&rcs=ATrhOU75HRnKLZwPMD8f-3q_cCgOxaPnUSlIEwIb_UpHqJHfYVMuMCfFZp8BGu3zv62sTRVOXqkISEKZGke18uwCrhZjg5mz_FyTEo_dh_rHrmoZMR2mKQkw8fEFYzeY_3sWXdtAo0PjePnQJG8ehIXwBRVkodPqg9N9LorT5pe-DN8X4yOty4lJ3tA" + ], + "final_url": "https://auth.meta.com/?waterfall_id=d468891e-29bd-426d-9516-997753deb786&redirect_uri=https%3A%2F%2Fauth.meta.com%2Foidc%2F%3Fapp_id%3D633385687760560%26nonce%3DAdTU08Ikjcm9mTIgoaGrOVdc2Hw%26redirect_uri%3Dhttps%253A%252F%252Faccountscenter.meta.com%252Foidc%252Fcallback%252F%26response_type%3Dcode%26scope%3Dopenid%26state%3DATp-FrkXUmikeLjBdUcvg88XWZqfg28_w4CfZo0pU1jvEZhZDxHUizuDNqY3DshXdxThHi2x3FCqCt83Iu33y6UbRDBm7gK1XsydiOLSex3CeO8NHJ1vpzdAp_pc5Jiks73LDbvyaxt5sFFzmJNXZdndeOnFC--DIn_PJ2jkG1rkG7lIZc0ltpTX2PZG3wdcmPHD1BQed-tioVY%26waterfall_id%3Dd468891e-29bd-426d-9516-997753deb786&source_app_id=633385687760560&force_reauth=0&rcs=ATrhOU75HRnKLZwPMD8f-3q_cCgOxaPnUSlIEwIb_UpHqJHfYVMuMCfFZp8BGu3zv62sTRVOXqkISEKZGke18uwCrhZjg5mz_FyTEo_dh_rHrmoZMR2mKQkw8fEFYzeY_3sWXdtAo0PjePnQJG8ehIXwBRVkodPqg9N9LorT5pe-DN8X4yOty4lJ3tA", + "final_title": "Meta", + "dom_markers": { + "forms": [], + "inputs": [], + "buttons": [ + "Continue with Facebook", + "Continue with Instagram", + "Use mobile number or email", + "English (US)" + ] + }, + "node": "phone", + "captured_at": "2026-10-03T17:42:26.963885+00:00", + "egress_ip": "104.28.195.181" +} \ No newline at end of file diff --git a/snapshots/meta-ac/snap-20261003-174227.json b/snapshots/meta-ac/snap-20261003-174227.json new file mode 100644 index 0000000..137e623 --- /dev/null +++ b/snapshots/meta-ac/snap-20261003-174227.json @@ -0,0 +1,21 @@ +{ + "redirect_chain": [ + "https://accountscenter.meta.com/", + "https://auth.meta.com/?waterfall_id=d468891e-29bd-426d-9516-997753deb786&redirect_uri=https%3A%2F%2Fauth.meta.com%2Foidc%2F%3Fapp_id%3D633385687760560%26nonce%3DAdTU08Ikjcm9mTIgoaGrOVdc2Hw%26redirect_uri%3Dhttps%253A%252F%252Faccountscenter.meta.com%252Foidc%252Fcallback%252F%26response_type%3Dcode%26scope%3Dopenid%26state%3DATp-FrkXUmikeLjBdUcvg88XWZqfg28_w4CfZo0pU1jvEZhZDxHUizuDNqY3DshXdxThHi2x3FCqCt83Iu33y6UbRDBm7gK1XsydiOLSex3CeO8NHJ1vpzdAp_pc5Jiks73LDbvyaxt5sFFzmJNXZdndeOnFC--DIn_PJ2jkG1rkG7lIZc0ltpTX2PZG3wdcmPHD1BQed-tioVY%26waterfall_id%3Dd468891e-29bd-426d-9516-997753deb786&source_app_id=633385687760560&force_reauth=0&rcs=ATrhOU75HRnKLZwPMD8f-3q_cCgOxaPnUSlIEwIb_UpHqJHfYVMuMCfFZp8BGu3zv62sTRVOXqkISEKZGke18uwCrhZjg5mz_FyTEo_dh_rHrmoZMR2mKQkw8fEFYzeY_3sWXdtAo0PjePnQJG8ehIXwBRVkodPqg9N9LorT5pe-DN8X4yOty4lJ3tA" + ], + "final_url": "https://auth.meta.com/?waterfall_id=d468891e-29bd-426d-9516-997753deb786&redirect_uri=https%3A%2F%2Fauth.meta.com%2Foidc%2F%3Fapp_id%3D633385687760560%26nonce%3DAdTU08Ikjcm9mTIgoaGrOVdc2Hw%26redirect_uri%3Dhttps%253A%252F%252Faccountscenter.meta.com%252Foidc%252Fcallback%252F%26response_type%3Dcode%26scope%3Dopenid%26state%3DATp-FrkXUmikeLjBdUcvg88XWZqfg28_w4CfZo0pU1jvEZhZDxHUizuDNqY3DshXdxThHi2x3FCqCt83Iu33y6UbRDBm7gK1XsydiOLSex3CeO8NHJ1vpzdAp_pc5Jiks73LDbvyaxt5sFFzmJNXZdndeOnFC--DIn_PJ2jkG1rkG7lIZc0ltpTX2PZG3wdcmPHD1BQed-tioVY%26waterfall_id%3Dd468891e-29bd-426d-9516-997753deb786&source_app_id=633385687760560&force_reauth=0&rcs=ATrhOU75HRnKLZwPMD8f-3q_cCgOxaPnUSlIEwIb_UpHqJHfYVMuMCfFZp8BGu3zv62sTRVOXqkISEKZGke18uwCrhZjg5mz_FyTEo_dh_rHrmoZMR2mKQkw8fEFYzeY_3sWXdtAo0PjePnQJG8ehIXwBRVkodPqg9N9LorT5pe-DN8X4yOty4lJ3tA", + "final_title": "Meta", + "dom_markers": { + "forms": [], + "inputs": [], + "buttons": [ + "Continue with Facebook", + "Continue with Instagram", + "Use mobile number or email", + "English (US)" + ] + }, + "node": "phone", + "captured_at": "2026-10-03T17:42:26.963885+00:00", + "egress_ip": "104.28.195.181" +} \ No newline at end of file diff --git a/snapshots/meta-ac/snap-20261003-174329.json b/snapshots/meta-ac/snap-20261003-174329.json new file mode 100644 index 0000000..e3db6d1 --- /dev/null +++ b/snapshots/meta-ac/snap-20261003-174329.json @@ -0,0 +1,21 @@ +{ + "redirect_chain": [ + "https://accountscenter.meta.com/", + "https://auth.meta.com/?waterfall_id=cb070318-7a4b-49e6-8fc3-d290170d43a6&redirect_uri=https%3A%2F%2Fauth.meta.com%2Foidc%2F%3Fapp_id%3D633385687760560%26nonce%3DAdTU08Ikjcm9mTIgoaGrOVdcRAw%26redirect_uri%3Dhttps%253A%252F%252Faccountscenter.meta.com%252Foidc%252Fcallback%252F%26response_type%3Dcode%26scope%3Dopenid%26state%3DATpq0JxkZwqHfWFB0jh5kR6RenDkDrmgBt5LE0KJyZfdDnriGRq9OoNdvrAZlzfXnMdVIWAqUbc2xusK10YYetA7LuvVjmBYrbh49GgzHiSRknK6ZkW1Cunv1PvlIq3rKo9vsNNrk-_HfmdRqcbal8P6msApW3B8yp_LbOC5smAQUFE-0tdRyXXuOjDfaC2Qcb_wrV5WRTzzYLM%26waterfall_id%3Dcb070318-7a4b-49e6-8fc3-d290170d43a6&source_app_id=633385687760560&force_reauth=0&rcs=ATq7O-Ly9QzkbaEB6ft9NPrYs_cEB5CoxdWH8ejoTxYwqOzsNA4rOBiSOctEtS-xvWC8YFdIq7HgGcRPk2uoM-Nrs0LujStVyK18UHX2EXj7A_Po1Bg6gv7zPq25hTzUS5fExigM8FPTl86JtcctntHqe9XU-81ZpWJjlu_s1OnO_JNtqBZb1LBqFz8" + ], + "final_url": "https://auth.meta.com/?waterfall_id=cb070318-7a4b-49e6-8fc3-d290170d43a6&redirect_uri=https%3A%2F%2Fauth.meta.com%2Foidc%2F%3Fapp_id%3D633385687760560%26nonce%3DAdTU08Ikjcm9mTIgoaGrOVdcRAw%26redirect_uri%3Dhttps%253A%252F%252Faccountscenter.meta.com%252Foidc%252Fcallback%252F%26response_type%3Dcode%26scope%3Dopenid%26state%3DATpq0JxkZwqHfWFB0jh5kR6RenDkDrmgBt5LE0KJyZfdDnriGRq9OoNdvrAZlzfXnMdVIWAqUbc2xusK10YYetA7LuvVjmBYrbh49GgzHiSRknK6ZkW1Cunv1PvlIq3rKo9vsNNrk-_HfmdRqcbal8P6msApW3B8yp_LbOC5smAQUFE-0tdRyXXuOjDfaC2Qcb_wrV5WRTzzYLM%26waterfall_id%3Dcb070318-7a4b-49e6-8fc3-d290170d43a6&source_app_id=633385687760560&force_reauth=0&rcs=ATq7O-Ly9QzkbaEB6ft9NPrYs_cEB5CoxdWH8ejoTxYwqOzsNA4rOBiSOctEtS-xvWC8YFdIq7HgGcRPk2uoM-Nrs0LujStVyK18UHX2EXj7A_Po1Bg6gv7zPq25hTzUS5fExigM8FPTl86JtcctntHqe9XU-81ZpWJjlu_s1OnO_JNtqBZb1LBqFz8", + "final_title": "Meta", + "dom_markers": { + "forms": [], + "inputs": [], + "buttons": [ + "Continue with Facebook", + "Continue with Instagram", + "Use mobile number or email", + "English (US)" + ] + }, + "node": "phone", + "captured_at": "2026-10-03T17:43:29.468850+00:00", + "egress_ip": "104.28.195.181" +} \ No newline at end of file