DM: signed-DM pipeline (dm-sign.sh) + attribution unification + honest docs
- New bin/dm-sign.sh: produce signed DMs (ssh-keygen -Y sign, namespace dm) in the [from:X] [id:Y] wire format that dm.py verify-sig checks. dm.py referenced it in usage but it never existed. - dm.py: rewrite stale docstring/argparse (claimed verification was removed / delivery unconfirmed — it does recipient-side read-back with 3 retries); unify all attribution on [from:X]/[id:Y] (was three formats: [from X], [X], [from:X]); fix dm_thread double-attribution; --no-verify kept as a documented no-op; raw mode sends verbatim and reuses the embedded [id:Y] for the audit log; navigation/send/park now all target the recipient browser (fixes cross-operator side-chat sends); drop dead --from-sender flag. - Register dm-signers/operator-main.pub. - Round-trip verified: sign (operator-main) -> send --raw via opm loopback -> read-back SENT+VERIFIED -> verify-sig GOOD.
This commit is contained in:
Executable
+74
@@ -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:<identity>] [id:<id>]
|
||||||
|
#
|
||||||
|
# <message body>
|
||||||
|
#
|
||||||
|
# -----BEGIN SSH SIGNATURE-----
|
||||||
|
# ...
|
||||||
|
# -----END SSH SIGNATURE-----
|
||||||
|
#
|
||||||
|
# The signed payload is exactly "[from:X] [id:Y]\n\n<body>" (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 <identity> [--key <privkey>] [--id <id>] <message>
|
||||||
|
#
|
||||||
|
# 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 <identity> [--key <privkey>] [--id <id>] <message>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MESSAGE="$*"
|
||||||
|
|
||||||
|
[[ -n "$FROM" ]] || { echo "error: --from <identity> 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"
|
||||||
@@ -1,23 +1,33 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
Wire format (every send, signed or not):
|
||||||
NOTE (2026-10-03): read-back verification was REMOVED. It only read the
|
[from:<sender>] [id:<8-hex>] <body>
|
||||||
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
|
Signed DMs (produced by bin/dm-sign.sh, namespace "dm") append the SSH
|
||||||
the send command ran — it is NOT proof of delivery. Confirm receipt from
|
signature block after a blank line; `verify-sig` checks it against the
|
||||||
an independent session (e.g. the web UI) instead.
|
sender's key in dm-signers/<sender>.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:
|
Usage:
|
||||||
dm.py send --agent 646 --target <chat_id|main>\n dm.py send --agent opm --to 646 --target main "message"
|
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 send --agent opm --target main --raw "$(dm-sign.sh --from operator-main 'hi')"
|
||||||
dm.py verify-sig --agent pip --target main # verify signed DMs in recent reads
|
dm.py verify-sig --agent opm --target main # scan recent reads for signed DMs
|
||||||
dm.py read --agent 646 --target <chat_id|main> [n]
|
dm.py verify-sig "$(dm-sign.sh --from operator-main 'hi')" # verify text directly
|
||||||
dm.py verify --agent 646 --target <chat_id|main> <uuid>
|
dm.py read --agent 646 --target main [n]
|
||||||
dm.py log [--n 20]
|
dm.py log [--n 20]
|
||||||
|
dm.py thread --from opm --to 646 --target main "message"
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import sys
|
import sys
|
||||||
@@ -49,12 +59,12 @@ def run_full(cmd, timeout=60):
|
|||||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||||
|
|
||||||
def dm_send(agent, target, message, verify=True, raw=False, to_agent=None):
|
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
|
"""Send a DM. raw=True sends verbatim (for pre-signed messages from
|
||||||
tag, no truncation."""
|
dm-sign.sh, which already carry [from:X] [id:Y]): no tagging, no
|
||||||
|
truncation. Non-raw messages are tagged [from:<agent>] [id:<uuid8>]."""
|
||||||
# Cross-operator: to_agent is the recipient (whose browser/chat to use).
|
# 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.
|
# agent is the sender (for attribution). If to_agent is None, send to own chat.
|
||||||
recipient = to_agent if to_agent else agent
|
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:
|
if agent not in VALID_AGENTS:
|
||||||
print(f"ERROR: Unknown agent {agent}", file=sys.stderr)
|
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:
|
if raw:
|
||||||
tagged = message
|
tagged = message
|
||||||
msg_id = "raw"
|
m = re.search(r'\[id:([^\]]+)\]', message)
|
||||||
|
msg_id = m.group(1) if m else "raw"
|
||||||
else:
|
else:
|
||||||
msg_id = str(uuid.uuid4())[:8]
|
msg_id = str(uuid.uuid4())[:8]
|
||||||
# Embed ID in message for tracking
|
# Single unified attribution format (matches verify-sig's regex).
|
||||||
tagged = f"[{msg_id}] {message}"
|
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]})
|
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":
|
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:
|
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)
|
time.sleep(2)
|
||||||
|
|
||||||
# Send (raw mode: no truncation — signatures must survive intact)
|
# Send (raw mode: no truncation — signatures must survive intact)
|
||||||
tagged = sender_prefix + tagged
|
|
||||||
safe = tagged.replace('"', '\\"').replace('$', '\\$').replace('`', '\\`')
|
safe = tagged.replace('"', '\\"').replace('$', '\\$').replace('`', '\\`')
|
||||||
if not raw:
|
if not raw:
|
||||||
safe = safe[:1000]
|
safe = safe[:1000]
|
||||||
|
|
||||||
# Send with verification retries
|
# Send with verification retries
|
||||||
# The underlying muse-chat-api.py send returns None/unreliable status,
|
# The underlying muse-chat-api.py send returns None/unreliable status,
|
||||||
# so we verify by reading the recipient's chat for our message ID.
|
# 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):
|
for attempt in range(max_retries):
|
||||||
run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"')
|
run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} send "{safe}"')
|
||||||
time.sleep(3) # Wait for message to propagate
|
time.sleep(3) # Wait for message to propagate
|
||||||
|
|
||||||
# Verify by reading recipient's chat (independent check, not local echo)
|
# Verify by reading recipient's chat (independent check, not local echo)
|
||||||
try:
|
try:
|
||||||
check_msgs = run(f'{NETVM_EXEC} {recipient} -- python3 {API} --account {recipient} messages 5 200')
|
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})
|
log_event({"type": "retry", "id": msg_id, "agent": agent, "to": recipient, "attempt": attempt + 1})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_event({"type": "verify_error", "id": msg_id, "error": str(e)[:100]})
|
log_event({"type": "verify_error", "id": msg_id, "error": str(e)[:100]})
|
||||||
|
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
time.sleep(2) # Brief pause before retry
|
time.sleep(2) # Brief pause before retry
|
||||||
|
|
||||||
# Back to main
|
# Park the recipient's browser back on main
|
||||||
run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat 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})
|
log_event({"type": "send_done", "id": msg_id, "agent": agent, "to": recipient, "target": target})
|
||||||
|
|
||||||
if delivered:
|
if delivered:
|
||||||
log_event({"type": "sent", "id": msg_id, "agent": agent, "to": recipient, "target": target, "verified": True})
|
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")
|
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:
|
if agent not in VALID_AGENTS:
|
||||||
print(f"ERROR: Unknown agent {agent}", file=sys.stderr)
|
print(f"ERROR: Unknown agent {agent}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
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:
|
||||||
run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {target}")
|
run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat use {target}")
|
||||||
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
rc, msgs, err = run_full(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} messages {n} {width}")
|
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")
|
run(f"{NETVM_EXEC} {agent} -- python3 {API} --account {agent} sidechat main")
|
||||||
@@ -280,26 +292,28 @@ def dm_log(n=20):
|
|||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
for line in lines[-n:]:
|
for line in lines[-n:]:
|
||||||
e = json.loads(line)
|
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):
|
def dm_thread(from_agent, to_agent, target, message):
|
||||||
attributed = f"[{from_agent}] {message}"
|
"""Thread from one agent to another. Attribution is applied exactly once,
|
||||||
return dm_send(from_agent, target, attributed, to_agent=to_agent)
|
in the unified [from:X] [id:Y] format, by dm_send."""
|
||||||
|
return dm_send(from_agent, target, message, to_agent=to_agent)
|
||||||
|
|
||||||
def main():
|
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)
|
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('--agent', required=True, choices=VALID_AGENTS)
|
||||||
ps.add_argument('--to', required=False, choices=VALID_AGENTS, default=None,
|
ps.add_argument('--to', required=False, choices=VALID_AGENTS, default=None,
|
||||||
help='Recipient operator (for cross-operator DMs). Uses recipient\'s browser/chat.')
|
help='Recipient operator (for cross-operator DMs). Uses recipient\'s browser/chat.')
|
||||||
ps.add_argument('--target', required=True)
|
ps.add_argument('--target', required=True)
|
||||||
ps.add_argument('--no-verify', action='store_true', help='Deprecated no-op: verification was removed')
|
ps.add_argument('--no-verify', action='store_true',
|
||||||
ps.add_argument('--raw', action='store_true', help='Send verbatim: no UUID tag, no truncation (for pre-signed messages)')
|
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.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))
|
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 = sub.add_parser('select', help='Select active conversation')
|
||||||
psel.add_argument('--agent', required=True, choices=VALID_AGENTS)
|
psel.add_argument('--agent', required=True, choices=VALID_AGENTS)
|
||||||
psel.add_argument('--target', required=False, default=None,
|
psel.add_argument('--target', required=False, default=None,
|
||||||
@@ -311,25 +325,24 @@ def main():
|
|||||||
pr.add_argument('--target', required=True)
|
pr.add_argument('--target', required=True)
|
||||||
pr.add_argument('--n', type=int, default=5)
|
pr.add_argument('--n', type=int, default=5)
|
||||||
pr.set_defaults(func=lambda a: dm_read(a.agent, a.target, a.n))
|
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('--agent', required=False, choices=VALID_AGENTS)
|
||||||
pvs.add_argument('--target', required=False)
|
pvs.add_argument('--target', required=False)
|
||||||
pvs.add_argument('--from-sender', required=False, dest='from_sender')
|
|
||||||
pvs.add_argument('message', nargs='?')
|
pvs.add_argument('message', nargs='?')
|
||||||
pvs.set_defaults(func=lambda a: dm_verify_sig(message=a.message, agent=a.agent, target=a.target))
|
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 = sub.add_parser('log', help='Show DM log')
|
||||||
pl.add_argument('--n', type=int, default=20)
|
pl.add_argument('--n', type=int, default=20)
|
||||||
pl.set_defaults(func=lambda a: dm_log(a.n))
|
pl.set_defaults(func=lambda a: dm_log(a.n))
|
||||||
|
|
||||||
pt = sub.add_parser('thread', help='Thread from one agent to another')
|
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('--from', dest='from_agent', required=True, choices=VALID_AGENTS)
|
||||||
pt.add_argument('--to', dest='to_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('--target', required=True)
|
||||||
pt.add_argument('message')
|
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))
|
||||||
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2fFix4z5PB8ICKgo7Pk8JceWrNJeI0QGkVbwYp3Jpv operator-main
|
||||||
Reference in New Issue
Block a user