agent-leash 0.3.0__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.
- agent_leash-0.3.0.dist-info/METADATA +18 -0
- agent_leash-0.3.0.dist-info/RECORD +17 -0
- agent_leash-0.3.0.dist-info/WHEEL +4 -0
- agent_leash-0.3.0.dist-info/entry_points.txt +2 -0
- agent_leash-0.3.0.dist-info/licenses/LICENSE +21 -0
- aleash/__init__.py +0 -0
- aleash/bwrap.py +218 -0
- aleash/cli.py +209 -0
- aleash/db.py +223 -0
- aleash/profiles.py +70 -0
- aleash/proxy_addon.py +50 -0
- aleash/runner.py +556 -0
- aleash/server.py +657 -0
- aleash/services.py +126 -0
- aleash/static/assets/index-DZWOxijS.css +32 -0
- aleash/static/assets/index-gPIKKcCv.js +25 -0
- aleash/static/index.html +13 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-leash
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Isolated sandbox for AI coding agents
|
|
5
|
+
Project-URL: Repository, https://github.com/mathieu-lacage/agent-leash.git
|
|
6
|
+
Project-URL: Issues, https://github.com/mathieu-lacage/agent-leash/issues
|
|
7
|
+
Author-email: Mathieu Lacage <mathieu.lacage@cutebugs.net>
|
|
8
|
+
Maintainer-email: Mathieu Lacage <mathieu.lacage@cutebugs.net>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: aiosqlite>=0.20
|
|
13
|
+
Requires-Dist: click>=8.1
|
|
14
|
+
Requires-Dist: fastapi>=0.111
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Requires-Dist: mitmproxy>=10.3
|
|
17
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
18
|
+
Provides-Extra: dev
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
aleash/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
aleash/bwrap.py,sha256=9U5SyPF1fYgYkpabajoeZEzWQPOYXNI7kY5cUBvPPWo,5953
|
|
3
|
+
aleash/cli.py,sha256=Eh-gZfCYECanyQ-UFawlFeckOFRcdSs1DVoG6HMYuKo,5666
|
|
4
|
+
aleash/db.py,sha256=uC8GKvNpZ6CalfQE4AHhv5TKC-8VddKVa4RX6Wzo6jg,7189
|
|
5
|
+
aleash/profiles.py,sha256=qos28gB5ENG_NskzHi-Hb2dllpQm3OnT4Yt_pBT7264,1695
|
|
6
|
+
aleash/proxy_addon.py,sha256=sYaCWzHI0eQlg9exUjKhYnO9nEkLcEIkA4ZwHXM4JL8,1617
|
|
7
|
+
aleash/runner.py,sha256=9LjjXtGV09BFSV9Mpuvn6zXSrIWA7g9MKkg5ZW1-48I,17333
|
|
8
|
+
aleash/server.py,sha256=seXOrBNY5JQZnUTgdOpjla5Qktndtc-Mf3qtcFzpCJc,20061
|
|
9
|
+
aleash/services.py,sha256=jkrLMfxaSujhbyN9Y4yPmphBxsLxiBO9_W_4M6dx2DI,3910
|
|
10
|
+
aleash/static/index.html,sha256=x36JhTRMoots4Q-rbxYCsGf5-WBSSc0SNd4VzLO0Tl4,371
|
|
11
|
+
aleash/static/assets/index-DZWOxijS.css,sha256=vfJ5yOABrOLquigwEjeWQGWNHqTahGv1ISCYFEuVmw8,10985
|
|
12
|
+
aleash/static/assets/index-gPIKKcCv.js,sha256=LDpUiEBOlfQqThu54BQMFOvX0suTpoFYi5rBA1Fs71A,374291
|
|
13
|
+
agent_leash-0.3.0.dist-info/METADATA,sha256=Yw1kS13v8UEhIqiMYiEYuchDr-xkQQ7G4vnRueS9ddY,647
|
|
14
|
+
agent_leash-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
15
|
+
agent_leash-0.3.0.dist-info/entry_points.txt,sha256=doBYgl7_2fdgFoWYPvnrMycQvk0Un4QTYjlTOA4E4PY,43
|
|
16
|
+
agent_leash-0.3.0.dist-info/licenses/LICENSE,sha256=zbOaTPxyhqlr08nomI4PGYiGhr4reJUD-WyR4lSUBn4,1071
|
|
17
|
+
agent_leash-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mathieu Lacage
|
|
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.
|
aleash/__init__.py
ADDED
|
File without changes
|
aleash/bwrap.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .profiles import Profile
|
|
5
|
+
|
|
6
|
+
# Prefixes already mounted inside the sandbox — binary paths under these need no extra bind.
|
|
7
|
+
_MOUNTED_PREFIXES = (
|
|
8
|
+
"/usr",
|
|
9
|
+
"/etc",
|
|
10
|
+
"/lib",
|
|
11
|
+
"/lib64",
|
|
12
|
+
"/lib32",
|
|
13
|
+
"/sys",
|
|
14
|
+
"/run",
|
|
15
|
+
"/dev",
|
|
16
|
+
"/proc",
|
|
17
|
+
str(Path.home() / ".local" / "bin"),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Real host-path bind mounts from the fixed section of build_bwrap_argv.
|
|
21
|
+
# Excludes --tmpfs, --proc, --symlink, and internal sockets/certs.
|
|
22
|
+
# Used by the server to display system mounts without duplicating this list.
|
|
23
|
+
SYSTEM_BIND_DISPLAY: list[tuple[str, str, str]] = [
|
|
24
|
+
("/dev", "/dev", "rw"),
|
|
25
|
+
("/usr", "/usr", "ro"),
|
|
26
|
+
("/etc", "/etc", "ro"),
|
|
27
|
+
("/lib", "/lib", "ro"),
|
|
28
|
+
("/lib64", "/lib64", "ro"),
|
|
29
|
+
("/lib32", "/lib32", "ro"),
|
|
30
|
+
("/sys", "/sys", "ro"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _outside_mounts(path: str) -> bool:
|
|
35
|
+
return not any(path == p or path.startswith(p + "/") for p in _MOUNTED_PREFIXES)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
FLATPAK_INFO_CONTENT = """\
|
|
39
|
+
[Application]
|
|
40
|
+
name=fake.app.Id
|
|
41
|
+
|
|
42
|
+
[Instance]
|
|
43
|
+
app-id=fake.app.Id
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _uid() -> str:
|
|
48
|
+
return str(os.getuid())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_bwrap_argv(
|
|
52
|
+
profile: Profile,
|
|
53
|
+
cwd: str,
|
|
54
|
+
proxy_port: int,
|
|
55
|
+
xdg_proxy_sock: str,
|
|
56
|
+
ca_cert_path: str,
|
|
57
|
+
fake_flatpak_info: str,
|
|
58
|
+
cmd: list[str],
|
|
59
|
+
user_binds: list[tuple[str, str, str]] | None = None,
|
|
60
|
+
service_binds: list[tuple[str, str, str]] | None = None,
|
|
61
|
+
service_env: dict[str, str] | None = None,
|
|
62
|
+
) -> list[str]:
|
|
63
|
+
uid = _uid()
|
|
64
|
+
ca_dest = "/tmp/aleash-ca.pem"
|
|
65
|
+
proxy_url = f"http://127.0.0.1:{proxy_port}"
|
|
66
|
+
|
|
67
|
+
args = [
|
|
68
|
+
"bwrap",
|
|
69
|
+
"--unshare-pid",
|
|
70
|
+
"--unshare-uts",
|
|
71
|
+
"--unshare-ipc",
|
|
72
|
+
"--share-net",
|
|
73
|
+
"--die-with-parent",
|
|
74
|
+
"--dev-bind",
|
|
75
|
+
"/dev",
|
|
76
|
+
"/dev",
|
|
77
|
+
"--ro-bind",
|
|
78
|
+
"/usr",
|
|
79
|
+
"/usr",
|
|
80
|
+
"--ro-bind",
|
|
81
|
+
"/etc",
|
|
82
|
+
"/etc",
|
|
83
|
+
"--ro-bind",
|
|
84
|
+
"/lib",
|
|
85
|
+
"/lib",
|
|
86
|
+
"--ro-bind-try",
|
|
87
|
+
"/lib64",
|
|
88
|
+
"/lib64",
|
|
89
|
+
"--ro-bind-try",
|
|
90
|
+
"/lib32",
|
|
91
|
+
"/lib32",
|
|
92
|
+
"--symlink",
|
|
93
|
+
"usr/bin",
|
|
94
|
+
"/bin",
|
|
95
|
+
"--symlink",
|
|
96
|
+
"usr/sbin",
|
|
97
|
+
"/sbin",
|
|
98
|
+
"--ro-bind",
|
|
99
|
+
"/sys",
|
|
100
|
+
"/sys",
|
|
101
|
+
"--tmpfs",
|
|
102
|
+
"/sys/fs/cgroup",
|
|
103
|
+
"--proc",
|
|
104
|
+
"/proc",
|
|
105
|
+
"--tmpfs",
|
|
106
|
+
f"/run/user/{uid}",
|
|
107
|
+
"--tmpfs",
|
|
108
|
+
"/tmp",
|
|
109
|
+
# CA cert: bind into /tmp (writable tmpfs, must come after --tmpfs /tmp above)
|
|
110
|
+
"--ro-bind",
|
|
111
|
+
ca_cert_path,
|
|
112
|
+
ca_dest,
|
|
113
|
+
# flatpak-info for portal auth
|
|
114
|
+
"--ro-bind",
|
|
115
|
+
fake_flatpak_info,
|
|
116
|
+
f"/run/user/{uid}/flatpak-info",
|
|
117
|
+
# xdg-dbus-proxy socket
|
|
118
|
+
"--bind",
|
|
119
|
+
xdg_proxy_sock,
|
|
120
|
+
xdg_proxy_sock,
|
|
121
|
+
# environment
|
|
122
|
+
"--setenv",
|
|
123
|
+
"http_proxy",
|
|
124
|
+
proxy_url,
|
|
125
|
+
"--setenv",
|
|
126
|
+
"HTTP_PROXY",
|
|
127
|
+
proxy_url,
|
|
128
|
+
"--setenv",
|
|
129
|
+
"https_proxy",
|
|
130
|
+
proxy_url,
|
|
131
|
+
"--setenv",
|
|
132
|
+
"HTTPS_PROXY",
|
|
133
|
+
proxy_url,
|
|
134
|
+
"--setenv",
|
|
135
|
+
"SSL_CERT_FILE",
|
|
136
|
+
ca_dest,
|
|
137
|
+
"--setenv",
|
|
138
|
+
"REQUESTS_CA_BUNDLE",
|
|
139
|
+
ca_dest,
|
|
140
|
+
"--setenv",
|
|
141
|
+
"NODE_EXTRA_CA_CERTS",
|
|
142
|
+
ca_dest,
|
|
143
|
+
"--setenv",
|
|
144
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
145
|
+
f"unix:path={xdg_proxy_sock}",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
# Collect content binds then sort shortest-dest-first so parent RO mounts
|
|
149
|
+
# are always applied before child RW mounts. bwrap's --ro-bind uses
|
|
150
|
+
# MS_RDONLY|MS_REC which would retroactively RO any child mount that
|
|
151
|
+
# already existed — sorting prevents that.
|
|
152
|
+
content: list[tuple[str, str, str]] = [] # (flag, host, dest)
|
|
153
|
+
|
|
154
|
+
# ~/.local/bin — user-installed binaries (pipx, npm, cargo, etc.)
|
|
155
|
+
local_bin = Path.home() / ".local" / "bin"
|
|
156
|
+
if local_bin.is_dir():
|
|
157
|
+
content.append(("--ro-bind", str(local_bin), str(local_bin)))
|
|
158
|
+
|
|
159
|
+
# working directory (writable)
|
|
160
|
+
content.append(("--bind", cwd, cwd))
|
|
161
|
+
|
|
162
|
+
# profile extra binds
|
|
163
|
+
for host, dest in profile.extra_binds:
|
|
164
|
+
if Path(host).exists():
|
|
165
|
+
content.append(("--bind", host, dest))
|
|
166
|
+
for host, dest in profile.extra_ro_binds:
|
|
167
|
+
if Path(host).exists():
|
|
168
|
+
content.append(("--ro-bind", host, dest))
|
|
169
|
+
|
|
170
|
+
# profile ensure_home_dirs: bind real dirs (create if needed) into cwd/subpath
|
|
171
|
+
for rel in profile.ensure_home_dirs:
|
|
172
|
+
host_path = Path.home() / rel
|
|
173
|
+
host_path.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
content.append(("--bind", str(host_path), str(Path(cwd) / rel)))
|
|
175
|
+
|
|
176
|
+
# user-configured extra binds (from .aleash-fs-binds.json)
|
|
177
|
+
for host, dest, mode in user_binds or []:
|
|
178
|
+
if Path(host).exists():
|
|
179
|
+
content.append(("--bind" if mode == "rw" else "--ro-bind", host, dest))
|
|
180
|
+
|
|
181
|
+
# service binds (from .aleash-services.json)
|
|
182
|
+
for host, dest, mode in service_binds or []:
|
|
183
|
+
if Path(host).exists():
|
|
184
|
+
content.append(("--bind" if mode == "rw" else "--ro-bind", host, dest))
|
|
185
|
+
|
|
186
|
+
content.sort(key=lambda b: len(b[2]))
|
|
187
|
+
for flag, host, dest in content:
|
|
188
|
+
args += [flag, host, dest]
|
|
189
|
+
|
|
190
|
+
args += ["--chdir", cwd]
|
|
191
|
+
|
|
192
|
+
# extra env from profile
|
|
193
|
+
for k, v in profile.extra_env.items():
|
|
194
|
+
args += ["--setenv", k, v]
|
|
195
|
+
|
|
196
|
+
# service env vars
|
|
197
|
+
for k, v in (service_env or {}).items():
|
|
198
|
+
args += ["--setenv", k, v]
|
|
199
|
+
|
|
200
|
+
# auto-bind the command binary if it lives outside already-mounted paths
|
|
201
|
+
# (e.g. ~/.local/bin/claude installed via pipx/npm)
|
|
202
|
+
binary = cmd[0]
|
|
203
|
+
if os.path.isabs(binary):
|
|
204
|
+
for p in dict.fromkeys(
|
|
205
|
+
[binary, os.path.realpath(binary)]
|
|
206
|
+
): # dedup, preserve order
|
|
207
|
+
if _outside_mounts(p) and os.path.exists(p):
|
|
208
|
+
args += ["--ro-bind", p, p]
|
|
209
|
+
|
|
210
|
+
args += ["--", *cmd]
|
|
211
|
+
return args
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def write_fake_flatpak_info(tmp_dir: str) -> str:
|
|
215
|
+
p = Path(tmp_dir) / "flatpak-info"
|
|
216
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
p.write_text(FLATPAK_INFO_CONTENT)
|
|
218
|
+
return str(p)
|
aleash/cli.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
DAEMON_DIR = Path.home() / ".aleash"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _free_port(preferred: int = 7612) -> int:
|
|
13
|
+
with socket.socket() as s:
|
|
14
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
15
|
+
try:
|
|
16
|
+
s.bind(("127.0.0.1", preferred))
|
|
17
|
+
return preferred
|
|
18
|
+
except OSError:
|
|
19
|
+
pass
|
|
20
|
+
with socket.socket() as s:
|
|
21
|
+
s.bind(("127.0.0.1", 0))
|
|
22
|
+
return s.getsockname()[1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _start_server_background(port: int):
|
|
26
|
+
import threading
|
|
27
|
+
import uvicorn
|
|
28
|
+
from .server import app as _app
|
|
29
|
+
|
|
30
|
+
ready = threading.Event()
|
|
31
|
+
config = uvicorn.Config(_app, host="127.0.0.1", port=port, log_level="warning")
|
|
32
|
+
server = uvicorn.Server(config)
|
|
33
|
+
|
|
34
|
+
orig_startup = server.startup
|
|
35
|
+
|
|
36
|
+
async def _patched_startup(sockets=None):
|
|
37
|
+
await orig_startup(sockets)
|
|
38
|
+
ready.set()
|
|
39
|
+
|
|
40
|
+
server.startup = _patched_startup
|
|
41
|
+
threading.Thread(target=lambda: asyncio.run(server.serve()), daemon=True).start()
|
|
42
|
+
ready.wait(timeout=5.0)
|
|
43
|
+
return server
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group()
|
|
47
|
+
@click.option(
|
|
48
|
+
"--profile", default=None, metavar="PROFILE", help="Override sandbox profile."
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--browser-master",
|
|
52
|
+
is_flag=True,
|
|
53
|
+
default=False,
|
|
54
|
+
help="Let the browser control terminal size.",
|
|
55
|
+
)
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def main(ctx, profile, browser_master):
|
|
58
|
+
"""Sandbox runner for AI coding agents."""
|
|
59
|
+
from . import db as _db
|
|
60
|
+
|
|
61
|
+
_db.DB_PATH = Path.cwd() / ".aleash" / "data.db"
|
|
62
|
+
ctx.ensure_object(dict)
|
|
63
|
+
ctx.obj["profile"] = profile
|
|
64
|
+
ctx.obj["browser_master"] = browser_master
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command(name="list")
|
|
68
|
+
def list_sandboxes():
|
|
69
|
+
"""List sandboxes."""
|
|
70
|
+
import sqlite3
|
|
71
|
+
from .db import DB_PATH
|
|
72
|
+
|
|
73
|
+
if not DB_PATH.exists():
|
|
74
|
+
click.echo("No sandboxes yet.")
|
|
75
|
+
return
|
|
76
|
+
with sqlite3.connect(str(DB_PATH)) as conn:
|
|
77
|
+
conn.row_factory = sqlite3.Row
|
|
78
|
+
rows = conn.execute(
|
|
79
|
+
"SELECT * FROM sandboxes ORDER BY started_at DESC"
|
|
80
|
+
).fetchall()
|
|
81
|
+
if not rows:
|
|
82
|
+
click.echo("No sandboxes yet.")
|
|
83
|
+
return
|
|
84
|
+
for s in rows:
|
|
85
|
+
status = "running" if s["ended_at"] is None else f"exited({s['exit_code']})"
|
|
86
|
+
click.echo(f"{s['id'][:8]} {status:12} {s['profile']:10} {s['cwd']}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run_agent(
|
|
90
|
+
profile_name: str,
|
|
91
|
+
extra_args: tuple,
|
|
92
|
+
profile_override: str | None,
|
|
93
|
+
browser_master: bool = False,
|
|
94
|
+
):
|
|
95
|
+
from .profiles import PROFILES
|
|
96
|
+
from .runner import run_sandbox
|
|
97
|
+
|
|
98
|
+
profile_key = profile_override or profile_name
|
|
99
|
+
if profile_key not in PROFILES:
|
|
100
|
+
click.echo(
|
|
101
|
+
f"Unknown profile '{profile_key}'. Available: {', '.join(PROFILES)}",
|
|
102
|
+
err=True,
|
|
103
|
+
)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
profile = PROFILES[profile_key]
|
|
106
|
+
|
|
107
|
+
import shutil
|
|
108
|
+
|
|
109
|
+
binary = shutil.which(profile_name)
|
|
110
|
+
if not binary:
|
|
111
|
+
click.echo(f"'{profile_name}' not found on PATH", err=True)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
port = _free_port()
|
|
115
|
+
server = _start_server_background(port)
|
|
116
|
+
click.echo(f"Sandbox UI available on http://localhost:{port}/")
|
|
117
|
+
import subprocess
|
|
118
|
+
|
|
119
|
+
subprocess.Popen(
|
|
120
|
+
["xdg-open", f"http://localhost:{port}/"],
|
|
121
|
+
stdout=subprocess.DEVNULL,
|
|
122
|
+
stderr=subprocess.DEVNULL,
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
exit_code = asyncio.run(
|
|
126
|
+
run_sandbox(
|
|
127
|
+
profile=profile,
|
|
128
|
+
cwd=os.getcwd(),
|
|
129
|
+
cmd=[binary, *extra_args],
|
|
130
|
+
browser_master=browser_master,
|
|
131
|
+
server_url=f"http://localhost:{port}",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
finally:
|
|
135
|
+
server.should_exit = True
|
|
136
|
+
sys.exit(exit_code)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@main.command(
|
|
140
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
|
|
141
|
+
)
|
|
142
|
+
@click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
|
|
143
|
+
@click.pass_context
|
|
144
|
+
def claude(ctx, extra_args):
|
|
145
|
+
"""Run claude in a sandbox."""
|
|
146
|
+
_run_agent(
|
|
147
|
+
"claude",
|
|
148
|
+
extra_args,
|
|
149
|
+
ctx.obj.get("profile"),
|
|
150
|
+
ctx.obj.get("browser_master", False),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@main.command(
|
|
155
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
|
|
156
|
+
)
|
|
157
|
+
@click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def opencode(ctx, extra_args):
|
|
160
|
+
"""Run opencode in a sandbox."""
|
|
161
|
+
_run_agent(
|
|
162
|
+
"opencode",
|
|
163
|
+
extra_args,
|
|
164
|
+
ctx.obj.get("profile"),
|
|
165
|
+
ctx.obj.get("browser_master", False),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@main.command(
|
|
170
|
+
name="run",
|
|
171
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
172
|
+
)
|
|
173
|
+
@click.argument("cmd", nargs=-1, type=click.UNPROCESSED, required=True)
|
|
174
|
+
@click.pass_context
|
|
175
|
+
def run_cmd(ctx, cmd):
|
|
176
|
+
"""Run an arbitrary command in a sandbox."""
|
|
177
|
+
from .profiles import PROFILES
|
|
178
|
+
from .runner import run_sandbox
|
|
179
|
+
|
|
180
|
+
profile = ctx.obj.get("profile") or "generic"
|
|
181
|
+
if profile not in PROFILES:
|
|
182
|
+
click.echo(
|
|
183
|
+
f"Unknown profile '{profile}'. Available: {', '.join(PROFILES)}", err=True
|
|
184
|
+
)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
port = _free_port()
|
|
188
|
+
server = _start_server_background(port)
|
|
189
|
+
click.echo(f"Sandbox UI available on http://localhost:{port}/")
|
|
190
|
+
import subprocess
|
|
191
|
+
|
|
192
|
+
subprocess.Popen(
|
|
193
|
+
["xdg-open", f"http://localhost:{port}/"],
|
|
194
|
+
stdout=subprocess.DEVNULL,
|
|
195
|
+
stderr=subprocess.DEVNULL,
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
exit_code = asyncio.run(
|
|
199
|
+
run_sandbox(
|
|
200
|
+
profile=PROFILES[profile],
|
|
201
|
+
cwd=os.getcwd(),
|
|
202
|
+
cmd=list(cmd),
|
|
203
|
+
browser_master=ctx.obj.get("browser_master", False),
|
|
204
|
+
server_url=f"http://localhost:{port}",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
finally:
|
|
208
|
+
server.should_exit = True
|
|
209
|
+
sys.exit(exit_code)
|