sbxloop 0.5.6__tar.gz → 0.5.8__tar.gz

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.
Files changed (45) hide show
  1. {sbxloop-0.5.6 → sbxloop-0.5.8}/PKG-INFO +1 -1
  2. sbxloop-0.5.8/src/sbxloop/_vendor/sbxloop_worker-0.5.8-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/app.py +72 -0
  5. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/doctor.py +23 -0
  6. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/tui.py +48 -5
  7. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/config.py +68 -1
  8. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/engine.py +73 -5
  9. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/model.py +29 -1
  10. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/phases.py +39 -4
  11. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/execute.md +7 -5
  12. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/plan.md +12 -2
  13. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/events.py +2 -0
  14. sbxloop-0.5.8/src/sbxloop/policy.py +167 -0
  15. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/worker/client.py +22 -1
  16. sbxloop-0.5.6/src/sbxloop/_vendor/sbxloop_worker-0.5.6-py3-none-any.whl +0 -0
  17. {sbxloop-0.5.6 → sbxloop-0.5.8}/.gitignore +0 -0
  18. {sbxloop-0.5.6 → sbxloop-0.5.8}/README.md +0 -0
  19. {sbxloop-0.5.6 → sbxloop-0.5.8}/hatch_build.py +0 -0
  20. {sbxloop-0.5.6 → sbxloop-0.5.8}/pyproject.toml +0 -0
  21. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/__init__.py +0 -0
  22. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/__init__.py +0 -0
  23. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/deliver.py +0 -0
  24. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/__init__.py +0 -0
  25. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/__init__.py +0 -0
  26. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/decompose.md +0 -0
  27. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  28. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/validate.md +0 -0
  29. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/store.py +0 -0
  30. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/errors.py +0 -0
  31. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/gh/__init__.py +0 -0
  32. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/gh/ops.py +0 -0
  33. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/gh/reporter.py +0 -0
  34. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/ids.py +0 -0
  35. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/py.typed +0 -0
  36. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/__init__.py +0 -0
  37. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/cli.py +0 -0
  38. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/models.py +0 -0
  39. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/pair.py +0 -0
  40. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/parse.py +0 -0
  41. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/provision.py +0 -0
  42. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/prune.py +0 -0
  43. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/sandbox.py +0 -0
  44. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/worker/__init__.py +0 -0
  45. {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop
3
- Version: 0.5.6
3
+ Version: 0.5.8
4
4
  Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.6'
22
- __version_tuple__ = version_tuple = (0, 5, 6)
21
+ __version__ = version = '0.5.8'
22
+ __version_tuple__ = version_tuple = (0, 5, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -587,6 +587,56 @@ def config_show() -> None:
587
587
  console.print(table)
588
588
 
589
589
 
590
+ @config_app.command("policy")
591
+ def config_policy() -> None:
592
+ """Show the effective per-phase network egress policy."""
593
+ from sbxloop.policy import PROMPT_ADVERTISED_DOMAINS
594
+ from sbxloop.sbx.provision import AGENT_ALLOW_DOMAINS, GITHUB_ALLOW_DOMAINS
595
+
596
+ try:
597
+ config = load_config()
598
+ except SdxloopError as exc:
599
+ console.print(f"[bold red]{exc}[/]")
600
+ raise typer.Exit(2) from exc
601
+
602
+ extra = list(config.sandbox.extra_allow_domains)
603
+ baseline = ", ".join([*AGENT_ALLOW_DOMAINS, *extra])
604
+ advertised = ", ".join(PROMPT_ADVERTISED_DOMAINS)
605
+
606
+ table = Table(title="agent sandbox: effective egress per phase")
607
+ table.add_column("phase", no_wrap=True)
608
+ table.add_column("policy", overflow="fold")
609
+ table.add_row("decompose / plan", "baseline")
610
+ table.add_row(
611
+ "execute",
612
+ "baseline + plan-declared grants (auto-granted just before execute, "
613
+ "within the [policy] bounds below; every grant/refusal is event-logged)",
614
+ )
615
+ table.add_row(
616
+ "scrutinize / verify / validate",
617
+ "baseline + grants already made — sbx has no policy revocation, so "
618
+ "grants persist for the sandbox's lifetime (sandboxes are removed at "
619
+ "run end; grants never outlive a run)",
620
+ )
621
+ console.print(table)
622
+ console.print(f"baseline (provisioned per-sandbox): {baseline}")
623
+ console.print(f"advertised by the user's balanced preset: {advertised}")
624
+
625
+ bounds = Table(title="[policy] bounds for plan-declared grants")
626
+ bounds.add_column("bound", no_wrap=True)
627
+ bounds.add_column("patterns", overflow="fold")
628
+ bounds.add_row(
629
+ "allow", ", ".join(config.policy.allow) or "(empty — plans may only use the baseline)"
630
+ )
631
+ bounds.add_row("deny", ", ".join(config.policy.deny) or "(none)")
632
+ console.print(bounds)
633
+
634
+ if config.github.enabled:
635
+ gh_domains = ", ".join([*GITHUB_ALLOW_DOMAINS, *extra])
636
+ console.print(f"github sandbox (all phases, no plan grants): {gh_domains}")
637
+ console.print("audit trail: [cyan]sbxloop logs RUN_ID --type policy.[/]")
638
+
639
+
590
640
  @app.command()
591
641
  def init(
592
642
  force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
@@ -635,6 +685,19 @@ secret_strategy = "proxy"
635
685
  # Extra network allow rules applied to both sandboxes.
636
686
  extra_allow_domains = []
637
687
 
688
+ [policy]
689
+ # Bounds for plan-declared egress. At PLAN time the agent may declare extra
690
+ # domains a task needs during EXECUTE (each with a justification); they are
691
+ # auto-granted to the agent sandbox just before EXECUTE only when they match
692
+ # `allow` and no `deny` pattern, and every grant/refusal is logged as a run
693
+ # event (`sbxloop logs RUN --type policy.`). Patterns: exact domains,
694
+ # "*.example.com" (the domain and all subdomains), or "*" (everything).
695
+ # Empty `allow` (the default) means plans may only use the always-reachable
696
+ # baseline: the Copilot/GitHub hosts, PyPI, and apt mirrors.
697
+ # See `sbxloop config policy` for the effective per-phase policy.
698
+ allow = []
699
+ deny = []
700
+
638
701
  [github]
639
702
  # The GitHub integration. Unset (the default) disables GitHub entirely:
640
703
  # no github sandbox is provisioned, GH_TOKEN is not required, and
@@ -655,6 +718,15 @@ max_replans_per_task = 1
655
718
  max_tasks = 20
656
719
  max_wall_clock_s = 7200.0
657
720
  per_job_timeout_s = 900.0
721
+
722
+ [limits]
723
+ # Sandbox resource guardrails (percent used; 0 disables). Sampled in-VM on
724
+ # the worker heartbeat and shown as a gauge in the TUI status panel.
725
+ # Crossing disk_abort fails the current task with an explicit
726
+ # "sandbox disk exhausted" error.
727
+ disk_warn = 85.0
728
+ disk_abort = 95.0
729
+ mem_warn = 90.0
658
730
  """
659
731
 
660
732
 
@@ -122,6 +122,29 @@ def collect_checks(
122
122
  )
123
123
  )
124
124
 
125
+ # Host-side resource stats: sbx 0.35.x has no stats command, so
126
+ # resource telemetry samples in-VM on the worker heartbeat. This is
127
+ # the field-check for the day sbx grows one (issue #54) — purely
128
+ # informational either way.
129
+ report("checking for host-side sandbox stats")
130
+ stats_available = False
131
+ try:
132
+ stats_available = cli.run("stats", "--help", check=False).returncode == 0
133
+ except SbxError:
134
+ stats_available = False
135
+ checks.append(
136
+ Check(
137
+ "sandbox stats",
138
+ True,
139
+ "host-side `sbx stats` available — sbxloop still samples in-VM; "
140
+ "consider filing an issue to prefer host-side stats"
141
+ if stats_available
142
+ else "no host-side `sbx stats`; resource telemetry samples in-VM "
143
+ f"on the worker heartbeat (expected on {TESTED_SBX_SERIES}.x)",
144
+ hard=False,
145
+ )
146
+ )
147
+
125
148
  # tokens
126
149
  checks.append(
127
150
  Check(
@@ -39,10 +39,12 @@ TASK_STATE_STYLES = {
39
39
  }
40
40
 
41
41
  # Compact one-liner lifecycle events (chat "system" messages).
42
- _LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.")
42
+ _LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.", "policy.")
43
43
 
44
44
  # High-volume noise excluded from the transcript (still queryable via
45
- # `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats.
45
+ # `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats,
46
+ # per-beat resource samples (they live in the pinned gauge instead —
47
+ # threshold *crossings* arrive as sandbox.resources_warning and do print).
46
48
  _TRANSCRIPT_SKIP = {
47
49
  EventTypes.AGENT_MESSAGE_DELTA,
48
50
  EventTypes.WORKER_STDOUT,
@@ -51,8 +53,11 @@ _TRANSCRIPT_SKIP = {
51
53
  EventTypes.WORKER_RESULT,
52
54
  EventTypes.WORKER_END,
53
55
  EventTypes.AGENT_USAGE,
56
+ EventTypes.SANDBOX_RESOURCES,
54
57
  }
55
58
 
59
+ RESOURCE_LEVEL_STYLES = {"ok": "dim", "warn": "yellow", "abort": "bold red"}
60
+
56
61
  AGENT_MESSAGE_CLIP = 4000
57
62
  # One-line clip for tool arguments; output tail lines shown on tool failure.
58
63
  TOOL_ARGS_LINE_CLIP = 160
@@ -148,7 +153,12 @@ def render_event(event: Event) -> RenderableType | None:
148
153
  style = "dim"
149
154
  if event.type == HostEventTypes.TASK_STATE:
150
155
  style = TASK_STATE_STYLES.get(str(data.get("state", "")), "dim")
151
- elif "fallback" in event.type or "missing" in event.type:
156
+ elif (
157
+ "fallback" in event.type
158
+ or "missing" in event.type
159
+ or "warning" in event.type
160
+ or event.type == HostEventTypes.POLICY_DENY
161
+ ):
152
162
  style = "yellow"
153
163
  return Text(line, style=style, overflow="fold")
154
164
 
@@ -166,13 +176,18 @@ class Dashboard:
166
176
  self.run_state: str = "starting"
167
177
  self.outcome: str = ""
168
178
  self.tasks: dict[str, dict[str, Any]] = {}
179
+ # Latest resource sample per sandbox role, rendered as one compact
180
+ # gauge line each; only escalates visually past warn/abort levels.
181
+ self.resources: dict[str, dict[str, Any]] = {}
169
182
 
170
183
  # -- event intake ------------------------------------------------------
171
184
 
172
185
  def on_event(self, event: Event) -> None:
173
186
  self.run_id = self.run_id or event.run_id
174
187
  data = event.data
175
- if event.type == HostEventTypes.RUN_START:
188
+ if event.type == EventTypes.SANDBOX_RESOURCES:
189
+ self.resources[str(data.get("role") or "sandbox")] = dict(data)
190
+ elif event.type == HostEventTypes.RUN_START:
176
191
  self.outcome = str(data.get("outcome", ""))
177
192
  elif event.type in (HostEventTypes.RUN_STATE, HostEventTypes.RUN_END):
178
193
  self.run_state = str(data.get("state", self.run_state))
@@ -219,12 +234,29 @@ class Dashboard:
219
234
  if not self.tasks:
220
235
  table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
221
236
 
237
+ gauges = [self._gauge_line(role) for role in sorted(self.resources)]
222
238
  return Panel(
223
- Group(header, outcome, table),
239
+ Group(header, outcome, table, *gauges),
224
240
  border_style="cyan",
225
241
  padding=(0, 1),
226
242
  )
227
243
 
244
+ def _gauge_line(self, role: str) -> Text:
245
+ sample = self.resources[role]
246
+ level = str(sample.get("level", "ok"))
247
+ parts = []
248
+ if sample.get("disk_used_pct") is not None:
249
+ parts.append(f"disk {sample['disk_used_pct']:.0f}%")
250
+ if sample.get("mem_used_pct") is not None:
251
+ parts.append(f"mem {sample['mem_used_pct']:.0f}%")
252
+ if sample.get("load1") is not None:
253
+ parts.append(f"load {sample['load1']}")
254
+ suffix = " ⚠ " + level if level != "ok" else ""
255
+ return Text(
256
+ f"{role}: " + " · ".join(parts) + suffix,
257
+ style=RESOURCE_LEVEL_STYLES.get(level, "dim"),
258
+ )
259
+
228
260
 
229
261
  def format_event(event: Event) -> str:
230
262
  """Dense single-line form (used by `sbxloop logs` and lifecycle lines)."""
@@ -239,6 +271,17 @@ def format_event(event: Event) -> str:
239
271
  value = str(event.data[key]).replace("\n", " ")
240
272
  parts.append(value[:160])
241
273
  break
274
+ if event.data.get("disk_used_pct") is not None:
275
+ # Resource samples: make `sbxloop logs --type-prefix sandbox.resources`
276
+ # answer "what did disk look like before the failure" at a glance.
277
+ summary = [f"disk={event.data['disk_used_pct']}%"]
278
+ if event.data.get("mem_used_pct") is not None:
279
+ summary.append(f"mem={event.data['mem_used_pct']}%")
280
+ if event.data.get("load1") is not None:
281
+ summary.append(f"load={event.data['load1']}")
282
+ if event.data.get("level"):
283
+ summary.append(str(event.data["level"]))
284
+ parts.append(" ".join(summary))
242
285
  if event.data.get("args"):
243
286
  parts.append(_one_line(str(event.data["args"]), 120))
244
287
  if picked != "error" and event.data.get("error"):
@@ -18,7 +18,14 @@ from collections.abc import Mapping
18
18
  from pathlib import Path
19
19
  from typing import Any, Literal
20
20
 
21
- from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
21
+ from pydantic import (
22
+ BaseModel,
23
+ ConfigDict,
24
+ Field,
25
+ ValidationError,
26
+ field_validator,
27
+ model_validator,
28
+ )
22
29
 
23
30
  from sbxloop.errors import ConfigError
24
31
 
@@ -42,6 +49,35 @@ class SandboxConfig(_ConfigModel):
42
49
  extra_allow_domains: list[str] = Field(default_factory=list)
43
50
 
44
51
 
52
+ class PolicyConfig(_ConfigModel):
53
+ """Operator bounds for plan-declared egress.
54
+
55
+ The PLAN phase may request extra network allows for EXECUTE (each with a
56
+ justification). Requests are auto-granted just before EXECUTE only when
57
+ they match ``allow`` and no ``deny`` pattern; everything is event-logged
58
+ (``sbxloop logs RUN --type policy.``). Patterns are exact domains,
59
+ ``*.example.com`` wildcards (the domain and all subdomains), or ``*``
60
+ (everything). Empty ``allow`` — the default — means plans may only use
61
+ the always-reachable baseline (Copilot/GitHub hosts, PyPI, apt mirrors).
62
+ """
63
+
64
+ allow: list[str] = Field(default_factory=list)
65
+ deny: list[str] = Field(default_factory=list)
66
+
67
+ @field_validator("allow", "deny")
68
+ @classmethod
69
+ def _check_patterns(cls, value: list[str]) -> list[str]:
70
+ from sbxloop.policy import valid_pattern
71
+
72
+ value = [p.strip().lower() for p in value]
73
+ bad = [p for p in value if not valid_pattern(p, operator=True)]
74
+ if bad:
75
+ raise ValueError(
76
+ f"invalid egress pattern(s) {bad}: use a domain, *.domain wildcard, or *"
77
+ )
78
+ return value
79
+
80
+
45
81
  class GithubConfig(_ConfigModel):
46
82
  """The GitHub integration. ``repo`` is the gate: unset (the default)
47
83
  disables GitHub entirely — no github sandbox is provisioned, no GH_TOKEN
@@ -68,6 +104,35 @@ class GithubConfig(_ConfigModel):
68
104
  return self.repo is not None
69
105
 
70
106
 
107
+ class Limits(_ConfigModel):
108
+ """Sandbox resource guardrails, sampled in-VM on the worker heartbeat.
109
+
110
+ Thresholds are percent-used of the workspace filesystem / memory; 0
111
+ disables a guardrail. Crossing a warn threshold emits a prominent
112
+ ``sandbox.resources_warning`` event and escalates the TUI gauge;
113
+ crossing ``disk_abort`` fails the current task with an explicit
114
+ "sandbox disk exhausted" error instead of letting in-VM tooling fail
115
+ confusingly on a full disk.
116
+ """
117
+
118
+ disk_warn: float = 85.0
119
+ disk_abort: float = 95.0
120
+ mem_warn: float = 90.0
121
+
122
+ @model_validator(mode="after")
123
+ def _check_thresholds(self) -> Limits:
124
+ for name in ("disk_warn", "disk_abort", "mem_warn"):
125
+ value = getattr(self, name)
126
+ if value < 0 or value > 100:
127
+ raise ValueError(f"limits.{name} must be a percentage in 0..100, got {value}")
128
+ if 0 < self.disk_abort <= self.disk_warn:
129
+ raise ValueError(
130
+ f"limits.disk_abort ({self.disk_abort}) must be greater than "
131
+ f"limits.disk_warn ({self.disk_warn})"
132
+ )
133
+ return self
134
+
135
+
71
136
  class Budgets(_ConfigModel):
72
137
  max_revisions_per_task: int = 2
73
138
  max_replans_per_task: int = 1
@@ -98,8 +163,10 @@ class Config(_ConfigModel):
98
163
  worker_python: str = "/home/agent/.sbxloop/venv/bin/python"
99
164
  install_workers: bool = True
100
165
  sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
166
+ policy: PolicyConfig = Field(default_factory=PolicyConfig)
101
167
  github: GithubConfig = Field(default_factory=GithubConfig)
102
168
  budgets: Budgets = Field(default_factory=Budgets)
169
+ limits: Limits = Field(default_factory=Limits)
103
170
 
104
171
 
105
172
  def _read_toml(path: Path) -> dict[str, Any]:
@@ -18,6 +18,7 @@ import json
18
18
  import logging
19
19
  import time
20
20
  from collections.abc import Callable, Sequence
21
+ from typing import Any
21
22
 
22
23
  from sbxloop.config import Config, load_config, load_dotenv_file
23
24
  from sbxloop.deliver import deliver_workspace
@@ -40,6 +41,7 @@ from sbxloop.events import EventBus, Hook, HostEventTypes
40
41
  from sbxloop.gh.ops import GithubOps
41
42
  from sbxloop.gh.reporter import GithubReporterHook
42
43
  from sbxloop.ids import new_run_id
44
+ from sbxloop.policy import EgressGranter
43
45
  from sbxloop.sbx.cli import SbxCLI
44
46
  from sbxloop.sbx.pair import SandboxPair
45
47
  from sbxloop.sbx.provision import Provisioner
@@ -75,9 +77,13 @@ class LoopEngine:
75
77
  install_workers if install_workers is not None else self.config.install_workers
76
78
  )
77
79
  self.clock = clock
80
+ # Latest sandbox.resources sample per sandbox role, fed by the bus;
81
+ # consulted for the disk guardrail and the harvest-truncation note.
82
+ self._last_resources: dict[str, dict[str, object]] = {}
78
83
  for hook in hooks:
79
84
  self.bus.attach_hook(hook)
80
85
  self.bus.subscribe(self._persist_event)
86
+ self.bus.subscribe(self._track_resources)
81
87
 
82
88
  def _persist_event(self, event: object) -> None:
83
89
  from sbxloop_worker.protocol import Event
@@ -85,6 +91,14 @@ class LoopEngine:
85
91
  assert isinstance(event, Event)
86
92
  self.store.append_event(event)
87
93
 
94
+ def _track_resources(self, event: object) -> None:
95
+ from sbxloop_worker.protocol import Event, EventTypes
96
+
97
+ assert isinstance(event, Event)
98
+ if event.type == EventTypes.SANDBOX_RESOURCES:
99
+ role = str(event.data.get("role") or "agent")
100
+ self._last_resources[role] = dict(event.data)
101
+
88
102
  # -- public API --------------------------------------------------------
89
103
 
90
104
  def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
@@ -124,6 +138,8 @@ class LoopEngine:
124
138
  self.bus,
125
139
  transport=self.config.worker_transport,
126
140
  python=self.worker_python,
141
+ role="agent",
142
+ limits=self.config.limits,
127
143
  )
128
144
  github = (
129
145
  WorkerClient(
@@ -131,6 +147,8 @@ class LoopEngine:
131
147
  self.bus,
132
148
  transport=self.config.worker_transport,
133
149
  python=self.worker_python,
150
+ role="github",
151
+ limits=self.config.limits,
134
152
  )
135
153
  if pair.github is not None
136
154
  else None
@@ -146,7 +164,10 @@ class LoopEngine:
146
164
  phases = PhaseRunner(
147
165
  agent, self.config, run_id, outcome, workdir=pair.agent_workdir
148
166
  )
149
- state = self._run_phases(run_id, phases, deadline, pair)
167
+ granter = EgressGranter(
168
+ self.sbx, self.config, self.bus, run_id, pair.agent.name
169
+ )
170
+ state = self._run_phases(run_id, phases, deadline, pair, granter)
150
171
  # Summary must post while the github sandbox is alive;
151
172
  # on an infra exception the run is resumable and the
152
173
  # resumed run reopens the same tracking issue.
@@ -249,12 +270,28 @@ class LoopEngine:
249
270
  if target is None or not target.is_dir():
250
271
  return
251
272
  count = sum(1 for p in target.rglob("*") if p.is_file())
273
+ extra: dict[str, Any] = {}
274
+ sample = self._last_resources.get("agent")
275
+ if sample and sample.get("level") in ("warn", "abort"):
276
+ # Disk was under pressure at the last sample — harvested
277
+ # artifacts may be truncated or missing.
278
+ extra = {
279
+ "disk_used_pct": sample.get("disk_used_pct"),
280
+ "resources_level": sample.get("level"),
281
+ }
282
+ logger.warning(
283
+ "run %s: sandbox disk was at %s%% at the last sample — "
284
+ "harvested artifacts may be incomplete",
285
+ run_id,
286
+ sample.get("disk_used_pct"),
287
+ )
252
288
  self.bus.emit(
253
289
  HostEventTypes.RUN_ARTIFACTS,
254
290
  run_id,
255
291
  path=str(target),
256
292
  files=count,
257
293
  mounted=pair.mounted,
294
+ **extra,
258
295
  )
259
296
 
260
297
  def _deliver(
@@ -301,7 +338,12 @@ class LoopEngine:
301
338
  self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, pr=pr.number, url=pr.url)
302
339
 
303
340
  def _run_phases(
304
- self, run_id: str, phases: PhaseRunner, deadline: float, pair: SandboxPair
341
+ self,
342
+ run_id: str,
343
+ phases: PhaseRunner,
344
+ deadline: float,
345
+ pair: SandboxPair,
346
+ granter: EgressGranter,
305
347
  ) -> RunState:
306
348
  tasks = self.store.get_tasks(run_id)
307
349
  if not tasks:
@@ -340,7 +382,7 @@ class LoopEngine:
340
382
  self.store.update_task(run_id, task)
341
383
  self._emit_task_end(run_id, task)
342
384
  continue
343
- self._run_task(run_id, phases, task, deadline, pair)
385
+ self._run_task(run_id, phases, task, deadline, pair, granter)
344
386
  if task.state == "failed":
345
387
  failed_ids.add(task.spec.id)
346
388
 
@@ -356,6 +398,7 @@ class LoopEngine:
356
398
  task: TaskRecord,
357
399
  deadline: float,
358
400
  pair: SandboxPair,
401
+ granter: EgressGranter,
359
402
  ) -> None:
360
403
  budgets = self.config.budgets
361
404
  if task.state == "pending":
@@ -374,10 +417,18 @@ class LoopEngine:
374
417
 
375
418
  while not task.terminal:
376
419
  self._check_cancelled_and_clock(run_id, deadline)
420
+ abort_reason = self._resource_abort_reason()
421
+ if abort_reason:
422
+ # Fail the task with a diagnosis instead of letting the next
423
+ # phase produce garbage in a full sandbox. Dependents are
424
+ # skipped by the normal failed-task machinery.
425
+ task.last_feedback = abort_reason
426
+ self._set_task_state(run_id, task, "failed")
427
+ break
377
428
  if task.state == "planning":
378
429
  self._phase_plan(run_id, phases, task)
379
430
  elif task.state == "executing":
380
- self._phase_execute_and_scrutinize(run_id, phases, task, budgets)
431
+ self._phase_execute_and_scrutinize(run_id, phases, task, granter)
381
432
  elif task.state == "verifying":
382
433
  self._phase_verify(run_id, phases, task, budgets)
383
434
  elif task.state == "validating":
@@ -410,9 +461,14 @@ class LoopEngine:
410
461
  run_id: str,
411
462
  phases: PhaseRunner,
412
463
  task: TaskRecord,
413
- budgets: object,
464
+ granter: EgressGranter,
414
465
  ) -> None:
