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.
- {sbxloop-0.5.8 → sbxloop-0.5.10}/PKG-INFO +1 -1
- 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
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/app.py +219 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/doctor.py +2 -5
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/errors.py +4 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/cli.py +6 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/provision.py +11 -109
- sbxloop-0.5.10/src/sbxloop/sbx/secretstate.py +420 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/.gitignore +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/README.md +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/hatch_build.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/pyproject.toml +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/cli/tui.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/config.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/engine.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/model.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/phases.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/events.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/policy.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.5.8 → sbxloop-0.5.10}/src/sbxloop/worker/client.py +0 -0
- {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.
|
|
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
|
|
Binary file
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 5,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|