fix(supervise): remove queue directory from db-backed flow
This commit is contained in:
+2
-2
@@ -18,7 +18,7 @@
|
||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||
# /git-gate/creds/* docker-cp'd at start time
|
||||
# /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
|
||||
#
|
||||
# Exposed ports inside the container:
|
||||
@@ -81,7 +81,7 @@ RUN mkdir -p \
|
||||
/etc/git-gate \
|
||||
/git-gate/creds \
|
||||
/git \
|
||||
/run/supervise/queue \
|
||||
/run/supervise \
|
||||
/home/mitmproxy/.mitmproxy
|
||||
|
||||
# Documentation only — the compose renderer publishes whichever
|
||||
|
||||
@@ -35,7 +35,6 @@ from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
from ...supervise import (
|
||||
DB_PATH_IN_CONTAINER,
|
||||
QUEUE_DIR_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
)
|
||||
@@ -165,7 +164,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
volumes.append({
|
||||
@@ -174,13 +172,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"target": DB_PATH_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
volumes.append({
|
||||
"type": "bind",
|
||||
"source": str(sp.queue_dir),
|
||||
"target": QUEUE_DIR_IN_CONTAINER,
|
||||
"read_only": False,
|
||||
})
|
||||
|
||||
internal_aliases = [EGRESS_HOSTNAME]
|
||||
if gp.upstreams:
|
||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||
|
||||
@@ -33,7 +33,7 @@ from ...git_gate import (
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
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 ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
||||
from ..docker.git_gate import (
|
||||
@@ -380,7 +380,6 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
return tuple(env)
|
||||
@@ -407,7 +406,6 @@ def _sidecar_mounts(
|
||||
sp = plan.supervise_plan
|
||||
if sp is not None:
|
||||
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)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from ...egress import (
|
||||
egress_resolve_token_values,
|
||||
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 ..docker import util as docker_mod
|
||||
from ..docker.egress import (
|
||||
@@ -370,11 +370,9 @@ def _bundle_launch_spec(
|
||||
env += [
|
||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
]
|
||||
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 —
|
||||
# published on host loopback so the guest can dial via TSI +
|
||||
|
||||
@@ -284,9 +284,8 @@ def git_gate_state_dir(identity: str) -> Path:
|
||||
|
||||
def supervise_state_dir(identity: str) -> Path:
|
||||
"""State subdir reserved for supervise sidecar bind-mount sources.
|
||||
The queue dir is intentionally NOT under here — it lives at
|
||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||
survives state-dir cleanup."""
|
||||
Runtime queue/audit rows live in the host-level bot-bottle SQLite
|
||||
database, so they survive state-dir cleanup."""
|
||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ from ..supervise import (
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
list_pending_proposals,
|
||||
list_all_pending_proposals,
|
||||
render_diff,
|
||||
write_audit_entry,
|
||||
write_response,
|
||||
@@ -63,10 +63,9 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueuedProposal:
|
||||
"""A pending proposal plus the queue dir it was found in."""
|
||||
"""A pending proposal from the supervise queue."""
|
||||
|
||||
proposal: Proposal
|
||||
queue_dir: Path
|
||||
|
||||
|
||||
# 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]:
|
||||
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
|
||||
queue_root = _supervise.bot_bottle_root() / "queue"
|
||||
if not queue_root.is_dir():
|
||||
return []
|
||||
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))
|
||||
"""Collect pending proposals across bottles."""
|
||||
out = [
|
||||
QueuedProposal(proposal=proposal)
|
||||
for proposal in list_all_pending_proposals()
|
||||
]
|
||||
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
||||
return out
|
||||
|
||||
@@ -118,7 +112,6 @@ def _detail_lines(
|
||||
(f"tool: {p.tool}", 0),
|
||||
(f"id: {p.id}", 0),
|
||||
(f"arrived: {p.arrival_timestamp}", 0),
|
||||
(f"queue: {qp.queue_dir}", 0),
|
||||
("", 0),
|
||||
("justification:", 0),
|
||||
]
|
||||
@@ -165,7 +158,7 @@ def approve(
|
||||
notes=notes,
|
||||
final_file=final_file,
|
||||
)
|
||||
write_response(qp.queue_dir, response)
|
||||
write_response(qp.proposal.bottle_slug, response)
|
||||
_write_audit(
|
||||
qp, action=status, notes=notes,
|
||||
diff_before=diff_before, diff_after=diff_after,
|
||||
@@ -179,7 +172,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
notes=reason,
|
||||
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="")
|
||||
|
||||
|
||||
|
||||
@@ -79,14 +79,13 @@ class EgressAddon:
|
||||
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||
# runs the addon hooks, so no lock is needed.
|
||||
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._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||
self._reload(initial=True)
|
||||
self._install_sighup()
|
||||
|
||||
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:
|
||||
try:
|
||||
@@ -393,9 +392,8 @@ class EgressAddon:
|
||||
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||
current_file_hash=_sv.sha256_hex(payload),
|
||||
)
|
||||
queue_dir = Path(self._supervise_queue_dir)
|
||||
try:
|
||||
_sv.write_proposal(queue_dir, proposal)
|
||||
_sv.write_proposal(proposal)
|
||||
except OSError as e:
|
||||
sys.stderr.write(
|
||||
f"egress: could not queue token-allow proposal: {e}; "
|
||||
@@ -411,8 +409,8 @@ class EgressAddon:
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
|
||||
response = await self._await_token_response(queue_dir, proposal.id)
|
||||
_sv.archive_proposal(queue_dir, proposal.id)
|
||||
response = await self._await_token_response(proposal.id)
|
||||
_sv.archive_proposal(self._supervise_slug, proposal.id)
|
||||
|
||||
if response is not None and response.status in (
|
||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||
@@ -439,16 +437,15 @@ class EgressAddon:
|
||||
|
||||
async def _await_token_response(
|
||||
self,
|
||||
queue_dir: Path,
|
||||
proposal_id: str,
|
||||
) -> "_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."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self._token_allow_timeout
|
||||
while True:
|
||||
try:
|
||||
return _sv.read_response(queue_dir, proposal_id)
|
||||
return _sv.read_response(self._supervise_slug, proposal_id)
|
||||
except (OSError, ValueError, KeyError):
|
||||
# Not written yet, or a partial/malformed write — retry until
|
||||
# the deadline, then fail closed.
|
||||
|
||||
@@ -239,9 +239,8 @@ from pathlib import Path
|
||||
from bot_bottle import supervise as _sv
|
||||
|
||||
report_path = Path(sys.argv[1])
|
||||
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||
if not queue_dir or not slug:
|
||||
if not slug:
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
@@ -289,7 +288,7 @@ proposal = _sv.Proposal.new(
|
||||
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
now=datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
_sv.write_proposal(Path(queue_dir), proposal)
|
||||
_sv.write_proposal(proposal)
|
||||
print(proposal.id)
|
||||
PY
|
||||
)
|
||||
@@ -303,7 +302,7 @@ PY
|
||||
return 1
|
||||
fi
|
||||
|
||||
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
||||
slug=${SUPERVISE_BOTTLE_SLUG:-}
|
||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||
case "$timeout" in
|
||||
''|*[!0-9]*)
|
||||
@@ -315,14 +314,14 @@ PY
|
||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||
waited=0
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
status=$(python3 - "$queue_dir" "$proposal_id" <<'PY'
|
||||
status=$(python3 - "$slug" "$proposal_id" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle import supervise as _sv
|
||||
|
||||
slug = sys.argv[1]
|
||||
try:
|
||||
response = _sv.read_response(Path(sys.argv[1]), sys.argv[2])
|
||||
response = _sv.read_response(slug, sys.argv[2])
|
||||
except FileNotFoundError:
|
||||
sys.exit(2)
|
||||
print(response.status)
|
||||
@@ -337,13 +336,12 @@ PY
|
||||
if [ -n "$status" ]; then
|
||||
case "$status" in
|
||||
approved|modified)
|
||||
python3 - "$queue_dir" "$proposal_id" <<'PY' || true
|
||||
python3 - "$slug" "$proposal_id" <<'PY' || true
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||
return 0
|
||||
|
||||
+59
-58
@@ -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
|
||||
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
|
||||
(bot_bottle.cli.supervise) sees the proposal, accepts
|
||||
approve / modify / reject, and writes a response file alongside the
|
||||
proposal. The sidecar sees the response and returns `{status, notes}`
|
||||
to the agent.
|
||||
approve / modify / reject, and writes a response row. The sidecar sees
|
||||
the response and returns `{status, notes}` to the agent.
|
||||
|
||||
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
|
||||
bot_bottle/supervise_server.py; the supervise daemon's container
|
||||
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.
|
||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||
|
||||
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
||||
DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db"
|
||||
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
||||
HOST_DB_FILENAME = "bot-bottle.db"
|
||||
@@ -99,10 +97,6 @@ def bot_bottle_root() -> Path:
|
||||
return Path.home() / ".bot-bottle"
|
||||
|
||||
|
||||
def queue_dir_for_slug(slug: str) -> Path:
|
||||
return bot_bottle_root() / "queue" / slug
|
||||
|
||||
|
||||
def audit_dir() -> Path:
|
||||
return bot_bottle_root() / "audit"
|
||||
|
||||
@@ -115,8 +109,7 @@ def host_db_path() -> Path:
|
||||
return bot_bottle_root() / HOST_DB_FILENAME
|
||||
|
||||
|
||||
def queue_db_path(queue_dir: Path) -> Path:
|
||||
del queue_dir
|
||||
def queue_db_path() -> Path:
|
||||
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
||||
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)
|
||||
class Proposal:
|
||||
"""One pending tool-call from the agent. The sidecar writes one
|
||||
of these to the queue dir on a tool call; the operator's TUI
|
||||
reads them; the sidecar polls for a matching Response."""
|
||||
"""One pending tool-call from the agent."""
|
||||
|
||||
id: str
|
||||
bottle_slug: str
|
||||
@@ -182,7 +173,7 @@ class Proposal:
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
"""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.
|
||||
|
||||
`final_file` carries the file content the supervisor will
|
||||
@@ -238,33 +229,38 @@ class AuditEntry:
|
||||
# --- 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.
|
||||
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:
|
||||
return _QueueStore(queue_dir).read_proposal(proposal_id)
|
||||
def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal:
|
||||
return _QueueStore(bottle_slug).read_proposal(proposal_id)
|
||||
|
||||
|
||||
def list_pending_proposals(queue_dir: Path) -> list[Proposal]:
|
||||
"""All proposals in `queue_dir` that do not yet have a matching
|
||||
def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
|
||||
"""All proposals for `bottle_slug` that do not yet have a matching
|
||||
response. Sorted by `arrival_timestamp` so the operator
|
||||
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:
|
||||
return _QueueStore(queue_dir).write_response(response)
|
||||
def list_all_pending_proposals() -> list[Proposal]:
|
||||
"""All pending proposals across bottles, sorted FIFO."""
|
||||
return _QueueStore("").list_all_pending_proposals()
|
||||
|
||||
|
||||
def read_response(queue_dir: Path, proposal_id: str) -> Response:
|
||||
return _QueueStore(queue_dir).read_response(proposal_id)
|
||||
def write_response(bottle_slug: str, response: Response) -> Path:
|
||||
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(
|
||||
queue_dir: Path,
|
||||
bottle_slug: str,
|
||||
proposal_id: str,
|
||||
*,
|
||||
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.
|
||||
|
||||
Polls SQLite so the implementation stays portable and stdlib-only."""
|
||||
store = _QueueStore(queue_dir)
|
||||
store = _QueueStore(bottle_slug)
|
||||
while True:
|
||||
try:
|
||||
return store.read_response(proposal_id)
|
||||
@@ -287,10 +283,10 @@ def wait_for_response(
|
||||
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.
|
||||
Idempotent — missing rows are silently skipped."""
|
||||
_QueueStore(queue_dir).archive_proposal(proposal_id)
|
||||
_QueueStore(bottle_slug).archive_proposal(proposal_id)
|
||||
|
||||
|
||||
# --- Audit log -------------------------------------------------------------
|
||||
@@ -333,9 +329,9 @@ def sha256_hex(content: str) -> str:
|
||||
|
||||
|
||||
class _QueueStore:
|
||||
def __init__(self, queue_dir: Path) -> None:
|
||||
self.queue_key = _queue_key(queue_dir)
|
||||
self.db_path = queue_db_path(queue_dir)
|
||||
def __init__(self, queue_key: str) -> None:
|
||||
self.queue_key = queue_key
|
||||
self.db_path = queue_db_path()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init()
|
||||
|
||||
@@ -396,6 +392,25 @@ class _QueueStore:
|
||||
).fetchall()
|
||||
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:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
@@ -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 -------------------------------------
|
||||
|
||||
|
||||
@@ -611,39 +619,33 @@ def _queue_key(queue_dir: Path) -> str:
|
||||
class SupervisePlan:
|
||||
"""Output of Supervise.prepare; consumed by .start.
|
||||
|
||||
`queue_dir` is the host directory bind-mounted into the sidecar
|
||||
at /run/supervise/queue. `internal_network` is empty at prepare
|
||||
time; the backend's launch step fills it via dataclasses.replace
|
||||
before calling .start."""
|
||||
`db_path` is the host database bind-mounted into the sidecar at
|
||||
/run/supervise/bot-bottle.db. `internal_network` is empty at
|
||||
prepare time; the backend's launch step fills it via
|
||||
dataclasses.replace before calling .start."""
|
||||
|
||||
slug: str
|
||||
queue_dir: Path
|
||||
db_path: Path
|
||||
internal_network: str = ""
|
||||
|
||||
|
||||
class Supervise(ABC):
|
||||
"""Per-bottle supervise sidecar. Encapsulates the host-side
|
||||
prepare (queue dir staging); the sidecar's start/stop lifecycle
|
||||
is backend-specific."""
|
||||
"""Per-bottle supervise sidecar. Encapsulates host-side database
|
||||
staging; the sidecar's start/stop lifecycle is backend-specific."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
) -> SupervisePlan:
|
||||
"""Stage the per-bottle queue dir on the host. Returns the
|
||||
plan; `internal_network` must be set by the launch step before
|
||||
.start runs."""
|
||||
"""Stage the host database. Returns the plan; `internal_network`
|
||||
must be set by the launch step before .start runs."""
|
||||
del stage_dir
|
||||
queue_dir = queue_dir_for_slug(slug)
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = host_db_path()
|
||||
_QueueStore(queue_dir)
|
||||
_QueueStore(slug)
|
||||
_AuditStore(db_path)
|
||||
return SupervisePlan(
|
||||
slug=slug,
|
||||
queue_dir=queue_dir,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
@@ -664,7 +666,6 @@ __all__ = [
|
||||
"DEFAULT_POLL_INTERVAL_SEC",
|
||||
"DB_PATH_IN_CONTAINER",
|
||||
"Proposal",
|
||||
"QUEUE_DIR_IN_CONTAINER",
|
||||
"Response",
|
||||
"STATUSES",
|
||||
"STATUS_APPROVED",
|
||||
@@ -688,8 +689,8 @@ __all__ = [
|
||||
"bot_bottle_root",
|
||||
"host_db_path",
|
||||
"list_pending_proposals",
|
||||
"list_all_pending_proposals",
|
||||
"queue_db_path",
|
||||
"queue_dir_for_slug",
|
||||
"read_audit_entries",
|
||||
"read_proposal",
|
||||
"read_response",
|
||||
|
||||
@@ -7,14 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
|
||||
Each queued tool call:
|
||||
|
||||
1. Validates the proposed file syntactically.
|
||||
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
|
||||
the host's ~/.bot-bottle/queue/<slug>/).
|
||||
3. Blocks polling for a matching Response file.
|
||||
2. Writes a Proposal to the host SQLite database.
|
||||
3. Blocks polling for a matching Response row.
|
||||
4. Returns the operator's `{status, notes}` to the agent.
|
||||
|
||||
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
|
||||
container creation by the backend's start step). The queue dir comes
|
||||
from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`).
|
||||
container creation by the backend's start step). SUPERVISE_DB_PATH
|
||||
points at the bind-mounted host database.
|
||||
|
||||
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
||||
|
||||
@@ -42,7 +41,6 @@ import typing
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
# 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)
|
||||
class ServerConfig:
|
||||
bottle_slug: str
|
||||
queue_dir: Path
|
||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
@@ -376,7 +373,7 @@ def handle_tools_call(
|
||||
current_file_hash=_sv.sha256_hex(proposed_file),
|
||||
)
|
||||
try:
|
||||
_sv.write_proposal(config.queue_dir, proposal)
|
||||
_sv.write_proposal(proposal)
|
||||
except OSError as e:
|
||||
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
|
||||
sys.stderr.write(
|
||||
@@ -387,7 +384,7 @@ def handle_tools_call(
|
||||
deadline = time.monotonic() + config.response_timeout_seconds
|
||||
try:
|
||||
response = _sv.wait_for_response(
|
||||
config.queue_dir,
|
||||
config.bottle_slug,
|
||||
proposal.id,
|
||||
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
|
||||
deadline=deadline,
|
||||
@@ -399,7 +396,7 @@ def handle_tools_call(
|
||||
"isError": False,
|
||||
}
|
||||
try:
|
||||
_sv.archive_proposal(config.queue_dir, proposal.id)
|
||||
_sv.archive_proposal(config.bottle_slug, proposal.id)
|
||||
except OSError as 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):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path())
|
||||
config: ServerConfig = ServerConfig(bottle_slug="")
|
||||
|
||||
|
||||
# --- Entry point -----------------------------------------------------------
|
||||
@@ -548,21 +545,18 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
def serve(
|
||||
*,
|
||||
bottle_slug: str,
|
||||
queue_dir: Path,
|
||||
port: int = _sv.SUPERVISE_PORT,
|
||||
bind: str = "0.0.0.0",
|
||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> typing.NoReturn:
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
server = MCPServer((bind, port), MCPHandler)
|
||||
server.config = ServerConfig(
|
||||
bottle_slug=bottle_slug,
|
||||
queue_dir=queue_dir,
|
||||
response_timeout_seconds=response_timeout_seconds,
|
||||
)
|
||||
sys.stderr.write(
|
||||
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]
|
||||
)
|
||||
sys.stderr.flush()
|
||||
@@ -581,7 +575,6 @@ def main(argv: list[str]) -> int:
|
||||
if not bottle_slug:
|
||||
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
||||
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)))
|
||||
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
|
||||
try:
|
||||
@@ -591,7 +584,6 @@ def main(argv: list[str]) -> int:
|
||||
return 2
|
||||
serve(
|
||||
bottle_slug=bottle_slug,
|
||||
queue_dir=queue_dir,
|
||||
port=port,
|
||||
bind=bind,
|
||||
response_timeout_seconds=response_timeout_seconds,
|
||||
|
||||
@@ -27,12 +27,10 @@ one-off persistence.
|
||||
|
||||
1. Supervise proposals and responses are persisted through SQLite.
|
||||
2. Audit entries are persisted through SQLite.
|
||||
3. Existing public supervise helpers keep their current call shape where
|
||||
practical: `write_proposal`, `read_proposal`, `list_pending_proposals`,
|
||||
`write_response`, `read_response`, `wait_for_response`,
|
||||
`archive_proposal`, `write_audit_entry`, and `read_audit_entries`.
|
||||
4. The sidecar queue mount still works across docker, smolmachines, and
|
||||
macOS-container backends.
|
||||
3. Supervise queue helpers use the bottle slug / queue key instead of a queue
|
||||
directory path.
|
||||
4. The sidecar receives the host database mount across docker, smolmachines,
|
||||
and macOS-container backends.
|
||||
5. The implementation stays stdlib-only.
|
||||
6. Unit tests cover queue round-trips, pending discovery, response waits,
|
||||
archive semantics, audit round-trips, and path creation.
|
||||
@@ -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
|
||||
`/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
|
||||
with the supervise sidecar contract and any adjacent tooling that still expects a
|
||||
queue directory, but the active queue records live in the host database. This
|
||||
creates the shared host database that later forge/native lifecycle work can
|
||||
extend in separate PRDs.
|
||||
No per-slug queue directory is mounted into the sidecar. This creates the shared
|
||||
host database that later forge/native lifecycle work can extend in separate
|
||||
PRDs.
|
||||
|
||||
### Tables
|
||||
|
||||
@@ -113,9 +109,8 @@ CREATE TABLE supervise_audit_entries (
|
||||
|
||||
### Compatibility
|
||||
|
||||
The existing helper functions keep accepting `Path` arguments for queue
|
||||
directories. Internally, they map the queue directory to a queue key and perform
|
||||
equivalent operations against `~/.bot-bottle/bot-bottle.db`:
|
||||
The queue helpers take a bottle slug / queue key and perform equivalent
|
||||
operations against `~/.bot-bottle/bot-bottle.db`:
|
||||
|
||||
- `list_pending_proposals` returns non-archived proposals without a non-archived
|
||||
response, sorted by arrival time.
|
||||
@@ -123,9 +118,9 @@ equivalent operations against `~/.bot-bottle/bot-bottle.db`:
|
||||
moving files into `processed/`.
|
||||
- `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
|
||||
available for compatibility. `audit_log_path` no longer describes the active
|
||||
storage location; callers should use `read_audit_entries`.
|
||||
The old audit path helpers (`audit_dir`, `audit_log_path`) stay available for
|
||||
compatibility. `audit_log_path` no longer describes the active storage location;
|
||||
callers should use `read_audit_entries`.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
|
||||
@@ -107,7 +107,6 @@ def _egress_plan(
|
||||
def _supervise_plan() -> SupervisePlan:
|
||||
return SupervisePlan(
|
||||
slug=SLUG,
|
||||
queue_dir=STATE / "supervise" / "queue",
|
||||
db_path=STATE / "bot-bottle.db",
|
||||
internal_network=f"bot-bottle-net-{SLUG}",
|
||||
)
|
||||
@@ -394,7 +393,6 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
env_strings = sc["environment"]
|
||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", 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))
|
||||
|
||||
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/creds/upstream-known_hosts", 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):
|
||||
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||
|
||||
@@ -74,7 +74,6 @@ def _plan(
|
||||
if supervise:
|
||||
supervise_plan = SupervisePlan(
|
||||
slug="demo-abc12",
|
||||
queue_dir=Path("/tmp/queue"),
|
||||
db_path=Path("/tmp/bot-bottle.db"),
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
|
||||
@@ -77,7 +77,6 @@ def _plan(
|
||||
if supervise:
|
||||
supervise_plan = SupervisePlan(
|
||||
slug="demo-abc12",
|
||||
queue_dir=Path("/tmp/queue"),
|
||||
db_path=Path("/tmp/bot-bottle.db"),
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
|
||||
@@ -47,7 +47,6 @@ def _addon() -> EgressAddon:
|
||||
a: EgressAddon = EgressAddon.__new__(EgressAddon)
|
||||
a.config = Config(routes=(), log=LOG_FULL)
|
||||
a.safe_tokens = set()
|
||||
a._supervise_queue_dir = ""
|
||||
a._supervise_slug = ""
|
||||
a._token_allow_timeout = 300.0
|
||||
return a
|
||||
|
||||
@@ -212,7 +212,6 @@ def _addon(config: Config) -> EgressAddon:
|
||||
a: EgressAddon = EgressAddon.__new__(EgressAddon)
|
||||
a.config = config
|
||||
a.safe_tokens = set()
|
||||
a._supervise_queue_dir = ""
|
||||
a._supervise_slug = ""
|
||||
a._token_allow_timeout = 300.0
|
||||
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:
|
||||
return "hash"
|
||||
|
||||
def _noop(_a: Any, _b: Any) -> None:
|
||||
def _noop(*_args: Any) -> None:
|
||||
return None
|
||||
|
||||
def _read_response(_qd: Any, _pid: Any) -> Any:
|
||||
def _read_response(_slug: Any, _pid: Any) -> Any:
|
||||
if response_status is None:
|
||||
raise OSError("not written yet") # forces poll -> timeout
|
||||
return types.SimpleNamespace(status=response_status)
|
||||
@@ -409,7 +408,6 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
|
||||
class TestSuperviseBranch(unittest.TestCase):
|
||||
def _supervised_addon(self) -> EgressAddon:
|
||||
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
|
||||
addon._supervise_queue_dir = "/tmp/egress-queue"
|
||||
addon._supervise_slug = "test-bottle"
|
||||
addon._token_allow_timeout = 0.05
|
||||
return addon
|
||||
@@ -632,14 +630,13 @@ class TestRedactSurfaces(unittest.TestCase):
|
||||
class TestSuperviseWriteFailure(unittest.TestCase):
|
||||
def test_write_proposal_oserror_blocks(self) -> None:
|
||||
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
|
||||
addon._supervise_queue_dir = "/tmp/egress-queue"
|
||||
addon._supervise_slug = "test-bottle"
|
||||
addon._token_allow_timeout = 0.05
|
||||
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
||||
|
||||
fake = _fake_sv("approved")
|
||||
|
||||
def _raise(_qd: Any, _p: Any) -> None:
|
||||
def _raise(_p: Any) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
fake.write_proposal = _raise
|
||||
|
||||
@@ -213,7 +213,6 @@ class TestHookRender(unittest.TestCase):
|
||||
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
|
||||
self.assertIn("_sv.write_proposal", hook)
|
||||
self.assertIn("_sv.read_response", hook)
|
||||
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
|
||||
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
||||
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||
|
||||
@@ -72,7 +72,6 @@ def _plan(
|
||||
git_gate_plan = SimpleNamespace(upstreams=())
|
||||
supervise_plan = (
|
||||
SimpleNamespace(
|
||||
queue_dir=Path("/state/supervise/queue"),
|
||||
db_path=Path("/state/bot-bottle.db"),
|
||||
)
|
||||
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",
|
||||
argv,
|
||||
)
|
||||
self.assertIn(
|
||||
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
||||
argv,
|
||||
)
|
||||
|
||||
def test_sidecar_argv_registers_canary_env_as_sensitive(self):
|
||||
plan = _plan(stage_dir=self.stage_dir, canary=True)
|
||||
|
||||
@@ -130,7 +130,6 @@ def _plan(
|
||||
if supervise:
|
||||
supervise_plan = SupervisePlan(
|
||||
slug="demo-abc12",
|
||||
queue_dir=Path("/tmp/queue"),
|
||||
db_path=Path("/tmp/bot-bottle.db"),
|
||||
)
|
||||
return SmolmachinesBottlePlan(
|
||||
|
||||
@@ -112,33 +112,44 @@ class TestResponseRoundtrip(unittest.TestCase):
|
||||
class TestQueueIO(unittest.TestCase):
|
||||
def setUp(self):
|
||||
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):
|
||||
self._home_patch()
|
||||
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):
|
||||
p = _proposal()
|
||||
path = write_proposal(self.queue_dir, p)
|
||||
path = write_proposal(p)
|
||||
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)
|
||||
loaded = read_proposal(self.queue_dir, p.id)
|
||||
loaded = read_proposal(self.slug, p.id)
|
||||
self.assertEqual(p, loaded)
|
||||
|
||||
def test_list_pending_excludes_responded(self):
|
||||
a = _proposal(justification="first")
|
||||
b = _proposal(justification="second")
|
||||
write_proposal(self.queue_dir, a)
|
||||
write_proposal(self.queue_dir, b)
|
||||
write_response(self.queue_dir, Response(
|
||||
write_proposal(a)
|
||||
write_proposal(b)
|
||||
write_response(self.slug, Response(
|
||||
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])
|
||||
|
||||
def test_list_pending_returns_empty_for_missing_dir(self):
|
||||
self.assertEqual([], list_pending_proposals(self.queue_dir / "nope"))
|
||||
def test_list_pending_returns_empty_for_missing_slug(self):
|
||||
self.assertEqual([], list_pending_proposals("nope"))
|
||||
|
||||
def test_list_pending_sorted_by_arrival(self):
|
||||
# Fabricate two with explicit timestamps.
|
||||
@@ -155,30 +166,30 @@ class TestQueueIO(unittest.TestCase):
|
||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
# Write in reverse order.
|
||||
write_proposal(self.queue_dir, b)
|
||||
write_proposal(self.queue_dir, a)
|
||||
ordered = list_pending_proposals(self.queue_dir)
|
||||
write_proposal(b)
|
||||
write_proposal(a)
|
||||
ordered = list_pending_proposals(self.slug)
|
||||
self.assertEqual([a.id, b.id], [p.id for p in ordered])
|
||||
|
||||
def test_write_and_read_response(self):
|
||||
r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no")
|
||||
write_response(self.queue_dir, r)
|
||||
self.assertEqual(r, read_response(self.queue_dir, "xyz"))
|
||||
write_response(self.slug, r)
|
||||
self.assertEqual(r, read_response(self.slug, "xyz"))
|
||||
|
||||
def test_wait_for_response_returns_when_file_appears(self):
|
||||
p = _proposal()
|
||||
write_proposal(self.queue_dir, p)
|
||||
write_proposal(p)
|
||||
|
||||
def write_after_delay():
|
||||
time.sleep(0.05)
|
||||
write_response(self.queue_dir, Response(
|
||||
write_response(self.slug, Response(
|
||||
proposal_id=p.id, status=STATUS_APPROVED, notes="ok",
|
||||
))
|
||||
|
||||
t = threading.Thread(target=write_after_delay)
|
||||
t.start()
|
||||
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:
|
||||
t.join()
|
||||
self.assertEqual(STATUS_APPROVED, r.status)
|
||||
@@ -188,24 +199,24 @@ class TestQueueIO(unittest.TestCase):
|
||||
deadline = time.monotonic() + 0.05
|
||||
with self.assertRaises(TimeoutError):
|
||||
wait_for_response(
|
||||
self.queue_dir, "never",
|
||||
self.slug, "never",
|
||||
poll_interval=0.01, deadline=deadline,
|
||||
)
|
||||
|
||||
def test_archive_proposal_moves_both_files(self):
|
||||
def test_archive_proposal_hides_rows(self):
|
||||
p = _proposal()
|
||||
write_proposal(self.queue_dir, p)
|
||||
write_response(self.queue_dir, Response(
|
||||
write_proposal(p)
|
||||
write_response(self.slug, Response(
|
||||
proposal_id=p.id, status=STATUS_APPROVED, notes="",
|
||||
))
|
||||
archive_proposal(self.queue_dir, p.id)
|
||||
self.assertEqual([], list_pending_proposals(self.queue_dir))
|
||||
archive_proposal(self.slug, p.id)
|
||||
self.assertEqual([], list_pending_proposals(self.slug))
|
||||
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):
|
||||
# Should not raise.
|
||||
archive_proposal(self.queue_dir, "nope")
|
||||
archive_proposal(self.slug, "nope")
|
||||
|
||||
|
||||
class TestAuditLog(unittest.TestCase):
|
||||
@@ -381,7 +392,6 @@ class TestSupervisePrepare(unittest.TestCase):
|
||||
|
||||
def test_prepare_creates_queue(self):
|
||||
plan = _StubSupervise().prepare("dev", self.stage_dir)
|
||||
self.assertTrue(plan.queue_dir.is_dir())
|
||||
self.assertTrue(plan.db_path.is_file())
|
||||
self.assertEqual("dev", plan.slug)
|
||||
self.assertEqual("", plan.internal_network)
|
||||
|
||||
@@ -77,9 +77,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
|
||||
|
||||
def test_walks_all_slug_subdirs(self):
|
||||
for slug in ("dev", "api"):
|
||||
qdir = supervise.queue_dir_for_slug(slug)
|
||||
qdir.mkdir(parents=True)
|
||||
supervise.write_proposal(qdir, _proposal(slug=slug))
|
||||
supervise.write_proposal(_proposal(slug=slug))
|
||||
pending = supervise_cli.discover_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),
|
||||
)
|
||||
for p in (late, early):
|
||||
qdir = supervise.queue_dir_for_slug(p.bottle_slug)
|
||||
qdir.mkdir(parents=True, exist_ok=True)
|
||||
supervise.write_proposal(qdir, p)
|
||||
supervise.write_proposal(p)
|
||||
pending = supervise_cli.discover_pending()
|
||||
self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending])
|
||||
|
||||
def test_excludes_already_responded(self):
|
||||
p = _proposal()
|
||||
qdir = supervise.queue_dir_for_slug("dev")
|
||||
qdir.mkdir(parents=True)
|
||||
supervise.write_proposal(qdir, p)
|
||||
supervise.write_response(qdir, supervise.Response(
|
||||
supervise.write_proposal(p)
|
||||
supervise.write_response("dev", supervise.Response(
|
||||
proposal_id=p.id, status=STATUS_APPROVED, notes="",
|
||||
))
|
||||
self.assertEqual([], supervise_cli.discover_pending())
|
||||
@@ -123,10 +117,8 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
|
||||
def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
|
||||
p = _proposal(tool=tool)
|
||||
qdir = supervise.queue_dir_for_slug("dev")
|
||||
qdir.mkdir(parents=True, exist_ok=True)
|
||||
supervise.write_proposal(qdir, p)
|
||||
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
|
||||
supervise.write_proposal(p)
|
||||
return supervise_cli.QueuedProposal(proposal=p)
|
||||
|
||||
def test_approve_writes_response(self):
|
||||
qp = self._enqueue()
|
||||
@@ -135,7 +127,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
|
||||
):
|
||||
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.assertIsNone(resp.final_file)
|
||||
|
||||
@@ -150,7 +142,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
final_file="routes:\n - host: edited.example.com\n",
|
||||
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("routes:\n - host: edited.example.com\n", resp.final_file)
|
||||
self.assertEqual("tweaked", resp.notes)
|
||||
@@ -158,7 +150,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
def test_reject_writes_rejection(self):
|
||||
qp = self._enqueue()
|
||||
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("nope", resp.notes)
|
||||
|
||||
@@ -181,36 +173,33 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
def test_approve_gitleaks_allow_leaves_response_for_gate(self):
|
||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||
supervise_cli.approve(qp, notes="dummy fixture")
|
||||
# Gate polls the queue dir for the response; TUI must not archive it.
|
||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||
# Gate polls the DB for the response; TUI must not archive it.
|
||||
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||
self.assertEqual("dummy fixture", resp.notes)
|
||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||
|
||||
def test_tui_gitleaks_allow_requires_reason(self):
|
||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||
with patch.object(supervise_cli, "_prompt", return_value=""):
|
||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||
self.assertEqual("approve aborted (empty reason)", status)
|
||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||
|
||||
def test_tui_gitleaks_allow_writes_reason(self):
|
||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
|
||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||
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)
|
||||
|
||||
def test_approve_token_allow_leaves_response_for_egress(self):
|
||||
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
||||
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).
|
||||
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("false positive", resp.notes)
|
||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||
|
||||
def test_token_allow_writes_no_audit_log(self):
|
||||
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=""):
|
||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||
self.assertEqual("approve aborted (empty reason)", status)
|
||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||
|
||||
def test_tui_token_allow_writes_reason(self):
|
||||
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
||||
with patch.object(supervise_cli, "_prompt", return_value="legit"):
|
||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||
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)
|
||||
|
||||
def test_suffix_for_token_allow_is_txt(self):
|
||||
|
||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle import supervise
|
||||
@@ -39,61 +38,56 @@ class TestPathHelpers(unittest.TestCase):
|
||||
def test_bot_bottle_root(self) -> None:
|
||||
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
|
||||
|
||||
def test_queue_dir_for_slug(self) -> None:
|
||||
self.assertIn("slug", str(supervise.queue_dir_for_slug("slug")))
|
||||
|
||||
def test_queue_db_path_for_slug_dir(self) -> None:
|
||||
self.assertEqual(
|
||||
supervise.host_db_path(),
|
||||
supervise.queue_db_path(Path("/tmp/queue")),
|
||||
)
|
||||
def test_queue_db_path_is_host_db_path(self) -> None:
|
||||
self.assertEqual(supervise.host_db_path(), supervise.queue_db_path())
|
||||
|
||||
|
||||
class TestReadMalformed(unittest.TestCase):
|
||||
def test_read_proposal_missing_row(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
read_proposal(Path(d), "p")
|
||||
with patch.dict("os.environ", {"HOME": d}), \
|
||||
self.assertRaises(FileNotFoundError):
|
||||
read_proposal("slug", "p")
|
||||
|
||||
def test_read_response_missing_row(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
read_response(Path(d), "p")
|
||||
with patch.dict("os.environ", {"HOME": d}), \
|
||||
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:
|
||||
qd = Path(d)
|
||||
(qd / "bad.proposal.json").write_text("{ not json")
|
||||
(qd / "arr.proposal.json").write_text("[]")
|
||||
supervise.write_proposal(qd, _proposal()) # one valid
|
||||
pending = list_pending_proposals(qd)
|
||||
with patch.dict("os.environ", {"HOME": d}):
|
||||
supervise.write_proposal(_proposal())
|
||||
pending = list_pending_proposals("slug")
|
||||
self.assertEqual(1, len(pending))
|
||||
self.assertEqual("slug", pending[0].bottle_slug)
|
||||
|
||||
def test_list_pending_skips_when_response_present(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
qd = Path(d)
|
||||
p = _proposal()
|
||||
supervise.write_proposal(qd, p)
|
||||
supervise.write_response(qd, supervise.Response(
|
||||
proposal_id=p.id,
|
||||
status=STATUS_APPROVED,
|
||||
notes="",
|
||||
))
|
||||
self.assertEqual([], list_pending_proposals(qd))
|
||||
with patch.dict("os.environ", {"HOME": d}):
|
||||
p = _proposal()
|
||||
supervise.write_proposal(p)
|
||||
supervise.write_response("slug", supervise.Response(
|
||||
proposal_id=p.id,
|
||||
status=STATUS_APPROVED,
|
||||
notes="",
|
||||
))
|
||||
self.assertEqual([], list_pending_proposals("slug"))
|
||||
|
||||
|
||||
class TestWaitForResponse(unittest.TestCase):
|
||||
def test_missing_response_times_out(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with self.assertRaises(TimeoutError):
|
||||
wait_for_response(Path(d), "p", deadline=time.monotonic())
|
||||
with patch.dict("os.environ", {"HOME": d}), \
|
||||
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:
|
||||
(Path(d) / "p.response.json").write_text("{}") # dict but from_dict raises
|
||||
with self.assertRaises(TimeoutError):
|
||||
wait_for_response(Path(d), "p", deadline=time.monotonic())
|
||||
with patch.dict("os.environ", {"HOME": d}), \
|
||||
self.assertRaises(TimeoutError):
|
||||
wait_for_response("slug", "p", deadline=time.monotonic())
|
||||
|
||||
|
||||
class TestReadAuditEntries(unittest.TestCase):
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
|
||||
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
|
||||
|
||||
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:
|
||||
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
|
||||
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):
|
||||
config = ServerConfig(
|
||||
bottle_slug="dev",
|
||||
queue_dir=Path("/unused"),
|
||||
)
|
||||
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
|
||||
self.assertRaises(_RpcInternalError) as cm:
|
||||
@@ -266,21 +265,31 @@ class TestHandleToolsList(unittest.TestCase):
|
||||
class TestHandleToolsCall(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
|
||||
self.queue_dir = Path(self._tmp.name)
|
||||
self.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir)
|
||||
self._home_patch = self._patch_home(Path(self._tmp.name))
|
||||
self.config = ServerConfig(bottle_slug="dev")
|
||||
|
||||
def tearDown(self):
|
||||
self._home_patch()
|
||||
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:
|
||||
"""Background thread: poll the queue for a fresh proposal, write a
|
||||
matching response. Returns the thread so the test can join it."""
|
||||
def runner():
|
||||
for _ in range(200):
|
||||
pending = _sv.list_pending_proposals(self.queue_dir)
|
||||
pending = _sv.list_pending_proposals("dev")
|
||||
if pending:
|
||||
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,
|
||||
))
|
||||
return
|
||||
@@ -413,13 +422,11 @@ class TestHandleToolsCall(unittest.TestCase):
|
||||
finally:
|
||||
responder.join()
|
||||
# No pending proposals left after archive.
|
||||
self.assertEqual([], _sv.list_pending_proposals(self.queue_dir))
|
||||
self.assertFalse((self.queue_dir / "processed").exists())
|
||||
self.assertEqual([], _sv.list_pending_proposals("dev"))
|
||||
|
||||
def test_pending_response_times_out_without_archive(self):
|
||||
config = ServerConfig(
|
||||
bottle_slug="dev",
|
||||
queue_dir=self.queue_dir,
|
||||
response_timeout_seconds=0.05,
|
||||
)
|
||||
result = handle_tools_call(
|
||||
@@ -437,8 +444,7 @@ class TestHandleToolsCall(unittest.TestCase):
|
||||
text = result["content"][0]["text"] # type: ignore[index]
|
||||
self.assertIn("status: pending", text)
|
||||
self.assertIn("proposal remains queued", text)
|
||||
self.assertEqual(1, len(_sv.list_pending_proposals(self.queue_dir)))
|
||||
self.assertFalse((self.queue_dir / "processed").exists())
|
||||
self.assertEqual(1, len(_sv.list_pending_proposals("dev")))
|
||||
|
||||
|
||||
class TestHandleListEgressRoutes(unittest.TestCase):
|
||||
@@ -460,7 +466,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
|
||||
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
||||
result = handle_list_egress_routes(
|
||||
{},
|
||||
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")),
|
||||
ServerConfig(bottle_slug="dev"),
|
||||
)
|
||||
|
||||
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()):
|
||||
result = handle_list_egress_routes(
|
||||
{},
|
||||
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")),
|
||||
ServerConfig(bottle_slug="dev"),
|
||||
)
|
||||
|
||||
self.assertTrue(result["isError"]) # type: ignore[index]
|
||||
@@ -543,7 +549,6 @@ class TestHttpEndToEnd(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.")
|
||||
self.queue_dir = Path(self._tmp.name)
|
||||
# Pick a random port by binding to :0 first.
|
||||
import socket
|
||||
s = socket.socket()
|
||||
@@ -551,7 +556,7 @@ class TestHttpEndToEnd(unittest.TestCase):
|
||||
self.port = s.getsockname()[1]
|
||||
s.close()
|
||||
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(
|
||||
target=self.server.serve_forever, daemon=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user