agentboxer 1.0.1__py3-none-any.whl
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.
- agentbox/__init__.py +1 -0
- agentbox/__main__.py +3 -0
- agentbox/cli.py +296 -0
- agentbox/config.py +127 -0
- agentbox/share/devcontainer.base.json +22 -0
- agentbox/share/post-create.sh +14 -0
- agentboxer-1.0.1.dist-info/METADATA +279 -0
- agentboxer-1.0.1.dist-info/RECORD +12 -0
- agentboxer-1.0.1.dist-info/WHEEL +5 -0
- agentboxer-1.0.1.dist-info/entry_points.txt +2 -0
- agentboxer-1.0.1.dist-info/licenses/LICENSE +21 -0
- agentboxer-1.0.1.dist-info/top_level.txt +1 -0
agentbox/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
agentbox/__main__.py
ADDED
agentbox/cli.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import config as cfg
|
|
12
|
+
|
|
13
|
+
DEVCONTAINER_CLI = ["npx", "--yes", "@devcontainers/cli"]
|
|
14
|
+
CONTAINER_USER = "dev"
|
|
15
|
+
CONTAINER_SSH_PORT = 2222
|
|
16
|
+
EDITORS = ("code-oss", "codium", "code")
|
|
17
|
+
GITIGNORE_ENTRY = cfg.MERGED_IN_PROJECT
|
|
18
|
+
UP_BINARIES = ("docker", "npx", "ssh-keygen")
|
|
19
|
+
EXEC_BINARIES = ("docker", "npx")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def missing_binaries(names: tuple[str, ...]) -> list[str]:
|
|
23
|
+
return [name for name in names if shutil.which(name) is None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_binaries(names: tuple[str, ...]) -> int:
|
|
27
|
+
missing = missing_binaries(names)
|
|
28
|
+
if not missing:
|
|
29
|
+
return 0
|
|
30
|
+
print(f"✖ missing on this host: {', '.join(missing)}", file=sys.stderr)
|
|
31
|
+
print(f" agentbox needs: {', '.join(names)}", file=sys.stderr)
|
|
32
|
+
return 3
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def state_dir() -> Path:
|
|
36
|
+
override = os.environ.get("AGENTBOX_HOME")
|
|
37
|
+
if override:
|
|
38
|
+
return Path(override)
|
|
39
|
+
return Path.home() / ".config" / "agentbox"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ssh_dir(state: Path) -> Path:
|
|
43
|
+
return state / "ssh.d"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def include_line(state: Path) -> str:
|
|
47
|
+
return f"Include {ssh_dir(state)}/*.conf"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _capture(cmd: list[str]) -> str:
|
|
51
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
52
|
+
return result.stdout.strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def container_id(workspace: Path) -> str | None:
|
|
56
|
+
label = f"label=devcontainer.local_folder={workspace.resolve()}"
|
|
57
|
+
out = _capture(["docker", "ps", "-aq", "--filter", label])
|
|
58
|
+
ids = out.splitlines()
|
|
59
|
+
if not ids:
|
|
60
|
+
return None
|
|
61
|
+
return ids[0]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ssh_port(cid: str) -> int:
|
|
65
|
+
out = _capture(["docker", "port", cid, f"{CONTAINER_SSH_PORT}/tcp"])
|
|
66
|
+
first = out.splitlines()[0]
|
|
67
|
+
return int(first.rsplit(":", 1)[1])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ensure_key(state: Path, alias: str) -> Path:
|
|
71
|
+
key_file = state / "keys" / alias / "id_ed25519"
|
|
72
|
+
if key_file.exists():
|
|
73
|
+
return key_file
|
|
74
|
+
key_file.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
subprocess.run(
|
|
76
|
+
[
|
|
77
|
+
"ssh-keygen",
|
|
78
|
+
"-q",
|
|
79
|
+
"-t",
|
|
80
|
+
"ed25519",
|
|
81
|
+
"-N",
|
|
82
|
+
"",
|
|
83
|
+
"-C",
|
|
84
|
+
f"agentbox-{alias}",
|
|
85
|
+
"-f",
|
|
86
|
+
str(key_file),
|
|
87
|
+
],
|
|
88
|
+
check=True,
|
|
89
|
+
)
|
|
90
|
+
return key_file
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def exec_command(workspace: Path, override: Path | None, command: list[str]) -> list[str]:
|
|
94
|
+
prefix = [*DEVCONTAINER_CLI, "exec", "--workspace-folder", str(workspace)]
|
|
95
|
+
if override is not None:
|
|
96
|
+
prefix += ["--override-config", str(override)]
|
|
97
|
+
return [*prefix, *command]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def inject_key(workspace: Path, override: Path | None, key_file: Path) -> None:
|
|
101
|
+
remote = (
|
|
102
|
+
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
|
|
103
|
+
"cat >~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
|
104
|
+
)
|
|
105
|
+
subprocess.run(
|
|
106
|
+
exec_command(workspace, override, ["bash", "-c", remote]),
|
|
107
|
+
input=key_file.with_suffix(".pub").read_text(encoding="utf-8"),
|
|
108
|
+
text=True,
|
|
109
|
+
check=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def alias_dir(state: Path) -> Path:
|
|
114
|
+
return state / "aliases"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def claim_alias(workspace: Path, state: Path) -> str:
|
|
118
|
+
alias = cfg.alias_for(workspace, alias_dir(state))
|
|
119
|
+
cfg.claim_alias(alias_dir(state), alias, workspace)
|
|
120
|
+
return alias
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def alias_for(workspace: Path, state: Path) -> str:
|
|
124
|
+
return cfg.alias_for(workspace, alias_dir(state))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_ssh_config(state: Path, alias: str, port: int, key_file: Path) -> bool:
|
|
128
|
+
target = ssh_dir(state) / f"{alias}.conf"
|
|
129
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
target.write_text(
|
|
131
|
+
cfg.ssh_config_block(alias, port, key_file, CONTAINER_USER),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
)
|
|
134
|
+
user_config = Path.home() / ".ssh" / "config"
|
|
135
|
+
try:
|
|
136
|
+
return include_line(state) in user_config.read_text(encoding="utf-8")
|
|
137
|
+
except OSError:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def up_command(workspace: Path, override: Path | None, rebuild: bool) -> list[str]:
|
|
142
|
+
command = [*DEVCONTAINER_CLI, "up", "--workspace-folder", str(workspace)]
|
|
143
|
+
if override is not None:
|
|
144
|
+
command += ["--override-config", str(override)]
|
|
145
|
+
if not override.is_relative_to(workspace):
|
|
146
|
+
command += ["--no-lockfile"]
|
|
147
|
+
if rebuild:
|
|
148
|
+
command += ["--remove-existing-container"]
|
|
149
|
+
return command
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_up(args: argparse.Namespace) -> int:
|
|
153
|
+
blocked = require_binaries(UP_BINARIES)
|
|
154
|
+
if blocked:
|
|
155
|
+
return blocked
|
|
156
|
+
|
|
157
|
+
workspace = args.workspace.resolve()
|
|
158
|
+
state = state_dir()
|
|
159
|
+
alias = claim_alias(workspace, state)
|
|
160
|
+
key_file = ensure_key(state, alias)
|
|
161
|
+
|
|
162
|
+
override = cfg.resolve_config(workspace, state / "run", cfg.SHARE_DIR, alias)
|
|
163
|
+
subprocess.run(up_command(workspace, override, args.rebuild), check=True)
|
|
164
|
+
|
|
165
|
+
cid = container_id(workspace)
|
|
166
|
+
if cid is None:
|
|
167
|
+
print("✖ container not found after up", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
inject_key(workspace, override, key_file)
|
|
171
|
+
port = ssh_port(cid)
|
|
172
|
+
included = write_ssh_config(state, alias, port, key_file)
|
|
173
|
+
|
|
174
|
+
print(f"\nagentbox '{alias}' is up on 127.0.0.1:{port}\n")
|
|
175
|
+
print(" agentbox run claude agent inside the container")
|
|
176
|
+
print(" agentbox shell shell inside the container")
|
|
177
|
+
print(" agentbox code open the editor on the container\n")
|
|
178
|
+
if not included:
|
|
179
|
+
print(f"Add this line once to ~/.ssh/config, then 'ssh {alias}' works:")
|
|
180
|
+
print(f" {include_line(state)}\n")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
185
|
+
blocked = require_binaries(EXEC_BINARIES)
|
|
186
|
+
if blocked:
|
|
187
|
+
return blocked
|
|
188
|
+
|
|
189
|
+
workspace = args.workspace.resolve()
|
|
190
|
+
state = state_dir()
|
|
191
|
+
alias = alias_for(workspace, state)
|
|
192
|
+
override = cfg.resolve_config(workspace, state / "run", cfg.SHARE_DIR, alias)
|
|
193
|
+
return subprocess.run(exec_command(workspace, override, args.command)).returncode
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_shell(args: argparse.Namespace) -> int:
|
|
197
|
+
args.command = ["zsh"]
|
|
198
|
+
return cmd_run(args)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_code(args: argparse.Namespace) -> int:
|
|
202
|
+
workspace = args.workspace.resolve()
|
|
203
|
+
alias = alias_for(workspace, state_dir())
|
|
204
|
+
editor = next((shutil.which(name) for name in EDITORS if shutil.which(name)), None)
|
|
205
|
+
if editor is None:
|
|
206
|
+
print(f"✖ no editor found, looked for: {', '.join(EDITORS)}", file=sys.stderr)
|
|
207
|
+
return 1
|
|
208
|
+
command = [editor, "--remote", f"ssh-remote+{alias}", cfg.workspace_target(workspace)]
|
|
209
|
+
return subprocess.run(command).returncode
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_down(args: argparse.Namespace) -> int:
|
|
213
|
+
blocked = require_binaries(("docker",))
|
|
214
|
+
if blocked:
|
|
215
|
+
return blocked
|
|
216
|
+
|
|
217
|
+
workspace = args.workspace.resolve()
|
|
218
|
+
cid = container_id(workspace)
|
|
219
|
+
if cid is None:
|
|
220
|
+
print(f"→ no agentbox container for {workspace}")
|
|
221
|
+
return 0
|
|
222
|
+
subprocess.run(["docker", "rm", "-f", cid], check=True)
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
227
|
+
workspace = args.workspace.resolve()
|
|
228
|
+
target = workspace / cfg.PROJECT_CONFIG
|
|
229
|
+
if target.exists() and not args.force:
|
|
230
|
+
print(f"✖ {target} exists, use --force to overwrite", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
config = cfg.apply_alias(
|
|
233
|
+
json.loads(cfg.BASE_CONFIG.read_text(encoding="utf-8")),
|
|
234
|
+
claim_alias(workspace, state_dir()),
|
|
235
|
+
)
|
|
236
|
+
config["postCreateCommand"] = "npm install -g $AGENTBOX_AGENTS"
|
|
237
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
target.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
239
|
+
print(f"→ wrote {target}")
|
|
240
|
+
|
|
241
|
+
gitignore = workspace / ".gitignore"
|
|
242
|
+
if gitignore.exists() and GITIGNORE_ENTRY not in gitignore.read_text(encoding="utf-8"):
|
|
243
|
+
with gitignore.open("a", encoding="utf-8") as handle:
|
|
244
|
+
handle.write(f"{GITIGNORE_ENTRY}\n")
|
|
245
|
+
print(f"→ added {GITIGNORE_ENTRY} to .gitignore")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
250
|
+
parser = argparse.ArgumentParser(
|
|
251
|
+
prog="agentbox",
|
|
252
|
+
description="Run coding agents in a per-project dev container sandbox.",
|
|
253
|
+
)
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"--workspace",
|
|
256
|
+
type=Path,
|
|
257
|
+
default=Path.cwd(),
|
|
258
|
+
help="project directory (default: current directory)",
|
|
259
|
+
)
|
|
260
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
261
|
+
|
|
262
|
+
up = subparsers.add_parser("up", help="build and start the sandbox")
|
|
263
|
+
up.add_argument("--rebuild", action="store_true", help="recreate an existing container")
|
|
264
|
+
up.set_defaults(func=cmd_up)
|
|
265
|
+
|
|
266
|
+
run = subparsers.add_parser("run", help="run a command inside the sandbox")
|
|
267
|
+
run.add_argument("command", nargs=argparse.REMAINDER)
|
|
268
|
+
run.set_defaults(func=cmd_run)
|
|
269
|
+
|
|
270
|
+
subparsers.add_parser("shell", help="open a shell inside the sandbox").set_defaults(
|
|
271
|
+
func=cmd_shell
|
|
272
|
+
)
|
|
273
|
+
subparsers.add_parser("code", help="open the editor on the sandbox").set_defaults(
|
|
274
|
+
func=cmd_code
|
|
275
|
+
)
|
|
276
|
+
subparsers.add_parser("down", help="remove the sandbox container").set_defaults(
|
|
277
|
+
func=cmd_down
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
init = subparsers.add_parser("init", help="write a project devcontainer.json")
|
|
281
|
+
init.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
282
|
+
init.set_defaults(func=cmd_init)
|
|
283
|
+
|
|
284
|
+
return parser
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: list[str] | None = None) -> int:
|
|
288
|
+
args = build_parser().parse_args(argv)
|
|
289
|
+
if args.subcommand == "run" and not args.command:
|
|
290
|
+
print("✖ agentbox run needs a command", file=sys.stderr)
|
|
291
|
+
return 2
|
|
292
|
+
return args.func(args)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
raise SystemExit(main())
|
agentbox/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SHARE_DIR = Path(__file__).resolve().parent / "share"
|
|
9
|
+
BASE_CONFIG = SHARE_DIR / "devcontainer.base.json"
|
|
10
|
+
POST_CREATE = "bash /agentbox/post-create.sh"
|
|
11
|
+
SHARE_MOUNT_TARGET = "/agentbox"
|
|
12
|
+
PROJECT_CONFIG = ".devcontainer/devcontainer.json"
|
|
13
|
+
LOCAL_OVERRIDE = ".devcontainer/agentbox.local.json"
|
|
14
|
+
MERGED_IN_PROJECT = ".devcontainer/.agentbox.json"
|
|
15
|
+
BASENAME_PLACEHOLDER = "${localWorkspaceFolderBasename}"
|
|
16
|
+
DIGEST_LENGTH = 6
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def alias_registry(registry_dir: Path | None) -> dict[str, Path]:
|
|
20
|
+
if registry_dir is None or not registry_dir.is_dir():
|
|
21
|
+
return {}
|
|
22
|
+
return {
|
|
23
|
+
entry.name: Path(entry.read_text(encoding="utf-8").strip())
|
|
24
|
+
for entry in sorted(registry_dir.iterdir())
|
|
25
|
+
if entry.is_file()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def claim_alias(registry_dir: Path, alias: str, workspace: Path) -> None:
|
|
30
|
+
registry_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
(registry_dir / alias).write_text(f"{workspace.resolve()}\n", encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def alias_for(workspace: Path, registry_dir: Path | None = None) -> str:
|
|
35
|
+
workspace = workspace.resolve()
|
|
36
|
+
slug = re.sub(r"[^A-Za-z0-9._-]", "-", workspace.name).strip("-")
|
|
37
|
+
if not slug:
|
|
38
|
+
raise ValueError(f"cannot derive an alias from {workspace}")
|
|
39
|
+
|
|
40
|
+
registry = alias_registry(registry_dir)
|
|
41
|
+
for alias, owner in registry.items():
|
|
42
|
+
if owner == workspace:
|
|
43
|
+
return alias
|
|
44
|
+
|
|
45
|
+
if slug not in registry:
|
|
46
|
+
return slug
|
|
47
|
+
|
|
48
|
+
digest = hashlib.sha256(str(workspace).encode("utf-8")).hexdigest()[:DIGEST_LENGTH]
|
|
49
|
+
return f"{slug}-{digest}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def apply_alias(config: dict, alias: str) -> dict:
|
|
53
|
+
config["name"] = alias
|
|
54
|
+
mounts = config.get("mounts")
|
|
55
|
+
if mounts:
|
|
56
|
+
config["mounts"] = [
|
|
57
|
+
mount.replace(BASENAME_PLACEHOLDER, alias) for mount in mounts
|
|
58
|
+
]
|
|
59
|
+
return config
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def workspace_target(workspace: Path) -> str:
|
|
63
|
+
return f"/workspaces/{workspace.resolve().name}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def deep_merge(base: dict, override: dict) -> dict:
|
|
67
|
+
merged = dict(base)
|
|
68
|
+
for key, value in override.items():
|
|
69
|
+
current = merged.get(key)
|
|
70
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
71
|
+
merged[key] = deep_merge(current, value)
|
|
72
|
+
else:
|
|
73
|
+
merged[key] = value
|
|
74
|
+
return merged
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_json(path: Path) -> dict:
|
|
78
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def base_config(share_dir: Path, alias: str) -> dict:
|
|
82
|
+
config = apply_alias(_read_json(BASE_CONFIG), alias)
|
|
83
|
+
mounts = list(config.get("mounts", []))
|
|
84
|
+
mounts.append(
|
|
85
|
+
f"source={share_dir},target={SHARE_MOUNT_TARGET},type=bind,readonly"
|
|
86
|
+
)
|
|
87
|
+
config["mounts"] = mounts
|
|
88
|
+
config["postCreateCommand"] = POST_CREATE
|
|
89
|
+
return config
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_config(workspace: Path, state_dir: Path, share_dir: Path, alias: str) -> Path | None:
|
|
93
|
+
workspace = workspace.resolve()
|
|
94
|
+
project = workspace / PROJECT_CONFIG
|
|
95
|
+
override = workspace / LOCAL_OVERRIDE
|
|
96
|
+
|
|
97
|
+
if project.exists():
|
|
98
|
+
if not override.exists():
|
|
99
|
+
return None
|
|
100
|
+
merged = deep_merge(_read_json(project), _read_json(override))
|
|
101
|
+
target = workspace / MERGED_IN_PROJECT
|
|
102
|
+
target.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8")
|
|
103
|
+
return target
|
|
104
|
+
|
|
105
|
+
config = base_config(share_dir, alias)
|
|
106
|
+
if override.exists():
|
|
107
|
+
config = deep_merge(config, _read_json(override))
|
|
108
|
+
target = state_dir / alias / "devcontainer.json"
|
|
109
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
target.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
111
|
+
return target
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ssh_config_block(alias: str, port: int, key_file: Path, user: str) -> str:
|
|
115
|
+
return "\n".join(
|
|
116
|
+
[
|
|
117
|
+
f"Host {alias}",
|
|
118
|
+
" HostName 127.0.0.1",
|
|
119
|
+
f" Port {port}",
|
|
120
|
+
f" User {user}",
|
|
121
|
+
f" IdentityFile {key_file}",
|
|
122
|
+
" IdentitiesOnly yes",
|
|
123
|
+
" StrictHostKeyChecking no",
|
|
124
|
+
" UserKnownHostsFile /dev/null",
|
|
125
|
+
"",
|
|
126
|
+
]
|
|
127
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"image": "docker.io/library/debian:bookworm",
|
|
3
|
+
"features": {
|
|
4
|
+
"ghcr.io/devcontainers/features/common-utils:2": {
|
|
5
|
+
"username": "dev",
|
|
6
|
+
"installZsh": true,
|
|
7
|
+
"configureZshAsDefaultShell": true
|
|
8
|
+
},
|
|
9
|
+
"ghcr.io/devcontainers/features/git:1": {},
|
|
10
|
+
"ghcr.io/devcontainers/features/node:1": {},
|
|
11
|
+
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
|
12
|
+
"ghcr.io/devcontainers/features/sshd:1": {}
|
|
13
|
+
},
|
|
14
|
+
"remoteUser": "dev",
|
|
15
|
+
"appPort": ["127.0.0.1::2222"],
|
|
16
|
+
"containerEnv": {
|
|
17
|
+
"AGENTBOX_AGENTS": "@anthropic-ai/claude-code"
|
|
18
|
+
},
|
|
19
|
+
"mounts": [
|
|
20
|
+
"source=agentbox-home-${localWorkspaceFolderBasename},target=/home/dev,type=volume"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
: "${AGENTBOX_AGENTS:?set containerEnv.AGENTBOX_AGENTS in the devcontainer config}"
|
|
5
|
+
|
|
6
|
+
read -r -a agents <<<"$AGENTBOX_AGENTS"
|
|
7
|
+
|
|
8
|
+
if [ "${#agents[@]}" -eq 0 ]; then
|
|
9
|
+
echo "→ no agents requested"
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
echo "→ installing agents: ${agents[*]}"
|
|
14
|
+
npm install -g "${agents[@]}"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentboxer
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Run coding agents in a per-project dev container sandbox.
|
|
5
|
+
Author-email: Kevin Veen-Birkenbach <kevin@veen.world>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kevinveenbirkenbach/agentbox
|
|
8
|
+
Project-URL: Source, https://github.com/kevinveenbirkenbach/agentbox
|
|
9
|
+
Project-URL: Issues, https://github.com/kevinveenbirkenbach/agentbox/issues
|
|
10
|
+
Keywords: devcontainer,sandbox,agent,docker,claude-code
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# agentbox
|
|
27
|
+
|
|
28
|
+
Run coding agents in a per-project dev container sandbox.
|
|
29
|
+
|
|
30
|
+
The agent gets a container with its own Docker daemon and nothing of the host but the project directory: no host Docker socket, no host home, no sibling repositories. One command per project, no per-project boilerplate.
|
|
31
|
+
|
|
32
|
+
Homepage: https://github.com/kevinveenbirkenbach/agentbox
|
|
33
|
+
|
|
34
|
+
## How it fits together
|
|
35
|
+
|
|
36
|
+
```mermaid
|
|
37
|
+
flowchart LR
|
|
38
|
+
subgraph host["Host"]
|
|
39
|
+
editor["Code - OSS / VSCodium<br/>open-remote-ssh"]
|
|
40
|
+
cli["agentbox CLI"]
|
|
41
|
+
state["~/.config/agentbox<br/>aliases + keys + ssh.d/*.conf"]
|
|
42
|
+
dockerd["host docker daemon"]
|
|
43
|
+
repo[("project directory")]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
subgraph layers["Configuration layers"]
|
|
47
|
+
base["1 base<br/>share/devcontainer.base.json"]
|
|
48
|
+
project["2 project<br/>.devcontainer/devcontainer.json"]
|
|
49
|
+
override["3 local<br/>.devcontainer/agentbox.local.json"]
|
|
50
|
+
merged["effective devcontainer.json"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
subgraph box["agentbox container"]
|
|
54
|
+
sshd["sshd on 2222"]
|
|
55
|
+
exthost["remote extension host<br/>agent extension"]
|
|
56
|
+
agent["agent CLI<br/>claude / codex / ..."]
|
|
57
|
+
dind["own docker daemon"]
|
|
58
|
+
workspace["/workspaces/PROJECT"]
|
|
59
|
+
nested["containers the agent starts"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
base --> merged
|
|
63
|
+
project --> merged
|
|
64
|
+
override --> merged
|
|
65
|
+
cli -- "deep merge" --> merged
|
|
66
|
+
merged -- "override config" --> devcli["npx @devcontainers/cli"]
|
|
67
|
+
cli --> devcli
|
|
68
|
+
devcli -- "build and start" --> dockerd
|
|
69
|
+
dockerd -- "creates" --> box
|
|
70
|
+
cli -- "ssh key, host entry" --> state
|
|
71
|
+
cli -- "agentbox run / shell" --> agent
|
|
72
|
+
editor -- "reads" --> state
|
|
73
|
+
editor -- "ssh 127.0.0.1 on a free port" --> sshd
|
|
74
|
+
sshd --> exthost
|
|
75
|
+
exthost <-- "~/.claude/ide + localhost" --> agent
|
|
76
|
+
agent --> workspace
|
|
77
|
+
agent --> dind
|
|
78
|
+
dind --> nested
|
|
79
|
+
repo -- "bind mount" --> workspace
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
What the picture says:
|
|
83
|
+
|
|
84
|
+
- The agent only ever reaches `/workspaces/PROJECT`, which is the bind-mounted project directory. No host home, no sibling repositories.
|
|
85
|
+
- The container talks to its **own** Docker daemon. The host daemon is used once, by the CLI, to create the sandbox — the agent never gets a handle on it.
|
|
86
|
+
- The agent extension runs in the container, next to the agent CLI, because the two communicate over `~/.claude/ide` plus localhost. An extension host on the host side cannot reach either.
|
|
87
|
+
- The three configuration layers are merged on the host and handed to the devcontainer CLI as one file; nothing has to be edited by hand.
|
|
88
|
+
|
|
89
|
+
## Install
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pipx install agentboxer
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The distribution is named `agentboxer` because `agentbox` is taken on PyPI and `agentbox-cli` collides with an existing project once PyPI strips the separators; the command it installs is `agentbox`.
|
|
96
|
+
|
|
97
|
+
Independent of PyPI, from a checkout:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
make install
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The package itself has no Python dependencies, but it drives three host binaries and refuses to start without them:
|
|
104
|
+
|
|
105
|
+
| Binary | Used for |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `docker` | building and running the sandbox |
|
|
108
|
+
| `npx` (Node.js) | fetching the [devcontainer CLI](https://github.com/devcontainers/cli) |
|
|
109
|
+
| `ssh-keygen` | the per-project key |
|
|
110
|
+
|
|
111
|
+
## Quickstart
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cd ~/Repositories/some-project
|
|
115
|
+
agentbox up
|
|
116
|
+
agentbox run claude
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`agentbox up` builds and starts the sandbox, publishes its SSH port on a free `127.0.0.1` port, installs a per-project key, and writes an SSH host entry named after the project directory.
|
|
120
|
+
|
|
121
|
+
## Commands
|
|
122
|
+
|
|
123
|
+
| Command | What it does |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `agentbox up [--rebuild]` | Build and start the sandbox for the current directory |
|
|
126
|
+
| `agentbox run <cmd…>` | Run a command inside the sandbox, e.g. `agentbox run claude` |
|
|
127
|
+
| `agentbox shell` | Open a shell inside the sandbox |
|
|
128
|
+
| `agentbox code` | Open Code - OSS / VSCodium / VS Code on the sandbox |
|
|
129
|
+
| `agentbox down` | Remove the sandbox container |
|
|
130
|
+
| `agentbox init` | Write a project-owned `.devcontainer/devcontainer.json` |
|
|
131
|
+
|
|
132
|
+
All commands accept `--workspace <dir>` and otherwise act on the current directory.
|
|
133
|
+
|
|
134
|
+
## One box per repository
|
|
135
|
+
|
|
136
|
+
Boxes run side by side and share nothing. Each project gets its own container, SSH port, key, host entry and agent home volume:
|
|
137
|
+
|
|
138
|
+
| Resource | Keyed by |
|
|
139
|
+
|---|---|
|
|
140
|
+
| Container | label `devcontainer.local_folder=<absolute path>` |
|
|
141
|
+
| SSH port | published by Docker on a free `127.0.0.1` port |
|
|
142
|
+
| Key, host entry | `~/.config/agentbox/keys/<alias>/`, `~/.config/agentbox/ssh.d/<alias>.conf` |
|
|
143
|
+
| Agent home (logins, history) | volume `agentbox-home-<alias>` |
|
|
144
|
+
|
|
145
|
+
The alias is the project directory name. Two repositories with the same directory name — `~/work/web` and `~/client/web` — would otherwise collide in all of the above, so the first one to claim `web` keeps it and the next gets a path digest appended: `web-0ac1b2`. Claims live in `~/.config/agentbox/aliases/` and are sticky, so an alias never changes under a running box.
|
|
146
|
+
|
|
147
|
+
Removing a box: `agentbox down`, plus `docker volume rm agentbox-home-<alias>` if its agent state should go too.
|
|
148
|
+
|
|
149
|
+
## Configuration layers
|
|
150
|
+
|
|
151
|
+
Later layers win; each is optional.
|
|
152
|
+
|
|
153
|
+
| Layer | File | Versioned |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| 1. agentbox default | `src/agentbox/share/devcontainer.base.json` | in this repo |
|
|
156
|
+
| 2. Project | `<project>/.devcontainer/devcontainer.json` | in the project |
|
|
157
|
+
| 3. Local override | `<project>/.devcontainer/agentbox.local.json` | no, gitignore it |
|
|
158
|
+
|
|
159
|
+
Layer 3 is deep-merged over whatever layer sits below it; dictionaries merge, lists and scalars are replaced. The merged result is handed to the devcontainer CLI via `--override-config`, so nothing needs to be edited by hand.
|
|
160
|
+
|
|
161
|
+
Example — this project needs Codex instead of Claude and a Python toolchain, but only on this machine:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"containerEnv": { "AGENTBOX_AGENTS": "@openai/codex" },
|
|
166
|
+
"features": { "ghcr.io/devcontainers/features/python:1": {} }
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Agents are npm packages listed in `AGENTBOX_AGENTS`, installed on first start.
|
|
171
|
+
|
|
172
|
+
## Editor
|
|
173
|
+
|
|
174
|
+
The agent extension must run inside the container, otherwise it cannot reach the agent CLI. That happens automatically once the editor window itself is remote.
|
|
175
|
+
|
|
176
|
+
1. Install `jeanp413.open-remote-ssh` from Open VSX (the proprietary Dev Containers extension is not needed and is unavailable on Open VSX).
|
|
177
|
+
2. Add this line once to `~/.ssh/config`:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
Include ~/.config/agentbox/ssh.d/*.conf
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
3. `agentbox code`, or connect manually to the host entry named after the project and open `/workspaces/<project>`.
|
|
184
|
+
|
|
185
|
+
Ports change on every rebuild; `agentbox up` rewrites the host entry each time, so the alias stays valid.
|
|
186
|
+
|
|
187
|
+
## Projects that already have a devcontainer.json
|
|
188
|
+
|
|
189
|
+
`agentbox up` uses the project's own config unchanged. To install the agents from there, add the feature in this repository:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
"features": { "ghcr.io/kevinveenbirkenbach/agentbox/agentbox:0": { "agents": "@anthropic-ai/claude-code" } }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The feature source lives in `features/agentbox/`; publish it with `devcontainer features publish`.
|
|
196
|
+
|
|
197
|
+
## Limitations
|
|
198
|
+
|
|
199
|
+
- The project directory is bind-mounted, so build artifacts inside it (`.venv/`, `node_modules/`) are shared with the host and can collide between host and container toolchains. Mount them as volumes in layer 3 if that bites.
|
|
200
|
+
- Containers started *inside* the sandbox run in its nested Docker daemon; their published ports are not reachable from the host.
|
|
201
|
+
- Network access is not restricted yet — the sandbox isolates the filesystem and the Docker daemon, not the internet.
|
|
202
|
+
- The agent CLIs themselves are proprietary; only the sandbox around them is open source.
|
|
203
|
+
|
|
204
|
+
## Other agents
|
|
205
|
+
|
|
206
|
+
`AGENTBOX_AGENTS` is a space separated list of npm packages installed on first start. Override it per project in `.devcontainer/agentbox.local.json`:
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"containerEnv": {
|
|
211
|
+
"AGENTBOX_AGENTS": "@anthropic-ai/claude-code @openai/codex @google/gemini-cli"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Then `agentbox up --rebuild` and `agentbox run codex`. Agents that are not npm packages (pipx tools, plain binaries) have no install path yet. The Pi agent (`@oh-my-pi/pi-coding-agent`, binary `omp`) needs bun rather than node.
|
|
217
|
+
|
|
218
|
+
## Local LLMs
|
|
219
|
+
|
|
220
|
+
The sandbox has its own network namespace, so an Ollama or LM Studio server running on the **host** is not reachable from inside by default. Punch one hole into `.devcontainer/agentbox.local.json`:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"runArgs": ["--add-host=host.docker.internal:host-gateway"],
|
|
225
|
+
"containerEnv": {
|
|
226
|
+
"OLLAMA_BASE_URL": "http://host.docker.internal:11434",
|
|
227
|
+
"OLLAMA_HOST": "http://host.docker.internal:11434"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
What each agent does with that, verified in the e2e suite below:
|
|
233
|
+
|
|
234
|
+
| Agent | Ollama | LM Studio | Invocation |
|
|
235
|
+
|---|---|---|---|
|
|
236
|
+
| codex | yes | yes | `codex exec -c model_provider=x -c model_providers.x.base_url=<url>/v1 -c model_providers.x.wire_api=responses -c model_providers.x.requires_openai_auth=false -m <model>` |
|
|
237
|
+
| pi (`omp`) | yes | catalog discovery works | `omp --model ollama/<model>` with `OLLAMA_BASE_URL` set, or `omp --model lm-studio/<model>` |
|
|
238
|
+
| Claude Code | via proxy | via proxy | Anthropic protocol only — needs a translator (e.g. LiteLLM) behind `ANTHROPIC_BASE_URL` |
|
|
239
|
+
|
|
240
|
+
Three constraints found the hard way, each encoded in the e2e suite:
|
|
241
|
+
|
|
242
|
+
- codex accepts only `wire_api = "responses"`; the chat-completions wire was removed. Both servers implement that endpoint.
|
|
243
|
+
- `codex --oss` insists on a daemon at `localhost:11434` and ignores `OLLAMA_HOST`, so a remote endpoint needs an explicit provider.
|
|
244
|
+
- Agent CLIs need a model that supports tool calling. `smollm2:135m` answers plain chat requests but fails every agent.
|
|
245
|
+
|
|
246
|
+
## Tests
|
|
247
|
+
|
|
248
|
+
Everything at once — unit tests plus the end-to-end suite:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
make test
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Unit tests alone, no containers:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
make test-unit
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
End-to-end against real local LLMs, fully isolated in compose — Ollama, LM Studio in headless server mode, and a runner carrying codex and pi. No host network, no API keys, no accounts:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
make test-e2e # tears the stack down afterwards
|
|
264
|
+
bash tests/e2e/run.sh --keep # leaves it up for debugging
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Models are pulled once into named volumes: `qwen2.5:0.5b` for Ollama, and for LM Studio the Hugging Face repository pinned in `tests/e2e/.env` — its CLI resolves search terms only against staff picks, so the source is a full URL rather than a name. Twelve checks then assert reachability, the native and OpenAI-compatible endpoints, and that codex and pi actually answer from a local model.
|
|
268
|
+
|
|
269
|
+
The runner shares the LM Studio container's network namespace, so LM Studio sits on `localhost:1234` exactly as the agent CLIs expect while Ollama stays reachable by service name.
|
|
270
|
+
|
|
271
|
+
Everything runs in CI on every push and pull request, and again before a release.
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT — see [LICENSE](LICENSE).
|
|
276
|
+
|
|
277
|
+
## Author
|
|
278
|
+
|
|
279
|
+
Kevin Veen-Birkenbach <kevin@veen.world>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agentbox/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
agentbox/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
agentbox/cli.py,sha256=eJXYokEwWLu2FRIqP7yQqbL7xuzp4OwXIB0jHiRm-Zk,9463
|
|
4
|
+
agentbox/config.py,sha256=zHFR3luI0tag5ASYMvU5R9GJO5svpAs9GqiyXPgaSl8,4047
|
|
5
|
+
agentbox/share/devcontainer.base.json,sha256=5lgLEERBxP1Bs81Qs3QNj5MMR7BB87G-VzFOfRGRdZY,672
|
|
6
|
+
agentbox/share/post-create.sh,sha256=-2I6H-cpjMtZ_izobSUAZChs_0Wr244Y3wucNdTITSQ,319
|
|
7
|
+
agentboxer-1.0.1.dist-info/licenses/LICENSE,sha256=HZ45yCTAIaoS9tYIZ6YbYNXkq3_7rpN5-IwPLc32_FY,1078
|
|
8
|
+
agentboxer-1.0.1.dist-info/METADATA,sha256=DBtblfWE8iWBOwwKSo_6hxhdZQ26ae4R-JeDKJ94Ia8,11723
|
|
9
|
+
agentboxer-1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
agentboxer-1.0.1.dist-info/entry_points.txt,sha256=u_320LVs7Vndr2ti0gFQXhR8o9kkjW0LmNjLzEXLr50,47
|
|
11
|
+
agentboxer-1.0.1.dist-info/top_level.txt,sha256=lNpen_9W4iU-XQIGpizrWb3jS7tj6k27fUBWT4u0HEQ,9
|
|
12
|
+
agentboxer-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Veen-Birkenbach
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentbox
|