415
466
  assert task.plan is not None
467
+ # Grant-late: plan-declared egress is applied at EXECUTE entry, not
468
+ # at plan time. Runs here (not in _phase_plan) so resumed tasks whose
469
+ # persisted state skips planning still get their grants on the
470
+ # freshly provisioned sandbox.
471
+ granter.apply(task.spec.id, [(egress.domain, egress.reason) for egress in task.plan.egress])
416
472
  started = time.time()
417
473
  result = phases.execute(task, task.plan)
418
474
  task.session_id = result.session_id
@@ -510,6 +566,18 @@ class LoopEngine:
510
566
 
511
567
  # -- bookkeeping -------------------------------------------------------
512
568
 
569
+ def _resource_abort_reason(self) -> str | None:
570
+ """Non-None when the agent sandbox's last resource sample crossed
571
+ the disk_abort threshold (the worker classifies; the level rides on
572
+ the event)."""
573
+ sample = self._last_resources.get("agent")
574
+ if sample and sample.get("level") == "abort":
575
+ return (
576
+ f"sandbox disk exhausted: {sample.get('disk_used_pct')}% of the workspace "
577
+ f"filesystem is used (limits.disk_abort={self.config.limits.disk_abort}%)"
578
+ )
579
+ return None
580
+
513
581
  def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
