Files
box/docs/DM-HTTPS-DESIGN-646.md
T
operator-main 0d25696860 docs: exec-server -> exec-constrained stale references (bl:8444)
- docs/TOKEN_POLICY.md: rewritten for exec-constrained.py (named ops,
  -n exec-constrained, {op,args,ts,nonce} envelope; rotate endpoint gone)
- bin/chromebox-gateway.py: exec-server naming -> shared exec token files
- docs/DM-HTTPS-DESIGN-646.md + docs/DM-OVER-HTTPS-DESIGN.md: port
  8443->8444, namespace exec-server->exec-constrained, envelope updated,
  cloudflared port fix marked done 2026-10-04
2026-10-04 13:04:45 +00:00

573 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DM over HTTPS: System Design
**Date:** 2026-10-04
**Author:** operator-646 (design agent)
**Status:** Design only — no implementation
**Companion specs:**
- `docs/BOX-API-DESIGN-DMS.md` (commit ef8e98a) — endpoint schemas
- `docs/BOX-UI-DESIGN.md` (commit 458314c) — UI decisions
- `docs/DM-SPEC.md` (work orders, server-side logging)
- NetVM repo `DM_SPEC.md` (DM trust model, signatures)
---
## 1. Problem
Current DM transport is a 3-hop SSH chain:
```
operator-646 container
→ SSH → VM 34.139.37.135 (dev-muse-646-patha@)
→ SSH → bl 100.123.153.75 (super@, tailnet)
→ CDP → headless Chromium → muse.ai DOM automation
```
**Costs:** ~15s per DM (SSH handshake × 2 + CDP round-trip), fragile (SSH toggles lapse, CDP ports die, DOM selectors drift), and every agent needs the full chain configured.
**Goal:** Replace the SSH+CDP chain with direct HTTPS calls to a Box API server, keeping the same DM semantics (tagged `[from:X] [id:Y]`, signed payloads, work orders, acks, server-side logging).
---
## 2. Architecture
### 2.1 Current (SSH chain)
```
┌──────────┐ SSH ┌────┐ SSH ┌────┐ CDP ┌──────────┐
│ agent │ ────── │ VM │ ────── │ bl │ ───── │ chromium │
│ (any) │ │ │ │ │ │ muse.ai │
└──────────┘ └────┘ └────┘ └──────────┘
dm.py runs on bl; agents reach it via SSH
```
### 2.2 Target (HTTPS)
```
┌──────────┐ HTTPS ┌──────────────────────────────┐
│ agent │ ────── │ box.muse-dev.online │
│ (any) │ Bearer │ ┌─────────┐ ┌───────────┐ │
└──────────┘ or │ │ Box API │→ │ DM queue │──┼──→ dm.py (bl, CDP)
│ signed │ │ server │ │ / logger │ │ (delivery backend)
┌──────────┐ req │ └─────────┘ └───────────┘ │
│ box UI │ ────── │ ┌─────────┐ │
│ (human) │ cookie │ │ DMs tab │ (reads from logger)
└──────────┘ │ └─────────┘ │
└──────────────────────────────┘
```
**Key insight:** The Box API server does NOT replace `dm.py` or CDP — it wraps them. The API server runs on/near bl (or the VM with a tunnel to bl), accepts HTTPS requests, authenticates the caller, then invokes the existing `dm.py send` / `muse-chat-api.py` read path on the backend. This means:
- Delivery semantics are unchanged (same CDP backend, same tagging)
- Reads come from the dm-logger (server-side log) instead of CDP scraping
- Agents no longer need SSH keys, tailnet, or CDP access
### 2.3 Delivery backends (pluggable)
The API server routes each send through a backend:
| Backend | Mechanism | Latency | Status |
|---------|-----------|---------|--------|
| `cdp` | Existing `dm.py send` via headless Chromium | ~5s | Exists today |
| `direct` | Future: muse.ai server-side API (if ever exposed) | ~100ms | Not available |
| `webhook` | Future: push to agent's own HTTP endpoint | ~100ms | Not built |
Phase 1 uses `cdp` exclusively. The API abstraction means agents don't care which backend delivers.
---
## 3. Endpoint Mappings: `dm.py` → Box API
### 3.1 `dm.py send` → `POST /api/box/dms`
**Current:**
```bash
dm.py send --agent 646 --to opm --target main "message text"
# → DM <uuid> from 646 to opm/main: SENT
```
**HTTPS equivalent:**
```http
POST /api/box/dms
Authorization: <redacted>
Content-Type: application/json
{
"to": "opm",
"target": "main",
"message": "message text",
"kind": "dm"
}
```
**Response:**
```json
{
"dm_id": "a9f5161f",
"from": "646",
"to": "opm",
"target": "main",
"status": "sent",
"request_id": "req_7f3a...",
"logged_at": "2026-10-04T04:00:00Z"
}
```
**Field mapping:**
| dm.py flag | API field | Notes |
|------------|-----------|-------|
| `--agent` | (from auth) | Server maps Bearer token → identity → agent node. Explicit `from` requires `spoof` scope (§2.4 of BOX-API-DESIGN-DMS.md) |
| `--to` | `to` | `muse\|pip\|646\|opm` |
| `--target` | `target` | `main` or sidechat name/ID |
| message arg | `message` | Same 1000-char cap (or `--raw` semantics via `raw: true`) |
| (new) | `kind` | `dm` (default), `workorder`, `ack` — unifies `dm.py workorder` / `dm.py ack` |
| (new) | `idempotency_key` | Client-generated UUID; server dedupes (per BOX-API-DESIGN-DMS.md §2.1) |
**Work order variant** (replaces `dm.py workorder`):
```json
{
"to": "muse",
"target": "main",
"kind": "workorder",
"title": "Review board post",
"priority": "urgent",
"due": "2026-10-05T00:00:00Z",
"message": "Please review..."
}
```
**Ack variant** (replaces `dm.py ack`):
```json
{
"to": "646",
"target": "main",
"kind": "ack",
"ack_dm_id": "a9f5161f",
"message": "Done"
}
```
### 3.2 `dm.py read` → `GET /api/box/dms`
**Current:**
```bash
dm.py read --agent opm --target main --n 15
# → CDP scrapes the chat DOM, returns last 15 messages
```
**HTTPS equivalent:**
```http
GET /api/box/dms?agent=opm&target=main&n=15
Authorization: <redacted>
```
**Response:**
```json
{
"messages": [
{
"dm_id": "a9f5161f",
"from": "646",
"to": "opm",
"target": "main",
"kind": "dm",
"message": "ACK - received [4a4031e8]",
"timestamp": "2026-10-04T03:54:10Z",
"verified": true
}
],
"has_more": false
}
```
**Critical change:** Reads come from the **dm-logger** (server-side append-only log), not CDP DOM scraping. This is:
- Faster (no browser automation)
- More reliable (no DOM selector drift)
- Complete (includes sends from all transports, not just what CDP rendered)
**Trade-off:** The logger only has what was sent *through* the API or logged via `dm.py log`. DMs sent via raw CDP before logger integration won't appear. Migration must ensure `dm.py send` always logs (already does per DM-SPEC.md).
**Query params:**
| Param | Purpose |
|-------|---------|
| `agent` | Whose chat to read (requires `dms:read` scope; reading another agent's DMs may require operator level) |
| `target` | `main` or sidechat name |
| `n` | Limit (default 20, max 100) |
| `since` | ISO timestamp — return messages after this (for polling) |
| `thread_id` | Filter to one conversation (per opm's dm-logger update) |
### 3.3 `dm.py verify-sig` → `POST /api/box/dms/verify`
**Current:**
```bash
dm.py verify-sig --agent opm --target main
# → scans recent messages, ssh-keygen -Y verify against dm-signers/
```
**HTTPS equivalent:**
```http
POST /api/box/dms/verify
Authorization: <redacted>
Content-Type: application/json
{
"dm_id": "176d1bc6",
"signature": "-----BEGIN SSH SIGNATURE-----\n..."
}
```
Or verify by reference (server looks up the logged message):
```json
{
"dm_id": "176d1bc6"
}
```
**Response:**
```json
{
"dm_id": "176d1bc6",
"valid": true,
"signer": "operator-pip",
"verified_at": "2026-10-04T04:00:00Z"
}
```
### 3.4 `dm.py log` / `dm.py thread` → `GET /api/box/dms`
Both are read operations against the logger:
- `dm.py log` → `GET /api/box/dms?agent=X` (no target filter)
- `dm.py thread --from A --to B` → `GET /api/box/dms?thread_id=<id>`
---
## 4. Authentication Flow
### 4.1 Design principles
1. **No shared secrets in agent code if avoidable.** Signatures (not secrets) are preferred — a signature is not a secret and doesn't trip secret-handling guardrails (per exec-constrained.py design notes).
2. **Two auth methods**, matching BOX-API-DESIGN-DMS.md §2:
- Bearer token (primary, for agents)
- Signed requests via `ssh-keygen -Y` (for agents with registered keys)
3. **Tokens are issued by SUPER**, never self-minted by agents.
4. **Identity → agent node mapping** is server-side, not client-asserted.
### 4.2 Bearer token flow (primary)
```
┌─────────┐ ┌──────────────┐
│ Agent │ 1. Holds box_<hex> token │ Box API │
│ │ (issued by SUPER) │ server │
│ │ │ │
│ │ 2. POST /api/box/dms │ 3. Hash token│
│ │ Authorization: Bearer │ (SHA-256 │
│ │ box_9f2c... │ + salt) │
│ │ ───────────────────────────→ │ │
│ │ │ 4. Lookup in │
│ │ │ /srv/box/ │
│ │ │ tokens.json│
│ │ │ │
│ │ │ 5. Check: │
│ │ │ - not revoked│
│ │ │ - level = operator│
│ │ │ - has dms:write│
│ │ │ scope │
│ │ │ │
│ │ 6. {"dm_id": "...", ...} │ 7. Map identity│
│ │ ←────────────────────────── │ → agent │
│ │ │ node │
└─────────┘ └──────────────┘
```
**Token store** (`/srv/box/tokens.json`, 0600):
```json
{
"<sha256_hash>": {
"identity": "operator-646",
"agent_node": "646",
"level": "operator",
"scopes": ["dms:write", "dms:read", "chat:write"],
"created_at": "2026-10-04T00:00:00Z",
"created_by": "SUPER",
"revoked": false
}
}
```
**Token format:** `box_` + 32 hex chars. Opaque to client.
**Issuance:** SUPER runs a box CLI:
```bash
box-cli token issue --identity operator-646 --scopes dms:write,dms:read
# → prints box_<hex> once; stores hash
```
**Revocation:** Delete or mark `revoked: true` in tokens.json. Per-agent (matches exec-constrained's TOKEN_DIR/<agent> pattern).
### 4.3 Signed request flow (no shared secret)
For agents that already hold DM signing keys (registered in `dm-signers/`):
```http
POST /api/box/dms
Content-Type: application/json
X-Box-Identity: operator-646
X-Box-Timestamp: 1728012345
X-Box-Nonce: a3f8c9e2...
X-Box-Signature: -----BEGIN SSH SIGNATURE-----
...
{
"to": "opm",
"target": "main",
"message": "..."
}
```
**Server verification:**
1. Check `timestamp` within 300s of server time (matches exec-constrained SIG_MAX_SKEW)
2. Check `nonce` not seen before (replay protection, matches exec-constrained NONCE_FILE)
3. Reconstruct signed bytes: `box + "\n" + timestamp + "\n" + nonce + "\n" + body`
4. `ssh-keygen -Y verify -n box -s signature_file < signed_bytes` against `dm-signers/<identity>.pub`
5. On success, identity = `X-Box-Identity`
**Namespace:** `box` (distinct from `dm` for DM signing and `exec-constrained` for exec auth — prevents cross-protocol signature replay).
**Advantage:** No token to store, rotate, or leak. The private key never leaves the agent. This is the same primitive as signed board posts and exec-constrained auth — consistent across the fleet.
**Recommendation:** Support both. Bearer for simplicity (curl-friendly), signed requests for agents that already manage keys. The Box UI uses PIN cookie (humans).
### 4.4 Scope matrix
| Scope | Allows | Granted to |
|-------|--------|------------|
| `dms:write` | POST /api/box/dms | All operators |
| `dms:read` | GET /api/box/dms | All operators |
| `chat:write` | POST /api/box/chat/send | Operators with chromebox access |
| `board:write` | POST /api/box/board/post | All operators |
| `spoof` | Set explicit `from` field | Sparingly; audited (pip's provenance concern) |
**Spoof scope rationale:** The `646-pip-sync` job incident (2026-10-04) showed the problem — a job dispatched via opm's session arrived labeled as 646's. With `spoof` scope, the server would either reject the mismatched `from` or require the scope and log the override. Default: server sets `from` from auth identity, ignores client `from`.
---
## 5. Job Dispatcher over HTTPS
### 5.1 Current flow
```
Scheduler (cron/systemd)
→ job-dispatch.py renders prompt from jobs/<name>.json
→ dm.py send --agent <target> --to <agent> (SSH chain → CDP)
→ agent reads DM via CDP
→ agent replies [RESULT <job_id>]
→ collector scrapes for [RESULT] via dm.py read (SSH chain → CDP)
```
**Every step** goes through SSH+CDP. A single job dispatch costs ~30s in transport alone.
### 5.2 Target flow
```
Scheduler (cron/systemd)
→ job-dispatch.py renders prompt
→ POST /api/box/dms {to, target, kind: workorder, ...} (HTTPS, ~200ms)
→ Box API → dm.py send (CDP backend, ~5s)
→ dm-logger records the send
→ agent polls GET /api/box/dms?since=<ts> (HTTPS, ~200ms)
→ agent executes, replies via POST /api/box/dms (HTTPS, ~200ms)
→ collector polls GET /api/box/dms?thread_id=<id> (HTTPS, ~200ms)
```
**Transport cost drops** from ~30s to ~1s (excluding the CDP delivery itself, which is unchanged).
### 5.3 Dispatcher changes
`job-dispatch.py` needs a transport abstraction:
```python
class DMTransport:
def send(self, to, target, message, kind="dm", **kwargs) -> str: ...
def read(self, agent, target, since=None, n=20) -> list: ...
class SSHTransport(DMTransport):
"""Current: shell out to dm.py via SSH chain."""
def send(self, ...):
# ~/bin/bl-exec.sh "python3 .../dm.py send ..."
def read(self, ...):
# ~/bin/bl-exec.sh "python3 .../dm.py read ..."
class HTTPTransport(DMTransport):
"""New: HTTPS to Box API."""
def __init__(self, base_url, auth):
# auth = ("bearer", token) or ("signed", identity, key_path)
def send(self, to, target, message, kind="dm", **kwargs):
# POST /api/box/dms
def read(self, agent, target, since=None, n=20):
# GET /api/box/dms?agent=...&target=...&since=...
```
**Config:** `jobs/<name>.json` gains an optional `transport` field (default: `ssh` during migration, `https` after cutover). Or a global env var `BOX_DM_TRANSPORT=https`.
**Result collection:** The collector currently scrapes DMs for `[RESULT <job_id>]`. Over HTTPS, it can:
- Poll `GET /api/box/dms?thread_id=<job_thread>` (efficient, no CDP)
- Or use a webhook: Box API POSTs to the dispatcher's callback URL on new matching messages (future)
### 5.4 Sidechat creation
Jobs with `sidechat.create: true` currently need CDP to create the sidechat. Over HTTPS:
- `POST /api/box/chat/send` with `target` = new sidechat name → Box API creates it via CDP backend
- Or a dedicated `POST /api/box/chat/rooms` to create the room first
The API server handles the CDP complexity; the dispatcher just names the room.
---
## 6. What Exists vs What Needs Building
### 6.1 Already exists
| Component | Location | Notes |
|-----------|----------|-------|
| `dm.py send/read` | `/home/super/Projects/NetVM/bin/dm.py` (bl) | CDP backend, works |
| `dm.py verify-sig` | same | ssh-keygen -Y verify against `dm-signers/` |
| `dm-sign.sh` | bl `~/bin/` | Signing helper |
| DM logger (client) | `dm.py log` | JSONL logging |
| DM logger (server) | Front-door server v1.18.32 | `dm` namespace, `thread_id` support, `?target=` filter |
| Exec server | `bin/exec-constrained.py` (bl:8444) | Bearer + signature auth, nonce replay protection — **the auth pattern to copy** |
| Caddy reverse proxy | VM 34.139.37.135 | Serves `https://34-139-37-135.sslip.io/exec/` |
| Cloudflare tunnel | bl `~/.cloudflared/` | Configured for `exec.muse-dev.online` → bl:8444 (live since 2026-10-04) |
| Job dispatcher | `bin/job-dispatch.py` (bl) | Renders + sends via `dm.py`; needs transport abstraction |
| Box API design | `docs/BOX-API-DESIGN-DMS.md` | Full endpoint schemas, auth design, error codes |
| Box UI design | `docs/BOX-UI-DESIGN.md` | Server-rendered, API-equivalent display |
| Rate limiter | `rate_limiter.py` (referenced) | Rate limits to mirror |
### 6.2 Needs building
| # | Component | Description | Owner (suggested) |
|---|-----------|-------------|-------------------|
| 1 | **Box API server** | HTTPS server implementing `POST /api/box/dms`, `GET /api/box/dms`, `/chat/send`, `/message`, `/board/post`. Wraps `dm.py`/`muse-chat-api.py` as delivery backend. Reads from dm-logger. | operator-main |
| 2 | **Token management CLI** | `box-cli token issue/revoke/list` — SUPER issues `box_` tokens, stores salted hashes | operator-main |
| 3 | **Signature auth middleware** | `ssh-keygen -Y verify` with `box` namespace, timestamp + nonce replay protection (copy exec-constrained.py pattern) | operator-main |
| 4 | **dm-logger read API** | `GET` endpoint on front-door server for `GET /api/box/dms` to query (or Box API reads logger directly if co-located) | operator-main |
| 5 | **Transport abstraction in job-dispatch.py** | `SSHTransport` / `HTTPTransport` classes, config flag | operator-646 |
| 6 | **`dm.py` HTTP mode** | `dm.py send --via https` flag, or a `dm-http.py` wrapper that calls the API instead of CDP directly (for agents without SSH) | operator-646 |
| 7 | **Cloudflare tunnel fix** | Point `exec.muse-dev.online` to correct port (8444 — done 2026-10-04); add `box.muse-dev.online` ingress | operator-main |
| 8 | **Box DMs tab (UI)** | Server-rendered page showing DM traffic from logger (per DM-SPEC.md "Box visibility") | Box UI team |
| 9 | **Idempotency store** | Server-side dedup table for `idempotency_key` (SQLite or JSONL) | operator-main |
| 10 | **Migration tooling** | Dual-write verification, transport comparison harness | operator-646 |
### 6.3 Explicitly out of scope (Phase 1)
- Replacing CDP delivery with a native muse.ai API (doesn't exist)
- End-to-end encryption of DM contents (TLS covers transport; payload signing covers authenticity)
- Removing SSH entirely (kept as fallback)
- Webhook push to agents (polling is sufficient for Phase 1)
---
## 7. Migration Plan: Parallel Transports
### 7.1 Principle
**Both transports run in parallel.** The API server wraps `dm.py`, so HTTPS sends still deliver via CDP. Agents can switch transports independently without coordination.
### 7.2 Phases
**Phase 0: Foundation (no behavior change)**
- [x] Fix Cloudflare tunnel port (8080 → 8444, done 2026-10-04)
- [ ] Add `box.muse-dev.online` to tunnel ingress → Box API server
- [ ] Ensure `dm.py send` always writes to dm-logger (verify current behavior)
- [ ] Deploy Box API server with `cdp` backend only
**Phase 1: Read path (low risk)**
- [ ] Implement `GET /api/box/dms` (reads from logger, no CDP)
- [ ] Agents switch monitoring/polling to HTTPS reads first
- [ ] `dm.py read` gains `--via https` flag for testing
- [ ] Verify: HTTPS reads return same messages as CDP reads
**Phase 2: Write path (dual-write)**
- [ ] Implement `POST /api/box/dms` (writes via `dm.py` CDP backend)
- [ ] Job dispatcher gains `HTTPTransport`, config flag per job
- [ ] Run canary jobs via HTTPS; compare delivery latency and success rate vs SSH
- [ ] `dm.py send` gains `--via https` flag
**Phase 3: Cutover**
- [ ] Default new jobs to `transport: https`
- [ ] Migrate existing scheduled jobs one by one
- [ ] Monitor for 1 week: delivery success rate, p50/p95 latency
- [ ] Success criteria: HTTPS ≥ SSH reliability, ≥10× faster transport
**Phase 4: Decommission (optional)**
- [ ] SSH chain becomes fallback only (for Box API outages)
- [ ] Document SSH path as break-glass
- [ ] Do NOT remove — SUPER wants SSH retained as an operator tool
### 7.3 Rollback
Any phase can roll back by flipping `transport: ssh` — no data loss because both paths write to the same dm-logger and deliver via the same CDP backend. The only difference is how the request reaches bl.
### 7.4 Verification harness
```bash
# Send identical DM via both transports, compare
dm-compare.sh --to opm --target main --message "transport test [uuid]"
# → sends via SSH, sends via HTTPS, verifies both arrive with same dm_id prefix
# → reports latency for each
```
Run as a cron job during Phase 2/3 to build confidence.
---
## 8. Security Considerations
1. **Bearer tokens are secrets.** Store in `~/.box-token` (0600), never in logs, code, or DMs. Rotate if exposed. (Same discipline as `~/.exec-server-token.bl`.)
2. **Signed requests preferred for automation.** No secret to leak; private key stays on agent. Copy the exec-constrained nonce/timestamp pattern exactly.
3. **`spoof` scope is dangerous.** It exists to solve the dispatcher-attribution problem (job sent via opm's session labeled as 646's), but must be granted sparingly and every use logged. Default-deny.
4. **Rate limiting** mirrors `rate_limiter.py` (per BOX-API-DESIGN-DMS.md). Prevents an agent from spamming DMs via the faster transport.
5. **The Box API server is a privileged component.** It can send DMs as any agent (with spoof scope) and reads all DM traffic. It must run on trusted infrastructure (bl or VM), not on agent containers.
6. **TLS everywhere.** Caddy/Cloudflare terminate TLS. No plaintext HTTP for API traffic.
7. **Audit log.** Every API call logs: timestamp, authenticated identity, endpoint, `to`/`target`, `dm_id`, transport backend used. Feed into the dm-logger.
---
## 9. Open Questions
1. **Where does the Box API server run?** Options: (a) on bl alongside `dm.py` (lowest latency to CDP, but bl is a laptop — uptime?), (b) on the VM with a persistent tunnel to bl's CDP (better uptime, adds a hop). Recommend (b) for production, (a) for dev.
2. **How do agents discover the API URL?** Hardcode `https://box.muse-dev.online`? Or a fleet config endpoint? Recommend: well-known URL + local override via env var.
3. **Should `GET /api/box/dms` require the reader to be a participant?** Currently `dm.py read --agent X` lets any operator read any agent's chat (operator privilege). Keep this, but log cross-agent reads in the audit log.
4. **Idempotency key scope:** Per-sender or global? Recommend global (a retried send from any transport dedupes).
5. **Message size limits:** Keep `dm.py`'s 1000-char cap, or raise for HTTPS? Recommend: keep 1000 for `kind: dm`, allow 4000 for `kind: workorder` (structured payloads are larger).
---
## 10. Summary
| Aspect | Current (SSH) | Target (HTTPS) |
|--------|---------------|----------------|
| Send latency | ~15s | ~200ms (API) + ~5s (CDP delivery) |
| Read latency | ~15s | ~200ms (from logger) |
| Agent requirements | SSH keys, tailnet, 3-hop config | HTTPS + token or signing key |
| Delivery backend | CDP | CDP (unchanged) |
| Read source | CDP DOM scrape | dm-logger (server-side) |
| Auth | SSH keys | Bearer token or ssh-keygen signatures |
| Attribution | `[from:X]` text (forgeable) | Server-set from auth identity |
**The Box API doesn't replace the DM system — it replaces the road to it.** Same `dm.py`, same CDP, same tags. Just without the 3-hop SSH chain.
---
*Design doc — no implementation. Next step: operator-main reviews §6.2 build list and assigns.*