diff --git a/ACCOUNTS.md b/ACCOUNTS.md index db51aa0..1b6d7d2 100644 --- a/ACCOUNTS.md +++ b/ACCOUNTS.md @@ -28,7 +28,7 @@ node name, chrome-box profile, API `--account`, and the agent's display name. |-------|------|---------|------------|------------|-------|-----------|------------------|--------|-----------|----------|--------------|-------| | muse | muse | muse | email_otp | ltd.pixels.ltd@gmail.com | ltd.pixels.ltd@gmail.com | no | unknown | active | 104.28.195.181 | 9410 | muse | Main dev agent. Logged in 2026-10-03 via email OTP on bl. | | pip | pip | pip | phone_otp | piparada | - | yes | yes | active | 104.28.195.181 | 9420 | pip | Phone OTP login 2026-10-03. Renamed from 'Muse' to 'pip'. Session lost on browser restart 2026-10-03; needs re-auth. IG avatar visible in account selector. | -| 646 | 646 | 646 | phone_otp | Meta Account | - | yes | unknown | pending_auth | - | - | 646 | Shares phone number with piparada's account. Logged in 2026-10-03 via phone OTP (first Meta Account option). Node not yet created on bl. | +| 646 | 646 | 646 | phone_otp | Meta Account | - | yes | unknown | active | 104.28.195.181 | 9430 | 646 | Shares phone number with piparada's account. Logged in 2026-10-03 via phone OTP (first Meta Account option). Node created 2026-10-03 (warp-646, CDP 9430). Browser up, session active (verified 2026-10-03). | ## Login Type Details diff --git a/NODES.md b/NODES.md index 64c5850..36784a1 100644 --- a/NODES.md +++ b/NODES.md @@ -10,3 +10,4 @@ Unified naming: node == agent == profile == API account. ## History - 2026-10-03: Renamed smoke->muse, phone-test->pip for unified naming. Profiles preserved, sessions persisted (muse). WireGuard identities renamed. +| 646 | warp-646 | 104.28.195.181 | 9430 | active | 646 (phone_otp, first Meta Account option) | diff --git a/bin/meta-acct.py b/bin/meta-acct.py new file mode 100755 index 0000000..7bbf76c --- /dev/null +++ b/bin/meta-acct.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Meta Accounts Center read API (via authenticated browser session). + +Usage: + meta-acct.py list-linked # linked profiles in this Meta Account + meta-acct.py security-status # login & recovery / security checkup summary + meta-acct.py login-activity # "Where you're logged in" sessions + +The agent's browser must have an active Meta session (phone/email OTP login). +Reads NODES.md for the CDP port. Outputs JSON. + +Scope: Accounts Center linkage/security surface only. No writes. +""" +import json, sys, time, urllib.request, os + +NETVM = os.environ.get("NETVM_DIR", os.path.expanduser("~/Projects/NetVM")) +AC_BASE = "https://accountscenter.meta.com" + +def cdp_port(agent): + for line in open(os.path.join(NETVM, "NODES.md")): + line = line.strip() + if not line.startswith("|"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + if len(cells) >= 4 and cells[0] == agent: + return int(cells[3]) + raise SystemExit(f"meta-acct: unknown agent '{agent}' (not in NODES.md)") + +def cdp_get(port, path, method="GET", timeout=10): + req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", method=method) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + +def get_ac_tab(port): + tabs = cdp_get(port, "/json/list") + for t in tabs: + if "accountscenter.meta.com" in t.get("url", "") and t.get("type") == "page": + return t + # create one + return cdp_get(port, f"/json/new?{AC_BASE}/", method="PUT") + +def evaluate(port, ws_url, js, timeout=15): + import websocket + ws = websocket.create_connection(ws_url, timeout=timeout) + try: + ws.send(json.dumps({"id": 1, "method": "Page.navigate", + "params": {"url": js[0]}})) + ws.recv() + time.sleep(4) + ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", + "params": {"expression": js[1], "returnByValue": True}})) + resp = json.loads(ws.recv()) + return json.loads(resp["result"]["result"]["value"]) + finally: + ws.close() + +def cmd_list_linked(port): + tab = get_ac_tab(port) + data = evaluate(port, tab["webSocketDebuggerUrl"], ( + f"{AC_BASE}/account_overview/", + """JSON.stringify((() => { + const h2 = [...document.querySelectorAll('h2')] + .find(e=>e.innerText.trim()==='Profiles'); + // Full section is 3 levels up (H2 > DIV > DIV > MAIN) + const container = h2?.parentElement?.parentElement?.parentElement; + return { + email: (document.body.innerText.match( + /[\\w.+-]+@[\\w-]+\\.[\\w.]+/)||[])[0]||null, + section: container?.innerText?.slice(0,1000) || null + }; + })())""")) + # Parse: lines after "Profiles" are name/type pairs until "Add more", + # then "AI and devices" section follows + profiles = [] + section = data.get("section", "") + if section: + lines = [l.strip() for l in section.split("\n") if l.strip()] + try: + start = lines.index("Profiles") + 1 + except ValueError: + start = 0 + i = start + while i < len(lines): + if lines[i] == "Add more": + i += 1 + continue + if lines[i] == "AI and devices": + # Next line is the device/service name + if i + 1 < len(lines): + profiles.append({"name": lines[i+1], "type": "ai_device"}) + break + # Expect name + type pair + if i + 1 < len(lines) and lines[i+1] not in ( + "Add more", "AI and devices", "Profiles"): + profiles.append({"name": lines[i], + "type": lines[i+1].lower()}) + i += 2 + else: + i += 1 + return {"email": data.get("email"), "profiles": profiles} + +def cmd_security_status(port): + tab = get_ac_tab(port) + data = evaluate(port, tab["webSocketDebuggerUrl"], ( + f"{AC_BASE}/password_and_security/", + """JSON.stringify({ + checkup: document.body.innerText.match( + /Meta Security Checkup\\s*(\\d+) recommended actions/)?.[1] || null, + sections: [...document.querySelectorAll('h2')] + .map(e=>e.innerText.trim()).slice(0,10), + body: document.body.innerText.slice(0,2000) + })""")) + return {"security_checkup_actions": data.get("checkup"), + "sections": data.get("sections")} + +def cmd_login_activity(port): + tab = get_ac_tab(port) + # "Where you're logged in" is on the password_and_security page + data = evaluate(port, tab["webSocketDebuggerUrl"], ( + f"{AC_BASE}/password_and_security/", + """JSON.stringify({ + body: document.body.innerText.slice(0,4000) + })""")) + body = data.get("body", "") + # Extract the "Where you're logged in" section + idx = body.find("Where you're logged in") + section = body[idx:idx+1500] if idx >= 0 else "" + return {"where_logged_in": section} + +def main(): + if len(sys.argv) != 3: + print(__doc__.strip().split("\n")[0]) + print("Usage: meta-acct.py ") + sys.exit(2) + cmd, agent = sys.argv[1], sys.argv[2] + port = cdp_port(agent) + try: + if cmd == "list-linked": + out = cmd_list_linked(port) + elif cmd == "security-status": + out = cmd_security_status(port) + elif cmd == "login-activity": + out = cmd_login_activity(port) + else: + raise SystemExit(f"meta-acct: unknown command '{cmd}'") + except Exception as e: + print(json.dumps({"error": str(e), "agent": agent})) + sys.exit(1) + out["agent"] = agent + out["checked_at"] = int(time.time()) + print(json.dumps(out, indent=2)) + +if __name__ == "__main__": + main() diff --git a/bin/meta-acct.sh b/bin/meta-acct.sh new file mode 100755 index 0000000..b39c770 --- /dev/null +++ b/bin/meta-acct.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# meta-acct.sh — Meta Accounts Center read API (via authenticated browser session). +# +# Usage: +# meta-acct.sh list-linked # linked profiles in this Meta Account +# meta-acct.sh security-status # login & recovery / security checkup +# meta-acct.sh login-activity # "Where you're logged in" sessions +# +# The agent's browser must have an active Meta session (phone/email OTP login). +# Reads NODES.md for the CDP port. Outputs JSON. +# +# Scope: Accounts Center linkage/security surface only. No writes. +# Runs the Python implementation inside the agent's NetVM netns. + +set -euo pipefail + +NETVM_DIR="${NETVM_DIR:-$HOME/Projects/NetVM}" +SCRIPT="$NETVM_DIR/bin/meta-acct.py" + +if [ $# -ne 2 ]; then + echo "Usage: meta-acct.sh " >&2 + exit 2 +fi + +cmd="$1" +agent="$2" + +# Get the node name from ACCOUNTS.md (agent == node per unified naming) +node="$agent" + +# Run inside the netns so we reach the browser's CDP on 127.0.0.1. +# NETVM_DIR must be explicit: sudo resets HOME to /root. +exec sudo -n ip netns exec "warp-$node" \ + env NETVM_DIR="$NETVM_DIR" python3 "$SCRIPT" "$cmd" "$agent" diff --git a/docs/META-ACCOUNTS-API.md b/docs/META-ACCOUNTS-API.md new file mode 100644 index 0000000..c10b313 --- /dev/null +++ b/docs/META-ACCOUNTS-API.md @@ -0,0 +1,174 @@ +# Meta Accounts Center API + +**Status:** active (2026-10-03) +**Motivation:** the phone number used in the muse.ai OTP pilot is linked to a +Facebook account with its own credentials. Meta account management must be a +separate, isolated API — never tangled with the phone-OTP flow. + +## What it is + +A dedicated operator CLI + privileged gateway for automating +[Accounts Center](https://accountscenter.meta.com/) operations: the Meta hub +where Facebook, Instagram, WhatsApp, and Meta accounts are linked, with +shared login, security settings, and connected experiences. + +It is **not** a general Facebook/Instagram automation tool. It manages the +*account linkage and security surface* — which accounts are linked, how they +log into each other, 2FA/password state, login activity. Content operations +(posting, messaging) are out of scope. + +## Separation guarantees + +1. **Credential namespace:** uses `meta-creds.sh` types `facebook` / + `instagram` only. The phone number is a *contact method* on the Facebook + account, never a credential identifier. The `phone-otp` route (muse.ai + SMS flow) and Meta credentials never share IDs, profiles, or audit logs. +2. **Browser/session reuse:** Meta automation runs in the agent's + existing chrome-box profile — the same profile serves muse.ai and + accountscenter.meta.com. The 1:1 rule (node == agent == profile) + already gives us one egress per agent; no separate Meta node needed. + The phone-OTP login that established the muse.ai session also + establishes the Meta session, so read ops reuse it without + re-authenticating. +3. **Authorization without exposure:** follows `CREDENTIAL-GATEWAY.md`. + Agents know credential IDs and target actions; the privileged gateway + decrypts from `store.age` and injects via CDP. Secrets never enter agent + context, logs, or chat. +4. **Audit:** every call logs `{ts, credential_id, action, target}` — + never values. Separate audit trail from phone-OTP operations. + +## Operations + +### Read (operator may run freely) + +| Command | What it returns | +|---|---| +| `meta-acct.sh list-linked ` | Accounts in the Accounts Center (type, username, linked since) | +| `meta-acct.sh login-activity ` | Recent logins: device, IP/geo, time; flags unrecognized | +| `meta-acct.sh security-status ` | 2FA on/off + method, login alerts on/off, password age | +| `meta-acct.sh connected-experiences ` | Per-experience toggles: cross-app login, cross-posting, etc. | + +### Write (human-gated via `/etc/netvm/approvals/`) + +| Command | Gate reason | +|---|---| +| `meta-acct.sh add-account --type ` | Links a new account; changes login blast radius | +| `meta-acct.sh remove-account --account ` | Unlinks; some connected experiences don't revert cleanly | +| `meta-acct.sh set-login-linking --on\|--off` | Controls cross-app login (see 2026 change below) | +| `meta-acct.sh rotate-password ` | Credential change; invalidates sessions | +| `meta-acct.sh set-2fa --method ` | Security-critical | + +Human gates use the existing `netvm-approve.sh` flow: the agent prepares the +exact action, the human approves the specific `(credential_id, action, +target)` tuple, the gateway executes once. + +## The early-2026 login change + +Meta is removing the "Logging in with accounts" toggle: starting early 2026, +**all accounts in the same Accounts Center log into each other by default**. +Accounts Center membership *becomes* the login boundary. + +Implications for this API: +- `list-linked` is now security-critical: anyone with one account's + credentials can reach the others. +- The API must surface a "blast radius" view: for a given credential, which + accounts are reachable through the Accounts Center. +- `remove-account` becomes the only way to revoke cross-app login — it must + stay human-gated and be auditable. +- Recommend: keep each client's accounts in their own Accounts Center; + never mix clients (consistent with the one-client-one-credential-set rule). + +## Architecture + +``` +Agent (untrusted) ──▶ meta-acct.sh (operator CLI) + │ validates scope, checks human-gate + ▼ + Gateway (privileged, runs as operator) + │ meta-creds.sh get + │ decrypts store.age, never logs values + ▼ + CDP ──▶ accountscenter.meta.com + (in the agent's chrome-box profile — + same browser as the muse.ai session) +``` + +- Transport: Unix socket IPC between agent and gateway (not network). +- Injection: CDP `Runtime.evaluate` into the Accounts Center DOM; falls + back to field-focus + virtual keyboard only if DOM injection fails. +- 2FA during a write op: the gateway pauses, requests the code through the + existing OTP handoff (human relays), resumes. The code is transient — + used once, never stored. + +## Registry + +`ACCOUNTS.md` rows per Meta login, same discipline as today: +`pending_auth` → `active`, `2fa-pending` where applicable. The `notes` +column records the Accounts Center linkage (e.g. "facebook + instagram +linked in one Accounts Center; blast radius = 2 accounts"). No credential +values, no phone numbers. + +## Change detection + +`bin/meta-ac-snapshot.py` turns the smoke test into a change detector. +Each run captures a structural snapshot via CDP inside the node's netns +and diffs against `snapshots/meta-ac/baseline.json`: + +- **redirect_chain** (normalized: query params stripped — nonces change + every visit): seeded with the navigation target, then polls for where + Meta sends us. Catches auth-flow reroutes. +- **dom_markers**: form IDs, input names, button labels. Catches page + restructures. +- **final_title**: sanity signal. + +Outcomes: `PASS` (matches), `CHANGED` (structural diff — baseline +untouched, human reviews then `--promote`), `FAIL` (automation broke). +Snapshots are timestamped JSON; the diff doubles as the audit trail for +what Meta changed and when. + +Validated 2026-10-03 on bl: headless Chromium in `warp-phone` netns via +CDP reached accountscenter.meta.com → expected Meta auth redirect +(auth.meta.com OIDC), login page renders. Baseline established; second +run PASS. + +## Out of scope (explicit) + +- Posting, messaging, ad management, Page/Portfolio operations. +- The phone-OTP / muse.ai flow (separate API, separate credentials — + see `docs/PHONE-OTP.md`). +- WhatsApp linking (not available in all regions; revisit later). +- Business Suite / Partner access (different trust model; separate spec if + needed). + +## Open questions + +1. Which NetVM node hosts Meta automation — new dedicated node, or reuse + an existing client node? (Lean: dedicated `meta` node, one egress.) +2. Should read ops also require the approvals flow for high-sensitivity + accounts, or is operator-level enough? +3. Recovery codes: store in `store.age` (retrievable) or human-only? + +## Implementation status (2026-10-03) + +**Read ops implemented** in bin/meta-acct.py + bin/meta-acct.sh: + +- list-linked AGENT — linked profiles from account_overview. Returns + email (if present), profiles array with name/type (instagram, ai_device). + Verified on pip (1 Instagram + Muse) and 646 (Muse only, no email). +- security-status AGENT — security checkup action count + section list + from password_and_security. +- login-activity AGENT — "Where you're logged in" section text. + (Currently returns the section header; the expandable session list needs + a click-through — future work.) + +**Key finding:** the phone-OTP login for muse.ai leaves a full Meta session +in the browser profile. accountscenter.meta.com loads without a login +redirect. No separate Meta login needed — the agent profile IS the +Accounts Center session. + +**Usage:** runs inside the agent's netns via meta-acct.sh wrapper: + + ~/Projects/NetVM/bin/meta-acct.sh list-linked pip + +Write ops (add-account, remove-account, etc.) remain human-gated per the +spec above. Not yet implemented.