514
582
  if self.store.get_run(run_id).state == "cancelled":
515
583
  raise StateError(f"run {run_id} was cancelled")
@@ -6,7 +6,7 @@ from graphlib import CycleError, TopologicalSorter
6
6
  from pathlib import Path
7
7
  from typing import Literal
8
8
 
9
- from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10
10
 
11
11
  RunState = Literal[
12
12
  "created",
@@ -95,12 +95,40 @@ class TaskGraph(_Model):
95
95
  return 1 + max(self._depth(by_id[d], by_id) for d in task.depends_on)
96
96
 
97
97
 
98
+ class EgressSpec(_Model):
99
+ """One plan-declared network need: a domain EXECUTE must reach, and why.
100
+
101
+ Granted to the agent sandbox just before EXECUTE — but only within the
102
+ operator's ``[policy]`` bounds (checked at plan time, enforced again at
103
+ grant time). ``domain`` is a bare domain or ``*.domain`` wildcard; no
104
+ scheme, path, or port, and never the bare ``"*"``.
105
+ """
106
+
107
+ domain: str
108
+ reason: str = ""
109
+
110
+ @field_validator("domain")
111
+ @classmethod
112
+ def _check_domain(cls, value: str) -> str:
113
+ from sbxloop.policy import valid_pattern
114
+
115
+ value = value.strip().lower()
116
+ if not valid_pattern(value):
117
+ raise ValueError(
118
+ f"egress domain must be a domain or *.domain wildcard "
119
+ f"(no scheme/path/port), got {value!r}"
120
+ )
121
+ return value
122
+
123
+
98
124
  class PlanModel(_Model):
99
125
  """The agent's plan for one task."""
100
126
 
101
127
  steps: list[str]
102
128
  expected_artifacts: list[str] = Field(default_factory=list)
103
129
  verify_commands: list[str] = Field(default_factory=list)
130
+ # External domains EXECUTE needs beyond the baseline, with justification.
131
+ egress: list[EgressSpec] = Field(default_factory=list)
104
132
 
105
133
 
106
134
  class Issue(_Model):
@@ -15,9 +15,10 @@ Session strategy per phase (a deliberate design decision):
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ from collections.abc import Callable
18
19
  from typing import Literal, TypeVar
19
20
 
20
- from pydantic import BaseModel, ValidationError
21
+ from pydantic import BaseModel
21
22
 
22
23
  from sbxloop.config import Config
23
24
  from sbxloop.engine.model import PlanModel, TaskGraph, TaskRecord, Verdict
@@ -99,16 +100,25 @@ class PhaseRunner:
99
100
  context: dict[str, str],
100
101
  *,
101
102
  permission_mode: Literal["auto", "read_only"] = "auto",
103
+ check: Callable[[ModelT], None] | None = None,
102
104
  ) -> ModelT:
