feat(hybrid-gateway): integrate muse-cli with Cloudflare netns isolation, symmetric sidechat routing, and 646-pip sync unblock
This commit is contained in:
@@ -18,3 +18,8 @@ main-chat-watchdog.state
|
|||||||
strategy.json
|
strategy.json
|
||||||
variables.json
|
variables.json
|
||||||
chained-jobs.json
|
chained-jobs.json
|
||||||
|
.state/
|
||||||
|
*.lock
|
||||||
|
*watermark*
|
||||||
|
ssl/
|
||||||
|
var/
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ node name, chrome-box profile, API `--account`, and the agent's display name.
|
|||||||
| 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. |
|
| 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 | 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). |
|
| 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). |
|
||||||
| def | def | def | email_otp | defnotabotnet@gmail.com | defnotabotnet@gmail.com | no | yes | active | 104.28.195.181 | 9450 | def | Full onboarding completed 2026-10-04; age verification cleared via Instagram linking (paradahub). Active chat session. |
|
| def | def | def | email_otp | defnotabotnet@gmail.com | defnotabotnet@gmail.com | no | yes | active | 104.28.195.181 | 9450 | def | Full onboarding completed 2026-10-04; age verification cleared via Instagram linking (paradahub). Active chat session. |
|
||||||
| opm | opm | opm | email_otp | Nico Parada | yourfriendnico@proton.me | no | unknown | active | 104.28.195.181 | 9440 | opm | Node created 2026-10-03 (warp-opm, CDP 9440). Browser up, session active. |
|
| opm | opm | opm | email_otp | Nico Parada | artglobal.cc@gmail.com | no | yes | active | 104.28.195.181 | 9440 | opm | Email changed from yourfriendnico@proton.me to artglobal.cc@gmail.com. Linked with IG auxfate. Browser up, session active. |
|
||||||
| dev | dev | dev | email_otp | paradaproduced@gmail.com | paradaproduced@gmail.com | no | yes | active | 104.28.195.181 | 9460 | dev | Full onboarding completed 2026-10-04; unlocked /access gate via Meta Accounts Center IG linking (veryraremeta). Active chat session. |
|
| dev | dev | dev | email_otp | paradaproduced@gmail.com | paradaproduced@gmail.com | no | yes | active | 104.28.195.181 | 9460 | dev | Full onboarding completed 2026-10-04; unlocked /access gate via Meta Accounts Center IG linking (veryraremeta). Active chat session. |
|
||||||
|
|
||||||
## Login Type Details
|
## Login Type Details
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ veth IPs aren't routable off the host and Warp forwards no inbound traffic.
|
|||||||
- `bin/accounts-health.py` — per-account CDP session probe (runs inside the netns).
|
- `bin/accounts-health.py` — per-account CDP session probe (runs inside the netns).
|
||||||
- `bin/accounts-health.sh` — aggregates account vitality from ACCOUNTS.md,
|
- `bin/accounts-health.sh` — aggregates account vitality from ACCOUNTS.md,
|
||||||
signs + POSTs to the board health ingest (systemd timer, every 15 min).
|
signs + POSTs to the board health ingest (systemd timer, every 15 min).
|
||||||
|
- `bin/muse-cli-node <node> [args]` — runs muse-cli inside node's netns with dedicated Cloudflare WARP egress & auto-refreshing cookies.
|
||||||
|
- `bin/refresh-node-cookies.py <node>` — extracts fresh cookies from running Chromium CDP in netns into `~/.config/muse-cli/<node>/cookies.txt`.
|
||||||
|
- `bin/muse_hybrid.py` — programmatic hybrid bridge combining fast gateway calls with CDP fallbacks.
|
||||||
|
- docs/HYBRID-GATEWAY-ADAPTATION.md — architectural guide on the muse-cli fast gateway adaptation and per-node egress isolation.
|
||||||
|
|
||||||
## Verification checklist
|
## Verification checklist
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
box-query.py — Lightweight client to query box.muse-dev.online APIs over HTTPS.
|
||||||
|
|
||||||
|
Works inside containers and nodes without direct SSH access:
|
||||||
|
Uses SSH signature authentication (?identity=bl&ts=...&sig=...) against
|
||||||
|
the VM Box API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
box-query.py timers
|
||||||
|
box-query.py jobs
|
||||||
|
box-query.py agents
|
||||||
|
box-query.py dms [limit]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BOX_API_BASE = os.environ.get("BOX_API_BASE", "https://box.muse-dev.online")
|
||||||
|
BOX_SIGN_KEY = os.environ.get("BOX_SIGN_KEY", os.path.expanduser("~/.ssh/id_ed25519"))
|
||||||
|
|
||||||
|
def sign_request(identity, endpoint):
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
payload = f"{ts}\n{endpoint}".encode()
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
["ssh-keygen", "-Y", "sign", "-f", BOX_SIGN_KEY, "-n", "box"],
|
||||||
|
input=payload, capture_output=True, timeout=15)
|
||||||
|
if p.returncode != 0:
|
||||||
|
return None, None
|
||||||
|
return ts, p.stdout.decode()
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def query(endpoint):
|
||||||
|
ts, sig = sign_request("bl", endpoint)
|
||||||
|
if not ts or not sig:
|
||||||
|
sys.stderr.write("Failed to sign request (missing key or ssh-keygen error)\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
query_str = urllib.parse.urlencode({
|
||||||
|
"identity": "bl",
|
||||||
|
"ts": ts,
|
||||||
|
"sig": sig
|
||||||
|
})
|
||||||
|
url = f"{BOX_API_BASE}/api/box/{endpoint}?{query_str}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "NetVM-box-query/1.0 (container)"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = resp.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
return json.loads(data)
|
||||||
|
except Exception:
|
||||||
|
return data
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Query box.muse-dev.online APIs via HTTPS signature auth")
|
||||||
|
p.add_argument("endpoint", choices=["timers", "jobs", "agents", "dms", "health"])
|
||||||
|
p.add_argument("--json", action="store_true")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
res = query(args.endpoint)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Chat rate metric: messages per minute for main chat and each side chat.
|
||||||
|
|
||||||
|
Polls muse-chat-api.py for message counts, calculates delta vs previous poll,
|
||||||
|
logs rates to a time-series file.
|
||||||
|
|
||||||
|
Usage: chat-rate.py --account <name> --cdp-port <port> [--interval 60]
|
||||||
|
"""
|
||||||
|
import json, subprocess, sys, time, os, argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
API = os.path.expanduser("~/Projects/NetVM/bin/muse-chat-api.py")
|
||||||
|
STATE_FILE = os.path.expanduser("~/Projects/NetVM/logs/chat-rate-state.json")
|
||||||
|
LOG_FILE = os.path.expanduser("~/Projects/NetVM/logs/chat-rate.log")
|
||||||
|
|
||||||
|
def run_api(account, *args):
|
||||||
|
"""Run muse-chat-api.py and return stdout."""
|
||||||
|
cmd = ["python3", API, "--account", account] + list(args)
|
||||||
|
# Note: cdp-port is baked into the account config, not passed here
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
def count_messages(text):
|
||||||
|
"""Count messages in API output. Messages are separated by '---'."""
|
||||||
|
# The API outputs messages separated by ---\n
|
||||||
|
parts = [p.strip() for p in text.split("---") if p.strip()]
|
||||||
|
# Filter out non-message lines (headers, etc.)
|
||||||
|
# Messages typically have substantial content
|
||||||
|
return len([p for p in parts if len(p) > 10])
|
||||||
|
|
||||||
|
def get_side_chats(account):
|
||||||
|
"""List side chat names."""
|
||||||
|
out = run_api(account, "sidechat", "list")
|
||||||
|
# Parse: names and timestamps separated by blank lines
|
||||||
|
# Format: "Side chats\n\n<name>\n\n<timestamp>\n\n<name>\n\n<timestamp>..."
|
||||||
|
lines = [l.strip() for l in out.split("\n") if l.strip()]
|
||||||
|
chats = []
|
||||||
|
# Skip header "Side chats", then pair up (name, timestamp)
|
||||||
|
lines = [l for l in lines if l != "Side chats"]
|
||||||
|
# Lines alternate: name, timestamp, name, timestamp...
|
||||||
|
for i in range(0, len(lines), 2):
|
||||||
|
if i < len(lines):
|
||||||
|
name = lines[i]
|
||||||
|
# Verify next is a timestamp (ends with m/h/d)
|
||||||
|
if i + 1 < len(lines) and lines[i+1][-1] in "mhd":
|
||||||
|
chats.append(name)
|
||||||
|
return chats
|
||||||
|
|
||||||
|
def get_chat_count(account, chat_name=None):
|
||||||
|
"""Get message count for main or a side chat."""
|
||||||
|
if chat_name:
|
||||||
|
# Switch to side chat, get messages, switch back
|
||||||
|
run_api(account, "sidechat", "use", chat_name)
|
||||||
|
out = run_api(account, "messages")
|
||||||
|
run_api(account, "sidechat", "main") # switch back
|
||||||
|
else:
|
||||||
|
out = run_api(account, "messages")
|
||||||
|
return count_messages(out)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--account", required=True)
|
||||||
|
p.add_argument("--interval", type=int, default=60, help="poll interval seconds")
|
||||||
|
p.add_argument("--once", action="store_true", help="single poll, no loop")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
# Load previous state
|
||||||
|
prev = {}
|
||||||
|
if os.path.exists(STATE_FILE):
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
prev = json.load(f)
|
||||||
|
|
||||||
|
def poll():
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
counts = {}
|
||||||
|
|
||||||
|
# Main chat
|
||||||
|
try:
|
||||||
|
counts["main"] = get_chat_count(args.account)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"main: error {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Side chats
|
||||||
|
try:
|
||||||
|
sc_list = get_side_chats(args.account)
|
||||||
|
print(f"DEBUG: found {len(sc_list)} side chats", file=sys.stderr)
|
||||||
|
for sc in sc_list:
|
||||||
|
try:
|
||||||
|
counts[f"side:{sc}"] = get_chat_count(args.account, sc)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"side:{sc}: error {e}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"sidechat list: error {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Calculate rates
|
||||||
|
results = []
|
||||||
|
for chat, count in counts.items():
|
||||||
|
rate = 0.0
|
||||||
|
if chat in prev:
|
||||||
|
prev_count, prev_time = prev[chat]
|
||||||
|
dt = (datetime.fromisoformat(now) - datetime.fromisoformat(prev_time)).total_seconds() / 60.0
|
||||||
|
if dt > 0:
|
||||||
|
rate = (count - prev_count) / dt
|
||||||
|
results.append((now, chat, count, round(rate, 2)))
|
||||||
|
prev[chat] = (count, now)
|
||||||
|
|
||||||
|
# Log
|
||||||
|
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||||
|
with open(LOG_FILE, "a") as f:
|
||||||
|
for ts, chat, count, rate in results:
|
||||||
|
f.write(f"{ts} {chat} count={count} rate={rate}/min\n")
|
||||||
|
|
||||||
|
# Save state
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(prev, f)
|
||||||
|
|
||||||
|
# Print
|
||||||
|
for ts, chat, count, rate in results:
|
||||||
|
print(f"{chat}: {count} msgs, {rate}/min")
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
poll()
|
||||||
|
else:
|
||||||
|
while True:
|
||||||
|
poll()
|
||||||
|
time.sleep(args.interval)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -68,9 +68,45 @@ healthy() {
|
|||||||
|| { HEALTH_FAIL_REASON="CDP up but no page target in list"; return 1; }
|
|| { HEALTH_FAIL_REASON="CDP up but no page target in list"; return 1; }
|
||||||
echo "$list" | grep -E -q '"url": "https://muse\.ai' \
|
echo "$list" | grep -E -q '"url": "https://muse\.ai' \
|
||||||
|| { HEALTH_FAIL_REASON="CDP up but not on muse.ai"; return 1; }
|
|| { HEALTH_FAIL_REASON="CDP up but not on muse.ai"; return 1; }
|
||||||
|
warp_egress_healthy || return 1
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Stage 5: Warp egress health (2026-10-04). A partitioned browser (tunnel down,
|
||||||
|
# CDP green) passes stages 1-4 while being unable to reach muse.ai. Check the
|
||||||
|
# WireGuard handshake age and probe egress from inside the node's netns.
|
||||||
|
# Sets HEALTH_FAIL_REASON with a distinct "warp egress down" prefix so
|
||||||
|
# root-cause analysis can distinguish partitions from Chromium crashes.
|
||||||
|
warp_egress_healthy() {
|
||||||
|
local wg_out hs_line age_s code
|
||||||
|
wg_out="$(sudo -n ip netns exec "warp-${PROFILE}" wg show 2>/dev/null)" \
|
||||||
|
|| { HEALTH_FAIL_REASON="warp egress down (wg show failed in warp-${PROFILE})"; return 1; }
|
||||||
|
hs_line="$(printf '%s\n' "$wg_out" | grep -i "latest handshake" | head -1)"
|
||||||
|
[ -n "$hs_line" ] \
|
||||||
|
|| { HEALTH_FAIL_REASON="warp egress down (no WireGuard handshake in warp-${PROFILE})"; return 1; }
|
||||||
|
# "latest handshake: 1 minute, 41 seconds ago" -> total seconds
|
||||||
|
age_s="$(printf '%s\n' "$hs_line" | python3 -c '
|
||||||
|
import sys, re
|
||||||
|
s = sys.stdin.read()
|
||||||
|
m = re.search(r"(\d+)\s*hour", s); h = int(m.group(1)) if m else 0
|
||||||
|
m = re.search(r"(\d+)\s*minute", s); mi = int(m.group(1)) if m else 0
|
||||||
|
m = re.search(r"(\d+)\s*second", s); se = int(m.group(1)) if m else 0
|
||||||
|
print(h*3600 + mi*60 + se)
|
||||||
|
' 2>/dev/null)"
|
||||||
|
{ [ -n "$age_s" ] && [ "$age_s" -ge 0 ]; } 2>/dev/null \
|
||||||
|
|| { HEALTH_FAIL_REASON="warp egress down (unparseable handshake: $hs_line)"; return 1; }
|
||||||
|
[ "$age_s" -le 180 ] \
|
||||||
|
|| { HEALTH_FAIL_REASON="warp egress down (handshake ${age_s}s old in warp-${PROFILE})"; return 1; }
|
||||||
|
code="$(sudo -n ip netns exec "warp-${PROFILE}" curl -m 5 -s -o /dev/null -w "%{http_code}" "https://muse.ai/" 2>/dev/null)" \
|
||||||
|
|| { HEALTH_FAIL_REASON="warp egress down (egress probe curl failed in warp-${PROFILE})"; return 1; }
|
||||||
|
# Any 2xx/3xx means we reached muse.ai infra ("/" 307-redirects to the
|
||||||
|
# auth flow). The probe tests egress connectivity, not page content.
|
||||||
|
case "$code" in
|
||||||
|
2*|3*) return 0 ;;
|
||||||
|
*) HEALTH_FAIL_REASON="warp egress down (egress probe HTTP $code in warp-${PROFILE})"; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
if healthy; then
|
if healthy; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -101,6 +137,23 @@ if [ -n "$recent_pid" ]; then
|
|||||||
log "browser launched recently (pid $recent_pid), skipping relaunch (probably still starting)"
|
log "browser launched recently (pid $recent_pid), skipping relaunch (probably still starting)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# Warp-partition recovery (2026-10-04): relaunching Chrome cannot fix a dead
|
||||||
|
# Warp tunnel — the new browser would fail the same egress check and the
|
||||||
|
# watchdog would loop. If the failure is warp-egress, restart the tunnel first
|
||||||
|
# (netvm-node-up.sh is idempotent); only fall through to the Chrome relaunch
|
||||||
|
# if the tunnel does not recover.
|
||||||
|
case "$HEALTH_FAIL_REASON" in
|
||||||
|
"warp egress down"*)
|
||||||
|
log "warp partition detected ($HEALTH_FAIL_REASON), restarting tunnel via netvm-node-up.sh"
|
||||||
|
sudo -n "$NETVM_BIN/netvm-node-up.sh" "$PROFILE" >>"$LOG" 2>&1 || true
|
||||||
|
sleep 5
|
||||||
|
if healthy; then
|
||||||
|
log "tunnel restart recovered warp egress, chrome relaunch not needed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "tunnel restart did not recover egress ($HEALTH_FAIL_REASON), proceeding with chrome relaunch"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
log "unhealthy ($HEALTH_FAIL_REASON), relaunching chromebox"
|
log "unhealthy ($HEALTH_FAIL_REASON), relaunching chromebox"
|
||||||
# bracket trick so pkill never matches its own command line
|
# bracket trick so pkill never matches its own command line
|
||||||
pat="profiles/${PROFILE:0:${#PROFILE}-1}[${PROFILE: -1}]/"
|
pat="profiles/${PROFILE:0:${#PROFILE}-1}[${PROFILE: -1}]/"
|
||||||
@@ -125,9 +178,13 @@ rm -f "/home/super/.local/share/chrome-box/profiles/${PROFILE}/home/.config/chro
|
|||||||
# muse-chat-api.py uses — a relaunch on the wrong port looks healthy to the
|
# muse-chat-api.py uses — a relaunch on the wrong port looks healthy to the
|
||||||
# launcher but is unreachable to the API (observed 2026-10-03: pip relaunched
|
# launcher but is unreachable to the API (observed 2026-10-03: pip relaunched
|
||||||
# on 9278 instead of 9420, watchdog looped on "relaunch FAILED").
|
# on 9278 instead of 9420, watchdog looped on "relaunch FAILED").
|
||||||
|
# 9>&-: do NOT let the backgrounded launcher inherit the watchdog lock fd.
|
||||||
|
# Inherited flock fds wedge the lock forever (observed 2026-10-04: opm/muse
|
||||||
|
# launchers held their profile lock for 100+ min, every later watchdog run
|
||||||
|
# skipped as "another run in progress" — the watchdog was silently dead).
|
||||||
systemd-run --user --scope --unit="netvm-chrome-${PROFILE}-$(date +%s)" \
|
systemd-run --user --scope --unit="netvm-chrome-${PROFILE}-$(date +%s)" \
|
||||||
"$NETVM_BIN/netvm-chrome.sh" --headless --cdp-port "$CDP_PORT" "$PROFILE" "https://muse.ai" \
|
"$NETVM_BIN/netvm-chrome.sh" --headless --cdp-port "$CDP_PORT" "$PROFILE" "https://muse.ai" \
|
||||||
>>"$CHROME_LOG" 2>&1 < /dev/null &
|
>>"$CHROME_LOG" 2>&1 < /dev/null 9>&- &
|
||||||
# Retry with backoff (2026-10-04): cold starts (fresh egress IP, Cloudflare
|
# Retry with backoff (2026-10-04): cold starts (fresh egress IP, Cloudflare
|
||||||
# handshake) can take >25s for the page title to appear. A single check after
|
# handshake) can take >25s for the page title to appear. A single check after
|
||||||
# 25s kills working-but-slow browsers. Try up to 4 times, 15s apart (~60s
|
# 25s kills working-but-slow browsers. Try up to 4 times, 15s apart (~60s
|
||||||
|
|||||||
Executable
+178
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
crypt-server.py — Public cryptographic attestation and key directory for NetVM.
|
||||||
|
|
||||||
|
Serves https://crypt.muse-dev.online/ (via cloudflared / reverse proxy).
|
||||||
|
Endpoints:
|
||||||
|
GET / -> Service directory / health JSON
|
||||||
|
GET /health -> Health check
|
||||||
|
GET /keys/allowed_signers -> OpenSSH allowed_signers formatted file
|
||||||
|
GET /keys/{identity}.pub -> Individual public key
|
||||||
|
GET /proofs -> List known proof hashes / work order attestations
|
||||||
|
GET /proofs/{id} -> Retrieve proof envelope and SSH signature
|
||||||
|
POST /proofs -> Submit / register a signed proof record
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
REPO_DIR = "/home/super/Projects/NetVM"
|
||||||
|
SIGNERS_DIR = os.path.join(REPO_DIR, "dm-signers")
|
||||||
|
PROOFS_DIR = os.path.join(REPO_DIR, "var", "proofs")
|
||||||
|
|
||||||
|
os.makedirs(PROOFS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
class CryptHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "crypt-attestation/1.0"
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
sys.stderr.write(f"crypt-server: {self.client_address[0]} - {format % args}\n")
|
||||||
|
|
||||||
|
def _send(self, code, content, content_type="application/json"):
|
||||||
|
if isinstance(content, (dict, list)):
|
||||||
|
body = json.dumps(content, indent=2).encode("utf-8")
|
||||||
|
elif isinstance(content, str):
|
||||||
|
body = content.encode("utf-8")
|
||||||
|
else:
|
||||||
|
body = bytes(content)
|
||||||
|
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.split("?")[0].rstrip("/")
|
||||||
|
if not path:
|
||||||
|
path = "/"
|
||||||
|
|
||||||
|
if path in ("/", "/health"):
|
||||||
|
self._send(200, {
|
||||||
|
"service": "crypt.muse-dev.online",
|
||||||
|
"status": "active",
|
||||||
|
"mode": "public_attestation",
|
||||||
|
"endpoints": [
|
||||||
|
"/keys/allowed_signers",
|
||||||
|
"/keys/<identity>.pub",
|
||||||
|
"/proofs",
|
||||||
|
"/proofs/<id>"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/keys/allowed_signers":
|
||||||
|
allowed_path = os.path.join(SIGNERS_DIR, "allowed_signers")
|
||||||
|
if os.path.exists(allowed_path):
|
||||||
|
with open(allowed_path, "r") as f:
|
||||||
|
data = f.read()
|
||||||
|
self._send(200, data, content_type="text/plain; charset=utf-8")
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": "allowed_signers not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
m_key = re.match(r"^/keys/([a-zA-Z0-9_\-\.]+)\.pub$", path)
|
||||||
|
if m_key:
|
||||||
|
ident = m_key.group(1)
|
||||||
|
pub_path = os.path.join(SIGNERS_DIR, f"{ident}.pub")
|
||||||
|
if os.path.exists(pub_path):
|
||||||
|
with open(pub_path, "r") as f:
|
||||||
|
data = f.read()
|
||||||
|
self._send(200, data, content_type="text/plain; charset=utf-8")
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": f"Public key for {ident} not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/proofs":
|
||||||
|
proof_files = glob.glob(os.path.join(PROOFS_DIR, "*.json"))
|
||||||
|
ids = [os.path.basename(p)[:-5] for p in proof_files]
|
||||||
|
self._send(200, {"proofs": sorted(ids)})
|
||||||
|
return
|
||||||
|
|
||||||
|
m_proof = re.match(r"^/proofs/([a-zA-Z0-9_\-]+)$", path)
|
||||||
|
if m_proof:
|
||||||
|
pid = m_proof.group(1)
|
||||||
|
pf = os.path.join(PROOFS_DIR, f"{pid}.json")
|
||||||
|
if os.path.exists(pf):
|
||||||
|
with open(pf, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self._send(200, data)
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": f"Proof {pid} not found"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = self.path.split("?")[0].rstrip("/")
|
||||||
|
if path == "/proofs":
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
except ValueError:
|
||||||
|
length = 0
|
||||||
|
if length <= 0 or length > 65536:
|
||||||
|
self._send(400, {"error": "invalid content length"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.rfile.read(length))
|
||||||
|
except Exception:
|
||||||
|
self._send(400, {"error": "malformed JSON"})
|
||||||
|
return
|
||||||
|
|
||||||
|
pid = data.get("id")
|
||||||
|
if not pid or not re.match(r"^[a-zA-Z0-9_\-]+$", pid):
|
||||||
|
self._send(400, {"error": "missing or invalid proof id"})
|
||||||
|
return
|
||||||
|
|
||||||
|
pf = os.path.join(PROOFS_DIR, f"{pid}.json")
|
||||||
|
with open(pf, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
self._send(201, {"status": "stored", "id": pid, "url": f"https://crypt.muse-dev.online/proofs/{pid}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Crypt attestation and key server")
|
||||||
|
parser.add_argument("--port", type=int, default=8446)
|
||||||
|
parser.add_argument("--host", default="100.123.153.75")
|
||||||
|
parser.add_argument("--ssl", action="store_true", help="Enable self-signed HTTPS")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server = HTTPServer((args.host, args.port), CryptHandler)
|
||||||
|
|
||||||
|
if args.ssl:
|
||||||
|
cert_file = os.path.join(REPO_DIR, "ssl", "crypt-selfsigned.crt")
|
||||||
|
key_file = os.path.join(REPO_DIR, "ssl", "crypt-selfsigned.key")
|
||||||
|
os.makedirs(os.path.dirname(cert_file), exist_ok=True)
|
||||||
|
if not os.path.exists(cert_file):
|
||||||
|
import subprocess
|
||||||
|
subprocess.run([
|
||||||
|
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||||
|
"-keyout", key_file, "-out", cert_file,
|
||||||
|
"-days", "3650", "-nodes",
|
||||||
|
"-subj", "/CN=crypt.muse-dev.online"
|
||||||
|
], check=True, capture_output=True)
|
||||||
|
os.chmod(key_file, 0o600)
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(cert_file, key_file)
|
||||||
|
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
||||||
|
|
||||||
|
print(f"Crypt server running on {'https' if args.ssl else 'http'}://{args.host}:{args.port}", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down crypt server", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Side-chat to main-chat work siphon — detection rules.
|
||||||
|
|
||||||
|
Monitors side chat messages and identifies "siphon-worthy" content:
|
||||||
|
work that should surface in main chat for visibility.
|
||||||
|
|
||||||
|
Categories:
|
||||||
|
COMPLETED - work finished, results ready
|
||||||
|
BLOCKER - something is stuck, needs intervention
|
||||||
|
DECISION - a decision is needed from the user/operator
|
||||||
|
ALERT - health/security/urgency signal
|
||||||
|
MILESTONE - significant progress checkpoint
|
||||||
|
|
||||||
|
Detection is purely pattern-based (raw Python, no AI).
|
||||||
|
Each rule returns (category, confidence, summary) or None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SiphonHit:
|
||||||
|
category: str # COMPLETED, BLOCKER, DECISION, ALERT, MILESTONE
|
||||||
|
confidence: float # 0.0 - 1.0
|
||||||
|
summary: str # one-line summary for main chat
|
||||||
|
thread_id: str # source side chat
|
||||||
|
message_id: str # source message
|
||||||
|
# Full message text is NOT stored here — main chat gets a summary
|
||||||
|
# plus a link back, never the full content (safety: no sensitive
|
||||||
|
# data siphoned verbatim).
|
||||||
|
|
||||||
|
|
||||||
|
# --- Keyword sets (configurable) ---
|
||||||
|
|
||||||
|
COMPLETED_PATTERNS = [
|
||||||
|
re.compile(r'\b(done|completed|finished|deployed|shipped|live|verified)\b', re.I),
|
||||||
|
re.compile(r'\b(all|tests?)\s+(pass|green|passing)\b', re.I),
|
||||||
|
re.compile(r'\[RESULT[^\]]*\]\s*OK', re.I),
|
||||||
|
re.compile(r'\b(merged|committed|pushed|published)\b', re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
BLOCKER_PATTERNS = [
|
||||||
|
re.compile(r'\b(blocked|stuck|failing|broken|down|error|failed)\b', re.I),
|
||||||
|
re.compile(r'\b(need|needs|waiting)\s+(your|approval|input|decision)\b', re.I),
|
||||||
|
re.compile(r'\b(can\'t|cannot|unable to)\b', re.I),
|
||||||
|
re.compile(r'\[RESULT[^\]]*\]\s*(FAIL|ERROR)', re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
DECISION_PATTERNS = [
|
||||||
|
re.compile(r'\b(should (i|we)|shall i|want me to)\b', re.I),
|
||||||
|
re.compile(r'\b(your call|needs? your|awaiting your)\b', re.I),
|
||||||
|
re.compile(r'\b(approve|approval)\b.*\?', re.I),
|
||||||
|
re.compile(r'^(yes|no)\s*\?\s*$', re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
ALERT_PATTERNS = [
|
||||||
|
re.compile(r'\b(security|vulnerability|breach|compromised|exploit)\b', re.I),
|
||||||
|
re.compile(r'\b(urgent|critical|emergency|asap)\b', re.I),
|
||||||
|
re.compile(r'\b(502|503|500)\b.*\b(error|down)\b', re.I),
|
||||||
|
re.compile(r'\b(ssh|tunnel).*\b(down|broken|failed)\b', re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
MILESTONE_PATTERNS = [
|
||||||
|
re.compile(r'\b(milestone|phase \d+ (complete|done)|shipped v)\b', re.I),
|
||||||
|
re.compile(r'\b(all \d+ (items? )?done)\b', re.I),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Patterns that suppress siphoning (safety)
|
||||||
|
SUPPRESS_PATTERNS = [
|
||||||
|
re.compile(r'\b(password|secret|token|key|pin)\s*[:=]', re.I),
|
||||||
|
re.compile(r'-----BEGIN', re.I), # never siphon key material
|
||||||
|
re.compile(r'\[do not siphon\]', re.I), # explicit opt-out marker
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _match_score(text: str, patterns) -> float:
|
||||||
|
"""Return confidence based on how many patterns match."""
|
||||||
|
hits = sum(1 for p in patterns if p.search(text))
|
||||||
|
if hits == 0:
|
||||||
|
return 0.0
|
||||||
|
# Diminishing returns: 1 hit = 0.6, 2 = 0.8, 3+ = 0.95
|
||||||
|
return min(0.95, 0.6 + (hits - 1) * 0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_summary(text: str, max_len: int = 120) -> str:
|
||||||
|
"""Extract a safe one-line summary. Strips to first meaningful line."""
|
||||||
|
# Take first non-empty line, truncate
|
||||||
|
for line in text.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line and len(line) > 10:
|
||||||
|
if len(line) > max_len:
|
||||||
|
return line[:max_len - 3] + '...'
|
||||||
|
return line
|
||||||
|
return text[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def detect(text: str, thread_id: str, message_id: str,
|
||||||
|
min_confidence: float = 0.6) -> Optional[SiphonHit]:
|
||||||
|
"""
|
||||||
|
Check a side chat message for siphon-worthy content.
|
||||||
|
Returns SiphonHit or None.
|
||||||
|
"""
|
||||||
|
# Safety: suppress sensitive content
|
||||||
|
for p in SUPPRESS_PATTERNS:
|
||||||
|
if p.search(text):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
("COMPLETED", _match_score(text, COMPLETED_PATTERNS)),
|
||||||
|
("BLOCKER", _match_score(text, BLOCKER_PATTERNS)),
|
||||||
|
("DECISION", _match_score(text, DECISION_PATTERNS)),
|
||||||
|
("ALERT", _match_score(text, ALERT_PATTERNS)),
|
||||||
|
("MILESTONE", _match_score(text, MILESTONE_PATTERNS)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sort by confidence descending; ALERT wins ties (safety: urgency first)
|
||||||
|
# Use negative confidence for descending, and ALERT as tiebreaker
|
||||||
|
candidates.sort(key=lambda x: (-x[1], 0 if x[0] == "ALERT" else 1))
|
||||||
|
best_cat, best_conf = candidates[0]
|
||||||
|
|
||||||
|
if best_conf < min_confidence:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return SiphonHit(
|
||||||
|
category=best_cat,
|
||||||
|
confidence=best_conf,
|
||||||
|
summary=_extract_summary(text),
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Opt-out registry ---
|
||||||
|
|
||||||
|
_opt_out_threads: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def opt_out(thread_id: str):
|
||||||
|
"""Agent opts a side chat out of siphoning."""
|
||||||
|
_opt_out_threads.add(thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
def opt_in(thread_id: str):
|
||||||
|
"""Re-enable siphoning for a side chat."""
|
||||||
|
_opt_out_threads.discard(thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
def is_opted_out(thread_id: str) -> bool:
|
||||||
|
return thread_id in _opt_out_threads
|
||||||
@@ -43,6 +43,14 @@ import urllib.request
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# Shared per-agent rate limiter (jittered) — de-correlates fleet sends
|
||||||
|
# so the 4 nodes don't hit the shared egress IP in lockstep.
|
||||||
|
try:
|
||||||
|
from rate_limiter import rate_limit_wait
|
||||||
|
HAS_RATE_LIMITER = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_RATE_LIMITER = False
|
||||||
|
|
||||||
API = "/home/super/Projects/NetVM/bin/muse-chat-api.py"
|
API = "/home/super/Projects/NetVM/bin/muse-chat-api.py"
|
||||||
NETVM_EXEC = "/home/super/Projects/NetVM/bin/netvm-exec.sh"
|
NETVM_EXEC = "/home/super/Projects/NetVM/bin/netvm-exec.sh"
|
||||||
VALID_AGENTS = ["muse", "pip", "646", "opm"]
|
VALID_AGENTS = ["muse", "pip", "646", "opm"]
|
||||||
@@ -222,8 +230,9 @@ SIDCHAT_ALIASES = {
|
|||||||
# autoprovision, which registers the live UUID in job-sidechats.json.
|
# autoprovision, which registers the live UUID in job-sidechats.json.
|
||||||
# Do NOT re-add hardcoded UUIDs here; use job-sidechats.json instead.
|
# Do NOT re-add hardcoded UUIDs here; use job-sidechats.json instead.
|
||||||
|
|
||||||
def resolve_sidechat_target(target):
|
def resolve_sidechat_target(target, agent=None):
|
||||||
"""Resolve target alias or name to UUID dynamically from job-sidechats.json."""
|
"""Resolve target alias or name to UUID dynamically from job-sidechats.json.
|
||||||
|
Supports agent-specific scoped targets (e.g. '646-pip' resolving to recipient's local thread)."""
|
||||||
if not target or not str(target).strip():
|
if not target or not str(target).strip():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"DM target must not be empty: specify a sidechat name/UUID, "
|
"DM target must not be empty: specify a sidechat name/UUID, "
|
||||||
@@ -239,8 +248,26 @@ def resolve_sidechat_target(target):
|
|||||||
try:
|
try:
|
||||||
with open(sc_file, "r", encoding="utf-8") as f:
|
with open(sc_file, "r", encoding="utf-8") as f:
|
||||||
sc_data = json.load(f)
|
sc_data = json.load(f)
|
||||||
|
|
||||||
|
# 1. Check agent-scoped alias first (e.g. target:646-pip, agent:646)
|
||||||
|
if agent:
|
||||||
|
scoped_key = f"{target}@{agent}"
|
||||||
|
if scoped_key in sc_data:
|
||||||
|
val = sc_data[scoped_key]
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return val.get("thread_uuid") or val.get("uuid") or target
|
||||||
|
elif isinstance(val, str):
|
||||||
|
return val
|
||||||
|
|
||||||
|
# 2. Check general key
|
||||||
val = sc_data.get(target)
|
val = sc_data.get(target)
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
|
# If mapped entry defines an agent, but recipient differs, check for agent match
|
||||||
|
if agent and val.get("agent") and val.get("agent") != agent:
|
||||||
|
# Look for an entry explicitly matching this agent
|
||||||
|
for k, item in sc_data.items():
|
||||||
|
if isinstance(item, dict) and item.get("agent") == agent and k.startswith(target):
|
||||||
|
return item.get("thread_uuid") or target
|
||||||
return val.get("thread_uuid") or val.get("uuid") or target
|
return val.get("thread_uuid") or val.get("uuid") or target
|
||||||
elif isinstance(val, str):
|
elif isinstance(val, str):
|
||||||
return val
|
return val
|
||||||
@@ -541,7 +568,7 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
|||||||
pre_create_uuid = None # parked thread before autoprovision (creation check)
|
pre_create_uuid = None # parked thread before autoprovision (creation check)
|
||||||
nav_is_uuid = False
|
nav_is_uuid = False
|
||||||
# Resolve well-known aliases or dynamic thread mappings to UUIDs.
|
# Resolve well-known aliases or dynamic thread mappings to UUIDs.
|
||||||
nav_target = resolve_sidechat_target(target)
|
nav_target = resolve_sidechat_target(target, recipient)
|
||||||
if nav_target != target:
|
if nav_target != target:
|
||||||
log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target,
|
log_event({"type": "alias_resolved", "id": msg_id, "target": target, "thread_uuid": nav_target,
|
||||||
"source": resolve_sidechat_source(target)})
|
"source": resolve_sidechat_source(target)})
|
||||||
@@ -646,6 +673,11 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
|||||||
f"actual_url={_ps_detail.get('actual_url')})", file=sys.stderr)
|
f"actual_url={_ps_detail.get('actual_url')})", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Per-agent rate limit (jittered): de-correlate fleet sends across the
|
||||||
|
# shared egress IP. Near no-op at normal pacing (sends already sleep).
|
||||||
|
if HAS_RATE_LIMITER:
|
||||||
|
rate_limit_wait(agent)
|
||||||
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# Send (raw mode: no truncation — signatures must survive intact)
|
# Send (raw mode: no truncation — signatures must survive intact)
|
||||||
@@ -830,7 +862,7 @@ def dm_read(agent, target, n=5, quiet=False, width=200):
|
|||||||
print(f"ERROR: Unknown agent {agent}", file=sys.stderr)
|
print(f"ERROR: Unknown agent {agent}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
nav_target = resolve_sidechat_target(target)
|
nav_target = resolve_sidechat_target(target, agent)
|
||||||
if target == "main":
|
if target == "main":
|
||||||
run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main")
|
run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main")
|
||||||
else:
|
else:
|
||||||
|
|||||||
Executable
+714
@@ -0,0 +1,714 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
exec-constrained.py — Constrained HTTPS script-execution endpoint for bl.
|
||||||
|
|
||||||
|
SSH-fallback: when SSH to bl is down, operators can still run a constrained
|
||||||
|
set of scripts (dm.py, job-dispatch, chromebox control) over HTTPS.
|
||||||
|
|
||||||
|
This is the hardened replacement for exec-server.py's /exec endpoint, which
|
||||||
|
executes ARBITRARY shell commands (subprocess.run(cmd, shell=True)). This
|
||||||
|
server NEVER takes a command string. Clients request a named OPERATION with
|
||||||
|
validated ARGUMENTS; the server maps op -> fixed argv. No shell. No
|
||||||
|
interpolation. No RCE.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 exec-constrained.py --port 8444
|
||||||
|
|
||||||
|
Client:
|
||||||
|
curl -sk -X POST https://127.0.0.1:8444/exec \\
|
||||||
|
-H 'Authorization: Bearer <token>' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{"op": "dm.send", "args": {"agent": "opm", "to": "646",
|
||||||
|
"target": "main", "message": "hello"}}'
|
||||||
|
|
||||||
|
Or signature auth (no secret in transit):
|
||||||
|
payload=$(python3 -c "import json,time,secrets; print(json.dumps({
|
||||||
|
'op': 'dm.send',
|
||||||
|
'args': {'agent':'opm','to':'646','target':'main','message':'hi'},
|
||||||
|
'ts': time.time(), 'nonce': secrets.token_hex(16)}))")
|
||||||
|
sig=$(printf '%s' "$payload" | ssh-keygen -Y sign -f ~/.ssh/id_frontdoor -n exec-constrained)
|
||||||
|
curl -sk -X POST https://127.0.0.1:8444/exec \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d "$(python3 -c "import json,sys; print(json.dumps({
|
||||||
|
'identity': 'operator-main',
|
||||||
|
'payload': sys.argv[1], 'signature': sys.argv[2]}))" "$payload" "$sig")"
|
||||||
|
|
||||||
|
Auth model: master bearer token + per-agent bearer tokens (same files as
|
||||||
|
exec-server.py, so migration is drop-in) OR ssh-keygen -Y signatures over
|
||||||
|
the op envelope (namespace 'exec-constrained'). See SECURITY.md.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- config
|
||||||
|
|
||||||
|
BIN_DIR = '/home/super/Projects/NetVM/bin'
|
||||||
|
JOBS_DIR = '/home/super/Projects/NetVM/jobs'
|
||||||
|
TOKEN_FILE = '/home/super/.exec-server-token'
|
||||||
|
TOKEN_DIR = '/home/super/.exec-tokens'
|
||||||
|
SIGNERS_FILE = '/home/super/.exec-signers'
|
||||||
|
NONCE_FILE = '/home/super/.exec-constrained-nonces'
|
||||||
|
AUDIT_LOG = '/home/super/.exec-constrained-audit.jsonl'
|
||||||
|
SIG_NAMESPACE = 'exec-constrained'
|
||||||
|
SIG_MAX_SKEW = 300
|
||||||
|
|
||||||
|
AGENTS = ('muse', 'pip', '646', 'opm')
|
||||||
|
TARGET_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9/_.-]{0,63}$')
|
||||||
|
JOB_RE = re.compile(r'^[a-z0-9][a-z0-9-]{0,63}$')
|
||||||
|
IDENT_RE = re.compile(r'^[a-z0-9-]+$')
|
||||||
|
NONCE_RE = re.compile(r'^[0-9a-fA-F]{16,128}$')
|
||||||
|
HEX_RE = re.compile(r'^[0-9a-f]{8,128}$')
|
||||||
|
|
||||||
|
MAX_BODY = 64 * 1024 # 64KB request cap
|
||||||
|
MAX_MESSAGE = 2000 # dm.py message cap
|
||||||
|
MAX_OUTPUT = 256 * 1024 # per-stream output cap
|
||||||
|
WORK_DIR = '/home/super'
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- rate limiting
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""Token bucket per identity. Thread-safe."""
|
||||||
|
def __init__(self, rate_per_min=20, burst=5):
|
||||||
|
self.rate = rate_per_min / 60.0
|
||||||
|
self.burst = burst
|
||||||
|
self._buckets = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def allow(self, ident):
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
tokens, last = self._buckets.get(ident, (self.burst, now))
|
||||||
|
tokens = min(self.burst, tokens + (now - last) * self.rate)
|
||||||
|
if tokens >= 1.0:
|
||||||
|
self._buckets[ident] = (tokens - 1.0, now)
|
||||||
|
return True
|
||||||
|
self._buckets[ident] = (tokens, now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
LIMITER = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- auth
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
try:
|
||||||
|
with open(TOKEN_FILE) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_token(token):
|
||||||
|
"""Bearer token -> identity label, or None. Never logs the token."""
|
||||||
|
master = get_token()
|
||||||
|
if token and master and hmac.compare_digest(token, master):
|
||||||
|
return 'master'
|
||||||
|
try:
|
||||||
|
names = os.listdir(TOKEN_DIR)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
for name in names:
|
||||||
|
if not IDENT_RE.fullmatch(name):
|
||||||
|
continue
|
||||||
|
p = os.path.join(TOKEN_DIR, name)
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(p) as f:
|
||||||
|
t = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if t and hmac.compare_digest(token, t):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_nonce(nonce):
|
||||||
|
now = time.time()
|
||||||
|
fresh = []
|
||||||
|
try:
|
||||||
|
with open(NONCE_FILE) as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
n, t = parts
|
||||||
|
try:
|
||||||
|
if now - float(t) < 2 * SIG_MAX_SKEW:
|
||||||
|
fresh.append((n, t))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
if any(n == nonce for n, _ in fresh):
|
||||||
|
return False
|
||||||
|
fresh.append((nonce, str(now)))
|
||||||
|
try:
|
||||||
|
with open(NONCE_FILE, 'w') as f:
|
||||||
|
for n, t in fresh:
|
||||||
|
f.write(f'{n} {t}\n')
|
||||||
|
os.chmod(NONCE_FILE, 0o600)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_signature(identity, payload, signature):
|
||||||
|
"""ssh-keygen -Y signature over {"op","args","ts","nonce"} -> identity/None."""
|
||||||
|
if not IDENT_RE.fullmatch(identity or ''):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
op = data.get('op')
|
||||||
|
args = data.get('args')
|
||||||
|
ts = data.get('ts')
|
||||||
|
nonce = data.get('nonce')
|
||||||
|
if not isinstance(op, str) or op not in OPS:
|
||||||
|
return None
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return None
|
||||||
|
if not isinstance(nonce, str) or not NONCE_RE.fullmatch(nonce):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ts = float(ts)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if abs(time.time() - ts) > SIG_MAX_SKEW:
|
||||||
|
return None
|
||||||
|
if not check_nonce(nonce):
|
||||||
|
return None
|
||||||
|
sig_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile('w', delete=False, suffix='.sig') as f:
|
||||||
|
f.write(signature if signature.endswith('\n') else signature + '\n')
|
||||||
|
sig_path = f.name
|
||||||
|
p = subprocess.run(
|
||||||
|
['ssh-keygen', '-Y', 'verify', '-f', SIGNERS_FILE, '-I', identity,
|
||||||
|
'-n', SIG_NAMESPACE, '-s', sig_path],
|
||||||
|
input=payload.encode(), capture_output=True, timeout=15)
|
||||||
|
return identity if p.returncode == 0 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if sig_path:
|
||||||
|
try:
|
||||||
|
os.unlink(sig_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------- allowlist
|
||||||
|
#
|
||||||
|
# Each op maps to a FIXED script with VALIDATED arguments. The client can
|
||||||
|
# never influence: the executable path, the subcommand, or any flag name.
|
||||||
|
# Only whitelisted argument VALUES flow through, each checked below.
|
||||||
|
#
|
||||||
|
# To add an op: add an entry here with a build() function. build() receives
|
||||||
|
# the validated args dict and returns an argv list (no shell). Arg spec is
|
||||||
|
# enforced by validate() before build() runs.
|
||||||
|
|
||||||
|
class OpError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_message(s):
|
||||||
|
if not isinstance(s, str) or not s.strip():
|
||||||
|
raise OpError('message must be a non-empty string')
|
||||||
|
if len(s) > MAX_MESSAGE:
|
||||||
|
raise OpError(f'message too long (max {MAX_MESSAGE})')
|
||||||
|
if any(ord(c) < 32 and c not in '\n\t' for c in s):
|
||||||
|
raise OpError('message contains control characters')
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(v):
|
||||||
|
if v not in AGENTS:
|
||||||
|
raise OpError(f'agent must be one of {AGENTS}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _target(v):
|
||||||
|
if not isinstance(v, str) or not TARGET_RE.fullmatch(v):
|
||||||
|
raise OpError('target must match ^[A-Za-z0-9][A-Za-z0-9/_.-]{0,63}$')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_int(v, lo, hi, name):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, bool) or not isinstance(v, int):
|
||||||
|
raise OpError(f'{name} must be an integer')
|
||||||
|
if not (lo <= v <= hi):
|
||||||
|
raise OpError(f'{name} must be {lo}..{hi}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _job_name(v):
|
||||||
|
# Must exist in JOBS_DIR and match the safe pattern (no path traversal).
|
||||||
|
if not isinstance(v, str) or not JOB_RE.fullmatch(v):
|
||||||
|
raise OpError('job must match ^[a-z0-9][a-z0-9-]{0,63}$')
|
||||||
|
path = os.path.join(JOBS_DIR, v + '.json')
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise OpError('unknown job')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _dm_send_build(a):
|
||||||
|
argv = [sys.executable, os.path.join(BIN_DIR, 'dm.py'), 'send',
|
||||||
|
'--agent', a['agent'], '--target', a['target']]
|
||||||
|
if a.get('to'):
|
||||||
|
argv += ['--to', a['to']]
|
||||||
|
if a.get('expect_reply'):
|
||||||
|
argv += ['--expect-reply']
|
||||||
|
if a.get('reply_timeout') is not None:
|
||||||
|
argv += ['--reply-timeout', str(a['reply_timeout'])]
|
||||||
|
if a.get('reply_nudges') is not None:
|
||||||
|
argv += ['--reply-nudges', str(a['reply_nudges'])]
|
||||||
|
if a.get('reply_escalate'):
|
||||||
|
argv += ['--reply-escalate', a['reply_escalate']]
|
||||||
|
if a.get('route'):
|
||||||
|
argv += ['--route', a['route']]
|
||||||
|
for t in a.get('tags', []):
|
||||||
|
argv += ['--tag', t]
|
||||||
|
argv.append(a['message'])
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def _dm_send_validate(raw):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise OpError('args must be an object')
|
||||||
|
allowed = {'agent', 'to', 'target', 'message', 'expect_reply',
|
||||||
|
'reply_timeout', 'reply_nudges', 'reply_escalate',
|
||||||
|
'route', 'tags'}
|
||||||
|
for k in raw:
|
||||||
|
if k not in allowed:
|
||||||
|
raise OpError(f'unknown arg: {k}')
|
||||||
|
a = {}
|
||||||
|
a['agent'] = _agent(raw.get('agent'))
|
||||||
|
if 'to' in raw and raw['to'] is not None:
|
||||||
|
a['to'] = _agent(raw['to'])
|
||||||
|
a['target'] = _target(raw.get('target'))
|
||||||
|
a['message'] = _clean_message(raw.get('message'))
|
||||||
|
a['expect_reply'] = bool(raw.get('expect_reply', False))
|
||||||
|
a['reply_timeout'] = _opt_int(raw.get('reply_timeout'), 60, 604800,
|
||||||
|
'reply_timeout')
|
||||||
|
a['reply_nudges'] = _opt_int(raw.get('reply_nudges'), 0, 10,
|
||||||
|
'reply_nudges')
|
||||||
|
esc = raw.get('reply_escalate')
|
||||||
|
if esc is not None:
|
||||||
|
a['reply_escalate'] = _agent(esc)
|
||||||
|
route = raw.get('route')
|
||||||
|
if route is not None:
|
||||||
|
if not isinstance(route, str) or not TARGET_RE.fullmatch(route):
|
||||||
|
raise OpError('route must match target pattern')
|
||||||
|
a['route'] = route
|
||||||
|
tags = raw.get('tags', [])
|
||||||
|
if not isinstance(tags, list) or len(tags) > 8:
|
||||||
|
raise OpError('tags must be a list of at most 8 strings')
|
||||||
|
clean_tags = []
|
||||||
|
for t in tags:
|
||||||
|
if not isinstance(t, str) or len(t) > 120:
|
||||||
|
raise OpError('tag must be a string <= 120 chars')
|
||||||
|
# Canonical tag vocabulary only (see DEPLOY-DECISIONS.md).
|
||||||
|
if not re.fullmatch(r'[a-z0-9_:-]+=[a-zA-Z0-9_.:/-]*', t) and \
|
||||||
|
not re.fullmatch(r'[a-z0-9_:-]+', t):
|
||||||
|
raise OpError(f'malformed tag: {t}')
|
||||||
|
clean_tags.append(t)
|
||||||
|
a['tags'] = clean_tags
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def _dm_thread_validate(raw):
|
||||||
|
# Same shape as dm.send but uses the thread subcommand.
|
||||||
|
return _dm_send_validate(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _dm_thread_build(a):
|
||||||
|
argv = _dm_send_build(a)
|
||||||
|
argv[2] = 'thread'
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def _dm_read_validate(raw):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise OpError('args must be an object')
|
||||||
|
allowed = {'agent', 'target', 'limit'}
|
||||||
|
for k in raw:
|
||||||
|
if k not in allowed:
|
||||||
|
raise OpError(f'unknown arg: {k}')
|
||||||
|
return {
|
||||||
|
'agent': _agent(raw.get('agent')),
|
||||||
|
'target': _target(raw.get('target')),
|
||||||
|
'limit': _opt_int(raw.get('limit', 20), 1, 100, 'limit') or 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dm_read_build(a):
|
||||||
|
return [sys.executable, os.path.join(BIN_DIR, 'dm.py'), 'read',
|
||||||
|
'--agent', a['agent'], '--target', a['target'],
|
||||||
|
'--limit', str(a['limit'])]
|
||||||
|
|
||||||
|
|
||||||
|
def _job_run_validate(raw):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise OpError('args must be an object')
|
||||||
|
allowed = {'job'}
|
||||||
|
for k in raw:
|
||||||
|
if k not in allowed:
|
||||||
|
raise OpError(f'unknown arg: {k}')
|
||||||
|
return {'job': _job_name(raw.get('job'))}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_run_build(a):
|
||||||
|
return [sys.executable, os.path.join(BIN_DIR, 'job-dispatch.py'), a['job']]
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_messages_validate(raw):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise OpError('args must be an object')
|
||||||
|
allowed = {'account', 'limit'}
|
||||||
|
for k in raw:
|
||||||
|
if k not in allowed:
|
||||||
|
raise OpError(f'unknown arg: {k}')
|
||||||
|
return {
|
||||||
|
'account': _agent(raw.get('account')),
|
||||||
|
'limit': _opt_int(raw.get('limit', 20), 1, 100, 'limit') or 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_messages_build(a):
|
||||||
|
return [sys.executable, os.path.join(BIN_DIR, 'muse-chat-api.py'),
|
||||||
|
'--account', a['account'], 'messages', '--limit', str(a['limit'])]
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_send_validate(raw):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise OpError('args must be an object')
|
||||||
|
allowed = {'account', 'message', 'thread'}
|
||||||
|
for k in raw:
|
||||||
|
if k not in allowed:
|
||||||
|
raise OpError(f'unknown arg: {k}')
|
||||||
|
a = {
|
||||||
|
'account': _agent(raw.get('account')),
|
||||||
|
'message': _clean_message(raw.get('message')),
|
||||||
|
}
|
||||||
|
if raw.get('thread') is not None:
|
||||||
|
th = raw['thread']
|
||||||
|
if not isinstance(th, str) or not HEX_RE.fullmatch(th):
|
||||||
|
raise OpError('thread must be a hex uuid')
|
||||||
|
a['thread'] = th
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_send_build(a):
|
||||||
|
argv = [sys.executable, os.path.join(BIN_DIR, 'muse-chat-api.py'),
|
||||||
|
'--account', a['account'], 'send', '--message', a['message']]
|
||||||
|
if a.get('thread'):
|
||||||
|
argv += ['--thread', a['thread']]
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def _health_validate(raw):
|
||||||
|
if raw not in ({}, None):
|
||||||
|
raise OpError('health.check takes no args')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _health_build(a):
|
||||||
|
return ['/bin/bash', os.path.join(BIN_DIR, 'agent-health.sh'), '--check']
|
||||||
|
|
||||||
|
|
||||||
|
# op -> {validate, build, timeout, side_effecting, description}
|
||||||
|
OPS = {
|
||||||
|
'dm.send': {
|
||||||
|
'validate': _dm_send_validate, 'build': _dm_send_build,
|
||||||
|
'timeout': 120, 'side_effecting': True,
|
||||||
|
'desc': 'Send a DM via dm.py (verified delivery)',
|
||||||
|
},
|
||||||
|
'dm.thread': {
|
||||||
|
'validate': _dm_thread_validate, 'build': _dm_thread_build,
|
||||||
|
'timeout': 120, 'side_effecting': True,
|
||||||
|
'desc': 'Send a threaded DM via dm.py',
|
||||||
|
},
|
||||||
|
'dm.read': {
|
||||||
|
'validate': _dm_read_validate, 'build': _dm_read_build,
|
||||||
|
'timeout': 60, 'side_effecting': False,
|
||||||
|
'desc': 'Read recent DMs (read-only)',
|
||||||
|
},
|
||||||
|
'job.run': {
|
||||||
|
'validate': _job_run_validate, 'build': _job_run_build,
|
||||||
|
'timeout': 300, 'side_effecting': True,
|
||||||
|
'desc': 'Run a job from the jobs directory',
|
||||||
|
},
|
||||||
|
'chat.messages': {
|
||||||
|
'validate': _chat_messages_validate, 'build': _chat_messages_build,
|
||||||
|
'timeout': 60, 'side_effecting': False,
|
||||||
|
'desc': 'Read recent chat messages (read-only)',
|
||||||
|
},
|
||||||
|
'chat.send': {
|
||||||
|
'validate': _chat_send_validate, 'build': _chat_send_build,
|
||||||
|
'timeout': 120, 'side_effecting': True,
|
||||||
|
'desc': 'Send a chat message via muse-chat-api.py',
|
||||||
|
},
|
||||||
|
'health.check': {
|
||||||
|
'validate': _health_validate, 'build': _health_build,
|
||||||
|
'timeout': 120, 'side_effecting': False,
|
||||||
|
'desc': 'Run agent-health.sh --check (read-only)',
|
||||||
|
},
|
||||||
|
'exec.ping': {
|
||||||
|
'validate': _health_validate,
|
||||||
|
'build': lambda a: ['/bin/echo', 'PONG'],
|
||||||
|
'timeout': 10, 'side_effecting': False,
|
||||||
|
'desc': 'Canary no-op for watchdogs',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# identity -> set of ops. 'master' may invoke everything. Unknown identities
|
||||||
|
# get the read-only subset. Per-agent tokens inherit their agent name as the
|
||||||
|
# identity; tighten per agent here as needed.
|
||||||
|
PERMISSIONS = {
|
||||||
|
'master': set(OPS),
|
||||||
|
'operator-main': set(OPS),
|
||||||
|
'operator-646': {'dm.send', 'dm.thread', 'dm.read', 'job.run',
|
||||||
|
'chat.messages', 'health.check', 'exec.ping'},
|
||||||
|
'operator-muse': {'dm.send', 'dm.read', 'chat.messages', 'exec.ping'},
|
||||||
|
'operator-pip': {'dm.send', 'dm.read', 'chat.messages', 'exec.ping'},
|
||||||
|
'exec-canary': {'exec.ping'},
|
||||||
|
}
|
||||||
|
DEFAULT_PERMS = {'dm.read', 'chat.messages', 'health.check', 'exec.ping'}
|
||||||
|
|
||||||
|
|
||||||
|
def permitted(ident, op):
|
||||||
|
perms = PERMISSIONS.get(ident, DEFAULT_PERMS)
|
||||||
|
return op in perms
|
||||||
|
|
||||||
|
# ------------------------------------------------------- audit log
|
||||||
|
|
||||||
|
_audit_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def audit(entry):
|
||||||
|
"""Append-only JSONL audit. Never includes tokens or signatures."""
|
||||||
|
entry = dict(entry)
|
||||||
|
entry['ts'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||||
|
line = json.dumps(entry, separators=(',', ':')) + '\n'
|
||||||
|
with _audit_lock:
|
||||||
|
try:
|
||||||
|
with open(AUDIT_LOG, 'a') as f:
|
||||||
|
f.write(line)
|
||||||
|
os.chmod(AUDIT_LOG, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- HTTP handler
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = 'exec-constrained/1.0'
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
sys.stderr.write(f'{self.client_address[0]} - {format % args}\n')
|
||||||
|
|
||||||
|
def _send(self, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/health':
|
||||||
|
self._send(200, {'status': 'ok', 'mode': 'constrained'})
|
||||||
|
elif self.path == '/ops':
|
||||||
|
# List ops (names + descriptions only; no arg details needed).
|
||||||
|
self._send(200, {'ops': [
|
||||||
|
{'op': k, 'desc': v['desc'],
|
||||||
|
'side_effecting': v['side_effecting']}
|
||||||
|
for k, v in sorted(OPS.items())]})
|
||||||
|
else:
|
||||||
|
self._send(404, {'error': 'not found'})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path == '/exec':
|
||||||
|
self._handle_exec()
|
||||||
|
else:
|
||||||
|
self._send(404, {'error': 'not found'})
|
||||||
|
|
||||||
|
def _handle_exec(self):
|
||||||
|
peer = self.client_address[0]
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
except ValueError:
|
||||||
|
length = 0
|
||||||
|
if length <= 0 or length > MAX_BODY:
|
||||||
|
self._send(413 if length > MAX_BODY else 400,
|
||||||
|
{'error': 'bad content length'})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.rfile.read(length))
|
||||||
|
except Exception:
|
||||||
|
self._send(400, {'error': 'invalid json'})
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
self._send(400, {'error': 'body must be an object'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- auth: Bearer <redacted> header (preferred) or legacy body token,
|
||||||
|
# --- or ssh-keygen signature envelope.
|
||||||
|
ident = None
|
||||||
|
op = data.get('op')
|
||||||
|
args = data.get('args', {})
|
||||||
|
authz = self.headers.get('Authorization', '')
|
||||||
|
if authz.startswith('Bearer '):
|
||||||
|
token = authz[7:].strip()
|
||||||
|
ident = check_token(token)
|
||||||
|
elif data.get('token'):
|
||||||
|
ident = check_token(data['token'])
|
||||||
|
elif data.get('identity') and data.get('payload') \
|
||||||
|
and data.get('signature'):
|
||||||
|
# Signature envelope carries op/args; ignore body's op/args.
|
||||||
|
ident = check_signature(data['identity'], data['payload'],
|
||||||
|
data['signature'])
|
||||||
|
if ident:
|
||||||
|
try:
|
||||||
|
env = json.loads(data['payload'])
|
||||||
|
op, args = env.get('op'), env.get('args', {})
|
||||||
|
except Exception:
|
||||||
|
op, args = None, {}
|
||||||
|
if not ident:
|
||||||
|
audit({'event': 'auth_failed', 'peer': peer,
|
||||||
|
'op': str(op)[:64]})
|
||||||
|
self._send(401, {'error': 'unauthorized'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- rate limit
|
||||||
|
if not LIMITER.allow(ident):
|
||||||
|
audit({'event': 'rate_limited', 'ident': ident, 'peer': peer,
|
||||||
|
'op': str(op)[:64]})
|
||||||
|
self._send(429, {'error': 'rate limited'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- op + permission + args
|
||||||
|
if not isinstance(op, str) or op not in OPS:
|
||||||
|
self._send(400, {'error': 'unknown op',
|
||||||
|
'hint': 'GET /ops for the allowlist'})
|
||||||
|
return
|
||||||
|
if not permitted(ident, op):
|
||||||
|
audit({'event': 'forbidden', 'ident': ident, 'peer': peer,
|
||||||
|
'op': op})
|
||||||
|
self._send(403, {'error': 'forbidden for this identity'})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
spec = OPS[op]
|
||||||
|
clean = spec['validate'](args)
|
||||||
|
argv = spec['build'](clean)
|
||||||
|
except OpError as e:
|
||||||
|
self._send(400, {'error': f'bad args: {e}'})
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
self._send(500, {'error': 'internal'})
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- execute (NO shell, argv only)
|
||||||
|
audit({'event': 'exec_start', 'ident': ident, 'peer': peer,
|
||||||
|
'op': op,
|
||||||
|
'args_sha': hashlib.sha256(
|
||||||
|
json.dumps(clean, sort_keys=True).encode()
|
||||||
|
).hexdigest()[:16]})
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
p = subprocess.run(argv, capture_output=True, text=True,
|
||||||
|
timeout=spec['timeout'], cwd=WORK_DIR)
|
||||||
|
rc = p.returncode
|
||||||
|
out = p.stdout[-MAX_OUTPUT:]
|
||||||
|
err = p.stderr[-MAX_OUTPUT:]
|
||||||
|
ok = True
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rc, out, err, ok = -1, '', 'timeout', False
|
||||||
|
except Exception as e:
|
||||||
|
rc, out, err, ok = -1, '', str(e)[:500], False
|
||||||
|
dt = round(time.monotonic() - t0, 2)
|
||||||
|
audit({'event': 'exec_done', 'ident': ident, 'peer': peer,
|
||||||
|
'op': op, 'rc': rc, 'duration_s': dt, 'ok': ok})
|
||||||
|
if ok:
|
||||||
|
self._send(200, {'rc': rc, 'stdout': out, 'stderr': err,
|
||||||
|
'duration_s': dt})
|
||||||
|
else:
|
||||||
|
self._send(500, {'error': err, 'rc': rc})
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- main
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global TOKEN_FILE, TOKEN_DIR, SIGNERS_FILE, NONCE_FILE, AUDIT_LOG
|
||||||
|
global BIN_DIR, JOBS_DIR, WORK_DIR
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--port', type=int, default=8444)
|
||||||
|
ap.add_argument('--host', default='127.0.0.1')
|
||||||
|
ap.add_argument('--token-file', default=TOKEN_FILE)
|
||||||
|
ap.add_argument('--token-dir', default=TOKEN_DIR)
|
||||||
|
ap.add_argument('--signers-file', default=SIGNERS_FILE)
|
||||||
|
ap.add_argument('--nonce-file', default=NONCE_FILE)
|
||||||
|
ap.add_argument('--audit-log', default=AUDIT_LOG)
|
||||||
|
ap.add_argument('--bin-dir', default=BIN_DIR)
|
||||||
|
ap.add_argument('--jobs-dir', default=JOBS_DIR)
|
||||||
|
ap.add_argument('--cert-file',
|
||||||
|
default='/home/super/.exec-constrained-cert.pem')
|
||||||
|
ap.add_argument('--key-file',
|
||||||
|
default='/home/super/.exec-constrained-key.pem')
|
||||||
|
ap.add_argument('--work-dir', default='/home/super')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
TOKEN_FILE, TOKEN_DIR = args.token_file, args.token_dir
|
||||||
|
SIGNERS_FILE, NONCE_FILE = args.signers_file, args.nonce_file
|
||||||
|
AUDIT_LOG = args.audit_log
|
||||||
|
BIN_DIR, JOBS_DIR = args.bin_dir, args.jobs_dir
|
||||||
|
WORK_DIR = args.work_dir
|
||||||
|
|
||||||
|
server = HTTPServer((args.host, args.port), Handler)
|
||||||
|
|
||||||
|
cert_file = args.cert_file
|
||||||
|
key_file = args.key_file
|
||||||
|
if not os.path.exists(cert_file):
|
||||||
|
print('Generating self-signed cert...', file=sys.stderr)
|
||||||
|
subprocess.run([
|
||||||
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||||
|
'-keyout', key_file, '-out', cert_file,
|
||||||
|
'-days', '3650', '-nodes',
|
||||||
|
'-subj', '/CN=bl-exec-constrained',
|
||||||
|
], check=True, capture_output=True)
|
||||||
|
os.chmod(key_file, 0o600)
|
||||||
|
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(cert_file, key_file)
|
||||||
|
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
||||||
|
|
||||||
|
print(f'Constrained exec listening on https://{args.host}:{args.port}/exec',
|
||||||
|
file=sys.stderr)
|
||||||
|
print(f'Allowlist: {", ".join(sorted(OPS))}', file=sys.stderr)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\nShutting down', file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+377
@@ -0,0 +1,377 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
HTTPS exec server for operator remote command execution.
|
||||||
|
Runs on bl (stable), accepts authenticated POST /exec, returns command output.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 exec-server.py --port 8443 --token-file /home/super/.exec-token
|
||||||
|
|
||||||
|
646 (or any operator) can then:
|
||||||
|
curl -k -X POST https://100.123.153.75:8443/exec \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"cmd": "dm.py send --agent 646 --to opm --target main \"hello\"", "token": "..."}'
|
||||||
|
|
||||||
|
Via the VM Caddy (no tailnet needed from the agent's container):
|
||||||
|
curl -k -X POST https://34-139-37-135.sslip.io/exec/exec \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"cmd": "...", "token": "<per-agent token>"}'
|
||||||
|
|
||||||
|
Signature auth (preferred for agents): no secret crosses the wire at all.
|
||||||
|
The agent signs {"cmd","ts","nonce"} with their registered SSH key
|
||||||
|
(`ssh-keygen -Y sign -n exec-server`) and posts
|
||||||
|
{"identity","payload","signature"}. Verified against the signers file
|
||||||
|
with `ssh-keygen -Y verify`; ts must be within 300s and the nonce unused.
|
||||||
|
This is the same identity primitive as signed board posts, and it never
|
||||||
|
trips secret-handling guardrails because a signature is not a secret.
|
||||||
|
|
||||||
|
Auth: the master token (TOKEN_FILE) plus per-agent tokens, one file per
|
||||||
|
agent under TOKEN_DIR (0600). Each agent's token is individually revocable
|
||||||
|
by deleting its file. The using identity is logged (never the token).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
# Token file - generated on first run if not exists
|
||||||
|
TOKEN_FILE = '/home/super/.exec-server-token'
|
||||||
|
# Per-agent tokens: TOKEN_DIR/<agent> contains that agent's token
|
||||||
|
TOKEN_DIR = '/home/super/.exec-tokens'
|
||||||
|
# ssh-keygen signature auth: public keys of fleet identities, one per line
|
||||||
|
# ("<identity> ssh-ed25519 AAAA..."), synced from the VM's allowed_signers.
|
||||||
|
SIGNERS_FILE = '/home/super/.exec-signers'
|
||||||
|
# Seen nonces for replay protection ("<nonce> <ts>" per line).
|
||||||
|
NONCE_FILE = '/home/super/.exec-nonces'
|
||||||
|
SIG_NAMESPACE = 'exec-server'
|
||||||
|
SIG_MAX_SKEW = 300 # seconds; nonces remembered for 2x this
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
"""Load or generate the auth token."""
|
||||||
|
try:
|
||||||
|
with open(TOKEN_FILE, 'r') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
with open(TOKEN_FILE, 'w') as f:
|
||||||
|
f.write(token)
|
||||||
|
# Secure permissions
|
||||||
|
import os
|
||||||
|
os.chmod(TOKEN_FILE, 0o600)
|
||||||
|
print(f'Generated new token in {TOKEN_FILE}', file=sys.stderr)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def check_token(token):
|
||||||
|
"""Check token against the master token and per-agent tokens.
|
||||||
|
Returns the identity label ('master' or the agent filename), or None."""
|
||||||
|
if token and hmac.compare_digest(token, get_token()):
|
||||||
|
return 'master'
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
names = os.listdir(TOKEN_DIR)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
for name in names:
|
||||||
|
p = os.path.join(TOKEN_DIR, name)
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(p) as f:
|
||||||
|
t = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if t and hmac.compare_digest(token, t):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_nonce(nonce):
|
||||||
|
"""True if the nonce was never used; records it. Prunes expired entries."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
now = time.time()
|
||||||
|
fresh = []
|
||||||
|
try:
|
||||||
|
with open(NONCE_FILE) as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
n, t = parts
|
||||||
|
try:
|
||||||
|
if now - float(t) < 2 * SIG_MAX_SKEW:
|
||||||
|
fresh.append((n, t))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
if any(n == nonce for n, _ in fresh):
|
||||||
|
return False
|
||||||
|
fresh.append((nonce, str(now)))
|
||||||
|
try:
|
||||||
|
with open(NONCE_FILE, 'w') as f:
|
||||||
|
for n, t in fresh:
|
||||||
|
f.write(f"{n} {t}\n")
|
||||||
|
os.chmod(NONCE_FILE, 0o600)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_signature(identity, payload, signature):
|
||||||
|
"""Verify an `ssh-keygen -Y` signature over the payload envelope.
|
||||||
|
Returns the identity on success, None on failure. The envelope must be
|
||||||
|
JSON {"cmd","ts","nonce"} with a fresh ts and an unused nonce."""
|
||||||
|
import json as _json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
if not re.fullmatch(r'[a-z0-9-]+', identity or ''):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = _json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
cmd = data.get('cmd')
|
||||||
|
ts = data.get('ts')
|
||||||
|
nonce = data.get('nonce')
|
||||||
|
if not isinstance(cmd, str) or not cmd:
|
||||||
|
return None
|
||||||
|
if not isinstance(nonce, str) or not re.fullmatch(r'[0-9a-fA-F]{16,128}', nonce):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ts = float(ts)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if abs(time.time() - ts) > SIG_MAX_SKEW:
|
||||||
|
return None
|
||||||
|
if not check_nonce(nonce):
|
||||||
|
return None
|
||||||
|
sig_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile('w', delete=False, suffix='.sig') as f:
|
||||||
|
f.write(signature if signature.endswith('\n') else signature + '\n')
|
||||||
|
sig_path = f.name
|
||||||
|
p = subprocess.run(
|
||||||
|
['ssh-keygen', '-Y', 'verify', '-f', SIGNERS_FILE, '-I', identity,
|
||||||
|
'-n', SIG_NAMESPACE, '-s', sig_path],
|
||||||
|
input=payload.encode(), capture_output=True, timeout=15)
|
||||||
|
return identity if p.returncode == 0 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if sig_path:
|
||||||
|
try:
|
||||||
|
os.unlink(sig_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExecHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
# Quiet logging, just to stderr
|
||||||
|
sys.stderr.write(f'{self.client_address[0]} - {format % args}\n')
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
# Read body
|
||||||
|
content_length = int(self.headers.get('Content-Length', 0))
|
||||||
|
if content_length > 1024 * 1024: # 1MB max
|
||||||
|
self.send_response(413)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "invalid json"}')
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.path == '/exec/rotate':
|
||||||
|
self.handle_rotate(data)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.path != '/exec':
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Auth: bearer token OR ssh-keygen -Y signature (no secret in transit).
|
||||||
|
# Bearer: {"token": "...", "cmd": "..."}.
|
||||||
|
# Signature: {"identity": "...", "payload": "{\"cmd\":...,\"ts\":...,\"nonce\":...}",
|
||||||
|
# "signature": "<ssh-keygen -Y armor>"}. check_signature
|
||||||
|
# validates the envelope; cmd comes from the signed payload.
|
||||||
|
ident = None
|
||||||
|
cmd = ''
|
||||||
|
token = data.get('token', '')
|
||||||
|
if token:
|
||||||
|
ident = check_token(token)
|
||||||
|
cmd = data.get('cmd', '')
|
||||||
|
elif data.get('identity') and data.get('payload') and data.get('signature'):
|
||||||
|
ident = check_signature(data['identity'], data['payload'], data['signature'])
|
||||||
|
if ident:
|
||||||
|
try:
|
||||||
|
cmd = json.loads(data['payload']).get('cmd', '')
|
||||||
|
except Exception:
|
||||||
|
cmd = ''
|
||||||
|
if not ident:
|
||||||
|
self.send_response(401)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "unauthorized"}')
|
||||||
|
return
|
||||||
|
sys.stderr.write(f'exec as {ident} from {self.client_address[0]}\n')
|
||||||
|
# Get command
|
||||||
|
if not cmd or not isinstance(cmd, str):
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "missing cmd"}')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Execute (with timeout)
|
||||||
|
timeout = min(data.get('timeout', 60), 300) # max 5 min
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
cwd='/home/super'
|
||||||
|
)
|
||||||
|
response = {
|
||||||
|
'stdout': result.stdout,
|
||||||
|
'stderr': result.stderr,
|
||||||
|
'rc': result.returncode,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
response = {'error': 'timeout', 'rc': -1}
|
||||||
|
except Exception as e:
|
||||||
|
response = {'error': str(e), 'rc': -1}
|
||||||
|
|
||||||
|
# Send response
|
||||||
|
resp_body = json.dumps(response).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(resp_body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(resp_body)
|
||||||
|
|
||||||
|
def handle_rotate(self, data):
|
||||||
|
"""POST /exec/rotate - replace an agent's token with a fresh one.
|
||||||
|
The old token dies immediately; the new one is returned ONLY in the
|
||||||
|
response body (never logged). Lets an agent bootstrap from a
|
||||||
|
trust-root-delivered token and end up with one nobody else knows.
|
||||||
|
Agents may rotate only their own token; master may rotate any
|
||||||
|
agent's (not its own - that stays manual on bl)."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
token = data.get('token', '')
|
||||||
|
ident = check_token(token)
|
||||||
|
if not ident:
|
||||||
|
self.send_response(401)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "unauthorized"}')
|
||||||
|
return
|
||||||
|
target = data.get('agent', ident)
|
||||||
|
if not re.fullmatch(r'[a-z0-9-]+', target or ''):
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "bad agent name"}')
|
||||||
|
return
|
||||||
|
if target == 'master' or (ident != 'master' and target != ident):
|
||||||
|
self.send_response(403)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "forbidden"}')
|
||||||
|
return
|
||||||
|
path = os.path.join(TOKEN_DIR, target)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "no such agent token"}')
|
||||||
|
return
|
||||||
|
new_token = secrets.token_hex(32)
|
||||||
|
tmp = path + '.tmp'
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
|
f.write(new_token + '\n')
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
sys.stderr.write(f'token rotated for {target} by {ident}\n')
|
||||||
|
resp_body = json.dumps({'token': new_token}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(resp_body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(resp_body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/health':
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"status": "ok"}')
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global TOKEN_FILE, TOKEN_DIR
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--port', type=int, default=8443)
|
||||||
|
parser.add_argument('--host', default='0.0.0.0')
|
||||||
|
parser.add_argument('--token-file', default='/home/super/.exec-server-token')
|
||||||
|
parser.add_argument('--token-dir', default='/home/super/.exec-tokens')
|
||||||
|
parser.add_argument('--signers-file', default='/home/super/.exec-signers')
|
||||||
|
parser.add_argument('--nonce-file', default='/home/super/.exec-nonces')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
TOKEN_FILE = args.token_file
|
||||||
|
TOKEN_DIR = args.token_dir
|
||||||
|
global SIGNERS_FILE, NONCE_FILE
|
||||||
|
SIGNERS_FILE = args.signers_file
|
||||||
|
NONCE_FILE = args.nonce_file
|
||||||
|
|
||||||
|
# Ensure token exists
|
||||||
|
token = get_token()
|
||||||
|
print(f'Token: {token[:8]}... (full in {TOKEN_FILE})', file=sys.stderr)
|
||||||
|
|
||||||
|
server = HTTPServer((args.host, args.port), ExecHandler)
|
||||||
|
|
||||||
|
# Wrap with TLS (self-signed is fine for our use, we use -k)
|
||||||
|
# Generate self-signed cert if not exists
|
||||||
|
import os
|
||||||
|
cert_file = '/home/super/.exec-server-cert.pem'
|
||||||
|
key_file = '/home/super/.exec-server-key.pem'
|
||||||
|
|
||||||
|
if not os.path.exists(cert_file):
|
||||||
|
print('Generating self-signed cert...', file=sys.stderr)
|
||||||
|
subprocess.run([
|
||||||
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||||
|
'-keyout', key_file, '-out', cert_file,
|
||||||
|
'-days', '3650', '-nodes',
|
||||||
|
'-subj', '/CN=bl-exec-server'
|
||||||
|
], check=True, capture_output=True)
|
||||||
|
os.chmod(key_file, 0o600)
|
||||||
|
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.load_cert_chain(cert_file, key_file)
|
||||||
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||||
|
|
||||||
|
print(f'Exec server listening on https://{args.host}:{args.port}/exec', file=sys.stderr)
|
||||||
|
print('Health check: https://<host>:<port>/health', file=sys.stderr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\nShutting down', file=sys.stderr)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# exec-sign.sh — call the bl exec-constrained server with SSH-signature auth.
|
||||||
|
# No bearer token, no secret crosses the wire: you sign the request envelope
|
||||||
|
# with your registered fleet key and the server verifies it against the
|
||||||
|
# signers file. A signature is not a secret, so this never trips
|
||||||
|
# secret-handling guardrails.
|
||||||
|
#
|
||||||
|
# Server: exec-constrained.py — named ops ONLY, no arbitrary shell.
|
||||||
|
# Signature namespace: exec-constrained
|
||||||
|
# Envelope: {"op","args","ts","nonce"} — op must be in the server allowlist:
|
||||||
|
# dm.send, dm.thread, dm.read, job.run, chat.messages, chat.send,
|
||||||
|
# health.check, exec.ping
|
||||||
|
# Nonce: 16+ hex chars, replay-protected server-side. ts: unix epoch, ±300s skew.
|
||||||
|
#
|
||||||
|
# Usage: exec-sign.sh <op> '<args-json>' [identity] [keyfile] [url]
|
||||||
|
# op a named op, e.g. exec.ping
|
||||||
|
# args-json JSON object of the op's arguments, e.g. '{}'
|
||||||
|
# identity defaults to operator-646 (must be a principal in the
|
||||||
|
# server's signers file)
|
||||||
|
# keyfile defaults to ~/.ssh/id_frontdoor
|
||||||
|
# url defaults to https://exec.muse-dev.online/exec (cloudflared).
|
||||||
|
# NOTE: the old VM Caddy /exec route to bl:8443 died with
|
||||||
|
# exec-server.py — do not point this at the sslip.io URL.
|
||||||
|
set -euo pipefail
|
||||||
|
OP="${1:?usage: exec-sign.sh <op> '<args-json>' [identity] [keyfile] [url]}"
|
||||||
|
# NOTE: do NOT write this as ${2:-{}} — bash matches the first } as the
|
||||||
|
# expansion's close brace and appends a literal } when $2 is set.
|
||||||
|
ARGS_JSON="${2-}"
|
||||||
|
if [ -z "$ARGS_JSON" ]; then ARGS_JSON='{}'; fi
|
||||||
|
IDENTITY="${3:-operator-646}"
|
||||||
|
KEY="${4:-$HOME/.ssh/id_frontdoor}"
|
||||||
|
URL="${5:-https://exec.muse-dev.online/exec}"
|
||||||
|
|
||||||
|
TS=$(date +%s)
|
||||||
|
NONCE=$(python3 -c "import secrets; print(secrets.token_hex(16))")
|
||||||
|
PAYLOAD=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
op, args_json, ts, nonce = sys.argv[1:5]
|
||||||
|
args = json.loads(args_json)
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
raise SystemExit('args-json must be a JSON object')
|
||||||
|
print(json.dumps({'op': op, 'args': args, 'ts': int(ts), 'nonce': nonce}))
|
||||||
|
" "$OP" "$ARGS_JSON" "$TS" "$NONCE")
|
||||||
|
SIG=$(printf '%s' "$PAYLOAD" | ssh-keygen -Y sign -f "$KEY" -n exec-constrained)
|
||||||
|
BODY=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
ident, payload, sig = sys.argv[1:4]
|
||||||
|
print(json.dumps({'identity': ident, 'payload': payload, 'signature': sig}))
|
||||||
|
" "$IDENTITY" "$PAYLOAD" "$SIG")
|
||||||
|
# Cloudflare Bot Fight Mode blocks python-urllib POSTs (error 1010):
|
||||||
|
# use curl with a browser User-Agent instead.
|
||||||
|
curl -sS -X POST "$URL" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' \
|
||||||
|
--data "$BODY"
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# exec-watch.sh — 15-min watchdog for the bl exec server's signature-auth path.
|
||||||
|
# Signs a canary op as the exec-canary identity and POSTs it to the
|
||||||
|
# local exec-constrained server. Records result in /home/super/.exec-watch-status.json
|
||||||
|
# and appends to logs/exec-watch.log. Failures are pull-based (status file +
|
||||||
|
# log); wire push alerting here if the fleet wants paging.
|
||||||
|
#
|
||||||
|
# Runs via systemd user timer exec-watch.timer (OnUnitActiveSec=15min).
|
||||||
|
# Server: exec-constrained.py — named ops ONLY, no arbitrary shell;
|
||||||
|
# signature namespace: exec-constrained, with {op,args,ts,nonce} envelope.
|
||||||
|
# NOTE: signers file (/home/super/.exec-signers) is synced MANUALLY from the
|
||||||
|
# VM's /srv/board/allowed_signers on identity renames/adds — bl cannot ssh
|
||||||
|
# back to the VM, so there is no pull sync. Operator step, documented in
|
||||||
|
# docs/TOKEN_POLICY.md.
|
||||||
|
set -uo pipefail
|
||||||
|
KEY=/home/super/.exec-canary
|
||||||
|
URL=https://100.123.153.75:8444/exec
|
||||||
|
STATUS=/home/super/.exec-watch-status.json
|
||||||
|
LOG=/home/super/Projects/NetVM/logs/exec-watch.log
|
||||||
|
|
||||||
|
ts=$(date +%s)
|
||||||
|
nonce=$(python3 -c "import secrets; print(secrets.token_hex(16))")
|
||||||
|
payload=$(python3 -c "import json,sys; print(json.dumps({'op':'exec.ping','args':{},'ts':int(sys.argv[1]),'nonce':sys.argv[2]}))" "$ts" "$nonce")
|
||||||
|
sig=$(printf '%s' "$payload" | ssh-keygen -Y sign -f "$KEY" -n exec-constrained 2>/dev/null)
|
||||||
|
if [ -z "${sig:-}" ]; then
|
||||||
|
result="sign-failed"
|
||||||
|
else
|
||||||
|
body=$(python3 -c "import json,sys; print(json.dumps({'identity': 'exec-canary', 'payload': sys.argv[1], 'signature': sys.argv[2]}))" "$payload" "$sig")
|
||||||
|
out=$(curl -sk -m 25 -X POST "$URL" -H 'Content-Type: application/json' -d "$body" 2>/dev/null)
|
||||||
|
if echo "$out" | grep -q '"rc": 0'; then
|
||||||
|
result="ok"
|
||||||
|
else
|
||||||
|
result="bad-response"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
now_iso=$(date -u +%FT%TZ)
|
||||||
|
{
|
||||||
|
python3 - "$STATUS" "$now_iso" "$result" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
status_path, now_iso, result = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
try:
|
||||||
|
st = json.load(open(status_path))
|
||||||
|
except Exception:
|
||||||
|
st = {}
|
||||||
|
if result == "ok":
|
||||||
|
st.update({"last_ok": now_iso, "last_fail": None,
|
||||||
|
"consecutive_failures": 0, "result": "ok"})
|
||||||
|
else:
|
||||||
|
st.update({"last_ok": st.get("last_ok"),
|
||||||
|
"last_fail": now_iso,
|
||||||
|
"consecutive_failures": st.get("consecutive_failures", 0) + 1,
|
||||||
|
"result": result})
|
||||||
|
json.dump(st, open(status_path, "w"))
|
||||||
|
print(f"[{now_iso}] exec-watch: {result} "
|
||||||
|
f"(consecutive_failures={st['consecutive_failures']})")
|
||||||
|
PYEOF
|
||||||
|
} >> "$LOG" 2>&1
|
||||||
|
[ "$result" = "ok" ]
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# flap-check.sh - count browser relaunches per profile in the last hour
|
||||||
|
# Called by the browser-flap-detector cron. Avoids nested SSH quoting hell.
|
||||||
|
cutoff=$(date -u -d "1 hour ago" +%Y-%m-%dT%H:%M:%S)
|
||||||
|
for prof in muse pip 646 opm; do
|
||||||
|
n=$(grep "\[$prof\]" /home/super/Projects/NetVM/chromebox-watchdog.log 2>/dev/null | grep "relaunch OK" | awk -v d="$cutoff" '{ts=substr($1,2,19); if (ts > d) c++} END {print c+0}')
|
||||||
|
echo "$prof:$n"
|
||||||
|
done
|
||||||
@@ -185,6 +185,60 @@ HEALTHY_AGENTS=""
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# --- Warp partition detection (2026-10-04) ---
|
||||||
|
# A partitioned node has a live browser + CDP but no internet egress: the
|
||||||
|
# chromebox watchdog sees a healthy browser while all automation fails.
|
||||||
|
# Condition id: partition:<node>. The detail names the node, the WireGuard
|
||||||
|
# handshake age, the egress probe result, and the timestamp, and says
|
||||||
|
# PARTITION explicitly so #lobby readers can tell a network partition from
|
||||||
|
# a browser crash at a glance. Anti-spam comes from the shared consecutive-
|
||||||
|
# failure state machine (2 consecutive failures before first page, re-page
|
||||||
|
# at most every 30 min).
|
||||||
|
WARP_PROBE_URL="${WARP_PROBE_URL:-https://1.1.1.1/cdn-cgi/trace}"
|
||||||
|
warp_partition_probe() { # <node> -> prints "<handshake_age_s|unknown> <ok|FAIL>"
|
||||||
|
local node="$1" iface epoch now age_s
|
||||||
|
iface=$(sudo -n ip netns exec "warp-$node" sh -c 'wg show interfaces 2>/dev/null | head -1')
|
||||||
|
now=$(date +%s)
|
||||||
|
epoch=$(sudo -n ip netns exec "warp-$node" wg show "$iface" latest-handshakes 2>/dev/null | awk '{print $2}')
|
||||||
|
case "$epoch" in ''|*[!0-9]*) age_s="unknown" ;; *) age_s=$(( now - epoch )) ;; esac
|
||||||
|
if sudo -n ip netns exec "warp-$node" curl -s -m 8 -o /dev/null "$WARP_PROBE_URL" 2>/dev/null; then
|
||||||
|
echo "$age_s ok"
|
||||||
|
else
|
||||||
|
echo "$age_s FAIL"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
"$BIN/netvm-registry.py" 2>/dev/null | while IFS=: read -r node port; do
|
||||||
|
[ -n "$node" ] && [ -n "$port" ] || continue
|
||||||
|
cond="partition:$node"
|
||||||
|
read -r hs_age probe_res < <(warp_partition_probe "$node")
|
||||||
|
ts=$(date -u +%FT%TZ)
|
||||||
|
if [ "$probe_res" = "ok" ]; then
|
||||||
|
failing=0
|
||||||
|
detail="warp egress restored for $node at $ts (probe $WARP_PROBE_URL ok)"
|
||||||
|
else
|
||||||
|
failing=1
|
||||||
|
detail="PARTITION $node: warp egress down at $ts (handshake ${hs_age}s ago, probe $WARP_PROBE_URL FAILED)"
|
||||||
|
fi
|
||||||
|
if injected "$cond"; then
|
||||||
|
failing=1
|
||||||
|
detail="PARTITION $node: warp egress down at $ts (handshake ${hs_age}s ago, probe $WARP_PROBE_URL FAILED) [INJECTED]"
|
||||||
|
fi
|
||||||
|
read -r action fails < <(state_machine "$cond" "$failing")
|
||||||
|
case "$action" in
|
||||||
|
ALERT_FIRST|ALERT_REALERT)
|
||||||
|
emit_record "ALERT" "$cond" "$detail" "$fails"
|
||||||
|
echo "$cond" >> "$STATE_DIR/.alerts.tmp"
|
||||||
|
;;
|
||||||
|
RECOVERY)
|
||||||
|
emit_record "RECOVERY" "$cond" "$detail" "$fails"
|
||||||
|
;;
|
||||||
|
SUPPRESSED)
|
||||||
|
log "$cond still critical x$fails — re-page suppressed by quiet hours ($QUIET_HOURS)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# Recompute healthy agents in the main shell (pipeline subshell above can't export).
|
# Recompute healthy agents in the main shell (pipeline subshell above can't export).
|
||||||
HEALTHY_AGENTS=""
|
HEALTHY_AGENTS=""
|
||||||
"$BIN/netvm-registry.py" 2>/dev/null | while IFS=: read -r node port; do
|
"$BIN/netvm-registry.py" 2>/dev/null | while IFS=: read -r node port; do
|
||||||
|
|||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# fleet-status.sh - one-line health per profile: browser up/down + relay code
|
||||||
|
declare -A ports=( [muse]=9410 [pip]=9420 [646]=9430 [opm]=9440 )
|
||||||
|
declare -A veth=( [muse]=10.201.35.2 [pip]=10.201.87.2 [646]=10.201.202.2 [opm]=10.201.157.2 )
|
||||||
|
for p in muse pip 646 opm; do
|
||||||
|
port=${ports[$p]}
|
||||||
|
if pgrep -f "remote-debugging-port=$port" >/dev/null 2>&1; then b=UP; else b=DOWN; fi
|
||||||
|
r=$(curl -s -m 4 -o /dev/null -w "%{http_code}" "http://${veth[$p]}:$port/json/version" 2>/dev/null || echo 000)
|
||||||
|
echo "$p: browser=$b relay=$r"
|
||||||
|
done
|
||||||
|
echo ===
|
||||||
|
tail -20 /home/super/Projects/NetVM/chromebox-watchdog.log | grep -E "FAILED|unhealthy" | tail -4
|
||||||
+10
-1
@@ -416,6 +416,7 @@ def reconstruct_loops(limit=50, agent=None, status_filter=None) -> list:
|
|||||||
# 2. Extract tracked DMs from dm-log.jsonl
|
# 2. Extract tracked DMs from dm-log.jsonl
|
||||||
answers_map = {} # id or ref -> answer event
|
answers_map = {} # id or ref -> answer event
|
||||||
dm_events = []
|
dm_events = []
|
||||||
|
failed_ids = set() # DM ids that failed to send - never delivered
|
||||||
if os.path.exists(DM_LOG_FILE):
|
if os.path.exists(DM_LOG_FILE):
|
||||||
try:
|
try:
|
||||||
with open(DM_LOG_FILE, "r") as f:
|
with open(DM_LOG_FILE, "r") as f:
|
||||||
@@ -429,7 +430,8 @@ def reconstruct_loops(limit=50, agent=None, status_filter=None) -> list:
|
|||||||
msg = entry.get("msg") or ""
|
msg = entry.get("msg") or ""
|
||||||
|
|
||||||
# Check for answers/results/acks
|
# Check for answers/results/acks
|
||||||
if "[RESULT" in msg or "[ACK" in msg or "acknowledged" in msg or ev_type == "verified":
|
# Note: "verified" means delivered, NOT answered - do not include it here
|
||||||
|
if "[RESULT" in msg or "[ACK" in msg or "acknowledged" in msg:
|
||||||
sender = entry.get("agent", "")
|
sender = entry.get("agent", "")
|
||||||
# Extract referenced DM id if present
|
# Extract referenced DM id if present
|
||||||
m_ref = re.search(r"\[ref:([a-f0-9-]+)\]", msg) or re.search(r"\[(?:ACK|RESULT)\s+([a-f0-9-]+)", msg)
|
m_ref = re.search(r"\[ref:([a-f0-9-]+)\]", msg) or re.search(r"\[(?:ACK|RESULT)\s+([a-f0-9-]+)", msg)
|
||||||
@@ -438,6 +440,11 @@ def reconstruct_loops(limit=50, agent=None, status_filter=None) -> list:
|
|||||||
if entry.get("id"):
|
if entry.get("id"):
|
||||||
answers_map[entry["id"]] = entry
|
answers_map[entry["id"]] = entry
|
||||||
|
|
||||||
|
# Track failed sends - these were never delivered
|
||||||
|
if ev_type in ("send_failed", "failed"):
|
||||||
|
if entry.get("id"):
|
||||||
|
failed_ids.add(entry.get("id"))
|
||||||
|
|
||||||
tags = entry.get("tags") or {}
|
tags = entry.get("tags") or {}
|
||||||
if tags.get("reply:expected") or "[reply:expected]" in msg:
|
if tags.get("reply:expected") or "[reply:expected]" in msg:
|
||||||
dm_events.append(entry)
|
dm_events.append(entry)
|
||||||
@@ -453,6 +460,8 @@ def reconstruct_loops(limit=50, agent=None, status_filter=None) -> list:
|
|||||||
continue
|
continue
|
||||||
if did in loops:
|
if did in loops:
|
||||||
continue # Already have active record from followups.json
|
continue # Already have active record from followups.json
|
||||||
|
if did in failed_ids:
|
||||||
|
continue # Send failed - never delivered, don't count against agent health
|
||||||
|
|
||||||
tags = entry.get("tags") or {}
|
tags = entry.get("tags") or {}
|
||||||
recipient = entry.get("to") or entry.get("recipient") or ""
|
recipient = entry.get("to") or entry.get("recipient") or ""
|
||||||
|
|||||||
Executable
+296
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Identity variance testing framework.
|
||||||
|
|
||||||
|
Sends gentle, controlled probes from each NetVM node's Warp identity and
|
||||||
|
measures per-identity response behavior so that future rate limits can be
|
||||||
|
attributed (per-identity vs per-IP vs time-based vs random).
|
||||||
|
|
||||||
|
Design goals:
|
||||||
|
- Very gentle traffic: 2 probes per node per cycle, default 10-min cycle.
|
||||||
|
- Never logs secrets: only sha256 hashes of WireGuard private keys.
|
||||||
|
- JSONL results log: one line per probe, machine-readable.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
identity-variance-test.py probe # run one probe cycle over all nodes
|
||||||
|
identity-variance-test.py analyze [--since HOURS] [--log PATH]
|
||||||
|
|
||||||
|
Probes per node:
|
||||||
|
1. https://www.cloudflare.com/cdn-cgi/trace (identity + egress IP Cloudflare sees)
|
||||||
|
2. https://muse.ai/ (production-relevant landing page)
|
||||||
|
|
||||||
|
Exit codes: 0 ok, 1 partial (some nodes failed), 2 fatal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
DEFAULT_LOG = os.path.expanduser(
|
||||||
|
"~/Projects/NetVM/.state/identity-variance/variance.jsonl"
|
||||||
|
)
|
||||||
|
NODES = ["muse", "pip", "646", "opm", "def"]
|
||||||
|
|
||||||
|
PROBES = [
|
||||||
|
("cf-trace", "https://www.cloudflare.com/cdn-cgi/trace"),
|
||||||
|
("muse-landing", "https://muse.ai/"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CURL_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd, timeout=30):
|
||||||
|
"""Run cmd (list) and return (rc, stdout, stderr)."""
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=timeout
|
||||||
|
)
|
||||||
|
return p.returncode, p.stdout, p.stderr
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
return 124, (e.stdout or ""), "timeout"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 127, "", "command not found"
|
||||||
|
|
||||||
|
|
||||||
|
def node_netns_exists(node):
|
||||||
|
rc, out, _ = sh(["sudo", "-n", "ip", "netns", "list"])
|
||||||
|
return rc == 0 and f"warp-{node}" in out
|
||||||
|
|
||||||
|
|
||||||
|
def identity_hash(node):
|
||||||
|
"""Return truncated sha256 of the node's WireGuard private key (never the key)."""
|
||||||
|
rc, out, _ = sh(["sudo", "-n", "cat", f"/etc/netvm/{node}.conf"])
|
||||||
|
if rc == 0:
|
||||||
|
for line in out.splitlines():
|
||||||
|
m = re.match(r"\s*PrivateKey\s*=\s*(\S+)", line)
|
||||||
|
if m:
|
||||||
|
return hashlib.sha256(m.group(1).encode()).hexdigest()[:16]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def probe_node(node, target_name, url):
|
||||||
|
"""One probe from inside the node's netns. Returns dict."""
|
||||||
|
# -w fields: http_code, time_total, size_download, remote_ip
|
||||||
|
fmt = "%{http_code} %{time_total} %{size_download} %{remote_ip}"
|
||||||
|
cmd = [
|
||||||
|
"sudo", "-n", "ip", "netns", "exec", f"warp-{node}",
|
||||||
|
"curl", "-s", "-o", "/dev/null", "-m", str(CURL_TIMEOUT),
|
||||||
|
"-w", fmt, url,
|
||||||
|
]
|
||||||
|
started = datetime.now(timezone.utc)
|
||||||
|
rc, out, err = sh(cmd, timeout=CURL_TIMEOUT + 10)
|
||||||
|
ended = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
rec = {
|
||||||
|
"ts": started.isoformat(),
|
||||||
|
"node": node,
|
||||||
|
"probe": target_name,
|
||||||
|
"url": url,
|
||||||
|
"curl_rc": rc,
|
||||||
|
}
|
||||||
|
parts = out.strip().split()
|
||||||
|
if rc == 0 and len(parts) == 4:
|
||||||
|
try:
|
||||||
|
rec["http_status"] = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
rec["http_status"] = None
|
||||||
|
try:
|
||||||
|
rec["response_ms"] = round(float(parts[1]) * 1000, 1)
|
||||||
|
except ValueError:
|
||||||
|
rec["response_ms"] = None
|
||||||
|
try:
|
||||||
|
rec["bytes"] = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
rec["bytes"] = None
|
||||||
|
rec["remote_ip"] = parts[3] if parts[3] != "0.0.0.0" else None
|
||||||
|
else:
|
||||||
|
rec["http_status"] = None
|
||||||
|
rec["response_ms"] = None
|
||||||
|
rec["bytes"] = None
|
||||||
|
rec["remote_ip"] = None
|
||||||
|
rec["curl_error"] = (err or "curl failed").strip()[:200]
|
||||||
|
|
||||||
|
rec["rate_limited"] = rec["http_status"] in (429,)
|
||||||
|
rec["blocked"] = rec["http_status"] in (403,)
|
||||||
|
rec["ok"] = rec["http_status"] is not None and 200 <= rec["http_status"] < 400
|
||||||
|
rec["elapsed_wall_ms"] = round(
|
||||||
|
(ended - started).total_seconds() * 1000, 1
|
||||||
|
)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def egress_ip(node):
|
||||||
|
"""Best-effort egress IP as seen from inside the netns."""
|
||||||
|
rc, out, _ = sh([
|
||||||
|
"sudo", "-n", "ip", "netns", "exec", f"warp-{node}",
|
||||||
|
"curl", "-s", "-m", "10", "https://api.ipify.org",
|
||||||
|
], timeout=20)
|
||||||
|
if rc == 0 and re.fullmatch(r"[0-9a-fA-F.:]+", out.strip()):
|
||||||
|
return out.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe_cycle(log_path):
|
||||||
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
|
results = []
|
||||||
|
any_ok, any_fail = False, False
|
||||||
|
idhash = {}
|
||||||
|
for node in NODES:
|
||||||
|
if not node_netns_exists(node):
|
||||||
|
results.append({
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"node": node, "probe": "node-skip",
|
||||||
|
"ok": False, "note": "netns warp-%s missing" % node,
|
||||||
|
})
|
||||||
|
any_fail = True
|
||||||
|
continue
|
||||||
|
idhash[node] = identity_hash(node)
|
||||||
|
for target_name, url in PROBES:
|
||||||
|
rec = probe_node(node, target_name, url)
|
||||||
|
rec["identity_hash"] = idhash[node]
|
||||||
|
results.append(rec)
|
||||||
|
if rec["ok"]:
|
||||||
|
any_ok = True
|
||||||
|
else:
|
||||||
|
any_fail = True
|
||||||
|
time.sleep(1) # gentle pacing between probes
|
||||||
|
|
||||||
|
# Attach egress IP per node (one lookup per node, cached per cycle).
|
||||||
|
egress = {}
|
||||||
|
for node in {r["node"] for r in results if r.get("probe") != "node-skip"}:
|
||||||
|
egress[node] = egress_ip(node)
|
||||||
|
for rec in results:
|
||||||
|
if rec.get("probe") != "node-skip":
|
||||||
|
rec["egress_ip"] = egress.get(rec["node"])
|
||||||
|
|
||||||
|
with open(log_path, "a") as f:
|
||||||
|
for rec in results:
|
||||||
|
f.write(json.dumps(rec) + "\n")
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"cycle_ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"log": log_path,
|
||||||
|
"records": len(results),
|
||||||
|
"ok": sum(1 for r in results if r.get("ok")),
|
||||||
|
"failed": sum(1 for r in results if not r.get("ok")),
|
||||||
|
"nodes": sorted({r["node"] for r in results}),
|
||||||
|
"egress": egress,
|
||||||
|
"identities": idhash,
|
||||||
|
}, indent=2))
|
||||||
|
|
||||||
|
if not any_ok:
|
||||||
|
return 2
|
||||||
|
return 1 if any_fail else 0
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(log_path, since_hours=None):
|
||||||
|
if not os.path.exists(log_path):
|
||||||
|
print("no log yet at %s" % log_path, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
cutoff = None
|
||||||
|
if since_hours:
|
||||||
|
cutoff = time.time() - since_hours * 3600
|
||||||
|
|
||||||
|
per_node = {}
|
||||||
|
rate_limit_events = []
|
||||||
|
identity_changes = {}
|
||||||
|
egress_by_node = {}
|
||||||
|
|
||||||
|
with open(log_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if r.get("probe") == "node-skip":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(r["ts"]).timestamp()
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
if cutoff and ts < cutoff:
|
||||||
|
continue
|
||||||
|
node = r["node"]
|
||||||
|
st = per_node.setdefault(node, {
|
||||||
|
"probes": 0, "ok": 0, "ms": [], "429": 0, "403": 0,
|
||||||
|
"statuses": {}, "identities": set(), "probes_by_target": {},
|
||||||
|
})
|
||||||
|
st["probes"] += 1
|
||||||
|
if r.get("ok"):
|
||||||
|
st["ok"] += 1
|
||||||
|
if r.get("response_ms") is not None:
|
||||||
|
st["ms"].append(r["response_ms"])
|
||||||
|
if r.get("rate_limited"):
|
||||||
|
st["429"] += 1
|
||||||
|
rate_limit_events.append((r["ts"], node, r["probe"]))
|
||||||
|
if r.get("blocked"):
|
||||||
|
st["403"] += 1
|
||||||
|
s = r.get("http_status")
|
||||||
|
st["statuses"][str(s)] = st["statuses"].get(str(s), 0) + 1
|
||||||
|
if r.get("identity_hash"):
|
||||||
|
st["identities"].add(r["identity_hash"])
|
||||||
|
t = r.get("probe")
|
||||||
|
st["probes_by_target"][t] = st["probes_by_target"].get(t, 0) + 1
|
||||||
|
if r.get("egress_ip"):
|
||||||
|
egress_by_node.setdefault(node, set()).add(r["egress_ip"])
|
||||||
|
|
||||||
|
def pct(vals, p):
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
s = sorted(vals)
|
||||||
|
return round(s[min(len(s) - 1, int(p / 100 * len(s)))], 1)
|
||||||
|
|
||||||
|
report = {"log": log_path, "nodes": {}}
|
||||||
|
for node in sorted(per_node):
|
||||||
|
st = per_node[node]
|
||||||
|
report["nodes"][node] = {
|
||||||
|
"probes": st["probes"],
|
||||||
|
"ok_rate": round(st["ok"] / st["probes"], 3) if st["probes"] else 0,
|
||||||
|
"latency_ms": {
|
||||||
|
"mean": round(sum(st["ms"]) / len(st["ms"]), 1) if st["ms"] else None,
|
||||||
|
"p50": pct(st["ms"], 50),
|
||||||
|
"p99": pct(st["ms"], 99),
|
||||||
|
},
|
||||||
|
"http_429": st["429"],
|
||||||
|
"http_403": st["403"],
|
||||||
|
"statuses": st["statuses"],
|
||||||
|
"identity_rotations": max(0, len(st["identities"]) - 1),
|
||||||
|
"egress_ips": sorted(egress_by_node.get(node, set())),
|
||||||
|
}
|
||||||
|
if len(st["identities"]) > 1:
|
||||||
|
identity_changes[node] = sorted(st["identities"])
|
||||||
|
|
||||||
|
# Divergence analysis: did all nodes see the same fate at the same time?
|
||||||
|
report["rate_limit_events"] = [
|
||||||
|
{"ts": ts, "node": n, "probe": p} for ts, n, p in rate_limit_events[-50:]
|
||||||
|
]
|
||||||
|
report["identity_changes"] = identity_changes
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Identity variance testing framework")
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
p_probe = sub.add_parser("probe", help="run one probe cycle")
|
||||||
|
p_probe.add_argument("--log", default=DEFAULT_LOG)
|
||||||
|
p_an = sub.add_parser("analyze", help="summarize variance data")
|
||||||
|
p_an.add_argument("--log", default=DEFAULT_LOG)
|
||||||
|
p_an.add_argument("--since", type=float, default=None,
|
||||||
|
help="only include last N hours")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.cmd == "probe":
|
||||||
|
sys.exit(run_probe_cycle(args.log))
|
||||||
|
sys.exit(analyze(args.log, args.since))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -222,10 +222,56 @@ def send_dm(agent, target, message, dry_run=False, followup_tags=None,
|
|||||||
if HAS_RATE_LIMITER:
|
if HAS_RATE_LIMITER:
|
||||||
rate_limit_wait(agent)
|
rate_limit_wait(agent)
|
||||||
|
|
||||||
|
# Cryptographic attestation: sign message with local SSH key
|
||||||
|
signed_payload = None
|
||||||
|
dm_sign_sh = NETVM_ROOT / "bin" / "dm-sign.sh"
|
||||||
|
priv_key = Path(os.path.expanduser("~/.ssh/id_ed25519"))
|
||||||
|
if dm_sign_sh.exists() and priv_key.exists():
|
||||||
|
try:
|
||||||
|
sign_res = subprocess.run(
|
||||||
|
[str(dm_sign_sh), "--from", "super", "--key", str(priv_key), message],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if sign_res.returncode == 0 and "-----BEGIN SSH SIGNATURE-----" in sign_res.stdout:
|
||||||
|
signed_payload = sign_res.stdout.strip()
|
||||||
|
# Extract message id and register proof to crypt.muse-dev.online
|
||||||
|
id_m = re.search(r"\[id:([a-f0-9]+)\]", signed_payload)
|
||||||
|
proof_id = id_m.group(1) if id_m else None
|
||||||
|
if proof_id:
|
||||||
|
proof_data = {
|
||||||
|
"id": proof_id,
|
||||||
|
"signer": "super",
|
||||||
|
"target_agent": agent,
|
||||||
|
"target_conversation": target,
|
||||||
|
"raw_payload": signed_payload,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://crypt.muse-dev.online/proofs",
|
||||||
|
data=json.dumps(proof_data).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "job-dispatch/1.0"},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||||
|
pass
|
||||||
|
except Exception as pe:
|
||||||
|
sys.stderr.write(f"warning: proof registration to crypt.muse-dev.online failed: {pe}\n")
|
||||||
|
except Exception as se:
|
||||||
|
sys.stderr.write(f"warning: dm signing failed: {se}\n")
|
||||||
|
|
||||||
|
if signed_payload:
|
||||||
|
cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent,
|
||||||
|
"--target", target, "--raw"]
|
||||||
|
+ (["--allow-main-chat"] if (target == "main" and allow_main_chat) else [])
|
||||||
|
+ (followup_tags or []) + [signed_payload])
|
||||||
|
else:
|
||||||
cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent,
|
cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent,
|
||||||
"--target", target]
|
"--target", target]
|
||||||
+ (["--allow-main-chat"] if (target == "main" and allow_main_chat) else [])
|
+ (["--allow-main-chat"] if (target == "main" and allow_main_chat) else [])
|
||||||
+ (followup_tags or []) + [message])
|
+ (followup_tags or []) + [message])
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
|||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Thread keepalive timer entry point (runs on bl).
|
||||||
|
|
||||||
|
Reads keepalive-config.json, ticks every registered thread:
|
||||||
|
ensure reachable -> ping if idle past threshold -> recreate if dead.
|
||||||
|
|
||||||
|
Designed to run from a systemd timer (e.g. every 5 minutes). Each tick is
|
||||||
|
idempotent and cheap: threads that are alive and recently active are a
|
||||||
|
single navigate+verify (no ping sent).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
keepalive-timer.py [--config PATH] [--state PATH] [--dry-run] [--status]
|
||||||
|
|
||||||
|
--status print the registry with idle times and exit (no changes)
|
||||||
|
|
||||||
|
Exit codes: 0 = all ok, 1 = one or more threads failed, 2 = config error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from keepalive import Keepalive
|
||||||
|
|
||||||
|
NETVM_ROOT = os.environ.get("NETVM_ROOT", "/home/super/Projects/NetVM")
|
||||||
|
DEFAULT_CONFIG = os.path.join(NETVM_ROOT, "keepalive-config.json")
|
||||||
|
DEFAULT_STATE = os.path.join(NETVM_ROOT, "keepalive-threads.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"keepalive: config not found: {path}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"keepalive: bad config {path}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
threads = cfg.get("threads", [])
|
||||||
|
if not isinstance(threads, list):
|
||||||
|
print("keepalive: config 'threads' must be a list", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
return threads
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--config", default=DEFAULT_CONFIG)
|
||||||
|
ap.add_argument("--state", default=DEFAULT_STATE)
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--status", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
ka = Keepalive(state_path=args.state, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
print(json.dumps(ka.status(), indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
threads = load_config(args.config)
|
||||||
|
if threads is None:
|
||||||
|
return 2
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for t in threads:
|
||||||
|
key = t.get("key")
|
||||||
|
agent = t.get("agent", "opm")
|
||||||
|
if not key or not t.get("enabled", True):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
status = ka.tick(
|
||||||
|
key,
|
||||||
|
agent,
|
||||||
|
idle_threshold_s=int(t.get("idle_threshold_s", 3600)),
|
||||||
|
max_retries=int(t.get("max_retries", 3)),
|
||||||
|
ping_message=t.get("ping_message"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
status = f"error: {e}"
|
||||||
|
ka.log_event("keepalive_tick_error",
|
||||||
|
{"key": key, "agent": agent, "error": str(e)[:200]})
|
||||||
|
results[key] = status
|
||||||
|
print(f"keepalive: {key}@{agent} -> {status}")
|
||||||
|
|
||||||
|
failed = [k for k, v in results.items()
|
||||||
|
if v in ("failed",) or v.startswith("error")]
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generic thread keepalive for bl side chats.
|
||||||
|
|
||||||
|
Pattern proven by the heartbeat job overnight: a state file maps a stable
|
||||||
|
key -> thread UUID, and each tick navigates directly to
|
||||||
|
https://muse.ai/thread/<uuid> (UUID reuse fix in muse-chat-api.py
|
||||||
|
cmd_sidechat_use) instead of name-based sidebar lookup.
|
||||||
|
|
||||||
|
This module generalizes that pattern:
|
||||||
|
|
||||||
|
- ANY side chat can register for keepalive (not just job sidechats).
|
||||||
|
- Each tick: ensure the thread is reachable; ping it if idle past threshold;
|
||||||
|
recreate it if the stored UUID is dead.
|
||||||
|
- State lives in keepalive-threads.json (atomic write via tmp+rename).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from keepalive import Keepalive
|
||||||
|
|
||||||
|
ka = Keepalive(state_path="/home/super/Projects/NetVM/keepalive-threads.json")
|
||||||
|
ka.ensure("ops-watch", agent="opm") # reuse or create
|
||||||
|
ka.tick("ops-watch", agent="opm",
|
||||||
|
ping_message="[keepalive] ops-watch {ts}",
|
||||||
|
idle_threshold_s=3600) # ping if idle
|
||||||
|
|
||||||
|
The timer entry point (keepalive-timer.py) drives this from a JSON config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths (overridable for tests)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NETVM_ROOT = os.environ.get("NETVM_ROOT", "/home/super/Projects/NetVM")
|
||||||
|
NETVM_EXEC = os.path.join(NETVM_ROOT, "bin", "netvm-exec.sh")
|
||||||
|
CHAT_API = os.path.join(NETVM_ROOT, "bin", "muse-chat-api.py")
|
||||||
|
DEFAULT_STATE = os.path.join(NETVM_ROOT, "keepalive-threads.json")
|
||||||
|
DEFAULT_LOG = os.path.join(NETVM_ROOT, "keepalive-log.jsonl")
|
||||||
|
|
||||||
|
UUID_RE = re.compile(
|
||||||
|
r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
||||||
|
r"[0-9a-f]{4}-[0-9a-f]{12})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _ts():
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Keepalive:
|
||||||
|
"""Thread keepalive registry + ensure/ping/check operations."""
|
||||||
|
|
||||||
|
def __init__(self, state_path=DEFAULT_STATE, log_path=DEFAULT_LOG,
|
||||||
|
dry_run=False):
|
||||||
|
self.state_path = state_path
|
||||||
|
self.log_path = log_path
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
# -- state ---------------------------------------------------------
|
||||||
|
|
||||||
|
def load_state(self):
|
||||||
|
if os.path.exists(self.state_path):
|
||||||
|
try:
|
||||||
|
with open(self.state_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_state(self, state):
|
||||||
|
if self.dry_run:
|
||||||
|
return
|
||||||
|
tmp = self.state_path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
os.replace(tmp, self.state_path)
|
||||||
|
|
||||||
|
def log_event(self, event_type, data):
|
||||||
|
if self.dry_run:
|
||||||
|
return
|
||||||
|
entry = {"ts": _utcnow(), "type": event_type, **data}
|
||||||
|
try:
|
||||||
|
with open(self.log_path, "a") as f:
|
||||||
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- browser ops (mirrors job-dispatch.py; UUID navigation, not names) --
|
||||||
|
|
||||||
|
def _run(self, agent, *api_args, timeout=60):
|
||||||
|
"""Run muse-chat-api.py in the agent's netns via netvm-exec.sh."""
|
||||||
|
cmd = [NETVM_EXEC, agent, "--", "python3", CHAT_API,
|
||||||
|
"--account", agent] + list(api_args)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=timeout)
|
||||||
|
return r.returncode, r.stdout.strip(), r.stderr.strip()
|
||||||
|
except Exception as e:
|
||||||
|
return -1, "", str(e)
|
||||||
|
|
||||||
|
def navigate_to_uuid(self, agent, thread_uuid):
|
||||||
|
"""Go directly to https://muse.ai/thread/<uuid>.
|
||||||
|
|
||||||
|
This is the heartbeat UUID-reuse fix: cmd_sidechat_use navigates by
|
||||||
|
URL for UUID args instead of searching sidebar titles by name.
|
||||||
|
Returns True if the command succeeded.
|
||||||
|
"""
|
||||||
|
if self.dry_run:
|
||||||
|
print(f"[DRY] navigate {agent} -> {thread_uuid}")
|
||||||
|
return True
|
||||||
|
rc, out, err = self._run(agent, "sidechat", "use", thread_uuid,
|
||||||
|
timeout=45)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
|
def current_thread_uuid(self, agent):
|
||||||
|
"""Read the browser's current URL and extract the thread UUID."""
|
||||||
|
if self.dry_run:
|
||||||
|
return None
|
||||||
|
rc, out, err = self._run(agent, "url", timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return None
|
||||||
|
m = UUID_RE.search(out or "")
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
def create_sidechat(self, agent, name=None):
|
||||||
|
"""Create a new sidechat in the agent's account. Returns True."""
|
||||||
|
if self.dry_run:
|
||||||
|
print(f"[DRY] create sidechat for {agent}")
|
||||||
|
return True
|
||||||
|
rc, out, err = self._run(agent, "sidechat", "create", timeout=90)
|
||||||
|
return rc == 0 and "Created:" in (out or "")
|
||||||
|
|
||||||
|
def send_message(self, agent, message):
|
||||||
|
"""Send a message to the current chat. Returns True on success."""
|
||||||
|
if self.dry_run:
|
||||||
|
print(f"[DRY] send to {agent}: {message[:80]}")
|
||||||
|
return True
|
||||||
|
rc, out, err = self._run(agent, "send", message, timeout=60)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
|
# -- keepalive ops ---------------------------------------------------
|
||||||
|
|
||||||
|
def check(self, key, agent):
|
||||||
|
"""Verify the registered thread is reachable.
|
||||||
|
|
||||||
|
Navigates to the stored UUID and confirms the browser lands on it.
|
||||||
|
Returns (ok, thread_uuid). Updates last_verified_ts on success.
|
||||||
|
"""
|
||||||
|
state = self.load_state()
|
||||||
|
rec = state.get(key)
|
||||||
|
if not rec or not rec.get("thread_uuid"):
|
||||||
|
return False, None
|
||||||
|
uuid = rec["thread_uuid"]
|
||||||
|
if not self.navigate_to_uuid(agent, uuid):
|
||||||
|
self.log_event("keepalive_check_failed",
|
||||||
|
{"key": key, "agent": agent, "thread_uuid": uuid,
|
||||||
|
"reason": "navigate_failed"})
|
||||||
|
return False, uuid
|
||||||
|
cur = self.current_thread_uuid(agent)
|
||||||
|
if cur == uuid:
|
||||||
|
rec["last_verified_ts"] = _ts()
|
||||||
|
rec["consecutive_failures"] = 0
|
||||||
|
state[key] = rec
|
||||||
|
self.save_state(state)
|
||||||
|
self.log_event("keepalive_check_ok",
|
||||||
|
{"key": key, "agent": agent, "thread_uuid": uuid})
|
||||||
|
return True, uuid
|
||||||
|
self.log_event("keepalive_check_failed",
|
||||||
|
{"key": key, "agent": agent, "thread_uuid": uuid,
|
||||||
|
"reason": "url_mismatch", "current": cur})
|
||||||
|
return False, uuid
|
||||||
|
|
||||||
|
def ensure(self, key, agent, name=None):
|
||||||
|
"""Ensure the thread exists and is reachable; create if needed.
|
||||||
|
|
||||||
|
Returns (ok, thread_uuid). Mirrors the job-dispatch.py reuse-or-
|
||||||
|
create flow: try stored UUID first, fall back to creation, then
|
||||||
|
capture the new UUID from the browser URL.
|
||||||
|
"""
|
||||||
|
state = self.load_state()
|
||||||
|
rec = state.get(key)
|
||||||
|
if rec and rec.get("thread_uuid"):
|
||||||
|
ok, uuid = self.check(key, agent)
|
||||||
|
if ok:
|
||||||
|
return True, uuid
|
||||||
|
# Stored thread is dead — fall through to recreate.
|
||||||
|
print(f"keepalive: stored thread for {key} unreachable, "
|
||||||
|
f"recreating", file=sys.stderr)
|
||||||
|
# Create a fresh sidechat.
|
||||||
|
if not self.create_sidechat(agent, name=name):
|
||||||
|
self.log_event("keepalive_create_failed",
|
||||||
|
{"key": key, "agent": agent})
|
||||||
|
return False, None
|
||||||
|
# Capture the new thread UUID from the browser URL.
|
||||||
|
uuid = None
|
||||||
|
if not self.dry_run:
|
||||||
|
for _ in range(15):
|
||||||
|
time.sleep(1)
|
||||||
|
uuid = self.current_thread_uuid(agent)
|
||||||
|
if uuid:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
uuid = "dry-run-uuid"
|
||||||
|
if not uuid:
|
||||||
|
self.log_event("keepalive_create_failed",
|
||||||
|
{"key": key, "agent": agent,
|
||||||
|
"reason": "uuid_capture_failed"})
|
||||||
|
return False, None
|
||||||
|
now = _ts()
|
||||||
|
state[key] = {
|
||||||
|
"thread_uuid": uuid,
|
||||||
|
"agent": agent,
|
||||||
|
"created_ts": now,
|
||||||
|
"last_ping_ts": 0,
|
||||||
|
"last_verified_ts": now,
|
||||||
|
"last_activity_ts": now,
|
||||||
|
"ping_count": 0,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
}
|
||||||
|
self.save_state(state)
|
||||||
|
self.log_event("keepalive_created",
|
||||||
|
{"key": key, "agent": agent, "thread_uuid": uuid})
|
||||||
|
return True, uuid
|
||||||
|
|
||||||
|
def ping(self, key, agent, message=None):
|
||||||
|
"""Send a keepalive ping into the thread.
|
||||||
|
|
||||||
|
Navigates to the thread first (cheap no-op if already there),
|
||||||
|
then sends the ping message. Updates last_ping_ts / ping_count.
|
||||||
|
"""
|
||||||
|
state = self.load_state()
|
||||||
|
rec = state.get(key)
|
||||||
|
if not rec or not rec.get("thread_uuid"):
|
||||||
|
return False
|
||||||
|
uuid = rec["thread_uuid"]
|
||||||
|
if not self.navigate_to_uuid(agent, uuid):
|
||||||
|
return False
|
||||||
|
msg = (message or "[keepalive:{key}] tick {ts} {uuid}").format(
|
||||||
|
key=key, ts=_utcnow(), uuid=uuid)
|
||||||
|
if not self.send_message(agent, msg):
|
||||||
|
self.log_event("keepalive_ping_failed",
|
||||||
|
{"key": key, "agent": agent, "thread_uuid": uuid})
|
||||||
|
return False
|
||||||
|
now = _ts()
|
||||||
|
rec["last_ping_ts"] = now
|
||||||
|
rec["last_activity_ts"] = now
|
||||||
|
rec["ping_count"] = rec.get("ping_count", 0) + 1
|
||||||
|
state[key] = rec
|
||||||
|
self.save_state(state)
|
||||||
|
self.log_event("keepalive_ping",
|
||||||
|
{"key": key, "agent": agent, "thread_uuid": uuid,
|
||||||
|
"ping_count": rec["ping_count"]})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def note_activity(self, key):
|
||||||
|
"""Record external activity (e.g. a job just sent to the thread)
|
||||||
|
so the idle timer doesn't ping unnecessarily."""
|
||||||
|
state = self.load_state()
|
||||||
|
rec = state.get(key)
|
||||||
|
if rec:
|
||||||
|
rec["last_activity_ts"] = _ts()
|
||||||
|
state[key] = rec
|
||||||
|
self.save_state(state)
|
||||||
|
|
||||||
|
def tick(self, key, agent, idle_threshold_s=3600, max_retries=3,
|
||||||
|
ping_message=None):
|
||||||
|
"""One keepalive tick for a registered thread.
|
||||||
|
|
||||||
|
- Ensures the thread exists (recreate if dead).
|
||||||
|
- Pings only if idle longer than idle_threshold_s.
|
||||||
|
- After max_retries consecutive failures, forces recreation.
|
||||||
|
Returns a status string: ok | pinged | recreated | failed.
|
||||||
|
"""
|
||||||
|
state = self.load_state()
|
||||||
|
rec = state.get(key)
|
||||||
|
|
||||||
|
if not rec or not rec.get("thread_uuid"):
|
||||||
|
ok, _ = self.ensure(key, agent)
|
||||||
|
return "recreated" if ok else "failed"
|
||||||
|
|
||||||
|
failures = rec.get("consecutive_failures", 0)
|
||||||
|
if failures >= max_retries:
|
||||||
|
# Force recreation: drop the dead UUID and re-ensure.
|
||||||
|
self.log_event("keepalive_force_recreate",
|
||||||
|
{"key": key, "agent": agent,
|
||||||
|
"failures": failures,
|
||||||
|
"old_uuid": rec.get("thread_uuid")})
|
||||||
|
rec["thread_uuid"] = None
|
||||||
|
state[key] = rec
|
||||||
|
self.save_state(state)
|
||||||
|
ok, _ = self.ensure(key, agent)
|
||||||
|
return "recreated" if ok else "failed"
|
||||||
|
|
||||||
|
ok, _ = self.check(key, agent)
|
||||||
|
if not ok:
|
||||||
|
state = self.load_state()
|
||||||
|
rec = state.get(key, {})
|
||||||
|
rec["consecutive_failures"] = failures + 1
|
||||||
|
state[key] = rec
|
||||||
|
self.save_state(state)
|
||||||
|
return "failed"
|
||||||
|
|
||||||
|
now = _ts()
|
||||||
|
idle_for = now - rec.get("last_activity_ts", 0)
|
||||||
|
if idle_for >= idle_threshold_s:
|
||||||
|
if self.ping(key, agent, message=ping_message):
|
||||||
|
return "pinged"
|
||||||
|
return "failed"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def unregister(self, key):
|
||||||
|
"""Remove a thread from keepalive (does not delete the sidechat)."""
|
||||||
|
state = self.load_state()
|
||||||
|
if key in state:
|
||||||
|
del state[key]
|
||||||
|
self.save_state(state)
|
||||||
|
self.log_event("keepalive_unregistered", {"key": key})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
"""Return the full registry with computed idle times."""
|
||||||
|
state = self.load_state()
|
||||||
|
now = _ts()
|
||||||
|
out = {}
|
||||||
|
for key, rec in state.items():
|
||||||
|
r = dict(rec)
|
||||||
|
r["idle_s"] = int(now - rec.get("last_activity_ts", now))
|
||||||
|
out[key] = r
|
||||||
|
return out
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Side-chat to main-chat work siphon — monitor loop.
|
||||||
|
|
||||||
|
Polls side chats for new messages, runs detection, siphons hits to main.
|
||||||
|
|
||||||
|
This is the integration point for bl. In production:
|
||||||
|
- list_sidechats() calls muse-chat-api.py or the sidechat manager
|
||||||
|
- get_messages() reads thread messages via CDP
|
||||||
|
- post_to_main() sends via muse-chat-api.py send to main chat
|
||||||
|
|
||||||
|
For the prototype, all three are injectable (see tests).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Callable, Dict, List
|
||||||
|
|
||||||
|
from detect import detect, is_opted_out
|
||||||
|
from siphon import siphon, RateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
# Message shape: {"id": str, "text": str, "author": str, "ts": str}
|
||||||
|
Message = Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_once(
|
||||||
|
list_sidechats: Callable[[], List[Dict[str, str]]],
|
||||||
|
get_messages: Callable[[str, str], List[Message]],
|
||||||
|
post_to_main: Callable[[str], bool],
|
||||||
|
watermarks: Dict[str, str],
|
||||||
|
limiter: RateLimiter = None,
|
||||||
|
min_confidence: float = 0.6,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
One poll cycle. Returns updated watermarks.
|
||||||
|
|
||||||
|
list_sidechats: () -> [{"id": thread_id, "name": str, "agent": str}]
|
||||||
|
get_messages: (thread_id, since_msg_id) -> [messages newer than watermark]
|
||||||
|
post_to_main: (text) -> True on success
|
||||||
|
watermarks: {thread_id: last_seen_message_id}
|
||||||
|
"""
|
||||||
|
lim = limiter or RateLimiter()
|
||||||
|
new_marks = dict(watermarks)
|
||||||
|
|
||||||
|
for chat in list_sidechats():
|
||||||
|
tid = chat["id"]
|
||||||
|
agent = chat.get("agent", "unknown")
|
||||||
|
|
||||||
|
if is_opted_out(tid):
|
||||||
|
continue
|
||||||
|
|
||||||
|
since = watermarks.get(tid, "")
|
||||||
|
try:
|
||||||
|
messages = get_messages(tid, since)
|
||||||
|
except Exception:
|
||||||
|
continue # don't let one bad chat kill the cycle
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
mid = msg.get("id", "")
|
||||||
|
text = msg.get("text", "")
|
||||||
|
if not mid or not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update watermark to newest seen
|
||||||
|
new_marks[tid] = mid
|
||||||
|
|
||||||
|
hit = detect(text, tid, mid, min_confidence)
|
||||||
|
if hit:
|
||||||
|
siphon(hit, agent, post_to_main, lim)
|
||||||
|
|
||||||
|
return new_marks
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_loop(
|
||||||
|
list_sidechats,
|
||||||
|
get_messages,
|
||||||
|
post_to_main,
|
||||||
|
poll_interval: int = 60,
|
||||||
|
watermarks: Dict[str, str] = None,
|
||||||
|
):
|
||||||
|
"""Run forever. For production use with systemd timer instead."""
|
||||||
|
marks = watermarks or {}
|
||||||
|
limiter = RateLimiter()
|
||||||
|
while True:
|
||||||
|
marks = monitor_once(
|
||||||
|
list_sidechats, get_messages, post_to_main, marks, limiter
|
||||||
|
)
|
||||||
|
time.sleep(poll_interval)
|
||||||
+125
-1
@@ -16,6 +16,9 @@ Usage:
|
|||||||
muse-chat-api.py --account <agent> wait [timeout]
|
muse-chat-api.py --account <agent> wait [timeout]
|
||||||
muse-chat-api.py --account <agent> approvals # check pending approvals
|
muse-chat-api.py --account <agent> approvals # check pending approvals
|
||||||
muse-chat-api.py --account <agent> upload <file> [--message txt] [--dry-run]
|
muse-chat-api.py --account <agent> upload <file> [--message txt] [--dry-run]
|
||||||
|
|
||||||
|
Exit codes: 0 ok, 1 error, 2 APPROVAL_NEEDED (human decision),
|
||||||
|
3 DOM_NOT_READY (browser not in expected chat state - safe to retry).
|
||||||
"""
|
"""
|
||||||
import json, urllib.request, websocket, time, sys, argparse, importlib.util
|
import json, urllib.request, websocket, time, sys, argparse, importlib.util
|
||||||
import os
|
import os
|
||||||
@@ -146,6 +149,120 @@ def check_approvals(ws):
|
|||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
# Exit code for "browser DOM not ready" — distinct from 1 (generic error)
|
||||||
|
# and 2 (APPROVAL_NEEDED, needs a human). DOM_NOT_READY is safe to retry
|
||||||
|
# after a few seconds: the React app may still be hydrating (S3 partial
|
||||||
|
# load) or the Warp tunnel may have just hiccupped. dm.py treats any
|
||||||
|
# non-"sent" send output as a failed attempt and retries, so this is a
|
||||||
|
# fail-closed retry signal, not a crash.
|
||||||
|
DOM_NOT_READY_EXIT = 3
|
||||||
|
|
||||||
|
# Shimmer spans (span.animate-pulse-light) exist in the settled state too
|
||||||
|
# (2 observed — avatar/media skeleton slots, not load progress). Only a
|
||||||
|
# mass-skeleton count indicates a stuck/broken render. Heuristic threshold
|
||||||
|
# from docs/DOM-EDGE-STATES.md 1.1; the settled-state signature below is
|
||||||
|
# the primary readiness signal.
|
||||||
|
SHIMMER_STORM_THRESHOLD = 30
|
||||||
|
|
||||||
|
|
||||||
|
def chat_ready_probe(ws):
|
||||||
|
"""One-shot DOM readiness probe. Returns a dict of observed state, or
|
||||||
|
{} if the page could not be evaluated at all."""
|
||||||
|
result = ev1(ws, """(() => {
|
||||||
|
const q = s => { try { return document.querySelector(s); } catch(e) { return null; } };
|
||||||
|
const qa = s => { try { return document.querySelectorAll(s); } catch(e) { return []; } };
|
||||||
|
const title = document.title || '';
|
||||||
|
const url = window.location.href || '';
|
||||||
|
const switcher = !!q('[data-testid="hatch-chat-switcher-trigger"]');
|
||||||
|
const buttons = qa('button').length;
|
||||||
|
const msglog = !!q('div[role="log"]');
|
||||||
|
const composer = !!(q('[contenteditable="true"]') ||
|
||||||
|
q('textarea[placeholder*="Message"]') ||
|
||||||
|
q('div[role="textbox"]'));
|
||||||
|
const shimmer = qa('span.animate-pulse-light').length;
|
||||||
|
let alertText = '';
|
||||||
|
try {
|
||||||
|
alertText = [...qa('[role="alert"]')]
|
||||||
|
.filter(e => e.tagName !== 'SCRIPT' && (e.innerText||'').trim().length > 0)
|
||||||
|
.slice(0, 2).map(e => e.innerText.slice(0, 160)).join(' | ');
|
||||||
|
} catch(e) {}
|
||||||
|
let onLine = true;
|
||||||
|
try { onLine = navigator.onLine; } catch(e) {}
|
||||||
|
return JSON.stringify({title, url, switcher, buttons, msglog,
|
||||||
|
composer, shimmer, alertText, onLine});
|
||||||
|
})()""")
|
||||||
|
try:
|
||||||
|
return json.loads(result) if result else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_chat_state(p):
|
||||||
|
"""Classify a readiness probe. Returns (state, diagnosis). READY is the
|
||||||
|
only state a send may proceed from. Reference: docs/DOM-EDGE-STATES.md.
|
||||||
|
Note: 'Chat \u2014' is the 'Chat \u2014 <identity>' title prefix."""
|
||||||
|
if not p:
|
||||||
|
return ("NO_PROBE",
|
||||||
|
"CDP evaluate returned nothing - page blank, navigating, or CDP wedged")
|
||||||
|
title = p.get("title", "")
|
||||||
|
url = p.get("url", "")
|
||||||
|
if title == "muse.ai":
|
||||||
|
return ("LANDING",
|
||||||
|
"browser is on the muse.ai landing page, not in chat (state: LANDING)")
|
||||||
|
if not p.get("onLine", True):
|
||||||
|
return ("OFFLINE",
|
||||||
|
"navigator.onLine is false - browser reports no network")
|
||||||
|
if p.get("alertText"):
|
||||||
|
return ("REAL_ERROR",
|
||||||
|
"page shows an error banner: %s" % p["alertText"][:160])
|
||||||
|
if "/thread/new" in url:
|
||||||
|
# Legitimate only right after `sidechat create`: the new-thread
|
||||||
|
# composer is up and the title has settled. A stripped /thread/new
|
||||||
|
# (no title, no composer) is the S8 broken state.
|
||||||
|
if p.get("composer") and "Chat \u2014" in title:
|
||||||
|
return ("READY", "new-thread composer state (/thread/new)")
|
||||||
|
return ("S8_STRIPPED",
|
||||||
|
"stripped /thread/new page - no composer/title (state: S8)")
|
||||||
|
switcher = p.get("switcher", False)
|
||||||
|
buttons = p.get("buttons", 0)
|
||||||
|
if title == "Muse" and (not switcher or buttons < 30):
|
||||||
|
return ("S3_PARTIAL",
|
||||||
|
"React still hydrating: title 'Muse', %d buttons, switcher=%s (state: S3)"
|
||||||
|
% (buttons, switcher))
|
||||||
|
if not ("Chat \u2014" in title and switcher and buttons > 30 and p.get("msglog")):
|
||||||
|
return ("NOT_SETTLED",
|
||||||
|
"settled-state signature not met (title=%r switcher=%s buttons=%d msglog=%s)" %
|
||||||
|
(title[:40], switcher, buttons, p.get("msglog", False)))
|
||||||
|
if p.get("shimmer", 0) > SHIMMER_STORM_THRESHOLD:
|
||||||
|
return ("SHIMMER_STORM",
|
||||||
|
"mass skeleton render: %d shimmer spans (settled has ~2)" % p["shimmer"])
|
||||||
|
if not p.get("composer"):
|
||||||
|
return ("COMPOSER_MISSING",
|
||||||
|
"chat settled but no composer input found - send has nowhere to type")
|
||||||
|
return ("READY", "settled chat state (title=%r)" % title[:40])
|
||||||
|
|
||||||
|
|
||||||
|
def assert_chat_ready(ws, context="send"):
|
||||||
|
"""Fail-closed React-readiness gate ("matching chromebox").
|
||||||
|
|
||||||
|
Call before any DOM mutation (send, upload). On mismatch prints a
|
||||||
|
structured diagnosis to stderr and exits DOM_NOT_READY_EXIT (3) - a
|
||||||
|
retryable signal, distinct from APPROVAL_NEEDED (2, needs a human).
|
||||||
|
S3 partial load gets one 5s grace re-probe (transient render phase);
|
||||||
|
everything else fails immediately.
|
||||||
|
"""
|
||||||
|
probe = chat_ready_probe(ws)
|
||||||
|
state, detail = classify_chat_state(probe)
|
||||||
|
if state == "S3_PARTIAL":
|
||||||
|
time.sleep(5)
|
||||||
|
probe = chat_ready_probe(ws)
|
||||||
|
state, detail = classify_chat_state(probe)
|
||||||
|
if state != "READY":
|
||||||
|
print("DOM_NOT_READY [%s] %s" % (state, detail), file=sys.stderr)
|
||||||
|
print("DOM_NOT_READY probe: %s" % json.dumps(probe), file=sys.stderr)
|
||||||
|
sys.exit(DOM_NOT_READY_EXIT)
|
||||||
|
return probe
|
||||||
|
|
||||||
def cmd_approvals(ws):
|
def cmd_approvals(ws):
|
||||||
"""Check and handle pending approvals."""
|
"""Check and handle pending approvals."""
|
||||||
actions = check_approvals(ws)
|
actions = check_approvals(ws)
|
||||||
@@ -160,7 +277,12 @@ def cmd_approvals(ws):
|
|||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
def cmd_send(ws, message):
|
def cmd_send(ws, message):
|
||||||
# Check approvals first
|
# React-readiness gate ("matching chromebox"): fail closed if the
|
||||||
|
# browser is not in the expected chat state (landing page,
|
||||||
|
# partial load, partition). Exit 3 = safe to retry, unlike
|
||||||
|
# APPROVAL_NEEDED (2, needs a human).
|
||||||
|
assert_chat_ready(ws, context="send")
|
||||||
|
# Check approvals
|
||||||
actions = check_approvals(ws)
|
actions = check_approvals(ws)
|
||||||
for dialog, trusted, action in actions:
|
for dialog, trusted, action in actions:
|
||||||
if not trusted:
|
if not trusted:
|
||||||
@@ -479,6 +601,8 @@ def cmd_upload(ws, filepath, message=None, dry_run=False):
|
|||||||
if not os.path.isfile(filepath):
|
if not os.path.isfile(filepath):
|
||||||
print("ERROR: file not found: %s" % filepath, file=sys.stderr)
|
print("ERROR: file not found: %s" % filepath, file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
# React-readiness gate ("matching chromebox") - see cmd_send.
|
||||||
|
assert_chat_ready(ws, context="upload")
|
||||||
actions = check_approvals(ws)
|
actions = check_approvals(ws)
|
||||||
for dialog, trusted, action in actions:
|
for dialog, trusted, action in actions:
|
||||||
if not trusted:
|
if not trusted:
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# muse-cli-node <node> <muse-cli-args...>
|
||||||
|
# Runs muse-cli inside the node's isolated network namespace with its own
|
||||||
|
# dedicated Cloudflare WARP egress identity and isolated cookies/config.
|
||||||
|
# Auto-refreshes cookies via Chromium CDP if authentication fails.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NETVM_BIN="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
NODE="${1:?usage: muse-cli-node <node> [args...]}"
|
||||||
|
shift
|
||||||
|
|
||||||
|
CONF_DIR="$HOME/.config/muse-cli/$NODE"
|
||||||
|
mkdir -p "$CONF_DIR"
|
||||||
|
|
||||||
|
run_cli() {
|
||||||
|
export MUSE_CONFIG_DIR="$CONF_DIR"
|
||||||
|
"$NETVM_BIN/netvm-exec.sh" "$NODE" -- python3 -c "
|
||||||
|
import os, sys
|
||||||
|
os.environ['MUSE_CONFIG_DIR'] = '$CONF_DIR'
|
||||||
|
import muse_cli.cli as cli
|
||||||
|
cli.CONFIG_DIR = '$CONF_DIR'
|
||||||
|
cli.CONFIG_FILE = os.path.join('$CONF_DIR', 'config.json')
|
||||||
|
cli.COOKIES_FILE = os.path.join('$CONF_DIR', 'cookies.txt')
|
||||||
|
try:
|
||||||
|
cli.main()
|
||||||
|
except SystemExit as e:
|
||||||
|
sys.exit(e.code)
|
||||||
|
" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First attempt
|
||||||
|
set +e
|
||||||
|
OUTPUT=$(run_cli "$@" 2>&1)
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Detect authentication failure and trigger single auto-refresh retry
|
||||||
|
if [ $RC -ne 0 ] && echo "$OUTPUT" | grep -qiE "AuthError|hatch_sess|401|unauthorized"; then
|
||||||
|
echo "Notice: Auth failure detected for $NODE; refreshing cookies from Chromium CDP..." >&2
|
||||||
|
if "$NETVM_BIN/netvm-exec.sh" "$NODE" -- python3 "$NETVM_BIN/refresh-node-cookies.py" "$NODE"; then
|
||||||
|
echo "Notice: Cookies refreshed for $NODE. Retrying operation..." >&2
|
||||||
|
run_cli "$@"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If succeeded or failed on something else, emit output and exit with RC
|
||||||
|
echo "$OUTPUT"
|
||||||
|
exit $RC
|
||||||
Executable
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
muse_hybrid.py: Hybrid integration layer combining fast headless gateway actions
|
||||||
|
(via muse-cli-node with per-node Cloudflare WARP egress & cookies) with Chromebox
|
||||||
|
CDP DOM interactions.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- muse_threads(agent) -> list of threads
|
||||||
|
- muse_history(agent, thread_id=None, limit=10) -> list of messages
|
||||||
|
- muse_send(agent, text, thread_id=None, wait=0) -> dict response
|
||||||
|
- muse_session_start(agent, title=None) -> dict response
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BIN_DIR = Path(__file__).resolve().parent
|
||||||
|
MUSE_CLI_NODE = BIN_DIR / "muse-cli-node"
|
||||||
|
|
||||||
|
def is_node_configured(node):
|
||||||
|
conf_dir = Path.home() / ".config" / "muse-cli" / node
|
||||||
|
return (conf_dir / "cookies.txt").exists()
|
||||||
|
|
||||||
|
def run_muse_cli(node, args, timeout=30):
|
||||||
|
cmd = [str(MUSE_CLI_NODE), node] + args
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
return res.returncode, res.stdout, res.stderr
|
||||||
|
|
||||||
|
def get_threads(node):
|
||||||
|
"""Retrieve thread list via fast gateway. Returns (threads_list, error_str)."""
|
||||||
|
rc, stdout, stderr = run_muse_cli(node, ["threads"])
|
||||||
|
if rc != 0:
|
||||||
|
return None, stderr or stdout
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
return data, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"JSON parse error: {e}"
|
||||||
|
|
||||||
|
def get_history(node, thread_id=None, limit=15):
|
||||||
|
"""Retrieve message history via fast gateway. Returns (history_list, error_str)."""
|
||||||
|
args = ["history", "--limit", str(limit)]
|
||||||
|
if thread_id and thread_id != "main":
|
||||||
|
args.extend(["--thread", str(thread_id)])
|
||||||
|
rc, stdout, stderr = run_muse_cli(node, args)
|
||||||
|
if rc != 0:
|
||||||
|
return None, stderr or stdout
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
return data, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"JSON parse error: {e}"
|
||||||
|
|
||||||
|
def send_message(node, text, thread_id=None, wait=0):
|
||||||
|
"""Send message via fast gateway. Returns (result_dict, error_str)."""
|
||||||
|
args = ["send"]
|
||||||
|
if thread_id and thread_id != "main":
|
||||||
|
args.extend(["--thread", str(thread_id)])
|
||||||
|
if wait > 0:
|
||||||
|
args.extend(["--wait", str(wait)])
|
||||||
|
args.append(text)
|
||||||
|
rc, stdout, stderr = run_muse_cli(node, args, timeout=max(30, wait + 15))
|
||||||
|
if rc != 0:
|
||||||
|
return None, stderr or stdout
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
return data, None
|
||||||
|
except Exception as e:
|
||||||
|
return {"raw": stdout}, None
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: muse_hybrid.py <node> <threads|history|send> [args...]")
|
||||||
|
sys.exit(1)
|
||||||
|
node = sys.argv[1]
|
||||||
|
action = sys.argv[2]
|
||||||
|
if action == "threads":
|
||||||
|
threads, err = get_threads(node)
|
||||||
|
if err:
|
||||||
|
print("Error:", err, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(json.dumps(threads, indent=2))
|
||||||
|
elif action == "history":
|
||||||
|
tid = sys.argv[3] if len(sys.argv) > 3 else None
|
||||||
|
msgs, err = get_history(node, tid)
|
||||||
|
if err:
|
||||||
|
print("Error:", err, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(json.dumps(msgs, indent=2))
|
||||||
|
elif action == "send":
|
||||||
|
txt = sys.argv[3]
|
||||||
|
tid = sys.argv[4] if len(sys.argv) > 4 else None
|
||||||
|
res, err = send_message(node, txt, tid)
|
||||||
|
if err:
|
||||||
|
print("Error:", err, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -29,10 +29,10 @@ fi
|
|||||||
# 94x0 sequence (9410 muse, 9420 pip, 9430 646, 9440 opm, ...).
|
# 94x0 sequence (9410 muse, 9420 pip, 9430 646, 9440 opm, ...).
|
||||||
# Scan the registry and live listeners; first free wins.
|
# Scan the registry and live listeners; first free wins.
|
||||||
used_ports() {
|
used_ports() {
|
||||||
{ grep -oP '^\|\s*\K94\d0(?=\s*\|)' "$NODES_MD" 2>/dev/null || true; \
|
{ awk -F'|' 'NF>=5 && $5 ~ /94[0-9]0/ {gsub(/ /,"",$5); print $5}' "$NODES_MD" 2>/dev/null || true; \
|
||||||
ss -tln 2>/dev/null | grep -oP ':\K94\d0\b' || true; } | sort -u
|
ss -tln 2>/dev/null | grep -oP ':\K94\d0\b' || true; } | sort -u
|
||||||
}
|
}
|
||||||
PORT=9450
|
PORT=9410
|
||||||
while used_ports | grep -qx "$PORT"; do PORT=$((PORT+10)); done
|
while used_ports | grep -qx "$PORT"; do PORT=$((PORT+10)); done
|
||||||
[ "$PORT" -gt 9600 ] && { echo "port pool exhausted" >&2; exit 1; }
|
[ "$PORT" -gt 9600 ] && { echo "port pool exhausted" >&2; exit 1; }
|
||||||
|
|
||||||
|
|||||||
+88
-9
@@ -8,15 +8,70 @@ Any .py script can use this to avoid hitting Muse rate limits:
|
|||||||
|
|
||||||
Uses a token bucket per agent stored in /tmp (persists across invocations,
|
Uses a token bucket per agent stored in /tmp (persists across invocations,
|
||||||
not across reboots). Default: 1 op per 3s sustained, burst of 5, max ~20/min.
|
not across reboots). Default: 1 op per 3s sustained, burst of 5, max ~20/min.
|
||||||
|
|
||||||
|
Jitter (2026-10-04): each agent gets a DETERMINISTIC per-agent jitter factor
|
||||||
|
derived from sha256(salt + agent), in [1-jitter, 1+jitter] (default
|
||||||
|
jitter=0.3, i.e. +/-30%). Rationale: all 4 fleet nodes egress from a single
|
||||||
|
Cloudflare IP, so Cloudflare sees correlated traffic. When fleet timers fire
|
||||||
|
simultaneously, per-agent jitter drifts each node's send phase apart instead
|
||||||
|
of hitting in lockstep. Deterministic per agent = reproducible behavior;
|
||||||
|
different agents = de-correlated phases. Does not make limits stricter: the
|
||||||
|
mean interval is unchanged.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
RATE_LIMIT_FILE = "/tmp/netvm-rate-limit.json"
|
RATE_LIMIT_FILE = "/tmp/netvm-rate-limit.json"
|
||||||
RATE_LIMIT_INTERVAL = 3.0 # seconds between ops
|
RATE_LIMIT_INTERVAL = 3.0 # seconds between ops (base; jittered per agent)
|
||||||
RATE_LIMIT_BURST = 5
|
RATE_LIMIT_BURST = 5
|
||||||
RATE_LIMIT_MAX_PER_MIN = 20
|
RATE_LIMIT_MAX_PER_MIN = 20
|
||||||
|
DEFAULT_JITTER = 0.3 # +/-30% deterministic per-agent interval jitter
|
||||||
|
|
||||||
|
JITTER_SEED_SALT = "netvm-rate-jitter:v1:"
|
||||||
|
|
||||||
|
|
||||||
|
def _jitter_factor(agent, jitter=DEFAULT_JITTER):
|
||||||
|
"""Deterministic per-agent multiplier in [1-jitter, 1+jitter].
|
||||||
|
|
||||||
|
Seeded by sha256(salt + agent): the same agent always gets the same
|
||||||
|
factor (reproducible), different agents get different factors
|
||||||
|
(de-correlated). Uses an isolated Random instance; global random
|
||||||
|
state is untouched.
|
||||||
|
"""
|
||||||
|
if jitter <= 0:
|
||||||
|
return 1.0
|
||||||
|
digest = hashlib.sha256((JITTER_SEED_SALT + agent).encode()).digest()
|
||||||
|
seed = int.from_bytes(digest[:8], "big")
|
||||||
|
rng = random.Random(seed)
|
||||||
|
return 1.0 + jitter * (rng.random() * 2.0 - 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_interval(agent, interval=RATE_LIMIT_INTERVAL,
|
||||||
|
jitter=DEFAULT_JITTER):
|
||||||
|
"""The actual minimum op spacing for this agent after jitter."""
|
||||||
|
return interval * _jitter_factor(agent, jitter)
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_policy(agent, interval=RATE_LIMIT_INTERVAL,
|
||||||
|
burst=RATE_LIMIT_BURST, jitter=DEFAULT_JITTER):
|
||||||
|
"""Return the effective rate-limit policy for an agent (audit/docs)."""
|
||||||
|
return {
|
||||||
|
"agent": agent,
|
||||||
|
"base_interval_s": interval,
|
||||||
|
"jitter": jitter,
|
||||||
|
"jitter_factor": round(_jitter_factor(agent, jitter), 4),
|
||||||
|
"effective_interval_s": round(
|
||||||
|
effective_interval(agent, interval, jitter), 3),
|
||||||
|
"burst": burst,
|
||||||
|
"max_per_min": RATE_LIMIT_MAX_PER_MIN,
|
||||||
|
"state_file": RATE_LIMIT_FILE,
|
||||||
|
"scope": ("per-agent buckets; state shared in one file, "
|
||||||
|
"keys namespaced by agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_state():
|
def _load_state():
|
||||||
try:
|
try:
|
||||||
@@ -25,6 +80,7 @@ def _load_state():
|
|||||||
except:
|
except:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _save_state(state):
|
def _save_state(state):
|
||||||
try:
|
try:
|
||||||
# Atomic write via temp file
|
# Atomic write via temp file
|
||||||
@@ -35,11 +91,17 @@ def _save_state(state):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def rate_limit_wait(agent, interval=RATE_LIMIT_INTERVAL, burst=RATE_LIMIT_BURST):
|
|
||||||
|
def rate_limit_wait(agent, interval=RATE_LIMIT_INTERVAL,
|
||||||
|
burst=RATE_LIMIT_BURST, jitter=DEFAULT_JITTER):
|
||||||
"""
|
"""
|
||||||
Block until the agent is allowed to perform an operation.
|
Block until the agent is allowed to perform an operation.
|
||||||
Returns the time waited in seconds (0 if no wait needed).
|
Returns the time waited in seconds (0 if no wait needed).
|
||||||
|
|
||||||
|
The interval is jittered deterministically per agent so fleet nodes
|
||||||
|
don't send in lockstep.
|
||||||
"""
|
"""
|
||||||
|
eff_interval = effective_interval(agent, interval, jitter)
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
@@ -58,11 +120,11 @@ def rate_limit_wait(agent, interval=RATE_LIMIT_INTERVAL, burst=RATE_LIMIT_BURST)
|
|||||||
recent = state.get(recent_key, [])
|
recent = state.get(recent_key, [])
|
||||||
recent = [t for t in recent if time.time() - t < 60]
|
recent = [t for t in recent if time.time() - t < 60]
|
||||||
|
|
||||||
# Check interval limit
|
# Check interval limit (jittered per agent)
|
||||||
last = state.get(agent, 0)
|
last = state.get(agent, 0)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last < interval:
|
if now - last < eff_interval:
|
||||||
wait = interval - (now - last)
|
wait = eff_interval - (now - last)
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
|
|
||||||
# Record this operation
|
# Record this operation
|
||||||
@@ -73,10 +135,13 @@ def rate_limit_wait(agent, interval=RATE_LIMIT_INTERVAL, burst=RATE_LIMIT_BURST)
|
|||||||
_save_state(state)
|
_save_state(state)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def rate_limit_check(agent):
|
|
||||||
|
def rate_limit_check(agent, interval=RATE_LIMIT_INTERVAL,
|
||||||
|
jitter=DEFAULT_JITTER):
|
||||||
"""
|
"""
|
||||||
Non-blocking check. Returns (allowed: bool, wait_seconds: float).
|
Non-blocking check. Returns (allowed: bool, wait_seconds: float).
|
||||||
"""
|
"""
|
||||||
|
eff_interval = effective_interval(agent, interval, jitter)
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
recent = state.get(f"{agent}_recent", [])
|
recent = state.get(f"{agent}_recent", [])
|
||||||
@@ -87,7 +152,21 @@ def rate_limit_check(agent):
|
|||||||
return False, max(0, wait)
|
return False, max(0, wait)
|
||||||
|
|
||||||
last = state.get(agent, 0)
|
last = state.get(agent, 0)
|
||||||
if now - last < RATE_LIMIT_INTERVAL:
|
if now - last < eff_interval:
|
||||||
return False, RATE_LIMIT_INTERVAL - (now - last)
|
return False, eff_interval - (now - last)
|
||||||
|
|
||||||
return True, 0
|
return True, 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Inspect NetVM per-agent rate-limit policy")
|
||||||
|
p.add_argument("--policy", nargs="*", default=["muse", "pip", "646", "opm"],
|
||||||
|
help="Show effective policy per agent")
|
||||||
|
p.add_argument("--no-jitter", action="store_true",
|
||||||
|
help="Show policy without jitter")
|
||||||
|
args = p.parse_args()
|
||||||
|
j = 0.0 if args.no_jitter else DEFAULT_JITTER
|
||||||
|
for a in args.policy:
|
||||||
|
print(json.dumps(rate_limit_policy(a, jitter=j), indent=2))
|
||||||
|
|||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
refresh-node-cookies.py: Extract fresh cookies from a running node's Chromium CDP
|
||||||
|
instance inside its isolated network namespace and save to ~/.config/muse-cli/<node>/cookies.txt.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NODES = {
|
||||||
|
"muse": 9410,
|
||||||
|
"pip": 9420,
|
||||||
|
"646": 9430,
|
||||||
|
"opm": 9440,
|
||||||
|
"def": 9450,
|
||||||
|
}
|
||||||
|
|
||||||
|
def export_cookies(node):
|
||||||
|
port = NODES.get(node)
|
||||||
|
if not port:
|
||||||
|
print(f"Unknown node: {node}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
conf_dir = Path.home() / ".config" / "muse-cli" / node
|
||||||
|
conf_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cookie_file = conf_dir / "cookies.txt"
|
||||||
|
cfg_file = conf_dir / "config.json"
|
||||||
|
|
||||||
|
# We import websocket inside netns execution
|
||||||
|
try:
|
||||||
|
import websocket
|
||||||
|
except ImportError:
|
||||||
|
print("websocket-client not installed", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=3)
|
||||||
|
tabs = json.loads(req.read().decode())
|
||||||
|
if not tabs:
|
||||||
|
print(f"No tabs found on port {port}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
ws_url = tabs[0]["webSocketDebuggerUrl"]
|
||||||
|
ws = websocket.create_connection(ws_url, timeout=5)
|
||||||
|
ws.send(json.dumps({
|
||||||
|
"id": 1,
|
||||||
|
"method": "Network.getCookies",
|
||||||
|
"params": {"urls": ["https://muse.ai"]}
|
||||||
|
}))
|
||||||
|
res = json.loads(ws.recv())
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
cookies = res.get("result", {}).get("cookies", [])
|
||||||
|
if not cookies:
|
||||||
|
print(f"No cookies returned from CDP for {node}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
lines = [f"{c['name']}={c['value']}" for c in cookies]
|
||||||
|
cookie_file.write_text("; ".join(lines) + "\n")
|
||||||
|
cookie_file.chmod(0o600)
|
||||||
|
|
||||||
|
cfg_data = {}
|
||||||
|
if cfg_file.exists():
|
||||||
|
try:
|
||||||
|
cfg_data = json.loads(cfg_file.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cfg_data["cookies_file"] = str(cookie_file)
|
||||||
|
cfg_file.write_text(json.dumps(cfg_data, indent=2))
|
||||||
|
cfg_file.chmod(0o600)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to export cookies for {node}: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: refresh-node-cookies.py <node>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
success = export_cookies(sys.argv[1])
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
+26
-6
@@ -42,6 +42,8 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
BASE = "/home/super/Projects/NetVM"
|
BASE = "/home/super/Projects/NetVM"
|
||||||
BIN = os.path.join(BASE, "bin")
|
BIN = os.path.join(BASE, "bin")
|
||||||
|
if BIN not in sys.path:
|
||||||
|
sys.path.insert(0, BIN)
|
||||||
BOX_CHAT = os.path.join(BIN, "box-chat.py")
|
BOX_CHAT = os.path.join(BIN, "box-chat.py")
|
||||||
DM_PY = os.path.join(BIN, "dm.py")
|
DM_PY = os.path.join(BIN, "dm.py")
|
||||||
STATE_FILE = os.path.join(BASE, "self-main-loop-watermark.json")
|
STATE_FILE = os.path.join(BASE, "self-main-loop-watermark.json")
|
||||||
@@ -132,7 +134,25 @@ def get_config(st):
|
|||||||
|
|
||||||
|
|
||||||
def read_main_chat(agent, limit):
|
def read_main_chat(agent, limit):
|
||||||
"""Read agent's muse.ai Main chat via box-chat.py. Returns (ok, messages|error)."""
|
"""Read agent's muse.ai Main chat. Tries fast headless gateway (muse_hybrid) first, falling back to box-chat.py."""
|
||||||
|
# Fast path: muse_hybrid over WebSocket Noise frame inside isolated netns
|
||||||
|
try:
|
||||||
|
import muse_hybrid
|
||||||
|
msgs, err = muse_hybrid.get_history(agent, thread_id=None, limit=limit)
|
||||||
|
if msgs and not err:
|
||||||
|
formatted = []
|
||||||
|
for m in msgs:
|
||||||
|
formatted.append({
|
||||||
|
"id": m.get("message_id") or f"msg-{m.get('seq')}",
|
||||||
|
"ts": None,
|
||||||
|
"from": {"name": m.get("role", "unknown")},
|
||||||
|
"text": m.get("text", "")
|
||||||
|
})
|
||||||
|
return True, formatted
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to box-chat.py / CDP if gateway fails or returns empty
|
||||||
cmd = [sys.executable, BOX_CHAT, "thread-messages", agent, "main",
|
cmd = [sys.executable, BOX_CHAT, "thread-messages", agent, "main",
|
||||||
"--limit", str(limit)]
|
"--limit", str(limit)]
|
||||||
try:
|
try:
|
||||||
@@ -172,16 +192,16 @@ def author_of(m):
|
|||||||
|
|
||||||
|
|
||||||
def compose_digest(agent, new):
|
def compose_digest(agent, new):
|
||||||
"""Concise, actionable digest for the prompting sidechat."""
|
"""Concise, actionable digest for the prompting sidechat without synthetic ceremony."""
|
||||||
lines = ["[main-loop] %d new in %s main chat:" % (len(new), agent)]
|
lines = ["New messages in %s main chat (%d):" % (agent, len(new))]
|
||||||
for m in new[:5]:
|
for m in new[:5]:
|
||||||
text = (m.get("text") or "").strip().replace("\n", " ")
|
text = (m.get("text") or "").strip().replace("\n", " ")
|
||||||
q = " [?]" if "?" in text else ""
|
q = " (?)" if "?" in text else ""
|
||||||
hot = " [!]" if (OPERATOR_RE.search(text) or "urgent" in text.lower()) else ""
|
hot = " (!)" if (OPERATOR_RE.search(text) or "urgent" in text.lower()) else ""
|
||||||
lines.append("- %s: %s%s%s" % (author_of(m), text[:PREVIEW_MAX], q, hot))
|
lines.append("- %s: %s%s%s" % (author_of(m), text[:PREVIEW_MAX], q, hot))
|
||||||
if len(new) > 5:
|
if len(new) > 5:
|
||||||
lines.append("(+%d more)" % (len(new) - 5))
|
lines.append("(+%d more)" % (len(new) - 5))
|
||||||
lines.append("Check main chat via box when you can.")
|
lines.append("Check main chat via box when available.")
|
||||||
digest = "\n".join(lines)
|
digest = "\n".join(lines)
|
||||||
return digest[:DIGEST_MAX]
|
return digest[:DIGEST_MAX]
|
||||||
|
|
||||||
|
|||||||
Executable
+256
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Siphon bl wiring — connects siphon/monitor.py to bl's chat infrastructure.
|
||||||
|
|
||||||
|
Implements the three injectable functions:
|
||||||
|
- list_sidechats(): threads to monitor (from state files)
|
||||||
|
- get_messages(thread_id, since): read via muse-chat-api.py
|
||||||
|
- post_to_main(text): send to opm's main chat via muse-chat-api.py
|
||||||
|
|
||||||
|
Run via systemd timer every 60s, or manually:
|
||||||
|
python3 siphon-bl.py --once
|
||||||
|
python3 siphon-bl.py --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Add bin dir to path for siphon imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from monitor import monitor_once
|
||||||
|
from siphon import RateLimiter
|
||||||
|
|
||||||
|
NETVM_BIN = "/home/super/Projects/NetVM/bin"
|
||||||
|
CHAT_API = os.path.join(NETVM_BIN, "muse-chat-api.py")
|
||||||
|
|
||||||
|
# State files that map reuse_key -> thread UUID
|
||||||
|
STATE_FILES = [
|
||||||
|
"/home/super/Projects/NetVM/job-sidechats.json",
|
||||||
|
"/home/super/sidechat-wake/wake-sidechats.json",
|
||||||
|
"/home/super/Projects/NetVM/keepalive-threads.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
WATERMARKS_FILE = "/home/super/Projects/NetVM/siphon-watermarks.json"
|
||||||
|
LOG_FILE = "/home/super/Projects/NetVM/siphon-bl.log"
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
line = f"[{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}] {msg}"
|
||||||
|
print(line, flush=True)
|
||||||
|
try:
|
||||||
|
with open(LOG_FILE, "a") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run_chat_api(agent, *args, timeout=60):
|
||||||
|
"""Run muse-chat-api.py and return stdout."""
|
||||||
|
cmd = [sys.executable, CHAT_API, "--account", agent] + list(args)
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=timeout
|
||||||
|
)
|
||||||
|
return result.stdout.strip(), result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def list_sidechats():
|
||||||
|
"""
|
||||||
|
Build list of threads to monitor from state files.
|
||||||
|
Returns: [{"id": thread_uuid, "name": reuse_key, "agent": agent}]
|
||||||
|
"""
|
||||||
|
chats = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for sf in STATE_FILES:
|
||||||
|
try:
|
||||||
|
with open(sf) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, val in state.items():
|
||||||
|
# Handle different state formats
|
||||||
|
if isinstance(val, dict):
|
||||||
|
uuid = val.get("thread_uuid") or val.get("uuid")
|
||||||
|
agent = val.get("agent", "opm")
|
||||||
|
elif isinstance(val, str):
|
||||||
|
uuid = val
|
||||||
|
agent = "opm"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if uuid and uuid not in seen:
|
||||||
|
seen.add(uuid)
|
||||||
|
chats.append({
|
||||||
|
"id": uuid,
|
||||||
|
"name": key,
|
||||||
|
"agent": agent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return chats
|
||||||
|
|
||||||
|
|
||||||
|
def get_messages(thread_id, since_msg_id):
|
||||||
|
"""
|
||||||
|
Get messages from a thread newer than since_msg_id.
|
||||||
|
Uses muse-chat-api.py: sidechat use <uuid>, then messages.
|
||||||
|
Returns: [{"id": str, "text": str, "author": str, "ts": str}]
|
||||||
|
"""
|
||||||
|
# Find which agent owns this thread
|
||||||
|
agent = "opm" # default
|
||||||
|
for chat in list_sidechats():
|
||||||
|
if chat["id"] == thread_id:
|
||||||
|
agent = chat["agent"]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Navigate to the thread
|
||||||
|
out, rc = run_chat_api(agent, "sidechat", "use", thread_id, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get messages
|
||||||
|
out, rc = run_chat_api(agent, "messages", timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse messages — format is "---" separated
|
||||||
|
messages = []
|
||||||
|
# Use timestamp + hash as message ID (no stable IDs from the API)
|
||||||
|
for i, chunk in enumerate(out.split("\n---\n")):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
if not chunk or chunk == "Ok":
|
||||||
|
continue
|
||||||
|
# Create a stable-ish ID from content hash
|
||||||
|
import hashlib
|
||||||
|
mid = hashlib.md5(chunk.encode()).hexdigest()[:12]
|
||||||
|
# Skip if we've seen this (watermark comparison)
|
||||||
|
if since_msg_id and mid <= since_msg_id:
|
||||||
|
continue
|
||||||
|
messages.append({
|
||||||
|
"id": mid,
|
||||||
|
"text": chunk[:2000], # truncate long messages
|
||||||
|
"author": agent,
|
||||||
|
"ts": str(time.time()),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return to main chat
|
||||||
|
run_chat_api(agent, "sidechat", "main", timeout=15)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_main(text):
|
||||||
|
"""
|
||||||
|
Post siphoned summary to opm's main chat.
|
||||||
|
Returns True on success.
|
||||||
|
|
||||||
|
Tracked hits (text contains [reply:expected]) route via dm.py
|
||||||
|
--expect-reply --thread so a dm_followup record is created.
|
||||||
|
Untracked hits use the direct API path.
|
||||||
|
"""
|
||||||
|
# Tracked? Look for the follow-up tag the modulated siphon appends.
|
||||||
|
if "[reply:expected]" in text:
|
||||||
|
# Extract source thread UUID from the thread URL in the text.
|
||||||
|
m = re.search(r"https://muse\.ai/thread/([a-f0-9-]{36})", text)
|
||||||
|
thread_id = m.group(1) if m else None
|
||||||
|
if thread_id:
|
||||||
|
return post_to_main_tracked(text, thread_id)
|
||||||
|
# Tracked but no thread URL: fall through to direct (fail-open
|
||||||
|
# toward visibility).
|
||||||
|
log("tracked siphon hit without thread URL, using direct post")
|
||||||
|
# Untracked (or fallback): direct API send to main chat.
|
||||||
|
run_chat_api("opm", "sidechat", "main", timeout=15)
|
||||||
|
out, rc = run_chat_api("opm", "send", text, timeout=60)
|
||||||
|
return rc == 0
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_main_tracked(text, thread_id):
|
||||||
|
"""
|
||||||
|
Post a tracked siphon hit via dm.py so a dm_followup record is
|
||||||
|
created. Returns True on success.
|
||||||
|
"""
|
||||||
|
import shlex
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
os.path.join(NETVM_BIN, "dm.py"),
|
||||||
|
"send",
|
||||||
|
"--agent", "opm",
|
||||||
|
"--to", "opm",
|
||||||
|
"--target", "main",
|
||||||
|
"--expect-reply",
|
||||||
|
"--thread", thread_id,
|
||||||
|
text,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=120
|
||||||
|
)
|
||||||
|
ok = "SENT and VERIFIED" in (result.stdout or "")
|
||||||
|
if not ok:
|
||||||
|
log(f"dm.py tracked post failed: {(result.stdout or '')[:200]}")
|
||||||
|
return ok
|
||||||
|
except Exception as e:
|
||||||
|
log(f"dm.py tracked post exception: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_watermarks():
|
||||||
|
try:
|
||||||
|
with open(WATERMARKS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_watermarks(marks):
|
||||||
|
tmp = WATERMARKS_FILE + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(marks, f)
|
||||||
|
os.replace(tmp, WATERMARKS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--once", action="store_true",
|
||||||
|
help="Run one poll cycle and exit")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Don't post, just show what would be siphoned")
|
||||||
|
parser.add_argument("--min-confidence", type=float, default=0.6)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
# Dry run: show what would be detected without posting
|
||||||
|
def dry_post(text):
|
||||||
|
print(f"[DRY] would post to main:\n{text}\n")
|
||||||
|
return True
|
||||||
|
post_fn = dry_post
|
||||||
|
else:
|
||||||
|
post_fn = post_to_main
|
||||||
|
|
||||||
|
watermarks = load_watermarks()
|
||||||
|
limiter = RateLimiter()
|
||||||
|
|
||||||
|
chats = list_sidechats()
|
||||||
|
log(f"Monitoring {len(chats)} threads")
|
||||||
|
|
||||||
|
new_marks = monitor_once(
|
||||||
|
list_sidechats,
|
||||||
|
get_messages,
|
||||||
|
post_fn,
|
||||||
|
watermarks,
|
||||||
|
limiter,
|
||||||
|
min_confidence=args.min_confidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
save_watermarks(new_marks)
|
||||||
|
log(f"Cycle complete. Watermarks: {len(new_marks)} threads tracked.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Side-chat to main-chat work siphon — siphon action.
|
||||||
|
|
||||||
|
When detection fires, post a summary to main chat with:
|
||||||
|
- Category badge
|
||||||
|
- One-line summary (never full message text)
|
||||||
|
- Link back to the source side chat thread
|
||||||
|
- Confidence score (for transparency)
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- Rate limited (max N siphons per hour per thread)
|
||||||
|
- Never posts full message content
|
||||||
|
- Respects opt-out registry
|
||||||
|
- Deduplicates (same message_id never siphoned twice)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from detect import SiphonHit, is_opted_out
|
||||||
|
|
||||||
|
|
||||||
|
# --- Follow-up modulation ---
|
||||||
|
#
|
||||||
|
# Wire the follow-up modulation table into the siphon so each hit gets
|
||||||
|
# the right follow-up policy:
|
||||||
|
# ALERT / BLOCKER / DECISION -> tracked, fast fuse for ALERT/BLOCKER
|
||||||
|
# COMPLETED / MILESTONE -> untracked (no nudge budget burned)
|
||||||
|
#
|
||||||
|
# modulate.py must be landed on bl before this runs (rollout step 1).
|
||||||
|
# If the import fails we degrade to the old behavior: post the summary
|
||||||
|
# with no follow-up tags (fail-closed toward visibility, not tracking).
|
||||||
|
try:
|
||||||
|
from modulate import for_siphon_hit, render_tags
|
||||||
|
_MODULATION_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover - deploy keeps modulate.py present
|
||||||
|
_MODULATION_AVAILABLE = False
|
||||||
|
for_siphon_hit = None
|
||||||
|
render_tags = None
|
||||||
|
|
||||||
|
|
||||||
|
def policy_for_hit(hit: SiphonHit):
|
||||||
|
"""Follow-up policy for a siphon hit, or None when untracked.
|
||||||
|
|
||||||
|
COMPLETED / MILESTONE hits return None (post the summary, create no
|
||||||
|
follow-up record). ALERT / BLOCKER / DECISION return a Policy whose
|
||||||
|
tags render into the canonical bracket vocabulary.
|
||||||
|
"""
|
||||||
|
if not _MODULATION_AVAILABLE:
|
||||||
|
return None
|
||||||
|
return for_siphon_hit(hit.category)
|
||||||
|
|
||||||
|
|
||||||
|
def is_tracked(hit: SiphonHit) -> bool:
|
||||||
|
"""True when this hit should create a follow-up record.
|
||||||
|
|
||||||
|
Callers that route tracked posts through dm.py --expect-reply (so a
|
||||||
|
dm_followup record is actually created) can use this to choose the
|
||||||
|
post path. Untracked hits post as plain summaries.
|
||||||
|
"""
|
||||||
|
return policy_for_hit(hit) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rate limiting ---
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimiter:
|
||||||
|
max_per_hour: int = 5
|
||||||
|
_timestamps: dict = field(default_factory=dict) # thread_id -> [ts, ...]
|
||||||
|
|
||||||
|
def allow(self, thread_id: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
stamps = self._timestamps.get(thread_id, [])
|
||||||
|
# Prune older than 1 hour
|
||||||
|
stamps = [s for s in stamps if now - s < 3600]
|
||||||
|
if len(stamps) >= self.max_per_hour:
|
||||||
|
return False
|
||||||
|
stamps.append(now)
|
||||||
|
self._timestamps[thread_id] = stamps
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Deduplication ---
|
||||||
|
|
||||||
|
_siphoned_ids: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def already_siphoned(message_id: str) -> bool:
|
||||||
|
return message_id in _siphoned_ids
|
||||||
|
|
||||||
|
|
||||||
|
def mark_siphoned(message_id: str):
|
||||||
|
_siphoned_ids.add(message_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Siphon action ---
|
||||||
|
|
||||||
|
CATEGORY_EMOJI = {
|
||||||
|
"COMPLETED": "✅",
|
||||||
|
"BLOCKER": "🚧",
|
||||||
|
"DECISION": "❓",
|
||||||
|
"ALERT": "🚨",
|
||||||
|
"MILESTONE": "🎯",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_siphon(hit: SiphonHit, agent_name: str = "sidechat") -> str:
|
||||||
|
"""
|
||||||
|
Format a siphon message for main chat.
|
||||||
|
Never includes full message text — summary + link only.
|
||||||
|
"""
|
||||||
|
emoji = CATEGORY_EMOJI.get(hit.category, "📋")
|
||||||
|
thread_url = f"https://muse.ai/thread/{hit.thread_id}"
|
||||||
|
return (
|
||||||
|
f"{emoji} [{hit.category}] from {agent_name} side chat\n"
|
||||||
|
f"{hit.summary}\n"
|
||||||
|
f"→ {thread_url}\n"
|
||||||
|
f"(confidence {hit.confidence:.0%})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def siphon(hit: SiphonHit,
|
||||||
|
agent_name: str,
|
||||||
|
post_to_main: Callable[[str], bool],
|
||||||
|
limiter: Optional[RateLimiter] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Execute the siphon: post summary to main chat.
|
||||||
|
|
||||||
|
post_to_main: callable that posts text to main chat, returns True on success.
|
||||||
|
Returns True if siphoned, False if suppressed.
|
||||||
|
"""
|
||||||
|
# Safety checks
|
||||||
|
if is_opted_out(hit.thread_id):
|
||||||
|
return False
|
||||||
|
if already_siphoned(hit.message_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
lim = limiter or RateLimiter()
|
||||||
|
if not lim.allow(hit.thread_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = format_siphon(hit, agent_name)
|
||||||
|
|
||||||
|
# Follow-up modulation: tracked hits (ALERT/BLOCKER/DECISION) get
|
||||||
|
# the canonical follow-up tags appended — [reply:expected],
|
||||||
|
# [reply:timeout=N], [reply:nudges=N], [reply:escalate=X], and
|
||||||
|
# [input:siphon] for the audit trail. Untracked hits
|
||||||
|
# (COMPLETED/MILESTONE) post as plain summaries.
|
||||||
|
policy = policy_for_hit(hit)
|
||||||
|
if policy is not None:
|
||||||
|
text = text + "\n" + render_tags(policy)
|
||||||
|
|
||||||
|
ok = post_to_main(text)
|
||||||
|
if ok:
|
||||||
|
mark_siphoned(hit.message_id)
|
||||||
|
return ok
|
||||||
+54
-17
@@ -1174,24 +1174,38 @@ def cmd_dm_files(args):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def cmd_thread_list(args):
|
def cmd_thread_list(args):
|
||||||
agent = args.agent
|
agent = args.agent
|
||||||
|
threads = []
|
||||||
|
|
||||||
|
# Fast path: try fast headless gateway via muse_hybrid (isolated per-node WARP egress)
|
||||||
|
try:
|
||||||
|
import muse_hybrid
|
||||||
|
gw_threads, gw_err = muse_hybrid.get_threads(agent)
|
||||||
|
if gw_threads and not gw_err:
|
||||||
|
for t in gw_threads:
|
||||||
|
threads.append({
|
||||||
|
"id": t.get("session_id", ""),
|
||||||
|
"kind": "thread" if t.get("thread") else "chat",
|
||||||
|
"title": t.get("title") or "(no title)",
|
||||||
|
"participants": [agent],
|
||||||
|
"last_message_at": t.get("updated", "")
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to box-chat.py / CDP if gateway returned no threads
|
||||||
|
if not threads:
|
||||||
cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-list", agent]
|
cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-list", agent]
|
||||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(res.stdout)
|
data = json.loads(res.stdout)
|
||||||
|
threads = data.get("threads", []) if data.get("ok") else []
|
||||||
except Exception:
|
except Exception:
|
||||||
print(c_red("Failed to parse box-chat.py response:") + f"\n{res.stdout}\n{res.stderr}")
|
threads = []
|
||||||
return
|
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(data, indent=2))
|
print(json.dumps({"ok": True, "threads": threads}, indent=2))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not data.get("ok"):
|
|
||||||
print(c_red(f"Error fetching threads for {agent}: {data.get('error', 'unknown error')}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
threads = data.get("threads", [])
|
|
||||||
print("\n" + c_bold(f"=== ACTIVE THREADS FOR {agent.upper()} ({len(threads)}) ===") + "\n")
|
print("\n" + c_bold(f"=== ACTIVE THREADS FOR {agent.upper()} ({len(threads)}) ===") + "\n")
|
||||||
|
|
||||||
headers = ["ID / UUID", "KIND", "TITLE", "PARTICIPANTS", "LAST ACTIVE"]
|
headers = ["ID / UUID", "KIND", "TITLE", "PARTICIPANTS", "LAST ACTIVE"]
|
||||||
@@ -1213,25 +1227,39 @@ def cmd_thread_view(args):
|
|||||||
agent = args.agent
|
agent = args.agent
|
||||||
thread_id = args.thread_id
|
thread_id = args.thread_id
|
||||||
limit = getattr(args, "limit", 15)
|
limit = getattr(args, "limit", 15)
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# Fast path: try fast headless gateway via muse_hybrid (isolated per-node WARP egress)
|
||||||
|
try:
|
||||||
|
import muse_hybrid
|
||||||
|
gw_msgs, gw_err = muse_hybrid.get_history(agent, thread_id=thread_id, limit=limit)
|
||||||
|
if gw_msgs and not gw_err:
|
||||||
|
for m in gw_msgs:
|
||||||
|
role = m.get("role", "unknown")
|
||||||
|
messages.append({
|
||||||
|
"from": {"name": role, "role": role},
|
||||||
|
"text": m.get("text", "")
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to box-chat.py / CDP if gateway returned no messages
|
||||||
|
if not messages:
|
||||||
cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-messages", agent, thread_id, "--limit", str(limit)]
|
cmd = ["python3", str(BIN_DIR / "box-chat.py"), "thread-messages", agent, thread_id, "--limit", str(limit)]
|
||||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(res.stdout)
|
data = json.loads(res.stdout)
|
||||||
|
messages = data.get("messages", []) if data.get("ok") else []
|
||||||
except Exception:
|
except Exception:
|
||||||
print(c_red("Failed to parse box-chat.py response:") + f"\n{res.stdout}\n{res.stderr}")
|
messages = []
|
||||||
|
if not messages:
|
||||||
|
print(c_red(f"Error viewing thread {thread_id}: no messages returned"))
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(data, indent=2))
|
print(json.dumps({"ok": True, "messages": messages}, indent=2))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not data.get("ok"):
|
|
||||||
print(c_red(f"Error viewing thread {thread_id}: {data.get('error', 'unknown error')}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
messages = data.get("messages", [])
|
|
||||||
print("\n" + c_bold(f"=== THREAD {thread_id} ({agent}) — {len(messages)} messages ===") + "\n")
|
print("\n" + c_bold(f"=== THREAD {thread_id} ({agent}) — {len(messages)} messages ===") + "\n")
|
||||||
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
@@ -3280,6 +3308,11 @@ def build_parser():
|
|||||||
p_va_rb.add_argument("name", help="Variable name")
|
p_va_rb.add_argument("name", help="Variable name")
|
||||||
p_va_rb.add_argument("--revision", default=None, help="Revision step (int) or timestamp")
|
p_va_rb.add_argument("--revision", default=None, help="Revision step (int) or timestamp")
|
||||||
|
|
||||||
|
# Domain: muse (fast headless gateway via muse-cli-node with isolated per-node Cloudflare WARP egress)
|
||||||
|
p_muse = subparsers.add_parser("muse", parents=[common], help="Direct headless gateway client (muse-cli-node)")
|
||||||
|
p_muse.add_argument("node", choices=VALID_NODES, help="Target agent node")
|
||||||
|
p_muse.add_argument("muse_args", nargs=argparse.REMAINDER, help="Arguments passed directly to muse-cli-node")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -3446,6 +3479,10 @@ def main():
|
|||||||
cmd_loop_strat(args)
|
cmd_loop_strat(args)
|
||||||
elif args.domain == "vars":
|
elif args.domain == "vars":
|
||||||
cmd_loop_vars(args)
|
cmd_loop_vars(args)
|
||||||
|
elif args.domain == "muse":
|
||||||
|
cmd = [str(BIN_DIR / "muse-cli-node"), args.node] + (args.muse_args or [])
|
||||||
|
res = subprocess.run(cmd)
|
||||||
|
sys.exit(res.returncode)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Thread lifecycle manager for side chat rotation.
|
||||||
|
|
||||||
|
Evaluates rotation triggers (age, message count, explicit flag) for a reuse_key
|
||||||
|
and performs rotation when triggered. Integrates with:
|
||||||
|
- job-sidechats.json (state + lifecycle policy)
|
||||||
|
- sidechat_manager.py (thread operations)
|
||||||
|
- box follow-up system (thread_id migration)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 thread_lifecycle.py check --reuse-key <key> [--state PATH]
|
||||||
|
python3 thread_lifecycle.py rotate --reuse-key <key> --reason <reason> [--state PATH]
|
||||||
|
|
||||||
|
The `check` command is designed to run at job dispatch time, before the reuse
|
||||||
|
check in job-dispatch.py. It returns:
|
||||||
|
exit 0: no rotation needed, reuse existing thread
|
||||||
|
exit 2: rotation triggered, new thread needed (caller should create fresh)
|
||||||
|
exit 1: error
|
||||||
|
|
||||||
|
This is a prototype. Not deployed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# Default lifecycle policy (applied when no per-key policy exists)
|
||||||
|
DEFAULT_POLICY = {
|
||||||
|
"max_age_hours": 168, # 7 days
|
||||||
|
"max_messages": 200,
|
||||||
|
"channel_type": "job",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Channel-type defaults (override DEFAULT_POLICY)
|
||||||
|
CHANNEL_DEFAULTS = {
|
||||||
|
"heartbeat": {"max_age_hours": 24, "max_messages": 500, "channel_type": "heartbeat"},
|
||||||
|
"job": {"max_age_hours": 168, "max_messages": 200, "channel_type": "job"},
|
||||||
|
"coordination": {"max_age_hours": 720, "max_messages": 200, "channel_type": "coordination"},
|
||||||
|
"dm": {"max_age_hours": None, "max_messages": None, "channel_type": "dm"}, # never rotate
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts_str):
|
||||||
|
"""Parse ISO-8601 UTC timestamp. Returns datetime or None."""
|
||||||
|
if not ts_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Handle both "2026-10-04T12:00:00Z" and "... +00:00" forms
|
||||||
|
ts_str = ts_str.replace("Z", "+00:00")
|
||||||
|
dt = datetime.fromisoformat(ts_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(state_path):
|
||||||
|
"""Load job-sidechats.json. Returns dict."""
|
||||||
|
if not os.path.exists(state_path):
|
||||||
|
return {}
|
||||||
|
with open(state_path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state_path, state):
|
||||||
|
"""Save state atomically (write temp + rename)."""
|
||||||
|
tmp = state_path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
os.rename(tmp, state_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_policy(state, reuse_key):
|
||||||
|
"""
|
||||||
|
Get lifecycle policy for a reuse_key.
|
||||||
|
Priority: per-key policy > channel-type default > global default.
|
||||||
|
Returns dict with max_age_hours, max_messages, channel_type.
|
||||||
|
"""
|
||||||
|
lifecycle = state.get("_lifecycle", {})
|
||||||
|
defaults = state.get("_lifecycle_defaults", {})
|
||||||
|
|
||||||
|
# Start with global defaults
|
||||||
|
policy = dict(DEFAULT_POLICY)
|
||||||
|
policy.update(defaults)
|
||||||
|
|
||||||
|
# Apply channel-type defaults if specified
|
||||||
|
key_policy = lifecycle.get(reuse_key, {})
|
||||||
|
channel_type = key_policy.get("channel_type")
|
||||||
|
if channel_type and channel_type in CHANNEL_DEFAULTS:
|
||||||
|
policy.update(CHANNEL_DEFAULTS[channel_type])
|
||||||
|
|
||||||
|
# Per-key overrides win
|
||||||
|
policy.update(key_policy)
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
def get_thread_age_hours(state, reuse_key):
|
||||||
|
"""
|
||||||
|
Get thread age in hours from creation.
|
||||||
|
Returns None if unknown (fail-open: age trigger skipped).
|
||||||
|
"""
|
||||||
|
# Prefer explicit creation timestamp
|
||||||
|
created_key = f"{reuse_key}:created_at"
|
||||||
|
created = _parse_ts(state.get(created_key))
|
||||||
|
if created:
|
||||||
|
delta = _utcnow() - created
|
||||||
|
return delta.total_seconds() / 3600
|
||||||
|
|
||||||
|
# Fall back to last rotation timestamp
|
||||||
|
rotated = _parse_ts(state.get(f"{reuse_key}:rotated_at"))
|
||||||
|
if rotated:
|
||||||
|
delta = _utcnow() - rotated
|
||||||
|
return delta.total_seconds() / 3600
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_rotation_needed(state, reuse_key, message_count=None, force=False):
|
||||||
|
"""
|
||||||
|
Evaluate rotation triggers for a reuse_key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: loaded state dict
|
||||||
|
reuse_key: the reuse key to check
|
||||||
|
message_count: current message count (None = unknown, skip count trigger)
|
||||||
|
force: explicit rotation request (topic change / manual)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(needed: bool, reason: str, details: dict)
|
||||||
|
"""
|
||||||
|
policy = get_policy(state, reuse_key)
|
||||||
|
|
||||||
|
# DM channels never rotate
|
||||||
|
if policy.get("channel_type") == "dm":
|
||||||
|
return False, "dm_no_rotate", {}
|
||||||
|
|
||||||
|
# Explicit force always wins
|
||||||
|
if force:
|
||||||
|
return True, "explicit", {"requested": True}
|
||||||
|
|
||||||
|
# Age trigger
|
||||||
|
max_age = policy.get("max_age_hours")
|
||||||
|
if max_age is not None:
|
||||||
|
age_hours = get_thread_age_hours(state, reuse_key)
|
||||||
|
if age_hours is not None and age_hours >= max_age:
|
||||||
|
return True, "age", {
|
||||||
|
"age_hours": round(age_hours, 1),
|
||||||
|
"max_age_hours": max_age,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Message count trigger
|
||||||
|
max_msg = policy.get("max_messages")
|
||||||
|
if max_msg is not None and message_count is not None:
|
||||||
|
if message_count >= max_msg:
|
||||||
|
return True, "message_count", {
|
||||||
|
"message_count": message_count,
|
||||||
|
"max_messages": max_msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
return False, "none", {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thread_for_nudge(state, thread_id):
|
||||||
|
"""
|
||||||
|
Resolve a thread_id for nudge delivery, handling rotation.
|
||||||
|
|
||||||
|
If thread_id points to an archived/rotated thread, follow the
|
||||||
|
:previous chain to find the current UUID.
|
||||||
|
|
||||||
|
Returns the UUID to use (may be the input if no rotation found).
|
||||||
|
"""
|
||||||
|
# Build reverse map: old_uuid -> reuse_key
|
||||||
|
for key, value in state.items():
|
||||||
|
if key.startswith("_") or ":" in key:
|
||||||
|
continue
|
||||||
|
prev_key = f"{key}:previous"
|
||||||
|
if state.get(prev_key) == thread_id:
|
||||||
|
# This thread was rotated; use the current UUID
|
||||||
|
current = state.get(key)
|
||||||
|
if current and current != thread_id:
|
||||||
|
return current
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_rotation(state, reuse_key, old_uuid, new_uuid, reason):
|
||||||
|
"""
|
||||||
|
Update state after rotation. Returns updated state dict.
|
||||||
|
Does not save — caller saves.
|
||||||
|
"""
|
||||||
|
now = _utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
prev_count = state.get(f"{reuse_key}:rotation_count", 0)
|
||||||
|
|
||||||
|
state[f"{reuse_key}:previous"] = old_uuid
|
||||||
|
state[reuse_key] = new_uuid
|
||||||
|
state[f"{reuse_key}:rotated_at"] = now
|
||||||
|
state[f"{reuse_key}:created_at"] = now # new thread creation baseline
|
||||||
|
state[f"{reuse_key}:rotation_reason"] = reason
|
||||||
|
state[f"{reuse_key}:rotation_count"] = prev_count + 1
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_check(args):
|
||||||
|
"""Check if rotation is needed. Exit 0=no, 2=yes, 1=error."""
|
||||||
|
state = load_state(args.state)
|
||||||
|
reuse_key = args.reuse_key
|
||||||
|
|
||||||
|
if reuse_key not in state:
|
||||||
|
print(f"No existing thread for {reuse_key}, no rotation check needed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
needed, reason, details = check_rotation_needed(
|
||||||
|
state, reuse_key,
|
||||||
|
message_count=args.messages,
|
||||||
|
force=args.force,
|
||||||
|
)
|
||||||
|
if needed:
|
||||||
|
print(json.dumps({"rotate": True, "reason": reason, "details": details}))
|
||||||
|
return 2
|
||||||
|
else:
|
||||||
|
print(json.dumps({"rotate": False, "reason": reason}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_rotate(args):
|
||||||
|
"""Record a rotation (called after new thread is created)."""
|
||||||
|
state = load_state(args.state)
|
||||||
|
reuse_key = args.reuse_key
|
||||||
|
|
||||||
|
old_uuid = state.get(reuse_key)
|
||||||
|
if not old_uuid:
|
||||||
|
print(f"No existing thread for {reuse_key}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not args.new_uuid:
|
||||||
|
print("--new-uuid required", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
state = record_rotation(state, reuse_key, old_uuid, args.new_uuid, args.reason)
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(json.dumps({
|
||||||
|
"rotated": True,
|
||||||
|
"reuse_key": reuse_key,
|
||||||
|
"old_uuid": old_uuid,
|
||||||
|
"new_uuid": args.new_uuid,
|
||||||
|
"reason": args.reason,
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_resolve(args):
|
||||||
|
"""Resolve a thread_id through rotation chain (for nudge router)."""
|
||||||
|
state = load_state(args.state)
|
||||||
|
resolved = resolve_thread_for_nudge(state, args.thread_id)
|
||||||
|
print(json.dumps({
|
||||||
|
"input": args.thread_id,
|
||||||
|
"resolved": resolved,
|
||||||
|
"rotated": resolved != args.thread_id,
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Side chat thread lifecycle manager")
|
||||||
|
p.add_argument("--state", default=os.path.expanduser(
|
||||||
|
"~/Projects/NetVM/job-sidechats.json"),
|
||||||
|
help="Path to job-sidechats.json")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
c = sub.add_parser("check", help="Check if rotation is needed")
|
||||||
|
c.add_argument("--reuse-key", required=True)
|
||||||
|
c.add_argument("--messages", type=int, default=None,
|
||||||
|
help="Current message count (None = skip count trigger)")
|
||||||
|
c.add_argument("--force", action="store_true",
|
||||||
|
help="Force rotation (explicit topic change)")
|
||||||
|
|
||||||
|
r = sub.add_parser("rotate", help="Record a completed rotation")
|
||||||
|
r.add_argument("--reuse-key", required=True)
|
||||||
|
r.add_argument("--new-uuid", required=True)
|
||||||
|
r.add_argument("--reason", default="manual")
|
||||||
|
|
||||||
|
v = sub.add_parser("resolve", help="Resolve thread_id through rotation")
|
||||||
|
v.add_argument("--thread-id", required=True)
|
||||||
|
|
||||||
|
args = p.parse_args()
|
||||||
|
if args.cmd == "check":
|
||||||
|
sys.exit(cmd_check(args))
|
||||||
|
elif args.cmd == "rotate":
|
||||||
|
sys.exit(cmd_rotate(args))
|
||||||
|
elif args.cmd == "resolve":
|
||||||
|
sys.exit(cmd_resolve(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
super ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEOK8JqoqsWPx9E1N8L69yC3eNN90eCi7RstClqvxQpe super@bl
|
||||||
|
operator-main ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2fFix4z5PB8ICKgo7Pk8JceWrNJeI0QGkVbwYp3Jpv operator-main
|
||||||
|
operator-646 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVohZns+An7a7lMrYe0wD6NkOWOVEt/819+JhmL844n hatch
|
||||||
|
646 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVohZns+An7a7lMrYe0wD6NkOWOVEt/819+JhmL844n hatch
|
||||||
|
opm ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2fFix4z5PB8ICKgo7Pk8JceWrNJeI0QGkVbwYp3Jpv operator-main
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVohZns+An7a7lMrYe0wD6NkOWOVEt/819+JhmL844n hatch
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEOK8JqoqsWPx9E1N8L69yC3eNN90eCi7RstClqvxQpe super@bl
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# DOM Headless Approvals Spec (muse.ai automation)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
When headless automation (`muse-chat-api.py` via CDP) drives a muse.ai
|
||||||
|
session, the browser may surface permission/confirmation dialogs that block
|
||||||
|
the flow (e.g. "Allow pip to share information with 34.139.37.135?").
|
||||||
|
This spec defines how the automation detects, classifies, and handles those
|
||||||
|
dialogs. Principle (from INFRA.md): **the chat IS the approval interface** —
|
||||||
|
no file-based queue; the automation signals when stuck and the operator
|
||||||
|
resolves it in conversation.
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
- **Approval dialog**: any in-DOM permission/confirmation prompt that gates
|
||||||
|
the automation's next action.
|
||||||
|
- **Trusted origin**: an IP in `TRUSTED_IPS` — our own infrastructure, where
|
||||||
|
auto-approval is safe. Current set:
|
||||||
|
- `34.139.37.135` — VM (gateway)
|
||||||
|
- `100.123.153.75` — bl (main compute)
|
||||||
|
- `100.81.31.9` — VM tailnet
|
||||||
|
- **APPROVAL_NEEDED**: the escalation signal. Printed to stderr as
|
||||||
|
`APPROVAL_NEEDED: <dialog summary>`, process exits with code **2**.
|
||||||
|
|
||||||
|
## Detection
|
||||||
|
`check_approvals(ws)` evaluates in the page DOM:
|
||||||
|
1. Body text containing both `Allow` and `to share` → permission prompt.
|
||||||
|
Narrows to elements whose innerText contains both and is < 500 chars.
|
||||||
|
2. Two or more buttons whose text includes `allow`, `deny`, or `block`
|
||||||
|
→ likely permission dialog; captures the closest container's text.
|
||||||
|
|
||||||
|
Returns a list of `(dialog_text, is_trusted, action_taken)`.
|
||||||
|
|
||||||
|
## Classification
|
||||||
|
Extract IPv4 addresses from the dialog text. The dialog is **trusted** iff
|
||||||
|
any extracted IP is in `TRUSTED_IPS`. Dialogs with no recognizable IP are
|
||||||
|
**untrusted** (fail closed).
|
||||||
|
|
||||||
|
## Handling
|
||||||
|
- **Trusted**: auto-approve by clicking the button whose text contains
|
||||||
|
`allow once`, else the button whose text is exactly `allow`.
|
||||||
|
Records `clicked:<button text>`.
|
||||||
|
- **Untrusted**: do NOT click. Return `APPROVAL_NEEDED`; the calling command
|
||||||
|
prints the summary to stderr and exits 2.
|
||||||
|
|
||||||
|
## Enforcement points
|
||||||
|
| Command | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `send` | Checks approvals first. Untrusted dialog → `APPROVAL_NEEDED`, exit 2, message NOT sent. |
|
||||||
|
| `messages` | Non-blocking check (observes, does not gate). |
|
||||||
|
| `wait` | Re-checks every 5s during the wait. Untrusted dialog → `APPROVAL_NEEDED`, exit 2. |
|
||||||
|
| `approvals` | Reports pending dialogs with trusted/action status. Never gates. |
|
||||||
|
|
||||||
|
## Exit-code contract
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `0` | Done. |
|
||||||
|
| `1` | Failed (bad args, no page, send error, …). |
|
||||||
|
| `2` | `APPROVAL_NEEDED` — human input required; safe to retry after resolution. |
|
||||||
|
|
||||||
|
## Operator flow
|
||||||
|
1. Automation exits 2 with `APPROVAL_NEEDED: <details>`.
|
||||||
|
2. Operator relays the details to the user via chat.
|
||||||
|
3. User provides the needed input (e.g. confirms, provides OTP).
|
||||||
|
4. Operator re-runs the command; the dialog is either gone or now trusted.
|
||||||
|
|
||||||
|
## Non-goals (explicitly out of scope)
|
||||||
|
- **DM content provenance**: this spec covers *browser dialogs encountered by
|
||||||
|
automation*. It does NOT authenticate who authored a DM. A message injected
|
||||||
|
via `dm.py send` carries no signature; a receiving agent cannot verify the
|
||||||
|
claimed sender from the tooling. (Observed 2026-10-03: pip's agent rightly
|
||||||
|
refused to act on an unsigned DM.) Provenance is a separate spec.
|
||||||
|
- **Credential approval**: Meta account write operations use the human-gated
|
||||||
|
flow in `docs/META-ACCOUNTS-API.md`, not this spec.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
- Allowlist dialog *types* (not just IPs) for finer auto-approval.
|
||||||
|
- Structured `APPROVAL_NEEDED` payloads (JSON) for machine-readable relay.
|
||||||
|
- DONE 2026-10-03: dm-level provenance via ssh-keygen signing. `dm-sign.sh` signs with `ssh-keygen -Y sign -n dm` (file-based); `dm.py send --raw` transports the signed block verbatim; `dm.py verify-sig` verifies against `dm-signers/<sender>.pub` via `ssh-keygen -Y verify`.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Hybrid Headless Gateway & Chromebox Architecture: `muse-cli` Adaptation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
NetVM previously automated agent interaction (thread reading, message sends, job execution) solely via headless Chromebox containers over Chrome DevTools Protocol (CDP) and DOM manipulation.
|
||||||
|
|
||||||
|
While browser automation is indispensable for interactive UI flows (initial authentication, OTP submission, age verification, visual inspection), routine agent messaging and thread polling over DOM mutation suffered from high latency and UI placement flakiness (`placement_failed`, `NOCOMPOSE`, `NOINPUT`).
|
||||||
|
|
||||||
|
To resolve this, we adapted **`muse-cli`** into the NetVM `box` (`super-cli.py`) ecosystem as a **fast, headless gateway transport** connecting directly over WebSockets (`wss://gateway.muse.ai/v1/noise`) via encrypted Noise protocol frames (`Noise_XX_25519_AESGCM_SHA256`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Strict Cloudflare Identity & Egress Isolation
|
||||||
|
|
||||||
|
A core architectural invariant of NetVM is that **no fleet node shares an unisolated egress network identity or ambient host IP**. Every command executed via the CLI maintains strict network and session separation.
|
||||||
|
|
||||||
|
### Network Namespace Boundary
|
||||||
|
Each node possesses a dedicated Linux network namespace (`warp-<node>`) containing its own WireGuard interface (`wb-<tag>`), routing all traffic through its assigned Cloudflare WARP client identity:
|
||||||
|
- `warp-muse` (Interface: `wb-4016c3db`)
|
||||||
|
- `warp-pip` (Interface: `wb-fed5038b`)
|
||||||
|
- `warp-646` (Interface: `wb-ed0b853b`)
|
||||||
|
- `warp-opm` (Interface: `wb-54353f9c`)
|
||||||
|
|
||||||
|
### Execution Wrapper: `bin/muse-cli-node`
|
||||||
|
The executable wrapper [`bin/muse-cli-node`](file:///home/super/Projects/NetVM/bin/muse-cli-node) bridges CLI requests into the agent's isolated namespace:
|
||||||
|
```bash
|
||||||
|
bin/muse-cli-node <node> <subcommand> [args...]
|
||||||
|
```
|
||||||
|
1. **Network Egress**: Dispatches execution through [`bin/netvm-exec.sh`](file:///home/super/Projects/NetVM/bin/netvm-exec.sh), ensuring all WebSocket and HTTP requests originate from the node's specific WARP interface.
|
||||||
|
2. **Session Cookie Partitioning**: Uses isolated cookie stores located in `~/.config/muse-cli/<node>/cookies.txt` (permissions `0600`).
|
||||||
|
3. **Auto-Healing Cookie Refresh**: On receiving an `AuthError` (or 401 Unauthorized), `muse-cli-node` intercepts the error, runs [`bin/refresh-node-cookies.py`](file:///home/super/Projects/NetVM/bin/refresh-node-cookies.py) to export fresh session cookies directly from the node's local Chromium instance via CDP inside its netns, and transparently retries the command once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hybrid Modality Architecture
|
||||||
|
|
||||||
|
NetVM leverages both modalities according to their operational strengths:
|
||||||
|
|
||||||
|
| Task / Domain | Primary Modality | Fallback / Complementary Modality |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Routine Thread Listing** | Gateway (`muse_hybrid.get_threads`) | DOM CDP (`box-chat.py thread-list`) |
|
||||||
|
| **Thread History Reading** | Gateway (`muse_hybrid.get_history`) | DOM CDP (`box-chat.py thread-messages`) |
|
||||||
|
| **High-Frequency Job/DM Dispatch** | Gateway (`muse-cli send`) | DOM CDP (`muse-chat-api.py send`) |
|
||||||
|
| **Live Event Streaming** | Gateway (`muse-cli watch`) | Siphon log polling (`siphon-bl.py`) |
|
||||||
|
| **Initial Login & OTP Entry** | DOM CDP ([`bin/muse-signin.py`](file:///home/super/Projects/NetVM/bin/muse-signin.py)) | N/A (Requires interactive DOM inputs) |
|
||||||
|
| **Visual Screencast & Audit** | DOM CDP (`Page.startScreencast`) | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Programmatic & CLI Interfaces
|
||||||
|
|
||||||
|
### Python Interface: `bin/muse_hybrid.py`
|
||||||
|
Provides clean programmatic access for scripts and background daemons:
|
||||||
|
```python
|
||||||
|
import muse_hybrid
|
||||||
|
|
||||||
|
# Fetch thread listing (sub-second)
|
||||||
|
threads, err = muse_hybrid.get_threads("pip")
|
||||||
|
|
||||||
|
# Fetch message history
|
||||||
|
msgs, err = muse_hybrid.get_history("pip", thread_id="4466d0c1-7961-4cf3-b99d-1ab7c38484c2", limit=5)
|
||||||
|
|
||||||
|
# Fast send
|
||||||
|
res, err = muse_hybrid.send_message("pip", "Hello from gateway", thread_id="4466d0c1-...")
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Interface: `super-cli.py` (`box`)
|
||||||
|
|
||||||
|
1. **Direct Gateway Passthrough**:
|
||||||
|
Operators can execute any `muse-cli` command under a specific node's identity:
|
||||||
|
```bash
|
||||||
|
box muse pip status
|
||||||
|
box muse 646 threads
|
||||||
|
box muse opm watch
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Accelerated Thread Commands**:
|
||||||
|
`box thread list <agent>` and `box thread view <agent> <thread_id>` automatically query `muse_hybrid` first. If the gateway encounters an issue, they gracefully fall back to the existing `box-chat.py` CDP path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verification & Health Audit
|
||||||
|
|
||||||
|
To verify the hybrid gateway across all fleet nodes:
|
||||||
|
```bash
|
||||||
|
# Verify status per node
|
||||||
|
for node in muse pip 646 opm; do
|
||||||
|
echo "=== $node ==="
|
||||||
|
box muse $node status | jq -r '.identity.name'
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify thread view via hybrid path
|
||||||
|
box thread view pip 4466d0c1-7961-4cf3-b99d-1ab7c38484c2 --limit 3
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Identity Variance Testing
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
All NetVM nodes currently egress from a single Cloudflare IP (`104.28.195.181`),
|
||||||
|
but each node holds a *distinct* WireGuard identity. When production rate limits
|
||||||
|
hit, we need data to answer: per-identity, per-IP, time-based, or random?
|
||||||
|
|
||||||
|
This framework builds that dataset gently: ~2 probes per node every 10 minutes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `bin/identity-variance-test.py` — probe harness + analyzer
|
||||||
|
- `.state/identity-variance/variance.jsonl` — results log (git-ignored, append-only)
|
||||||
|
- (proposed, not installed) systemd unit + timer for 10-min cadence
|
||||||
|
|
||||||
|
## What it measures
|
||||||
|
|
||||||
|
Per probe, per node:
|
||||||
|
- `http_status` — 200s vs 429 (rate limited) vs 403 (blocked) vs curl failures
|
||||||
|
- `response_ms` — full-request timing from inside the netns
|
||||||
|
- `egress_ip` — IP seen from inside the netns (per node, per cycle)
|
||||||
|
- `identity_hash` — sha256 (truncated) of the node's WireGuard private key.
|
||||||
|
Detects identity rotation; the key itself is NEVER logged.
|
||||||
|
- `remote_ip` — the IP curl actually connected to
|
||||||
|
|
||||||
|
Probes (both lightweight, no auth, no writes):
|
||||||
|
1. `https://www.cloudflare.com/cdn-cgi/trace` — what Cloudflare sees
|
||||||
|
2. `https://muse.ai/` — production-relevant landing page
|
||||||
|
|
||||||
|
Traffic budget: 5 nodes × 2 probes every 10 min ≈ 6 req/hr per node. Negligible.
|
||||||
|
|
||||||
|
## Log format (JSONL)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts": "2026-10-04T21:50:00+00:00", "node": "muse", "probe": "cf-trace",
|
||||||
|
"url": "https://www.cloudflare.com/cdn-cgi/trace", "curl_rc": 0,
|
||||||
|
"http_status": 200, "response_ms": 312.4, "bytes": 240,
|
||||||
|
"remote_ip": "104.16.0.0", "rate_limited": false, "blocked": false,
|
||||||
|
"ok": true, "elapsed_wall_ms": 415.2,
|
||||||
|
"identity_hash": "a43dded8edc358e2", "egress_ip": "104.28.195.181"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one manual cycle
|
||||||
|
bin/identity-variance-test.py probe
|
||||||
|
|
||||||
|
# analyze (all data)
|
||||||
|
bin/identity-variance-test.py analyze
|
||||||
|
|
||||||
|
# last 24h only
|
||||||
|
bin/identity-variance-test.py analyze --since 24
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading the analysis
|
||||||
|
|
||||||
|
- `http_429` differing by node → per-identity differential treatment
|
||||||
|
- all nodes 429 at the same `ts` → correlated (per-IP) throttling
|
||||||
|
- 429s clustering by time of day → time-based limits
|
||||||
|
- uniform, rare 429s with no pattern → effectively random
|
||||||
|
|
||||||
|
## Installing the timer (human-gated)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp systemd/identity-variance.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now identity-variance.timer
|
||||||
|
systemctl list-timers | grep identity-variance
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `sudo -n ip netns exec` is required; script fails cleanly otherwise.
|
||||||
|
- The `def` node is included but will report `node-skip` until assigned.
|
||||||
|
- Log rotation: JSONL grows ~1.5KB/cycle (~200KB/day). Rotate monthly:
|
||||||
|
`mv variance.jsonl variance-$(date +%Y%m).jsonl`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Per-Node Rate Limit Policy (2026-10-04)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
All 4 fleet nodes (muse, pip, 646, opm) egress from a single Cloudflare IP
|
||||||
|
(`104.28.195.181`). Cloudflare therefore sees the whole fleet as one client.
|
||||||
|
When fleet timers fire simultaneously, synchronized send bursts look like one
|
||||||
|
aggressive client and invite correlated rate-limiting — the likely shape of
|
||||||
|
the "all 4 browsers flapping" episodes.
|
||||||
|
|
||||||
|
Per-agent rate limiting with deterministic jitter de-correlates the fleet:
|
||||||
|
each node's send phase drifts apart instead of hitting in lockstep.
|
||||||
|
|
||||||
|
## Policy
|
||||||
|
|
||||||
|
| Agent | Base interval | Jitter factor | Effective interval | Burst | Max/min |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| muse | 3.0s | 1.2917 | 3.875s | 5 | 20 |
|
||||||
|
| pip | 3.0s | 0.8183 | 2.455s | 5 | 20 |
|
||||||
|
| 646 | 3.0s | 1.1193 | 3.358s | 5 | 20 |
|
||||||
|
| opm | 3.0s | 0.8287 | 2.486s | 5 | 20 |
|
||||||
|
|
||||||
|
Mean effective interval ≈ 3.04s — the jitter does **not** make limits
|
||||||
|
stricter on average; it only spreads phases.
|
||||||
|
|
||||||
|
- **Jitter:** ±30% deterministic per agent, seeded by
|
||||||
|
`sha256("netvm-rate-jitter:v1:" + agent)`. Same agent → same factor every
|
||||||
|
run (reproducible); different agents → different factors (de-correlated).
|
||||||
|
Uses an isolated `random.Random`, never touches global random state.
|
||||||
|
- **Scope:** per-agent token buckets. State lives in one shared file
|
||||||
|
(`/tmp/netvm-rate-limit.json`) but keys are namespaced by agent
|
||||||
|
(`{agent}`, `{agent}_recent`) — buckets are fully independent.
|
||||||
|
- **Enforcement points:**
|
||||||
|
- `job-dispatch.py` — `rate_limit_wait(agent)` / `rate_limit_wait(sender_agent)`
|
||||||
|
(pre-existing; now jittered, no call-site change needed)
|
||||||
|
- `dm.py::dm_send` — `rate_limit_wait(agent)` before send, keyed on the
|
||||||
|
sender (added 2026-10-04). Near no-op at normal pacing since sends
|
||||||
|
already sleep 2–3s; it only bites on burst loops.
|
||||||
|
|
||||||
|
## Inspect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 bin/rate_limiter.py --policy # effective policy per agent
|
||||||
|
python3 bin/rate_limiter.py --policy opm --no-jitter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `/tmp` state does not survive reboots; buckets start empty (permissive).
|
||||||
|
- To disable jitter for a caller: `rate_limit_wait(agent, jitter=0)`.
|
||||||
|
- Distinct egress IPs per node (Cloudflare Zero Trust dedicated egress)
|
||||||
|
remains the structural fix; this policy is the de-correlation layer that
|
||||||
|
works regardless.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Thread Bookkeeping with Pin & Archive
|
||||||
|
|
||||||
|
**Status:** runbook (2026-10-04)
|
||||||
|
**Applies to:** muse.ai side-chat threads used by the fleet DM system
|
||||||
|
|
||||||
|
## Why bookkeeping matters
|
||||||
|
|
||||||
|
Side-chat threads are addressed by UUID. UUIDs go stale (deletion,
|
||||||
|
account-side cleanup, platform reissue — observed 2026-10-04 when
|
||||||
|
`heartbeat-opm` changed UUIDs in a single day and when the hardcoded
|
||||||
|
`1e75a740` "646 tasks" UUID died ~90 minutes after being committed).
|
||||||
|
Stale UUIDs cause silent misdelivery: the SPA redirects to an arbitrary
|
||||||
|
valid thread instead of 404ing, and the old placement-blind verify
|
||||||
|
claimed success.
|
||||||
|
|
||||||
|
Duplicates compound the problem. On 2026-10-04 the user observed two
|
||||||
|
`BRAIN-INIT` messages, indicating two brain threads where one was
|
||||||
|
expected. Fuzzy sidebar name search then matches the wrong thread.
|
||||||
|
|
||||||
|
Pin and archive are the native muse.ai mechanisms for managing this.
|
||||||
|
They are gateway API methods (`/api/session/pin`,
|
||||||
|
`/api/session/unpin`, `/api/session/archive`,
|
||||||
|
`/api/session/unarchive`, `/api/session/delete`,
|
||||||
|
`/api/session/rename`), **not** CDP browser actions. Our
|
||||||
|
`muse-chat-api.py` (CDP-based) does not implement them.
|
||||||
|
|
||||||
|
## Tooling: muse-cli
|
||||||
|
|
||||||
|
The supported path is the third-party `muse-cli` tool
|
||||||
|
(`nikships/muse-cli`), which speaks the gateway protocol (Noise XX +
|
||||||
|
protobuf) directly. It is **not** installed on bl, the VM, or the
|
||||||
|
operator container as of 2026-10-04.
|
||||||
|
|
||||||
|
### Install (on bl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js required
|
||||||
|
npm install -g muse-cli # or per-repo install per muse-cli README
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
muse-cli needs a `hatch_sess` cookie from a logged-in browser session.
|
||||||
|
Obtain it from the operator's own browser profile — never from another
|
||||||
|
agent's session. Treat the cookie as a credential: transient use, never
|
||||||
|
logged, never in chat.
|
||||||
|
|
||||||
|
### Commands (per muse-cli PROTOCOL.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
muse-cli pin <session_id> # pin a thread
|
||||||
|
muse-cli unpin <session_id> # unpin a thread
|
||||||
|
muse-cli session-archive <session_id> # archive a thread
|
||||||
|
muse-cli session-unarchive <session_id> # restore an archived thread
|
||||||
|
muse-cli session-rename <session_id> <title> # rename a thread
|
||||||
|
muse-cli session-delete <session_id> # delete a thread (destructive)
|
||||||
|
```
|
||||||
|
|
||||||
|
`<session_id>` is the thread UUID (the hex string in
|
||||||
|
`https://muse.ai/thread/<uuid>`).
|
||||||
|
|
||||||
|
## When to pin a thread
|
||||||
|
|
||||||
|
Pin threads that are **critical infrastructure** or **frequently
|
||||||
|
accessed during triage**:
|
||||||
|
|
||||||
|
| Thread | Alias / reuse_key | Why pinned |
|
||||||
|
|---|---|---|
|
||||||
|
| main-loop brain | `main-loop brain` | Fleet coordination point |
|
||||||
|
| 646 tasks | `646 tasks` | 646's primary work thread |
|
||||||
|
| heartbeat | `heartbeat-opm` | Pipeline health signal |
|
||||||
|
| 646-pip-coord | `646-pip-coord` | Cross-operator coordination |
|
||||||
|
| muse tasks | `muse tasks` | muse's primary work thread |
|
||||||
|
|
||||||
|
Pinning keeps these at the top of the sidebar, which:
|
||||||
|
|
||||||
|
- Prevents fuzzy-name-search confusion (searching "646" is less likely
|
||||||
|
to match the wrong thread when the right one is pinned and visible).
|
||||||
|
- Makes important threads easy to find during incident triage.
|
||||||
|
- Does **not** change the UUID — pin is purely a display/ordering
|
||||||
|
operation. `job-sidechats.json` mappings are unaffected.
|
||||||
|
|
||||||
|
## When to archive a thread
|
||||||
|
|
||||||
|
Archive threads that are **stale, duplicate, test, or dead**:
|
||||||
|
|
||||||
|
- **Duplicates:** the double `BRAIN-INIT` incident — archive the
|
||||||
|
surplus thread, keep the registered one.
|
||||||
|
- **Stale UUIDs:** threads whose UUID no longer resolves (e.g. the dead
|
||||||
|
`1e75a740` if it still exists on 646's account).
|
||||||
|
- **Test threads:** `test-auto-prov` (`b96dd020`), `pipe-7d2896`,
|
||||||
|
`pipe-1b4579`, and other autoprovision experiments.
|
||||||
|
- **Superseded threads:** old threads replaced by a renamed or
|
||||||
|
re-provisioned canonical thread.
|
||||||
|
|
||||||
|
Prefer **archive** over **delete**: archiving is reversible
|
||||||
|
(`session-unarchive`), deleting is not. Delete only when certain the
|
||||||
|
thread holds nothing worth recovering.
|
||||||
|
|
||||||
|
## Listing pinned vs archived threads
|
||||||
|
|
||||||
|
muse-cli's session listing distinguishes state. Our CDP-based
|
||||||
|
`sidechat list` scrapes visible sidebar text only and **cannot**
|
||||||
|
distinguish pinned/archived/deleted threads — another reason to use
|
||||||
|
muse-cli for bookkeeping operations.
|
||||||
|
|
||||||
|
After any pin/archive operation, verify with the list command and
|
||||||
|
confirm the expected state before updating mappings.
|
||||||
|
|
||||||
|
## Integration with job-sidechats.json
|
||||||
|
|
||||||
|
`job-sidechats.json` (on bl) is the dynamic UUID registry:
|
||||||
|
`{ "<alias>": { "thread_uuid": ..., "agent": ..., ... } }`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. **Pin changes nothing** in `job-sidechats.json`. The UUID is the
|
||||||
|
same; only sidebar ordering changes.
|
||||||
|
2. **Archive the registered thread → update the mapping.** Either
|
||||||
|
remove the entry (so the next send autoprovision a fresh thread) or
|
||||||
|
point it at the surviving canonical thread's UUID.
|
||||||
|
3. **Archive a non-registered duplicate → no mapping change needed**,
|
||||||
|
but note it in the commit message for traceability.
|
||||||
|
4. **Never hardcode UUIDs** in `SIDCHAT_ALIASES` or elsewhere (lesson
|
||||||
|
from 2026-10-04). Use name-based autoprovision with the dynamic
|
||||||
|
registry. A hardcoded UUID is a ticking time bomb: it *will* go
|
||||||
|
stale, and there is no alert when it does.
|
||||||
|
|
||||||
|
## Case study: duplicate brain INIT (2026-10-04)
|
||||||
|
|
||||||
|
**What happened.** The user observed two `BRAIN-INIT` messages in the
|
||||||
|
UI, indicating two brain threads where the design calls for one. The
|
||||||
|
brain thread (`5f18476d-8994-49e7-a9e0-4838732363fe`, created 19:40 UTC
|
||||||
|
on opm's account with placement confirmed) was never registered in
|
||||||
|
`job-sidechats.json`, so 646 reported it as "still pending" and
|
||||||
|
"unwired" while a second INIT appeared.
|
||||||
|
|
||||||
|
**Contributing factors.**
|
||||||
|
|
||||||
|
- The brain thread was created but the registration step was skipped.
|
||||||
|
- Without registration, later code paths could not find the canonical
|
||||||
|
thread and created (or appeared to create) another.
|
||||||
|
- No pin/archive pass had ever been run, so there was no canonical
|
||||||
|
"this is the one true brain" signal in the sidebar.
|
||||||
|
|
||||||
|
**How archive fixes it.**
|
||||||
|
|
||||||
|
1. Identify both thread UUIDs (from dm-log `alias_resolved` /
|
||||||
|
`nav_ok` records or muse-cli session list).
|
||||||
|
2. Confirm which UUID is registered in `job-sidechats.json` under
|
||||||
|
`"main-loop brain"` — that one is canonical.
|
||||||
|
3. `muse-cli session-archive <duplicate_uuid>` on the surplus thread.
|
||||||
|
4. Pin the canonical brain thread so it stays visible.
|
||||||
|
5. Commit the `job-sidechats.json` state with a note referencing the
|
||||||
|
archived duplicate.
|
||||||
|
|
||||||
|
**Prevention.** After creating any canonical thread: register it in
|
||||||
|
`job-sidechats.json` immediately, pin it, and note the UUID in the
|
||||||
|
commit message. Run a periodic (monthly) archive pass over test and
|
||||||
|
stale threads.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-time: install + auth (hatch_sess cookie, transient)
|
||||||
|
# pin the critical set (opm account, adjust per agent)
|
||||||
|
muse-cli pin 5f18476d-8994-49e7-a9e0-4838732363fe # main-loop brain
|
||||||
|
|
||||||
|
# archive a duplicate / stale thread
|
||||||
|
muse-cli session-archive <duplicate_uuid>
|
||||||
|
|
||||||
|
# verify, then update job-sidechats.json on bl if the archived
|
||||||
|
# thread was the registered one (re-point or remove the entry)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open items (2026-10-04)
|
||||||
|
|
||||||
|
- muse-cli is not yet installed anywhere in the fleet.
|
||||||
|
- `hatch_sess` cookie handling needs a defined transient-use procedure.
|
||||||
|
- No scheduled archive pass exists yet; propose monthly.
|
||||||
|
- CDP-based `sidechat list` cannot see archived state — bookkeeping
|
||||||
|
must go through muse-cli, not dm.py.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Timer Stagger — Fleet Polling De-correlation
|
||||||
|
|
||||||
|
**Date:** 2026-10-04 ~21:50 UTC
|
||||||
|
**Why:** All 4 NetVM nodes egress via the same Warp IP (104.28.195.181). Synchronized
|
||||||
|
timers = 4x correlated traffic bursts from one IP = correlated rate-limit risk.
|
||||||
|
Staggering spreads load; combined with per-node rate limiting it de-correlates
|
||||||
|
the fleet's egress signature.
|
||||||
|
|
||||||
|
## Before
|
||||||
|
|
||||||
|
Three heaviest muse.ai-egress timers fired at the **exact same second** every 5 min
|
||||||
|
(`OnCalendar=*:0/5`): `job-heartbeat`, `self-main-loop`, `main-chat-watchdog`.
|
||||||
|
`accounts-health` + `loop-remediator` coincided every 15 min. All four `wake-*`
|
||||||
|
timers fired hourly at :00. Three of four `chromebox-watchdog` timers were
|
||||||
|
synchronized to the second.
|
||||||
|
|
||||||
|
## After — stagger schedule
|
||||||
|
|
||||||
|
### bl user timers (`~/.config/systemd/user/`, hand-managed)
|
||||||
|
|
||||||
|
| Timer | Old | New | Fires at |
|
||||||
|
|---|---|---|---|
|
||||||
|
| job-heartbeat | `*:0/5` | `*:0/5` (phase 0, unchanged) | :00 :05 :10 … |
|
||||||
|
| self-main-loop | `*:0/5` | `*:1/5` | :01 :06 :11 … |
|
||||||
|
| main-chat-watchdog | `*:0/5` | `*:2/5` | :02 :07 :12 … |
|
||||||
|
| keepalive (box-managed) | `*:0/5` | `*:4/5` | :04 :09 :14 … |
|
||||||
|
| accounts-health | `*:0/15` | `*:3/15` | :03 :18 :33 :48 |
|
||||||
|
| loop-remediator | `*:0/15` | `*:8/15` | :08 :23 :38 :53 |
|
||||||
|
| followup-sweeper | `*:*` (@:00s) | `*:*:20` | :20s of every minute |
|
||||||
|
| agent-health | OnBootSec/OnUnitActiveSec 5m | + `RandomizedDelaySec=60s` | jittered ±60s |
|
||||||
|
| fleet-alert-check | OnBootSec/OnUnitActiveSec 5m | + `RandomizedDelaySec=60s` | jittered ±60s |
|
||||||
|
| exec-watch | OnUnitActiveSec 15m | + `RandomizedDelaySec=120s` | jittered ±120s |
|
||||||
|
|
||||||
|
systemd natively supports offset calendar specs (`*:1/5`); no wrapper scripts needed.
|
||||||
|
|
||||||
|
### Box-managed timers (`/srv/box/timers.json` → `timer-sync.py --all`)
|
||||||
|
|
||||||
|
| Timer | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| wake-muse | `*-*-* *:00:00` | `*-*-* *:05:00` |
|
||||||
|
| wake-pip | `*-*-* *:00:00` | `*-*-* *:20:00` |
|
||||||
|
| wake-646 | `*:0/30` | `*-*-* *:35:00` |
|
||||||
|
| wake-opm | `*:0/30` | `*-*-* *:50:00` |
|
||||||
|
| loop-health | `*:0/15` | `*:11/15` |
|
||||||
|
| keepalive | `*:0/5` | `*:4/5` |
|
||||||
|
|
||||||
|
(`wake-646`/`wake-opm` were actually on `*:0/30`, not hourly — now spread 15 min apart
|
||||||
|
across the hour like the others.)
|
||||||
|
|
||||||
|
### System timers (`/etc/systemd/system/`, hand-managed)
|
||||||
|
|
||||||
|
| Timer | Change |
|
||||||
|
|---|---|
|
||||||
|
| chromebox-watchdog-{pip,646,muse,opm} | + `RandomizedDelaySec=30s` (local checks only; de-correlates restart bursts) |
|
||||||
|
|
||||||
|
## Backups (pre-change)
|
||||||
|
|
||||||
|
- bl user timers: `~/.config/systemd/user/bak-stagger-20261004/` (12 files)
|
||||||
|
- system timers: `/root/bak-stagger-20261004/`
|
||||||
|
- timers.json: `/srv/box/timers.json.bak-stagger-20261004`
|
||||||
|
|
||||||
|
## Verification (2026-10-04 ~21:52 UTC)
|
||||||
|
|
||||||
|
`systemctl --user list-timers` and `systemctl list-timers` confirm the new phases:
|
||||||
|
followup-sweeper @ :50:20, main-chat-watchdog @ :52:00, loop-remediator @ :53:00,
|
||||||
|
accounts-health @ 22:03:00, keepalive @ :54:00, wake-muse @ 22:05, wake-pip @ 22:20,
|
||||||
|
wake-646 @ 22:35, wake-opm @ 22:50. Watchdogs spread over ~7s via jitter.
|
||||||
|
|
||||||
|
Note: `self-main-loop` did one immediate catch-up run at 21:49:21 on reload
|
||||||
|
(`Persistent=true` treating the 21:46 slot as missed under the new phase) — benign,
|
||||||
|
one extra run.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bl user timers
|
||||||
|
cp ~/.config/systemd/user/bak-stagger-20261004/*.timer ~/.config/systemd/user/
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
# system timers
|
||||||
|
sudo cp /root/bak-stagger-20261004/*.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
# box timers
|
||||||
|
cp /srv/box/timers.json.bak-stagger-20261004 /srv/box/timers.json
|
||||||
|
/srv/box/bin/timer-sync.py --all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Not changed (deliberately)
|
||||||
|
|
||||||
|
- `box-http-health` (`*:0/15`) / `box-service-health` (`*:7/15`) — already staggered.
|
||||||
|
- `siphon`, `cdp-relay-watchdog`, `shadow`, `backup-sync` — unrelated cadences.
|
||||||
|
- `chat-state`, `response-harvester` — inactive.
|
||||||
|
- `self-main-loop` still touches all 4 browsers inside one run — staggering the
|
||||||
|
*timer* doesn't de-correlate *within* the run. Follow-up: add inter-node sleeps
|
||||||
|
inside `self_main_loop.py` itself.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Live on bl + VM as of 2026-10-04 ~21:52 UTC. Doc uncommitted (review-then-commit);
|
||||||
|
timer unit files live outside the NetVM repo (`~/.config/systemd/user`,
|
||||||
|
`/etc/systemd/system`) — consider versioning them if drift recurs.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# WARP Egress Fix — Distinct Egress IPs per NetVM Node
|
||||||
|
|
||||||
|
Date: 2026-10-04
|
||||||
|
Status: DESIGN (not yet implemented)
|
||||||
|
Author: operator-main
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Give each NetVM node (`muse`, `pip`, `646`, `opm`, `def`) its own distinct
|
||||||
|
public egress IP, so that a Cloudflare rate-limit / block / partition on one
|
||||||
|
node's egress IP does not take down the whole fleet at once.
|
||||||
|
|
||||||
|
Background: the 2026-10-04 partitioning investigation found all nodes egress
|
||||||
|
from **one shared IP (104.28.195.181)**. Cloudflare sees the fleet as a single
|
||||||
|
client; a single throttle/block correlates into a fleet-wide outage — which
|
||||||
|
matches the observed "all 4 browsers flapping" pattern better than four
|
||||||
|
independent failures.
|
||||||
|
|
||||||
|
## 2. Empirical finding (corrects the premise)
|
||||||
|
|
||||||
|
**Each node ALREADY has a distinct Warp identity** — and still shares one
|
||||||
|
egress IP.
|
||||||
|
|
||||||
|
Verified live 2026-10-04 ~21:40 UTC on bl:
|
||||||
|
|
||||||
|
| node | identity (sha256 of PrivateKey, truncated) | egress IP now |
|
||||||
|
|------|--------------------------------------------|---------------|
|
||||||
|
| muse | 0fce50a5aff0 | 104.28.195.181 |
|
||||||
|
| pip | e9fbb39cc4c5 | 104.28.195.181 |
|
||||||
|
| 646 | e764d9d80fa8 | 104.28.195.181 |
|
||||||
|
| opm | 771725bf8a5b | 104.28.195.181 |
|
||||||
|
| def | fa868411c422 | 104.28.195.181 |
|
||||||
|
|
||||||
|
Identities live at `/etc/netvm/<node>.conf` (root-owned, 0600), one per node,
|
||||||
|
generated via `netvm-new-identity.sh`. Egress IPs probed per-node with
|
||||||
|
`ip netns exec warp-<node> curl -s https://api.ipify.org`.
|
||||||
|
|
||||||
|
**Why distinct identities share one egress IP:** consumer Cloudflare Warp
|
||||||
|
(wgcf-registered) exits through Cloudflare's anycast edge. The egress IP is
|
||||||
|
determined by the serving PoP/edge, not by the client identity — it is drawn
|
||||||
|
from a pool shared across many Warp users. Registering more identities does
|
||||||
|
not change the egress IP.
|
||||||
|
|
||||||
|
**Consequence:** "generate 4 distinct Warp identities" is already done and is
|
||||||
|
NOT the fix. The fix must change *how traffic exits*, not *who the client is*.
|
||||||
|
|
||||||
|
## 3. Options
|
||||||
|
|
||||||
|
### Option A — Cloudflare Zero Trust dedicated egress IPs (paid, native)
|
||||||
|
|
||||||
|
Cloudflare Zero Trust (WARP for Teams) offers **dedicated egress IPs** as a
|
||||||
|
paid add-on. With one dedicated egress IP per node and Gateway egress
|
||||||
|
policies matched on device identity, each node gets a stable, distinct,
|
||||||
|
organization-owned egress IP.
|
||||||
|
|
||||||
|
- Pros: stays on Cloudflare/WireGuard; `netvm-node-up.sh` flow mostly
|
||||||
|
unchanged (different endpoint + enrollment cert); stable IPs you control;
|
||||||
|
per-node egress policies give true isolation.
|
||||||
|
- Cons: paid plan + per-IP add-on (human decision + payment); replaces
|
||||||
|
wgcf consumer identities with Zero Trust enrollment — the largest config
|
||||||
|
change of the four options; enrollment certs become new credentials to
|
||||||
|
guard (same transient-handling rules as Warp keys).
|
||||||
|
- Human-gated: yes (purchase + org setup).
|
||||||
|
|
||||||
|
### Option B — Per-node commercial VPN providers (distinct IPs, non-Warp)
|
||||||
|
|
||||||
|
Give each node a WireGuard config from a *different* commercial VPN provider
|
||||||
|
(e.g. Mullvad, ProtonVPN, IVPN), each with its own exit IP.
|
||||||
|
|
||||||
|
- Pros: genuinely distinct egress IPs, guaranteed by construction; no
|
||||||
|
Cloudflare correlation at all.
|
||||||
|
- Cons: one account + credentials per provider (5 accounts); non-uniform
|
||||||
|
configs; recurring cost per provider; abandons the Warp uniformity the
|
||||||
|
fleet was built on; each provider's ToS / automation posture must be
|
||||||
|
vetted.
|
||||||
|
- Human-gated: yes (accounts + payment).
|
||||||
|
|
||||||
|
### Option C — Accept shared egress; de-correlate and detect (no new infra)
|
||||||
|
|
||||||
|
Keep consumer Warp. Fix the *failure mode* instead of the IP:
|
||||||
|
|
||||||
|
1. **Warp egress health stage in `chromebox-watchdog.sh`** (5th stage after
|
||||||
|
the existing 4): check `wg show <WG> latest-handshakes` age AND probe
|
||||||
|
egress from inside the netns
|
||||||
|
(`ip netns exec warp-<node> curl -m5 https://www.cloudflare.com/cdn-cgi/trace`).
|
||||||
|
On stale handshake or failed probe set
|
||||||
|
`HEALTH_FAIL_REASON="warp egress down"` and take the same relaunch path
|
||||||
|
as a dead CDP. This converts the invisible partition (CDP green, browser
|
||||||
|
offline) into a visible, recoverable event.
|
||||||
|
2. **React-readiness "match" assertion** in `muse-chat-api.py` before
|
||||||
|
send/verify: assert title contains `Chat —` and the message-list
|
||||||
|
container (`role="log"`) is present and non-skeleton. Fail closed
|
||||||
|
otherwise. (A partitioned browser otherwise passes naive readiness.)
|
||||||
|
3. **Per-node rate limiting + jitter** on muse.ai-bound traffic so the four
|
||||||
|
nodes don't look like one coordinated client. `bin/rate_limiter.py`
|
||||||
|
already exists — ensure token buckets are per-node, not shared, and add
|
||||||
|
jitter to the 5-minute polling loops.
|
||||||
|
4. **Stagger the fleet polling loops** (heartbeat, main-loop digests,
|
||||||
|
health checks) so four nodes never fire in lockstep.
|
||||||
|
|
||||||
|
- Pros: no cost, no new credentials, no account changes; directly fixes the
|
||||||
|
observed failure (invisible partition) and the most likely trigger
|
||||||
|
(correlated rate-limiting); fully reversible.
|
||||||
|
- Cons: egress IP stays shared — a hard Cloudflare block of 104.28.195.181
|
||||||
|
still hits all nodes (but now it is *detected*, *logged distinctly*, and
|
||||||
|
*recovered from* instead of flapping silently).
|
||||||
|
- Human-gated: no.
|
||||||
|
|
||||||
|
### Option D — Hybrid: per-node egress proxies for sensitive paths only
|
||||||
|
|
||||||
|
Keep Warp for general traffic; route muse.ai-bound traffic per node through
|
||||||
|
distinct SOCKS5/HTTP proxies with distinct IPs (browser `--proxy-server`
|
||||||
|
per profile).
|
||||||
|
|
||||||
|
- Pros: distinct IPs where it matters (muse.ai automation); rest of stack
|
||||||
|
untouched.
|
||||||
|
- Cons: proxy accounts + credentials per node; added latency on the hot
|
||||||
|
path; proxy reliability becomes a new failure domain; Chromium proxy
|
||||||
|
config per profile adds bookkeeping.
|
||||||
|
- Human-gated: partially (proxy accounts).
|
||||||
|
|
||||||
|
## 4. Recommendation
|
||||||
|
|
||||||
|
**Implement Option C now.** It is the only option that fixes the failure we
|
||||||
|
actually observed (partitions invisible to the watchdog) with zero new cost
|
||||||
|
or credentials, and it is fully reversible.
|
||||||
|
|
||||||
|
**Keep Option A as the paid path** if truly distinct, stable egress IPs are
|
||||||
|
later required (e.g. Cloudflare starts hard-blocking the shared egress IP,
|
||||||
|
or per-node IP reputation becomes a product need). Option A is the
|
||||||
|
architecturally clean answer; it just needs a human purchase decision.
|
||||||
|
|
||||||
|
**Do not pursue Option B** unless the fleet leaves Cloudflare for other
|
||||||
|
reasons — the ops overhead (5 VPN accounts) outweighs the benefit while
|
||||||
|
Option C mitigates the correlation.
|
||||||
|
|
||||||
|
## 5. Identity rotation procedure (for reference)
|
||||||
|
|
||||||
|
Not needed for this fix (identities are already distinct), but recorded
|
||||||
|
because the task asked how identities are generated. Per AGENTS.md, the user
|
||||||
|
has explicitly authorized operators to run `netvm-new-identity.sh`
|
||||||
|
("Warp credentials are NOT top secret").
|
||||||
|
|
||||||
|
One node at a time, rolling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on bl, as super (sudo -n via allowlist for netvm-node-up.sh)
|
||||||
|
NODE=muse # repeat per node
|
||||||
|
sudo cp /etc/netvm/${NODE}.conf /root/netvm-backup-$(date +%F)/${NODE}.conf
|
||||||
|
sudo rm /etc/netvm/${NODE}.conf
|
||||||
|
sudo /home/super/Projects/NetVM/bin/netvm-new-identity.sh ${NODE}
|
||||||
|
sudo /home/super/Projects/NetVM/bin/netvm-node-up.sh ${NODE}
|
||||||
|
ip netns exec warp-${NODE} curl -s -m 10 https://api.ipify.org; echo " <- ${NODE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
`netvm-node-up.sh` is idempotent: it rebuilds the netns, veth pair,
|
||||||
|
WireGuard interface, and CDP relay from the conf. Wait for the new tunnel's
|
||||||
|
handshake (`wg show wb-<tag> latest-handshakes`) before moving to the next
|
||||||
|
node. Never rotate two nodes simultaneously — keep 4/5 of the fleet up.
|
||||||
|
|
||||||
|
## 6. Migration plan (Option C)
|
||||||
|
|
||||||
|
Order matters: instrument first, then de-correlate.
|
||||||
|
|
||||||
|
1. **Watchdog 5th stage** — edit `bin/chromebox-watchdog.sh` `healthy()`:
|
||||||
|
add Warp handshake-age check + in-netns egress probe; set
|
||||||
|
`HEALTH_FAIL_REASON="warp egress down"`. Deploy to one node (opm) first,
|
||||||
|
watch one full 5-minute cycle, then roll to all nodes. Commit.
|
||||||
|
2. **React-readiness assertion** — add the match check in `muse-chat-api.py`
|
||||||
|
before send/verify paths. Test against a known-good and a known-bad
|
||||||
|
(partitioned or landing-page) browser. Commit.
|
||||||
|
3. **Per-node rate limiter audit** — confirm `bin/rate_limiter.py` buckets
|
||||||
|
are keyed per node; add jitter to fleet polling loops (heartbeat timer,
|
||||||
|
self-main-loop.timer, health checks) so nodes don't fire in lockstep.
|
||||||
|
Commit.
|
||||||
|
4. **Stagger verification** — after deploy, confirm via logs that the four
|
||||||
|
nodes' 5-minute loops are spread across the minute, not clustered.
|
||||||
|
|
||||||
|
Each step is independently committable and revertible. No node downtime is
|
||||||
|
required for steps 1–4 (watchdog and API changes take effect on next cycle).
|
||||||
|
|
||||||
|
## 7. Migration plan (Option A, if later authorized)
|
||||||
|
|
||||||
|
1. Human: purchase Cloudflare Zero Trust plan + 5 dedicated egress IPs.
|
||||||
|
2. Create the Zero Trust organization; create 5 egress policies, one per
|
||||||
|
node identity, each pinned to its dedicated egress IP.
|
||||||
|
3. On bl: enroll each node's Warp client into the org (replaces wgcf
|
||||||
|
consumer registration); per-node enrollment certs stored like current
|
||||||
|
confs (root-owned, 0600).
|
||||||
|
4. Rolling cutover, one node at a time: swap the node's WireGuard config to
|
||||||
|
the Zero Trust endpoint, run `netvm-node-up.sh <node>`, verify distinct
|
||||||
|
egress IP (see §8), then proceed.
|
||||||
|
5. Update NODES.md egress_ip column per node; commit.
|
||||||
|
|
||||||
|
## 8. Verification
|
||||||
|
|
||||||
|
Per-node, after any change:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for n in muse pip 646 opm def; do
|
||||||
|
ip=$(sudo ip netns exec warp-$n curl -s -m 10 https://api.ipify.org)
|
||||||
|
echo "$n: $ip"
|
||||||
|
done | tee /tmp/egress-check-$(date +%F).log
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance for Option C:
|
||||||
|
- `HEALTH_FAIL_REASON="warp egress down"` appears in watchdog logs when a
|
||||||
|
tunnel is (test-)partitioned, and the node recovers without manual
|
||||||
|
intervention.
|
||||||
|
- Forcing a partition on ONE node (e.g. `wg set <WG> peer <PK>
|
||||||
|
remove` in a test netns) does not disturb the other nodes' automation.
|
||||||
|
- Fleet polling loops are measurably staggered in logs.
|
||||||
|
|
||||||
|
Acceptance for Option A:
|
||||||
|
- The 5 egress IPs are pairwise distinct AND stable across 24h
|
||||||
|
(hourly probe, logged).
|
||||||
|
- Rate-limiting one node's IP (if testable) leaves the others unaffected.
|
||||||
|
|
||||||
|
## 9. Rollback
|
||||||
|
|
||||||
|
- **Option C rollback:** revert the watchdog/API commits; the change is
|
||||||
|
purely additive to health checking — old behavior restored on next
|
||||||
|
watchdog cycle. No credential or network state to unwind.
|
||||||
|
- **Identity/config rollback:** `/root/netvm-backup-<date>/` holds the
|
||||||
|
pre-change `/etc/netvm/*.conf` copies; restore and re-run
|
||||||
|
`netvm-node-up.sh <node>` per node.
|
||||||
|
- **Option A rollback:** swap each node's WireGuard config back to the
|
||||||
|
wgcf consumer conf (kept in backup), re-run `netvm-node-up.sh`.
|
||||||
|
|
||||||
|
## 10. Open items (human-gated)
|
||||||
|
|
||||||
|
1. Option A requires a paid Cloudflare Zero Trust plan + dedicated egress
|
||||||
|
IP add-on — purchase decision belongs to the user.
|
||||||
|
2. If Cloudflare hard-blocks 104.28.195.181 (not just throttles), Option C
|
||||||
|
detects it but cannot route around it — that event should trigger the
|
||||||
|
Option A conversation.
|
||||||
|
3. `def` node is unassigned; include it in the rollout for uniformity, or
|
||||||
|
explicitly exclude it and note why.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# dm.py Cross-Operator DMs — Operator Runbook
|
||||||
|
|
||||||
|
## The `--to` Flag
|
||||||
|
|
||||||
|
Send messages between operators (opm, 646, pip, muse) using the recipient's
|
||||||
|
browser session. The sender is attributed via `[from <agent>]` prefix.
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
```
|
||||||
|
dm.py send --agent <sender> --to <recipient> --target <chat> "message"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--agent`: Sender identity (for `[from X]` attribution)
|
||||||
|
- `--to`: Recipient operator (whose browser/chat to post to)
|
||||||
|
- `--target`: `main` or a side chat name (must exist)
|
||||||
|
- Without `--to`: sends to own chat (legacy behavior)
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**opm → 646 (main chat):**
|
||||||
|
```
|
||||||
|
dm.py send --agent opm --to 646 --target main "hello from opm"
|
||||||
|
# Posts to 646's main as: [from opm] [uuid] hello from opm
|
||||||
|
```
|
||||||
|
|
||||||
|
**646 → opm (main chat):**
|
||||||
|
```
|
||||||
|
dm.py send --agent 646 --to opm --target main "hello from 646"
|
||||||
|
# Posts to opm's main as: [from 646] [uuid] hello from 646
|
||||||
|
```
|
||||||
|
|
||||||
|
**opm → 646 (side chat, must exist):**
|
||||||
|
```
|
||||||
|
dm.py send --agent opm --to 646 --target dm-opm "side thread msg"
|
||||||
|
# Posts to 646's 'dm-opm' side chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading
|
||||||
|
```
|
||||||
|
dm.py read --agent 646 --target main --n 5
|
||||||
|
# Reads 646's main chat (use recipient's --agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verified Working (2026-10-03)
|
||||||
|
- opm → 646 via main: ✓ [aea2809b]
|
||||||
|
- 646 → opm via main: ✓ [2d4fdb1d]
|
||||||
|
- Attribution `[from X]` appears correctly both ways
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- **No `sidechat create`**: Target side chats must exist. Create via UI manually.
|
||||||
|
`muse-chat-api.py` has only `use`/`main`/`list` — no create API found.
|
||||||
|
- **Delivery NOT confirmed**: `SENT` means posted to DOM, not verified by recipient.
|
||||||
|
Confirm via independent read or recipient ack.
|
||||||
|
- **Backups**: `dm.py.bak` (pre---to), `dm.py.bak-20261003-toflag` (646's NameError fix)
|
||||||
|
|
||||||
|
### Design Notes
|
||||||
|
- DM via `dm.py` is the primary operator plane.
|
||||||
|
- Side chats preferred for direct calls (efficient, autonomous, threaded).
|
||||||
|
- Main chat for broadcasts or when side chat doesn't exist.
|
||||||
|
- Everything else (board, etc.) is fallback if DM goes down.
|
||||||
+33
-2
@@ -3,6 +3,16 @@
|
|||||||
"thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2",
|
"thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2",
|
||||||
"agent": "pip"
|
"agent": "pip"
|
||||||
},
|
},
|
||||||
|
"646-pip-coord@646": {
|
||||||
|
"thread_uuid": "e3442b78-0300-4034-8cbe-7a0ab6c5764a",
|
||||||
|
"agent": "646",
|
||||||
|
"description": "646 side of 646-pip coordination pair"
|
||||||
|
},
|
||||||
|
"646-pip-coord@pip": {
|
||||||
|
"thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2",
|
||||||
|
"agent": "pip",
|
||||||
|
"description": "pip side of 646-pip coordination pair"
|
||||||
|
},
|
||||||
"646-opm-coord": {
|
"646-opm-coord": {
|
||||||
"thread_uuid": "4139dd4e-96fe-4222-a99f-82a59a7b0eeb",
|
"thread_uuid": "4139dd4e-96fe-4222-a99f-82a59a7b0eeb",
|
||||||
"agent": "opm"
|
"agent": "opm"
|
||||||
@@ -43,13 +53,34 @@
|
|||||||
"created_at": "2026-10-04T17:17:48.449687+00:00"
|
"created_at": "2026-10-04T17:17:48.449687+00:00"
|
||||||
},
|
},
|
||||||
"646-pip": {
|
"646-pip": {
|
||||||
"thread_uuid": "75feb3a2-be36-499a-b314-bfd5803f1db0",
|
"thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2",
|
||||||
"agent": "pip",
|
"agent": "pip",
|
||||||
"created_at": "2026-10-04T18:01:17.397213+00:00"
|
"description": "default 646-pip coordination target"
|
||||||
},
|
},
|
||||||
"646 tasks": {
|
"646 tasks": {
|
||||||
"thread_uuid": "1dfb3199-2f99-446c-83e3-848ae2da0a12",
|
"thread_uuid": "1dfb3199-2f99-446c-83e3-848ae2da0a12",
|
||||||
"agent": "646",
|
"agent": "646",
|
||||||
"created_at": "2026-10-04T18:30:21.863666+00:00"
|
"created_at": "2026-10-04T18:30:21.863666+00:00"
|
||||||
|
},
|
||||||
|
"pipe-c3f686": {
|
||||||
|
"thread_uuid": "afa977ba-c75b-4b3e-8c1f-f83f13fb287e",
|
||||||
|
"agent": "opm",
|
||||||
|
"created_at": "2026-10-04T20:13:56.485442+00:00"
|
||||||
|
},
|
||||||
|
"main-loop brain": {
|
||||||
|
"thread_uuid": "5f18476d-8994-49e7-a9e0-4838732363fe",
|
||||||
|
"agent": "opm",
|
||||||
|
"created_at": "2026-10-04T19:40:31.161131+00:00",
|
||||||
|
"purpose": "Main loop coordination: health reports, digests, alerts"
|
||||||
|
},
|
||||||
|
"646-pip@646": {
|
||||||
|
"thread_uuid": "e3442b78-0300-4034-8cbe-7a0ab6c5764a",
|
||||||
|
"agent": "646",
|
||||||
|
"description": "646 side of 646-pip coordination pair"
|
||||||
|
},
|
||||||
|
"646-pip@pip": {
|
||||||
|
"thread_uuid": "4466d0c1-7961-4cf3-b99d-1ab7c38484c2",
|
||||||
|
"agent": "pip",
|
||||||
|
"description": "pip side of 646-pip coordination pair"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"646","chain_next":null,"description":"Daily check-in pinging 646 in her sidechat (job-driven sidechat adoption)","dm_target":"646 tasks","followup":{"escalate":"opm","expect_reply":true,"nudges":2,"route":"646-tasks","timeout":"4h"},"name":"646-daily-checkin","on_failure":"alert","prompt_template":"Daily check-in for 646.\nJob ID: {job_id}\nTime: {datetime}\n\nPlease reply in this thread with:\n[RESULT {job_id}] CHECKIN\n1. Shipped: what did you complete since last check-in?\n2. In flight: what are you working on now?\n3. Blocked: anything stuck or needing help?\n\nKeep it brief. Reply here, not in main chat.","schedule":"0 9 * * *","sidechat":{"create":false},"timeout":300}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "646-exec-health",
|
||||||
|
"agent": "646",
|
||||||
|
"description": "Monitor exec server health every 10 minutes",
|
||||||
|
"prompt_template": "Exec server health check.\nJob ID: {job_id}\nTime: {datetime}\n\nCheck https://34-139-37-135.sslip.io/exec/health and report status. Reply with [RESULT {job_id}] OK/FAIL.",
|
||||||
|
"schedule": "*/10 * * * *",
|
||||||
|
"timeout": 120,
|
||||||
|
"on_failure": "alert",
|
||||||
|
"dm_target": "646 tasks",
|
||||||
|
"sidechat": {"create": false},
|
||||||
|
"chain_next": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "646-opm-watch",
|
||||||
|
"agent": "646",
|
||||||
|
"description": "Monitor opm main chat for DM system work",
|
||||||
|
"prompt_template": "Check opm's main chat for new DM system work (box API, job dispatcher, exec server). Report any new commits, endpoints, or decisions. Job ID: {job_id}",
|
||||||
|
"schedule": "manual",
|
||||||
|
"timeout": 300,
|
||||||
|
"on_failure": "alert",
|
||||||
|
"dm_target": "646 tasks",
|
||||||
|
"sidechat": {"create": false},
|
||||||
|
"chain_next": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "646-pip-sync",
|
||||||
|
"agent": "pip",
|
||||||
|
"description": "Sync with pip on operator launch status",
|
||||||
|
"prompt_template": "Operator sync from 646.\nJob ID: {job_id}\nTime: {datetime}\n\nPlease report: (1) your current task status, (2) any blockers, (3) readiness for the new operator launch. Reply with [RESULT {job_id}] and your status.",
|
||||||
|
"schedule": "manual",
|
||||||
|
"timeout": 300,
|
||||||
|
"on_failure": "alert",
|
||||||
|
"dm_target": "646-pip-coord",
|
||||||
|
"sidechat": {"create": false},
|
||||||
|
"chain_next": null
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"646","chain_next":null,"description":"Small task requiring 646 to reply in her sidechat thread (job-driven sidechat adoption)","dm_target":"646 tasks","followup":{"escalate":"opm","expect_reply":true,"nudges":2,"route":"646-tasks","timeout":"1h"},"name":"646-sidechat-task","on_failure":"alert","prompt_template":"Sidechat task for 646.\nJob ID: {job_id}\nTime: {datetime}\n\nPlease confirm you are receiving this in your sidechat thread by replying with:\n[RESULT {job_id}] CONFIRMED - I see sidechat messages\n\nThen tell me: what is the most important task on your plate right now?\n\nReply in this thread (not main chat).","schedule":"manual","sidechat":{"create":false},"timeout":300}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"646","chain_next":null,"description":"Box service + data health — every 15 minutes (offset 7m)","dm_target":"646 tasks","followup":{"escalate":"opm","expect_reply":true,"nudges":2,"route":"box-health","timeout":"30m"},"name":"box-service-health","on_failure":"alert","prompt_template":"Box service health check.\nJob ID: {job_id}\nTime: {datetime}\n\nRun the Box service and data checks on the VM:\n ssh dev-operator-646@34.139.37.135 \"/srv/box/bin/box-health-check.sh services\"\n ssh dev-operator-646@34.139.37.135 \"/srv/box/bin/box-health-check.sh data\"\n(or run /srv/box/bin/box-health-check.sh via any VM access you have)\n\nExpected: board.service active, caddy active, sweeper timer firing,\n/srv/box writable. If any FAIL, investigate and include failure lines.\n\nReply with [RESULT {job_id}] OK or [RESULT {job_id}] FAIL <one-line summary>.","schedule":"7,22,37,52 * * * *","sidechat":{"create":false},"timeout":600}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Thread keepalive config. Each entry registers one side chat for keepalive.",
|
||||||
|
"threads": [
|
||||||
|
{
|
||||||
|
"key": "heartbeat-opm",
|
||||||
|
"agent": "opm",
|
||||||
|
"enabled": true,
|
||||||
|
"ping_interval_s": 300,
|
||||||
|
"idle_threshold_s": 3600,
|
||||||
|
"max_retries": 3,
|
||||||
|
"ping_message": "[keepalive:heartbeat-opm] tick {ts} -- thread alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ops-watch",
|
||||||
|
"agent": "opm",
|
||||||
|
"enabled": false,
|
||||||
|
"ping_interval_s": 600,
|
||||||
|
"idle_threshold_s": 7200,
|
||||||
|
"max_retries": 3,
|
||||||
|
"ping_message": "[keepalive:ops-watch] tick {ts}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "646-coord",
|
||||||
|
"agent": "646",
|
||||||
|
"enabled": false,
|
||||||
|
"ping_interval_s": 600,
|
||||||
|
"idle_threshold_s": 7200,
|
||||||
|
"max_retries": 3,
|
||||||
|
"ping_message": "[keepalive:646-coord] tick {ts}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Identity variance probe cycle (per-node Warp egress behavior)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=super
|
||||||
|
ExecStart=/usr/bin/python3 /home/super/Projects/NetVM/bin/identity-variance-test.py probe
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=best-effort
|
||||||
|
IOSchedulingPriority=7
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run identity variance probe every 10 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
RandomizedDelaySec=90
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user