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 subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||
BIN_DIR = REPO_ROOT / "bin"
|
||||
@@ -138,8 +140,12 @@ class TestMuseChatApiConnection(unittest.TestCase):
|
||||
def test_muse_chat_api_approvals_command(self):
|
||||
cmd = [sys.executable, str(BIN_DIR / "muse-chat-api.py"), "--account", "pip", "approvals"]
|
||||
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)
|
||||
else:
|
||||
self.assertIn("APPROVAL_NEEDED", r.stdout)
|
||||
|
||||
|
||||
|
||||
class TestApprovalsReplySafety(unittest.TestCase):
|
||||
@@ -242,6 +248,106 @@ class TestKeyApprovalsAndPasskey(unittest.TestCase):
|
||||
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__":
|
||||
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")):
|
||||
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):
|
||||
with mock.patch.object(dialog, "cdp_evaluate",
|
||||
side_effect=["CLICKED", "CLICKED",
|
||||
@@ -147,6 +158,11 @@ class DialogTests(unittest.TestCase):
|
||||
"Permissions"))
|
||||
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):
|
||||
rows = [{"name": "Websites", "help": "1 site", "extra": ""}]
|
||||
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",
|
||||
"checked": True}]
|
||||
with mock.patch.object(controls, "cdp_evaluate",
|
||||
side_effect=["CLICKED", off,
|
||||
{"x": 5, "y": 6}, on]), \
|
||||
side_effect=["CLICKED"] + [off] * 10
|
||||
+ [{"x": 5, "y": 6}, on]), \
|
||||
mock.patch.object(controls, "real_click") as click:
|
||||
self.assertTrue(controls.set_radio_by_heading(
|
||||
mock.Mock(), "Connector defaults", "auto_allow"))
|
||||
@@ -194,7 +210,8 @@ class ControlsTests(unittest.TestCase):
|
||||
off = [{"heading": "Connector defaults", "value": "auto_allow",
|
||||
"checked": False}]
|
||||
with mock.patch.object(controls, "cdp_evaluate",
|
||||
side_effect=["CLICKED", off, None]), \
|
||||
side_effect=["CLICKED"] + [off] * 10
|
||||
+ [None]), \
|
||||
mock.patch.object(controls, "real_click"):
|
||||
self.assertFalse(controls.set_radio_by_heading(
|
||||
mock.Mock(), "Connector defaults", "auto_allow"))
|
||||
@@ -234,6 +251,22 @@ class ControlsTests(unittest.TestCase):
|
||||
self.assertTrue(controls.set_switch(mock.Mock(),
|
||||
"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):
|
||||
def test_resolve_static_website_protocol(self):
|
||||
@@ -248,6 +281,14 @@ class TogglesTests(unittest.TestCase):
|
||||
"permissions.protocols: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):
|
||||
for bad in ("nope", "", "permissions.websites:",
|
||||
"permissions.protocols:nope"):
|
||||
@@ -315,6 +356,23 @@ class TogglesTests(unittest.TestCase):
|
||||
res = toggles.set_toggle("pip", "permissions.web_access",
|
||||
"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):
|
||||
with mock.patch.object(toggles, "get_cdp_ws",
|
||||
@@ -405,6 +463,15 @@ class TabContractTests(unittest.TestCase):
|
||||
"Model Context Protocol servers (SSE)")
|
||||
self.assertEqual(permissions.resolve_protocol(
|
||||
"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(""))
|
||||
self.assertIsNone(permissions.resolve_protocol(None))
|
||||
@@ -415,12 +482,76 @@ class TabContractTests(unittest.TestCase):
|
||||
mock.Mock(), "x.com", "Sometimes"))
|
||||
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):
|
||||
with mock.patch.object(dialog, "go_back", return_value=True), \
|
||||
mock.patch.object(dialog, "dialog_text",
|
||||
return_value="Manage permissions\nrows"):
|
||||
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):
|
||||
one = [{"label": "Help improve the model", "aria": "",
|
||||
"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"]
|
||||
|
||||
h, w = self.tui.stdscr.getmaxyx()
|
||||
modal_w = min(74, w - 6)
|
||||
modal_h = min(20, h - 4)
|
||||
modal_w = min(84 if self.tui.modal in ("prompts", "history_search") else 74, w - 6)
|
||||
modal_h = min(22 if self.tui.modal in ("prompts", "history_search") else 20, h - 4)
|
||||
top_y = (h - modal_h) // 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[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__":
|
||||
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