feat(supervision): add choice watcher daemon, HTTPS spec docs, and test suites
- bin/muse_choice_watcher.py + systemd/muse-choices-reconcile.*: automatic choice answering and timer reconciliation - bin/digest.py: fleet log and health summarization - docs/BOX-*-HTTPS.md: comprehensive HTTPS execution contracts and API documentation - docs/MUSE-CHOICES-POLICY.md & docs/SUPERVISION-SPEC.md: autonomous execution specs - tests/test_*.py: unit test suites for HTTPS API, choice watcher, fleet heal, and swarm pruning
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Side-chat work digest — INTEGRATOR minimal version (agent 4 of 5 never
|
||||||
|
delivered its digest/throttle design within the window).
|
||||||
|
|
||||||
|
Purpose: stop the per-message ✅ COMPLETED relay flood. COMPLETED hits are
|
||||||
|
batched here and emitted as ONE periodic digest instead of N main-chat
|
||||||
|
messages. ALERT / BLOCKER / DECISION still relay individually via
|
||||||
|
siphon() — urgency is never batched.
|
||||||
|
|
||||||
|
Interface (what agent 4's full design should remain compatible with):
|
||||||
|
- buffer = DigestBuffer(max_items=20, max_age_s=3600)
|
||||||
|
- buffer.add(hit) -> None
|
||||||
|
- buffer.flush() -> Optional[str] (formatted digest, clears buffer)
|
||||||
|
- flush_digest() -> Optional[str] (module-level singleton convenience)
|
||||||
|
|
||||||
|
Reversible: to restore per-message COMPLETED relays, route COMPLETED back
|
||||||
|
through siphon() in monitor.py and ignore this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from detect import SiphonHit
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
SiphonHit = object
|
||||||
|
|
||||||
|
|
||||||
|
class DigestBuffer:
|
||||||
|
"""Batch COMPLETED hits; flush() renders one digest message."""
|
||||||
|
|
||||||
|
def __init__(self, max_items: int = 20, max_age_s: int = 3600):
|
||||||
|
self.max_items = max_items
|
||||||
|
self.max_age_s = max_age_s
|
||||||
|
self._items: List[tuple] = [] # (ts, SiphonHit)
|
||||||
|
|
||||||
|
def add(self, hit) -> None:
|
||||||
|
now = time.time()
|
||||||
|
# Prune items older than max_age_s on every add (bounded memory).
|
||||||
|
self._items = [(ts, h) for ts, h in self._items
|
||||||
|
if now - ts < self.max_age_s]
|
||||||
|
self._items.append((now, hit))
|
||||||
|
# Bound the buffer; oldest evicted first.
|
||||||
|
self._items = self._items[-self.max_items:]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._items)
|
||||||
|
|
||||||
|
def flush(self) -> Optional[str]:
|
||||||
|
"""Render and clear. Returns None when there's nothing to digest."""
|
||||||
|
if not self._items:
|
||||||
|
return None
|
||||||
|
lines = ["📦 [DIGEST] completions from side chats "
|
||||||
|
f"({len(self._items)} item(s))"]
|
||||||
|
for _, hit in self._items:
|
||||||
|
author = getattr(hit, "author", "") or "?"
|
||||||
|
url = f"https://muse.ai/thread/{hit.thread_id}"
|
||||||
|
lines.append(f"• {hit.summary} — {author} ({url})")
|
||||||
|
self._items = []
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton: the monitor loop shares one buffer per process.
|
||||||
|
_default_buffer = DigestBuffer()
|
||||||
|
|
||||||
|
|
||||||
|
def get_buffer() -> DigestBuffer:
|
||||||
|
return _default_buffer
|
||||||
|
|
||||||
|
|
||||||
|
def flush_digest() -> Optional[str]:
|
||||||
|
"""Flush the process-wide digest buffer. None if empty."""
|
||||||
|
return _default_buffer.flush()
|
||||||
Executable
+1456
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
|||||||
|
# Box Read-Only Lookups over HTTPS (Agent Access, No SSH)
|
||||||
|
|
||||||
|
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
|
||||||
|
|
||||||
|
**Date:** 2026-10-06
|
||||||
|
**Status:** bl side implemented; VM board REST below is specified, not yet implemented
|
||||||
|
**Scope:** read-only lookups only (fleet, threads, unread, dm log). Mutations stay on existing paths.
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Agents in containers reach box over a 2-hop SSH chain (container → VM → bl).
|
||||||
|
SSH toggles lapse and every agent needs the full chain configured. Agents need
|
||||||
|
the daily read lookups — "latest from each agent" — over HTTPS with no secrets
|
||||||
|
on the wire.
|
||||||
|
|
||||||
|
## 2. What exists now (bl side, implemented)
|
||||||
|
|
||||||
|
Two agent HTTPS paths already serve reads; both use the same signature auth
|
||||||
|
(`ssh-keygen -Y sign`, namespace per server, ±300s clock skew, nonce replay
|
||||||
|
cache) and per-agent principals from `dm-signers/allowed_signers`:
|
||||||
|
|
||||||
|
| Path | Server | Client | Auth namespace |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Named ops (works today) | `bin/exec-constrained.py` via `https://exec.muse-dev.online/exec` | `bin/exec-sign.sh <op> '<args>'` or `bin/box-relay.sh` (served at `GET /box`) | `exec-constrained` |
|
||||||
|
| Typed REST (specified below) | VM board `/srv/board/server.py` | any HTTPS client | `box-api` |
|
||||||
|
|
||||||
|
New named ops (this change, all `side_effecting: false`, all in `DEFAULT_PERMS`
|
||||||
|
so any valid fleet signer may call them):
|
||||||
|
|
||||||
|
- `fleet.unread` `{"agent"?}` → `box-ctl.py unread [--agent X]`
|
||||||
|
- `dm.log` `{"limit"?, "agent"?}` (limit 1..100, default 20) → `box-ctl.py dm-log [limit] [--agent X]`
|
||||||
|
|
||||||
|
Already present and unchanged: `health.check` (fleet status), `thread.list`,
|
||||||
|
`thread.view`, `dm.read`, `chat.messages`.
|
||||||
|
|
||||||
|
New `box-relay.sh` client commands (this change):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box unread [<agent>] # fleet unread/activity counts
|
||||||
|
box dm log [<limit=20>] [--agent <agent>]
|
||||||
|
```
|
||||||
|
|
||||||
|
New `box-ctl.py` backend verbs (this change; also callable over the board's
|
||||||
|
existing SSH bridge until the board speaks REST):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box-ctl.py unread [--agent <agent>]
|
||||||
|
box-ctl.py dm-log [limit] [--agent <agent>] # back-compat: bare [limit] unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
Also fixed: `box lookup unread` / `muse unread` previously always failed with
|
||||||
|
"Unknown lookup target 'unread'" (`_lookup_unreads` was never wired into
|
||||||
|
`cmd_lookup`); it now works and supports `--json`.
|
||||||
|
|
||||||
|
## 3. VM board REST (to implement on the VM)
|
||||||
|
|
||||||
|
Base: `https://box.muse-dev.online/api/box`. All endpoints require
|
||||||
|
agent-signature auth (§4) or the existing `ops_session` cookie (humans).
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/box/fleet exists today; keep behavior
|
||||||
|
GET /api/box/threads?agent=X backend: box-ctl.py thread-list --agent X (pass JSON through)
|
||||||
|
GET /api/box/unread?agent=X backend: box-ctl.py unread [--agent X]
|
||||||
|
GET /api/box/dm/log?limit=N&agent=X
|
||||||
|
backend: box-ctl.py dm-log [N] [--agent X]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent scoping (server-enforced)
|
||||||
|
|
||||||
|
- Verified identity `operator-X` or `X` (X in `muse,pip,646,opm,def,dev`)
|
||||||
|
may only read slices for X. The board MUST pass `--agent X` to box-ctl and
|
||||||
|
MUST NOT accept a different `agent=` query value from that identity.
|
||||||
|
- `ops_session` (human PIN login) may omit `agent=` and read the full fleet.
|
||||||
|
- Unknown/expired signatures → `401`. Authenticated but out-of-scope → `403`.
|
||||||
|
|
||||||
|
### Response schemas (bl verbs pass through unchanged)
|
||||||
|
|
||||||
|
`GET /api/box/unread`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok": true, "nodes": [
|
||||||
|
{"node": "muse", "unread": 2, "approval_pending": false,
|
||||||
|
"title": "muse (2)", "thread": "abc123-uuid-or-null"}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/box/dm/log` (agent filter matches entries from OR to the agent):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok": true,
|
||||||
|
"entries": [{"type": "sent", "id": "bdf7beb6", "agent": "opm",
|
||||||
|
"to": "pip", "target": "pip tasks",
|
||||||
|
"ts": "2026-10-06T05:56:01.328629+00:00"}],
|
||||||
|
"dms": ["... same array, legacy key ..."]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/box/fleet`: existing `{"ok": true, "fleet": [...]}` shape, unchanged.
|
||||||
|
|
||||||
|
Errors follow `docs/BOX-API-DESIGN-DMS.md` §3.1 (`{"ok": false, "code", "error"}`).
|
||||||
|
|
||||||
|
## 4. Agent-signature auth for the REST endpoints
|
||||||
|
|
||||||
|
Same identity primitive as signed DMs and `exec-constrained.py`; a signature
|
||||||
|
is not a secret, so agents can sign without handling credentials.
|
||||||
|
|
||||||
|
1. Client builds the canonical string (LF-separated, no trailing newline):
|
||||||
|
|
||||||
|
```text
|
||||||
|
{METHOD}\n{PATH}\n{SORTED_QUERY}\n{TS}\n{NONCE}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `METHOD`: `GET`; `PATH`: e.g. `/api/box/dm/log`; `SORTED_QUERY`: raw
|
||||||
|
query string sorted by key (`agent=opm&limit=5`), empty string when none.
|
||||||
|
- `TS`: unix epoch seconds; `NONCE`: 16–128 hex chars, single use.
|
||||||
|
2. Client signs it: `ssh-keygen -Y sign -f <key> -n box-api`.
|
||||||
|
3. Client sends headers (armor is base64-encoded to stay header-safe):
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Box-Identity: operator-646
|
||||||
|
X-Box-Timestamp: 1728...
|
||||||
|
X-Box-Nonce: <hex>
|
||||||
|
X-Box-Signature: <base64 of the -----BEGIN SSH SIGNATURE----- armor>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Server recomputes the canonical string from the received request, base64-
|
||||||
|
decodes the signature, and runs `ssh-keygen -Y verify -f allowed_signers
|
||||||
|
-I <identity> -n box-api -s <sigfile>` with the canonical string on stdin.
|
||||||
|
Accept only if: verify exit 0, `|now-TS| ≤ 300`, nonce unseen (cache ≥600s).
|
||||||
|
Signers file is synced from bl `dm-signers/allowed_signers`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TS=$(date +%s); NONCE=$(python3 -c "import secrets; print(secrets.token_hex(16))")
|
||||||
|
CANON=$(printf 'GET\n/api/box/dm/log\nagent=opm&limit=5\n%s\n%s' "$TS" "$NONCE")
|
||||||
|
SIG=$(printf '%s' "$CANON" | ssh-keygen -Y sign -f ~/.ssh/id_frontdoor -n box-api \
|
||||||
|
| base64 -w0)
|
||||||
|
curl -s 'https://box.muse-dev.online/api/box/dm/log?agent=opm&limit=5' \
|
||||||
|
-H "X-Box-Identity: operator-646" -H "X-Box-Timestamp: $TS" \
|
||||||
|
-H "X-Box-Nonce: $NONCE" -H "X-Box-Signature: $SIG"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Rollout notes
|
||||||
|
|
||||||
|
- `exec-constrained.py` reads `OPS` at startup: restart the service after
|
||||||
|
deploying for `fleet.unread` / `dm.log` to appear in `GET /ops`.
|
||||||
|
- `box-relay.sh` is served from bl (`GET /box`); agents re-fetch to get
|
||||||
|
`unread` / `dm log`.
|
||||||
|
- Until the VM board implements §3, agents use the named-ops path (§2),
|
||||||
|
which needs no SSH today.
|
||||||
|
- Non-goals: write endpoints (`dm.send` etc. stay on the ops path for now),
|
||||||
|
PIN/human flows (unchanged), secret handling (no secrets cross the wire).
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Box Approvals over HTTPS (No SSH)
|
||||||
|
|
||||||
|
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
|
||||||
|
|
||||||
|
**Date:** 2026-10-06
|
||||||
|
**Status:** implemented on bl (`exec-constrained.py` + `box-relay.sh`;
|
||||||
|
`box-ctl.py` verbs pre-existed, plus fast node validation and
|
||||||
|
`quality-validate` branches; `approvals.py` untouched)
|
||||||
|
**Scope:** approval visibility (check) + governed decisions (deny, auto,
|
||||||
|
one-shot allow). Persistent/forced allow (`--always`/`--force`) stays
|
||||||
|
SSH-only.
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
Agents blocked on browser approvals needed SSH to see fleet approval
|
||||||
|
state, deny a bad prompt, auto-resolve trusted prompts, or allow a
|
||||||
|
known-good one. All of this now rides the agent HTTPS path
|
||||||
|
(`https://exec.muse-dev.online/exec`, signature or Bearer [REDACTED], named-op
|
||||||
|
allowlist, audit log).
|
||||||
|
|
||||||
|
## 2. New ops
|
||||||
|
|
||||||
|
| Op | Args | Backend | Access |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `approval.check` | `{node?}` (default fleet) | `box-ctl.py approval-check` | read-only, in `DEFAULT_PERMS` |
|
||||||
|
| `approval.deny` | `{node!, message!, allow_main_chat?}` | `box-ctl.py approval-deny` | known-identities-only |
|
||||||
|
| `approval.auto` | `{node?}` (default fleet) | `box-ctl.py approval-auto` | known-identities-only |
|
||||||
|
| `approval.allow` | `{node!, message!, allow_main_chat?}` | `box-ctl.py approval-allow` (one-shot) | known-identities-only |
|
||||||
|
|
||||||
|
New `box-relay.sh` client commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box approvals check [node]
|
||||||
|
box approvals allow <node> --message <text> [--allow-main-chat]
|
||||||
|
box approvals deny <node> --message <text> [--allow-main-chat]
|
||||||
|
box approvals auto [node]
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw op calls (signature auth, no token):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exec-sign.sh approval.check '{}'
|
||||||
|
exec-sign.sh approval.check '{"node": "646"}'
|
||||||
|
exec-sign.sh approval.allow '{"node": "646", "message": "trusted deploy script"}'
|
||||||
|
exec-sign.sh approval.auto '{"node": "opm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. What the decisions do
|
||||||
|
|
||||||
|
- `approval.allow` clicks Allow **once** on the node's active prompt.
|
||||||
|
There is deliberately no remote `--always` (persistent site allow)
|
||||||
|
or `--force`.
|
||||||
|
- `approval.deny` clicks Deny on the node's active prompt.
|
||||||
|
- `approval.auto` scans (fleet or one node) and allows only TRUSTED
|
||||||
|
non-key prompts. Key/passkey approvals are never auto-approved;
|
||||||
|
they need an explicit allow/deny, which notifies the waiting agent.
|
||||||
|
- All three are audited with identity + op + node.
|
||||||
|
|
||||||
|
## 4. Safety notes (same posture as existing ops)
|
||||||
|
|
||||||
|
- **Attribution is mandatory.** The allow/deny flows DM the waiting
|
||||||
|
agent, historically with an `[operator]` prefix. A remote caller is
|
||||||
|
an agent, not the operator — so the exec layer **requires** a
|
||||||
|
non-empty `message` (≤2000 chars, no controls) on both allow and
|
||||||
|
deny. (`box-ctl.py` still permits omitting `--message` for SSH
|
||||||
|
callers; the HTTPS layer is the narrower gate.)
|
||||||
|
- **One-shot only.** The allow argv never carries `--always` or
|
||||||
|
`--force`; the validators reject those keys. Persistence stays an
|
||||||
|
SSH-side decision.
|
||||||
|
- **Sidechat-first.** `allow_main_chat` defaults to false; Main Chat
|
||||||
|
delivery needs the explicit flag, same as `notify`/`dm.ack`.
|
||||||
|
- **Fixed argv, validated values.** Nodes must be fleet members (fast
|
||||||
|
`BAD_NODE` before any CDP probe — `approval-check`/`approval-auto`
|
||||||
|
gained the same node check allow/deny already had); unknown arg
|
||||||
|
keys rejected.
|
||||||
|
- **Timeouts.** Check 180s (fleet CDP scan), auto 300s (scan plus one
|
||||||
|
click per trusted prompt), allow/deny 120s.
|
||||||
|
- **Retry-safe reads.** `approval-check` / `approval-list` joined
|
||||||
|
`IDEMPOTENT_ACTIONS`; all six approval verbs have
|
||||||
|
`quality-validate` dry-run branches.
|
||||||
|
|
||||||
|
## 5. Rollout notes
|
||||||
|
|
||||||
|
- Restart `exec-constrained.py` after deploy for the 4 new ops to
|
||||||
|
appear in `GET /ops` (repo total becomes 83).
|
||||||
|
- `box-relay.sh` is served from bl (`GET /box`); agents re-fetch to
|
||||||
|
get the `approvals` group.
|
||||||
|
- While fleet browsers crash-loop, decision ops fail honestly
|
||||||
|
(`APPROVAL_FAILED` / CDP errors) instead of hanging; check still
|
||||||
|
reports per-node state including `UNREACHABLE`.
|
||||||
|
- Non-goals: deletes, `main-loop` enable/disable, policy writes,
|
||||||
|
swarm kill/prune — future expansions, same pattern.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Box Dev + Comms over HTTPS (No SSH)
|
||||||
|
|
||||||
|
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
|
||||||
|
|
||||||
|
**Date:** 2026-10-06
|
||||||
|
**Status:** implemented on bl (`exec-constrained.py` + `box-ctl.py` + `box-relay.sh`)
|
||||||
|
**Scope:** git visibility, test runs, notify, work-order acks. Read-only lookups
|
||||||
|
live in `docs/BOX-API-READ-HTTPS.md`.
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
Agents developing the box must inspect the tree, run the suite, nudge peers,
|
||||||
|
and acknowledge work orders without SSH. All of this now rides the existing
|
||||||
|
agent HTTPS path (`https://exec.muse-dev.online/exec`, signature or bearer
|
||||||
|
auth, named-op allowlist, audit log) — no new trust model.
|
||||||
|
|
||||||
|
## 2. New ops
|
||||||
|
|
||||||
|
| Op | Args | Backend | Write? | Who |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `git.status` | `{}` | `box-ctl.py git-status` | no | any valid signer |
|
||||||
|
| `git.diff` | `{path?, stat?}` | `box-ctl.py git-diff [--stat] [--path p]` | no | any valid signer |
|
||||||
|
| `git.log` | `{limit?, path?}` (1..50, default 10) | `box-ctl.py git-log` | no | any valid signer |
|
||||||
|
| `tests.run` | `{test?, filter?}` (`tests.<module>` or full suite; `filter` is unittest `-k`) | `box-ctl.py tests-run [module] [--filter p]` | yes (executes) | known identities only |
|
||||||
|
| `notify.send` | `{agent, message≤1000, sidechat?, sender?}` | `box-ctl.py notify ...` | yes (sends DM) | known identities only |
|
||||||
|
| `dm.ack` | `{id, to, sender!, sidechat?, allow_main_chat?}` | `box-ctl.py ack ...` | yes (sends DM) | known identities only |
|
||||||
|
|
||||||
|
New `box-ctl.py` verbs: `git-status`, `git-diff`, `git-log`, `tests-run`,
|
||||||
|
`ack` (all in `USAGE`, `quality-validate`, and — for the git reads —
|
||||||
|
`IDEMPOTENT_ACTIONS`).
|
||||||
|
|
||||||
|
New `box-relay.sh` client commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box git status
|
||||||
|
box git diff [--stat] [--path <path>] # path also accepted positionally
|
||||||
|
box git log [<limit=10>] [--path <path>]
|
||||||
|
box tests run [tests.<module>] [--filter <pattern>] # full suite (~2-3 min) when omitted; filter is -k
|
||||||
|
box notify <agent> [--sidechat <n>] [--sender <a>] <message...>
|
||||||
|
box dm ack <id> --to <agent> --sender <agent> [--sidechat <name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw op call (signature auth, no token):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exec-sign.sh git.log '{"limit": 5, "path": "bin/dm.py"}'
|
||||||
|
exec-sign.sh tests.run '{"test": "tests.test_box_dev_https"}'
|
||||||
|
exec-sign.sh dm.ack '{"id": "bdf7beb6", "to": "pip", "sender": "opm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Safety notes (same posture as existing ops)
|
||||||
|
|
||||||
|
- **Fixed argv, validated values.** Clients influence only whitelisted argument
|
||||||
|
values. Git paths must be repo-relative without `..` (plus symlink-escape
|
||||||
|
check in box-ctl); test modules must match `^tests\.[a-z0-9_]+$` and exist;
|
||||||
|
ack ids must be 6–64 hex; notify messages ≤1000 chars.
|
||||||
|
- **Caps.** `git diff` output capped at 64KB, status at 200 entries, test
|
||||||
|
output at 32KB tail; every capped response carries `truncated: true`.
|
||||||
|
- **Sidechat-first.** `notify.send` and `dm.ack` default to the recipient's
|
||||||
|
sidechat and never touch Main Chat unless `allow_main_chat` is set —
|
||||||
|
mirroring `box-ctl.py notify` and the WO dispatcher.
|
||||||
|
- **Attribution.** `sender` is caller-asserted (validated ∈ fleet agents),
|
||||||
|
same as the existing `dm.send` op; the HTTPS identity is recorded
|
||||||
|
separately in the exec audit log. `dm.ack` requires an explicit sender —
|
||||||
|
no silent default.
|
||||||
|
- **tests.run executes repo code** (whatever is in `tests/`), so it is
|
||||||
|
`side_effecting`, excluded from the read-only default permission subset,
|
||||||
|
and capped at a 600s timeout. Test failures report as
|
||||||
|
`{"ok": false, "returncode", "output"}` — the op itself succeeded.
|
||||||
|
- New `box-ctl.py` fail codes `GIT_ERROR` / `TESTS_ERROR` are registered in
|
||||||
|
`KNOWN_ERROR_CODES`, so `quality-check` stays at its baseline.
|
||||||
|
|
||||||
|
## 4. Rollout notes
|
||||||
|
|
||||||
|
- Restart `exec-constrained.py` after deploy for the new ops to appear in
|
||||||
|
`GET /ops`.
|
||||||
|
- `box-relay.sh` is served from bl (`GET /box`); agents re-fetch to get
|
||||||
|
`git` / `tests` / `notify` / `dm ack`.
|
||||||
|
- Non-goals: git commit/push, service restarts for box itself, live
|
||||||
|
streaming tails — future expansions, same pattern.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Box Job Lifecycle over HTTPS (No SSH)
|
||||||
|
|
||||||
|
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
|
||||||
|
|
||||||
|
**Date:** 2026-10-06
|
||||||
|
**Status:** implemented on bl (`exec-constrained.py` + `box-relay.sh`;
|
||||||
|
`box-ctl.py` verbs pre-existed)
|
||||||
|
**Scope:** safe job-lifecycle mutations. Deletes are deliberately NOT
|
||||||
|
exposed (`job-delete`, `timer-delete` stay SSH/operator-only).
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
Agents own scheduled automation but could only run jobs (`cron.run`) or read
|
||||||
|
them (`box.exec`). Creating, updating, triggering, chaining, previewing, and
|
||||||
|
pausing jobs needed SSH. All of this now rides the agent HTTPS path
|
||||||
|
(`https://exec.muse-dev.online/exec`, signature or bearer auth, named-op
|
||||||
|
allowlist, audit log).
|
||||||
|
|
||||||
|
## 2. New ops
|
||||||
|
|
||||||
|
| Op | Args | Backend | Write? | Who |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `job.put` | `{name, definition}` | `box-ctl.py job-put` (definition on stdin) | yes (writes + commits) | known identities only |
|
||||||
|
| `job.trigger` | `{name}` | `box-ctl.py job-trigger` | yes (dispatches now) | known identities only |
|
||||||
|
| `job.chain` | `{from, to, on_failure?}` | `box-ctl.py job-chain` | yes (writes + commits) | known identities only |
|
||||||
|
| `job.next` | `{job_id, success?}` | `box-ctl.py job-next` | no (dry-run) | any valid signer |
|
||||||
|
| `cron.timer_stop` | `{name}` | `box-ctl.py timer-stop` | yes (systemd) | known identities only |
|
||||||
|
| `cron.timer_disable` | `{name}` | `box-ctl.py timer-disable` | yes (systemd) | known identities only |
|
||||||
|
|
||||||
|
New `box-relay.sh` client commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box cron put <name> '<json-definition>' # create or update (see §3)
|
||||||
|
box cron trigger <name> # dispatch now (audited JSON)
|
||||||
|
box cron chain <from> <to> [--on-failure] # wire chain_next
|
||||||
|
box cron next <job-id> [--success|--fail] # dry-run: what dispatches next
|
||||||
|
box timer stop <name> # pause schedule (keeps unit)
|
||||||
|
box timer disable <name> # pause schedule (disables unit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw op call (signature auth, no token):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exec-sign.sh job.next '{"job_id": "heartbeat-20200101-000000-deadbeef"}'
|
||||||
|
exec-sign.sh job.chain '{"from": "ops-audit-step2", "to": "ops-audit-step3"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `job.trigger` vs existing `job.run`: `job.run` shells straight to
|
||||||
|
`job-dispatch.py` and relays raw output; `job.trigger` goes through
|
||||||
|
`box-ctl.py` (existence check, 300s bound, audit trail, JSON contract).
|
||||||
|
Prefer `job.trigger` for agent-driven dispatches.
|
||||||
|
- `job.next` job ids look like `<name>-YYYYMMDD-HHMMSS-<8hex>`; with no
|
||||||
|
`success` flag the box infers it from the last recorded result.
|
||||||
|
|
||||||
|
## 3. Job definition shape (`job.put`)
|
||||||
|
|
||||||
|
The full schema is enforced by `box-ctl.py validate_job` (single copy);
|
||||||
|
required fields: `name` (must match the argv name), `schedule` (`manual`
|
||||||
|
or convertible cron), `agent` (fleet member), `prompt_template` (1–4000
|
||||||
|
chars, no protocol literals, known `{placeholders}` only). `timeout`
|
||||||
|
60–3600s, `on_failure` policy, optional `chain_next` (must exist) and
|
||||||
|
`sidechat` / `dm_target` routing. `box-ctl.py` writes `jobs/<name>.json`
|
||||||
|
and commits (`Add/Update job <name> via box-ctl`).
|
||||||
|
|
||||||
|
## 4. Safety notes (same posture as existing ops)
|
||||||
|
|
||||||
|
- **Fixed argv, validated values.** Job names match
|
||||||
|
`^[a-z0-9][a-z0-9-]{0,63}$`; trigger/chain/timer ops require the job
|
||||||
|
file to exist; chain rejects self-links and cycles; timer ops require
|
||||||
|
the unit to exist. All checks run before any side effect.
|
||||||
|
- **Stdin plumbing.** `job.put` is the first op to pipe a request body to
|
||||||
|
`box-ctl.py` stdin (the raw definition, not the envelope); the routing
|
||||||
|
lives in one helper (`_stdin_body`) covered by unit tests.
|
||||||
|
- **No deletes, no kills.** `job-delete` / `timer-delete` are reachable
|
||||||
|
only over the operator SSH path, by explicit scope decision.
|
||||||
|
- **Audited.** Every execution records identity + op + args hash;
|
||||||
|
`box-ctl.py` additionally audits each mutation with its target.
|
||||||
|
|
||||||
|
## 5. Rollout notes
|
||||||
|
|
||||||
|
- Restart `exec-constrained.py` after deploy for the new ops to appear in
|
||||||
|
`GET /ops`.
|
||||||
|
- `box-relay.sh` is served from bl (`GET /box`); agents re-fetch to get
|
||||||
|
the `cron put|trigger|chain|next` and `timer stop|disable` commands.
|
||||||
|
- Non-goals: deletes, `job-result` ingestion, `strat`/`loop` writes,
|
||||||
|
approvals — future expansions, same pattern.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Box Loop + Strategy Writes over HTTPS (No SSH)
|
||||||
|
|
||||||
|
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
|
||||||
|
|
||||||
|
**Date:** 2026-10-06
|
||||||
|
**Status:** implemented on bl (`exec-constrained.py` + `box-relay.sh`;
|
||||||
|
`box-ctl.py` verbs pre-existed, plus a vars-name validator fix)
|
||||||
|
**Scope:** safe loop/strategy/variable mutations. Reads (`loop-status`,
|
||||||
|
`loop-health`, `loop-breaks`, `strat-get`, `vars-get`, ...) already ride
|
||||||
|
`box.exec` or typed read ops and are unchanged here.
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
Agents watching loop health could see breaks but needed SSH to remediate
|
||||||
|
them, resolve stale followups, tune strategy overrides, or undo variable
|
||||||
|
changes. All of this now rides the agent HTTPS path
|
||||||
|
(`https://exec.muse-dev.online/exec`, signature or bearer auth, named-op
|
||||||
|
allowlist, audit log).
|
||||||
|
|
||||||
|
## 2. New ops (all known-identities-only, none in the read-only subset)
|
||||||
|
|
||||||
|
| Op | Args | Backend |
|
||||||
|
|---|---|---|
|
||||||
|
| `loop.remediate` | `{dry_run?}` (default false) | `box-ctl.py loop-remediate [--dry-run]` |
|
||||||
|
| `loop.resolve` | `{dm_id, note?}` | `box-ctl.py loop-resolve` |
|
||||||
|
| `strat.set` | `{type!, subtype?, agent?, track?, priority?, timeout_s?, nudges?, escalate?}` | `box-ctl.py strat-set` (payload as argv JSON) |
|
||||||
|
| `strat.reset` | `{type!, subtype?, agent?}` | `box-ctl.py strat-reset` |
|
||||||
|
| `vars.reset` | `{name}` | `box-ctl.py vars-reset` (restore default) |
|
||||||
|
| `vars.rollback` | `{name, revision?}` (int step or timestamp) | `box-ctl.py vars-rollback` |
|
||||||
|
|
||||||
|
New `box-relay.sh` client commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box loop remediate [--dry-run]
|
||||||
|
box loop resolve <dm_id> [note...]
|
||||||
|
box strat set <type> [--subtype S] [--agent A] [--track true|false] [--priority p] [--timeout N] [--nudges N] [--escalate E]
|
||||||
|
box strat reset <type> [subtype] [--agent <agent>]
|
||||||
|
box vars reset <name>
|
||||||
|
box vars rollback <name> [revision]
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw op call (signature auth, no token):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exec-sign.sh loop.remediate '{"dry_run": true}'
|
||||||
|
exec-sign.sh strat.set '{"type": "job", "priority": "important", "nudges": 3}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. What remediate does (non-dry)
|
||||||
|
|
||||||
|
`loop.remediate` delegates to `gravity.remediate_breaks`: resolves
|
||||||
|
followups already answered in logs, re-arms expired followups with nudges
|
||||||
|
remaining (runs the followup sweeper once), auto-allows TRUSTED (non-key)
|
||||||
|
browser approvals, and on hard breaks appends a `hard_break_alert` to
|
||||||
|
job-log plus one DM to opm. Use `{"dry_run": true}` first to preview the
|
||||||
|
`remediated` / `escalated` lists with zero side effects.
|
||||||
|
|
||||||
|
## 4. Safety notes (same posture as existing ops)
|
||||||
|
|
||||||
|
- **Fixed argv, validated values.** Strategy types are a strict enum
|
||||||
|
(`wake|job|siphon|manual|health|heartbeat`) — the backend silently maps
|
||||||
|
typos to MANUAL, so the op rejects them instead. Priorities are a
|
||||||
|
strict enum; timeouts/nudges must be integers (backend clamps);
|
||||||
|
dm ids must be 6–64 hex; variable names mirror the engine identifier
|
||||||
|
rule. All checks run before any side effect.
|
||||||
|
- **Stdin-free.** Unlike `job.put`, `strat.set` passes its JSON payload
|
||||||
|
as an argv token (`strat-set <type> [JSON]`), so no new stdin plumbing
|
||||||
|
was needed.
|
||||||
|
- **Vars-name validator fix.** `quality-validate` for all five vars
|
||||||
|
verbs used the job-name regex (`^[a-z0-9-]{1,64}$`), rejecting every
|
||||||
|
real variable name (`max_nudge_count`, ...). They now share
|
||||||
|
`qv_var_name` (`^[A-Za-z0-9_.-]{1,64}$`), mirroring the
|
||||||
|
`exec-constrained.py` rule.
|
||||||
|
- **Audited.** Every execution records identity + op + args hash;
|
||||||
|
`box-ctl.py` additionally audits each mutation with its target.
|
||||||
|
|
||||||
|
## 5. Rollout notes
|
||||||
|
|
||||||
|
- Restart `exec-constrained.py` after deploy for the new ops to appear in
|
||||||
|
`GET /ops`.
|
||||||
|
- `box-relay.sh` is served from bl (`GET /box`); agents re-fetch to get
|
||||||
|
the `loop` / `strat` groups and `vars reset|rollback`.
|
||||||
|
- Non-goals: approvals, deletes, `main-loop` enable/disable — future
|
||||||
|
expansions, same pattern. (md drive files shipped separately; see
|
||||||
|
BOX-MD-HTTPS.md.)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Box Md Drive Files over HTTPS (No SSH)
|
||||||
|
|
||||||
|
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
|
||||||
|
|
||||||
|
**Date:** 2026-10-06
|
||||||
|
**Status:** implemented on bl (`exec-constrained.py` + `box-relay.sh`;
|
||||||
|
`box-ctl.py` verbs pre-existed, plus traversal hardening, output caps,
|
||||||
|
`--stdin` content plumbing, and hyphenated amend/append/pull aliases)
|
||||||
|
**Scope:** md reads (audit/list/read/diff) + governed writes
|
||||||
|
(amend/append/pull/inject-drive/sync-all). Raw container writes
|
||||||
|
(`md-write`) stay SSH-only by design.
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
Agents shaping fleet behavior could see drive scores but needed SSH to
|
||||||
|
read an agent's `SOUL.md`, diff it against the canonical template, or
|
||||||
|
push updated operator files. All of this now rides the agent HTTPS path
|
||||||
|
(`https://exec.muse-dev.online/exec`, signature or Bearer [REDACTED], named-op
|
||||||
|
allowlist, audit log).
|
||||||
|
|
||||||
|
## 2. New ops
|
||||||
|
|
||||||
|
Reads (all `side_effecting: false`, all in `DEFAULT_PERMS`):
|
||||||
|
|
||||||
|
| Op | Args | Backend |
|
||||||
|
|---|---|---|
|
||||||
|
| `md.audit` | `{accounts?}` (default all) | `box-ctl.py md-audit [accounts...]` |
|
||||||
|
| `md.list` | `{account!, path?}` | `box-ctl.py md-list` (capped, see §4) |
|
||||||
|
| `md.read` | `{account!, filename!}` | `box-ctl.py md-read` (capped, see §4) |
|
||||||
|
| `md.diff` | `{account!, filename!}` (shared template only) | `box-ctl.py md-diff` (capped, see §4) |
|
||||||
|
|
||||||
|
Governed writes (all known-identities-only, none in the read-only subset):
|
||||||
|
|
||||||
|
| Op | Args | Backend |
|
||||||
|
|---|---|---|
|
||||||
|
| `md.pull` | `{account!, filename!}` (shared template only) | `box-ctl.py md-pull` |
|
||||||
|
| `md.inject_drive` | `{account!}` | `box-ctl.py md-inject-drive` |
|
||||||
|
| `md.sync_all` | `{}` | `box-ctl.py md-sync-all` |
|
||||||
|
| `md.amend` | `{filename!, content!, author?, reason?}` | `box-ctl.py md-amend --stdin` (content on stdin) |
|
||||||
|
| `md.append` | `{filename!, text!, author?, section?}` | `box-ctl.py md-append --stdin` (text on stdin) |
|
||||||
|
|
||||||
|
New `box-relay.sh` client commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
box md audit [accounts...]
|
||||||
|
box md list <account> [path]
|
||||||
|
box md read <account> <filename>
|
||||||
|
box md diff <account> <filename>
|
||||||
|
box md pull <account> <filename>
|
||||||
|
box md inject-drive <account>
|
||||||
|
box md sync-all
|
||||||
|
box md amend <filename> (--content <text>|--file <path>) [--author <name>] [--reason <why>]
|
||||||
|
box md append <filename> (--content <text>|--file <path>) [--author <name>] [--section <header>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw op calls (signature auth, no token):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exec-sign.sh md.audit '{"accounts": ["646", "opm"]}'
|
||||||
|
exec-sign.sh md.read '{"account": "646", "filename": "SOUL.md"}'
|
||||||
|
exec-sign.sh md.diff '{"account": "pip", "filename": "HEARTBEAT.md"}'
|
||||||
|
exec-sign.sh md.append '{"filename": "AGENTS.md", "text": "lesson ...", "author": "646"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. What the governed writes do
|
||||||
|
|
||||||
|
- `md.amend` rewrites a `shared/operators/` template after the
|
||||||
|
drive-safety checks (HEARTBEAT checklist not gutted,
|
||||||
|
PROACTIVE_PREFERENCES not blanked, SOUL not reverted to stock),
|
||||||
|
then git-commits it. Full-file content rides stdin (up to 256KB).
|
||||||
|
- `md.append` appends a timestamped, attributed note (optional section)
|
||||||
|
via the same validated + committed path (up to 64KB).
|
||||||
|
- `md.pull` / `md.inject_drive` / `md.sync_all` push canonical
|
||||||
|
templates *out* to containers; no agent-supplied content crosses.
|
||||||
|
Injection always overwrites (AGENTS.md preserves remote `## Lessons`).
|
||||||
|
|
||||||
|
## 4. Safety notes (same posture as existing ops)
|
||||||
|
|
||||||
|
- **Traversal hardening (single-copy in `agent_md.py`).** Account,
|
||||||
|
filename, and list-path validation now lives in `agent_md.py`
|
||||||
|
(`MDValidationError`, raised before any gateway call or write);
|
||||||
|
`box-ctl.py` maps it to `BAD_NAME`, and exec ops + quality-validate
|
||||||
|
mirror the same shapes. Previously `md-read 646 ../x` reached the
|
||||||
|
gateway and `md amend ../../x` could escape `shared/operators/`.
|
||||||
|
Template flows (diff/amend/append/pull) additionally require one of
|
||||||
|
the 8 known template names.
|
||||||
|
- **Fixed argv, validated values.** Unknown arg keys rejected; author /
|
||||||
|
reason / section are control-char-free with length caps; amend
|
||||||
|
content must be non-empty.
|
||||||
|
- **Caps with `truncated` flags.** Reads cap at 64KB, diffs at 64KB,
|
||||||
|
listings at 200 entries — same convention as git/tests verbs.
|
||||||
|
- **No raw `md-write` op.** Arbitrary content-to-container stays
|
||||||
|
SSH-only; remote writes go through the validated template flows.
|
||||||
|
- **Timeouts.** Audit 300s, sync-all 600s, single-file ops 120s.
|
||||||
|
- **Audited.** Every execution records identity + op; `box-ctl.py`
|
||||||
|
additionally audits each verb with its target.
|
||||||
|
- **Retry-safe reads.** `md-audit` / `md-list` / `md-read` / `md-diff`
|
||||||
|
joined `IDEMPOTENT_ACTIONS`; all ten md verbs have
|
||||||
|
`quality-validate` dry-run branches.
|
||||||
|
|
||||||
|
## 5. Rollout notes
|
||||||
|
|
||||||
|
- Restart `exec-constrained.py` after deploy for the 9 new ops to
|
||||||
|
appear in `GET /ops` (repo total becomes 79).
|
||||||
|
- `box-relay.sh` is served from bl (`GET /box`); agents re-fetch to
|
||||||
|
get the `md` group.
|
||||||
|
- Non-goals: deletes, `main-loop` enable/disable, policy writes,
|
||||||
|
swarm kill/prune — future expansions, same pattern. (Approvals
|
||||||
|
shipped separately; see BOX-APPROVALS-HTTPS.md.)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Muse-Choices Deny/Escalate Policy — DECISION RECORD (Final)
|
||||||
|
|
||||||
|
Topic: add deny/escalate decisions to the `muse-choices` auto-approve daemon
|
||||||
|
(`bin/muse_choice_watcher.py`), which today only approves (top choice per
|
||||||
|
prompt kind). Interviewed 2026-10-06/07 per grill contract; accepted
|
||||||
|
verbatim below, which flipped this record from Draft to Final.
|
||||||
|
|
||||||
|
## Standing constraints (settled by user)
|
||||||
|
|
||||||
|
- All prompts must resolve: no stuck states are acceptable in any outcome.
|
||||||
|
- The full-auto top-choice flow must always exist as a path.
|
||||||
|
- Model review of choices is a FUTURE layer. Deferred out of this interview.
|
||||||
|
|
||||||
|
## Settled decisions
|
||||||
|
|
||||||
|
- D0 (helper form): a checked-in repo rules file informs decisions.
|
||||||
|
Source: user's structured answerquared 2026-10-06 ("Repo rules file
|
||||||
|
(Recommended)" for "which helper should inform approve/deny/hold
|
||||||
|
decisions"). Rationale recorded at selection time: deterministic,
|
||||||
|
versioned, sub-second, unit-testable; no agent round-trip latency.
|
||||||
|
|
||||||
|
## Settled during interview
|
||||||
|
|
||||||
|
- D0b (approve path needs no helper): straightforward prompts resolve
|
||||||
|
locally with top-choice keys (the five matcher kinds, already
|
||||||
|
implemented and live). Helpers (D0 rules file) govern deny/hold
|
||||||
|
judgments only. Source: user direction 2026-10-06 ("the watcher
|
||||||
|
itself should be able to input 1"; "always have the flow for full
|
||||||
|
auto just top choice").
|
||||||
|
- D1 (rule match dimensions): command pattern first, plus kind and
|
||||||
|
text pattern. Source: user selected option 1, 2026-10-06.
|
||||||
|
Rationale: the observed risk lives in the `$ command` of approval
|
||||||
|
dialogs; kind/text add precision around it.
|
||||||
|
- D2 (deny mechanics): deny exists only for permission kinds --
|
||||||
|
`muse-approval` dialogs receive `2` + Enter, `y/n` prompts receive
|
||||||
|
`n` + Enter. Question kinds (interview, letter, numbered) always
|
||||||
|
resolve top-choice and are never denied. Source: user selected
|
||||||
|
option 1, 2026-10-06. Rationale: deny is only meaningful where a
|
||||||
|
permission is refused; questions stay total.
|
||||||
|
- D3 (hold mechanics): hold leaves the dialog untouched, suppresses
|
||||||
|
auto-answer, raises a HELD entry in `box muse-choices status` plus
|
||||||
|
an audit record; the operator resolves via a box command, otherwise
|
||||||
|
a SHORT window expires back to top-choice approve. Source: user
|
||||||
|
selected option 1 with "short window", 2026-10-06. Exact duration
|
||||||
|
proposed below (2 minutes, tunable); accepted or amended with the
|
||||||
|
scope text in D5.
|
||||||
|
|
||||||
|
- D4 (unmatched default): approve top-choice, exactly today's
|
||||||
|
behavior. Source: user selected option 1, 2026-10-06. Rationale:
|
||||||
|
follows from the standing constraints; rules carve out only
|
||||||
|
deny/hold exceptions, so an empty rules file changes nothing.
|
||||||
|
|
||||||
|
## Open questions (unresolved)
|
||||||
|
|
||||||
|
None. All interview questions resolved and the scope accepted.
|
||||||
|
|
||||||
|
## Scope contract (ACCEPTED)
|
||||||
|
|
||||||
|
Artifact boundary, IN:
|
||||||
|
|
||||||
|
- `docs/MUSE-CHOICES-POLICY.md`: this record (Draft -> Final on acceptance).
|
||||||
|
- New `muse-choices-rules.json` at repo root (beside
|
||||||
|
`keepalive-config.json`): the checked-in deny/hold rules.
|
||||||
|
- `bin/muse_choice_watcher.py`: rule evaluation, deny/hold paths, HELD
|
||||||
|
state with short-window expiry, resolve plumbing.
|
||||||
|
- `bin/super-cli.py`: `box muse-choices resolve` command + HELD display
|
||||||
|
in status.
|
||||||
|
- `tests/test_muse_choice_watcher.py`: rule eval, per-kind deny keys,
|
||||||
|
hold/suppress/expiry, resolve flow.
|
||||||
|
|
||||||
|
Artifact boundary, OUT (rejected or deferred, each needs its own
|
||||||
|
interview to re-enter):
|
||||||
|
|
||||||
|
- Model review of choices (deferred future stage).
|
||||||
|
- Peer-agent consultation (rejected in D0).
|
||||||
|
- New matcher shapes (matcher suite's lane).
|
||||||
|
- `box runtime` work (adjacent lane, untouched).
|
||||||
|
- Timer cadence / daemon supervision changes.
|
||||||
|
|
||||||
|
Done means (all observable):
|
||||||
|
|
||||||
|
- [ ] This record marked Final with the acceptance quoted.
|
||||||
|
- [ ] Rules file loads; empty rules == today's behavior exactly.
|
||||||
|
- [ ] Deny sends `2`+Enter / `n`+Enter per D2: unit tests + one live
|
||||||
|
scratch proof per permission kind.
|
||||||
|
- [ ] Hold suppresses + shows HELD + resolves via box + expires to
|
||||||
|
approve: unit tests + one live scratch proof of hold and one of
|
||||||
|
expiry.
|
||||||
|
- [ ] Audit records for deny/hold/resolve/expire in `box-ctl.jsonl`.
|
||||||
|
- [ ] Full suite green; fleet reloaded; desired state left as found.
|
||||||
|
|
||||||
|
Acceptance (quoted verbatim, chat, 2026-10-07T00:19:57Z): "ACCEPT".
|
||||||
|
Accepted as written, including the 2-minute tunable hold window. Per the
|
||||||
|
grill scope contract, later work outside the IN boundary needs explicit
|
||||||
|
owner approval or its own follow-up interview; "go" authorizes only this
|
||||||
|
boundary. No owning issue exists in this workflow, so this record is the
|
||||||
|
lane-coordination evidence.
|
||||||
|
|
||||||
|
## Non-goals (accepted with the scope)
|
||||||
|
|
||||||
|
- Model-based review of choices (deferred future layer).
|
||||||
|
- Peer-agent consultation over sidechat (rejected in favor of D0).
|
||||||
|
- Changes to approval matching shapes (covered by the matcher test suite).
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Supervision Scope Contract
|
||||||
|
|
||||||
|
Status: **Draft** — decisions below are unsettled until marked otherwise.
|
||||||
|
Only explicit user acceptance moves this document (or any decision) to Final.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Every fleet node stays alive and truthfully reported: browsers supervised,
|
||||||
|
relays supervised, dead nodes recovered or loudly paged, and `box` status
|
||||||
|
honest from any shell (including PID/net-blind sandboxed shells).
|
||||||
|
|
||||||
|
## Non-goals (proposed)
|
||||||
|
|
||||||
|
- Agent lifecycle/onboarding stages (provision, auth, OTP, invite redeem).
|
||||||
|
- Work completion (job dispatch, followups, harvester, completion auditor).
|
||||||
|
- Loop-health scoring and drive repair.
|
||||||
|
|
||||||
|
## Supervisors (observed, all installed 2026-10-06)
|
||||||
|
|
||||||
|
| Supervisor | Cadence | Coverage | Decides |
|
||||||
|
|---|---|---|---|
|
||||||
|
| chromebox-watchdog@\<node\>.timer ×6 | 2 min | all registry nodes (def/dev timers installed 18:32Z) | browser+egress per node; tunnel restart, chrome relaunch |
|
||||||
|
| cdp-relay-watchdog.timer | 5 min | registry-driven (`watched_nodes()`) | relay veth IP + connectivity; relay restart |
|
||||||
|
| agent-health.timer (user) | 5 min | registry-driven | API check per node; kill+restart with 2-strike rule + circuit breaker (3 futile → open 30 min) |
|
||||||
|
| ensure-node-supervision.sh | on node-up / `--all` | new + drifted nodes | NODES.md row + chromebox timer install |
|
||||||
|
| host_evidence fallback | on `box` read | registry (relay) + installed timers (browser) | effective status when live probes are blind |
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
(D1..D7 below — all UNRESOLVED unless marked.)
|
||||||
|
|
||||||
|
### D1. Contract boundary: which supervisors are in scope — SETTLED (recommended accepted)
|
||||||
|
|
||||||
|
IN: chromebox-watchdog ×6, cdp-relay-watchdog, agent-health + circuit
|
||||||
|
breaker, ensure-node-supervision feed, host_evidence fallback.
|
||||||
|
OUT: onboarding pipeline lifecycle, completion auditor, loop-health
|
||||||
|
(each keeps its own owner and interviews separately).
|
||||||
|
|
||||||
|
### D2. Kill-path precedence (chromebox-watchdog vs agent-health) — UNRESOLVED
|
||||||
|
|
||||||
|
Both can kill a browser today; only time guards (<2 min) de-conflict them.
|
||||||
|
|
||||||
|
### D3. Egress-down fall-through (relaunch chrome after failed tunnel restart?) — UNRESOLVED
|
||||||
|
|
||||||
|
Observed 19:12Z: tunnel restart failed, watchdog relaunched chrome 3×
|
||||||
|
anyway (one FAILED page). Browser was never the problem.
|
||||||
|
|
||||||
|
### D4. Circuit-breaker thresholds (3 futile / 30 min cooldown) — UNRESOLVED
|
||||||
|
|
||||||
|
Current values unvalidated against real recurrence intervals.
|
||||||
|
|
||||||
|
### D5. Coverage source of truth — UNRESOLVED
|
||||||
|
|
||||||
|
Registry-only vs registry+installed-timers for browser verdicts.
|
||||||
|
|
||||||
|
### D6. Concurrent-edit protocol for shared supervision files — UNRESOLVED
|
||||||
|
|
||||||
|
Two agents editing super-cli.py / watchdogs / runbook; one clobber
|
||||||
|
(18:03Z) and one unattributed commit (c9143a5) already occurred.
|
||||||
|
|
||||||
|
### D7. Done means — UNRESOLVED
|
||||||
|
|
||||||
|
Proposed checklist: timers on all 6 firing silent; relay/agent-health
|
||||||
|
loops registry-driven with tests; ensure hook live; this doc Final.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Egress-down pages read as browser failures (D3).
|
||||||
|
- Uncommitted supervisor work can be clobbered by a concurrent editor (D6).
|
||||||
|
- c9143a5 contains unattributed peer hunks (host_evidence `_covered_nodes`,
|
||||||
|
fleet-status test updates) — needs an amend-or-leave decision.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `box fleet status` truthful from blind shells (live-verified 6/6 ACTIVE).
|
||||||
|
- Focused suites green (supervision, fleet, agent-health, watchdog coverage).
|
||||||
|
- Timer firing proven via journal, not config presence.
|
||||||
|
|
||||||
|
## Unresolved items
|
||||||
|
|
||||||
|
D2–D7 unresolved. D1 settled.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NetVM Muse Choice Watcher Reconcile
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 /home/super/Projects/NetVM/bin/muse_choice_watcher.py reconcile
|
||||||
|
WorkingDirectory=/home/super/Projects/NetVM
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
# Reconcile spawns long-lived per-pane daemons. Default KillMode=
|
||||||
|
# control-group would SIGTERM/SIGKILL them (setsid cannot escape a
|
||||||
|
# cgroup) the moment this oneshot service exits -- leaving every
|
||||||
|
# timer-started pane uncovered (observed live). Only signal the main
|
||||||
|
# process so spawned watchers survive service exit.
|
||||||
|
KillMode=process
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Muse Choice Watcher reconcile every minute
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=1min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+107
-1
@@ -12,8 +12,10 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||||
BIN_DIR = REPO_ROOT / "bin"
|
BIN_DIR = REPO_ROOT / "bin"
|
||||||
@@ -138,8 +140,12 @@ class TestMuseChatApiConnection(unittest.TestCase):
|
|||||||
def test_muse_chat_api_approvals_command(self):
|
def test_muse_chat_api_approvals_command(self):
|
||||||
cmd = [sys.executable, str(BIN_DIR / "muse-chat-api.py"), "--account", "pip", "approvals"]
|
cmd = [sys.executable, str(BIN_DIR / "muse-chat-api.py"), "--account", "pip", "approvals"]
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
self.assertEqual(r.returncode, 0)
|
self.assertIn(r.returncode, (0, 2))
|
||||||
|
if r.returncode == 0:
|
||||||
self.assertIn("No pending approvals", r.stdout)
|
self.assertIn("No pending approvals", r.stdout)
|
||||||
|
else:
|
||||||
|
self.assertIn("APPROVAL_NEEDED", r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestApprovalsReplySafety(unittest.TestCase):
|
class TestApprovalsReplySafety(unittest.TestCase):
|
||||||
@@ -242,6 +248,106 @@ class TestKeyApprovalsAndPasskey(unittest.TestCase):
|
|||||||
self.assertEqual(deny_data.get("decision"), "deny")
|
self.assertEqual(deny_data.get("decision"), "deny")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWS:
|
||||||
|
"""Scripted stand-in for a CDP websocket (no network)."""
|
||||||
|
|
||||||
|
def __init__(self, recvs):
|
||||||
|
self._recvs = list(recvs)
|
||||||
|
self.sent_ids = []
|
||||||
|
|
||||||
|
def send(self, msg):
|
||||||
|
self.sent_ids.append(json.loads(msg)["id"])
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
if not self._recvs:
|
||||||
|
raise Exception("recv queue exhausted")
|
||||||
|
item = self._recvs.pop(0)
|
||||||
|
if callable(item):
|
||||||
|
return item(self)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_last_value(value):
|
||||||
|
def _recv(ws):
|
||||||
|
return json.dumps({"id": ws.sent_ids[-1],
|
||||||
|
"result": {"result": {"value": value}}})
|
||||||
|
return _recv
|
||||||
|
|
||||||
|
|
||||||
|
class TestInspectRobustness(unittest.TestCase):
|
||||||
|
"""Regression tests for intermittent approval failures."""
|
||||||
|
|
||||||
|
def test_cdp_request_ids_unique(self):
|
||||||
|
# Millisecond-clock ids collide for rapid successive evaluates;
|
||||||
|
# a stale buffered response can then be misattributed to the
|
||||||
|
# wrong call (e.g. verify-after-click reads the click result).
|
||||||
|
# Frozen clock makes the old collision deterministic.
|
||||||
|
with mock.patch("approvals.time.time", return_value=1728000000.123):
|
||||||
|
ws = _FakeWS([_echo_last_value("a"), _echo_last_value("b")])
|
||||||
|
self.assertEqual(approvals.cdp_evaluate(ws, "1+1"), "a")
|
||||||
|
self.assertEqual(approvals.cdp_evaluate(ws, "2+2"), "b")
|
||||||
|
self.assertNotEqual(ws.sent_ids[0], ws.sent_ids[1])
|
||||||
|
|
||||||
|
def test_cdp_skips_stale_ids(self):
|
||||||
|
stale = json.dumps({"id": 999999999,
|
||||||
|
"result": {"result": {"value": "stale"}}})
|
||||||
|
ws = _FakeWS([stale, _echo_last_value("fresh")])
|
||||||
|
self.assertEqual(approvals.cdp_evaluate(ws, "1+1"), "fresh")
|
||||||
|
|
||||||
|
def test_unreachable_returns_full_shape(self):
|
||||||
|
with mock.patch.object(approvals, "get_node_pages",
|
||||||
|
side_effect=ConnectionError("nope")), \
|
||||||
|
mock.patch.object(approvals, "check_node_key_request",
|
||||||
|
return_value=None):
|
||||||
|
res = approvals.inspect_node_approvals("pip")
|
||||||
|
self.assertEqual(res["status"], "UNREACHABLE")
|
||||||
|
self.assertFalse(res["has_pending"])
|
||||||
|
for key in ("node", "title", "buttons", "is_trusted",
|
||||||
|
"input_waits", "error"):
|
||||||
|
self.assertIn(key, res)
|
||||||
|
|
||||||
|
def test_all_pages_failed_reports_error(self):
|
||||||
|
pages = [{"title": "t", "url": "u", "type": "page",
|
||||||
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9/none"}]
|
||||||
|
|
||||||
|
class _DeadWSModule:
|
||||||
|
@staticmethod
|
||||||
|
def create_connection(*a, **k):
|
||||||
|
raise ConnectionError("refused")
|
||||||
|
|
||||||
|
with mock.patch.object(approvals, "get_node_pages",
|
||||||
|
return_value=pages), \
|
||||||
|
mock.patch.object(approvals, "websocket", _DeadWSModule()), \
|
||||||
|
mock.patch.object(approvals, "check_node_key_request",
|
||||||
|
return_value=None):
|
||||||
|
res = approvals.inspect_node_approvals("pip")
|
||||||
|
# Per-page CDP failures must surface as ERROR, never as a
|
||||||
|
# false CLEAR that hides pending approvals.
|
||||||
|
self.assertEqual(res["status"], "ERROR")
|
||||||
|
self.assertFalse(res["has_pending"])
|
||||||
|
self.assertIn("error", res)
|
||||||
|
|
||||||
|
def test_state_saves_roundtrip_without_leftovers(self):
|
||||||
|
# Guards the atomic-save refactor (tmp + replace): correct
|
||||||
|
# content and no stray temp files left behind.
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
rp = Path(td) / "resp.json"
|
||||||
|
with mock.patch.object(approvals, "RESPONDED_WAITS_FILE", rp):
|
||||||
|
approvals.save_responded_waits({"pip": {"t": "x"}})
|
||||||
|
self.assertEqual(json.loads(rp.read_text()),
|
||||||
|
{"pip": {"t": "x"}})
|
||||||
|
fp = Path(td) / "seen.json"
|
||||||
|
with mock.patch.object(approvals, "FIRST_SEEN_WAITS_FILE", fp):
|
||||||
|
approvals.save_first_seen_waits({"pip": {"t": "x"}})
|
||||||
|
self.assertEqual(json.loads(fp.read_text()),
|
||||||
|
{"pip": {"t": "x"}})
|
||||||
|
self.assertEqual(sorted(p.name for p in Path(td).iterdir()),
|
||||||
|
["resp.json", "seen.json"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""The bl-side #lobby relay must stay opt-in.
|
||||||
|
|
||||||
|
fleet-alert-check.sh invokes bin/fleet-alert-relay.sh only when
|
||||||
|
FLEET_BL_RELAY=1: the container-side hook is the live pager, and running
|
||||||
|
both double-posts every alert (2026-10-06). These tests run a copy of the
|
||||||
|
checker with stubbed-out fleet commands and assert the relay stub is (not)
|
||||||
|
invoked.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CHECKER = REPO_ROOT / "bin" / "fleet-alert-check.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class BlRelayGate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
root = Path(self.tmp.name)
|
||||||
|
self.bindir = root / "bin"
|
||||||
|
self.bindir.mkdir()
|
||||||
|
# Copy the real checker so BIN-relative lookups hit our stubs.
|
||||||
|
shutil.copy(CHECKER, self.bindir / "fleet-alert-check.sh")
|
||||||
|
(self.bindir / "fleet-alert-relay.sh").write_text(
|
||||||
|
"#!/bin/bash\necho RELAY-RAN >> \"$CALLS\"\n")
|
||||||
|
(self.bindir / "fleet-alert-relay.sh").chmod(0o755)
|
||||||
|
(self.bindir / "netvm-registry.py").write_text(
|
||||||
|
"#!/usr/bin/env python3\n") # no nodes: all fleet loops skip
|
||||||
|
(self.bindir / "netvm-registry.py").chmod(0o755)
|
||||||
|
(self.bindir / "box-ctl.py").write_text("#!/usr/bin/env python3\n")
|
||||||
|
(self.bindir / "box-ctl.py").chmod(0o755)
|
||||||
|
fakebin = root / "fakebin"
|
||||||
|
fakebin.mkdir()
|
||||||
|
(fakebin / "sudo").write_text("#!/bin/bash\necho sudo-stub >&2\nexit 1\n")
|
||||||
|
(fakebin / "sudo").chmod(0o755)
|
||||||
|
self.state = root / "state"
|
||||||
|
self.state.mkdir()
|
||||||
|
self.calls = root / "calls.log"
|
||||||
|
self.env = dict(os.environ)
|
||||||
|
self.env["PATH"] = str(fakebin) + ":/usr/bin:/bin"
|
||||||
|
self.env["FLEET_ALERT_STATE_DIR"] = str(self.state)
|
||||||
|
self.env["CALLS"] = str(self.calls)
|
||||||
|
self.env.pop("FLEET_BL_RELAY", None)
|
||||||
|
|
||||||
|
def run_checker(self, **extra):
|
||||||
|
env = dict(self.env)
|
||||||
|
env.update(extra)
|
||||||
|
# NOTE: appends 1-2 lines to the shared /tmp/fleet-alert-check.log
|
||||||
|
# (LOG path is hardcoded); same as any production timer run.
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(self.bindir / "fleet-alert-check.sh")],
|
||||||
|
capture_output=True, text=True, env=env, timeout=120)
|
||||||
|
|
||||||
|
def relay_ran(self):
|
||||||
|
return self.calls.exists() and "RELAY-RAN" in self.calls.read_text()
|
||||||
|
|
||||||
|
def test_relay_not_invoked_by_default(self):
|
||||||
|
r = self.run_checker()
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[-2000:])
|
||||||
|
self.assertFalse(self.relay_ran())
|
||||||
|
|
||||||
|
def test_relay_invoked_when_opted_in(self):
|
||||||
|
r = self.run_checker(FLEET_BL_RELAY="1")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[-2000:])
|
||||||
|
self.assertTrue(self.relay_ran())
|
||||||
|
|
||||||
|
def test_dry_run_never_invokes_relay(self):
|
||||||
|
r = self.run_checker(FLEET_BL_RELAY="1", FLEET_ALERT_DRY_RUN="1")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[-2000:])
|
||||||
|
self.assertFalse(self.relay_ran())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Tests for approvals over HTTPS (no SSH).
|
||||||
|
|
||||||
|
Covers the approvals expansion:
|
||||||
|
box-relay.sh (agent client) -> exec-constrained.py named ops
|
||||||
|
-> box-ctl.py backend verbs -> approvals.py (fleet CDP).
|
||||||
|
|
||||||
|
Live execution is limited to validation-failure paths (BAD_NODE/BAD_ARGS,
|
||||||
|
which fail before any CDP probe) plus quality-validate dry-runs. No live
|
||||||
|
browser traffic and no live-socket round-trips here; instead we assert the
|
||||||
|
exact argv each op builds. In particular the allow build must never carry
|
||||||
|
--always/--force: remote allow is one-shot only.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
RELAY = REPO_ROOT / "bin" / "box-relay.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
exec_constrained = _load("exec_constrained_approvals",
|
||||||
|
"bin/exec-constrained.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
capture_output=True, text=True, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecApprovalOpsTests(unittest.TestCase):
|
||||||
|
def test_ops_registered_and_side_effecting(self):
|
||||||
|
spec = exec_constrained.OPS
|
||||||
|
self.assertIn("approval.check", spec)
|
||||||
|
self.assertFalse(spec["approval.check"]["side_effecting"])
|
||||||
|
for op in ("approval.deny", "approval.auto", "approval.allow"):
|
||||||
|
self.assertIn(op, spec)
|
||||||
|
self.assertTrue(spec[op]["side_effecting"], op)
|
||||||
|
|
||||||
|
def test_known_identities_only(self):
|
||||||
|
p = exec_constrained.permitted
|
||||||
|
self.assertTrue(p("some-unknown-identity", "approval.check"))
|
||||||
|
for op in ("approval.deny", "approval.auto", "approval.allow"):
|
||||||
|
self.assertFalse(p("some-unknown-identity", op), op)
|
||||||
|
self.assertTrue(p("operator-646", op), op)
|
||||||
|
self.assertFalse(p("exec-canary", "approval.check"))
|
||||||
|
|
||||||
|
def test_check_validate(self):
|
||||||
|
v = exec_constrained.OPS["approval.check"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"node": None})
|
||||||
|
self.assertEqual(v({"node": None}), {"node": None})
|
||||||
|
self.assertEqual(v({"node": "646"}), {"node": "646"})
|
||||||
|
for bad in ({"node": "nope"}, {"node": "../x"},
|
||||||
|
{"node": "646", "bogus": 1}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_deny_validate(self):
|
||||||
|
v = exec_constrained.OPS["approval.deny"]["validate"]
|
||||||
|
good = v({"node": "646", "message": "not trusted",
|
||||||
|
"allow_main_chat": True})
|
||||||
|
self.assertEqual(good, {"node": "646", "message": "not trusted",
|
||||||
|
"allow_main_chat": True})
|
||||||
|
self.assertFalse(v({"node": "646",
|
||||||
|
"message": "m"})["allow_main_chat"])
|
||||||
|
# Multiline explanations are fine; other controls are not.
|
||||||
|
v({"node": "646", "message": "line1\nline2"})
|
||||||
|
over = "x" * (exec_constrained.MAX_MESSAGE + 1)
|
||||||
|
for bad in ({"node": "646"},
|
||||||
|
{"node": "646", "message": " "},
|
||||||
|
{"node": "646", "message": over},
|
||||||
|
{"node": "646", "message": "a\x07b"},
|
||||||
|
{"node": "nope", "message": "m"},
|
||||||
|
{"node": "646", "message": "m",
|
||||||
|
"allow_main_chat": "yes"},
|
||||||
|
{"node": "646", "message": "m", "force": True}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_auto_validate(self):
|
||||||
|
v = exec_constrained.OPS["approval.auto"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"node": None})
|
||||||
|
self.assertEqual(v({"node": "opm"}), {"node": "opm"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "nope"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "646", "always": True})
|
||||||
|
|
||||||
|
def test_allow_validate(self):
|
||||||
|
v = exec_constrained.OPS["approval.allow"]["validate"]
|
||||||
|
good = v({"node": "646", "message": "trusted deploy script"})
|
||||||
|
self.assertEqual(good["message"], "trusted deploy script")
|
||||||
|
self.assertFalse(good["allow_main_chat"])
|
||||||
|
# Attribution is mandatory: the flow notifies the waiting agent,
|
||||||
|
# so a remote allow must carry its reason.
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "646"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "646", "message": " "})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "nope", "message": "m"})
|
||||||
|
# No persistence/force remotely, not even as rejected keys.
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "646", "message": "m", "always": True})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"node": "646", "message": "m", "force": True})
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
ops = exec_constrained.OPS
|
||||||
|
ck = ops["approval.check"]
|
||||||
|
self.assertEqual(ck["build"]({"node": None})[-1],
|
||||||
|
"approval-check")
|
||||||
|
self.assertEqual(ck["build"]({"node": "646"})[-2:],
|
||||||
|
["approval-check", "646"])
|
||||||
|
de = ops["approval.deny"]
|
||||||
|
argv = de["build"]({"node": "646", "message": "m",
|
||||||
|
"allow_main_chat": False})
|
||||||
|
self.assertEqual(argv[-4:],
|
||||||
|
["approval-deny", "646", "--message", "m"])
|
||||||
|
argv = de["build"]({"node": "646", "message": "m",
|
||||||
|
"allow_main_chat": True})
|
||||||
|
self.assertEqual(argv[-1], "--allow-main-chat")
|
||||||
|
au = ops["approval.auto"]
|
||||||
|
self.assertEqual(au["build"]({"node": None})[-1], "approval-auto")
|
||||||
|
self.assertEqual(au["build"]({"node": "opm"})[-2:],
|
||||||
|
["approval-auto", "opm"])
|
||||||
|
al = ops["approval.allow"]
|
||||||
|
argv = al["build"]({"node": "646", "message": "m",
|
||||||
|
"allow_main_chat": False})
|
||||||
|
self.assertEqual(argv[-4:],
|
||||||
|
["approval-allow", "646", "--message", "m"])
|
||||||
|
self.assertNotIn("--always", argv)
|
||||||
|
self.assertNotIn("--force", argv)
|
||||||
|
argv = al["build"]({"node": "646", "message": "m",
|
||||||
|
"allow_main_chat": True})
|
||||||
|
self.assertEqual(argv[-1], "--allow-main-chat")
|
||||||
|
self.assertIsInstance(argv, list)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlApprovalTests(unittest.TestCase):
|
||||||
|
def test_rejects_unknown_node_before_cdp(self):
|
||||||
|
for args in (["approval-check", "badnode"],
|
||||||
|
["approval-list", "badnode"],
|
||||||
|
["approval-allow", "badnode", "--message", "m"],
|
||||||
|
["approval-deny", "badnode", "--message", "m"],
|
||||||
|
["approval-auto", "badnode"]):
|
||||||
|
r = _box_ctl(*args)
|
||||||
|
self.assertNotEqual(r.returncode, 0, args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NODE",
|
||||||
|
args)
|
||||||
|
|
||||||
|
def test_rejects_missing_node(self):
|
||||||
|
for args in (["approval-allow"], ["approval-deny"]):
|
||||||
|
r = _box_ctl(*args)
|
||||||
|
self.assertNotEqual(r.returncode, 0, args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_ARGS",
|
||||||
|
args)
|
||||||
|
|
||||||
|
def test_quality_validate_approval_verbs(self):
|
||||||
|
# Note: box-ctl leaves --message optional (SSH callers may rely on
|
||||||
|
# the flow default); the exec layer is the narrower gate and
|
||||||
|
# requires it. quality-validate mirrors box-ctl.
|
||||||
|
cases = [
|
||||||
|
(["approval-check"], True),
|
||||||
|
(["approval-check", "646"], True),
|
||||||
|
(["approval-check", "nope"], False),
|
||||||
|
(["approval-check", "646", "opm"], False),
|
||||||
|
(["approval-list", "646"], True),
|
||||||
|
(["approval-allow", "646", "--message", "hi"], True),
|
||||||
|
(["approval-allow", "646"], True),
|
||||||
|
(["approval-allow"], False),
|
||||||
|
(["approval-allow", "nope", "--message", "x"], False),
|
||||||
|
(["approval-allow", "646", "--always", "--force",
|
||||||
|
"--message", "x"], True),
|
||||||
|
(["approval-approve", "646", "--message", "x"], True),
|
||||||
|
(["approval-deny", "646", "--message", "x"], True),
|
||||||
|
(["approval-deny", "646", "--message", "x",
|
||||||
|
"--allow-main-chat"], True),
|
||||||
|
(["approval-deny"], False),
|
||||||
|
(["approval-auto"], True),
|
||||||
|
(["approval-auto", "646"], True),
|
||||||
|
(["approval-auto", "nope"], False),
|
||||||
|
(["approval-auto", "a", "b"], False),
|
||||||
|
]
|
||||||
|
for args, valid in cases:
|
||||||
|
r = _box_ctl("quality-validate", *args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["valid"], valid, args)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRelayApprovalTests(unittest.TestCase):
|
||||||
|
def test_relay_help_lists_approval_commands(self):
|
||||||
|
r = subprocess.run(["bash", str(RELAY), "help"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
for line in ("box approvals check", "box approvals allow",
|
||||||
|
"box approvals deny", "box approvals auto"):
|
||||||
|
self.assertIn(line, r.stdout)
|
||||||
|
|
||||||
|
def test_relay_maps_approval_commands_to_ops(self):
|
||||||
|
text = RELAY.read_text()
|
||||||
|
for op in ('"approval.check"', '"approval.deny"',
|
||||||
|
'"approval.auto"', '"approval.allow"'):
|
||||||
|
self.assertIn(op, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Tests for no-SSH agent development and communication streams.
|
||||||
|
|
||||||
|
Covers the second HTTPS expansion:
|
||||||
|
box-relay.sh (agent client) -> exec-constrained.py named ops
|
||||||
|
-> box-ctl.py backend verbs -> git / unittest / dm.py.
|
||||||
|
|
||||||
|
Live-socket round-trips are intentionally NOT covered here (loopback TCP is
|
||||||
|
unavailable in some sandboxes); instead we assert the exact argv each op
|
||||||
|
builds and execute the fast, side-effect-free argv directly. Live sends
|
||||||
|
(notify/ack) are NEVER executed here: only their validation-failure paths.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
RELAY = REPO_ROOT / "bin" / "box-relay.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
exec_constrained = _load("exec_constrained_dev", "bin/exec-constrained.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecGitOpsTests(unittest.TestCase):
|
||||||
|
def test_ops_registered_and_read_only(self):
|
||||||
|
for op in ("git.status", "git.diff", "git.log"):
|
||||||
|
self.assertIn(op, exec_constrained.OPS)
|
||||||
|
self.assertFalse(exec_constrained.OPS[op]["side_effecting"])
|
||||||
|
|
||||||
|
def test_default_perms_include_git_ops(self):
|
||||||
|
self.assertTrue(exec_constrained.permitted("some-unknown-identity", "git.status"))
|
||||||
|
self.assertTrue(exec_constrained.permitted("some-unknown-identity", "git.diff"))
|
||||||
|
self.assertTrue(exec_constrained.permitted("some-unknown-identity", "git.log"))
|
||||||
|
self.assertFalse(exec_constrained.permitted("exec-canary", "git.status"))
|
||||||
|
|
||||||
|
def test_git_status_validate(self):
|
||||||
|
v = exec_constrained.OPS["git.status"]["validate"]
|
||||||
|
self.assertEqual(v({}), {})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"bogus": 1})
|
||||||
|
|
||||||
|
def test_git_diff_validate(self):
|
||||||
|
v = exec_constrained.OPS["git.diff"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"path": None, "stat": False})
|
||||||
|
self.assertEqual(v({"path": "bin/dm.py", "stat": True}),
|
||||||
|
{"path": "bin/dm.py", "stat": True})
|
||||||
|
for bad in ("../x", "/abs/path", "a\x00b", ""):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v({"path": bad})
|
||||||
|
|
||||||
|
def test_git_log_validate(self):
|
||||||
|
v = exec_constrained.OPS["git.log"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"limit": 10, "path": None})
|
||||||
|
self.assertEqual(v({"limit": 3})["limit"], 3)
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"limit": 0})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"limit": 51})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"path": "../x"})
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
status = exec_constrained.OPS["git.status"]
|
||||||
|
self.assertEqual(status["build"]({})[-1], "git-status")
|
||||||
|
diff = exec_constrained.OPS["git.diff"]
|
||||||
|
self.assertEqual(diff["build"]({"path": None, "stat": False})[-1], "git-diff")
|
||||||
|
argv = diff["build"]({"path": "bin/dm.py", "stat": True})
|
||||||
|
self.assertEqual(argv[-4:], ["git-diff", "--stat", "--path", "bin/dm.py"])
|
||||||
|
log = exec_constrained.OPS["git.log"]
|
||||||
|
argv = log["build"]({"limit": 3, "path": None})
|
||||||
|
self.assertEqual(argv[-3:], ["git-log", "--limit", "3"])
|
||||||
|
self.assertIsInstance(argv, list)
|
||||||
|
|
||||||
|
def test_git_status_built_argv_executes(self):
|
||||||
|
spec = exec_constrained.OPS["git.status"]
|
||||||
|
argv = spec["build"](spec["validate"]({}))
|
||||||
|
argv[0] = sys.executable # hermetic interpreter, same script + args
|
||||||
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertIn("branch", data)
|
||||||
|
self.assertIsInstance(data["changes"], list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecTestsRunTests(unittest.TestCase):
|
||||||
|
def test_registered_and_side_effecting(self):
|
||||||
|
self.assertIn("tests.run", exec_constrained.OPS)
|
||||||
|
self.assertTrue(exec_constrained.OPS["tests.run"]["side_effecting"])
|
||||||
|
|
||||||
|
def test_known_identities_only(self):
|
||||||
|
# Executes repo code: excluded from the read-only default subset.
|
||||||
|
self.assertFalse(exec_constrained.permitted("some-unknown-identity", "tests.run"))
|
||||||
|
self.assertTrue(exec_constrained.permitted("operator-646", "tests.run"))
|
||||||
|
|
||||||
|
def test_validate(self):
|
||||||
|
v = exec_constrained.OPS["tests.run"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"test": None, "filter": None})
|
||||||
|
self.assertEqual(v({"test": "tests.test_box_read_https"}),
|
||||||
|
{"test": "tests.test_box_read_https",
|
||||||
|
"filter": None})
|
||||||
|
self.assertEqual(v({"filter": "safepath"})["filter"], "safepath")
|
||||||
|
for bad in ("os", "tests..x", "tests/x", "tests.test-x", ""):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v({"test": bad})
|
||||||
|
for bad in ("", "x" * 201, "a\nb"):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=repr(bad)):
|
||||||
|
v({"filter": bad})
|
||||||
|
|
||||||
|
def test_build_argv_shape(self):
|
||||||
|
b = exec_constrained.OPS["tests.run"]["build"]
|
||||||
|
self.assertEqual(b({"test": None, "filter": None})[-1], "tests-run")
|
||||||
|
argv = b({"test": "tests.test_box_read_https", "filter": None})
|
||||||
|
self.assertEqual(argv[-2:], ["tests-run", "tests.test_box_read_https"])
|
||||||
|
argv = b({"test": None, "filter": "safepath"})
|
||||||
|
self.assertEqual(argv[-3:], ["tests-run", "--filter", "safepath"])
|
||||||
|
|
||||||
|
|
||||||
|
class ExecCommsOpsTests(unittest.TestCase):
|
||||||
|
def test_registered_and_side_effecting(self):
|
||||||
|
for op in ("notify.send", "dm.ack"):
|
||||||
|
self.assertIn(op, exec_constrained.OPS)
|
||||||
|
self.assertTrue(exec_constrained.OPS[op]["side_effecting"])
|
||||||
|
|
||||||
|
def test_known_identities_only(self):
|
||||||
|
self.assertFalse(exec_constrained.permitted("some-unknown-identity", "notify.send"))
|
||||||
|
self.assertFalse(exec_constrained.permitted("some-unknown-identity", "dm.ack"))
|
||||||
|
self.assertTrue(exec_constrained.permitted("operator-646", "notify.send"))
|
||||||
|
self.assertTrue(exec_constrained.permitted("muse", "dm.ack"))
|
||||||
|
|
||||||
|
def test_notify_send_validate(self):
|
||||||
|
v = exec_constrained.OPS["notify.send"]["validate"]
|
||||||
|
self.assertEqual(v({"agent": "pip", "message": "hi"}),
|
||||||
|
{"agent": "pip", "message": "hi",
|
||||||
|
"sidechat": None, "sender": None})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"agent": "nope", "message": "hi"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"agent": "pip", "message": "x" * 1001})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"agent": "pip", "message": " "})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"agent": "pip", "message": "hi", "sidechat": "a" * 65})
|
||||||
|
|
||||||
|
def test_dm_ack_validate(self):
|
||||||
|
v = exec_constrained.OPS["dm.ack"]["validate"]
|
||||||
|
good = v({"id": "bdf7beb6", "to": "pip", "sender": "opm"})
|
||||||
|
self.assertEqual(good["id"], "bdf7beb6")
|
||||||
|
self.assertFalse(good["allow_main_chat"])
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"id": "xyz!", "to": "pip", "sender": "opm"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"id": "bdf7beb6", "to": "nope", "sender": "opm"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"id": "bdf7beb6", "to": "pip"}) # sender required
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
n = exec_constrained.OPS["notify.send"]
|
||||||
|
argv = n["build"]({"agent": "pip", "message": "hi",
|
||||||
|
"sidechat": None, "sender": None})
|
||||||
|
self.assertEqual(argv[-3:], ["notify", "pip", "hi"])
|
||||||
|
argv = n["build"]({"agent": "pip", "message": "hi",
|
||||||
|
"sidechat": "pip tasks", "sender": "opm"})
|
||||||
|
self.assertIn("--sidechat", argv)
|
||||||
|
self.assertIn("--sender", argv)
|
||||||
|
a = exec_constrained.OPS["dm.ack"]
|
||||||
|
argv = a["build"]({"id": "bdf7beb6", "to": "pip", "sender": "opm",
|
||||||
|
"sidechat": None, "allow_main_chat": False})
|
||||||
|
self.assertEqual(argv[-6:],
|
||||||
|
["ack", "bdf7beb6", "--to", "pip", "--sender", "opm"])
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlGitTests(unittest.TestCase):
|
||||||
|
def test_git_status_live(self):
|
||||||
|
r = _box_ctl("git-status")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertTrue(data["branch"])
|
||||||
|
self.assertIsInstance(data["changes"], list)
|
||||||
|
|
||||||
|
def test_git_log_live(self):
|
||||||
|
r = _box_ctl("git-log", "--limit", "2")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(len(data["commits"]), 2)
|
||||||
|
self.assertIn("sha", data["commits"][0])
|
||||||
|
self.assertIn("subject", data["commits"][0])
|
||||||
|
|
||||||
|
def test_git_diff_stat_live(self):
|
||||||
|
r = _box_ctl("git-diff", "--stat")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertIn("diff", data)
|
||||||
|
|
||||||
|
def test_rejects_bad_path_and_limit(self):
|
||||||
|
for bad in ("../x", "/abs/path"):
|
||||||
|
r = _box_ctl("git-diff", "--path", bad)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("git-log", "--limit", "0")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
r = _box_ctl("git-log", "--limit", "51")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_quality_validate_git_verbs(self):
|
||||||
|
for args in (["git-status"], ["git-diff", "--stat"],
|
||||||
|
["git-diff", "--path", "bin/dm.py"],
|
||||||
|
["git-log", "--limit", "5"]):
|
||||||
|
r = _box_ctl("quality-validate", *args)
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], args)
|
||||||
|
r = _box_ctl("quality-validate", "git-diff", "--path", "../x")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"])
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlTestsRunTests(unittest.TestCase):
|
||||||
|
def test_tests_run_single_module_live(self):
|
||||||
|
r = _box_ctl("tests-run", "tests.test_box_read_https")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"], data.get("output", "")[-2000:])
|
||||||
|
self.assertEqual(data["returncode"], 0)
|
||||||
|
|
||||||
|
def test_tests_run_discovery_importable(self):
|
||||||
|
# Full discover must import every test module. An impossible -k
|
||||||
|
# filter runs zero tests in seconds while still importing all of
|
||||||
|
# them, deterministically guarding the discover argv (a `-t .`
|
||||||
|
# regresses to ImportError here). Never asserts suite success:
|
||||||
|
# outage-sensitive tests may be red independently.
|
||||||
|
r = _box_ctl("tests-run", "--filter", "zzz_no_match_zzz")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertIn("Ran 0 tests", data.get("output", ""))
|
||||||
|
self.assertNotIn("Traceback", data.get("output", ""))
|
||||||
|
|
||||||
|
def test_tests_run_survives_safepath_invoker(self):
|
||||||
|
# `python -m` drops CWD from sys.path under PYTHONSAFEPATH/-P;
|
||||||
|
# tests-run pins PYTHONPATH so it still resolves the tests package.
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PYTHONSAFEPATH"] = "1"
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), "tests-run",
|
||||||
|
"tests.test_box_read_https"],
|
||||||
|
capture_output=True, text=True, timeout=120, env=env)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"], data.get("output", "")[-2000:])
|
||||||
|
|
||||||
|
def test_rejects_bad_module(self):
|
||||||
|
r = _box_ctl("tests-run", "os")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("tests-run", "tests.nonexistent_xyz")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "NOT_FOUND")
|
||||||
|
|
||||||
|
def test_quality_validate_tests_run(self):
|
||||||
|
r = _box_ctl("quality-validate", "tests-run")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "tests-run", "tests.test_box_read_https")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "tests-run", "--filter", "safepath")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "tests-run", "os")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "tests-run", "--filter")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlAckTests(unittest.TestCase):
|
||||||
|
# Validation-failure paths only: a live ack would send a real DM.
|
||||||
|
|
||||||
|
def test_rejects_bad_id_and_agents(self):
|
||||||
|
r = _box_ctl("ack", "xyz!", "--to", "pip", "--sender", "opm")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("ack", "bdf7beb6", "--to", "nope", "--sender", "opm")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
|
||||||
|
def test_requires_sender(self):
|
||||||
|
r = _box_ctl("ack", "bdf7beb6", "--to", "pip")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_ARGS")
|
||||||
|
|
||||||
|
def test_quality_validate_ack(self):
|
||||||
|
r = _box_ctl("quality-validate", "ack", "bdf7beb6",
|
||||||
|
"--to", "pip", "--sender", "opm")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "ack", "xyz!",
|
||||||
|
"--to", "pip", "--sender", "opm")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlNotifyValidationTests(unittest.TestCase):
|
||||||
|
# Failure paths only: act_notify validates before sending.
|
||||||
|
|
||||||
|
def test_rejects_unknown_agent(self):
|
||||||
|
r = _box_ctl("notify", "nope", "hi")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
|
||||||
|
def test_rejects_oversize_message(self):
|
||||||
|
r = _box_ctl("notify", "pip", "x" * 1001)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "INVALID_JOB")
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRelayDevTests(unittest.TestCase):
|
||||||
|
def test_relay_help_lists_dev_commands(self):
|
||||||
|
r = subprocess.run(["bash", str(RELAY), "help"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
for line in ("box git status", "box git diff", "box git log",
|
||||||
|
"box tests run", "box notify", "box dm ack"):
|
||||||
|
self.assertIn(line, r.stdout)
|
||||||
|
|
||||||
|
def test_relay_maps_dev_commands_to_ops(self):
|
||||||
|
text = RELAY.read_text()
|
||||||
|
for op in ("git.status", "git.diff", "git.log",
|
||||||
|
'"tests.run"', '"notify.send"', '"dm.ack"'):
|
||||||
|
self.assertIn(op, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Tests for job lifecycle over HTTPS (no SSH).
|
||||||
|
|
||||||
|
Covers the job-lifecycle expansion:
|
||||||
|
box-relay.sh (agent client) -> exec-constrained.py named ops
|
||||||
|
-> box-ctl.py backend verbs -> jobs/*.json / systemd / job-dispatch.
|
||||||
|
|
||||||
|
Safe mutations only: put/trigger/chain/stop/disable. Deletes are
|
||||||
|
deliberately NOT exposed. Live writes, triggers, and timer control are
|
||||||
|
NEVER executed here: only validation-failure paths (which fail before any
|
||||||
|
side effect) plus the read-only job-next dry-run. Live-socket round-trips
|
||||||
|
are intentionally NOT covered here; instead we assert the exact argv each
|
||||||
|
op builds and execute the fast, side-effect-free argv directly.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
RELAY = REPO_ROOT / "bin" / "box-relay.sh"
|
||||||
|
|
||||||
|
# An existing job used for existence-gated validation (read-only).
|
||||||
|
EXISTING_JOB = "heartbeat"
|
||||||
|
# Well-formed names that must not exist (validation-failure paths only).
|
||||||
|
MISSING_JOB = "definitely-no-such-job-xyz"
|
||||||
|
MISSING_ID = "definitely-no-such-job-xyz-20200101-000000-deadbeef"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
exec_constrained = _load("exec_constrained_jobs", "bin/exec-constrained.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args, stdin=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
input=stdin, capture_output=True, text=True, timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_def(name, **over):
|
||||||
|
d = {"name": name, "description": "unit test job",
|
||||||
|
"schedule": "manual", "agent": "opm",
|
||||||
|
"prompt_template": "test prompt {job_id}", "timeout": 300}
|
||||||
|
d.update(over)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class ExecJobOpsTests(unittest.TestCase):
|
||||||
|
def test_ops_registered_with_side_effect_flags(self):
|
||||||
|
spec = exec_constrained.OPS
|
||||||
|
for op in ("job.put", "job.trigger", "job.chain",
|
||||||
|
"cron.timer_stop", "cron.timer_disable"):
|
||||||
|
self.assertIn(op, spec)
|
||||||
|
self.assertTrue(spec[op]["side_effecting"])
|
||||||
|
self.assertIn("job.next", spec)
|
||||||
|
self.assertFalse(spec["job.next"]["side_effecting"])
|
||||||
|
|
||||||
|
def test_no_delete_ops_exposed(self):
|
||||||
|
names = set(exec_constrained.OPS)
|
||||||
|
self.assertNotIn("job.delete", names)
|
||||||
|
self.assertNotIn("cron.timer_delete", names)
|
||||||
|
|
||||||
|
def test_permissions(self):
|
||||||
|
p = exec_constrained.permitted
|
||||||
|
self.assertTrue(p("some-unknown-identity", "job.next"))
|
||||||
|
for op in ("job.put", "job.trigger", "job.chain",
|
||||||
|
"cron.timer_stop", "cron.timer_disable"):
|
||||||
|
self.assertFalse(p("some-unknown-identity", op))
|
||||||
|
self.assertTrue(p("operator-646", op))
|
||||||
|
self.assertFalse(p("exec-canary", "job.next"))
|
||||||
|
|
||||||
|
def test_job_put_validate(self):
|
||||||
|
v = exec_constrained.OPS["job.put"]["validate"]
|
||||||
|
good = v({"name": "my-job", "definition": _job_def("my-job")})
|
||||||
|
self.assertEqual(good["name"], "my-job")
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": "Bad_Name!", "definition": _job_def("x")})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": "my-job", "definition": ["not", "a", "dict"]})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": "my-job", "definition": _job_def("other")})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": "my-job"})
|
||||||
|
|
||||||
|
def test_job_trigger_validate(self):
|
||||||
|
v = exec_constrained.OPS["job.trigger"]["validate"]
|
||||||
|
self.assertEqual(v({"name": EXISTING_JOB})["name"], EXISTING_JOB)
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": MISSING_JOB})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": "Bad_Name!"})
|
||||||
|
|
||||||
|
def test_job_chain_validate(self):
|
||||||
|
v = exec_constrained.OPS["job.chain"]["validate"]
|
||||||
|
good = v({"from": EXISTING_JOB, "to": EXISTING_JOB})
|
||||||
|
self.assertFalse(good["on_failure"])
|
||||||
|
self.assertTrue(v({"from": EXISTING_JOB, "to": EXISTING_JOB,
|
||||||
|
"on_failure": True})["on_failure"])
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"from": MISSING_JOB, "to": EXISTING_JOB})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"from": EXISTING_JOB})
|
||||||
|
|
||||||
|
def test_job_next_validate(self):
|
||||||
|
v = exec_constrained.OPS["job.next"]["validate"]
|
||||||
|
good = v({"job_id": MISSING_ID})
|
||||||
|
self.assertEqual(good["job_id"], MISSING_ID)
|
||||||
|
self.assertIsNone(good["success"])
|
||||||
|
self.assertTrue(v({"job_id": MISSING_ID, "success": True})["success"])
|
||||||
|
for bad in ("plainname", "a-20200101-000000-xyz!",
|
||||||
|
"UPPER-20200101-000000-deadbeef", ""):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v({"job_id": bad})
|
||||||
|
|
||||||
|
def test_timer_validate(self):
|
||||||
|
for op in ("cron.timer_stop", "cron.timer_disable"):
|
||||||
|
v = exec_constrained.OPS[op]["validate"]
|
||||||
|
self.assertEqual(v({"name": EXISTING_JOB})["name"], EXISTING_JOB)
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"name": MISSING_JOB})
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
put = exec_constrained.OPS["job.put"]
|
||||||
|
argv = put["build"]({"name": "my-job",
|
||||||
|
"definition": _job_def("my-job")})
|
||||||
|
self.assertEqual(argv[-2:], ["job-put", "my-job"])
|
||||||
|
trig = exec_constrained.OPS["job.trigger"]
|
||||||
|
self.assertEqual(trig["build"]({"name": EXISTING_JOB})[-2:],
|
||||||
|
["job-trigger", EXISTING_JOB])
|
||||||
|
chain = exec_constrained.OPS["job.chain"]
|
||||||
|
argv = chain["build"]({"from": "a", "to": "b", "on_failure": False})
|
||||||
|
self.assertEqual(argv[-3:], ["job-chain", "a", "b"])
|
||||||
|
argv = chain["build"]({"from": "a", "to": "b", "on_failure": True})
|
||||||
|
self.assertEqual(argv[-4:], ["job-chain", "a", "b", "--on-failure"])
|
||||||
|
nxt = exec_constrained.OPS["job.next"]
|
||||||
|
self.assertEqual(nxt["build"]({"job_id": "i", "success": None})[-2:],
|
||||||
|
["job-next", "i"])
|
||||||
|
argv = nxt["build"]({"job_id": "i", "success": False})
|
||||||
|
self.assertEqual(argv[-3:], ["job-next", "i", "--fail"])
|
||||||
|
stop = exec_constrained.OPS["cron.timer_stop"]
|
||||||
|
self.assertEqual(stop["build"]({"name": EXISTING_JOB})[-2:],
|
||||||
|
["timer-stop", EXISTING_JOB])
|
||||||
|
dis = exec_constrained.OPS["cron.timer_disable"]
|
||||||
|
self.assertEqual(dis["build"]({"name": EXISTING_JOB})[-2:],
|
||||||
|
["timer-disable", EXISTING_JOB])
|
||||||
|
self.assertIsInstance(argv, list)
|
||||||
|
|
||||||
|
def test_stdin_body_routing(self):
|
||||||
|
body = exec_constrained._stdin_body(
|
||||||
|
"job.put", {"name": "my-job", "definition": _job_def("my-job")})
|
||||||
|
# box-ctl job-put reads the raw definition (not the envelope).
|
||||||
|
self.assertEqual(json.loads(body)["name"], "my-job")
|
||||||
|
self.assertNotIn("definition", json.loads(body))
|
||||||
|
self.assertIsNone(exec_constrained._stdin_body("job.trigger", {}))
|
||||||
|
env = exec_constrained._stdin_body("files.read", {"path": "x"})
|
||||||
|
self.assertEqual(json.loads(env), {"path": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlJobsTests(unittest.TestCase):
|
||||||
|
def test_job_next_dry_run_live(self):
|
||||||
|
r = _box_ctl("job-next", MISSING_ID)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["job_id"], MISSING_ID)
|
||||||
|
self.assertFalse(data["would_dispatch"])
|
||||||
|
|
||||||
|
def test_job_put_rejects_before_write(self):
|
||||||
|
r = _box_ctl("job-put", "Bad_Name!", stdin="{}")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("job-put", "my-job", stdin="not json")
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "INVALID_JOB")
|
||||||
|
r = _box_ctl("job-put", "my-job",
|
||||||
|
stdin=json.dumps(_job_def("other")))
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "NAME_MISMATCH")
|
||||||
|
bad = _job_def("my-job")
|
||||||
|
del bad["agent"]
|
||||||
|
r = _box_ctl("job-put", "my-job", stdin=json.dumps(bad))
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "INVALID_JOB")
|
||||||
|
|
||||||
|
def test_job_trigger_rejects_missing(self):
|
||||||
|
r = _box_ctl("job-trigger", "Bad_Name!")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("job-trigger", MISSING_JOB)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "NOT_FOUND")
|
||||||
|
|
||||||
|
def test_job_chain_rejects_before_write(self):
|
||||||
|
r = _box_ctl("job-chain", "Bad_Name!", EXISTING_JOB)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("job-chain", EXISTING_JOB, EXISTING_JOB)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "INVALID_JOB")
|
||||||
|
r = _box_ctl("job-chain", MISSING_JOB, EXISTING_JOB)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "NOT_FOUND")
|
||||||
|
|
||||||
|
def test_timer_control_rejects_before_action(self):
|
||||||
|
for verb in ("timer-stop", "timer-disable"):
|
||||||
|
r = _box_ctl(verb, "Bad_Name!")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl(verb, MISSING_JOB)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "NOT_FOUND")
|
||||||
|
|
||||||
|
def test_quality_validate_job_verbs(self):
|
||||||
|
r = _box_ctl("quality-validate", "job-put", "my-job")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "job-trigger", EXISTING_JOB)
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "job-chain", "a", "b")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "job-next", MISSING_ID)
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "timer-stop", EXISTING_JOB)
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "job-put", "Bad_Name!")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRelayJobsTests(unittest.TestCase):
|
||||||
|
def test_relay_help_lists_job_commands(self):
|
||||||
|
r = subprocess.run(["bash", str(RELAY), "help"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
for line in ("box cron put", "box cron trigger", "box cron chain",
|
||||||
|
"box cron next", "box timer stop"):
|
||||||
|
self.assertIn(line, r.stdout)
|
||||||
|
|
||||||
|
def test_relay_maps_job_commands_to_ops(self):
|
||||||
|
text = RELAY.read_text()
|
||||||
|
for op in ('"job.put"', '"job.trigger"', '"job.chain"', '"job.next"',
|
||||||
|
'"cron.timer_stop"', '"cron.timer_disable"'):
|
||||||
|
self.assertIn(op, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Tests for loop + strategy writes over HTTPS (no SSH).
|
||||||
|
|
||||||
|
Covers the loop/strategy expansion:
|
||||||
|
box-relay.sh (agent client) -> exec-constrained.py named ops
|
||||||
|
-> box-ctl.py backend verbs -> followups/variables/strategy state.
|
||||||
|
|
||||||
|
Safe mutations only. Live execution is limited to side-effect-free paths:
|
||||||
|
loop-remediate --dry-run (all writes guarded), strat-reset on a probe key
|
||||||
|
that can never exist (returns False, no write), and validation-failure
|
||||||
|
paths (which fail before any side effect). loop-resolve always appends to
|
||||||
|
job-log, and vars-reset/rollback/strat-set mutate live fleet state, so
|
||||||
|
those success paths are covered by quality-validate (dry-run) plus unit
|
||||||
|
tests — never executed here. Live-socket round-trips are intentionally
|
||||||
|
NOT covered here; instead we assert the exact argv each op builds.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
RELAY = REPO_ROOT / "bin" / "box-relay.sh"
|
||||||
|
|
||||||
|
MISSING_VAR = "definitely-no-such-var-xyz"
|
||||||
|
# A strategy key no agent will ever set: reset returns False, no write.
|
||||||
|
PROBE_TYPE = "heartbeat"
|
||||||
|
PROBE_SUBTYPE = "ZZZ_PROBE_NO_SUCH_SUBTYPE"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
exec_constrained = _load("exec_constrained_loop", "bin/exec-constrained.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
capture_output=True, text=True, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecLoopOpsTests(unittest.TestCase):
|
||||||
|
def test_ops_registered_and_side_effecting(self):
|
||||||
|
spec = exec_constrained.OPS
|
||||||
|
for op in ("loop.remediate", "loop.resolve", "strat.set",
|
||||||
|
"strat.reset", "vars.reset", "vars.rollback"):
|
||||||
|
self.assertIn(op, spec)
|
||||||
|
self.assertTrue(spec[op]["side_effecting"])
|
||||||
|
|
||||||
|
def test_known_identities_only(self):
|
||||||
|
p = exec_constrained.permitted
|
||||||
|
for op in ("loop.remediate", "loop.resolve", "strat.set",
|
||||||
|
"strat.reset", "vars.reset", "vars.rollback"):
|
||||||
|
self.assertFalse(p("some-unknown-identity", op))
|
||||||
|
self.assertTrue(p("operator-646", op))
|
||||||
|
self.assertFalse(p("exec-canary", "loop.remediate"))
|
||||||
|
|
||||||
|
def test_remediate_validate(self):
|
||||||
|
v = exec_constrained.OPS["loop.remediate"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"dry_run": False})
|
||||||
|
self.assertTrue(v({"dry_run": True})["dry_run"])
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"bogus": 1})
|
||||||
|
|
||||||
|
def test_resolve_validate(self):
|
||||||
|
v = exec_constrained.OPS["loop.resolve"]["validate"]
|
||||||
|
good = v({"dm_id": "bdf7beb6", "note": "looks good"})
|
||||||
|
self.assertEqual(good["dm_id"], "bdf7beb6")
|
||||||
|
self.assertEqual(good["note"], "looks good")
|
||||||
|
self.assertIsNone(v({"dm_id": "bdf7beb6"})["note"])
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"dm_id": "xyz!"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"dm_id": "bdf7beb6", "note": " "})
|
||||||
|
|
||||||
|
def test_strat_set_validate(self):
|
||||||
|
v = exec_constrained.OPS["strat.set"]["validate"]
|
||||||
|
good = v({"type": "job", "priority": "important", "nudges": 3})
|
||||||
|
self.assertEqual(good["type"], "job")
|
||||||
|
self.assertEqual(good["priority"], "important")
|
||||||
|
# Typos must fail: the backend silently maps unknown types to MANUAL.
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg="typo type"):
|
||||||
|
v({"type": "wkae"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"type": "job", "priority": "urgent"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"type": "job", "timeout_s": "soon"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"type": "job", "agent": "nope"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"priority": "normal"})
|
||||||
|
|
||||||
|
def test_strat_reset_validate(self):
|
||||||
|
v = exec_constrained.OPS["strat.reset"]["validate"]
|
||||||
|
good = v({"type": "heartbeat", "subtype": "DM", "agent": "opm"})
|
||||||
|
self.assertEqual(good, {"type": "heartbeat", "subtype": "DM",
|
||||||
|
"agent": "opm"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"type": "wkae"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"type": "job", "subtype": "has space"})
|
||||||
|
|
||||||
|
def test_vars_validate(self):
|
||||||
|
vr = exec_constrained.OPS["vars.reset"]["validate"]
|
||||||
|
self.assertEqual(vr({"name": "max_nudge_count"})["name"],
|
||||||
|
"max_nudge_count")
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
vr({"name": "has space"})
|
||||||
|
vb = exec_constrained.OPS["vars.rollback"]["validate"]
|
||||||
|
self.assertIsNone(vb({"name": "max_nudge_count"})["revision"])
|
||||||
|
self.assertEqual(vb({"name": "x", "revision": 2})["revision"], 2)
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
vb({"name": "x", "revision": 0})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
vb({"name": "x", "revision": "a\nb"})
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
rem = exec_constrained.OPS["loop.remediate"]
|
||||||
|
self.assertEqual(rem["build"]({"dry_run": False})[-1],
|
||||||
|
"loop-remediate")
|
||||||
|
argv = rem["build"]({"dry_run": True})
|
||||||
|
self.assertEqual(argv[-2:], ["loop-remediate", "--dry-run"])
|
||||||
|
res = exec_constrained.OPS["loop.resolve"]
|
||||||
|
argv = res["build"]({"dm_id": "abc123", "note": None})
|
||||||
|
self.assertEqual(argv[-2:], ["loop-resolve", "abc123"])
|
||||||
|
argv = res["build"]({"dm_id": "abc123", "note": "n"})
|
||||||
|
self.assertEqual(argv[-3:], ["loop-resolve", "abc123", "n"])
|
||||||
|
st = exec_constrained.OPS["strat.set"]
|
||||||
|
argv = st["build"]({"type": "job", "subtype": None, "agent": None,
|
||||||
|
"track": None, "priority": "normal",
|
||||||
|
"timeout_s": None, "nudges": 2, "escalate": None})
|
||||||
|
self.assertEqual(argv[-3], "strat-set")
|
||||||
|
payload = json.loads(argv[-1])
|
||||||
|
self.assertEqual(payload["priority"], "normal")
|
||||||
|
self.assertEqual(payload["nudges"], 2)
|
||||||
|
sr = exec_constrained.OPS["strat.reset"]
|
||||||
|
argv = sr["build"]({"type": "job", "subtype": "DM",
|
||||||
|
"agent": "opm"})
|
||||||
|
self.assertEqual(argv[-4:],
|
||||||
|
["strat-reset", "job", "DM", "--agent", "opm"][-4:])
|
||||||
|
vrt = exec_constrained.OPS["vars.reset"]
|
||||||
|
self.assertEqual(vrt["build"]({"name": "x"})[-2:],
|
||||||
|
["vars-reset", "x"])
|
||||||
|
vrb = exec_constrained.OPS["vars.rollback"]
|
||||||
|
argv = vrb["build"]({"name": "x", "revision": 2})
|
||||||
|
self.assertEqual(argv[-3:], ["vars-rollback", "x", "2"])
|
||||||
|
self.assertIsInstance(argv, list)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlLoopTests(unittest.TestCase):
|
||||||
|
def test_remediate_dry_run_live(self):
|
||||||
|
r = _box_ctl("loop-remediate", "--dry-run")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertTrue(data["dry_run"])
|
||||||
|
self.assertIn("remediated", data)
|
||||||
|
self.assertIn("escalated", data)
|
||||||
|
|
||||||
|
def test_strat_reset_probe_key_live(self):
|
||||||
|
r = _box_ctl("strat-reset", PROBE_TYPE, PROBE_SUBTYPE)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertFalse(data["reset"])
|
||||||
|
|
||||||
|
def test_strat_set_rejects_before_write(self):
|
||||||
|
r = _box_ctl("strat-set")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("strat-set", "job", "not json")
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "STRAT_ERROR")
|
||||||
|
bad = json.dumps({"priority": "urgent"})
|
||||||
|
r = _box_ctl("strat-set", "job", bad)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "STRAT_ERROR")
|
||||||
|
|
||||||
|
def test_vars_rejects_unknown_before_write(self):
|
||||||
|
r = _box_ctl("vars-reset", MISSING_VAR)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "VARS_ERROR")
|
||||||
|
r = _box_ctl("vars-rollback", MISSING_VAR)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "VARS_ERROR")
|
||||||
|
|
||||||
|
def test_quality_validate_loop_verbs(self):
|
||||||
|
cases = [
|
||||||
|
(["loop-remediate"], True),
|
||||||
|
(["loop-remediate", "--dry-run"], True),
|
||||||
|
(["loop-resolve", "bdf7beb6"], True),
|
||||||
|
(["loop-resolve", "bdf7beb6", "note"], True),
|
||||||
|
(["loop-resolve", "xyz!"], False),
|
||||||
|
(["strat-set", "job"], True),
|
||||||
|
(["strat-set"], False),
|
||||||
|
(["strat-reset", "job", "DM", "--agent", "opm"], True),
|
||||||
|
(["strat-reset", "--agent", "nope"], False),
|
||||||
|
(["vars-reset", "max_nudge_count"], True),
|
||||||
|
(["vars-rollback", "max_nudge_count", "2"], True),
|
||||||
|
(["vars-get", "loop_health_threshold"], True),
|
||||||
|
(["vars-set", "max_nudge_count", "5"], True),
|
||||||
|
(["vars-reset"], False),
|
||||||
|
(["vars-get", "has space"], False),
|
||||||
|
]
|
||||||
|
for args, valid in cases:
|
||||||
|
r = _box_ctl("quality-validate", *args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["valid"], valid, args)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRelayLoopTests(unittest.TestCase):
|
||||||
|
def test_relay_help_lists_loop_commands(self):
|
||||||
|
r = subprocess.run(["bash", str(RELAY), "help"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
for line in ("box loop remediate", "box loop resolve",
|
||||||
|
"box strat set", "box strat reset",
|
||||||
|
"box vars reset", "box vars rollback"):
|
||||||
|
self.assertIn(line, r.stdout)
|
||||||
|
|
||||||
|
def test_relay_maps_loop_commands_to_ops(self):
|
||||||
|
text = RELAY.read_text()
|
||||||
|
for op in ('"loop.remediate"', '"loop.resolve"', '"strat.set"',
|
||||||
|
'"strat.reset"', '"vars.reset"', '"vars.rollback"'):
|
||||||
|
self.assertIn(op, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Tests for md-file reads + governed writes over HTTPS (no SSH).
|
||||||
|
|
||||||
|
Covers the md expansion:
|
||||||
|
box-relay.sh (agent client) -> exec-constrained.py named ops
|
||||||
|
-> box-ctl.py backend verbs -> agent_md.py (Hatch gateway / shared templates).
|
||||||
|
|
||||||
|
Raw container writes (md-write) are deliberately NOT exposed over HTTPS;
|
||||||
|
the governed flows are amend/append (validated shared templates with git
|
||||||
|
commit) and pull/inject-drive/sync-all (push canonical templates).
|
||||||
|
|
||||||
|
Live execution is limited to validation-failure paths (which fail before
|
||||||
|
any gateway call or write), one stdin-plumbing path that the backend
|
||||||
|
safety gate rejects before writing, quality-validate dry-runs, and
|
||||||
|
agent_md validator unit tests. No live gateway traffic, no template
|
||||||
|
writes, and no live-socket round-trips here; instead we assert the exact
|
||||||
|
argv each op builds.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
RELAY = REPO_ROOT / "bin" / "box-relay.sh"
|
||||||
|
HEARTBEAT = REPO_ROOT / "shared" / "operators" / "HEARTBEAT.md"
|
||||||
|
|
||||||
|
MD_READ_OPS = ("md.audit", "md.list", "md.read", "md.diff")
|
||||||
|
MD_WRITE_OPS = ("md.pull", "md.inject_drive", "md.sync_all",
|
||||||
|
"md.amend", "md.append")
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
exec_constrained = _load("exec_constrained_md", "bin/exec-constrained.py")
|
||||||
|
agent_md = _load("agent_md_mdtest", "bin/agent_md.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args, stdin=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
input=stdin, capture_output=True, text=True, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecMdOpsTests(unittest.TestCase):
|
||||||
|
def test_ops_registered_and_side_effecting(self):
|
||||||
|
spec = exec_constrained.OPS
|
||||||
|
for op in MD_READ_OPS:
|
||||||
|
self.assertIn(op, spec)
|
||||||
|
self.assertFalse(spec[op]["side_effecting"], op)
|
||||||
|
for op in MD_WRITE_OPS:
|
||||||
|
self.assertIn(op, spec)
|
||||||
|
self.assertTrue(spec[op]["side_effecting"], op)
|
||||||
|
|
||||||
|
def test_raw_write_not_exposed(self):
|
||||||
|
self.assertNotIn("md.write", exec_constrained.OPS)
|
||||||
|
|
||||||
|
def test_known_identities_only(self):
|
||||||
|
p = exec_constrained.permitted
|
||||||
|
for op in MD_READ_OPS:
|
||||||
|
self.assertTrue(p("some-unknown-identity", op), op)
|
||||||
|
self.assertTrue(p("operator-646", op), op)
|
||||||
|
for op in MD_WRITE_OPS:
|
||||||
|
self.assertFalse(p("some-unknown-identity", op), op)
|
||||||
|
self.assertTrue(p("operator-646", op), op)
|
||||||
|
self.assertFalse(p("exec-canary", "md.read"))
|
||||||
|
self.assertFalse(p("exec-canary", "md.amend"))
|
||||||
|
|
||||||
|
def test_audit_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.audit"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"accounts": None})
|
||||||
|
# Explicit null means "all accounts", same as omitted (optional-arg
|
||||||
|
# convention shared with strat.set / loop.resolve).
|
||||||
|
self.assertEqual(v({"accounts": None}), {"accounts": None})
|
||||||
|
self.assertEqual(v({"accounts": ["646", "muse-main"]})["accounts"],
|
||||||
|
["646", "muse-main"])
|
||||||
|
for bad in ({"accounts": "646"}, {"accounts": []},
|
||||||
|
{"accounts": ["../x"]}, {"accounts": ["a b"]},
|
||||||
|
{"accounts": ["ok", ""]}, {"bogus": 1}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_list_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.list"]["validate"]
|
||||||
|
self.assertEqual(v({"account": "646"}),
|
||||||
|
{"account": "646", "path": ""})
|
||||||
|
self.assertEqual(v({"account": "646", "path": "sub/dir"})["path"],
|
||||||
|
"sub/dir")
|
||||||
|
for bad in ({"account": "../x"}, {"account": "a b"},
|
||||||
|
{"account": "646", "path": ".."},
|
||||||
|
{"account": "646", "path": "/abs"},
|
||||||
|
{"account": "646", "path": "a/../../x"},
|
||||||
|
{"account": "646", "bogus": 1},
|
||||||
|
{"path": "sub"}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_read_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.read"]["validate"]
|
||||||
|
good = v({"account": "646", "filename": "SOUL.md"})
|
||||||
|
self.assertEqual(good, {"account": "646", "filename": "SOUL.md"})
|
||||||
|
# Reads accept any container basename, not just shared templates.
|
||||||
|
self.assertEqual(
|
||||||
|
v({"account": "646", "filename": "NOTES.md"})["filename"],
|
||||||
|
"NOTES.md")
|
||||||
|
for bad in ({"account": "646", "filename": "../x"},
|
||||||
|
{"account": "646", "filename": "/abs"},
|
||||||
|
{"account": "646", "filename": ".."},
|
||||||
|
{"account": "646", "filename": "a/b"},
|
||||||
|
{"account": "646", "filename": ""},
|
||||||
|
{"account": "a b", "filename": "SOUL.md"},
|
||||||
|
{"account": "646", "filename": "SOUL.md", "x": 1}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_diff_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.diff"]["validate"]
|
||||||
|
good = v({"account": "646", "filename": "SOUL.md"})
|
||||||
|
self.assertEqual(good, {"account": "646", "filename": "SOUL.md"})
|
||||||
|
# Template flows reject non-templates: the backend indexes
|
||||||
|
# shared/operators/ by filename.
|
||||||
|
for bad in ({"account": "646", "filename": "NOPE.md"},
|
||||||
|
{"account": "646", "filename": "../x"},
|
||||||
|
{"account": "646", "filename": "NOTES.md"}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_pull_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.pull"]["validate"]
|
||||||
|
self.assertEqual(v({"account": "opm", "filename": "TOOLS.md"}),
|
||||||
|
{"account": "opm", "filename": "TOOLS.md"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"account": "opm", "filename": "NOPE.md"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"account": "../x", "filename": "TOOLS.md"})
|
||||||
|
|
||||||
|
def test_inject_drive_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.inject_drive"]["validate"]
|
||||||
|
self.assertEqual(v({"account": "646"}), {"account": "646"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"account": "../x"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"account": "646", "force": True})
|
||||||
|
|
||||||
|
def test_sync_all_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.sync_all"]["validate"]
|
||||||
|
self.assertEqual(v({}), {})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"accounts": ["646"]})
|
||||||
|
|
||||||
|
def test_amend_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.amend"]["validate"]
|
||||||
|
good = v({"filename": "SOUL.md", "content": "body",
|
||||||
|
"author": "646", "reason": "tune"})
|
||||||
|
self.assertEqual(good, {"filename": "SOUL.md", "content": "body",
|
||||||
|
"author": "646", "reason": "tune"})
|
||||||
|
defaults = v({"filename": "SOUL.md", "content": "body"})
|
||||||
|
self.assertEqual(defaults["author"], "operator")
|
||||||
|
self.assertEqual(defaults["reason"], "")
|
||||||
|
over = "x" * (exec_constrained.MD_MAX_AMEND + 1)
|
||||||
|
for bad in ({"filename": "NOPE.md", "content": "body"},
|
||||||
|
{"filename": "SOUL.md", "content": " "},
|
||||||
|
{"filename": "SOUL.md", "content": over},
|
||||||
|
{"filename": "SOUL.md", "content": "x", "author": ""},
|
||||||
|
{"filename": "SOUL.md", "content": "x",
|
||||||
|
"author": "a\nb"},
|
||||||
|
{"filename": "SOUL.md", "content": "x",
|
||||||
|
"author": "a" * 65},
|
||||||
|
{"filename": "SOUL.md", "content": "x",
|
||||||
|
"reason": "r" * 257},
|
||||||
|
{"filename": "SOUL.md", "content": "x",
|
||||||
|
"reason": "a\nb"},
|
||||||
|
{"filename": "SOUL.md", "content": "x", "bogus": 1},
|
||||||
|
{"filename": "SOUL.md"}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_append_validate(self):
|
||||||
|
v = exec_constrained.OPS["md.append"]["validate"]
|
||||||
|
good = v({"filename": "AGENTS.md", "text": "lesson",
|
||||||
|
"author": "opm", "section": "Wins"})
|
||||||
|
self.assertEqual(good["section"], "Wins")
|
||||||
|
self.assertIsNone(v({"filename": "AGENTS.md",
|
||||||
|
"text": "lesson"})["section"])
|
||||||
|
over = "x" * (exec_constrained.MD_MAX_APPEND + 1)
|
||||||
|
for bad in ({"filename": "NOPE.md", "text": "lesson"},
|
||||||
|
{"filename": "AGENTS.md", "text": " "},
|
||||||
|
{"filename": "AGENTS.md", "text": over},
|
||||||
|
{"filename": "AGENTS.md", "text": "t",
|
||||||
|
"section": " "},
|
||||||
|
{"filename": "AGENTS.md", "text": "t",
|
||||||
|
"section": "s" * 129}):
|
||||||
|
with self.assertRaises(exec_constrained.OpError, msg=bad):
|
||||||
|
v(bad)
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
ops = exec_constrained.OPS
|
||||||
|
au = ops["md.audit"]
|
||||||
|
self.assertEqual(au["build"]({"accounts": None})[-1], "md-audit")
|
||||||
|
self.assertEqual(
|
||||||
|
au["build"]({"accounts": ["646", "opm"]})[-3:],
|
||||||
|
["md-audit", "646", "opm"])
|
||||||
|
li = ops["md.list"]
|
||||||
|
self.assertEqual(li["build"]({"account": "646", "path": ""})[-2:],
|
||||||
|
["md-list", "646"])
|
||||||
|
self.assertEqual(
|
||||||
|
li["build"]({"account": "646", "path": "sub"})[-3:],
|
||||||
|
["md-list", "646", "sub"])
|
||||||
|
rd = ops["md.read"]
|
||||||
|
self.assertEqual(
|
||||||
|
rd["build"]({"account": "646", "filename": "SOUL.md"})[-3:],
|
||||||
|
["md-read", "646", "SOUL.md"])
|
||||||
|
df = ops["md.diff"]
|
||||||
|
self.assertEqual(
|
||||||
|
df["build"]({"account": "646", "filename": "SOUL.md"})[-3:],
|
||||||
|
["md-diff", "646", "SOUL.md"])
|
||||||
|
pu = ops["md.pull"]
|
||||||
|
self.assertEqual(
|
||||||
|
pu["build"]({"account": "646", "filename": "SOUL.md"})[-3:],
|
||||||
|
["md-pull", "646", "SOUL.md"])
|
||||||
|
inj = ops["md.inject_drive"]
|
||||||
|
self.assertEqual(inj["build"]({"account": "646"})[-2:],
|
||||||
|
["md-inject-drive", "646"])
|
||||||
|
self.assertEqual(ops["md.sync_all"]["build"]({})[-1], "md-sync-all")
|
||||||
|
am = ops["md.amend"]
|
||||||
|
argv = am["build"]({"filename": "SOUL.md", "content": "x",
|
||||||
|
"author": "646", "reason": "why"})
|
||||||
|
self.assertEqual(argv[-7:],
|
||||||
|
["md-amend", "SOUL.md", "--stdin",
|
||||||
|
"--author", "646", "--reason", "why"])
|
||||||
|
argv = am["build"]({"filename": "SOUL.md", "content": "x",
|
||||||
|
"author": "646", "reason": ""})
|
||||||
|
self.assertEqual(argv[-5:],
|
||||||
|
["md-amend", "SOUL.md", "--stdin",
|
||||||
|
"--author", "646"])
|
||||||
|
ap = ops["md.append"]
|
||||||
|
argv = ap["build"]({"filename": "AGENTS.md", "text": "t",
|
||||||
|
"author": "opm", "section": "Wins"})
|
||||||
|
self.assertEqual(argv[-7:],
|
||||||
|
["md-append", "AGENTS.md", "--stdin",
|
||||||
|
"--author", "opm", "--section", "Wins"])
|
||||||
|
argv = ap["build"]({"filename": "AGENTS.md", "text": "t",
|
||||||
|
"author": "opm", "section": None})
|
||||||
|
self.assertEqual(argv[-5:],
|
||||||
|
["md-append", "AGENTS.md", "--stdin",
|
||||||
|
"--author", "opm"])
|
||||||
|
self.assertIsInstance(argv, list)
|
||||||
|
|
||||||
|
def test_stdin_body(self):
|
||||||
|
sb = exec_constrained._stdin_body
|
||||||
|
self.assertEqual(sb("md.amend", {"content": "C"}), "C")
|
||||||
|
self.assertEqual(sb("md.append", {"text": "T"}), "T")
|
||||||
|
self.assertIsNone(sb("md.read", {"account": "646"}))
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMdValidationTests(unittest.TestCase):
|
||||||
|
def test_account(self):
|
||||||
|
for good in ("646", "muse", "muse-main", "opm", "dev", "def"):
|
||||||
|
self.assertEqual(agent_md.validate_account(good), good)
|
||||||
|
for bad in ("../x", "a b", "", "a/b", "x" * 33, "-lead"):
|
||||||
|
with self.assertRaises(agent_md.MDValidationError, msg=bad):
|
||||||
|
agent_md.validate_account(bad)
|
||||||
|
|
||||||
|
def test_filename(self):
|
||||||
|
for good in ("SOUL.md", "NOTES.md", "a"):
|
||||||
|
self.assertEqual(agent_md.validate_filename(good), good)
|
||||||
|
self.assertEqual(
|
||||||
|
agent_md.validate_filename("SOUL.md", template_only=True),
|
||||||
|
"SOUL.md")
|
||||||
|
for bad in ("../x", "/abs", "..", ".", "a/b", ""):
|
||||||
|
with self.assertRaises(agent_md.MDValidationError, msg=bad):
|
||||||
|
agent_md.validate_filename(bad)
|
||||||
|
for bad in ("NOPE.md", "../SOUL.md", "NOTES.md"):
|
||||||
|
with self.assertRaises(agent_md.MDValidationError, msg=bad):
|
||||||
|
agent_md.validate_filename(bad, template_only=True)
|
||||||
|
|
||||||
|
def test_subpath(self):
|
||||||
|
self.assertEqual(agent_md.validate_subpath(""), "")
|
||||||
|
self.assertEqual(agent_md.validate_subpath("a/b"), "a/b")
|
||||||
|
for bad in ("..", "/abs", "a/../../x", "a b"):
|
||||||
|
with self.assertRaises(agent_md.MDValidationError, msg=bad):
|
||||||
|
agent_md.validate_subpath(bad)
|
||||||
|
|
||||||
|
def test_rejects_before_gateway(self):
|
||||||
|
# Validation failures raise MDValidationError; a call that
|
||||||
|
# reached the gateway would raise RuntimeError (no gateway
|
||||||
|
# module here) or FileNotFoundError (no cookies) instead.
|
||||||
|
with self.assertRaises(agent_md.MDValidationError):
|
||||||
|
agent_md.read_md("646", "../x")
|
||||||
|
with self.assertRaises(agent_md.MDValidationError):
|
||||||
|
agent_md.list_files("../x", "")
|
||||||
|
with self.assertRaises(agent_md.MDValidationError):
|
||||||
|
agent_md.audit_agents(["ok", "../x"])
|
||||||
|
with self.assertRaises(agent_md.MDValidationError):
|
||||||
|
agent_md.diff_md("646", "NOPE.md")
|
||||||
|
|
||||||
|
def test_amend_rejects_before_write(self):
|
||||||
|
with self.assertRaises(agent_md.MDValidationError):
|
||||||
|
agent_md.amend_md("../x", "body")
|
||||||
|
with self.assertRaises(agent_md.MDValidationError):
|
||||||
|
agent_md.append_md("NOPE.md", "note")
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlMdTests(unittest.TestCase):
|
||||||
|
def test_rejects_traversal_before_gateway(self):
|
||||||
|
cases = [
|
||||||
|
["md-read", "646", "../x"],
|
||||||
|
["md-read", "646", ".."],
|
||||||
|
["md-list", "bad!", "x"],
|
||||||
|
["md-list", "646", "../.."],
|
||||||
|
["md-write", "646", "/abs", "hi"],
|
||||||
|
["md-audit", "../x"],
|
||||||
|
["md", "read", "646", "../x"],
|
||||||
|
]
|
||||||
|
for args in cases:
|
||||||
|
r = _box_ctl(*args)
|
||||||
|
self.assertNotEqual(r.returncode, 0, args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME", args)
|
||||||
|
|
||||||
|
def test_rejects_non_template_before_write(self):
|
||||||
|
cases = [
|
||||||
|
["md-diff", "646", "NOPE.md"],
|
||||||
|
["md-pull", "646", "NOPE.md"],
|
||||||
|
["md", "diff", "646", "NOPE.md"],
|
||||||
|
["md", "amend", "NOPE.md", "content here"],
|
||||||
|
["md", "append", "NOPE.md", "note here"],
|
||||||
|
]
|
||||||
|
for args in cases:
|
||||||
|
r = _box_ctl(*args)
|
||||||
|
self.assertNotEqual(r.returncode, 0, args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME", args)
|
||||||
|
r = _box_ctl("md-amend", "../x", "--stdin", stdin="hi")
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
r = _box_ctl("md-append", "../x", "--stdin", stdin="hi")
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NAME")
|
||||||
|
|
||||||
|
def test_amend_stdin_safety_rejection_writes_nothing(self):
|
||||||
|
before = hashlib.sha256(HEARTBEAT.read_bytes()).hexdigest()
|
||||||
|
# Gutted HEARTBEAT content via --stdin: proves stdin plumbing
|
||||||
|
# reaches the backend, and the safety gate rejects it before
|
||||||
|
# any write or git commit.
|
||||||
|
r = _box_ctl("md-amend", "HEARTBEAT.md", "--stdin", stdin="gutted")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "AMEND_FAILED")
|
||||||
|
after = hashlib.sha256(HEARTBEAT.read_bytes()).hexdigest()
|
||||||
|
self.assertEqual(before, after)
|
||||||
|
|
||||||
|
def test_quality_validate_md_verbs(self):
|
||||||
|
cases = [
|
||||||
|
(["md-audit"], True),
|
||||||
|
(["md-audit", "646", "opm"], True),
|
||||||
|
(["md-audit", "../x"], False),
|
||||||
|
(["md-list", "646"], True),
|
||||||
|
(["md-list", "646", "sub/dir"], True),
|
||||||
|
(["md-list", "646", ".."], False),
|
||||||
|
(["md-list"], False),
|
||||||
|
(["md-read", "646", "SOUL.md"], True),
|
||||||
|
(["md-read", "646", "../x"], False),
|
||||||
|
(["md-read", "646"], False),
|
||||||
|
(["md-diff", "646", "SOUL.md"], True),
|
||||||
|
(["md-diff", "646", "NOPE.md"], False),
|
||||||
|
(["md-pull", "646", "SOUL.md"], True),
|
||||||
|
(["md-pull", "646"], False),
|
||||||
|
(["md-inject-drive", "646"], True),
|
||||||
|
(["md-inject-drive", "646", "--force"], True),
|
||||||
|
(["md-inject-drive"], False),
|
||||||
|
(["md-sync-all"], True),
|
||||||
|
(["md-sync-all", "--force"], True),
|
||||||
|
(["md-sync-all", "646"], False),
|
||||||
|
(["md-amend", "SOUL.md", "--stdin"], True),
|
||||||
|
(["md-amend", "SOUL.md", "--stdin", "--author", "646"], True),
|
||||||
|
(["md-amend", "NOPE.md", "--stdin"], False),
|
||||||
|
(["md-amend"], False),
|
||||||
|
(["md-append", "SOUL.md", "--stdin"], True),
|
||||||
|
(["md-append", "SOUL.md", "note", "--section", "s"], True),
|
||||||
|
(["md-append", "x", "y", "z"], False),
|
||||||
|
(["md-write", "646", "SOUL.md", "x"], True),
|
||||||
|
(["md-write", "646", "SOUL.md"], False),
|
||||||
|
]
|
||||||
|
for args, valid in cases:
|
||||||
|
r = _box_ctl("quality-validate", *args)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["valid"], valid, args)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRelayMdTests(unittest.TestCase):
|
||||||
|
def test_relay_help_lists_md_commands(self):
|
||||||
|
r = subprocess.run(["bash", str(RELAY), "help"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
for line in ("box md audit", "box md list", "box md read",
|
||||||
|
"box md diff", "box md pull", "box md inject-drive",
|
||||||
|
"box md sync-all", "box md amend", "box md append"):
|
||||||
|
self.assertIn(line, r.stdout)
|
||||||
|
|
||||||
|
def test_relay_maps_md_commands_to_ops(self):
|
||||||
|
text = RELAY.read_text()
|
||||||
|
for op in ('"md.audit"', '"md.list"', '"md.read"', '"md.diff"',
|
||||||
|
'"md.pull"', '"md.inject_drive"', '"md.sync_all"',
|
||||||
|
'"md.amend"', '"md.append"'):
|
||||||
|
self.assertIn(op, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Tests for read-only box lookups over HTTPS (no SSH).
|
||||||
|
|
||||||
|
Covers the agent-facing read path:
|
||||||
|
box-relay.sh (agent client) -> exec-constrained.py named ops
|
||||||
|
-> box-ctl.py backend verbs -> super-cli.py lookups.
|
||||||
|
|
||||||
|
Live-socket round-trips are intentionally NOT covered here (loopback TCP is
|
||||||
|
unavailable in some sandboxes); instead we assert the exact argv each op
|
||||||
|
builds and execute the fast argv directly.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
RELAY = REPO_ROOT / "bin" / "box-relay.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
exec_constrained = _load("exec_constrained_read", "bin/exec-constrained.py")
|
||||||
|
super_cli = _load("super_cli_read", "bin/super-cli.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecReadOpsTests(unittest.TestCase):
|
||||||
|
def test_ops_registered_and_read_only(self):
|
||||||
|
self.assertIn("fleet.unread", exec_constrained.OPS)
|
||||||
|
self.assertIn("dm.log", exec_constrained.OPS)
|
||||||
|
self.assertFalse(exec_constrained.OPS["fleet.unread"]["side_effecting"])
|
||||||
|
self.assertFalse(exec_constrained.OPS["dm.log"]["side_effecting"])
|
||||||
|
|
||||||
|
def test_default_perms_include_read_ops(self):
|
||||||
|
# Any valid fleet signer can read; canary stays ping-only.
|
||||||
|
self.assertTrue(exec_constrained.permitted("some-unknown-identity", "fleet.unread"))
|
||||||
|
self.assertTrue(exec_constrained.permitted("some-unknown-identity", "dm.log"))
|
||||||
|
self.assertFalse(exec_constrained.permitted("exec-canary", "fleet.unread"))
|
||||||
|
self.assertFalse(exec_constrained.permitted("exec-canary", "dm.log"))
|
||||||
|
|
||||||
|
def test_fleet_unread_validate(self):
|
||||||
|
v = exec_constrained.OPS["fleet.unread"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"agent": None})
|
||||||
|
self.assertEqual(v({"agent": "pip"}), {"agent": "pip"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"agent": "nope"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"bogus": 1})
|
||||||
|
|
||||||
|
def test_dm_log_validate(self):
|
||||||
|
v = exec_constrained.OPS["dm.log"]["validate"]
|
||||||
|
self.assertEqual(v({}), {"limit": 20, "agent": None})
|
||||||
|
self.assertEqual(v({"limit": 5, "agent": "opm"}), {"limit": 5, "agent": "opm"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"limit": 0})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"limit": 101})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"agent": "nope"})
|
||||||
|
with self.assertRaises(exec_constrained.OpError):
|
||||||
|
v({"bogus": 1})
|
||||||
|
|
||||||
|
def test_build_argv_shapes(self):
|
||||||
|
unread = exec_constrained.OPS["fleet.unread"]
|
||||||
|
argv = unread["build"]({"agent": None})
|
||||||
|
self.assertEqual(argv[-1], "unread")
|
||||||
|
self.assertNotIn("--agent", argv)
|
||||||
|
argv = unread["build"]({"agent": "muse"})
|
||||||
|
self.assertEqual(argv[-3:], ["unread", "--agent", "muse"])
|
||||||
|
|
||||||
|
dmlog = exec_constrained.OPS["dm.log"]
|
||||||
|
argv = dmlog["build"]({"limit": 5, "agent": "opm"})
|
||||||
|
self.assertEqual(argv[-4:], ["dm-log", "5", "--agent", "opm"])
|
||||||
|
argv = dmlog["build"]({"limit": 20, "agent": None})
|
||||||
|
self.assertEqual(argv[-2:], ["dm-log", "20"])
|
||||||
|
# argv only, never a shell string.
|
||||||
|
self.assertIsInstance(argv, list)
|
||||||
|
|
||||||
|
def test_dm_log_built_argv_executes(self):
|
||||||
|
spec = exec_constrained.OPS["dm.log"]
|
||||||
|
clean = spec["validate"]({"limit": 2})
|
||||||
|
argv = spec["build"](clean)
|
||||||
|
argv[0] = sys.executable # hermetic interpreter, same script + args
|
||||||
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(len(data["entries"]), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCtlReadVerbsTests(unittest.TestCase):
|
||||||
|
def test_unread_rejects_unknown_agent(self):
|
||||||
|
r = _box_ctl("unread", "--agent", "nope")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NODE")
|
||||||
|
|
||||||
|
def test_unread_rejects_positional_and_missing_value(self):
|
||||||
|
r = _box_ctl("unread", "pip")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_ARGS")
|
||||||
|
r = _box_ctl("unread", "--agent")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_ARGS")
|
||||||
|
|
||||||
|
def test_dm_log_rejects_bad_limit_and_agent(self):
|
||||||
|
r = _box_ctl("dm-log", "abc")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_LIMIT")
|
||||||
|
r = _box_ctl("dm-log", "5", "--agent", "nope")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_NODE")
|
||||||
|
r = _box_ctl("dm-log", "1", "2")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertEqual(json.loads(r.stdout)["code"], "BAD_ARGS")
|
||||||
|
|
||||||
|
def test_dm_log_back_compat_limit_only(self):
|
||||||
|
r = _box_ctl("dm-log", "2")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(len(data["entries"]), 2)
|
||||||
|
|
||||||
|
def test_quality_validate_new_verbs(self):
|
||||||
|
r = _box_ctl("quality-validate", "unread", "--agent", "pip")
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "dm-log", "5", "--agent", "opm")
|
||||||
|
self.assertTrue(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "unread", "--agent", "nope")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
r = _box_ctl("quality-validate", "unread", "extra-positional")
|
||||||
|
self.assertFalse(json.loads(r.stdout)["valid"], r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class SuperCliUnreadTests(unittest.TestCase):
|
||||||
|
def test_lookup_dispatches_unread(self):
|
||||||
|
args = argparse.Namespace(target="unread", lookup_args=[], json=False)
|
||||||
|
with mock.patch.object(super_cli, "_lookup_unreads") as m:
|
||||||
|
super_cli.cmd_lookup(args)
|
||||||
|
m.assert_called_once_with(args)
|
||||||
|
|
||||||
|
def test_lookup_unreads_json_shape(self):
|
||||||
|
fleet = [
|
||||||
|
{"node": "muse", "title": "muse (2)", "url": "https://muse.ai/thread/abc123",
|
||||||
|
"approval_pending": False},
|
||||||
|
{"node": "pip", "title": "muse", "url": "https://muse.ai/",
|
||||||
|
"approval_pending": True},
|
||||||
|
]
|
||||||
|
args = argparse.Namespace(json=True)
|
||||||
|
buf = io.StringIO()
|
||||||
|
with mock.patch.object(super_cli, "collect_fleet_data", return_value=fleet):
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
super_cli._lookup_unreads(args)
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
by_node = {n["node"]: n for n in data["nodes"]}
|
||||||
|
self.assertEqual(by_node["muse"]["unread"], 2)
|
||||||
|
self.assertEqual(by_node["muse"]["thread"], "abc123")
|
||||||
|
self.assertFalse(by_node["muse"]["approval_pending"])
|
||||||
|
self.assertEqual(by_node["pip"]["unread"], 0)
|
||||||
|
self.assertTrue(by_node["pip"]["approval_pending"])
|
||||||
|
self.assertEqual(by_node["pip"]["thread"], "home")
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRelayClientTests(unittest.TestCase):
|
||||||
|
def test_relay_syntax_valid(self):
|
||||||
|
r = subprocess.run(["bash", "-n", str(RELAY)],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
|
||||||
|
def test_relay_help_lists_read_commands(self):
|
||||||
|
r = subprocess.run(["bash", str(RELAY), "help"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertIn("box unread", r.stdout)
|
||||||
|
self.assertIn("box dm log", r.stdout)
|
||||||
|
|
||||||
|
def test_relay_maps_read_commands_to_ops(self):
|
||||||
|
text = RELAY.read_text()
|
||||||
|
self.assertIn("fleet.unread", text)
|
||||||
|
self.assertIn('"dm.log"', text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Tests for `box run` and `box watch` (headless muse-code tmux runs) in super-cli.py."""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"super_cli_box_run", REPO_ROOT / "bin" / "super-cli.py")
|
||||||
|
super_cli = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(super_cli)
|
||||||
|
|
||||||
|
|
||||||
|
def _ns(**over):
|
||||||
|
kw = dict(prompt="hello from unit test", prompt_file=None, session="ut-boxrun",
|
||||||
|
log=None, model=None, effort=None, provider="echo",
|
||||||
|
permission_profile=None, approval_mode="never",
|
||||||
|
trust_workspace=False, auto_approve=False)
|
||||||
|
kw.update(over)
|
||||||
|
return argparse.Namespace(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCompleted:
|
||||||
|
def __init__(self, returncode=0, stderr=""):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRunTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
for f in ("/tmp/ut-boxrun.prompt", "/tmp/ut-boxrun.sh", "/tmp/ut-boxrun.exit",
|
||||||
|
"/tmp/ut-boxrun-watch.sh", "/tmp/utw-watch.sh"):
|
||||||
|
try:
|
||||||
|
Path(f).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
self.addCleanup(self._cleanup)
|
||||||
|
|
||||||
|
def _cleanup(self):
|
||||||
|
for f in ("/tmp/ut-boxrun.prompt", "/tmp/ut-boxrun.sh", "/tmp/ut-boxrun.exit",
|
||||||
|
"/tmp/ut-boxrun-watch.sh", "/tmp/utw-watch.sh"):
|
||||||
|
try:
|
||||||
|
Path(f).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
for log in ("ut-boxrun.log", "ut-boxrun-watch.log", "utw-watch.log"):
|
||||||
|
try:
|
||||||
|
Path(super_cli.MUSE_TMUX_LOG_DIR / log).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _which(self, name):
|
||||||
|
if name == "tmux":
|
||||||
|
return "/usr/bin/tmux"
|
||||||
|
if "muse-code" in name:
|
||||||
|
return "/home/super/.local/bin/muse-code"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_spawn_writes_prompt_wrapper_and_tmux_argv(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(argv, **kw):
|
||||||
|
calls.append(argv)
|
||||||
|
return FakeCompleted(0)
|
||||||
|
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", side_effect=self._which), \
|
||||||
|
mock.patch.object(super_cli.subprocess, "run", side_effect=fake_run):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
super_cli.cmd_run(_ns())
|
||||||
|
out = buf.getvalue()
|
||||||
|
|
||||||
|
self.assertIn("session: ut-boxrun", out)
|
||||||
|
self.assertIn("attach: tmux -S /tmp/tmux-muse.sock attach -t ut-boxrun", out)
|
||||||
|
self.assertEqual(Path("/tmp/ut-boxrun.prompt").read_text(encoding="utf-8"),
|
||||||
|
"hello from unit test\n")
|
||||||
|
wrapper = Path("/tmp/ut-boxrun.sh").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("muse-code", wrapper)
|
||||||
|
self.assertIn("--provider echo", wrapper)
|
||||||
|
self.assertIn("--prompt-file", wrapper)
|
||||||
|
self.assertIn("cd /home/super/Projects/NetVM", wrapper)
|
||||||
|
new_session = [c for c in calls if "new-session" in c]
|
||||||
|
self.assertEqual(len(new_session), 1)
|
||||||
|
self.assertIn("/tmp/tmux-muse.sock", new_session[0])
|
||||||
|
self.assertIn("ut-boxrun", new_session[0])
|
||||||
|
|
||||||
|
def test_optional_flags_passed_through(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(argv, **kw):
|
||||||
|
calls.append(argv)
|
||||||
|
return FakeCompleted(0)
|
||||||
|
|
||||||
|
ns = _ns(model="m1", effort="low", permission_profile="prof",
|
||||||
|
trust_workspace=True, approval_mode="on-request")
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", side_effect=self._which), \
|
||||||
|
mock.patch.object(super_cli.subprocess, "run", side_effect=fake_run):
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
super_cli.cmd_run(ns)
|
||||||
|
wrapper = Path("/tmp/ut-boxrun.sh").read_text(encoding="utf-8")
|
||||||
|
for flag in ("--model m1", "--reasoning-effort low", "--permission-profile prof",
|
||||||
|
"--trust-workspace", "--approval-mode on-request"):
|
||||||
|
self.assertIn(flag, wrapper)
|
||||||
|
|
||||||
|
def test_no_prompt_exits_2(self):
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", side_effect=self._which), \
|
||||||
|
mock.patch.object(super_cli.sys.stdin, "isatty", return_value=True):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_run(_ns(prompt=None))
|
||||||
|
self.assertEqual(cm.exception.code, 2)
|
||||||
|
|
||||||
|
def test_missing_muse_code_exits_2(self):
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", return_value=None):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_run(_ns())
|
||||||
|
self.assertEqual(cm.exception.code, 2)
|
||||||
|
|
||||||
|
def test_sanitize_session_name(self):
|
||||||
|
self.assertEqual(super_cli._sanitize_tmux_name("run:2026/10/06 05.00"), "run-2026-10-06-05-00")
|
||||||
|
self.assertEqual(super_cli._sanitize_tmux_name("!!!"), "run")
|
||||||
|
|
||||||
|
def test_run_auto_approve_spawns_watcher(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(argv, **kw):
|
||||||
|
calls.append(argv)
|
||||||
|
return FakeCompleted(0)
|
||||||
|
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", side_effect=self._which), \
|
||||||
|
mock.patch.object(super_cli.subprocess, "run", side_effect=fake_run):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
super_cli.cmd_run(_ns(auto_approve=True))
|
||||||
|
out = buf.getvalue()
|
||||||
|
self.assertIn("watcher: ut-boxrun-watch", out)
|
||||||
|
self.assertIn("watcher-log:", out)
|
||||||
|
new_session = [c for c in calls if "new-session" in c]
|
||||||
|
self.assertEqual(len(new_session), 2)
|
||||||
|
watch_argv = [c for c in new_session if "ut-boxrun-watch" in c]
|
||||||
|
self.assertEqual(len(watch_argv), 1)
|
||||||
|
self.assertTrue(Path("/tmp/ut-boxrun-watch.sh").exists())
|
||||||
|
|
||||||
|
def test_write_watch_script_content(self):
|
||||||
|
watch_session, script_file = super_cli._write_watch_script("utw")
|
||||||
|
self.assertEqual(watch_session, "utw-watch")
|
||||||
|
text = Path(script_file).read_text(encoding="utf-8")
|
||||||
|
self.assertIn('target="utw"', text)
|
||||||
|
self.assertIn('exit_file="/tmp/utw.exit"', text)
|
||||||
|
self.assertIn("capture-pane", text)
|
||||||
|
self.assertIn('send-keys -t "$target" "1" Enter', text)
|
||||||
|
self.assertIn("max=200", text)
|
||||||
|
self.assertIn("has-session", text)
|
||||||
|
|
||||||
|
def test_watch_missing_session_exits_2(self):
|
||||||
|
def fake_run(argv, **kw):
|
||||||
|
if "has-session" in argv:
|
||||||
|
return FakeCompleted(1)
|
||||||
|
return FakeCompleted(0)
|
||||||
|
|
||||||
|
ns = argparse.Namespace(session="nope-missing")
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", side_effect=self._which), \
|
||||||
|
mock.patch.object(super_cli.subprocess, "run", side_effect=fake_run):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_watch(ns)
|
||||||
|
self.assertEqual(cm.exception.code, 2)
|
||||||
|
|
||||||
|
def test_watch_spawns_watcher_session(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(argv, **kw):
|
||||||
|
calls.append(argv)
|
||||||
|
return FakeCompleted(0)
|
||||||
|
|
||||||
|
ns = argparse.Namespace(session="utw")
|
||||||
|
with mock.patch.object(super_cli.shutil, "which", side_effect=self._which), \
|
||||||
|
mock.patch.object(super_cli.subprocess, "run", side_effect=fake_run):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
super_cli.cmd_watch(ns)
|
||||||
|
out = buf.getvalue()
|
||||||
|
self.assertIn("watching: utw", out)
|
||||||
|
self.assertIn("watcher: utw-watch", out)
|
||||||
|
new_session = [c for c in calls if "new-session" in c]
|
||||||
|
self.assertEqual(len(new_session), 1)
|
||||||
|
self.assertIn("utw-watch", new_session[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""test_box_runtime.py — Runtime state sensing + `box runtime` management.
|
||||||
|
|
||||||
|
Covers: runtime_state classification (approval-pending/working/open-prompt),
|
||||||
|
muse argv approval-posture parsing, runtime_rows assembly (mocked tmux),
|
||||||
|
and the `box runtime` CLI surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||||
|
BIN_DIR = REPO_ROOT / "bin"
|
||||||
|
sys.path.insert(0, str(BIN_DIR))
|
||||||
|
|
||||||
|
import muse_choice_watcher as w
|
||||||
|
|
||||||
|
PROMPT = "❯" # Muse TUI input glyph (U+276F)
|
||||||
|
|
||||||
|
STATE_OPEN = (
|
||||||
|
"Some completed agent output here.\n"
|
||||||
|
"\n"
|
||||||
|
"───────────────────────────────────\n"
|
||||||
|
+ PROMPT + "\n"
|
||||||
|
"───────────────────────────────────\n"
|
||||||
|
" muse-spark-1.3-con… · Auto-review\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
STATE_WORKING = (
|
||||||
|
"Partial agent output...\n"
|
||||||
|
"\n"
|
||||||
|
"… — running (10s · esc to interrupt)\n"
|
||||||
|
"\n"
|
||||||
|
"───────────────────────────────────\n"
|
||||||
|
+ PROMPT + "\n"
|
||||||
|
"───────────────────────────────────\n"
|
||||||
|
" muse-spark-1.3-con… · Auto-review\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
STATE_WORKING_CUT = (
|
||||||
|
"Partial agent output...\n"
|
||||||
|
"◆ Calling tools (5m 53s · esc to in\n"
|
||||||
|
"\n"
|
||||||
|
"───────────────────────────────────\n"
|
||||||
|
+ PROMPT + "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
STATE_APPROVAL = (
|
||||||
|
"───────────────────────────────────\n"
|
||||||
|
"Would you like to run the following\n"
|
||||||
|
"\n"
|
||||||
|
" $ tmux capture-pane -p\n"
|
||||||
|
"\n"
|
||||||
|
"› 1. Yes, proceed (y)\n"
|
||||||
|
" 2. No, and tell Muse Code what to do instead\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
STATE_SHELL = "[super@bl NetVM]$ printf 'hi'\nhi\n[super@bl NetVM]$ "
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeState(unittest.TestCase):
|
||||||
|
def test_open_prompt(self):
|
||||||
|
st = w.runtime_state(STATE_OPEN)
|
||||||
|
self.assertEqual(st["state"], "open-prompt")
|
||||||
|
self.assertIsNone(st["match"])
|
||||||
|
|
||||||
|
def test_working(self):
|
||||||
|
st = w.runtime_state(STATE_WORKING)
|
||||||
|
self.assertEqual(st["state"], "working")
|
||||||
|
|
||||||
|
def test_working_edge_cut_indicator(self):
|
||||||
|
st = w.runtime_state(STATE_WORKING_CUT)
|
||||||
|
self.assertEqual(st["state"], "working")
|
||||||
|
|
||||||
|
def test_approval_pending(self):
|
||||||
|
st = w.runtime_state(STATE_APPROVAL)
|
||||||
|
self.assertEqual(st["state"], "approval-pending")
|
||||||
|
self.assertEqual(st["match"]["kind"], "muse-approval")
|
||||||
|
self.assertEqual(st["match"]["key"], "1")
|
||||||
|
|
||||||
|
def test_approval_beats_working(self):
|
||||||
|
st = w.runtime_state(STATE_WORKING + STATE_APPROVAL)
|
||||||
|
self.assertEqual(st["state"], "approval-pending")
|
||||||
|
|
||||||
|
def test_working_beats_open_prompt(self):
|
||||||
|
# A working pane still renders its prompt footer.
|
||||||
|
st = w.runtime_state(STATE_WORKING)
|
||||||
|
self.assertEqual(st["state"], "working")
|
||||||
|
|
||||||
|
def test_shell_is_unknown(self):
|
||||||
|
self.assertEqual(w.runtime_state(STATE_SHELL)["state"], "unknown")
|
||||||
|
|
||||||
|
def test_empty_is_unknown(self):
|
||||||
|
self.assertEqual(w.runtime_state("")["state"], "unknown")
|
||||||
|
self.assertEqual(w.runtime_state(None)["state"], "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
class TestApprovalFlags(unittest.TestCase):
|
||||||
|
def test_bare_is_not_auto(self):
|
||||||
|
p = w.muse_approval_flags(["/home/super/.local/bin/muse-bin-1.4.3"])
|
||||||
|
self.assertFalse(p["auto_approve"])
|
||||||
|
self.assertEqual(p["flags"], [])
|
||||||
|
|
||||||
|
def test_yolo(self):
|
||||||
|
p = w.muse_approval_flags(["muse", "--yolo"])
|
||||||
|
self.assertTrue(p["auto_approve"])
|
||||||
|
self.assertIn("yolo", p["flags"])
|
||||||
|
|
||||||
|
def test_disable_approval(self):
|
||||||
|
p = w.muse_approval_flags(["muse", "--disable-approval"])
|
||||||
|
self.assertTrue(p["auto_approve"])
|
||||||
|
|
||||||
|
def test_approval_mode_never(self):
|
||||||
|
p = w.muse_approval_flags(["muse", "--approval-mode", "never"])
|
||||||
|
self.assertTrue(p["auto_approve"])
|
||||||
|
self.assertIn("approval-mode=never", p["flags"])
|
||||||
|
|
||||||
|
def test_approval_mode_equals(self):
|
||||||
|
p = w.muse_approval_flags(["muse", "--approval-mode=never"])
|
||||||
|
self.assertTrue(p["auto_approve"])
|
||||||
|
|
||||||
|
def test_approval_mode_on_request_is_not_auto(self):
|
||||||
|
p = w.muse_approval_flags(["muse", "--approval-mode", "on-request"])
|
||||||
|
self.assertFalse(p["auto_approve"])
|
||||||
|
self.assertIn("approval-mode=on-request", p["flags"])
|
||||||
|
|
||||||
|
def test_empty_argv(self):
|
||||||
|
p = w.muse_approval_flags([])
|
||||||
|
self.assertFalse(p["auto_approve"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tmux_result(returncode=0, stdout="", stderr=""):
|
||||||
|
r = mock.Mock()
|
||||||
|
r.returncode = returncode
|
||||||
|
r.stdout = stdout
|
||||||
|
r.stderr = stderr
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeRows(unittest.TestCase):
|
||||||
|
LISTING = ("muse\t1\t%37\tmuse-bin-1.4.3-R5018.1\t2880158\t71\t27\n"
|
||||||
|
"muse\t1\t%38\tbash\t2880200\t100\t30\n")
|
||||||
|
|
||||||
|
def _patched(self, tmux_stdout=LISTING, tmux_rc=0, captures=None,
|
||||||
|
children=None, cmdlines=None, watcher=None):
|
||||||
|
captures = captures or {}
|
||||||
|
cmdlines = cmdlines or {}
|
||||||
|
children = children or {}
|
||||||
|
return (mock.patch.object(w, "_tmux", return_value=_tmux_result(
|
||||||
|
tmux_rc, tmux_stdout)),
|
||||||
|
mock.patch.object(w, "capture_pane",
|
||||||
|
side_effect=lambda s, p: captures.get(p)),
|
||||||
|
mock.patch.object(w, "_child_pids",
|
||||||
|
side_effect=lambda p: children.get(p, [])),
|
||||||
|
mock.patch.object(w, "_cmdline",
|
||||||
|
side_effect=lambda p: cmdlines.get(p, [])),
|
||||||
|
mock.patch.object(w, "is_running", return_value=watcher))
|
||||||
|
|
||||||
|
def test_rows_shape(self):
|
||||||
|
patches = self._patched(
|
||||||
|
captures={"%37": STATE_OPEN, "%38": STATE_SHELL},
|
||||||
|
children={2880158: [2881158]},
|
||||||
|
cmdlines={2881158: ["/home/super/.local/bin/muse-bin-1.4.3",
|
||||||
|
"--disable-approval"]},
|
||||||
|
watcher=1234)
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
rows = w.runtime_rows("/tmp/sock")
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
muse = rows[0]
|
||||||
|
self.assertEqual(muse["pane"], "%37")
|
||||||
|
self.assertTrue(muse["is_muse"])
|
||||||
|
self.assertTrue(muse["auto_approve"])
|
||||||
|
self.assertEqual(muse["approval_flags"], ["disable-approval"])
|
||||||
|
self.assertEqual(muse["state"], "open-prompt")
|
||||||
|
self.assertTrue(muse["watcher_alive"])
|
||||||
|
self.assertEqual(muse["watcher_pid"], 1234)
|
||||||
|
self.assertEqual(muse["width"], 71)
|
||||||
|
self.assertEqual(muse["height"], 27)
|
||||||
|
self.assertFalse(muse["squeezed"])
|
||||||
|
shell = rows[1]
|
||||||
|
self.assertFalse(shell["is_muse"])
|
||||||
|
self.assertIsNone(shell["auto_approve"])
|
||||||
|
self.assertEqual(shell["state"], "unknown")
|
||||||
|
|
||||||
|
def test_bare_muse_reports_not_auto(self):
|
||||||
|
patches = self._patched(
|
||||||
|
captures={"%37": STATE_WORKING, "%38": STATE_SHELL},
|
||||||
|
children={2880158: [2881158]},
|
||||||
|
cmdlines={2881158: ["/home/super/.local/bin/muse-bin-1.4.3"]})
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
rows = w.runtime_rows("/tmp/sock")
|
||||||
|
self.assertFalse(rows[0]["auto_approve"])
|
||||||
|
self.assertEqual(rows[0]["state"], "working")
|
||||||
|
self.assertFalse(rows[0]["watcher_alive"])
|
||||||
|
|
||||||
|
def test_approval_pending_row_carries_kind(self):
|
||||||
|
patches = self._patched(captures={"%37": STATE_APPROVAL,
|
||||||
|
"%38": STATE_SHELL})
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
rows = w.runtime_rows("/tmp/sock")
|
||||||
|
self.assertEqual(rows[0]["state"], "approval-pending")
|
||||||
|
self.assertEqual(rows[0]["prompt_kind"], "muse-approval")
|
||||||
|
self.assertEqual(rows[0]["prompt_key"], "1")
|
||||||
|
|
||||||
|
def test_tmux_failure_returns_empty(self):
|
||||||
|
patches = self._patched(tmux_rc=1, tmux_stdout="")
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
self.assertEqual(w.runtime_rows("/tmp/sock"), [])
|
||||||
|
|
||||||
|
def test_vanished_pane_skipped(self):
|
||||||
|
patches = self._patched(captures={"%37": None, "%38": STATE_SHELL})
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
rows = w.runtime_rows("/tmp/sock")
|
||||||
|
self.assertEqual([r["pane"] for r in rows], ["%38"])
|
||||||
|
|
||||||
|
def test_pane_state_found_and_missing(self):
|
||||||
|
patches = self._patched(captures={"%37": STATE_OPEN,
|
||||||
|
"%38": STATE_SHELL})
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
hit = w.pane_state("/tmp/sock", "%37")
|
||||||
|
miss = w.pane_state("/tmp/sock", "%99")
|
||||||
|
self.assertEqual(hit["pane"], "%37")
|
||||||
|
self.assertEqual(miss["error"], "no_such_pane")
|
||||||
|
|
||||||
|
def test_rows_flag_squeezed(self):
|
||||||
|
listing = ("muse\t1\t%37\tmuse-bin-1.4\t2880158\t35\t7\n"
|
||||||
|
"muse\t1\t%38\tbash\t2880200\t35\t7\n")
|
||||||
|
patches = self._patched(
|
||||||
|
tmux_stdout=listing,
|
||||||
|
captures={"%37": STATE_OPEN, "%38": STATE_SHELL})
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4]:
|
||||||
|
rows = w.runtime_rows("/tmp/sock")
|
||||||
|
self.assertTrue(rows[0]["squeezed"])
|
||||||
|
self.assertEqual(rows[0]["width"], 35)
|
||||||
|
self.assertEqual(rows[0]["height"], 7)
|
||||||
|
self.assertTrue(rows[1]["squeezed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpreadTargets(unittest.TestCase):
|
||||||
|
def _row(self, pane, is_muse, squeezed):
|
||||||
|
return {"socket": "/tmp/s", "session": "muse", "window": "1",
|
||||||
|
"pane": pane, "is_muse": is_muse, "squeezed": squeezed}
|
||||||
|
|
||||||
|
def test_selects_squeezed_muse_only(self):
|
||||||
|
rows = [self._row("%22", True, False),
|
||||||
|
self._row("%23", True, True),
|
||||||
|
self._row("%38", False, True)]
|
||||||
|
targets = w.spread_targets(rows)
|
||||||
|
self.assertEqual([t["pane"] for t in targets], ["%23"])
|
||||||
|
|
||||||
|
def test_empty_when_nothing_squeezed(self):
|
||||||
|
rows = [self._row("%22", True, False)]
|
||||||
|
self.assertEqual(w.spread_targets(rows), [])
|
||||||
|
|
||||||
|
def test_tolerates_missing_keys(self):
|
||||||
|
self.assertEqual(w.spread_targets([{"pane": "%1"}]), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoxRuntimeCLI(unittest.TestCase):
|
||||||
|
def _box(self, *argv, timeout=60):
|
||||||
|
import subprocess
|
||||||
|
cmd = [sys.executable, str(BIN_DIR / "super-cli.py"),
|
||||||
|
"runtime"] + list(argv)
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=timeout)
|
||||||
|
|
||||||
|
def test_list_empty_socket_json(self):
|
||||||
|
r = self._box("list", "--socket", "/nonexistent.sock", "--json")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["runtimes"], [])
|
||||||
|
|
||||||
|
def test_list_muse_only_flag_accepted(self):
|
||||||
|
r = self._box("list", "--socket", "/nonexistent.sock", "--json",
|
||||||
|
"--muse-only")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
self.assertTrue(json.loads(r.stdout)["ok"])
|
||||||
|
|
||||||
|
def test_subcommand_help(self):
|
||||||
|
for sub in ("list", "send", "launch", "layout", "spread"):
|
||||||
|
r = self._box(sub, "--help")
|
||||||
|
self.assertEqual(r.returncode, 0, sub)
|
||||||
|
|
||||||
|
def test_launch_dry_run_injects_approve(self):
|
||||||
|
r = self._box("launch", "--session", "probe-x",
|
||||||
|
"--dry-run", "--json")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertTrue(data["dry_run"])
|
||||||
|
self.assertEqual(data["injected"], ["--disable-approval"])
|
||||||
|
self.assertIn("--disable-approval", data["cmdline"])
|
||||||
|
self.assertIn("muse-code", data["cmdline"])
|
||||||
|
|
||||||
|
def test_launch_dry_run_respects_caller_flags(self):
|
||||||
|
r = self._box("launch", "--session", "probe-x",
|
||||||
|
"--dry-run", "--json", "--", "--yolo")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertEqual(data["injected"], [])
|
||||||
|
self.assertIn("--yolo", data["cmdline"])
|
||||||
|
self.assertNotIn("--disable-approval", data["cmdline"])
|
||||||
|
|
||||||
|
def test_send_missing_pane_json(self):
|
||||||
|
r = self._box("send", "--socket", "/nonexistent.sock",
|
||||||
|
"%99", "hi", "--json")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertFalse(data["ok"])
|
||||||
|
self.assertEqual(data["error"], "no_such_pane")
|
||||||
|
|
||||||
|
def test_layout_empty_socket_json(self):
|
||||||
|
r = self._box("layout", "--socket", "/nonexistent.sock", "--json")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["runtimes"], [])
|
||||||
|
self.assertIn("width", data["minimum"])
|
||||||
|
self.assertIn("height", data["minimum"])
|
||||||
|
|
||||||
|
def test_spread_empty_socket_json(self):
|
||||||
|
r = self._box("spread", "--socket", "/nonexistent.sock", "--json")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["spread"], [])
|
||||||
|
|
||||||
|
def test_spread_dry_run_empty_socket_json(self):
|
||||||
|
r = self._box("spread", "--socket", "/nonexistent.sock",
|
||||||
|
"--dry-run", "--json")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr[:500])
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
self.assertTrue(data["dry_run"])
|
||||||
|
self.assertEqual(data["targets"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""Tests for `box fleet heal` and `box watchdog` in super-cli.py.
|
||||||
|
|
||||||
|
Heal drives lock cleanup, watchdog-timer install, tunnel restart, and
|
||||||
|
egress/relay verification; watchdog status/run expose the systemd
|
||||||
|
watchdog layer. Shell-outs are faked; no sudo/systemctl/netns touch
|
||||||
|
the host.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"super_cli_heal", REPO_ROOT / "bin" / "super-cli.py")
|
||||||
|
super_cli = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(super_cli)
|
||||||
|
|
||||||
|
|
||||||
|
def _ns(**over):
|
||||||
|
kw = dict(node=None, target=None, json=True)
|
||||||
|
kw.update(over)
|
||||||
|
return argparse.Namespace(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSh:
|
||||||
|
"""Programmable stand-in for super_cli._sh; records calls."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.handlers = []
|
||||||
|
|
||||||
|
def on(self, *needles, rc=0, out=""):
|
||||||
|
self.handlers.append((needles, rc, out))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __call__(self, cmd, timeout=120, input_text=None):
|
||||||
|
self.calls.append((list(cmd), timeout, input_text))
|
||||||
|
blob = " ".join(cmd)
|
||||||
|
for needles, rc, out in self.handlers:
|
||||||
|
if all(n in blob for n in needles):
|
||||||
|
return rc, out() if callable(out) else out
|
||||||
|
raise AssertionError("unexpected command: %r" % (cmd,))
|
||||||
|
|
||||||
|
|
||||||
|
class HealBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
import tempfile
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.systemd = self.root / "systemd"
|
||||||
|
self.systemd.mkdir()
|
||||||
|
self.locks = self.root / "locks"
|
||||||
|
self.locks.mkdir()
|
||||||
|
self.patchers = [
|
||||||
|
mock.patch.object(super_cli, "SYSTEMD_SYSTEM_DIR", self.systemd),
|
||||||
|
mock.patch.object(super_cli, "WATCHDOG_LOCK_TMPL",
|
||||||
|
str(self.locks / "chromebox-watchdog-{node}.lock")),
|
||||||
|
]
|
||||||
|
for p in self.patchers:
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(self._unpatch)
|
||||||
|
self.sh = FakeSh()
|
||||||
|
self.sh_mock = mock.patch.object(super_cli, "_sh", self.sh)
|
||||||
|
self.sh_mock.start()
|
||||||
|
self.addCleanup(self.sh_mock.stop)
|
||||||
|
|
||||||
|
def _unpatch(self):
|
||||||
|
for p in self.patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
def run_heal(self, node="dev", as_json=True):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_fleet_heal(_ns(node=node, json=as_json))
|
||||||
|
return cm.exception.code, buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class FleetHealTests(HealBase):
|
||||||
|
def _healthy_sh(self):
|
||||||
|
(self.sh
|
||||||
|
.on("systemctl", "enable", "--now", rc=0, out="")
|
||||||
|
.on("netvm-node-up.sh", "dev", rc=0,
|
||||||
|
out="tunnel already up (egress=1.2.3.4), skipping handshake wait\n"
|
||||||
|
"node=dev netns=warp-dev egress=1.2.3.4")
|
||||||
|
.on("curl", rc=0, out="ip=1.2.3.4\nfoo=bar"))
|
||||||
|
|
||||||
|
def test_heal_recovered(self):
|
||||||
|
(self.systemd / "chromebox-watchdog-dev.timer").write_text("x")
|
||||||
|
self._healthy_sh()
|
||||||
|
with mock.patch.object(super_cli, "probe_cdp_status",
|
||||||
|
return_value={"ok": True, "latency_ms": 12}):
|
||||||
|
code, out = self.run_heal()
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
data = json.loads(out)
|
||||||
|
self.assertEqual(data["verdict"], "RECOVERED")
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual([s["step"] for s in data["steps"]],
|
||||||
|
["lock", "timer", "tunnel", "egress", "relay"])
|
||||||
|
self.assertTrue(all(s["ok"] for s in data["steps"]))
|
||||||
|
|
||||||
|
def test_heal_down_when_egress_fails(self):
|
||||||
|
(self.systemd / "chromebox-watchdog-dev.timer").write_text("x")
|
||||||
|
(self.sh
|
||||||
|
.on("systemctl", "enable", "--now", rc=0, out="")
|
||||||
|
.on("netvm-node-up.sh", "dev", rc=0,
|
||||||
|
out="no handshake yet (endpoint=162.159.192.1)\n"
|
||||||
|
"node=dev netns=warp-dev egress=unknown")
|
||||||
|
.on("curl", rc=7, out="curl: (7) couldn't connect"))
|
||||||
|
with mock.patch.object(super_cli, "probe_cdp_status",
|
||||||
|
return_value={"ok": False, "error": "refused",
|
||||||
|
"latency_ms": None}):
|
||||||
|
code, out = self.run_heal()
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
data = json.loads(out)
|
||||||
|
self.assertEqual(data["verdict"], "DOWN")
|
||||||
|
self.assertFalse(data["ok"])
|
||||||
|
by_step = {s["step"]: s for s in data["steps"]}
|
||||||
|
self.assertFalse(by_step["tunnel"]["ok"])
|
||||||
|
self.assertFalse(by_step["egress"]["ok"])
|
||||||
|
|
||||||
|
def test_heal_down_prints_identity_guidance(self):
|
||||||
|
(self.systemd / "chromebox-watchdog-dev.timer").write_text("x")
|
||||||
|
(self.sh
|
||||||
|
.on("systemctl", rc=0, out="")
|
||||||
|
.on("netvm-node-up.sh", rc=0, out="node=dev egress=unknown")
|
||||||
|
.on("curl", rc=7, out="fail"))
|
||||||
|
with mock.patch.object(super_cli, "probe_cdp_status",
|
||||||
|
return_value={"ok": False, "error": "x",
|
||||||
|
"latency_ms": None}):
|
||||||
|
code, out = self.run_heal(as_json=False)
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertIn("netvm-new-identity.sh dev", out)
|
||||||
|
|
||||||
|
def test_heal_installs_missing_timer(self):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake(cmd, timeout=120, input_text=None):
|
||||||
|
blob = " ".join(cmd)
|
||||||
|
self.sh.calls.append((list(cmd), timeout, input_text))
|
||||||
|
if "tee" in blob:
|
||||||
|
seen["tee_target"] = cmd[-1]
|
||||||
|
seen["tee_input"] = input_text
|
||||||
|
return 0, ""
|
||||||
|
if "daemon-reload" in blob:
|
||||||
|
seen["reload"] = True
|
||||||
|
return 0, ""
|
||||||
|
if "enable" in blob:
|
||||||
|
return 0, ""
|
||||||
|
if "netvm-node-up.sh" in blob:
|
||||||
|
return 0, "node=dev egress=1.2.3.4"
|
||||||
|
if "curl" in blob:
|
||||||
|
return 0, "ip=1.2.3.4"
|
||||||
|
raise AssertionError("unexpected: %r" % (cmd,))
|
||||||
|
|
||||||
|
with mock.patch.object(super_cli, "_sh", fake):
|
||||||
|
with mock.patch.object(
|
||||||
|
super_cli, "probe_cdp_status",
|
||||||
|
return_value={"ok": True, "latency_ms": 3}):
|
||||||
|
code, _ = self.run_heal()
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(seen["tee_target"],
|
||||||
|
str(self.systemd / "chromebox-watchdog-dev.timer"))
|
||||||
|
self.assertIn("Unit=chromebox-watchdog@dev.service", seen["tee_input"])
|
||||||
|
self.assertTrue(seen["reload"])
|
||||||
|
|
||||||
|
def test_heal_clears_unwritable_lock(self):
|
||||||
|
(self.systemd / "chromebox-watchdog-dev.timer").write_text("x")
|
||||||
|
lock = self.locks / "chromebox-watchdog-dev.lock"
|
||||||
|
lock.write_text("")
|
||||||
|
lock.chmod(0o444)
|
||||||
|
self._healthy_sh()
|
||||||
|
self.sh.on("rm", str(lock), rc=0, out="")
|
||||||
|
with mock.patch.object(super_cli, "probe_cdp_status",
|
||||||
|
return_value={"ok": True, "latency_ms": 3}):
|
||||||
|
code, out = self.run_heal()
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
data = json.loads(out)
|
||||||
|
by_step = {s["step"]: s for s in data["steps"]}
|
||||||
|
self.assertTrue(by_step["lock"]["ok"])
|
||||||
|
self.assertIn("removed stale lock", by_step["lock"]["detail"])
|
||||||
|
self.assertTrue(any("rm" in " ".join(c[0]) for c in self.sh.calls))
|
||||||
|
|
||||||
|
def test_heal_rejects_unknown_node(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_fleet_heal(_ns(node="ghost"))
|
||||||
|
self.assertEqual(cm.exception.code, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ShHelperTests(unittest.TestCase):
|
||||||
|
def test_timeout_and_oserror(self):
|
||||||
|
import subprocess as real_subprocess
|
||||||
|
with mock.patch.object(super_cli.subprocess, "run",
|
||||||
|
side_effect=real_subprocess.TimeoutExpired("x", 1)):
|
||||||
|
self.assertEqual(super_cli._sh(["x"], timeout=1)[0], 124)
|
||||||
|
with mock.patch.object(super_cli.subprocess, "run",
|
||||||
|
side_effect=OSError("nope")):
|
||||||
|
self.assertEqual(super_cli._sh(["x"])[0], 127)
|
||||||
|
|
||||||
|
|
||||||
|
class WatchdogTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.sh = FakeSh()
|
||||||
|
self.sh_mock = mock.patch.object(super_cli, "_sh", self.sh)
|
||||||
|
self.sh_mock.start()
|
||||||
|
self.addCleanup(self.sh_mock.stop)
|
||||||
|
|
||||||
|
def _unit_states(self, missing=()):
|
||||||
|
def fake(cmd, timeout=120, input_text=None):
|
||||||
|
self.sh.calls.append((list(cmd), timeout, input_text))
|
||||||
|
unit = cmd[-1]
|
||||||
|
if "is-enabled" in cmd or "is-active" in cmd:
|
||||||
|
return (1, "") if unit in missing else (0, "")
|
||||||
|
if cmd[:3] == ["sudo", "systemctl", "start"]:
|
||||||
|
return 0, ""
|
||||||
|
raise AssertionError("unexpected: %r" % (cmd,))
|
||||||
|
return fake
|
||||||
|
|
||||||
|
def test_status_json(self):
|
||||||
|
stub = mock.Mock()
|
||||||
|
stub.collect.return_value = {
|
||||||
|
n: {"browser": "healthy", "browser_detail": "silent",
|
||||||
|
"cdp": "healthy", "cdp_detail": "silent"}
|
||||||
|
for n in super_cli.VALID_NODES}
|
||||||
|
with mock.patch.object(super_cli, "_sh", self._unit_states(
|
||||||
|
missing=("chromebox-watchdog-def.timer",))):
|
||||||
|
with mock.patch.dict(sys.modules, {"host_evidence": stub}):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
super_cli.cmd_watchdog_status(_ns(json=True))
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(len(data["nodes"]), 6)
|
||||||
|
by_node = {n["node"]: n for n in data["nodes"]}
|
||||||
|
self.assertFalse(by_node["def"]["enabled"])
|
||||||
|
self.assertFalse(by_node["def"]["active"])
|
||||||
|
self.assertTrue(by_node["dev"]["active"])
|
||||||
|
self.assertEqual(by_node["dev"]["browser"], "healthy")
|
||||||
|
self.assertIn("timer", data["relay"])
|
||||||
|
|
||||||
|
def test_status_survives_missing_evidence(self):
|
||||||
|
with mock.patch.object(super_cli, "_sh", self._unit_states()):
|
||||||
|
with mock.patch.dict(sys.modules, {"host_evidence": None}):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
super_cli.cmd_watchdog_status(_ns(json=True))
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["nodes"][0]["browser"], "unknown")
|
||||||
|
|
||||||
|
def test_run_node_and_relay(self):
|
||||||
|
for target, unit in (("dev", "chromebox-watchdog@dev.service"),
|
||||||
|
("relay", "cdp-relay-watchdog.service")):
|
||||||
|
with self.subTest(target=target):
|
||||||
|
with mock.patch.object(super_cli, "_sh",
|
||||||
|
self._unit_states()) as _:
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_watchdog_run(
|
||||||
|
_ns(target=target, json=True))
|
||||||
|
self.assertEqual(cm.exception.code, 0)
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["unit"], unit)
|
||||||
|
|
||||||
|
def test_run_rejects_bad_target(self):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_watchdog_run(_ns(target="ghost"))
|
||||||
|
self.assertEqual(cm.exception.code, 1)
|
||||||
|
|
||||||
|
def test_run_reports_start_failure(self):
|
||||||
|
def fake(cmd, timeout=120, input_text=None):
|
||||||
|
return 1, "Failed to start"
|
||||||
|
|
||||||
|
with mock.patch.object(super_cli, "_sh", fake):
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
super_cli.cmd_watchdog_run(_ns(target="dev", json=True))
|
||||||
|
self.assertEqual(cm.exception.code, 1)
|
||||||
|
self.assertFalse(json.loads(buf.getvalue())["ok"])
|
||||||
|
|
||||||
|
|
||||||
|
class ParserTests(unittest.TestCase):
|
||||||
|
def test_fleet_heal_parses(self):
|
||||||
|
args = super_cli.build_parser().parse_args(["fleet", "heal", "dev"])
|
||||||
|
self.assertEqual((args.domain, args.action, args.node),
|
||||||
|
("fleet", "heal", "dev"))
|
||||||
|
|
||||||
|
def test_watchdog_parses(self):
|
||||||
|
args = super_cli.build_parser().parse_args(["watchdog", "run", "relay"])
|
||||||
|
self.assertEqual((args.domain, args.action, args.target),
|
||||||
|
("watchdog", "run", "relay"))
|
||||||
|
args = super_cli.build_parser().parse_args(["watchdog"])
|
||||||
|
self.assertEqual((args.domain, args.action), ("watchdog", "status"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+134
-3
@@ -129,6 +129,17 @@ class DialogTests(unittest.TestCase):
|
|||||||
side_effect=RuntimeError("boom")):
|
side_effect=RuntimeError("boom")):
|
||||||
self.assertIsNone(dialog.dialog_text(mock.Mock()))
|
self.assertIsNone(dialog.dialog_text(mock.Mock()))
|
||||||
|
|
||||||
|
def test_dialog_text_rejects_non_string(self):
|
||||||
|
with mock.patch.object(dialog, "cdp_evaluate", return_value=123):
|
||||||
|
self.assertIsNone(dialog.dialog_text(mock.Mock()))
|
||||||
|
|
||||||
|
def test_describe_rows_rejects_non_list(self):
|
||||||
|
with mock.patch.object(dialog, "goto_tab", return_value=True), \
|
||||||
|
mock.patch.object(dialog, "cdp_evaluate",
|
||||||
|
return_value="error"):
|
||||||
|
self.assertEqual(dialog.describe_rows(mock.Mock(),
|
||||||
|
"Permissions"), [])
|
||||||
|
|
||||||
def test_click_row_success_and_no_match(self):
|
def test_click_row_success_and_no_match(self):
|
||||||
with mock.patch.object(dialog, "cdp_evaluate",
|
with mock.patch.object(dialog, "cdp_evaluate",
|
||||||
side_effect=["CLICKED", "CLICKED",
|
side_effect=["CLICKED", "CLICKED",
|
||||||
@@ -147,6 +158,11 @@ class DialogTests(unittest.TestCase):
|
|||||||
"Permissions"))
|
"Permissions"))
|
||||||
self.assertEqual(cdp.call_count, 1)
|
self.assertEqual(cdp.call_count, 1)
|
||||||
|
|
||||||
|
def test_row_js_excludes_tab_rail(self):
|
||||||
|
blob = json.dumps(dialog.TAB_NAMES)
|
||||||
|
self.assertIn(blob, dialog.JS_CLICK_ROW_TMPL)
|
||||||
|
self.assertIn(blob, dialog.JS_DESCRIBE_ROWS)
|
||||||
|
|
||||||
def test_describe_rows_maps(self):
|
def test_describe_rows_maps(self):
|
||||||
rows = [{"name": "Websites", "help": "1 site", "extra": ""}]
|
rows = [{"name": "Websites", "help": "1 site", "extra": ""}]
|
||||||
with mock.patch.object(dialog, "goto_tab", return_value=True), \
|
with mock.patch.object(dialog, "goto_tab", return_value=True), \
|
||||||
@@ -183,8 +199,8 @@ class ControlsTests(unittest.TestCase):
|
|||||||
on = [{"heading": "Connector defaults", "value": "auto_allow",
|
on = [{"heading": "Connector defaults", "value": "auto_allow",
|
||||||
"checked": True}]
|
"checked": True}]
|
||||||
with mock.patch.object(controls, "cdp_evaluate",
|
with mock.patch.object(controls, "cdp_evaluate",
|
||||||
side_effect=["CLICKED", off,
|
side_effect=["CLICKED"] + [off] * 10
|
||||||
{"x": 5, "y": 6}, on]), \
|
+ [{"x": 5, "y": 6}, on]), \
|
||||||
mock.patch.object(controls, "real_click") as click:
|
mock.patch.object(controls, "real_click") as click:
|
||||||
self.assertTrue(controls.set_radio_by_heading(
|
self.assertTrue(controls.set_radio_by_heading(
|
||||||
mock.Mock(), "Connector defaults", "auto_allow"))
|
mock.Mock(), "Connector defaults", "auto_allow"))
|
||||||
@@ -194,7 +210,8 @@ class ControlsTests(unittest.TestCase):
|
|||||||
off = [{"heading": "Connector defaults", "value": "auto_allow",
|
off = [{"heading": "Connector defaults", "value": "auto_allow",
|
||||||
"checked": False}]
|
"checked": False}]
|
||||||
with mock.patch.object(controls, "cdp_evaluate",
|
with mock.patch.object(controls, "cdp_evaluate",
|
||||||
side_effect=["CLICKED", off, None]), \
|
side_effect=["CLICKED"] + [off] * 10
|
||||||
|
+ [None]), \
|
||||||
mock.patch.object(controls, "real_click"):
|
mock.patch.object(controls, "real_click"):
|
||||||
self.assertFalse(controls.set_radio_by_heading(
|
self.assertFalse(controls.set_radio_by_heading(
|
||||||
mock.Mock(), "Connector defaults", "auto_allow"))
|
mock.Mock(), "Connector defaults", "auto_allow"))
|
||||||
@@ -234,6 +251,22 @@ class ControlsTests(unittest.TestCase):
|
|||||||
self.assertTrue(controls.set_switch(mock.Mock(),
|
self.assertTrue(controls.set_switch(mock.Mock(),
|
||||||
"Transparent proxy", True))
|
"Transparent proxy", True))
|
||||||
|
|
||||||
|
def test_set_switch_polls_then_lands(self):
|
||||||
|
off = [{"label": "Transparent proxy row", "aria": "",
|
||||||
|
"checked": False}]
|
||||||
|
on = [{"label": "Transparent proxy row", "aria": "",
|
||||||
|
"checked": True}]
|
||||||
|
with mock.patch.object(controls, "cdp_evaluate",
|
||||||
|
side_effect=[off, "CLICKED", off, off, on]):
|
||||||
|
self.assertTrue(controls.set_switch(mock.Mock(),
|
||||||
|
"Transparent proxy", True))
|
||||||
|
|
||||||
|
def test_listers_reject_wrong_types(self):
|
||||||
|
with mock.patch.object(controls, "cdp_evaluate",
|
||||||
|
return_value="error"):
|
||||||
|
self.assertEqual(controls.list_radios(mock.Mock()), [])
|
||||||
|
self.assertEqual(controls.list_switches(mock.Mock()), [])
|
||||||
|
|
||||||
|
|
||||||
class TogglesTests(unittest.TestCase):
|
class TogglesTests(unittest.TestCase):
|
||||||
def test_resolve_static_website_protocol(self):
|
def test_resolve_static_website_protocol(self):
|
||||||
@@ -248,6 +281,14 @@ class TogglesTests(unittest.TestCase):
|
|||||||
"permissions.protocols:Agent Skills endpoints")
|
"permissions.protocols:Agent Skills endpoints")
|
||||||
self.assertEqual(spec["label"], "Agent Skills endpoints")
|
self.assertEqual(spec["label"], "Agent Skills endpoints")
|
||||||
|
|
||||||
|
def test_resolve_protocol_network_and_substring(self):
|
||||||
|
spec = toggles.resolve_toggle("permissions.protocols:outbound-ssh")
|
||||||
|
self.assertEqual(spec["label"], "Outbound SSH")
|
||||||
|
spec = toggles.resolve_toggle("permissions.protocols:ssh")
|
||||||
|
self.assertEqual(spec["label"], "Outbound SSH")
|
||||||
|
with self.assertRaises(toggles.MenuError):
|
||||||
|
toggles.resolve_toggle("permissions.protocols:mail")
|
||||||
|
|
||||||
def test_resolve_unknowns_raise_before_cdp(self):
|
def test_resolve_unknowns_raise_before_cdp(self):
|
||||||
for bad in ("nope", "", "permissions.websites:",
|
for bad in ("nope", "", "permissions.websites:",
|
||||||
"permissions.protocols:nope"):
|
"permissions.protocols:nope"):
|
||||||
@@ -315,6 +356,23 @@ class TogglesTests(unittest.TestCase):
|
|||||||
res = toggles.set_toggle("pip", "permissions.web_access",
|
res = toggles.set_toggle("pip", "permissions.web_access",
|
||||||
"always_ask")
|
"always_ask")
|
||||||
self.assertEqual((res["ok"], res["value"]), (True, "always_ask"))
|
self.assertEqual((res["ok"], res["value"]), (True, "always_ask"))
|
||||||
|
self.assertNotIn("readback_only", res)
|
||||||
|
|
||||||
|
def test_set_toggle_readback_only_success(self):
|
||||||
|
with mock.patch.object(toggles, "get_cdp_ws",
|
||||||
|
return_value=(mock.Mock(), {})), \
|
||||||
|
mock.patch.object(dialog, "open_settings",
|
||||||
|
return_value=True), \
|
||||||
|
mock.patch.object(dialog, "goto_tab", return_value=True), \
|
||||||
|
mock.patch.object(controls, "set_radio_by_heading",
|
||||||
|
return_value=False), \
|
||||||
|
mock.patch.object(toggles, "get_toggle",
|
||||||
|
return_value={"ok": True,
|
||||||
|
"value": "always_ask"}):
|
||||||
|
res = toggles.set_toggle("pip", "permissions.web_access",
|
||||||
|
"always_ask")
|
||||||
|
self.assertTrue(res["ok"])
|
||||||
|
self.assertTrue(res["readback_only"])
|
||||||
|
|
||||||
def test_set_toggle_readback_mismatch_fails(self):
|
def test_set_toggle_readback_mismatch_fails(self):
|
||||||
with mock.patch.object(toggles, "get_cdp_ws",
|
with mock.patch.object(toggles, "get_cdp_ws",
|
||||||
@@ -405,6 +463,15 @@ class TabContractTests(unittest.TestCase):
|
|||||||
"Model Context Protocol servers (SSE)")
|
"Model Context Protocol servers (SSE)")
|
||||||
self.assertEqual(permissions.resolve_protocol(
|
self.assertEqual(permissions.resolve_protocol(
|
||||||
"Agent Skills endpoints"), "Agent Skills endpoints")
|
"Agent Skills endpoints"), "Agent Skills endpoints")
|
||||||
|
self.assertEqual(permissions.resolve_protocol("outbound-ssh"),
|
||||||
|
"Outbound SSH")
|
||||||
|
self.assertEqual(permissions.resolve_protocol("Outbound SSH"),
|
||||||
|
"Outbound SSH")
|
||||||
|
self.assertEqual(permissions.resolve_protocol("ssh"),
|
||||||
|
"Outbound SSH")
|
||||||
|
self.assertEqual(permissions.resolve_protocol("tcp"),
|
||||||
|
"Other TCP connections")
|
||||||
|
self.assertIsNone(permissions.resolve_protocol("mail"))
|
||||||
self.assertIsNone(permissions.resolve_protocol("nope"))
|
self.assertIsNone(permissions.resolve_protocol("nope"))
|
||||||
self.assertIsNone(permissions.resolve_protocol(""))
|
self.assertIsNone(permissions.resolve_protocol(""))
|
||||||
self.assertIsNone(permissions.resolve_protocol(None))
|
self.assertIsNone(permissions.resolve_protocol(None))
|
||||||
@@ -415,12 +482,76 @@ class TabContractTests(unittest.TestCase):
|
|||||||
mock.Mock(), "x.com", "Sometimes"))
|
mock.Mock(), "x.com", "Sometimes"))
|
||||||
ev.assert_not_called()
|
ev.assert_not_called()
|
||||||
|
|
||||||
|
def test_set_website_noop_without_click(self):
|
||||||
|
rows = [{"host": "x.com", "mode": "Allow", "x": 1, "y": 2}]
|
||||||
|
with mock.patch.object(permissions, "_websites_raw",
|
||||||
|
return_value=rows), \
|
||||||
|
mock.patch.object(permissions, "_back_to_root",
|
||||||
|
return_value=True), \
|
||||||
|
mock.patch.object(permissions, "real_click") as click, \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
self.assertTrue(permissions.set_website_mode(
|
||||||
|
mock.Mock(), "x.com", "Allow"))
|
||||||
|
click.assert_not_called()
|
||||||
|
|
||||||
|
def test_set_website_absent_host_fails(self):
|
||||||
|
with mock.patch.object(permissions, "_websites_raw",
|
||||||
|
return_value=[]), \
|
||||||
|
mock.patch.object(permissions, "_back_to_root",
|
||||||
|
return_value=True), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
self.assertFalse(permissions.set_website_mode(
|
||||||
|
mock.Mock(), "y.com", "Ask"))
|
||||||
|
|
||||||
|
def test_set_website_ask_verifies_by_absence(self):
|
||||||
|
rows = [{"host": "x.com", "mode": "Allow", "x": 1, "y": 2}]
|
||||||
|
items = [{"text": "Allow"}, {"text": "Ask"}, {"text": "Deny"}]
|
||||||
|
with mock.patch.object(permissions, "_websites_raw",
|
||||||
|
return_value=rows), \
|
||||||
|
mock.patch.object(permissions, "_back_to_root",
|
||||||
|
return_value=True), \
|
||||||
|
mock.patch.object(permissions, "real_click"), \
|
||||||
|
mock.patch.object(permissions, "_eval",
|
||||||
|
side_effect=[items, "CLICKED", []]), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
self.assertTrue(permissions.set_website_mode(
|
||||||
|
mock.Mock(), "x.com", "Ask"))
|
||||||
|
|
||||||
|
def test_stable_rows_waits_for_agreement(self):
|
||||||
|
rows = [{"host": "x.com"}]
|
||||||
|
with mock.patch.object(permissions, "_eval",
|
||||||
|
side_effect=[None, rows, rows]), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
self.assertEqual(
|
||||||
|
permissions._stable_rows(mock.Mock(), "js"), rows)
|
||||||
|
|
||||||
|
def test_stable_rows_gives_up(self):
|
||||||
|
with mock.patch.object(permissions, "_eval", return_value=None), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
self.assertIsNone(
|
||||||
|
permissions._stable_rows(mock.Mock(), "js"))
|
||||||
|
|
||||||
def test_back_to_root_prefers_back_affordance(self):
|
def test_back_to_root_prefers_back_affordance(self):
|
||||||
with mock.patch.object(dialog, "go_back", return_value=True), \
|
with mock.patch.object(dialog, "go_back", return_value=True), \
|
||||||
mock.patch.object(dialog, "dialog_text",
|
mock.patch.object(dialog, "dialog_text",
|
||||||
return_value="Manage permissions\nrows"):
|
return_value="Manage permissions\nrows"):
|
||||||
self.assertTrue(permissions._back_to_root(mock.Mock()))
|
self.assertTrue(permissions._back_to_root(mock.Mock()))
|
||||||
|
|
||||||
|
def test_theme_values_match_live(self):
|
||||||
|
self.assertIn("avatar", general.THEME_VALUES)
|
||||||
|
self.assertNotIn("match", general.THEME_VALUES)
|
||||||
|
|
||||||
|
def test_data_controls_pinned_label(self):
|
||||||
|
self.assertEqual(data_controls.SWITCH_LABEL,
|
||||||
|
"Help improve our AI models")
|
||||||
|
sws = [{"label": "Something else", "aria": "", "checked": False},
|
||||||
|
{"label": "Help improve our AI models", "aria": "",
|
||||||
|
"checked": True}]
|
||||||
|
with mock.patch.object(dialog, "goto_tab", return_value=True), \
|
||||||
|
mock.patch.object(controls, "list_switches",
|
||||||
|
return_value=sws):
|
||||||
|
self.assertTrue(data_controls.ai_improvement(mock.Mock()))
|
||||||
|
|
||||||
def test_data_controls_single_switch(self):
|
def test_data_controls_single_switch(self):
|
||||||
one = [{"label": "Help improve the model", "aria": "",
|
one = [{"label": "Help improve the model", "aria": "",
|
||||||
"checked": True}]
|
"checked": True}]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -272,8 +272,8 @@ class TestPromptTUIIntegration(unittest.TestCase):
|
|||||||
target_text = sends[0]["text"]
|
target_text = sends[0]["text"]
|
||||||
|
|
||||||
h, w = self.tui.stdscr.getmaxyx()
|
h, w = self.tui.stdscr.getmaxyx()
|
||||||
modal_w = min(74, w - 6)
|
modal_w = min(84 if self.tui.modal in ("prompts", "history_search") else 74, w - 6)
|
||||||
modal_h = min(20, h - 4)
|
modal_h = min(22 if self.tui.modal in ("prompts", "history_search") else 20, h - 4)
|
||||||
top_y = (h - modal_h) // 2
|
top_y = (h - modal_h) // 2
|
||||||
left_x = (w - modal_w) // 2
|
left_x = (w - modal_w) // 2
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,17 @@ class TestRateLimitUserInteraction(unittest.TestCase):
|
|||||||
self.assertEqual(updated[0]["text"], "Earlier reply")
|
self.assertEqual(updated[0]["text"], "Earlier reply")
|
||||||
self.assertEqual(updated[1]["text"], "New uncommitted instruction")
|
self.assertEqual(updated[1]["text"], "New uncommitted instruction")
|
||||||
|
|
||||||
|
def test_history_payload_mentioning_rate_limits_not_falsely_flagged(self):
|
||||||
|
"""Chat history discussing rate limits or HTTP 429 does NOT place node into cooldown when rc == 0."""
|
||||||
|
msgs_about_rate_limits = [
|
||||||
|
{"role": "user", "text": "Are we hitting any rate limits or 429 errors?"},
|
||||||
|
{"role": "assistant", "text": "No rate limit reached, all queues are unthrottled and healthy."}
|
||||||
|
]
|
||||||
|
with patch.object(muse_tui_rl, "run_command_isolated", return_value=(0, json.dumps(msgs_about_rate_limits), "")):
|
||||||
|
self.tui.data._fetch_history("pip", "thread-chat")
|
||||||
|
self.assertFalse(self.tui.data.is_node_rate_limited("pip"))
|
||||||
|
self.assertEqual(len(self.tui.data.history_cache.get(("pip", "thread-chat"), [])), 2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Regression tests for the hyphenated swarm-prune branch.
|
||||||
|
|
||||||
|
`swarm-prune` was dispatched but had no handler branch, so it exited 0
|
||||||
|
with no output. It now mirrors `swarm prune` (preview counts without
|
||||||
|
--confirm, archive only with --confirm). These tests never pass
|
||||||
|
--confirm, so they never write.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BOX_CTL = REPO_ROOT / "bin" / "box-ctl.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _box_ctl(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(BOX_CTL), *args],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
class SwarmPruneTests(unittest.TestCase):
|
||||||
|
def test_hyphenated_prune_previews_without_confirm(self):
|
||||||
|
r = _box_ctl("swarm-prune")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertTrue(r.stdout.strip(), "hyphenated prune must emit JSON")
|
||||||
|
payload = json.loads(r.stdout)
|
||||||
|
self.assertEqual(payload["code"], "CONFIRM_REQUIRED")
|
||||||
|
self.assertIn("matching_count", payload["detail"])
|
||||||
|
self.assertEqual(payload["detail"]["stale_hours"], 6)
|
||||||
|
|
||||||
|
def test_hyphenated_matches_space_form(self):
|
||||||
|
hyphen = json.loads(_box_ctl("swarm-prune").stdout)
|
||||||
|
space = json.loads(_box_ctl("swarm", "prune").stdout)
|
||||||
|
self.assertEqual(hyphen["code"], "CONFIRM_REQUIRED")
|
||||||
|
self.assertEqual(space["code"], "CONFIRM_REQUIRED")
|
||||||
|
self.assertEqual(sorted(hyphen["detail"]),
|
||||||
|
sorted(space["detail"]))
|
||||||
|
|
||||||
|
def test_bad_stale_hours_falls_back_to_default(self):
|
||||||
|
r = _box_ctl("swarm-prune", "--stale-hours", "bogus")
|
||||||
|
payload = json.loads(r.stdout)
|
||||||
|
self.assertEqual(payload["code"], "CONFIRM_REQUIRED")
|
||||||
|
self.assertEqual(payload["detail"]["stale_hours"], 6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Watchdog coverage: every registry node is supervised.
|
||||||
|
|
||||||
|
Regression test for the dev/def outage (2026-10-06): both watchdogs
|
||||||
|
hardcoded the original four nodes (muse/pip/646/opm), so dev/def had no
|
||||||
|
browser supervision, no Warp-tunnel auto-recovery, and no relay
|
||||||
|
supervision. A dead tunnel paged CRITICAL partition alerts forever with
|
||||||
|
nothing acting on it.
|
||||||
|
|
||||||
|
Both scripts now resolve nodes/ports from the fleet registry
|
||||||
|
(bin/netvm-registry.py). These tests drive the real shell code sourced
|
||||||
|
with a LIB_ONLY guard (same pattern as test_agent_health.py).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CHROMEBOX_WD = REPO_ROOT / "bin" / "chromebox-watchdog.sh"
|
||||||
|
RELAY_WD = REPO_ROOT / "bin" / "cdp-relay-watchdog.sh"
|
||||||
|
|
||||||
|
# The fleet's pinned contract (also pinned in bin/netvm-names.sh).
|
||||||
|
EXPECTED_PORTS = {
|
||||||
|
"muse": 9410, "pip": 9420, "646": 9430,
|
||||||
|
"opm": 9440, "def": 9450, "dev": 9455,
|
||||||
|
}
|
||||||
|
EXPECTED_PEERS = {
|
||||||
|
"muse": "10.201.35.2", "pip": "10.201.87.2", "646": "10.201.202.2",
|
||||||
|
"opm": "10.201.157.2", "def": "10.201.66.2", "dev": "10.201.36.2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load(mod_name, rel_path):
|
||||||
|
spec = importlib.util.spec_from_file_location(mod_name, REPO_ROOT / rel_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _chromebox(profile, snippet):
|
||||||
|
prog = "set -- '%s'\nsource '%s'\n%s\n" % (profile, CHROMEBOX_WD, snippet)
|
||||||
|
env = {"PATH": "/usr/bin:/bin", "CHROMEBOX_WATCHDOG_LIB_ONLY": "1"}
|
||||||
|
return subprocess.run(["bash", "-c", prog], capture_output=True,
|
||||||
|
text=True, env=env, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _relay(snippet):
|
||||||
|
prog = "source '%s'\n%s\n" % (RELAY_WD, snippet)
|
||||||
|
env = {"PATH": "/usr/bin:/bin", "CDP_RELAY_WATCHDOG_LIB_ONLY": "1"}
|
||||||
|
return subprocess.run(["bash", "-c", prog], capture_output=True,
|
||||||
|
text=True, env=env, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryContract(unittest.TestCase):
|
||||||
|
def test_registry_matches_pinned_ports(self):
|
||||||
|
reg = _load("netvm_registry_cov", "bin/netvm-registry.py")
|
||||||
|
self.assertEqual(reg.active_nodes(), EXPECTED_PORTS)
|
||||||
|
for node, peer in EXPECTED_PEERS.items():
|
||||||
|
self.assertEqual(reg.peer_ip_for(node), peer)
|
||||||
|
|
||||||
|
|
||||||
|
class ChromeboxWatchdogPorts(unittest.TestCase):
|
||||||
|
def test_all_registry_nodes_resolve_to_pinned_ports(self):
|
||||||
|
for node, port in sorted(EXPECTED_PORTS.items()):
|
||||||
|
with self.subTest(node=node):
|
||||||
|
r = _chromebox(node, "echo \"PORT=$CDP_PORT\"")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
line = next((ln for ln in r.stdout.splitlines()
|
||||||
|
if ln.startswith("PORT=")), None)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
line, "no PORT line: stdout=%r stderr=%r"
|
||||||
|
% (r.stdout, r.stderr))
|
||||||
|
self.assertEqual(int(line.split("=", 1)[1]), port)
|
||||||
|
|
||||||
|
def test_unknown_profile_rejected(self):
|
||||||
|
r = _chromebox("ghost", "echo UNREACHABLE")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertIn("unknown profile", r.stderr)
|
||||||
|
self.assertNotIn("UNREACHABLE", r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class RelayWatchdogCoverage(unittest.TestCase):
|
||||||
|
def test_watched_nodes_covers_registry(self):
|
||||||
|
r = _relay("watched_nodes")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
nodes = set(r.stdout.split())
|
||||||
|
for node in EXPECTED_PORTS:
|
||||||
|
self.assertIn(node, nodes)
|
||||||
|
|
||||||
|
def test_relay_targets_use_pinned_ports(self):
|
||||||
|
for node, port in sorted(EXPECTED_PORTS.items()):
|
||||||
|
with self.subTest(node=node):
|
||||||
|
r = _relay("relay_target %s" % node)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertEqual(r.stdout.strip(),
|
||||||
|
"%s:%d" % (EXPECTED_PEERS[node], port))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user