sbxloop 0.5.76__tar.gz → 0.5.78__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 (50) hide show
  1. {sbxloop-0.5.76 → sbxloop-0.5.78}/PKG-INFO +2 -2
  2. sbxloop-0.5.76/src/sbxloop/_vendor/sbxloop_worker-0.5.76-py3-none-any.whl → sbxloop-0.5.78/src/sbxloop/_vendor/sbxloop_worker-0.5.78-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/cli/app.py +18 -5
  5. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/cli/doctor.py +2 -2
  6. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/config.py +45 -6
  7. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/model.py +54 -6
  8. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/conformance.py +14 -10
  9. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/parse.py +30 -12
  10. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/provision.py +31 -5
  11. {sbxloop-0.5.76 → sbxloop-0.5.78}/.gitignore +0 -0
  12. {sbxloop-0.5.76 → sbxloop-0.5.78}/README.md +0 -0
  13. {sbxloop-0.5.76 → sbxloop-0.5.78}/hatch_build.py +0 -0
  14. {sbxloop-0.5.76 → sbxloop-0.5.78}/pyproject.toml +0 -0
  15. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/__init__.py +0 -0
  16. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/cli/__init__.py +0 -0
  17. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/cli/models.py +0 -0
  18. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/cli/tui.py +0 -0
  19. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/deliver.py +0 -0
  20. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/__init__.py +0 -0
  21. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/engine.py +0 -0
  22. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/phases.py +0 -0
  23. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/__init__.py +0 -0
  24. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/decompose.md +0 -0
  25. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/execute.md +0 -0
  26. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/plan.md +0 -0
  27. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  28. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/steer.md +0 -0
  29. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/prompts/validate.md +0 -0
  30. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/engine/store.py +0 -0
  31. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/errors.py +0 -0
  32. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/events.py +0 -0
  33. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/gh/__init__.py +0 -0
  34. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/gh/ops.py +0 -0
  35. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/gh/reporter.py +0 -0
  36. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/ids.py +0 -0
  37. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/policy.py +0 -0
  38. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/py.typed +0 -0
  39. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/__init__.py +0 -0
  40. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/bake.py +0 -0
  41. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/cli.py +0 -0
  42. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/models.py +0 -0
  43. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/pair.py +0 -0
  44. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/prune.py +0 -0
  45. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/sandbox.py +0 -0
  46. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/sbx/secretstate.py +0 -0
  47. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/toolchains.py +0 -0
  48. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/worker/__init__.py +0 -0
  49. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/worker/client.py +0 -0
  50. {sbxloop-0.5.76 → sbxloop-0.5.78}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sbxloop
3
- Version: 0.5.76
3
+ Version: 0.5.78
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.76'
22
- __version_tuple__ = version_tuple = (0, 5, 76)
21
+ __version__ = version = '0.5.78'
22
+ __version_tuple__ = version_tuple = (0, 5, 78)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1274,11 +1274,24 @@ deny = []
1274
1274
  # deliver_draft = false
1275
1275
 
1276
1276
  [artifacts]
1277
- # Path components excluded from artifact listings and delivery, matched at
1278
- # any depth (a bare name each, no slashes). The default drops only noisy
1279
- # state dirs — dot-path artifacts like .github/ or .gitignore are kept.
1280
- # Exclusions are counted and surfaced, never silent.
1281
- exclude = [".git", ".sbxloop"]
1277
+ # Path components excluded from artifact listings, harvest and delivery,
1278
+ # matched at any depth (a bare name each, no slashes). Setting this replaces
1279
+ # the default list below rather than adding to it. The default drops run/VCS
1280
+ # state plus regenerable dependency and build trees — dot-path artifacts like
1281
+ # .github/ or .gitignore are kept, and so are the ambiguous generic names
1282
+ # (bin, build, dist, out, lib, vendor), which you can add if you want them
1283
+ # dropped. Exclusions are counted and surfaced, never silent.
1284
+ exclude = [
1285
+ ".git", ".sbxloop",
1286
+ ".mypy_cache", ".nox", ".pytest_cache", ".ruff_cache", ".tox",
1287
+ ".venv", "venv", "__pycache__", # Python
1288
+ "node_modules", # JavaScript / TypeScript
1289
+ "target", # Rust (cargo), Java (Maven)
1290
+ ".gradle", # Java (Gradle)
1291
+ "obj", # C# / .NET
1292
+ ".bundle", # Ruby (bundler)
1293
+ "CMakeFiles", # C / C++
1294
+ ]
1282
1295
 
