diff --git a/bin/dm-sign.sh b/bin/dm-sign.sh new file mode 100755 index 0000000..f661fbe --- /dev/null +++ b/bin/dm-sign.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# dm-sign.sh — produce a signed DM for the fleet DM system. +# +# Signs a message with an SSH key (namespace "dm", Ed25519) and prints the +# signed wire format that `dm.py verify-sig` checks: +# +# [from:] [id:] +# +# +# +# -----BEGIN SSH SIGNATURE----- +# ... +# -----END SSH SIGNATURE----- +# +# The signed payload is exactly "[from:X] [id:Y]\n\n" (no trailing +# newline) — verify-sig reconstructs it by stripping everything from the +# signature block onward, so the two must match byte-for-byte. +# +# Usage: +# dm-sign.sh --from [--key ] [--id ] +# +# Defaults: --key ~/.ssh/id_frontdoor, --id = 8 random hex chars. +# The private key is only ever read locally; it is never moved or copied. +# Pipe the output straight into `dm.py send --raw` (never truncate it). +# +# Example: +# dm.py send --agent opm --target main --raw \ +# "$(dm-sign.sh --from operator-main 'hello from the operator')" +set -euo pipefail + +FROM="" +KEY="$HOME/.ssh/id_frontdoor" +ID="$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' \n')" + +usage() { + sed -n '2,/^set -euo/p' "$0" | sed 's/^# \?//' +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) FROM="${2:?--from needs a value}"; shift 2 ;; + --key) KEY="${2:?--key needs a value}"; shift 2 ;; + --id) ID="${2:?--id needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "error: unknown option: $1" >&2; exit 1 ;; + *) break ;; + esac +done + +if [[ $# -eq 0 ]]; then + echo "error: no message given" >&2 + echo "usage: dm-sign.sh --from [--key ] [--id ] " >&2 + exit 1 +fi +MESSAGE="$*" + +[[ -n "$FROM" ]] || { echo "error: --from is required" >&2; exit 1; } +[[ -f "$KEY" ]] || { echo "error: private key not found: $KEY" >&2; exit 1; } + +TD="$(mktemp -d)" +trap 'rm -rf "$TD"' EXIT +PAYLOAD="$TD/payload" + +# Payload: header, blank line, body — NO trailing newline (verify-sig strips). +printf '[from:%s] [id:%s]\n\n%s' "$FROM" "$ID" "$MESSAGE" > "$PAYLOAD" + +# Never reuse a stale signature: a leftover .sig from an earlier run would +# silently sign the wrong payload (burned 20 minutes on the board, 2026-10-03). +rm -f "$PAYLOAD.sig" +ssh-keygen -Y sign -f "$KEY" -n dm "$PAYLOAD" >/dev/null + +printf '[from:%s] [id:%s]\n\n%s\n\n' "$FROM" "$ID" "$MESSAGE" +cat "$PAYLOAD.sig" diff --git a/bin/dm.py b/bin/dm.py index e9f4c27..d6193c8 100755 --- a/bin/dm.py +++ b/bin/dm.py @@ -1,23 +1,33 @@ #!/usr/bin/env python3 """ -DM: Headless Direct Message API (muse.ai) — with UUID tagging and logging. +DM: Headless Direct Message API (muse.ai) — tagged, logged, verifiable. -Every send generates a UUID and logs to dm-log.jsonl. -NOTE (2026-10-03): read-back verification was REMOVED. It only read the -sender's own headless DOM (local echo) and reported VERIFIED for messages -that never reached the server (confirmed: nothing in the web UI). SENT means -the send command ran — it is NOT proof of delivery. Confirm receipt from -an independent session (e.g. the web UI) instead. +Wire format (every send, signed or not): + [from:] [id:<8-hex>] + +Signed DMs (produced by bin/dm-sign.sh, namespace "dm") append the SSH +signature block after a blank line; `verify-sig` checks it against the +sender's key in dm-signers/.pub. An unsigned message carrying a +[from:X] header is just a claim — only a GOOD verify-sig result is proof. + +Every send is appended to dm-log.jsonl. Delivery is confirmed by reading +the RECIPIENT's chat for the message id (up to 3 attempts): SENT+VERIFIED +means the id was seen in the recipient's chat; FAILED means it wasn't +after 3 attempts. `send --raw` transmits verbatim (for pre-signed +messages): no tagging, no truncation — the signed payload must survive +byte-identical. Usage: - dm.py send --agent 646 --target \n dm.py send --agent opm --to 646 --target main "message" - dm.py send --agent pip --target main --raw "$(dm-sign.sh operator-646 'hi')" - dm.py verify-sig --agent pip --target main # verify signed DMs in recent reads - dm.py read --agent 646 --target [n] - dm.py verify --agent 646 --target + 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 verify-sig "$(dm-sign.sh --from operator-main 'hi')" # verify text directly + dm.py read --agent 646 --target main [n] dm.py log [--n 20] + dm.py thread --from opm --to 646 --target main "message" """ import argparse +import re import subprocess import time import sys @@ -49,12 +59,12 @@ def run_full(cmd, timeout=60): return result.returncode, result.stdout.strip(), result.stderr.strip() def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): - """Send a DM. raw=True sends verbatim (for pre-signed messages): no UUID - tag, no truncation.""" + """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:] [id:].""" # Cross-operator: to_agent is the recipient (whose browser/chat to use). # agent is the sender (for attribution). If to_agent is None, send to own chat. recipient = to_agent if to_agent else agent - sender_prefix = f"[from {agent}] " if (to_agent and to_agent != agent) else "" if agent not in VALID_AGENTS: print(f"ERROR: Unknown agent {agent}", file=sys.stderr) @@ -65,28 +75,30 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): if raw: tagged = message - msg_id = "raw" + m = re.search(r'\[id:([^\]]+)\]', message) + msg_id = m.group(1) if m else "raw" else: msg_id = str(uuid.uuid4())[:8] - # Embed ID in message for tracking - tagged = f"[{msg_id}] {message}" + # Single unified attribution format (matches verify-sig's regex). + tagged = f"[from:{agent}] [id:{msg_id}] {message}" log_event({"type": "send_start", "id": msg_id, "agent": agent, "to": recipient, "target": target, "msg": message[:100]}) - - # Navigate to target + + # Navigate the RECIPIENT's browser to the target chat (the send and the + # read-back both happen there; navigating the sender's browser was a bug + # for cross-operator side-chat targets). if target == "main": - run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main") + run(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") else: - run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {target}") - + run(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat use {target}") + time.sleep(2) - + # Send (raw mode: no truncation — signatures must survive intact) - tagged = sender_prefix + tagged safe = tagged.replace('"', '\\"').replace('$', '\\$').replace('`', '\\`') if not raw: safe = safe[:1000] - + # Send with verification retries # The underlying muse-chat-api.py send returns None/unreliable status, # so we verify by reading the recipient's chat for our message ID. @@ -95,7 +107,7 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): for attempt in range(max_retries): run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"') time.sleep(3) # Wait for message to propagate - + # Verify by reading recipient's chat (independent check, not local echo) try: check_msgs = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200') @@ -107,15 +119,15 @@ def dm_send(agent, target, message, verify=True, raw=False, to_agent=None): log_event({"type": "retry", "id": msg_id, "agent": agent, "to": recipient, "attempt": attempt + 1}) except Exception as e: log_event({"type": "verify_error", "id": msg_id, "error": str(e)[:100]}) - + if attempt < max_retries - 1: time.sleep(2) # Brief pause before retry - - # Back to main - run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main") - + + # Park the recipient's browser back on main + run(f"{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} sidechat main") + log_event({"type": "send_done", "id": msg_id, "agent": agent, "to": recipient, "target": target}) - + if delivered: log_event({"type": "sent", "id": msg_id, "agent": agent, "to": recipient, "target": target, "verified": True}) print(f"DM {msg_id} from {agent} to {recipient}/{target}: SENT and VERIFIED") @@ -131,12 +143,12 @@ def dm_read(agent, target, n=5, quiet=False, width=200): if agent not in VALID_AGENTS: print(f"ERROR: Unknown agent {agent}", file=sys.stderr) sys.exit(1) - + if target == "main": run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main") else: run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {target}") - + time.sleep(2) rc, msgs, err = run_full(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} messages {n} {width}") run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main") @@ -280,26 +292,28 @@ def dm_log(n=20): lines = f.readlines() for line in lines[-n:]: e = json.loads(line) - print(f"{e['ts'][:19]} {e['type']:12} {e.get('id','-'):8} {e.get('agent','-')}/{e.get('target','-')}") + 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): - attributed = f"[{from_agent}] {message}" - return dm_send(from_agent, target, attributed, to_agent=to_agent) + """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) def main(): - p = argparse.ArgumentParser(description="DM: Headless Direct Messages (UUID-tagged, logged; delivery NOT confirmed)") + p = argparse.ArgumentParser(description="DM: Headless Direct Messages (tagged [from:X] [id:Y], logged; delivery confirmed by recipient read-back)") sub = p.add_subparsers(dest='cmd', required=True) - - ps = sub.add_parser('send', help='Send a DM (UUID-tagged; delivery NOT confirmed)') + + 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_AGENTS) ps.add_argument('--to', required=False, choices=VALID_AGENTS, default=None, help='Recipient operator (for cross-operator DMs). Uses recipient\'s browser/chat.') ps.add_argument('--target', required=True) - ps.add_argument('--no-verify', action='store_true', help='Deprecated no-op: verification was removed') - ps.add_argument('--raw', action='store_true', help='Send verbatim: no UUID tag, no truncation (for pre-signed messages)') + 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)') ps.add_argument('message') ps.set_defaults(func=lambda a: dm_send(a.agent, a.target, a.message, verify=not a.no_verify, raw=a.raw, to_agent=a.to)) - + psel = sub.add_parser('select', help='Select active conversation') psel.add_argument('--agent', required=True, choices=VALID_AGENTS) psel.add_argument('--target', required=False, default=None, @@ -311,25 +325,24 @@ def main(): pr.add_argument('--target', required=True) pr.add_argument('--n', type=int, default=5) pr.set_defaults(func=lambda a: dm_read(a.agent, a.target, a.n)) - - pvs = sub.add_parser('verify-sig', help='Verify SSH signature on a signed DM') + + pvs = sub.add_parser('verify-sig', help='Verify SSH signature on a signed DM (pass message text, or --agent/--target to scan recent reads)') pvs.add_argument('--agent', required=False, choices=VALID_AGENTS) pvs.add_argument('--target', required=False) - pvs.add_argument('--from-sender', required=False, dest='from_sender') pvs.add_argument('message', nargs='?') pvs.set_defaults(func=lambda a: dm_verify_sig(message=a.message, agent=a.agent, target=a.target)) pl = sub.add_parser('log', help='Show DM log') pl.add_argument('--n', type=int, default=20) pl.set_defaults(func=lambda a: dm_log(a.n)) - + pt = sub.add_parser('thread', help='Thread from one agent to another') pt.add_argument('--from', dest='from_agent', required=True, choices=VALID_AGENTS) pt.add_argument('--to', dest='to_agent', required=True, choices=VALID_AGENTS) pt.add_argument('--target', required=True) pt.add_argument('message') pt.set_defaults(func=lambda a: dm_thread(a.from_agent, a.to_agent, a.target, a.message)) - + args = p.parse_args() args.func(args) diff --git a/dm-signers/operator-main.pub b/dm-signers/operator-main.pub new file mode 100644 index 0000000..cfd0784 --- /dev/null +++ b/dm-signers/operator-main.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2fFix4z5PB8ICKgo7Pk8JceWrNJeI0QGkVbwYp3Jpv operator-main