generated from coulomb/repo-seed
Implement SAND-WP-0002 meta-framework foundation (T01–T09)
Add meta-framework spec, pydantic schemas, profile/extension YAML, extension registry, ext.compose-ssh backend, SandboxManager with State Hub events, CLI commands, integration docs, capability registry entry, and compose-e2e runbook. Nine unit tests pass. T10 remote smoke test remains for operator.
This commit is contained in:
@@ -26,6 +26,16 @@ make build # uv build (wheel + sdist)
|
||||
make cli-version # smoke test: sandboxer version
|
||||
```
|
||||
|
||||
Sandbox CLI (v0):
|
||||
|
||||
```bash
|
||||
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
|
||||
sandboxer get <id>
|
||||
sandboxer list
|
||||
sandboxer destroy <id>
|
||||
sandboxer recreate <id>
|
||||
```
|
||||
|
||||
Equivalent `uv` invocations without Make:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -90,6 +90,9 @@ sand-boxer stays inside the **sandboxing boundary**. Three sibling Coulomb
|
||||
projects own adjacent concerns. Integration is contractual — they **request**
|
||||
sandboxes; sand-boxer **establishes** them.
|
||||
|
||||
Per-sibling integration contracts: `docs/integrations/` (glas-harness,
|
||||
wise-validator, snuggle-inventor).
|
||||
|
||||
### glas-harness — agent harness
|
||||
|
||||
**Owns:** Gateway, tool orchestration, skills, memory, channels, subagent
|
||||
|
||||
38
docs/integrations/glas-harness.md
Normal file
38
docs/integrations/glas-harness.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# glas-harness integration
|
||||
|
||||
glas-harness owns the agent gateway, tools, memory, and channels. sand-boxer
|
||||
delivers an isolated execution environment; the harness executes tools inside it.
|
||||
|
||||
## Example request
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.agent-dev \
|
||||
--input repo=/path/to/workspace \
|
||||
--actor agt \
|
||||
--project glas-harness
|
||||
```
|
||||
|
||||
## Response fields (ready state)
|
||||
|
||||
| Field | Owner | Description |
|
||||
|-------|-------|-------------|
|
||||
| `sandbox_id` | sand-boxer | Stable instance identifier |
|
||||
| `reachability.ssh` | sand-boxer | SSH target for harness exec channel |
|
||||
| `reachability.remote_dir` | sand-boxer | Workspace root on remote host |
|
||||
| `state` | sand-boxer | Lifecycle state (`ready`, etc.) |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|-------|
|
||||
| Sandbox provision / teardown | sand-boxer |
|
||||
| Tool call parsing and policies | glas-harness |
|
||||
| SSH / tunnel reachability setup | glas-harness + ops-bridge |
|
||||
| Agent memory and session state | glas-harness |
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Tool schemas and approval flows
|
||||
- Channel bridges (Slack, email, etc.)
|
||||
- Subagent orchestration
|
||||
37
docs/integrations/snuggle-inventor.md
Normal file
37
docs/integrations/snuggle-inventor.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# snuggle-inventor integration
|
||||
|
||||
snuggle-inventor owns code generation and modernization workflows. sand-boxer
|
||||
provides a build sandbox with setup metadata resolved at the provision boundary.
|
||||
|
||||
## Example request
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.build \
|
||||
--input repo=/path/to/target \
|
||||
--actor agt \
|
||||
--project snuggle-inventor
|
||||
```
|
||||
|
||||
## Response fields (ready state)
|
||||
|
||||
| Field | Owner | Description |
|
||||
|-------|-------|-------------|
|
||||
| `sandbox_id` | sand-boxer | Build environment instance |
|
||||
| `reachability.ssh` | sand-boxer | Remote workspace access |
|
||||
| `profile.setup.secret_refs` | sand-boxer resolves | Secrets never returned to agent context |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|-------|
|
||||
| Sandbox provision and teardown | sand-boxer |
|
||||
| Setup instructions content (Blitzy-style) | snuggle-inventor |
|
||||
| Generated code and PR output | snuggle-inventor |
|
||||
| Secret resolution at boundary | sand-boxer (via ops-warden / OpenBao) |
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Code generation prompts and tech specs
|
||||
- AAP-style planning
|
||||
- PR creation and review loops
|
||||
40
docs/integrations/wise-validator.md
Normal file
40
docs/integrations/wise-validator.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# wise-validator integration
|
||||
|
||||
wise-validator owns e2e test orchestration, health check semantics, and pass/fail
|
||||
interpretation. sand-boxer delivers the compose environment; wise-validator runs
|
||||
the validation story on top.
|
||||
|
||||
## Example request
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.compose-e2e \
|
||||
--input repo=/path/to/repo \
|
||||
--actor atm \
|
||||
--project wise-validator
|
||||
```
|
||||
|
||||
## Response fields (ready state)
|
||||
|
||||
| Field | Owner | Description |
|
||||
|-------|-------|-------------|
|
||||
| `sandbox_id` | sand-boxer | Instance id for the validation run |
|
||||
| `reachability.ssh` | sand-boxer | SSH endpoint for test execution |
|
||||
| `reachability.compose_project` | sand-boxer | Docker compose project name |
|
||||
| `reachability.remote_dir` | sand-boxer | Synced repo path on remote host |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|-------|
|
||||
| Environment provision (rsync + compose up) | sand-boxer |
|
||||
| `e2e/e2e.yml` parsing and semantics | wise-validator |
|
||||
| HTTP health polling and timeouts | wise-validator |
|
||||
| Test command execution and reporting | wise-validator |
|
||||
| State Hub test result events | wise-validator |
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Running `test_command` from e2e.yml
|
||||
- Interpreting health check pass/fail
|
||||
- Posting validation results to State Hub
|
||||
187
docs/meta-framework.md
Normal file
187
docs/meta-framework.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# sand-boxer meta-framework specification
|
||||
|
||||
Version 0.1 — derived from `research/03-meta-framework-synthesis.md` and `INTENT.md`.
|
||||
|
||||
sand-boxer is the **sandbox establishment service**: one API for consumers, many
|
||||
extension backends. It provisions *where and how* code runs; sibling projects own
|
||||
agent harnessing, validation, and code generation.
|
||||
|
||||
---
|
||||
|
||||
## Resource model
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| **Profile** | Named, versioned sandbox recipe: extension binding, isolation, network, TTL, placement |
|
||||
| **Extension** | Backend adapter implementing provision / wait_ready / teardown |
|
||||
| **Host** | Registered placement target for self-hosted extensions |
|
||||
| **Sandbox** | Running instance of a profile |
|
||||
| **Snapshot** | Point-in-time workspace checkpoint (deferred — SAND-WP-0003) |
|
||||
| **Route** | Extension selection policy when multiple backends qualify |
|
||||
| **Meter** | Usage record for payments layer (SaaS extensions — SAND-WP-0006) |
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle states
|
||||
|
||||
```
|
||||
requested → provisioning → ready → active → { expired | failed } → destroying → destroyed
|
||||
```
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| `requested` | Create accepted; not yet handed to extension |
|
||||
| `provisioning` | Extension running provision + wait_ready |
|
||||
| `ready` | Reachability confirmed; consumer may attach |
|
||||
| `active` | Consumer has marked sandbox in use (optional transition) |
|
||||
| `expired` | TTL elapsed before explicit destroy |
|
||||
| `failed` | Provision or readiness failed |
|
||||
| `destroying` | Teardown in progress |
|
||||
| `destroyed` | Resources released; record retained for audit |
|
||||
|
||||
### State Hub event mapping
|
||||
|
||||
Each transition emits a State Hub progress event (or dedicated registration API
|
||||
when available):
|
||||
|
||||
| Transition | `event_type` | Required fields |
|
||||
|------------|--------------|-----------------|
|
||||
| → `requested` | `note` | `sandbox_id`, `profile_id`, `consumer` |
|
||||
| → `provisioning` | `note` | `extension_id`, `host` |
|
||||
| → `ready` | `milestone` | `reachability` descriptor |
|
||||
| → `active` | `note` | `actor_type`, timestamps |
|
||||
| → `failed` | `note` | `error` summary |
|
||||
| → `destroying` | `note` | — |
|
||||
| → `destroyed` | `milestone` | `duration_s`, cleanup report |
|
||||
|
||||
Event `detail` payload (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"sandbox_id": "abc12345",
|
||||
"profile_id": "profile.compose-e2e",
|
||||
"extension_id": "ext.compose-ssh",
|
||||
"host": "coulombcore",
|
||||
"consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."},
|
||||
"actor_type": "atm",
|
||||
"state": "ready",
|
||||
"reachability": {"ssh": "root@coulombcore", "remote_dir": "/tmp/sandboxer/abc12345"},
|
||||
"timestamps": {"created_at": "...", "ready_at": "..."}
|
||||
}
|
||||
```
|
||||
|
||||
Extends the `build-agent` self-register pattern: generic sandbox identities carry
|
||||
`profile_id` + `extension_id` instead of build-machine metadata.
|
||||
|
||||
---
|
||||
|
||||
## Core API operations (v0)
|
||||
|
||||
| Operation | Description | v0 scope |
|
||||
|-----------|-------------|----------|
|
||||
| `create` | Provision from profile + inputs | **Yes** |
|
||||
| `get` | Inspect sandbox status | **Yes** |
|
||||
| `list` | List sandboxes (filter by consumer optional) | **Yes** |
|
||||
| `extend_ttl` | Extend time-to-live | Stub |
|
||||
| `recreate` | Destroy and reprovision from stored seed | **Yes** |
|
||||
| `destroy` | Idempotent teardown | **Yes** |
|
||||
| `snapshot` / `restore` | Checkpoint workspace | Deferred (SAND-WP-0003) |
|
||||
| `exec` | Run command in sandbox | Harness-owned via SSH (glas-harness) |
|
||||
|
||||
HTTP surface (optional v0; CLI calls core library directly):
|
||||
|
||||
- `POST /v1/sandboxes` — create
|
||||
- `GET /v1/sandboxes/{id}` — get
|
||||
- `GET /v1/sandboxes` — list
|
||||
- `DELETE /v1/sandboxes/{id}` — destroy
|
||||
|
||||
---
|
||||
|
||||
## Consumer attribution
|
||||
|
||||
Every create request carries a **consumer** block:
|
||||
|
||||
```yaml
|
||||
consumer:
|
||||
actor: adm | agt | atm
|
||||
project: <calling-repo-or-service> # e.g. wise-validator, glas-harness
|
||||
session_id: <optional>
|
||||
run_id: <optional>
|
||||
```
|
||||
|
||||
| Actor | Typical caller |
|
||||
|-------|----------------|
|
||||
| `adm` | Human operator via CLI |
|
||||
| `agt` | LLM agent session |
|
||||
| `atm` | Deterministic automation (CI, activity-core, wise-validator) |
|
||||
|
||||
sand-boxer records attribution on every lifecycle event. It does not interpret
|
||||
agent intent or authorize the caller — flex-auth owns authorization when enforced.
|
||||
|
||||
---
|
||||
|
||||
## Extension interface
|
||||
|
||||
Each extension implements:
|
||||
|
||||
```text
|
||||
provision(profile, inputs, placement) → SandboxHandle
|
||||
wait_ready(handle) → Reachability
|
||||
teardown(handle) → CleanupReport
|
||||
estimate_cost?(profile, duration) → MeterQuote # optional; SaaS only
|
||||
```
|
||||
|
||||
Registration requirements (validated at load time):
|
||||
|
||||
- `id` — unique extension identifier (`ext.<name>`)
|
||||
- `capabilities` — isolation levels, regions, persistence, pricing model
|
||||
- `handler` — Python entry point or built-in registry binding
|
||||
|
||||
Extensions are discovered from `extensions/*.yaml` at repo root and loaded via
|
||||
`sandboxer.extensions.registry`.
|
||||
|
||||
---
|
||||
|
||||
## Routing policy vocabulary
|
||||
|
||||
When multiple extensions satisfy a profile capability:
|
||||
|
||||
| Strategy | Behavior |
|
||||
|----------|----------|
|
||||
| `prefer-self-hosted` | Self-hosted extensions first; SaaS fallback (default Coulomb posture) |
|
||||
| `lowest-cost` | Cheapest `estimate_cost` quote wins |
|
||||
| `lowest-latency` | Closest region / host wins |
|
||||
| `explicit` | Profile names a single `extension`; no auto-routing |
|
||||
|
||||
v0 resolves `profile.extension` directly — routing engine deferred to SAND-WP-0006.
|
||||
|
||||
---
|
||||
|
||||
## Security limits
|
||||
|
||||
sand-boxer commits to:
|
||||
|
||||
1. **Default-deny network** unless profile explicitly allows egress
|
||||
2. **Secrets at provision boundary** — `secret_refs` resolved via ops-warden /
|
||||
OpenBao; never returned to agent context
|
||||
3. **Blast-radius isolation** — dedicated hosts (sandboxer01, CoulombCore) away
|
||||
from Railiance01 production
|
||||
4. **Observable lifecycle** — every transition attributed to `adm` / `agt` / `atm`
|
||||
5. **Honest limits** — allowed tool paths can be abused by compromised agents
|
||||
|
||||
sand-boxer does **not** provide intent-aware egress filtering in v1.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (sibling ownership)
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|-------|
|
||||
| Agent gateway, tools, memory | glas-harness |
|
||||
| e2e.yml semantics, health checks, test pass/fail | wise-validator |
|
||||
| Code generation, setup instructions content | snuggle-inventor |
|
||||
| SSH tunnels | ops-bridge |
|
||||
| SSH certificates | ops-warden |
|
||||
| Workstream / task state | state-hub |
|
||||
|
||||
See `docs/integrations/` for per-sibling contracts.
|
||||
90
docs/runbooks/profile-compose-e2e.md
Normal file
90
docs/runbooks/profile-compose-e2e.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Runbook: profile.compose-e2e
|
||||
|
||||
Provision a compose-based e2e sandbox via `ext.compose-ssh` (e2e-framework lineage).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Workstation**
|
||||
|
||||
- `uv`, `ssh`, `rsync`
|
||||
- sand-boxer installed: `make setup`
|
||||
|
||||
**Remote host** (CoulombCore or sandboxer01)
|
||||
|
||||
- SSH access (`SANDBOXER_HOST` or `--host`)
|
||||
- Docker + compose plugin
|
||||
- Sufficient disk for images
|
||||
|
||||
```bash
|
||||
export SANDBOXER_HOST=<ip-or-hostname> # e.g. coulombcore IP
|
||||
export SANDBOXER_SSH_USER=root # optional
|
||||
export SANDBOXER_SSH_KEY=~/.ssh/id_rsa # optional
|
||||
```
|
||||
|
||||
## Create
|
||||
|
||||
Target repo must contain `e2e/e2e.yml` (for compose file path) or a
|
||||
`docker-compose*.yml` at repo root.
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.compose-e2e \
|
||||
--input repo=/path/to/repo \
|
||||
--actor adm \
|
||||
--project sand-boxer
|
||||
```
|
||||
|
||||
Confirm `state: ready` and note `reachability.ssh`, `reachability.remote_dir`,
|
||||
and `reachability.compose_project`.
|
||||
|
||||
## Manual readiness check
|
||||
|
||||
sand-boxer confirms compose services are running (not HTTP health — that is
|
||||
wise-validator's job):
|
||||
|
||||
```bash
|
||||
ssh $SANDBOXER_SSH_USER@$SANDBOXER_HOST \
|
||||
'docker compose -p <compose_project> -f <remote_dir>/<compose_file> ps'
|
||||
```
|
||||
|
||||
## Destroy
|
||||
|
||||
```bash
|
||||
sandboxer destroy <sandbox_id>
|
||||
```
|
||||
|
||||
Verify cleanup:
|
||||
|
||||
```bash
|
||||
ssh $SANDBOXER_SSH_USER@$SANDBOXER_HOST 'docker compose ls'
|
||||
ssh $SANDBOXER_SSH_USER@$SANDBOXER_HOST 'ls /tmp/sandboxer/'
|
||||
```
|
||||
|
||||
## Compatibility with legacy `make e2e`
|
||||
|
||||
Interim callers in `the-custodian` should migrate to sand-boxer for
|
||||
provision/teardown only. Test execution remains in wise-validator (SAND-WP-0003).
|
||||
|
||||
| Legacy | sand-boxer |
|
||||
|--------|------------|
|
||||
| `make e2e REPO=foo` (full pipeline) | `sandboxer create` + wise-validator run |
|
||||
| rsync + compose up | `ext.compose-ssh` provision |
|
||||
| compose down + dir removal | `sandboxer destroy` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Provision fails — no compose file**
|
||||
|
||||
Ensure repo has `e2e/e2e.yml` with `compose_file` or a root `docker-compose*.yml`.
|
||||
|
||||
**Leftover compose projects**
|
||||
|
||||
```bash
|
||||
ssh $SANDBOXER_HOST 'docker compose -p sbx-e2e-<id> down -v; rm -rf /tmp/sandboxer/<id>'
|
||||
```
|
||||
|
||||
**Skip State Hub events (local debug)**
|
||||
|
||||
```bash
|
||||
export SANDBOXER_NO_STATE_HUB=1
|
||||
```
|
||||
29
extensions/README.md
Normal file
29
extensions/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Extensions
|
||||
|
||||
Backend adapters for sandbox establishment. Each extension is declared in
|
||||
`ext.<name>.yaml` and implements `provision`, `wait_ready`, and `teardown`.
|
||||
|
||||
## ext.compose-ssh
|
||||
|
||||
Self-hosted extension migrated from `the-custodian/e2e-framework/`.
|
||||
|
||||
**Provision:** SSH to configured host → isolated directory per sandbox id → rsync
|
||||
repo → `docker compose up` with unique project name.
|
||||
|
||||
**wait_ready:** Confirms compose services are running (`docker compose ps`). Does
|
||||
not run HTTP health checks — wise-validator owns that.
|
||||
|
||||
**teardown:** `docker compose down -v` + remove remote directory (idempotent).
|
||||
|
||||
### Compatibility note for `make e2e` callers
|
||||
|
||||
Legacy `make e2e REPO=<repo>` runs provision + health + tests + teardown in one
|
||||
step. sand-boxer splits responsibilities:
|
||||
|
||||
| Step | Owner |
|
||||
|------|-------|
|
||||
| rsync + compose up/down | sand-boxer (`ext.compose-ssh`) |
|
||||
| health checks + test_command | wise-validator (SAND-WP-0003) |
|
||||
|
||||
Interim workflow: `sandboxer create --profile profile.compose-e2e --input repo=...`
|
||||
then run validation separately until wise-validator migration lands.
|
||||
16
extensions/ext.compose-ssh.yaml
Normal file
16
extensions/ext.compose-ssh.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
id: ext.compose-ssh
|
||||
title: Compose over SSH
|
||||
description: >
|
||||
Self-hosted extension migrated from the-custodian/e2e-framework. Provisions an
|
||||
isolated directory on a remote host, rsyncs the consumer repo, and runs docker
|
||||
compose. Test execution and health polling are consumer-owned (wise-validator).
|
||||
handler: sandboxer.extensions.compose_ssh:ComposeSSHExtension
|
||||
capabilities:
|
||||
isolation_levels: [container]
|
||||
regions: []
|
||||
persistence: false
|
||||
pricing_model: self-hosted
|
||||
config:
|
||||
base_dir: /tmp/sandboxer
|
||||
ssh_user: root
|
||||
compose_timeout_s: 180
|
||||
31
profiles/profile.compose-e2e.yaml
Normal file
31
profiles/profile.compose-e2e.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
id: profile.compose-e2e
|
||||
version: "1.0.0"
|
||||
extension: ext.compose-ssh
|
||||
isolation:
|
||||
level: container
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: session
|
||||
ttl:
|
||||
default: 4h
|
||||
max: 24h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: ""
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: [sandboxer01]
|
||||
fallback: [coulombcore]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: self-hosted
|
||||
latency_class: standard
|
||||
@@ -11,6 +11,9 @@ requires-python = ">=3.11"
|
||||
license = "MIT-0"
|
||||
dependencies = [
|
||||
"typer>=0.12",
|
||||
"pydantic>=2.0",
|
||||
"pyyaml>=6.0",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
45
registry/capabilities/execution.sandbox-provision.md
Normal file
45
registry/capabilities/execution.sandbox-provision.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: capability.execution.sandbox-provision
|
||||
name: Sandbox Provisioning
|
||||
summary: Isolated execution environments for agentic development, e2e testing, and bounded automations — profile-based provision, TTL teardown, and State Hub lifecycle registration.
|
||||
owner: sand-boxer
|
||||
status: draft
|
||||
domain: infotech
|
||||
tags: [sandbox, isolation, provision, e2e, agentic, execution, profile]
|
||||
|
||||
maturity:
|
||||
discovery:
|
||||
current: D4
|
||||
target: D6
|
||||
confidence: high
|
||||
rationale: >
|
||||
Charter (INTENT.md), meta-framework spec (docs/meta-framework.md), and
|
||||
research synthesis define scope. First extension (ext.compose-ssh) in progress.
|
||||
availability:
|
||||
current: A2
|
||||
target: A5
|
||||
confidence: medium
|
||||
rationale: >
|
||||
CLI v0 and ext.compose-ssh scaffold land in SAND-WP-0002. SaaS extensions
|
||||
and payments deferred.
|
||||
|
||||
external_evidence:
|
||||
completeness:
|
||||
level: C2
|
||||
name: Partial
|
||||
confidence: medium
|
||||
basis: scope_vs_intent_and_consumer_expectations
|
||||
satisfied_expectations:
|
||||
- profile-based create/destroy via CLI
|
||||
- State Hub lifecycle events on transitions
|
||||
broken_expectations:
|
||||
- SaaS extensions and payments layer not yet built
|
||||
- wise-validator migration not complete
|
||||
out_of_scope_expectations:
|
||||
- agent harness and tool orchestration (glas-harness)
|
||||
- e2e test semantics (wise-validator)
|
||||
|
||||
consumption_modes:
|
||||
- CLI (sandboxer)
|
||||
- core library (Python)
|
||||
- HTTP API (planned)
|
||||
@@ -1,4 +1,23 @@
|
||||
version: 1
|
||||
updated: '2026-06-16'
|
||||
domain: helix_forge
|
||||
capabilities: []
|
||||
updated: '2026-06-22'
|
||||
domain: infotech
|
||||
capabilities:
|
||||
- id: capability.execution.sandbox-provision
|
||||
name: Sandbox Provisioning
|
||||
summary: Isolated execution environments for agentic development, e2e testing, and bounded automations — profile-based provision, TTL teardown, and State Hub lifecycle registration.
|
||||
vector: D4 / A2 / C2 / R1
|
||||
domain: infotech
|
||||
status: draft
|
||||
owner: sand-boxer
|
||||
path: registry/capabilities/execution.sandbox-provision.md
|
||||
tags:
|
||||
- sandbox
|
||||
- isolation
|
||||
- provision
|
||||
- e2e
|
||||
- agentic
|
||||
- execution
|
||||
- profile
|
||||
consumption_modes:
|
||||
- CLI
|
||||
- core library
|
||||
@@ -1,8 +1,15 @@
|
||||
"""CLI entry point (bootstrap stub; expanded in SAND-WP-0002)."""
|
||||
"""sand-boxer CLI — sandbox establishment surface (v0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from sandboxer import __version__
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
||||
|
||||
app = typer.Typer(
|
||||
name="sandboxer",
|
||||
@@ -22,5 +29,90 @@ def version() -> None:
|
||||
typer.echo(__version__)
|
||||
|
||||
|
||||
def _parse_inputs(values: list[str]) -> dict[str, str]:
|
||||
inputs: dict[str, str] = {}
|
||||
for item in values:
|
||||
if "=" not in item:
|
||||
raise typer.BadParameter(f"Expected key=value, got: {item}")
|
||||
key, val = item.split("=", 1)
|
||||
inputs[key] = val
|
||||
return inputs
|
||||
|
||||
|
||||
def _print_status(status: object) -> None:
|
||||
typer.echo(json.dumps(status, default=str, indent=2))
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def sandbox_create(
|
||||
profile: Annotated[str, typer.Option("--profile", help="Profile id")],
|
||||
input: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option("--input", help="Input key=value (repeatable)"),
|
||||
] = None,
|
||||
actor: Annotated[str, typer.Option(help="Consumer actor type")] = "adm",
|
||||
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
|
||||
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
|
||||
) -> None:
|
||||
"""Provision a sandbox from a profile."""
|
||||
request = SandboxCreateRequest(
|
||||
profile=profile,
|
||||
inputs=_parse_inputs(input or []),
|
||||
consumer=Consumer(actor=ActorType(actor), project=project),
|
||||
)
|
||||
manager = SandboxManager()
|
||||
try:
|
||||
status = manager.create(request, host=host)
|
||||
except Exception as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_status(status.model_dump(mode="json"))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def sandbox_get(sandbox_id: str) -> None:
|
||||
"""Get sandbox status by id."""
|
||||
status = SandboxManager().get(sandbox_id)
|
||||
if not status:
|
||||
typer.echo(f"Sandbox not found: {sandbox_id}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
_print_status(status.model_dump(mode="json"))
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def sandbox_list(
|
||||
state: Annotated[str | None, typer.Option(help="Filter by state")] = None,
|
||||
) -> None:
|
||||
"""List known sandboxes."""
|
||||
items = SandboxManager().list()
|
||||
if state:
|
||||
items = [s for s in items if s.state.value == state]
|
||||
_print_status([s.model_dump(mode="json") for s in items])
|
||||
|
||||
|
||||
@app.command("destroy")
|
||||
def sandbox_destroy(sandbox_id: str) -> None:
|
||||
"""Destroy a sandbox (idempotent)."""
|
||||
manager = SandboxManager()
|
||||
try:
|
||||
status = manager.destroy(sandbox_id)
|
||||
except KeyError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_status(status.model_dump(mode="json"))
|
||||
|
||||
|
||||
@app.command("recreate")
|
||||
def sandbox_recreate(sandbox_id: str) -> None:
|
||||
"""Destroy and reprovision from stored inputs."""
|
||||
manager = SandboxManager()
|
||||
try:
|
||||
status = manager.recreate(sandbox_id)
|
||||
except (KeyError, Exception) as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_status(status.model_dump(mode="json"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
1
src/sandboxer/core/__init__.py
Normal file
1
src/sandboxer/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core sandbox establishment logic."""
|
||||
122
src/sandboxer/core/manager.py
Normal file
122
src/sandboxer/core/manager.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Core sandbox establishment logic — harness-agnostic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sandboxer.extensions.registry import load_extension, resolve_backend
|
||||
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
||||
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
||||
from sandboxer.models import (
|
||||
Reachability,
|
||||
SandboxCreateRequest,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
def __init__(self, store: SandboxStore | None = None) -> None:
|
||||
self.store = store or SandboxStore()
|
||||
|
||||
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
||||
profile = load_profile(request.profile)
|
||||
extension = load_extension(profile.extension)
|
||||
backend = resolve_backend(extension)
|
||||
resolved_host = resolve_host(profile, override=host)
|
||||
|
||||
now = utcnow()
|
||||
status = SandboxStatus(
|
||||
sandbox_id="pending",
|
||||
profile_id=profile.id,
|
||||
extension_id=extension.id,
|
||||
state=SandboxState.REQUESTED,
|
||||
consumer=request.consumer,
|
||||
host=resolved_host,
|
||||
inputs=dict(request.inputs),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
|
||||
status.state = SandboxState.PROVISIONING
|
||||
status.updated_at = utcnow()
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
|
||||
try:
|
||||
handle = backend.provision(profile, request.inputs, resolved_host)
|
||||
status.sandbox_id = handle["sandbox_id"]
|
||||
status.inputs["compose_file"] = handle.get("compose_file", "")
|
||||
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
status.updated_at = status.ready_at
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
return status
|
||||
except Exception as exc:
|
||||
status.state = SandboxState.FAILED
|
||||
status.error = str(exc)
|
||||
status.updated_at = utcnow()
|
||||
if status.sandbox_id != "pending":
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=f"Sandbox provision failed: {exc}",
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
raise
|
||||
|
||||
def get(self, sandbox_id: str) -> SandboxStatus | None:
|
||||
return self.store.get(sandbox_id)
|
||||
|
||||
def list(self) -> list[SandboxStatus]:
|
||||
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
def destroy(self, sandbox_id: str) -> SandboxStatus:
|
||||
status = self.store.get(sandbox_id)
|
||||
if not status:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
if status.state == SandboxState.DESTROYED:
|
||||
return status
|
||||
|
||||
profile = load_profile(status.profile_id)
|
||||
extension = load_extension(profile.extension)
|
||||
backend = resolve_backend(extension)
|
||||
|
||||
status.state = SandboxState.DESTROYING
|
||||
status.updated_at = utcnow()
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
|
||||
handle = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"host": status.host or "",
|
||||
"remote_dir": status.reachability.remote_dir if status.reachability else "",
|
||||
"compose_project": status.reachability.compose_project if status.reachability else "",
|
||||
"compose_file": status.inputs.get("compose_file", ""),
|
||||
"ssh_user": status.inputs.get("ssh_user", ""),
|
||||
}
|
||||
backend.teardown(handle)
|
||||
|
||||
status.state = SandboxState.DESTROYED
|
||||
status.destroyed_at = utcnow()
|
||||
status.updated_at = status.destroyed_at
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
return status
|
||||
|
||||
def recreate(self, sandbox_id: str) -> SandboxStatus:
|
||||
existing = self.store.get(sandbox_id)
|
||||
if not existing:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
request = SandboxCreateRequest(
|
||||
profile=existing.profile_id,
|
||||
inputs=dict(existing.inputs),
|
||||
consumer=existing.consumer,
|
||||
)
|
||||
if existing.state != SandboxState.DESTROYED:
|
||||
self.destroy(sandbox_id)
|
||||
return self.create(request, host=existing.host)
|
||||
123
src/sandboxer/extensions/compose_ssh.py
Normal file
123
src/sandboxer/extensions/compose_ssh.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""ext.compose-ssh — SSH + docker compose provisioning (e2e-framework lineage)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from sandboxer.extensions.ssh import SSHConfig
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
class ComposeSSHExtension:
|
||||
"""Provision isolated compose stacks on a remote host via SSH."""
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
||||
cfg = config or {}
|
||||
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
|
||||
self.ssh_user: str = cfg.get("ssh_user", "root")
|
||||
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
repo = inputs.get("repo")
|
||||
if not repo:
|
||||
raise ValueError("inputs.repo is required for profile.compose-e2e")
|
||||
repo_path = Path(repo).expanduser().resolve()
|
||||
if not repo_path.exists():
|
||||
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
|
||||
|
||||
sandbox_id = inputs.get("sandbox_id") or str(uuid.uuid4())[:8]
|
||||
remote_dir = f"{self.base_dir}/{sandbox_id}"
|
||||
ssh = SSHConfig.from_env(host, user=self.ssh_user)
|
||||
|
||||
rc, out = ssh.run(f"mkdir -p {remote_dir}")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Failed to create remote dir: {out}")
|
||||
|
||||
ssh.rsync(repo_path, remote_dir)
|
||||
|
||||
compose_file = self._resolve_compose_file(repo_path)
|
||||
project_name = f"sbx-{profile.id.split('.')[-1]}-{sandbox_id}"
|
||||
|
||||
up_cmd = (
|
||||
f"cd {remote_dir} && "
|
||||
f"docker compose -p {project_name} -f {compose_file} up -d"
|
||||
)
|
||||
rc, out = ssh.run(up_cmd, timeout=self.compose_timeout_s)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"docker compose up failed: {out}")
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"host": host,
|
||||
"remote_dir": remote_dir,
|
||||
"compose_project": project_name,
|
||||
"compose_file": compose_file,
|
||||
"repo": str(repo_path),
|
||||
"ssh_user": ssh.user,
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
"""Confirm compose services are running (no HTTP health polling)."""
|
||||
ssh = SSHConfig.from_env(handle["host"], user=handle.get("ssh_user", self.ssh_user))
|
||||
project = handle["compose_project"]
|
||||
remote_dir = handle["remote_dir"]
|
||||
compose_file = handle["compose_file"]
|
||||
cmd = (
|
||||
f"cd {remote_dir} && "
|
||||
f"docker compose -p {project} -f {compose_file} ps --status running -q"
|
||||
)
|
||||
rc, out = ssh.run(cmd, timeout=60)
|
||||
if rc != 0 or not out.strip():
|
||||
raise RuntimeError(f"compose services not running: {out}")
|
||||
return {
|
||||
"ssh": ssh.target,
|
||||
"remote_dir": remote_dir,
|
||||
"compose_project": project,
|
||||
"host": handle["host"],
|
||||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
ssh = SSHConfig.from_env(handle["host"], user=handle.get("ssh_user", self.ssh_user))
|
||||
project = handle.get("compose_project")
|
||||
remote_dir = handle.get("remote_dir")
|
||||
compose_file = handle.get("compose_file")
|
||||
cleaned_compose = False
|
||||
|
||||
if project and remote_dir and compose_file:
|
||||
down_cmd = (
|
||||
f"cd {remote_dir} && "
|
||||
f"docker compose -p {project} -f {compose_file} "
|
||||
f"down -v --remove-orphans 2>&1 || true"
|
||||
)
|
||||
ssh.run(down_cmd, timeout=60)
|
||||
cleaned_compose = True
|
||||
|
||||
cleaned_dir = False
|
||||
if remote_dir:
|
||||
rc, _ = ssh.run(f"rm -rf {remote_dir}", timeout=30)
|
||||
cleaned_dir = rc == 0
|
||||
|
||||
return {
|
||||
"compose_removed": str(cleaned_compose),
|
||||
"remote_dir_removed": str(cleaned_dir),
|
||||
"remote_dir": remote_dir or "",
|
||||
}
|
||||
|
||||
def _resolve_compose_file(self, repo_path: Path) -> str:
|
||||
e2e_yml = repo_path / "e2e" / "e2e.yml"
|
||||
if e2e_yml.exists():
|
||||
raw = yaml.safe_load(e2e_yml.read_text())
|
||||
if raw and raw.get("compose_file"):
|
||||
return raw["compose_file"]
|
||||
for candidate in ("docker-compose.dev.yml", "docker-compose.yml", "compose.yml"):
|
||||
if (repo_path / candidate).exists():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
f"No compose file found in {repo_path} (expected e2e/e2e.yml or docker-compose*.yml)"
|
||||
)
|
||||
74
src/sandboxer/extensions/registry.py
Normal file
74
src/sandboxer/extensions/registry.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Extension discovery, validation, and handler resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import yaml
|
||||
|
||||
from sandboxer.models import Extension, Profile
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_EXTENSIONS_DIR = _REPO_ROOT / "extensions"
|
||||
|
||||
_REQUIRED_CAPABILITY_FIELDS = ("isolation_levels", "pricing_model")
|
||||
|
||||
|
||||
class ExtensionBackend(Protocol):
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]: ...
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: ...
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]: ...
|
||||
|
||||
|
||||
def extensions_dir() -> Path:
|
||||
return _EXTENSIONS_DIR
|
||||
|
||||
|
||||
def _validate_extension_caps(ext: Extension) -> None:
|
||||
caps = ext.capabilities
|
||||
for field in _REQUIRED_CAPABILITY_FIELDS:
|
||||
if not getattr(caps, field, None):
|
||||
raise ValueError(f"Extension {ext.id} missing capability field: {field}")
|
||||
if not ext.handler:
|
||||
raise ValueError(f"Extension {ext.id} missing handler")
|
||||
|
||||
|
||||
def load_extension(extension_id: str, *, extensions_root: Path | None = None) -> Extension:
|
||||
root = extensions_root or _EXTENSIONS_DIR
|
||||
path = root / f"{extension_id}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Extension not found: {extension_id} ({path})")
|
||||
raw = yaml.safe_load(path.read_text())
|
||||
ext = Extension.model_validate(raw)
|
||||
if ext.id != extension_id:
|
||||
raise ValueError(f"Extension id mismatch: file {extension_id}, content {ext.id}")
|
||||
_validate_extension_caps(ext)
|
||||
return ext
|
||||
|
||||
|
||||
def load_all_extensions(*, extensions_root: Path | None = None) -> dict[str, Extension]:
|
||||
root = extensions_root or _EXTENSIONS_DIR
|
||||
extensions: dict[str, Extension] = {}
|
||||
if not root.exists():
|
||||
return extensions
|
||||
for path in sorted(root.glob("*.yaml")):
|
||||
ext = load_extension(path.stem, extensions_root=root)
|
||||
if ext.id in extensions:
|
||||
raise ValueError(f"Duplicate extension id: {ext.id}")
|
||||
extensions[ext.id] = ext
|
||||
return extensions
|
||||
|
||||
|
||||
def resolve_backend(extension: Extension) -> ExtensionBackend:
|
||||
module_path, _, attr = extension.handler.partition(":")
|
||||
if not attr:
|
||||
raise ValueError(f"Invalid handler for {extension.id}: {extension.handler}")
|
||||
module = importlib.import_module(module_path)
|
||||
cls = getattr(module, attr)
|
||||
return cls(extension.config)
|
||||
71
src/sandboxer/extensions/ssh.py
Normal file
71
src/sandboxer/extensions/ssh.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""SSH and rsync helpers for self-hosted extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHConfig:
|
||||
host: str
|
||||
user: str = "root"
|
||||
key: str | None = None
|
||||
connect_timeout: int = 15
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
return f"{self.user}@{self.host}"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, host: str, *, user: str | None = None, key: str | None = None) -> SSHConfig:
|
||||
return cls(
|
||||
host=host,
|
||||
user=user or os.environ.get("SANDBOXER_SSH_USER", "root"),
|
||||
key=key or os.environ.get("SANDBOXER_SSH_KEY"),
|
||||
)
|
||||
|
||||
def ssh_base(self) -> list[str]:
|
||||
args = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
f"ConnectTimeout={self.connect_timeout}",
|
||||
]
|
||||
if self.key:
|
||||
args += ["-i", self.key]
|
||||
args.append(self.target)
|
||||
return args
|
||||
|
||||
def run(self, cmd: str, *, timeout: int = 60) -> tuple[int, str]:
|
||||
result = subprocess.run(
|
||||
self.ssh_base() + [cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.returncode, result.stdout + result.stderr
|
||||
|
||||
def rsync(self, local_path: Path, remote_dir: str, *, timeout: int = 120) -> None:
|
||||
rsync_args = [
|
||||
"rsync",
|
||||
"-az",
|
||||
"--delete",
|
||||
"--exclude=.git",
|
||||
"--exclude=__pycache__",
|
||||
"--exclude=*.pyc",
|
||||
"--exclude=.venv",
|
||||
"--exclude=node_modules",
|
||||
]
|
||||
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
|
||||
if self.key:
|
||||
ssh_cmd = f"ssh -i {self.key} -o StrictHostKeyChecking=no"
|
||||
rsync_args += ["-e", ssh_cmd, f"{local_path}/", f"{self.target}:{remote_dir}/"]
|
||||
result = subprocess.run(rsync_args, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"rsync failed: {result.stdout + result.stderr}")
|
||||
62
src/sandboxer/lifecycle/state_hub.py
Normal file
62
src/sandboxer/lifecycle/state_hub.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""State Hub lifecycle event emission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from sandboxer.models import SandboxState, SandboxStatus
|
||||
|
||||
_DEFAULT_HUB = "http://127.0.0.1:8000"
|
||||
|
||||
|
||||
def hub_url() -> str:
|
||||
return os.environ.get("STATE_HUB_URL", _DEFAULT_HUB)
|
||||
|
||||
|
||||
def emit_lifecycle_event(
|
||||
status: SandboxStatus,
|
||||
*,
|
||||
summary: str | None = None,
|
||||
event_type: str = "note",
|
||||
author: str = "sandboxer",
|
||||
) -> dict[str, Any] | None:
|
||||
if os.environ.get("SANDBOXER_NO_STATE_HUB", "").lower() in ("1", "true", "yes"):
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"event_type": event_type,
|
||||
"summary": summary or f"Sandbox {status.sandbox_id} → {status.state.value}",
|
||||
"author": author,
|
||||
"detail": {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"extension_id": status.extension_id,
|
||||
"host": status.host,
|
||||
"consumer": status.consumer.model_dump(),
|
||||
"actor_type": status.consumer.actor.value,
|
||||
"state": status.state.value,
|
||||
"reachability": status.reachability.model_dump() if status.reachability else None,
|
||||
"timestamps": {
|
||||
"created_at": status.created_at.isoformat(),
|
||||
"updated_at": status.updated_at.isoformat(),
|
||||
"ready_at": status.ready_at.isoformat() if status.ready_at else None,
|
||||
"destroyed_at": status.destroyed_at.isoformat() if status.destroyed_at else None,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.post(f"{hub_url()}/progress/", json=payload, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def event_type_for_state(state: SandboxState) -> str:
|
||||
if state in (SandboxState.READY, SandboxState.DESTROYED):
|
||||
return "milestone"
|
||||
return "note"
|
||||
52
src/sandboxer/lifecycle/store.py
Normal file
52
src/sandboxer/lifecycle/store.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Persistent sandbox status store (JSON file)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sandboxer.models import SandboxStatus
|
||||
|
||||
|
||||
def _default_store_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return base / "sandboxer" / "sandboxes.json"
|
||||
|
||||
|
||||
class SandboxStore:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or _default_store_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> dict[str, dict]:
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
return json.loads(self.path.read_text())
|
||||
|
||||
def _write(self, data: dict[str, dict]) -> None:
|
||||
self.path.write_text(json.dumps(data, indent=2, default=str))
|
||||
|
||||
def save(self, status: SandboxStatus) -> None:
|
||||
data = self._read()
|
||||
data[status.sandbox_id] = status.model_dump(mode="json")
|
||||
self._write(data)
|
||||
|
||||
def get(self, sandbox_id: str) -> SandboxStatus | None:
|
||||
raw = self._read().get(sandbox_id)
|
||||
if not raw:
|
||||
return None
|
||||
return SandboxStatus.model_validate(raw)
|
||||
|
||||
def list_all(self) -> list[SandboxStatus]:
|
||||
return [SandboxStatus.model_validate(v) for v in self._read().values()]
|
||||
|
||||
def delete(self, sandbox_id: str) -> None:
|
||||
data = self._read()
|
||||
data.pop(sandbox_id, None)
|
||||
self._write(data)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
147
src/sandboxer/models.py
Normal file
147
src/sandboxer/models.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Pydantic schemas for profiles, extensions, and sandbox lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ActorType(StrEnum):
|
||||
ADM = "adm"
|
||||
AGT = "agt"
|
||||
ATM = "atm"
|
||||
|
||||
|
||||
class SandboxState(StrEnum):
|
||||
REQUESTED = "requested"
|
||||
PROVISIONING = "provisioning"
|
||||
READY = "ready"
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
FAILED = "failed"
|
||||
DESTROYING = "destroying"
|
||||
DESTROYED = "destroyed"
|
||||
|
||||
|
||||
class RouteStrategy(StrEnum):
|
||||
PREFER_SELF_HOSTED = "prefer-self-hosted"
|
||||
LOWEST_COST = "lowest-cost"
|
||||
LOWEST_LATENCY = "lowest-latency"
|
||||
EXPLICIT = "explicit"
|
||||
|
||||
|
||||
class Consumer(BaseModel):
|
||||
actor: ActorType
|
||||
project: str
|
||||
session_id: str | None = None
|
||||
run_id: str | None = None
|
||||
|
||||
|
||||
class IsolationSpec(BaseModel):
|
||||
level: Literal["container", "microvm", "policy"] = "container"
|
||||
|
||||
|
||||
class NetworkSpec(BaseModel):
|
||||
default: Literal["deny", "allow"] = "deny"
|
||||
egress: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkspaceSpec(BaseModel):
|
||||
mode: Literal["mirror", "remote-canonical"] = "remote-canonical"
|
||||
access: Literal["none", "ro", "rw"] = "rw"
|
||||
|
||||
|
||||
class TtlSpec(BaseModel):
|
||||
default: str = "4h"
|
||||
max: str = "24h"
|
||||
idle_reap: str | None = None
|
||||
|
||||
|
||||
class ResourceSpec(BaseModel):
|
||||
cpu: str | None = None
|
||||
memory_mb: int | None = None
|
||||
|
||||
|
||||
class SetupSpec(BaseModel):
|
||||
instructions: str = ""
|
||||
secret_refs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlacementSpec(BaseModel):
|
||||
prefer: list[str] = Field(default_factory=list)
|
||||
fallback: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReachabilitySpec(BaseModel):
|
||||
tunnel: str = "ops-bridge"
|
||||
identity: str = "ops-warden"
|
||||
|
||||
|
||||
class ProfileMetadata(BaseModel):
|
||||
cost_class: Literal["self-hosted", "saas-metered"] = "self-hosted"
|
||||
latency_class: str = "standard"
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
id: str
|
||||
version: str
|
||||
extension: str
|
||||
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
||||
network: NetworkSpec = Field(default_factory=NetworkSpec)
|
||||
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
|
||||
scope_default: Literal["session", "agent", "shared"] = "session"
|
||||
ttl: TtlSpec = Field(default_factory=TtlSpec)
|
||||
resources: ResourceSpec = Field(default_factory=ResourceSpec)
|
||||
setup: SetupSpec = Field(default_factory=SetupSpec)
|
||||
placement: PlacementSpec = Field(default_factory=PlacementSpec)
|
||||
reachability: ReachabilitySpec = Field(default_factory=ReachabilitySpec)
|
||||
metadata: ProfileMetadata = Field(default_factory=ProfileMetadata)
|
||||
|
||||
|
||||
class ExtensionCapabilities(BaseModel):
|
||||
isolation_levels: list[str] = Field(default_factory=lambda: ["container"])
|
||||
regions: list[str] = Field(default_factory=list)
|
||||
persistence: bool = False
|
||||
pricing_model: Literal["self-hosted", "metered"] = "self-hosted"
|
||||
|
||||
|
||||
class Extension(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
handler: str
|
||||
capabilities: ExtensionCapabilities = Field(default_factory=ExtensionCapabilities)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SandboxCreateRequest(BaseModel):
|
||||
profile: str
|
||||
inputs: dict[str, str] = Field(default_factory=dict)
|
||||
consumer: Consumer
|
||||
ttl: str | None = None
|
||||
|
||||
|
||||
class Reachability(BaseModel):
|
||||
ssh: str | None = None
|
||||
remote_dir: str | None = None
|
||||
compose_project: str | None = None
|
||||
host: str | None = None
|
||||
|
||||
|
||||
class SandboxStatus(BaseModel):
|
||||
sandbox_id: str
|
||||
profile_id: str
|
||||
extension_id: str
|
||||
state: SandboxState
|
||||
consumer: Consumer
|
||||
host: str | None = None
|
||||
reachability: Reachability | None = None
|
||||
inputs: dict[str, str] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
ready_at: datetime | None = None
|
||||
destroyed_at: datetime | None = None
|
||||
28
src/sandboxer/placement.py
Normal file
28
src/sandboxer/placement.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Host placement resolution from profile policy and environment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def resolve_host(profile: Profile, *, override: str | None = None) -> str:
|
||||
if override:
|
||||
return override
|
||||
env_host = os.environ.get("SANDBOXER_HOST")
|
||||
if env_host:
|
||||
return env_host
|
||||
for candidate in [*profile.placement.prefer, *profile.placement.fallback]:
|
||||
mapped = os.environ.get(f"SANDBOXER_HOST_{candidate.upper()}")
|
||||
if mapped:
|
||||
return mapped
|
||||
if candidate in os.environ:
|
||||
return os.environ[candidate]
|
||||
if profile.placement.prefer:
|
||||
return profile.placement.prefer[0]
|
||||
if profile.placement.fallback:
|
||||
return profile.placement.fallback[0]
|
||||
raise ValueError(
|
||||
"No host resolved. Set SANDBOXER_HOST or profile placement hosts in environment."
|
||||
)
|
||||
35
src/sandboxer/profiles/loader.py
Normal file
35
src/sandboxer/profiles/loader.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Load and validate profile definitions from YAML files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from sandboxer.models import Profile
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_PROFILES_DIR = _REPO_ROOT / "profiles"
|
||||
|
||||
|
||||
def profiles_dir() -> Path:
|
||||
return _PROFILES_DIR
|
||||
|
||||
|
||||
def load_profile(profile_id: str, *, profiles_root: Path | None = None) -> Profile:
|
||||
root = profiles_root or _PROFILES_DIR
|
||||
path = root / f"{profile_id}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Profile not found: {profile_id} ({path})")
|
||||
raw = yaml.safe_load(path.read_text())
|
||||
profile = Profile.model_validate(raw)
|
||||
if profile.id != profile_id:
|
||||
raise ValueError(f"Profile id mismatch: file {profile_id}, content {profile.id}")
|
||||
return profile
|
||||
|
||||
|
||||
def list_profile_ids(*, profiles_root: Path | None = None) -> list[str]:
|
||||
root = profiles_root or _PROFILES_DIR
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(p.stem for p in root.glob("*.yaml"))
|
||||
84
tests/test_manager.py
Normal file
84
tests/test_manager.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""SandboxManager unit tests with mocked backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.lifecycle.store import SandboxStore
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState, SandboxStatus
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def provision(self, profile, inputs, host):
|
||||
return {
|
||||
"sandbox_id": "test1234",
|
||||
"host": host,
|
||||
"remote_dir": "/tmp/sandboxer/test1234",
|
||||
"compose_project": "sbx-e2e-test1234",
|
||||
"compose_file": "docker-compose.yml",
|
||||
"ssh_user": "root",
|
||||
}
|
||||
|
||||
def wait_ready(self, handle):
|
||||
return {
|
||||
"ssh": f"root@{handle['host']}",
|
||||
"remote_dir": handle["remote_dir"],
|
||||
"compose_project": handle["compose_project"],
|
||||
"host": handle["host"],
|
||||
}
|
||||
|
||||
def teardown(self, handle):
|
||||
return {
|
||||
"compose_removed": "True",
|
||||
"remote_dir_removed": "True",
|
||||
"remote_dir": handle["remote_dir"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> SandboxStore:
|
||||
return SandboxStore(path=tmp_path / "sandboxes.json")
|
||||
|
||||
|
||||
def test_create_and_destroy(store: SandboxStore) -> None:
|
||||
manager = SandboxManager(store=store)
|
||||
request = SandboxCreateRequest(
|
||||
profile="profile.compose-e2e",
|
||||
inputs={"repo": "/tmp/repo"},
|
||||
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=FakeBackend()),
|
||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
||||
):
|
||||
status = manager.create(request)
|
||||
assert status.state == SandboxState.READY
|
||||
assert status.sandbox_id == "test1234"
|
||||
|
||||
destroyed = manager.destroy(status.sandbox_id)
|
||||
assert destroyed.state == SandboxState.DESTROYED
|
||||
|
||||
|
||||
def test_destroy_idempotent(store: SandboxStore) -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = SandboxStatus(
|
||||
sandbox_id="gone1234",
|
||||
profile_id="profile.compose-e2e",
|
||||
extension_id="ext.compose-ssh",
|
||||
state=SandboxState.DESTROYED,
|
||||
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
destroyed_at=now,
|
||||
)
|
||||
store.save(status)
|
||||
manager = SandboxManager(store=store)
|
||||
result = manager.destroy("gone1234")
|
||||
assert result.state == SandboxState.DESTROYED
|
||||
61
tests/test_models.py
Normal file
61
tests/test_models.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Schema and loader tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from sandboxer.extensions.registry import load_all_extensions, load_extension
|
||||
from sandboxer.models import Consumer, Profile, SandboxCreateRequest
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
|
||||
|
||||
def test_load_compose_e2e_profile() -> None:
|
||||
profile = load_profile("profile.compose-e2e")
|
||||
assert profile.extension == "ext.compose-ssh"
|
||||
assert profile.placement.prefer == ["sandboxer01"]
|
||||
|
||||
|
||||
def test_load_compose_ssh_extension() -> None:
|
||||
ext = load_extension("ext.compose-ssh")
|
||||
assert ext.handler.endswith("ComposeSSHExtension")
|
||||
assert "container" in ext.capabilities.isolation_levels
|
||||
|
||||
|
||||
def test_load_all_extensions_no_duplicates() -> None:
|
||||
extensions = load_all_extensions()
|
||||
assert "ext.compose-ssh" in extensions
|
||||
assert len(extensions) == len(set(extensions))
|
||||
|
||||
|
||||
def test_profile_roundtrip(tmp_path: Path) -> None:
|
||||
data = load_profile("profile.compose-e2e").model_dump()
|
||||
path = tmp_path / "profile.test.yaml"
|
||||
path.write_text(yaml.dump(data))
|
||||
loaded = Profile.model_validate(yaml.safe_load(path.read_text()))
|
||||
assert loaded.id == "profile.compose-e2e"
|
||||
|
||||
|
||||
def test_sandbox_create_request() -> None:
|
||||
req = SandboxCreateRequest(
|
||||
profile="profile.compose-e2e",
|
||||
inputs={"repo": "/tmp/foo"},
|
||||
consumer=Consumer(actor="adm", project="sand-boxer"),
|
||||
)
|
||||
assert req.inputs["repo"] == "/tmp/foo"
|
||||
|
||||
|
||||
def test_extension_missing_handler(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "ext.bad.yaml"
|
||||
bad.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"id": "ext.bad",
|
||||
"title": "Bad",
|
||||
"handler": "",
|
||||
"capabilities": {"isolation_levels": ["container"], "pricing_model": "self-hosted"},
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing handler"):
|
||||
load_extension("ext.bad", extensions_root=tmp_path)
|
||||
280
uv.lock
generated
280
uv.lock
generated
@@ -10,6 +10,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.6.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -19,6 +50,52 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -67,6 +144,123 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -92,6 +286,61 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
@@ -135,6 +384,9 @@ name = "sandboxer"
|
||||
version = "0.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
@@ -145,7 +397,12 @@ dev = [
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "typer", specifier = ">=0.12" }]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "typer", specifier = ">=0.12" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -176,3 +433,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410d
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "Meta-framework foundation and first extension"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: ready
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-22"
|
||||
@@ -27,7 +27,7 @@ place; T03 package layout partially satisfied)
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5a45289c-3130-40f2-99e0-fbb533f56bda"
|
||||
```
|
||||
@@ -51,7 +51,7 @@ validator, or codegen concerns.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5747603d-bed2-4d4b-9287-9949befff0c2"
|
||||
```
|
||||
@@ -73,7 +73,7 @@ metadata.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c90d6aaf-8dc7-4ea2-8c78-b3c9255e331e"
|
||||
```
|
||||
@@ -97,7 +97,7 @@ and CLI entry point. Satisfies SAND-WP-0001-T02 if not already done.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "578105e8-947b-4755-aeff-181ddb85d750"
|
||||
```
|
||||
@@ -115,7 +115,7 @@ No SaaS extensions in this workplan — self-hosted only.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6262786d-1019-46a2-b745-b111dfe83620"
|
||||
```
|
||||
@@ -135,7 +135,7 @@ Provide a compatibility note in extension README for interim `make e2e` callers.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "79b22b16-17f3-48eb-a4ad-9eae88f94202"
|
||||
```
|
||||
@@ -163,7 +163,7 @@ snuggle-inventor call the same functions.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "79312c62-1213-4045-8bf6-84030f6b9aa7"
|
||||
```
|
||||
@@ -182,7 +182,7 @@ identities. Document contract in meta-framework spec.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "27221d26-7900-46e4-8c4e-1012023afb65"
|
||||
```
|
||||
@@ -202,7 +202,7 @@ Cross-link from `INTENT.md` Coulomb boundaries section.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0002-T09
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e2b089b2-3742-4feb-86c3-788a1f6ffb81"
|
||||
```
|
||||
@@ -234,6 +234,9 @@ End-to-end proof on CoulombCore or sandboxer01 (when reachable):
|
||||
Record gaps for wise-validator migration (SAND-WP-0003) and `the-custodian`
|
||||
shim (SAND-WP-0004).
|
||||
|
||||
Runbook published at `docs/runbooks/profile-compose-e2e.md`. Remote host smoke
|
||||
test (steps 1–4) pending operator run against CoulombCore/sandboxer01.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (follow-on workplans)
|
||||
|
||||
Reference in New Issue
Block a user