sdxloop 0.1.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.
- sdxloop/__init__.py +26 -0
- sdxloop/_vendor/sdxloop_worker-0.1.0-py3-none-any.whl +0 -0
- sdxloop/cli/__init__.py +1 -0
- sdxloop/cli/app.py +359 -0
- sdxloop/cli/doctor.py +145 -0
- sdxloop/cli/tui.py +129 -0
- sdxloop/config.py +180 -0
- sdxloop/engine/__init__.py +26 -0
- sdxloop/engine/engine.py +380 -0
- sdxloop/engine/model.py +151 -0
- sdxloop/engine/phases.py +223 -0
- sdxloop/engine/prompts/__init__.py +31 -0
- sdxloop/engine/prompts/decompose.md +41 -0
- sdxloop/engine/prompts/execute.md +31 -0
- sdxloop/engine/prompts/plan.md +35 -0
- sdxloop/engine/prompts/scrutinize.md +43 -0
- sdxloop/engine/prompts/validate.md +38 -0
- sdxloop/engine/store.py +257 -0
- sdxloop/errors.py +73 -0
- sdxloop/events.py +87 -0
- sdxloop/gh/__init__.py +6 -0
- sdxloop/gh/ops.py +140 -0
- sdxloop/gh/reporter.py +75 -0
- sdxloop/ids.py +35 -0
- sdxloop/py.typed +0 -0
- sdxloop/sbx/__init__.py +6 -0
- sdxloop/sbx/cli.py +210 -0
- sdxloop/sbx/models.py +83 -0
- sdxloop/sbx/pair.py +125 -0
- sdxloop/sbx/parse.py +67 -0
- sdxloop/sbx/provision.py +209 -0
- sdxloop/sbx/sandbox.py +70 -0
- sdxloop/worker/__init__.py +6 -0
- sdxloop/worker/client.py +260 -0
- sdxloop/worker/wheel.py +73 -0
- sdxloop-0.1.0.dist-info/METADATA +32 -0
- sdxloop-0.1.0.dist-info/RECORD +39 -0
- sdxloop-0.1.0.dist-info/WHEEL +4 -0
- sdxloop-0.1.0.dist-info/entry_points.txt +2 -0
sdxloop/sbx/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Typed subprocess wrapper around the sbx binary.
|
|
2
|
+
|
|
3
|
+
Every invocation injects ``--app-name`` (unless disabled) so sdxloop'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 sdxloop.errors import SbxError, SbxNotFoundError
|
|
15
|
+
from sdxloop.sbx.models import ExecResult, SandboxInfo, SandboxSpec
|
|
16
|
+
from sdxloop.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 = "sdxloop",
|
|
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
|
+
)
|
sdxloop/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
|
sdxloop/sbx/pair.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""SandboxPair — the core sdxloop 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 sdxloop.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()
|
sdxloop/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; ``sdxloop doctor`` warns
|
|
7
|
+
on version mismatch.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from sdxloop.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
|
sdxloop/sbx/provision.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Provision the per-run sandbox pair: create, network policy, secrets, dirs.
|
|
2
|
+
|
|
3
|
+
The credential split is enforced here:
|
|
4
|
+
|
|
5
|
+
- **agent sandbox** gets only ``COPILOT_GITHUB_TOKEN`` (read from the host
|
|
6
|
+
environment), injected via ``sbx secret set-custom`` bound to the Copilot
|
|
7
|
+
API hosts — the value never enters the VM under the default ``proxy``
|
|
8
|
+
strategy.
|
|
9
|
+
- **github sandbox** gets only ``GH_TOKEN`` via sbx's built-in ``github``
|
|
10
|
+
secret service (scoped to github.com hosts).
|
|
11
|
+
|
|
12
|
+
The ``plain-env`` fallback strategy writes tokens to ``~/.sdxloop/env.sh``
|
|
13
|
+
inside the sandbox (weaker: the value is visible in the VM) for environments
|
|
14
|
+
where the experimental ``set-custom`` proxy rewriting is unavailable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from sdxloop.config import Config
|
|
26
|
+
from sdxloop.errors import ProvisionError, SbxError
|
|
27
|
+
from sdxloop.events import EventBus
|
|
28
|
+
from sdxloop.sbx.cli import SbxCLI
|
|
29
|
+
from sdxloop.sbx.models import SandboxRole, SandboxSpec, SecretSpec
|
|
30
|
+
from sdxloop.sbx.pair import SandboxPair
|
|
31
|
+
from sdxloop.sbx.sandbox import ENV_FILE, EVENTS_DIR, JOBS_DIR, RESULTS_DIR, Sandbox
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
COPILOT_TOKEN_ENV = "COPILOT_GITHUB_TOKEN"
|
|
36
|
+
GH_TOKEN_ENVS = ("GH_TOKEN", "GITHUB_TOKEN")
|
|
37
|
+
|
|
38
|
+
# Hosts the Copilot token must reach: token exchange happens against
|
|
39
|
+
# api.github.com, model/agent traffic against the copilot API hosts.
|
|
40
|
+
AGENT_TOKEN_HOSTS = ("api.githubcopilot.com", "api.github.com")
|
|
41
|
+
|
|
42
|
+
AGENT_ALLOW_DOMAINS = (
|
|
43
|
+
"api.githubcopilot.com",
|
|
44
|
+
"*.githubcopilot.com",
|
|
45
|
+
"api.github.com",
|
|
46
|
+
"github.com",
|
|
47
|
+
"objects.githubusercontent.com", # copilot CLI runtime downloads
|
|
48
|
+
"raw.githubusercontent.com",
|
|
49
|
+
)
|
|
50
|
+
GITHUB_ALLOW_DOMAINS = (
|
|
51
|
+
"api.github.com",
|
|
52
|
+
"github.com",
|
|
53
|
+
"uploads.github.com",
|
|
54
|
+
"objects.githubusercontent.com",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
PostCreate = Callable[[Sandbox, SandboxRole], None]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sandbox_name(run_id: str, role: SandboxRole) -> str:
|
|
61
|
+
return f"sdxloop-{run_id}-{role}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Provisioner:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
cli: SbxCLI,
|
|
68
|
+
config: Config,
|
|
69
|
+
bus: EventBus | None = None,
|
|
70
|
+
*,
|
|
71
|
+
env: Mapping[str, str] | None = None,
|
|
72
|
+
post_create: PostCreate | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.cli = cli
|
|
75
|
+
self.config = config
|
|
76
|
+
self.bus = bus or EventBus()
|
|
77
|
+
self.env = os.environ if env is None else env
|
|
78
|
+
self.post_create = post_create
|
|
79
|
+
|
|
80
|
+
# -- spec construction -------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def build_specs(self, run_id: str, workspace: Path) -> tuple[SandboxSpec, SandboxSpec]:
|
|
83
|
+
extra = tuple(self.config.sandbox.extra_allow_domains)
|
|
84
|
+
template = self.config.sandbox.template
|
|
85
|
+
agent = SandboxSpec(
|
|
86
|
+
name=sandbox_name(run_id, "agent"),
|
|
87
|
+
role="agent",
|
|
88
|
+
workspace=workspace,
|
|
89
|
+
template=template,
|
|
90
|
+
policy_allows=[*AGENT_ALLOW_DOMAINS, *extra],
|
|
91
|
+
secrets=[
|
|
92
|
+
SecretSpec(kind="custom", host=host, env=COPILOT_TOKEN_ENV)
|
|
93
|
+
for host in AGENT_TOKEN_HOSTS
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
github = SandboxSpec(
|
|
97
|
+
name=sandbox_name(run_id, "github"),
|
|
98
|
+
role="github",
|
|
99
|
+
workspace=workspace,
|
|
100
|
+
template=template,
|
|
101
|
+
policy_allows=[*GITHUB_ALLOW_DOMAINS, *extra],
|
|
102
|
+
secrets=[SecretSpec(kind="service", service="github")],
|
|
103
|
+
)
|
|
104
|
+
return agent, github
|
|
105
|
+
|
|
106
|
+
# -- tokens ------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def copilot_token(self) -> str:
|
|
109
|
+
token = self.env.get(COPILOT_TOKEN_ENV, "")
|
|
110
|
+
if not token:
|
|
111
|
+
raise ProvisionError(
|
|
112
|
+
f"{COPILOT_TOKEN_ENV} is not set on the host. Create a fine-grained PAT "
|
|
113
|
+
'with the "Copilot Requests" permission and export it.'
|
|
114
|
+
)
|
|
115
|
+
return token
|
|
116
|
+
|
|
117
|
+
def gh_token(self) -> str:
|
|
118
|
+
for name in GH_TOKEN_ENVS:
|
|
119
|
+
token = self.env.get(name, "")
|
|
120
|
+
if token:
|
|
121
|
+
return token
|
|
122
|
+
raise ProvisionError(
|
|
123
|
+
f"none of {', '.join(GH_TOKEN_ENVS)} are set on the host. Create a fine-grained "
|
|
124
|
+
"PAT with the repository permissions sdxloop should act with (e.g. issues:write, "
|
|
125
|
+
"contents:read) and export GH_TOKEN."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# -- provisioning ------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def ensure_pair(self, run_id: str, workspace: Path | None = None) -> SandboxPair:
|
|
131
|
+
workspace = workspace or self.config.sandbox.workspace
|
|
132
|
+
if workspace is None:
|
|
133
|
+
workspace = self.config.state_dir / "runs" / run_id / "workspace"
|
|
134
|
+
workspace = workspace.resolve()
|
|
135
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
# Fail fast on missing tokens before creating any microVM.
|
|
138
|
+
tokens: dict[SandboxRole, str] = {
|
|
139
|
+
"agent": self.copilot_token(),
|
|
140
|
+
"github": self.gh_token(),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
agent_spec, github_spec = self.build_specs(run_id, workspace)
|
|
144
|
+
created: list[Sandbox] = []
|
|
145
|
+
try:
|
|
146
|
+
sandboxes: dict[SandboxRole, Sandbox] = {}
|
|
147
|
+
for spec in (agent_spec, github_spec):
|
|
148
|
+
self.bus.emit("sandbox.provision_start", run_id, name=spec.name, role=spec.role)
|
|
149
|
+
self.cli.create(spec)
|
|
150
|
+
sandbox = Sandbox(self.cli, spec.name)
|
|
151
|
+
created.append(sandbox)
|
|
152
|
+
self._apply_policy(spec)
|
|
153
|
+
self._apply_secrets(spec, sandbox, tokens[spec.role])
|
|
154
|
+
sandbox.mkdirs(JOBS_DIR, RESULTS_DIR, EVENTS_DIR)
|
|
155
|
+
if self.post_create is not None:
|
|
156
|
+
self.post_create(sandbox, spec.role)
|
|
157
|
+
sandboxes[spec.role] = sandbox
|
|
158
|
+
self.bus.emit("sandbox.ready", run_id, name=spec.name, role=spec.role)
|
|
159
|
+
return SandboxPair(
|
|
160
|
+
run_id,
|
|
161
|
+
agent=sandboxes["agent"],
|
|
162
|
+
github=sandboxes["github"],
|
|
163
|
+
keep=self.config.keep_sandboxes,
|
|
164
|
+
)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
for sandbox in created:
|
|
167
|
+
try:
|
|
168
|
+
sandbox.rm()
|
|
169
|
+
except SbxError:
|
|
170
|
+
logger.warning("rollback: failed to remove %s", sandbox.name, exc_info=True)
|
|
171
|
+
if isinstance(exc, ProvisionError):
|
|
172
|
+
raise
|
|
173
|
+
raise ProvisionError(f"provisioning run {run_id} failed: {exc}") from exc
|
|
174
|
+
|
|
175
|
+
def _apply_policy(self, spec: SandboxSpec) -> None:
|
|
176
|
+
for domain in spec.policy_allows:
|
|
177
|
+
self.cli.policy_allow(domain, sandbox=spec.name)
|
|
178
|
+
|
|
179
|
+
def _apply_secrets(self, spec: SandboxSpec, sandbox: Sandbox, token: str) -> None:
|
|
180
|
+
if self.config.secret_strategy == "plain-env":
|
|
181
|
+
self._apply_plain_env(spec, sandbox, token)
|
|
182
|
+
return
|
|
183
|
+
for secret in spec.secrets:
|
|
184
|
+
if secret.kind == "service":
|
|
185
|
+
assert secret.service is not None
|
|
186
|
+
self.cli.secret_set(secret.service, sandbox=spec.name, token=token)
|
|
187
|
+
else:
|
|
188
|
+
assert secret.host is not None and secret.env is not None
|
|
189
|
+
self.cli.secret_set_custom(
|
|
190
|
+
host=secret.host,
|
|
191
|
+
env=secret.env,
|
|
192
|
+
value=token,
|
|
193
|
+
sandbox=spec.name,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _apply_plain_env(self, spec: SandboxSpec, sandbox: Sandbox, token: str) -> None:
|
|
197
|
+
"""Weaker fallback: write tokens/env into ~/.sdxloop/env.sh in the VM."""
|
|
198
|
+
exports: dict[str, str] = dict(spec.persistent_env)
|
|
199
|
+
if spec.role == "agent":
|
|
200
|
+
exports[COPILOT_TOKEN_ENV] = token
|
|
201
|
+
else:
|
|
202
|
+
exports["GH_TOKEN"] = token
|
|
203
|
+
exports["GITHUB_TOKEN"] = token
|
|
204
|
+
lines = "".join(
|
|
205
|
+
f"export {key}={shlex.quote(value)}\n" for key, value in sorted(exports.items())
|
|
206
|
+
)
|
|
207
|
+
sandbox.exec(["mkdir", "-p", ENV_FILE.rsplit("/", 1)[0]])
|
|
208
|
+
sandbox.write_text(ENV_FILE, lines)
|
|
209
|
+
sandbox.exec(["chmod", "600", ENV_FILE])
|