botbox 0.0.2__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: botbox
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Run a coding agent inside a locked-down Docker container.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/botbox
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -0,0 +1,93 @@
1
+ import hashlib
2
+ from collections.abc import Sequence
3
+ from contextlib import suppress
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+
7
+ from python_on_whales import docker
8
+ from python_on_whales.exceptions import DockerException
9
+
10
+ from botbox.agents.agent import Agent
11
+ from botbox.bind_mounts import Mount, bind_mounts
12
+ from botbox.collect import collect
13
+ from botbox.lockdown import lockdown
14
+ from botbox.RunResult import RunResult
15
+
16
+
17
+ class Botbox:
18
+ """An agent, an optional image layer on top of it, and the extra hosts its proxy admits."""
19
+
20
+ def __init__(
21
+ self,
22
+ agent: Agent,
23
+ *,
24
+ dockerfile: str | Path | None = None,
25
+ allow: Sequence[str] = (),
26
+ ) -> None:
27
+ self.agent = agent
28
+ self.dockerfile = None if dockerfile is None else Path(dockerfile).resolve()
29
+ self.allow = tuple(allow)
30
+
31
+ def build(self) -> str:
32
+ """Build the agent's image, then `dockerfile` on top, and return the final tag.
33
+
34
+ Built on every call; Docker's layer cache keeps an unchanged build fast.
35
+ """
36
+ agent_dockerfile = self.agent.dockerfile
37
+ docker.build(
38
+ agent_dockerfile.parent, tags=self.agent.image, file=agent_dockerfile, progress=False
39
+ )
40
+ if self.dockerfile is None:
41
+ return self.agent.image
42
+ digest = hashlib.sha256(str(self.dockerfile).encode()).hexdigest()[:12]
43
+ tag = f"{self.agent.image.split(':')[0]}-{digest}:latest"
44
+ docker.build(self.dockerfile.parent, tags=tag, file=self.dockerfile, progress=False)
45
+ return tag
46
+
47
+ def run(
48
+ self,
49
+ command: str,
50
+ *,
51
+ mounts: Sequence[Mount] = (),
52
+ workdir: str | Path | None = None,
53
+ ) -> RunResult:
54
+ """Run `command` as `sh -c` in the locked-down container and return what it left.
55
+
56
+ Mounts and `workdir` are checked before anything is built. The agent's home is a fresh
57
+ host temp directory with the credentials staged in; the proxy logs into the same
58
+ directory. Both are read into the result once the container is gone, then deleted.
59
+ A non-zero exit is a normal result; so is the daemon dying mid-run, with `exit_code`
60
+ -1 and whatever output, transcript and proxy log made it to the host. Raises only when
61
+ setup fails or the run is interrupted, after tearing down what was started.
62
+ """
63
+ agent = self.agent
64
+ volumes = bind_mounts(mounts, workdir=workdir, home=agent.home)
65
+ image = self.build()
66
+ with TemporaryDirectory(prefix="botbox-") as scratch:
67
+ home = Path(scratch) / "home"
68
+ home.mkdir()
69
+ agent.stage_auth(home)
70
+ with lockdown((*agent.allow, *self.allow), Path(scratch)) as jail:
71
+ container = docker.run(
72
+ image,
73
+ ["sh", "-c", command],
74
+ detach=True,
75
+ envs=jail.envs,
76
+ volumes=[(str(home), agent.home, "rw"), *volumes],
77
+ workdir=agent.home if workdir is None else Path(workdir).as_posix(),
78
+ networks=[jail.network],
79
+ user="node",
80
+ cap_drop=["ALL"],
81
+ security_options=["no-new-privileges"],
82
+ )
83
+ try:
84
+ stdout, stderr, exit_code = collect(container)
85
+ finally:
86
+ with suppress(DockerException):
87
+ docker.container.remove(container, force=True)
88
+ transcript = {
89
+ path.relative_to(home).as_posix(): path.read_text()
90
+ for path in sorted(home.glob(agent.transcripts))
91
+ if path.is_file()
92
+ }
93
+ return RunResult(stdout, stderr, exit_code, transcript, jail.log)
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class RunResult:
6
+ """What a run left behind. Nothing parsed.
7
+
8
+ `exit_code` is -1 when no exit code came back: the Docker daemon went away mid-run.
9
+ `transcript` maps each transcript file's path, relative to the agent's home, to its text.
10
+ `proxy` is the proxy's log: every connection, admitted or refused.
11
+ """
12
+
13
+ stdout: str
14
+ stderr: str
15
+ exit_code: int
16
+ transcript: dict[str, str]
17
+ proxy: str
@@ -2,5 +2,6 @@
2
2
 
