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/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
@@ -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])