PRD: SQLite local storage #320

Open
didericis-codex wants to merge 10 commits from sqlite-local-storage into main
23 changed files with 212 additions and 270 deletions
Showing only changes of commit 29904609da - Show all commits
+2 -2
View File
@@ -18,7 +18,7 @@
# /git-gate-entrypoint.sh docker-cp'd at start time # /git-gate-entrypoint.sh docker-cp'd at start time
# /git-gate/creds/* docker-cp'd at start time # /git-gate/creds/* docker-cp'd at start time
# /git/* bare repos, populated at runtime # /git/* bare repos, populated at runtime
# /run/supervise/queue/ bind-mounted at run time # /run/supervise/bot-bottle.db bind-mounted at run time
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
# #
# Exposed ports inside the container: # Exposed ports inside the container:
@@ -81,7 +81,7 @@ RUN mkdir -p \
/etc/git-gate \ /etc/git-gate \
/git-gate/creds \ /git-gate/creds \
/git \ /git \
/run/supervise/queue \ /run/supervise \
/home/mitmproxy/.mitmproxy /home/mitmproxy/.mitmproxy
# Documentation only — the compose renderer publishes whichever # Documentation only — the compose renderer publishes whichever
-9
View File
@@ -35,7 +35,6 @@ from ...git_gate import GIT_GATE_HOSTNAME
from ...log import die, warn from ...log import die, warn
from ...supervise import ( from ...supervise import (
DB_PATH_IN_CONTAINER, DB_PATH_IN_CONTAINER,
QUEUE_DIR_IN_CONTAINER,
SUPERVISE_HOSTNAME, SUPERVISE_HOSTNAME,
SUPERVISE_PORT, SUPERVISE_PORT,
) )
@@ -165,7 +164,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
] ]
volumes.append({ volumes.append({
@@ -174,13 +172,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"target": DB_PATH_IN_CONTAINER, "target": DB_PATH_IN_CONTAINER,
"read_only": False, "read_only": False,
}) })
volumes.append({
"type": "bind",
"source": str(sp.queue_dir),
"target": QUEUE_DIR_IN_CONTAINER,
"read_only": False,
})
internal_aliases = [EGRESS_HOSTNAME] internal_aliases = [EGRESS_HOSTNAME]
if gp.upstreams: if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME) internal_aliases.append(GIT_GATE_HOSTNAME)
+1 -3
View File
@@ -33,7 +33,7 @@ from ...git_gate import (
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import die, info, warn from ...log import die, info, warn
from ...supervise import DB_PATH_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
from ..docker.git_gate import ( from ..docker.git_gate import (
@@ -380,7 +380,6 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
] ]
return tuple(env) return tuple(env)
@@ -407,7 +406,6 @@ def _sidecar_mounts(
sp = plan.supervise_plan sp = plan.supervise_plan
if sp is not None: if sp is not None:
mounts.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False)) mounts.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False))
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
return tuple(mounts) return tuple(mounts)
+1 -3
View File
@@ -27,7 +27,7 @@ from ...egress import (
egress_resolve_token_values, egress_resolve_token_values,
egress_sidecar_env_entries, egress_sidecar_env_entries,
) )
from ...supervise import DB_PATH_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.egress import ( from ..docker.egress import (
@@ -370,11 +370,9 @@ def _bundle_launch_spec(
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
] ]
volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False)) volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False))
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest — # Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI + # published on host loopback so the guest can dial via TSI +
+2 -3
View File
@@ -284,9 +284,8 @@ def git_gate_state_dir(identity: str) -> Path:
def supervise_state_dir(identity: str) -> Path: def supervise_state_dir(identity: str) -> Path:
"""State subdir reserved for supervise sidecar bind-mount sources. """State subdir reserved for supervise sidecar bind-mount sources.
The queue dir is intentionally NOT under here — it lives at Runtime queue/audit rows live in the host-level bot-bottle SQLite
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it database, so they survive state-dir cleanup."""
survives state-dir cleanup."""
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
+9 -16
View File
@@ -45,7 +45,7 @@ from ..supervise import (
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
list_pending_proposals, list_all_pending_proposals,
render_diff, render_diff,
write_audit_entry, write_audit_entry,
write_response, write_response,
@@ -63,10 +63,9 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
@dataclass(frozen=True) @dataclass(frozen=True)
class QueuedProposal: class QueuedProposal:
"""A pending proposal plus the queue dir it was found in.""" """A pending proposal from the supervise queue."""
proposal: Proposal proposal: Proposal
queue_dir: Path
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
@@ -86,16 +85,11 @@ def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
"""Walk ~/.bot-bottle/queue/* and collect pending proposals.""" """Collect pending proposals across bottles."""
queue_root = _supervise.bot_bottle_root() / "queue" out = [
if not queue_root.is_dir(): QueuedProposal(proposal=proposal)
return [] for proposal in list_all_pending_proposals()
out: list[QueuedProposal] = [] ]
for slug_dir in sorted(queue_root.iterdir()):
if not slug_dir.is_dir():
continue
for proposal in list_pending_proposals(slug_dir):
out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir))
out.sort(key=lambda q: q.proposal.arrival_timestamp) out.sort(key=lambda q: q.proposal.arrival_timestamp)
return out return out
@@ -118,7 +112,6 @@ def _detail_lines(
(f"tool: {p.tool}", 0), (f"tool: {p.tool}", 0),
(f"id: {p.id}", 0), (f"id: {p.id}", 0),
(f"arrived: {p.arrival_timestamp}", 0), (f"arrived: {p.arrival_timestamp}", 0),
(f"queue: {qp.queue_dir}", 0),
("", 0), ("", 0),
("justification:", 0), ("justification:", 0),
] ]
@@ -165,7 +158,7 @@ def approve(
notes=notes, notes=notes,
final_file=final_file, final_file=final_file,
) )
write_response(qp.queue_dir, response) write_response(qp.proposal.bottle_slug, response)
_write_audit( _write_audit(
qp, action=status, notes=notes, qp, action=status, notes=notes,
diff_before=diff_before, diff_after=diff_after, diff_before=diff_before, diff_after=diff_after,
@@ -179,7 +172,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
notes=reason, notes=reason,
final_file=None, final_file=None,
) )
write_response(qp.queue_dir, response) write_response(qp.proposal.bottle_slug, response)
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
+6 -9
View File
@@ -79,14 +79,13 @@ class EgressAddon:
# only — a restart re-prompts. Mutated only from the asyncio loop that # only — a restart re-prompts. Mutated only from the asyncio loop that
# runs the addon hooks, so no lock is needed. # runs the addon hooks, so no lock is needed.
self.safe_tokens: set[str] = set() self.safe_tokens: set[str] = set()
self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip()
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip() self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True) self._reload(initial=True)
self._install_sighup() self._install_sighup()
def _supervise_available(self) -> bool: def _supervise_available(self) -> bool:
return bool(self._supervise_queue_dir and self._supervise_slug) return bool(self._supervise_slug)
def _reload(self, *, initial: bool = False) -> None: def _reload(self, *, initial: bool = False) -> None:
try: try:
@@ -393,9 +392,8 @@ class EgressAddon:
justification=_TOKEN_ALLOW_JUSTIFICATION, justification=_TOKEN_ALLOW_JUSTIFICATION,
current_file_hash=_sv.sha256_hex(payload), current_file_hash=_sv.sha256_hex(payload),
) )
queue_dir = Path(self._supervise_queue_dir)
try: try:
_sv.write_proposal(queue_dir, proposal) _sv.write_proposal(proposal)
except OSError as e: except OSError as e:
sys.stderr.write( sys.stderr.write(
f"egress: could not queue token-allow proposal: {e}; " f"egress: could not queue token-allow proposal: {e}; "
@@ -411,8 +409,8 @@ class EgressAddon:
**self._req_ctx(flow), **self._req_ctx(flow),
}) + "\n") }) + "\n")
response = await self._await_token_response(queue_dir, proposal.id) response = await self._await_token_response(proposal.id)
_sv.archive_proposal(queue_dir, proposal.id) _sv.archive_proposal(self._supervise_slug, proposal.id)
if response is not None and response.status in ( if response is not None and response.status in (
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED, _sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
@@ -439,16 +437,15 @@ class EgressAddon:
async def _await_token_response( async def _await_token_response(
self, self,
queue_dir: Path,
proposal_id: str, proposal_id: str,
) -> "_sv.Response | None": ) -> "_sv.Response | None":
"""Poll the queue dir for the operator's response without blocking the """Poll the DB for the operator's response without blocking the
proxy event loop. Returns the Response, or None on timeout.""" proxy event loop. Returns the Response, or None on timeout."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
deadline = loop.time() + self._token_allow_timeout deadline = loop.time() + self._token_allow_timeout
while True: while True:
try: try:
return _sv.read_response(queue_dir, proposal_id) return _sv.read_response(self._supervise_slug, proposal_id)
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
# Not written yet, or a partial/malformed write — retry until # Not written yet, or a partial/malformed write — retry until
# the deadline, then fail closed. # the deadline, then fail closed.
+8 -10
View File
@@ -239,9 +239,8 @@ from pathlib import Path
from bot_bottle import supervise as _sv from bot_bottle import supervise as _sv
report_path = Path(sys.argv[1]) report_path = Path(sys.argv[1])
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not queue_dir or not slug: if not slug:
sys.exit(2) sys.exit(2)
try: try:
@@ -289,7 +288,7 @@ proposal = _sv.Proposal.new(
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(), current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
now=datetime.datetime.now(datetime.timezone.utc), now=datetime.datetime.now(datetime.timezone.utc),
) )
_sv.write_proposal(Path(queue_dir), proposal) _sv.write_proposal(proposal)
print(proposal.id) print(proposal.id)
didericis marked this conversation as resolved
Review

Why do we still need a queue_dir if the queue is in the db?

Why do we still need a `queue_dir` if the queue is in the db?
PY PY
) )
@@ -303,7 +302,7 @@ PY
return 1 return 1
fi fi
queue_dir=${SUPERVISE_QUEUE_DIR:-} slug=${SUPERVISE_BOTTLE_SLUG:-}
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300} timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
case "$timeout" in case "$timeout" in
''|*[!0-9]*) ''|*[!0-9]*)
@@ -315,14 +314,14 @@ PY
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2 echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
waited=0 waited=0
while [ "$waited" -lt "$timeout" ]; do while [ "$waited" -lt "$timeout" ]; do
status=$(python3 - "$queue_dir" "$proposal_id" <<'PY' status=$(python3 - "$slug" "$proposal_id" <<'PY'
import sys import sys
from pathlib import Path
from bot_bottle import supervise as _sv from bot_bottle import supervise as _sv
slug = sys.argv[1]
try: try:
response = _sv.read_response(Path(sys.argv[1]), sys.argv[2]) response = _sv.read_response(slug, sys.argv[2])
except FileNotFoundError: except FileNotFoundError:
sys.exit(2) sys.exit(2)
print(response.status) print(response.status)
@@ -337,13 +336,12 @@ PY
if [ -n "$status" ]; then if [ -n "$status" ]; then
case "$status" in case "$status" in
approved|modified) approved|modified)
python3 - "$queue_dir" "$proposal_id" <<'PY' || true python3 - "$slug" "$proposal_id" <<'PY' || true
import sys import sys
from pathlib import Path
from bot_bottle import supervise as _sv from bot_bottle import supervise as _sv
_sv.archive_proposal(Path(sys.argv[1]), sys.argv[2]) _sv.archive_proposal(sys.argv[1], sys.argv[2])
PY PY
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2 echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
return 0 return 0
+59 -58
View File
@@ -9,15 +9,14 @@ calls when it needs an operator-reviewed egress change:
Each tool call: the agent passes the full proposed file plus a Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
writes it to the host's per-bottle queue dir, and holds the tool-call writes it to the host SQLite queue table, and holds the tool-call
connection open. The operator's supervise TUI connection open. The operator's supervise TUI
(bot_bottle.cli.supervise) sees the proposal, accepts (bot_bottle.cli.supervise) sees the proposal, accepts
approve / modify / reject, and writes a response file alongside the approve / modify / reject, and writes a response row. The sidecar sees
proposal. The sidecar sees the response and returns `{status, notes}` the response and returns `{status, notes}` to the agent.
to the agent.
This module defines the host-side library: dataclasses for the queue This module defines the host-side library: dataclasses for the queue
file shapes, queue read/write helpers, the audit log writer, and the record shapes, queue read/write helpers, the audit log writer, and the
diff renderer. The in-container sidecar lives in diff renderer. The in-container sidecar lives in
bot_bottle/supervise_server.py; the supervise daemon's container bot_bottle/supervise_server.py; the supervise daemon's container
lifecycle is owned by the sidecar bundle (PRD 0024). lifecycle is owned by the sidecar bundle (PRD 0024).
@@ -86,7 +85,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
# `routes edit <bottle>` verb writes entries with this action. # `routes edit <bottle>` verb writes entries with this action.
ACTION_OPERATOR_EDIT = "operator-edit" ACTION_OPERATOR_EDIT = "operator-edit"
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db" DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db"
DEFAULT_POLL_INTERVAL_SEC = 0.5 DEFAULT_POLL_INTERVAL_SEC = 0.5
HOST_DB_FILENAME = "bot-bottle.db" HOST_DB_FILENAME = "bot-bottle.db"
1
@@ -99,10 +97,6 @@ def bot_bottle_root() -> Path:
return Path.home() / ".bot-bottle" return Path.home() / ".bot-bottle"
def queue_dir_for_slug(slug: str) -> Path:
return bot_bottle_root() / "queue" / slug
def audit_dir() -> Path: def audit_dir() -> Path:
return bot_bottle_root() / "audit" return bot_bottle_root() / "audit"
@@ -115,8 +109,7 @@ def host_db_path() -> Path:
return bot_bottle_root() / HOST_DB_FILENAME return bot_bottle_root() / HOST_DB_FILENAME
def queue_db_path(queue_dir: Path) -> Path: def queue_db_path() -> Path:
Outdated
Review

Don't think this should be a separate db path: we want to indicate that there's a single db on the host that's used for this. I also don't think we want to make it possible to have a separate supervisor db with an env var: it'll likely be important to have supervisor proposals and running bottles accessible to the same query for creating a good dashboard UI.

Don't think this should be a separate db path: we want to indicate that there's a single db on the host that's used for this. I also don't think we want to make it possible to have a separate supervisor db with an env var: it'll likely be important to have supervisor proposals and running bottles accessible to the same query for creating a good dashboard UI.
del queue_dir
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip() env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
return Path(env_path) if env_path else host_db_path() return Path(env_path) if env_path else host_db_path()
@@ -126,9 +119,7 @@ def queue_db_path(queue_dir: Path) -> Path:
@dataclass(frozen=True) @dataclass(frozen=True)
class Proposal: class Proposal:
"""One pending tool-call from the agent. The sidecar writes one """One pending tool-call from the agent."""
of these to the queue dir on a tool call; the operator's TUI
reads them; the sidecar polls for a matching Response."""
id: str id: str
bottle_slug: str bottle_slug: str
@@ -182,7 +173,7 @@ class Proposal:
@dataclass(frozen=True) @dataclass(frozen=True)
class Response: class Response:
"""The operator's decision on a proposal. The TUI writes one of """The operator's decision on a proposal. The TUI writes one of
these to the queue dir; the sidecar reads it and returns the these to the queue table; the sidecar reads it and returns the
`{status, notes}` pair to the agent's tool call. `{status, notes}` pair to the agent's tool call.
`final_file` carries the file content the supervisor will `final_file` carries the file content the supervisor will
@@ -238,33 +229,38 @@ class AuditEntry:
# --- Queue I/O ------------------------------------------------------------- # --- Queue I/O -------------------------------------------------------------
def write_proposal(queue_dir: Path, proposal: Proposal) -> Path: def write_proposal(proposal: Proposal) -> Path:
"""Persist `proposal` in the queue database, mode 0o600. """Persist `proposal` in the queue database, mode 0o600.
Directory is created if missing.""" Directory is created if missing."""
return _QueueStore(queue_dir).write_proposal(proposal) return _QueueStore(proposal.bottle_slug).write_proposal(proposal)
def read_proposal(queue_dir: Path, proposal_id: str) -> Proposal: def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal:
return _QueueStore(queue_dir).read_proposal(proposal_id) return _QueueStore(bottle_slug).read_proposal(proposal_id)
def list_pending_proposals(queue_dir: Path) -> list[Proposal]: def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
"""All proposals in `queue_dir` that do not yet have a matching """All proposals for `bottle_slug` that do not yet have a matching
response. Sorted by `arrival_timestamp` so the operator response. Sorted by `arrival_timestamp` so the operator
sees the queue FIFO.""" sees the queue FIFO."""
return _QueueStore(queue_dir).list_pending_proposals() return _QueueStore(bottle_slug).list_pending_proposals()
def write_response(queue_dir: Path, response: Response) -> Path: def list_all_pending_proposals() -> list[Proposal]:
return _QueueStore(queue_dir).write_response(response) """All pending proposals across bottles, sorted FIFO."""
return _QueueStore("").list_all_pending_proposals()
def read_response(queue_dir: Path, proposal_id: str) -> Response: def write_response(bottle_slug: str, response: Response) -> Path:
return _QueueStore(queue_dir).read_response(proposal_id) return _QueueStore(bottle_slug).write_response(response)
def read_response(bottle_slug: str, proposal_id: str) -> Response:
return _QueueStore(bottle_slug).read_response(proposal_id)
def wait_for_response( def wait_for_response(
queue_dir: Path, bottle_slug: str,
proposal_id: str, proposal_id: str,
*, *,
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC, poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
@@ -276,7 +272,7 @@ def wait_for_response(
natural shape, since the operator's response time is unbounded. natural shape, since the operator's response time is unbounded.
Polls SQLite so the implementation stays portable and stdlib-only.""" Polls SQLite so the implementation stays portable and stdlib-only."""
store = _QueueStore(queue_dir) store = _QueueStore(bottle_slug)
while True: while True:
try: try:
return store.read_response(proposal_id) return store.read_response(proposal_id)
@@ -287,10 +283,10 @@ def wait_for_response(
time.sleep(poll_interval) time.sleep(poll_interval)
def archive_proposal(queue_dir: Path, proposal_id: str) -> None: def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
"""Mark both proposal and response rows processed. """Mark both proposal and response rows processed.
Idempotent missing rows are silently skipped.""" Idempotent missing rows are silently skipped."""
_QueueStore(queue_dir).archive_proposal(proposal_id) _QueueStore(bottle_slug).archive_proposal(proposal_id)
# --- Audit log ------------------------------------------------------------- # --- Audit log -------------------------------------------------------------
@@ -333,9 +329,9 @@ def sha256_hex(content: str) -> str:
class _QueueStore: class _QueueStore:
def __init__(self, queue_dir: Path) -> None: def __init__(self, queue_key: str) -> None:
self.queue_key = _queue_key(queue_dir) self.queue_key = queue_key
self.db_path = queue_db_path(queue_dir) self.db_path = queue_db_path()
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init() self._init()
@@ -396,6 +392,25 @@ class _QueueStore:
).fetchall() ).fetchall()
return [_proposal_from_row(row) for row in rows] return [_proposal_from_row(row) for row in rows]
def list_all_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file():
return []
with self._connect() as conn:
rows = conn.execute(
"""
SELECT p.* FROM supervise_proposals p
WHERE p.archived = 0
AND NOT EXISTS (
SELECT 1 FROM supervise_responses r
WHERE r.queue_key = p.queue_key
AND r.proposal_id = p.id
AND r.archived = 0
)
ORDER BY p.arrival_timestamp, p.id
"""
).fetchall()
return [_proposal_from_row(row) for row in rows]
def write_response(self, response: Response) -> Path: def write_response(self, response: Response) -> Path:
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
1
@@ -597,13 +612,6 @@ def _audit_entry_from_row(row: sqlite3.Row) -> AuditEntry:
) )
def _queue_key(queue_dir: Path) -> str:
env_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
if env_slug:
return env_slug
return queue_dir.name
# --- Sidecar plan + abstract lifecycle ------------------------------------- # --- Sidecar plan + abstract lifecycle -------------------------------------
@@ -611,39 +619,33 @@ def _queue_key(queue_dir: Path) -> str:
class SupervisePlan: class SupervisePlan:
"""Output of Supervise.prepare; consumed by .start. """Output of Supervise.prepare; consumed by .start.
`queue_dir` is the host directory bind-mounted into the sidecar `db_path` is the host database bind-mounted into the sidecar at
at /run/supervise/queue. `internal_network` is empty at prepare /run/supervise/bot-bottle.db. `internal_network` is empty at
time; the backend's launch step fills it via dataclasses.replace prepare time; the backend's launch step fills it via
before calling .start.""" dataclasses.replace before calling .start."""
slug: str slug: str
queue_dir: Path
db_path: Path db_path: Path
internal_network: str = "" internal_network: str = ""
class Supervise(ABC): class Supervise(ABC):
"""Per-bottle supervise sidecar. Encapsulates the host-side """Per-bottle supervise sidecar. Encapsulates host-side database
prepare (queue dir staging); the sidecar's start/stop lifecycle staging; the sidecar's start/stop lifecycle is backend-specific."""
is backend-specific."""
def prepare( def prepare(
self, self,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
) -> SupervisePlan: ) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host. Returns the """Stage the host database. Returns the plan; `internal_network`
plan; `internal_network` must be set by the launch step before must be set by the launch step before .start runs."""
.start runs."""
del stage_dir del stage_dir
queue_dir = queue_dir_for_slug(slug)
queue_dir.mkdir(parents=True, exist_ok=True)
db_path = host_db_path() db_path = host_db_path()
_QueueStore(queue_dir) _QueueStore(slug)
_AuditStore(db_path) _AuditStore(db_path)
return SupervisePlan( return SupervisePlan(
slug=slug, slug=slug,
queue_dir=queue_dir,
db_path=db_path, db_path=db_path,
) )
@@ -664,7 +666,6 @@ __all__ = [
"DEFAULT_POLL_INTERVAL_SEC", "DEFAULT_POLL_INTERVAL_SEC",
"DB_PATH_IN_CONTAINER", "DB_PATH_IN_CONTAINER",
"Proposal", "Proposal",
"QUEUE_DIR_IN_CONTAINER",
"Response", "Response",
"STATUSES", "STATUSES",
"STATUS_APPROVED", "STATUS_APPROVED",
@@ -688,8 +689,8 @@ __all__ = [
"bot_bottle_root", "bot_bottle_root",
"host_db_path", "host_db_path",
"list_pending_proposals", "list_pending_proposals",
"list_all_pending_proposals",
"queue_db_path", "queue_db_path",
"queue_dir_for_slug",
"read_audit_entries", "read_audit_entries",
"read_proposal", "read_proposal",
"read_response", "read_response",
+9 -17
View File
@@ -7,14 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
Each queued tool call: Each queued tool call:
1. Validates the proposed file syntactically. 1. Validates the proposed file syntactically.
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from 2. Writes a Proposal to the host SQLite database.
the host's ~/.bot-bottle/queue/<slug>/). 3. Blocks polling for a matching Response row.
3. Blocks polling for a matching Response file.
4. Returns the operator's `{status, notes}` to the agent. 4. Returns the operator's `{status, notes}` to the agent.
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
container creation by the backend's start step). The queue dir comes container creation by the backend's start step). SUPERVISE_DB_PATH
from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`). points at the bind-mounted host database.
Speaks MCP over HTTP+JSON-RPC. Methods handled: Speaks MCP over HTTP+JSON-RPC. Methods handled:
@@ -42,7 +41,6 @@ import typing
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
try: try:
# Same-directory imports inside the bundle container; these files are # Same-directory imports inside the bundle container; these files are
@@ -277,7 +275,6 @@ def validate_proposed_file(tool: str, content: str) -> None:
@dataclass(frozen=True) @dataclass(frozen=True)
class ServerConfig: class ServerConfig:
bottle_slug: str bottle_slug: str
queue_dir: Path
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
@@ -376,7 +373,7 @@ def handle_tools_call(
current_file_hash=_sv.sha256_hex(proposed_file), current_file_hash=_sv.sha256_hex(proposed_file),
) )
try: try:
_sv.write_proposal(config.queue_dir, proposal) _sv.write_proposal(proposal)
except OSError as e: except OSError as e:
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
sys.stderr.write( sys.stderr.write(
@@ -387,7 +384,7 @@ def handle_tools_call(
deadline = time.monotonic() + config.response_timeout_seconds deadline = time.monotonic() + config.response_timeout_seconds
try: try:
response = _sv.wait_for_response( response = _sv.wait_for_response(
config.queue_dir, config.bottle_slug,
proposal.id, proposal.id,
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS, poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
deadline=deadline, deadline=deadline,
@@ -399,7 +396,7 @@ def handle_tools_call(
"isError": False, "isError": False,
} }
try: try:
_sv.archive_proposal(config.queue_dir, proposal.id) _sv.archive_proposal(config.bottle_slug, proposal.id)
except OSError as e: except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e raise _RpcInternalError(f"failed to archive proposal: {e}") from e
@@ -539,7 +536,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True daemon_threads = True
config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path()) config: ServerConfig = ServerConfig(bottle_slug="")
# --- Entry point ----------------------------------------------------------- # --- Entry point -----------------------------------------------------------
@@ -548,21 +545,18 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def serve( def serve(
*, *,
bottle_slug: str, bottle_slug: str,
queue_dir: Path,
port: int = _sv.SUPERVISE_PORT, port: int = _sv.SUPERVISE_PORT,
bind: str = "0.0.0.0", bind: str = "0.0.0.0",
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS, response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
) -> typing.NoReturn: ) -> typing.NoReturn:
queue_dir.mkdir(parents=True, exist_ok=True)
server = MCPServer((bind, port), MCPHandler) server = MCPServer((bind, port), MCPHandler)
server.config = ServerConfig( server.config = ServerConfig(
bottle_slug=bottle_slug, bottle_slug=bottle_slug,
queue_dir=queue_dir,
response_timeout_seconds=response_timeout_seconds, response_timeout_seconds=response_timeout_seconds,
) )
sys.stderr.write( sys.stderr.write(
f"supervise listening on {bind}:{port}; " f"supervise listening on {bind}:{port}; "
f"slug={bottle_slug!r}; queue={queue_dir}; " f"slug={bottle_slug!r}; "
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type] f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
) )
sys.stderr.flush() sys.stderr.flush()
@@ -581,7 +575,6 @@ def main(argv: list[str]) -> int:
if not bottle_slug: if not bottle_slug:
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n") sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
return 2 return 2
queue_dir = Path(os.environ.get("SUPERVISE_QUEUE_DIR", _sv.QUEUE_DIR_IN_CONTAINER))
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT))) port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0") bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
try: try:
@@ -591,7 +584,6 @@ def main(argv: list[str]) -> int:
return 2 return 2
serve( serve(
bottle_slug=bottle_slug, bottle_slug=bottle_slug,
queue_dir=queue_dir,
port=port, port=port,
bind=bind, bind=bind,
response_timeout_seconds=response_timeout_seconds, response_timeout_seconds=response_timeout_seconds,
+12 -17
View File
@@ -27,12 +27,10 @@ one-off persistence.
1. Supervise proposals and responses are persisted through SQLite. 1. Supervise proposals and responses are persisted through SQLite.
2. Audit entries are persisted through SQLite. 2. Audit entries are persisted through SQLite.
3. Existing public supervise helpers keep their current call shape where 3. Supervise queue helpers use the bottle slug / queue key instead of a queue
practical: `write_proposal`, `read_proposal`, `list_pending_proposals`, directory path.
`write_response`, `read_response`, `wait_for_response`, 4. The sidecar receives the host database mount across docker, smolmachines,
`archive_proposal`, `write_audit_entry`, and `read_audit_entries`. and macOS-container backends.
4. The sidecar queue mount still works across docker, smolmachines, and
macOS-container backends.
5. The implementation stays stdlib-only. 5. The implementation stays stdlib-only.
6. Unit tests cover queue round-trips, pending discovery, response waits, 6. Unit tests cover queue round-trips, pending discovery, response waits,
archive semantics, audit round-trips, and path creation. archive semantics, audit round-trips, and path creation.
2
@@ -57,11 +55,9 @@ Queue and audit state use the host-level local database:
The supervise sidecar receives that database as a writable bind mount at The supervise sidecar receives that database as a writable bind mount at
`/run/supervise/bot-bottle.db` and gets the path through `SUPERVISE_DB_PATH`. `/run/supervise/bot-bottle.db` and gets the path through `SUPERVISE_DB_PATH`.
The existing per-slug queue directory mount remains in place for compatibility No per-slug queue directory is mounted into the sidecar. This creates the shared
with the supervise sidecar contract and any adjacent tooling that still expects a host database that later forge/native lifecycle work can extend in separate
queue directory, but the active queue records live in the host database. This PRDs.
creates the shared host database that later forge/native lifecycle work can
extend in separate PRDs.
### Tables ### Tables
@@ -113,9 +109,8 @@ CREATE TABLE supervise_audit_entries (
### Compatibility ### Compatibility
The existing helper functions keep accepting `Path` arguments for queue The queue helpers take a bottle slug / queue key and perform equivalent
directories. Internally, they map the queue directory to a queue key and perform operations against `~/.bot-bottle/bot-bottle.db`:
equivalent operations against `~/.bot-bottle/bot-bottle.db`:
- `list_pending_proposals` returns non-archived proposals without a non-archived - `list_pending_proposals` returns non-archived proposals without a non-archived
response, sorted by arrival time. response, sorted by arrival time.
@@ -123,9 +118,9 @@ equivalent operations against `~/.bot-bottle/bot-bottle.db`:
moving files into `processed/`. moving files into `processed/`.
- `wait_for_response` keeps the current polling behavior but polls SQLite. - `wait_for_response` keeps the current polling behavior but polls SQLite.
The old path helpers (`queue_dir_for_slug`, `audit_dir`, `audit_log_path`) stay The old audit path helpers (`audit_dir`, `audit_log_path`) stay available for
available for compatibility. `audit_log_path` no longer describes the active compatibility. `audit_log_path` no longer describes the active storage location;
storage location; callers should use `read_audit_entries`. callers should use `read_audit_entries`.
## Implementation chunks ## Implementation chunks
-4
View File
@@ -107,7 +107,6 @@ def _egress_plan(
def _supervise_plan() -> SupervisePlan: def _supervise_plan() -> SupervisePlan:
return SupervisePlan( return SupervisePlan(
slug=SLUG, slug=SLUG,
queue_dir=STATE / "supervise" / "queue",
db_path=STATE / "bot-bottle.db", db_path=STATE / "bot-bottle.db",
internal_network=f"bot-bottle-net-{SLUG}", internal_network=f"bot-bottle-net-{SLUG}",
) )
@@ -394,7 +393,6 @@ class TestSidecarBundleShape(unittest.TestCase):
env_strings = sc["environment"] env_strings = sc["environment"]
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings) self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings) self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings)
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings)) self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
def test_volumes_always_includes_egress_ca(self): def test_volumes_always_includes_egress_ca(self):
@@ -411,8 +409,6 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate-entrypoint.sh", targets)
self.assertIn("/git-gate/creds/upstream-known_hosts", targets) self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
self.assertIn("/run/supervise/bot-bottle.db", targets) self.assertIn("/run/supervise/bot-bottle.db", targets)
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
for t in targets))
def test_extra_hosts_omitted_for_git_upstreams(self): def test_extra_hosts_omitted_for_git_upstreams(self):
sc = self._render(with_git=True)["services"]["sidecars"] sc = self._render(with_git=True)["services"]["sidecars"]
@@ -74,7 +74,6 @@ def _plan(
if supervise: if supervise:
supervise_plan = SupervisePlan( supervise_plan = SupervisePlan(
slug="demo-abc12", slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
db_path=Path("/tmp/bot-bottle.db"), db_path=Path("/tmp/bot-bottle.db"),
) )
return DockerBottlePlan( return DockerBottlePlan(
@@ -77,7 +77,6 @@ def _plan(
if supervise: if supervise:
supervise_plan = SupervisePlan( supervise_plan = SupervisePlan(
slug="demo-abc12", slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
db_path=Path("/tmp/bot-bottle.db"), db_path=Path("/tmp/bot-bottle.db"),
) )
return DockerBottlePlan( return DockerBottlePlan(
@@ -47,7 +47,6 @@ def _addon() -> EgressAddon:
a: EgressAddon = EgressAddon.__new__(EgressAddon) a: EgressAddon = EgressAddon.__new__(EgressAddon)
a.config = Config(routes=(), log=LOG_FULL) a.config = Config(routes=(), log=LOG_FULL)
a.safe_tokens = set() a.safe_tokens = set()
a._supervise_queue_dir = ""
a._supervise_slug = "" a._supervise_slug = ""
a._token_allow_timeout = 300.0 a._token_allow_timeout = 300.0
return a return a
+3 -6
View File
@@ -212,7 +212,6 @@ def _addon(config: Config) -> EgressAddon:
a: EgressAddon = EgressAddon.__new__(EgressAddon) a: EgressAddon = EgressAddon.__new__(EgressAddon)
a.config = config a.config = config
a.safe_tokens = set() a.safe_tokens = set()
a._supervise_queue_dir = ""
a._supervise_slug = "" a._supervise_slug = ""
a._token_allow_timeout = 300.0 a._token_allow_timeout = 300.0
a.routes_path = "/nonexistent/routes.yaml" a.routes_path = "/nonexistent/routes.yaml"
@@ -386,10 +385,10 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
def _sha256_hex(_payload: Any) -> str: def _sha256_hex(_payload: Any) -> str:
return "hash" return "hash"
def _noop(_a: Any, _b: Any) -> None: def _noop(*_args: Any) -> None:
return None return None
def _read_response(_qd: Any, _pid: Any) -> Any: def _read_response(_slug: Any, _pid: Any) -> Any:
if response_status is None: if response_status is None:
raise OSError("not written yet") # forces poll -> timeout raise OSError("not written yet") # forces poll -> timeout
return types.SimpleNamespace(status=response_status) return types.SimpleNamespace(status=response_status)
@@ -409,7 +408,6 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
class TestSuperviseBranch(unittest.TestCase): class TestSuperviseBranch(unittest.TestCase):
def _supervised_addon(self) -> EgressAddon: def _supervised_addon(self) -> EgressAddon:
addon = _addon(Config(routes=(Route(host="api.example.com"),))) addon = _addon(Config(routes=(Route(host="api.example.com"),)))
addon._supervise_queue_dir = "/tmp/egress-queue"
addon._supervise_slug = "test-bottle" addon._supervise_slug = "test-bottle"
addon._token_allow_timeout = 0.05 addon._token_allow_timeout = 0.05
return addon return addon
@@ -632,14 +630,13 @@ class TestRedactSurfaces(unittest.TestCase):
class TestSuperviseWriteFailure(unittest.TestCase): class TestSuperviseWriteFailure(unittest.TestCase):
def test_write_proposal_oserror_blocks(self) -> None: def test_write_proposal_oserror_blocks(self) -> None:
addon = _addon(Config(routes=(Route(host="api.example.com"),))) addon = _addon(Config(routes=(Route(host="api.example.com"),)))
addon._supervise_queue_dir = "/tmp/egress-queue"
addon._supervise_slug = "test-bottle" addon._supervise_slug = "test-bottle"
addon._token_allow_timeout = 0.05 addon._token_allow_timeout = 0.05
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")) flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
fake = _fake_sv("approved") fake = _fake_sv("approved")
def _raise(_qd: Any, _p: Any) -> None: def _raise(_p: Any) -> None:
raise OSError("disk full") raise OSError("disk full")
fake.write_proposal = _raise fake.write_proposal = _raise
-1
View File
@@ -213,7 +213,6 @@ class TestHookRender(unittest.TestCase):
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook) self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
self.assertIn("_sv.write_proposal", hook) self.assertIn("_sv.write_proposal", hook)
self.assertIn("_sv.read_response", hook) self.assertIn("_sv.read_response", hook)
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook) self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
self.assertIn("supervisor approved # gitleaks:allow", hook) self.assertIn("supervisor approved # gitleaks:allow", hook)
self.assertIn("supervisor rejected # gitleaks:allow", hook) self.assertIn("supervisor rejected # gitleaks:allow", hook)
@@ -72,7 +72,6 @@ def _plan(
git_gate_plan = SimpleNamespace(upstreams=()) git_gate_plan = SimpleNamespace(upstreams=())
supervise_plan = ( supervise_plan = (
SimpleNamespace( SimpleNamespace(
queue_dir=Path("/state/supervise/queue"),
db_path=Path("/state/bot-bottle.db"), db_path=Path("/state/bot-bottle.db"),
) )
if supervise else None if supervise else None
@@ -143,10 +142,6 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
"type=bind,source=/state/bot-bottle.db,target=/run/supervise/bot-bottle.db", "type=bind,source=/state/bot-bottle.db,target=/run/supervise/bot-bottle.db",
argv, argv,
) )
self.assertIn(
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
argv,
)
def test_sidecar_argv_registers_canary_env_as_sensitive(self): def test_sidecar_argv_registers_canary_env_as_sensitive(self):
plan = _plan(stage_dir=self.stage_dir, canary=True) plan = _plan(stage_dir=self.stage_dir, canary=True)
@@ -130,7 +130,6 @@ def _plan(
if supervise: if supervise:
supervise_plan = SupervisePlan( supervise_plan = SupervisePlan(
slug="demo-abc12", slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
db_path=Path("/tmp/bot-bottle.db"), db_path=Path("/tmp/bot-bottle.db"),
) )
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
+37 -27
View File
@@ -112,33 +112,44 @@ class TestResponseRoundtrip(unittest.TestCase):
class TestQueueIO(unittest.TestCase): class TestQueueIO(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.") self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.")
self.queue_dir = Path(self._tmp.name) self._home_patch = self._patch_home(Path(self._tmp.name))
self.slug = "dev"
def tearDown(self): def tearDown(self):
self._home_patch()
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
original = supervise.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
return lambda: setattr(supervise, "bot_bottle_root", original)
def test_write_and_read_proposal(self): def test_write_and_read_proposal(self):
p = _proposal() p = _proposal()
path = write_proposal(self.queue_dir, p) path = write_proposal(p)
self.assertTrue(path.exists()) self.assertTrue(path.exists())
self.assertEqual(queue_db_path(self.queue_dir), path) self.assertEqual(queue_db_path(), path)
self.assertEqual(0o600, path.stat().st_mode & 0o777) self.assertEqual(0o600, path.stat().st_mode & 0o777)
loaded = read_proposal(self.queue_dir, p.id) loaded = read_proposal(self.slug, p.id)
self.assertEqual(p, loaded) self.assertEqual(p, loaded)
def test_list_pending_excludes_responded(self): def test_list_pending_excludes_responded(self):
a = _proposal(justification="first") a = _proposal(justification="first")
b = _proposal(justification="second") b = _proposal(justification="second")
write_proposal(self.queue_dir, a) write_proposal(a)
write_proposal(self.queue_dir, b) write_proposal(b)
write_response(self.queue_dir, Response( write_response(self.slug, Response(
proposal_id=a.id, status=STATUS_APPROVED, notes="", proposal_id=a.id, status=STATUS_APPROVED, notes="",
)) ))
pending = list_pending_proposals(self.queue_dir) pending = list_pending_proposals(self.slug)
self.assertEqual([b.id], [p.id for p in pending]) self.assertEqual([b.id], [p.id for p in pending])
def test_list_pending_returns_empty_for_missing_dir(self): def test_list_pending_returns_empty_for_missing_slug(self):
self.assertEqual([], list_pending_proposals(self.queue_dir / "nope")) self.assertEqual([], list_pending_proposals("nope"))
def test_list_pending_sorted_by_arrival(self): def test_list_pending_sorted_by_arrival(self):
# Fabricate two with explicit timestamps. # Fabricate two with explicit timestamps.
@@ -155,30 +166,30 @@ class TestQueueIO(unittest.TestCase):
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
) )
# Write in reverse order. # Write in reverse order.
write_proposal(self.queue_dir, b) write_proposal(b)
write_proposal(self.queue_dir, a) write_proposal(a)
ordered = list_pending_proposals(self.queue_dir) ordered = list_pending_proposals(self.slug)
self.assertEqual([a.id, b.id], [p.id for p in ordered]) self.assertEqual([a.id, b.id], [p.id for p in ordered])
def test_write_and_read_response(self): def test_write_and_read_response(self):
r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no") r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no")
write_response(self.queue_dir, r) write_response(self.slug, r)
self.assertEqual(r, read_response(self.queue_dir, "xyz")) self.assertEqual(r, read_response(self.slug, "xyz"))
def test_wait_for_response_returns_when_file_appears(self): def test_wait_for_response_returns_when_file_appears(self):
p = _proposal() p = _proposal()
write_proposal(self.queue_dir, p) write_proposal(p)
def write_after_delay(): def write_after_delay():
time.sleep(0.05) time.sleep(0.05)
write_response(self.queue_dir, Response( write_response(self.slug, Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="ok", proposal_id=p.id, status=STATUS_APPROVED, notes="ok",
)) ))
t = threading.Thread(target=write_after_delay) t = threading.Thread(target=write_after_delay)
t.start() t.start()
try: try:
r = wait_for_response(self.queue_dir, p.id, poll_interval=0.01) r = wait_for_response(self.slug, p.id, poll_interval=0.01)
finally: finally:
t.join() t.join()
self.assertEqual(STATUS_APPROVED, r.status) self.assertEqual(STATUS_APPROVED, r.status)
@@ -188,24 +199,24 @@ class TestQueueIO(unittest.TestCase):
deadline = time.monotonic() + 0.05 deadline = time.monotonic() + 0.05
with self.assertRaises(TimeoutError): with self.assertRaises(TimeoutError):
wait_for_response( wait_for_response(
self.queue_dir, "never", self.slug, "never",
poll_interval=0.01, deadline=deadline, poll_interval=0.01, deadline=deadline,
) )
def test_archive_proposal_moves_both_files(self): def test_archive_proposal_hides_rows(self):
p = _proposal() p = _proposal()
write_proposal(self.queue_dir, p) write_proposal(p)
write_response(self.queue_dir, Response( write_response(self.slug, Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="", proposal_id=p.id, status=STATUS_APPROVED, notes="",
)) ))
archive_proposal(self.queue_dir, p.id) archive_proposal(self.slug, p.id)
self.assertEqual([], list_pending_proposals(self.queue_dir)) self.assertEqual([], list_pending_proposals(self.slug))
with self.assertRaises(FileNotFoundError): with self.assertRaises(FileNotFoundError):
read_response(self.queue_dir, p.id) read_response(self.slug, p.id)
def test_archive_is_idempotent_on_missing_files(self): def test_archive_is_idempotent_on_missing_files(self):
# Should not raise. # Should not raise.
archive_proposal(self.queue_dir, "nope") archive_proposal(self.slug, "nope")
class TestAuditLog(unittest.TestCase): class TestAuditLog(unittest.TestCase):
@@ -381,7 +392,6 @@ class TestSupervisePrepare(unittest.TestCase):
def test_prepare_creates_queue(self): def test_prepare_creates_queue(self):
plan = _StubSupervise().prepare("dev", self.stage_dir) plan = _StubSupervise().prepare("dev", self.stage_dir)
self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.db_path.is_file()) self.assertTrue(plan.db_path.is_file())
self.assertEqual("dev", plan.slug) self.assertEqual("dev", plan.slug)
self.assertEqual("", plan.internal_network) self.assertEqual("", plan.internal_network)
+15 -27
View File
@@ -77,9 +77,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
def test_walks_all_slug_subdirs(self): def test_walks_all_slug_subdirs(self):
for slug in ("dev", "api"): for slug in ("dev", "api"):
qdir = supervise.queue_dir_for_slug(slug) supervise.write_proposal(_proposal(slug=slug))
qdir.mkdir(parents=True)
supervise.write_proposal(qdir, _proposal(slug=slug))
pending = supervise_cli.discover_pending() pending = supervise_cli.discover_pending()
self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending}) self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending})
@@ -97,18 +95,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
) )
for p in (late, early): for p in (late, early):
qdir = supervise.queue_dir_for_slug(p.bottle_slug) supervise.write_proposal(p)
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
pending = supervise_cli.discover_pending() pending = supervise_cli.discover_pending()
self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending]) self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending])
def test_excludes_already_responded(self): def test_excludes_already_responded(self):
p = _proposal() p = _proposal()
qdir = supervise.queue_dir_for_slug("dev") supervise.write_proposal(p)
qdir.mkdir(parents=True) supervise.write_response("dev", supervise.Response(
supervise.write_proposal(qdir, p)
supervise.write_response(qdir, supervise.Response(
proposal_id=p.id, status=STATUS_APPROVED, notes="", proposal_id=p.id, status=STATUS_APPROVED, notes="",
)) ))
self.assertEqual([], supervise_cli.discover_pending()) self.assertEqual([], supervise_cli.discover_pending())
@@ -123,10 +117,8 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW): def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
p = _proposal(tool=tool) p = _proposal(tool=tool)
qdir = supervise.queue_dir_for_slug("dev") supervise.write_proposal(p)
qdir.mkdir(parents=True, exist_ok=True) return supervise_cli.QueuedProposal(proposal=p)
supervise.write_proposal(qdir, p)
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_approve_writes_response(self): def test_approve_writes_response(self):
qp = self._enqueue() qp = self._enqueue()
@@ -135,7 +127,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
return_value=("routes: []\n", "routes:\n - host: example.com\n"), return_value=("routes: []\n", "routes:\n - host: example.com\n"),
): ):
supervise_cli.approve(qp) supervise_cli.approve(qp)
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual(STATUS_APPROVED, resp.status)
self.assertIsNone(resp.final_file) self.assertIsNone(resp.final_file)
@@ -150,7 +142,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
final_file="routes:\n - host: edited.example.com\n", final_file="routes:\n - host: edited.example.com\n",
notes="tweaked", notes="tweaked",
) )
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual(STATUS_MODIFIED, resp.status)
self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file) self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file)
self.assertEqual("tweaked", resp.notes) self.assertEqual("tweaked", resp.notes)
@@ -158,7 +150,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_reject_writes_rejection(self): def test_reject_writes_rejection(self):
qp = self._enqueue() qp = self._enqueue()
supervise_cli.reject(qp, reason="nope") supervise_cli.reject(qp, reason="nope")
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual(STATUS_REJECTED, resp.status)
self.assertEqual("nope", resp.notes) self.assertEqual("nope", resp.notes)
@@ -181,36 +173,33 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def test_approve_gitleaks_allow_leaves_response_for_gate(self): def test_approve_gitleaks_allow_leaves_response_for_gate(self):
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
supervise_cli.approve(qp, notes="dummy fixture") supervise_cli.approve(qp, notes="dummy fixture")
# Gate polls the queue dir for the response; TUI must not archive it. # Gate polls the DB for the response; TUI must not archive it.
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual(STATUS_APPROVED, resp.status)
self.assertEqual("dummy fixture", resp.notes) self.assertEqual("dummy fixture", resp.notes)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_tui_gitleaks_allow_requires_reason(self): def test_tui_gitleaks_allow_requires_reason(self):
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
with patch.object(supervise_cli, "_prompt", return_value=""): with patch.object(supervise_cli, "_prompt", return_value=""):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertEqual("approve aborted (empty reason)", status) self.assertEqual("approve aborted (empty reason)", status)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_tui_gitleaks_allow_writes_reason(self): def test_tui_gitleaks_allow_writes_reason(self):
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW) qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
with patch.object(supervise_cli, "_prompt", return_value="test fixture"): with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertIn("approved gitleaks-allow", status) self.assertIn("approved gitleaks-allow", status)
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual("test fixture", resp.notes) self.assertEqual("test fixture", resp.notes)
def test_approve_token_allow_leaves_response_for_egress(self): def test_approve_token_allow_leaves_response_for_egress(self):
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW) qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
supervise_cli.approve(qp, notes="false positive") supervise_cli.approve(qp, notes="false positive")
# The egress addon polls the queue dir for the response; the TUI must # The egress addon polls the DB for the response; the TUI must
# not archive it (the addon archives after reading). # not archive it (the addon archives after reading).
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual(STATUS_APPROVED, resp.status)
self.assertEqual("false positive", resp.notes) self.assertEqual("false positive", resp.notes)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_token_allow_writes_no_audit_log(self): def test_token_allow_writes_no_audit_log(self):
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW) qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
@@ -222,14 +211,13 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
with patch.object(supervise_cli, "_prompt", return_value=""): with patch.object(supervise_cli, "_prompt", return_value=""):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertEqual("approve aborted (empty reason)", status) self.assertEqual("approve aborted (empty reason)", status)
self.assertFalse((qp.queue_dir / "processed").exists())
def test_tui_token_allow_writes_reason(self): def test_tui_token_allow_writes_reason(self):
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW) qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
with patch.object(supervise_cli, "_prompt", return_value="legit"): with patch.object(supervise_cli, "_prompt", return_value="legit"):
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type] status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
self.assertIn("approved egress-token-allow", status) self.assertIn("approved egress-token-allow", status)
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
self.assertEqual("legit", resp.notes) self.assertEqual("legit", resp.notes)
def test_suffix_for_token_allow_is_txt(self): def test_suffix_for_token_allow_is_txt(self):
+28 -34
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import tempfile import tempfile
import time import time
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle import supervise from bot_bottle import supervise
@@ -39,61 +38,56 @@ class TestPathHelpers(unittest.TestCase):
def test_bot_bottle_root(self) -> None: def test_bot_bottle_root(self) -> None:
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle")) self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
def test_queue_dir_for_slug(self) -> None: def test_queue_db_path_is_host_db_path(self) -> None:
self.assertIn("slug", str(supervise.queue_dir_for_slug("slug"))) self.assertEqual(supervise.host_db_path(), supervise.queue_db_path())
def test_queue_db_path_for_slug_dir(self) -> None:
self.assertEqual(
supervise.host_db_path(),
supervise.queue_db_path(Path("/tmp/queue")),
)
class TestReadMalformed(unittest.TestCase): class TestReadMalformed(unittest.TestCase):
def test_read_proposal_missing_row(self) -> None: def test_read_proposal_missing_row(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
with self.assertRaises(FileNotFoundError): with patch.dict("os.environ", {"HOME": d}), \
read_proposal(Path(d), "p") self.assertRaises(FileNotFoundError):
read_proposal("slug", "p")
def test_read_response_missing_row(self) -> None: def test_read_response_missing_row(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
with self.assertRaises(FileNotFoundError): with patch.dict("os.environ", {"HOME": d}), \
read_response(Path(d), "p") self.assertRaises(FileNotFoundError):
read_response("slug", "p")
def test_list_pending_ignores_legacy_json_files(self) -> None: def test_list_pending_reads_db_only(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
qd = Path(d) with patch.dict("os.environ", {"HOME": d}):
(qd / "bad.proposal.json").write_text("{ not json") supervise.write_proposal(_proposal())
(qd / "arr.proposal.json").write_text("[]") pending = list_pending_proposals("slug")
supervise.write_proposal(qd, _proposal()) # one valid
pending = list_pending_proposals(qd)
self.assertEqual(1, len(pending)) self.assertEqual(1, len(pending))
self.assertEqual("slug", pending[0].bottle_slug) self.assertEqual("slug", pending[0].bottle_slug)
def test_list_pending_skips_when_response_present(self) -> None: def test_list_pending_skips_when_response_present(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
qd = Path(d) with patch.dict("os.environ", {"HOME": d}):
p = _proposal() p = _proposal()
supervise.write_proposal(qd, p) supervise.write_proposal(p)
supervise.write_response(qd, supervise.Response( supervise.write_response("slug", supervise.Response(
proposal_id=p.id, proposal_id=p.id,
status=STATUS_APPROVED, status=STATUS_APPROVED,
notes="", notes="",
)) ))
self.assertEqual([], list_pending_proposals(qd)) self.assertEqual([], list_pending_proposals("slug"))
class TestWaitForResponse(unittest.TestCase): class TestWaitForResponse(unittest.TestCase):
def test_missing_response_times_out(self) -> None: def test_missing_response_times_out(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
with self.assertRaises(TimeoutError): with patch.dict("os.environ", {"HOME": d}), \
wait_for_response(Path(d), "p", deadline=time.monotonic()) self.assertRaises(TimeoutError):
wait_for_response("slug", "p", deadline=time.monotonic())
def test_legacy_response_file_does_not_count(self) -> None: def test_empty_db_response_does_not_count(self) -> None:
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
(Path(d) / "p.response.json").write_text("{}") # dict but from_dict raises with patch.dict("os.environ", {"HOME": d}), \
with self.assertRaises(TimeoutError): self.assertRaises(TimeoutError):
wait_for_response(Path(d), "p", deadline=time.monotonic()) wait_for_response("slug", "p", deadline=time.monotonic())
class TestReadAuditEntries(unittest.TestCase): class TestReadAuditEntries(unittest.TestCase):
+20 -15
View File
@@ -112,7 +112,7 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n") validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
def test_unknown_tool_in_tools_call_is_client_error(self): def test_unknown_tool_in_tools_call_is_client_error(self):
config = ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")) config = ServerConfig(bottle_slug="dev")
with self.assertRaises(_RpcClientError) as cm: with self.assertRaises(_RpcClientError) as cm:
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config) handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
@@ -122,7 +122,6 @@ class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
def test_write_proposal_os_error_raises_internal(self): def test_write_proposal_os_error_raises_internal(self):
config = ServerConfig( config = ServerConfig(
bottle_slug="dev", bottle_slug="dev",
queue_dir=Path("/unused"),
) )
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \ with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
self.assertRaises(_RpcInternalError) as cm: self.assertRaises(_RpcInternalError) as cm:
@@ -266,21 +265,31 @@ class TestHandleToolsList(unittest.TestCase):
class TestHandleToolsCall(unittest.TestCase): class TestHandleToolsCall(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
self.queue_dir = Path(self._tmp.name) self._home_patch = self._patch_home(Path(self._tmp.name))
self.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir) self.config = ServerConfig(bottle_slug="dev")
def tearDown(self): def tearDown(self):
self._home_patch()
self._tmp.cleanup() self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
original = _sv.bot_bottle_root
def fake_root() -> Path:
return fake_home / ".bot-bottle"
_sv.bot_bottle_root = fake_root # type: ignore[assignment]
return lambda: setattr(_sv, "bot_bottle_root", original)
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
"""Background thread: poll the queue for a fresh proposal, write a """Background thread: poll the queue for a fresh proposal, write a
matching response. Returns the thread so the test can join it.""" matching response. Returns the thread so the test can join it."""
def runner(): def runner():
for _ in range(200): for _ in range(200):
pending = _sv.list_pending_proposals(self.queue_dir) pending = _sv.list_pending_proposals("dev")
if pending: if pending:
p = pending[0] p = pending[0]
_sv.write_response(self.queue_dir, _sv.Response( _sv.write_response("dev", _sv.Response(
proposal_id=p.id, status=status, notes=notes, proposal_id=p.id, status=status, notes=notes,
)) ))
return return
@@ -413,13 +422,11 @@ class TestHandleToolsCall(unittest.TestCase):
finally: finally:
responder.join() responder.join()
# No pending proposals left after archive. # No pending proposals left after archive.
self.assertEqual([], _sv.list_pending_proposals(self.queue_dir)) self.assertEqual([], _sv.list_pending_proposals("dev"))
self.assertFalse((self.queue_dir / "processed").exists())
def test_pending_response_times_out_without_archive(self): def test_pending_response_times_out_without_archive(self):
config = ServerConfig( config = ServerConfig(
bottle_slug="dev", bottle_slug="dev",
queue_dir=self.queue_dir,
response_timeout_seconds=0.05, response_timeout_seconds=0.05,
) )
result = handle_tools_call( result = handle_tools_call(
@@ -437,8 +444,7 @@ class TestHandleToolsCall(unittest.TestCase):
text = result["content"][0]["text"] # type: ignore[index] text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text) self.assertIn("status: pending", text)
self.assertIn("proposal remains queued", text) self.assertIn("proposal remains queued", text)
self.assertEqual(1, len(_sv.list_pending_proposals(self.queue_dir))) self.assertEqual(1, len(_sv.list_pending_proposals("dev")))
self.assertFalse((self.queue_dir / "processed").exists())
class TestHandleListEgressRoutes(unittest.TestCase): class TestHandleListEgressRoutes(unittest.TestCase):
@@ -460,7 +466,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()): with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
result = handle_list_egress_routes( result = handle_list_egress_routes(
{}, {},
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")), ServerConfig(bottle_slug="dev"),
) )
self.assertFalse(result["isError"]) # type: ignore[index] self.assertFalse(result["isError"]) # type: ignore[index]
@@ -475,7 +481,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()): with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
result = handle_list_egress_routes( result = handle_list_egress_routes(
{}, {},
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")), ServerConfig(bottle_slug="dev"),
) )
self.assertTrue(result["isError"]) # type: ignore[index] self.assertTrue(result["isError"]) # type: ignore[index]
@@ -543,7 +549,6 @@ class TestHttpEndToEnd(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.") self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.")
self.queue_dir = Path(self._tmp.name)
# Pick a random port by binding to :0 first. # Pick a random port by binding to :0 first.
import socket import socket
s = socket.socket() s = socket.socket()
@@ -551,7 +556,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.port = s.getsockname()[1] self.port = s.getsockname()[1]
s.close() s.close()
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler) self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
self.server.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir) self.server.config = ServerConfig(bottle_slug="dev")
self.thread = threading.Thread( self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True, target=self.server.serve_forever, daemon=True,
) )