1283
1296
  [budgets]
1284
1297
  max_revisions_per_task = 2
@@ -31,7 +31,7 @@ from sbxloop.sbx.secretstate import COPILOT_TOKEN_ENV
31
31
  from sbxloop.worker.wheel import resolve_worker_wheel
32
32
  from sbxloop_worker.backends.copilot import SDK_PERMISSION_KINDS, installed_sdk_permission_kinds
33
33
 
34
- TESTED_SBX_SERIES = "0.35"
34
+ TESTED_SBX_SERIES = "0.38"
35
35
 
36
36
 
37
37
  @dataclass
@@ -163,7 +163,7 @@ def collect_checks(
163
163
  "consider filing an issue to prefer host-side stats"
164
164
  if stats_available
165
165
  else "no host-side `sbx stats`; resource telemetry samples in-VM "
166
- f"on the worker heartbeat (expected on {TESTED_SBX_SERIES}.x)",
166
+ "on the worker heartbeat",
167
167
  hard=False,
168
168
  )
169
169
  )
@@ -153,10 +153,28 @@ class ArtifactsConfig(_ConfigModel):
153
153
  """What artifact listings and delivery leave out, and how harvesting works.
154
154
 
155
155
  ``exclude`` entries are single path components matched at any depth;
156
- a file is excluded when any component of its path matches. The default
157
- drops only genuinely noisy state dirs (``.git``, ``.sbxloop``) — dot-path
158
- artifacts like ``.github/`` or ``.gitignore`` are delivered. Exclusions
159
- are always counted and surfaced, never silent.
156
+ a file is excluded when any component of its path matches. Setting it
157
+ replaces the default list wholesale rather than adding to it.
158
+
159
+ The default drops run/VCS state (``.git``, ``.sbxloop``) plus the
160
+ machine-generated dependency and build trees of the supported languages
161
+ — ``node_modules``, ``__pycache__``, ``.venv``/``venv``, ``target``,
162
+ ``obj``, ``.gradle``, ``.bundle``, ``CMakeFiles`` and the Python tool
163
+ caches. Those are regenerable, can reach hundreds of MB, and are exactly
164
+ what nobody wants in an ``sbxloop artifacts`` listing or a ``--deliver``
165
+ PR diff. Dot-path artifacts like ``.github/`` or ``.gitignore`` are still
166
+ delivered.
167
+
168
+ Generic names that mean build output in one ecosystem and hand-written
169
+ content in another — ``bin``, ``build``, ``dist``, ``out``, ``lib``,
170
+ ``vendor`` — are deliberately *not* excluded; add them here if your
171
+ project wants them dropped. Exclusions are always counted and surfaced,
172
+ never silent.
173
+
174
+ The list covers every supported language regardless of
175
+ ``[sandbox] languages``: that key governs which toolchains are
176
+ pre-installed, not which ones the agent ends up using, and it defaults to
177
+ Python alone — see ``engine.model.DEFAULT_ARTIFACT_EXCLUDES``.
160
178
 
161
179
  ``harvest_mode`` controls when unmounted-run artifacts are copied to the
162
180
  host. ``"per-task"`` (default) copies after every task boundary plus the
@@ -166,8 +184,29 @@ class ArtifactsConfig(_ConfigModel):
166
184
  """
167
185
 
168
186
  # Mirrors engine.model.DEFAULT_ARTIFACT_EXCLUDES (kept literal here —
169
- # importing engine.model from config would be a circular import).
170
- exclude: list[str] = Field(default_factory=lambda: [".git", ".sbxloop"])
187
+ # importing engine.model from config would be a circular import). See
188
+ # that module for why each entry is in and why "bin"/"build"/"vendor"
189
+ # are out; test_config_default_mirrors_model_default pins the two.
190
+ exclude: list[str] = Field(
191
+ default_factory=lambda: [
192
+ ".git",
193
+ ".sbxloop",
194
+ ".mypy_cache",
195
+ ".nox",
196
+ ".pytest_cache",
197
+ ".ruff_cache",
198
+ ".tox",
199
+ ".venv",
200
+ "__pycache__",
201
+ "venv",
202
+ "node_modules",
203
+ "target",
204
+ ".gradle",
205
+ "obj",
206
+ ".bundle",
207
+ "CMakeFiles",
208
+ ]
209
+ )
171
210
  harvest_mode: HarvestMode = "per-task"
172
211
 
173
212
  @field_validator("exclude")
@@ -226,12 +226,60 @@ class RunResult(_Model):
226
226
  return self.state == "completed"
227
227
 