103
- """Run a JSON-expecting agent job; one retry with the validation error."""
105
+ """Run a JSON-expecting agent job; one retry with the validation error.
106
+
107
+ ``check`` runs host-side semantic validation on the parsed model and
108
+ raises ValueError to reject — same retry path as schema failures
109
+ (pydantic's ValidationError is a ValueError subclass).
110
+ """
104
111
  retry_context = ""
105
112
  last_error: Exception | None = None
106
113
  for _ in range(2):
107
114
  prompt = render(prompt_name, retry_context=retry_context, **context)
108
115
  result = self._agent_job(prompt, permission_mode=permission_mode, expect="json")
109
116
  try:
110
- return model_cls.model_validate(result.output_json)
111
- except ValidationError as exc:
117
+ model = model_cls.model_validate(result.output_json)
118
+ if check is not None:
119
+ check(model)
120
+ return model
121
+ except ValueError as exc: # includes pydantic's ValidationError
112
122
  last_error = exc
113
123
  retry_context = (
114
124
  "\n## Previous attempt was invalid\n\n"
@@ -156,8 +166,33 @@ class PhaseRunner:
156
166
  "acceptance_criteria": bullet_list(task.spec.acceptance_criteria),
157
167
  "feedback": task.last_feedback or "(none — first attempt)",
158
168
  },
