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.
- {botbox-0.0.2 → botbox-0.0.4}/PKG-INFO +1 -1
- botbox-0.0.4/src/botbox/Botbox.py +93 -0
- botbox-0.0.4/src/botbox/RunResult.py +17 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/__init__.py +2 -1
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/agents/ClaudeAgent.py +20 -1
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/agents/PiAgent.py +11 -1
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/agents/agent.py +4 -0
- botbox-0.0.4/src/botbox/bind_mounts.py +28 -0
- botbox-0.0.4/src/botbox/collect.py +26 -0
- botbox-0.0.4/src/botbox/lockdown.py +99 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/proxy/tinyproxy.conf +2 -0
- botbox-0.0.2/src/botbox/Botbox.py +0 -38
- {botbox-0.0.2 → botbox-0.0.4}/.gitignore +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/CHANGELOG.md +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/MIGRATIONS.md +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/README.md +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/pyproject.toml +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/agents/__init__.py +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/agents/claude.Dockerfile +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/agents/pi.Dockerfile +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/proxy/Dockerfile +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/src/botbox/py.typed +0 -0
- {botbox-0.0.2 → botbox-0.0.4}/uv.lock +0 -0
|
@@ -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
|
|
@@ -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__(
|
|
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__(
|
|
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()
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|