228
228
 
229
- # Path components excluded from artifact listings and delivery by default.
230
- # A denylist, not "anything dot-prefixed": agents legitimately produce
231
- # .github/workflows, .gitignore, .env.example and friends — only genuinely
232
- # noisy state dirs are dropped (an agent's .git would otherwise swamp
233
- # listings and deliveries). Overridable via [artifacts] exclude.
234
- DEFAULT_ARTIFACT_EXCLUDES = (".git", ".sbxloop")
229
+ # Path components excluded from artifact listings, harvest and delivery by
230
+ # default. A denylist, not "anything dot-prefixed": agents legitimately
231
+ # produce .github/workflows, .gitignore, .env.example and friends — only
232
+ # machine-generated dependency and build trees are dropped. An agent's .git
233
+ # or node_modules would otherwise swamp listings, balloon an unmounted run's
234
+ # harvest, and land in a delivery PR diff. Overridable via [artifacts]
235
+ # exclude (which replaces this list wholesale).
236
+ #
237
+ # The list is static rather than derived from [sandbox] languages, which
238
+ # selects what gets PRE-INSTALLED, not what the workspace ends up holding.
239
+ # That key defaults to toolchains.DEFAULT_LANGUAGES ("python",) and setting
240
+ # it replaces rather than extends, so deriving from it would leave every
241
+ # non-Python default run harvesting node_modules/target/obj — the exact bug
242
+ # this list exists to fix — and would drop __pycache__ back into listings for
243
+ # anyone who set languages = ["rust"]. Provisioning is only a head start
244
+ # besides: agents self-heal missing toolchains mid-run, so a Rust-configured
245
+ # run that npm-installs a docs site still grows a node_modules. A name that
246
+ # never occurs costs nothing to carry, so the narrowing buys nothing either.
247
+ # Entries earn a place only if they are (i) conventionally
248
+ # gitignored in their ecosystem and (ii) implausible as hand-written content
249
+ # in any other. That rule deliberately keeps out the generic names —
250
+ # "bin", "build", "dist", "out", "lib", "vendor" — which are build output in
251
+ # one ecosystem and checked-in scripts or deps in the next; a project that
252
+ # wants those dropped can say so in [artifacts] exclude. Exclusions are
253
+ # always counted and surfaced, never silent (#67), so a wrong call here is
254
+ # visible and recoverable rather than a quiet truncation.
255
+ DEFAULT_ARTIFACT_EXCLUDES = (
256
+ # Run/VCS state, any ecosystem.
257
+ ".git",
258
+ ".sbxloop",
259
+ # Python: virtualenvs, bytecode, tool caches.
260
+ ".mypy_cache",
261
+ ".nox",
262
+ ".pytest_cache",
263
+ ".ruff_cache",
264
+ ".tox",
265
+ ".venv",
266
+ "__pycache__",
267
+ "venv",
268
+ # JavaScript / TypeScript.
269
+ "node_modules",
270
+ # Rust (cargo) and Java (Maven) both name their build tree "target".
271
+ "target",
272
+ # Java (Gradle's project-local cache; "build" is deliberately kept).
273
+ ".gradle",
274
+ # C# / .NET intermediate objects ("bin" is deliberately kept — too many
275
+ # ecosystems check hand-written scripts into it).
276
+ "obj",
277
+ # Ruby (bundler's project-local config/cache).
278
+ ".bundle",
279
+ # C / C++ (CMake scratch; Go needs no entry — GOCACHE and the module
280
+ # cache live under $HOME, outside the harvested workspace).
281
+ "CMakeFiles",
282
+ )
235
283
 
236
284
 
237
285
  @dataclass(frozen=True)
@@ -135,8 +135,16 @@ def _probe_ls_columns(ctx: ProbeContext) -> tuple[str, str]:
135
135
  stdout = ctx.cli.run("ls").stdout
136
136
  lines = [line for line in stdout.splitlines() if line.strip()]
137
137
  header = lines[0].strip() if lines else ""
138
- have = {cell.lower() for cell in _CELL_SPLIT.split(header)} if header else set()
139
- missing = [col for col in ("name", "agent", "status", "workspace") if col not in have]
138
+ if not header or header.lower().startswith("no sandboxes"):
139
+ # An empty listing has no header row to inspect (0.38 prints "No
140
+ # sandboxes found."); parse_ls returns [] for it either way, so this
141
+ # is not drift evidence — the column check waits for a real listing.
142
+ return "expected-columns", f"empty sandbox list ({header!r}) — no header to check"
143
+ have = {cell.lower() for cell in _CELL_SPLIT.split(header)}
144
+ missing = [col for col in ("agent", "status", "workspace") if col not in have]
145
+ if not {"name", "sandbox"} & have:
146
+ # NAME on 0.35.x, renamed to SANDBOX in 0.38 — parse_ls accepts both.
147
+ missing.insert(0, "name|sandbox")
140
148
  if missing:
141
149
  return f"drifted({','.join(missing)})", f"header: {header!r}"
142
150
  return "expected-columns", f"header: {header!r}"
@@ -183,22 +191,18 @@ def _probe_cp_dir_semantics(ctx: ProbeContext) -> tuple[str, str]:
183
191
 
184
192
  def _probe_workspace_mount(ctx: ProbeContext) -> tuple[str, str]:
185
193
  assert ctx.sandbox is not None and ctx.workspace is not None
186
- from sbxloop.sbx.provision import MOUNT_SEARCH_MAXDEPTH, MOUNT_SEARCH_ROOTS
194
+ from sbxloop.sbx.provision import mount_probe_command, mount_search_roots
187
195
 
188
196
  marker = f".sbxloop-conformance-{_secrets.token_hex(8)}"
189
197
  (ctx.workspace / marker).write_text("")
190
198
  try:
191
- command = (
192
- f"find -L {' '.join(MOUNT_SEARCH_ROOTS)} "
193
- f"-maxdepth {MOUNT_SEARCH_MAXDEPTH} -name {marker} -print 2>/dev/null | head -1"
194
- )
195
- result = ctx.sandbox.exec(["sh", "-c", command])
199
+ result = ctx.sandbox.exec(["sh", "-c", mount_probe_command(ctx.workspace, marker)])
196
200
  hit = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
197
201
  finally:
198
202
  (ctx.workspace / marker).unlink(missing_ok=True)
199
203
  if hit.endswith(f"/{marker}"):
200
204
  return "discoverable", f"workspace mounted at {hit[: -len(f'/{marker}')] or '/'}"
201
- return "not-found", f"marker not found under {', '.join(MOUNT_SEARCH_ROOTS)}"
205
+ return "not-found", f"marker not found under {', '.join(mount_search_roots(ctx.workspace))}"
202
206
 
203
207
 
204
208
  def _probe_python3_venv(ctx: ProbeContext) -> tuple[str, str]:
@@ -317,7 +321,7 @@ CATALOG: tuple[Probe, ...] = (
317
321
  ),
318
322
  Probe(
319
323
  id=PROBE_LS_COLUMNS,
320
- summary="`sbx ls` header carries the NAME/AGENT/STATUS/WORKSPACE columns",
324
+ summary="`sbx ls` header carries the NAME|SANDBOX/AGENT/STATUS/WORKSPACE columns",
321
325
  tier="cheap",
322
326
  expected="expected-columns",
323
327
  depends="parse_ls (sandbox listing, `sandbox rm --all`, pair cleanup)",
@@ -3,13 +3,15 @@
3
3
  The sbx binary is proprietary and its output format is not a stable API, so
4
4
  parsing is column-header-driven and deliberately forgiving: unknown columns
5
5
  are ignored and malformed lines are skipped rather than raising. Formats are
6
- pinned by fixture tests captured from sbx v0.35.x; ``sbxloop doctor`` warns
7
- on version mismatch.
6
+ pinned by fixture tests captured from sbx v0.35.x and v0.38.x (which renamed
7
+ the ``NAME`` column to ``SANDBOX`` and added a frequently-empty ``PORTS``
8
+ column); ``sbxloop doctor`` warns on version mismatch.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
11
12
 
12
13
  import re
14
+ from bisect import bisect_right
13
15
 
14
16
  from sbxloop.sbx.models import SandboxInfo
15
17
 
@@ -18,36 +20,52 @@ _VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)")
18
20
 
19
21
  _CELL_SPLIT = re.compile(r"\s{2,}")
20
22
 
23
+ # A cell is a run of words separated by single spaces; runs of two or more
24
+ # spaces separate cells.
25
+ _CELL_ITER = re.compile(r"\S+(?: \S+)*")
26
+
21
27
 
22
28
  def parse_columns(text: str) -> list[dict[str, str]]:
23
29
  """Parse a left-aligned column table using the header row for names.
24
30
 
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.
31
+ Cells are assigned to columns by their start offset against the header
32
+ positions rather than by index, so a row with an empty middle cell
33
+ (sbx 0.38's ``PORTS`` column) doesn't shift its later cells into the
34
+ wrong columns. A cell that overflows its column width still lands on
35
+ the nearest header at or left of where it starts; if that column is
36
+ already taken the cell spills into the next free one. Missing cells
37
+ stay empty strings.
28
38
  """
