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/sbx/cli.py ADDED
@@ -0,0 +1,236 @@
1
+ """Typed subprocess wrapper around the sbx binary.
2
+
3
+ Every invocation injects ``--app-name`` (unless disabled) so sbxloop's
4
+ sandboxes, policies, and secrets live in an isolated sbx application state,
5
+ invisible to the user's interactive sbx usage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import time
12
+ from collections.abc import Sequence
13
+
14
+ from sbxloop.errors import SbxError, SbxNotFoundError
15
+ from sbxloop.sbx.models import ExecResult, SandboxInfo, SandboxSpec
16
+ from sbxloop.sbx.parse import parse_ls, parse_version
17
+
18
+ _NOT_FOUND_MARKERS = ("not found", "no such sandbox", "does not exist", "unknown sandbox")
19
+
20
+
21
+ class SbxCLI:
22
+ """Blocking, typed access to the sbx CLI."""
23
+
24
+ def __init__(
25
+ self,
26
+ binary: str = "sbx",
27
+ app_name: str | None = None,
28
+ default_timeout: float = 120.0,
29
+ ) -> None:
30
+ self.binary = binary
31
+ self.app_name = app_name
32
+ self.default_timeout = default_timeout
33
+
34
+ def argv(self, *args: str) -> list[str]:
35
+ prefix = [self.binary]
36
+ if self.app_name:
37
+ prefix += ["--app-name", self.app_name]
38
+ return prefix + list(args)
39
+
40
+ def run(
41
+ self,
42
+ *args: str,
43
+ timeout: float | None = None,
44
+ check: bool = True,
45
+ stdin: str | None = None,
46
+ ) -> ExecResult:
47
+ argv = self.argv(*args)
48
+ started = time.monotonic()
49
+ try:
50
+ proc = subprocess.run(
51
+ argv,
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=timeout or self.default_timeout,
55
+ input=stdin,
56
+ check=False,
57
+ )
58
+ except FileNotFoundError as exc:
59
+ raise SbxNotFoundError(
60
+ f"sbx binary {self.binary!r} not found on PATH — install Docker Sandboxes "
61
+ "(https://docs.docker.com/ai/sandboxes/) and run `sbx login`",
62
+ argv=argv,
63
+ ) from exc
64
+ except subprocess.TimeoutExpired as exc:
65
+ raise SbxError(
66
+ f"sbx invocation timed out after {timeout or self.default_timeout:.0f}s",
67
+ argv=argv,
68
+ stderr=(exc.stderr or b"").decode() if isinstance(exc.stderr, bytes) else "",
69
+ ) from exc
70
+
71
+ result = ExecResult(
72
+ argv=argv,
73
+ returncode=proc.returncode,
74
+ stdout=proc.stdout,
75
+ stderr=proc.stderr,
76
+ duration_s=time.monotonic() - started,
77
+ )
78
+ if check and not result.ok:
79
+ raise self._error_for(result)
80
+ return result
81
+
82
+ def popen(self, *args: str) -> subprocess.Popen[str]:
83
+ """Start a streaming sbx invocation (stdout piped, line-buffered)."""
84
+ argv = self.argv(*args)
85
+ try:
86
+ return subprocess.Popen(
87
+ argv,
88
+ stdout=subprocess.PIPE,
89
+ stderr=subprocess.PIPE,
90
+ text=True,
91
+ bufsize=1,
92
+ )
93
+ except FileNotFoundError as exc:
94
+ raise SbxNotFoundError(
95
+ f"sbx binary {self.binary!r} not found on PATH",
96
+ argv=argv,
97
+ ) from exc
98
+
99
+ def _error_for(self, result: ExecResult) -> SbxError:
100
+ message = f"sbx command failed: {' '.join(result.argv[1:3])}"
101
+ lowered = result.stderr.lower()
102
+ cls = SbxNotFoundError if any(m in lowered for m in _NOT_FOUND_MARKERS) else SbxError
103
+ return cls(
104
+ message,
105
+ argv=result.argv,
106
+ returncode=result.returncode,
107
+ stderr=result.stderr,
108
+ )
109
+
110
+ # -- lifecycle ---------------------------------------------------------
111
+
112
+ def create(self, spec: SandboxSpec) -> None:
113
+ args = ["create", f"--name={spec.name}"]
114
+ if spec.template:
115
+ args += ["--template", spec.template]
116
+ args += [spec.agent, str(spec.workspace)]
117
+ # Creating a microVM can take a while on first template pull.
118
+ self.run(*args, timeout=600.0)
119
+
120
+ def exec(
121
+ self,
122
+ name: str,
123
+ cmd: Sequence[str],
124
+ *,
125
+ timeout: float | None = None,
126
+ ) -> ExecResult:
127
+ """Run a command inside a sandbox; the inner exit code is returned,
128
+ but sbx-level failures (missing sandbox, daemon down) raise."""
129
+ result = self.run("exec", name, *cmd, timeout=timeout, check=False)
130
+ if not result.ok:
131
+ lowered = result.stderr.lower()
132
+ if any(m in lowered for m in _NOT_FOUND_MARKERS):
133
+ raise self._error_for(result)
134
+ return result
135
+
136
+ def cp(self, src: str, dst: str, *, timeout: float | None = None) -> None:
137
+ self.run("cp", src, dst, timeout=timeout)
138
+
139
+ def ls(self) -> list[SandboxInfo]:
140
+ return parse_ls(self.run("ls").stdout)
141
+
142
+ def stop(self, name: str) -> None:
143
+ self.run("stop", name)
144
+
145
+ def rm(self, name: str, *, force: bool = True) -> None:
146
+ args = ["rm"]
147
+ if force:
148
+ args.append("--force")
149
+ self.run(*args, name)
150
+
151
+ def version(self) -> str | None:
152
+ return parse_version(self.run("version", check=False).stdout)
153
+
154
+ # -- network policy ----------------------------------------------------
155
+
156
+ def policy_allow(self, domain: str, *, sandbox: str | None = None) -> None:
157
+ args = ["policy", "allow", "network", domain]
158
+ if sandbox:
159
+ args += ["--sandbox", sandbox]
160
+ self.run(*args)
161
+
162
+ def policy_check(self, host: str, *, sandbox: str | None = None) -> bool:
163
+ args = ["policy", "check", "network", host]
164
+ if sandbox:
165
+ args += ["--sandbox", sandbox]
166
+ result = self.run(*args, check=False)
167
+ if not result.ok:
168
+ return False
169
+ lowered = result.stdout.lower()
170
+ return not any(marker in lowered for marker in ("deny", "denied", "block"))
171
+
172
+ def policy_ls(self) -> str:
173
+ return self.run("policy", "ls").stdout
174
+
175
+ def policy_init(self, preset: str) -> None:
176
+ self.run("policy", "init", preset)
177
+
178
+ # -- secrets -----------------------------------------------------------
179
+
180
+ def secret_set(
181
+ self,
182
+ service: str,
183
+ *,
184
+ sandbox: str | None = None,
185
+ token: str | None = None,
186
+ ) -> None:
187
+ """Attach a built-in secret service (global with ``-g``, else scoped)."""
188
+ scope = [sandbox] if sandbox else ["-g"]
189
+ self.run("secret", "set", *scope, service, stdin=token)
190
+
191
+ def secret_set_custom(
192
+ self,
193
+ *,
194
+ host: str,
195
+ env: str,
196
+ value: str,
197
+ sandbox: str | None = None,
198
+ ) -> None:
199
+ scope = [sandbox] if sandbox else ["-g"]
200
+ self.run(
201
+ "secret",
202
+ "set-custom",
203
+ *scope,
204
+ "--host",
205
+ host,
206
+ "--env",
207
+ env,
208
+ "--value",
209
+ value,
210
+ )
211
+
212
+ def secret_rm(
213
+ self,
214
+ *,
215
+ service: str | None = None,
216
+ host: str | None = None,
217
+ env: str | None = None,
218
+ sandbox: str | None = None,
219
+ ) -> bool:
220
+ """Best-effort secret removal; returns whether sbx accepted it.
221
+
222
+ Used by the replace-on-exists flow: sbx refuses to overwrite an
223
+ existing secret, so provisioning removes and re-sets. Removal syntax
224
+ for custom secrets is not a stable documented API, hence best-effort
225
+ (callers keep the existing value when removal is rejected).
226
+ """
227
+ scope = [sandbox] if sandbox else ["-g"]
228
+ args = ["secret", "rm", *scope]
229
+ if service:
230
+ args.append(service)
231
+ else:
232
+ assert env is not None
233
+ if host:
234
+ args += ["--host", host]
235
+ args += ["--env", env]
236
+ return self.run(*args, check=False).ok
sbxloop/sbx/models.py ADDED
@@ -0,0 +1,83 @@
1
+ """Models for the sbx CLI layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+ SandboxRole = Literal["agent", "github"]
11
+
12
+
13
+ class SecretSpec(BaseModel):
14
+ """A secret to inject into a sandbox.
15
+
16
+ Either a built-in sbx secret *service* (e.g. ``github``, which injects
17
+ ``GH_TOKEN``/``GITHUB_TOKEN`` scoped to github.com hosts), or a *custom*
18
+ secret bound to one host + env var via ``sbx secret set-custom``.
19
+ Values are never stored on this model; they are passed at provision time.
20
+ """
21
+
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ kind: Literal["service", "custom"]
25
+ service: str | None = None
26
+ host: str | None = None
27
+ env: str | None = None
28
+
29
+ @model_validator(mode="after")
30
+ def _check_kind(self) -> SecretSpec:
31
+ if self.kind == "service":
32
+ if not self.service:
33
+ raise ValueError("service secret requires a service name")
34
+ if self.host or self.env:
35
+ raise ValueError("service secret must not set host/env")
36
+ else:
37
+ if not self.host or not self.env:
38
+ raise ValueError("custom secret requires host and env")
39
+ if self.service:
40
+ raise ValueError("custom secret must not set service")
41
+ return self
42
+
43
+
44
+ class SandboxSpec(BaseModel):
45
+ """Everything needed to create and configure one sandbox."""
46
+
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+ name: str
50
+ role: SandboxRole
51
+ workspace: Path
52
+ agent: str = "shell"
53
+ template: str | None = None
54
+ policy_allows: list[str] = Field(default_factory=list)
55
+ secrets: list[SecretSpec] = Field(default_factory=list)
56
+ persistent_env: dict[str, str] = Field(default_factory=dict)
57
+
58
+
59
+ class SandboxInfo(BaseModel):
60
+ """One row of ``sbx ls`` (parsed tolerantly; unknown columns dropped)."""
61
+
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ name: str
65
+ agent: str | None = None
66
+ status: str | None = None
67
+ workspace: str | None = None
68
+
69
+
70
+ class ExecResult(BaseModel):
71
+ """Outcome of one sbx invocation (or an exec'd command inside a sandbox)."""
72
+
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ argv: list[str]
76
+ returncode: int
77
+ stdout: str
78
+ stderr: str
79
+ duration_s: float
80
+
81
+ @property
82
+ def ok(self) -> bool:
83
+ return self.returncode == 0
sbxloop/sbx/pair.py ADDED
@@ -0,0 +1,125 @@
1
+ """SandboxPair — the core sbxloop primitive — and its cleanup guarantees.
2
+
3
+ A run's pair consists of the agent sandbox (COPILOT_GITHUB_TOKEN only) and
4
+ the github-ops sandbox (GH_TOKEN only). The pair is a context manager whose
5
+ exit stops and removes both sandboxes unless ``keep`` is set; a process-wide
6
+ registry additionally cleans up on interpreter exit and on SIGINT/SIGTERM,
7
+ so aborted runs do not leak microVMs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import atexit
13
+ import contextlib
14
+ import logging
15
+ import signal
16
+ import threading
17
+ import types
18
+ from typing import Any
19
+
20
+ from sbxloop.sbx.sandbox import Sandbox
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class SandboxPair:
26
+ def __init__(
27
+ self,
28
+ run_id: str,
29
+ agent: Sandbox,
30
+ github: Sandbox,
31
+ *,
32
+ keep: bool = False,
33
+ ) -> None:
34
+ self.run_id = run_id
35
+ self.agent = agent
36
+ self.github = github
37
+ self.keep = keep
38
+ self._cleaned = False
39
+
40
+ def __enter__(self) -> SandboxPair:
41
+ cleanup_registry.register(self)
42
+ return self
43
+
44
+ def __exit__(
45
+ self,
46
+ exc_type: type[BaseException] | None,
47
+ exc: BaseException | None,
48
+ tb: types.TracebackType | None,
49
+ ) -> None:
50
+ if self.keep:
51
+ cleanup_registry.unregister(self)
52
+ else:
53
+ self.cleanup()
54
+
55
+ def cleanup(self) -> None:
56
+ """Stop and remove both sandboxes. Idempotent, best-effort per sandbox."""
57
+ if self._cleaned:
58
+ return
59
+ self._cleaned = True
60
+ cleanup_registry.unregister(self)
61
+ for sandbox in (self.agent, self.github):
62
+ try:
63
+ sandbox.stop()
64
+ except Exception:
65
+ logger.warning("failed to stop sandbox %s", sandbox.name, exc_info=True)
66
+ try:
67
+ sandbox.rm()
68
+ except Exception:
69
+ logger.warning("failed to remove sandbox %s", sandbox.name, exc_info=True)
70
+
71
+
72
+ class CleanupRegistry:
73
+ """Process-wide safety net: cleans registered pairs on exit and signals."""
74
+
75
+ def __init__(self) -> None:
76
+ self._pairs: list[SandboxPair] = []
77
+ self._lock = threading.Lock()
78
+ self._installed = False
79
+ self._previous: dict[int, Any] = {}
80
+
81
+ def register(self, pair: SandboxPair) -> None:
82
+ with self._lock:
83
+ if pair not in self._pairs:
84
+ self._pairs.append(pair)
85
+ self._install_handlers()
86
+
87
+ def unregister(self, pair: SandboxPair) -> None:
88
+ with self._lock:
89
+ if pair in self._pairs:
90
+ self._pairs.remove(pair)
91
+
92
+ def cleanup_all(self) -> None:
93
+ with self._lock:
94
+ pairs = list(self._pairs)
95
+ for pair in pairs:
96
+ try:
97
+ pair.cleanup()
98
+ except Exception:
99
+ logger.warning("cleanup failed for run %s", pair.run_id, exc_info=True)
100
+
101
+ def _install_handlers(self) -> None:
102
+ if self._installed:
103
+ return
104
+ self._installed = True
105
+ atexit.register(self.cleanup_all)
106
+ if threading.current_thread() is not threading.main_thread():
107
+ return # signal handlers can only be installed from the main thread
108
+ for signum in (signal.SIGINT, signal.SIGTERM):
109
+ # ValueError/OSError: not installable in this context (e.g. no tty)
110
+ with contextlib.suppress(ValueError, OSError):
111
+ self._previous[signum] = signal.signal(signum, self._handle_signal)
112
+
113
+ def _handle_signal(self, signum: int, frame: types.FrameType | None) -> None:
114
+ logger.info("signal %s received; cleaning up sandboxes", signum)
115
+ self.cleanup_all()
116
+ previous = self._previous.get(signum)
117
+ if callable(previous):
118
+ previous(signum, frame)
119
+ elif signum == signal.SIGINT:
120
+ raise KeyboardInterrupt
121
+ else:
122
+ raise SystemExit(128 + signum)
123
+
124
+
125
+ cleanup_registry = CleanupRegistry()
sbxloop/sbx/parse.py ADDED
@@ -0,0 +1,67 @@
1
+ """Tolerant parsers for sbx CLI text output.
2
+
3
+ The sbx binary is proprietary and its output format is not a stable API, so
4
+ parsing is column-header-driven and deliberately forgiving: unknown columns
5
+ are ignored and malformed lines are skipped rather than raising. Formats are
6
+ pinned by fixture tests captured from sbx v0.35.x; ``sbxloop doctor`` warns
7
+ on version mismatch.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from sbxloop.sbx.models import SandboxInfo
15
+
16
+ _VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)")
17
+
18
+
19
+ _CELL_SPLIT = re.compile(r"\s{2,}")
20
+
21
+
22
+ def parse_columns(text: str) -> list[dict[str, str]]:
23
+ """Parse a left-aligned column table using the header row for names.
24
+
25
+ Cells are split on runs of two or more spaces, which survives cells that
26
+ overflow their column width (unlike offset slicing). Rows with fewer
27
+ cells than headers get empty strings; extra cells are dropped.
28
+ """
29
+ lines = [line.rstrip() for line in text.splitlines() if line.strip()]
30
+ if not lines:
31
+ return []
32
+ headers = [h.lower() for h in _CELL_SPLIT.split(lines[0].strip())]
33
+ if not headers:
34
+ return []
35
+
36
+ rows: list[dict[str, str]] = []
37
+ for line in lines[1:]:
38
+ cells = _CELL_SPLIT.split(line.strip())
39
+ row = {
40
+ header: cells[i].strip() if i < len(cells) else "" for i, header in enumerate(headers)
41
+ }
42
+ rows.append(row)
43
+ return rows
44
+
45
+
46
+ def parse_ls(text: str) -> list[SandboxInfo]:
47
+ """Parse ``sbx ls`` output into SandboxInfo rows (nameless rows skipped)."""
48
+ infos: list[SandboxInfo] = []
49
+ for row in parse_columns(text):
50
+ name = row.get("name", "")
51
+ if not name:
52
+ continue
53
+ infos.append(
54
+ SandboxInfo(
55
+ name=name,
56
+ agent=row.get("agent") or None,
57
+ status=row.get("status") or None,
58
+ workspace=row.get("workspace") or None,
59
+ )
60
+ )
61
+ return infos
62
+
63
+
64
+ def parse_version(text: str) -> str | None:
65
+ """Extract a semver from ``sbx version`` output, if present."""
66
+ match = _VERSION_RE.search(text)
67
+ return match.group(1) if match else None