169
+ check=self._check_plan_egress,
159
170
  )
160
171
 
172
+ def _check_plan_egress(self, plan: PlanModel) -> None:
173
+ """Reject plans declaring egress outside the operator's bounds.
174
+
175
+ Feeds the retry loop, so the planner gets one chance to drop the
176
+ domain (or find a baseline-reachable alternative) before the run
177
+ fails — the "grant only within operator-set limits" guardrail.
178
+ """
179
+ from sbxloop.policy import effective_egress_bounds, egress_rejection
180
+
181
+ allow, deny = effective_egress_bounds(self.config)
182
+ problems = [
183
+ f"- {egress.domain}: {rejection}"
184
+ for egress in plan.egress
185
+ if (rejection := egress_rejection(egress.domain, allow, deny)) is not None
186
+ ]
187
+ if problems:
188
+ raise ValueError(
189
+ "plan-declared egress is outside the operator's bounds:\n"
190
+ + "\n".join(problems)
191
+ + "\nDrop these domains from `egress` (prefer baseline-reachable hosts: "
192
+ "PyPI, GitHub, apt mirrors). Only the operator can extend the bounds, "
193
+ "via [policy] allow in sbxloop.toml."
194
+ )
195
+
161
196
  def execute(self, task: TaskRecord, plan: PlanModel) -> JobResult:
162
197
  prompt = render(
163
198
  "execute",
@@ -17,11 +17,13 @@ destroyed.
17
17
  project commands through `.venv/bin/...`.
18
18
  - If a tool or apt package is missing, you have passwordless sudo:
19
19
  `sudo apt-get install -y <package>`.
20
- - Network egress is allowlisted. PyPI, GitHub, and apt mirrors are
21
- reachable; other hosts (npm registry, arbitrary APIs) may not be. If a
22
- download times out repeatedly, say so in your summary instead of
23
- retrying forever the allowlist is user configuration, not something
24
- you can change.
20
+ - Network egress is allowlisted. PyPI, GitHub, apt mirrors, and any
21
+ domains the plan declared as `egress` are reachable; other hosts (npm
22
+ registry, arbitrary APIs) may not be. If a download times out
23
+ repeatedly, treat the host as blocked: name the exact blocked domain in
24
+ your summary — a re-plan can declare it — instead of retrying forever.
25
+ The allowlist is operator-bounded configuration, not something you can
26
+ change from in here.
25
27
 
26
28
  ## Overall outcome
27
29
 
@@ -26,17 +26,27 @@ Respond with exactly one fenced JSON block:
26
26
  {
27
27
  "steps": ["specific action 1", "specific action 2"],
28
28
  "expected_artifacts": ["files or outputs this task should produce"],
29
- "verify_commands": ["shell commands that exit 0 only when the work is correct"]
29
+ "verify_commands": ["shell commands that exit 0 only when the work is correct"],
30
+ "egress": [{"domain": "registry.npmjs.org", "reason": "npm install for the build"}]
30
31
  }
31
32
  ```
32
33
 
33
34
  Steps must be specific enough that an executor with no other context can
34
35
  follow them. Include the task's own verification ideas in `verify_commands`.
35
36
 
37
+ `egress` declares external domains the executor will need to reach beyond
38
+ the baseline. PyPI, GitHub, and apt mirrors are always reachable — never
39
+ declare those. Each entry needs a short justification; use `[]` when the
40
+ baseline suffices (the common case). Domains only — no scheme, path, or
41
+ port; `*.example.com` wildcards are accepted. Declarations are auto-granted
42
+ only within an operator-set allowlist: a request outside it fails this
43
+ plan's validation, so prefer baseline-reachable alternatives.
44
+
36
45
  Environment facts to plan around: Debian/Ubuntu VM; the system Python is
37
46
  externally managed (PEP 668), so Python dependencies belong in a project
38
47
  virtualenv (`python3 -m venv .venv`) and commands — including your
39
48
  `verify_commands` — should use `.venv/bin/...` paths; missing apt packages
40
49
  can be installed with passwordless sudo; network egress is allowlisted
41
- (PyPI, GitHub, and apt mirrors are reachable — other registries may not be).
50
+ (PyPI, GitHub, and apt mirrors are reachable — declare anything else in
51
+ `egress`).
42
52
  $retry_context
@@ -82,6 +82,8 @@ class HostEventTypes:
82
82
  TASK_END = "task.end"
83
83
  PHASE_START = "phase.start"
84
84
  PHASE_END = "phase.end"
85
+ POLICY_ALLOW = "policy.allow"
86
+ POLICY_DENY = "policy.deny"
85
87
  SANDBOX_PROVISION_START = "sandbox.provision_start"
86
88
  SANDBOX_READY = "sandbox.ready"
87
89
  SANDBOX_CLEANUP = "sandbox.cleanup"
@@ -0,0 +1,167 @@
1
+ """Plan-declared network egress: bounds checking and grant-late application.
2
+
3
+ The PLAN phase may declare external domains a task needs during EXECUTE
4
+ (``PlanModel.egress``). Declarations are validated against operator-set
5
+ bounds (``[policy] allow`` / ``[policy] deny`` in sbxloop.toml) at plan time,
6
+ and granted to the agent sandbox — via ``sbx policy allow network <domain>
7
+ --sandbox <agent>`` — only just before EXECUTE runs ("grant late"). Every
8
+ grant and refusal is emitted as a ``policy.*`` run event, so the persisted
9
+ event log doubles as an egress audit trail (``sbxloop logs RUN --type
10
+ policy.``).
11
+
12
+ sbx 0.35 has no primitive for revoking or narrowing an allow on a live
13
+ sandbox, so grants are additive for the sandbox's lifetime — SCRUTINIZE and
14
+ VERIFY inherit whatever EXECUTE was granted. Grants never outlive a run:
15
+ sandboxes are removed at run end, and resume provisions fresh ones. If a
16
+ future sbx grows revocation, this module is where "revoke early" lands.
17
+
18
+ Pattern semantics (ours, not sbx's — bounds govern what may be *requested*;
19
+ the requested domain is passed to sbx verbatim): a pattern is an exact
20
+ domain, a wildcard ``*.example.com`` (covers ``example.com`` and every
21
+ subdomain), or the operator-only ``*`` (covers everything).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ from sbxloop.config import Config
29
+ from sbxloop.errors import SbxError
30
+ from sbxloop.events import EventBus, HostEventTypes
31
+ from sbxloop.sbx.cli import SbxCLI
32
+
33
+ DOMAIN_PATTERN_RE = re.compile(r"(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}")
34
+
35
+ # Hosts the plan/execute prompts advertise as reachable (the user's balanced
36
+ # preset covers PyPI and apt mirrors). A plan declaring one of these is
37
+ # in-bounds without operator configuration — granting it per-sandbox cannot
38
+ # expand egress beyond what the prompts already promise, and makes the
39
+ # promise hold even under a preset that lacks the host.
40
+ PROMPT_ADVERTISED_DOMAINS = (
41
+ "pypi.org",
42
+ "files.pythonhosted.org",
43
+ "deb.debian.org",
44
+ "security.debian.org",
45
+ "archive.ubuntu.com",
46
+ "ports.ubuntu.com",
47
+ )
48
+
49
+
50
+ def valid_pattern(pattern: str, *, operator: bool = False) -> bool:
51
+ """Whether ``pattern`` is a well-formed domain pattern.
52
+
53
+ ``operator=True`` additionally permits the bare ``"*"`` — meaningful as
54
+ an operator bound ("grant anything the plan asks for") but rejected in
55
+ plan declarations, which must name what they need.
56
+ """
57
+ if pattern == "*":
58
+ return operator
59
+ return DOMAIN_PATTERN_RE.fullmatch(pattern) is not None
60
+
61
+
62
+ def pattern_covers(pattern: str, domain: str) -> bool:
63
+ """Whether a bounds pattern covers a requested domain (or wildcard)."""
64
+ pattern = pattern.lower()
65
+ domain = domain.lower()
66
+ if pattern == "*" or pattern == domain:
67
+ return True
68
+ if not pattern.startswith("*."):
69
+ return False
70
+ base = pattern[2:]
71
+ requested = domain[2:] if domain.startswith("*.") else domain
72
+ return requested == base or requested.endswith(f".{base}")
73
+
74
+
75
+ def egress_rejection(domain: str, allow: list[str], deny: list[str]) -> str | None:
76
+ """Why ``domain`` may not be granted, or None when it is in bounds."""
77
+ for pattern in deny:
78
+ if pattern_covers(pattern, domain):
79
+ return f"matches [policy] deny pattern {pattern!r}"
80
+ if not any(pattern_covers(pattern, domain) for pattern in allow):
81
+ return "not covered by [policy] allow in sbxloop.toml"
82
+ return None
83
+
84
+
85
+ def effective_egress_bounds(config: Config) -> tuple[list[str], list[str]]:
86
+ """The (allow, deny) bounds plan-declared egress is checked against.
87
+
88
+ The allow side is the operator's ``[policy] allow`` plus everything the
89
+ agent sandbox can already reach (its provision-time baseline and the
90
+ prompt-advertised hosts) — declaring an already-reachable domain must
91
+ never fail a plan. ``[policy] deny`` still wins over all of it.
92
+ """
93
+ from sbxloop.sbx.provision import AGENT_ALLOW_DOMAINS
94
+
95
+ allow = [
96
+ *AGENT_ALLOW_DOMAINS,
97
+ *config.sandbox.extra_allow_domains,
98
+ *PROMPT_ADVERTISED_DOMAINS,
99
+ *config.policy.allow,
100
+ ]
101
+ return allow, list(config.policy.deny)
102
+
103
+
104
+ class EgressGranter:
105
+ """Applies one run's plan-declared egress grants to the agent sandbox.
106
+
107
+ Grants are applied at EXECUTE entry, not at plan time — the tightest
108
+ point sbx's grant-only policy model permits. Idempotent per domain: the
109
+ provision-time baseline is pre-seeded, and revision loops re-entering
110
+ EXECUTE do not re-grant. Out-of-bounds declarations are refused with a
111
+ ``policy.deny`` event; plan validation normally rejects them, so a deny
112
+ here means the operator tightened bounds after the plan was persisted
113
+ (e.g. across a resume).
114
+ """
115
+
116
+ def __init__(self, cli: SbxCLI, config: Config, bus: EventBus, run_id: str, sandbox: str):
117
+ from sbxloop.sbx.provision import AGENT_ALLOW_DOMAINS
118
+
119
+ self.cli = cli
120
+ self.bus = bus
121
+ self.run_id = run_id
122
+ self.sandbox = sandbox
123
+ self.allow, self.deny = effective_egress_bounds(config)
124
+ self._granted = {
125
+ d.lower() for d in (*AGENT_ALLOW_DOMAINS, *config.sandbox.extra_allow_domains)
126
+ }
127
+
128
+ def apply(self, task_id: str, egress: list[tuple[str, str]]) -> None:
129
+ """Grant each declared (domain, justification), event-logging both
130
+ grants and refusals. sbx failures propagate (infrastructure error)."""
131
+ for domain, reason in egress:
132
+ domain = domain.lower()
133
+ rejection = egress_rejection(domain, self.allow, self.deny)
134
+ if rejection is not None:
135
+ self.bus.emit(
136
+ HostEventTypes.POLICY_DENY,
137
+ self.run_id,
138
+ task_id=task_id,
139
+ domain=domain,
140
+ sandbox=self.sandbox,
141
+ message=f"egress refused: {domain} — {rejection}",
142
+ )
143
+ continue
144
+ if domain in self._granted:
145
+ continue
146
+ try:
147
+ self.cli.policy_allow(domain, sandbox=self.sandbox)
148
+ except SbxError:
149
+ self.bus.emit(
150
+ HostEventTypes.POLICY_DENY,
151
+ self.run_id,
152
+ task_id=task_id,
153
+ domain=domain,
154
+ sandbox=self.sandbox,
155
+ message=f"egress grant failed: {domain} — sbx policy allow errored",
156
+ )
157
+ raise
158
+ self._granted.add(domain)
159
+ self.bus.emit(
160
+ HostEventTypes.POLICY_ALLOW,
161
+ self.run_id,
162
+ task_id=task_id,
163
+ domain=domain,
164
+ reason=reason,
165
+ sandbox=self.sandbox,
166
+ message=f"egress granted: {domain} — {reason or 'no justification given'}",
167
+ )
@@ -27,7 +27,7 @@ from collections import deque
27
27
  from pathlib import Path
28
28
 
29
29
  import sbxloop
30
- from sbxloop.config import WorkerTransport
30
+ from sbxloop.config import Limits, WorkerTransport
31
31
  from sbxloop.errors import SbxError, WorkerError, WorkerTimeoutError
32
32
  from sbxloop.events import EventBus
33
33
  from sbxloop.sbx.models import ExecResult
@@ -61,6 +61,8 @@ class WorkerClient:
61
61
  python: str = DEFAULT_PYTHON,
62
62
  poll_interval: float = 2.0,
63
63
  grace_s: float = 60.0,
64
+ role: str | None = None,
65
+ limits: Limits | None = None,
64
66
  ) -> None:
65
67
  self.sandbox = sandbox
66
68
  self.bus = bus or EventBus()
@@ -68,6 +70,11 @@ class WorkerClient:
68
70
  self.python = python
69
71
  self.poll_interval = poll_interval
70
72
  self.grace_s = grace_s
73
+ # Sandbox role for enriching resource telemetry (the worker doesn't
74
+ # know which sandbox it lives in), and guardrail thresholds to pass
75
+ # through to the worker's heartbeat sampler.
76
+ self.role = role
77
+ self.limits = limits
71
78
 
72
79
  # -- install -----------------------------------------------------------
73
80
 
@@ -266,6 +273,15 @@ class WorkerClient:
266
273
  # process itself chdirs there — agent SDK sessions inherit it.
267
274
  if job.cwd:
268
275
  argv += ["--cwd", job.cwd]
276
+ if self.limits is not None:
277
+ argv += [
278
+ "--disk-warn",
279
+ str(self.limits.disk_warn),
280
+ "--disk-abort",
281
+ str(self.limits.disk_abort),
282
+ "--mem-warn",
283
+ str(self.limits.mem_warn),
284
+ ]
269
285
  # sbx injects secrets through the sandbox session/profile machinery;
270
286
  # a bare exec'd process may not see them. Run the worker under a
271
287
  # login shell so the sandbox environment is fully loaded.
@@ -338,6 +354,11 @@ class WorkerClient:
338
354
  Event.now(EventTypes.WORKER_STDOUT, job.run_id, job_id=job.job_id, line=line)
339
355
  )
340
356
  return
357
+ if self.role is not None and event.type in (
358
+ EventTypes.SANDBOX_RESOURCES,
359
+ EventTypes.SANDBOX_RESOURCES_WARNING,
360
+ ):
361
+ event.data.setdefault("role", self.role)
341
362
  self.bus.publish(event)
342
363
 
343
364
  # -- poll transport ----------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes