sbxloop 0.5.8__tar.gz → 0.5.10__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.8 → sbxloop-0.5.10}/PKG-INFO +1 -1
  2. sbxloop-0.5.8/src/sbxloop/_vendor/sbxloop_worker-0.5.8-py3-none-any.whl → sbxloop-0.5.10/src/sbxloop/_vendor/sbxloop_worker-0.5.10-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/app.py +219 -0
  5. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/doctor.py +2 -5
  6. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/errors.py +4 -0
  7. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/cli.py +6 -0
  8. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/provision.py +11 -109
  9. sbxloop-0.5.10/src/sbxloop/sbx/secretstate.py +420 -0
  10. {sbxloop-0.5.8 → sbxloop-0.5.10}/.gitignore +0 -0
  11. {sbxloop-0.5.8 → sbxloop-0.5.10}/README.md +0 -0
  12. {sbxloop-0.5.8 → sbxloop-0.5.10}/hatch_build.py +0 -0
  13. {sbxloop-0.5.8 → sbxloop-0.5.10}/pyproject.toml +0 -0
  14. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/__init__.py +0 -0
  15. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/__init__.py +0 -0
  16. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/tui.py +0 -0
  17. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/config.py +0 -0
  18. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/deliver.py +0 -0
  19. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/__init__.py +0 -0
  20. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/engine.py +0 -0
  21. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/model.py +0 -0
  22. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/phases.py +0 -0
  23. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/__init__.py +0 -0
  24. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/decompose.md +0 -0
  25. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/execute.md +0 -0
  26. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/plan.md +0 -0
  27. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  28. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/validate.md +0 -0
  29. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/store.py +0 -0
  30. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/events.py +0 -0
  31. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/gh/__init__.py +0 -0
  32. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/gh/ops.py +0 -0
  33. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/gh/reporter.py +0 -0
  34. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/ids.py +0 -0
  35. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/policy.py +0 -0
  36. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/py.typed +0 -0
  37. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/__init__.py +0 -0
  38. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/models.py +0 -0
  39. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/pair.py +0 -0
  40. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/parse.py +0 -0
  41. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/prune.py +0 -0
  42. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/sandbox.py +0 -0
  43. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/worker/__init__.py +0 -0
  44. {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/worker/client.py +0 -0
  45. {sbxloop-0.5.8 → sbxloop-0.5.10}/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.8
3
+ Version: 0.5.10
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.8'
22
- __version_tuple__ = version_tuple = (0, 5, 8)
21
+ __version__ = version = '0.5.10'
22
+ __version_tuple__ = version_tuple = (0, 5, 10)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import queue
6
7
  import threading
7
8
  import time
@@ -26,6 +27,16 @@ from sbxloop.events import Event
26
27
  from sbxloop.sbx.cli import SbxCLI
27
28
  from sbxloop.sbx.provision import sandbox_name
28
29
  from sbxloop.sbx.prune import classify_sandboxes, format_age, remove_sandbox
30
+ from sbxloop.sbx.secretstate import (
31
+ COPILOT_TOKEN_ENV,
32
+ SANDBOX_SCOPE_PREFIX,
33
+ assess,
34
+ inspect_custom_secret,
35
+ removal_ladder,
36
+ replace_registration,
37
+ tracked_custom_secrets,
38
+ verify_secret_visibility,
39
+ )
29
40
 
30
41
  app = typer.Typer(
31
42
  name="sbxloop",
@@ -35,8 +46,12 @@ app = typer.Typer(
35
46
  )
36
47
  sandbox_app = typer.Typer(help="Manage sbxloop sandboxes.", no_args_is_help=True)
37
48
  config_app = typer.Typer(help="Inspect configuration.", no_args_is_help=True)
49
+ secrets_app = typer.Typer(
50
+ help="Manage the sbx custom-secret registrations sbxloop owns.", no_args_is_help=True
51
+ )
38
52
  app.add_typer(sandbox_app, name="sandbox")
39
53
  app.add_typer(config_app, name="config")
54
+ app.add_typer(secrets_app, name="secrets")
40
55
 
41
56
  console = Console()
42
57
 
@@ -481,6 +496,210 @@ def sandbox_rm(
481
496
  console.print(f"[yellow]skip {target}:[/] {exc}")
482
497
 
483
498
 
499
+ _STATUS_STYLES = {"ok": "[green]ok[/]", "warn": "[yellow]warn[/]", "unknown": "[dim]?[/]"}
500
+
501
+
502
+ def _secrets_context() -> tuple[Config, SbxCLI, set[str]]:
503
+ """Config, an sbx handle, and the live sbxloop sandbox names (for
504
+ telling in-use registration scopes from stale ones)."""
505
+ config = load_config()
506
+ cli = SbxCLI(app_name=config.app_name or None)
507
+ live = {i.name for i in cli.ls() if i.name.startswith(SANDBOX_SCOPE_PREFIX)}
508
+ return config, cli, live
509
+
510
+
511
+ @secrets_app.command("list")
512
+ def secrets_list(
513
+ probe: Annotated[
514
+ bool,
515
+ typer.Option(
516
+ "--probe/--no-probe",
517
+ help="When `sbx secret ls` cannot answer, detect registrations by "
518
+ "transiently registering (and immediately removing) a sentinel — "
519
+ "the collision error names the real owner.",
520
+ ),
521
+ ] = True,
522
+ ) -> None:
523
+ """Show sbxloop's custom-secret registrations across scopes.
524
+
525
+ Flags registrations that no longer match what provisioning would
526
+ register (stale scopes, wrong host bindings) — the pre-collision
527
+ warnings. The built-in `github` service secret is sbx-managed and never
528
+ touched by these commands.
529
+ """
530
+ try:
531
+ config, cli, live = _secrets_context()
532
+ table = Table(title="sbxloop custom-secret registrations")
533
+ for column in ("env", "expected", "actual", "status", "note"):
534
+ table.add_column(column)
535
+ warned = False
536
+ for env, host in tracked_custom_secrets(config):
537
+ state = inspect_custom_secret(cli, env, host=host, probe=probe)
538
+ judgement = assess(state, canonical_host=host, live_sandboxes=live)
539
+ warned = warned or judgement.status == "warn"
540
+ if state.exists:
541
+ actual = f"scope {state.scope or '(unknown)'}"
542
+ if state.hosts:
543
+ actual += f" @ {', '.join(state.hosts)}"
544
+ elif state.exists is None:
545
+ actual = "(undetermined)"
546
+ else:
547
+ actual = "not registered"
548
+ table.add_row(
549
+ env,
550
+ f"custom @ {host} (per-run scope)",
551
+ actual,
552
+ _STATUS_STYLES[judgement.status],
553
+ judgement.note,
554
+ )
555
+ console.print(table)
556
+ console.print(
557
+ "[dim]GH_TOKEN uses sbx's built-in `github` service secret; "
558
+ "it is never managed here.[/]"
559
+ )
560
+ if warned:
561
+ console.print(
562
+ "\n[yellow]warnings above are pre-collision state[/] — "
563
+ "`sbxloop secrets clean` removes the stale entries"
564
+ )
565
+ except SdxloopError as exc:
566
+ console.print(f"[bold red]{exc}[/]")
567
+ raise typer.Exit(2) from exc
568
+
569
+
570
+ @secrets_app.command("clean")
571
+ def secrets_clean(
572
+ apply: Annotated[
573
+ bool, typer.Option("--apply", help="Actually remove (default is a dry run).")
574
+ ] = False,
575
+ all_: Annotated[
576
+ bool,
577
+ typer.Option(
578
+ "--all",
579
+ help="Also remove healthy sbxloop-owned registrations (global with the "
580
+ "canonical binding, live-sandbox scopes), not just stale ones.",
581
+ ),
582
+ ] = False,
583
+ ) -> None:
584
+ """Remove stale sbxloop-owned custom-secret registrations (dry-run by default).
585
+
586
+ Only touches registrations sbxloop itself created (sbxloop-* sandbox
587
+ scopes and global entries for its tracked env vars) — never foreign
588
+ scopes and never the built-in `github` service secret.
589
+ """
590
+ try:
591
+ config, cli, live = _secrets_context()
592
+ failed = False
593
+ removed_any = False
594
+ for env, host in tracked_custom_secrets(config):
595
+ state = inspect_custom_secret(cli, env, host=host)
596
+ judgement = assess(state, canonical_host=host, live_sandboxes=live)
597
+ if not (judgement.stale or (all_ and judgement.owned)):
598
+ console.print(f"{env}: nothing to clean ({judgement.note})")
599
+ continue
600
+ where = f"scope {state.scope or '(unknown)'}"
601
+ if not apply:
602
+ console.print(f"{env}: would remove the registration in {where} — {judgement.note}")
603
+ removed_any = True
604
+ continue
605
+ if any(rm() for rm in removal_ladder(cli, state, host=host)):
606
+ console.print(f"[green]{env}: removed the registration in {where}[/]")
607
+ removed_any = True
608
+ else:
609
+ console.print(f"[bold red]{env}: sbx rejected every removal for {where}[/]")
610
+ failed = True
611
+ if not apply and removed_any:
612
+ console.print("\ndry run — re-run with [cyan]--apply[/] to remove")
613
+ if failed:
614
+ raise typer.Exit(1)
615
+ except SdxloopError as exc:
616
+ console.print(f"[bold red]{exc}[/]")
617
+ raise typer.Exit(2) from exc
618
+
619
+
620
+ @secrets_app.command("rotate")
621
+ def secrets_rotate(
622
+ prompt: Annotated[
623
+ bool,
624
+ typer.Option(
625
+ "--prompt",
626
+ help="Read the new token from a hidden interactive prompt instead of "
627
+ f"the {COPILOT_TOKEN_ENV} environment variable / ./.env.",
628
+ ),
629
+ ] = False,
630
+ verify: Annotated[
631
+ bool,
632
+ typer.Option(
633
+ "--verify/--no-verify",
634
+ help="Boot a throwaway sandbox to report which secret strategy "
635
+ "(proxy vs plain-env fallback) the next run will use.",
636
+ ),
637
+ ] = True,
638
+ ) -> None:
639
+ """Rotate the Copilot token's sbx registration in one step.
640
+
641
+ Replaces the existing registration (wherever its scope) with a global
642
+ one carrying the canonical host binding — the rm + set-custom dance
643
+ provisioning would otherwise perform mid-run. The token is read from
644
+ the environment/.env or an interactive prompt, never from argv.
645
+ """
646
+ if prompt:
647
+ token = typer.prompt(f"new {COPILOT_TOKEN_ENV}", hide_input=True)
648
+ else:
649
+ token = os.environ.get(COPILOT_TOKEN_ENV, "")
650
+ if not token:
651
+ console.print(
652
+ f"[bold red]{COPILOT_TOKEN_ENV} is not set.[/] Export the new token "
653
+ "(or put it in ./.env), or pass [cyan]--prompt[/] to type it — "
654
+ "it is never accepted as a command-line argument."
655
+ )
656
+ raise typer.Exit(2)
657
+ try:
658
+ config, cli, live = _secrets_context()
659
+ for env, host in tracked_custom_secrets(config):
660
+ replace_registration(cli, env=env, host=host, token=token)
661
+ console.print(f"[green]rotated:[/] {env} registered @ {host} (global scope)")
662
+ if live:
663
+ console.print(
664
+ f"[yellow]live sbxloop sandboxes exist ({', '.join(sorted(live))})[/] — "
665
+ "they may still hold the old token in their in-VM env file; "
666
+ "remove them with `sbxloop sandbox rm --all`"
667
+ )
668
+ if prompt:
669
+ console.print(
670
+ f"[yellow]runs read {COPILOT_TOKEN_ENV} from the environment at "
671
+ "provision time[/] — update your export / ./.env with the new value too"
672
+ )
673
+ if config.secret_strategy == "plain-env":
674
+ console.print(
675
+ "next run: [bold]plain-env[/] strategy (configured) — the token is "
676
+ "written to the in-VM env file from your environment"
677
+ )
678
+ elif verify:
679
+ workspace = config.state_dir / "secretcheck"
680
+ workspace.mkdir(parents=True, exist_ok=True)
681
+ visible = verify_secret_visibility(
682
+ cli,
683
+ env=COPILOT_TOKEN_ENV,
684
+ workspace=workspace,
685
+ template=config.sandbox.template,
686
+ )
687
+ if visible is True:
688
+ console.print(
689
+ "next run: [bold green]proxy[/] strategy — the token stays out of the VM"
690
+ )
691
+ elif visible is False:
692
+ console.print(
693
+ "next run: [bold yellow]plain-env fallback[/] — sbx's proxy secret is "
694
+ "invisible to exec sessions, so provisioning will write the in-VM env file"
695
+ )
696
+ else:
697
+ console.print("[yellow]could not verify secret visibility[/] (see logs)")
698
+ except SdxloopError as exc:
699
+ console.print(f"[bold red]rotate failed:[/] {exc}")
700
+ raise typer.Exit(2) from exc
701
+
702
+
484
703
  @sandbox_app.command("prune")
485
704
  def sandbox_prune(
486
705
  force: Annotated[
@@ -13,12 +13,9 @@ from sbxloop.config import load_config
13
13
  from sbxloop.engine.store import StateStore
14
14
  from sbxloop.errors import SbxError, SbxNotFoundError
15
15
  from sbxloop.sbx.cli import SbxCLI
16
- from sbxloop.sbx.provision import (
17
- AGENT_TOKEN_HOSTS,
18
- COPILOT_TOKEN_ENV,
19
- GH_TOKEN_ENVS,
20
- )
16
+ from sbxloop.sbx.provision import AGENT_TOKEN_HOSTS, GH_TOKEN_ENVS
21
17
  from sbxloop.sbx.prune import count_orphans
18
+ from sbxloop.sbx.secretstate import COPILOT_TOKEN_ENV
22
19
  from sbxloop.worker.wheel import resolve_worker_wheel
23
20
 
24
21
  TESTED_SBX_SERIES = "0.35"
@@ -13,6 +13,10 @@ class ConfigError(SdxloopError):
13
13
  """Invalid or unloadable configuration."""
14
14
 
15
15
 
16
+ class SecretStateError(SdxloopError):
17
+ """A secret registration could not be inspected or replaced."""
18
+
19
+
16
20
  class SbxError(SdxloopError):
17
21
  """An sbx CLI invocation failed."""
18
22
 
@@ -247,6 +247,12 @@ class SbxCLI:
247
247
  value,
248
248
  )
249
249
 
250
+ def secret_ls(self) -> ExecResult:
251
+ """``sbx secret ls``, never raising on failure: whether (and how) a
252
+ given sbx build enumerates secrets is unverified, so callers treat a
253
+ non-ok result as "listing unsupported" and fall back to probing."""
254
+ return self.run("secret", "ls", check=False)
255
+
250
256
  def secret_rm(
251
257
  self,
252
258
  *,
@@ -20,7 +20,6 @@ from __future__ import annotations
20
20
 
21
21
  import logging
22
22
  import os
23
- import re
24
23
  import secrets
25
24
  import shlex
26
25
  from collections.abc import Callable, Mapping
@@ -34,19 +33,18 @@ from sbxloop.sbx.cli import SbxCLI
34
33
  from sbxloop.sbx.models import SandboxRole, SandboxSpec, SecretSpec
35
34
  from sbxloop.sbx.pair import SandboxPair
36
35
  from sbxloop.sbx.sandbox import ENV_FILE, EVENTS_DIR, JOBS_DIR, RESULTS_DIR, WORK_DIR, Sandbox
36
+ from sbxloop.sbx.secretstate import (
37
+ COPILOT_TOKEN_ENV,
38
+ COPILOT_TOKEN_HOST,
39
+ custom_rm_candidates,
40
+ service_rm_candidates,
41
+ set_secret_replacing,
42
+ )
37
43
 
38
44
  logger = logging.getLogger(__name__)
39
45
 
40
- COPILOT_TOKEN_ENV = "COPILOT_GITHUB_TOKEN"
41
46
  GH_TOKEN_ENVS = ("GH_TOKEN", "GITHUB_TOKEN")
42
47
 
43
- # The PAT is exchanged for a Copilot API token at api.github.com; the
44
- # exchanged token lives in SDK process memory, so the copilot API hosts only
45
- # need network allows - never an env rewrite. One env var also cannot be
46
- # registered twice: sbx keys custom secrets by env name, so binding the same
47
- # env to two hosts fails with "already exists".
48
- COPILOT_TOKEN_HOST = "api.github.com"
49
-
50
48
  # Hosts the agent sandbox must be able to reach (doctor checks these).
51
49
  AGENT_TOKEN_HOSTS = ("api.githubcopilot.com", "api.github.com")
52
50
 
@@ -211,14 +209,14 @@ class Provisioner:
211
209
  if secret.kind == "service":
212
210
  assert secret.service is not None
213
211
  service = secret.service
214
- self._set_secret_replacing(
212
+ set_secret_replacing(
215
213
  f"service {service} ({spec.name})",
216
214
  set_fn=partial(self.cli.secret_set, service, sandbox=spec.name, token=token),
217
- rm_candidates=partial(self._service_rm_candidates, service, spec.name),
215
+ rm_candidates=partial(service_rm_candidates, self.cli, service, spec.name),
218
216
  )
219
217
  else:
220
218
  assert secret.host is not None and secret.env is not None
221
- self._set_secret_replacing(
219
+ set_secret_replacing(
222
220
  f"custom {secret.env}@{secret.host} ({spec.name})",
223
221
  set_fn=partial(
224
222
  self.cli.secret_set_custom,
@@ -228,106 +226,10 @@ class Provisioner:
228
226
  sandbox=spec.name,
229
227
  ),
230
228
  rm_candidates=partial(
231
- self._custom_rm_candidates, secret.host, secret.env, spec.name
229
+ custom_rm_candidates, self.cli, secret.host, secret.env, spec.name
232
230
  ),
233
231
  )
234
232
 
235
- _SECRET_EXISTS_MARKERS = ("exist", "already")
236
- # sbx reports the owner of a conflicting secret, e.g.
237
- # ERROR: custom secret env "X" already exists in scope NAME with placeholder ...
238
- _SCOPE_RE = re.compile(r'in scope "?([A-Za-z0-9._-]+)"?')
239
-
240
- @classmethod
241
- def _parsed_scope(cls, stderr: str) -> str | None:
242
- """The scope owning the conflicting secret, per sbx's error message.
243
-
244
- Returns None when unparseable; the literal scopes "global"/"-g" map
245
- to None-as-global in secret_rm terms via the callers below.
246
- """
247
- match = cls._SCOPE_RE.search(stderr)
248
- return match.group(1) if match else None
249
-
250
- def _service_rm_candidates(
251
- self, service: str, sandbox: str, stderr: str
252
- ) -> list[Callable[[], bool]]:
253
- scopes: list[str | None] = []
254
- parsed = self._parsed_scope(stderr)
255
- if parsed:
256
- scopes.append(None if parsed in ("global", "-g") else parsed)
257
- scopes += [sandbox, None]
258
- seen: list[str | None] = []
259
- candidates: list[Callable[[], bool]] = []
260
- for scope in scopes:
261
- if scope in seen:
262
- continue
263
- seen.append(scope)
264
- candidates.append(partial(self.cli.secret_rm, service=service, sandbox=scope))
265
- return candidates
266
-
267
- def _custom_rm_candidates(
268
- self, host: str, env: str, sandbox: str, stderr: str
269
- ) -> list[Callable[[], bool]]:
270
- scopes: list[str | None] = []
271
- parsed = self._parsed_scope(stderr)
272
- if parsed:
273
- scopes.append(None if parsed in ("global", "-g") else parsed)
274
- scopes += [sandbox, None]
275
- seen: list[str | None] = []
276
- candidates: list[Callable[[], bool]] = []
277
- for scope in scopes:
278
- if scope in seen:
279
- continue
280
- seen.append(scope)
281
- # env+host first, then env-only: sbx keys custom secrets by env
282
- # name, so the conflicting entry may carry a different host.
283
- candidates.append(partial(self.cli.secret_rm, host=host, env=env, sandbox=scope))
284
- candidates.append(partial(self.cli.secret_rm, env=env, sandbox=scope))
285
- return candidates
286
-
287
- def _set_secret_replacing(
288
- self,
289
- describe: str,
290
- *,
291
- set_fn: Callable[[], None],
292
- rm_candidates: Callable[[str], list[Callable[[], bool]]],
293
- ) -> None:
294
- """Set a secret, replacing a leftover one from a previous run.
295
-
296
- sbx refuses to overwrite an existing secret and keys custom secrets
297
- by env name, with the conflicting entry possibly owned by another
298
- scope (a previous run's sandbox). On an exists-error we parse the
299
- owning scope out of sbx's stderr and try removal candidates from
300
- most to least specific, retrying the set after each successful
301
- removal. An exists-conflict NEVER fails provisioning: if nothing
302
- can be replaced, the existing value is kept with a warning (it may
303
- be stale if the token was rotated). Non-exists errors raise.
304
-
305
- Only sbx's stderr is matched for exists-markers: the full exception
306
- string embeds argv, and arbitrary paths can contain words like
307
- "exists" (a pytest tmp dir did exactly that).
308
- """
309
- try:
310
- set_fn()
311
- return
312
- except SbxError as exc:
313
- if not any(m in exc.stderr.lower() for m in self._SECRET_EXISTS_MARKERS):
314
- raise
315
- stderr = exc.stderr
316
- for rm_fn in rm_candidates(stderr):
317
- if not rm_fn():
318
- continue
319
- try:
320
- set_fn()
321
- return
322
- except SbxError as exc:
323
- if not any(m in exc.stderr.lower() for m in self._SECRET_EXISTS_MARKERS):
324
- raise
325
- logger.warning(
326
- "secret %s already exists and could not be replaced; keeping the "
327
- "existing value (it may be stale if the token was rotated)",
328
- describe,
329
- )
330
-
331
233
  def _verify_secret_env(
332
234
  self, run_id: str, spec: SandboxSpec, sandbox: Sandbox, token: str
333
235
  ) -> None:
@@ -0,0 +1,420 @@
1
+ """Custom-secret registration state, shared by provisioning and `sbxloop secrets`.
2
+
3
+ Field-verified sbx behavior this module encodes (2026-07-23):
4
+
5
+ - sbx keys custom secrets by ENV VAR NAME — one registration per env var,
6
+ whatever the scope. Registering the same env again fails with
7
+ ``custom secret env "X" already exists in scope <scope> with placeholder
8
+ <p>``; the error names the owning scope, which is parseable for targeted
9
+ removal.
10
+ - sbx refuses to overwrite an existing secret; replacement is rm + set.
11
+ - Removal syntax for custom secrets is not a stable documented API, so
12
+ removals are best-effort ladders from most- to least-specific scope.
13
+ - ``sbx secret ls``-style enumeration is UNVERIFIED across sbx builds:
14
+ listing is attempted and parsed tolerantly, with the exists-error
15
+ collision probe as the authoritative fallback.
16
+
17
+ Provisioning's replace-on-exists collision recovery and the `sbxloop secrets`
18
+ command group both call into here, so the field-hardened logic has exactly
19
+ one implementation.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import re
26
+ from collections.abc import Callable
27
+ from functools import partial
28
+ from pathlib import Path
29
+ from secrets import token_hex
30
+ from typing import Literal
31
+
32
+ from pydantic import BaseModel, ConfigDict, Field
33
+
34
+ from sbxloop.config import Config
35
+ from sbxloop.errors import SbxError, SecretStateError
36
+ from sbxloop.sbx.cli import SbxCLI
37
+ from sbxloop.sbx.models import SandboxSpec
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ COPILOT_TOKEN_ENV = "COPILOT_GITHUB_TOKEN"
42
+
43
+ # The PAT is exchanged for a Copilot API token at api.github.com; the
44
+ # exchanged token lives in SDK process memory, so the copilot API hosts only
45
+ # need network allows - never an env rewrite. One env var also cannot be
46
+ # registered twice: sbx keys custom secrets by env name, so binding the same
47
+ # env to two hosts fails with "already exists".
48
+ COPILOT_TOKEN_HOST = "api.github.com"
49
+
50
+ # Every sbxloop sandbox (and therefore every sandbox-scoped registration
51
+ # sbxloop creates) is named with this prefix.
52
+ SANDBOX_SCOPE_PREFIX = "sbxloop-"
53
+
54
+ SECRET_EXISTS_MARKERS = ("exist", "already")
55
+ # sbx reports the owner of a conflicting secret, e.g.
56
+ # ERROR: custom secret env "X" already exists in scope NAME with placeholder ...
57
+ _SCOPE_RE = re.compile(r'in scope "?([A-Za-z0-9._-]+)"?')
58
+ # Spellings sbx uses for the global scope in errors and listings.
59
+ _GLOBAL_SCOPE_NAMES = ("global", "-g")
60
+
61
+ _DOMAIN_RE = re.compile(r"(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
62
+
63
+ Source = Literal["ls", "probe"]
64
+
65
+ RmCandidates = Callable[[str], list[Callable[[], bool]]]
66
+
67
+
68
+ def tracked_custom_secrets(config: Config) -> list[tuple[str, str]]:
69
+ """The (env, canonical host) custom secrets provisioning registers.
70
+
71
+ Today that is exactly the Copilot token; config declares no additional
72
+ custom secrets (the github sandbox uses sbx's built-in ``github``
73
+ service secret, which is never managed here).
74
+ """
75
+ del config
76
+ return [(COPILOT_TOKEN_ENV, COPILOT_TOKEN_HOST)]
77
+
78
+
79
+ def parsed_scope(stderr: str) -> str | None:
80
+ """The scope owning the conflicting secret, per sbx's error message.
81
+
82
+ Returns None when unparseable; the literal global spellings
83
+ ("global"/"-g") map to None-as-global in secret_rm terms via the
84
+ candidate builders below.
85
+ """
86
+ match = _SCOPE_RE.search(stderr)
87
+ return match.group(1) if match else None
88
+
89
+
90
+ def service_rm_candidates(
91
+ cli: SbxCLI, service: str, sandbox: str | None, stderr: str
92
+ ) -> list[Callable[[], bool]]:
93
+ scopes: list[str | None] = []
94
+ parsed = parsed_scope(stderr)
95
+ if parsed:
96
+ scopes.append(None if parsed in _GLOBAL_SCOPE_NAMES else parsed)
97
+ scopes += [sandbox, None]
98
+ seen: list[str | None] = []
99
+ candidates: list[Callable[[], bool]] = []
100
+ for scope in scopes:
101
+ if scope in seen:
102
+ continue
103
+ seen.append(scope)
104
+ candidates.append(partial(cli.secret_rm, service=service, sandbox=scope))
105
+ return candidates
106
+
107
+
108
+ def custom_rm_candidates(
109
+ cli: SbxCLI, host: str, env: str, sandbox: str | None, stderr: str
110
+ ) -> list[Callable[[], bool]]:
111
+ scopes: list[str | None] = []
112
+ parsed = parsed_scope(stderr)
113
+ if parsed:
114
+ scopes.append(None if parsed in _GLOBAL_SCOPE_NAMES else parsed)
115
+ scopes += [sandbox, None]
116
+ seen: list[str | None] = []
117
+ candidates: list[Callable[[], bool]] = []
118
+ for scope in scopes:
119
+ if scope in seen:
120
+ continue
121
+ seen.append(scope)
122
+ # env+host first, then env-only: sbx keys custom secrets by env
123
+ # name, so the conflicting entry may carry a different host.
124
+ candidates.append(partial(cli.secret_rm, host=host, env=env, sandbox=scope))
125
+ candidates.append(partial(cli.secret_rm, env=env, sandbox=scope))
126
+ return candidates
127
+
128
+
129
+ def set_secret_replacing(
130
+ describe: str,
131
+ *,
132
+ set_fn: Callable[[], None],
133
+ rm_candidates: RmCandidates,
134
+ strict: bool = False,
135
+ ) -> bool:
136
+ """Set a secret, replacing a leftover one from a previous run.
137
+
138
+ sbx refuses to overwrite an existing secret and keys custom secrets
139
+ by env name, with the conflicting entry possibly owned by another
140
+ scope (a previous run's sandbox). On an exists-error we parse the
141
+ owning scope out of sbx's stderr and try removal candidates from
142
+ most to least specific, retrying the set after each successful
143
+ removal. Returns True when the set succeeded (fresh or replaced).
144
+
145
+ By default an exists-conflict NEVER fails: if nothing can be replaced,
146
+ the existing value is kept with a warning (it may be stale if the
147
+ token was rotated) and False is returned — provisioning must not die
148
+ on a leftover registration. Under ``strict`` (rotation, where keeping
149
+ a stale value defeats the point) an irreplaceable secret raises
150
+ SecretStateError instead. Non-exists errors always raise.
151
+
152
+ Only sbx's stderr is matched for exists-markers: the full exception
153
+ string embeds argv, and arbitrary paths can contain words like
154
+ "exists" (a pytest tmp dir did exactly that).
155
+ """
156
+ try:
157
+ set_fn()
158
+ return True
159
+ except SbxError as exc:
160
+ if not any(m in exc.stderr.lower() for m in SECRET_EXISTS_MARKERS):
161
+ raise
162
+ stderr = exc.stderr
163
+ for rm_fn in rm_candidates(stderr):
164
+ if not rm_fn():
165
+ continue
166
+ try:
167
+ set_fn()
168
+ return True
169
+ except SbxError as exc:
170
+ if not any(m in exc.stderr.lower() for m in SECRET_EXISTS_MARKERS):
171
+ raise
172
+ if strict:
173
+ raise SecretStateError(
174
+ f"secret {describe} already exists and could not be replaced "
175
+ f"(sbx said: {' '.join(stderr.split())})"
176
+ )
177
+ logger.warning(
178
+ "secret %s already exists and could not be replaced; keeping the "
179
+ "existing value (it may be stale if the token was rotated)",
180
+ describe,
181
+ )
182
+ return False
183
+
184
+
185
+ # -- inspection --------------------------------------------------------------
186
+
187
+
188
+ class CustomSecretState(BaseModel):
189
+ """What sbx currently has registered for one custom-secret env var."""
190
+
191
+ model_config = ConfigDict(extra="forbid")
192
+
193
+ env: str
194
+ # None: undetermined (listing unsupported and probing disabled/failed).
195
+ exists: bool | None = None
196
+ # "global", a sandbox-scope name, or None when the owner is unknown.
197
+ scope: str | None = None
198
+ # Host bindings, when a listing revealed them (a probe cannot).
199
+ hosts: list[str] = Field(default_factory=list)
200
+ source: Source | None = None
201
+ detail: str = ""
202
+
203
+
204
+ def parse_secret_ls_entry(raw: str, env: str) -> tuple[str | None, list[str]] | None:
205
+ """Best-effort (scope, hosts) for ``env`` from ``sbx secret ls`` output.
206
+
207
+ The listing format is unverified across sbx builds, so parsing is
208
+ token-based on whichever line names the env var: domain-shaped tokens
209
+ are host bindings, a ``sbxloop-*`` token is the owning sandbox scope,
210
+ and a global spelling maps to "global". Returns None when no line
211
+ mentions the env (which is NOT proof of absence — the build's listing
212
+ may simply omit custom secrets; callers corroborate with the probe).
213
+ """
214
+ for line in raw.splitlines():
215
+ if not re.search(rf"(?<![A-Za-z0-9_]){re.escape(env)}(?![A-Za-z0-9_])", line):
216
+ continue
217
+ tokens = line.replace(",", " ").split()
218
+ hosts = [t for t in tokens if _DOMAIN_RE.fullmatch(t)]
219
+ scope = next((t for t in tokens if t.startswith(SANDBOX_SCOPE_PREFIX)), None)
220
+ if scope is None and any(t.lower() in _GLOBAL_SCOPE_NAMES for t in tokens):
221
+ scope = "global"
222
+ return scope, hosts
223
+ return None
224
+
225
+
226
+ def probe_custom_secret(cli: SbxCLI, env: str, *, host: str) -> CustomSecretState:
227
+ """Detect a registration via the field-verified exists-error.
228
+
229
+ Attempts a global set-custom with a sentinel value: an exists-error
230
+ proves the registration (and names its owner scope); success proves
231
+ absence, and the sentinel is removed again immediately. A leftover
232
+ sentinel (removal rejected mid-probe) is harmless — provisioning
233
+ replaces on collision and never trusts a pre-existing value — but is
234
+ logged loudly.
235
+ """
236
+ sentinel = f"sbxloop-probe-{token_hex(4)}"
237
+ try:
238
+ cli.secret_set_custom(host=host, env=env, value=sentinel, sandbox=None)
239
+ except SbxError as exc:
240
+ if any(m in exc.stderr.lower() for m in SECRET_EXISTS_MARKERS):
241
+ scope = parsed_scope(exc.stderr)
242
+ if scope in _GLOBAL_SCOPE_NAMES:
243
+ scope = "global"
244
+ return CustomSecretState(
245
+ env=env,
246
+ exists=True,
247
+ scope=scope,
248
+ source="probe",
249
+ detail=exc.stderr.strip(),
250
+ )
251
+ return CustomSecretState(env=env, exists=None, detail=str(exc))
252
+ removed = cli.secret_rm(host=host, env=env, sandbox=None) or cli.secret_rm(
253
+ env=env, sandbox=None
254
+ )
255
+ if not removed:
256
+ logger.warning(
257
+ "probe: sbx rejected removing the transient sentinel registration for %s; "
258
+ "the next provisioning will replace it",
259
+ env,
260
+ )
261
+ return CustomSecretState(env=env, exists=False, source="probe")
262
+
263
+
264
+ def inspect_custom_secret(
265
+ cli: SbxCLI, env: str, *, host: str, probe: bool = True
266
+ ) -> CustomSecretState:
267
+ """Current registration state for ``env``: listing first, probe fallback."""
268
+ listing = cli.secret_ls()
269
+ if listing.ok:
270
+ entry = parse_secret_ls_entry(listing.stdout, env)
271
+ if entry is not None:
272
+ scope, hosts = entry
273
+ return CustomSecretState(env=env, exists=True, scope=scope, hosts=hosts, source="ls")
274
+ # No line names the env. That usually means "not registered", but a
275
+ # build whose listing omits custom secrets would look identical, so
276
+ # the collision probe settles it when allowed.
277
+ if not probe:
278
+ return CustomSecretState(
279
+ env=env,
280
+ exists=None,
281
+ detail="not in `sbx secret ls` output and probing disabled (--no-probe)",
282
+ )
283
+ return probe_custom_secret(cli, env, host=host)
284
+
285
+
286
+ # -- assessment --------------------------------------------------------------
287
+
288
+
289
+ class Assessment(BaseModel):
290
+ """One tracked secret's registration state, judged against what current
291
+ provisioning would register — mismatches are pre-collision warnings."""
292
+
293
+ model_config = ConfigDict(extra="forbid")
294
+
295
+ status: Literal["ok", "warn", "unknown"]
296
+ note: str
297
+ # Safe for `secrets clean` to remove: definitely sbxloop-owned and no
298
+ # longer what provisioning would produce.
299
+ stale: bool = False
300
+ # sbxloop-owned at all (stale or not) — what `clean --all` may remove.
301
+ owned: bool = False
302
+
303
+
304
+ def assess(
305
+ state: CustomSecretState, *, canonical_host: str, live_sandboxes: set[str]
306
+ ) -> Assessment:
307
+ if state.exists is False:
308
+ return Assessment(status="ok", note="not registered — the next run registers it fresh")
309
+ if state.exists is None:
310
+ return Assessment(status="unknown", note=state.detail or "state undetermined")
311
+ host_mismatch = bool(state.hosts) and canonical_host not in state.hosts
312
+ if state.scope is not None and state.scope.startswith(SANDBOX_SCOPE_PREFIX):
313
+ if state.scope in live_sandboxes:
314
+ return Assessment(
315
+ status="warn" if host_mismatch else "ok",
316
+ note=(
317
+ f"owned by live sandbox {state.scope}"
318
+ + (
319
+ f" but bound to {', '.join(state.hosts)} (expected {canonical_host})"
320
+ if host_mismatch
321
+ else ""
322
+ )
323
+ ),
324
+ stale=host_mismatch,
325
+ owned=True,
326
+ )
327
+ return Assessment(
328
+ status="warn",
329
+ note=(
330
+ f"stale: owner scope {state.scope} no longer exists — the next run "
331
+ "will hit (and recover from) a set-custom collision"
332
+ ),
333
+ stale=True,
334
+ owned=True,
335
+ )
336
+ if state.scope == "global":
337
+ if host_mismatch:
338
+ return Assessment(
339
+ status="warn",
340
+ note=(
341
+ f"global registration bound to {', '.join(state.hosts)}, expected "
342
+ f"{canonical_host} — likely left by an older sbxloop version"
343
+ ),
344
+ stale=True,
345
+ owned=True,
346
+ )
347
+ return Assessment(
348
+ status="ok",
349
+ note="global registration with the canonical host binding",
350
+ owned=True,
351
+ )
352
+ if state.scope is None:
353
+ return Assessment(
354
+ status="warn",
355
+ note="registered but the owner scope could not be determined — "
356
+ "inspect with `sbx secret ls` or remove manually",
357
+ )
358
+ return Assessment(
359
+ status="warn",
360
+ note=(
361
+ f"owned by foreign scope {state.scope!r} — not sbxloop's to remove; the next "
362
+ "run will attempt collision recovery against it. Remove it yourself with "
363
+ "`sbx secret rm` if it is yours."
364
+ ),
365
+ )
366
+
367
+
368
+ def removal_ladder(cli: SbxCLI, state: CustomSecretState, *, host: str) -> list[Callable[[], bool]]:
369
+ """rm candidates for a known registration, most- to least-specific.
370
+
371
+ ``state.detail`` may carry the probe's exists-error, whose parsed scope
372
+ then leads the ladder exactly as in provisioning's collision recovery.
373
+ """
374
+ scope = None if state.scope in (None, "global") else state.scope
375
+ return custom_rm_candidates(cli, host, state.env, scope, state.detail)
376
+
377
+
378
+ # -- rotation ----------------------------------------------------------------
379
+
380
+
381
+ def replace_registration(cli: SbxCLI, *, env: str, host: str, token: str) -> None:
382
+ """Atomically replace ``env``'s registration at global scope: the rm +
383
+ set-custom dance with the canonical host binding, failing loudly (unlike
384
+ provisioning's keep-on-conflict) when the old entry cannot be removed."""
385
+ set_secret_replacing(
386
+ f"custom {env}@{host} (global)",
387
+ set_fn=partial(cli.secret_set_custom, host=host, env=env, value=token, sandbox=None),
388
+ rm_candidates=partial(custom_rm_candidates, cli, host, env, None),
389
+ strict=True,
390
+ )
391
+
392
+
393
+ def verify_secret_visibility(
394
+ cli: SbxCLI, *, env: str, workspace: Path, template: str | None = None
395
+ ) -> bool | None:
396
+ """Boot a throwaway sandbox and check whether the proxy-injected env is
397
+ visible to exec'd processes (the same probe provisioning runs per-run).
398
+
399
+ Returns True (proxy strategy will hold), False (runs will fall back to
400
+ the in-VM plain-env file), or None when the check could not run. The
401
+ sandbox is always removed.
402
+ """
403
+ name = f"sbxloop-secretcheck-{token_hex(4)}"
404
+ spec = SandboxSpec(name=name, role="agent", workspace=workspace, template=template)
405
+ try:
406
+ cli.create(spec)
407
+ except SbxError:
408
+ logger.warning("secret visibility check: could not create %s", name, exc_info=True)
409
+ return None
410
+ try:
411
+ result = cli.exec(name, ["sh", "-lc", f'test -n "${{{env}}}"'])
412
+ return result.ok
413
+ except SbxError:
414
+ logger.warning("secret visibility check failed in %s", name, exc_info=True)
415
+ return None
416
+ finally:
417
+ try:
418
+ cli.rm(name)
419
+ except SbxError:
420
+ logger.warning("secret visibility check: failed to remove %s", name, exc_info=True)
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