29
39
  lines = [line.rstrip() for line in text.splitlines() if line.strip()]
30
40
  if not lines:
31
41
  return []
32
- headers = [h.lower() for h in _CELL_SPLIT.split(lines[0].strip())]
42
+ headers = [(match.group().lower(), match.start()) for match in _CELL_ITER.finditer(lines[0])]
33
43
  if not headers:
34
44
  return []
45
+ starts = [start for _, start in headers]
35
46
 
36
47
  rows: list[dict[str, str]] = []
37
48
  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
- }
49
+ row = {name: "" for name, _ in headers}
50
+ for match in _CELL_ITER.finditer(line):
51
+ i = max(bisect_right(starts, match.start()) - 1, 0)
52
+ while i < len(headers) and row[headers[i][0]]:
53
+ i += 1
54
+ if i < len(headers):
55
+ row[headers[i][0]] = match.group()
42
56
  rows.append(row)
43
57
  return rows
44
58
 
45
59
 
46
60
  def parse_ls(text: str) -> list[SandboxInfo]:
47
- """Parse ``sbx ls`` output into SandboxInfo rows (nameless rows skipped)."""
61
+ """Parse ``sbx ls`` output into SandboxInfo rows (nameless rows skipped).
62
+
63
+ The name column is ``NAME`` on sbx 0.35.x and ``SANDBOX`` since 0.38;
64
+ both are accepted.
65
+ """
48
66
  infos: list[SandboxInfo] = []
49
67
  for row in parse_columns(text):
50
- name = row.get("name", "")
68
+ name = row.get("name") or row.get("sandbox") or ""
51
69
  if not name:
52
70
  continue
53
71
  infos.append(
@@ -76,6 +76,36 @@ GITHUB_ALLOW_DOMAINS = (
76
76
  MOUNT_SEARCH_ROOTS = ("/workspace", "/home/agent", "/mnt", "/host", "/root")
77
77
  MOUNT_SEARCH_MAXDEPTH = 4
78
78
 
79
+
80
+ def mount_search_roots(workspace: Path | None) -> tuple[str, ...]:
81
+ """The in-VM roots to search for the workspace mount, most likely first.
82
+
83
+ sbx 0.38 mounts the host workspace at its own host absolute path inside
84
+ the VM (identity mount, field-verified on 0.38.0), so the workspace path
85
+ itself is probed first; the 0.35-era candidate roots remain as fallback.
86
+ """
87
+ if workspace is None:
88
+ return MOUNT_SEARCH_ROOTS
89
+ return (str(workspace), *MOUNT_SEARCH_ROOTS)
90
+
91
+
92
+ def mount_probe_command(workspace: Path | None, marker: str) -> str:
93
+ """One bounded in-VM marker search over the candidate mount roots.
94
+
95
+ Roots are existence-filtered before the ``find`` and the command always
96
+ exits 0 on a clean answer: on sbx 0.38 most candidate roots don't exist
97
+ in the VM, and a bare ``find`` over missing roots exits nonzero — which
98
+ would misclassify every clean "not mounted" answer as a broken probe
99
+ (the probe="answered" vs probe="error" split, #63).
100
+ """
101
+ roots = " ".join(shlex.quote(root) for root in mount_search_roots(workspace))
102
+ return (
103
+ f'set --; for r in {roots}; do [ -e "$r" ] && set -- "$@" "$r"; done; '
104
+ f'[ $# -eq 0 ] || find -L "$@" -maxdepth {MOUNT_SEARCH_MAXDEPTH} '
105
+ f"-name {marker} -print 2>/dev/null | head -1; :"
106
+ )
107
+
108
+
79
109
  PostCreate = Callable[[Sandbox, SandboxRole], None]
80
110
 
81
111
 
@@ -433,11 +463,7 @@ class Provisioner:
433
463
  return None
434
464
  probe_error = ""
435
465
  try:
436
- command = (
437
- f"find -L {' '.join(MOUNT_SEARCH_ROOTS)} "
438
- f"-maxdepth {MOUNT_SEARCH_MAXDEPTH} -name {marker} -print 2>/dev/null"
439
- " | head -1"
440
- )
466
+ command = mount_probe_command(workspace, marker)
441
467
  result = sandbox.exec(["sh", "-c", command])
442
468
  hit = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
443
469
  if not result.ok:
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