sbxloop 0.2.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.
- sbxloop/__init__.py +26 -0
- sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- sbxloop/cli/__init__.py +1 -0
- sbxloop/cli/app.py +365 -0
- sbxloop/cli/doctor.py +172 -0
- sbxloop/cli/tui.py +206 -0
- sbxloop/config.py +205 -0
- sbxloop/engine/__init__.py +26 -0
- sbxloop/engine/engine.py +383 -0
- sbxloop/engine/model.py +151 -0
- sbxloop/engine/phases.py +223 -0
- sbxloop/engine/prompts/__init__.py +31 -0
- sbxloop/engine/prompts/decompose.md +41 -0
- sbxloop/engine/prompts/execute.md +31 -0
- sbxloop/engine/prompts/plan.md +35 -0
- sbxloop/engine/prompts/scrutinize.md +43 -0
- sbxloop/engine/prompts/validate.md +38 -0
- sbxloop/engine/store.py +257 -0
- sbxloop/errors.py +73 -0
- sbxloop/events.py +87 -0
- sbxloop/gh/__init__.py +6 -0
- sbxloop/gh/ops.py +140 -0
- sbxloop/gh/reporter.py +75 -0
- sbxloop/ids.py +35 -0
- sbxloop/py.typed +0 -0
- sbxloop/sbx/__init__.py +6 -0
- sbxloop/sbx/cli.py +236 -0
- sbxloop/sbx/models.py +83 -0
- sbxloop/sbx/pair.py +125 -0
- sbxloop/sbx/parse.py +67 -0
- sbxloop/sbx/provision.py +361 -0
- sbxloop/sbx/sandbox.py +75 -0
- sbxloop/worker/__init__.py +6 -0
- sbxloop/worker/client.py +397 -0
- sbxloop/worker/wheel.py +73 -0
- sbxloop-0.2.0.dist-info/METADATA +33 -0
- sbxloop-0.2.0.dist-info/RECORD +39 -0
- sbxloop-0.2.0.dist-info/WHEEL +4 -0
- sbxloop-0.2.0.dist-info/entry_points.txt +2 -0
sbxloop/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""sbxloop — agentic loop orchestration on Docker Sandboxes (sbx).
|
|
2
|
+
|
|
3
|
+
Every sbxloop operation runs as a pair of isolated microVM sandboxes: an agent
|
|
4
|
+
sandbox holding only ``COPILOT_GITHUB_TOKEN`` for the GitHub Copilot SDK
|
|
5
|
+
agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
|
|
6
|
+
user-facing GitHub interactions. The balanced network policy is the default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
from sbxloop.config import Budgets, Config, load_config
|
|
12
|
+
from sbxloop.engine import LoopEngine, RunResult, run_outcome
|
|
13
|
+
from sbxloop.events import Event, EventBus, Hook
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Budgets",
|
|
17
|
+
"Config",
|
|
18
|
+
"Event",
|
|
19
|
+
"EventBus",
|
|
20
|
+
"Hook",
|
|
21
|
+
"LoopEngine",
|
|
22
|
+
"RunResult",
|
|
23
|
+
"__version__",
|
|
24
|
+
"load_config",
|
|
25
|
+
"run_outcome",
|
|
26
|
+
]
|
|
Binary file
|
sbxloop/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The sbxloop command-line interface."""
|
sbxloop/cli/app.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""The sbxloop CLI: run agentic loops on Docker Sandboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
import sbxloop
|
|
16
|
+
from sbxloop.cli.doctor import run_doctor
|
|
17
|
+
from sbxloop.cli.tui import Dashboard, format_event, plain_printer
|
|
18
|
+
from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
|
|
19
|
+
from sbxloop.engine.engine import LoopEngine
|
|
20
|
+
from sbxloop.engine.model import RunResult
|
|
21
|
+
from sbxloop.engine.store import StateStore
|
|
22
|
+
from sbxloop.errors import SdxloopError
|
|
23
|
+
from sbxloop.sbx.cli import SbxCLI
|
|
24
|
+
from sbxloop.sbx.provision import sandbox_name
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="sbxloop",
|
|
28
|
+
help="Agentic loop orchestration on Docker Sandboxes with isolated credentials.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
pretty_exceptions_show_locals=False,
|
|
31
|
+
)
|
|
32
|
+
sandbox_app = typer.Typer(help="Manage sbxloop sandboxes.", no_args_is_help=True)
|
|
33
|
+
config_app = typer.Typer(help="Inspect configuration.", no_args_is_help=True)
|
|
34
|
+
app.add_typer(sandbox_app, name="sandbox")
|
|
35
|
+
app.add_typer(config_app, name="config")
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _version_callback(value: bool) -> None:
|
|
41
|
+
if value:
|
|
42
|
+
console.print(f"sbxloop {sbxloop.__version__}")
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def _main_callback(
|
|
48
|
+
version: Annotated[
|
|
49
|
+
bool, typer.Option("--version", callback=_version_callback, is_eager=True)
|
|
50
|
+
] = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""sbxloop — agentic loops on Docker Sandboxes."""
|
|
53
|
+
# Every command sees ./.env (tokens + SBXLOOP_* settings); real
|
|
54
|
+
# environment variables always take precedence.
|
|
55
|
+
load_dotenv_file()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _config_with_overrides(**overrides: Any) -> Config:
|
|
59
|
+
config = load_config()
|
|
60
|
+
updates = {k: v for k, v in overrides.items() if v is not None}
|
|
61
|
+
return config.model_copy(update=updates) if updates else config
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _store(config: Config) -> StateStore:
|
|
65
|
+
return StateStore(config.state_dir / "state.db")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
|
|
69
|
+
"""Run start/resume with either the live dashboard or plain event logs."""
|
|
70
|
+
if not tui:
|
|
71
|
+
engine.bus.subscribe(plain_printer(console))
|
|
72
|
+
return action() # type: ignore[no-any-return]
|
|
73
|
+
|
|
74
|
+
dashboard = Dashboard()
|
|
75
|
+
engine.bus.subscribe(dashboard.on_event)
|
|
76
|
+
outcome: dict[str, Any] = {}
|
|
77
|
+
|
|
78
|
+
def target() -> None:
|
|
79
|
+
try:
|
|
80
|
+
outcome["result"] = action()
|
|
81
|
+
except BaseException as exc:
|
|
82
|
+
outcome["error"] = exc
|
|
83
|
+
|
|
84
|
+
thread = threading.Thread(target=target, daemon=True)
|
|
85
|
+
with Live(dashboard.renderable(), console=console, refresh_per_second=8) as live:
|
|
86
|
+
thread.start()
|
|
87
|
+
while thread.is_alive():
|
|
88
|
+
live.update(dashboard.renderable())
|
|
89
|
+
time.sleep(0.15)
|
|
90
|
+
live.update(dashboard.renderable())
|
|
91
|
+
if "error" in outcome:
|
|
92
|
+
raise outcome["error"]
|
|
93
|
+
return outcome["result"] # type: ignore[no-any-return]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _finish(result: RunResult) -> None:
|
|
97
|
+
style = "green" if result.succeeded else "red"
|
|
98
|
+
console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
|
|
99
|
+
for task in result.tasks:
|
|
100
|
+
console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
|
|
101
|
+
raise typer.Exit(0 if result.succeeded else 1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def run(
|
|
106
|
+
outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
|
|
107
|
+
repo: Annotated[
|
|
108
|
+
str | None, typer.Option("--repo", help="owner/repo for GitHub progress reporting.")
|
|
109
|
+
] = None,
|
|
110
|
+
model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
|
|
111
|
+
keep_sandboxes: Annotated[
|
|
112
|
+
bool, typer.Option("--keep-sandboxes", help="Do not remove sandboxes at the end.")
|
|
113
|
+
] = False,
|
|
114
|
+
tui: Annotated[bool, typer.Option("--tui/--no-tui", help="Live dashboard.")] = True,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Run an agentic loop for OUTCOME in a fresh sandbox pair."""
|
|
117
|
+
config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
|
|
118
|
+
if repo:
|
|
119
|
+
config = config.model_copy(
|
|
120
|
+
update={"github": config.github.model_copy(update={"report_repo": repo})}
|
|
121
|
+
)
|
|
122
|
+
engine = LoopEngine(config)
|
|
123
|
+
try:
|
|
124
|
+
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
|
|
125
|
+
except SdxloopError as exc:
|
|
126
|
+
console.print(f"[bold red]run failed:[/] {exc}")
|
|
127
|
+
raise typer.Exit(2) from exc
|
|
128
|
+
_finish(result)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def resume(
|
|
133
|
+
run_id: Annotated[str, typer.Argument(help="Run id to resume.")],
|
|
134
|
+
tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Resume an unfinished run (fresh sandboxes, persisted state)."""
|
|
137
|
+
engine = LoopEngine(load_config())
|
|
138
|
+
try:
|
|
139
|
+
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
|
|
140
|
+
except SdxloopError as exc:
|
|
141
|
+
console.print(f"[bold red]resume failed:[/] {exc}")
|
|
142
|
+
raise typer.Exit(2) from exc
|
|
143
|
+
_finish(result)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def cancel(run_id: Annotated[str, typer.Argument()]) -> None:
|
|
148
|
+
"""Mark a run cancelled (takes effect at the next phase boundary)."""
|
|
149
|
+
config = load_config()
|
|
150
|
+
engine = LoopEngine(config)
|
|
151
|
+
try:
|
|
152
|
+
engine.cancel(run_id)
|
|
153
|
+
except SdxloopError as exc:
|
|
154
|
+
console.print(f"[bold red]{exc}[/]")
|
|
155
|
+
raise typer.Exit(2) from exc
|
|
156
|
+
console.print(f"run {run_id} cancelled")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def status(
|
|
161
|
+
run_id: Annotated[str | None, typer.Argument(help="Run id for details.")] = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""List runs, or show one run's tasks and phase history."""
|
|
164
|
+
config = load_config()
|
|
165
|
+
store = _store(config)
|
|
166
|
+
if run_id is None:
|
|
167
|
+
table = Table(title="sbxloop runs")
|
|
168
|
+
table.add_column("run")
|
|
169
|
+
table.add_column("state")
|
|
170
|
+
table.add_column("outcome", max_width=60)
|
|
171
|
+
table.add_column("updated")
|
|
172
|
+
for record in store.list_runs():
|
|
173
|
+
table.add_row(
|
|
174
|
+
record.run_id,
|
|
175
|
+
record.state,
|
|
176
|
+
record.outcome[:60],
|
|
177
|
+
time.strftime("%Y-%m-%d %H:%M", time.localtime(record.updated_at)),
|
|
178
|
+
)
|
|
179
|
+
console.print(table)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
record = store.get_run(run_id)
|
|
184
|
+
except SdxloopError as exc:
|
|
185
|
+
console.print(f"[bold red]{exc}[/]")
|
|
186
|
+
raise typer.Exit(2) from exc
|
|
187
|
+
console.print(f"run [bold cyan]{record.run_id}[/] state: [bold]{record.state}[/]")
|
|
188
|
+
console.print(f"outcome: {record.outcome}")
|
|
189
|
+
table = Table(title="tasks")
|
|
190
|
+
for column in ("task", "title", "state", "revisions", "replans"):
|
|
191
|
+
table.add_column(column)
|
|
192
|
+
for task in store.get_tasks(run_id):
|
|
193
|
+
table.add_row(
|
|
194
|
+
task.spec.id,
|
|
195
|
+
task.spec.title,
|
|
196
|
+
task.state,
|
|
197
|
+
str(task.revisions),
|
|
198
|
+
str(task.replans),
|
|
199
|
+
)
|
|
200
|
+
console.print(table)
|
|
201
|
+
attempts = store.phase_attempts(run_id)
|
|
202
|
+
console.print(f"{len(attempts)} phase attempts recorded")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.command()
|
|
206
|
+
def logs(
|
|
207
|
+
run_id: Annotated[str, typer.Argument()],
|
|
208
|
+
follow: Annotated[bool, typer.Option("--follow", "-f")] = False,
|
|
209
|
+
type_prefix: Annotated[
|
|
210
|
+
str | None, typer.Option("--type", help="Filter by event type prefix.")
|
|
211
|
+
] = None,
|
|
212
|
+
task: Annotated[str | None, typer.Option("--task", help="Filter by task id.")] = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Replay (or tail) a run's event stream from the state store."""
|
|
215
|
+
config = load_config()
|
|
216
|
+
store = _store(config)
|
|
217
|
+
store.get_run(run_id) # validates existence
|
|
218
|
+
last_seq = 0
|
|
219
|
+
while True:
|
|
220
|
+
for seq, event in store.events(run_id, after_seq=last_seq, type_prefix=type_prefix):
|
|
221
|
+
last_seq = seq
|
|
222
|
+
if task and event.data.get("task_id") != task:
|
|
223
|
+
continue
|
|
224
|
+
console.print(format_event(event), highlight=False)
|
|
225
|
+
if not follow:
|
|
226
|
+
break
|
|
227
|
+
if store.get_run(run_id).state in ("completed", "failed", "cancelled"):
|
|
228
|
+
break
|
|
229
|
+
time.sleep(0.5)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@sandbox_app.command("ls")
|
|
233
|
+
def sandbox_ls() -> None:
|
|
234
|
+
"""List sbxloop-managed sandboxes."""
|
|
235
|
+
config = load_config()
|
|
236
|
+
cli = SbxCLI(app_name=config.app_name or None)
|
|
237
|
+
table = Table(title="sbxloop sandboxes")
|
|
238
|
+
for column in ("name", "agent", "status", "workspace"):
|
|
239
|
+
table.add_column(column)
|
|
240
|
+
for info in cli.ls():
|
|
241
|
+
if info.name.startswith("sbxloop-"):
|
|
242
|
+
table.add_row(info.name, info.agent or "", info.status or "", info.workspace or "")
|
|
243
|
+
console.print(table)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@sandbox_app.command("rm")
|
|
247
|
+
def sandbox_rm(
|
|
248
|
+
name: Annotated[str | None, typer.Argument(help="Sandbox name.")] = None,
|
|
249
|
+
run_id: Annotated[str | None, typer.Option("--run", help="Remove a run's pair.")] = None,
|
|
250
|
+
all_: Annotated[bool, typer.Option("--all", help="Remove all sbxloop sandboxes.")] = False,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Remove sbxloop sandboxes by name, by run, or all of them."""
|
|
253
|
+
config = load_config()
|
|
254
|
+
cli = SbxCLI(app_name=config.app_name or None)
|
|
255
|
+
targets: list[str] = []
|
|
256
|
+
if name:
|
|
257
|
+
targets.append(name)
|
|
258
|
+
if run_id:
|
|
259
|
+
targets += [sandbox_name(run_id, "agent"), sandbox_name(run_id, "github")]
|
|
260
|
+
if all_:
|
|
261
|
+
targets += [i.name for i in cli.ls() if i.name.startswith("sbxloop-")]
|
|
262
|
+
if not targets:
|
|
263
|
+
console.print("nothing to remove: pass a NAME, --run, or --all")
|
|
264
|
+
raise typer.Exit(2)
|
|
265
|
+
for target in dict.fromkeys(targets):
|
|
266
|
+
try:
|
|
267
|
+
cli.rm(target)
|
|
268
|
+
console.print(f"removed {target}")
|
|
269
|
+
except SdxloopError as exc:
|
|
270
|
+
console.print(f"[yellow]skip {target}:[/] {exc}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@config_app.command("show")
|
|
274
|
+
def config_show() -> None:
|
|
275
|
+
"""Show the resolved configuration and where each value came from."""
|
|
276
|
+
try:
|
|
277
|
+
config, sources = load_config_with_sources()
|
|
278
|
+
except SdxloopError as exc:
|
|
279
|
+
console.print(f"[bold red]{exc}[/]")
|
|
280
|
+
raise typer.Exit(2) from exc
|
|
281
|
+
table = Table(title="sbxloop configuration")
|
|
282
|
+
table.add_column("key")
|
|
283
|
+
table.add_column("value")
|
|
284
|
+
table.add_column("source")
|
|
285
|
+
flat: dict[str, Any] = {}
|
|
286
|
+
|
|
287
|
+
def flatten(prefix: str, data: dict[str, Any]) -> None:
|
|
288
|
+
for key, value in data.items():
|
|
289
|
+
dotted = f"{prefix}{key}"
|
|
290
|
+
if isinstance(value, dict):
|
|
291
|
+
flatten(f"{dotted}.", value)
|
|
292
|
+
else:
|
|
293
|
+
flat[dotted] = value
|
|
294
|
+
|
|
295
|
+
flatten("", config.model_dump(mode="json"))
|
|
296
|
+
for dotted in sorted(flat):
|
|
297
|
+
table.add_row(dotted, repr(flat[dotted]), sources.get(dotted, "default"))
|
|
298
|
+
console.print(table)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.command()
|
|
302
|
+
def init(
|
|
303
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Write a commented sbxloop.toml with the default configuration."""
|
|
306
|
+
path = Path("sbxloop.toml")
|
|
307
|
+
if path.exists() and not force:
|
|
308
|
+
console.print("sbxloop.toml already exists (use --force to overwrite)")
|
|
309
|
+
raise typer.Exit(2)
|
|
310
|
+
path.write_text(DEFAULT_CONFIG_TOML)
|
|
311
|
+
console.print(f"wrote {path}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command()
|
|
315
|
+
def doctor() -> None:
|
|
316
|
+
"""Check that this host is ready to run sbxloop."""
|
|
317
|
+
ok = run_doctor(console)
|
|
318
|
+
raise typer.Exit(0 if ok else 1)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
DEFAULT_CONFIG_TOML = """\
|
|
322
|
+
# sbxloop configuration. Every key is optional; these are the defaults.
|
|
323
|
+
# Precedence: SBXLOOP_* env vars > this file > pyproject [tool.sbxloop].
|
|
324
|
+
|
|
325
|
+
# Copilot model for agent sessions ("auto" lets the SDK choose).
|
|
326
|
+
model = "auto"
|
|
327
|
+
# Optional sbx --app-name isolating sbxloop's sandboxes/policies/secrets.
|
|
328
|
+
# Empty shares your normal sbx state (your login + balanced policy apply).
|
|
329
|
+
# If set, that isolated state needs its own `sbx --app-name <name> login`
|
|
330
|
+
# and `sbx --app-name <name> policy init balanced`.
|
|
331
|
+
app_name = ""
|
|
332
|
+
# Where run state (SQLite) and per-run workspaces live.
|
|
333
|
+
state_dir = ".sbxloop"
|
|
334
|
+
# Keep sandboxes around after a run (for debugging).
|
|
335
|
+
keep_sandboxes = false
|
|
336
|
+
# Worker transport: "stream" (default) or "poll".
|
|
337
|
+
worker_transport = "stream"
|
|
338
|
+
# Secret injection: "proxy" (sbx keychain proxy; recommended) or "plain-env".
|
|
339
|
+
secret_strategy = "proxy"
|
|
340
|
+
|
|
341
|
+
[sandbox]
|
|
342
|
+
# Custom sandbox template reference (defaults to the sbx shell template).
|
|
343
|
+
# template = "docker.io/you/your-template:v1"
|
|
344
|
+
# Extra network allow rules applied to both sandboxes.
|
|
345
|
+
extra_allow_domains = []
|
|
346
|
+
|
|
347
|
+
[github]
|
|
348
|
+
# Report run progress to a GitHub repo ("owner/repo"); empty disables.
|
|
349
|
+
# report_repo = "you/your-repo"
|
|
350
|
+
|
|
351
|
+
[budgets]
|
|
352
|
+
max_revisions_per_task = 2
|
|
353
|
+
max_replans_per_task = 1
|
|
354
|
+
max_tasks = 20
|
|
355
|
+
max_wall_clock_s = 7200.0
|
|
356
|
+
per_job_timeout_s = 900.0
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main() -> None:
|
|
361
|
+
app()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|
sbxloop/cli/doctor.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""sbxloop doctor: host readiness checks with remediation hints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from sbxloop.config import load_config
|
|
12
|
+
from sbxloop.errors import SbxError, SbxNotFoundError
|
|
13
|
+
from sbxloop.sbx.cli import SbxCLI
|
|
14
|
+
from sbxloop.sbx.provision import (
|
|
15
|
+
AGENT_TOKEN_HOSTS,
|
|
16
|
+
COPILOT_TOKEN_ENV,
|
|
17
|
+
GH_TOKEN_ENVS,
|
|
18
|
+
)
|
|
19
|
+
from sbxloop.worker.wheel import resolve_worker_wheel
|
|
20
|
+
|
|
21
|
+
TESTED_SBX_SERIES = "0.35"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Check:
|
|
26
|
+
name: str
|
|
27
|
+
ok: bool
|
|
28
|
+
detail: str
|
|
29
|
+
hard: bool = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ProgressFn = Callable[[str], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collect_checks(
|
|
36
|
+
env: dict[str, str],
|
|
37
|
+
cli: SbxCLI | None = None,
|
|
38
|
+
progress: ProgressFn | None = None,
|
|
39
|
+
) -> list[Check]:
|
|
40
|
+
config = load_config(env=env)
|
|
41
|
+
cli = cli or SbxCLI(app_name=config.app_name or None)
|
|
42
|
+
checks: list[Check] = []
|
|
43
|
+
report = progress or (lambda _message: None)
|
|
44
|
+
|
|
45
|
+
# sbx binary + version. The very first sbx invocation may trigger
|
|
46
|
+
# Docker's interactive browser login if this sbx application state has
|
|
47
|
+
# never authenticated - say so instead of appearing hung.
|
|
48
|
+
report("checking sbx binary (Docker may open a browser window for authentication on first use)")
|
|
49
|
+
try:
|
|
50
|
+
version = cli.version()
|
|
51
|
+
except SbxNotFoundError:
|
|
52
|
+
checks.append(
|
|
53
|
+
Check(
|
|
54
|
+
"sbx binary",
|
|
55
|
+
False,
|
|
56
|
+
"sbx not found on PATH — install Docker Sandboxes: "
|
|
57
|
+
"https://docs.docker.com/ai/sandboxes/",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
version = None
|
|
61
|
+
else:
|
|
62
|
+
detail = f"found sbx {version or '(unknown version)'}"
|
|
63
|
+
if version and not version.startswith(TESTED_SBX_SERIES):
|
|
64
|
+
detail += f" (sbxloop is tested against {TESTED_SBX_SERIES}.x; parsing may drift)"
|
|
65
|
+
checks.append(Check("sbx binary", True, detail))
|
|
66
|
+
|
|
67
|
+
if version is not None:
|
|
68
|
+
# login / daemon reachable
|
|
69
|
+
report("checking sbx login")
|
|
70
|
+
try:
|
|
71
|
+
cli.ls()
|
|
72
|
+
checks.append(Check("sbx login", True, "sbx ls succeeded"))
|
|
73
|
+
except SbxError as exc:
|
|
74
|
+
login_cmd = (
|
|
75
|
+
f"sbx --app-name {config.app_name} login" if config.app_name else "sbx login"
|
|
76
|
+
)
|
|
77
|
+
checks.append(Check("sbx login", False, f"sbx ls failed ({exc}); run `{login_cmd}`"))
|
|
78
|
+
|
|
79
|
+
# network policy reachable for the copilot hosts
|
|
80
|
+
for host in AGENT_TOKEN_HOSTS:
|
|
81
|
+
report(f"checking network policy for {host}")
|
|
82
|
+
allowed = False
|
|
83
|
+
try:
|
|
84
|
+
allowed = cli.policy_check(host)
|
|
85
|
+
except SbxError:
|
|
86
|
+
allowed = False
|
|
87
|
+
checks.append(
|
|
88
|
+
Check(
|
|
89
|
+
f"policy: {host}",
|
|
90
|
+
allowed,
|
|
91
|
+
"reachable"
|
|
92
|
+
if allowed
|
|
93
|
+
else (
|
|
94
|
+
"blocked — run `sbx policy init balanced` and/or "
|
|
95
|
+
f"`sbx policy allow network {host}`"
|
|
96
|
+
),
|
|
97
|
+
hard=False, # per-sandbox allows are applied at provision time
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# tokens
|
|
102
|
+
checks.append(
|
|
103
|
+
Check(
|
|
104
|
+
COPILOT_TOKEN_ENV,
|
|
105
|
+
bool(env.get(COPILOT_TOKEN_ENV)),
|
|
106
|
+
"set"
|
|
107
|
+
if env.get(COPILOT_TOKEN_ENV)
|
|
108
|
+
else 'not set — create a fine-grained PAT with the "Copilot Requests" '
|
|
109
|
+
f"permission and export {COPILOT_TOKEN_ENV}",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
|
|
113
|
+
checks.append(
|
|
114
|
+
Check(
|
|
115
|
+
"/".join(GH_TOKEN_ENVS),
|
|
116
|
+
gh_set,
|
|
117
|
+
"set"
|
|
118
|
+
if gh_set
|
|
119
|
+
else "not set — create a fine-grained PAT (issues:write, contents:read, ...) "
|
|
120
|
+
"and export GH_TOKEN",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# worker wheel
|
|
125
|
+
wheel = resolve_worker_wheel()
|
|
126
|
+
checks.append(
|
|
127
|
+
Check(
|
|
128
|
+
"worker wheel",
|
|
129
|
+
True,
|
|
130
|
+
f"resolved: {wheel.name}" if wheel else "no local wheel; will install from PyPI",
|
|
131
|
+
hard=False,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# state dir writable
|
|
136
|
+
try:
|
|
137
|
+
config.state_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
probe = config.state_dir / ".doctor-probe"
|
|
139
|
+
probe.write_text("ok")
|
|
140
|
+
probe.unlink()
|
|
141
|
+
checks.append(Check("state dir", True, str(config.state_dir.resolve())))
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
checks.append(Check("state dir", False, f"not writable: {exc}"))
|
|
144
|
+
|
|
145
|
+
return checks
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _clean(detail: str, limit: int = 300) -> str:
|
|
149
|
+
"""Collapse whitespace/newlines so multi-line sbx stderr can't shatter
|
|
150
|
+
the table layout; long tails are elided."""
|
|
151
|
+
flat = " ".join(detail.split())
|
|
152
|
+
return flat if len(flat) <= limit else flat[: limit - 1] + "\u2026"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_doctor(console: Console, env: dict[str, str] | None = None) -> bool:
|
|
156
|
+
import os
|
|
157
|
+
|
|
158
|
+
checks = collect_checks(
|
|
159
|
+
dict(os.environ) if env is None else env,
|
|
160
|
+
progress=lambda message: console.print(f"[dim]\u2026 {message}[/dim]", highlight=False),
|
|
161
|
+
)
|
|
162
|
+
table = Table(title="sbxloop doctor")
|
|
163
|
+
table.add_column("check", no_wrap=True)
|
|
164
|
+
table.add_column("status", no_wrap=True)
|
|
165
|
+
table.add_column("detail", overflow="fold")
|
|
166
|
+
for check in checks:
|
|
167
|
+
status = (
|
|
168
|
+
"[green]ok[/]" if check.ok else ("[red]FAIL[/]" if check.hard else "[yellow]warn[/]")
|
|
169
|
+
)
|
|
170
|
+
table.add_row(check.name, status, _clean(check.detail))
|
|
171
|
+
console.print(table)
|
|
172
|
+
return all(check.ok or not check.hard for check in checks)
|