3
3
  from botbox.agents import Agent, ClaudeAgent, PiAgent
4
4
  from botbox.Botbox import Botbox
5
+ from botbox.RunResult import RunResult
5
6
 
6
- __all__ = ["Agent", "Botbox", "ClaudeAgent", "PiAgent"]
7
+ __all__ = ["Agent", "Botbox", "ClaudeAgent", "PiAgent", "RunResult"]
@@ -1,3 +1,4 @@
1
+ import shlex
1
2
  import shutil
2
3
  from pathlib import Path
3
4
 
@@ -13,9 +14,27 @@ class ClaudeAgent:
13
14
  transcripts = ".claude/projects/**/*.jsonl"
14
15
  allow: tuple[str, ...] = ("api.anthropic.com",)
15
16
 
16
- def __init__(self, *, host_home: Path | None = None) -> None:
17
+ def __init__(
18
+ self,
19
+ model: str | None = None,
20
+ effort: str | None = None,
21
+ *,
22
+ host_home: Path | None = None,
23
+ ) -> None:
24
+ self.model = model
25
+ self.effort = effort
17
26
  self.host_home = Path.home() / ".claude" if host_home is None else host_home
18
27
 
28
+ def command(self, prompt: str) -> str:
29
+ command = (
30
+ f"claude -p {shlex.quote(prompt)} --output-format json --dangerously-skip-permissions"
31
+ )
32
+ if self.model is not None:
33
+ command += f" --model {self.model}"
34
+ if self.effort is not None:
35
+ command += f" --effort {self.effort}"
36
+ return command
37
+
19
38
  def stage_auth(self, home: Path) -> None:
20
39
  destination = home / ".claude"
21
40
  destination.mkdir()
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import shlex
2
3
  import shutil
3
4
  from pathlib import Path
4
5
 
@@ -29,13 +30,22 @@ class PiAgent:
29
30
  home = "/home/node"
30
31
  transcripts = ".pi/agent/sessions/**/*.jsonl"
31
32
 
32
- def __init__(self, provider: str, *, host_home: Path | None = None) -> None:
33
+ def __init__(
34
+ self, provider: str, model: str | None = None, *, host_home: Path | None = None
35
+ ) -> None:
33
36
  if provider not in PROVIDERS:
34
37
  raise ValueError(f"unknown provider {provider!r}: expected {', '.join(PROVIDERS)}")
35
38
  self.provider = provider
39
+ self.model = model
36
40
  self.allow = resolve_allow(provider)
37
41
  self.host_home = Path.home() / ".pi" / "agent" if host_home is None else host_home
38
42
 
43
+ def command(self, prompt: str) -> str:
44
+ command = f"pi -p {shlex.quote(prompt)} --mode json --provider {self.provider}"
45
+ if self.model is not None:
46
+ command += f" --model {self.model}"
47
+ return command
48
+
39
49
  def stage_auth(self, home: Path) -> None:
40
50
  destination = home / ".pi" / "agent"
41
51
  destination.mkdir(parents=True)
@@ -17,3 +17,7 @@ class Agent(Protocol):
17
17
  def stage_auth(self, home: Path) -> None:
18
18
  """Copy the host's credentials into `home`, the per-run temp home."""
19
19
  ...
20
+
21
+ def command(self, prompt: str) -> str:
22
+ """The agent's CLI invocation for `prompt`, as a shell string for `run()`."""
23
+ ...
@@ -0,0 +1,28 @@
1
+ import posixpath
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ Mount = tuple[str | Path, str | Path, Literal["ro", "rw"]]
7
+
8
+
9
+ def bind_mounts(
10
+ mounts: Sequence[Mount], *, workdir: str | Path | None, home: str
11
+ ) -> list[tuple[str, str, str]]:
12
+ """Check `mounts` and `workdir` and return the mounts as docker volumes.
13
+
14
+ A missing host path raises: Docker would create it, root-owned. So would a `workdir`
15
+ outside every mount, the agent's home included, so that raises too.
16
+ """
17
+ volumes: list[tuple[str, str, str]] = []
18
+ for host, container, mode in mounts:
19
+ source = Path(host).resolve()
20
+ if not source.exists():
21
+ raise FileNotFoundError(f"mount source {host} does not exist")
22
+ volumes.append((str(source), Path(container).as_posix(), mode))
23
+ if workdir is not None:
24
+ start = posixpath.normpath(Path(workdir).as_posix())
25
+ targets = [home, *(posixpath.normpath(target) for _, target, _ in volumes)]
26
+ if not any(start == t or start.startswith(t.rstrip("/") + "/") for t in targets):
27
+ raise ValueError(f"workdir {workdir} is not a mounted path or inside one")
28
+ return volumes
@@ -0,0 +1,26 @@
1
+ from collections.abc import Iterable
2
+ from typing import cast
3
+
4
+ from python_on_whales import docker
5
+ from python_on_whales.components.container.cli_wrapper import Container
6
+ from python_on_whales.exceptions import DockerException
7
+
8
+
9
+ def collect(container: Container | str) -> tuple[str, str, int]:
10
+ """Follow a container to its exit: its stdout, its stderr, and its exit code.
11
+
12
+ Output streams to the host as the container writes it. If the daemon goes away first,
13
+ what already arrived is kept and the exit code is -1.
14
+ """
15
+ output: dict[str, list[bytes]] = {"stdout": [], "stderr": []}
16
+ exit_code = -1
17
+ try:
18
+ # stream=True makes logs() yield (source, chunk) pairs instead of returning a str.
19
+ stream = cast(Iterable[tuple[str, bytes]], docker.logs(container, follow=True, stream=True))
20
+ for source, chunk in stream:
21
+ output[source].append(chunk)
22
+ exit_code = docker.wait(container)
23
+ except DockerException:
24
+ pass # The daemon died mid-run: return what arrived rather than raise.
25
+ stdout, stderr = (b"".join(output[s]).decode(errors="replace") for s in ("stdout", "stderr"))
26
+ return stdout, stderr, exit_code
@@ -0,0 +1,99 @@
1
+ import re
2
+ from collections.abc import Generator, Iterable
3
+ from contextlib import contextmanager, suppress
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ from python_on_whales import docker
8
+ from python_on_whales import utils as docker_utils
9
+ from python_on_whales.exceptions import DockerException
10
+
11
+ PROXY_DIR = Path(__file__).with_name("proxy")
12
+ PROXY_IMAGE = "botbox-proxy:latest"
13
+ PROXY_PORT = 8888
14
+ PROXY_LOG_DIR = "/var/log/tinyproxy"
15
+
16
+
17
+ class Lockdown:
18
+ """The network a locked-down container joins, the proxy env it runs under, and the proxy log."""
19
+
20
+ def __init__(self, network: str, envs: dict[str, str]) -> None:
21
+ self.network = network
22
+ self.envs = envs
23
+ self.log = ""
24
+
25
+
26
+ @contextmanager
27
+ def lockdown(allow: Iterable[str], scratch: Path) -> Generator[Lockdown]:
28
+ """Yield the network and proxy env a locked-down container runs under.
29
+
30
+ The container joins an `--internal` network: no gateway, no route out, no external DNS.
31
+ Its only way out is a CONNECT to the sidecar, which sits on a second network and refuses
32
+ anything outside `allow`. tinyproxy writes its log into `scratch` on the host as it runs,
33
+ so the log survives the daemon; it is read into `Lockdown.log` on the way out.
34
+ """
35
+ docker.build(PROXY_DIR, tags=PROXY_IMAGE, file=PROXY_DIR / "Dockerfile", progress=False)
36
+
37
+ run_id = uuid4().hex[:12]
38
+ proxy_name = f"botbox-proxy-{run_id}"
39
+ url = f"http://{proxy_name}:{PROXY_PORT}"
40
+ jail = Lockdown(
41
+ network=f"botbox-jail-{run_id}",
42
+ envs={
43
+ "HTTP_PROXY": url,
44
+ "HTTPS_PROXY": url,
45
+ "http_proxy": url,
46
+ "https_proxy": url,
47
+ "NO_PROXY": "localhost,127.0.0.1",
48
+ },
49
+ )
50
+ egress = f"botbox-egress-{run_id}"
51
+
52
+ allowlist = scratch / "filter"
53
+ allowlist.write_text("".join(f"^{re.escape(host)}$\n" for host in allow))
54
+ allowlist.chmod(0o644)
55
+ # The proxy runs as its own user with no capabilities: it needs the bits to write here.
56
+ log_dir = scratch / "proxy"
57
+ log_dir.mkdir()
58
+ log_dir.chmod(0o755)
59
+ log = log_dir / "tinyproxy.log"
60
+ log.touch()
61
+ log.chmod(0o666)
62
+
63
+ proxy = None
64
+ networks = []
65
+ try:
66
+ # python-on-whales' network.create wraps no --internal, and --internal is the point.
67
+ docker_utils.run(
68
+ [*docker.network.docker_cmd, "network", "create", "--internal", jail.network]
69
+ )
70
+ networks.append(jail.network)
71
+ docker.network.create(egress)
72
+ networks.append(egress)
73
+ proxy = docker.run(
74
+ PROXY_IMAGE,
75
+ detach=True,
76
+ name=proxy_name,
77
+ networks=[egress],
78
+ volumes=[
79
+ (str(allowlist), "/etc/tinyproxy/filter", "ro"),
80
+ (str(log_dir), PROXY_LOG_DIR, "rw"),
81
+ ],
82
+ cap_drop=["ALL"],
83
+ security_options=["no-new-privileges"],
84
+ )
85
+ docker.network.connect(jail.network, proxy)
86
+ yield jail
87
+ finally:
88
+ gone = False
89
+ if proxy is not None:
90
+ with suppress(DockerException):
91
+ docker.container.remove(proxy, force=True)
92
+ gone = True
93
+ for network in networks:
94
+ if proxy is not None and not gone:
95
+ with suppress(DockerException):
96
+ docker.network.disconnect(network, proxy, force=True)
97
+ with suppress(DockerException):
98
+ docker.network.remove(network)
99
+ jail.log = log.read_text()
@@ -7,6 +7,8 @@ MaxClients 100
7
7
 
