fix: placement verification and sidechat policy enforcement
- dm.py: placement-aware verify (verify_placement), checked_uuid logging, placement_mismatch events - main-chat-watchdog.py: P0 alerts on placement_mismatch - box-ctl.py: notify routes to sidechat, box policy command - jobs: ops-audit and pipe-demo use sidechats - response-harvester.py: chain deduplication Session: sidechat/ops-restore
This commit is contained in:
@@ -17,14 +17,18 @@ after 3 attempts. `send --raw` transmits verbatim (for pre-signed
|
||||
messages): no tagging, no truncation — the signed payload must survive
|
||||
byte-identical.
|
||||
|
||||
Policy (2026-10-04): sidechat-first. Main-chat sends are refused unless
|
||||
--allow-main-chat is passed explicitly. Never saturate main threads by
|
||||
default; use a sidechat target instead.
|
||||
|
||||
Usage:
|
||||
dm.py send --agent opm --to 646 --target main "message"
|
||||
dm.py send --agent opm --target main --raw "$(dm-sign.sh --from operator-main 'hi')"
|
||||
dm.py verify-sig --agent opm --target main # scan recent reads for signed DMs
|
||||
dm.py send --agent opm --to 646 --target "646 tasks" "message"
|
||||
dm.py send --agent opm --target main --allow-main-chat --raw "$(dm-sign.sh --from operator-main 'hi')"
|
||||
dm.py verify-sig --agent opm --target "646 tasks" # scan recent reads for signed DMs
|
||||
dm.py verify-sig "$(dm-sign.sh --from operator-main 'hi')" # verify text directly
|
||||
dm.py read --agent 646 --target main [n]
|
||||
dm.py read --agent 646 --target "646 tasks" [n]
|
||||
dm.py log [--n 20]
|
||||
dm.py thread --from opm --to 646 --target main "message"
|
||||
dm.py thread --from opm --to 646 --target "646 tasks" "message"
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -203,12 +207,16 @@ LOG_FILE = "/home/super/Projects/NetVM/dm-log.jsonl"
|
||||
SIDCHAT_ALIASES = {
|
||||
"646-opm-work": "d410b9ad-f667-465f-a103-43fabc0f69fe",
|
||||
"646 tasks": "1e75a740-d08f-443d-a0f9-793db196e24f",
|
||||
"heartbeat": "1e75a740-d08f-443d-a0f9-793db196e24f",
|
||||
"heartbeat": "0077e918-9ac8-40ba-b0ad-65f9f78037c4", # dedicated heartbeat sidechat (was colliding with 646 tasks)
|
||||
}
|
||||
|
||||
def resolve_sidechat_target(target):
|
||||
"""Resolve target alias or name to UUID dynamically from job-sidechats.json."""
|
||||
if not target or target == "main":
|
||||
if not target or not str(target).strip():
|
||||
raise ValueError(
|
||||
"DM target must not be empty: specify a sidechat name/UUID, "
|
||||
"or main (main requires --allow-main-chat)")
|
||||
if target == "main":
|
||||
return target
|
||||
if re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target.lower()):
|
||||
return target.lower()
|
||||
@@ -229,6 +237,32 @@ def resolve_sidechat_target(target):
|
||||
return target
|
||||
|
||||
|
||||
def resolve_sidechat_source(target):
|
||||
"""Return where a target resolved from, for placement auditing.
|
||||
|
||||
Values: main / direct_uuid / static_alias / dynamic_mapping / passthrough.
|
||||
Mirrors resolve_sidechat_target() logic without changing its signature.
|
||||
"""
|
||||
if not target or not str(target).strip():
|
||||
return "invalid"
|
||||
if target == "main":
|
||||
return "main"
|
||||
if re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", target.lower()):
|
||||
return "direct_uuid"
|
||||
if target in SIDCHAT_ALIASES:
|
||||
return "static_alias"
|
||||
sc_file = "/home/super/Projects/NetVM/job-sidechats.json"
|
||||
if os.path.exists(sc_file):
|
||||
try:
|
||||
with open(sc_file, "r", encoding="utf-8") as f:
|
||||
sc_data = json.load(f)
|
||||
if sc_data.get(target):
|
||||
return "dynamic_mapping"
|
||||
except Exception:
|
||||
pass
|
||||
return "passthrough"
|
||||
|
||||
|
||||
def log_event(event):
|
||||
"""Append to JSONL log."""
|
||||
event["ts"] = datetime.now(timezone.utc).isoformat()
|
||||
@@ -250,8 +284,81 @@ def run_full(cmd, timeout=60):
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
|
||||
def verify_placement(recipient, msg_id, target, thread_uuid):
|
||||
"""Authoritative post-send placement verification (2026-10-04).
|
||||
|
||||
The send/verify loop only proves the message exists in whichever chat
|
||||
the browser was parked on at read time. A silent navigation drift (the
|
||||
SPA restoring main chat after a thread-URL navigation, a stale DOM read)
|
||||
used to produce verified:true for messages that actually landed in main.
|
||||
|
||||
This re-navigates to the target by direct URL, asserts the post-nav URL
|
||||
matches the target, then reads the chat back and asserts the message ID
|
||||
is present IN THAT CHAT. Returns (ok, detail). Read-only retries only --
|
||||
never resends: a misplaced send must fail loudly, not be duplicated.
|
||||
"""
|
||||
detail = {"target": target, "thread_uuid": thread_uuid}
|
||||
try:
|
||||
if target == "main":
|
||||
_rc, _out, _err = run_full(
|
||||
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
|
||||
if _rc != 0:
|
||||
detail.update({"reason": "nav_failed", "rc": _rc, "err": _err[:200]})
|
||||
return False, detail
|
||||
time.sleep(2)
|
||||
_u_rc, _u_out, _u_err = run_full(
|
||||
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url")
|
||||
detail["actual_url"] = _u_out[:200]
|
||||
if "/thread/" in _u_out:
|
||||
detail["reason"] = "url_mismatch"
|
||||
detail["expected"] = "main chat (no /thread/ in URL)"
|
||||
return False, detail
|
||||
else:
|
||||
expected_uuid = (thread_uuid or "").lower()
|
||||
detail["expected_uuid"] = expected_uuid or None
|
||||
if not expected_uuid:
|
||||
detail["reason"] = "no_thread_uuid"
|
||||
return False, detail
|
||||
# Direct-URL navigation: sidechat use <uuid> sets
|
||||
# window.location.href to /thread/<uuid> and confirms it.
|
||||
_rc, _out, _err = run_full(
|
||||
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat use {expected_uuid}")
|
||||
if _rc != 0 or "NOTFOUND" in _out or expected_uuid not in _out.lower():
|
||||
detail.update({"reason": "nav_failed", "rc": _rc,
|
||||
"out": _out[:200], "err": _err[:200]})
|
||||
return False, detail
|
||||
time.sleep(2)
|
||||
# Independent URL assertion: the nav command's own confirm read
|
||||
# the same window.location.href, so sample it again here.
|
||||
_u_rc, _u_out, _u_err = run_full(
|
||||
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url")
|
||||
detail["actual_url"] = _u_out[:200]
|
||||
if expected_uuid not in _u_out.lower():
|
||||
detail["reason"] = "url_mismatch"
|
||||
return False, detail
|
||||
# Read-back with read-only retries: the SPA may still be rendering
|
||||
# the thread after navigation. A miss here must NOT trigger a resend.
|
||||
for _r in range(3):
|
||||
try:
|
||||
check_msgs = run(
|
||||
f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200")
|
||||
except Exception as e:
|
||||
detail["read_error"] = str(e)[:100]
|
||||
time.sleep(2)
|
||||
continue
|
||||
if msg_id in check_msgs:
|
||||
return True, detail
|
||||
time.sleep(2)
|
||||
detail["reason"] = "msg_not_in_target_chat"
|
||||
return False, detail
|
||||
except Exception as e:
|
||||
detail["reason"] = "exception"
|
||||
detail["error"] = str(e)[:200]
|
||||
return False, detail
|
||||
|
||||
def dm_send(agent, target, message, verify=True, raw=False,
|
||||
to_agent=None, tags=None, nudge_meta=None):
|
||||
to_agent=None, tags=None, nudge_meta=None,
|
||||
allow_main_chat=False):
|
||||
"""Send a DM. raw=True sends verbatim (for pre-signed messages from
|
||||
dm-sign.sh, which already carry [from:X] [id:Y]): no tagging, no
|
||||
truncation. Non-raw messages are tagged [from:<agent>] [id:<uuid8>]."""
|
||||
@@ -275,9 +382,39 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
# Single unified attribution format (matches verify-sig's regex).
|
||||
tagged = f"[from:{agent}] [id:{msg_id}] {message}"
|
||||
|
||||
# Empty target is an error, not a silent main redirect (2026-10-04).
|
||||
# Only explicit --allow-main-chat reaches main.
|
||||
if not target or not str(target).strip():
|
||||
print(f"ERROR: DM target must not be empty (agent={agent} to={recipient}). "
|
||||
f"Specify a sidechat name/UUID, or main with --allow-main-chat.",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
# Sidechat-first policy (2026-10-04): refuse main-chat sends unless the
|
||||
# caller explicitly opted in. Main threads must never be saturated by
|
||||
# default; use a sidechat target instead.
|
||||
if target == "main" and not allow_main_chat:
|
||||
log_event({"type": "main_chat_blocked", "id": msg_id, "agent": agent,
|
||||
"to": recipient, "target": target})
|
||||
print(f"ERROR: Refusing main-chat send by policy (agent={agent} to={recipient}). "
|
||||
f"Use a sidechat target, or pass --allow-main-chat for explicit main-chat sends.",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Canonical follow-up tags: trailing [bracket] tokens are metadata,
|
||||
# stripped from the delivered text, recorded on the log events.
|
||||
tags = tags or {}
|
||||
# Sidechat-first policy audit marker (2026-10-04): record explicit
|
||||
# main-chat opt-in so the main-chat watchdog can distinguish
|
||||
# authorized main sends from policy violations.
|
||||
if allow_main_chat and target == "main":
|
||||
tags["allow_main_chat"] = True
|
||||
# Extract job_id from [JOB <id>] marker for followup correlation.
|
||||
# This lets the harvester resolve followups by job_id when a
|
||||
# [RESULT <job_id>] reply arrives, even if thread_uuid is null.
|
||||
if "job_id" not in tags:
|
||||
_jm = re.search(r'\[JOB\s+([A-Za-z0-9_-]+)\]', message)
|
||||
if _jm:
|
||||
tags["job_id"] = _jm.group(1)
|
||||
if not raw:
|
||||
message, text_tags = extract_trailing_tags(message)
|
||||
if text_tags:
|
||||
@@ -301,7 +438,8 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
# Resolve well-known aliases or dynamic thread mappings to UUIDs.
|
||||
nav_target = resolve_sidechat_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)})
|
||||
if target == "main":
|
||||
_rc, _out, _err = run_full(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main")
|
||||
else:
|
||||
@@ -333,7 +471,8 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
sys.exit(1)
|
||||
is_new_sidechat = True
|
||||
log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient,
|
||||
"target": target, "status": "sidechat_created_pending_uuid"})
|
||||
"target": target, "status": "sidechat_created_pending_uuid",
|
||||
"browser_url": thread_url})
|
||||
else:
|
||||
m = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", _out, re.I)
|
||||
if m:
|
||||
@@ -358,7 +497,8 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
log_event({"type": "nav_ok", "id": msg_id, "agent": agent, "to": recipient,
|
||||
"target": target, "thread_uuid": thread_uuid})
|
||||
"target": target, "thread_uuid": thread_uuid,
|
||||
"browser_url": thread_url})
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
@@ -372,6 +512,7 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
# so we verify by reading the recipient's chat for our message ID.
|
||||
max_retries = 3
|
||||
delivered = False
|
||||
verified_attempt = None
|
||||
for attempt in range(max_retries):
|
||||
send_out = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"',
|
||||
priority="high")
|
||||
@@ -408,8 +549,28 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
check_msgs = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200')
|
||||
if msg_id in check_msgs:
|
||||
delivered = True
|
||||
log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient, "target": target,
|
||||
"thread_uuid": thread_uuid, "attempt": attempt + 1})
|
||||
verified_attempt = attempt + 1
|
||||
# Capture the actual browser URL at verify time. `messages`
|
||||
# reads whichever chat the browser is currently parked on; if
|
||||
# that isn't the thread we navigated to, the send went to the
|
||||
# wrong chat (placement-blindness, 2026-10-04). This is only
|
||||
# an early signal -- the authoritative placement check runs
|
||||
# after this loop, and verified:true is only logged there.
|
||||
_v_rc, _v_out, _v_err = run_full(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} url')
|
||||
_v_m = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", _v_out, re.I)
|
||||
checked_uuid = _v_m.group(1).lower() if _v_m else None
|
||||
if target == "main":
|
||||
if checked_uuid:
|
||||
# Browser was parked in a sidechat thread while
|
||||
# sending to main: the message likely went to the
|
||||
# sidechat, not main.
|
||||
log_event({"type": "placement_mismatch", "id": msg_id, "agent": agent, "to": recipient,
|
||||
"target": target, "expected": "main", "actual_uuid": checked_uuid,
|
||||
"attempt": attempt + 1})
|
||||
elif checked_uuid and thread_uuid and checked_uuid != thread_uuid:
|
||||
log_event({"type": "placement_mismatch", "id": msg_id, "agent": agent, "to": recipient,
|
||||
"target": target, "expected_uuid": thread_uuid, "actual_uuid": checked_uuid,
|
||||
"attempt": attempt + 1})
|
||||
break
|
||||
else:
|
||||
log_event({"type": "retry", "id": msg_id, "agent": agent, "to": recipient, "attempt": attempt + 1})
|
||||
@@ -458,6 +619,29 @@ def dm_send(agent, target, message, verify=True, raw=False,
|
||||
if delivered and thread_uuid and "thread" not in tags:
|
||||
tags["thread"] = thread_uuid
|
||||
|
||||
if delivered:
|
||||
# Authoritative placement verification (2026-10-04): the loop above
|
||||
# only proved the message exists in whichever chat the browser was
|
||||
# parked on. Re-navigate to the target by direct URL, assert the
|
||||
# post-nav URL, and read the message back IN THAT CHAT before
|
||||
# logging verified:true. A failure here means the message landed in
|
||||
# the wrong chat: fail loudly (do NOT resend -- that would
|
||||
# duplicate the misplaced message).
|
||||
placed, pdetail = verify_placement(recipient, msg_id, target, thread_uuid)
|
||||
if not placed:
|
||||
log_event({"type": "placement_failed", "id": msg_id, "agent": agent,
|
||||
"to": recipient, "loop_attempt": verified_attempt, **pdetail})
|
||||
log_event({"type": "failed", "id": msg_id, "agent": agent, "to": recipient,
|
||||
"target": target, "verified": False, "reason": "placement_failed"})
|
||||
print(f"DM {msg_id} from {agent} to {recipient}/{target}: FAILED "
|
||||
f"(placement check failed: {pdetail.get('reason')}; "
|
||||
f"expected={pdetail.get('expected_uuid') or pdetail.get('expected')}; "
|
||||
f"actual_url={pdetail.get('actual_url')})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
log_event({"type": "verified", "id": msg_id, "agent": agent, "to": recipient,
|
||||
"target": target, "thread_uuid": thread_uuid,
|
||||
"placement": "confirmed", "attempt": verified_attempt})
|
||||
|
||||
# Note: Leave recipient browser parked in the sidechat thread to preserve Main Chat DOM
|
||||
log_event({"type": "send_done", "id": msg_id, "agent": agent, "to": recipient, "target": target})
|
||||
|
||||
@@ -646,20 +830,24 @@ def dm_log(n=20):
|
||||
e = json.loads(line)
|
||||
print(f"{e['ts'][:19]} {e.get('type','?'):12} {e.get('id','-'):8} {e.get('agent','-')}/{e.get('target','-')}")
|
||||
|
||||
def dm_thread(from_agent, to_agent, target, message):
|
||||
def dm_thread(from_agent, to_agent, target, message, allow_main_chat=False):
|
||||
"""Thread from one agent to another. Attribution is applied exactly once,
|
||||
in the unified [from:X] [id:Y] format, by dm_send."""
|
||||
return dm_send(from_agent, target, message, to_agent=to_agent)
|
||||
return dm_send(from_agent, target, message, to_agent=to_agent,
|
||||
allow_main_chat=allow_main_chat)
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="DM: Headless Direct Messages (tagged [from:X] [id:Y], logged; delivery confirmed by recipient read-back)")
|
||||
p = argparse.ArgumentParser(description="DM: Headless Direct Messages (tagged [from:X] [id:Y], logged; delivery confirmed by recipient read-back). Sidechat-first policy: --target main requires --allow-main-chat.")
|
||||
sub = p.add_subparsers(dest='cmd', required=True)
|
||||
|
||||
ps = sub.add_parser('send', help='Send a DM (tagged; delivery confirmed by recipient read-back, up to 3 attempts)')
|
||||
ps.add_argument('--agent', required=True, choices=VALID_SENDERS)
|
||||
ps.add_argument('--to', required=False, choices=VALID_RECIPIENTS, default=None,
|
||||
help='Recipient operator (for cross-operator DMs). Uses recipient\'s browser/chat.')
|
||||
ps.add_argument('--target', required=True)
|
||||
ps.add_argument('--target', required=True,
|
||||
help='Conversation: sidechat name or thread UUID. Sidechat-first policy: --target main requires --allow-main-chat.')
|
||||
ps.add_argument('--allow-main-chat', action='store_true',
|
||||
help='Explicit opt-in for main-chat sends (refused by default per sidechat-first policy)')
|
||||
ps.add_argument('--no-verify', action='store_true',
|
||||
help='Accepted for compatibility but ignored: recipient-side read-back verification always runs.')
|
||||
ps.add_argument('--raw', action='store_true', help='Send verbatim: no tagging, no truncation (for pre-signed messages from dm-sign.sh)')
|
||||
@@ -698,7 +886,8 @@ def main():
|
||||
sys.exit(2)
|
||||
return dm_send(a.agent, a.target, a.message, verify=not a.no_verify,
|
||||
raw=a.raw, to_agent=a.to, tags=tags,
|
||||
nudge_meta=nudge_meta)
|
||||
nudge_meta=nudge_meta,
|
||||
allow_main_chat=a.allow_main_chat)
|
||||
ps.set_defaults(func=_send_with_tags)
|
||||
|
||||
psel = sub.add_parser('select', help='Select active conversation')
|
||||
@@ -726,9 +915,13 @@ def main():
|
||||
pt = sub.add_parser('thread', help='Thread from one agent to another')
|
||||
pt.add_argument('--from', dest='from_agent', required=True, choices=VALID_SENDERS)
|
||||
pt.add_argument('--to', dest='to_agent', required=True, choices=VALID_RECIPIENTS)
|
||||
pt.add_argument('--target', required=True)
|
||||
pt.add_argument('--target', required=True,
|
||||
help='Conversation: sidechat name or thread UUID. Sidechat-first policy: main requires --allow-main-chat.')
|
||||
pt.add_argument('--allow-main-chat', action='store_true',
|
||||
help='Explicit opt-in for main-chat sends (refused by default per sidechat-first policy)')
|
||||
pt.add_argument('message')
|
||||
pt.set_defaults(func=lambda a: dm_thread(a.from_agent, a.to_agent, a.target, a.message))
|
||||
pt.set_defaults(func=lambda a: dm_thread(a.from_agent, a.to_agent, a.target, a.message,
|
||||
allow_main_chat=a.allow_main_chat))
|
||||
|
||||
args = p.parse_args()
|
||||
args.func(args)
|
||||
@@ -771,7 +964,7 @@ def _register_followup(msg_id, agent, recipient, target, tags):
|
||||
if tags.get("reply:expected"):
|
||||
tags_obj["reply:expected"] = True
|
||||
for key in ("reply:timeout", "reply:nudges", "reply:escalate",
|
||||
"route", "thread"):
|
||||
"route", "thread", "job_id"):
|
||||
if key in tags:
|
||||
tags_obj[key] = tags[key]
|
||||
|
||||
@@ -824,6 +1017,7 @@ def _register_followup(msg_id, agent, recipient, target, tags):
|
||||
"recipient": recipient,
|
||||
"target": target,
|
||||
"thread_uuid": tags.get("thread"),
|
||||
"job_id": tags.get("job_id"),
|
||||
"route": tags.get("route"),
|
||||
"sent_at": now_dt.isoformat(),
|
||||
"deadline": deadline_dt.isoformat(),
|
||||
|
||||
Reference in New Issue
Block a user