Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3f55357cc |
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|||||||
@@ -61,13 +61,6 @@ class AgentProviderRuntime:
|
|||||||
prompt_mode: PromptMode
|
prompt_mode: PromptMode
|
||||||
bypass_args: tuple[str, ...]
|
bypass_args: tuple[str, ...]
|
||||||
resume_args: tuple[str, ...]
|
resume_args: tuple[str, ...]
|
||||||
# argv run inside a throwaway container of a freshly built agent
|
|
||||||
# image, right after `build_image()`, to catch a build that
|
|
||||||
# exited 0 but produced a broken CLI (e.g. an npm
|
|
||||||
# optionalDependencies fetch for a platform-native binary that
|
|
||||||
# silently no-ops on a transient failure). Empty tuple skips the
|
|
||||||
# check — not every provider has opted in yet.
|
|
||||||
smoke_test: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ from contextlib import ExitStack, contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...agent_provider import runtime_for
|
|
||||||
from ...egress import egress_resolve_token_values
|
from ...egress import egress_resolve_token_values
|
||||||
from ...git_gate import (
|
from ...git_gate import (
|
||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
@@ -112,9 +111,6 @@ def launch(
|
|||||||
plan.image, _REPO_DIR,
|
plan.image, _REPO_DIR,
|
||||||
dockerfile=plan.dockerfile_path,
|
dockerfile=plan.dockerfile_path,
|
||||||
)
|
)
|
||||||
docker_mod.verify_agent_image(
|
|
||||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ existence, and building images."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -109,45 +108,15 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
|
|
||||||
`dockerfile` is an optional path (relative to `context`, or
|
`dockerfile` is an optional path (relative to `context`, or
|
||||||
absolute) for callers that need to build from a non-default
|
absolute) for callers that need to build from a non-default
|
||||||
Dockerfile in the same context — e.g. `Dockerfile.git-gate`.
|
Dockerfile in the same context — e.g. `Dockerfile.git-gate`."""
|
||||||
|
|
||||||
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
|
||||||
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
|
||||||
shell out to can silently no-op on a transient network failure —
|
|
||||||
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
|
||||||
and Docker will then cache that broken layer indefinitely."""
|
|
||||||
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||||
args = ["docker", "build", "-t", ref]
|
args = ["docker", "build", "-t", ref]
|
||||||
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
|
||||||
args.append("--no-cache")
|
|
||||||
if dockerfile:
|
if dockerfile:
|
||||||
args.extend(["-f", dockerfile])
|
args.extend(["-f", dockerfile])
|
||||||
args.append(context)
|
args.append(context)
|
||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
|
||||||
"""Run `argv` inside a throwaway container of a freshly built agent
|
|
||||||
image and die loudly if it fails, instead of shipping an image
|
|
||||||
whose CLI only breaks at first real use. No-op when the provider
|
|
||||||
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
|
||||||
if not argv:
|
|
||||||
return
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
detail = (result.stderr or result.stdout or "").strip()
|
|
||||||
die(
|
|
||||||
f"agent image {image!r} failed its post-build smoke test "
|
|
||||||
f"({' '.join(argv)}): {detail}\n"
|
|
||||||
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# def build_image_with_cwd(
|
# def build_image_with_cwd(
|
||||||
# derived: str,
|
# derived: str,
|
||||||
# base: str,
|
# base: str,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from contextlib import ExitStack, contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...agent_provider import runtime_for
|
|
||||||
from ...bottle_state import (
|
from ...bottle_state import (
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
git_gate_state_dir,
|
git_gate_state_dir,
|
||||||
@@ -170,9 +169,6 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|||||||
_REPO_DIR,
|
_REPO_DIR,
|
||||||
dockerfile=plan.dockerfile_path,
|
dockerfile=plan.dockerfile_path,
|
||||||
)
|
)
|
||||||
container_mod.verify_agent_image(
|
|
||||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
|
||||||
)
|
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,21 +60,13 @@ def dns_server() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||||
"""Build an OCI image with Apple's BuildKit-backed `container build`.
|
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
||||||
|
|
||||||
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
|
||||||
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
|
||||||
shell out to can silently no-op on a transient network failure —
|
|
||||||
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
|
||||||
and the builder will then cache that broken layer indefinitely."""
|
|
||||||
info(
|
info(
|
||||||
f"building image {ref} from {context} with Apple Container "
|
f"building image {ref} from {context} with Apple Container "
|
||||||
"(layer cache keeps repeat builds fast)"
|
"(layer cache keeps repeat builds fast)"
|
||||||
)
|
)
|
||||||
_ensure_builder_dns()
|
_ensure_builder_dns()
|
||||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||||
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
|
||||||
args.append("--no-cache")
|
|
||||||
if dockerfile:
|
if dockerfile:
|
||||||
# `container build` resolves -f relative to the current working
|
# `container build` resolves -f relative to the current working
|
||||||
# directory, not the build context. Anchor a relative Dockerfile to
|
# directory, not the build context. Anchor a relative Dockerfile to
|
||||||
@@ -86,28 +78,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
|
||||||
"""Run `argv` inside a throwaway container of a freshly built agent
|
|
||||||
image and die loudly if it fails, instead of shipping an image
|
|
||||||
whose CLI only breaks at first real use. No-op when the provider
|
|
||||||
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
|
||||||
if not argv:
|
|
||||||
return
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
detail = (result.stderr or result.stdout or "").strip()
|
|
||||||
die(
|
|
||||||
f"agent image {image!r} failed its post-build smoke test "
|
|
||||||
f"({' '.join(argv)}): {detail}\n"
|
|
||||||
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
def commit_container(container_name: str, image_tag: str) -> None:
|
||||||
"""Snapshot a running Apple Container as a local image.
|
"""Snapshot a running Apple Container as a local image.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from contextlib import ExitStack, contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...agent_provider import runtime_for
|
|
||||||
from ...egress import (
|
from ...egress import (
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
EGRESS_ROUTES_IN_CONTAINER,
|
||||||
egress_agent_env_entries,
|
egress_agent_env_entries,
|
||||||
@@ -463,13 +462,10 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
|||||||
return _ensure_smolmachine(
|
return _ensure_smolmachine(
|
||||||
plan.agent_image,
|
plan.agent_image,
|
||||||
dockerfile=plan.agent_dockerfile_path,
|
dockerfile=plan.agent_dockerfile_path,
|
||||||
smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(
|
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||||
image_ref: str, *, dockerfile: str = "", smoke_test: tuple[str, ...] = (),
|
|
||||||
) -> Path:
|
|
||||||
"""Build the agent docker image and convert it into a
|
"""Build the agent docker image and convert it into a
|
||||||
`.smolmachine` artifact, caching the result under
|
`.smolmachine` artifact, caching the result under
|
||||||
`~/.cache/bot-bottle/smolmachines/` keyed by the docker image
|
`~/.cache/bot-bottle/smolmachines/` keyed by the docker image
|
||||||
@@ -495,7 +491,6 @@ def _ensure_smolmachine(
|
|||||||
already on disk for this image ID."""
|
already on disk for this image ID."""
|
||||||
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
|
docker_mod.build_image(image_ref, _REPO_DIR, dockerfile=dockerfile)
|
||||||
docker_mod.verify_agent_image(image_ref, smoke_test)
|
|
||||||
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
|
||||||
# keep filenames manageable, long enough to make collisions
|
# keep filenames manageable, long enough to make collisions
|
||||||
# astronomically unlikely.
|
# astronomically unlikely.
|
||||||
|
|||||||
@@ -46,17 +46,6 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||||
parser.add_argument(
|
|
||||||
"--no-cache",
|
|
||||||
action="store_true",
|
|
||||||
help=(
|
|
||||||
"rebuild agent/sidecar images from scratch, bypassing the "
|
|
||||||
"build layer cache. Use when an image looks broken after a "
|
|
||||||
"dependency bump — e.g. an installer's optionalDependencies "
|
|
||||||
"fetch silently no-op'd on a transient failure and got baked "
|
|
||||||
"into a cached layer."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
choices=known_backend_names(),
|
choices=known_backend_names(),
|
||||||
@@ -108,11 +97,6 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
if args.no_cache or os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
|
||||||
# Read by build_image() in each backend's util module — set here
|
|
||||||
# so both the interactive and --headless paths pick it up without
|
|
||||||
# threading a no_cache field through every backend's plan dataclass.
|
|
||||||
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
|
|
||||||
|
|
||||||
manifest = ManifestIndex.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
backend_name: str | None = args.backend
|
backend_name: str | None = args.backend
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
prompt_mode="append_file",
|
prompt_mode="append_file",
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
resume_args=("--continue",),
|
resume_args=("--continue",),
|
||||||
smoke_test=("claude", "--version"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
prompt_mode="read_prompt_file",
|
prompt_mode="read_prompt_file",
|
||||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
resume_args=("resume", "--last"),
|
resume_args=("resume", "--last"),
|
||||||
smoke_test=(_CODEX_CLI, "--version"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PRD 0068: smolmachines backend on Linux
|
# PRD prd-new: smolmachines backend on Linux
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Draft
|
||||||
- **Author:** Claude
|
- **Author:** Claude
|
||||||
- **Created:** 2026-06-25
|
- **Created:** 2026-06-25
|
||||||
- **Issue:** #283
|
- **Issue:** #283
|
||||||
@@ -103,8 +103,6 @@ class TestLaunchCommittedImage(unittest.TestCase):
|
|||||||
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
||||||
), mock.patch.object(
|
), mock.patch.object(
|
||||||
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.docker_mod, "verify_agent_image",
|
|
||||||
), mock.patch.object(
|
), mock.patch.object(
|
||||||
launch_mod, "egress_tls_init",
|
launch_mod, "egress_tls_init",
|
||||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ class TestTeardownWarning(unittest.TestCase):
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
|
|
||||||
with mock.patch.object(launch_mod.docker_mod, "build_image"), \
|
with mock.patch.object(launch_mod.docker_mod, "build_image"), \
|
||||||
mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \
|
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
launch_mod, "egress_tls_init",
|
launch_mod, "egress_tls_init",
|
||||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||||
|
|||||||
@@ -357,17 +357,12 @@ class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
|||||||
launch.container_mod, "image_exists", return_value=False,
|
launch.container_mod, "image_exists", return_value=False,
|
||||||
), patch.object(
|
), patch.object(
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
launch.container_mod, "build_image", side_effect=fake_build,
|
||||||
), patch.object(
|
):
|
||||||
launch.container_mod, "verify_agent_image",
|
|
||||||
) as verify:
|
|
||||||
updated = launch._build_images(plan)
|
updated = launch._build_images(plan)
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
||||||
self.assertEqual(2, len(calls))
|
self.assertEqual(2, len(calls))
|
||||||
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
||||||
verify.assert_called_once_with(
|
|
||||||
"bot-bottle-agent:latest", ("claude", "--version"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ class TestAgentFromPath(unittest.TestCase):
|
|||||||
slug="dev-abc12",
|
slug="dev-abc12",
|
||||||
agent_image="bot-bottle-claude:latest",
|
agent_image="bot-bottle-claude:latest",
|
||||||
agent_dockerfile_path="/repo/Dockerfile",
|
agent_dockerfile_path="/repo/Dockerfile",
|
||||||
agent_provider_template="claude",
|
|
||||||
))
|
))
|
||||||
|
|
||||||
def test_uses_committed_artifact_when_present(self):
|
def test_uses_committed_artifact_when_present(self):
|
||||||
@@ -186,7 +185,6 @@ class TestAgentFromPath(unittest.TestCase):
|
|||||||
ensure.assert_called_once_with(
|
ensure.assert_called_once_with(
|
||||||
"bot-bottle-claude:latest",
|
"bot-bottle-claude:latest",
|
||||||
dockerfile="/repo/Dockerfile",
|
dockerfile="/repo/Dockerfile",
|
||||||
smoke_test=("claude", "--version"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user