8
8
  # Notice carries the refusals, which are the only evidence an allowlist entry is argued from.
9
9
  LogLevel Info
10
+ # A bind-mounted host directory: the log survives the daemon.
11
+ LogFile "/var/log/tinyproxy/tinyproxy.log"
10
12
 
11
13
  ConnectPort 443
12
14
 
@@ -1,38 +0,0 @@
1
- import hashlib
2
- from collections.abc import Sequence
3
- from pathlib import Path
4
-
5
- from python_on_whales import docker
6
-
7
- from botbox.agents.agent import Agent
8
-
9
-
10
- class Botbox:
11
- """An agent, an optional image layer on top of it, and the extra hosts its proxy admits."""
12
-
13
- def __init__(
14
- self,
15
- agent: Agent,
16
- *,
17
- dockerfile: str | Path | None = None,
18
- allow: Sequence[str] = (),
19
- ) -> None:
20
- self.agent = agent
21
- self.dockerfile = None if dockerfile is None else Path(dockerfile).resolve()
22
- self.allow = tuple(allow)
23
-
24
- def build(self) -> str:
25
- """Build the agent's image, then `dockerfile` on top, and return the final tag.
26
-
27
- Built on every call; Docker's layer cache keeps an unchanged build fast.
28
- """
29
- agent_dockerfile = self.agent.dockerfile
30
- docker.build(
31
- agent_dockerfile.parent, tags=self.agent.image, file=agent_dockerfile, progress=False
32
- )
33
- if self.dockerfile is None:
34
- return self.agent.image
35
- digest = hashlib.sha256(str(self.dockerfile).encode()).hexdigest()[:12]
36
- tag = f"{self.agent.image.split(':')[0]}-{digest}:latest"
37
- docker.build(self.dockerfile.parent, tags=tag, file=self.dockerfile, progress=False)
38
- return tag
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes