From 283ff91dae79ffaf0e27c5503b1c7788bdc26de7 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:14:20 +0000 Subject: [PATCH 1/5] feat: define whole-template launch contract --- .../2026-10-25-unified-template-contract.md | 27 + .../2026-10-25-unified-template-launch.md | 46 + contracts/launch-v1.json | 48 + contracts/launch-vectors.json | 1082 +++++++++++++++++ 4 files changed, 1203 insertions(+) create mode 100644 .agents/evidence/verify/2026-10-25-unified-template-contract.md create mode 100644 .agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md create mode 100644 contracts/launch-v1.json create mode 100644 contracts/launch-vectors.json diff --git a/.agents/evidence/verify/2026-10-25-unified-template-contract.md b/.agents/evidence/verify/2026-10-25-unified-template-contract.md new file mode 100644 index 00000000..45ae6c21 --- /dev/null +++ b/.agents/evidence/verify/2026-10-25-unified-template-contract.md @@ -0,0 +1,27 @@ +# Verification: unified template launch contract foundation + +- Date: 2026-10-25 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/107 +- Result: PARTIAL +- Scope: `contracts/launch-v1.json`, `contracts/launch-vectors.json` and the working + Python planner used to evaluate them; runtime changes remain in progress. +- Environment: isolated Linux Python development environment with both SDK packages. + +## Commands run + +`python -m pytest client-sdk/tests/test_workflows/test_launch_contract.py -q` + +## Results + +17 passed. Cases cover source preparation, prepared Python consumption, all services, +native references, images, missing operations, unsupported provider combinations and +strict rejection of old declarations. These are local static contract tests. + +## Cleanup + +No cloud resource was created by the contract tests. + +## Not verified + +The full runtime, real-cloud E2E and coordinated consumer migrations are pending. +This foundation commit does not claim that specification 5 has been released. diff --git a/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md b/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md new file mode 100644 index 00000000..a79f951e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md @@ -0,0 +1,46 @@ +# Decision: Launch a complete template without workflow selection + +Status: proposed + +## Problem + +A template exposes several services, but selecting a workflow reduced launch to one +operation and made callers choose source/image/native execution types. + +## Decision + +Specification 5 removes workflow declarations and retains the complete `provides` +map plus the existing startup/readiness commands. `contracts/launch-v1.json` and +`launch-vectors.json` define the shared static launch plan. Old receipt contracts +remain immutable historical inputs and do not certify specification 5 launches. + +## API Design + +`launch_template(manifest, provider=..., reference=...)` starts a previously prepared +reference, checks all SandboxServer services and returns their clients. Python does +not acquire source building. The static planner returns `supported`, required/missing +operations, all services and an optional unsupported-combination reason. + +## Alternatives considered + +- Hide the workflow flag while retaining selectable manifests: preserves the source + of conflicting behavior and incomplete service exposure. +- Infer an old default workflow: hides migration and continues selecting a subset. + +## Dependencies + +- https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/107 +- CLI consumer: https://github.com/Alibaba-Agent-Sandbox/agent-sandbox +- Template owner: https://github.com/Alibaba-Agent-Sandbox/awesome-templates + +## Test Strategy + +Shared cross-language vectors, SDK model and launcher regressions, full package CI, +and real provider startup/cleanup. Current runtime verification is pending. + +## Acceptance criteria + +- No workflow argument or current manifest workflow selection remains. +- All declared services survive launch; no unrelated interpreter requirement blocks it. +- Required tests, real-component validation, bilingual docs and changelog are recorded. +- CLI and template consumers migrate together; current v5 receipts are not fabricated. diff --git a/contracts/launch-v1.json b/contracts/launch-v1.json new file mode 100644 index 00000000..b7cbc579 --- /dev/null +++ b/contracts/launch-v1.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "template_spec_version": 5, + "max_safe_integer": 9007199254740991, + "template_keys": [ + "spec_version", + "provides" + ], + "retired_keys": [ + "workflow_contract_version", + "default_workflow", + "workflows", + "capabilities", + "tested_on", + "providers" + ], + "service_keys": [ + "protocol", + "version", + "port", + "operations" + ], + "consumers": { + "cli": "cli", + "python-provider": "python" + }, + "required_operations": { + "create": [ + "sandbox.create", + "sandbox.delete" + ], + "prepare": [ + "template.prepare" + ], + "start": [ + "exec.run" + ], + "services": [ + "ports.resolve" + ] + }, + "description": "Start the complete template once. Services describe its endpoints, not selectable execution flows.", + "unsupported_reasons": [ + "provider-mismatch", + "input-unimplemented", + "startup-unimplemented" + ] +} diff --git a/contracts/launch-vectors.json b/contracts/launch-vectors.json new file mode 100644 index 00000000..3c3e513a --- /dev/null +++ b/contracts/launch-vectors.json @@ -0,0 +1,1082 @@ +{ + "cases": [ + { + "id": "source-cli", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "source-python-unprepared", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "python-provider", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [ + "template.prepare" + ], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "source-python-prepared", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "python-provider", + "prepared": true + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "multiple-services", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + }, + "api": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9001, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + }, + "api": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9001, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "no-services", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": {}, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": {} + } + }, + { + "id": "managed-no-start", + "input": { + "manifest": { + "spec_version": 5, + "provides": {}, + "provider": "fixture", + "template_id": "base" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": {} + } + }, + { + "id": "managed-foreign-provider", + "input": { + "manifest": { + "spec_version": 5, + "provides": {}, + "provider": "other", + "template_id": "base" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": {}, + "reason": "provider-mismatch" + } + }, + { + "id": "image-entrypoint", + "input": { + "manifest": { + "spec_version": 5, + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "image": "registry/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "ports.resolve", + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "ready-command", + "input": { + "manifest": { + "spec_version": 5, + "provides": {}, + "image": "image", + "ready_cmd": "test -f /tmp/ready" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": {} + } + }, + { + "id": "unsupported-input", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "reason": "input-unimplemented" + } + }, + { + "id": "unsupported-startup", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "entrypoint" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "reason": "startup-unimplemented" + } + }, + { + "id": "missing-port", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run" + ], + "python": [] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [ + "ports.resolve" + ], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "retired-v4", + "input": { + "manifest": { + "spec_version": 4, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "retired-workflows", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py", + "workflows": {} + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "unknown-service-field", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ], + "binding": {} + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "bad-provider-binding", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py", + "provider": "fixture" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "bad-port-bool", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": true, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + } + ] +} From ff073167f8993a5c580c78cccccd5779db1f5836 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:27:45 +0000 Subject: [PATCH 2/5] feat!: launch complete templates without workflow selection --- .agents/README.md | 11 +- .../2026-10-25-unified-template-runtime.md | 56 + AGENTS.md | 21 +- CHANGELOG.md | 14 + README.md | 6 +- README.zh-CN.md | 4 +- agent-sandbox-provider/SKILL.md | 8 +- agent-sandbox-sdk/SKILL.md | 44 +- client-sdk/README.md | 2 +- client-sdk/pyproject.toml | 2 +- client-sdk/src/agent_sandbox/_version.py | 2 +- client-sdk/src/agent_sandbox/api/__init__.py | 8 +- .../src/agent_sandbox/api/capability.py | 6 +- client-sdk/src/agent_sandbox/api/template.py | 325 +++++ client-sdk/src/agent_sandbox/api/workflow.py | 350 ------ .../src/agent_sandbox/models/template.py | 29 +- .../src/agent_sandbox/workflows/__init__.py | 9 +- .../agent_sandbox/workflows/launch-v1.json | 48 + .../workflows/launch-vectors.json | 1082 +++++++++++++++++ .../src/agent_sandbox/workflows/launch.py | 103 ++ .../tests/integration/test_workflow_e2e.py | 6 +- client-sdk/tests/template_fixtures.py | 21 + client-sdk/tests/test_api/test_capability.py | 12 +- ...flow_launch.py => test_template_launch.py} | 242 ++-- client-sdk/tests/test_models/test_template.py | 18 +- .../test_models/test_template_server_block.py | 8 +- .../test_templates/test_template_catalog.py | 10 +- .../test_workflows/test_launch_contract.py | 20 + client-sdk/tests/workflow_fixtures.py | 39 - docs/en/design/server-api.md | 2 +- docs/en/design/template-system.md | 186 +-- docs/en/guide/authoring-templates.md | 69 +- docs/en/guide/client-sdk.md | 2 +- docs/en/guide/install-python.md | 10 +- docs/en/guide/sdk-usage.md | 2 +- docs/en/guide/template-workflows.md | 50 +- docs/en/reference/template-yaml-spec.md | 81 +- docs/zh/design/server-api.md | 2 +- docs/zh/design/template-system.md | 178 +-- docs/zh/guide/authoring-templates.md | 67 +- docs/zh/guide/client-sdk.md | 2 +- docs/zh/guide/install-python.md | 8 +- docs/zh/guide/sdk-usage.md | 2 +- docs/zh/guide/template-workflows.md | 40 +- docs/zh/reference/template-yaml-spec.md | 77 +- ...d_workflow.py => test_managed_template.py} | 34 +- examples/templates/agents-cli/template.yaml | 50 +- examples/templates/python-hello/template.yaml | 43 +- scripts/check_dists.py | 6 +- .../src/agent_sandbox/server/_version.py | 2 +- 50 files changed, 2238 insertions(+), 1181 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-25-unified-template-runtime.md create mode 100644 client-sdk/src/agent_sandbox/api/template.py delete mode 100644 client-sdk/src/agent_sandbox/api/workflow.py create mode 100644 client-sdk/src/agent_sandbox/workflows/launch-v1.json create mode 100644 client-sdk/src/agent_sandbox/workflows/launch-vectors.json create mode 100644 client-sdk/src/agent_sandbox/workflows/launch.py create mode 100644 client-sdk/tests/template_fixtures.py rename client-sdk/tests/test_api/{test_workflow_launch.py => test_template_launch.py} (56%) create mode 100644 client-sdk/tests/test_workflows/test_launch_contract.py delete mode 100644 client-sdk/tests/workflow_fixtures.py rename e2e/{test_managed_workflow.py => test_managed_template.py} (64%) diff --git a/.agents/README.md b/.agents/README.md index 0743015b..7c59f9a5 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,6 +1,15 @@ # `.agents/` — engineering record of the python-sdk repository -## Current delivery — 2026-10-24 +## Specification 5 migration — 2026-10-25 + +The [unified startup proposal](notes/proposed/architecture/2026-10-25-unified-template-launch.md) +removes workflow selection and preserves all template services. The +[contract foundation](evidence/verify/2026-10-25-unified-template-contract.md) records +local shared-vector checks only. [Runtime checkpoint](evidence/verify/2026-10-25-unified-template-runtime.md) records +real native-template acceptance and retained environment failures; full gates are in progress; +the previous 92-case matrix below remains specification 4 history, not v5 evidence. + +## Previous delivery — 2026-10-24 - Published client/server pair: **0.3.1a5**, with [immutable public artifacts, fresh installation and retained propagation failure](evidence/verify/2026-10-24-python-a5-public-release.md). This is an alpha release; host installation does not update in-image runtimes. - Standard `Sandbox`/`AsyncSandbox` and `code_interpreter` clients retain their migration contract. Asynchronous `ProviderSandbox` owns provider/server extensions; [SDK usage](../docs/en/guide/sdk-usage.md) now keeps process execution, interpreter calls, application HTTP and resource cleanup separate. diff --git a/.agents/evidence/verify/2026-10-25-unified-template-runtime.md b/.agents/evidence/verify/2026-10-25-unified-template-runtime.md new file mode 100644 index 00000000..fa591ee5 --- /dev/null +++ b/.agents/evidence/verify/2026-10-25-unified-template-runtime.md @@ -0,0 +1,56 @@ +# Verification: complete template startup candidate + +- Date: 2026-10-25 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/107 +- Result: PARTIAL +- Package under test: matching editable client/server candidate 0.4.0a1; no PyPI + publication is claimed. +- Environment: Linux, Python 3.13.13; Express credentials supplied only through + environment variables. The CI Python-version matrix remains a remote gate. + +## What was verified + +Specification 5 rejects workflow selection, starts one complete template, preserves +all declared services and resources, and cleans every resolved client on failure. +Provider operations remain independent from application service declarations. + +## Commands and results + +- `python -m pytest client-sdk/tests/test_workflows/test_launch_contract.py -q`: + 17 passed, shared vectors. +- Focused launcher/model/policy/capability/contract tests: 126 passed. +- Launcher and offline catalog regressions: 57 passed, including multiple services, + later-service cleanup failure, preparation boundary and generic protocol exposure. +- `make lint`: PASS, both packages. +- `make typecheck`: PASS, 56 server and 99 client modules. +- `bash scripts/check-repo-health.sh`: PASS=106, WARN=1, FAIL=0. +- `python -m pytest e2e/test_managed_template.py -q -ra`: 4 passed in 18.46 seconds. + Real Express `base` and `code-interpreter-v1`, selected by provider name and built-in + instance, executed shell commands and confirmed deletion of the owned sandbox. + This does not test the interpreter API. This initial run preceded the candidate + version bump and exercised the same new launcher implementation. +- `make test`: server suite 878 passed, 2 failed; it therefore did not start the + client suite. The separate full client run and full `make e2e` are in progress. +- `make format-check build dist-check`: candidate artifacts built and twine checks + passed; combined-install verification is still running at this checkpoint. + +## Environment failures retained + +The same two server tests failed before the launcher changes and on repeat: +`test_upgrade_without_a_key_is_a_400_not_a_hang` received HTTP 501 instead of 400; +`test_transfer_encoding_rejected_in_policy_mode` received 502 instead of 400. +A direct malformed upgrade request to the real loopback server returned plain text +`项目网络审计已关闭`, demonstrating interception outside the server's JSON handler. +The chunked test passed alone; the upgrade test still failed alone. Neither test nor +server implementation was weakened. Remote CI must pass these checks before merge. + +## Cleanup + +Each of the four native scenarios confirmed the sandbox was absent after context +exit. No template/image was built by Python. The full E2E cleanup report is pending. + +## Not verified + +Full remote CI, candidate publication, source-template Python application consumption +and any specification 5 catalog-wide cloud matrix remain pending. Historical receipts +were not rewritten or promoted to new-template verification. diff --git a/AGENTS.md b/AGENTS.md index b17edc12..f5714742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,15 +167,26 @@ All SDK errors live in [`models/errors.py`](client-sdk/src/agent_sandbox/models/ Every exception carries: `code`, `message`, `suggestion`, `docs_url` (populated when documentation is available). -### Workflow and operation model +### Template startup and operation model -Templates use strict specification 4 (`provides`, `workflows`, `default_workflow`). Shared contract 1 is owned in `contracts/` with byte-identical package copies and cross-language vectors. Retired template `capabilities`, `tested_on` and provider claims are rejected. `DEFAULT_CAPABILITIES` is empty: missing metadata makes no runtime claims. Coarse discovery is not authorization. Provider operations are consumer-specific; a missing interpreter operation cannot block an unrelated HTTP application. +Templates use strict specification 5 with the complete `provides` service map, +`start_cmd` and `ready_cmd`. There is no workflow selector or named startup type. +Canonical `contracts/launch-v1.json` and `launch-vectors.json` have byte-identical +package copies. Historical workflow/receipt files remain for old evidence only; +they do not certify the new launcher. Provider operation declarations remain exact. -`api/workflow.py` launches prepared references or explicit services, journals ownership before creation and checks protocol before payload. Python source preparation is not implemented. `api/code.py` has exact stateless/default/context/stream gates and no shell fallback. See [workflow guide](docs/en/guide/template-workflows.md). +`api/template.py` exposes `launch_template` for prepared references, journals owned +resources, starts the template once and checks all SandboxServer services. Python +does not build source. Use `session.services` for all resolved clients and +`session.servers` for SandboxServer clients. Do not block startup on an unrelated +interpreter operation. See [the launch guide](docs/en/guide/template-workflows.md). ### Template Definition -Templates use `template.yaml` (not `manifest.yaml` for capability purposes). Key fields: `name`, `spec_version`, `provides`, `workflows`, `default_workflow` and `server`. The client rejects `custom_commands`; named commands are registered in the sandbox server (`@sandbox.register`) and called with `sandbox.run(name, **kwargs)`. +Use `template.yaml`, `spec_version: 5`, `provides`, startup/readiness, and the existing +server policy. Reject workflow_contract_version/workflows/default_workflow with +migration guidance. Named application commands are registered by the server, never +created from YAML. Template discovery grants no default runtime operations. --- @@ -232,7 +243,7 @@ See [`.agents/notes/README.md`](.agents/notes/README.md) for the full template a **MUST follow:** 1. **Never auto-commit.** Make changes; let the human review and commit. -2. **Respect exact workflow operations.** Fix missing adapter/client support or choose an explicit supported workflow; never add a capability claim or shell fallback to bypass a failed check. +2. **Respect exact Provider operations.** Fix missing adapter/client support; never add a capability claim or shell fallback to bypass a failed check. 3. **Never fabricate FC/envd API endpoints.** If you're unsure about a real backend API, flag it as a TODO rather than inventing a placeholder. 4. **Run the full test suite** (`make test && make lint && make typecheck`) before claiming a task is done. These targets cover both `client-sdk` and `server-sdk`. 5. **Secret scanning** is enforced via `.githooks/check-secrets.sh`. Never commit API keys, tokens, or passwords. Use `REPLACE_ME` or `placeholder` for examples. diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b6ddc1..013a6ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.4.0a1] - 2026-10-25 + +Candidate: not yet published. + +### Changed + +- Specification 5 starts complete templates without workflow selection. Python now + exposes `launch_template`, `TemplateSession` and `TemplateLaunchError`, preserving + all declared services and cleanup ownership. Source preparation remains CLI-owned. +- Migrate specification 4 by removing workflow declarations and using the existing + startup/readiness fields. `launch_workflow` and `workflow_id` are removed; historical + receipts remain unchanged and do not certify the new launch path. + + ### Documentation synchronization - Correct current SDK examples, reconnect semantics, packaged skills and installation guidance; index accepted release/workflow records without rewriting historical verification. / 修正当前 SDK 示例、重连语义、配套技能与安装说明,索引已交付发布和工作流记录,保留历史验证。 diff --git a/README.md b/README.md index debbceef..911b207d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Agent Sandbox -[Template workflows](docs/en/guide/template-workflows.md): explicit services, exact provider operations and independently verified consumer paths. +[Launch templates](docs/en/guide/template-workflows.md): start the complete template and access all declared services. **English** | [中文](README.zh-CN.md) @@ -13,7 +13,7 @@ > Cloud sandboxes for AI agents — create, execute, and manage isolated environments in seconds. **Agent Sandbox** is a Python SDK for sandboxes. The default provider is Alibaba Cloud Express (`alicloud-express`, Function Compute). OpenSandbox, the container edition, CubeSandbox, and E2B are catalog `node` plugins in `awesome-providers` (`provider.mjs`). Install one with `abx provider install alibaba-agent-sandbox/awesome-providers// --allow-unpinned --yes` (or `python -m agent_sandbox.providers install registry: --yes`). After that, both `abx --provider ` and `ProviderSandbox.create(provider="")` use it. The Python SDK reaches a `node` provider through the local engine (`abx provider engine serve`; `abx` on `PATH` or `ABX_BIN`). The CLI loads Express plus installed `node`, `exec` and `rest` plugins. It is the -standard sync/async runtime and Template client. Project provider/server template workflows use +standard sync/async runtime and Template client. Project provider/server template launches use `abx template build|deploy` from the CLI. The SDK uses E2B-shaped method names so that E2B code ports with few changes; the standard API follows Alibaba-supported E2B behavior (see [Migrate from E2B](docs/en/guide/migrate-from-e2b.md)). @@ -120,7 +120,7 @@ This `python3` invocation is a process, not a persistent interpreter. Interprete | Standard `code_interpreter.Sandbox.run_code()` | Execute interpreter code | A matching native runtime such as `code-interpreter-v1` | | `ProviderSandbox.application_client()` | Call application commands registered by a template HTTP service | Start the template service and supply the correct port and application token | -Named application commands are registered by the in-sandbox server. Follow [template workflows](docs/en/guide/template-workflows.md) to create or connect to that application, preserving provider gateway headers, URL prefixes and query parameters; do not assume `base` registers `hello`. See [Providers](docs/en/guide/providers.md) for cross-language plugin execution. +Named application commands are registered by the in-sandbox server. Follow [template startup](docs/en/guide/template-workflows.md) to create or connect to that application, preserving provider gateway headers, URL prefixes and query parameters; do not assume `base` registers `hello`. See [Providers](docs/en/guide/providers.md) for cross-language plugin execution. ## Templates diff --git a/README.zh-CN.md b/README.zh-CN.md index df1e7b11..637344b3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Agent Sandbox -[模板工作流](docs/zh/guide/template-workflows.md):显式服务、Provider 精确操作与独立验证的消费入口。 +[模板启动](docs/zh/guide/template-workflows.md):整体启动模板并访问全部声明服务。 [English](README.md) | **中文** @@ -118,7 +118,7 @@ asyncio.run(main()) | 标准 `code_interpreter.Sandbox.run_code()` | 执行解释器代码 | 使用 `code-interpreter-v1` 等对应原生运行时 | | `ProviderSandbox.application_client()` | 调用模板 HTTP 服务注册的应用命令 | 先启动模板服务,传入正确端口与应用令牌 | -命名应用命令由沙箱内服务端注册。通过 [模板工作流](docs/zh/guide/template-workflows.md) 创建或连接对应应用,保留 Provider 返回的网关请求头、URL 前缀和查询参数;不要假定 `base` 注册了 `hello`。Provider 插件的跨语言执行方式见 [Provider 指南](docs/zh/guide/providers.md)。 +命名应用命令由沙箱内服务端注册。通过 [模板启动](docs/zh/guide/template-workflows.md) 创建或连接对应应用,保留 Provider 返回的网关请求头、URL 前缀和查询参数;不要假定 `base` 注册了 `hello`。Provider 插件的跨语言执行方式见 [Provider 指南](docs/zh/guide/providers.md)。 ## 模板 diff --git a/agent-sandbox-provider/SKILL.md b/agent-sandbox-provider/SKILL.md index 7532b1f7..da0f9879 100644 --- a/agent-sandbox-provider/SKILL.md +++ b/agent-sandbox-provider/SKILL.md @@ -5,18 +5,20 @@ description: Write an Agent Sandbox provider plugin so ProviderSandbox.create(pr # Write an Agent Sandbox provider +Specification 5 and 0.4.0a1 are release candidates until publication; use the matching checkout for validation. + This file is instructions for a coding agent. It is not executed by the SDK. Calling `Sandbox.create` against a backend that already exists is `agent-sandbox-sdk`. This skill only covers the provider you add. The client distribution is `agent-sandbox-client` (import `agent_sandbox`). -Install the verified public alpha pair; the client installs its matching server dependency: +For the specification 5 candidate, install the matching alpha pair after publication; the client installs its matching server dependency: ```bash -python -m pip install 'agent-sandbox-client==0.3.1a5' +python -m pip install 'agent-sandbox-client==0.4.0a1' python -m pip check ``` -The image needs only `agent-sandbox-server==0.3.1a5`. Installing that package on the host does not update an existing image. Development source installations must use a matching client/server revision. +The image needs only `agent-sandbox-server==0.4.0a1`. Installing that package on the host does not update an existing image. Development source installations must use a matching client/server revision. Do not install `agent-sandbox`; that PyPI project is unrelated. Only `alicloud-express` is built in. Do not reinstall it and do not reuse diff --git a/agent-sandbox-sdk/SKILL.md b/agent-sandbox-sdk/SKILL.md index bb584e44..9aaa1425 100644 --- a/agent-sandbox-sdk/SKILL.md +++ b/agent-sandbox-sdk/SKILL.md @@ -5,6 +5,8 @@ description: Write a Python client or a sandbox template for Agent Sandbox. Use # Write an Agent Sandbox client or template +Specification 5 and 0.4.0a1 are release candidates until publication; use the matching checkout for validation. + This file is instructions for a coding agent. It is not executed by the SDK. Operating a sandbox with `abx` or MCP is the `agent-sandbox` skill. This skill only covers the code you write. @@ -16,14 +18,14 @@ Two packages share one version: | `agent-sandbox-client` | `agent_sandbox` | The caller's machine | | `agent-sandbox-server` | `agent_sandbox.server` | Inside the image, port 9000 | -Install the verified public alpha pair; the client installs its matching server dependency: +For the specification 5 candidate, install the matching alpha pair after publication; the client installs its matching server dependency: ```bash -python -m pip install 'agent-sandbox-client==0.3.1a5' +python -m pip install 'agent-sandbox-client==0.4.0a1' python -m pip check ``` -The image needs only `agent-sandbox-server==0.3.1a5`. Installing that package on the host does not update an existing image. Development source installations must use a matching client/server revision. +The image needs only `agent-sandbox-server==0.4.0a1`. Installing that package on the host does not update an existing image. Development source installations must use a matching client/server revision. Do not run `pip install agent-sandbox`. That PyPI name is an unrelated project. ## Credentials @@ -130,39 +132,27 @@ Use strict specification 4. `capabilities` and `tested_on` are retired; installe ```yaml name: my-template -version: "1.0.0" -description: "A hello application" +version: 1.0.0 +description: A hello application generation: 2 -ports: [9000] +ports: +- 9000 env: - ABX_SERVER_TOKEN: "" -spec_version: 4 -workflow_contract_version: 1 + ABX_SERVER_TOKEN: '' +spec_version: 5 provides: app: protocol: agent-sandbox-server version: 2 port: 9000 - operations: [commands.list, commands.run] -default_workflow: application + operations: + - commands.list + - commands.run start_cmd: agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py ready_cmd: agent-sandbox-server healthcheck --port 9000 -workflows: - application: - kind: application - input: source - startup: exec - service: app - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: commands.run - required_operations: [] ``` -Set `ABX_SERVER_TOKEN` in the launch environment before creating this service-token workflow; the empty manifest value declares runtime input, not a usable secret. Keep credentials out of the manifest and image. +Set `ABX_SERVER_TOKEN` in the launch environment before creating this template; the empty manifest value declares runtime input, not a usable secret. Keep credentials out of the manifest and image. `commands.py` — module-level `@command`, then `start()` only under `if __name__ == "__main__"`. A `start()` call at import time breaks the @@ -205,7 +195,7 @@ Do not listen on 49983, 5000, or 49999. `` defaults to `cn-hangzhou`. ```dockerfile FROM fc-e2b-registry..cr.aliyuncs.com/runtime/base: -RUN pip install --no-cache-dir 'agent-sandbox-server==0.3.1a5' +RUN pip install --no-cache-dir 'agent-sandbox-server==0.4.0a1' WORKDIR /home/user/app COPY commands.py /home/user/app/commands.py EXPOSE 9000 @@ -217,7 +207,7 @@ The CLI can prepare the supported official native base recipe for both Express a ```dockerfile FROM python:3.12-slim -RUN pip install --no-cache-dir 'agent-sandbox-server==0.3.1a5' +RUN pip install --no-cache-dir 'agent-sandbox-server==0.4.0a1' WORKDIR /home/user/app COPY commands.py /home/user/app/commands.py EXPOSE 9000 diff --git a/client-sdk/README.md b/client-sdk/README.md index 3bbc7354..62773a06 100644 --- a/client-sdk/README.md +++ b/client-sdk/README.md @@ -1,6 +1,6 @@ # agent-sandbox-client -Provider extensions use [explicit template workflows](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/en/guide/template-workflows.md) ([中文](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/zh/guide/template-workflows.md)); application HTTP, stateless execution and context operations are independent. +Provider extensions use [whole-template startup](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/en/guide/template-workflows.md) ([中文](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/zh/guide/template-workflows.md)); application HTTP, stateless execution and context operations are independent. Install the current alpha with `--pre`; use the [installation guide](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/en/guide/install-python.md) for a pinned version and environment setup. diff --git a/client-sdk/pyproject.toml b/client-sdk/pyproject.toml index 86f6c385..fb47d5b9 100644 --- a/client-sdk/pyproject.toml +++ b/client-sdk/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "tomli>=2.0; python_version < '3.11'", # The client uses the matching local server runtime for its server/declarative helpers. # Keep the required distribution version equal to ``_version.py``. - "agent-sandbox-server==0.3.1a5", + "agent-sandbox-server==0.4.0a1", ] [project.optional-dependencies] diff --git a/client-sdk/src/agent_sandbox/_version.py b/client-sdk/src/agent_sandbox/_version.py index ea816294..dfe2f13a 100644 --- a/client-sdk/src/agent_sandbox/_version.py +++ b/client-sdk/src/agent_sandbox/_version.py @@ -1 +1 @@ -__version__ = "0.3.1a5" +__version__ = "0.4.0a1" diff --git a/client-sdk/src/agent_sandbox/api/__init__.py b/client-sdk/src/agent_sandbox/api/__init__.py index c4c3368c..5b403394 100644 --- a/client-sdk/src/agent_sandbox/api/__init__.py +++ b/client-sdk/src/agent_sandbox/api/__init__.py @@ -14,13 +14,13 @@ from agent_sandbox.api.sandbox import Sandbox from agent_sandbox.api.server_capabilities import ServerCapabilities from agent_sandbox.api.server_client import ServerClient -from agent_sandbox.api.workflow import WorkflowError, WorkflowSession, launch_workflow +from agent_sandbox.api.template import TemplateLaunchError, TemplateSession, launch_template __all__ = [ "Sandbox", - "WorkflowError", - "WorkflowSession", - "launch_workflow", + "TemplateLaunchError", + "TemplateSession", + "launch_template", "ServerClient", "ServerCapabilities", "CommandsModule", diff --git a/client-sdk/src/agent_sandbox/api/capability.py b/client-sdk/src/agent_sandbox/api/capability.py index 83c2335f..ecb785f2 100644 --- a/client-sdk/src/agent_sandbox/api/capability.py +++ b/client-sdk/src/agent_sandbox/api/capability.py @@ -1,4 +1,4 @@ -"""Strict local workflow discovery. Coarse tokens never authorize provider operations.""" +"""Strict local template discovery. Coarse tokens never authorize provider operations.""" from __future__ import annotations @@ -14,14 +14,14 @@ class ResolvedCapabilities(BaseModel): - """Discovery derived from an explicitly validated workflow, empty when unknown.""" + """Discovery derived from an explicitly validated template, empty when unknown.""" capabilities: set[str] = Field(default_factory=set) matched: bool = False def parse_template_data(data: dict[str, Any]) -> SandboxTemplate: - """Validate specification 4, preserving unrelated application topology metadata.""" + """Validate specification 5, preserving unrelated application topology metadata.""" normalized = dict(data) resources = normalized.get("resources", {}) or {} if "application" not in normalized and "cpu" in resources and "cpu_count" not in normalized: diff --git a/client-sdk/src/agent_sandbox/api/template.py b/client-sdk/src/agent_sandbox/api/template.py new file mode 100644 index 00000000..8233d7e5 --- /dev/null +++ b/client-sdk/src/agent_sandbox/api/template.py @@ -0,0 +1,325 @@ +"""Launch an entire prepared template and retain all of its service clients.""" + +from __future__ import annotations + +import asyncio +import copy +import json +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from agent_sandbox.api.sandbox import Sandbox +from agent_sandbox.models.process import ProcessResult +from agent_sandbox.transport.streaming import StreamReader +from agent_sandbox.workflows.identity import bytes_digest +from agent_sandbox.workflows.launch import plan_template_launch, validate_template_manifest + +if TYPE_CHECKING: + from types import TracebackType + + from agent_sandbox.api.server_client import ServerClient + from agent_sandbox.providers.base import Provider + + +class TemplateLaunchError(RuntimeError): + """A phase-specific failure; durable journals retain uncertain or residual resources.""" + + def __init__(self, reason: str, phase: str) -> None: + self.reason, self.phase = reason, phase + super().__init__(f"Template {phase}: {reason}") + + +class _Journal: + def __init__(self, path: Path) -> None: + self.path = path + path.parent.mkdir(parents=True, exist_ok=True) + + def append(self, event: str, **values: Any) -> None: + payload = (json.dumps({"event": event, **values}, sort_keys=True) + "\n").encode() + descriptor = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + try: + with os.fdopen(descriptor, "ab") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + raise + + +class TemplateSession: + """One complete template and the sandbox exclusively owned by this session.""" + + def __init__( + self, + plan: dict[str, Any], + sandbox: Sandbox | None, + services: dict[str, ServerClient], + journal: _Journal, + ) -> None: + self.plan, self.sandbox, self.services = plan, sandbox, services + self.servers = { + name: client + for name, client in services.items() + if plan["services"][name]["protocol"] == "agent-sandbox-server" + } + self.server = next(iter(self.servers.values())) if len(self.servers) == 1 else None + self.journal_path = journal.path + self._journal = journal + self._closed = False + self.creation_uncertain = False + + async def run_command(self, name: str, /, **arguments: Any) -> Any: + """Call the sole SandboxServer; use ``servers[id]`` when there are several.""" + if self._closed or self.server is None: + raise TemplateLaunchError("select-a-server-from-session.servers", "before-invoke") + result = await self.server.commands.run(name, **arguments) + self._journal.append("invoked", operation="commands.run") + return result + + async def close(self) -> None: + """Attempt every owned cleanup even if client close fails or cancellation arrives.""" + if self._closed: + return + + async def cleanup() -> None: + failures = [] + journal_failures = [] + + def record(event: str, **values: Any) -> None: + try: + self._journal.append(event, **values) + except BaseException as exc: + journal_failures.append(type(exc).__name__) + + if self.sandbox is not None: + record( + "cleanup-intent", provider_id=self.sandbox.provider, sandbox_id=self.sandbox.id + ) + for client in self.services.values(): + try: + await asyncio.wait_for(client.aclose(), 10) + except BaseException as exc: + failures.append(type(exc).__name__) + record("application-client-close-failed") + if self.sandbox is not None: + try: + await self.sandbox.kill() + except BaseException as exc: + failures.append(type(exc).__name__) + record( + "cleanup-failed", + provider_id=self.sandbox.provider, + sandbox_id=self.sandbox.id, + ) + else: + record("deleted", provider_id=self.sandbox.provider, sandbox_id=self.sandbox.id) + if journal_failures: + raise TemplateLaunchError( + "journal-write-failed; owned cleanup attempted", "cleanup" + ) + if failures: + raise TemplateLaunchError( + "cleanup-failed; inspect owned resource journal", "cleanup" + ) + if self.creation_uncertain: + record("reconciliation-required") + return + record("cleanup-complete") + if journal_failures: + raise TemplateLaunchError( + "journal-write-failed; owned cleanup attempted", "cleanup" + ) + self._closed = True + + task = asyncio.create_task(cleanup()) + try: + await asyncio.shield(task) + except asyncio.CancelledError: + await task + raise + + async def __aenter__(self) -> TemplateSession: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.close() + + +async def _dispatch_start(sandbox: Sandbox, command: str, timeout: float) -> None: + """Start the template once without binding startup to a particular service.""" + stream = await sandbox.commands._start_for_startup(command, timeout=86400) + if not isinstance(stream, StreamReader): + raise TemplateLaunchError("process-stream-missing", "before-start") + started: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + async def drain() -> None: + try: + async for chunk in stream: + if chunk.pid and not started.done(): + started.set_result(True) + finally: + if not started.done(): + started.set_result(False) + await stream.cancel() + + task = asyncio.create_task(drain()) + sandbox._server_stream_tasks.add(task) + task.add_done_callback(sandbox._server_stream_tasks.discard) + try: + if not await asyncio.wait_for(started, timeout): + raise TemplateLaunchError("startup-not-acknowledged", "before-start") + except BaseException: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + raise + + +async def launch_template( + manifest: dict[str, Any] | str | Path, + *, + provider: str | Provider, + reference: str | None = None, + application_token: str | None = None, + provider_options: dict[str, Any] | None = None, + envs: dict[str, str] | None = None, + timeout: int = 300, + readiness_timeout: float = 30, + journal_path: str | Path | None = None, +) -> TemplateSession: + """Start the complete template using a previously prepared provider reference. + + Python never builds source projects. Prepare sources with the CLI and pass + the resulting reference, or declare an image/native template in the manifest. + All services are returned. SandboxServer services are checked with health and + discovery; other protocols use the template's ready_cmd for readiness. + """ + from agent_sandbox.providers.registry import builtin_provider, get_provider + + if isinstance(manifest, (str, Path)): + import yaml + + manifest_bytes = Path(manifest).read_bytes() + snapshot = yaml.safe_load(manifest_bytes) + else: + snapshot = copy.deepcopy(manifest) + manifest_bytes = json.dumps(snapshot, sort_keys=True).encode() + snapshot = validate_template_manifest(snapshot) + if snapshot.get("application"): + raise TemplateLaunchError("deployment-orchestrator-required", "before-create") + resolved = reference or snapshot.get("image") or snapshot.get("template_id") + if not isinstance(resolved, str) or not resolved.strip(): + raise TemplateLaunchError("prepare-source-with-cli-and-pass-reference", "before-prepare") + if snapshot.get("image") and not re.fullmatch( + r"(?:[A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[a-f0-9]{64}", resolved + ): + raise TemplateLaunchError("immutable-image-reference-required", "before-create") + if readiness_timeout <= 0: + raise ValueError("readiness_timeout must be positive") + effective_env = { + key: value or os.environ.get(key, "") for key, value in snapshot.get("env", {}).items() + } + effective_env.update(envs or {}) + if application_token is not None: + effective_env["ABX_SERVER_TOKEN"] = application_token + token = effective_env.get("ABX_SERVER_TOKEN") or None + if "ABX_SERVER_TOKEN" in effective_env and not token: + raise TemplateLaunchError("application-token-required", "before-create") + prov = get_provider(provider) if isinstance(provider, str) else provider + plan = plan_template_launch(snapshot, prov.describe(), "python-provider", prepared=True) + if not plan["supported"]: + raise TemplateLaunchError( + plan.get("reason") or ("missing-operations: " + ", ".join(plan["missing_operations"])), + "before-create", + ) + run_id = uuid4().hex + journal = _Journal( + Path(journal_path) if journal_path else Path.home() / ".abx" / "runs" / f"{run_id}.jsonl" + ) + provider_digest = prov.content_digest() + journal.append( + "planned", + run_id=run_id, + provider_id=prov.id, + manifest_digest=bytes_digest(manifest_bytes), + provider_digest=provider_digest, + contract_version=1, + contract_digest=bytes_digest( + Path(__file__).parents[1].joinpath("workflows/launch-v1.json").read_bytes() + ), + ) + creation_uncertain = False + sandbox: Sandbox | None = None + clients: dict[str, ServerClient] = {} + session: TemplateSession | None = None + try: + if prov.content_digest() != provider_digest: + raise TemplateLaunchError("provider-content-changed", "before-create") + journal.append("create-intent", run_id=run_id, provider_id=prov.id) + try: + sandbox = await Sandbox.create( + template=resolved, + provider=prov.id if prov is builtin_provider() else provider, + provider_options=provider_options, + envs=effective_env, + cpu=snapshot.get("cpu_count", snapshot.get("resources", {}).get("cpu")), + memory=snapshot.get("memory_mb", snapshot.get("resources", {}).get("memory")), + timeout=timeout, + metadata={"abx_template_run": run_id}, + on_created=lambda sandbox_id: journal.append( + "created", sandbox_id=sandbox_id, provider_id=prov.id + ), + ) + except BaseException as exc: + resource = getattr(exc, "owned_resource", None) + cleaned = getattr(exc, "resource_cleanup_complete", False) + if resource and cleaned: + journal.append("deleted", **resource) + creation_uncertain = not cleaned + journal.append( + "create-uncertain" if creation_uncertain else "create-failed-cleaned", + run_id=run_id, + provider_id=prov.id, + ) + raise + journal.append("created", sandbox_id=sandbox.id, provider_id=prov.id) + if prov.content_digest() != provider_digest: + raise TemplateLaunchError("provider-content-changed", "before-start") + if snapshot.get("start_cmd"): + await _dispatch_start(sandbox, snapshot["start_cmd"], readiness_timeout) + journal.append("started", sandbox_id=sandbox.id) + if snapshot.get("ready_cmd"): + result = await sandbox.commands.run( + snapshot["ready_cmd"], timeout=int(readiness_timeout) + ) + if not isinstance(result, ProcessResult) or result.exit_code != 0: + raise TemplateLaunchError("readiness-command-failed", "before-invoke") + for name, service in plan["services"].items(): + client = await sandbox.application_client(service["port"], token=token) + clients[name] = client + if service["protocol"] != "agent-sandbox-server": + continue + deadline = asyncio.get_running_loop().time() + readiness_timeout + while not await client.health(): + if asyncio.get_running_loop().time() >= deadline: + raise TemplateLaunchError("application-not-ready: " + name, "before-invoke") + await asyncio.sleep(0.2) + discovery = await client.discover() + if discovery.api_version != service["version"]: + raise TemplateLaunchError("protocol-mismatch: " + name, "before-invoke") + journal.append("protocol-checked", service=name, api_version=discovery.api_version) + session = TemplateSession(plan, sandbox, clients, journal) + return session + except BaseException: + if session is None: + session = TemplateSession(plan, sandbox, clients, journal) + session.creation_uncertain = creation_uncertain + await session.close() + raise diff --git a/client-sdk/src/agent_sandbox/api/workflow.py b/client-sdk/src/agent_sandbox/api/workflow.py deleted file mode 100644 index 0134d565..00000000 --- a/client-sdk/src/agent_sandbox/api/workflow.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Explicit Python workflow launch with durable ownership and application preflight.""" - -from __future__ import annotations - -import asyncio -import copy -import json -import os -import re -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast -from uuid import uuid4 - -from agent_sandbox.api.sandbox import Sandbox -from agent_sandbox.api.server_client import ServerClient -from agent_sandbox.models.process import ProcessResult -from agent_sandbox.transport.streaming import StreamReader -from agent_sandbox.workflows import plan_workflow -from agent_sandbox.workflows.identity import binding_digest, bytes_digest - -if TYPE_CHECKING: - from types import TracebackType - - from agent_sandbox.providers.base import Provider - - -class WorkflowError(RuntimeError): - """A phase-specific failure; durable journals retain uncertain or residual resources.""" - - def __init__(self, reason: str, phase: str) -> None: - self.reason, self.phase = reason, phase - super().__init__(f"Workflow {phase}: {reason}") - - -class _Journal: - def __init__(self, path: Path) -> None: - self.path = path - path.parent.mkdir(parents=True, exist_ok=True) - - def append(self, event: str, **values: Any) -> None: - payload = (json.dumps({"event": event, **values}, sort_keys=True) + "\n").encode() - descriptor = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) - try: - with os.fdopen(descriptor, "ab") as stream: - stream.write(payload) - stream.flush() - os.fsync(stream.fileno()) - except BaseException: - raise - - -class WorkflowSession: - """One checked workflow and its exclusively owned sandbox, if any.""" - - def __init__( - self, - plan: dict[str, Any], - sandbox: Sandbox | None, - server: ServerClient | None, - journal: _Journal, - ) -> None: - self.plan, self.sandbox, self.server = plan, sandbox, server - self.journal_path = journal.path - self._journal = journal - self._closed = False - self.creation_uncertain = False - - async def run_command(self, name: str, /, **arguments: Any) -> Any: - """Invoke the selected named-command workflow after protocol checks passed.""" - if self._closed or self.server is None or self.plan["kind"] != "application": - raise WorkflowError("application-client-unavailable", "before-invoke") - if self.plan["service"]["protocol"] != "agent-sandbox-server": - raise WorkflowError("protocol-mismatch", "before-invoke") - if self.plan["operation"] != "commands.run": - raise WorkflowError("operation-mismatch", "before-invoke") - result = await self.server.commands.run(name, **arguments) - self._journal.append("invoked", operation="commands.run") - return result - - async def close(self) -> None: - """Attempt every owned cleanup even if client close fails or cancellation arrives.""" - if self._closed: - return - - async def cleanup() -> None: - failures = [] - journal_failures = [] - - def record(event: str, **values: Any) -> None: - try: - self._journal.append(event, **values) - except BaseException as exc: - journal_failures.append(type(exc).__name__) - - if self.sandbox is not None: - record( - "cleanup-intent", provider_id=self.sandbox.provider, sandbox_id=self.sandbox.id - ) - if self.server is not None: - try: - await asyncio.wait_for(self.server.aclose(), 10) - except BaseException as exc: - failures.append(type(exc).__name__) - record("application-client-close-failed") - if self.sandbox is not None: - try: - await self.sandbox.kill() - except BaseException as exc: - failures.append(type(exc).__name__) - record( - "cleanup-failed", - provider_id=self.sandbox.provider, - sandbox_id=self.sandbox.id, - ) - else: - record("deleted", provider_id=self.sandbox.provider, sandbox_id=self.sandbox.id) - if journal_failures: - raise WorkflowError("journal-write-failed; owned cleanup attempted", "cleanup") - if failures: - raise WorkflowError("cleanup-failed; inspect owned resource journal", "cleanup") - if self.creation_uncertain: - record("reconciliation-required") - return - record("cleanup-complete") - if journal_failures: - raise WorkflowError("journal-write-failed; owned cleanup attempted", "cleanup") - self._closed = True - - task = asyncio.create_task(cleanup()) - try: - await asyncio.shield(task) - except asyncio.CancelledError: - await task - raise - - async def __aenter__(self) -> WorkflowSession: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - await self.close() - - -async def _dispatch_start(sandbox: Sandbox, command: str, timeout: float) -> None: - """Acknowledge an exec workflow without requiring any application HTTP port.""" - stream = await sandbox.commands._start_for_startup(command, timeout=86400) - if not isinstance(stream, StreamReader): - raise WorkflowError("process-stream-missing", "before-start") - started: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - - async def drain() -> None: - try: - async for chunk in stream: - if chunk.pid and not started.done(): - started.set_result(True) - finally: - if not started.done(): - started.set_result(False) - await stream.cancel() - - task = asyncio.create_task(drain()) - sandbox._server_stream_tasks.add(task) - task.add_done_callback(sandbox._server_stream_tasks.discard) - try: - if not await asyncio.wait_for(started, timeout): - raise WorkflowError("startup-not-acknowledged", "before-start") - except BaseException: - task.cancel() - await asyncio.gather(task, return_exceptions=True) - raise - - -async def launch_workflow( - manifest: dict[str, Any] | str | Path, - *, - provider: str | Provider, - workflow_id: str | None = None, - reference: str | None = None, - application_url: str | None = None, - application_token: str | None = None, - provider_options: dict[str, Any] | None = None, - envs: dict[str, str] | None = None, - timeout: int = 300, - readiness_timeout: float = 30, - journal_path: str | Path | None = None, -) -> WorkflowSession: - """Launch a prepared image/reference or connect an explicit external service. - - Source preparation is a separate CLI operation; Python never starts Node to - build sources for a native provider. A workflow with missing operations is - rejected before creating resources. This standalone launcher does not execute - application deployment hooks or silently reinterpret deployment topology. - """ - from agent_sandbox.providers.registry import builtin_provider, get_provider - - if isinstance(manifest, (str, Path)): - import yaml - - manifest_bytes = Path(manifest).read_bytes() - snapshot = yaml.safe_load(manifest_bytes) - else: - snapshot = copy.deepcopy(manifest) - manifest_bytes = json.dumps(snapshot, sort_keys=True).encode() - - selected = workflow_id or snapshot.get("default_workflow") - prov = get_provider(provider) if isinstance(provider, str) else provider - plan = plan_workflow(snapshot, prov.describe(), selected, "python-provider") - plan = { - **plan, - "startup": snapshot["workflows"][selected]["startup"], - "operation": snapshot["workflows"][selected]["operation"], - } - if plan["state"] != "T": - raise WorkflowError("requirements-unmet", "before-create") - if snapshot.get("application"): - raise WorkflowError("deployment-orchestrator-required", "before-create") - if plan["input"] == "source": - raise WorkflowError("source-preparation-unimplemented", "before-prepare") - if plan["input"] == "service": - if not application_url or plan["binding"]["endpoint"] != "explicit-url": - raise WorkflowError("explicit-application-url-required", "before-invoke") - elif not isinstance(reference, str) or not reference.strip(): - raise WorkflowError("prepared-reference-required", "before-create") - artifact_digest = "unknown" - if plan["input"] == "image": - if not re.fullmatch( - r"(?:[A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[a-f0-9]{64}", reference or "" - ): - raise WorkflowError("immutable-image-reference-required", "before-create") - artifact_digest = "sha256:" + (reference or "").rsplit("sha256:", 1)[1] - # A native template ID is a valid create input, even when the platform does - # not expose an immutable build identity. Keep its artifact digest unknown; - # evidence reuse must not become a prerequisite for executing a workflow. - if plan["binding"]["authentication"] == "service-token" and not application_token: - raise WorkflowError("application-token-required", "before-create") - if plan["input"] == "service" and plan["kind"] != "application": - raise WorkflowError("external-service-requires-application-client", "before-invoke") - if readiness_timeout <= 0: - raise ValueError("readiness_timeout must be positive") - run_id = uuid4().hex - journal = _Journal( - Path(journal_path) if journal_path else Path.home() / ".abx" / "runs" / f"{run_id}.jsonl" - ) - provider_digest = prov.content_digest() - journal.append( - "planned", - run_id=run_id, - provider_id=prov.id, - workflow_id=selected, - manifest_digest=bytes_digest(manifest_bytes), - provider_digest=provider_digest, - contract_version=1, - contract_digest=bytes_digest( - Path(__file__).parents[1].joinpath("workflows/workflow-v1.json").read_bytes() - ), - binding_digest=binding_digest(plan["binding"]), - artifact_digest=artifact_digest, - build_digest="unknown", - template_source_digest="unknown", - reference=reference if plan["input"] == "image" else None, - ) - creation_uncertain = False - sandbox: Sandbox | None = None - client: ServerClient | None = None - session: WorkflowSession | None = None - try: - if prov.content_digest() != provider_digest: - raise WorkflowError("provider-content-changed", "before-create") - if plan["input"] != "service": - journal.append("create-intent", run_id=run_id, provider_id=prov.id) - try: - sandbox = await Sandbox.create( - template=cast("str", reference), - provider=prov.id if prov is builtin_provider() else provider, - provider_options=provider_options, - envs={ - **(envs or {}), - **({"ABX_SERVER_TOKEN": application_token} if application_token else {}), - }, - timeout=timeout, - metadata={"abx_workflow_run": run_id}, - on_created=lambda sandbox_id: journal.append( - "created", sandbox_id=sandbox_id, provider_id=prov.id - ), - ) - except BaseException as exc: - resource = getattr(exc, "owned_resource", None) - cleaned = getattr(exc, "resource_cleanup_complete", False) - if resource and cleaned: - journal.append("deleted", **resource) - creation_uncertain = not cleaned - journal.append( - "create-uncertain" if creation_uncertain else "create-failed-cleaned", - run_id=run_id, - provider_id=prov.id, - ) - raise - journal.append("created", sandbox_id=sandbox.id, provider_id=prov.id) - if prov.content_digest() != provider_digest: - raise WorkflowError("provider-content-changed", "before-start") - if plan["startup"] == "exec": - if plan["kind"] == "application": - client = await sandbox.start_server( - command=snapshot["start_cmd"], - token=application_token, - port=plan["service"]["port"], - wait=False, - timeout=readiness_timeout, - ) - else: - await _dispatch_start(sandbox, snapshot["start_cmd"], readiness_timeout) - journal.append("started", sandbox_id=sandbox.id) - if snapshot.get("ready_cmd"): - result = await sandbox.commands.run( - snapshot["ready_cmd"], timeout=int(readiness_timeout) - ) - if not isinstance(result, ProcessResult) or result.exit_code != 0: - raise WorkflowError("readiness-command-failed", "before-invoke") - if plan["kind"] == "application" and client is None: - client = await sandbox.application_client( - plan["service"]["port"], token=application_token - ) - else: - client = ServerClient(cast("str", application_url), token=application_token) - session = WorkflowSession(plan, sandbox, client, journal) - if plan["kind"] == "application": - if client is None: - raise WorkflowError("application-client-unavailable", "before-invoke") - deadline = asyncio.get_running_loop().time() + readiness_timeout - while not await client.health(): - if asyncio.get_running_loop().time() >= deadline: - raise WorkflowError("application-not-ready", "before-invoke") - await asyncio.sleep(0.2) - discovery = await client.discover() - if discovery.api_version != plan["service"]["version"]: - raise WorkflowError("protocol-mismatch", "before-invoke") - if snapshot["workflows"][selected]["operation"] == "commands.run": - discovery.require("run_command") - journal.append("protocol-checked", api_version=discovery.api_version) - return session - except BaseException: - if session is None: - session = WorkflowSession(plan, sandbox, client, journal) - session.creation_uncertain = creation_uncertain - await session.close() - raise diff --git a/client-sdk/src/agent_sandbox/models/template.py b/client-sdk/src/agent_sandbox/models/template.py index 7576eedd..383992c3 100644 --- a/client-sdk/src/agent_sandbox/models/template.py +++ b/client-sdk/src/agent_sandbox/models/template.py @@ -1,4 +1,4 @@ -"""Strict workflow template, server policy and source reference models.""" +"""Whole-template launch, server policy and source reference models.""" from __future__ import annotations @@ -22,7 +22,7 @@ """All recognised capability tokens. Extensible in future phases.""" DEFAULT_CAPABILITIES: frozenset[str] = frozenset() -"""No runtime operation is inferred when no workflow or provider is known.""" +"""No runtime operation is inferred when no template or provider is known.""" SERVER_POLICY_TOP_KEYS: frozenset[str] = frozenset( @@ -162,10 +162,7 @@ class SandboxTemplate(BaseModel): tags: list[str] = Field(default_factory=list) spec_version: int - workflow_contract_version: int provides: dict[str, Any] - default_workflow: str - workflows: dict[str, Any] start_cmd: str | None = None ready_cmd: str | None = None @@ -178,7 +175,7 @@ class SandboxTemplate(BaseModel): @model_validator(mode="before") @classmethod def _reject_removed_keys(cls, data: Any) -> Any: - from agent_sandbox.workflows import validate_workflow_manifest + from agent_sandbox.workflows.launch import validate_template_manifest if isinstance(data, dict) and any( k in data @@ -191,9 +188,9 @@ def _reject_removed_keys(cls, data: Any) -> Any: ) ): raise ValueError( - "retired template fields are not accepted; use Dockerfile and workflows" + "retired template fields are not accepted; use Dockerfile and template startup" ) - validate_workflow_manifest(data) + validate_template_manifest(data) return data @field_validator("server") @@ -202,20 +199,8 @@ def _validate_server(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: return None if v is None else validate_server_block(v) def discovery_capabilities(self) -> set[str]: - """Coarse discovery only, derived from workflows rather than permission claims.""" - result: set[str] = set() - for flow in self.workflows.values(): - operation = flow["operation"] - if operation.startswith("code."): - result.add("code") - elif operation.startswith("exec."): - result.add("shell") - for op in flow["required_operations"]: - if op.startswith("files."): - result.add("files") - if flow["binding"]["endpoint"] == "provider-port": - result.add("ports") - return result + """Describe exposed ports without claiming platform execution capabilities.""" + return {"ports"} if self.provides else set() class TemplateRef(BaseModel): diff --git a/client-sdk/src/agent_sandbox/workflows/__init__.py b/client-sdk/src/agent_sandbox/workflows/__init__.py index 960f0ef9..6c9d00b8 100644 --- a/client-sdk/src/agent_sandbox/workflows/__init__.py +++ b/client-sdk/src/agent_sandbox/workflows/__init__.py @@ -2,4 +2,11 @@ from .planner import plan_workflow, validate_workflow_manifest -__all__ = ["plan_workflow", "validate_workflow_manifest"] +__all__ = [ + "plan_workflow", + "validate_workflow_manifest", + "plan_template_launch", + "validate_template_manifest", +] + +from .launch import plan_template_launch, validate_template_manifest diff --git a/client-sdk/src/agent_sandbox/workflows/launch-v1.json b/client-sdk/src/agent_sandbox/workflows/launch-v1.json new file mode 100644 index 00000000..b7cbc579 --- /dev/null +++ b/client-sdk/src/agent_sandbox/workflows/launch-v1.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "template_spec_version": 5, + "max_safe_integer": 9007199254740991, + "template_keys": [ + "spec_version", + "provides" + ], + "retired_keys": [ + "workflow_contract_version", + "default_workflow", + "workflows", + "capabilities", + "tested_on", + "providers" + ], + "service_keys": [ + "protocol", + "version", + "port", + "operations" + ], + "consumers": { + "cli": "cli", + "python-provider": "python" + }, + "required_operations": { + "create": [ + "sandbox.create", + "sandbox.delete" + ], + "prepare": [ + "template.prepare" + ], + "start": [ + "exec.run" + ], + "services": [ + "ports.resolve" + ] + }, + "description": "Start the complete template once. Services describe its endpoints, not selectable execution flows.", + "unsupported_reasons": [ + "provider-mismatch", + "input-unimplemented", + "startup-unimplemented" + ] +} diff --git a/client-sdk/src/agent_sandbox/workflows/launch-vectors.json b/client-sdk/src/agent_sandbox/workflows/launch-vectors.json new file mode 100644 index 00000000..3c3e513a --- /dev/null +++ b/client-sdk/src/agent_sandbox/workflows/launch-vectors.json @@ -0,0 +1,1082 @@ +{ + "cases": [ + { + "id": "source-cli", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "source-python-unprepared", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "python-provider", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [ + "template.prepare" + ], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "source-python-prepared", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "python-provider", + "prepared": true + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "multiple-services", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + }, + "api": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9001, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + }, + "api": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9001, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "no-services", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": {}, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": {} + } + }, + { + "id": "managed-no-start", + "input": { + "manifest": { + "spec_version": 5, + "provides": {}, + "provider": "fixture", + "template_id": "base" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": {} + } + }, + { + "id": "managed-foreign-provider", + "input": { + "manifest": { + "spec_version": 5, + "provides": {}, + "provider": "other", + "template_id": "base" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": {}, + "reason": "provider-mismatch" + } + }, + { + "id": "image-entrypoint", + "input": { + "manifest": { + "spec_version": 5, + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "image": "registry/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "ports.resolve", + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "ready-command", + "input": { + "manifest": { + "spec_version": 5, + "provides": {}, + "image": "image", + "ready_cmd": "test -f /tmp/ready" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": true, + "required_operations": [ + "exec.run", + "sandbox.create", + "sandbox.delete" + ], + "missing_operations": [], + "services": {} + } + }, + { + "id": "unsupported-input", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "reason": "input-unimplemented" + } + }, + { + "id": "unsupported-startup", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "entrypoint" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "reason": "startup-unimplemented" + } + }, + { + "id": "missing-port", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run" + ], + "python": [] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "expected": { + "supported": false, + "required_operations": [ + "exec.run", + "ports.resolve", + "sandbox.create", + "sandbox.delete", + "template.prepare" + ], + "missing_operations": [ + "ports.resolve" + ], + "services": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + } + } + }, + { + "id": "retired-v4", + "input": { + "manifest": { + "spec_version": 4, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "retired-workflows", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py", + "workflows": {} + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "unknown-service-field", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ], + "binding": {} + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "bad-provider-binding", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py", + "provider": "fixture" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + }, + { + "id": "bad-port-bool", + "input": { + "manifest": { + "spec_version": 5, + "name": "test", + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": true, + "operations": [ + "commands.run", + "health" + ] + } + }, + "start_cmd": "python app.py" + }, + "provider": { + "id": "fixture", + "operation_contract_version": 1, + "operations": { + "cli": [ + "sandbox.create", + "sandbox.delete", + "template.prepare", + "exec.run", + "ports.resolve" + ], + "python": [ + "sandbox.create", + "sandbox.delete", + "exec.run", + "ports.resolve" + ] + }, + "workflow_inputs": [ + "source", + "image", + "managed" + ], + "startup_modes": [ + "exec", + "entrypoint", + "none" + ] + }, + "consumer": "cli", + "prepared": false + }, + "error": true + } + ] +} diff --git a/client-sdk/src/agent_sandbox/workflows/launch.py b/client-sdk/src/agent_sandbox/workflows/launch.py new file mode 100644 index 00000000..9af997b6 --- /dev/null +++ b/client-sdk/src/agent_sandbox/workflows/launch.py @@ -0,0 +1,103 @@ +"""Validate and plan one complete template without selecting a workflow.""" + +from __future__ import annotations + +import copy +import json +import re +from pathlib import Path +from typing import Any + +LAUNCH_CONTRACT: dict[str, Any] = json.loads( + Path(__file__).with_name("launch-v1.json").read_text(encoding="utf-8") +) +_ID = re.compile(r"^[a-z][a-z0-9_-]*$") + + +def validate_template_manifest(manifest: dict[str, Any]) -> dict[str, Any]: + """Validate current launch metadata without inventing application defaults.""" + if not isinstance(manifest, dict): + raise ValueError("template: expected an object") + if manifest.get("spec_version") != 5 or type(manifest.get("spec_version")) is not int: + raise ValueError("template.spec_version: expected 5; migrate retired workflow declarations") + if any(key in manifest for key in LAUNCH_CONTRACT["retired_keys"]): + raise ValueError("template: remove retired workflow and capability declarations") + services = manifest.get("provides") + if not isinstance(services, dict): + raise ValueError("template.provides: expected an object") + for name, service in services.items(): + if not isinstance(name, str) or not _ID.fullmatch(name): + raise ValueError("provides: invalid service ID") + if not isinstance(service, dict) or set(service) != set(LAUNCH_CONTRACT["service_keys"]): + raise ValueError("service: unknown or missing fields") + if not isinstance(service["protocol"], str) or not service["protocol"]: + raise ValueError("service.protocol: expected a nonempty string") + for field, maximum in [("version", LAUNCH_CONTRACT["max_safe_integer"]), ("port", 65535)]: + value = service[field] + if type(value) is not int or not 1 <= value <= maximum: + raise ValueError(f"service.{field}: expected a bounded integer") + ops = service["operations"] + if not isinstance(ops, list) or any(not isinstance(op, str) or not op for op in ops): + raise ValueError("service.operations: expected nonempty string entries") + if len(ops) != len(set(ops)): + raise ValueError("service.operations: duplicate entries") + for key in ("start_cmd", "ready_cmd", "image", "provider", "template_id"): + if key in manifest and (not isinstance(manifest[key], str) or not manifest[key].strip()): + raise ValueError(f"template.{key}: expected a nonempty string") + if ("provider" in manifest) != ("template_id" in manifest): + raise ValueError("template: provider and template_id must be declared together") + if "image" in manifest and "template_id" in manifest: + raise ValueError("template: image and template_id are mutually exclusive") + return copy.deepcopy(manifest) + + +def plan_template_launch( + manifest: dict[str, Any], + provider: dict[str, Any], + consumer: str, + prepared: bool = False, +) -> dict[str, Any]: + """Check startup prerequisites; this does not certify runtime readiness. + + ``prepared`` means the caller already has the provider's prepared reference. + Python does not gain source building through this flag. + """ + body = validate_template_manifest(manifest) + entry = LAUNCH_CONTRACT["consumers"].get(consumer) + if entry is None: + raise ValueError("consumer: unknown launch entry point") + if not isinstance(provider.get("id"), str) or not provider["id"]: + raise ValueError("provider.id: expected a nonempty string") + if provider.get("operation_contract_version") != 1: + raise ValueError("provider.operation_contract_version: expected 1") + operations = provider.get("operations", {}).get(entry) + if not isinstance(operations, list) or any(not isinstance(op, str) for op in operations): + raise ValueError("provider.operations: expected consumer operation declarations") + reason = ( + "provider-mismatch" if body.get("provider") and body["provider"] != provider["id"] else None + ) + input_kind = "managed" if "template_id" in body else "image" if "image" in body else "source" + if not (prepared and input_kind == "source") and input_kind not in provider.get( + "workflow_inputs", [] + ): + reason = reason or "input-unimplemented" + startup = ( + "exec" if body.get("start_cmd") else "entrypoint" if input_kind != "managed" else "none" + ) + if startup not in provider.get("startup_modes", []): + reason = reason or "startup-unimplemented" + required = set(LAUNCH_CONTRACT["required_operations"]["create"]) + if input_kind == "source" and not prepared: + required.update(LAUNCH_CONTRACT["required_operations"]["prepare"]) + if body.get("start_cmd") or body.get("ready_cmd"): + required.update(LAUNCH_CONTRACT["required_operations"]["start"]) + if body["provides"]: + required.update(LAUNCH_CONTRACT["required_operations"]["services"]) + missing = required - set(operations) + return { + "supported": not missing and reason is None, + "required_operations": sorted(required), + "missing_operations": sorted(missing), + "services": body["provides"], + **({"reason": reason} if reason else {}), + } diff --git a/client-sdk/tests/integration/test_workflow_e2e.py b/client-sdk/tests/integration/test_workflow_e2e.py index 23852546..4851187a 100644 --- a/client-sdk/tests/integration/test_workflow_e2e.py +++ b/client-sdk/tests/integration/test_workflow_e2e.py @@ -23,7 +23,7 @@ DEFAULT_CAPABILITIES, SandboxTemplate, ) -from tests.workflow_fixtures import workflow_manifest +from tests.template_fixtures import template_manifest # --------------------------------------------------------------------------- # Paths @@ -63,7 +63,7 @@ class TestCapabilityResolution: async def test_resolve_from_local_yaml(self, tmp_path): yaml_file = tmp_path / "template.yaml" - yaml_file.write_text(yaml.safe_dump(workflow_manifest())) + yaml_file.write_text(yaml.safe_dump(template_manifest())) result = await resolve_capabilities("test", local_yaml_path=str(yaml_file)) assert isinstance(result, ResolvedCapabilities) assert result.capabilities == {"ports"} @@ -71,7 +71,7 @@ async def test_resolve_from_local_yaml(self, tmp_path): async def test_resolve_from_local_cache(self, tmp_path): cache_dir = tmp_path / "templates" / "test" cache_dir.mkdir(parents=True) - (cache_dir / "template.yaml").write_text(yaml.safe_dump(workflow_manifest())) + (cache_dir / "template.yaml").write_text(yaml.safe_dump(template_manifest())) with patch("agent_sandbox.api.capability.TEMPLATE_CACHE_DIR", tmp_path / "templates"): result = await resolve_capabilities("test") assert result.capabilities == {"ports"} diff --git a/client-sdk/tests/template_fixtures.py b/client-sdk/tests/template_fixtures.py new file mode 100644 index 00000000..3839b0c8 --- /dev/null +++ b/client-sdk/tests/template_fixtures.py @@ -0,0 +1,21 @@ +"""Current template fixture factory; no schema migration occurs at runtime.""" + +from copy import deepcopy + +MANIFEST = { + "name": "test", + "spec_version": 5, + "provides": { + "app": { + "protocol": "agent-sandbox-server", + "version": 2, + "port": 9000, + "operations": ["commands.list", "commands.run"], + } + }, + "start_cmd": "python commands.py", +} + + +def template_manifest(**extra): + return {**deepcopy(MANIFEST), **extra} diff --git a/client-sdk/tests/test_api/test_capability.py b/client-sdk/tests/test_api/test_capability.py index 5b1e4367..ed8483c0 100644 --- a/client-sdk/tests/test_api/test_capability.py +++ b/client-sdk/tests/test_api/test_capability.py @@ -11,7 +11,7 @@ ) from agent_sandbox.models.errors import TemplateParseError from agent_sandbox.models.template import DEFAULT_CAPABILITIES -from tests.workflow_fixtures import workflow_manifest +from tests.template_fixtures import template_manifest def test_no_default_interpreter_claim(): @@ -19,7 +19,7 @@ def test_no_default_interpreter_claim(): def test_application_service_does_not_imply_code_interpreter(): - template = parse_template_data(workflow_manifest()) + template = parse_template_data(template_manifest()) assert template.discovery_capabilities() == {"ports"} @@ -36,7 +36,7 @@ def test_application_service_does_not_imply_code_interpreter(): ) def test_retired_inputs_rejected(retired): with pytest.raises(ValidationError): - parse_template_data(workflow_manifest(**{retired: []})) + parse_template_data(template_manifest(**{retired: []})) async def test_unknown_remote_reference_is_unknown_not_default(): @@ -46,7 +46,7 @@ async def test_unknown_remote_reference_is_unknown_not_default(): async def test_explicit_manifest_is_strict_and_resources_do_not_mutate_input(tmp_path): path = tmp_path / "template.yaml" - data = workflow_manifest(resources={"cpu": 2, "memory": 512}) + data = template_manifest(resources={"cpu": 2, "memory": 512}) path.write_text(yaml.safe_dump(data)) result = await resolve_capabilities("fixture", local_yaml_path=str(path)) assert result.matched and result.capabilities == {"ports"} @@ -72,7 +72,7 @@ async def test_cached_alias_matches_only_valid_manifest(tmp_path, monkeypatch): monkeypatch.setattr("agent_sandbox.api.capability.TEMPLATE_CACHE_DIR", tmp_path) path = tmp_path / "cache" / "template.yaml" path.parent.mkdir() - path.write_text(yaml.safe_dump(workflow_manifest(aliases=["alias"]))) + path.write_text(yaml.safe_dump(template_manifest(aliases=["alias"]))) result = await resolve_capabilities_for("not-matching", "alias") assert result.matched and "code" not in result.capabilities path.write_text("name: old\nalias: alias\ncapabilities: [code]") @@ -83,7 +83,7 @@ async def test_cached_alias_matches_only_valid_manifest(tmp_path, monkeypatch): def test_application_resources_are_preserved_as_topology_metadata(tmp_path): from agent_sandbox.utils.registry import load_template_from_yaml - data = workflow_manifest( + data = template_manifest( application={"name": "service"}, resources={"cpu": 0.5, "memory": "1Gi"} ) path = tmp_path / "template.yaml" diff --git a/client-sdk/tests/test_api/test_workflow_launch.py b/client-sdk/tests/test_api/test_template_launch.py similarity index 56% rename from client-sdk/tests/test_api/test_workflow_launch.py rename to client-sdk/tests/test_api/test_template_launch.py index e69ed3bf..f4284391 100644 --- a/client-sdk/tests/test_api/test_workflow_launch.py +++ b/client-sdk/tests/test_api/test_template_launch.py @@ -6,8 +6,8 @@ import pytest -from agent_sandbox.api.workflow import WorkflowError, launch_workflow -from tests.workflow_fixtures import workflow_manifest +from agent_sandbox.api.template import TemplateLaunchError, launch_template +from tests.template_fixtures import template_manifest def provider(operations=()): @@ -25,11 +25,12 @@ def provider(operations=()): def manifest(input="image"): - result = workflow_manifest(provider="fixture") - result["workflows"]["application"].update(input=input, startup="none") - result["workflows"]["application"]["binding"]["authentication"] = "none" - if input == "service": - result["workflows"]["application"]["binding"]["endpoint"] = "explicit-url" + result = template_manifest() + result.pop("start_cmd", None) + if input == "image": + result["image"] = "registry/image@sha256:" + "f" * 64 + elif input == "managed": + result.update(provider="fixture", template_id="base") return result @@ -39,10 +40,10 @@ def events(path): async def test_missing_operations_reject_before_any_resource_or_journal(tmp_path, monkeypatch): create = AsyncMock() - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) journal = tmp_path / "run.jsonl" - with pytest.raises(WorkflowError, match="requirements-unmet"): - await launch_workflow( + with pytest.raises(TemplateLaunchError, match="missing-operations|provider-mismatch"): + await launch_template( manifest(), provider=provider(), reference="registry/image@sha256:" + "f" * 64, @@ -73,16 +74,14 @@ async def test_intent_precedes_create_and_owned_resource_is_deleted( async def create(**kwargs): assert events(journal)[-1]["event"] == "create-intent" assert kwargs["template"] == reference - assert "abx_workflow_run" in kwargs["metadata"] + assert "abx_template_run" in kwargs["metadata"] return sandbox - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) - session = await launch_workflow( + session = await launch_template( manifest(input_kind), provider=p, reference=reference, journal_path=journal ) - if input_kind == "managed": - assert events(journal)[0]["artifact_digest"] == "unknown" await session.run_command("hello", name="test") await session.close() sandbox.kill.assert_awaited_once() @@ -106,11 +105,11 @@ async def test_protocol_mismatch_never_sends_application_payload_and_cleans( application_client=AsyncMock(return_value=server), ) monkeypatch.setattr( - "agent_sandbox.api.workflow.Sandbox.create", AsyncMock(return_value=sandbox) + "agent_sandbox.api.template.Sandbox.create", AsyncMock(return_value=sandbox) ) p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) - with pytest.raises(WorkflowError, match="protocol-mismatch"): - await launch_workflow( + with pytest.raises(TemplateLaunchError, match="protocol-mismatch"): + await launch_template( manifest(input_kind), provider=p, reference=reference, @@ -123,19 +122,6 @@ async def test_protocol_mismatch_never_sends_application_payload_and_cleans( async def test_managed_exec_has_no_application_startup(tmp_path, monkeypatch): body = manifest("managed") body["provides"] = {} - body["workflows"]["application"] = { - "kind": "exec", - "input": "managed", - "startup": "none", - "operation": "exec.run", - "binding": { - "transport": "exec", - "endpoint": "provider-native", - "caller": "host", - "authentication": "provider", - }, - "required_operations": [], - } sandbox = SimpleNamespace( id="owned", provider="fixture", @@ -144,9 +130,9 @@ async def test_managed_exec_has_no_application_startup(tmp_path, monkeypatch): application_client=AsyncMock(), ) create = AsyncMock(return_value=sandbox) - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) p = provider(["sandbox.create", "sandbox.delete", "exec.run"]) - async with await launch_workflow( + async with await launch_template( body, provider=p, reference="base", journal_path=tmp_path / "run" ) as session: assert session.sandbox is sandbox @@ -161,20 +147,20 @@ async def test_managed_foreign_provider_rejects_before_create(tmp_path, monkeypa body = manifest("managed") body["provider"] = "foreign-provider" create = AsyncMock() - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) - with pytest.raises(WorkflowError, match="requirements-unmet"): - await launch_workflow(body, provider=p, reference="base", journal_path=tmp_path / "run") + with pytest.raises(TemplateLaunchError, match="missing-operations|provider-mismatch"): + await launch_template(body, provider=p, reference="base", journal_path=tmp_path / "run") create.assert_not_awaited() async def test_uncertain_create_is_not_replayed_or_marked_clean(tmp_path, monkeypatch): create = AsyncMock(side_effect=TimeoutError("create outcome unknown")) - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) journal = tmp_path / "run" with pytest.raises(TimeoutError): - await launch_workflow( + await launch_template( manifest(), provider=p, reference="registry/image@sha256:" + "f" * 64, @@ -196,14 +182,14 @@ async def test_cleanup_failure_retains_resource_identity(tmp_path, monkeypatch): application_client=AsyncMock(return_value=server), ) monkeypatch.setattr( - "agent_sandbox.api.workflow.Sandbox.create", AsyncMock(return_value=sandbox) + "agent_sandbox.api.template.Sandbox.create", AsyncMock(return_value=sandbox) ) p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) journal = tmp_path / "run" - session = await launch_workflow( + session = await launch_template( manifest(), provider=p, reference="registry/image@sha256:" + "f" * 64, journal_path=journal ) - with pytest.raises(WorkflowError, match="cleanup-failed"): + with pytest.raises(TemplateLaunchError, match="cleanup-failed"): await session.close() assert events(journal)[-1] == { "event": "cleanup-failed", @@ -213,30 +199,24 @@ async def test_cleanup_failure_retains_resource_identity(tmp_path, monkeypatch): async def test_client_close_failure_still_deletes_owned_sandbox(tmp_path): - from agent_sandbox.api.workflow import WorkflowSession, _Journal + from agent_sandbox.api.template import TemplateSession, _Journal server = SimpleNamespace(aclose=AsyncMock(side_effect=RuntimeError("close failed"))) sandbox = SimpleNamespace(id="owned", provider="fixture", kill=AsyncMock()) journal = tmp_path / "run" - session = WorkflowSession({}, sandbox, server, _Journal(journal)) - with pytest.raises(WorkflowError, match="cleanup-failed"): + session = TemplateSession( + {"services": {"app": {"protocol": "agent-sandbox-server"}}}, + sandbox, + {"app": server}, + _Journal(journal), + ) + with pytest.raises(TemplateLaunchError, match="cleanup-failed"): await session.close() sandbox.kill.assert_awaited_once() assert any(row["event"] == "deleted" for row in events(journal)) assert not any(row["event"] == "cleanup-complete" for row in events(journal)) -async def test_mutable_image_never_creates_or_executes(tmp_path, monkeypatch): - create = AsyncMock() - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) - p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) - with pytest.raises(WorkflowError, match="immutable-image-reference-required"): - await launch_workflow( - manifest(), provider=p, reference="image:latest", journal_path=tmp_path / "run" - ) - create.assert_not_awaited() - - async def test_created_id_survives_session_assembly_failure(tmp_path, monkeypatch): journal = tmp_path / "run" @@ -248,10 +228,10 @@ async def fail(**kwargs): error.resource_cleanup_complete = False raise error - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", fail) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", fail) p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) with pytest.raises(RuntimeError, match="session assembly failed"): - await launch_workflow( + await launch_template( manifest(), provider=p, reference="sha256:" + "f" * 64, journal_path=journal ) rows = events(journal) @@ -263,7 +243,7 @@ async def fail(**kwargs): async def test_cancelled_close_waits_for_owned_resource_cleanup(tmp_path): import asyncio - from agent_sandbox.api.workflow import WorkflowSession, _Journal + from agent_sandbox.api.template import TemplateSession, _Journal entered, release = asyncio.Event(), asyncio.Event() @@ -272,7 +252,7 @@ async def kill(): await release.wait() sandbox = SimpleNamespace(id="owned", provider="fixture", kill=AsyncMock(side_effect=kill)) - session = WorkflowSession({}, sandbox, None, _Journal(tmp_path / "run")) + session = TemplateSession({"services": {}}, sandbox, {}, _Journal(tmp_path / "run")) task = asyncio.create_task(session.close()) await entered.wait() task.cancel() @@ -284,15 +264,20 @@ async def kill(): async def test_journal_failure_never_blocks_owned_cleanup(tmp_path): - from agent_sandbox.api.workflow import WorkflowSession, _Journal + from agent_sandbox.api.template import TemplateSession, _Journal journal = _Journal(tmp_path / "run") journal.append("created", sandbox_id="owned", provider_id="fixture") journal.append = lambda *args, **kwargs: (_ for _ in ()).throw(OSError("disk full")) sandbox = SimpleNamespace(id="owned", provider="fixture", kill=AsyncMock()) server = SimpleNamespace(aclose=AsyncMock()) - session = WorkflowSession({}, sandbox, server, journal) - with pytest.raises(WorkflowError, match="journal-write-failed"): + session = TemplateSession( + {"services": {"app": {"protocol": "agent-sandbox-server"}}}, + sandbox, + {"app": server}, + journal, + ) + with pytest.raises(TemplateLaunchError, match="journal-write-failed"): await session.close() sandbox.kill.assert_awaited_once() server.aclose.assert_awaited_once() @@ -305,23 +290,11 @@ async def test_named_builtin_provider_is_not_reregistered(tmp_path, monkeypatch, sandbox = SimpleNamespace(id="owned", provider="alicloud-express", kill=AsyncMock()) create = AsyncMock(return_value=sandbox) - monkeypatch.setattr("agent_sandbox.api.workflow.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) spec = manifest("managed") spec["provider"] = "alicloud-express" spec["provides"] = {} - spec["workflows"]["application"].update( - kind="exec", - operation="exec.run", - startup="none", - binding={ - "transport": "exec", - "endpoint": "provider-native", - "caller": "host", - "authentication": "provider", - }, - ) - spec["workflows"]["application"].pop("service") - async with await launch_workflow( + async with await launch_template( spec, provider=builtin_provider() if as_object else "alicloud-express", reference="base", @@ -330,3 +303,120 @@ async def test_named_builtin_provider_is_not_reregistered(tmp_path, monkeypatch, pass assert create.call_args.kwargs["provider"] == "alicloud-express" assert builtin_provider().id == "alicloud-express" + + +async def test_all_services_start_together_without_invoking_commands(tmp_path, monkeypatch): + from agent_sandbox.models.process import ProcessResult + + body = template_manifest( + ready_cmd="test -f /tmp/ready", + env={"ABX_SERVER_TOKEN": ""}, + resources={"cpu": 2, "memory": 2048}, + ) + body["provides"]["second"] = {**body["provides"]["app"], "port": 9001} + first, second = AsyncMock(), AsyncMock() + for server in (first, second): + server.health.return_value = True + server.discover.return_value = SimpleNamespace(api_version=2) + sandbox = SimpleNamespace( + id="owned", + provider="fixture", + kill=AsyncMock(), + application_client=AsyncMock(side_effect=[first, second]), + commands=SimpleNamespace(run=AsyncMock(return_value=ProcessResult(exit_code=0))), + ) + create = AsyncMock(return_value=sandbox) + start = AsyncMock() + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) + monkeypatch.setattr("agent_sandbox.api.template._dispatch_start", start) + p = provider(["sandbox.create", "sandbox.delete", "ports.resolve", "exec.run"]) + async with await launch_template( + body, + provider=p, + reference="prepared-provider-reference", + application_token="placeholder-token", + journal_path=tmp_path / "run", + ) as session: + assert set(session.services) == set(session.servers) == {"app", "second"} + assert session.server is None + start.assert_awaited_once_with(sandbox, body["start_cmd"], 30) + sandbox.commands.run.assert_awaited_once_with(body["ready_cmd"], timeout=30) + assert create.call_args.kwargs["envs"]["ABX_SERVER_TOKEN"] == "placeholder-token" + assert create.call_args.kwargs["cpu"] == 2 + assert create.call_args.kwargs["memory"] == 2048 + for call in sandbox.application_client.await_args_list: + assert call.kwargs["token"] == "placeholder-token" + first.discover.assert_awaited_once() + second.discover.assert_awaited_once() + first.commands.run.assert_not_awaited() + second.commands.run.assert_not_awaited() + first.aclose.assert_awaited_once() + second.aclose.assert_awaited_once() + sandbox.kill.assert_awaited_once() + + +async def test_second_service_failure_closes_every_resolved_client(tmp_path, monkeypatch): + body = manifest() + body["provides"]["second"] = {**body["provides"]["app"], "port": 9001} + first, second = AsyncMock(), AsyncMock() + for server, version in ((first, 2), (second, 99)): + server.health.return_value = True + server.discover.return_value = SimpleNamespace(api_version=version) + sandbox = SimpleNamespace( + id="owned", + provider="fixture", + kill=AsyncMock(), + application_client=AsyncMock(side_effect=[first, second]), + ) + monkeypatch.setattr( + "agent_sandbox.api.template.Sandbox.create", AsyncMock(return_value=sandbox) + ) + p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) + with pytest.raises(TemplateLaunchError, match="protocol-mismatch: second"): + await launch_template(body, provider=p, journal_path=tmp_path / "run") + first.aclose.assert_awaited_once() + second.aclose.assert_awaited_once() + sandbox.kill.assert_awaited_once() + + +async def test_source_requires_preparation_before_any_resource(tmp_path, monkeypatch): + create = AsyncMock() + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) + with pytest.raises(TemplateLaunchError, match="prepare-source-with-cli"): + await launch_template( + template_manifest(), provider=provider(), journal_path=tmp_path / "run" + ) + create.assert_not_awaited() + assert not (tmp_path / "run").exists() + + +async def test_fixed_mutable_image_is_rejected_before_create(tmp_path, monkeypatch): + create = AsyncMock() + monkeypatch.setattr("agent_sandbox.api.template.Sandbox.create", create) + body = manifest() + body["image"] = "image:latest" + with pytest.raises(TemplateLaunchError, match="immutable-image-reference-required"): + await launch_template(body, provider=provider(), journal_path=tmp_path / "run") + create.assert_not_awaited() + + +async def test_other_protocol_is_exposed_without_guessing_http_routes(tmp_path, monkeypatch): + body = manifest() + body["provides"]["app"]["protocol"] = "custom-http" + server = AsyncMock() + sandbox = SimpleNamespace( + id="owned", + provider="fixture", + kill=AsyncMock(), + application_client=AsyncMock(return_value=server), + ) + monkeypatch.setattr( + "agent_sandbox.api.template.Sandbox.create", AsyncMock(return_value=sandbox) + ) + p = provider(["sandbox.create", "sandbox.delete", "ports.resolve"]) + async with await launch_template(body, provider=p, journal_path=tmp_path / "run") as session: + assert session.services["app"] is server + assert session.servers == {} + server.health.assert_not_awaited() + server.discover.assert_not_awaited() + server.aclose.assert_awaited_once() diff --git a/client-sdk/tests/test_models/test_template.py b/client-sdk/tests/test_models/test_template.py index 87d32949..76039359 100644 --- a/client-sdk/tests/test_models/test_template.py +++ b/client-sdk/tests/test_models/test_template.py @@ -8,27 +8,27 @@ class TestSandboxTemplate: """Current schema preserves application metadata and rejects old contracts.""" - def test_workflow_template(self) -> None: - from tests.workflow_fixtures import workflow_manifest + def test_complete_template(self) -> None: + from tests.template_fixtures import template_manifest - tmpl = SandboxTemplate.model_validate(workflow_manifest()) - assert tmpl.spec_version == 4 - assert tmpl.default_workflow == "application" + tmpl = SandboxTemplate.model_validate(template_manifest()) + assert tmpl.spec_version == 5 + assert "workflows" not in SandboxTemplate.model_fields assert tmpl.env == {} and tmpl.ports == [] assert "code" not in tmpl.discovery_capabilities() def test_application_topology_round_trips(self) -> None: - from tests.workflow_fixtures import workflow_manifest + from tests.template_fixtures import template_manifest topology = {"workers": {"limits": {"cpu": 0.5}}} - tmpl = SandboxTemplate.model_validate(workflow_manifest(deployment=topology)) + tmpl = SandboxTemplate.model_validate(template_manifest(deployment=topology)) assert tmpl.model_dump()["deployment"] == topology def test_retired_build_fields_are_rejected(self) -> None: import pytest from pydantic import ValidationError - from tests.workflow_fixtures import workflow_manifest + from tests.template_fixtures import template_manifest for field in ( "system_packages", @@ -38,7 +38,7 @@ def test_retired_build_fields_are_rejected(self) -> None: "custom_commands", ): with pytest.raises(ValidationError, match="retired template fields"): - SandboxTemplate.model_validate(workflow_manifest(**{field: ["old"]})) + SandboxTemplate.model_validate(template_manifest(**{field: ["old"]})) def test_old_missing_version_is_rejected(self) -> None: import pytest diff --git a/client-sdk/tests/test_models/test_template_server_block.py b/client-sdk/tests/test_models/test_template_server_block.py index 0ffff932..974eb716 100644 --- a/client-sdk/tests/test_models/test_template_server_block.py +++ b/client-sdk/tests/test_models/test_template_server_block.py @@ -14,7 +14,7 @@ SandboxTemplate, validate_server_block, ) -from tests.workflow_fixtures import workflow_manifest +from tests.template_fixtures import template_manifest VALID = { "preset": "transfer-only", @@ -29,11 +29,11 @@ def make(server: object) -> SandboxTemplate: - return SandboxTemplate.model_validate(workflow_manifest(server=server)) + return SandboxTemplate.model_validate(template_manifest(server=server)) def test_default_is_none() -> None: - assert SandboxTemplate.model_validate(workflow_manifest()).server is None + assert SandboxTemplate.model_validate(template_manifest()).server is None def test_valid_block_round_trips() -> None: @@ -41,7 +41,7 @@ def test_valid_block_round_trips() -> None: def test_existing_templates_without_server_are_unaffected() -> None: - t = SandboxTemplate.model_validate(workflow_manifest(unknown_future_key=1)) + t = SandboxTemplate.model_validate(template_manifest(unknown_future_key=1)) assert t.server is None diff --git a/client-sdk/tests/test_templates/test_template_catalog.py b/client-sdk/tests/test_templates/test_template_catalog.py index c6db4576..0c1d7ea5 100644 --- a/client-sdk/tests/test_templates/test_template_catalog.py +++ b/client-sdk/tests/test_templates/test_template_catalog.py @@ -160,10 +160,9 @@ def test_dockerfile_has_from_line(self, template_dir: Path) -> None: class TestWorkflows: """Fixture application service is distinct from a native interpreter.""" - def test_default_workflow_is_explicit(self, template_dir: Path) -> None: + def test_template_has_single_startup(self, template_dir: Path) -> None: tmpl = _load(template_dir) - assert tmpl.default_workflow in tmpl.workflows - assert tmpl.spec_version == 4 + assert tmpl.spec_version == 5 def test_no_old_capability_input(self, template_dir: Path) -> None: assert "capabilities" not in _raw(template_dir) @@ -171,9 +170,8 @@ def test_no_old_capability_input(self, template_dir: Path) -> None: def test_application_port_matches_provided_service(self, template_dir: Path) -> None: tmpl = _load(template_dir) - for flow in tmpl.workflows.values(): - if flow["kind"] == "application": - assert tmpl.provides[flow["service"]]["port"] in tmpl.ports + for service in tmpl.provides.values(): + assert service["port"] in tmpl.ports def test_model_rejects_retired_input(self) -> None: with pytest.raises(ValidationError): diff --git a/client-sdk/tests/test_workflows/test_launch_contract.py b/client-sdk/tests/test_workflows/test_launch_contract.py new file mode 100644 index 00000000..a817e639 --- /dev/null +++ b/client-sdk/tests/test_workflows/test_launch_contract.py @@ -0,0 +1,20 @@ +"""Shared whole-template plans stay identical across consumers.""" + +import json +from pathlib import Path + +import pytest + +from agent_sandbox.workflows.launch import plan_template_launch + +CONTRACTS = Path(__file__).parents[3] / "contracts" +CASES = json.loads((CONTRACTS / "launch-vectors.json").read_text())["cases"] + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case["id"]) +def test_launch_vectors(case): + if case.get("error"): + with pytest.raises(ValueError): + plan_template_launch(**case["input"]) + else: + assert plan_template_launch(**case["input"]) == case["expected"] diff --git a/client-sdk/tests/workflow_fixtures.py b/client-sdk/tests/workflow_fixtures.py deleted file mode 100644 index ee5deac2..00000000 --- a/client-sdk/tests/workflow_fixtures.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Current workflow fixture factory; no schema migration occurs at runtime.""" - -from copy import deepcopy - -MANIFEST = { - "name": "test", - "spec_version": 4, - "workflow_contract_version": 1, - "provides": { - "app": { - "protocol": "agent-sandbox-server", - "version": 2, - "port": 9000, - "operations": ["commands.list", "commands.run"], - } - }, - "default_workflow": "application", - "start_cmd": "python commands.py", - "workflows": { - "application": { - "kind": "application", - "input": "source", - "startup": "exec", - "service": "app", - "operation": "commands.run", - "binding": { - "transport": "http", - "endpoint": "provider-port", - "caller": "host", - "authentication": "service-token", - }, - "required_operations": [], - } - }, -} - - -def workflow_manifest(**extra): - return {**deepcopy(MANIFEST), **extra} diff --git a/docs/en/design/server-api.md b/docs/en/design/server-api.md index 0222197b..ea800600 100644 --- a/docs/en/design/server-api.md +++ b/docs/en/design/server-api.md @@ -20,7 +20,7 @@ The container-side HTTP Server is a lightweight HTTP service that relies **solel - Each route is an endpoint with one id, declared once in [`endpoints.py`](../../../server-sdk/src/agent_sandbox/server/endpoints.py). A **Capability Group** is a name for a set of endpoint ids. - Request/response objects are stdlib `dataclass`es ([`types.py`](../../../server-sdk/src/agent_sandbox/server/types.py)), not Pydantic models. -> The groups described here are aliases for **server endpoint-policy rules** in RouteTable. They are separate from template specification 4 workflows and provider operation contract 1. A template declares its supplied application protocol; the selected provider adapter and SDK/CLI consumer must separately implement the workflow’s exact operations and binding. Unknown metadata grants no operations. Server discovery, policy and token scopes can still deny an endpoint; a template declaration or historical receipt does not enable it. See [Template System Design](template-system.md) and [explicit template workflows](../guide/template-workflows.md). +> Server endpoint-policy groups are independent from specification 5 template startup and Provider operations. Launch exposes all declared services; actual discovery, policy and token scopes still control each API call. See [Template System](template-system.md). ### 1.2 Startup diff --git a/docs/en/design/template-system.md b/docs/en/design/template-system.md index cf3cb5e2..02f4ebb6 100644 --- a/docs/en/design/template-system.md +++ b/docs/en/design/template-system.md @@ -1,161 +1,49 @@ -# Template System Design +# Template System -Templates use specification 4 and explicit workflow contract 1. A template describes supplied software and named ways to use it. Provider adapters, the selected SDK/CLI consumer, local configuration and trusted verification are separate inputs; installing Python or an HTTP server does not implement a provider interpreter operation. +Templates use specification 5. A template defines the complete sandbox contents, +its startup/readiness commands and every service it provides. Starting a template +never selects a subset by workflow, operation or application/exec/interpreter type. -The current specification is [template.yaml Specification](../reference/template-yaml-spec.md), the publishing authority is [template-v4.schema.json](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v4.schema.json), and execution guidance is [Explicit template workflows](../guide/template-workflows.md). The Node.js CLI is maintained in [agent-sandbox](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox); Python owns its client, in-sandbox server and the shared [workflow contracts](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts). +## Responsibilities -## 1. Official Core Templates +The template owns application source, dependencies, configuration and startup. +The Provider owns image upload/registration, platform identifiers, sandbox creation +and endpoint resolution. The CLI prepares source and starts the complete template. +Python consumes a prepared reference or connects to an existing sandbox; it does +not acquire Docker/source-build responsibilities. -The source of current template inventory is [awesome-templates](https://github.com/Alibaba-Agent-Sandbox/awesome-templates). Catalog membership, author smoke commands and historical acceptance do not guarantee that every provider/client can run every workflow. Use the actual manifest and selected workflow, rather than a fixed list of promised launch environments. +## Launch and use - - -The earlier launch/extension lists are replaced by the source-owned catalog. This page does not promise template release dates, installed package versions or an automatic security certification. +Use `abx --provider PROVIDER template launch TEMPLATE_REF` or Python +`launch_template(manifest, provider=..., reference=...)`. The launcher checks only +operations needed to create, start, inspect readiness and clean up. Missing interpreter +operations never prevent an unrelated application from starting. Actual operations +remain guarded by the Provider and server policy at invocation time. - - - - - - - - - - +Every `provides` service is retained. SandboxServer endpoints receive protocol checks; +unknown protocols are returned and use the author's readiness command. A multi-service +template starts once. Calling one endpoint later does not start a different template. -## 2. Template Sources and Distribution Mechanism +## Manifest and migration -### 2.1 Template Name Resolution Rules +See [the field reference](../reference/template-yaml-spec.md) and the authoritative +[template-v5 schema](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v5.schema.json). +Remove `workflow_contract_version`, `default_workflow` and `workflows`; keep +`spec_version: 5`, `provides`, `start_cmd`, `ready_cmd` and application metadata. +Old running manifests require explicit migration. Source locations, prepared image +references and native template IDs are preparation details, not user-selectable flows. -A source index entry locates one authoritative `template.yaml`; it cannot override its workflow or startup commands. A provider-managed entry embeds a complete strict manifest and belongs to that provider. Local source paths, Git revisions, image references and managed IDs represent different input kinds, not interchangeable aliases. +## Ownership and verification -| Workflow input | Meaning | -| --- | --- | -| `source` | A project snapshot requiring an implemented preparation path and explicit build configuration | -| `image` | A prepared image; the Python launcher requires an immutable SHA256 reference | -| `managed` | A provider-owned reference; launching requires a provider-resolved immutable identity | -| `service` | An existing application HTTP service with an explicit binding and `startup: none` | +The launcher records creation intent and owned IDs. Uncertain creates are not replayed; +cleanup attempts every owned client and sandbox, retaining failures for reconciliation. +Connecting to an existing sandbox does not confer deletion ownership. -Unknown references grant no default operations. Python's standalone launcher does not prepare source projects, and unresolved managed IDs fail before create. Source Dockerfiles are never rewritten from `base` metadata or replaced by a guessed fallback image. +Catalog membership or a static supported plan is not a test pass. Historical workflow +receipts remain readable history; they do not certify specification 5. A template's +metadata version is not a registry storage/versioning service. Use actual Git revisions +and recorded inputs to reproduce a run. -### 2.2 Usage from Different Entry Points - -For an existing sandbox, use `ProviderSandbox.connect(...)` and `await sandbox.application_client(port, token=...)` to reach its application service. This resolves an independent provider port endpoint; the provider's internal control or execution endpoint is not the application URL. - -For a new prepared-image workflow, `agent_sandbox.api.launch_workflow(...)` takes the actual manifest, workflow ID, provider and immutable reference. It checks requirements before sending application payloads. See [the complete Python examples and limitations](../guide/template-workflows.md#python-application-consumption). - -The CLI catalog guidance uses `abx template launch SOURCE --workflow ID`; a portable source build additionally selects `--build-profile portable` when that profile exists. The selected CLI/provider must actually support its input and startup path. Express currently declares only managed input and entrypoint/none startup; that says what this project implements, not whether its platform can build images through other native APIs. - -### 2.3 GitHub Tarball Resolution Flow - -Source acquisition and execution are separate phases: resolve the requested source revision, safely acquire the project, parse its one manifest, freeze the eligible source snapshot, then plan the selected workflow. A downloaded archive or a cache hit proves neither provider support nor runtime verification. Remote authentication failures must not silently retry as anonymous requests or change the source identity. - -### 2.4 Template Package Structure (Repository/Subdir Contents) - -```text -my-template/ -├── template.yaml # spec_version: 4, workflow_contract_version: 1 -├── Dockerfile # explicit native image build -├── Dockerfile.portable # optional explicitly selected build profile -├── commands.py # application commands, if this service uses them -└── requirements.txt # application dependencies, if needed -``` - -Optional `AGENTS.md` is an instruction file consumed by external agent tools. Its presence does not add a runtime operation. Snapshot identity excludes unsafe secret/ignored paths and rejects unsupported symbolic-link inputs; evidence must be written outside the frozen snapshot to avoid self-reference. - -### 2.5 Official Template Repository - -[awesome-templates](https://github.com/Alibaba-Agent-Sandbox/awesome-templates) owns source manifests, Dockerfiles, publishing schema and catalog. [awesome-providers](https://github.com/Alibaba-Agent-Sandbox/awesome-providers) owns installable provider adapters. The built-in Express descriptor belongs to the CLI. Registry and website consume these sources and do not grant missing handlers. - -### 2.6 GitHub Token Configuration (Private Repositories & Rate Limits) - -Source-download credentials belong to acquisition, provider credentials to the control plane, gateway headers to the resolved endpoint, and an application token to the application. They must not be copied across these audiences. Consult the owning CLI documentation for its actual token configuration; never put credentials or signed endpoint URLs into manifests, receipts or public diagnostics. - -### 2.7 Local Cache Management - -A cache is an acquisition optimization. Reuse requires the expected identity and must not skip current manifest validation, operation planning or verification qualification. A mutable branch name is not the immutable source/build/artifact identity required by a current receipt. - -## 3. Custom Template Methods - - -### Dockerfile and an explicit build profile - -Put real dependencies, users, files and entrypoint settings in the selected Dockerfile. A portable profile is an explicit alternative, not a silent rewrite of the provider-native build. The source-root context is the quoted string `'.'` so YAML consumers agree on its type. - - -### A strict manifest - -`template.yaml` carries template metadata and named workflows. A separate `sandbox.yaml` client configuration is not a metadata alias. Named application functions are registered in the in-sandbox server (`@command`); a YAML key does not generate those functions. See [authoring templates](../guide/authoring-templates.md). - - -### Publishing source - -Publish the real source revision and preserve its manifest/build inputs. A mutable ref can locate it, but verification must bind resolved content. Publishing source does not publish a provider-managed reference or make a workflow verified. - -## 4. Template Specification (template.yaml) - -### Complete Field Definitions - -Use the complete [specification reference](../reference/template-yaml-spec.md#complete-field-definitions) and source-owned schema rather than a second independent field list. Required publishing metadata includes name, version, description, author and license. `spec_version` is integer `4`; `workflow_contract_version` is integer `1`. - -### Field Classification - -| Declaration | Responsibility | -| --- | --- | -| `provides` | Service protocol/version/port and application operations actually supplied by the template | -| `workflows` | Named kind, input, startup, binding, operation, service and additional requirements | -| `default_workflow` | Exactly one declared workflow selected when no other ID is given | -| `start_cmd` / `ready_cmd` | Explicit startup/readiness commands; a nonempty readiness command requires execution even with entrypoint/none startup | -| `build_profiles` | Explicit Dockerfile/context selections; no implicit portability rewrite | -| `server` | In-sandbox endpoint policy, which must actually be installed and applied by the chosen build/startup flow | - -Old `capabilities`, `tested_on`, handwritten `providers` compatibility claims and unknown workflow keys are rejected. No omitted field grants `{shell, files, code}`. `base` remains valid metadata; it does not silently rewrite Dockerfile `FROM` or construct an inheritance chain. - -### Key Design Points - -1. The template supplies software and application protocols. It cannot implement a platform adapter or consumer handler by declaration. -2. The provider operation contract declares exact operations separately per consumer and its supported input/startup modes. For example, `exec.run`, `ports.resolve` and `code.context.execute` are distinct operations. -3. Planning is three-valued: T permits subsequent local checks, F names an explicit mismatch/missing implementation, U means unresolved information. F dominates U. Missing project adapter support does not establish that the native platform lacks the capability. -4. An application server can use shell startup plus a resolved HTTP port without any unified interpreter. Missing interpreter support blocks that interpreter workflow alone. -5. SDK/CLI transport and network binding must match the selected operation. Server policy can further deny an application endpoint even if its handler exists. Unknown discovery is denied; application payloads follow protocol and policy checks. -6. Maturity, local environment checks and current trusted verification remain independent. A Stable label or historical test cannot certify this manifest, consumer and binding. - -### Example: Extension Based on a GitHub Template - -Extend a source project by editing its actual Dockerfile and strict manifest, rather than relying on a remote `base.from` inheritance rule. The source-owned [python-hello manifest](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/python-hello/template.yaml) declares distinct source and prepared-image application workflows for the same `app` service. Its portable build profile is selected explicitly. Copying this pattern still requires actual provider/client support and local credentials; it is not a readiness guarantee. - -## 5. Template Version Management - -### Semantic Versioning - -A template's `version` is metadata. Specification version, workflow contract version, resolved source revision, manifest digest, artifact digest and consumer revision have separate meanings. A SemVer label never substitutes for these identities. - -### Version Selection - -Select the source revision and declared workflow explicitly. A prepared image launch requires a digest-pinned image; a managed launch requires an immutable provider resolution. The Python launcher does not expand invented template SemVer ranges or automatically translate a repository reference into a cloud template. - -### Version Listing - -Read available source versions and manifests from the owning catalog/repository. Current verification is qualified only for matching source, manifest, build, artifact, provider code, consumer, workflow, binding and contract identities. Content changes invalidate reuse even when the human-readable name stays unchanged. - -## 6. Template Marketplace Architecture - -Registry ingests source-owned metadata, computes scoped plans and presents qualified evidence. Website presents its accepted strict catalog snapshot. Neither executes visitor workloads or obtains visitor cloud credentials while building catalog pages. - -### CLI Command Overview - -The current command surface belongs to [the CLI's Using Templates guide](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/en/guide/using-templates.md). Catalog workflow launch uses explicit source and workflow IDs. Provider-native deployment, application deployment topology and workflow launch have their own explicit operation requirements. - -### Template Security - -Trust requires independent approved CI provenance and matching artifacts. A template may add required checks but cannot remove a verification profile minimum. Missing, failed or skipped required checks do not qualify. Author-authored PASS JSON, a runner name or historical smoke output cannot certify itself. Signatures and security audits must be supported by actual evidence rather than assumed for every catalog item. - -## 7. Note: Session Management and Templates - -A workflow plans a selected use of the static source/artifact and owns only the runtime resources it creates. Creation intent and returned resource IDs must be durably recorded; an uncertain create is not replayed automatically. Cleanup attempts owned deletion even if journal writes or connection close fail, and unresolved resources remain visible for reconciliation. Closing a connection to an existing sandbox does not transfer ownership or delete it. - -Application HTTP binding preserves sandbox proxy prefixes and signed query values, separates gateway headers from application tokens, checks endpoint lifetime, and rejects redirects, authority overrides and encoded namespace traversal. The in-sandbox HTTP server remains a separate protocol with endpoint policy and token scopes; see [Server API design](server-api.md). - -## Related: the abx CLI - -See the CLI's [Using Templates](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/en/guide/using-templates.md), the current [Python workflow guide](../guide/template-workflows.md), [template specification](../reference/template-yaml-spec.md) and [server application API](../guide/template-server-api.md). The pinned standard clients keep their independent public contract; project provider adapters currently give Express/E2B/REST no unified interpreter operations and CubeSandbox/container only the persistent default-context operation. +See [Python launch examples](../guide/template-workflows.md), +[authoring templates](../guide/authoring-templates.md), and +[the server API](../guide/template-server-api.md). diff --git a/docs/en/guide/authoring-templates.md b/docs/en/guide/authoring-templates.md index 80aff9d2..1f02d471 100644 --- a/docs/en/guide/authoring-templates.md +++ b/docs/en/guide/authoring-templates.md @@ -6,7 +6,7 @@ For current SDK installation, use the [paired source installation guide](install This is the **Server SDK**: `commands.py` starts with the image and serves HTTP inside the sandbox. Creating and calling a sandbox from your machine is the [Client SDK](client-sdk.md). -This document explains how to create custom Agent Sandbox templates, including directory structure, field reference, explicit workflows, registered commands, and publishing. +This document explains how to create custom Agent Sandbox templates, including directory structure, field reference, whole-template startup, registered commands, and publishing. A template is a directory of plain files, written by hand or generated. The `abx template scaffold` command generates one three ways (built-in scaffold, a one-sentence description, or adopting a project that already has source code); that workflow is documented in the CLI repository: [Authoring Templates (CLI)](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/guide/authoring-templates.md). @@ -32,40 +32,32 @@ my-template/ ```yaml name: my-python-template -version: "1.0.1" -description: "Scaffold for a new Agent Sandbox template. Not a catalog entry." -author: "alibaba-agent-sandbox" +version: 1.0.1 +description: Scaffold for a new Agent Sandbox template. Not a catalog entry. +author: alibaba-agent-sandbox license: Apache-2.0 -spec_version: 4 -base: "fc-e2b-registry..cr.aliyuncs.com/runtime/base:" +spec_version: 5 +base: fc-e2b-registry..cr.aliyuncs.com/runtime/base: generation: 2 -start_cmd: "agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py" -ready_cmd: "agent-sandbox-server healthcheck --port 9000" - +start_cmd: agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py +ready_cmd: agent-sandbox-server healthcheck --port 9000 resources: cpu: 2 memory: 2048 - ports: - - 9000 - +- 9000 tags: - - example - - skeleton - +- example +- skeleton priority: P2 wave: other - - env: - ABX_SERVER_TOKEN: "" - + ABX_SERVER_TOKEN: '' smoke: - - command: hello - args: - name: Express - expect: "Hello, Express!" -workflow_contract_version: 1 +- command: hello + args: + name: Express + expect: Hello, Express! provides: app: protocol: agent-sandbox-server @@ -74,20 +66,6 @@ provides: operations: - commands.list - commands.run -default_workflow: application -workflows: - application: - kind: application - input: source - service: app - startup: exec - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: commands.run - required_operations: [] ``` --- @@ -105,8 +83,6 @@ workflows: | `ports` | `list[int]` | ❌ | `[]` | Exposed port list | | `author` | `str` | ❌ | `""` | Author | | `license` | `str` | ❌ | `""` | License | -| `spec_version` / `workflow_contract_version` | integer | ✅ | — | `4` / `1` | -| `provides` / `workflows` / `default_workflow` | mapping / identifier | ✅ | — | Explicit workflow contract | | `tags` | `list[str]` | ❌ | `[]` | Tags | For detailed field definitions, see [template.yaml Spec](../reference/template-yaml-spec.md). @@ -114,11 +90,16 @@ For detailed field definitions, see [template.yaml Spec](../reference/template-y --- -## Template Workflows +## Template startup -`provides` describes software/services and `workflows` declares explicit invocation paths. The default names one workflow and grants no inferred operations. Retired `capabilities`, `tested_on` and provider compatibility tables are rejected; unknown remote references grant no default interpreter. Application HTTP does not depend on a unified code interpreter. See [explicit template workflows](template-workflows.md) for exact operations, T/F/U, contexts and verification scope. +Use `spec_version: 5` and declare every service in `provides`. `start_cmd` and +`ready_cmd` apply to the complete template, without named workflow selections. +The Provider supplies platform operations; service policy controls application APIs. +Neither layer implies interpreter support. See [launching templates](template-workflows.md). -The HTTP server independently applies endpoint policy across nine groups. `core` stays on; `dev_tools`, `browser` and `agents` start off. `server:` does not authorize provider operations, and a workflow does not automatically enable server endpoints. See [Template server HTTP API](template-server-api.md). +Server endpoint policies remain independent. The `core` group stays enabled; +`dev_tools`, `browser`, and `agents` require explicit configuration. Declaring an +API in template metadata does not enable its server policy. --- @@ -151,7 +132,7 @@ Keep the source Dockerfile unchanged. Resolve base-image placeholders in the sou ## Provider template profile -The server does not select a provider. Port 9000 is the application HTTP listen default; injected envd, interpreter services and reserved ports belong to each backend. Those differences do not imply the application is unusable. `startup: exec`, `entrypoint` and `none` are distinct paths and must match provider declarations. Do not run native startup and a start command twice; actually check a nonempty `ready_cmd`. Build configurations are explicit, and the effective Dockerfile/arguments participate in input identity. +The server does not select a provider. Port 9000 is the application HTTP listen default; injected envd, interpreter services and reserved ports belong to each backend. Those differences do not imply the application is unusable. A declared `start_cmd` runs once; without it, the image or managed template owns startup. Do not run native startup and a start command twice; actually check a nonempty `ready_cmd`. Build configurations are explicit, and the effective Dockerfile/arguments participate in input identity. `commands.py` imports only `agent_sandbox.server` and calls `start(port)` from its main entry; the image explicitly installs the server SDK. For the provider-native diff --git a/docs/en/guide/client-sdk.md b/docs/en/guide/client-sdk.md index 68da0103..b004bcba 100644 --- a/docs/en/guide/client-sdk.md +++ b/docs/en/guide/client-sdk.md @@ -5,7 +5,7 @@ For current SDK installation, use the [installation guide](install-python.md). The Client SDK runs on **your machine**. It creates a sandbox, connects to one that already exists, and then runs commands, reads and writes files, and calls the code interpreter. The package is `agent-sandbox-client`. Import `agent_sandbox`. ```bash -python -m pip install 'agent-sandbox-client==0.3.1a5' +python -m pip install 'agent-sandbox-client==0.4.0a1' python -m pip check ``` diff --git a/docs/en/guide/install-python.md b/docs/en/guide/install-python.md index 157ec861..58b7bf18 100644 --- a/docs/en/guide/install-python.md +++ b/docs/en/guide/install-python.md @@ -1,5 +1,7 @@ # Install the Python packages +> Candidate documentation: 0.4.0a1 is not yet published. Validate this revision with the editable checkout installation below until publication. The previous 0.3.x package does not implement specification 5 launch. + Use Python 3.10 or newer and a virtual environment: ```bash @@ -7,7 +9,7 @@ python -m venv .venv # Linux or macOS: source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1 -python -m pip install --index-url https://pypi.org/simple 'agent-sandbox-client==0.3.1a5' +python -m pip install --index-url https://pypi.org/simple 'agent-sandbox-client==0.4.0a1' python -m pip check ``` @@ -19,12 +21,12 @@ Check the installed pair: python -c "from importlib.metadata import version; print(version('agent-sandbox-client'), version('agent-sandbox-server'))" ``` -Install the [abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) separately. For the template workflows in this guide, use CLI 0.4.16 or newer and configure the selected provider before creating a sandbox. +Install the [abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) separately. For the template workflows in this guide, use CLI 0.5.0 or newer and configure the selected provider before creating a sandbox. ## Optional dependencies ```bash -python -m pip install 'agent-sandbox-client[all]==0.3.1a5' +python -m pip install 'agent-sandbox-client[all]==0.4.0a1' ``` Replace `[all]` with `[mcp]`, `[session]` or `[fast]` for that subset. Workflow YAML and the declarative decorator belong to the base client; they need no extra. Browser automation requires Playwright installed inside the sandbox image. @@ -32,7 +34,7 @@ Replace `[all]` with `[mcp]`, `[session]` or `[fast]` for that subset. Workflow ## Server-only installation inside an image ```bash -python -m pip install 'agent-sandbox-server==0.3.1a5' +python -m pip install 'agent-sandbox-server==0.4.0a1' ``` The server runs the application inside an image. Installing it does not create a sandbox. See the [server guide](server-sdk.md) to register commands and start the service. diff --git a/docs/en/guide/sdk-usage.md b/docs/en/guide/sdk-usage.md index 1d9ce997..3113f19f 100644 --- a/docs/en/guide/sdk-usage.md +++ b/docs/en/guide/sdk-usage.md @@ -6,7 +6,7 @@ This page documents the asynchronous **ProviderSandbox** provider/server extensi ## Installation -Follow the [installation guide](install-python.md). The public client/server alpha pair is 0.3.1a5; installing the client installs the matching server dependency. Host package installation does not change existing sandbox images. +Follow the [installation guide](install-python.md). The public client/server alpha pair is 0.4.0a1; installing the client installs the matching server dependency. Host package installation does not change existing sandbox images. Do not run `pip install agent-sandbox`. That name on PyPI is an unrelated project. diff --git a/docs/en/guide/template-workflows.md b/docs/en/guide/template-workflows.md index 406d3e2e..14ebe220 100644 --- a/docs/en/guide/template-workflows.md +++ b/docs/en/guide/template-workflows.md @@ -21,21 +21,50 @@ finally: The client also sends the explicit application token in the server's existing `X-Access-Token` header when that header is unused. This keeps authentication working through proxies that strip `Authorization`, including OpenSandbox 1.1.1. Existing gateway headers, including defaults on an injected HTTP client, are never replaced. Bearer authentication remains available for services that require it. -For a new prepared-image workflow: +To start the complete template from a prepared provider reference: ```python -from agent_sandbox.api import launch_workflow +from agent_sandbox.api import launch_template -async with await launch_workflow( - "template.yaml", provider="opensandbox", workflow_id="application-image", - reference=immutable_image_reference, application_token=application_token, +async with await launch_template( + "template.yaml", provider="opensandbox", + reference=prepared_reference, application_token=application_token, ) as session: result = await session.run_command("hello", name="Python") ``` -The selected manifest must declare that workflow. Python does not build source projects. Use the CLI to prepare them first; connect to its returned sandbox ID, or use an `image` workflow with the resulting SHA256 image reference. A `managed` workflow accepts the native template ID belonging to its declared provider; it does not require a platform version number. An external application uses a `service` workflow and explicit application URL. The standalone launcher does not execute application deployment hooks or reinterpret deployment topology. - -The launcher freezes manifest inputs, checks the provider's actual code digest, persists intent before create, records owned resource IDs, checks application protocol version before invocation, and deletes only its owned sandbox. An uncertain create is not replayed and remains in a journal for reconciliation using its `abx_workflow_run` metadata. Cleanup failure preserves resource IDs and raises. Journals contain no API keys, application tokens, environment secrets or signed endpoint URLs. A journal alone is not a trusted verification receipt. +There is no workflow selection. Python creates one sandbox, runs `start_cmd` once, +checks `ready_cmd`, and resolves every service in `provides`. SandboxServer services +receive health/discovery checks before use. Other service protocols use the template's +readiness command; their endpoints are returned without guessing protocol routes. +`session.services` contains every service client, while `session.servers` contains +SandboxServer clients. `session.server` and `session.run_command` are conveniences +only when there is exactly one SandboxServer. With several, call +`session.servers["service-name"].commands.run(...)` to choose the invocation target. +Choosing a client never changes which template services were started. + +Python does not build sources. Prepare them with the CLI and pass the resulting +provider reference, or connect to the sandbox returned by the CLI. A manifest may +also declare a fixed digest-pinned `image`, or `provider` plus `template_id` for a +platform-managed template. References are interpreted by that Provider; native IDs +need no artificial version. For an existing external HTTP service, use `ServerClient` +directly. Application deployment hooks still belong to the deployment orchestrator. + +The launcher freezes the manifest, records intent and owned IDs, and deletes only its +own sandbox. Uncertain creation is not retried and remains recorded for reconciliation +using `abx_template_run` metadata. Every resolved client is closed on failure, including +failure of a later service. Journals exclude tokens, environment values and signed URLs; +they do not constitute verified cloud receipts. One effective `ABX_SERVER_TOKEN` value +is used both for the sandbox environment and the application clients. + +## Migrating specification 4 + +Use `spec_version: 5`; remove `workflow_contract_version`, `default_workflow`, and +`workflows`. Retain the full `provides` map and the template's actual `start_cmd` and +`ready_cmd`. Remove CLI `--workflow` and replace Python `launch_workflow` with +`launch_template` without `workflow_id`. Old manifests fail with a migration error; +they are not silently reduced to their former default. Historical specification 4 +receipts remain history and do not prove the specification 5 startup path. ## Interpreter operations @@ -58,7 +87,10 @@ finally: await sandbox.kill() # this code created and owns the sandbox ``` -If you already have a manifest declaring `input: managed`, use `launch_workflow(manifest, provider="alicloud-express", reference="base")` instead. Its async context manager deletes the sandbox it creates. The workflow still checks the provider, required operations and startup mode. A native ID without a content digest is usable; its journal records the artifact digest as unknown and makes no immutable-content claim. +With a specification 5 manifest containing `provider: alicloud-express`, +`template_id: base` and `provides: {}`, `launch_template(manifest, +provider="alicloud-express")` offers the same owned cleanup through its async context +manager. No workflow declaration or selector is required. Executing a shell command on `code-interpreter-v1` does not exercise the interpreter API. Express, E2B and REST project adapters currently do not implement unified interpreter operations; use the documented native SDK where applicable. CubeSandbox and container edition support the persistent default-context operation. diff --git a/docs/en/reference/template-yaml-spec.md b/docs/en/reference/template-yaml-spec.md index 9fc95a77..5e13dfa0 100644 --- a/docs/en/reference/template-yaml-spec.md +++ b/docs/en/reference/template-yaml-spec.md @@ -1,6 +1,6 @@ # template.yaml Specification -Current templates use `spec_version: 4` and `workflow_contract_version: 1`. The complete publishing authority is [awesome-templates/template-v4.schema.json](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v4.schema.json); the shared workflow contract lives in [contracts](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts). The full schema retains application deployment topology, hooks and durable state. +Current templates use `spec_version: 5`. The complete publishing authority is [awesome-templates/template-v5.schema.json](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v5.schema.json); the shared launch contract lives in [contracts](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts). The full schema retains application deployment topology, hooks and durable state. ## File Location @@ -25,21 +25,20 @@ Published source manifests use `resources.cpu` and `resources.memory`; the SDK r `generation` belongs to the selected template/provider startup configuration; the catalog source schema currently requires `2`. Creation still requires the provider to implement the declared input and startup mode. -### Workflow Declarations +### Template startup -| Field | Meaning | -| --- | --- | -| `spec_version` | Required integer `4` | -| `workflow_contract_version` | Required integer `1` | -| `provides` | Services, each with `protocol`, `version`, `port`, `operations` | -| `default_workflow` | Required identifier of one declared workflow | -| `workflows` | Named `application`, `exec` or `interpreter` paths | +`spec_version` is integer `5`. `provides` describes every service through `protocol`, +`version`, `port` and `operations`, and may be empty. `start_cmd` starts the complete +template once; when absent, the image or platform template supplies its own startup. +`ready_cmd`, when present, is executed before service clients are returned. A fixed +`image` or `provider` plus `template_id` can identify a prepared template; otherwise +the CLI prepares its source. Python requires a prepared reference. -Every workflow explicitly supplies `kind`, `input` (`source/image/managed/service`), `startup` (`exec/entrypoint/none`), `binding`, `operation` and `required_operations`. An application also names a `service` from `provides`. Binding fields are `transport`, `endpoint`, `caller`, `authentication`. Unknown workflow fields are rejected. - -`startup: exec` requires a nonempty `start_cmd` and `exec.run`. Native startup uses `entrypoint` without running the start command again. A `service` input requires `startup: none`. A declared nonempty `ready_cmd` requires execution support and must actually be checked, regardless of startup mode. Managed references belong to one provider. Templates may add `required_operations` and `required_checks`, never remove derived requirements or minimum profile checks. - -Template `capabilities`, `tested_on` and handwritten `providers` compatibility tables are rejected. There is no default `{shell, files, code}` grant. Providing Python or HTTP does not imply provider interpreter operations. See [explicit template workflows](../guide/template-workflows.md). +Remove `workflow_contract_version`, `default_workflow` and `workflows` when migrating +from specification 4. No application/exec/interpreter type or operation is selected +at launch. Retired `capabilities`, `tested_on` and provider compatibility tables are +still rejected. Actual Provider and server capabilities are checked at invocation. +See [launching templates](../guide/template-workflows.md). ### Removed key: `custom_commands` @@ -82,40 +81,32 @@ This source-publishing example includes required metadata. Resolve its base-imag ```yaml name: my-python-template -version: "1.0.1" -description: "Scaffold for a new Agent Sandbox template. Not a catalog entry." -author: "alibaba-agent-sandbox" +version: 1.0.1 +description: Scaffold for a new Agent Sandbox template. Not a catalog entry. +author: alibaba-agent-sandbox license: Apache-2.0 -spec_version: 4 -base: "fc-e2b-registry..cr.aliyuncs.com/runtime/base:" +spec_version: 5 +base: fc-e2b-registry..cr.aliyuncs.com/runtime/base: generation: 2 -start_cmd: "agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py" -ready_cmd: "agent-sandbox-server healthcheck --port 9000" - +start_cmd: agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py +ready_cmd: agent-sandbox-server healthcheck --port 9000 resources: cpu: 2 memory: 2048 - ports: - - 9000 - +- 9000 tags: - - example - - skeleton - +- example +- skeleton priority: P2 wave: other - - env: - ABX_SERVER_TOKEN: "" - + ABX_SERVER_TOKEN: '' smoke: - - command: hello - args: - name: Express - expect: "Hello, Express!" -workflow_contract_version: 1 +- command: hello + args: + name: Express + expect: Hello, Express! provides: app: protocol: agent-sandbox-server @@ -124,22 +115,8 @@ provides: operations: - commands.list - commands.run -default_workflow: application -workflows: - application: - kind: application - input: source - service: app - startup: exec - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: commands.run - required_operations: [] ``` ## Build Notes -`base` is valid source/template metadata. Builds use the selected effective Dockerfile and explicit build configuration; they never silently replace `FROM`, users, copy paths or entrypoints. Install dependencies through native files in the image. The Python launcher does not prepare source; select a prepared `image`/`managed` reference or explicit `service` path. +`base` is valid source/template metadata. Builds use the selected effective Dockerfile and explicit build configuration; they never silently replace `FROM`, users, copy paths or entrypoints. Install dependencies through native files in the image. The Python launcher does not prepare source; pass a prepared Provider reference or connect an existing ServerClient. diff --git a/docs/zh/design/server-api.md b/docs/zh/design/server-api.md index 473523fc..721bc478 100644 --- a/docs/zh/design/server-api.md +++ b/docs/zh/design/server-api.md @@ -21,7 +21,7 @@ - 每个路由归属一个 **能力组(CapabilityGroup)**,可整组启用/禁用。 - 请求/响应对象是 stdlib `dataclass`([`types.py`](../../../server-sdk/src/agent_sandbox/server/types.py)),不使用 Pydantic。 -> 本文的组名是 **RouteTable 服务端端点策略规则**的别名,与模板规范 4 工作流、提供方操作契约 1 分开。模板声明自身提供的应用协议;所选提供方适配器及 SDK/CLI 调用方必须分别实现工作流要求的精确操作和绑定。未知元数据不授予操作。服务发现、端点策略和 token scopes 仍可拒绝端点,模板声明或历史 receipt 不会启用它。见[模板系统设计](template-system.md)和[显式模板工作流](../guide/template-workflows.md)。 +> 服务端端点策略组与规范 5 模板启动、Provider 操作分开。整体启动保留全部服务,具体 API 调用仍由真实服务发现、策略和令牌范围控制。见[模板系统](template-system.md)。 ### 1.2 启动方式 diff --git a/docs/zh/design/template-system.md b/docs/zh/design/template-system.md index 398e1785..40fdb0d5 100644 --- a/docs/zh/design/template-system.md +++ b/docs/zh/design/template-system.md @@ -1,161 +1,41 @@ -# 模板系统设计 +# 模板系统 -模板采用规范 4 和显式工作流契约 1。模板描述所提供的软件及其具名使用方式。提供方适配器、所选 SDK/CLI 调用方、本地配置和可信验证是彼此独立的输入;安装 Python 或 HTTP 服务不会实现提供方解释器操作。 +模板采用规范 5,描述完整沙箱内容、启动与就绪命令,以及所有对外服务。 +启动模板不会再按工作流、单个操作或 application/exec/interpreter 类型选择其中一部分。 -当前规范见 [template.yaml 规范](../reference/template-yaml-spec.md),发布权威是 [template-v4.schema.json](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v4.schema.json),执行说明见 [显式模板工作流](../guide/template-workflows.md)。Node.js CLI 由 [agent-sandbox](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) 维护;Python 仓库拥有客户端、沙箱内服务和共享的[工作流契约](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts)。 +## 职责 -## 1. 官方核心模板 +模板负责应用源码、依赖、配置和启动。Provider 负责镜像上传注册、平台标识、沙箱创建和 +服务地址解析。CLI 准备源码并整体启动模板。Python 消费已准备好的引用,或连接既有沙箱, +不承担 Docker 和源码构建职责。 -当前模板清单以 [awesome-templates](https://github.com/Alibaba-Agent-Sandbox/awesome-templates) 为准。进入目录、作者提供的 smoke 命令和历史验收,都不保证每个提供方/调用方可以运行每条工作流。应读取真实 manifest 和所选工作流,不能使用固定的首发环境承诺推导支持。 +## 启动与使用 - - -以前的首发/扩展列表由源仓库目录取代。本文不承诺模板发布日期、已安装依赖版本或自动安全认证。 +CLI 使用 `abx --provider PROVIDER template launch TEMPLATE_REF`;Python 使用 +`launch_template(manifest, provider=..., reference=...)`。启动前只检查创建、启动、 +就绪和清理实际需要的操作。缺少解释器不阻止无关应用启动。执行具体操作时, +Provider 和服务端策略仍负责实际能力与权限检查。 - - - - - - - - - - +保留 `provides` 中所有服务。SandboxServer 检查协议;其他协议返回客户端, +由作者的就绪命令检查。多服务模板只启动一次,后续选择调用地址不会改变启动内容。 -## 2. 模板来源与分发机制 +## 清单与迁移 -### 2.1 模板名解析规则 +参见[字段参考](../reference/template-yaml-spec.md)和权威的 +[规范 5 schema](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v5.schema.json)。 +删除 `workflow_contract_version`、`default_workflow`、`workflows`,保留 +`spec_version: 5`、`provides`、`start_cmd`、`ready_cmd` 以及应用元数据。 +旧运行清单明确要求迁移,不静默选择旧默认项。源码位置、镜像引用和平台模板 ID +属于准备过程的输入,不再暴露为用户选择的启动流程。 -源码索引只定位唯一权威 `template.yaml`,不能覆盖其工作流或启动命令。提供方托管条目内嵌完整严格 manifest,且归属于该提供方。本地源码路径、Git 修订、镜像引用和托管 ID 表示不同输入类型,不能当作可互换别名。 +## 资源归属与验证 -| 工作流输入 | 含义 | -| --- | --- | -| `source` | 项目快照,需要已实现的准备路径及显式构建配置 | -| `image` | 已准备镜像;Python 启动器要求不可变 SHA256 引用 | -| `managed` | 提供方拥有的引用;启动需要提供方解析出的不可变身份 | -| `service` | 已存在的应用 HTTP 服务,使用显式绑定及 `startup: none` | +启动器记录创建意图与资源 ID,不自动重放结果不确定的创建请求。 +清理时尝试关闭所有客户端并删除自己创建的沙箱,保留失败记录供核对。 +连接已有沙箱不代表获得删除它的所有权。 -未知引用不授予默认操作。Python 独立启动器不准备源码项目;未解析的托管 ID 在创建前拒绝。源码 Dockerfile 不会根据 `base` 元数据被重写,也不会替换成猜测的备用镜像。 +目录收录或静态计划支持不等于实测成功。历史工作流证据保留,但不认证规范 5。 +模板元数据版本不代表有一个 Registry 版本存储服务器;复现应使用真实 Git 修订和运行输入。 -### 2.2 各入口使用方式 - -对于已有沙箱,使用 `ProviderSandbox.connect(...)`,再通过 `await sandbox.application_client(port, token=...)` 访问应用。这会解析独立的提供方端口端点;提供方内部控制或执行端点不是应用 URL。 - -对于新建的已准备镜像工作流,`agent_sandbox.api.launch_workflow(...)` 接收真实 manifest、工作流 ID、提供方和不可变引用。在发送应用负载前检查条件。见[完整 Python 示例与限制](../guide/template-workflows.md#python-应用调用)。 - -CLI 目录指引使用 `abx template launch SOURCE --workflow ID`;如果存在 portable profile,可移植源码构建还需显式选择 `--build-profile portable`。所选 CLI/提供方必须实际支持对应输入及启动路径。Express 当前只声明 managed 输入和 entrypoint/none 启动;这说明本项目实现范围,不能据此断言平台其他原生 API 无法构建镜像。 - -### 2.3 GitHub tarball 解析流程 - -源码获取和执行分开:解析请求的源码修订,安全获取项目,读取唯一 manifest,冻结符合条件的源码快照,再规划所选工作流。下载归档或命中缓存都不证明提供方支持或运行验证通过。远端认证失败不能静默改为匿名重试,也不能改变源码身份。 - -### 2.4 模板包结构(仓库/subdir 内容) - -```text -my-template/ -├── template.yaml # spec_version: 4,workflow_contract_version: 1 -├── Dockerfile # 显式原生镜像构建 -├── Dockerfile.portable # 可选,显式选择的构建 profile -├── commands.py # 使用命令服务时的应用命令 -└── requirements.txt # 需要时声明应用依赖 -``` - -可选 `AGENTS.md` 由外部智能体工具读取,不会增加运行时操作。快照身份排除不安全的秘密/忽略路径,拒绝不支持的符号链接输入;证据必须写到冻结快照之外,避免自引用。 - -### 2.5 官方模板仓库 - -[awesome-templates](https://github.com/Alibaba-Agent-Sandbox/awesome-templates) 拥有源码 manifest、Dockerfile、发布 schema 和目录。[awesome-providers](https://github.com/Alibaba-Agent-Sandbox/awesome-providers) 拥有可安装的适配器。内置 Express 描述由 CLI 拥有。Registry 和官网消费这些来源,不会补足缺失的处理器。 - -### 2.6 GitHub Token 配置(私有仓库与限流) - -源码下载凭据属于获取过程,提供方凭据属于控制面,网关请求头属于解析出的端点,应用 token 属于应用。不能跨受众复制。CLI 实际 token 配置以其维护的文档为准;不得把凭据或签名端点 URL 放入 manifest、receipt 或公开诊断。 - -### 2.7 本地缓存管理 - -缓存只是获取优化。复用需要匹配预期身份,不能跳过当前 manifest 校验、操作规划或验证资格检查。可变分支名不能代替当前 receipt 要求的不可变源码/构建/产物身份。 - -## 3. 自定义模板方式 - - -### Dockerfile 与显式构建 profile - -将真实依赖、用户、文件和入口设置写入所选 Dockerfile。Portable profile 是显式替代方案,不是对原生构建的静默重写。源码根 context 使用带引号字符串 `'.'`,使 YAML 调用方对其类型达成一致。 - - -### 严格 manifest - -`template.yaml` 承载模板元数据和具名工作流。单独的 `sandbox.yaml` 客户端配置不是元数据别名。具名应用函数通过沙箱内服务的 `@command` 注册;YAML 字段不会生成函数。见[编写模板](../guide/authoring-templates.md)。 - - -### 发布源码 - -发布真实源码修订并保留 manifest/构建输入。可变 ref 可以定位源码,但验证必须绑定解析出的内容。发布源码不等于发布提供方托管引用,也不会使工作流变成已验证。 - -## 4. 模板规范(template.yaml) - -### 完整字段定义 - -使用完整[规范参考](../reference/template-yaml-spec.md#完整字段定义)和源仓库 schema,避免维护另一份独立字段清单。发布必须包含 name、version、description、author、license 元数据。`spec_version` 是整数 `4`;`workflow_contract_version` 是整数 `1`。 - -### 字段分类说明 - -| 声明 | 职责 | -| --- | --- | -| `provides` | 模板实际提供的服务协议/版本/端口和应用操作 | -| `workflows` | 具名的 kind、input、startup、binding、operation、service 及附加条件 | -| `default_workflow` | 未指定其他 ID 时选择的唯一已声明工作流 | -| `start_cmd` / `ready_cmd` | 显式启动/就绪命令;非空就绪命令即使配合 entrypoint/none 也需要执行能力 | -| `build_profiles` | 显式 Dockerfile/context 选择,不做隐式可移植改写 | -| `server` | 沙箱内端点策略,必须由所选构建/启动路径实际安装并应用 | - -旧 `capabilities`、`tested_on`、手写 `providers` 兼容结论和未知工作流字段均被拒绝。省略字段不授予 `{shell, files, code}`。`base` 仍是合法元数据,不会静默改写 Dockerfile `FROM`,也不会构造模板继承链。 - -### 关键设计要点 - -1. 模板提供软件和应用协议,无法通过声明实现平台适配器或调用方处理器。 -2. 提供方操作契约按调用方分别声明精确操作,以及支持的输入/启动方式。例如 `exec.run`、`ports.resolve`、`code.context.execute` 是不同操作。 -3. 规划采用三值:T 允许继续本地检查;F 表示明确不匹配或缺失实现;U 表示信息尚未解析。F 优先于 U。本项目缺少适配不能证明原生平台缺少该能力。 -4. 应用服务可以通过 shell 启动及已解析 HTTP 端口使用,不需要统一解释器。缺少解释器只阻断对应解释器工作流。 -5. SDK/CLI 传输和网络绑定必须符合所选操作。即使处理器存在,服务端策略仍可拒绝应用端点。未知发现结果按拒绝处理;应用负载在协议及策略检查之后发送。 -6. 成熟度、本地环境检查和当前可信验证相互独立。Stable 标签或历史测试不能认证当前 manifest、调用方及绑定。 - -### 示例:基于 GitHub 模板的扩展 - -扩展源码项目应编辑真实 Dockerfile 和严格 manifest,不能依赖远端 `base.from` 继承规则。源仓库的 [python-hello manifest](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/python-hello/template.yaml) 为同一 `app` 服务分别声明源码及已准备镜像应用工作流,portable 构建 profile 需显式选择。借鉴这一结构仍需实际提供方/调用方支持及本地凭据,不是就绪保证。 - -## 5. 模板版本管理 - -### 语义版本号 - -模板 `version` 是元数据。规范版本、工作流契约版本、已解析源码修订、manifest 摘要、产物摘要和调用方修订具有不同含义。SemVer 标签不能代替这些身份。 - -### 版本选择 - -显式选择源码修订及已声明工作流。已准备镜像启动需要摘要固定的镜像;managed 启动需要不可变提供方解析。Python 启动器不会展开虚构的模板 SemVer 范围,也不会自动把仓库引用翻译成云模板。 - -### 版本列表 - -从拥有目录/源码的仓库读取可用版本和 manifest。只有源码、manifest、构建、产物、提供方代码、调用方、工作流、绑定及契约身份匹配,当前验证才具备资格。即使人类可读名称不变,内容变化也会使旧证据无法复用。 - -## 6. 模板市场架构 - -Registry 聚合源仓库元数据、计算分范围规划并展示合格证据。官网展示已接收的严格目录快照。两者构建目录页面时均不执行访客工作负载,也不获取访客云凭据。 - -### CLI 命令总览 - -当前命令面由 [CLI 使用模板指南](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/zh/guide/using-templates.md) 维护。目录工作流启动显式指定源码及工作流 ID。提供方原生部署、应用部署拓扑和工作流启动各自遵循显式操作要求。 - -### 模板安全 - -信任需要独立核实的已批准 CI 来源及匹配产物。模板可增加必需检查,不能移除验证 profile 下限。必需检查缺失、失败或跳过均不合格。作者填写的 PASS JSON、runner 名称或历史 smoke 输出不能自证。签名和安全审计必须有实际证据,不能假定每个目录条目都具备。 - -## 7. 备注:Session 管理与模板的关系 - -工作流规划静态源码/产物的某种用法,只拥有其创建的运行资源。创建意图和返回资源 ID 必须持久记录;不确定创建不能自动重试。即使 journal 写入或连接关闭失败,清理仍尝试删除自有资源,未解决资源仍保留以便对账。关闭已有沙箱连接不会取得其所有权,也不删除它。 - -应用 HTTP 绑定保留沙箱代理前缀及签名 query 值,分离网关请求头与应用 token,检查端点有效期,并拒绝重定向、authority 覆盖及编码命名空间穿越。沙箱内 HTTP 服务仍是独立协议,拥有端点策略和 token scopes;见[服务端 API 设计](server-api.md)。 - -## 相关:abx CLI - -参见 CLI [使用模板](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/zh/guide/using-templates.md)、当前 [Python 工作流指南](../guide/template-workflows.md)、[模板规范](../reference/template-yaml-spec.md)及[应用服务 API](../guide/template-server-api.md)。固定版本标准客户端维持独立公共契约;项目提供方适配器当前不给 Express/E2B/REST 声明统一解释器操作,CubeSandbox/container 仅实现持久默认上下文操作。 +参见 [Python 启动示例](../guide/template-workflows.md)、 +[模板编写](../guide/authoring-templates.md)和[服务端 API](../guide/template-server-api.md)。 diff --git a/docs/zh/guide/authoring-templates.md b/docs/zh/guide/authoring-templates.md index 783c4f90..092e64bb 100644 --- a/docs/zh/guide/authoring-templates.md +++ b/docs/zh/guide/authoring-templates.md @@ -6,7 +6,7 @@ 这是 **Server SDK**:`commands.py` 随镜像启动,在沙箱里面提供 HTTP 服务。在你的机器上创建并调用沙箱见 [Client SDK](client-sdk.md)。 -本文档介绍如何创建自定义 Agent Sandbox 模板,包括目录结构、字段参考、显式工作流、注册命令和发布流程。 +本文档介绍如何创建自定义 Agent Sandbox 模板,包括目录结构、字段参考、整体启动、注册命令和发布流程。 模板是一个由纯文件组成的目录,可手写也可生成。`abx template scaffold` 命令有三种生成方式(内置脚手架、一句话描述、适配已有源码的项目);该工作流记录在 CLI 仓库:[模板编写(CLI)](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/guide/authoring-templates.md)。 @@ -32,40 +32,32 @@ my-template/ ```yaml name: my-python-template -version: "1.0.1" -description: "Scaffold for a new Agent Sandbox template. Not a catalog entry." -author: "alibaba-agent-sandbox" +version: 1.0.1 +description: Scaffold for a new Agent Sandbox template. Not a catalog entry. +author: alibaba-agent-sandbox license: Apache-2.0 -spec_version: 4 -base: "fc-e2b-registry..cr.aliyuncs.com/runtime/base:" +spec_version: 5 +base: fc-e2b-registry..cr.aliyuncs.com/runtime/base: generation: 2 -start_cmd: "agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py" -ready_cmd: "agent-sandbox-server healthcheck --port 9000" - +start_cmd: agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py +ready_cmd: agent-sandbox-server healthcheck --port 9000 resources: cpu: 2 memory: 2048 - ports: - - 9000 - +- 9000 tags: - - example - - skeleton - +- example +- skeleton priority: P2 wave: other - - env: - ABX_SERVER_TOKEN: "" - + ABX_SERVER_TOKEN: '' smoke: - - command: hello - args: - name: Express - expect: "Hello, Express!" -workflow_contract_version: 1 +- command: hello + args: + name: Express + expect: Hello, Express! provides: app: protocol: agent-sandbox-server @@ -74,20 +66,6 @@ provides: operations: - commands.list - commands.run -default_workflow: application -workflows: - application: - kind: application - input: source - service: app - startup: exec - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: commands.run - required_operations: [] ``` --- @@ -105,8 +83,6 @@ workflows: | `ports` | `list[int]` | ❌ | `[]` | 暴露的端口列表 | | `author` | `str` | ❌ | `""` | 作者 | | `license` | `str` | ❌ | `""` | 许可证 | -| `spec_version` / `workflow_contract_version` | 整数 | ✅ | — | `4` / `1` | -| `provides` / `workflows` / `default_workflow` | 映射 / 标识符 | ✅ | — | 显式工作流契约 | | `tags` | `list[str]` | ❌ | `[]` | 标签 | 详细的字段定义参见 [template.yaml 规范](../reference/template-yaml-spec.md)。 @@ -114,11 +90,14 @@ workflows: --- -## 模板工作流 +## 模板启动 -模板的 `provides` 描述软件服务,`workflows` 描述显式调用路径。默认工作流只指向一条声明,不推导其他能力。旧 `capabilities`、`tested_on` 和 Provider 兼容表均拒绝;未知远端引用不授予默认解释器。应用 HTTP 不依赖统一代码解释器。精确操作、T/F/U、上下文及验收范围见[显式模板工作流](template-workflows.md)。 +使用 `spec_version: 5`,在 `provides` 声明全部服务。`start_cmd` 和 `ready_cmd` +作用于完整模板,不再声明或选择工作流。Provider 提供平台操作,服务端策略控制应用 API, +二者都不推导解释器支持。见[模板启动指南](template-workflows.md)。 -服务端 HTTP 端点策略独立控制九个能力组;`core` 始终开启,`dev_tools`、`browser`、`agents` 默认关闭。`server:` 不是 Provider 操作授权,工作流也不自动打开服务端策略。详见[模板服务器 HTTP API](template-server-api.md)。 +服务端端点策略保持独立:`core` 始终开启,`dev_tools`、`browser`、`agents` +需要显式配置。模板元数据声明 API 不代表打开对应服务端策略。 --- @@ -151,7 +130,7 @@ if __name__ == "__main__": ## 提供者模板配置 -服务端不选择 Provider。9000 是应用 HTTP 默认监听端口;平台注入的 envd、解释器及保留端口属于各后端,不能据此推导应用服务不可用。`startup: exec`、`entrypoint`、`none` 是不同路径,必须和 Provider 声明一致;不要让原生入口与启动命令重复运行。非空 `ready_cmd` 必须实际检查。构建配置必须明确选择,其有效 Dockerfile 和构建参数进入输入身份。 +服务端不选择 Provider。9000 是应用 HTTP 默认监听端口;平台注入的 envd、解释器及保留端口属于各后端,不能据此推导应用服务不可用。声明的 `start_cmd` 只运行一次,没有该字段时使用镜像或平台模板入口;不要让原生入口与启动命令重复运行。非空 `ready_cmd` 必须实际检查。构建配置必须明确选择,其有效 Dockerfile 和构建参数进入输入身份。 `commands.py` 只导入 `agent_sandbox.server`,在主入口调用 `start(port)`;镜像显式安装 server SDK。 已发布 0.3.1a1 alpha 中的 Provider 原生应用 API 使用 `from agent_sandbox import ProviderSandbox`, diff --git a/docs/zh/guide/client-sdk.md b/docs/zh/guide/client-sdk.md index 47dbc2b7..77015ecd 100644 --- a/docs/zh/guide/client-sdk.md +++ b/docs/zh/guide/client-sdk.md @@ -5,7 +5,7 @@ Client SDK 跑在**你的机器**上。它创建沙箱、连上已经存在的沙箱,然后在沙箱里执行命令、读写文件、调用代码解释器。包名是 `agent-sandbox-client`,导入 `agent_sandbox`。 ```bash -python -m pip install 'agent-sandbox-client==0.3.1a5' +python -m pip install 'agent-sandbox-client==0.4.0a1' python -m pip check ``` diff --git a/docs/zh/guide/install-python.md b/docs/zh/guide/install-python.md index b558200b..3861e3a0 100644 --- a/docs/zh/guide/install-python.md +++ b/docs/zh/guide/install-python.md @@ -1,5 +1,7 @@ # 安装 Python 配套包 +> 候选版本文档:0.4.0a1 尚未发布。发布前请按下文从检出源码安装验证;旧 0.3.x 包不支持规范 5 启动。 + 使用 Python 3.10 或更高版本及虚拟环境: ```bash @@ -7,7 +9,7 @@ python -m venv .venv # Linux 或 macOS: source .venv/bin/activate # Windows PowerShell:.venv\Scripts\Activate.ps1 -python -m pip install --index-url https://pypi.org/simple 'agent-sandbox-client==0.3.1a5' +python -m pip install --index-url https://pypi.org/simple 'agent-sandbox-client==0.4.0a1' python -m pip check ``` @@ -24,7 +26,7 @@ python -c "from importlib.metadata import version; print(version('agent-sandbox- ## 可选依赖 ```bash -python -m pip install 'agent-sandbox-client[all]==0.3.1a5' +python -m pip install 'agent-sandbox-client[all]==0.4.0a1' ``` 可把 `[all]` 换成 `[mcp]`、`[session]` 或 `[fast]`。工作流 YAML 和声明式装饰器属于基础客户端,无需 extra。浏览器自动化需要在沙箱镜像内安装 Playwright。 @@ -32,7 +34,7 @@ python -m pip install 'agent-sandbox-client[all]==0.3.1a5' ## 镜像内只安装服务端 ```bash -python -m pip install 'agent-sandbox-server==0.3.1a5' +python -m pip install 'agent-sandbox-server==0.4.0a1' ``` 服务端负责在镜像内运行应用,安装它不会创建沙箱。注册命令与启动服务见[服务端指南](server-sdk.md)。 diff --git a/docs/zh/guide/sdk-usage.md b/docs/zh/guide/sdk-usage.md index b45da6f5..9dc07de2 100644 --- a/docs/zh/guide/sdk-usage.md +++ b/docs/zh/guide/sdk-usage.md @@ -7,7 +7,7 @@ ## 安装 -请按[安装指南](install-python.md)安装。公开客户端/服务端 alpha 版本为 0.3.1a5;安装客户端会安装匹配的服务端依赖。宿主机安装不会更新已有沙箱镜像。 +请按[安装指南](install-python.md)安装。公开客户端/服务端 alpha 版本为 0.4.0a1;安装客户端会安装匹配的服务端依赖。宿主机安装不会更新已有沙箱镜像。 不要执行 `pip install agent-sandbox`。PyPI 上的 `agent-sandbox` 是另一个无关项目。 diff --git a/docs/zh/guide/template-workflows.md b/docs/zh/guide/template-workflows.md index bdcfd473..56b556d6 100644 --- a/docs/zh/guide/template-workflows.md +++ b/docs/zh/guide/template-workflows.md @@ -21,21 +21,43 @@ finally: 如果 `X-Access-Token` 请求头未被占用,客户端还会通过服务端已有的这个请求头发送同一个显式应用令牌,使认证能通过 OpenSandbox 1.1.1 等会过滤 `Authorization` 的代理。已有网关请求头(包括注入 HTTP 客户端的默认请求头)不会被覆盖;需要 Bearer 的服务仍可使用原认证方式。 -使用已准备的镜像工作流创建独立沙箱: +通过已准备好的 Provider 引用启动完整模板: ```python -from agent_sandbox.api import launch_workflow +from agent_sandbox.api import launch_template -async with await launch_workflow( - "template.yaml", provider="opensandbox", workflow_id="application-image", - reference=immutable_image_reference, application_token=application_token, +async with await launch_template( + "template.yaml", provider="opensandbox", + reference=prepared_reference, application_token=application_token, ) as session: result = await session.run_command("hello", name="Python") ``` -模板必须声明所选工作流。Python 不构建源码项目;先用 CLI 准备,再连接返回的沙箱 ID,或用构建产物的 SHA256 镜像引用启动 `image` 工作流。`managed` 工作流直接接受所声明平台的原生模板 ID,不要求平台提供版本号。外部应用使用 `service` 工作流及显式应用 URL。这个独立启动器不执行 application 部署 hooks,也不重新解释应用部署拓扑。 +不再选择工作流。Python 创建一个沙箱,执行一次 `start_cmd`,检查 `ready_cmd`, +并解析 `provides` 中所有服务。SandboxServer 服务执行 health/discovery 检查; +其他协议由模板的就绪命令检查,返回对应客户端但不猜测其协议路由。 +`session.services` 包含全部服务客户端,`session.servers` 包含 SandboxServer 客户端。 +只有一个 SandboxServer 时可以使用 `session.server` 和 `session.run_command`; +多个时使用 `session.servers["服务名"].commands.run(...)` 选择调用目标。 +这不会改变已经整体启动的模板内容。 -启动器固化 manifest 输入,核对 Provider 实际代码摘要,在创建前持久化意图并记录自有资源 ID,在调用前检查应用协议版本,只删除自己创建的沙箱。创建结果不确定时不重试创建,保留 journal,可按 `abx_workflow_run` metadata 核对资源。清理失败会保留资源 ID 并抛错。journal 不写 API key、应用令牌、环境秘密或带签名的端点 URL,也不等于可信验证收据。 +Python 不构建源码。先使用 CLI 准备并传入生成的 Provider 引用,或连接 CLI 返回的沙箱。 +模板也可声明固定摘要的 `image`,或者 `provider` 与 `template_id` 原生模板标识。 +引用由 Provider 解释,原生 ID 不需要虚构版本。已有外部 HTTP 服务直接使用 +`ServerClient`;应用部署 hook 仍由部署编排器处理。 + +启动器冻结 manifest、记录创建意图和所拥有的资源 ID,仅删除自己创建的沙箱。 +创建结果不确定时不自动重试,可使用 `abx_template_run` 元数据核对资源。 +后续服务失败也会关闭之前解析的所有客户端。日志不记录令牌、环境变量值和签名 URL, +也不等于云端验证证据。同一个最终 `ABX_SERVER_TOKEN` 同时用于沙箱环境和客户端。 + +## 从规范 4 迁移 + +改为 `spec_version: 5`,删除 `workflow_contract_version`、`default_workflow`、 +`workflows`,保留完整的 `provides`、实际 `start_cmd` 和 `ready_cmd`。 +CLI 命令删除 `--workflow`;Python 改用 `launch_template`,不传 `workflow_id`。 +旧清单明确报告迁移错误,不自动挑选以前的默认工作流。 +历史规范 4 证据保留,但不能证明规范 5 的启动链路已验证。 ## 解释器操作 @@ -58,7 +80,9 @@ finally: await sandbox.kill() # 本段代码创建并负责删除这个沙箱 ``` -已有声明 `input: managed` 的 manifest 时,也可以调用 `launch_workflow(manifest, provider="alicloud-express", reference="base")`;它的异步上下文管理器会删除自己创建的沙箱。启动前仍检查所属平台、所需操作及启动方式。原生 ID 没有内容摘要也可以使用,journal 会如实记录产物摘要未知,不承诺内容不可变。 +规范 5 清单声明 `provider: alicloud-express`、`template_id: base` 和 +`provides: {}` 后,可以调用 `launch_template(manifest, provider="alicloud-express")`, +通过异步上下文管理器自动删除自己创建的沙箱。不再声明或选择工作流。 在 `code-interpreter-v1` 上运行 shell 命令,并不代表调用了解释器 API。极速版、E2B 和 REST 的项目适配器尚未接入统一解释器操作,需要时使用已记录的平台原生 SDK;CubeSandbox 和容器版接入了持久默认上下文操作。 diff --git a/docs/zh/reference/template-yaml-spec.md b/docs/zh/reference/template-yaml-spec.md index 38f00977..338596ea 100644 --- a/docs/zh/reference/template-yaml-spec.md +++ b/docs/zh/reference/template-yaml-spec.md @@ -1,6 +1,6 @@ # template.yaml 规范 -当前模板使用 `spec_version: 4` 和 `workflow_contract_version: 1`。发布清单的完整权威 schema 是 [awesome-templates/template-v4.schema.json](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v4.schema.json);共享工作流契约见 [contracts](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts)。应用部署拓扑、hooks 和持久状态字段仍由完整 schema 定义。 +当前模板使用 `spec_version: 5`。发布清单的完整权威 schema 是 [awesome-templates/template-v5.schema.json](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/blob/main/spec/template-v5.schema.json);共享启动契约见 [contracts](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts)。应用部署拓扑、hooks 和持久状态字段仍由完整 schema 定义。 ## 文件位置 @@ -25,21 +25,18 @@ `generation` 属于具体模板和提供方启动配置,目录源码模板 schema 当前要求 `2`。是否可以创建,还要检查提供方实际支持的输入和启动方式。 -### 工作流声明 +### 模板启动 -| 字段 | 含义 | -| --- | --- | -| `spec_version` | 必须为整数 `4` | -| `workflow_contract_version` | 必须为整数 `1` | -| `provides` | 服务映射;每项声明 `protocol`、`version`、`port`、`operations` | -| `default_workflow` | 必须指向一个已声明工作流 | -| `workflows` | 具名 `application`、`exec` 或 `interpreter` 路径 | +`spec_version` 为整数 `5`。`provides` 通过 `protocol`、`version`、`port`、`operations` +描述全部服务,也可以为空。`start_cmd` 整体启动模板一次;没有它时使用镜像或平台模板 +自身入口。`ready_cmd` 如有声明,在返回服务客户端前执行。固定 `image` 或 +`provider` 与 `template_id` 可以定位已准备模板;其他情况由 CLI 准备源码。 +Python 要求已准备好的引用。 -每个工作流显式提供 `kind`、`input`(`source/image/managed/service`)、`startup`(`exec/entrypoint/none`)、`binding`、`operation` 和 `required_operations`;应用工作流另需 `service` 指向 `provides`。绑定包含 `transport`、`endpoint`、`caller`、`authentication`。未知工作流字段直接拒绝。 - -`startup: exec` 需要非空 `start_cmd` 和 `exec.run`。已有入口使用 `entrypoint`,不能再次执行启动命令。`service` 输入只能配 `startup: none`。非空 `ready_cmd` 需要执行能力并实际检查,不能因为启动方式不同而忽略。托管引用必须属于所选 Provider。模板可增加 `required_operations` 和 `required_checks`,不能删减派生要求或最低验收检查。 - -不再接受模板 `capabilities`、`tested_on` 或手填 `providers` 兼容表,也没有默认 `{shell, files, code}` 授权。应用提供 Python/HTTP 服务,不表示 Provider 实现了代码解释器操作。详见[显式模板工作流](../guide/template-workflows.md)。 +从规范 4 迁移时删除 `workflow_contract_version`、`default_workflow`、`workflows`。 +启动时不再选择 application/exec/interpreter 类型或某个操作。旧 `capabilities`、 +`tested_on` 和手填 Provider 兼容表仍拒绝。具体调用能力由 Provider 和服务端实际检查。 +见[模板启动指南](../guide/template-workflows.md)。 ### 已移除的键:`custom_commands` @@ -81,40 +78,32 @@ server: ```yaml name: my-python-template -version: "1.0.1" -description: "Scaffold for a new Agent Sandbox template. Not a catalog entry." -author: "alibaba-agent-sandbox" +version: 1.0.1 +description: Scaffold for a new Agent Sandbox template. Not a catalog entry. +author: alibaba-agent-sandbox license: Apache-2.0 -spec_version: 4 -base: "fc-e2b-registry..cr.aliyuncs.com/runtime/base:" +spec_version: 5 +base: fc-e2b-registry..cr.aliyuncs.com/runtime/base: generation: 2 -start_cmd: "agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py" -ready_cmd: "agent-sandbox-server healthcheck --port 9000" - +start_cmd: agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py +ready_cmd: agent-sandbox-server healthcheck --port 9000 resources: cpu: 2 memory: 2048 - ports: - - 9000 - +- 9000 tags: - - example - - skeleton - +- example +- skeleton priority: P2 wave: other - - env: - ABX_SERVER_TOKEN: "" - + ABX_SERVER_TOKEN: '' smoke: - - command: hello - args: - name: Express - expect: "Hello, Express!" -workflow_contract_version: 1 +- command: hello + args: + name: Express + expect: Hello, Express! provides: app: protocol: agent-sandbox-server @@ -123,20 +112,6 @@ provides: operations: - commands.list - commands.run -default_workflow: application -workflows: - application: - kind: application - input: source - service: app - startup: exec - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: commands.run - required_operations: [] ``` ## 构建说明 diff --git a/e2e/test_managed_workflow.py b/e2e/test_managed_template.py similarity index 64% rename from e2e/test_managed_workflow.py rename to e2e/test_managed_template.py index 28971a9e..753a4b1f 100644 --- a/e2e/test_managed_workflow.py +++ b/e2e/test_managed_template.py @@ -1,4 +1,4 @@ -"""Real Express native IDs through the public workflow launcher.""" +"""Real Express native IDs through the public template launcher.""" import json @@ -6,7 +6,7 @@ import pytest from agent_sandbox import ProviderSandbox -from agent_sandbox.api import launch_workflow +from agent_sandbox.api import launch_template from agent_sandbox.models.errors import SandboxNotFoundError from agent_sandbox.providers.registry import builtin_provider from conftest import wait_envd_ready @@ -18,30 +18,15 @@ def test_native_id_launch_exec_and_owned_cleanup(run, tracker, tmp_path, templat manifest = { "name": template, "provider": "alicloud-express", - "spec_version": 4, - "workflow_contract_version": 1, + "spec_version": 5, + "template_id": template, "provides": {}, - "default_workflow": "exec", - "workflows": { - "exec": { - "kind": "exec", - "input": "managed", - "startup": "none", - "operation": "exec.run", - "required_operations": [], - "binding": { - "transport": "exec", - "endpoint": "provider-native", - "caller": "host", - "authentication": "provider", - }, - }, - }, } - journal = tmp_path / "workflow.jsonl" + + journal = tmp_path / "template.jsonl" async def scenario(): - async with await launch_workflow( + async with await launch_template( manifest, provider=builtin_provider() if as_object else "alicloud-express", reference=template, @@ -52,9 +37,9 @@ async def scenario(): assert sandbox is not None tracker.sandbox(sandbox.id) await wait_envd_ready(sandbox) - result = await sandbox.commands.run("printf managed-workflow-ok") + result = await sandbox.commands.run("printf managed-template-ok") assert result.exit_code == 0 - assert result.stdout == "managed-workflow-ok" + assert result.stdout == "managed-template-ok" sandbox_id = sandbox.id async def absent(): @@ -68,7 +53,6 @@ async def absent(): absent, timeout=30, interval=1, what="owned sandbox deletion" ) events = [json.loads(line) for line in journal.read_text().splitlines()] - assert events[0]["artifact_digest"] == "unknown" assert events[-1]["event"] == "cleanup-complete" run(scenario()) diff --git a/examples/templates/agents-cli/template.yaml b/examples/templates/agents-cli/template.yaml index 3bb50669..16bfd74f 100644 --- a/examples/templates/agents-cli/template.yaml +++ b/examples/templates/agents-cli/template.yaml @@ -1,50 +1,29 @@ -# FIXTURE — offline example kept in this repository so the Agents API case can be -# tested without a publish. Publishable templates live in +# FIXTURE — offline only; publishable templates live in # https://github.com/alibaba-agent-sandbox/awesome-templates name: agents-cli -version: "1.0.0" -description: "Agents API for Codex and Qwen Code. The image does not preinstall either CLI." -author: "alibaba-agent-sandbox" +version: 1.0.0 +description: Agents API for Codex and Qwen Code. The image does not preinstall either + CLI. +author: alibaba-agent-sandbox tags: - - agents-api - - codex - - qwen-code - - self-hosted - +- agents-api +- codex +- qwen-code +- self-hosted base: python:3.12-slim - -spec_version: 4 -workflow_contract_version: 1 +spec_version: 5 provides: app: protocol: agent-sandbox-server version: 2 port: 9000 - operations: [agents.sessions.create] -default_workflow: agents -workflows: - agents: - kind: application - input: source - startup: entrypoint - service: app - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: agents.sessions.create - required_operations: [] - + operations: + - agents.sessions.create ports: - - 9000 - -# Endpoint rules are the only switch. `"*"` closes every endpoint (shell, files, and -# the rest of the Agents API) and the exact rules below open the six routes this -# template serves. An exact endpoint rule also opens an endpoint of an opt-in group. +- 9000 server: endpoints: - "*": + '*': enabled: false agents_sessions_create: enabled: true @@ -58,7 +37,6 @@ server: enabled: true agents_runtimes_list: enabled: true - resources: cpu: 1 memory: 1024 diff --git a/examples/templates/python-hello/template.yaml b/examples/templates/python-hello/template.yaml index 8d30bf66..6f9c00f2 100644 --- a/examples/templates/python-hello/template.yaml +++ b/examples/templates/python-hello/template.yaml @@ -1,41 +1,24 @@ -# FIXTURE — offline test sample only, do NOT publish from this repository. -# The single source of truth for this template (content, index, release) is: -# https://github.com/alibaba-agent-sandbox/awesome-templates/tree/main/python-hello +# FIXTURE — offline only; publishable templates live in +# https://github.com/alibaba-agent-sandbox/awesome-templates name: python-hello -version: "1.0.0" -description: "A minimal Python hello world template for testing" -author: "alibaba-agent-sandbox" +version: 1.0.0 +description: A minimal Python hello world template for testing +author: alibaba-agent-sandbox tags: - - python - - hello-world - - example - -spec_version: 4 -workflow_contract_version: 1 +- python +- hello-world +- example +spec_version: 5 provides: app: protocol: agent-sandbox-server version: 2 port: 9000 - operations: [commands.list, commands.run] -default_workflow: application + operations: + - commands.list + - commands.run start_cmd: agent-sandbox-server serve --port 9000 --commands /home/user/app/commands.py -workflows: - application: - kind: application - input: source - startup: exec - service: app - binding: - transport: http - endpoint: provider-port - caller: host - authentication: service-token - operation: commands.run - required_operations: [] - ports: - - 9000 - +- 9000 env: LANG: C.UTF-8 diff --git a/scripts/check_dists.py b/scripts/check_dists.py index 6901c530..aaafee9f 100644 --- a/scripts/check_dists.py +++ b/scripts/check_dists.py @@ -36,14 +36,14 @@ import agent_sandbox import agent_sandbox.server import yaml -from agent_sandbox.api import launch_workflow +from agent_sandbox.api import launch_template from agent_sandbox import Sandbox, AsyncSandbox, ProviderSandbox from agent_sandbox.server import default_registry, start assert all(callable(cls.create) for cls in (Sandbox, AsyncSandbox, ProviderSandbox)) -assert callable(launch_workflow) +assert callable(launch_template) assert yaml.safe_load("spec_version: 4") == {"spec_version": 4} -print("workflow YAML parser: available in the base install") +print("template YAML parser: available in the base install") print("agent_sandbox :", agent_sandbox.__file__) print("agent_sandbox.server:", agent_sandbox.server.__file__) """ diff --git a/server-sdk/src/agent_sandbox/server/_version.py b/server-sdk/src/agent_sandbox/server/_version.py index ea816294..dfe2f13a 100644 --- a/server-sdk/src/agent_sandbox/server/_version.py +++ b/server-sdk/src/agent_sandbox/server/_version.py @@ -1 +1 @@ -__version__ = "0.3.1a5" +__version__ = "0.4.0a1" From 66c86f2b6dc7cd33e02c2a53d98dda2125d05236 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:36:48 +0000 Subject: [PATCH 3/5] fix: enable verified Express template process startup --- .agents/README.md | 4 +- .../2026-10-25-unified-template-cloud.md | 62 ++++++++++++++ AGENTS.md | 2 +- CHANGELOG.md | 2 + client-sdk/src/agent_sandbox/api/template.py | 3 +- .../providers/alicloud_express/provider.py | 2 +- .../tests/test_api/test_template_launch.py | 13 ++- docs/en/design/template-system.md | 2 +- docs/en/guide/template-workflows.md | 2 +- docs/zh/design/template-system.md | 2 +- docs/zh/guide/template-workflows.md | 2 +- e2e/test_managed_template.py | 84 +++++++++++++++++++ 12 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-25-unified-template-cloud.md diff --git a/.agents/README.md b/.agents/README.md index 7c59f9a5..c8611479 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -6,7 +6,9 @@ The [unified startup proposal](notes/proposed/architecture/2026-10-25-unified-te removes workflow selection and preserves all template services. The [contract foundation](evidence/verify/2026-10-25-unified-template-contract.md) records local shared-vector checks only. [Runtime checkpoint](evidence/verify/2026-10-25-unified-template-runtime.md) records -real native-template acceptance and retained environment failures; full gates are in progress; +real native-template acceptance and retained environment failures. The +[cloud follow-up](evidence/verify/2026-10-25-unified-template-cloud.md) verifies all five +startup scenarios including two HTTP services; final-head gates remain in progress; the previous 92-case matrix below remains specification 4 history, not v5 evidence. ## Previous delivery — 2026-10-24 diff --git a/.agents/evidence/verify/2026-10-25-unified-template-cloud.md b/.agents/evidence/verify/2026-10-25-unified-template-cloud.md new file mode 100644 index 00000000..7076bf26 --- /dev/null +++ b/.agents/evidence/verify/2026-10-25-unified-template-cloud.md @@ -0,0 +1,62 @@ +# Verification: complete-template candidate and real startup + +- Date: 2026-10-25 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/108 +- Result: PARTIAL — startup and configured CLI acceptance passed; final-head remote CI is pending. +- Packages: client/server 0.4.0a1, paired editable checkout and clean built-wheel install. +- Environment: Linux Python 3.13.13, real Express account; credentials supplied through + process environment only. Nothing was published to PyPI during this verification. + +## Startup regression and correction + +The first real multi-service test was refused before resource creation because the +Python Express Provider's startup metadata omitted `exec`, despite having an existing +process startup implementation. Adding `exec` to that Provider's startup declarations +is verified by the actual startup stream below. No capability checks were bypassed. + +`python -m pytest e2e/test_managed_template.py -q -ra`: **5 passed in 22.37 seconds**. +The four native scenarios create `base`/`code-interpreter-v1` using provider name/object, +execute shell commands and confirm deletion. The fifth starts two independent Python +HTTP listeners from one `start_cmd`, executes the template's readiness command, resolves +both Provider endpoints, requests the two declared `/health` routes from the host and +confirms deletion. The journal contains exactly one `started` event. It does not test +an interpreter API or claim arbitrary HTTP protocols have standard health routes. + +The final focused launcher suite passed 21 cases, including unknown protocol/version +endpoints without invented health probes. Only SandboxServer protocol v2 receives +built-in health/discovery; an author-supplied ready command checks overall readiness. + +## Broader validation + +- `make lint`, `make typecheck`: PASS for both packages. +- `make format-check build dist-check`: PASS, including four distributions, twine, + clean combined installation, imports and both `py.typed` markers. +- `make test`: 878 server passes and two environment-intercepted failures, so make + stopped before the client target. Separate full client run: **2260 passed, + 20 skipped, 2 failed** (Cube engine HTTP 502). These failures remain recorded. +- A localhost-only `NO_PROXY` rerun retained the invalid WebSocket upgrade HTTP 501; + chunked HTTP rejection passed alone. No test or server behavior was weakened. +- Remote CI for `ff073167f8993a5c580c78cccccd5779db1f5836`: all 11 checks passed, + including client/server Python 3.10/3.11/3.12, engine integration, build and docs. + The later startup metadata/test commit requires its own remote checks. +- Full `make e2e`: **124 passed, 9 skipped, 2 deselected, 1 failed**. The sole failure + is the CLI scenario lacking `ABX_BIN`; it ran no CLI sandbox operations. Skips are + missing installed optional providers, opt-in volume tests, CLI named-array setup, + and account-dependent pause/snapshot prerequisites. A separately configured CLI + follow-up using the built 0.5.0 CLI passed: **1 passed in 376.63 seconds**. + It exercised create/info/list, exec/stream, upload/download and repeated deletion. + The original missing-prerequisite failure is retained above. + +## Cleanup + +The five new startup scenarios verified owned resources absent after context exit. +The E2E finalizer reported zero leaked tracked sandboxes. The required +`python scripts/e2e-cleanup.py --all-tagged` dry run found zero tagged candidates; +it deleted nothing. The configured CLI follow-up completed its sandbox deletion and repeated-deletion +checks. A second explicitly scoped configuration run is still in progress. + +## Limits + +There is no new whole-catalog cloud matrix, source-image build test in Python, +PyPI release, or claim that specification 4 receipts verify specification 5. The +standard SDK contract remains independent. Application server behavior is unchanged. diff --git a/AGENTS.md b/AGENTS.md index f5714742..6cb163e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,7 +176,7 @@ package copies. Historical workflow/receipt files remain for old evidence only; they do not certify the new launcher. Provider operation declarations remain exact. `api/template.py` exposes `launch_template` for prepared references, journals owned -resources, starts the template once and checks all SandboxServer services. Python +resources, starts the template once and checks all SandboxServer protocol v2 services. Python does not build source. Use `session.services` for all resolved clients and `session.servers` for SandboxServer clients. Do not block startup on an unrelated interpreter operation. See [the launch guide](docs/en/guide/template-workflows.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 013a6ce2..3147e61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ Candidate: not yet published. - Specification 5 starts complete templates without workflow selection. Python now exposes `launch_template`, `TemplateSession` and `TemplateLaunchError`, preserving all declared services and cleanup ownership. Source preparation remains CLI-owned. +- Express Provider startup metadata now includes its implemented process-start path, + verified with a real two-service template. - Migrate specification 4 by removing workflow declarations and using the existing startup/readiness fields. `launch_workflow` and `workflow_id` are removed; historical receipts remain unchanged and do not certify the new launch path. diff --git a/client-sdk/src/agent_sandbox/api/template.py b/client-sdk/src/agent_sandbox/api/template.py index 8233d7e5..1c7cab0d 100644 --- a/client-sdk/src/agent_sandbox/api/template.py +++ b/client-sdk/src/agent_sandbox/api/template.py @@ -64,6 +64,7 @@ def __init__( name: client for name, client in services.items() if plan["services"][name]["protocol"] == "agent-sandbox-server" + and plan["services"][name]["version"] == 2 } self.server = next(iter(self.servers.values())) if len(self.servers) == 1 else None self.journal_path = journal.path @@ -304,7 +305,7 @@ async def launch_template( for name, service in plan["services"].items(): client = await sandbox.application_client(service["port"], token=token) clients[name] = client - if service["protocol"] != "agent-sandbox-server": + if service["protocol"] != "agent-sandbox-server" or service["version"] != 2: continue deadline = asyncio.get_running_loop().time() + readiness_timeout while not await client.health(): diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py index 76e3c96d..d6db9afe 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py @@ -85,7 +85,7 @@ class AlicloudExpressProvider(Provider): } ) workflow_inputs = frozenset({"managed"}) - startup_modes = frozenset({"entrypoint", "none"}) + startup_modes = frozenset({"exec", "entrypoint", "none"}) inherit = frozenset(GENERIC_ENDPOINT_KEYS) def __init__(self) -> None: diff --git a/client-sdk/tests/test_api/test_template_launch.py b/client-sdk/tests/test_api/test_template_launch.py index f4284391..7dffc823 100644 --- a/client-sdk/tests/test_api/test_template_launch.py +++ b/client-sdk/tests/test_api/test_template_launch.py @@ -205,7 +205,7 @@ async def test_client_close_failure_still_deletes_owned_sandbox(tmp_path): sandbox = SimpleNamespace(id="owned", provider="fixture", kill=AsyncMock()) journal = tmp_path / "run" session = TemplateSession( - {"services": {"app": {"protocol": "agent-sandbox-server"}}}, + {"services": {"app": {"protocol": "agent-sandbox-server", "version": 2}}}, sandbox, {"app": server}, _Journal(journal), @@ -272,7 +272,7 @@ async def test_journal_failure_never_blocks_owned_cleanup(tmp_path): sandbox = SimpleNamespace(id="owned", provider="fixture", kill=AsyncMock()) server = SimpleNamespace(aclose=AsyncMock()) session = TemplateSession( - {"services": {"app": {"protocol": "agent-sandbox-server"}}}, + {"services": {"app": {"protocol": "agent-sandbox-server", "version": 2}}}, sandbox, {"app": server}, journal, @@ -400,9 +400,14 @@ async def test_fixed_mutable_image_is_rejected_before_create(tmp_path, monkeypat create.assert_not_awaited() -async def test_other_protocol_is_exposed_without_guessing_http_routes(tmp_path, monkeypatch): +@pytest.mark.parametrize( + ("protocol", "version"), [("custom-http", 1), ("agent-sandbox-server", 99)] +) +async def test_other_protocol_is_exposed_without_guessing_http_routes( + tmp_path, monkeypatch, protocol, version +): body = manifest() - body["provides"]["app"]["protocol"] = "custom-http" + body["provides"]["app"].update(protocol=protocol, version=version) server = AsyncMock() sandbox = SimpleNamespace( id="owned", diff --git a/docs/en/design/template-system.md b/docs/en/design/template-system.md index 02f4ebb6..9853b6e8 100644 --- a/docs/en/design/template-system.md +++ b/docs/en/design/template-system.md @@ -20,7 +20,7 @@ operations needed to create, start, inspect readiness and clean up. Missing inte operations never prevent an unrelated application from starting. Actual operations remain guarded by the Provider and server policy at invocation time. -Every `provides` service is retained. SandboxServer endpoints receive protocol checks; +Every `provides` service is retained. SandboxServer protocol v2 endpoints receive protocol checks; unknown protocols are returned and use the author's readiness command. A multi-service template starts once. Calling one endpoint later does not start a different template. diff --git a/docs/en/guide/template-workflows.md b/docs/en/guide/template-workflows.md index 14ebe220..16dc6f14 100644 --- a/docs/en/guide/template-workflows.md +++ b/docs/en/guide/template-workflows.md @@ -34,7 +34,7 @@ async with await launch_template( ``` There is no workflow selection. Python creates one sandbox, runs `start_cmd` once, -checks `ready_cmd`, and resolves every service in `provides`. SandboxServer services +checks `ready_cmd`, and resolves every service in `provides`. SandboxServer protocol v2 services receive health/discovery checks before use. Other service protocols use the template's readiness command; their endpoints are returned without guessing protocol routes. `session.services` contains every service client, while `session.servers` contains diff --git a/docs/zh/design/template-system.md b/docs/zh/design/template-system.md index 40fdb0d5..3153dbfa 100644 --- a/docs/zh/design/template-system.md +++ b/docs/zh/design/template-system.md @@ -16,7 +16,7 @@ CLI 使用 `abx --provider PROVIDER template launch TEMPLATE_REF`;Python 使 就绪和清理实际需要的操作。缺少解释器不阻止无关应用启动。执行具体操作时, Provider 和服务端策略仍负责实际能力与权限检查。 -保留 `provides` 中所有服务。SandboxServer 检查协议;其他协议返回客户端, +保留 `provides` 中所有服务。SandboxServer 协议 v2 额外检查 health/discovery;其他协议返回客户端, 由作者的就绪命令检查。多服务模板只启动一次,后续选择调用地址不会改变启动内容。 ## 清单与迁移 diff --git a/docs/zh/guide/template-workflows.md b/docs/zh/guide/template-workflows.md index 56b556d6..05ec5bad 100644 --- a/docs/zh/guide/template-workflows.md +++ b/docs/zh/guide/template-workflows.md @@ -34,7 +34,7 @@ async with await launch_template( ``` 不再选择工作流。Python 创建一个沙箱,执行一次 `start_cmd`,检查 `ready_cmd`, -并解析 `provides` 中所有服务。SandboxServer 服务执行 health/discovery 检查; +并解析 `provides` 中所有服务。SandboxServer 协议 v2 服务执行 health/discovery 检查; 其他协议由模板的就绪命令检查,返回对应客户端但不猜测其协议路由。 `session.services` 包含全部服务客户端,`session.servers` 包含 SandboxServer 客户端。 只有一个 SandboxServer 时可以使用 `session.server` 和 `session.run_command`; diff --git a/e2e/test_managed_template.py b/e2e/test_managed_template.py index 753a4b1f..19f19034 100644 --- a/e2e/test_managed_template.py +++ b/e2e/test_managed_template.py @@ -56,3 +56,87 @@ async def absent(): assert events[-1]["event"] == "cleanup-complete" run(scenario()) + + +def test_complete_template_starts_both_http_services(run, tracker, tmp_path): + """Real startup stream, readiness command, port resolution and owned cleanup.""" + import shlex + + start = """ +import http.server, threading +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = b'{"status":"ok"}' + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass +for port in (9101, 9102): + server = http.server.ThreadingHTTPServer(('0.0.0.0', port), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() +threading.Event().wait() +""" + ready = """ +import time, urllib.request +for attempt in range(100): + try: + for port in (9101, 9102): + url = f'http://127.0.0.1:{port}/health' + assert urllib.request.urlopen(url, timeout=1).status == 200 + break + except Exception: + time.sleep(.1) +else: + raise RuntimeError('template services not ready') +""" + manifest = { + "name": "two-http-services", + "provider": "alicloud-express", + "spec_version": 5, + "template_id": "base", + "start_cmd": "python3 -u -c " + shlex.quote(start), + "ready_cmd": "python3 -c " + shlex.quote(ready), + "provides": { + name: {"protocol": "http", "version": 1, "port": port, "operations": []} + for name, port in (("first", 9101), ("second", 9102)) + }, + } + journal = tmp_path / "multi-service.jsonl" + + async def scenario(): + async with await launch_template( + manifest, + provider="alicloud-express", + timeout=180, + readiness_timeout=30, + journal_path=journal, + ) as session: + sandbox = session.sandbox + assert sandbox is not None + tracker.sandbox(sandbox.id) + assert set(session.services) == {"first", "second"} + assert session.servers == {} + # These test services explicitly implement /health. The launcher + # itself does not invent this route for arbitrary HTTP protocols. + assert await session.services["first"].health() + assert await session.services["second"].health() + sandbox_id = sandbox.id + + async def absent(): + try: + await ProviderSandbox.get_info_by_id(sandbox_id, provider="alicloud-express") + except SandboxNotFoundError: + return True + return False + + await e2e_helpers.eventually_async( + absent, timeout=30, interval=1, what="multi-service owned deletion" + ) + events = [json.loads(line) for line in journal.read_text().splitlines()] + assert sum(event["event"] == "started" for event in events) == 1 + assert events[-1]["event"] == "cleanup-complete" + + run(scenario()) From 14fad5c4a37ec01e41f859ea318f27353faf6421 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:51:50 +0000 Subject: [PATCH 4/5] docs: preserve CLI diagnostic failure and focused verification --- .agents/README.md | 2 +- ...6-10-25-unified-template-cli-diagnostic.md | 79 +++++++++++++++++++ .../2026-10-25-unified-template-launch.md | 7 +- 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-25-unified-template-cli-diagnostic.md diff --git a/.agents/README.md b/.agents/README.md index c8611479..eb9c0833 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -8,7 +8,7 @@ removes workflow selection and preserves all template services. The local shared-vector checks only. [Runtime checkpoint](evidence/verify/2026-10-25-unified-template-runtime.md) records real native-template acceptance and retained environment failures. The [cloud follow-up](evidence/verify/2026-10-25-unified-template-cloud.md) verifies all five -startup scenarios including two HTTP services; final-head gates remain in progress; +startup scenarios including two HTTP services. The [CLI diagnostic follow-up](evidence/verify/2026-10-25-unified-template-cli-diagnostic.md) keeps the separate passing and failing CLI runs, final implementation CI and focused file-transfer results explicit; the previous 92-case matrix below remains specification 4 history, not v5 evidence. ## Previous delivery — 2026-10-24 diff --git a/.agents/evidence/verify/2026-10-25-unified-template-cli-diagnostic.md b/.agents/evidence/verify/2026-10-25-unified-template-cli-diagnostic.md new file mode 100644 index 00000000..7bd50c65 --- /dev/null +++ b/.agents/evidence/verify/2026-10-25-unified-template-cli-diagnostic.md @@ -0,0 +1,79 @@ +# Verification: CLI acceptance discrepancy and focused file diagnostic + +- Date: 2026-10-25 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/108 +- Diagnostic follow-up: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/109 +- Candidate: paired Python packages 0.4.0a1 and built CLI 0.5.0. +- Implementation head: `66c86f2b6dc7cd33e02c2a53d98dda2125d05236`. +- Publication/merge: neither was performed by this verification. + +This follows the [earlier cloud checkpoint](2026-10-25-unified-template-cloud.md). +The five unified-template startup cases passed, including two services started +from one template. They are independent of the CLI file-transfer discrepancy below. + +## Final implementation CI + +All 11 remote checks passed for implementation head `66c86f2`: client and server +on Python 3.10/3.11/3.12, Python-to-Node Provider engine, package build, hygiene, +pinned standard contract and Markdown links. An evidence-only follow-up commit +must still receive its own applicable checks. + +## Keep the two full CLI runs separate + +| Run | Result | Duration | Detail | +| --- | --- | --- | --- | +| Configured CLI follow-up | PASS | 376.63 s | Lifecycle, list, exec/stream, upload/download and repeated deletion passed. | +| Additional explicit Express environment mapping | FAIL | 355.17 s | Lifecycle/list/exec/stream passed; upload exit 2, download exit 4 and absent downloaded content; both deletion calls exited 0. | + +The second run mapped the E2B API key, API URL and domain environment names to +existing ABX_SANDBOX Express settings. Credential values are not recorded. Its +second deletion reported the owned sandbox absent. + +Read-only review of `e2e/provider_live.py::cli_express` confirms that creation sets +`--timeout 300` and each subprocess allows up to 240 seconds. The fixture discards +file-command stderr and does not record individual operation durations or HTTP +status. Both whole-run durations exceed 300 seconds, but the passing run was longer. +This cannot establish that the failed transfer hit the sandbox deadline. Exit 2 +and exit 4 cover multiple error categories and cannot identify a cause alone. + +The failure's root cause remains **unconfirmed**. It is not attributed to template +startup, Provider support, file API logic or network interception. Issue 109 owns +the missing diagnostic evidence; no unrelated runtime or fixture change was added +to the template refactor. + +## Focused real-provider file check + +A separate diagnostic used the same explicit Express environment mapping and +built CLI, a fresh engine socket and a newly owned sandbox. It retained monotonic +operation timings and sanitized stderr in memory. It ran only this command sequence: + +```sh +abx --provider alicloud-express sandbox create base --detach --timeout 300 +abx --provider alicloud-express sandbox upload "$SANDBOX_ID" "$SOURCE_FILE" /tmp/unified-template-file-diagnostic.txt +abx --provider alicloud-express sandbox download "$SANDBOX_ID" /tmp/unified-template-file-diagnostic.txt "$DEST_FILE" +abx --provider alicloud-express sandbox kill "$SANDBOX_ID" +abx --provider alicloud-express sandbox kill "$SANDBOX_ID" +``` + +The runner saved the detached ID from creation, used a temporary local directory, +wrote `unified-template-file-diagnostic` followed by a newline, compared downloaded +bytes exactly, and always ran deletion in `finally`. No credentials or raw private +responses are included in this record. No retries or product changes were used. + +| Operation | Elapsed | Since start | Exit | Observation | +| --- | --- | --- | --- | --- | +| Create | 42.601 s | 42.601 s | 0 | One detached sandbox ID; stderr empty. | +| Upload | 29.551 s | 72.153 s | 0 | stderr empty. | +| Download | 30.885 s | 103.038 s | 0 | stderr empty; downloaded bytes equal source bytes. | +| Delete | 38.036 s | 141.074 s | 0 | stderr empty. | +| Delete again | 39.871 s | 180.945 s | 0 | Reports the owned sandbox was not found. | + +**Focused result: PASS**, total 180.946 seconds. File operations completed before +300 seconds even when measuring from before creation; both cleanup commands passed +and the owned sandbox was confirmed absent. The CLI exposed no failing HTTP status +in this successful run, so none is invented here. + +This establishes that the candidate's real file upload/download and cleanup worked +in this experiment. It does not explain or erase the failed full-gate run, prove +repeatability under all conditions, or close Issue 109. No further cloud reruns or +unsupported claims about deadline behavior were made. diff --git a/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md b/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md index a79f951e..0ab64d1a 100644 --- a/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md +++ b/.agents/notes/proposed/architecture/2026-10-25-unified-template-launch.md @@ -17,7 +17,7 @@ remain immutable historical inputs and do not certify specification 5 launches. ## API Design `launch_template(manifest, provider=..., reference=...)` starts a previously prepared -reference, checks all SandboxServer services and returns their clients. Python does +reference, checks SandboxServer protocol v2 services and returns all declared endpoints. Python does not acquire source building. The static planner returns `supported`, required/missing operations, all services and an optional unsupported-combination reason. @@ -36,7 +36,10 @@ operations, all services and an optional unsupported-combination reason. ## Test Strategy Shared cross-language vectors, SDK model and launcher regressions, full package CI, -and real provider startup/cleanup. Current runtime verification is pending. +and real provider startup/cleanup. Five new startup scenarios passed; all 11 checks +for implementation head `66c86f2` passed. The separate CLI acceptance rerun has an +unconfirmed file-transfer failure tracked in +https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/109. ## Acceptance criteria From 97888a7a8008053ecef2eb252324a0e8fa165dd6 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:09:19 +0000 Subject: [PATCH 5/5] docs: state stable specification 5 installation requirements --- .agents/README.md | 2 + ...6-10-25-python-a1-publication-preflight.md | 38 +++++++++++++++++++ docs/en/guide/install-python.md | 4 +- docs/zh/guide/install-python.md | 4 +- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-25-python-a1-publication-preflight.md diff --git a/.agents/README.md b/.agents/README.md index eb9c0833..8b4acdbd 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -10,6 +10,8 @@ real native-template acceptance and retained environment failures. The [cloud follow-up](evidence/verify/2026-10-25-unified-template-cloud.md) verifies all five startup scenarios including two HTTP services. The [CLI diagnostic follow-up](evidence/verify/2026-10-25-unified-template-cli-diagnostic.md) keeps the separate passing and failing CLI runs, final implementation CI and focused file-transfer results explicit; the previous 92-case matrix below remains specification 4 history, not v5 evidence. +The [publication preflight](evidence/verify/2026-10-25-python-a1-publication-preflight.md) +records candidate checks and stable installation requirements without claiming publication. ## Previous delivery — 2026-10-24 diff --git a/.agents/evidence/verify/2026-10-25-python-a1-publication-preflight.md b/.agents/evidence/verify/2026-10-25-python-a1-publication-preflight.md new file mode 100644 index 00000000..04c3fbf9 --- /dev/null +++ b/.agents/evidence/verify/2026-10-25-python-a1-publication-preflight.md @@ -0,0 +1,38 @@ +# Verification: 0.4.0a1 publication preflight + +- Date: 2026-10-25 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/108 +- Result: PASS for the read-only preflight; no merge, tag creation or publication. + +At the time of this check, PR 108 was draft/open and mergeable without conflicts. +Its head `14fad5c4a37ec01e41f859ea318f27353faf6421` passed all 11 remote CI jobs. +Both public PyPI version endpoints for client/server 0.4.0a1 returned HTTP 404, +as did the remote `v0.4.0a1` tag and GitHub release endpoints. This records absence +at inspection time, not a claim about subsequent availability. + +`python scripts/check_release.py candidate --tag v0.4.0a1` passed. Both package +versions and the required client dependency on the server are 0.4.0a1. The repository +has the `PYPI_API_TOKEN` Actions secret name configured; its value was not read or +printed, and this check does not establish its upload validity. The `pypi` +environment reported no protection rules. No publication credential was copied. + +The publication procedure remains the existing one in CONTRIBUTING: after merge +and required checks, bind an immutable matching tag; run tag CI; build and preserve +four artifacts; verify their metadata, installation and any existing public hashes; +upload server before client; verify public hashes and a fresh exact-version client +installation with the matching server dependency. A GitHub prerelease triggers the +existing release workflow; manual tag runs default to dry-run. + +The current English and Chinese installation guides now state the matching package +requirements without the time-sensitive claim that the version is unpublished. +Both specify CLI 0.5.0 for specification 5 template launch; the Chinese page's stale +0.4.16 reference was corrected. Source-checkout installation remains available. +Package README installation commands do not pin an obsolete release. Historical +verification records were not rewritten. + +Documentation checks: repository health 106 passes / 1 warning / 0 failures; +local-path/credential hygiene and whitespace checks passed. The new documentation +head still requires its own remote checks. No unrelated cloud tests were repeated. +The unexplained extra full CLI run remains tracked separately in +https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/109; the focused file +check and unified-template startup passes do not erase that history. diff --git a/docs/en/guide/install-python.md b/docs/en/guide/install-python.md index 58b7bf18..a73cb4ab 100644 --- a/docs/en/guide/install-python.md +++ b/docs/en/guide/install-python.md @@ -1,6 +1,6 @@ # Install the Python packages -> Candidate documentation: 0.4.0a1 is not yet published. Validate this revision with the editable checkout installation below until publication. The previous 0.3.x package does not implement specification 5 launch. +> Specification 5 template launch requires the matching `agent-sandbox-client==0.4.0a1` and `agent-sandbox-server==0.4.0a1` alpha packages used in this guide. The previous 0.3.x packages do not implement this entry point. To validate a source revision, install both distributions from the same checkout as described below. Use Python 3.10 or newer and a virtual environment: @@ -21,7 +21,7 @@ Check the installed pair: python -c "from importlib.metadata import version; print(version('agent-sandbox-client'), version('agent-sandbox-server'))" ``` -Install the [abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) separately. For the template workflows in this guide, use CLI 0.5.0 or newer and configure the selected provider before creating a sandbox. +Install the [abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) separately. For template launch in this guide, use CLI 0.5.0 or newer and configure the selected provider before creating a sandbox. ## Optional dependencies diff --git a/docs/zh/guide/install-python.md b/docs/zh/guide/install-python.md index 3861e3a0..e1d82336 100644 --- a/docs/zh/guide/install-python.md +++ b/docs/zh/guide/install-python.md @@ -1,6 +1,6 @@ # 安装 Python 配套包 -> 候选版本文档:0.4.0a1 尚未发布。发布前请按下文从检出源码安装验证;旧 0.3.x 包不支持规范 5 启动。 +> 规范 5 的模板启动需要本指南使用的配套 alpha 包:`agent-sandbox-client==0.4.0a1` 和 `agent-sandbox-server==0.4.0a1`。旧 0.3.x 包不实现此入口。验证源码修订时,请按下文从同一份检出同时安装两个发行包。 使用 Python 3.10 或更高版本及虚拟环境: @@ -21,7 +21,7 @@ python -m pip check python -c "from importlib.metadata import version; print(version('agent-sandbox-client'), version('agent-sandbox-server'))" ``` -[abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) 需要独立安装。本指南中的模板工作流使用 CLI 0.4.16 或更高版本;创建沙箱前先配置所选平台。 +[abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) 需要独立安装。本指南中的模板启动使用 CLI 0.5.0 或更高版本;创建沙箱前先配置所选平台。 ## 可选依赖