feat!: remove capability apply
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 18s
lint / lint (push) Failing after 1m53s
test / unit (push) Successful in 40s
test / integration (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 1m37s
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 18s
lint / lint (push) Failing after 1m53s
test / unit (push) Successful in 40s
test / integration (push) Successful in 20s
Update Quality Badges / update-badges (push) Successful in 1m37s
This commit was merged in pull request #282.
This commit is contained in:
@@ -1,211 +0,0 @@
|
|||||||
"""capability_apply — host-side orchestrator for capability-block
|
|
||||||
remediation (PRD 0016).
|
|
||||||
|
|
||||||
On approval of a capability-block proposal, the dashboard calls
|
|
||||||
apply_capability_change(slug, new_dockerfile) which:
|
|
||||||
|
|
||||||
1. Snapshots the agent's transcript dir to
|
|
||||||
~/.bot-bottle/state/<slug>/transcript/ (best-effort).
|
|
||||||
2. Pushes the agent's working tree via `git push` (best-effort —
|
|
||||||
no upstream / no commits / no git repo all skip with a log).
|
|
||||||
3. Writes the new Dockerfile to
|
|
||||||
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
|
|
||||||
state). The next `cli.py start <agent>` picks it up.
|
|
||||||
4. Force-removes the agent container + all sidecars + the
|
|
||||||
per-bottle networks. Idempotent — missing resources are not
|
|
||||||
errors.
|
|
||||||
|
|
||||||
Returns (before, after) Dockerfile contents so the dashboard can
|
|
||||||
record / render the diff. (capability-block has no audit log per
|
|
||||||
PRD 0013 — the per-bottle Dockerfile state is its own record.)
|
|
||||||
|
|
||||||
This is "fire-and-forget" from the agent's perspective: by the time
|
|
||||||
the dashboard writes the response file the supervise sidecar is
|
|
||||||
gone, so the agent's tool call connection drops without ever
|
|
||||||
receiving the response. The replacement agent (next manual
|
|
||||||
`cli.py start`) sees the new Dockerfile and starts from there.
|
|
||||||
v1 does not auto-relaunch — see PRD 0016's capability-block return
|
|
||||||
semantics open question.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...agent_provider import get_provider
|
|
||||||
from ...log import info, warn
|
|
||||||
from ...bottle_state import (
|
|
||||||
mark_preserved,
|
|
||||||
per_bottle_dockerfile,
|
|
||||||
transcript_snapshot_dir,
|
|
||||||
write_per_bottle_dockerfile,
|
|
||||||
)
|
|
||||||
from .sidecar_bundle import sidecar_bundle_container_name
|
|
||||||
|
|
||||||
|
|
||||||
# Agent home inside the container (per the repo Dockerfile's
|
|
||||||
# `USER node` + `WORKDIR /home/node`). Used to locate the transcript
|
|
||||||
# dir + the workspace dir for git push.
|
|
||||||
_AGENT_HOME_IN_CONTAINER = "/home/node"
|
|
||||||
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
|
|
||||||
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
|
|
||||||
|
|
||||||
# Per-bottle resource name patterns (mirroring prepare.py).
|
|
||||||
def _agent_container_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def _per_bottle_container_names(slug: str) -> list[str]:
|
|
||||||
"""All container names that belong to this bottle. Missing
|
|
||||||
containers are silently skipped by the teardown helper, so it's
|
|
||||||
fine to include names that don't exist for a given bottle."""
|
|
||||||
return [
|
|
||||||
_agent_container_name(slug),
|
|
||||||
sidecar_bundle_container_name(slug),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _per_bottle_network_names(slug: str) -> list[str]:
|
|
||||||
return [
|
|
||||||
f"bot-bottle-net-{slug}",
|
|
||||||
f"bot-bottle-egress-{slug}",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityApplyError(RuntimeError):
|
|
||||||
"""Raised when the apply fails in a way that should keep the
|
|
||||||
proposal pending (so the operator can retry). Best-effort
|
|
||||||
failures (transcript snapshot, git push) do not raise — they
|
|
||||||
just log and proceed."""
|
|
||||||
|
|
||||||
|
|
||||||
# --- Public helpers --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_dockerfile(slug: str) -> str:
|
|
||||||
"""Return the Dockerfile content the next `cli.py start <agent>`
|
|
||||||
would use for this bottle. If a per-bottle override exists, that
|
|
||||||
one; otherwise the repo's Dockerfile.
|
|
||||||
|
|
||||||
Used by the operator-edit verb to show the current source of
|
|
||||||
truth, and by apply_capability_change for the before-diff."""
|
|
||||||
override = per_bottle_dockerfile(slug)
|
|
||||||
if override is not None:
|
|
||||||
return override
|
|
||||||
repo_dockerfile = get_provider("claude").dockerfile
|
|
||||||
if repo_dockerfile.is_file():
|
|
||||||
return repo_dockerfile.read_text()
|
|
||||||
raise CapabilityApplyError(
|
|
||||||
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
|
|
||||||
f"{repo_dockerfile}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
|
||||||
"""End-to-end capability-block remediation. See module docstring
|
|
||||||
for the sequence. Returns (before, after) Dockerfile content."""
|
|
||||||
if not new_dockerfile.strip():
|
|
||||||
raise CapabilityApplyError("proposed Dockerfile is empty")
|
|
||||||
before = fetch_current_dockerfile(slug)
|
|
||||||
|
|
||||||
snapshot_transcript(slug)
|
|
||||||
_push_working_tree(slug)
|
|
||||||
write_per_bottle_dockerfile(slug, new_dockerfile)
|
|
||||||
# Set the preserve marker BEFORE teardown so cli.py's session-end
|
|
||||||
# cleanup sees it and keeps the state dir intact for the
|
|
||||||
# operator's `cli.py resume <identity>`. Without the marker the
|
|
||||||
# state dir would be deleted as part of normal session end.
|
|
||||||
mark_preserved(slug)
|
|
||||||
_teardown_bottle(slug)
|
|
||||||
|
|
||||||
return before, new_dockerfile
|
|
||||||
|
|
||||||
|
|
||||||
# --- Internals -------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_transcript(slug: str) -> None:
|
|
||||||
"""`docker cp` /home/node/.claude out of the agent container into
|
|
||||||
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
|
|
||||||
container, missing dir, or cp error all log a warning and return.
|
|
||||||
The transcript is what `claude --resume` reads to pick up where
|
|
||||||
the agent left off.
|
|
||||||
|
|
||||||
Called from two places:
|
|
||||||
- capability-apply, before tearing the bottle down.
|
|
||||||
- cli.py's session-end path, before the launch context closes,
|
|
||||||
so a crash or normal exit also leaves a transcript on disk
|
|
||||||
(deleted along with the state dir on clean exit, kept on
|
|
||||||
crash or capability-block per the preserve marker)."""
|
|
||||||
container = _agent_container_name(slug)
|
|
||||||
dest = transcript_snapshot_dir(slug)
|
|
||||||
if dest.exists():
|
|
||||||
# Remove any prior snapshot so the new one is a clean copy.
|
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "cp", f"{container}:{_AGENT_TRANSCRIPT_IN_CONTAINER}", str(dest)],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"transcript snapshot skipped "
|
|
||||||
f"({(r.stderr or '').strip() or 'no transcript dir in container?'})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
info(f"transcript snapshotted to {dest}")
|
|
||||||
|
|
||||||
|
|
||||||
def _push_working_tree(slug: str) -> None:
|
|
||||||
"""`docker exec <agent> git push` from /home/node/workspace.
|
|
||||||
Best-effort: not-a-git-repo, no upstream, nothing-to-push, no
|
|
||||||
network all log a warning and return. The replacement bottle
|
|
||||||
will pick up whatever's actually upstream."""
|
|
||||||
container = _agent_container_name(slug)
|
|
||||||
r = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker", "exec", container, "sh", "-c",
|
|
||||||
f"cd {_AGENT_WORKSPACE_IN_CONTAINER} && "
|
|
||||||
f"git rev-parse --is-inside-work-tree >/dev/null 2>&1 && "
|
|
||||||
f"git push origin HEAD 2>&1 || true",
|
|
||||||
],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
warn(
|
|
||||||
f"capability-apply: git push skipped "
|
|
||||||
f"({(r.stderr or '').strip() or 'docker exec failed'})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
output = (r.stdout or "").strip()
|
|
||||||
if output:
|
|
||||||
info(f"capability-apply: git push: {output}")
|
|
||||||
else:
|
|
||||||
info("capability-apply: git push ran (no output — likely not a git workspace)")
|
|
||||||
|
|
||||||
|
|
||||||
def _teardown_bottle(slug: str) -> None:
|
|
||||||
"""Force-remove all per-bottle docker resources. Idempotent —
|
|
||||||
`docker rm -f` / `docker network rm` silently ignore missing
|
|
||||||
names, so this can be called even mid-rebuild."""
|
|
||||||
info(f"capability-apply: tearing down bottle {slug}")
|
|
||||||
for name in _per_bottle_container_names(slug):
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
for net in _per_bottle_network_names(slug):
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "network", "rm", net],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CapabilityApplyError",
|
|
||||||
"apply_capability_change",
|
|
||||||
"fetch_current_dockerfile",
|
|
||||||
"snapshot_transcript",
|
|
||||||
]
|
|
||||||
@@ -34,7 +34,6 @@ from ...egress import (
|
|||||||
from ...git_gate import GIT_GATE_HOSTNAME
|
from ...git_gate import GIT_GATE_HOSTNAME
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
from ...supervise import (
|
from ...supervise import (
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
||||||
QUEUE_DIR_IN_CONTAINER,
|
QUEUE_DIR_IN_CONTAINER,
|
||||||
SUPERVISE_HOSTNAME,
|
SUPERVISE_HOSTNAME,
|
||||||
SUPERVISE_PORT,
|
SUPERVISE_PORT,
|
||||||
@@ -233,15 +232,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if plan.use_runsc:
|
if plan.use_runsc:
|
||||||
service["runtime"] = "runsc"
|
service["runtime"] = "runsc"
|
||||||
|
|
||||||
volumes: list[dict[str, Any]] = []
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
volumes.append(_bind(
|
|
||||||
plan.supervise_plan.current_config_dir,
|
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
||||||
))
|
|
||||||
if volumes:
|
|
||||||
service["volumes"] = volumes
|
|
||||||
|
|
||||||
# The init supervisor inside the bundle owns intra-bundle
|
# The init supervisor inside the bundle owns intra-bundle
|
||||||
# daemon ordering, so the agent only waits for the bundle
|
# daemon ordering, so the agent only waits for the bundle
|
||||||
# container itself.
|
# container itself.
|
||||||
|
|||||||
+10
-16
@@ -1,8 +1,7 @@
|
|||||||
"""Per-bottle persistent state (PRD 0016).
|
"""Per-bottle persistent state.
|
||||||
|
|
||||||
Holds the per-bottle Dockerfile override that capability-block
|
Holds optional per-bottle Dockerfile overrides, the transcript snapshot
|
||||||
remediation writes, the transcript snapshot the state-preservation
|
the state-preservation helper saves before teardown, and the launch metadata that lets
|
||||||
helper saves before teardown, and the launch metadata that lets
|
|
||||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
||||||
lives at:
|
lives at:
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ _METADATA_NAME = "metadata.json"
|
|||||||
_LIVE_CONFIG_SUBDIR = "live-config"
|
_LIVE_CONFIG_SUBDIR = "live-config"
|
||||||
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
||||||
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
||||||
# Empty marker file. capability_apply writes it before teardown so
|
# Empty marker file. Session preservation writes it before teardown so
|
||||||
# cli.py's session-end cleanup knows to preserve the state dir for
|
# cli.py's session-end cleanup knows to preserve the state dir for
|
||||||
# `cli.py resume <identity>`. Absent = clean up.
|
# `cli.py resume <identity>`. Absent = clean up.
|
||||||
_PRESERVE_MARKER = ".preserve"
|
_PRESERVE_MARKER = ".preserve"
|
||||||
@@ -164,8 +163,7 @@ def per_bottle_dockerfile_path(identity: str) -> Path:
|
|||||||
|
|
||||||
def per_bottle_dockerfile(identity: str) -> str | None:
|
def per_bottle_dockerfile(identity: str) -> str | None:
|
||||||
"""Return the per-bottle Dockerfile content if present, else
|
"""Return the per-bottle Dockerfile content if present, else
|
||||||
None. None means: use the repo's Dockerfile (the original
|
None. None means: use the provider or manifest Dockerfile."""
|
||||||
pre-capability-block behavior)."""
|
|
||||||
p = per_bottle_dockerfile_path(identity)
|
p = per_bottle_dockerfile_path(identity)
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
return p.read_text()
|
return p.read_text()
|
||||||
@@ -249,9 +247,7 @@ def write_live_config(
|
|||||||
|
|
||||||
|
|
||||||
def transcript_snapshot_dir(identity: str) -> Path:
|
def transcript_snapshot_dir(identity: str) -> Path:
|
||||||
"""Where capability_apply stashes the agent's transcript before
|
"""Where agent session snapshots are kept for resume flows."""
|
||||||
teardown, so the next `cli.py start <agent>` can offer to
|
|
||||||
resume from it."""
|
|
||||||
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
@@ -278,8 +274,7 @@ def git_gate_state_dir(identity: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def supervise_state_dir(identity: str) -> Path:
|
def supervise_state_dir(identity: str) -> Path:
|
||||||
"""State subdir for the supervise sidecar's current-config dir
|
"""State subdir reserved for supervise sidecar bind-mount sources.
|
||||||
(bind-mounted into the agent at /etc/bot-bottle/current-config).
|
|
||||||
The queue dir is intentionally NOT under here — it lives at
|
The queue dir is intentionally NOT under here — it lives at
|
||||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||||
survives state-dir cleanup."""
|
survives state-dir cleanup."""
|
||||||
@@ -301,9 +296,8 @@ def preserve_marker_path(identity: str) -> Path:
|
|||||||
|
|
||||||
def mark_preserved(identity: str) -> Path:
|
def mark_preserved(identity: str) -> Path:
|
||||||
"""Mark this bottle's state for preservation across session
|
"""Mark this bottle's state for preservation across session
|
||||||
teardown. Written by capability_apply.apply_capability_change so
|
teardown so cli.py's session-end cleanup leaves the state dir
|
||||||
cli.py's session-end cleanup leaves the state dir intact for a
|
intact for a subsequent `cli.py resume`."""
|
||||||
subsequent `cli.py resume`."""
|
|
||||||
path = preserve_marker_path(identity)
|
path = preserve_marker_path(identity)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.touch()
|
path.touch()
|
||||||
@@ -316,7 +310,7 @@ def is_preserved(identity: str) -> bool:
|
|||||||
|
|
||||||
def clear_preserve_marker(identity: str) -> None:
|
def clear_preserve_marker(identity: str) -> None:
|
||||||
"""Idempotent removal. Called at fresh launch (start or resume)
|
"""Idempotent removal. Called at fresh launch (start or resume)
|
||||||
so a marker left from a prior capability-block doesn't keep
|
so a marker left from a prior preserved session doesn't keep
|
||||||
state alive past the next normal session-end."""
|
state alive past the next normal session-end."""
|
||||||
try:
|
try:
|
||||||
preserve_marker_path(identity).unlink()
|
preserve_marker_path(identity).unlink()
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ dirs are shared layout, so docker is the single owner of that
|
|||||||
bucket.
|
bucket.
|
||||||
|
|
||||||
State dirs with `.preserve` are intentionally never touched — they
|
State dirs with `.preserve` are intentionally never touched — they
|
||||||
hold capability-block rebuilds or crash snapshots the operator may
|
hold preserved sessions the operator may want to `resume`. Manual
|
||||||
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
`rm -rf ~/.bot-bottle/state/<identity>` is the path for those.
|
||||||
is the path for those.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
|
|||||||
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
||||||
then runs the same launch core as `start` — but pinned to the
|
then runs the same launch core as `start` — but pinned to the
|
||||||
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
||||||
(from capability-block apply) and transcript snapshot under the same
|
override and transcript snapshot under the same state dir.
|
||||||
state dir.
|
|
||||||
|
|
||||||
Use case: an agent calls capability-block, the dashboard approves
|
Use case: an interrupted or preserved bottle needs to be relaunched;
|
||||||
and tears down the bottle, the operator runs
|
the operator runs
|
||||||
./cli.py resume <identity>
|
./cli.py resume <identity>
|
||||||
to bring up the replacement with the new capabilities baked in.
|
to bring up the replacement from the recorded state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from ..bottle_state import (
|
|||||||
is_preserved,
|
is_preserved,
|
||||||
mark_preserved,
|
mark_preserved,
|
||||||
)
|
)
|
||||||
# from ..backend.docker.capability_apply import snapshot_transcript
|
|
||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import ManifestIndex
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
from ._common import PROG, USER_CWD, read_tty_line
|
||||||
@@ -257,12 +256,8 @@ def _launch_bottle(
|
|||||||
)
|
)
|
||||||
# While the container is still alive: always snapshot the
|
# While the container is still alive: always snapshot the
|
||||||
# transcript and — if the agent exited non-zero — mark
|
# transcript and — if the agent exited non-zero — mark
|
||||||
# the state for preservation. Capability-block already
|
# the state for preservation. This picks up crashes /
|
||||||
# did both before triggering teardown from the dashboard;
|
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
||||||
# this picks up crashes / Ctrl-Cs / OOM kills the same
|
|
||||||
# way. snapshot_transcript is best-effort so the
|
|
||||||
# capability-block path's prior snapshot isn't clobbered
|
|
||||||
# when the container is already gone.
|
|
||||||
if agent_provider_template == "claude":
|
if agent_provider_template == "claude":
|
||||||
capture_claude_session_state(identity, exit_code)
|
capture_claude_session_state(identity, exit_code)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
act on them (approve / modify / reject).
|
act on them (approve / modify / reject).
|
||||||
|
|
||||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||||
approval handler wires to PRD 0016 (capability-block), which rebuilds
|
Egress proposals are queued for operator review as full routes.yaml
|
||||||
the bottle Dockerfile. Egress proposals are queued for operator review
|
updates.
|
||||||
as full routes.yaml updates.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,10 +21,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .. import supervise as _supervise
|
from .. import supervise as _supervise
|
||||||
from ..bottle_state import read_metadata
|
from ..bottle_state import read_metadata
|
||||||
# from ..backend.docker.capability_apply import (
|
|
||||||
# CapabilityApplyError,
|
|
||||||
# apply_capability_change,
|
|
||||||
# )
|
|
||||||
from ..backend.docker.egress_apply import (
|
from ..backend.docker.egress_apply import (
|
||||||
EgressApplyError,
|
EgressApplyError,
|
||||||
applicator as _docker_applicator,
|
applicator as _docker_applicator,
|
||||||
@@ -38,10 +33,6 @@ from ..backend.smolmachines.egress_apply import (
|
|||||||
)
|
)
|
||||||
from ..log import Die, error, info
|
from ..log import Die, error, info
|
||||||
|
|
||||||
|
|
||||||
class CapabilityApplyError(RuntimeError):
|
|
||||||
"""Placeholder while capability_apply is disabled."""
|
|
||||||
|
|
||||||
from ..supervise import (
|
from ..supervise import (
|
||||||
COMPONENT_FOR_TOOL,
|
COMPONENT_FOR_TOOL,
|
||||||
AuditEntry,
|
AuditEntry,
|
||||||
@@ -50,7 +41,6 @@ from ..supervise import (
|
|||||||
STATUS_APPROVED,
|
STATUS_APPROVED,
|
||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
@@ -83,7 +73,7 @@ class QueuedProposal:
|
|||||||
# Errors any remediation engine may raise. Caught by the TUI key
|
# Errors any remediation engine may raise. Caught by the TUI key
|
||||||
# handlers and surfaced in the status line so a failed apply keeps
|
# handlers and surfaced in the status line so a failed apply keeps
|
||||||
# the proposal pending rather than crashing curses.
|
# the proposal pending rather than crashing curses.
|
||||||
ApplyError = (CapabilityApplyError, EgressApplyError)
|
ApplyError = (EgressApplyError,)
|
||||||
|
|
||||||
|
|
||||||
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
||||||
@@ -143,8 +133,6 @@ def _detail_lines(
|
|||||||
|
|
||||||
|
|
||||||
def _suffix_for_tool(tool: str) -> str:
|
def _suffix_for_tool(tool: str) -> str:
|
||||||
if tool == TOOL_CAPABILITY_BLOCK:
|
|
||||||
return ".dockerfile"
|
|
||||||
if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||||
return ".yaml"
|
return ".yaml"
|
||||||
if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW):
|
if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW):
|
||||||
@@ -166,17 +154,6 @@ def approve(
|
|||||||
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
||||||
|
|
||||||
diff_before, diff_after = "", ""
|
diff_before, diff_after = "", ""
|
||||||
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
|
||||||
# _meta = read_metadata(qp.proposal.bottle_slug)
|
|
||||||
# if _meta is not None and not _meta.compose_project:
|
|
||||||
# raise CapabilityApplyError(
|
|
||||||
# "capability-block remediation is not supported for smolmachines "
|
|
||||||
# "bottles. Reject this proposal or handle the capability change "
|
|
||||||
# "manually, then restart the bottle."
|
|
||||||
# )
|
|
||||||
# diff_before, diff_after = apply_capability_change(
|
|
||||||
# qp.proposal.bottle_slug, file_to_apply,
|
|
||||||
# )
|
|
||||||
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||||
diff_before, diff_after = apply_routes_change(
|
diff_before, diff_after = apply_routes_change(
|
||||||
qp.proposal.bottle_slug,
|
qp.proposal.bottle_slug,
|
||||||
@@ -194,9 +171,6 @@ def approve(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
|
||||||
archive_proposal(qp.queue_dir, qp.proposal.id)
|
|
||||||
|
|
||||||
|
|
||||||
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||||
"""Write a rejection response and an audit entry."""
|
"""Write a rejection response and an audit entry."""
|
||||||
|
|||||||
@@ -113,10 +113,8 @@ class ManifestBottle:
|
|||||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
||||||
# default, issue #249), the launch step brings up a supervise
|
# default, issue #249), the launch step brings up a supervise
|
||||||
# sidecar that exposes MCP tools to the agent (egress-block,
|
# sidecar that exposes egress MCP tools to the agent. Set
|
||||||
# capability-block) plus mounts the current-config dir read-only
|
# `supervise: false` to skip the sidecar.
|
||||||
# into the agent at /etc/bot-bottle/current-config. Set
|
|
||||||
# `supervise: false` to skip the sidecar and mount.
|
|
||||||
supervise: bool = True
|
supervise: bool = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+10
-42
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
||||||
queue/audit support. The sidecar (bot_bottle.supervise_server)
|
queue/audit support. The sidecar (bot_bottle.supervise_server)
|
||||||
sits on the bottle's internal network and exposes three MCP tools the
|
sits on the bottle's internal network and exposes MCP tools the agent
|
||||||
agent calls when it hits a stuck-recovery category:
|
calls when it needs an operator-reviewed egress change:
|
||||||
|
|
||||||
* egress-block / allow — agent proposes a new routes.yaml
|
* egress-block / allow — agent proposes a new routes.yaml
|
||||||
* capability-block — agent proposes a new agent Dockerfile
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -48,7 +47,6 @@ from pathlib import Path
|
|||||||
SUPERVISE_HOSTNAME = "supervise"
|
SUPERVISE_HOSTNAME = "supervise"
|
||||||
SUPERVISE_PORT = 9100
|
SUPERVISE_PORT = 9100
|
||||||
|
|
||||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
|
||||||
TOOL_EGRESS_BLOCK = "egress-block"
|
TOOL_EGRESS_BLOCK = "egress-block"
|
||||||
TOOL_EGRESS_ALLOW = "egress-allow"
|
TOOL_EGRESS_ALLOW = "egress-allow"
|
||||||
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||||
@@ -58,7 +56,6 @@ TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
|||||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||||
TOOLS: tuple[str, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
@@ -75,10 +72,6 @@ TOOLS: tuple[str, ...] = (
|
|||||||
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
||||||
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
||||||
|
|
||||||
# capability-block has no on-disk config the operator edits in place
|
|
||||||
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
|
||||||
# here — those changes are captured by git history + the rebuild record
|
|
||||||
# laid down in PRD 0016.
|
|
||||||
COMPONENT_FOR_TOOL: dict[str, str] = {
|
COMPONENT_FOR_TOOL: dict[str, str] = {
|
||||||
TOOL_EGRESS_ALLOW: "egress",
|
TOOL_EGRESS_ALLOW: "egress",
|
||||||
TOOL_EGRESS_BLOCK: "egress",
|
TOOL_EGRESS_BLOCK: "egress",
|
||||||
@@ -94,8 +87,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
|||||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||||
|
|
||||||
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT = "/etc/bot-bottle/current-config"
|
|
||||||
|
|
||||||
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
||||||
|
|
||||||
|
|
||||||
@@ -438,59 +429,39 @@ def sha256_hex(content: str) -> str:
|
|||||||
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# Filename of the staged Dockerfile inside the agent's read-only
|
|
||||||
# current-config mount. The capability-block tool's description
|
|
||||||
# points the agent at this exact path so it can read the current
|
|
||||||
# Dockerfile and propose modifications.
|
|
||||||
#
|
|
||||||
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
|
|
||||||
# moved them behind the `list-egress-routes` MCP tool (live state
|
|
||||||
# from egress's introspection endpoint) so the agent always sees
|
|
||||||
# current data rather than a launch-time snapshot.
|
|
||||||
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
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
|
`queue_dir` is the host directory bind-mounted into the sidecar
|
||||||
at /run/supervise/queue. `current_config_dir` is the host
|
at /run/supervise/queue. `internal_network` is empty at prepare
|
||||||
directory bind-mounted (read-only) into the *agent* container
|
time; the backend's launch step fills it via dataclasses.replace
|
||||||
at /etc/bot-bottle/current-config — currently holds only the
|
before calling .start."""
|
||||||
Dockerfile snapshot (routes.yaml + allowlist moved to the
|
|
||||||
`list-egress-routes` MCP tool). `internal_network` is
|
|
||||||
empty at prepare time; the backend's launch step fills it via
|
|
||||||
dataclasses.replace before calling .start."""
|
|
||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
queue_dir: Path
|
queue_dir: Path
|
||||||
current_config_dir: 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 the host-side
|
||||||
prepare (queue dir + current-config staging); the sidecar's
|
prepare (queue dir staging); the sidecar's start/stop lifecycle
|
||||||
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 and the
|
"""Stage the per-bottle queue dir on the host. Returns the
|
||||||
current-config dir under `stage_dir`. Returns the plan;
|
plan; `internal_network` must be set by the launch step before
|
||||||
`internal_network` must be set by the launch step before
|
|
||||||
.start runs."""
|
.start runs."""
|
||||||
|
del stage_dir
|
||||||
queue_dir = queue_dir_for_slug(slug)
|
queue_dir = queue_dir_for_slug(slug)
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||||
current_config_dir = stage_dir / "current-config"
|
|
||||||
current_config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
queue_dir=queue_dir,
|
queue_dir=queue_dir,
|
||||||
current_config_dir=current_config_dir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Helpers ---------------------------------------------------------------
|
# --- Helpers ---------------------------------------------------------------
|
||||||
@@ -541,8 +512,6 @@ __all__ = [
|
|||||||
"ACTION_OPERATOR_EDIT",
|
"ACTION_OPERATOR_EDIT",
|
||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"COMPONENT_FOR_TOOL",
|
"COMPONENT_FOR_TOOL",
|
||||||
"CURRENT_CONFIG_DIR_IN_AGENT",
|
|
||||||
"CURRENT_CONFIG_DOCKERFILE",
|
|
||||||
"DEFAULT_POLL_INTERVAL_SEC",
|
"DEFAULT_POLL_INTERVAL_SEC",
|
||||||
"Proposal",
|
"Proposal",
|
||||||
"QUEUE_DIR_IN_CONTAINER",
|
"QUEUE_DIR_IN_CONTAINER",
|
||||||
@@ -558,7 +527,6 @@ __all__ = [
|
|||||||
"TOOLS",
|
"TOOLS",
|
||||||
"EGRESS_FORWARD_PROXY",
|
"EGRESS_FORWARD_PROXY",
|
||||||
"EGRESS_INTROSPECT_URL",
|
"EGRESS_INTROSPECT_URL",
|
||||||
"TOOL_CAPABILITY_BLOCK",
|
|
||||||
"TOOL_EGRESS_ALLOW",
|
"TOOL_EGRESS_ALLOW",
|
||||||
"TOOL_EGRESS_BLOCK",
|
"TOOL_EGRESS_BLOCK",
|
||||||
"TOOL_GITLEAKS_ALLOW",
|
"TOOL_GITLEAKS_ALLOW",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Supervise sidecar HTTP server (PRD 0013).
|
"""Supervise sidecar HTTP server (PRD 0013).
|
||||||
|
|
||||||
Per-bottle MCP server exposing tools the agent calls to propose config
|
Per-bottle MCP server exposing tools the agent calls to propose egress
|
||||||
changes when stuck. The tools are `allow`, `egress-block`,
|
config changes when stuck. The tools are `egress-allow`,
|
||||||
`capability-block`, and `list-egress-routes`.
|
`egress-block`, and `list-egress-routes`.
|
||||||
|
|
||||||
Each queued tool call:
|
Each queued tool call:
|
||||||
|
|
||||||
@@ -253,34 +253,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
"required": ["routes_yaml", "justification"],
|
"required": ["routes_yaml", "justification"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
|
||||||
"description": (
|
|
||||||
"Call when the bottle is missing a tool, skill, permission, "
|
|
||||||
"or env var you need — something that lives in the agent "
|
|
||||||
"Dockerfile rather than in the egress routes. "
|
|
||||||
"Read the current Dockerfile from "
|
|
||||||
"/etc/bot-bottle/current-config/Dockerfile, compose a "
|
|
||||||
"modified version, and pass the full new file plus a "
|
|
||||||
"justification. On approval the supervisor rebuilds the "
|
|
||||||
"bottle from the new Dockerfile and starts a replacement on "
|
|
||||||
"the same branch (wired in PRD 0016; v1 acknowledges only)."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"dockerfile": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Full proposed Dockerfile content.",
|
|
||||||
},
|
|
||||||
"justification": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Why this capability is needed.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["dockerfile", "justification"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -288,7 +260,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
# payload (stored in Proposal.proposed_file).
|
# payload (stored in Proposal.proposed_file).
|
||||||
PROPOSED_FILE_FIELD: dict[str, str] = {
|
PROPOSED_FILE_FIELD: dict[str, str] = {
|
||||||
_sv.TOOL_EGRESS_ALLOW: "routes_yaml",
|
_sv.TOOL_EGRESS_ALLOW: "routes_yaml",
|
||||||
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
|
||||||
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
|
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,11 +273,7 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
|||||||
enter the queue."""
|
enter the queue."""
|
||||||
if not content.strip():
|
if not content.strip():
|
||||||
raise _RpcClientError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
raise _RpcClientError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
||||||
if tool == _sv.TOOL_CAPABILITY_BLOCK:
|
if tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
||||||
# Dockerfiles are too varied to validate syntactically beyond
|
|
||||||
# non-empty. The operator reads the diff in the TUI.
|
|
||||||
pass
|
|
||||||
elif tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
|
||||||
try:
|
try:
|
||||||
config = load_config(content)
|
config = load_config(content)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -487,9 +454,8 @@ def format_pending_response_text(timeout_seconds: float) -> str:
|
|||||||
# --- HTTP transport --------------------------------------------------------
|
# --- HTTP transport --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# Max request body the server accepts. Generous because Dockerfile
|
# Max request body the server accepts. 1 MB is well above any realistic
|
||||||
# proposals can be a few KB; routes.json is small. 1 MB is well above
|
# routes.yaml proposal.
|
||||||
# any realistic config file.
|
|
||||||
MAX_BODY_BYTES = 1 * 1024 * 1024
|
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ class TestBottleIdentity(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestPreserveMarker(_FakeHomeMixin, unittest.TestCase):
|
class TestPreserveMarker(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""The .preserve marker is how capability_apply tells cli.py's
|
"""The .preserve marker tells cli.py's session-end cleanup to keep
|
||||||
session-end cleanup to keep the state dir instead of removing it."""
|
the state dir instead of removing it."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
self._setup_fake_home()
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ class _FakeHomeMixin:
|
|||||||
|
|
||||||
|
|
||||||
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
|
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
|
||||||
# snapshot_transcript is commented out (capability_apply is disabled);
|
# capture_claude_session_state handles the preserve marker for
|
||||||
# capture_claude_session_state now only handles the preserve marker.
|
# non-zero agent exits.
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ def _supervise_plan() -> SupervisePlan:
|
|||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=SLUG,
|
slug=SLUG,
|
||||||
queue_dir=STATE / "supervise" / "queue",
|
queue_dir=STATE / "supervise" / "queue",
|
||||||
current_config_dir=STATE / "supervise" / "current-config",
|
|
||||||
internal_network=f"bot-bottle-net-{SLUG}",
|
internal_network=f"bot-bottle-net-{SLUG}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,18 +270,11 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
||||||
self.assertEqual(["sidecars"], s["depends_on"])
|
self.assertEqual(["sidecars"], s["depends_on"])
|
||||||
|
|
||||||
def test_agent_current_config_mount_only_with_supervise(self):
|
def test_agent_has_no_current_config_mount_with_supervise(self):
|
||||||
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
||||||
self.assertTrue(any(
|
self.assertNotIn("volumes", with_sv)
|
||||||
v["target"] == "/etc/bot-bottle/current-config"
|
|
||||||
for v in with_sv.get("volumes", [])
|
|
||||||
))
|
|
||||||
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
|
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
|
||||||
# Either no volumes key at all, or no current-config target.
|
self.assertNotIn("volumes", without_sv)
|
||||||
self.assertFalse(any(
|
|
||||||
v["target"] == "/etc/bot-bottle/current-config"
|
|
||||||
for v in without_sv.get("volumes", [])
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSidecarBundleShape(unittest.TestCase):
|
class TestSidecarBundleShape(unittest.TestCase):
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ def _plan(
|
|||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
queue_dir=Path("/tmp/queue"),
|
||||||
current_config_dir=Path("/tmp/current-config"),
|
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ def _plan(
|
|||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
queue_dir=Path("/tmp/queue"),
|
||||||
current_config_dir=Path("/tmp/current-config"),
|
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_preserve_marker_skips_dir(self):
|
def test_preserve_marker_skips_dir(self):
|
||||||
# Preserve marker = capability-block or crash auto-preserve;
|
# Preserve marker means the user explicitly wanted this dir
|
||||||
# the user explicitly wanted this dir kept for `resume`.
|
# kept for `resume`.
|
||||||
bottle_state.write_per_bottle_dockerfile("kept-ccc", "FROM x\n")
|
bottle_state.write_per_bottle_dockerfile("kept-ccc", "FROM x\n")
|
||||||
bottle_state.mark_preserved("kept-ccc")
|
bottle_state.mark_preserved("kept-ccc")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ def _plan(
|
|||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
queue_dir=Path("/tmp/queue"),
|
||||||
current_config_dir=Path("/tmp/current-config"),
|
|
||||||
)
|
)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from bot_bottle.supervise import (
|
|||||||
STATUS_APPROVED,
|
STATUS_APPROVED,
|
||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
audit_log_path,
|
audit_log_path,
|
||||||
@@ -37,9 +37,9 @@ FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
|||||||
|
|
||||||
|
|
||||||
def _proposal(
|
def _proposal(
|
||||||
tool: str = TOOL_CAPABILITY_BLOCK,
|
tool: str = TOOL_EGRESS_ALLOW,
|
||||||
proposed: str = "FROM python:3.13\n",
|
proposed: str = "routes:\n - host: example.com\n",
|
||||||
justification: str = "need a capability",
|
justification: str = "need egress",
|
||||||
) -> Proposal:
|
) -> Proposal:
|
||||||
return Proposal.new(
|
return Proposal.new(
|
||||||
bottle_slug="dev",
|
bottle_slug="dev",
|
||||||
@@ -57,7 +57,7 @@ class TestProposalRoundtrip(unittest.TestCase):
|
|||||||
self.assertTrue(p.id)
|
self.assertTrue(p.id)
|
||||||
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
|
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
|
||||||
self.assertEqual("dev", p.bottle_slug)
|
self.assertEqual("dev", p.bottle_slug)
|
||||||
self.assertEqual(TOOL_CAPABILITY_BLOCK, p.tool)
|
self.assertEqual(TOOL_EGRESS_ALLOW, p.tool)
|
||||||
|
|
||||||
def test_to_from_dict_roundtrip(self):
|
def test_to_from_dict_roundtrip(self):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
@@ -142,14 +142,14 @@ class TestQueueIO(unittest.TestCase):
|
|||||||
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.
|
||||||
a = Proposal.new(
|
a = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_ALLOW,
|
||||||
proposed_file="FROM python:3.13\n", justification="early",
|
proposed_file="routes:\n - host: early.example.com\n", justification="early",
|
||||||
current_file_hash="x",
|
current_file_hash="x",
|
||||||
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
b = Proposal.new(
|
b = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_ALLOW,
|
||||||
proposed_file="FROM python:3.13\n", justification="late",
|
proposed_file="routes:\n - host: late.example.com\n", justification="late",
|
||||||
current_file_hash="x",
|
current_file_hash="x",
|
||||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
@@ -319,7 +319,6 @@ class TestToolConstants(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(
|
(
|
||||||
supervise.TOOL_EGRESS_ALLOW,
|
supervise.TOOL_EGRESS_ALLOW,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
|
||||||
supervise.TOOL_EGRESS_BLOCK,
|
supervise.TOOL_EGRESS_BLOCK,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
supervise.TOOL_EGRESS_TOKEN_ALLOW,
|
supervise.TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
@@ -378,20 +377,16 @@ class TestSupervisePrepare(unittest.TestCase):
|
|||||||
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
||||||
return lambda: setattr(supervise, "bot_bottle_root", original)
|
return lambda: setattr(supervise, "bot_bottle_root", original)
|
||||||
|
|
||||||
def test_prepare_creates_queue_and_current_config(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.queue_dir.is_dir())
|
||||||
self.assertTrue(plan.current_config_dir.is_dir())
|
|
||||||
self.assertEqual("dev", plan.slug)
|
self.assertEqual("dev", plan.slug)
|
||||||
self.assertEqual("", plan.internal_network)
|
self.assertEqual("", plan.internal_network)
|
||||||
|
|
||||||
def test_prepare_writes_no_files_to_current_config(self):
|
def test_prepare_does_not_create_current_config_dir(self):
|
||||||
# dockerfile_content is no longer accepted by prepare.
|
|
||||||
# routes.yaml + allowlist live behind the
|
|
||||||
# `list-egress-routes` MCP tool (PRD 0017 chunk 3).
|
|
||||||
plan = _StubSupervise().prepare("dev", self.stage_dir)
|
plan = _StubSupervise().prepare("dev", self.stage_dir)
|
||||||
files = sorted(p.name for p in plan.current_config_dir.iterdir())
|
self.assertFalse((self.stage_dir / "current-config").exists())
|
||||||
self.assertEqual([], files)
|
self.assertFalse(hasattr(plan, "current_config_dir"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from bot_bottle.supervise import (
|
|||||||
STATUS_APPROVED,
|
STATUS_APPROVED,
|
||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
@@ -30,9 +30,8 @@ from bot_bottle.supervise import (
|
|||||||
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal:
|
||||||
payloads = {
|
payloads = {
|
||||||
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
|
||||||
supervise.TOOL_EGRESS_ALLOW: "routes:\n - host: example.com\n",
|
supervise.TOOL_EGRESS_ALLOW: "routes:\n - host: example.com\n",
|
||||||
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
|
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
|
||||||
TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n",
|
TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n",
|
||||||
@@ -86,14 +85,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
|
|||||||
|
|
||||||
def test_sorted_by_arrival_across_bottles(self):
|
def test_sorted_by_arrival_across_bottles(self):
|
||||||
early = Proposal.new(
|
early = Proposal.new(
|
||||||
bottle_slug="api", tool=TOOL_CAPABILITY_BLOCK,
|
bottle_slug="api", tool=TOOL_EGRESS_ALLOW,
|
||||||
proposed_file="FROM python:3.13\n", justification="early",
|
proposed_file="routes:\n - host: early.example.com\n", justification="early",
|
||||||
current_file_hash="h",
|
current_file_hash="h",
|
||||||
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
late = Proposal.new(
|
late = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_ALLOW,
|
||||||
proposed_file="FROM python:3.13\n", justification="late",
|
proposed_file="routes:\n - host: late.example.com\n", justification="late",
|
||||||
current_file_hash="h",
|
current_file_hash="h",
|
||||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
@@ -122,7 +121,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self._teardown_fake_home()
|
self._teardown_fake_home()
|
||||||
|
|
||||||
def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK):
|
def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
|
||||||
p = _proposal(tool=tool)
|
p = _proposal(tool=tool)
|
||||||
qdir = supervise.queue_dir_for_slug("dev")
|
qdir = supervise.queue_dir_for_slug("dev")
|
||||||
qdir.mkdir(parents=True, exist_ok=True)
|
qdir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -131,19 +130,29 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
|
|
||||||
def test_approve_writes_response(self):
|
def test_approve_writes_response(self):
|
||||||
qp = self._enqueue()
|
qp = self._enqueue()
|
||||||
supervise_cli.approve(qp)
|
with patch(
|
||||||
# capability-block is archived on approve, so the response file
|
"bot_bottle.cli.supervise.apply_routes_change",
|
||||||
# moves to processed/ before the caller can read it.
|
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
|
||||||
resp = read_response(qp.queue_dir / "processed", qp.proposal.id)
|
):
|
||||||
|
supervise_cli.approve(qp)
|
||||||
|
resp = read_response(qp.queue_dir, 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)
|
||||||
|
|
||||||
def test_approve_with_final_file_marks_modified(self):
|
def test_approve_with_final_file_marks_modified(self):
|
||||||
qp = self._enqueue()
|
qp = self._enqueue()
|
||||||
supervise_cli.approve(qp, final_file="FROM bookworm\n", notes="tweaked")
|
with patch(
|
||||||
resp = read_response(qp.queue_dir / "processed", qp.proposal.id)
|
"bot_bottle.cli.supervise.apply_routes_change",
|
||||||
|
return_value=("routes: []\n", "routes:\n - host: edited.example.com\n"),
|
||||||
|
):
|
||||||
|
supervise_cli.approve(
|
||||||
|
qp,
|
||||||
|
final_file="routes:\n - host: edited.example.com\n",
|
||||||
|
notes="tweaked",
|
||||||
|
)
|
||||||
|
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_MODIFIED, resp.status)
|
self.assertEqual(STATUS_MODIFIED, resp.status)
|
||||||
self.assertEqual("FROM bookworm\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)
|
||||||
|
|
||||||
def test_reject_writes_rejection(self):
|
def test_reject_writes_rejection(self):
|
||||||
@@ -153,11 +162,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual(STATUS_REJECTED, resp.status)
|
self.assertEqual(STATUS_REJECTED, resp.status)
|
||||||
self.assertEqual("nope", resp.notes)
|
self.assertEqual("nope", resp.notes)
|
||||||
|
|
||||||
def test_no_audit_log_for_capability_block(self):
|
|
||||||
qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK)
|
|
||||||
supervise_cli.approve(qp)
|
|
||||||
self.assertEqual([], read_audit_entries("egress", "dev"))
|
|
||||||
|
|
||||||
def test_approve_egress_block_writes_audit_log(self):
|
def test_approve_egress_block_writes_audit_log(self):
|
||||||
qp = self._enqueue(tool=supervise.TOOL_EGRESS_BLOCK)
|
qp = self._enqueue(tool=supervise.TOOL_EGRESS_BLOCK)
|
||||||
with patch(
|
with patch(
|
||||||
@@ -232,11 +236,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual(".txt", supervise_cli._suffix_for_tool(TOOL_EGRESS_TOKEN_ALLOW))
|
self.assertEqual(".txt", supervise_cli._suffix_for_tool(TOOL_EGRESS_TOKEN_ALLOW))
|
||||||
|
|
||||||
|
|
||||||
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
# # DISABLED — capability_apply functionality is currently commented out.
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
class TestEditInEditor(unittest.TestCase):
|
class TestEditInEditor(unittest.TestCase):
|
||||||
def test_runs_editor_returns_edited_content(self):
|
def test_runs_editor_returns_edited_content(self):
|
||||||
original_editor = os.environ.get("EDITOR")
|
original_editor = os.environ.get("EDITOR")
|
||||||
@@ -281,10 +280,5 @@ class TestEditInEditor(unittest.TestCase):
|
|||||||
os.environ["EDITOR"] = original_editor
|
os.environ["EDITOR"] = original_editor
|
||||||
|
|
||||||
|
|
||||||
# class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
# # DISABLED — capability_apply functionality is currently commented out.
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -50,15 +50,15 @@ from bot_bottle.supervise_server import (
|
|||||||
|
|
||||||
|
|
||||||
class TestValidation(unittest.TestCase):
|
class TestValidation(unittest.TestCase):
|
||||||
def test_capability_block_accepts_anything_nonempty(self):
|
|
||||||
validate_proposed_file(
|
|
||||||
_sv.TOOL_CAPABILITY_BLOCK,
|
|
||||||
"FROM python:3.13\nRUN apk add git\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
|
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
|
||||||
with self.assertRaises(_RpcError):
|
with self.assertRaises(_RpcError):
|
||||||
validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t")
|
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, " \n\t")
|
||||||
|
|
||||||
|
def test_capability_block_rejected_as_unknown_tool(self):
|
||||||
|
with self.assertRaises(_RpcError) as cm:
|
||||||
|
validate_proposed_file("capability-block", "FROM python:3.13\n")
|
||||||
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
self.assertIn("unknown tool", cm.exception.message)
|
||||||
|
|
||||||
def test_egress_routes_yaml_is_validated(self):
|
def test_egress_routes_yaml_is_validated(self):
|
||||||
validate_proposed_file(
|
validate_proposed_file(
|
||||||
@@ -127,9 +127,9 @@ class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
|
|||||||
with self.assertRaises(_RpcInternalError) as cm:
|
with self.assertRaises(_RpcInternalError) as cm:
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"justification": "x",
|
"justification": "x",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -219,7 +219,6 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted([
|
sorted([
|
||||||
_sv.TOOL_EGRESS_ALLOW,
|
_sv.TOOL_EGRESS_ALLOW,
|
||||||
_sv.TOOL_CAPABILITY_BLOCK,
|
|
||||||
_sv.TOOL_EGRESS_BLOCK,
|
_sv.TOOL_EGRESS_BLOCK,
|
||||||
_sv.TOOL_LIST_EGRESS_ROUTES,
|
_sv.TOOL_LIST_EGRESS_ROUTES,
|
||||||
]),
|
]),
|
||||||
@@ -295,10 +294,10 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
result = handle_tools_call(
|
result = handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_BLOCK,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"justification": "need git",
|
"justification": "need example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
self.config,
|
self.config,
|
||||||
@@ -335,9 +334,9 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
result = handle_tools_call(
|
result = handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"justification": "needed for tests",
|
"justification": "needed for tests",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -359,20 +358,35 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
with self.assertRaises(_RpcError):
|
with self.assertRaises(_RpcError):
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {"dockerfile": "FROM python:3.13\n"},
|
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
|
||||||
},
|
},
|
||||||
self.config,
|
self.config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_capability_block_call_raises_unknown_tool(self):
|
||||||
|
with self.assertRaises(_RpcError) as cm:
|
||||||
|
handle_tools_call(
|
||||||
|
{
|
||||||
|
"name": "capability-block",
|
||||||
|
"arguments": {
|
||||||
|
"dockerfile": "FROM python:3.13\n",
|
||||||
|
"justification": "need git",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
self.config,
|
||||||
|
)
|
||||||
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
self.assertIn("unknown tool", cm.exception.message)
|
||||||
|
|
||||||
def test_archives_proposal_after_response(self):
|
def test_archives_proposal_after_response(self):
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
|
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
|
||||||
try:
|
try:
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"justification": "x",
|
"justification": "x",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -394,10 +408,10 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
result = handle_tools_call(
|
result = handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"justification": "need a capability",
|
"justification": "need egress",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
@@ -521,7 +535,7 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
self.assertEqual("2.0", result["jsonrpc"])
|
self.assertEqual("2.0", result["jsonrpc"])
|
||||||
self.assertEqual(1, result["id"])
|
self.assertEqual(1, result["id"])
|
||||||
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
|
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
|
||||||
self.assertIn(_sv.TOOL_CAPABILITY_BLOCK, names)
|
self.assertNotIn("capability-block", names)
|
||||||
self.assertIn(_sv.TOOL_EGRESS_ALLOW, names)
|
self.assertIn(_sv.TOOL_EGRESS_ALLOW, names)
|
||||||
self.assertIn(_sv.TOOL_EGRESS_BLOCK, names)
|
self.assertIn(_sv.TOOL_EGRESS_BLOCK, names)
|
||||||
|
|
||||||
@@ -541,9 +555,9 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
"id": 99,
|
"id": 99,
|
||||||
"method": "tools/call",
|
"method": "tools/call",
|
||||||
"params": {
|
"params": {
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"justification": "x",
|
"justification": "x",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user