diff --git a/.agents/README.md b/.agents/README.md index c25be5b..30eb496 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -46,8 +46,13 @@ Move the file and edit `Status:` in the same commit. Required sections and the t - [Bounded MCP binary files](notes/implemented/feature/2026-10-16-mcp-binary-files.md) · [verification](evidence/verify/2026-10-16-mcp-binary-files.md) +- [Provider portability and updates](notes/proposed/architecture/2026-10-18-provider-portability-updates.md) · [verification](evidence/verify/2026-10-18-provider-portability-updates.md) - [Independent template deployment metadata](notes/implemented/feature/2026-10-18-template-deploy-feature.md) · [verification](evidence/verify/2026-10-18-template-deploy-feature.md) +- [Provider isolation verification](evidence/verify/2026-10-18-provider-complete-isolation.md) + +- [Explicit native engine routing verification](evidence/verify/2026-10-18-provider-explicit-engine-routing.md) + Auth migration: [explicit sandbox API keys](notes/implemented/architecture/2026-10-18-data-plane-auth-contract.md). Accepted engine recovery: [avoid uncertain mutation replay](notes/implemented/architecture/2026-10-18-engine-write-replay.md). @@ -61,3 +66,8 @@ Builtin transport: [direct HTTP](notes/implemented/architecture/2026-10-18-nativ - [Distinct local listener ports](notes/implemented/process/2026-10-18-distinct-local-server-ports.md). - [Current paired source installation](notes/proposed/process/2026-10-18-current-source-installation.md). + +- [Provider/main integration](notes/proposed/architecture/2026-10-18-provider-main-integration.md) · [verification](evidence/verify/2026-10-18-provider-main-integration.md) +- [Current-host provider integration](evidence/verify/2026-10-18-provider-current-host-checkpoint.md) + +- [Provider port and file URL boundary checkpoint](evidence/verify/2026-10-18-provider-port-url-boundaries.md) diff --git a/.agents/evidence/README.md b/.agents/evidence/README.md index 88c9437..588732b 100644 --- a/.agents/evidence/README.md +++ b/.agents/evidence/README.md @@ -18,6 +18,13 @@ Evidence of the command-line interface lives in the ## `verify/` — test and E2E runs +- [`verify/2026-10-16-provider-volume-main-integration.md`](verify/2026-10-16-provider-volume-main-integration.md) + +- [`verify/2026-10-16-provider-volume-live.md`](verify/2026-10-16-provider-volume-live.md) + +- [`verify/2026-10-16-provider-management-recheck.md`](verify/2026-10-16-provider-management-recheck.md) +- [`verify/2026-10-16-provider-management-final-review.md`](verify/2026-10-16-provider-management-final-review.md) +- [`verify/2026-10-16-provider-command-integration-final.md`](verify/2026-10-16-provider-command-integration-final.md) - [`verify/2026-10-16-mcp-background-tasks.md`](verify/2026-10-16-mcp-background-tasks.md) - [`verify/2026-09-04-full-suite.md`](verify/2026-09-04-full-suite.md) @@ -25,3 +32,5 @@ Evidence of the command-line interface lives in the - [`verify/2026-09-05-server-framework-full-suite.md`](verify/2026-09-05-server-framework-full-suite.md) - [`verify/2026-09-08-real-e2e-verification.md`](verify/2026-09-08-real-e2e-verification.md) - [`verify/2026-10-12-e2e-foundation.md`](verify/2026-10-12-e2e-foundation.md) + +- [Provider portability and updates](../notes/proposed/architecture/2026-10-18-provider-portability-updates.md) · [verification](verify/2026-10-18-provider-portability-updates.md) diff --git a/.agents/evidence/verify/2026-10-16-provider-command-integration-final.md b/.agents/evidence/verify/2026-10-16-provider-command-integration-final.md new file mode 100644 index 0000000..0d8efdb --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-command-integration-final.md @@ -0,0 +1,29 @@ +# Verification: final provider command integration + +- Date: 2026-10-16. +- Result: PARTIAL; real cloud storage acceptance, shared-host landing and review remain required. +- Product revision: `29544dd11067288abb55865b636ea2ada0c554d9`. +- Main synchronization: `991c36c4515b7800d999e137408d49ccfbd4a685` adds the archive translation + and its records. Only the changelog conflicted; both changes are retained using standard + Added/Changed groups. No product source was changed by conflict resolution. +- Final sequential client suite on a unique per-run Unix socket: + `cd client-sdk && ABX_ENGINE_SOCKET= python -m pytest tests -q --tb=short` + passed **1981 tests**, with **23 skips**, **zero warnings**, in 255.35 seconds. +- The same ten mocked lifecycle integration cases passed on an isolated socket (2.07 seconds). + This supports default-socket reuse as the cause of the earlier nine authentication failures; + no unknown engine was killed or replaced. Earlier failed/concurrent evidence is preserved + in [the prior review record](2026-10-16-provider-management-final-review.md). +- After changing an existing ambiguous test-key literal to an explicit test placeholder, + `tests/test_engine/test_engine.py::test_engine_rejects_catalog_provider` passed independently + (one test, 1.02 seconds). Commit secret scanning passed on the corrected staged files. +- Mount/gRPC/model/protocol focused checks: 82 passed. Both packages' lint/format/mypy, + build and clean combined-wheel installation/import passed, as recorded in the prior review. +- Full local server gate remains failed (847 passed, 8 failed); no full make-ci pass is claimed. +- After main synchronization, `bash scripts/check-repo-health.sh` passes: 106 pass, + one warning, zero failures; local document targets and ADR references resolve. + `git diff --check` also passes. +- The current revision's GitHub matrix is pending. Delivery remains + [draft PR #38](https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/38), dependent on + [CLI PR #35](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/pull/35). +- Real Volume storage/mount and cloud cleanup acceptance remain unavailable; this local suite + creates no cloud storage and does not establish cloud mount compatibility. diff --git a/.agents/evidence/verify/2026-10-16-provider-management-commands.md b/.agents/evidence/verify/2026-10-16-provider-management-commands.md new file mode 100644 index 0000000..81ddb70 --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-management-commands.md @@ -0,0 +1,139 @@ +# Verification: provider management commands + +- Date: 2026-10-16 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/34 +- Result: PARTIAL; required repository/cloud gates remain blocked or failing as detailed below. +- Initial base revision: `bb7881d`; initial checks used an uncommitted scoped implementation. +- Final tested product revision after rebasing: `ae185c2d85724f3fc4d2cd87c233682184b6a2af` on `origin/main` `35da216a300f1e2e84254a1cf4b4a7ea98681505`. +- Package under test: client/server source trees, both version 0.3.0. Existing development environment reused; no dependency or lockfile changes. +- Environment: Linux, Python 3.13.13, Node 22.22.2; compatible Node CLI source engine. No credentials were printed or added to the isolated checkout. + +## What was verified + +The source changes are `client-sdk/src/agent_sandbox/providers/{manifest,installer,engine_client,commands,builtin,__init__}.py`. +Tests are `client-sdk/tests/test_providers/test_commands.py`, `test_commands_engine.py` and the +scoped built-in feature assertion in `test_registry_scoping.py`. Documentation/records accompany +those files. Manifest descriptors are bounded and included in consent; SDK metadata calls resolve no credentials and SDK invocation calls use the +same explicit provider in route and connection, close their clients, and never replay a +command POST. Three unimplemented standard feature claims are removed. + +The opt-in engine tests used a real Node process, authenticated local HTTP and a real +installed temporary Node plugin. They discovered exactly 20 built-in operations, retrieved +a descriptor, executed read/write handlers, required mutation confirmation, and rejected +extra input properties. The plugin made no cloud requests. This proves the local process +contract, not Alibaba Cloud resource support. + +## Commands run before rebasing + +All commands used the source checkout on `PYTHONPATH` (client and server source directories) +and the existing development tools on `PATH`. When a Make target changes directory, +`PYTHONPATH` was set to absolute source directories before invocation. + +| Command | Result | Evidence / limitation | +| --- | --- | --- | +| `python -m pytest client-sdk/tests/test_providers/{test_commands,test_manifest,test_installer,test_registry_scoping}.py -q --tb=short` | PASS | Latest run: 224 passed. Includes schema keyword/property-name distinction and explicit empty sections. | +| `ABX_COMMAND_TEST_BIN='' python -m pytest client-sdk/tests/test_providers/test_commands_engine.py -q --tb=short` | PASS | 2 passed initially; also included in the final 226-test run; real local Node engine and temporary plugin, no cloud. | +| Combined scoped suite plus `test_commands_engine.py`, with `ABX_COMMAND_TEST_BIN` set | PASS | Final run: 226 passed in 29.00s. Includes credential-free discovery and explicit region forwarding. | +| `make lint` | PASS | Both packages; latest static run also included the new engine tests. | +| `make format-check` | PASS | Both packages. | +| `cd client-sdk && mypy src/agent_sandbox/` | PASS | 84 source files; repeated after new command API was added. | +| `make typecheck` | FAIL | Stops at untouched `server-sdk/src/agent_sandbox/server/routes_browser.py:75`: unused type-ignore under the current environment. | +| `make test` | FAIL | Server phase: 852 passed, 8 failed; client phase was not reached by Make. Details below. | +| `cd client-sdk && python -m pytest tests/ -q --tb=short` | PASS | 2006 passed, 21 skipped in 159.13s; collected before the opt-in real-engine file was added. The latest schema refinements were verified by the 224-test scoped rerun. | +| `make hygiene` | PASS | No local paths or credential-like strings. | +| `bash scripts/check-repo-health.sh` | FAIL | 105 pass, 1 warning, 1 failure: existing English-only archived OpenSandbox research has no Chinese mirror. New relative links resolve. | +| `make e2e` | FAIL / BLOCKED cloud | 116 skipped for missing credentials/prerequisites, 2 deselected; 2 negative provider cases fail because catalog plugins are not installed in this isolated home. No cloud verification claimed. | +| `make build` | PASS | Both sdists and wheels built. | +| `env -u PYTHONPATH make dist-check` | PASS | Four archives pass twine; py.typed exists in both wheels; both wheels import from the clean temporary installation. The clean rerun avoids source-tree shadowing. | +| `make ci` | FAIL | Hygiene, server lint and formatting passed; existing server typecheck gate blocks the remaining aggregate target. | +| `git diff --check` | PASS | No whitespace errors. | + +## Results and existing gate failures + +The eight server failures do not touch provider-command source. Raw WebSocket/HTTP framing +cases receive environment-generated HTTP 501 or no expected status, rather than the local +server response. The unconfigured policy compatibility case sees an absent app instance, +and the packages case does not find pytest through its subprocess package listing. These +failures remain visible; no unrelated server code or tests were changed to suppress them. + +The full client suite's skips include the existing Node-provider tests, which still look +for a sibling directory named `cli`. The new opt-in tests take an explicit compatible binary +and were run separately without skips. No skipped suite is treated as engine verification. + +The isolated checkout did not contain a local credential file and its inherited environment +had no Express API key or Alibaba AccessKey pair. This describes the recorded attempt only; +additional authorized cloud verification with securely supplied credentials remains possible. + +A distribution check started before the final rebuild had finished and reported a missing +client wheel. It was repeated after the build completed; only the completed-build check +is used for final distribution evidence. + +The first complete pre-commit attempt rejected one new `isinstance` form under its pinned +Ruff version and reformatted an f-string fixture; both were corrected in source. Bandit also +flagged four existing subprocess boundaries in the two modified modules: B404 imports and +B603 calls. Review confirmed the installer uses fixed git argv, a constrained environment and +no shell; the engine launches the user's explicitly selected trusted local ABX_BIN/PATH +executable, not a remote command. Only those four lines received precise Bandit audit +annotations. No hook configuration, check selection or runtime behavior was weakened. + +The engineering record stays proposed. The local branch initially inherited three unrelated commits after `origin/main`. The final +rebase excluded that range; the final verification section below records the resulting scope. + +## Cleanup + +The real-engine tests use temporary HOME/provider directories and request engine shutdown in +fixture finalizers. They create no cloud resources. The attempted cloud suite skipped resource +creation; its Make cleanup target was not reached after the two negative-case failures. +No claim about unrelated pre-existing cloud resources is made. + +## Not verified + +Real Alibaba Cloud management operations, remote CI's interpreter matrix, required review, +publication/merge, and a fully green repository gate set. Both package builds and clean wheel installation/import checks passed independently of the +aggregate CI gate. Required failures above remain unresolved. + + +## Final verification after rebasing + +The reviewed implementation was committed with all repository hooks enabled, then rebased +with `git rebase --onto origin/main bb7881d feat/provider-commands-20261016`. Fetch resolved +`origin/main` to `35da216a300f1e2e84254a1cf4b4a7ea98681505`. Only the task commit remains in +that range, at product revision `ae185c2d85724f3fc4d2cd87c233682184b6a2af`. +The CHANGELOG conflict was resolved from the fetched main text plus this task's two provider +sections; inherited MCP, uv-workspace and server-loader changes were not reintroduced. +`git diff origin/main..HEAD --name-only` contains exactly the 19 approved task paths. + +The initial implementation commit ran the secret scanner and complete configured pre-commit +framework, including pinned Ruff, formatting, detect-secrets and Bandit. All applicable hooks +passed. User identity and hooksPath were supplied for the task command only; no global Git +configuration or hook configuration was changed, and no check was skipped by override. + +These checks used the final product revision above and the existing Python 3.13.13 development +environment. Client tests used absolute client/server source directories in `PYTHONPATH`; +`ABX_COMMAND_TEST_BIN` selected the compatible source Node CLI, so the new real-engine tests +were enabled. Distribution checks explicitly removed `PYTHONPATH` to verify installed wheels. + +| Command | Result | Final revision evidence | +| --- | --- | --- | +| `cd client-sdk && python -m pytest tests/ -q --tb=short` | PASS | 1944 passed, 21 skipped, 16 warnings in 172.25s. The two new real-engine tests were enabled. | +| `make lint format-check` | PASS | Both packages; all source/test files satisfy the configured checks. | +| `cd client-sdk && mypy src/agent_sandbox/` | PASS | 84 source files. | +| `make ci` | FAIL (exit 2) | Hygiene, server lint and server format pass; unchanged `server/routes_browser.py:75` has an unused type-ignore in this environment. Make stops before later aggregate targets. | +| `make build` | PASS | Both 0.3.0 sdists and wheels built on the rebased source. | +| `env -u PYTHONPATH make dist-check` | PASS | All four distributions pass twine; both typed wheels install together and import from temporary site-packages. | +| `bash scripts/check-repo-health.sh` | FAIL (exit 1) | 105 PASS, 1 WARN, 1 FAIL: existing English-only archived OpenSandbox research lacks its Chinese mirror; recommended release guide is absent. | +| `git diff --check` | PASS | No whitespace errors; working tree clean before recording these results. | + +The 16 full-client warnings are from existing integration fixtures whose background PTY +threads cannot bind fixed loopback port 9001 because it is already in use. No unknown +process or pre-existing resource was terminated. They are warnings, not proof that those +PTY fixture paths executed successfully. The provider command tests use isolated temporary +engine discovery and ports and report no failed case. + +Full-suite counts differ from the earlier pre-rebase run because the unrelated MCP changes +and their tests were excluded along with the inherited commits. The final table above is +the evidence for the reviewable branch. Required server/health gates remain failing; real +cloud management acceptance and remote CI/review remain outstanding and are not represented +as completed by these local checks. Host-side cloud evidence belongs to the linked CLI Issue +and can be referenced separately when published. This evidence-only update does not change +product source after the tested revision. diff --git a/.agents/evidence/verify/2026-10-16-provider-management-final-review.md b/.agents/evidence/verify/2026-10-16-provider-management-final-review.md new file mode 100644 index 0000000..599b4cf --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-management-final-review.md @@ -0,0 +1,125 @@ +# Verification: provider management final review and Volume mount parity + +- Date: 2026-10-16 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/34 +- Result: PARTIAL; real storage acceptance and current-revision remote checks/review remain outstanding. +- Revision: scoped uncommitted mount follow-up on `25fd4b6`; six source files in + `client-sdk/src/agent_sandbox/{api/sandbox.py,models/sandbox.py,engine/client.py,engine/server.py,engine/engine.proto,engine/engine_pb2.py}`, + `client-sdk/tests/test_api/test_volume_mounts.py`, and `client-sdk/tests/test_engine/test_engine.py`. + The opt-in cloud scenario is `e2e/test_09_volume_mounts.py`; documentation and this record accompany the change. +- Environment: Linux, Python 3.13.13, existing development environment; packages remain 0.3.0. + Tests used both source directories on `PYTHONPATH`. No runtime dependency changed. + +## What was verified + +Final review identified a missing distinction: Volume management does not mount a Volume +when a sandbox is created. The public Python creation API now accepts a mount-path to +existing Volume-name dictionary. Validation runs before provider lookup/construction; +unsupported providers fail E8004 before loading their implementation. The existing built-in +gRPC lifecycle carries additive map field 16, and the Python model emits the documented +`volumeMounts: [{name, path}]` REST shape. Omitted/empty hints preserve prior requests. + +Another review found that an already-running older gRPC engine would silently discard an +unknown protobuf field. `HealthStatus.supported_create_options` field 4 now advertises mount +support. Nonempty hints require that capability before Create, with a five-second Health RPC +deadline; absent support fails E8004 with an upgrade/restart suggestion. No existing process +or socket is replaced. Calls without mounts retain prior behavior. + +Schemas remain structurally validated by Python; the shared Node host performs authoritative +Ajv compilation. The built-in Python lifecycle remains gRPC and does not change to the Node +HTTP engine used by provider management commands. Earlier verification and remote results for +`25fd4b6` apply to that older revision, not these new mount changes. The original +[verification evidence](2026-10-16-provider-management-commands.md) is preserved unchanged. + +## Commands run + +Commands below used the development tools on `PATH` and client/server source directories on +`PYTHONPATH` as appropriate. Make targets were run from the repository root; the full client +suite was run from `client-sdk`. + +| Command | Result | Evidence and limitations | +| --- | --- | --- | +| `python -m pytest client-sdk/tests/test_api/test_volume_mounts.py client-sdk/tests/test_engine/test_engine.py client-sdk/tests/test_models/test_sandbox.py client-sdk/tests/test_protocol/test_sandbox.py -q --tb=short` | PASS | Final rerun: 82 passed in 5.28s; includes the final bounded Health negotiation. | +| `make lint format-check typecheck` | PASS | Both packages; mypy checks 56 server and 84 client files. The older environment's server unused-ignore failure does not reproduce here. | +| `make test` | FAIL | Server phase: 847 passed, 8 failed in 373.27s. Make stops before the client phase; failure details below. | +| `cd client-sdk && python -m pytest tests -q --tb=short` | PASS; concurrent observation | 1981 passed, 23 skipped, 47 warnings in 261.21s, while the server suite also ran. Collected before the final Health-timeout-only refinement; the final 82-test rerun covers that refinement. A separate client-only run follows below. | +| Full client suite alone, without a socket override | FAIL | 9 failed, 1972 passed, 23 skipped in 258.64s; no warning summary. All failures are existing mocked lifecycle tests receiving E1001 through a live default gRPC engine instead of their local HTTP patches. | +| Same ten `tests/integration/test_e2e.py` tests with `ABX_ENGINE_SOCKET` pointing to a unique temporary directory | PASS | 10 passed in 2.07s. No source change or unknown process termination was needed. | +| `make hygiene` | PASS | No local paths or credential-like strings reported. | +| `make build` | PASS | Both sdists and wheels built; no runtime dependency or package version change. | +| `env -u PYTHONPATH make dist-check` | PASS | Final build and distribution rerun includes the five-second Health refinement. Four archives pass twine; both typed wheels install together and import from temporary site-packages. | +| `bash scripts/check-repo-health.sh` | FAIL | Final result: 105 PASS, 1 WARN, 1 FAIL; only the existing archived OpenSandbox English/Chinese mismatch remains. All new relative links and record references resolve. | +| Pinned Ruff 0.4.9 check/format and Bandit 1.7.7 on changed Python source/tests and the E2E scenario | PASS | Reproduces hook tool versions without changing hook configuration. Existing audited boundaries received precise line annotations described below. | +| `ruff check e2e/test_09_volume_mounts.py` and `ruff format --check e2e/test_09_volume_mounts.py` | PASS | The new opt-in cloud scenario satisfies style checks independently of package-only Make targets. | +| Compile the embedded E2E probe and execute it against an ordinary temporary directory | PASS | Rejects the missing exact kernel mount and creates no sentinel. This is a negative local harness check, not storage E2E. | +| `ABX_E2E_VOLUME_MOUNT=0 python -m pytest e2e/test_09_volume_mounts.py -q -ra --tb=short` | SKIPPED | One skipped: missing `ABX_SANDBOX_API_KEY`. Storage prerequisites also remain unavailable; no cloud calls or resource creation occurred. | +| `git diff --check` | PASS | No whitespace errors in the scoped follow-up. | + +The protobuf descriptor was regenerated using isolated tooling `grpcio-tools==1.83.0`, whose +`libprotoc 35.1` matches the checked-in Python gencode version 7.35.1. Only `--python_out` was +needed because service definitions did not change. Generation used +`python -m grpc_tools.protoc -I client-sdk/src --python_out=client-sdk/src client-sdk/src/agent_sandbox/engine/engine.proto`. +This tooling is not added to runtime dependencies. + +## Problems found and required gates + +The eight server failures concern raw HTTP/WebSocket framing, policy compatibility and +secret-environment subprocess behavior, in untouched server source. Several receive +environment-generated HTTP 501 instead of the local server response; others return no +response, or the compatibility test finds no configured app. Their exact test names are: + +- `test_app_agents_transport.py::TestResponsesWebSocket::test_upgrade_without_a_key_is_a_400_not_a_hang` +- `test_app_agents_transport.py::TestResponsesWebSocket::test_token_is_required_for_the_upgrade` +- `test_http_hardening.py::TestContentLength::test_valid_still_works` +- `test_http_hardening.py::TestPolicyModeFraming::test_transfer_encoding_rejected_in_policy_mode` +- `test_http_hardening.py::TestBodyLimit::test_lying_content_length_cannot_exceed_cap` +- `test_policy_compat.py::test_unconfigured_server_still_has_the_one_engine` +- `test_policy_exec.py::TestSecretEnvironment::test_server_secrets_never_reach_children` +- `test_policy_pipeline.py::TestAuthentication::test_ambiguous_x_access_token_rejected` + +The concurrent full-client run's warnings include background test threads failing to bind +loopback port 9001 already in use. The client-only run has no warning summary, consistent +with fixture contention in the concurrent run; the concurrent warnings are not attributed +to a baseline product defect. That client-only run instead found nine mocked lifecycle +tests receiving an E1001 `/sandboxes` response through the default gRPC engine rather than +their current process's patched HTTP layer. Running the same ten cases on an explicitly +unique socket passes. We infer shared default-socket reuse across tool processes, whose +PID namespaces do not guarantee filesystem-unique socket names. No source workaround or +unknown process termination was used. A final full run uses a fresh unique socket and +runs sequentially. The 23 skips include two opt-in Node command tests without an explicitly +configured compatible binary; earlier Node-engine evidence remains historical. + +An initial health check ran before this new evidence file existed and therefore reported +two task-owned missing-record/link failures in addition to the pre-existing archived +OpenSandbox English/Chinese mismatch. The final health check must supersede that transient +result; missing-record failures are not described as baseline failures. + +Pinned Ruff requires a precise E721 exception for intentional exact-dictionary validation, +and its older TCH003 spelling for the existing Pydantic runtime datetime import. Bandit +flagged four pre-existing boundaries newly covered by the changed-file scan: the local +Python module subprocess import/launcher, the pytest-only Unix socket path hardened to +owner-only mode, and a terminal executable parameter named `shell` that is not subprocess +`shell=True`. Each received a narrow explanatory annotation after review. No scanner +configuration, selected check or runtime behavior was relaxed. + +## Cleanup + +The mount regressions use local mocked or loopback services. The real gRPC loopback tests +release their test-owned sessions and close their HTTP listeners. They do not create cloud +resources. The E2E scenario is opt-in and was skipped. When enabled with existing storage, +it first requires an exact kernel mount, uses an exclusive no-follow sentinel and bounded +read, checks inode ownership before unlinking, then independently checks cleanup. Existing +Volume metadata and pre-existing customer files are not targeted. The existing tracker +independently kills its tagged sandbox even if marker cleanup fails. +The full server suite generated an untracked five-byte `server-sdk/out.txt`; its bytes were +checked against the existing test fixture's literal payload before deletion. No other +untracked or pre-existing file was removed. +The failed mocked-lifecycle run observed authentication rejection rather than a successful +sandbox creation; it is not a real-cloud acceptance run. No cloud success or cleanup of +unrelated resources is inferred from it. + +## Not verified + +Real Volume mount/read/write/cleanup, storage permissions and role/runtime prerequisites, +current mount revision's remote CI/interpreter matrix and required review. No cloud mount +success, fully green repository gate set, current revision CI pass or merge is claimed. diff --git a/.agents/evidence/verify/2026-10-16-provider-management-recheck.md b/.agents/evidence/verify/2026-10-16-provider-management-recheck.md new file mode 100644 index 0000000..dec418c --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-management-recheck.md @@ -0,0 +1,66 @@ +# Verification: provider management recheck + +- Date: 2026-10-16. +- Result: PARTIAL; real storage and the shared host's remaining cloud acceptance are blocked. +- Issue: [python-sdk #34](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/34). +- Delivery: [draft PR #38](https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/38), dependent on + [CLI draft PR #35](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/pull/35). +- Tested product revision: `92b717c`, including accepted main `666657b`. +- Environment: Linux, Python 3.11 development environment and Node 22 source CLI. + No cloud credentials were passed to these Python tests. + +## What was verified + +An independent read-only review found no new confirmed defect in command discovery/invocation, +manifest consent, no-replay transport, provider isolation or create-time Volume forwarding. +The branch now includes accepted main's server-loader, workspace, lifecycle and MCP task fixes. +Four documentation conflicts were resolved by retaining both features. The automatic merge of +`api/sandbox.py` preserves main's lifecycle APIs and this branch's Volume option. + +## Commands and results + +Commands below use the existing development interpreter on PATH and both source directories +on PYTHONPATH. A compatible absolute Node binary command is supplied for engine integration. + +| Check | Result | Evidence / limitation | +| --- | --- | --- | +| `make lint format-check typecheck` | PASS | Both packages; mypy checked 56 server and 86 client source files. | +| `python -m pytest client-sdk/tests/test_providers/test_commands.py client-sdk/tests/test_api/test_volume_mounts.py client-sdk/tests/test_engine/test_engine.py client-sdk/tests/test_providers/test_commands_engine.py -q --tb=short` with `ABX_COMMAND_TEST_BIN` set | PASS | 95 passed in 20.61 seconds after main synchronization. Includes real local Python-to-Node and gRPC-to-HTTP checks; no cloud. | +| `python -m pytest client-sdk/tests/test_api/test_lifecycle_without_connect.py client-sdk/tests/test_agent/test_mcp_lifecycle.py client-sdk/tests/test_agent/test_mcp_tasks.py -q --tb=short` | PASS | 111 passed in 12.23 seconds; accepted lifecycle/task behavior remains intact. | +| `bash scripts/check-repo-health.sh` | PASS | 106 pass, one warning, zero failures after synchronization and the translated prerequisite clarification. | +| Independent pre-synchronization review | PASS | 93 targeted cases plus two actual Node engine cases; these overlap the later 95 and are not additive. | +| Pre-synchronization remote matrix at `5cc4407` | PASS | Nine checks: both packages on Python 3.10/3.11/3.12, build, hygiene and links. This does not establish the new head's status. | + +The reviewer initially selected a non-executable module instead of `dist/bin/abx.js`, causing +two startup timeouts. With the correct absolute binary command, both cases passed independently +in 33.45 seconds and again in the post-synchronization 95-case run. The failed invocation is +not attributed to product source. No successful full local server-suite rerun is claimed; +earlier failed attempts remain in the linked historical evidence. + +## Documentation and cloud prerequisites + +The [official OSS Volume guide](https://help.aliyun.com/zh/agent-sandbox/user-guide/create-oss-volume) +currently requires invitation access through a support ticket. Both language guides now say +to confirm target account/region/Team access. Successful empty listing does not prove create +or mount authorization. This is separate from the existing Bucket, role and same-Team key +requirements. No credentials or private resource identifiers appear in this record. + +The host recheck verified real `volume.list`, `team.list/get`, `api-key.list/get` and `quota.list` +without mutation; no Volume was visible. Host-owned details are recorded in the +[CLI recheck](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/feat/provider-operations-20261016/.agents/evidence/verify/2026-10-16-provider-management-recheck.md). +No real Python Volume mount or new Team/quota lifecycle is claimed. + +## Cleanup and remaining gates + +All local tests exited. Temporary plugin/engine fixtures finalized normally; no cloud resource +was created by this recheck. The review did not change another task's checkout or resources. + +The shared host must land before this consumer. Authorized existing storage and same-Team key, +Volume invitation access, isolated Team/quota test configuration and real mount/read/write/cleanup +acceptance remain unavailable. The complete required local server gate and final-head remote +matrix must not be inferred from targeted tests. The PR stays draft and the ADR stays proposed. +No required checks or reviews were bypassed and no merge is claimed. + +Historical evidence is preserved in [initial implementation](2026-10-16-provider-management-commands.md), +[mount review](2026-10-16-provider-management-final-review.md) and +[previous integration](2026-10-16-provider-command-integration-final.md). diff --git a/.agents/evidence/verify/2026-10-16-provider-volume-live.md b/.agents/evidence/verify/2026-10-16-provider-volume-live.md new file mode 100644 index 0000000..923d512 --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-volume-live.md @@ -0,0 +1,114 @@ +# Verification: real Python Volume mount and independent OSS readback + +- Date: 2026-10-16. +- Result: PARTIAL overall; real Volume mount/read/write/cleanup PASS. +- Tested SDK revision: `ec595c81030b30f0669de8989c0c432a74d136a0`, both source packages on PYTHONPATH. +- Shared CLI product revision: `940b9d558bd40d364bf9f4619e11f94eb91f37e3`. +- Environment: Linux, Python 3.11.15, Node 22.22.2, Alibaba Cloud cn-hangzhou. +- Issue/PR: [#34](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/34), + [draft PR #38](https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/38). + +## Commands and behavior + +With the user's explicit authorization, the host provisioned temporary infrastructure using +existing AK/SK; the Python run used a newly created same-Team sandbox key. The repository +configuration and original credentials were not changed. Source packages were selected with +PYTHONPATH; `ABX_ENGINE_SOCKET` was unique for this run, `E2E_VOLUME_TEMPLATE=base`, +`ABX_E2E_VOLUME_MOUNT=1`, and the exact owned Volume/path were passed privately. + +```sh +python -m pytest e2e/test_09_volume_mounts.py -q --tb=short +``` + +The formal scenario passed **1 test in 8.06 seconds**. It used the actual public API, local +gRPC engine, real cloud sandbox, kernel mount inspection and guarded sentinel I/O/cleanup. +An additional independent script used `Sandbox.create(volume_mounts=...)` and the public +commands/files/kill/info APIs, with a separate OSS SDK process checking object contents and +subsequent absence. No mock provided these storage results. + +Infrastructure ownership and detailed Team/key/quota results belong to the +[host verification](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/feat/provider-operations-20261016/.agents/evidence/verify/2026-10-16-provider-volume-live.md). + +## Real storage results + +| Check | Result | Observable evidence | +| --- | --- | --- | +| Private OSS Bucket and FC-trusted RAM role | PASS | Dedicated custom policy grants access only to the newly created Bucket; no existing role/policy was modified. | +| Volume create/get/list/delete | PASS | Two sequential test Volumes reached AVAILABLE; role configuration persisted; deleted resources returned not-found. | +| Dedicated same-Team API key | PASS | Exclusive secret file mode 0600; CLI output redacted; key metadata matched the Volume Team. | +| CLI `volume-mount-cloud/existing-volume-roundtrip` | PASS | Exact kernel mount checked before exclusive sentinel write/read/delete; sandbox deletion and built-in leak check passed. | +| Python `e2e/test_09_volume_mounts.py` | PASS | 1 passed in 8.06 seconds through the real built-in gRPC lifecycle. | +| Independent Python/OSS cross-check | PASS | SDK-created sandbox mounted the Volume, wrote/fsynced an exclusive file; separate OSS API read identical bytes, SDK read agreed, and OSS confirmed deletion. | +| Tagged sandbox query | PASS | Zero remaining resources with either exact test-run metadata tag. | + +The test explicitly selected the `base` template in cn-hangzhou; this is not a claim about +all templates or regions. Custom MicroVM source images require ossfs2 >= 2.0.10 according to +the [official mounting guide](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1). +The root credentials were read from the existing ignored configuration and passed privately; +no credential values, account IDs or private resource names are included here. + +## Cleanup + +The initial three test sandboxes were deleted and their absence checked. The first +infrastructure run removed two sequential Volumes, one private Bucket, one RAM role, one +bucket-scoped custom policy and one dedicated API key. A second independent infrastructure +run supplies the added sandbox-recreation check, recorded below. Cleanup order was sandboxes, inactive/delete key, Volume, Bucket objects and +multipart uploads/Bucket, policy attachment/custom policy, then role. Each control plane +confirmed not-found, including OSS NoSuchBucket and RAM EntityNotExist. The generated key +file was removed. Existing Teams, subscriptions, credentials, storage and permissions were +not modified. Separate empty-Team/quota tests deleted their own quota overrides and Teams. + +## Failed setup attempts and limits + +An initial supplemental cross-check passed the unsupported `base_url` keyword to +`Sandbox.create`; it failed with TypeError before creating any sandbox. The corrected +`sandbox_base_url` invocation passed all independent checks. An initial anchored runner +filter selected zero scenarios: its zero exit is excluded from verification results. The +corrected filter explicitly checked the number and status of selected cases. + +An attempt to reuse the deleted Bucket name for the supplemental run received OSS +BucketAlreadyExists (409), despite another NoSuchBucket read. No existing Bucket was +adopted or deleted; a fresh random name passed the unused-name guard and provisioning. + +The legacy locked FCSandbox SDK exposes Volume status updates without a documented writable +transition. No transition was invented or tested. Newer storage types, STS credentials, +dynamic mount/attach, custom domains and other templates/regions are not established by +this run. Domain API prerequisites remain tracked in +[CLI #32](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/issues/32). + +## Repository checks and remaining gates + +- Repository health: **106 PASS / 1 WARN / 0 FAIL**. +- Formal Python real Volume scenario: **1 PASS**; SDK product source is unchanged; the published opt-in E2E scenario adds persistence coverage. +- Hygiene and `git diff --check`: PASS, exit 0. + +The previous required local server suite remains **847 PASS / 8 FAIL**; the full isolated +client suite previously passed **1981 tests / 23 SKIP**. Those historical attempts are in +[integration evidence](2026-10-16-provider-command-integration-final.md). +No new full local make-ci pass is claimed for this E2E/translated documentation follow-up. +Real mount acceptance is now established for the tested configuration. The shared host must +land first, and the required local aggregates remain unresolved. PR #38 stays draft/unmerged; +the ADR remains proposed. Historical evidence is preserved unchanged. + +## Supplemental persistence acceptance + +The extended published Python suite ran at `ec595c8` plus the scoped changes to +`e2e/test_09_volume_mounts.py`: **2 PASS in 17.17 seconds**, exit 0. The original mount +roundtrip passed again. The added persistence case wrote/fsynced an exclusive file, +destroyed the first sandbox, required its exact not-found result, created a different +sandbox mounting the same Volume, then required identical contents and deleted the file. +Both sandbox mount tables were independently inspected before file I/O. Missing persisted +files fail the read step; cleanup mode alone cannot substitute for this assertion. +An independent, paginated SDK query using the exact run metadata confirmed **zero remaining +owned sandboxes** before the test key was revoked. + +The supplemental run's three sandboxes were removed. Its dedicated key was inactivated, +deleted and not-found verified; both sequential Volume resources were deleted; Bucket +objects/multipart uploads and the Bucket were removed (NoSuchBucket); the temporary policy +was detached/deleted and the role deleted (RAM EntityNotExist). The second-run cleanup +completed successfully with every owned resource absent. Across the two infrastructure +runs, six sandboxes, four sequential Volumes and two Bucket/role/custom-policy/key sets +were created and cleaned up; the metadata-only Team/key/quota runs also cleaned up their +own resources. Generated key files were removed; original credentials were unchanged. + +Scoped Ruff lint, Ruff format check and Python compilation of the changed E2E module PASS. diff --git a/.agents/evidence/verify/2026-10-16-provider-volume-main-integration.md b/.agents/evidence/verify/2026-10-16-provider-volume-main-integration.md new file mode 100644 index 0000000..b517b79 --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-volume-main-integration.md @@ -0,0 +1,58 @@ +# Verification: provider Volume acceptance after accepted binary-file integration + +- Date: 2026-10-16. +- Result: PARTIAL overall; fresh focused integration checks recorded below, prior required + local full-server aggregate remains failed. +- Starting branch: `452b5e53379a4d16a1fb95b8b677707a6ae59ed8`. +- Accepted main integrated: `0ce8359` (bounded MCP binary file transfers, PR #49). +- Delivery: [Python PR #38](https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/38). +- Environment: Linux, Python 3.11.15, Node 22.22.2; no cloud credentials supplied to local checks. + +## Merge review + +The remote PR became unmergeable after main accepted the bounded binary-file feature. A +normal merge retains both changelog entries. This is the only textual conflict; README +translations and `api/sandbox.py` merged automatically. Review confirms the create-time +Volume field/old-engine negotiation remain present and native connect now retains main's +bounded HTTP reader. Default file reads retain their prior engine route. No accepted +binary-file source, tests, ADR or evidence was rewritten. + +## Verification + +- `make lint format-check typecheck`: PASS, exit 0; both packages, 56 server and 87 client + source files checked by mypy. +- The following focused tests: **253 PASS in 23.27 seconds**, exit 0. This includes actual + Python-to-Node engine calls, shared commands/consent/no-replay, Volume forwarding, native + gRPC behavior, and accepted MCP binary-file, tools and transport regressions. + +```sh +python -m pytest \ + client-sdk/tests/test_providers/test_commands.py \ + client-sdk/tests/test_api/test_volume_mounts.py \ + client-sdk/tests/test_engine/test_engine.py \ + client-sdk/tests/test_providers/test_commands_engine.py \ + client-sdk/tests/test_agent/test_mcp_binary_files.py \ + client-sdk/tests/test_agent/test_mcp.py \ + client-sdk/tests/test_agent/test_mcp_http.py \ + client-sdk/tests/test_agent/test_tools.py -q --tb=short +``` + +- Repository health: **106 PASS / 1 WARN / 0 FAIL**. +- `make hygiene` and final `git diff --check`: PASS, exit 0. +- Independent read-only reviewer: no confirmed defect in persistence assertions/guarded + cleanup, Volume forwarding/old-engine negotiation or accepted bounded-reader integration. + The reviewer did not read credentials, change files or run cloud tests. + +Checks use both source packages on PYTHONPATH, a unique per-run Unix socket and the absolute +Node CLI binary for the two real local engine tests. No unknown daemon was killed or replaced. +The cloud acceptance at the preceding source state remains historical evidence in +[real Volume acceptance](2026-10-16-provider-volume-live.md): the formal mount/persistence +suite passed 2 cases, with independent zero-owned-sandbox and complete infrastructure cleanup. +This merge adds no cloud resource and does not claim a second live run after synchronization. + +## Remaining gates + +Required full local server aggregate (847 PASS / 8 FAIL) remains unresolved; the complete +client/remote matrices and focused success must not be called a current full local make-ci pass. +The shared CLI host PR must land first. PR #38 and the provider ADR remain draft/proposed. +No gate was weakened and no merge to the default branch is claimed. diff --git a/.agents/evidence/verify/2026-10-18-provider-complete-isolation.md b/.agents/evidence/verify/2026-10-18-provider-complete-isolation.md new file mode 100644 index 0000000..93183f3 --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-complete-isolation.md @@ -0,0 +1,57 @@ +# Verification: Complete provider isolation + +- Date: 2026-10-18 +- Change: proposed ADR `2026-10-18-provider-portability-updates`; Python PR #38, Issue #52. +- Result: PARTIAL — client full suite and real cloud pass; server aggregate has seven failures. +- Revision scope: provider-owned auth/engine/session assembly, structural contracts, compatibility facades and regressions in the accompanying commit; verification preceded this commit. +- Packages: source client/server 0.3.0 plus built wheels/sdists. +- Environment: Python 3.13.13, Linux, real Express cn-hangzhou; credentials supplied through an ignored environment file with values omitted. + +## Commands run + +```sh +cd client-sdk && pytest tests/ -q --tb=short +make test +make lint typecheck format-check +make build dist-check +bash scripts/check-repo-health.sh +``` + +## Results + +| Check | Result | Scope/limitation | +| --- | --- | --- | +| Complete client tests | PASS | 2151 passed, 24 skipped; 148.69 seconds. Includes ownership/import-boundary and configuration regressions | +| Client and server lint/format | PASS | Both packages; 107 client source modules pass mypy, 56 server modules pass mypy | +| Build and distribution checks | PASS | Both wheels/sdists; twine, py.typed, combined clean-venv install/import | +| Current-source real Express | PASS | Create, exec, write/read, connect, list, deletion; post-delete listing confirms absence | +| Aggregate make test | FAIL | Server: 848 passed, 7 failed, 1 skipped; stops before client, whose full suite was run independently | + +## Problems found + +The server aggregate failures are Responses WebSocket upgrade token enforcement, +HTTP Content-Length/Transfer-Encoding/body-limit framing, an unconfigured one-engine +assertion and ambiguous X-Access-Token rejection. This patch does not alter server source; +these failures are recorded without claiming their root cause or waiving acceptance. +The draft remains unmerged until required gates/reviews pass. + +## What was verified + +Provider-owned session binding, URLs, authentication, optional create inputs and resource +cleanup replace the core native/default bypass. Historical public exports remain. The +import-boundary regression prevents concrete backend imports in core implementation files; +explicit composition roots, declarative metadata and public export facades are intentional. +New architecture and configuration docs are available in English and Chinese. + +## Cleanup + +The real source CLI, standalone CLI and Python smoke each tracked and deleted only its own +new Express sandbox. Post-delete listing confirmed each owned ID absent. No Team, key, +Volume, Bucket, role or template was created by these follow-up smoke runs. Existing +storage acceptance remains the separate 2026-10-16 record and is not relabeled as a new test. + +## Not verified + +No new native E2B/Cube/container provisioning or optional image-factory activation is claimed. +No full-region/full-capability acceptance, package publication or verified merge is claimed. +The current implementation remains proposed while mandatory aggregate gates fail. diff --git a/.agents/evidence/verify/2026-10-18-provider-current-host-checkpoint.md b/.agents/evidence/verify/2026-10-18-provider-current-host-checkpoint.md new file mode 100644 index 0000000..bdc4191 --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-current-host-checkpoint.md @@ -0,0 +1,37 @@ +# Verification: Provider current-host integration checkpoint + +- Date: 2026-10-18 +- Result: PARTIAL — client and configured cloud pass; final aggregate/hosted gates pending. +- Scope: integration with main `e43a4d8e2e656adf9f25c20c6ef6ea93ab90f142`; provider-owned auth/session/URLs, lazy schema composition, native HTTP creation and no-replay management. +- Owner: Issue #52, PR #38; host dependency https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/pull/35. +- Environment: Linux, Python 3.11.15 in an isolated development environment; matching source client/server 0.3.0, Node 22.22.2. Credentials supplied in memory from ignored configuration and never recorded. + +## Commands and results + +- `make lint format-check typecheck`: PASS for both distributions, 99 client and 56 server modules. +- Initial integrated client: 2171 PASS / 3 FAIL / 24 SKIP. Failures exposed lost custom-auth classification and the no-replay `retry=False` contract when integrating main. Both fixed with the existing negative regressions. +- Complete client after fixes: `pytest tests/ -q --tb=short`: 2174 PASS / 24 SKIP, 143.93 seconds. +- Original `make test` server checkpoint: 847 PASS / 8 FAIL / 1 SKIP. It included intercepted raw HTTP, a readiness race and absent pip in the test environment. pip was installed as a development prerequisite; no production package behavior was changed for it. +- HTTP fixture follow-up: 67 PASS / 2 FAIL in 38.47 seconds. Placeholder Host returned 501 while the same initialized listener's actual authority returned 200. Correct authorities, complete framed reads and initialization waiting preserve assertions. Two intentionally malformed upgrade/chunked requests remain intercepted with 501/502 and require unchanged hosted CI. +- Configured `e2e and not e2e_slow`: 116 PASS / 6 SKIP / 2 DESELECTED / 1 known XFAIL, 424.64 seconds. The XFAIL is existing KI-009 code-interpreter behavior. Skips: unconfigured OpenSandbox/Cube/E2B/container and two non-opted-in Volume cases. +- Catalog plugins were installed with consent and doctor verification into a fresh test HOME before that E2E run; no native credential was inherited by another backend. + +## Current implementation + +The retired gRPC daemon, native lifecycle wrapper and framing hooks remain removed. +Express selects native HTTP through the same Provider contract as plugins. Its wire URL +helper and auth diagnostics are adapter-owned. Package composition injects schemas; +reading bundled metadata does not discover the registry, and public provider exports +remain lazy. Empty/nonempty Volume creation is tested through the real HTTP wire contract +with an unrelated ambient provider and the retired daemon unavailable. + +## Cleanup and limitations + +Independent listing after the full cloud suite observed zero total sandbox resources +and zero Python E2E tags. No new Team/key/Volume/Bucket/role/template was created by this +checkpoint. Existing storage evidence is preserved and is not relabeled as a new run. + +Final full Server repetition, distributions, health and exact-head hosted matrix/review +remain pending. The intentionally malformed local traffic failures are not converted to +passes or skips. Native unconfigured providers and optional image-factory activation +remain outside verified coverage; the PR stays draft until required gates pass. diff --git a/.agents/evidence/verify/2026-10-18-provider-explicit-engine-routing.md b/.agents/evidence/verify/2026-10-18-provider-explicit-engine-routing.md new file mode 100644 index 0000000..ccff088 --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-explicit-engine-routing.md @@ -0,0 +1,64 @@ +# Verification: Explicit provider routing through the native engine + +- Date: 2026-10-18 +- Change: follow-up to commit `6149076`, PR #38 and Issue #52; ADR `2026-10-18-provider-portability-updates`. +- Result: PARTIAL — client regression and real cloud pass; the previously recorded server aggregate remains blocking. +- Environment: Python 3.13.13, Linux, source client/server 0.3.0, real Express cn-hangzhou. Credentials were supplied through an ignored environment file; no values are recorded. + +## What was verified + +The Express adapter now forwards its selected provider into its internal engine-create +call. An unrelated ambient default cannot redirect an explicitly selected native create. +The core remains provider-neutral; the fix is entirely in the owning lifecycle adapter. + +## Commands run + +```sh +cd client-sdk +pytest tests/test_providers/test_complete_isolation.py -k native_engine -q +ruff check src/ tests/ --output-format concise +ruff format --check src/ tests/ +mypy src/agent_sandbox +pytest tests/ -q --tb=short +cd .. +make build dist-check +bash scripts/check-repo-health.sh +bash scripts/check-no-local-paths.sh +``` + +A task-local real-component runner set `ABX_PROVIDER=not-installed-provider`, explicitly +created and listed with `provider="alicloud-express"`, executed a command, round-tripped +a file, connected/disconnected a borrowed client and deleted its owned sandbox. +It emitted assertion names/results only, never provider payloads or credentials. + +## Results + +| Check | Result | Notes | +| --- | --- | --- | +| Negative control before fix | Expected failure | The new regression observed no forwarded provider; one failed, four deselected; no cloud request | +| Complete client suite after fix | PASS | **2152 passed, 24 skipped**, 147.71 seconds | +| Client lint/format/mypy | PASS | 228 formatted files; 107 source modules typechecked | +| Rebuilt distributions | PASS | Both wheels/sdists, twine, py.typed and clean combined installation/import | +| Repository health/hygiene | PASS | 106 health checks passed; one historical warning; no hygiene failures | +| Explicit native create over invalid ambient default | PASS | Real Express sandbox created through the selected provider | +| Exec/files/connect/list | PASS | All four real-component assertions passed | +| Owned cleanup | PASS | Delete completed and a subsequent explicit-provider list confirmed absence | + +## Cleanup + +Only the newly created sandbox was deleted. No Team, managed API-key, role, Bucket, +Volume or template management resources were created by this follow-up. + +## Problems found + +The regression exposed a missing provider argument in the Express wrapper. The fix +preserves the explicit selection rather than modifying the ambient environment or +adding a vendor branch to core. Changelog entries now use the standard Changed/Fixed +groups and describe only Python-owned behavior. + +## Not verified + +The prior [isolation evidence](2026-10-18-provider-complete-isolation.md) remains historical. +Its seven server aggregate failures are not waived, and unchanged server tests were not +repeated for this client-only follow-up. The full supported Python-version remote matrix +is unverified. The PR remains a draft and is not merged. diff --git a/.agents/evidence/verify/2026-10-18-provider-main-integration.md b/.agents/evidence/verify/2026-10-18-provider-main-integration.md new file mode 100644 index 0000000..86c8f46 --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-main-integration.md @@ -0,0 +1,61 @@ +# Provider/main integration verification — 2026-10-18 + +Result: PARTIAL pending real Express acceptance and exact-head CI. + +Source: provider isolation622dd4fa and accepted maine43a4d8e, ordinary merge +plus scoped integration repairs. Python3.13, fresh editable client/server packages. + +- Full client run:2174 PASS,24 SKIP. Earlier run had one custom-auth mapping + failure; adding the provider-owned rejection handler fixed it (nine KI regressions + PASS). Subsequent canonical auth/provider run:531 PASS,23 SKIP. +- Ruff lint/format and Makefile package-scoped mypy:PASS (56 server/99 client files). +- Health106 PASS,1 WARN,zero FAIL; hygienePASS. Fresh build, twine checks, py.typed + checks and actual combined client/server wheel install/import:PASS. +- First full server run:848 PASS,7 FAIL,1 SKIP,1 teardown ERROR. Six are known + platform raw-socket/framing/upgrade limitations (including HTTP501 network-audit + disabled responses). One additional test exposed premature fixture readiness. + The fixture now waits for its own policy and PTY initialization; focused policy + follow-up97 PASS. Full rerun is recorded below; failures are never called passes. + +No Python gRPC engine, old lifecycle hooks or engine-only bridge fixtures return. +Volume validation/payload coverage uses the real native HTTP contract. Config +metadata remains pure; provider discovery is injected. Root import aliases retain +class/module identity. No default AK/SK token creation route is restored. +Real Express E2E is currently running locally; no cloud secret was uploaded. + +Final full client run:2174 PASS/24 SKIP. Final full server run:849 PASS/6 FAIL/ +1 SKIP; only the six disclosed platform raw-socket limitations remain, and the +fixture readiness case now passes. Final local real Express E2E:112 PASS,2 SKIP +(explicit Volume opt-in absent),2 deselected slow tests,1 expected recorded KI-009 +platform code-interpreter expression limitation; exit0. This is real lifecycle, +commands, files, network, server SDK, declarative and MCP acceptance, not mocks. +Final canonical auth/provider regressions531 PASS/23 SKIP; quality gates PASS. +Wheel builds, distribution validation and actual paired installation/import PASS. +Cleanup dry-run confirmation is recorded separately before merge. + +Post-run cleanup dry-run:exit0,zero tagged sandbox candidates and zero remaining +sandboxes listed. No cloud resources remain from this acceptance run. + +Remote branch advanced before publication. The normal push correctly rejected +a non-fast-forward update; no force push was used. Ordinary merge retains remote +9f4a3f9845074281eb3d1e99d2709357a7a53a54 and its lazy composition, native URL and +accurate raw-HTTP fixture fixes alongside the validated integration. No assertions +were removed: raw HTTP identifies its actual loopback host and parses full bodies. +Fresh focused API/auth/provider regressions145 PASS; lint/typecheck/health PASS. +Focused server tests67 PASS/2 FAIL (remaining host WebSocket/framing limitations). +Policy/PTY readiness is retained. Pure lower metadata and canonical auth aliases +remain. Latest-head hosted CI is still required before merge. + +The first full port-integration run exposed9 failures/4 setup errors from missing +legacy callbacks in the composition metadata (2165 PASS/24 SKIP). Lower transport +must retain pure values, while the composition root needs the provider's decorated +URL/port/PTY factories. Restoring that separation fixes the actual public network, +capability and project-server constructors:focused100 PASS, zero failures/errors. +No fixture assertion or backend refusal was removed. One prior run was invalidated +by source reconciliation during collection and is not used as acceptance evidence. + +Final frozen-source full client run after the composition fix:2178 PASS,24 SKIP, +zero failures/errors (exit0). Package-scoped lint/format/mypy/hygiene PASS. This +supersedes the failed integration attempts as the current client result without +reclassifying or deleting their history. Current actual-head hosted matrix must +still pass; the disclosed local raw server limitations remain separate. diff --git a/.agents/evidence/verify/2026-10-18-provider-port-url-boundaries.md b/.agents/evidence/verify/2026-10-18-provider-port-url-boundaries.md new file mode 100644 index 0000000..a212cf9 --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-port-url-boundaries.md @@ -0,0 +1,52 @@ +# Verification: Explicit port and file URL boundaries + +- Date: 2026-10-18 +- Result: PARTIAL — implementation and targeted regressions pass; final aggregates and hosted checks are recorded separately. +- Scope: accompanying port/file adapter hooks, neutral import facades and fixture repairs on the current-host integration branch. +- Environment: Linux, Node 22.22.2, Python 3.11.15; locked dependencies. Credentials remain in the ignored environment and are not recorded. + +## Behavior and verification + +Provider-selected port access owns URL/host computation and access headers. Async-only +providers cannot inherit a bundled synchronous address. File URL helpers require an +explicit filesystem adapter hook, including when the session URL uses HTTP. The direct +standalone network API preserves its bundled compatibility behavior through composition. +Neutral Python HTTP/protocol imports no longer eagerly load built-in authentication or wire code. + +- Fresh-process import regression failed before lazy facade repair and passed afterwards. +- Python scoped isolation/network/native/sandbox/files: 106 passed. +- CLI scoped isolation/network/files/create/list: 112 passed. +- CLI strict typecheck/build and standalone Linux executable build/version smoke passed. +- CLI executable SHA256: `8797dbe0bf6b1f3ecac8afb28c37aa2f9f1e38263cb26ac1e7cd50a8d65e34ed`. +- CLI capture: 114 cases, zero skipped, 17 documents; new capture, no historical overwrite. +- Python both-package lint and mypy: PASS (101 client and 56 server modules). Commands run from the owning package as configured in the Makefile; a root-level mypy attempt ignored package settings and failed, then was corrected. +- Python client/server wheel builds, twine, py.typed and combined clean installation: PASS. +- npm dry-run package-content validation: PASS using the locked npm 10 task wrapper. + +## Checkpoint failures retained + +The first post-port Python aggregate had 2173 passed, 4 failed, 24 skipped and 1 teardown +error. One assertion incorrectly expected native addressing for an async-only provider; +that assertion now requires E8004. Three live loopback requests were disconnected by the +local intermediary; a separate API run passed all 372 tests. The live fixture now also +waits for the actual HTTP/PTY thread before accepting intermediary socket readiness. +No production assertions or timeouts were weakened. A final aggregate follows. + +The CLI aggregate had 3912 passed, 20 failed, 3 skipped. All 20 failures match the +unchanged main baseline after normalizing the renamed Cube no-PTY case. They involve +intermediary HTTP 501/502, malformed socket framing, TLS/proxy interception or WebSocket +close details. The baseline had 22 failures: the archive-umask and terminal-bridge +regressions are fixed. Hosted CI is still required and is not waived. + +The executable combined acceptance had 142 passed, 2 failed, 1 guarded skip; both owned +sandboxes were removed, zero leaked. Both failures were offline region isolation: the +harness inherited ALIBABA_CLOUD_REGION_ID independently of its explicit credential map. +After removing that inheritance, offline acceptance passed 61 cases with one no-resource +leak-check skip. Cloud results and new final validation will be preserved separately. + +## Cleanup and limits + +No Team, key, Volume, Bucket, role or template was created by these follow-up runs. +Native E2B, Cube, container and OpenSandbox environments are not configured; no native +acceptance or optional image-factory activation is claimed. Current-head CI and real +Express SDK acceptance remain pending this checkpoint. diff --git a/.agents/evidence/verify/2026-10-18-provider-portability-updates.md b/.agents/evidence/verify/2026-10-18-provider-portability-updates.md new file mode 100644 index 0000000..e63af2b --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-portability-updates.md @@ -0,0 +1,31 @@ +# Verification: bundled provider updates and Express adoption + +- Date: 2026-10-18. +- Result: PARTIAL; changed installer/CLI behavior and static checks pass, mandatory full server gate fails. +- Base: b91a1a162b8c02f386d182a21b00e06a363a12d1 plus scoped installer/CLI/tests/docs/record changes. +- Environment: Linux, Python 3.13.13, isolated development environment with both editable packages and declared extras. No cloud credentials supplied. +- Owner issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/52. + +## Commands and actual results + +| Check | Result | Evidence and limits | +| --- | --- | --- | +| `cd client-sdk && python -m pytest tests/test_providers/test_installer.py -q --tb=short` | PASS | 95 tests, including no-receipt bundled guidance, ref rejection, actual CLI JSON, Git/catalog/pin/rollback behavior. | +| `make lint format-check typecheck` | PASS | Both packages; mypy checks 56 server / 87 client files. An initial isolated environment lacked optional dependencies; installing the repository's declared extras resolved those setup errors. | +| `make ci` | FAIL | Server phase: 849 pass / 6 fail / 1 skip, 322.36 seconds, Make exit 2. Six raw HTTP/WebSocket/policy framing cases encounter environment-generated HTTP 501 or incompatible local responses in untouched server code. Later client/build/dist phases were not reached. | +| Repository health | PASS | 106 pass / 1 historical warning / zero fail. | +| Scoped links, diff and configured-secret scan | PASS | No source outside the intended change; no secret values in records. | + +The new documentation distinguishes keeping existing Express resources from E2B import/config/API compatibility. Built-in update reports bundled ownership and matching client/server/Node upgrade guidance; it does not install software, contact a catalog or claim a latest-version check. No public package availability, native factory activation or zero-change migration is inferred. + +## Additional validation and limits + +The independent complete client run used a unique test-owned Unix socket: `cd client-sdk && ABX_ENGINE_SOCKET= python -m pytest tests -q --tb=short`: **2141 PASS / 24 SKIP**, 155.40 seconds, exit 0. Skips remain skips; no native-cloud or newly enabled engine path is inferred. The server failure remains blocking despite the client pass. Native cloud acceptance in the existing provider-volume-live record remains historical; this follow-up creates no cloud resource and changes no existing Team, key, template or sandbox. Optional provider host/schema work is separate and cannot be declared released from this branch. The shared Node host still lands before its consumer. + +## Accepted-main integration + +Accepted main `145341d` is retained by normal merge. Conflicting record indexes retain both entries. Builtin feature declarations use explicit implemented support instead of inheriting new registry names; management commands remain present, and unsupported domain/storage/VPC/deployment features are not granted. CLI retains main's conservative pause declaration. Current integrated validation results are recorded separately from the earlier full aggregate above. + +Final integrated client validation after main 145341d: **2147 PASS / 24 SKIP**, 143.85 seconds, exit 0, using a separate owned Unix socket. Both-package lint/format/typecheck pass again (56 server / 87 client source files). `templates.deploy` parsing/consent remains supported without granting the builtin a factory. This does not convert the earlier full-server aggregate failure into a pass. + +Final integrated repository health: **106 PASS / 1 WARN / 0 FAIL**. diff --git a/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md new file mode 100644 index 0000000..e71ccf9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md @@ -0,0 +1,127 @@ +# Decision: Share provider management command contracts with the Node host +Status: proposed + +## Problem + +Provider features describe the standard sandbox protocol, while cloud-specific resource +operations have no shared discovery, consent or invocation contract. Reusing runtime named +commands would mix infrastructure operations with functions inside a sandbox. Automatic HTTP +replay can duplicate a resource mutation after a lost response. + +Owner Issue: . +Host dependency: . + +## Decision + +Parse bounded `provider-commands/1` descriptors in Python and include complete, ID-sorted +command descriptions in installation/update consent. Missing sections and legacy receipt +fields mean empty command sets. Nonempty command sets belong to node/exec providers only. +Python performs structural validation; the Node host owns strict schema compilation, input +and output validation, confirmation, dispatch and cloud APIs. No cloud SDK is added here. + +Expose a thin asynchronous engine API in `providers/commands.py`. Require explicit matching +provider identity, keep metadata discovery free of credential resolution, and send POST invocation once, with `confirm=False` unless explicitly +changed by the caller. Keep existing EngineClient retry behavior for other callers through +a backward-compatible `retry=True` default. Do not replay command POST requests even when +an operation declares itself idempotent. + +The built-in provider no longer declares `ports.domain`, `storage.mount` or `network.vpc`: +no callable standard sandbox protocol exists for those tokens. Resource commands are separate +and do not imply live mounting or networking support. Other feature declarations stay scoped +outside this decision. + +Final review found a documented creation-time mount contract that resource management alone +did not cover. Add `Sandbox.create(volume_mounts: dict[str, str] | None = None)` for the built-in +provider, mapping normalized absolute sandbox paths to existing Volume names. Validate before +provider lookup/construction and reject non-built-in nonempty hints with E8004 before plugin +loading. Preserve omitted/empty request behavior. This is an explicit creation option, not a +portable mount feature or runtime attach protocol. The built-in Python lifecycle still uses +the existing gRPC engine: add map field 16 to `CreateRequest`, regenerate its Python descriptor +with the matching protobuf compiler, and serialize `volumeMounts: [{name, path}]` in the +existing Python `SandboxConfig`/REST protocol. Do not reroute established sessions to Node. +The official OSS guide supplies name/Team/role semantics and mount restrictions; storage-type +limits remain backend-specific rather than applying an OSS-only limit to every Volume. +Add `HealthStatus.supported_create_options` field 4 and advertise `volume_mounts`; a client +with nonempty hints requires that capability before sending Create. An older live engine +otherwise silently discards unknown protobuf fields and could create an unmounted sandbox. +Missing support fails E8004 with an upgrade/restart hint without replacing the existing +engine. No-mount calls do not require this additive capability handshake. + +## API Design + +- `Manifest.commands: tuple[ProviderCommand, ...]`; descriptors contain `id`, `description`, + `description_zh`, `input_schema`, `output_schema`, `effect`, `idempotent`. +- `providers.commands.list_commands(provider)` returns descriptor dictionaries; + `get_command(provider, command_id)` returns one descriptor. These static metadata calls + send only provider identity and do not resolve configuration or credentials. +- `providers.commands.run_command(provider, command_id, input=None, *, confirm=False, + provider_options=None, **config)` returns the engine's JSON result. +- GET `/v1/providers/:id/commands` returns `{commands: [...]}`; GET + `/v1/providers/:id/commands/:command` returns a descriptor; POST to the latter route sends + `{input, confirm}`. Authentication and connection headers use `abx-engine/1`. +- `EngineClient.request(..., retry=True)` gains an optional retry switch. Command invocation + always passes `retry=False`, including reads, so no POST is transparently replayed. + +## Alternatives considered + +- **Arbitrary CLI command registration** — conflicts with the static CLI surface; the Node + host keeps a fixed provider management namespace instead. +- **Duplicate Python cloud implementations** — would diverge from Node handlers and schemas. +- **New entries in the fixed feature registry** — those features describe portable sandbox + behavior, not arbitrary provider resource operations. +- **Automatic retries for declared idempotent operations** — callers need to reconcile lost + responses and make explicit retry decisions. + +## Dependencies + +The existing `httpx` EngineClient, provider registry and layered configuration resolver. +The compatible shared host is maintained in the +[CLI repository](https://github.com/alibaba-agent-sandbox/agent-sandbox). +No new runtime dependency or package version change is introduced. + +## Test Strategy + +Storage acceptance also destroys the writing sandbox, verifies its absence, creates a +different sandbox mounting the same Volume, and requires the exclusive file contents to +survive before deleting the file and both test sandboxes. + +Manifest tests cover bounds, forbidden schema/JSON fields, duplicate IDs, descriptions, +legacy manifests, and rest-provider rejection. A real filesystem installation verifies +command additions require renewed consent. Adapter tests verify route/body/connection +shape, explicit confirmation, cleanup, metadata discovery without plugin execution, and +no replay after disconnect or HTTP 401. Existing manifest/installer/registry tests remain. +Run both package suites, lint/type checks, repository health/hygiene and real-component +E2E; record unavailable prerequisites and pre-existing gate failures separately. + +Independent agents may review disjoint contracts and source in parallel, with one coordinator +reconciling cross-language changes. Suites that share fixed loopback ports run sequentially. +Each test invocation gets its own temporary root and unique Unix socket; PID-only names do +not provide isolation across namespaced tool processes sharing a filesystem. Final review +demonstrated that a reused default engine can miss a test's in-process HTTP patches; the same +integration tests pass on a unique socket. Preserve failed runs, retry with proven isolation, +and never kill or replace an unknown process to obtain a pass. + +## Acceptance criteria + +1. Python structural descriptor rules match the shared host and changed descriptors require consent. +2. Legacy plugin manifests/receipts remain usable with empty commands. +3. Python cloud management calls use one Node implementation and an explicit provider connection. +4. Mutations require host confirmation and command POST requests are sent at most once. +5. Built-in feature reporting excludes the three standard operations without callable protocols. +6. English/Chinese guides, public exports, changelog and honest verification evidence are provided. +7. Required CI, review and real-component gates pass before merge; this record stays proposed meanwhile. +8. Built-in create retains mount hints across the public API, real local gRPC transport and + HTTP wire serialization; strict validation and provider gating precede resource I/O. + Real Volume mount/read/write/cleanup is verified using authorized temporary OSS storage; + complete required aggregates and shared-host landing remain outstanding. + +## Evidence + +[Verification record](../../../evidence/verify/2026-10-16-provider-management-commands.md). +[Final review and mount parity](../../../evidence/verify/2026-10-16-provider-management-final-review.md). +[Final isolated suite and main synchronization](../../../evidence/verify/2026-10-16-provider-command-integration-final.md). +[Independent recheck and accepted-main integration](../../../evidence/verify/2026-10-16-provider-management-recheck.md). + +[Authorized real storage and cleanup acceptance](../../../evidence/verify/2026-10-16-provider-volume-live.md). + +[Accepted binary-file integration recheck](../../../evidence/verify/2026-10-16-provider-volume-main-integration.md). diff --git a/.agents/notes/proposed/architecture/2026-10-18-provider-main-integration.md b/.agents/notes/proposed/architecture/2026-10-18-provider-main-integration.md new file mode 100644 index 0000000..cadede8 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-main-integration.md @@ -0,0 +1,53 @@ +# Decision: Preserve accepted contracts during provider isolation integration +Status: proposed + +## Problem + +The provider isolation branch conflicted with accepted authentication, dependency +layering and orphan retirement changes. Choosing either entire side would restore +retired implementations or discard provider-neutral dispatch. + +## Decision + +Use an ordinary merge and reconcile the active paths. Providers own backend +selection, wire/session adapters and specialized operations. Core transport consumes +structural contracts. Preserve explicit sandbox API key authentication, custom-only +AK/SK exchange and single-attempt mutation behavior. Distribution configuration +metadata is pure data consumed below the provider composition root. + +## API Design + +Keep historical import paths as aliases of the active implementation. Use one +canonical envd authentication implementation, preserving class identity and defaults. +No retired metadata-only template factory or Python gRPC engine is restored. +Unavailable backend operations remain E8004 rather than falling back to Express. + +## Alternatives considered + +Taking the old branch wholesale restores retired code. Taking main wholesale loses +provider isolation. Parallel compatibility implementations diverge and break class +identity. Importing the provider registry from transport reintroduces a cycle. + +## Dependencies + +https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/38 integrates accepted main; +paired contracts are reviewed in https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/pull/35 +and https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/38. + +## Test Strategy + +Run full local suites, focused provider/authentication/compatibility regressions, +typecheck, formatting/lint, health/hygiene and package checks. Exercise Express +against real components locally. Required hosted checks must pass on this exact +head. Preserve host-dependent failures as failures with exact causes. + +## Acceptance criteria + +No retired implementation returns. Explicit credentials and mutation safety remain. +Provider-isolated dispatch, compatibility imports, lifecycle cleanup and neutral +configuration pass their behavioral regressions. English/Chinese docs and contributor +instructions describe the same current ownership. Required CI/review gates pass. + +## Evidence + +[Verification](../../../evidence/verify/2026-10-18-provider-main-integration.md). diff --git a/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md new file mode 100644 index 0000000..8cd25a6 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md @@ -0,0 +1,66 @@ +# Decision: Describe built-in provider updates and existing Express adoption + +Status: proposed + +## Problem + +Python update treats the built-in provider as an uninstalled plugin. Existing Express resources can be reused, but the SDK is not a zero-change E2B replacement. + +## Decision + +Route bundled and external backends through the same Provider factory and session hooks. Move Express authentication, native engine creation and lifecycle binding into providers/alicloud_express; move explicit E2B wire adapters under providers/shared/e2b. Core depends on structural protocols. Preserve historical import/module identity, reject foreign defaults and distinguish created-resource cleanup from borrowed-resource ownership. Retain SDK-bundled update guidance without fetching/installing software. + +## API Design + +installer.update(alicloud-express) returns status bundled, installed SDK version, available null, changes and message; ref is rejected. Existing Git/catalog behavior is retained. Provider session_protocols, prepare_session, resolve_session_url, open_terminal and optional engine hooks own backend assembly; absent implementations fail closed. + +## Alternatives considered + +Separate built-in plugin updates would split the SDK release contract. Automatically adopting E2B_* secrets would weaken provider isolation. Rebuilding all resources is unnecessary for client adoption. + +## Dependencies + +Shared host design: https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/c5a6a3c75921cfb4359f1c23041ec847ce63c932/docs/en/design/provider-portability-and-updates.md; Node management requires compatible abx. + +## Test Strategy + +Existing installer suite plus offline no-receipt and actual CLI regressions, both-package lint/format/typecheck, scoped links and required full aggregate. Add architecture import-boundary and create/connect ownership regressions. Run real Express create/exec/files/connect/list/cleanup against current sources; historical storage evidence remains separate. + +## Acceptance criteria + +Bundled update is actionable and performs no plugin I/O; Git/catalog regressions pass; migration explicitly distinguishes resource reuse and source compatibility; mandatory gates pass before merge. + +Acceptance also requires no concrete backend imports in core implementation, no +null/default-provider lifecycle bypass, provider-owned authentication/session/URL/terminal +assembly, no foreign configuration defaults, and owned-versus-borrowed cleanup regressions. + +## Evidence + +[Verification](../../../evidence/verify/2026-10-18-provider-portability-updates.md). + +Owner issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/52. + +## Implementation + +The full-isolation follow-up removes the earlier guard-only limitation. Authentication, +cloud mappings and session defaults are provider-owned. CLI templates and UI metadata +remain owned by the CLI repository. Historical evidence remains unchanged; acceptance is tracked in the new +[isolation verification](../../../evidence/verify/2026-10-18-provider-complete-isolation.md). + +The Express wrapper explicitly forwards its selected provider into engine creation; +[follow-up verification](../../../evidence/verify/2026-10-18-provider-explicit-engine-routing.md) +records the failing negative control, 2152-pass client suite and real conflict-case cleanup. + + +The current-host integration retains the accepted API-key-only runtime authentication, +independent CLI error renderer and retired native gRPC slice. Provider-owned URL builders +replace core wire-format logic. Python schema composition remains lazy and injected; +CLI tool schemas and handler discovery are recomputed together. Earlier engine-routing +verification remains historical, not evidence that the retired daemon is still used. +See the [integration checkpoint](../../../evidence/verify/2026-10-18-provider-current-host-checkpoint.md). + +Port access and file-transfer URL conventions now belong to explicit adapter hooks. +Asynchronous-only discovery fails closed in synchronous helpers. Neutral Python imports +remain lazy; a fresh-process regression prevents built-in auth/wire loading. + +[Port and file URL boundary checkpoint](../../../evidence/verify/2026-10-18-provider-port-url-boundaries.md). diff --git a/AGENTS.md b/AGENTS.md index a7493de..c463c60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,8 @@ python-sdk/ │ │ ├── models/ # Data models: config, errors, sandbox, template, session, filesystem, process │ │ ├── transport/ # HTTP/WS transport, auth, codec, retry, streaming │ │ ├── protocol/ # Protocol abstractions: sandbox, filesystem, process, terminal, port, code_interpreter -│ │ ├── api/ # High-level API: Sandbox, files, code, commands, network, capability, session_manager, server client +│ │ ├── api/ # High-level API and server client; provider-neutral lifecycle/session contracts +│ │ ├── providers/ # Backend/session adapters; shared envd wire; external Node engine client │ │ ├── agent/ # AI agent tooling: MCP server, tool definitions │ │ ├── compat/ # E2B migration helper (partial; re-exports Sandbox) │ │ ├── declarative/ # @sandbox decorator, config, serializer diff --git a/CHANGELOG.md b/CHANGELOG.md index a97b2f9..f2fd246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed + +- Reconcile provider isolation with accepted native transport, explicit-key authentication and retired-module cleanup; preserve canonical compatibility aliases and pure configuration metadata. + ### Documentation - Retire the unsupported builtin automatic-retry claim. Existing retry configuration names/defaults and EPP compatibility values remain accepted; they do not enable builtin HTTP retries or backoff. @@ -20,11 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- `Sandbox.create(volume_mounts={"/mnt/data": "existing-volume-name"})` mounts existing Volumes with the built-in provider. Strict validation runs before provider/engine I/O; unsupported providers fail with E8004. The existing gRPC lifecycle forwards the hint and emits the official `volumeMounts: [{name, path}]` creation payload; empty maps preserve prior requests. Real OSS storage mount/read/write/cleanup is verified for the tested base template in cn-hangzhou. +- `agent_sandbox.providers.commands.list_commands`, `get_command` and `run_command` call the shared Node engine for provider-specific resource operations. Writes and deletes require explicit confirmation; command POST requests are never replayed. +- Plugin manifests validate bounded `provider-commands/1` descriptors and include commands in installation/update consent; existing manifests and receipts default to an empty command list. - Provider manifests accept the independent `templates.deploy` feature for CLI image deployment metadata. Legacy `templates.build` declarations remain valid; the built-in Express provider does not acquire the new feature. No Python deployment API is added. - - -### Added - - Python MCP bounded binary `upload_file`/`download_file` (1 MiB, strict Base64, SHA256) over both transports; SDK `files.read_bytes(max_bytes=...)` optionally limits streamed downloads and raises `FileSizeLimitError` (E4003), while its existing default remains unchanged. - Python MCP `start_task`, `get_task`, and `cancel_task` provide sandbox-lifetime background handles, bounded saved log pages, explicit disconnect retention and owned process-group cancellation. Normal synchronous cleanup remains unchanged; Python-only, Linux/python3 runtime. @@ -32,8 +35,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Python MCP lifecycle tools `list_sandboxes`, `get_sandbox`, and `set_sandbox_timeout`; creation accepts provider, region, CPU and memory options. Metadata uses a fixed allowlist, external inspection/renewal does not adopt resources, and lifecycle failures omit credential-bearing exception details. - Add the Chinese counterpart of the historical OpenSandbox provider research archive, preserving its versioned evidence and current-adapter boundary. +### Changed + +- Route bundled and external backends through the same Provider factory and session hooks. Move Express + authentication, native engine/session assembly and reusable E2B adapters into provider modules; preserve public imports and module identity. +- Keep Express endpoint/region defaults out of external-provider configuration. Clean up newly created + resources when session binding fails without deleting borrowed sandboxes. + +- Document mount-capable template selection, the custom MicroVM ossfs2 minimum version, and verified real OSS Volume acceptance with complete temporary-resource cleanup. Extend opt-in E2E coverage to destroy/recreate the sandbox and read the persisted file. + +- Clarify the current Volume invitation-access prerequisite in both language guides; an empty list does not prove create or mount access. + +- The built-in provider no longer advertises `ports.domain`, `storage.mount` or `network.vpc` as callable standard sandbox features. Provider resource management uses the separate command contract. + ### Fixed +- Keep port addresses, gateway headers and file-transfer URLs in selected provider adapters; reject missing hooks instead of guessing native routes. Resolve Python compatibility imports lazily. + + +- Integrate provider isolation with native HTTP creation, API-key-only runtime authentication and lazy schema composition. Preserve no-replay management requests and provider-owned authentication diagnostics; make local HTTP fixtures address and await their actual listener. + +- Keep an explicitly selected Express provider through native engine creation when the ambient default names another backend. + +- Built-in Express provider update reports its SDK-bundled lifecycle and coordinated client/server/Node upgrade guidance instead of a missing-plugin error; document existing-account adoption and explicit API/configuration changes. + - Use executable paired-source installation recipes before PyPI publication across current SDK/package guides and examples; replace the retired root version CLI with distribution metadata inspection. - Keep HTTP and PTY component-test port candidates distinct, preserving explicit PTY settings and preventing intermittent WebSocket CI bind collisions. diff --git a/README.md b/README.md index 45d1dba..e9f9d44 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ The installer fetches the catalog index at `https://registry.agent-sandbox.cn/pr --- +Provider management operations are available through `agent_sandbox.providers.commands`; see the [provider command guide](docs/en/guide/provider-commands.md). + ## Client SDK - **Async-first** — `Sandbox.create()`, shell execution, code interpretation, file I/O, port forwarding, and WebSocket streaming. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e02bbe..eb0baf4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,6 +38,8 @@ Node.js 项目,位于 [`cli`](https://github.com/alibaba-agent-sandbox/agent-s --- +提供方管理操作通过 `agent_sandbox.providers.commands` 调用,参阅[提供方命令指南](docs/zh/guide/provider-commands.md)。 + ## Client SDK - **异步优先** —— `Sandbox.create()`,Shell 执行、代码解释、文件读写、端口转发、WebSocket 流。 diff --git a/client-sdk/README.md b/client-sdk/README.md index 1ef3800..923deb2 100644 --- a/client-sdk/README.md +++ b/client-sdk/README.md @@ -54,6 +54,11 @@ async def main(): asyncio.run(main()) ``` +Provider management operations use `agent_sandbox.providers.commands`; see the +[English guide](../docs/en/guide/provider-commands.md) and +[中文指南](../docs/zh/guide/provider-commands.md). Writes require explicit confirmation, +and command POST requests are never automatically replayed. + ## Layout | Package | Layer | diff --git a/client-sdk/src/agent_sandbox/__init__.py b/client-sdk/src/agent_sandbox/__init__.py index 8b966d5..c39bb40 100644 --- a/client-sdk/src/agent_sandbox/__init__.py +++ b/client-sdk/src/agent_sandbox/__init__.py @@ -51,6 +51,22 @@ def __getattr__(name: str) -> Any: def _resolve_provider_schema(provider_id: str) -> Any: """Composition adapter; discovery stays lazy and outside transport.""" from agent_sandbox._provider_schema import ProviderSchema + from agent_sandbox.providers.bundled_config import bundled_config + + metadata = bundled_config(provider_id) + if metadata is not None: + return ProviderSchema( + metadata["id"], + metadata["keys"], + metadata["inherit"], + dict(metadata["derive"]), + dict(metadata["endpoint_defaults"]), + legacy_fields=metadata["legacy_fields"], + omit_default_options=metadata["omit_default_options"], + legacy_url_builder=metadata["legacy_url_builder"], + legacy_port_factory=metadata["legacy_port_factory"], + legacy_pty_url_builder=metadata["legacy_pty_url_builder"], + ) from agent_sandbox.providers.registry import get_provider provider = get_provider(provider_id) @@ -60,6 +76,9 @@ def _resolve_provider_schema(provider_id: str) -> Any: frozenset(provider.inherit), dict(provider.derive), dict(getattr(provider, "endpoint_defaults", {}) or {}), + legacy_fields=frozenset(getattr(provider, "legacy_fields", ())), + omit_default_options=bool(getattr(provider, "omit_default_options", False)), + legacy_url_builder=getattr(provider, "legacy_session_url", None), ) diff --git a/client-sdk/src/agent_sandbox/_provider_schema.py b/client-sdk/src/agent_sandbox/_provider_schema.py index dee4a42..6c9a289 100644 --- a/client-sdk/src/agent_sandbox/_provider_schema.py +++ b/client-sdk/src/agent_sandbox/_provider_schema.py @@ -24,12 +24,23 @@ def __init__( inherit: frozenset[str], derive: dict[str, str] | None = None, endpoint_defaults: dict[str, str] | None = None, + *, + legacy_fields: frozenset[str] = frozenset(), + omit_default_options: bool = False, + legacy_url_builder: Callable[[str, str], str] | None = None, + legacy_port_factory: Callable[[object], object] | None = None, + legacy_pty_url_builder: Callable[[str, int, int], str] | None = None, ) -> None: self.provider_id = provider_id self.keys = keys self.inherit = inherit self.derive = derive or {} self.endpoint_defaults = endpoint_defaults or {} + self.legacy_fields = legacy_fields + self.omit_default_options = omit_default_options + self.legacy_url_builder = legacy_url_builder + self.legacy_port_factory = legacy_port_factory + self.legacy_pty_url_builder = legacy_pty_url_builder @property def builtin(self) -> bool: diff --git a/client-sdk/src/agent_sandbox/api/code.py b/client-sdk/src/agent_sandbox/api/code.py index 249699d..85215f5 100644 --- a/client-sdk/src/agent_sandbox/api/code.py +++ b/client-sdk/src/agent_sandbox/api/code.py @@ -30,9 +30,8 @@ if TYPE_CHECKING: from collections.abc import Callable - from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol - from agent_sandbox.protocol.process import ProcessProtocol - from agent_sandbox.transport.auth import EnvdTokenManager + from agent_sandbox.protocol.contracts import CodeInterpreterProtocol, ProcessProtocol + from agent_sandbox.transport.auth_contract import DataPlaneAuth as EnvdTokenManager logger = get_logger("api.code") diff --git a/client-sdk/src/agent_sandbox/api/commands.py b/client-sdk/src/agent_sandbox/api/commands.py index f5e4257..eb910f3 100644 --- a/client-sdk/src/agent_sandbox/api/commands.py +++ b/client-sdk/src/agent_sandbox/api/commands.py @@ -19,8 +19,8 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable - from agent_sandbox.protocol.process import ProcessProtocol - from agent_sandbox.transport.auth import EnvdTokenManager + from agent_sandbox.protocol.contracts import ProcessProtocol + from agent_sandbox.transport.auth_contract import DataPlaneAuth as EnvdTokenManager from agent_sandbox.transport.streaming import StreamReader logger = get_logger("api.commands") diff --git a/client-sdk/src/agent_sandbox/api/files.py b/client-sdk/src/agent_sandbox/api/files.py index 87b6544..9fc2c92 100644 --- a/client-sdk/src/agent_sandbox/api/files.py +++ b/client-sdk/src/agent_sandbox/api/files.py @@ -11,9 +11,8 @@ if TYPE_CHECKING: from agent_sandbox.models.filesystem import FileInfo, WatchEvent - from agent_sandbox.protocol.filesystem import FilesystemProtocol - from agent_sandbox.protocol.sandbox import SandboxProtocol - from agent_sandbox.transport.auth import EnvdTokenManager + from agent_sandbox.protocol.contracts import FilesystemProtocol, SandboxProtocol + from agent_sandbox.transport.auth_contract import DataPlaneAuth as EnvdTokenManager from agent_sandbox.transport.streaming import StreamReader logger = get_logger("api.files") @@ -229,30 +228,20 @@ async def get_info(self, path: str) -> FileInfo: ) async def upload_url(self, path: str) -> str: - """envd file URL for an upload. The request must send ``X-Access-Token``. - - This is not a signature-only URL. A client that only has the URL is - denied by the data plane. - """ - self._require_envd_file_url() - return f"{self._envd_url}/files?path={path}&username=user" + """Ask the filesystem adapter for an upload URL and its access requirements.""" + return await self._transfer_url(path, "upload") async def download_url(self, path: str) -> str: - """envd file URL for a download. The request must send ``X-Access-Token``. - - This is not a signature-only URL. A client that only has the URL is - denied by the data plane. - """ - self._require_envd_file_url() - return f"{self._envd_url}/files?path={path}&username=user" + """Ask the filesystem adapter for a download URL.""" + return await self._transfer_url(path, "download") - def _require_envd_file_url(self) -> None: - """Signed-style file URLs exist only on an envd HTTP data plane.""" - if self._envd_url.startswith(("http://", "https://")): - return + async def _transfer_url(self, path: str, operation: str) -> str: from agent_sandbox.models.errors import ProviderFeatureNotSupportedError - raise ProviderFeatureNotSupportedError(self._provider_id, "files.transfer") + resolver = getattr(self._fs, "transfer_url", None) + if not callable(resolver): + raise ProviderFeatureNotSupportedError(self._provider_id, "files.transfer") + return str(await resolver(self._envd_url, self._envd_token, path=path, operation=operation)) # Sync variants read_sync = make_sync(read) diff --git a/client-sdk/src/agent_sandbox/api/network.py b/client-sdk/src/agent_sandbox/api/network.py index a1380f0..01770b5 100644 --- a/client-sdk/src/agent_sandbox/api/network.py +++ b/client-sdk/src/agent_sandbox/api/network.py @@ -1,17 +1,18 @@ -"""网络模块 — 端口 URL 计算和访问。 - -端口 URL 由客户端本地计算,格式为:https://{port}-{sandbox_id}.{domain} -其中 sandbox_id 已包含 "sbx-" 前缀(如 "sbx-xxxx")。 -不需要服务端 API 调用。secure 模式下端口访问需要 X-Access-Token header。 -""" +"""Provider-owned port addressing and access headers.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast + +from agent_sandbox._provider_schema import resolve_provider_schema +from agent_sandbox.protocol.port_contract import PortContext +from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID if TYPE_CHECKING: from collections.abc import Awaitable, Callable + from agent_sandbox.protocol.port_contract import PortAccess + from agent_sandbox.models.template import DEFAULT_CAPABILITIES from agent_sandbox.utils.logging import get_logger @@ -19,11 +20,7 @@ class NetworkModule: - """高层网络接口 — 端口 URL 计算和访问 header 管理。 - - 所有方法均为本地计算,不涉及任何网络请求。 - 端口 URL 格式参见官方文档端口访问说明。 - """ + """Port access through the selected adapter; async discovery uses resolve().""" def __init__( self, @@ -33,8 +30,17 @@ def __init__( access_token: str | None = None, capabilities: set[str] | None = None, port_resolver: Callable[[int], Awaitable[tuple[str, dict[str, str]]]] | None = None, + port_access: PortAccess | None = None, ) -> None: self._port_resolver = port_resolver + if port_access is None: + factory = resolve_provider_schema(BUILTIN_PROVIDER_ID).legacy_port_factory + if factory is None: + raise RuntimeError("The distribution has no legacy port factory.") + port_access = cast( + "PortAccess", factory(PortContext(sandbox_id, domain, secure, access_token)) + ) + self._port_access = port_access self._sandbox_id = sandbox_id self._domain = domain self._secure = secure @@ -42,33 +48,20 @@ def __init__( self._capabilities = capabilities if capabilities is not None else set(DEFAULT_CAPABILITIES) def get_host(self, port: int) -> str: - """获取端口访问的 host。 - - 格式:{port}-{sandbox_id}.{domain} - 其中 sandbox_id 已包含 "sbx-" 前缀。 - """ - return f"{port}-{self._sandbox_id}.{self._domain}" + """Return the host supplied by the synchronous adapter hook.""" + return self._port_access.get_host(port) def get_url(self, port: int) -> str: - """获取端口访问的完整 URL。 - - 格式:https://{port}-{sandbox_id}.{domain}(secure 模式) - 或 http://{port}-{sandbox_id}.{domain}(非 secure 模式) - """ - scheme = "https" if self._secure else "http" - return f"{scheme}://{self.get_host(port)}" + """Return the URL supplied by the synchronous adapter hook.""" + return self._port_access.get_url(port) def get_access_headers(self) -> dict[str, str]: - """端口访问所需的 HTTP headers。 + """Return access headers supplied by the selected adapter.""" + return self._port_access.get_access_headers() - secure 模式下包含 ``X-Access-Token``(envd 与部分网关)。Express 上 - 端口 9000 的 ``agent-sandbox-server`` 在未设置 ``ABX_SERVER_TOKEN`` 时 - 会把这个头当成登录失败(HTTP 401)。命名命令走 :meth:`Sandbox.run`, - 不带这个头。 - """ - if self._secure and self._access_token: - return {"X-Access-Token": self._access_token} - return {} + def get_server_headers(self) -> dict[str, str]: + """Gateway headers for the project server, owned by the selected adapter.""" + return self._port_access.get_server_headers() async def resolve(self, port: int) -> tuple[str, dict[str, str]]: """Return ``(url, headers)`` for *port*, asking the provider when it can answer. diff --git a/client-sdk/src/agent_sandbox/api/sandbox.py b/client-sdk/src/agent_sandbox/api/sandbox.py index 4f4da99..3aaa2e8 100644 --- a/client-sdk/src/agent_sandbox/api/sandbox.py +++ b/client-sdk/src/agent_sandbox/api/sandbox.py @@ -20,23 +20,17 @@ from agent_sandbox.api.capability import ( ResolvedCapabilities, - resolve_capabilities_for, ) -from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo, SandboxStatus -from agent_sandbox.models.template import DEFAULT_CAPABILITIES -from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol -from agent_sandbox.protocol.filesystem import FilesystemProtocol -from agent_sandbox.protocol.process import ProcessProtocol -from agent_sandbox.protocol.sandbox import SandboxProtocol -from agent_sandbox.transport.auth import ( - AuthProvider, - EnvdTokenManager, - create_auth_provider, +from agent_sandbox.models.sandbox import ( + SandboxConfig, + SandboxInfo, + SandboxStatus, + validate_volume_mounts, ) +from agent_sandbox.models.template import DEFAULT_CAPABILITIES +from agent_sandbox.providers.registry import get_provider from agent_sandbox.transport.config import TransportConfig, load_config -from agent_sandbox.transport.http import HttpClient from agent_sandbox.utils.async_bridge import make_classmethod_sync, make_sync -from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID from agent_sandbox.utils.logging import get_logger if TYPE_CHECKING: @@ -52,8 +46,17 @@ CodeResult, ) from agent_sandbox.models.server import ServerCommand - from agent_sandbox.protocol.terminal import TerminalSession as TerminalSession + from agent_sandbox.protocol.contracts import ( + CodeInterpreterProtocol, + FilesystemProtocol, + ProcessProtocol, + SandboxProtocol, + ) + from agent_sandbox.protocol.contracts import TerminalSession as TerminalSession from agent_sandbox.providers.base import Provider + from agent_sandbox.transport.auth_contract import AuthProvider + from agent_sandbox.transport.auth_contract import DataPlaneAuth as EnvdTokenManager + from agent_sandbox.transport.http import HttpClient from agent_sandbox.transport.streaming import StreamReader as StreamReader logger = get_logger("api.sandbox") @@ -73,31 +76,19 @@ def _build_infra( Shared helper for class methods that need standalone infra. This is the built-in (``alicloud-express``) path; other providers go through :func:`_provider_infra`. """ - config_overrides: dict[str, Any] = {} - # Callers pass the already-resolved value (legacy ``api_key`` / ``api_url`` included). - # ``load_config`` only honours the canonical override names. - if api_key is not None: - config_overrides["sandbox_api_key"] = api_key - if api_url is not None: - config_overrides["sandbox_base_url"] = api_url - if access_key_id is not None: - config_overrides["access_key_id"] = access_key_id - if access_key_secret is not None: - config_overrides["access_key_secret"] = access_key_secret - if region is not None: - config_overrides["region"] = region - if provider_options: - config_overrides["provider_options"] = dict(provider_options) - - config = load_config(**config_overrides) - auth = create_auth_provider( - api_key=config.sandbox_api_key, - access_key_id=config.access_key_id, - access_key_secret=config.access_key_secret, + from agent_sandbox.providers.registry import get_provider + + return cast( + "tuple[TransportConfig, AuthProvider, HttpClient, SandboxProtocol]", + get_provider().build_legacy_infra( + api_key=api_key, + api_url=api_url, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + region=region, + provider_options=provider_options, + ), ) - http_client = HttpClient(config, auth) - sandbox_protocol = SandboxProtocol(http_client) - return config, auth, http_client, sandbox_protocol def _same(canonical_name: str, canonical: Any, legacy_name: str, legacy: Any) -> Any: @@ -110,8 +101,8 @@ def _same(canonical_name: str, canonical: Any, legacy_name: str, legacy: Any) -> return canonical if canonical is not None else legacy -def _select_provider(provider: Any, sandbox_id: str | None = None) -> tuple[Any | None, str | None]: - """Resolve the effective provider; ``(None, id)`` means the built-in default path. +def _select_provider(provider: Any, sandbox_id: str | None = None) -> tuple[Any, str | None]: + """Resolve a real Provider instance, including for the bundled default. Also splits a qualified ``provider:sandbox_id`` reference. A qualified prefix that contradicts an explicit *provider* raises :class:`ProviderMismatchError` (E8030). @@ -136,8 +127,6 @@ def _select_provider(provider: Any, sandbox_id: str | None = None) -> tuple[Any suggestion="Drop the 'provider:' prefix or the provider argument.", ) pid = registry.resolve_provider_id(explicit or ref_provider) - if pid == BUILTIN_PROVIDER_ID: - return None, bare return registry.get_provider(pid), bare @@ -147,6 +136,10 @@ class _InfraCloser: def __init__(self, infra: Any) -> None: self._infra = infra + def __getattr__(self, name: str) -> Any: + """Preserve access to the provider HTTP transport for compatibility callers.""" + return getattr(self._infra.http_client, name) + async def close(self) -> None: if self._infra.close is not None: await self._infra.close() @@ -154,19 +147,6 @@ async def close(self) -> None: await self._infra.http_client.close() -def _envd_token(prov: Any, info: Any) -> Any: - """A provider may accept a missing envd token and a non-default basic user.""" - from agent_sandbox.transport.auth import EnvdTokenManager - - kwargs: dict[str, Any] = {} - if getattr(prov, "envd_allow_missing", False): - kwargs = { - "allow_missing": True, - "basic_user": getattr(prov, "envd_basic_user", "root") or "root", - } - return EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id, **kwargs) - - async def _provider_infra( prov: Any, overrides: dict[str, Any], options: dict[str, Any] | None ) -> Any: @@ -234,30 +214,57 @@ def __init__( process_protocol: ProcessProtocol | None = None, filesystem_protocol: FilesystemProtocol | None = None, code_interpreter_protocol: CodeInterpreterProtocol | None = None, - domain: str = "e2b.dev", + domain: str | None = None, secure: bool = True, resolved_capabilities: ResolvedCapabilities | None = None, declared_capabilities: frozenset[str] | None = None, - provider_id: str = BUILTIN_PROVIDER_ID, + provider_id: str | None = None, ) -> None: self._info = info self._config = config - self._provider_id = provider_id + self._provider_id = provider_id or config.provider - # Derive envd URL when the platform response omits it (Bug 1 fix) + from agent_sandbox.providers.registry import get_provider + + # The backend owns URL derivation and protocol construction. if not self._info.envd_url and self._info.sandbox_id: - self._info.envd_url = self._config.build_envd_url(self._info.sandbox_id) + self._info.envd_url = get_provider(self._provider_id).resolve_session_url( + self._info, self._config + ) self._http_client = http_client self._killed = False self._auth = auth self._envd_token = envd_token - self._domain = domain + self._domain = domain if domain is not None else config.sandbox_domain self._secure = secure - self._sandbox_protocol = sandbox_protocol or SandboxProtocol(http_client) - self._process_protocol = process_protocol or ProcessProtocol(http_client) - self._filesystem_protocol = filesystem_protocol or FilesystemProtocol(http_client) - self._code_interpreter_protocol = code_interpreter_protocol or CodeInterpreterProtocol( - http_client + defaults = ( + get_provider(self._provider_id).session_protocols(http_client) + if any( + p is None + for p in ( + sandbox_protocol, + process_protocol, + filesystem_protocol, + code_interpreter_protocol, + ) + ) + else {} + ) + self._sandbox_protocol = ( + sandbox_protocol if sandbox_protocol is not None else defaults["sandbox_protocol"] + ) + self._process_protocol = ( + process_protocol if process_protocol is not None else defaults["process_protocol"] + ) + self._filesystem_protocol = ( + filesystem_protocol + if filesystem_protocol is not None + else defaults["filesystem_protocol"] + ) + self._code_interpreter_protocol = ( + code_interpreter_protocol + if code_interpreter_protocol is not None + else defaults["code_interpreter_protocol"] ) # Capability model @@ -351,6 +358,8 @@ async def _resolver(port: int) -> tuple[str, dict[str, str]]: def network(self) -> NetworkModule: """Network sub-module for port URL calculation.""" from agent_sandbox.api.network import NetworkModule + from agent_sandbox.protocol.port_contract import PortContext + from agent_sandbox.providers.registry import get_provider return NetworkModule( sandbox_id=self.id, @@ -359,6 +368,15 @@ def network(self) -> NetworkModule: access_token=self._envd_token.token if self._envd_token else None, capabilities=self._capabilities, port_resolver=self._port_resolver(), + port_access=get_provider(self._provider_id).create_port_access( + PortContext( + self.id, + self._domain, + self._secure, + self._envd_token.token if self._envd_token else None, + ), + self._sandbox_protocol, + ), ) @functools.cached_property @@ -413,12 +431,7 @@ def server_at( return ServerClient( self.network.get_url(port), token=token, - # Express publishes port 9000 without requiring the sandbox access - # token. Forwarding X-Access-Token makes an unconfigured - # agent-sandbox-server return 401 (it treats any presented - # credential as a login). Callers that need the gateway header - # use network.get_access_headers() themselves. - headers=None, + headers=self.network.get_server_headers() or None, pty_url=self.network.get_url(pty_port), timeout=timeout, ) @@ -498,6 +511,7 @@ async def create( request_timeout: float | None = None, metadata: dict[str, str] | None = None, envs: dict[str, str] | None = None, + volume_mounts: dict[str, str] | None = None, cpu: int | None = None, memory: int | None = None, disk: int | None = None, @@ -531,6 +545,9 @@ async def create( value does not work around platform-side create stalls — it only controls how long the client waits before timing out. metadata: Arbitrary metadata key-value pairs. + volume_mounts: Absolute sandbox paths mapped to existing Volume names, + for example ``{"/mnt/data": "my-volume"}``. Supported only by the + built-in provider; the Volume and API key must belong to the same Team. envs: Environment variables injected into the envd root process. These are inherited by all subsequent ``commands.run()``, ``run_code()``, and terminal sessions. Per-call overrides @@ -576,97 +593,38 @@ async def create( f"request_timeout must be a positive number of seconds, got {request_timeout!r}" ) + mounts = validate_volume_mounts(volume_mounts) prov, _ = _select_provider(provider) - if prov is not None: - return await cls._create_via_provider( - prov, - template=template, - timeout=timeout, - request_timeout=request_timeout, - metadata=metadata, - envs=envs, - cpu=cpu, - memory=memory, - disk=disk, - gpu=gpu, - secure=secure, - domain=_same("sandbox_domain", sandbox_domain, "domain", domain), - overrides=_endpoint_overrides( - api_key=api_key, - sandbox_api_key=sandbox_api_key, - api_url=api_url, - sandbox_base_url=sandbox_base_url, - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - ), - options=provider_options, - ) - domain = _same("sandbox_domain", sandbox_domain, "domain", domain) + if mounts and "volume_mounts" not in prov.create_options: + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError - config, auth, http_client, sandbox_protocol = _build_infra( - api_key=_same("sandbox_api_key", sandbox_api_key, "api_key", api_key), - api_url=_same("sandbox_base_url", sandbox_base_url, "api_url", api_url), - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - provider_options=provider_options, + raise ProviderFeatureNotSupportedError(prov.id, "volume_mounts") + return await cls._create_via_provider( + prov, + template=template, + timeout=timeout, + request_timeout=request_timeout, + metadata=metadata, + envs=envs, + cpu=cpu, + memory=memory, + disk=disk, + gpu=gpu, + volume_mounts=mounts, + secure=secure, + domain=_same("sandbox_domain", sandbox_domain, "domain", domain), + overrides=_endpoint_overrides( + api_key=api_key, + sandbox_api_key=sandbox_api_key, + api_url=api_url, + sandbox_base_url=sandbox_base_url, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + region=region, + ), + options=provider_options, ) - info = None - try: - info = await sandbox_protocol.create( - SandboxConfig( - templateID=template, - timeout=timeout, - metadata=metadata or {}, - envVars=envs or {}, - cpu=cpu, - memory=memory, - disk=disk, - gpu=gpu, - ), - request_timeout=request_timeout, - ) - from agent_sandbox.transport.http import stream_envelope_for_envd - - http_client._stream_envelope = stream_envelope_for_envd(info.envd_version) - - envd_token = EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id) - - logger.info("Sandbox created: %s", info.sandbox_id) - - # The caller often passes a deploy alias. The cache directory is - # frequently the platform template id, which only exists on info - # after create returns. - resolved = await resolve_capabilities_for( - template, - info.template, - info.alias_name, - ) - - return cls( - info=info, - config=config, - http_client=http_client, - auth=auth, - envd_token=envd_token, - sandbox_protocol=sandbox_protocol, - process_protocol=ProcessProtocol(http_client), - filesystem_protocol=FilesystemProtocol(http_client), - domain=domain or config.sandbox_domain, - secure=True if secure is None else secure, - resolved_capabilities=resolved, - ) - except Exception: - if info is not None: - try: - await sandbox_protocol.kill(info.sandbox_id) - except Exception: - logger.debug("failed to kill orphaned sandbox %s", info.sandbox_id) - await http_client.close() - raise - @classmethod async def connect( cls, @@ -707,58 +665,22 @@ async def connect( """ prov, bare_id = _select_provider(provider, sandbox_id) domain = _same("sandbox_domain", sandbox_domain, "domain", domain) - if prov is not None: - return await cls._connect_via_provider( - prov, - bare_id or sandbox_id, - domain=domain, - overrides=_endpoint_overrides( - api_key=api_key, - sandbox_api_key=sandbox_api_key, - api_url=api_url, - sandbox_base_url=sandbox_base_url, - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - ), - options=provider_options, - ) - sandbox_id = bare_id or sandbox_id - - config, auth, http_client, sandbox_protocol = _build_infra( - api_key=_same("sandbox_api_key", sandbox_api_key, "api_key", api_key), - api_url=_same("sandbox_base_url", sandbox_base_url, "api_url", api_url), - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - provider_options=provider_options, + return await cls._connect_via_provider( + prov, + bare_id or sandbox_id, + domain=domain, + overrides=_endpoint_overrides( + api_key=api_key, + sandbox_api_key=sandbox_api_key, + api_url=api_url, + sandbox_base_url=sandbox_base_url, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + region=region, + ), + options=provider_options, ) - try: - info = await sandbox_protocol.connect(sandbox_id) - envd_token = EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id) - from agent_sandbox.transport.http import stream_envelope_for_envd - - http_client._stream_envelope = stream_envelope_for_envd(info.envd_version) - - logger.info("Connected to sandbox: %s", sandbox_id) - - resolved = await resolve_capabilities_for(info.template, info.alias_name) - - return cls( - info=info, - config=config, - http_client=http_client, - auth=auth, - envd_token=envd_token, - sandbox_protocol=sandbox_protocol, - domain=domain or config.sandbox_domain, - resolved_capabilities=resolved, - ) - except Exception: - await http_client.close() - raise - @classmethod async def list( cls, @@ -800,37 +722,23 @@ async def list( List of SandboxInfo objects. """ prov, _ = _select_provider(provider) - if prov is not None: - infra = await _provider_infra( - prov, - _endpoint_overrides( - api_key=api_key, - sandbox_api_key=sandbox_api_key, - api_url=api_url, - sandbox_base_url=sandbox_base_url, - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - ), - provider_options, - ) - try: - return await infra.sandbox_protocol.list(status=status, limit=limit, offset=offset) # type: ignore[no-any-return] - finally: - await _InfraCloser(infra).close() - - config, auth, http_client, sandbox_protocol = _build_infra( - api_key=_same("sandbox_api_key", sandbox_api_key, "api_key", api_key), - api_url=_same("sandbox_base_url", sandbox_base_url, "api_url", api_url), - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - provider_options=provider_options, + infra = await _provider_infra( + prov, + _endpoint_overrides( + api_key=api_key, + sandbox_api_key=sandbox_api_key, + api_url=api_url, + sandbox_base_url=sandbox_base_url, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + region=region, + ), + provider_options, ) try: - return await sandbox_protocol.list(status=status, limit=limit, offset=offset) + return await infra.sandbox_protocol.list(status=status, limit=limit, offset=offset) # type: ignore[no-any-return] finally: - await http_client.close() + await _InfraCloser(infra).close() @classmethod async def get_info_by_id( @@ -938,32 +846,21 @@ async def _control_plane_by_id( prov, bare = _select_provider(provider, sandbox_id) target = bare or sandbox_id closer: _InfraCloser | HttpClient - if prov is not None: - infra = await _provider_infra( - prov, - _endpoint_overrides( - api_key=api_key, - sandbox_api_key=sandbox_api_key, - api_url=api_url, - sandbox_base_url=sandbox_base_url, - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - ), - provider_options, - ) - protocol = infra.sandbox_protocol - closer = _InfraCloser(infra) - else: - _, _, http, protocol = _build_infra( - api_key=_same("sandbox_api_key", sandbox_api_key, "api_key", api_key), - api_url=_same("sandbox_base_url", sandbox_base_url, "api_url", api_url), + infra = await _provider_infra( + prov, + _endpoint_overrides( + api_key=api_key, + sandbox_api_key=sandbox_api_key, + api_url=api_url, + sandbox_base_url=sandbox_base_url, access_key_id=access_key_id, access_key_secret=access_key_secret, region=region, - provider_options=provider_options, - ) - closer = http + ), + provider_options, + ) + protocol = infra.sandbox_protocol + closer = _InfraCloser(infra) try: if operation == "get_info": return await protocol.get_info(target) @@ -1007,40 +904,25 @@ async def kill_by_id( """ prov, bare_id = _select_provider(provider, sandbox_id) sandbox_id = bare_id or sandbox_id - if prov is not None: - infra = await _provider_infra( - prov, - _endpoint_overrides( - api_key=api_key, - sandbox_api_key=sandbox_api_key, - api_url=api_url, - sandbox_base_url=sandbox_base_url, - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - ), - provider_options, - ) - try: - await infra.sandbox_protocol.kill(sandbox_id) - logger.info("Sandbox killed (by ID): %s", sandbox_id) - finally: - await _InfraCloser(infra).close() - return - - config, auth, http_client, sandbox_protocol = _build_infra( - api_key=_same("sandbox_api_key", sandbox_api_key, "api_key", api_key), - api_url=_same("sandbox_base_url", sandbox_base_url, "api_url", api_url), - access_key_id=access_key_id, - access_key_secret=access_key_secret, - region=region, - provider_options=provider_options, + infra = await _provider_infra( + prov, + _endpoint_overrides( + api_key=api_key, + sandbox_api_key=sandbox_api_key, + api_url=api_url, + sandbox_base_url=sandbox_base_url, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + region=region, + ), + provider_options, ) try: - await sandbox_protocol.kill(sandbox_id) + await infra.sandbox_protocol.kill(sandbox_id) logger.info("Sandbox killed (by ID): %s", sandbox_id) finally: - await http_client.close() + await _InfraCloser(infra).close() + return @classmethod async def _create_via_provider( @@ -1056,6 +938,7 @@ async def _create_via_provider( memory: int | None, disk: int | None, gpu: str | None, + volume_mounts: dict[str, str], secure: bool | None, domain: str | None, overrides: dict[str, Any], @@ -1063,6 +946,7 @@ async def _create_via_provider( ) -> Sandbox: infra = await _provider_infra(prov, overrides, options) closer = _InfraCloser(infra) + created: SandboxInfo | None = None try: sandbox_config = SandboxConfig( # type: ignore[call-arg] template=template, @@ -1073,13 +957,15 @@ async def _create_via_provider( memory=memory, disk=disk, gpu=gpu, + volume_mounts=volume_mounts, secure=secure, provider_options=dict(options or {}), ) info = await infra.sandbox_protocol.create( sandbox_config, request_timeout=request_timeout ) - envd_token = _envd_token(prov, info) + created = info + binding = await prov.prepare_session(infra, info, operation="create", template=template) logger.info("Sandbox created: %s (provider %s)", info.sandbox_id, prov.id) resolved = ResolvedCapabilities( capabilities=set(DEFAULT_CAPABILITIES) | set(prov.capabilities()) @@ -1089,18 +975,31 @@ async def _create_via_provider( config=infra.config, http_client=closer, # type: ignore[arg-type] auth=infra.auth, - envd_token=envd_token, + envd_token=binding.pop("envd_token"), sandbox_protocol=infra.sandbox_protocol, - process_protocol=infra.process_protocol, - filesystem_protocol=infra.filesystem_protocol, - code_interpreter_protocol=infra.code_interpreter_protocol, + process_protocol=binding.pop("process_protocol", infra.process_protocol), + filesystem_protocol=binding.pop("filesystem_protocol", infra.filesystem_protocol), + code_interpreter_protocol=binding.pop( + "code_interpreter_protocol", infra.code_interpreter_protocol + ), domain=domain or infra.config.sandbox_domain, secure=True if secure is None else secure, - resolved_capabilities=resolved, - declared_capabilities=frozenset(prov.capabilities()), + resolved_capabilities=binding.pop("resolved_capabilities", resolved), + declared_capabilities=binding.pop( + "declared_capabilities", frozenset(prov.capabilities()) + ), provider_id=prov.id, + **binding, ) except Exception: + if created is not None: + try: + await infra.sandbox_protocol.kill(created.sandbox_id) + except Exception: + logger.warning( + "Created sandbox cleanup failed after session binding failed; " + "inspect provider resources." + ) await closer.close() raise @@ -1118,7 +1017,9 @@ async def _connect_via_provider( closer = _InfraCloser(infra) try: info = await infra.sandbox_protocol.connect(sandbox_id) - envd_token = _envd_token(prov, info) + binding = await prov.prepare_session( + infra, info, operation="connect", template=info.template + ) logger.info("Connected to sandbox: %s (provider %s)", sandbox_id, prov.id) resolved = ResolvedCapabilities( capabilities=set(DEFAULT_CAPABILITIES) | set(prov.capabilities()) @@ -1128,15 +1029,20 @@ async def _connect_via_provider( config=infra.config, http_client=closer, # type: ignore[arg-type] auth=infra.auth, - envd_token=envd_token, + envd_token=binding.pop("envd_token"), sandbox_protocol=infra.sandbox_protocol, - process_protocol=infra.process_protocol, - filesystem_protocol=infra.filesystem_protocol, - code_interpreter_protocol=infra.code_interpreter_protocol, + process_protocol=binding.pop("process_protocol", infra.process_protocol), + filesystem_protocol=binding.pop("filesystem_protocol", infra.filesystem_protocol), + code_interpreter_protocol=binding.pop( + "code_interpreter_protocol", infra.code_interpreter_protocol + ), domain=domain or infra.config.sandbox_domain, - resolved_capabilities=resolved, - declared_capabilities=frozenset(prov.capabilities()), + resolved_capabilities=binding.pop("resolved_capabilities", resolved), + declared_capabilities=binding.pop( + "declared_capabilities", frozenset(prov.capabilities()) + ), provider_id=prov.id, + **binding, ) except Exception: await closer.close() @@ -1195,7 +1101,9 @@ async def refresh_info(self) -> SandboxInfo: self._info = await self._sandbox_protocol.get_info(self.id) # Re-derive envd URL if the platform still omits it if not self._info.envd_url and self._info.sandbox_id: - self._info.envd_url = self._config.build_envd_url(self._info.sandbox_id) + self._info.envd_url = get_provider(self._provider_id).resolve_session_url( + self._info, self._config + ) return self._info # ---- Convenience methods ---- @@ -1253,18 +1161,13 @@ async def get_terminal( rows: Terminal rows (default 24). shell: Shell binary (default ``/bin/bash``). """ - opener = getattr(self._process_protocol, "open_terminal", None) - if opener is not None: - return await opener(self.id, cols=cols, rows=rows) # type: ignore[no-any-return] - from agent_sandbox.protocol.terminal import TerminalSession + from agent_sandbox.providers.registry import get_provider - return await TerminalSession.create( - envd_url=self.url, - envd_token=self._envd_token, - config=self._config, - cols=cols, - rows=rows, - shell=shell, + return cast( + "TerminalSession", + await get_provider(self._provider_id).open_terminal( + self, cols=cols, rows=rows, shell=shell + ), ) # ---- Named commands (registered on the in-sandbox SandboxServer) ---- diff --git a/client-sdk/src/agent_sandbox/api/server_client.py b/client-sdk/src/agent_sandbox/api/server_client.py index dd143e0..f9e7a4d 100644 --- a/client-sdk/src/agent_sandbox/api/server_client.py +++ b/client-sdk/src/agent_sandbox/api/server_client.py @@ -25,7 +25,6 @@ import base64 import contextlib import json -import re from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import quote, urlsplit, urlunsplit @@ -1168,16 +1167,13 @@ def pty_url(self) -> str: return urlunsplit((scheme, parts.netloc, parts.path.rstrip("/"), "", "")) def _derive_pty_url(self) -> str: - parts = urlsplit(self.base_url) - host = parts.hostname or "" - gateway = re.match(rf"^{DEFAULT_SERVER_PORT}-(.+)$", host) - if gateway: - netloc = f"{DEFAULT_PTY_PORT}-{gateway.group(1)}" - if parts.port: - netloc += f":{parts.port}" - else: - netloc = f"{host}:{DEFAULT_PTY_PORT}" - return urlunsplit((parts.scheme, netloc, "", "", "")) + from agent_sandbox._provider_schema import resolve_provider_schema + from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID + + builder = resolve_provider_schema(BUILTIN_PROVIDER_ID).legacy_pty_url_builder + if builder is None: + raise RuntimeError("Supply an explicit pty_url for this distribution.") + return builder(self.base_url, DEFAULT_SERVER_PORT, DEFAULT_PTY_PORT) def auth_headers(self) -> dict[str, str]: """Headers sent on every request (gateway headers plus the server bearer token).""" diff --git a/client-sdk/src/agent_sandbox/models/config.py b/client-sdk/src/agent_sandbox/models/config.py index 0a51529..ee96e02 100644 --- a/client-sdk/src/agent_sandbox/models/config.py +++ b/client-sdk/src/agent_sandbox/models/config.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field +from agent_sandbox.providers.bundled_config import DEFAULT_CONFIG + class GlobalConfig(BaseModel): """SDK-wide configuration settings. @@ -18,9 +20,9 @@ class GlobalConfig(BaseModel): access_key_secret: str | None = None # Connection - api_url: str = "https://api.cn-hangzhou.e2b.fc.aliyuncs.com" - domain: str = "cn-hangzhou.e2b.fc.aliyuncs.com" - region: str = "cn-hangzhou" + api_url: str = DEFAULT_CONFIG["endpoint_defaults"]["sandbox_base_url"] + domain: str = DEFAULT_CONFIG["endpoint_defaults"]["sandbox_domain"] + region: str = str(next(key.default for key in DEFAULT_CONFIG["keys"] if key.key == "region")) timeout: int = Field(default=300, ge=1) max_retries: int = Field(default=3, ge=0, le=10) secure: bool = True @@ -28,12 +30,12 @@ class GlobalConfig(BaseModel): @staticmethod def api_url_for_region(region: str) -> str: """Generate the Platform API URL for a given region.""" - return f"https://api.{region}.e2b.fc.aliyuncs.com" + return str(DEFAULT_CONFIG["derive"]["sandbox_base_url"]).replace("{region}", region) @staticmethod def domain_for_region(region: str) -> str: """Generate the data-plane domain for a given region.""" - return f"{region}.e2b.fc.aliyuncs.com" + return str(DEFAULT_CONFIG["derive"]["sandbox_domain"]).replace("{region}", region) # Logging log_level: str = "WARNING" diff --git a/client-sdk/src/agent_sandbox/models/errors.py b/client-sdk/src/agent_sandbox/models/errors.py index 0f4faa6..c715213 100644 --- a/client-sdk/src/agent_sandbox/models/errors.py +++ b/client-sdk/src/agent_sandbox/models/errors.py @@ -87,9 +87,7 @@ class InvalidCredentialsError(AuthenticationError): """AK/SK credentials are invalid.""" code = "E1003" - suggestion = ( - "Check ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET environment variables." - ) + suggestion = "Check the selected provider authentication configuration (abx provider info)." class TokenExchangeError(AuthenticationError): diff --git a/client-sdk/src/agent_sandbox/models/sandbox.py b/client-sdk/src/agent_sandbox/models/sandbox.py index f867718..98b2bb7 100644 --- a/client-sdk/src/agent_sandbox/models/sandbox.py +++ b/client-sdk/src/agent_sandbox/models/sandbox.py @@ -1,15 +1,46 @@ """Sandbox-related data models. 字段命名与 camelCase alias 已根据真实 API 实测验证。 +Volume mount creation hints follow the published API contract; real storage acceptance is separate. """ from __future__ import annotations import enum -from datetime import datetime # noqa: TC003 # Pydantic needs at runtime +from datetime import datetime # noqa: TCH003 # Pydantic needs at runtime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + +from agent_sandbox.models.errors import ProviderConfigError + + +def validate_volume_mounts(value: Any) -> dict[str, str]: + """Validate mount-path to existing Volume-name hints without coercing values.""" + if value is None: + return {} + if type(value) is not dict: # noqa: E721 - reject custom dictionary subclasses + raise ProviderConfigError( + "volume_mounts must be a dictionary of mount paths to Volume names." + ) + for path, name in value.items(): + if ( + not isinstance(path, str) + or path in {"__proto__", "prototype", "constructor"} + or not path.startswith("/") + or path == "/" + or path != path.strip() + or "\x00" in path + or any(not part or part.startswith(".") for part in path.split("/")[1:]) + or path.split("/")[1] in {"bin", "opt", "var", "dev"} + ): + raise ProviderConfigError( + "volume_mounts paths must be normalized absolute Unix paths outside " + "/, /bin, /opt, /var and /dev, without hidden path segments." + ) + if not isinstance(name, str) or not name.strip() or name != name.strip() or "\x00" in name: + raise ProviderConfigError("volume_mounts requires nonblank, unpadded Volume names.") + return dict(value) class SandboxStatus(str, enum.Enum): @@ -38,6 +69,16 @@ class SandboxConfig(BaseModel): env_vars: dict[str, str] = Field( default_factory=dict, alias="envVars", description="环境变量键值对(已实测验证)" ) + volume_mounts: dict[str, str] = Field( + default_factory=dict, + exclude=True, + description="Absolute sandbox mount paths mapped to existing Volume names", + ) + + @field_validator("volume_mounts", mode="before") + @classmethod + def _mounts(cls, value: Any) -> dict[str, str]: + return validate_volume_mounts(value) # Resource configuration (optional — only sent when explicitly set) cpu: int | None = Field(default=None, description="CPU 核数") @@ -58,7 +99,8 @@ class SandboxConfig(BaseModel): model_config = {"populate_by_name": True} def to_create_payload(self) -> dict[str, Any]: - """Build the camelCase request payload for sandbox creation(已实测验证).""" + """Build the create payload; mount serialization follows the published API contract.""" + mounts = validate_volume_mounts(self.volume_mounts) payload: dict[str, Any] = { "templateID": self.template, "timeout": self.timeout, @@ -74,6 +116,10 @@ def to_create_payload(self) -> dict[str, Any]: payload["diskSizeMB"] = self.disk if self.gpu is not None: payload["gpu"] = self.gpu + if mounts: + payload["volumeMounts"] = [ + {"name": name, "path": path} for path, name in mounts.items() + ] return payload diff --git a/client-sdk/src/agent_sandbox/protocol/__init__.py b/client-sdk/src/agent_sandbox/protocol/__init__.py index 9fa0f59..016c648 100644 --- a/client-sdk/src/agent_sandbox/protocol/__init__.py +++ b/client-sdk/src/agent_sandbox/protocol/__init__.py @@ -1,13 +1,19 @@ -"""L2 Core Protocol layer — typed wrappers for Platform API and envd API.""" +"""Structural protocol package with lazy historical wire exports.""" from __future__ import annotations -from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol -from agent_sandbox.protocol.filesystem import FilesystemProtocol -from agent_sandbox.protocol.port import PortClient -from agent_sandbox.protocol.process import ProcessProtocol -from agent_sandbox.protocol.sandbox import SandboxProtocol -from agent_sandbox.protocol.terminal import TerminalSession +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from agent_sandbox.protocol.code_interpreter import ( + CodeInterpreterProtocol as CodeInterpreterProtocol, + ) + from agent_sandbox.protocol.filesystem import FilesystemProtocol as FilesystemProtocol + from agent_sandbox.protocol.port import PortClient as PortClient + from agent_sandbox.protocol.process import ProcessProtocol as ProcessProtocol + from agent_sandbox.protocol.sandbox import SandboxProtocol as SandboxProtocol + from agent_sandbox.protocol.terminal import TerminalSession as TerminalSession __all__ = [ "SandboxProtocol", @@ -17,3 +23,17 @@ "TerminalSession", "PortClient", ] + + +def __getattr__(name: str) -> Any: + modules = { + "SandboxProtocol": "sandbox", + "ProcessProtocol": "process", + "FilesystemProtocol": "filesystem", + "CodeInterpreterProtocol": "code_interpreter", + "TerminalSession": "terminal", + "PortClient": "port", + } + if name not in modules: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(import_module(f"agent_sandbox.protocol.{modules[name]}"), name) diff --git a/client-sdk/src/agent_sandbox/protocol/code_interpreter.py b/client-sdk/src/agent_sandbox/protocol/code_interpreter.py index 341e6e2..a50c3c2 100644 --- a/client-sdk/src/agent_sandbox/protocol/code_interpreter.py +++ b/client-sdk/src/agent_sandbox/protocol/code_interpreter.py @@ -1,210 +1,34 @@ -"""Code Interpreter 协议层。 - -注意:当前实现基于 E2B SDK 逆向推断,阿里云官方文档未公开 -Code Interpreter 的底层传输细节。需实测验证。 - -Uses Connect RPC over HTTP with application/connect+json content type. - -RPC endpoints: -- /code.CodeInterpreter/Execute — Execute code in a context -- /code.CodeInterpreter/CreateContext — Create an execution context -- /code.CodeInterpreter/ListContexts — List execution contexts -- /code.CodeInterpreter/RestartContext — Restart an execution context -- /code.CodeInterpreter/RemoveContext — Remove an execution context -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from agent_sandbox.transport.codec import ConnectCodec -from agent_sandbox.utils.logging import get_logger - -if TYPE_CHECKING: - from collections.abc import Callable - - from agent_sandbox.transport.auth import EnvdTokenManager - from agent_sandbox.transport.http import HttpClient - -logger = get_logger("protocol.code_interpreter") - -# Extra seconds added to the caller-supplied timeout to derive the HTTP read -# timeout. Mirrors the constant in protocol.process. -_HTTP_TIMEOUT_BUFFER: float = 5.0 - -_codec = ConnectCodec() - -# RPC paths — 注意:路径基于 E2B SDK 逆向推断,未经官方文档确认 -_EXECUTE = _codec.build_rpc_path("code", "CodeInterpreter", "Execute") -_CREATE_CONTEXT = _codec.build_rpc_path("code", "CodeInterpreter", "CreateContext") -_LIST_CONTEXTS = _codec.build_rpc_path("code", "CodeInterpreter", "ListContexts") -_RESTART_CONTEXT = _codec.build_rpc_path("code", "CodeInterpreter", "RestartContext") -_REMOVE_CONTEXT = _codec.build_rpc_path("code", "CodeInterpreter", "RemoveContext") - - -class CodeInterpreterProtocol: - """Code Interpreter Connect RPC 协议客户端。 - - 注意:当前实现基于 E2B SDK 逆向推断,阿里云官方文档未公开 - Code Interpreter 的底层传输细节。所有 RPC 路径和请求/响应格式 - 均需实测验证。 - """ - - def __init__(self, http_client: HttpClient) -> None: - self._http = http_client - - async def run_code( - self, - sandbox_id: str, - envd_url: str, - envd_token: EnvdTokenManager, - *, - code: str, - language: str = "python", - context_id: str | None = None, - timeout: int = 30, - envs: dict[str, str] | None = None, - on_stdout: Callable[[str], None] | None = None, - on_stderr: Callable[[str], None] | None = None, - on_result: Callable[..., None] | None = None, - ) -> dict[str, Any]: - """执行代码。对应官方 sandbox.runCode() / sandbox.run_code()。 - - POST envd_url + /code.CodeInterpreter/Execute (Connect unary) - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - - Implementation note — **buffered fallback path**: - The CodeInterpreter/Execute endpoint is accessed as a Connect unary - RPC (not a server-streaming RPC), so true incremental streaming is - not available at the protocol level. When ``on_stdout``, - ``on_stderr``, or ``on_result`` callbacks are supplied they are - invoked **once** after the full response is received — this keeps - the E2B-style callback API contract (callers can rely on the - callbacks firing) while the underlying transport remains unary. - """ - payload: dict[str, Any] = { - "code": code, - "language": language, - "timeout": timeout, - } - if context_id: - payload["contextId"] = context_id - if envs: - payload["envVars"] = envs - - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - # Derive the HTTP read timeout from the code-execution timeout so - # that long-running code is not killed by the HTTP layer's default - # 30 s timeout before the server-side timeout fires. - http_timeout = float(timeout) + _HTTP_TIMEOUT_BUFFER - - response = await self._http.envd_request( - envd_url, - _EXECUTE, - payload=payload, - envd_token=envd_token, - request_timeout=http_timeout, - ) - - # --- Buffered callback dispatch ------------------------------------ - # Fire callbacks with the complete response data so callers using - # the E2B streaming-callback style still receive notifications. - if on_stdout or on_stderr or on_result: - result = response.get("result", response) - stdout = result.get("stdout", "") - stderr = result.get("stderr", "") - - if on_stdout and stdout: - on_stdout(stdout) - if on_stderr and stderr: - on_stderr(stderr) - if on_result: - on_result(result) - - return response - - async def create_context( - self, - sandbox_id: str, - envd_url: str, - envd_token: EnvdTokenManager, - *, - language: str = "python", - ) -> dict[str, Any]: - """创建执行上下文。对应官方 sandbox.createCodeContext()。 - - POST envd_url + /code.CodeInterpreter/CreateContext (Connect unary) - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - """ - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - response = await self._http.envd_request( - envd_url, - _CREATE_CONTEXT, - payload={"language": language}, - envd_token=envd_token, - ) - return response - - async def list_contexts( - self, - sandbox_id: str, - envd_url: str, - envd_token: EnvdTokenManager, - ) -> list[dict[str, Any]]: - """查询执行上下文列表。对应官方 sandbox.listCodeContexts()。 - - POST envd_url + /code.CodeInterpreter/ListContexts (Connect unary) - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - """ - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - response = await self._http.envd_request( - envd_url, - _LIST_CONTEXTS, - payload={}, - envd_token=envd_token, - ) - contexts = response.get("contexts", response.get("result", {}).get("contexts", [])) - return contexts # type: ignore[no-any-return] - - async def restart_context( - self, - sandbox_id: str, - envd_url: str, - envd_token: EnvdTokenManager, - *, - context_id: str, - ) -> dict[str, Any]: - """重启执行上下文。对应官方 sandbox.restartCodeContext()。 - - POST envd_url + /code.CodeInterpreter/RestartContext (Connect unary) - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - """ - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - response = await self._http.envd_request( - envd_url, - _RESTART_CONTEXT, - payload={"contextId": context_id}, - envd_token=envd_token, - ) - return response - - async def remove_context( - self, - sandbox_id: str, - envd_url: str, - envd_token: EnvdTokenManager, - *, - context_id: str, - ) -> None: - """删除执行上下文。对应官方 sandbox.removeCodeContext()。 - - POST envd_url + /code.CodeInterpreter/RemoveContext (Connect unary) - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - """ - # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 - await self._http.envd_request( - envd_url, - _REMOVE_CONTEXT, - payload={"contextId": context_id}, - envd_token=envd_token, - ) +"""Compatibility exports; backend implementation lives in its provider.""" + +import sys as _sys +from importlib import import_module as _import_module + +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _CREATE_CONTEXT as _CREATE_CONTEXT, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _EXECUTE as _EXECUTE, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _HTTP_TIMEOUT_BUFFER as _HTTP_TIMEOUT_BUFFER, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _LIST_CONTEXTS as _LIST_CONTEXTS, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _REMOVE_CONTEXT as _REMOVE_CONTEXT, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _RESTART_CONTEXT as _RESTART_CONTEXT, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + CodeInterpreterProtocol as CodeInterpreterProtocol, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + _codec as _codec, +) +from agent_sandbox.providers.shared.e2b.code_interpreter import ( + logger as logger, +) + +_sys.modules[__name__] = _import_module("agent_sandbox.providers.shared.e2b.code_interpreter") diff --git a/client-sdk/src/agent_sandbox/protocol/contracts.py b/client-sdk/src/agent_sandbox/protocol/contracts.py new file mode 100644 index 0000000..9fce420 --- /dev/null +++ b/client-sdk/src/agent_sandbox/protocol/contracts.py @@ -0,0 +1,228 @@ +"""Structural protocol contracts. Core never constructs a backend wire implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + import builtins + from collections.abc import AsyncIterator, Callable + + from agent_sandbox.models.filesystem import FileInfo, WatchEvent + from agent_sandbox.models.process import ProcessChunk, ProcessInfo + from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo, SandboxStatus + from agent_sandbox.transport.auth_contract import DataPlaneAuth as EnvdTokenManager + from agent_sandbox.transport.config import TransportConfig + from agent_sandbox.transport.streaming import StreamReader + + +class SandboxProtocol(Protocol): + async def create( + self, config: SandboxConfig, *, request_timeout: float | None = None + ) -> SandboxInfo: ... + + async def list( + self, *, status: SandboxStatus | None = None, limit: int = 100, offset: int = 0 + ) -> builtins.list[SandboxInfo]: ... + + async def list_v2( + self, *, status: SandboxStatus | None = None, limit: int = 100, offset: int = 0 + ) -> builtins.list[SandboxInfo]: ... + + async def get_info(self, sandbox_id: str) -> SandboxInfo: ... + + async def kill(self, sandbox_id: str) -> None: ... + + async def set_timeout(self, sandbox_id: str, timeout: int) -> None: ... + + async def connect(self, sandbox_id: str) -> SandboxInfo: ... + + async def is_running(self, sandbox_id: str) -> bool: ... + + async def pause(self, sandbox_id: str) -> None: ... + + async def resume(self, sandbox_id: str) -> None: ... + + +class ProcessProtocol(Protocol): + async def start( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + cmd: str, + args: builtins.list[str] | None = None, + env: dict[str, str] | None = None, + cwd: str = "", + timeout: int = 60, + user: str = "", + ) -> StreamReader[ProcessChunk]: ... + + async def list_processes( + self, envd_url: str, envd_token: EnvdTokenManager + ) -> builtins.list[ProcessInfo]: ... + + async def connect_to_process( + self, envd_url: str, envd_token: EnvdTokenManager, *, pid: int | str + ) -> StreamReader[ProcessChunk]: ... + + async def update( + self, envd_url: str, envd_token: EnvdTokenManager, *, pid: int | str, **kwargs: Any + ) -> dict[str, Any]: ... + + async def send_input( + self, envd_url: str, envd_token: EnvdTokenManager, *, pid: int | str, data: str + ) -> None: ... + + async def send_signal( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + signal: int = 15, + request_timeout: float | None = None, + ) -> None: ... + + async def send_stdin( + self, envd_url: str, envd_token: EnvdTokenManager, *, pid: int | str, data: str + ) -> None: ... + + async def kill( + self, envd_url: str, envd_token: EnvdTokenManager, *, pid: int | str + ) -> None: ... + + +class FilesystemProtocol(Protocol): + async def stat(self, envd_url: str, envd_token: EnvdTokenManager, *, path: str) -> FileInfo: ... + + async def list_dir( + self, envd_url: str, envd_token: EnvdTokenManager, *, path: str = "/" + ) -> builtins.list[FileInfo]: ... + + async def make_dir(self, envd_url: str, envd_token: EnvdTokenManager, *, path: str) -> None: ... + + async def remove(self, envd_url: str, envd_token: EnvdTokenManager, *, path: str) -> None: ... + + async def move( + self, envd_url: str, envd_token: EnvdTokenManager, *, source: str, destination: str + ) -> None: ... + + async def watch_dir( + self, envd_url: str, envd_token: EnvdTokenManager, *, path: str + ) -> StreamReader[WatchEvent]: ... + + async def upload_file( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + content: bytes, + username: str = "user", + ) -> None: ... + + async def download_file( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + username: str = "user", + max_bytes: int | None = None, + ) -> bytes: ... + + async def exists(self, envd_url: str, envd_token: EnvdTokenManager, *, path: str) -> bool: ... + + async def get_info( + self, envd_url: str, envd_token: EnvdTokenManager, *, path: str + ) -> FileInfo: ... + + async def read( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + max_bytes: int | None = None, + ) -> bytes: ... + + async def read_text( + self, envd_url: str, envd_token: EnvdTokenManager, *, path: str, encoding: str = "utf-8" + ) -> str: ... + + async def write( + self, envd_url: str, envd_token: EnvdTokenManager, *, path: str, content: str | bytes + ) -> None: ... + + async def rename( + self, envd_url: str, envd_token: EnvdTokenManager, *, old_path: str, new_path: str + ) -> None: ... + + +class CodeInterpreterProtocol(Protocol): + async def run_code( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + *, + code: str, + language: str = "python", + context_id: str | None = None, + timeout: int = 30, + envs: dict[str, str] | None = None, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + on_result: Callable[..., None] | None = None, + ) -> dict[str, Any]: ... + + async def create_context( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + *, + language: str = "python", + ) -> dict[str, Any]: ... + + async def list_contexts( + self, sandbox_id: str, envd_url: str, envd_token: EnvdTokenManager + ) -> builtins.list[dict[str, Any]]: ... + + async def restart_context( + self, sandbox_id: str, envd_url: str, envd_token: EnvdTokenManager, *, context_id: str + ) -> dict[str, Any]: ... + + async def remove_context( + self, sandbox_id: str, envd_url: str, envd_token: EnvdTokenManager, *, context_id: str + ) -> None: ... + + +class TerminalSession(Protocol): + @classmethod + async def create( + cls, + envd_url: str, + envd_token: EnvdTokenManager, + config: TransportConfig, + *, + cols: int = 80, + rows: int = 24, + shell: str = "/bin/bash", + ) -> TerminalSession: ... + + async def send(self, data: str) -> None: ... + + async def send_bytes(self, data: bytes) -> None: ... + + async def receive(self, timeout: float | None = None) -> str | bytes: ... + + async def output_stream(self) -> AsyncIterator[str | bytes]: ... + + async def resize(self, cols: int, rows: int) -> None: ... + + @property + def is_connected(self) -> bool: ... + + async def close(self) -> None: ... diff --git a/client-sdk/src/agent_sandbox/protocol/filesystem.py b/client-sdk/src/agent_sandbox/protocol/filesystem.py index a171b19..86cb0e0 100644 --- a/client-sdk/src/agent_sandbox/protocol/filesystem.py +++ b/client-sdk/src/agent_sandbox/protocol/filesystem.py @@ -1,441 +1,55 @@ -"""envd API protocol — filesystem operations (Connect protocol + HTTP file API). +"""Compatibility exports; backend implementation lives in its provider.""" -Connect RPC endpoints(已实测验证): -- /filesystem.Filesystem/Stat — Get file/dir info -- /filesystem.Filesystem/MakeDir — Create directory -- /filesystem.Filesystem/ListDir — List directory contents -- /filesystem.Filesystem/Remove — Remove file/directory -- /filesystem.Filesystem/Move — Move/rename file -- /filesystem.Filesystem/WatchDir (streaming) — Watch for changes +import sys as _sys +from importlib import import_module as _import_module -HTTP file API(非 Connect RPC,已实测验证): -- POST /files?path={path}&username=user (multipart) — Upload file → 201 -- GET /files?path={path}&username=user — Download file → 200 -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, NoReturn - -import httpx - -from agent_sandbox.models.errors import ( - FileNotFoundError_, - FileOperationError, - PermissionDeniedError, +from agent_sandbox.providers.shared.e2b.filesystem import ( + _LIST_DIR as _LIST_DIR, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _MAKE_DIR as _MAKE_DIR, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _MOVE as _MOVE, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _REMOVE as _REMOVE, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _STAT as _STAT, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _WATCH_DIR as _WATCH_DIR, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + FilesystemProtocol as FilesystemProtocol, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _codec as _codec, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _is_directory as _is_directory, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _name_from_path as _name_from_path, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _parse_file_info as _parse_file_info, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _parse_watch_event as _parse_watch_event, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _raise_file_error as _raise_file_error, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _raise_transfer_timeout as _raise_transfer_timeout, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + _unwrap_entry as _unwrap_entry, +) +from agent_sandbox.providers.shared.e2b.filesystem import ( + logger as logger, ) -from agent_sandbox.models.filesystem import FileInfo, FileType, WatchEvent, WatchEventType -from agent_sandbox.transport.codec import ConnectCodec -from agent_sandbox.transport.streaming import StreamReader -from agent_sandbox.utils.logging import get_logger - -if TYPE_CHECKING: - from agent_sandbox.transport.auth import EnvdTokenManager - from agent_sandbox.transport.http import HttpClient - -logger = get_logger("protocol.filesystem") - -_codec = ConnectCodec() - -# RPC paths(已实测验证) -_STAT = _codec.build_rpc_path("filesystem", "Filesystem", "Stat") -_MAKE_DIR = _codec.build_rpc_path("filesystem", "Filesystem", "MakeDir") -_LIST_DIR = _codec.build_rpc_path("filesystem", "Filesystem", "ListDir") -_REMOVE = _codec.build_rpc_path("filesystem", "Filesystem", "Remove") -_MOVE = _codec.build_rpc_path("filesystem", "Filesystem", "Move") -_WATCH_DIR = _codec.build_rpc_path("filesystem", "Filesystem", "WatchDir") - - -def _name_from_path(path: str) -> str: - """Extract the last valid path segment from *path*. - - Handles Unix paths, Windows-style back-slashes, trailing separators, - and root-only paths. Returns ``""`` when no meaningful segment exists - (e.g. ``"/"`` or ``""`` or ``"C:\\``). - """ - if not path: - return "" - stripped = path.rstrip("/\\") - if not stripped: - # Root-only path (e.g. "/", "//", "\\") - return "" - last_sep = max(stripped.rfind("/"), stripped.rfind("\\")) - return stripped[last_sep + 1 :] if last_sep >= 0 else stripped - - -def _unwrap_entry(data: dict[str, Any]) -> dict[str, Any]: - """Return an envd EntryInfo from a flat object or a ``result``/``entry`` wrapper.""" - current = data - result = current.get("result") - if isinstance(result, dict): - current = result - entry = current.get("entry") - if isinstance(entry, dict): - return entry - return current - - -def _is_directory(data: dict[str, Any]) -> bool: - """True for ``isDir``, ``directory``, or envd's protobuf directory type (2).""" - if data.get("isDir"): - return True - raw = data.get("type") - if isinstance(raw, bool) or raw is None: - return False - if isinstance(raw, int): - return raw == 2 - if isinstance(raw, str): - return raw.strip() in {"2", "directory", "FILE_TYPE_DIRECTORY", "dir"} - return False - - -def _parse_file_info(data: dict[str, Any]) -> FileInfo: - """Parse a file info dict from the API response. - - * **name**: prefers an explicit non-empty ``name`` from the server; - falls back to the last path segment of ``path``. - * **type**: DIRECTORY when ``isDir`` is truthy, ``type`` is ``directory`` - (or the envd enum ``2`` / ``FILE_TYPE_DIRECTORY``), otherwise FILE. - * **size**: numeric strings from protobuf JSON are coerced to ``int``. - """ - file_type = FileType.DIRECTORY if _is_directory(data) else FileType.FILE - - name = data.get("name") or "" - if not name: - name = _name_from_path(data.get("path", "")) - - size = data.get("size", 0) - if isinstance(size, str) and size.strip().lstrip("-").isdigit(): - size = int(size.strip()) - - return FileInfo( - name=name, - path=data.get("path", ""), - type=file_type, - size=size, - ) - - -def _raise_transfer_timeout( - exc: httpx.TimeoutException, verb: str, path: str, size: int -) -> NoReturn: - """A transfer outlived the HTTP timeout; name the operation, the size and the knob.""" - sized = f" ({size} bytes)" if size >= 0 else "" - raise FileOperationError( - f"{verb} {path}{sized} timed out; the transfer took longer than the HTTP timeout.", - suggestion=( - "Raise the HTTP timeout (ABX_HTTP_TIMEOUT / http_timeout) or transfer smaller chunks." - ), - ) from exc - - -def _raise_file_error(exc: httpx.HTTPStatusError, path: str) -> NoReturn: - """Translate an envd HTTP error into the SDK's filesystem errors (no URL in the text).""" - status = exc.response.status_code - if status == 404: - raise FileNotFoundError_(f"File or directory not found in the sandbox: {path}") from exc - if status == 403: - raise PermissionDeniedError(f"Permission denied in the sandbox: {path}") from exc - detail = exc.response.text.strip()[:200] - message = f"envd file operation failed (HTTP {status}) for {path}" - raise FileOperationError(f"{message}: {detail}" if detail else message) from exc - - -def _parse_watch_event(frame: dict[str, Any]) -> WatchEvent: - """Parse a streaming watch event frame.""" - data = frame.get("result", frame) - filesystem = data.get("filesystem") if isinstance(data.get("filesystem"), dict) else None - if filesystem is not None: - raw = str(filesystem.get("type") or "") - mapped = { - "EVENT_TYPE_CREATE": WatchEventType.CREATED, - "EVENT_TYPE_WRITE": WatchEventType.MODIFIED, - "EVENT_TYPE_REMOVE": WatchEventType.DELETED, - "EVENT_TYPE_RENAME": WatchEventType.RENAMED, - "EVENT_TYPE_CHMOD": WatchEventType.MODIFIED, - }.get(raw, WatchEventType.MODIFIED) - return WatchEvent(type=mapped, path=str(filesystem.get("name") or "")) - event_type_str = data.get("type", data.get("eventType", "modified")) - - type_map = { - "created": WatchEventType.CREATED, - "modified": WatchEventType.MODIFIED, - "deleted": WatchEventType.DELETED, - "renamed": WatchEventType.RENAMED, - "create": WatchEventType.CREATED, - "modify": WatchEventType.MODIFIED, - "delete": WatchEventType.DELETED, - "rename": WatchEventType.RENAMED, - } - event_type = type_map.get(event_type_str, WatchEventType.MODIFIED) - - return WatchEvent( - type=event_type, - path=data.get("path", ""), - old_path=data.get("oldPath"), - ) - - -class FilesystemProtocol: - """Typed wrapper for envd filesystem operations (Connect protocol + HTTP file API). - - Connect RPC 用于目录操作;HTTP API 用于文件上传/下载(已实测验证)。 - """ - - def __init__(self, http_client: HttpClient) -> None: - self._http = http_client - - async def _rpc( - self, - envd_url: str, - envd_token: EnvdTokenManager, - rpc_path: str, - path: str, - *, - payload: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Unary envd call; HTTP errors become SDK filesystem errors for *path*.""" - try: - return await self._http.envd_request( - envd_url, - rpc_path, - payload=payload if payload is not None else {"path": path}, - envd_token=envd_token, - ) - except httpx.HTTPStatusError as exc: - _raise_file_error(exc, path) - - # --- Connect RPC methods(已实测验证)--- - - async def stat( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - ) -> FileInfo: - """Get info about a file or directory. - - POST envd_url + /filesystem.Filesystem/Stat(已实测验证) - """ - response = await self._rpc(envd_url, envd_token, _STAT, path) - return _parse_file_info(_unwrap_entry(response)) - - async def list_dir( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str = "/", - ) -> list[FileInfo]: - """List directory contents. - - POST envd_url + /filesystem.Filesystem/ListDir(已实测验证) - """ - response = await self._rpc(envd_url, envd_token, _LIST_DIR, path) - - entries = response.get("entries", response.get("result", {}).get("entries", [])) - if not entries: - # envd answers HTTP 200 with no entries for a missing path; Stat tells the cases - # apart (raises FileNotFoundError_ when the path does not exist). - await self.stat(envd_url, envd_token, path=path) - return [_parse_file_info(_unwrap_entry(entry)) for entry in entries] - - async def make_dir( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - ) -> None: - """Create a directory (and parents). - - POST envd_url + /filesystem.Filesystem/MakeDir(已实测验证) - """ - await self._rpc(envd_url, envd_token, _MAKE_DIR, path) - - async def remove( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - ) -> None: - """Remove a file or directory. - - POST envd_url + /filesystem.Filesystem/Remove(已实测验证) - """ - await self._rpc(envd_url, envd_token, _REMOVE, path) - - async def move( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - source: str, - destination: str, - ) -> None: - """Move/rename a file or directory. - - POST envd_url + /filesystem.Filesystem/Move(已实测验证) - """ - await self._rpc( - envd_url, - envd_token, - _MOVE, - source, - payload={"source": source, "destination": destination}, - ) - - async def watch_dir( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - ) -> StreamReader[WatchEvent]: - """Watch a directory for changes (streaming). - - POST envd_url + /filesystem.Filesystem/WatchDir (Connect streaming, 已实测验证) - """ - raw_stream = self._http.envd_stream( - envd_url, - _WATCH_DIR, - payload={"path": path}, - envd_token=envd_token, - ) - return StreamReader(raw_stream, _parse_watch_event) - - # --- HTTP file API(非 Connect RPC,已实测验证)--- - - async def upload_file( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - content: bytes, - username: str = "user", - ) -> None: - """Upload a file via HTTP multipart POST. - - POST envd_url/files?path={path}&username=user → 201(已实测验证) - """ - try: - await self._http.envd_http_request( - envd_url, - "POST", - "/files", - envd_token=envd_token, - params={"path": path, "username": username}, - files={"file": ("file", content)}, - ) - except httpx.TimeoutException as exc: - _raise_transfer_timeout(exc, "Upload", path, len(content)) - except httpx.HTTPStatusError as exc: - _raise_file_error(exc, path) - - async def download_file( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - username: str = "user", - max_bytes: int | None = None, - ) -> bytes: - """Download a file via HTTP GET. - - GET envd_url/files?path={path}&username=user → 200(已实测验证) - """ - limits: dict[str, Any] = {} - if max_bytes is not None: - limits["max_response_bytes"] = max_bytes - try: - response = await self._http.envd_http_request( - envd_url, - "GET", - "/files", - envd_token=envd_token, - params={"path": path, "username": username}, - **limits, - ) - except httpx.TimeoutException as exc: - _raise_transfer_timeout(exc, "Download", path, -1) - except httpx.HTTPStatusError as exc: - _raise_file_error(exc, path) - return response.content - - # --- convenience wrappers --- - - async def exists( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - ) -> bool: - """Check if a file or directory exists (via Stat).""" - try: - await self.stat(envd_url, envd_token, path=path) - return True - except Exception: - return False - - async def get_info( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - ) -> FileInfo: - """Alias for stat(). Kept for backward compatibility.""" - return await self.stat(envd_url, envd_token, path=path) - - async def read( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - max_bytes: int | None = None, - ) -> bytes: - """Read file content via HTTP download API.""" - if max_bytes is None: - return await self.download_file(envd_url, envd_token, path=path) - return await self.download_file(envd_url, envd_token, path=path, max_bytes=max_bytes) - - async def read_text( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - encoding: str = "utf-8", - ) -> str: - """Read file content as text.""" - raw = await self.read(envd_url, envd_token, path=path) - return raw.decode(encoding) - - async def write( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - path: str, - content: str | bytes, - ) -> None: - """Write content to a file via HTTP upload API.""" - raw_bytes = content.encode("utf-8") if isinstance(content, str) else content - await self.upload_file(envd_url, envd_token, path=path, content=raw_bytes) - async def rename( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - old_path: str, - new_path: str, - ) -> None: - """Deprecated: use move(). Kept for backward compatibility.""" - await self.move(envd_url, envd_token, source=old_path, destination=new_path) +_sys.modules[__name__] = _import_module("agent_sandbox.providers.shared.e2b.filesystem") diff --git a/client-sdk/src/agent_sandbox/protocol/port.py b/client-sdk/src/agent_sandbox/protocol/port.py index 5c5a0a8..389cc80 100644 --- a/client-sdk/src/agent_sandbox/protocol/port.py +++ b/client-sdk/src/agent_sandbox/protocol/port.py @@ -1,59 +1,5 @@ -"""Protocol layer — 端口 URL 本地计算。 +"""Historical port helper exports; implementation belongs to the wire adapter.""" -端口 URL 为客户端本地计算,不需要服务端 API 调用。 -已实测验证:端口 URL 格式为 https://{port}-{sandbox_id}.{domain} -其中 sandbox_id 已包含 "sbx-" 前缀(如 "sbx-xxxx")。 -""" +from agent_sandbox.providers.shared.e2b.ports import PortClient as PortClient -from __future__ import annotations - -from agent_sandbox.utils.logging import get_logger - -logger = get_logger("protocol.port") - - -class PortClient: - """端口 URL 本地计算客户端。 - - 端口 URL 为客户端本地计算,不需要服务端 API 调用。 - 所有方法均为纯本地计算,不涉及任何网络请求。 - - 已实测验证:URL 格式为 {port}-{sandbox_id}.{domain}, - 其中 sandbox_id 已包含 "sbx-" 前缀。 - """ - - @staticmethod - def get_host(sandbox_id: str, port: int, domain: str) -> str: - """获取端口访问的 host 地址(已实测验证)。 - - Args: - sandbox_id: Sandbox ID(已包含 "sbx-" 前缀,如 "sbx-xxxx")。 - port: 端口号。 - domain: 平台域名。 - - Returns: - 格式为 ``{port}-{sandbox_id}.{domain}`` 的 host 字符串。 - """ - return f"{port}-{sandbox_id}.{domain}" - - @staticmethod - def get_port_url( - sandbox_id: str, - port: int, - domain: str, - secure: bool = True, - ) -> str: - """获取端口访问的完整 URL(已实测验证)。 - - Args: - sandbox_id: Sandbox ID(已包含 "sbx-" 前缀)。 - port: 端口号。 - domain: 平台域名。 - secure: 是否使用 HTTPS(默认 True)。 - - Returns: - 完整 URL,格式为 ``https://{port}-{sandbox_id}.{domain}``。 - """ - scheme = "https" if secure else "http" - host = PortClient.get_host(sandbox_id, port, domain) - return f"{scheme}://{host}" +__all__ = ["PortClient"] diff --git a/client-sdk/src/agent_sandbox/protocol/port_contract.py b/client-sdk/src/agent_sandbox/protocol/port_contract.py new file mode 100644 index 0000000..8acf31b --- /dev/null +++ b/client-sdk/src/agent_sandbox/protocol/port_contract.py @@ -0,0 +1,47 @@ +"""Provider-neutral port addressing and gateway authentication contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import NoReturn, Protocol + +from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + + +@dataclass(frozen=True) +class PortContext: + sandbox_id: str + domain: str + secure: bool + access_token: str | None + + +class PortAccess(Protocol): + def get_host(self, port: int) -> str: ... + def get_url(self, port: int) -> str: ... + def get_access_headers(self) -> dict[str, str]: ... + def get_server_headers(self) -> dict[str, str]: ... + + +class UnsupportedPortAccess: + def __init__(self, provider_id: str) -> None: + self._provider_id = provider_id + + def _fail(self) -> NoReturn: + raise ProviderFeatureNotSupportedError( + self._provider_id, + "ports.url", + suggestion="Use await sandbox.network.resolve(port) for asynchronous port discovery.", + ) + + def get_host(self, port: int) -> str: + self._fail() + + def get_url(self, port: int) -> str: + self._fail() + + def get_access_headers(self) -> dict[str, str]: + self._fail() + + def get_server_headers(self) -> dict[str, str]: + self._fail() diff --git a/client-sdk/src/agent_sandbox/protocol/process.py b/client-sdk/src/agent_sandbox/protocol/process.py index 16dc1f3..9cf25fe 100644 --- a/client-sdk/src/agent_sandbox/protocol/process.py +++ b/client-sdk/src/agent_sandbox/protocol/process.py @@ -1,390 +1,46 @@ -"""envd API protocol — process management (Connect protocol). +"""Compatibility exports; backend implementation lives in its provider.""" -Uses Connect RPC over HTTP with application/connect+json content type. -Streaming endpoints (Start, Connect) return AsyncIterator of typed chunks. +import sys as _sys +from importlib import import_module as _import_module -RPC endpoints(已实测验证): -- /process.Process/Start (streaming) — Start a process and stream output -- /process.Process/List — List running processes -- /process.Process/Connect (streaming) — Connect to existing process output -- /process.Process/Update — Update process settings -- /process.Process/SendInput — Send stdin to a process -- /process.Process/SendSignal — Send signal to a process -""" - -from __future__ import annotations - -import base64 -from typing import TYPE_CHECKING, Any - -import httpx - -from agent_sandbox.models.errors import CommandTimeoutError -from agent_sandbox.models.process import ( - ProcessChunk, - ProcessChunkType, - ProcessInfo, +from agent_sandbox.providers.shared.e2b.process import ( + _CONNECT as _CONNECT, +) +from agent_sandbox.providers.shared.e2b.process import ( + _HTTP_TIMEOUT_BUFFER as _HTTP_TIMEOUT_BUFFER, +) +from agent_sandbox.providers.shared.e2b.process import ( + _LIST as _LIST, +) +from agent_sandbox.providers.shared.e2b.process import ( + _SEND_INPUT as _SEND_INPUT, +) +from agent_sandbox.providers.shared.e2b.process import ( + _SEND_SIGNAL as _SEND_SIGNAL, +) +from agent_sandbox.providers.shared.e2b.process import ( + _SIGNAL_NAMES as _SIGNAL_NAMES, +) +from agent_sandbox.providers.shared.e2b.process import ( + _START as _START, +) +from agent_sandbox.providers.shared.e2b.process import ( + _UPDATE as _UPDATE, +) +from agent_sandbox.providers.shared.e2b.process import ( + ProcessProtocol as ProcessProtocol, +) +from agent_sandbox.providers.shared.e2b.process import ( + _codec as _codec, +) +from agent_sandbox.providers.shared.e2b.process import ( + _parse_process_chunk as _parse_process_chunk, +) +from agent_sandbox.providers.shared.e2b.process import ( + _process_selector as _process_selector, +) +from agent_sandbox.providers.shared.e2b.process import ( + logger as logger, ) -from agent_sandbox.transport.codec import ConnectCodec -from agent_sandbox.transport.http import envd_status_error -from agent_sandbox.transport.streaming import StreamReader -from agent_sandbox.utils.logging import get_logger - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - - from agent_sandbox.transport.auth import EnvdTokenManager - from agent_sandbox.transport.http import HttpClient - -logger = get_logger("protocol.process") - -# Extra seconds added to the process-level timeout to derive the HTTP read -# timeout. This avoids the HTTP layer timing out *before* the server-side -# process timeout fires, which would produce a misleading ReadTimeout -# instead of a clean process-timeout error. -_HTTP_TIMEOUT_BUFFER: float = 5.0 - -_codec = ConnectCodec() - -# RPC paths(已实测验证) -_START = _codec.build_rpc_path("process", "Process", "Start") -_LIST = _codec.build_rpc_path("process", "Process", "List") -_CONNECT = _codec.build_rpc_path("process", "Process", "Connect") -_UPDATE = _codec.build_rpc_path("process", "Process", "Update") -_SEND_INPUT = _codec.build_rpc_path("process", "Process", "SendInput") -_SEND_SIGNAL = _codec.build_rpc_path("process", "Process", "SendSignal") - - -# envd ``Signal`` enum (process.proto): only these two can be delivered through ``SendSignal``. -_SIGNAL_NAMES: dict[int, str] = {9: "SIGNAL_SIGKILL", 15: "SIGNAL_SIGTERM"} - - -def _process_selector(pid: int | str) -> dict[str, Any]: - """Build envd's ``ProcessSelector`` (``{"pid": N}`` or ``{"tag": "..."}``). - - A digit-only string is a pid (the CLI passes pids as text); any other string is a process tag. - """ - if isinstance(pid, bool): - raise TypeError("pid must be an int or str, not bool") - if isinstance(pid, int): - return {"pid": pid} - text = str(pid).strip() - if text.isdigit(): - return {"pid": int(text)} - return {"tag": text} - - -def _parse_process_chunk(frame: dict[str, Any]) -> ProcessChunk: - """Parse a streaming frame into a ProcessChunk. - - 已实测验证的 NDJSON event 格式: - - {"event":{"start":{"pid":N}}} — 进程启动 - - {"event":{"data":{"stdout":"base64"}}} — stdout 数据(base64 编码) - - {"event":{"data":{"stderr":"base64"}}} — stderr 数据(base64 编码) - - {"event":{"end":{"exitCode":N,"exited":bool,"status":"exited"}}} — 进程结束 - - {} — 空结束帧(忽略) - """ - event = frame.get("event") - if not event or not isinstance(event, dict): - # 空帧或无 event 字段 — 忽略 - return ProcessChunk(type=ProcessChunkType.STDOUT, data="") - - # 进程启动事件 - if "start" in event: - start_data = event["start"] - pid = start_data.get("pid", 0) - return ProcessChunk( - type=ProcessChunkType.STDOUT, - data="", - exit_code=None, - pid=pid, - ) - - # 数据输出事件(stdout/stderr,base64 编码) - if "data" in event: - data_obj = event["data"] - if "stdout" in data_obj: - raw = data_obj["stdout"] - try: - decoded = base64.b64decode(raw).decode("utf-8", errors="replace") - except Exception: - decoded = raw - return ProcessChunk(type=ProcessChunkType.STDOUT, data=decoded) - if "stderr" in data_obj: - raw = data_obj["stderr"] - try: - decoded = base64.b64decode(raw).decode("utf-8", errors="replace") - except Exception: - decoded = raw - return ProcessChunk(type=ProcessChunkType.STDERR, data=decoded) - - # 进程结束事件 - if "end" in event: - end_data = event["end"] - exit_code = end_data.get("exitCode") - if exit_code is None: - status = str(end_data.get("status") or "") - tail = status.rsplit(" ", 1)[-1] if status else "" - exit_code = int(tail) if tail.isdigit() else 0 - return ProcessChunk( - type=ProcessChunkType.EXIT, - exit_code=exit_code, - ) - - # 未知事件 - return ProcessChunk(type=ProcessChunkType.STDOUT, data="") - - -class ProcessProtocol: - """Typed wrapper for envd process operations (Connect protocol). - - 已实测验证的方法: Start, List, Connect, Update, SendInput, SendSignal - """ - - def __init__(self, http_client: HttpClient) -> None: - self._http = http_client - - async def start( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - cmd: str, - args: list[str] | None = None, - env: dict[str, str] | None = None, - cwd: str = "", - timeout: int = 60, - user: str = "", - ) -> StreamReader[ProcessChunk]: - """Start a process and return a streaming reader. - - POST envd_url + /process.Process/Start (Connect streaming) - - 请求格式(已实测验证): {"process": {"cmd": "...", "args": [...]}} - - Note: ``cwd`` and ``user`` are only included in the payload when - explicitly provided (non-empty). Sending ``user="user"`` to - certain platform base images triggers ``fork/exec: no such file - or directory`` because the envd uses a namespace that doesn't - recognise that user account. - """ - process_obj: dict[str, Any] = { - "cmd": cmd, - } - if args: - process_obj["args"] = args - if env: - process_obj["envs"] = env - if cwd: - process_obj["cwd"] = cwd - if user: - process_obj["user"] = user - # StartRequest.stdin sits beside process. A missing flag defaults to - # true, so a reader waits until the HTTP timeout. Proto JSON drops an - # unknown field nested under process. - payload: dict[str, Any] = {"process": process_obj, "stdin": False} - - # Derive the HTTP read timeout from the process-level timeout so - # that long-running commands (e.g. ``pip install``) are not killed - # prematurely by the HTTP layer's default 30 s timeout. - http_timeout = float(timeout) + _HTTP_TIMEOUT_BUFFER - - raw_stream = self._http.envd_stream( - envd_url, - _START, - payload=payload, - envd_token=envd_token, - request_timeout=http_timeout, - ) - - guarded = self._guard_command_timeout( - raw_stream, envd_url, envd_token, timeout=timeout, http_timeout=http_timeout - ) - return StreamReader(guarded, _parse_process_chunk) - - async def _guard_command_timeout( - self, - raw_stream: AsyncIterator[dict[str, Any]], - envd_url: str, - envd_token: EnvdTokenManager, - *, - timeout: int, - http_timeout: float, - ) -> AsyncIterator[dict[str, Any]]: - """Re-raise the HTTP read timeout of a command stream as :class:`CommandTimeoutError`. - - The HTTP read timeout is derived from the command timeout, so a silent command that - outlives it surfaces from httpx as ``ReadTimeout``. The process is killed on a - best-effort basis (closing the HTTP stream does not stop it) and the SDK error is raised. - """ - pid: int | None = None - try: - async for frame in raw_stream: - started = (frame.get("event") or {}).get("start") - if isinstance(started, dict) and isinstance(started.get("pid"), int): - pid = started["pid"] - yield frame - except httpx.ReadTimeout as exc: - if pid is not None: - try: - await self.send_signal( - envd_url, envd_token, pid=pid, signal=9, request_timeout=10.0 - ) - except Exception: # best effort: the timeout is what the caller must see - logger.debug("could not kill timed-out process %s", pid) - detail = f"no output for {http_timeout:g}s" - if pid is not None: - detail += f"; pid {pid} was asked to stop" - raise CommandTimeoutError( - f"Command did not finish within {timeout}s ({detail})." - ) from exc - - async def _unary( - self, - envd_url: str, - envd_token: EnvdTokenManager, - rpc_path: str, - payload: dict[str, Any], - *, - request_timeout: float | None = None, - ) -> dict[str, Any]: - """Unary envd call whose HTTP errors surface as :class:`EnvdRpcError`.""" - try: - return await self._http.envd_request( - envd_url, - rpc_path, - payload=payload, - envd_token=envd_token, - request_timeout=request_timeout, - ) - except httpx.HTTPStatusError as exc: - raise envd_status_error(exc, rpc_path) from exc - - async def list_processes( - self, - envd_url: str, - envd_token: EnvdTokenManager, - ) -> list[ProcessInfo]: - """List running processes in the sandbox. - - POST envd_url + /process.Process/List (Connect unary, 已实测验证) - """ - response = await self._unary(envd_url, envd_token, _LIST, {}) - - processes = response.get("processes", response.get("result", {}).get("processes", [])) - return [ProcessInfo.model_validate(p) for p in processes] - - async def connect_to_process( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - pid: int | str, - ) -> StreamReader[ProcessChunk]: - """Connect to an existing process and stream its output. - - POST envd_url + /process.Process/Connect (Connect streaming, 已实测验证) - """ - raw_stream = self._http.envd_stream( - envd_url, - _CONNECT, - payload={"process": _process_selector(pid)}, - envd_token=envd_token, - ) - return StreamReader(raw_stream, _parse_process_chunk) - - async def update( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - pid: int | str, - **kwargs: Any, - ) -> dict[str, Any]: - """Update process settings. - - POST envd_url + /process.Process/Update (Connect unary, 已实测验证) - """ - payload: dict[str, Any] = {"process": _process_selector(pid), **kwargs} - return await self._unary(envd_url, envd_token, _UPDATE, payload) - - async def send_input( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - pid: int | str, - data: str, - ) -> None: - """Send data to a process's stdin. - - POST envd_url + /process.Process/SendInput (Connect unary, 已实测验证) - - Body: ``{"process": {"pid": N}, "input": {"stdin": }}`` (``bytes`` fields are - base64 in protobuf-JSON). - """ - stdin = base64.b64encode(data.encode("utf-8")).decode("ascii") - await self._unary( - envd_url, - envd_token, - _SEND_INPUT, - {"process": _process_selector(pid), "input": {"stdin": stdin}}, - ) - - async def send_signal( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - pid: int | str, - signal: int = 15, - request_timeout: float | None = None, - ) -> None: - """Send a signal to a process (SIGTERM=15 or SIGKILL=9). - - POST envd_url + /process.Process/SendSignal (Connect unary, 已实测验证) - - Body: ``{"process": {"pid": N}, "signal": "SIGNAL_SIGTERM" | "SIGNAL_SIGKILL"}``. - envd's ``Signal`` enum has only those two values. - - The pid is envd's process id (``Commands.list()`` / the ``start`` event), which is not - necessarily the OS pid, so other signals cannot be emulated with ``kill``. - - Raises: - ValueError: *signal* is neither 15 nor 9. - """ - name = _SIGNAL_NAMES.get(signal) - if name is None: - raise ValueError( - f"envd can only deliver SIGTERM (15) and SIGKILL (9) to a process; got {signal!r}" - ) - await self._unary( - envd_url, - envd_token, - _SEND_SIGNAL, - {"process": _process_selector(pid), "signal": name}, - request_timeout=request_timeout, - ) - - # --- backward-compat aliases --- - - async def send_stdin( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - pid: int | str, - data: str, - ) -> None: - """Deprecated: use send_input(). Kept for backward compatibility.""" - await self.send_input(envd_url, envd_token, pid=pid, data=data) - async def kill( - self, - envd_url: str, - envd_token: EnvdTokenManager, - *, - pid: int | str, - ) -> None: - """Deprecated: use send_signal(). Kept for backward compatibility.""" - await self.send_signal(envd_url, envd_token, pid=pid, signal=9) +_sys.modules[__name__] = _import_module("agent_sandbox.providers.shared.e2b.process") diff --git a/client-sdk/src/agent_sandbox/protocol/sandbox.py b/client-sdk/src/agent_sandbox/protocol/sandbox.py index c6010bf..dcc62bf 100644 --- a/client-sdk/src/agent_sandbox/protocol/sandbox.py +++ b/client-sdk/src/agent_sandbox/protocol/sandbox.py @@ -1,222 +1,13 @@ -"""Platform API protocol — sandbox lifecycle management (REST). +"""Compatibility exports; backend implementation lives in its provider.""" -All methods use standard HTTP REST with Authorization: Bearer authentication. -Endpoints are relative to the Platform API base URL. +import sys as _sys +from importlib import import_module as _import_module -路径无 /api/v1 前缀(已实测验证)。 -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import httpx - -from agent_sandbox.models.errors import ( - QuotaExceededError, - SandboxCreationError, - SandboxNotFoundError, - TemplateNotFoundError, +from agent_sandbox.providers.shared.e2b.sandbox import ( + SandboxProtocol as SandboxProtocol, +) +from agent_sandbox.providers.shared.e2b.sandbox import ( + logger as logger, ) -from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo, SandboxStatus -from agent_sandbox.utils.logging import get_logger - -if TYPE_CHECKING: - from agent_sandbox.transport.http import HttpClient - -logger = get_logger("protocol.sandbox") - - -class SandboxProtocol: - """Typed wrapper for Platform API sandbox operations.""" - - def __init__(self, http_client: HttpClient, *, unknown_if_absent: bool = False) -> None: - self._http = http_client - # A plugin provider speaks this wire format but is not the platform: - # an absent region/timeout stays "unknown" ("" and 0). - self._unknown_if_absent = unknown_if_absent - - def _info(self, data: Any) -> SandboxInfo: - if self._unknown_if_absent and isinstance(data, dict): - data = {"region": "", "timeout": 0, **data} - return SandboxInfo.model_validate(data) - - async def _sandbox_request( - self, - method: str, - sandbox_id: str, - suffix: str = "", - **kwargs: Any, - ) -> httpx.Response: - """Request ``/sandboxes/{id}{suffix}``; a 404 raises :class:`SandboxNotFoundError`.""" - try: - return await self._http.platform_request( - method, f"/sandboxes/{sandbox_id}{suffix}", **kwargs - ) - except httpx.HTTPStatusError as exc: - if exc.response.status_code == 404: - raise SandboxNotFoundError(f"Sandbox '{sandbox_id}' was not found.") from exc - raise - - async def create( - self, - config: SandboxConfig, - *, - request_timeout: float | None = None, - ) -> SandboxInfo: - """Create a new sandbox. - - POST /sandboxes → 201(已实测验证) - - 请求体使用 camelCase: templateID, timeout, autoPause, metadata, envVars - 响应体使用 camelCase: sandboxID, templateID, envdAccessToken, envdVersion, clientID - - Args: - config: Sandbox creation payload. - request_timeout: Per-request HTTP timeout override in seconds for - this single create call. When *None*, the client-level - default (``http_timeout`` / ``SANDBOX_HTTP_TIMEOUT``) is used. - This is independent of ``config.timeout`` (the sandbox TTL). - """ - payload = config.to_create_payload() - - try: - # 已实测验证:路径无 /api/v1 前缀 - response = await self._http.platform_request( - "POST", "/sandboxes", json=payload, request_timeout=request_timeout - ) - data = response.json() - return self._info(data) - except httpx.HTTPStatusError as exc: - self._handle_create_error(exc) - raise # unreachable but satisfies type checker - - async def list( - self, - *, - status: SandboxStatus | None = None, - limit: int = 100, - offset: int = 0, - ) -> list[SandboxInfo]: - """List sandboxes (v1). - - GET /sandboxes → 200(已实测验证) - """ - params: dict[str, Any] = {"limit": limit, "offset": offset} - if status is not None: - params["status"] = status.value - - # 已实测验证:路径无 /api/v1 前缀 - response = await self._http.platform_request("GET", "/sandboxes", params=params) - data = response.json() - # API may return list directly or wrapped in a data field - items = data if isinstance(data, list) else data.get("data", data.get("sandboxes", [])) - return [self._info(item) for item in items] - - async def list_v2( - self, - *, - status: SandboxStatus | None = None, - limit: int = 100, - offset: int = 0, - ) -> list[SandboxInfo]: # type: ignore[valid-type] - """List sandboxes (v2). - - GET /v2/sandboxes → 200(已实测验证) - """ - params: dict[str, Any] = {"limit": limit, "offset": offset} - if status is not None: - params["status"] = status.value - - response = await self._http.platform_request("GET", "/v2/sandboxes", params=params) - data = response.json() - items = data if isinstance(data, list) else data.get("data", data.get("sandboxes", [])) - return [self._info(item) for item in items] - - async def get_info(self, sandbox_id: str) -> SandboxInfo: - """Get sandbox information. - - GET /sandboxes/{sandboxId} → 200(已实测验证) - """ - # 已实测验证 - response = await self._sandbox_request("GET", sandbox_id) - data = response.json() - return self._info(data) - - async def kill(self, sandbox_id: str) -> None: - """Kill/destroy a sandbox. - - DELETE /sandboxes/{sandbox_id} → 204(已实测验证) - """ - try: - # 已实测验证 - await self._http.platform_request("DELETE", f"/sandboxes/{sandbox_id}") - except httpx.HTTPStatusError as exc: - if exc.response.status_code == 404: - logger.warning("Sandbox %s not found (already killed?)", sandbox_id) - return - raise - - async def set_timeout(self, sandbox_id: str, timeout: int) -> None: - """Update sandbox timeout. - - POST /sandboxes/{sandbox_id}/timeout → 204(已实测验证) - """ - # 已实测验证:POST(非 PUT) - await self._sandbox_request("POST", sandbox_id, "/timeout", json={"timeout": timeout}) - - async def connect(self, sandbox_id: str) -> SandboxInfo: - """Connect to an existing sandbox (fetch current info). - - GET /sandboxes/{sandbox_id} → 200(已实测验证) - """ - # 已实测验证 - response = await self._sandbox_request("GET", sandbox_id) - data = response.json() - return self._info(data) - - async def is_running(self, sandbox_id: str) -> bool: - """Check if a sandbox is currently running. - - Calls get_info and checks status. - """ - try: - info = await self.get_info(sandbox_id) - return info.status == SandboxStatus.RUNNING - except Exception: - return False - - async def pause(self, sandbox_id: str) -> None: - """Pause a running sandbox. - - POST /sandboxes/{sandbox_id}/pause - # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 - """ - # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 - await self._sandbox_request("POST", sandbox_id, "/pause") - - async def resume(self, sandbox_id: str) -> None: - """Resume a paused sandbox. - - POST /sandboxes/{sandbox_id}/resume - # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 - """ - # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 - await self._sandbox_request("POST", sandbox_id, "/resume") - - def _handle_create_error(self, exc: httpx.HTTPStatusError) -> None: - """Convert HTTP errors to typed exceptions for create.""" - status = exc.response.status_code - try: - body = exc.response.json() - except Exception: - body = {} - - message = body.get("message", str(exc)) - if status == 404: - raise TemplateNotFoundError(message) from exc - elif status == 429: - raise QuotaExceededError(message) from exc - elif status >= 400: - raise SandboxCreationError(message) from exc +_sys.modules[__name__] = _import_module("agent_sandbox.providers.shared.e2b.sandbox") diff --git a/client-sdk/src/agent_sandbox/protocol/terminal.py b/client-sdk/src/agent_sandbox/protocol/terminal.py index 6a3f61f..07375cf 100644 --- a/client-sdk/src/agent_sandbox/protocol/terminal.py +++ b/client-sdk/src/agent_sandbox/protocol/terminal.py @@ -1,120 +1,13 @@ -"""PTY terminal protocol — WebSocket bidirectional channel. +"""Compatibility exports; backend implementation lives in its provider.""" -Provides interactive terminal access to sandboxes via WebSocket. -Supports terminal resize, input/output streaming. -""" +import sys as _sys +from importlib import import_module as _import_module -from __future__ import annotations +from agent_sandbox.providers.shared.e2b.terminal import ( + TerminalSession as TerminalSession, +) +from agent_sandbox.providers.shared.e2b.terminal import ( + logger as logger, +) -import json -from typing import TYPE_CHECKING, Any - -from agent_sandbox.transport.ws import WebSocketClient -from agent_sandbox.utils.logging import get_logger - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - - from agent_sandbox.transport.auth import EnvdTokenManager - from agent_sandbox.transport.config import TransportConfig - -logger = get_logger("protocol.terminal") - - -class TerminalSession: - """Interactive PTY terminal session over WebSocket. - - Provides bidirectional communication with a pseudo-terminal - in the sandbox. - """ - - def __init__( - self, - ws_client: WebSocketClient, - ) -> None: - self._ws = ws_client - - @classmethod - async def create( - cls, - envd_url: str, - envd_token: EnvdTokenManager, - config: TransportConfig, - *, - cols: int = 80, - rows: int = 24, - shell: str = "/bin/bash", - ) -> TerminalSession: - """Create a new terminal session. - - Establishes WebSocket connection to the sandbox PTY endpoint. - """ - # Convert HTTP URL to WebSocket URL - ws_url = envd_url.replace("https://", "wss://").replace("http://", "ws://") - ws_url = f"{ws_url}/terminal" - - headers = { - **envd_token.get_headers(), - } - - ws_client = WebSocketClient( - ws_url, - config=config, - extra_headers=headers, - ) - await ws_client.connect() - - session = cls(ws_client) - - # Send initial resize - await session.resize(cols, rows) - - logger.debug("Terminal session created (%dx%d, shell=%s)", cols, rows, shell) - return session - - async def send(self, data: str) -> None: - """Send input to the terminal.""" - await self._ws.send(data) - - async def send_bytes(self, data: bytes) -> None: - """Send raw bytes to the terminal.""" - await self._ws.send(data) - - async def receive(self, timeout: float | None = None) -> str | bytes: - """Receive output from the terminal.""" - return await self._ws.receive(timeout=timeout) - - async def output_stream(self) -> AsyncIterator[str | bytes]: - """Iterate over terminal output.""" - async for msg in self._ws.recv_iter(): - yield msg - - async def resize(self, cols: int, rows: int) -> None: - """Resize the terminal. - - Sends a JSON control message to resize the PTY. - """ - control_msg = json.dumps( - { - "type": "resize", - "cols": cols, - "rows": rows, - } - ) - await self._ws.send(control_msg) - logger.debug("Terminal resized to %dx%d", cols, rows) - - @property - def is_connected(self) -> bool: - return self._ws.is_connected - - async def close(self) -> None: - """Close the terminal session.""" - await self._ws.close() - logger.debug("Terminal session closed") - - async def __aenter__(self) -> TerminalSession: - return self - - async def __aexit__(self, *args: Any) -> None: - await self.close() +_sys.modules[__name__] = _import_module("agent_sandbox.providers.shared.e2b.terminal") diff --git a/client-sdk/src/agent_sandbox/providers/__init__.py b/client-sdk/src/agent_sandbox/providers/__init__.py index 5ca0e25..b440bdc 100644 --- a/client-sdk/src/agent_sandbox/providers/__init__.py +++ b/client-sdk/src/agent_sandbox/providers/__init__.py @@ -6,24 +6,26 @@ ``ABX_PROVIDER`` or ``[core] provider`` in ``~/.abx/config.toml``. """ -from agent_sandbox.providers.base import ( - FEATURES, - ConfigField, - Provider, - ProviderFeature, - ProviderInfra, -) -from agent_sandbox.providers.registry import ( - ambient_provider_id, - get_provider, - list_providers, - parse_sandbox_ref, - qualify, - register_provider, - resolve_provider_id, - unregister_provider, -) -from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from agent_sandbox.providers import commands as commands + from agent_sandbox.providers.base import FEATURES as FEATURES + from agent_sandbox.providers.base import ConfigField as ConfigField + from agent_sandbox.providers.base import Provider as Provider + from agent_sandbox.providers.base import ProviderFeature as ProviderFeature + from agent_sandbox.providers.base import ProviderInfra as ProviderInfra + from agent_sandbox.providers.manifest import ProviderCommand as ProviderCommand + from agent_sandbox.providers.registry import ambient_provider_id as ambient_provider_id + from agent_sandbox.providers.registry import get_provider as get_provider + from agent_sandbox.providers.registry import list_providers as list_providers + from agent_sandbox.providers.registry import parse_sandbox_ref as parse_sandbox_ref + from agent_sandbox.providers.registry import qualify as qualify + from agent_sandbox.providers.registry import register_provider as register_provider + from agent_sandbox.providers.registry import resolve_provider_id as resolve_provider_id + from agent_sandbox.providers.registry import unregister_provider as unregister_provider + from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID as BUILTIN_PROVIDER_ID __all__ = [ "BUILTIN_PROVIDER_ID", @@ -32,6 +34,8 @@ "Provider", "ProviderFeature", "ProviderInfra", + "ProviderCommand", + "commands", "ambient_provider_id", "get_provider", "list_providers", @@ -41,3 +45,20 @@ "resolve_provider_id", "unregister_provider", ] + + +def __getattr__(name: str) -> Any: + """Keep public exports lazy so reading bundled metadata does not discover providers.""" + if name == "commands": + return import_module("agent_sandbox.providers.commands") + if name == "BUILTIN_PROVIDER_ID": + return getattr(import_module("agent_sandbox.utils.config_keys"), name) + if name in {"FEATURES", "ConfigField", "Provider", "ProviderFeature", "ProviderInfra"}: + module = "base" + elif name == "ProviderCommand": + module = "manifest" + elif name in __all__: + module = "registry" + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(import_module(f"agent_sandbox.providers.{module}"), name) diff --git a/client-sdk/src/agent_sandbox/providers/__main__.py b/client-sdk/src/agent_sandbox/providers/__main__.py index eabf1b2..ec2565e 100644 --- a/client-sdk/src/agent_sandbox/providers/__main__.py +++ b/client-sdk/src/agent_sandbox/providers/__main__.py @@ -115,7 +115,14 @@ def _cmd_update(args: argparse.Namespace) -> int: verify=None if args.no_verify else doctor_mod.verify_handshake, ) ) - _out(args, results, "\n".join(f"{r['id']}: {r['status']}" for r in results)) + _out( + args, + results, + "\n".join( + f"{r['id']}: {r['status']}" + (f" — {r['message']}" if r.get("message") else "") + for r in results + ), + ) return 0 diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/__init__.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/__init__.py new file mode 100644 index 0000000..380c07d --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/__init__.py @@ -0,0 +1 @@ +"""Provider-owned backend implementation.""" diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/auth.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/auth.py new file mode 100644 index 0000000..cfeb3b9 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/auth.py @@ -0,0 +1,176 @@ +"""Authentication providers for Platform API and Sandbox envd API. + +Sandbox requests use an explicitly configured API key as an Authorization Bearer. +AK/SK is used separately by official control-plane APIs. AkSkAuth remains an +explicit custom-exchange adapter; no default credential-resource creation occurs. + +After sandbox creation, envd API uses envdAccessToken from create response, +sent via multiple headers (X-Access-Token, E2b-Sandbox-Id, etc. 已实测验证). +""" + +from __future__ import annotations + +import time +from typing import Any + +from agent_sandbox.models.errors import ( + InvalidAPIKeyError, + InvalidCredentialsError, + TokenExchangeError, + TokenExpiredError, +) +from agent_sandbox.providers.shared.e2b.auth import ( + _ENVD_BASIC_AUTH as _ENVD_BASIC_AUTH, +) +from agent_sandbox.providers.shared.e2b.auth import ( + ENVD_AUTH_HEADER as ENVD_AUTH_HEADER, +) +from agent_sandbox.providers.shared.e2b.auth import ( + ENVD_SANDBOX_ID_HEADER as ENVD_SANDBOX_ID_HEADER, +) +from agent_sandbox.providers.shared.e2b.auth import ( + ENVD_SANDBOX_PORT_HEADER as ENVD_SANDBOX_PORT_HEADER, +) +from agent_sandbox.providers.shared.e2b.auth import ( + EnvdTokenManager as EnvdTokenManager, +) +from agent_sandbox.providers.shared.e2b.auth import ( + build_envd_headers as build_envd_headers, +) +from agent_sandbox.transport.auth_contract import ( + PLATFORM_AUTH_HEADER as PLATFORM_AUTH_HEADER, +) +from agent_sandbox.transport.auth_contract import ( + ApiKeyAuth as ApiKeyAuth, +) +from agent_sandbox.transport.auth_contract import ( + AuthProvider as AuthProvider, +) +from agent_sandbox.utils.logging import get_logger + +logger = get_logger("transport.auth") + + +class AkSkAuth: + """Legacy adapter for an explicitly configured custom token exchange. + + No Alibaba STS/API-key creation endpoint is selected automatically. Callers + must supply their own exchange and set the cache lifetime to that service's + documented token validity. Cached credentials are never persisted. + """ + + TOKEN_TTL = 3600 # Legacy custom-exchange cache policy, not a platform TTL. + REFRESH_BEFORE_EXPIRY = 300 + + def __init__(self, access_key_id: str, access_key_secret: str) -> None: + if not access_key_id or not access_key_secret: + raise InvalidCredentialsError( + "AccessKey ID and Secret cannot be empty", + suggestion=( + "Set ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET" + " environment variables." + ), + ) + self._ak = access_key_id.strip() + self._sk = access_key_secret.strip() + self._cached_token: str | None = None + self._token_expires_at: float = 0.0 + self._exchange_func: Any = None # Set by transport layer + + def _is_token_valid(self) -> bool: + """Check if cached token is still valid (with refresh buffer).""" + if self._cached_token is None: + return False + return time.time() < (self._token_expires_at - self.REFRESH_BEFORE_EXPIRY) + + async def _exchange_token(self) -> str: + """Exchange AK/SK for a temporary API key. + + The caller supplies the exchange implementation and owns its lifecycle. + There is no default network exchange. + """ + if self._exchange_func is None: + raise InvalidCredentialsError( + "Token exchange function not configured. " + "Set an explicit custom callback with set_exchange_func(), " + "or use ApiKeyAuth for sandbox requests.", + ) + token = await self._exchange_func(self._ak, self._sk) + if not isinstance(token, str) or not token.strip(): + raise TokenExchangeError( + "Custom token exchange must return a non-empty token string.", + suggestion="Check the custom exchange contract and token lifetime.", + ) + self._cached_token = token.strip() + self._token_expires_at = time.time() + self.TOKEN_TTL + logger.debug("AK/SK token exchanged, expires at %.0f", self._token_expires_at) + return self._cached_token + + async def get_headers(self) -> dict[str, str]: + """Return Authorization: Bearer header with exchanged token.""" + if not self._is_token_valid(): + await self._exchange_token() + if self._cached_token is None: + raise TokenExpiredError("Failed to obtain API token via AK/SK exchange") + return {PLATFORM_AUTH_HEADER: f"Bearer {self._cached_token}"} + + async def refresh(self) -> None: + """Force refresh the exchanged token.""" + if self._exchange_func is None: + raise InvalidCredentialsError( + "AK/SK token exchange function not configured.", + suggestion=( + "Set an explicit custom callback with set_exchange_func(), " + "or configure ABX_SANDBOX_API_KEY for sandbox requests." + ), + ) + self._cached_token = None + self._token_expires_at = 0.0 + await self._exchange_token() + + def authentication_error(self, path: str) -> InvalidCredentialsError: + """Map rejected custom-exchange credentials without exposing their values.""" + return InvalidCredentialsError( + f"The Platform API rejected the custom exchanged credential (HTTP 401) for {path}." + ) + + def set_exchange_func(self, func: Any) -> None: + """Set the token exchange function (called by transport layer).""" + self._exchange_func = func + + +def create_auth_provider( + api_key: str | None = None, + access_key_id: str | None = None, + access_key_secret: str | None = None, + *, + region: str = "cn-hangzhou", +) -> AuthProvider: + """Create API-key authentication for sandbox data-plane requests. + + Explicit api_key takes precedence over ABX_SANDBOX_API_KEY. The AK/SK and + region arguments remain accepted for signature compatibility, but cannot + select an automatic key-creation/exchange route. Official template and image + control-plane operations use their own AK/SK clients. + """ + if api_key: + logger.debug("Using API Key authentication") + return ApiKeyAuth(api_key) + + import os + + sandbox_key = os.environ.get("ABX_SANDBOX_API_KEY") + if sandbox_key: + logger.debug("Using API Key authentication (via ABX_SANDBOX_API_KEY)") + return ApiKeyAuth(sandbox_key) + + if access_key_id and access_key_secret: + raise InvalidAPIKeyError( + "A sandbox API Key is required; AK/SK credentials are for control-plane operations.", + suggestion="Configure ABX_SANDBOX_API_KEY or pass api_key explicitly.", + ) + + raise InvalidAPIKeyError( + "No authentication credentials provided", + suggestion=("Configure ABX_SANDBOX_API_KEY or pass api_key explicitly."), + ) diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py new file mode 100644 index 0000000..03941fd --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py @@ -0,0 +1,26 @@ +"""Pure Express configuration metadata. No SDK, engine or cloud calls.""" + +from typing import Any + +from agent_sandbox.providers.shared.e2b.url import envd_url +from agent_sandbox.utils.bundled_config import DEFAULT_CONFIG + + +def _legacy_ports(context: Any) -> Any: + from agent_sandbox.providers.shared.e2b.ports import WirePortAccess + + return WirePortAccess(context, server_auth=False) + + +def _legacy_pty_url(base_url: str, server_port: int, pty_port: int) -> str: + from agent_sandbox.providers.shared.e2b.ports import legacy_pty_url + + return legacy_pty_url(base_url, server_port, pty_port) + + +CONFIG_METADATA = { + **DEFAULT_CONFIG, + "legacy_url_builder": envd_url, + "legacy_port_factory": _legacy_ports, + "legacy_pty_url_builder": _legacy_pty_url, +} diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py new file mode 100644 index 0000000..8b117ca --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py @@ -0,0 +1,187 @@ +"""The built-in provider: Alibaba Cloud Express Edition (``alicloud-express``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agent_sandbox.providers.base import ConfigField, Provider, ProviderInfra +from agent_sandbox.utils.config_keys import ( + ALICLOUD_EXPRESS_KEYS, + BUILTIN_PROVIDER_ID, + BUILTIN_PROVIDER_NAME, + BUILTIN_PROVIDER_NAME_ZH, + GENERIC_ENDPOINT_KEYS, + env_names, +) + +if TYPE_CHECKING: + from agent_sandbox.protocol.port_contract import PortAccess, PortContext + from agent_sandbox.transport.config import TransportConfig + + +def _fields() -> tuple[ConfigField, ...]: + return tuple( + ConfigField( + key=k.key, + type=k.type, + secret=k.secret, + credential=k.credential, + default=k.default, + description=k.description, + description_zh=k.description_zh, + env=tuple(n for n in env_names(k) if n != k.env), + ) + for k in ALICLOUD_EXPRESS_KEYS + ) + + +class AlicloudExpressProvider(Provider): + """Wraps the SDK's existing platform behaviour; it is the default provider.""" + + id = BUILTIN_PROVIDER_ID + display_name = BUILTIN_PROVIDER_NAME + display_name_zh = BUILTIN_PROVIDER_NAME_ZH + kind = "builtin" + from .metadata import CONFIG_METADATA as _METADATA + + derive = _METADATA["derive"] + endpoint_defaults = _METADATA["endpoint_defaults"] + legacy_fields = _METADATA["legacy_fields"] + omit_default_options = True + # Explicit implementations; new registry features never grant builtin support. + features = frozenset( + { + "sandbox.lifecycle", + "sandbox.timeout", + "exec.run", + "exec.stream", + "process.manage", + "pty", + "files.rw", + "files.watch", + "files.transfer", + "code.interpret", + "templates.read", + "templates.build", + "ports.url", + "sessions", + } + ) + inherit = frozenset(GENERIC_ENDPOINT_KEYS) + + def __init__(self) -> None: + from agent_sandbox._version import __version__ + + self.version = __version__ + self.config_fields = _fields() + + def config_keys(self): # type: ignore[no-untyped-def] + return ALICLOUD_EXPRESS_KEYS + + create_options = frozenset({"volume_mounts"}) + + def build_legacy_infra(self, **options: Any) -> Any: + from agent_sandbox.providers.alicloud_express.auth import create_auth_provider + from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol + from agent_sandbox.transport.config import load_config + from agent_sandbox.transport.http import HttpClient + + config = load_config( + provider=self.id, + **{ + name: value + for name, value in { + "sandbox_api_key": options.get("api_key"), + "sandbox_base_url": options.get("api_url"), + "access_key_id": options.get("access_key_id"), + "access_key_secret": options.get("access_key_secret"), + "region": options.get("region"), + "provider_options": options.get("provider_options"), + }.items() + if value is not None + }, + ) + auth = create_auth_provider( + api_key=config.sandbox_api_key, + access_key_id=config.access_key_id, + access_key_secret=config.access_key_secret, + ) + http = HttpClient(config, auth) + return config, auth, http, SandboxProtocol(http) + + def session_protocols(self, http: Any) -> dict[str, Any]: + from agent_sandbox.providers.shared.e2b.session import session_protocols + + return session_protocols(http) + + def create_port_access(self, context: PortContext, protocol: Any = None) -> PortAccess: + from agent_sandbox.providers.shared.e2b.ports import WirePortAccess + + return WirePortAccess(context, server_auth=False) + + def resolve_session_url(self, info: Any, config: Any) -> str: + from agent_sandbox.providers.shared.e2b.url import envd_url + + return str(info.envd_url or envd_url(info.sandbox_id, config.sandbox_domain)) + + @staticmethod + def legacy_session_url(sandbox_id: str, domain: str) -> str: + """Compatibility URL helper exposed through the composition schema.""" + from agent_sandbox.providers.shared.e2b.url import envd_url + + return envd_url(sandbox_id, domain) + + async def prepare_session( + self, infra: ProviderInfra, info: Any, *, operation: str, template: str + ) -> dict[str, Any]: + from agent_sandbox.api.capability import resolve_capabilities_for + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager + from agent_sandbox.transport.http import stream_envelope_for_envd + + http = infra.http_client + if http is not None: + http._stream_envelope = stream_envelope_for_envd(info.envd_version) + resolved = await resolve_capabilities_for( + *( + (template, info.template, info.alias_name) + if operation == "create" + else (info.template, info.alias_name) + ) + ) + binding: dict[str, Any] = { + "envd_token": EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id), + "resolved_capabilities": resolved, + "declared_capabilities": frozenset(resolved.capabilities), + } + return binding + + async def open_terminal(self, sandbox: Any, **options: Any) -> Any: + from agent_sandbox.providers.shared.e2b.terminal import TerminalSession + + return await TerminalSession.create( + envd_url=sandbox.url, envd_token=sandbox._envd_token, config=sandbox._config, **options + ) + + async def build_infra(self, config: TransportConfig) -> ProviderInfra: + from agent_sandbox.providers.alicloud_express.auth import create_auth_provider + from agent_sandbox.providers.shared.e2b.code_interpreter import CodeInterpreterProtocol + from agent_sandbox.providers.shared.e2b.filesystem import FilesystemProtocol + from agent_sandbox.providers.shared.e2b.process import ProcessProtocol + from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol + from agent_sandbox.transport.http import HttpClient + + auth = create_auth_provider( + api_key=config.sandbox_api_key, + access_key_id=config.access_key_id, + access_key_secret=config.access_key_secret, + ) + http = HttpClient(config, auth) + return ProviderInfra( + config=config, + auth=auth, + http_client=http, + sandbox_protocol=SandboxProtocol(http), + process_protocol=ProcessProtocol(http), + filesystem_protocol=FilesystemProtocol(http), + code_interpreter_protocol=CodeInterpreterProtocol(http), + ) diff --git a/client-sdk/src/agent_sandbox/providers/base.py b/client-sdk/src/agent_sandbox/providers/base.py index f2c7703..e623205 100644 --- a/client-sdk/src/agent_sandbox/providers/base.py +++ b/client-sdk/src/agent_sandbox/providers/base.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, NamedTuple +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast from agent_sandbox.models.errors import ( InvalidCredentialsError, @@ -21,11 +21,14 @@ if TYPE_CHECKING: from collections.abc import Iterable, Mapping - from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol - from agent_sandbox.protocol.filesystem import FilesystemProtocol - from agent_sandbox.protocol.process import ProcessProtocol - from agent_sandbox.protocol.sandbox import SandboxProtocol - from agent_sandbox.transport.auth import AuthProvider + from agent_sandbox.protocol.contracts import ( + CodeInterpreterProtocol, + FilesystemProtocol, + ProcessProtocol, + SandboxProtocol, + ) + from agent_sandbox.protocol.port_contract import PortAccess, PortContext + from agent_sandbox.transport.auth_contract import AuthProvider from agent_sandbox.transport.config import TransportConfig from agent_sandbox.transport.http import HttpClient @@ -233,6 +236,68 @@ class Provider(ABC): #: Static endpoint defaults (``sandbox_base_url``/``sandbox_domain``) of declarative providers. endpoint_defaults: Mapping[str, str] = MappingProxyType({}) + #: Optional create inputs explicitly implemented by the backend. + create_options: frozenset[str] = frozenset() + + def session_protocols(self, http: Any) -> dict[str, Any]: + """Missing protocols fail explicitly rather than acquiring a native transport.""" + + class Unsupported: + prepare_session = None + + def __init__(self, provider: str, feature: str) -> None: + self.provider, self.feature = provider, feature + + def __getattr__(self, name: str) -> Any: + async def fail(*args: Any, **kwargs: Any) -> Any: + raise ProviderFeatureNotSupportedError(self.provider, self.feature) + + return fail + + return { + name: Unsupported(self.id, feature) + for name, feature in ( + ("sandbox_protocol", "sandbox.lifecycle"), + ("process_protocol", "exec.run"), + ("filesystem_protocol", "files.rw"), + ("code_interpreter_protocol", "code.interpret"), + ) + } + + def build_legacy_infra(self, **options: Any) -> Any: + """Compatibility hook. Normal operations always use build_infra.""" + raise ProviderFeatureNotSupportedError(self.id, "sandbox.legacy-infra") + + def resolve_session_url(self, info: Any, config: Any) -> str: + return str(info.envd_url or "") + + def create_port_access(self, context: PortContext, protocol: Any = None) -> PortAccess: + """Select local port semantics explicitly; absent implementations fail closed.""" + from agent_sandbox.protocol.port_contract import UnsupportedPortAccess + + factory = getattr(protocol, "create_port_access", None) + if callable(factory): + return cast("PortAccess", factory(context)) + return UnsupportedPortAccess(self.id) + + async def prepare_session( + self, infra: ProviderInfra, info: Any, *, operation: str, template: str + ) -> dict[str, Any]: + """Return only the backend's session binding; core never supplies wire adapters.""" + from agent_sandbox.transport.auth_contract import SessionAuth + + binder = getattr(infra.sandbox_protocol, "prepare_session", None) + if callable(binder): + return dict(await binder(info)) + return {"envd_token": SessionAuth(info.envd_access_token, info.sandbox_id)} + + async def open_terminal(self, sandbox: Any, **options: Any) -> Any: + self.require("pty") + opener = getattr(sandbox._process_protocol, "open_terminal", None) + if opener is None: + raise ProviderFeatureNotSupportedError(self.id, "pty") + return await opener(sandbox.id, cols=options.get("cols", 80), rows=options.get("rows", 24)) + def require(self, feature: str) -> None: """Raise :class:`ProviderFeatureNotSupportedError` unless *feature* is implemented.""" if feature not in self.features: diff --git a/client-sdk/src/agent_sandbox/providers/builtin.py b/client-sdk/src/agent_sandbox/providers/builtin.py index a083b54..dd79ed1 100644 --- a/client-sdk/src/agent_sandbox/providers/builtin.py +++ b/client-sdk/src/agent_sandbox/providers/builtin.py @@ -1,79 +1,13 @@ -"""The built-in provider: Alibaba Cloud Express Edition (``alicloud-express``).""" +"""Compatibility exports; backend implementation lives in its provider.""" -from __future__ import annotations +import sys as _sys +from importlib import import_module as _import_module -from typing import TYPE_CHECKING - -from agent_sandbox.providers.base import FEATURES, ConfigField, Provider, ProviderInfra -from agent_sandbox.utils.config_keys import ( - ALICLOUD_EXPRESS_KEYS, - BUILTIN_PROVIDER_ID, - BUILTIN_PROVIDER_NAME, - BUILTIN_PROVIDER_NAME_ZH, - GENERIC_ENDPOINT_KEYS, - env_names, +from agent_sandbox.providers.alicloud_express.provider import ( + AlicloudExpressProvider as AlicloudExpressProvider, +) +from agent_sandbox.providers.alicloud_express.provider import ( + _fields as _fields, ) -if TYPE_CHECKING: - from agent_sandbox.transport.config import TransportConfig - - -def _fields() -> tuple[ConfigField, ...]: - return tuple( - ConfigField( - key=k.key, - type=k.type, - secret=k.secret, - credential=k.credential, - default=k.default, - description=k.description, - description_zh=k.description_zh, - env=tuple(n for n in env_names(k) if n != k.env), - ) - for k in ALICLOUD_EXPRESS_KEYS - ) - - -class AlicloudExpressProvider(Provider): - """Wraps the SDK's existing platform behaviour; it is the default provider.""" - - id = BUILTIN_PROVIDER_ID - display_name = BUILTIN_PROVIDER_NAME - display_name_zh = BUILTIN_PROVIDER_NAME_ZH - kind = "builtin" - # Registering an image-only deployment feature does not implement its factory. - features = frozenset(FEATURES) - {"templates.deploy"} - inherit = frozenset(GENERIC_ENDPOINT_KEYS) - - def __init__(self) -> None: - from agent_sandbox._version import __version__ - - self.version = __version__ - self.config_fields = _fields() - - def config_keys(self): # type: ignore[no-untyped-def] - return ALICLOUD_EXPRESS_KEYS - - async def build_infra(self, config: TransportConfig) -> ProviderInfra: - from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol - from agent_sandbox.protocol.filesystem import FilesystemProtocol - from agent_sandbox.protocol.process import ProcessProtocol - from agent_sandbox.protocol.sandbox import SandboxProtocol - from agent_sandbox.transport.auth import create_auth_provider - from agent_sandbox.transport.http import HttpClient - - auth = create_auth_provider( - api_key=config.sandbox_api_key, - access_key_id=config.access_key_id, - access_key_secret=config.access_key_secret, - ) - http = HttpClient(config, auth) - return ProviderInfra( - config=config, - auth=auth, - http_client=http, - sandbox_protocol=SandboxProtocol(http), - process_protocol=ProcessProtocol(http), - filesystem_protocol=FilesystemProtocol(http), - code_interpreter_protocol=CodeInterpreterProtocol(http), - ) +_sys.modules[__name__] = _import_module("agent_sandbox.providers.alicloud_express.provider") diff --git a/client-sdk/src/agent_sandbox/providers/bundled_config.py b/client-sdk/src/agent_sandbox/providers/bundled_config.py new file mode 100644 index 0000000..4f9895f --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/bundled_config.py @@ -0,0 +1,13 @@ +"""Distribution composition for bundled provider metadata; no cloud implementation import.""" + +from typing import Any + +from agent_sandbox.providers.alicloud_express.metadata import CONFIG_METADATA + +BUNDLED_CONFIGS: tuple[dict[str, Any], ...] = (CONFIG_METADATA,) +DEFAULT_CONFIG: dict[str, Any] = CONFIG_METADATA +BUNDLED_KEYS = tuple(key for metadata in BUNDLED_CONFIGS for key in metadata["keys"]) + + +def bundled_config(provider_id: str) -> dict[str, Any] | None: + return next((metadata for metadata in BUNDLED_CONFIGS if metadata["id"] == provider_id), None) diff --git a/client-sdk/src/agent_sandbox/providers/commands.py b/client-sdk/src/agent_sandbox/providers/commands.py new file mode 100644 index 0000000..7ecfd2b --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/commands.py @@ -0,0 +1,150 @@ +"""Provider management commands over the shared Node engine. + +These operations manage provider resources, not commands registered inside a sandbox. +The engine owns schemas, dispatch, cloud APIs and mutation confirmation. POST calls +are never replayed by this adapter, including after connection failure or HTTP 401. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +from agent_sandbox.models.errors import ProviderConfigError, ProviderProtocolError +from agent_sandbox.providers.engine_client import EngineClient +from agent_sandbox.providers.manifest import COMMAND_ID_RE +from agent_sandbox.providers.registry import get_provider +from agent_sandbox.transport.config import load_config +from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID, validate_provider_id + +__all__ = ["get_command", "list_commands", "run_command"] + + +def _validate_provider(provider: str) -> None: + if not isinstance(provider, str) or ( + provider != BUILTIN_PROVIDER_ID and validate_provider_id(provider) is not None + ): + raise ProviderConfigError("A valid explicit provider id is required.") + + +def _client( + provider: str, provider_options: dict[str, Any] | None, config: dict[str, Any] +) -> EngineClient: + _validate_provider(provider) + resolved = load_config(provider=provider, provider_options=provider_options, **config) + if resolved.provider != provider: + raise ProviderConfigError( + "The command provider must match the resolved connection provider." + ) + schema = get_provider(provider) + options: dict[str, Any] = {} + for field in schema.config_fields: + value = resolved.provider_options.get(field.key, getattr(resolved, field.key, None)) + if value is not None and value != "": + options[field.key] = value + connection: dict[str, Any] = {"provider": provider, "provider_options": options} + for name, wire in ( + ("sandbox_api_key", "api_key"), + ("sandbox_base_url", "api_url"), + ("sandbox_domain", "domain"), + ): + if name in schema.inherit and (value := getattr(resolved, name, None)): + connection[wire] = value + if provider == BUILTIN_PROVIDER_ID: + connection["region"] = resolved.region + for name in ("access_key_id", "access_key_secret"): + if value := getattr(resolved, name, None): + connection[name] = value + return EngineClient(connection) + + +def _path(provider: str, command_id: str | None = None) -> str: + _validate_provider(provider) + path = f"/v1/providers/{quote(provider, safe='')}/commands" + if command_id is not None: + if ( + not isinstance(command_id, str) + or len(command_id) > 80 + or not COMMAND_ID_RE.fullmatch(command_id) + ): + raise ProviderConfigError("A valid command id of at most 80 characters is required.") + path += f"/{quote(command_id, safe='')}" + return path + + +async def list_commands(provider: str) -> list[dict[str, Any]]: + """List static command descriptors without resolving or sending credentials. + + Args: + provider: Explicit provider id. + + Returns: + Command descriptors with schemas and mutation metadata. + """ + path = _path(provider) + client = EngineClient({"provider": provider}) + try: + result = await client.call("GET", path) + entries = result.get("commands") if isinstance(result, dict) else None + if not isinstance(entries, list) or not all(isinstance(c, dict) for c in entries): + raise ProviderProtocolError("The engine returned an invalid provider command list.") + return entries + finally: + await client.close() + + +async def get_command(provider: str, command_id: str) -> dict[str, Any]: + """Return a static descriptor without resolving or sending credentials.""" + path = _path(provider, command_id) + client = EngineClient({"provider": provider}) + try: + result = await client.call("GET", path) + if not isinstance(result, dict) or result.get("id") != command_id: + raise ProviderProtocolError( + "The engine returned an invalid provider command descriptor." + ) + return result + finally: + await client.close() + + +async def run_command( + provider: str, + command_id: str, + input: dict[str, Any] | None = None, + *, + confirm: bool = False, + provider_options: dict[str, Any] | None = None, + **config: Any, +) -> Any: + """Invoke a provider command once, without replaying its POST request. + + Args: + provider: Explicit provider id. + command_id: A command declared by the provider, for example ``volumes.list``. + input: JSON object validated by the engine against the command schema. + confirm: Explicit mutation confirmation. Writes and deletes require ``True``. + provider_options: Provider-scoped configuration overrides. + **config: Canonical configuration overrides accepted by ``load_config``. + + Returns: + The command's JSON result, validated by the engine. + + Raises: + ProviderConfigError: Invalid ids, non-object input or non-boolean confirmation. + """ + path = _path(provider, command_id) + if input is not None and not isinstance(input, dict): + raise ProviderConfigError("Provider command input must be a JSON object.") + if not isinstance(confirm, bool): + raise ProviderConfigError("Provider command confirmation must be a boolean.") + client = _client(provider, provider_options, config) + try: + return await client.call( + "POST", + path, + json_body={"input": input if input is not None else {}, "confirm": confirm}, + retry=False, + ) + finally: + await client.close() diff --git a/client-sdk/src/agent_sandbox/providers/engine_client.py b/client-sdk/src/agent_sandbox/providers/engine_client.py index 042076d..2d7ab45 100644 --- a/client-sdk/src/agent_sandbox/providers/engine_client.py +++ b/client-sdk/src/agent_sandbox/providers/engine_client.py @@ -219,6 +219,7 @@ async def request( params: dict[str, Any] | None = None, timeout: float | None = None, connection: dict[str, Any] | None = None, + retry: bool = True, ) -> httpx.Response: """Recover safe reads once; never replay a mutation after transport failure.""" extra: dict[str, str] = {} @@ -227,7 +228,7 @@ async def request( if content is not None: extra["Content-Type"] = "application/octet-stream" last: Exception | None = None - for attempt in range(2): + for attempt in range(2 if retry else 1): record = await self._record_now(fresh=attempt > 0) try: res = await self._http.request( @@ -244,7 +245,7 @@ async def request( raise _unknown_outcome() from exc last = exc continue - if res.status_code == 401 and attempt == 0: + if res.status_code == 401 and attempt == 0 and retry: continue return res raise ProviderProtocolError(f"The provider engine is not reachable: {last}") diff --git a/client-sdk/src/agent_sandbox/providers/engine_provider.py b/client-sdk/src/agent_sandbox/providers/engine_provider.py index 5a773e2..e336d8d 100644 --- a/client-sdk/src/agent_sandbox/providers/engine_provider.py +++ b/client-sdk/src/agent_sandbox/providers/engine_provider.py @@ -28,7 +28,7 @@ from pathlib import Path from agent_sandbox.providers.manifest import Manifest - from agent_sandbox.transport.auth import EnvdTokenManager + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager from agent_sandbox.transport.config import TransportConfig _SCHEME = "engine://" @@ -515,6 +515,13 @@ def connection(self, config: TransportConfig) -> dict[str, Any]: connection["domain"] = config.sandbox_domain return connection + async def prepare_session( + self, infra: ProviderInfra, info: Any, *, operation: str, template: str + ) -> dict[str, Any]: + from agent_sandbox.providers.shared.e2b.session import prepare_session + + return await prepare_session(self, info) + async def build_infra(self, config: TransportConfig) -> ProviderInfra: self.validate_config(config) if self.receipt is not None and self.receipt.get("trust") != "link": diff --git a/client-sdk/src/agent_sandbox/providers/exec_provider.py b/client-sdk/src/agent_sandbox/providers/exec_provider.py index af3e6fc..8abb72c 100644 --- a/client-sdk/src/agent_sandbox/providers/exec_provider.py +++ b/client-sdk/src/agent_sandbox/providers/exec_provider.py @@ -36,7 +36,7 @@ from pathlib import Path from agent_sandbox.providers.manifest import Manifest - from agent_sandbox.transport.auth import EnvdTokenManager + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager from agent_sandbox.transport.config import TransportConfig _ENVD_SCHEMES = ("http://", "https://") @@ -640,6 +640,13 @@ def __init__( self.auth_any_of = manifest.auth_any_of self.inherit = consented_inherit(manifest.inherit, receipt) + async def prepare_session( + self, infra: ProviderInfra, info: Any, *, operation: str, template: str + ) -> dict[str, Any]: + from agent_sandbox.providers.shared.e2b.session import prepare_session + + return await prepare_session(self, info) + async def build_infra(self, config: TransportConfig) -> ProviderInfra: self.validate_config(config) if self.receipt is not None: @@ -690,8 +697,8 @@ async def build_infra(self, config: TransportConfig) -> ProviderInfra: http_client = None auth = None if supported & {"exec.run", "exec.stream", "files.rw", "process.manage"}: - from agent_sandbox.protocol.filesystem import FilesystemProtocol - from agent_sandbox.protocol.process import ProcessProtocol + from agent_sandbox.providers.shared.e2b.filesystem import FilesystemProtocol + from agent_sandbox.providers.shared.e2b.process import ProcessProtocol from agent_sandbox.transport.http import HttpClient auth = _NoPlatformAuth() diff --git a/client-sdk/src/agent_sandbox/providers/installer.py b/client-sdk/src/agent_sandbox/providers/installer.py index b41e013..abe365a 100644 --- a/client-sdk/src/agent_sandbox/providers/installer.py +++ b/client-sdk/src/agent_sandbox/providers/installer.py @@ -8,7 +8,7 @@ import re import secrets import shutil -import subprocess +import subprocess # nosec B404 - audited argv-only subprocess boundary; no shell import tempfile from collections.abc import Callable from dataclasses import dataclass, field @@ -42,7 +42,7 @@ _GIT_TIMEOUT = 120 #: Manifest aspects whose change forces a new consent on update. -RISK_ASPECTS = ("runtime", "security", "features", "inherit", "fields") +RISK_ASPECTS = ("runtime", "security", "features", "inherit", "fields", "commands") @dataclass @@ -71,6 +71,7 @@ def summary(self) -> dict[str, Any]: else [m.kind, m.endpoint.get("base_url", "")], "kind": m.kind, "features": sorted(m.features), + "commands": m.to_summary()["commands"], "security": m.security, "fields": [{"key": f.key, "secret": f.secret} for f in m.fields], "inherit": sorted(m.inherit), @@ -214,7 +215,8 @@ def _git(args: list[str], *, cwd: Path, env: dict[str, str], stdin: bytes | None *args, ] try: - proc = subprocess.run( # noqa: S603 + # Fixed git argv and constrained environment; no shell interpretation. + proc = subprocess.run( # noqa: S603 # nosec B603 - argv only; shell defaults to False cmd, cwd=cwd, env=env, @@ -365,6 +367,8 @@ def _diff_aspects(old: dict[str, Any], new: Manifest) -> list[str]: changed.append("inherit") if consented.get("fields") != summary["fields"]: changed.append("fields") + if consented.get("commands", []) != summary["commands"]: + changed.append("commands") return changed @@ -680,8 +684,28 @@ def update( Returns ``{"id", "status", "installed", "available", "changes"}`` where ``status`` is one of ``up-to-date``, ``pinned`` (pinned and no new ``ref`` given), ``available`` (``check=True``) or - ``updated``. ``local``/``link`` installs raise :class:`ProviderInstallError`. + ``updated``. The built-in provider returns ``bundled`` and host upgrade guidance, + without a latest-release check or software installation. ``local``/``link`` + installs raise :class:`ProviderInstallError`. """ + if provider_id == BUILTIN_PROVIDER_ID: + if ref: + raise ProviderInstallError( + "The bundled provider cannot use ref. Select a matching client/server SDK release." + ) + return { + "id": provider_id, + "status": "bundled", + "installed": __version__, + "available": None, + "changes": [], + "message": ( + "This provider ships with the Python SDK. Upgrade client and server packages " + "together using their original installation method; upgrade abx separately for " + "Node management commands. Restart only engines you own. This operation does " + "not check host releases or change installed software." + ), + } receipt = store.read_receipt(provider_id) if receipt is None: raise ProviderNotFoundError(f"Provider '{provider_id}' is not installed.") diff --git a/client-sdk/src/agent_sandbox/providers/manifest.py b/client-sdk/src/agent_sandbox/providers/manifest.py index 3a26ffb..bb68352 100644 --- a/client-sdk/src/agent_sandbox/providers/manifest.py +++ b/client-sdk/src/agent_sandbox/providers/manifest.py @@ -2,11 +2,13 @@ from __future__ import annotations +import copy import hashlib +import json import posixpath import re from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from agent_sandbox.models.errors import ProviderManifestError, ProviderVersionError from agent_sandbox.providers.base import FEATURES, ConfigField @@ -28,6 +30,48 @@ FILESYSTEM_SCOPES = ("data_dir", "cwd", "home", "any") _FORBIDDEN_ENV_PREFIXES = ("ABX_", "E2B_", "ALICLOUD_") _FORBIDDEN_LAUNCHERS = (".bat", ".cmd", ".ps1") +COMMAND_PROTOCOL = "provider-commands/1" +COMMAND_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$") +COMMAND_MAX_BYTES = 64 * 1024 +COMMAND_MAX_DEPTH = 16 +_FORBIDDEN_SCHEMA_KEYS = { + "$ref", + "$dynamicRef", + "$recursiveRef", + "$id", + "$async", + "pattern", + "patternProperties", + "format", +} +_FORBIDDEN_JSON_KEYS = {"__proto__", "prototype", "constructor"} + + +@dataclass(frozen=True) +class ProviderCommand: + """A provider management operation, independent of sandbox runtime commands.""" + + id: str + description: str + description_zh: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] + effect: Literal["read", "write", "delete"] + idempotent: bool + + def to_dict(self) -> dict[str, Any]: + """Return the wire descriptor without exposing mutable manifest schemas.""" + return copy.deepcopy( + { + "id": self.id, + "description": self.description, + "description_zh": self.description_zh, + "input_schema": self.input_schema, + "output_schema": self.output_schema, + "effect": self.effect, + "idempotent": self.idempotent, + } + ) @dataclass(frozen=True) @@ -56,6 +100,7 @@ class Manifest: homepage: str = "" sha256: str = "" raw: dict[str, Any] = field(default_factory=dict, compare=False, repr=False) + commands: tuple[ProviderCommand, ...] = () def to_summary(self) -> dict[str, Any]: """Fields shown in the consent screen and compared on update.""" @@ -69,6 +114,7 @@ def to_summary(self) -> dict[str, Any]: "features": sorted(self.features), "inherit": sorted(self.inherit), "fields": sorted(f.key for f in self.fields), + "commands": [c.to_dict() for c in sorted(self.commands, key=lambda c: c.id)], } @@ -194,6 +240,145 @@ def _str_list(tbl: dict[str, Any], key: str, where: str) -> list[str]: return list(val) +def _bounded_json(value: Any, *, depth: int = 0) -> None: + if depth > COMMAND_MAX_DEPTH: + raise _err("commands entry exceeds depth 16") + if isinstance(value, dict): + for key, child in value.items(): + if not isinstance(key, str) or key in _FORBIDDEN_JSON_KEYS: + raise _err("commands entries contain an invalid or forbidden JSON key") + _bounded_json(child, depth=depth + 1) + elif isinstance(value, list): + for child in value: + _bounded_json(child, depth=depth + 1) + elif value is not None and not isinstance(value, str | int | float | bool): + raise _err("commands entries must contain JSON values") + + +def _schema(value: Any, *, name: str, input_schema: bool = False) -> dict[str, Any]: + if not isinstance(value, dict): + raise _err(f"{name} must be a schema object") + if input_schema and ( + value.get("type") != "object" or value.get("additionalProperties") is not False + ): + raise _err(f"{name} must declare type object and additionalProperties = false") + + def visit(node: Any) -> None: + if not isinstance(node, dict): + return + if _FORBIDDEN_SCHEMA_KEYS.intersection(node): + raise _err( + f"{name} may not use references, ids, asynchronous schemas, patterns or formats" + ) + if "$schema" in node and node["$schema"] != "http://json-schema.org/draft-07/schema#": + raise _err(f"{name} must use JSON Schema draft 7") + for key in ("properties", "definitions", "$defs"): + children = node.get(key) + if isinstance(children, dict): + for child in children.values(): + visit(child) + for key in ( + "items", + "additionalItems", + "additionalProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + ): + child = node.get(key) + if isinstance(child, list): + for item in child: + visit(item) + else: + visit(child) + for key in ("allOf", "anyOf", "oneOf"): + children = node.get(key) + if isinstance(children, list): + for child in children: + visit(child) + dependencies = node.get("dependencies") + if isinstance(dependencies, dict): + for child in dependencies.values(): + visit(child) + + visit(value) + return value + + +def _parse_commands(doc: dict[str, Any], kind: str) -> tuple[ProviderCommand, ...]: + if "commands" not in doc: + return () + commands = _table(doc, "commands") + if commands.get("protocol") != COMMAND_PROTOCOL: + raise _err(f"commands.protocol must be '{COMMAND_PROTOCOL}'") + entries = commands.get("entries") + if not isinstance(entries, list) or len(entries) > 128: + raise _err("commands.entries must be an array of at most 128 entries") + if entries and kind not in ("node", "exec"): + raise _err("only node and exec providers may declare commands") + result: list[ProviderCommand] = [] + seen: set[str] = set() + keys = { + "id", + "description", + "description_zh", + "input_schema", + "output_schema", + "effect", + "idempotent", + } + for entry in entries: + if not isinstance(entry, dict) or set(entry) - keys: + raise _err("commands entry must be a descriptor with known fields") + _bounded_json(entry) + try: + size = len( + json.dumps( + entry, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ).encode("utf-8") + ) + except (ValueError, TypeError) as exc: + raise _err("commands entry must contain finite JSON values") from exc + if size > COMMAND_MAX_BYTES: + raise _err("commands entry exceeds 64 KiB") + cid = entry.get("id") + if not isinstance(cid, str) or len(cid) > 80 or not COMMAND_ID_RE.fullmatch(cid): + raise _err( + "commands entry id must be a lowercase operation id of at most 80 characters" + ) + if cid in seen: + raise _err(f"duplicate commands entry '{cid}'") + seen.add(cid) + if entry.get("effect") not in ("read", "write", "delete"): + raise _err(f"commands.{cid}.effect must be read, write or delete") + if not isinstance(entry.get("idempotent"), bool): + raise _err(f"commands.{cid}.idempotent must be a boolean") + for description in ("description", "description_zh"): + if not _str(entry, description, f"commands.{cid}", required=True).strip(): + raise _err(f"commands.{cid}.{description} must not be blank") + result.append( + ProviderCommand( + id=cid, + description=_str(entry, "description", f"commands.{cid}", required=True), + description_zh=_str(entry, "description_zh", f"commands.{cid}", required=True), + input_schema=_schema( + entry.get("input_schema"), + name=f"commands.{cid}.input_schema", + input_schema=True, + ), + output_schema=_schema( + entry.get("output_schema"), name=f"commands.{cid}.output_schema" + ), + effect=entry["effect"], + idempotent=entry["idempotent"], + ) + ) + return tuple(result) + + def parse_manifest(data: bytes | str) -> Manifest: """Parse and validate manifest bytes (design §7.5). Raises :class:`ProviderManifestError`.""" raw_bytes = data.encode("utf-8") if isinstance(data, str) else data @@ -404,6 +589,7 @@ def ms(key: str, default: int) -> int: auth_any_of=tuple(any_of), fields=tuple(fields), endpoint=endpoint, + commands=_parse_commands(doc, kind), sha256=hashlib.sha256(raw_bytes).hexdigest(), raw=doc, ) diff --git a/client-sdk/src/agent_sandbox/providers/registry.py b/client-sdk/src/agent_sandbox/providers/registry.py index 0058c65..e30bcc1 100644 --- a/client-sdk/src/agent_sandbox/providers/registry.py +++ b/client-sdk/src/agent_sandbox/providers/registry.py @@ -8,8 +8,8 @@ from agent_sandbox.models.errors import ProviderConflictError, ProviderNotFoundError from agent_sandbox.providers import store +from agent_sandbox.providers.alicloud_express.provider import AlicloudExpressProvider from agent_sandbox.providers.base import Provider, ordered_features -from agent_sandbox.providers.builtin import AlicloudExpressProvider from agent_sandbox.utils.config_keys import ( BUILTIN_PROVIDER_ID, OFFICIAL_CATALOG_IDS, diff --git a/client-sdk/src/agent_sandbox/providers/rest_provider.py b/client-sdk/src/agent_sandbox/providers/rest_provider.py index f3ef8e8..253dfc4 100644 --- a/client-sdk/src/agent_sandbox/providers/rest_provider.py +++ b/client-sdk/src/agent_sandbox/providers/rest_provider.py @@ -15,6 +15,7 @@ ) if TYPE_CHECKING: + from agent_sandbox.protocol.port_contract import PortAccess, PortContext from agent_sandbox.providers.manifest import Manifest from agent_sandbox.transport.config import TransportConfig @@ -64,11 +65,41 @@ def __init__(self, manifest: Manifest, receipt: dict[str, Any] | None = None) -> self.derive = dict(ep.get("derive", {})) self._auth_cfg = dict(ep.get("auth", {"scheme": "bearer"})) + async def prepare_session( + self, infra: ProviderInfra, info: Any, *, operation: str, template: str + ) -> dict[str, Any]: + from agent_sandbox.providers.shared.e2b.session import prepare_session + + return await prepare_session(self, info) + + def session_protocols(self, http: Any) -> dict[str, Any]: + from agent_sandbox.providers.shared.e2b.session import session_protocols + + return session_protocols(http) + + def create_port_access(self, context: PortContext, protocol: Any = None) -> PortAccess: + from agent_sandbox.providers.shared.e2b.ports import WirePortAccess + + return WirePortAccess(context, server_auth=True) + + def resolve_session_url(self, info: Any, config: Any) -> str: + from agent_sandbox.providers.shared.e2b.url import envd_url + + return str(info.envd_url or envd_url(info.sandbox_id, config.sandbox_domain)) + + async def open_terminal(self, sandbox: Any, **options: Any) -> Any: + self.require("pty") + from agent_sandbox.providers.shared.e2b.terminal import TerminalSession + + return await TerminalSession.create( + envd_url=sandbox.url, envd_token=sandbox._envd_token, config=sandbox._config, **options + ) + async def build_infra(self, config: TransportConfig) -> ProviderInfra: - from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol - from agent_sandbox.protocol.filesystem import FilesystemProtocol - from agent_sandbox.protocol.process import ProcessProtocol - from agent_sandbox.protocol.sandbox import SandboxProtocol + from agent_sandbox.providers.shared.e2b.code_interpreter import CodeInterpreterProtocol + from agent_sandbox.providers.shared.e2b.filesystem import FilesystemProtocol + from agent_sandbox.providers.shared.e2b.process import ProcessProtocol + from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol from agent_sandbox.transport.http import HttpClient self.validate_config(config) diff --git a/client-sdk/src/agent_sandbox/providers/shared/__init__.py b/client-sdk/src/agent_sandbox/providers/shared/__init__.py new file mode 100644 index 0000000..380c07d --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/__init__.py @@ -0,0 +1 @@ +"""Provider-owned backend implementation.""" diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/__init__.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/__init__.py new file mode 100644 index 0000000..380c07d --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/__init__.py @@ -0,0 +1 @@ +"""Provider-owned backend implementation.""" diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/auth.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/auth.py new file mode 100644 index 0000000..8593d39 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/auth.py @@ -0,0 +1,97 @@ +"""Authentication for providers explicitly selecting the E2B/envd wire adapter.""" + +from __future__ import annotations + +import base64 + +from agent_sandbox.models.errors import TokenExpiredError +from agent_sandbox.utils.logging import get_logger + +logger = get_logger("providers.shared.e2b.auth") +ENVD_AUTH_HEADER = "X-Access-Token" +ENVD_SANDBOX_ID_HEADER = "E2b-Sandbox-Id" +ENVD_SANDBOX_PORT_HEADER = "E2b-Sandbox-Port" +_ENVD_BASIC_AUTH = base64.b64encode(b"user:").decode("ascii") + + +class EnvdTokenManager: + """Manages envdAccessToken for sandbox envd API requests. + + Each sandbox has its own envdAccessToken returned during creation. + envd 认证需要四个 header(已实测验证): + - X-Access-Token: {envd_access_token} + - E2b-Sandbox-Id: {sandbox_id} + - E2b-Sandbox-Port: 49983 + - Authorization: Basic {base64("user:")} + """ + + def __init__( + self, + token: str | None = None, + sandbox_id: str | None = None, + *, + allow_missing: bool = False, + basic_user: str = "user", + ) -> None: + self._token = token + self._sandbox_id = sandbox_id + self._allow_missing = allow_missing + self._basic_user = basic_user or "user" + + @property + def token(self) -> str | None: + return self._token + + @property + def sandbox_id(self) -> str | None: + return self._sandbox_id + + def set_token(self, token: str, sandbox_id: str | None = None) -> None: + """Set the envd access token (from sandbox create response).""" + self._token = token + if sandbox_id is not None: + self._sandbox_id = sandbox_id + logger.debug("envdAccessToken set for sandbox %s", self._sandbox_id) + + def get_headers(self) -> dict[str, str]: + """Return all envd auth headers (已实测验证). + + Returns dict with X-Access-Token, E2b-Sandbox-Id, E2b-Sandbox-Port, + and Authorization (Basic) headers. + """ + basic = base64.b64encode(f"{self._basic_user}:".encode()).decode("ascii") + headers = {"Authorization": f"Basic {basic}"} + if self._token: + headers[ENVD_AUTH_HEADER] = self._token + elif not self._allow_missing: + raise TokenExpiredError( + "envdAccessToken not available. Sandbox may not be created or connected.", + suggestion="Create or connect to a sandbox first.", + ) + if self._sandbox_id is not None: + headers[ENVD_SANDBOX_ID_HEADER] = self._sandbox_id + headers[ENVD_SANDBOX_PORT_HEADER] = "49983" + return headers + + def clear(self) -> None: + """Clear the cached token.""" + self._token = None + self._sandbox_id = None + + +def build_envd_headers(sandbox_id: str, envd_access_token: str) -> dict[str, str]: + """Build envd authentication headers from sandbox_id and envd_access_token (已实测验证). + + Args: + sandbox_id: Sandbox ID (e.g., "sbx-xxxx"). + envd_access_token: The envdAccessToken from sandbox create response. + + Returns: + Dict with all required envd auth headers. + """ + return { + ENVD_AUTH_HEADER: envd_access_token, + ENVD_SANDBOX_ID_HEADER: sandbox_id, + ENVD_SANDBOX_PORT_HEADER: "49983", + "Authorization": f"Basic {_ENVD_BASIC_AUTH}", + } diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/code_interpreter.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/code_interpreter.py new file mode 100644 index 0000000..6f631ce --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/code_interpreter.py @@ -0,0 +1,210 @@ +"""Code Interpreter 协议层。 + +注意:当前实现基于 E2B SDK 逆向推断,阿里云官方文档未公开 +Code Interpreter 的底层传输细节。需实测验证。 + +Uses Connect RPC over HTTP with application/connect+json content type. + +RPC endpoints: +- /code.CodeInterpreter/Execute — Execute code in a context +- /code.CodeInterpreter/CreateContext — Create an execution context +- /code.CodeInterpreter/ListContexts — List execution contexts +- /code.CodeInterpreter/RestartContext — Restart an execution context +- /code.CodeInterpreter/RemoveContext — Remove an execution context +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agent_sandbox.transport.codec import ConnectCodec +from agent_sandbox.utils.logging import get_logger + +if TYPE_CHECKING: + from collections.abc import Callable + + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager + from agent_sandbox.transport.http import HttpClient + +logger = get_logger("protocol.code_interpreter") + +# Extra seconds added to the caller-supplied timeout to derive the HTTP read +# timeout. Mirrors the constant in protocol.process. +_HTTP_TIMEOUT_BUFFER: float = 5.0 + +_codec = ConnectCodec() + +# RPC paths — 注意:路径基于 E2B SDK 逆向推断,未经官方文档确认 +_EXECUTE = _codec.build_rpc_path("code", "CodeInterpreter", "Execute") +_CREATE_CONTEXT = _codec.build_rpc_path("code", "CodeInterpreter", "CreateContext") +_LIST_CONTEXTS = _codec.build_rpc_path("code", "CodeInterpreter", "ListContexts") +_RESTART_CONTEXT = _codec.build_rpc_path("code", "CodeInterpreter", "RestartContext") +_REMOVE_CONTEXT = _codec.build_rpc_path("code", "CodeInterpreter", "RemoveContext") + + +class CodeInterpreterProtocol: + """Code Interpreter Connect RPC 协议客户端。 + + 注意:当前实现基于 E2B SDK 逆向推断,阿里云官方文档未公开 + Code Interpreter 的底层传输细节。所有 RPC 路径和请求/响应格式 + 均需实测验证。 + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http = http_client + + async def run_code( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + *, + code: str, + language: str = "python", + context_id: str | None = None, + timeout: int = 30, + envs: dict[str, str] | None = None, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + on_result: Callable[..., None] | None = None, + ) -> dict[str, Any]: + """执行代码。对应官方 sandbox.runCode() / sandbox.run_code()。 + + POST envd_url + /code.CodeInterpreter/Execute (Connect unary) + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + + Implementation note — **buffered fallback path**: + The CodeInterpreter/Execute endpoint is accessed as a Connect unary + RPC (not a server-streaming RPC), so true incremental streaming is + not available at the protocol level. When ``on_stdout``, + ``on_stderr``, or ``on_result`` callbacks are supplied they are + invoked **once** after the full response is received — this keeps + the E2B-style callback API contract (callers can rely on the + callbacks firing) while the underlying transport remains unary. + """ + payload: dict[str, Any] = { + "code": code, + "language": language, + "timeout": timeout, + } + if context_id: + payload["contextId"] = context_id + if envs: + payload["envVars"] = envs + + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + # Derive the HTTP read timeout from the code-execution timeout so + # that long-running code is not killed by the HTTP layer's default + # 30 s timeout before the server-side timeout fires. + http_timeout = float(timeout) + _HTTP_TIMEOUT_BUFFER + + response = await self._http.envd_request( + envd_url, + _EXECUTE, + payload=payload, + envd_token=envd_token, + request_timeout=http_timeout, + ) + + # --- Buffered callback dispatch ------------------------------------ + # Fire callbacks with the complete response data so callers using + # the E2B streaming-callback style still receive notifications. + if on_stdout or on_stderr or on_result: + result = response.get("result", response) + stdout = result.get("stdout", "") + stderr = result.get("stderr", "") + + if on_stdout and stdout: + on_stdout(stdout) + if on_stderr and stderr: + on_stderr(stderr) + if on_result: + on_result(result) + + return response + + async def create_context( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + *, + language: str = "python", + ) -> dict[str, Any]: + """创建执行上下文。对应官方 sandbox.createCodeContext()。 + + POST envd_url + /code.CodeInterpreter/CreateContext (Connect unary) + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + """ + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + response = await self._http.envd_request( + envd_url, + _CREATE_CONTEXT, + payload={"language": language}, + envd_token=envd_token, + ) + return response + + async def list_contexts( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + ) -> list[dict[str, Any]]: + """查询执行上下文列表。对应官方 sandbox.listCodeContexts()。 + + POST envd_url + /code.CodeInterpreter/ListContexts (Connect unary) + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + """ + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + response = await self._http.envd_request( + envd_url, + _LIST_CONTEXTS, + payload={}, + envd_token=envd_token, + ) + contexts = response.get("contexts", response.get("result", {}).get("contexts", [])) + return contexts # type: ignore[no-any-return] + + async def restart_context( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + *, + context_id: str, + ) -> dict[str, Any]: + """重启执行上下文。对应官方 sandbox.restartCodeContext()。 + + POST envd_url + /code.CodeInterpreter/RestartContext (Connect unary) + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + """ + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + response = await self._http.envd_request( + envd_url, + _RESTART_CONTEXT, + payload={"contextId": context_id}, + envd_token=envd_token, + ) + return response + + async def remove_context( + self, + sandbox_id: str, + envd_url: str, + envd_token: EnvdTokenManager, + *, + context_id: str, + ) -> None: + """删除执行上下文。对应官方 sandbox.removeCodeContext()。 + + POST envd_url + /code.CodeInterpreter/RemoveContext (Connect unary) + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + """ + # 注意:RPC 路径基于 E2B SDK 逆向推断,未经官方文档确认 + await self._http.envd_request( + envd_url, + _REMOVE_CONTEXT, + payload={"contextId": context_id}, + envd_token=envd_token, + ) diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py new file mode 100644 index 0000000..5068682 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py @@ -0,0 +1,447 @@ +"""envd API protocol — filesystem operations (Connect protocol + HTTP file API). + +Connect RPC endpoints(已实测验证): +- /filesystem.Filesystem/Stat — Get file/dir info +- /filesystem.Filesystem/MakeDir — Create directory +- /filesystem.Filesystem/ListDir — List directory contents +- /filesystem.Filesystem/Remove — Remove file/directory +- /filesystem.Filesystem/Move — Move/rename file +- /filesystem.Filesystem/WatchDir (streaming) — Watch for changes + +HTTP file API(非 Connect RPC,已实测验证): +- POST /files?path={path}&username=user (multipart) — Upload file → 201 +- GET /files?path={path}&username=user — Download file → 200 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NoReturn + +import httpx + +from agent_sandbox.models.errors import ( + FileNotFoundError_, + FileOperationError, + PermissionDeniedError, +) +from agent_sandbox.models.filesystem import FileInfo, FileType, WatchEvent, WatchEventType +from agent_sandbox.transport.codec import ConnectCodec +from agent_sandbox.transport.streaming import StreamReader +from agent_sandbox.utils.logging import get_logger + +if TYPE_CHECKING: + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager + from agent_sandbox.transport.http import HttpClient + +logger = get_logger("protocol.filesystem") + +_codec = ConnectCodec() + +# RPC paths(已实测验证) +_STAT = _codec.build_rpc_path("filesystem", "Filesystem", "Stat") +_MAKE_DIR = _codec.build_rpc_path("filesystem", "Filesystem", "MakeDir") +_LIST_DIR = _codec.build_rpc_path("filesystem", "Filesystem", "ListDir") +_REMOVE = _codec.build_rpc_path("filesystem", "Filesystem", "Remove") +_MOVE = _codec.build_rpc_path("filesystem", "Filesystem", "Move") +_WATCH_DIR = _codec.build_rpc_path("filesystem", "Filesystem", "WatchDir") + + +def _name_from_path(path: str) -> str: + """Extract the last valid path segment from *path*. + + Handles Unix paths, Windows-style back-slashes, trailing separators, + and root-only paths. Returns ``""`` when no meaningful segment exists + (e.g. ``"/"`` or ``""`` or ``"C:\\``). + """ + if not path: + return "" + stripped = path.rstrip("/\\") + if not stripped: + # Root-only path (e.g. "/", "//", "\\") + return "" + last_sep = max(stripped.rfind("/"), stripped.rfind("\\")) + return stripped[last_sep + 1 :] if last_sep >= 0 else stripped + + +def _unwrap_entry(data: dict[str, Any]) -> dict[str, Any]: + """Return an envd EntryInfo from a flat object or a ``result``/``entry`` wrapper.""" + current = data + result = current.get("result") + if isinstance(result, dict): + current = result + entry = current.get("entry") + if isinstance(entry, dict): + return entry + return current + + +def _is_directory(data: dict[str, Any]) -> bool: + """True for ``isDir``, ``directory``, or envd's protobuf directory type (2).""" + if data.get("isDir"): + return True + raw = data.get("type") + if isinstance(raw, bool) or raw is None: + return False + if isinstance(raw, int): + return raw == 2 + if isinstance(raw, str): + return raw.strip() in {"2", "directory", "FILE_TYPE_DIRECTORY", "dir"} + return False + + +def _parse_file_info(data: dict[str, Any]) -> FileInfo: + """Parse a file info dict from the API response. + + * **name**: prefers an explicit non-empty ``name`` from the server; + falls back to the last path segment of ``path``. + * **type**: DIRECTORY when ``isDir`` is truthy, ``type`` is ``directory`` + (or the envd enum ``2`` / ``FILE_TYPE_DIRECTORY``), otherwise FILE. + * **size**: numeric strings from protobuf JSON are coerced to ``int``. + """ + file_type = FileType.DIRECTORY if _is_directory(data) else FileType.FILE + + name = data.get("name") or "" + if not name: + name = _name_from_path(data.get("path", "")) + + size = data.get("size", 0) + if isinstance(size, str) and size.strip().lstrip("-").isdigit(): + size = int(size.strip()) + + return FileInfo( + name=name, + path=data.get("path", ""), + type=file_type, + size=size, + ) + + +def _raise_transfer_timeout( + exc: httpx.TimeoutException, verb: str, path: str, size: int +) -> NoReturn: + """A transfer outlived the HTTP timeout; name the operation, the size and the knob.""" + sized = f" ({size} bytes)" if size >= 0 else "" + raise FileOperationError( + f"{verb} {path}{sized} timed out; the transfer took longer than the HTTP timeout.", + suggestion=( + "Raise the HTTP timeout (ABX_HTTP_TIMEOUT / http_timeout) or transfer smaller chunks." + ), + ) from exc + + +def _raise_file_error(exc: httpx.HTTPStatusError, path: str) -> NoReturn: + """Translate an envd HTTP error into the SDK's filesystem errors (no URL in the text).""" + status = exc.response.status_code + if status == 404: + raise FileNotFoundError_(f"File or directory not found in the sandbox: {path}") from exc + if status == 403: + raise PermissionDeniedError(f"Permission denied in the sandbox: {path}") from exc + detail = exc.response.text.strip()[:200] + message = f"envd file operation failed (HTTP {status}) for {path}" + raise FileOperationError(f"{message}: {detail}" if detail else message) from exc + + +def _parse_watch_event(frame: dict[str, Any]) -> WatchEvent: + """Parse a streaming watch event frame.""" + data = frame.get("result", frame) + filesystem = data.get("filesystem") if isinstance(data.get("filesystem"), dict) else None + if filesystem is not None: + raw = str(filesystem.get("type") or "") + mapped = { + "EVENT_TYPE_CREATE": WatchEventType.CREATED, + "EVENT_TYPE_WRITE": WatchEventType.MODIFIED, + "EVENT_TYPE_REMOVE": WatchEventType.DELETED, + "EVENT_TYPE_RENAME": WatchEventType.RENAMED, + "EVENT_TYPE_CHMOD": WatchEventType.MODIFIED, + }.get(raw, WatchEventType.MODIFIED) + return WatchEvent(type=mapped, path=str(filesystem.get("name") or "")) + event_type_str = data.get("type", data.get("eventType", "modified")) + + type_map = { + "created": WatchEventType.CREATED, + "modified": WatchEventType.MODIFIED, + "deleted": WatchEventType.DELETED, + "renamed": WatchEventType.RENAMED, + "create": WatchEventType.CREATED, + "modify": WatchEventType.MODIFIED, + "delete": WatchEventType.DELETED, + "rename": WatchEventType.RENAMED, + } + event_type = type_map.get(event_type_str, WatchEventType.MODIFIED) + + return WatchEvent( + type=event_type, + path=data.get("path", ""), + old_path=data.get("oldPath"), + ) + + +class FilesystemProtocol: + """Typed wrapper for envd filesystem operations (Connect protocol + HTTP file API). + + Connect RPC 用于目录操作;HTTP API 用于文件上传/下载(已实测验证)。 + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http = http_client + + async def transfer_url( + self, envd_url: str, envd_token: EnvdTokenManager, *, path: str, operation: str + ) -> str: + """Legacy envd URL; callers must also send the data-plane auth headers.""" + return f"{envd_url}/files?path={path}&username=user" + + async def _rpc( + self, + envd_url: str, + envd_token: EnvdTokenManager, + rpc_path: str, + path: str, + *, + payload: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Unary envd call; HTTP errors become SDK filesystem errors for *path*.""" + try: + return await self._http.envd_request( + envd_url, + rpc_path, + payload=payload if payload is not None else {"path": path}, + envd_token=envd_token, + ) + except httpx.HTTPStatusError as exc: + _raise_file_error(exc, path) + + # --- Connect RPC methods(已实测验证)--- + + async def stat( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + ) -> FileInfo: + """Get info about a file or directory. + + POST envd_url + /filesystem.Filesystem/Stat(已实测验证) + """ + response = await self._rpc(envd_url, envd_token, _STAT, path) + return _parse_file_info(_unwrap_entry(response)) + + async def list_dir( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str = "/", + ) -> list[FileInfo]: + """List directory contents. + + POST envd_url + /filesystem.Filesystem/ListDir(已实测验证) + """ + response = await self._rpc(envd_url, envd_token, _LIST_DIR, path) + + entries = response.get("entries", response.get("result", {}).get("entries", [])) + if not entries: + # envd answers HTTP 200 with no entries for a missing path; Stat tells the cases + # apart (raises FileNotFoundError_ when the path does not exist). + await self.stat(envd_url, envd_token, path=path) + return [_parse_file_info(_unwrap_entry(entry)) for entry in entries] + + async def make_dir( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + ) -> None: + """Create a directory (and parents). + + POST envd_url + /filesystem.Filesystem/MakeDir(已实测验证) + """ + await self._rpc(envd_url, envd_token, _MAKE_DIR, path) + + async def remove( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + ) -> None: + """Remove a file or directory. + + POST envd_url + /filesystem.Filesystem/Remove(已实测验证) + """ + await self._rpc(envd_url, envd_token, _REMOVE, path) + + async def move( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + source: str, + destination: str, + ) -> None: + """Move/rename a file or directory. + + POST envd_url + /filesystem.Filesystem/Move(已实测验证) + """ + await self._rpc( + envd_url, + envd_token, + _MOVE, + source, + payload={"source": source, "destination": destination}, + ) + + async def watch_dir( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + ) -> StreamReader[WatchEvent]: + """Watch a directory for changes (streaming). + + POST envd_url + /filesystem.Filesystem/WatchDir (Connect streaming, 已实测验证) + """ + raw_stream = self._http.envd_stream( + envd_url, + _WATCH_DIR, + payload={"path": path}, + envd_token=envd_token, + ) + return StreamReader(raw_stream, _parse_watch_event) + + # --- HTTP file API(非 Connect RPC,已实测验证)--- + + async def upload_file( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + content: bytes, + username: str = "user", + ) -> None: + """Upload a file via HTTP multipart POST. + + POST envd_url/files?path={path}&username=user → 201(已实测验证) + """ + try: + await self._http.envd_http_request( + envd_url, + "POST", + "/files", + envd_token=envd_token, + params={"path": path, "username": username}, + files={"file": ("file", content)}, + ) + except httpx.TimeoutException as exc: + _raise_transfer_timeout(exc, "Upload", path, len(content)) + except httpx.HTTPStatusError as exc: + _raise_file_error(exc, path) + + async def download_file( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + username: str = "user", + max_bytes: int | None = None, + ) -> bytes: + """Download a file via HTTP GET. + + GET envd_url/files?path={path}&username=user → 200(已实测验证) + """ + limits: dict[str, Any] = {} + if max_bytes is not None: + limits["max_response_bytes"] = max_bytes + try: + response = await self._http.envd_http_request( + envd_url, + "GET", + "/files", + envd_token=envd_token, + params={"path": path, "username": username}, + **limits, + ) + except httpx.TimeoutException as exc: + _raise_transfer_timeout(exc, "Download", path, -1) + except httpx.HTTPStatusError as exc: + _raise_file_error(exc, path) + return response.content + + # --- convenience wrappers --- + + async def exists( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + ) -> bool: + """Check if a file or directory exists (via Stat).""" + try: + await self.stat(envd_url, envd_token, path=path) + return True + except Exception: + return False + + async def get_info( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + ) -> FileInfo: + """Alias for stat(). Kept for backward compatibility.""" + return await self.stat(envd_url, envd_token, path=path) + + async def read( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + max_bytes: int | None = None, + ) -> bytes: + """Read file content via HTTP download API.""" + if max_bytes is None: + return await self.download_file(envd_url, envd_token, path=path) + return await self.download_file(envd_url, envd_token, path=path, max_bytes=max_bytes) + + async def read_text( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + encoding: str = "utf-8", + ) -> str: + """Read file content as text.""" + raw = await self.read(envd_url, envd_token, path=path) + return raw.decode(encoding) + + async def write( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + path: str, + content: str | bytes, + ) -> None: + """Write content to a file via HTTP upload API.""" + raw_bytes = content.encode("utf-8") if isinstance(content, str) else content + await self.upload_file(envd_url, envd_token, path=path, content=raw_bytes) + + async def rename( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + old_path: str, + new_path: str, + ) -> None: + """Deprecated: use move(). Kept for backward compatibility.""" + await self.move(envd_url, envd_token, source=old_path, destination=new_path) diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py new file mode 100644 index 0000000..8b6d1aa --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py @@ -0,0 +1,56 @@ +"""Local gateway addressing selected explicitly by E2B/envd adapters.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING +from urllib.parse import urlsplit, urlunsplit + +if TYPE_CHECKING: + from agent_sandbox.protocol.port_contract import PortContext + + +class PortClient: + @staticmethod + def get_host(sandbox_id: str, port: int, domain: str) -> str: + return f"{port}-{sandbox_id}.{domain}" + + @staticmethod + def get_port_url(sandbox_id: str, port: int, domain: str, secure: bool = True) -> str: + return f"{'https' if secure else 'http'}://{PortClient.get_host(sandbox_id, port, domain)}" + + +class WirePortAccess: + def __init__(self, context: PortContext, *, server_auth: bool = True) -> None: + self._context = context + self._server_auth = server_auth + + def get_host(self, port: int) -> str: + return PortClient.get_host(self._context.sandbox_id, port, self._context.domain) + + def get_url(self, port: int) -> str: + return PortClient.get_port_url( + self._context.sandbox_id, port, self._context.domain, self._context.secure + ) + + def get_access_headers(self) -> dict[str, str]: + if self._context.secure and self._context.access_token: + return {"X-Access-Token": self._context.access_token} + return {} + + def get_server_headers(self) -> dict[str, str]: + return self.get_access_headers() if self._server_auth else {} + + +def legacy_pty_url(base_url: str, server_port: int, pty_port: int) -> str: + """Preserve the historical standalone ServerClient gateway convention.""" + parts = urlsplit(base_url) + host = parts.hostname or "" + gateway = re.match(rf"^{server_port}-(.+)$", host) + if gateway: + netloc = f"{pty_port}-{gateway.group(1)}" + if parts.port: + netloc += f":{parts.port}" + else: + netloc = f"{host}:{pty_port}" + return urlunsplit((parts.scheme, netloc, "", "", "")) diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/process.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/process.py new file mode 100644 index 0000000..ffc07e3 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/process.py @@ -0,0 +1,390 @@ +"""envd API protocol — process management (Connect protocol). + +Uses Connect RPC over HTTP with application/connect+json content type. +Streaming endpoints (Start, Connect) return AsyncIterator of typed chunks. + +RPC endpoints(已实测验证): +- /process.Process/Start (streaming) — Start a process and stream output +- /process.Process/List — List running processes +- /process.Process/Connect (streaming) — Connect to existing process output +- /process.Process/Update — Update process settings +- /process.Process/SendInput — Send stdin to a process +- /process.Process/SendSignal — Send signal to a process +""" + +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING, Any + +import httpx + +from agent_sandbox.models.errors import CommandTimeoutError +from agent_sandbox.models.process import ( + ProcessChunk, + ProcessChunkType, + ProcessInfo, +) +from agent_sandbox.transport.codec import ConnectCodec +from agent_sandbox.transport.http import envd_status_error +from agent_sandbox.transport.streaming import StreamReader +from agent_sandbox.utils.logging import get_logger + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager + from agent_sandbox.transport.http import HttpClient + +logger = get_logger("protocol.process") + +# Extra seconds added to the process-level timeout to derive the HTTP read +# timeout. This avoids the HTTP layer timing out *before* the server-side +# process timeout fires, which would produce a misleading ReadTimeout +# instead of a clean process-timeout error. +_HTTP_TIMEOUT_BUFFER: float = 5.0 + +_codec = ConnectCodec() + +# RPC paths(已实测验证) +_START = _codec.build_rpc_path("process", "Process", "Start") +_LIST = _codec.build_rpc_path("process", "Process", "List") +_CONNECT = _codec.build_rpc_path("process", "Process", "Connect") +_UPDATE = _codec.build_rpc_path("process", "Process", "Update") +_SEND_INPUT = _codec.build_rpc_path("process", "Process", "SendInput") +_SEND_SIGNAL = _codec.build_rpc_path("process", "Process", "SendSignal") + + +# envd ``Signal`` enum (process.proto): only these two can be delivered through ``SendSignal``. +_SIGNAL_NAMES: dict[int, str] = {9: "SIGNAL_SIGKILL", 15: "SIGNAL_SIGTERM"} + + +def _process_selector(pid: int | str) -> dict[str, Any]: + """Build envd's ``ProcessSelector`` (``{"pid": N}`` or ``{"tag": "..."}``). + + A digit-only string is a pid (the CLI passes pids as text); any other string is a process tag. + """ + if isinstance(pid, bool): + raise TypeError("pid must be an int or str, not bool") + if isinstance(pid, int): + return {"pid": pid} + text = str(pid).strip() + if text.isdigit(): + return {"pid": int(text)} + return {"tag": text} + + +def _parse_process_chunk(frame: dict[str, Any]) -> ProcessChunk: + """Parse a streaming frame into a ProcessChunk. + + 已实测验证的 NDJSON event 格式: + - {"event":{"start":{"pid":N}}} — 进程启动 + - {"event":{"data":{"stdout":"base64"}}} — stdout 数据(base64 编码) + - {"event":{"data":{"stderr":"base64"}}} — stderr 数据(base64 编码) + - {"event":{"end":{"exitCode":N,"exited":bool,"status":"exited"}}} — 进程结束 + - {} — 空结束帧(忽略) + """ + event = frame.get("event") + if not event or not isinstance(event, dict): + # 空帧或无 event 字段 — 忽略 + return ProcessChunk(type=ProcessChunkType.STDOUT, data="") + + # 进程启动事件 + if "start" in event: + start_data = event["start"] + pid = start_data.get("pid", 0) + return ProcessChunk( + type=ProcessChunkType.STDOUT, + data="", + exit_code=None, + pid=pid, + ) + + # 数据输出事件(stdout/stderr,base64 编码) + if "data" in event: + data_obj = event["data"] + if "stdout" in data_obj: + raw = data_obj["stdout"] + try: + decoded = base64.b64decode(raw).decode("utf-8", errors="replace") + except Exception: + decoded = raw + return ProcessChunk(type=ProcessChunkType.STDOUT, data=decoded) + if "stderr" in data_obj: + raw = data_obj["stderr"] + try: + decoded = base64.b64decode(raw).decode("utf-8", errors="replace") + except Exception: + decoded = raw + return ProcessChunk(type=ProcessChunkType.STDERR, data=decoded) + + # 进程结束事件 + if "end" in event: + end_data = event["end"] + exit_code = end_data.get("exitCode") + if exit_code is None: + status = str(end_data.get("status") or "") + tail = status.rsplit(" ", 1)[-1] if status else "" + exit_code = int(tail) if tail.isdigit() else 0 + return ProcessChunk( + type=ProcessChunkType.EXIT, + exit_code=exit_code, + ) + + # 未知事件 + return ProcessChunk(type=ProcessChunkType.STDOUT, data="") + + +class ProcessProtocol: + """Typed wrapper for envd process operations (Connect protocol). + + 已实测验证的方法: Start, List, Connect, Update, SendInput, SendSignal + """ + + def __init__(self, http_client: HttpClient) -> None: + self._http = http_client + + async def start( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + cmd: str, + args: list[str] | None = None, + env: dict[str, str] | None = None, + cwd: str = "", + timeout: int = 60, + user: str = "", + ) -> StreamReader[ProcessChunk]: + """Start a process and return a streaming reader. + + POST envd_url + /process.Process/Start (Connect streaming) + + 请求格式(已实测验证): {"process": {"cmd": "...", "args": [...]}} + + Note: ``cwd`` and ``user`` are only included in the payload when + explicitly provided (non-empty). Sending ``user="user"`` to + certain platform base images triggers ``fork/exec: no such file + or directory`` because the envd uses a namespace that doesn't + recognise that user account. + """ + process_obj: dict[str, Any] = { + "cmd": cmd, + } + if args: + process_obj["args"] = args + if env: + process_obj["envs"] = env + if cwd: + process_obj["cwd"] = cwd + if user: + process_obj["user"] = user + # StartRequest.stdin sits beside process. A missing flag defaults to + # true, so a reader waits until the HTTP timeout. Proto JSON drops an + # unknown field nested under process. + payload: dict[str, Any] = {"process": process_obj, "stdin": False} + + # Derive the HTTP read timeout from the process-level timeout so + # that long-running commands (e.g. ``pip install``) are not killed + # prematurely by the HTTP layer's default 30 s timeout. + http_timeout = float(timeout) + _HTTP_TIMEOUT_BUFFER + + raw_stream = self._http.envd_stream( + envd_url, + _START, + payload=payload, + envd_token=envd_token, + request_timeout=http_timeout, + ) + + guarded = self._guard_command_timeout( + raw_stream, envd_url, envd_token, timeout=timeout, http_timeout=http_timeout + ) + return StreamReader(guarded, _parse_process_chunk) + + async def _guard_command_timeout( + self, + raw_stream: AsyncIterator[dict[str, Any]], + envd_url: str, + envd_token: EnvdTokenManager, + *, + timeout: int, + http_timeout: float, + ) -> AsyncIterator[dict[str, Any]]: + """Re-raise the HTTP read timeout of a command stream as :class:`CommandTimeoutError`. + + The HTTP read timeout is derived from the command timeout, so a silent command that + outlives it surfaces from httpx as ``ReadTimeout``. The process is killed on a + best-effort basis (closing the HTTP stream does not stop it) and the SDK error is raised. + """ + pid: int | None = None + try: + async for frame in raw_stream: + started = (frame.get("event") or {}).get("start") + if isinstance(started, dict) and isinstance(started.get("pid"), int): + pid = started["pid"] + yield frame + except httpx.ReadTimeout as exc: + if pid is not None: + try: + await self.send_signal( + envd_url, envd_token, pid=pid, signal=9, request_timeout=10.0 + ) + except Exception: # best effort: the timeout is what the caller must see + logger.debug("could not kill timed-out process %s", pid) + detail = f"no output for {http_timeout:g}s" + if pid is not None: + detail += f"; pid {pid} was asked to stop" + raise CommandTimeoutError( + f"Command did not finish within {timeout}s ({detail})." + ) from exc + + async def _unary( + self, + envd_url: str, + envd_token: EnvdTokenManager, + rpc_path: str, + payload: dict[str, Any], + *, + request_timeout: float | None = None, + ) -> dict[str, Any]: + """Unary envd call whose HTTP errors surface as :class:`EnvdRpcError`.""" + try: + return await self._http.envd_request( + envd_url, + rpc_path, + payload=payload, + envd_token=envd_token, + request_timeout=request_timeout, + ) + except httpx.HTTPStatusError as exc: + raise envd_status_error(exc, rpc_path) from exc + + async def list_processes( + self, + envd_url: str, + envd_token: EnvdTokenManager, + ) -> list[ProcessInfo]: + """List running processes in the sandbox. + + POST envd_url + /process.Process/List (Connect unary, 已实测验证) + """ + response = await self._unary(envd_url, envd_token, _LIST, {}) + + processes = response.get("processes", response.get("result", {}).get("processes", [])) + return [ProcessInfo.model_validate(p) for p in processes] + + async def connect_to_process( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + ) -> StreamReader[ProcessChunk]: + """Connect to an existing process and stream its output. + + POST envd_url + /process.Process/Connect (Connect streaming, 已实测验证) + """ + raw_stream = self._http.envd_stream( + envd_url, + _CONNECT, + payload={"process": _process_selector(pid)}, + envd_token=envd_token, + ) + return StreamReader(raw_stream, _parse_process_chunk) + + async def update( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + **kwargs: Any, + ) -> dict[str, Any]: + """Update process settings. + + POST envd_url + /process.Process/Update (Connect unary, 已实测验证) + """ + payload: dict[str, Any] = {"process": _process_selector(pid), **kwargs} + return await self._unary(envd_url, envd_token, _UPDATE, payload) + + async def send_input( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + data: str, + ) -> None: + """Send data to a process's stdin. + + POST envd_url + /process.Process/SendInput (Connect unary, 已实测验证) + + Body: ``{"process": {"pid": N}, "input": {"stdin": }}`` (``bytes`` fields are + base64 in protobuf-JSON). + """ + stdin = base64.b64encode(data.encode("utf-8")).decode("ascii") + await self._unary( + envd_url, + envd_token, + _SEND_INPUT, + {"process": _process_selector(pid), "input": {"stdin": stdin}}, + ) + + async def send_signal( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + signal: int = 15, + request_timeout: float | None = None, + ) -> None: + """Send a signal to a process (SIGTERM=15 or SIGKILL=9). + + POST envd_url + /process.Process/SendSignal (Connect unary, 已实测验证) + + Body: ``{"process": {"pid": N}, "signal": "SIGNAL_SIGTERM" | "SIGNAL_SIGKILL"}``. + envd's ``Signal`` enum has only those two values. + + The pid is envd's process id (``Commands.list()`` / the ``start`` event), which is not + necessarily the OS pid, so other signals cannot be emulated with ``kill``. + + Raises: + ValueError: *signal* is neither 15 nor 9. + """ + name = _SIGNAL_NAMES.get(signal) + if name is None: + raise ValueError( + f"envd can only deliver SIGTERM (15) and SIGKILL (9) to a process; got {signal!r}" + ) + await self._unary( + envd_url, + envd_token, + _SEND_SIGNAL, + {"process": _process_selector(pid), "signal": name}, + request_timeout=request_timeout, + ) + + # --- backward-compat aliases --- + + async def send_stdin( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + data: str, + ) -> None: + """Deprecated: use send_input(). Kept for backward compatibility.""" + await self.send_input(envd_url, envd_token, pid=pid, data=data) + + async def kill( + self, + envd_url: str, + envd_token: EnvdTokenManager, + *, + pid: int | str, + ) -> None: + """Deprecated: use send_signal(). Kept for backward compatibility.""" + await self.send_signal(envd_url, envd_token, pid=pid, signal=9) diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py new file mode 100644 index 0000000..95d40de --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py @@ -0,0 +1,234 @@ +"""Platform API protocol — sandbox lifecycle management (REST). + +All methods use standard HTTP REST with Authorization: Bearer authentication. +Endpoints are relative to the Platform API base URL. + +路径无 /api/v1 前缀(已实测验证)。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import httpx + +from agent_sandbox.models.errors import ( + QuotaExceededError, + SandboxCreationError, + SandboxNotFoundError, + TemplateNotFoundError, +) +from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo, SandboxStatus +from agent_sandbox.utils.logging import get_logger + +if TYPE_CHECKING: + from agent_sandbox.protocol.port_contract import PortAccess, PortContext + from agent_sandbox.transport.http import HttpClient + +logger = get_logger("protocol.sandbox") + + +class SandboxProtocol: + """Typed wrapper for Platform API sandbox operations.""" + + async def prepare_session(self, info: SandboxInfo) -> dict[str, Any]: + """Bind envd authentication only when this provider selected the E2B adapter.""" + from .auth import EnvdTokenManager + + return {"envd_token": EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id)} + + def create_port_access(self, context: PortContext) -> PortAccess: + from .ports import WirePortAccess + + return WirePortAccess(context) + + def __init__(self, http_client: HttpClient, *, unknown_if_absent: bool = False) -> None: + self._http = http_client + # A plugin provider speaks this wire format but is not the platform: + # an absent region/timeout stays "unknown" ("" and 0). + self._unknown_if_absent = unknown_if_absent + + def _info(self, data: Any) -> SandboxInfo: + if self._unknown_if_absent and isinstance(data, dict): + data = {"region": "", "timeout": 0, **data} + return SandboxInfo.model_validate(data) + + async def _sandbox_request( + self, + method: str, + sandbox_id: str, + suffix: str = "", + **kwargs: Any, + ) -> httpx.Response: + """Request ``/sandboxes/{id}{suffix}``; a 404 raises :class:`SandboxNotFoundError`.""" + try: + return await self._http.platform_request( + method, f"/sandboxes/{sandbox_id}{suffix}", **kwargs + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + raise SandboxNotFoundError(f"Sandbox '{sandbox_id}' was not found.") from exc + raise + + async def create( + self, + config: SandboxConfig, + *, + request_timeout: float | None = None, + ) -> SandboxInfo: + """Create a new sandbox. + + POST /sandboxes → 201(已实测验证) + + 请求体使用 camelCase: templateID, timeout, autoPause, metadata, envVars + 响应体使用 camelCase: sandboxID, templateID, envdAccessToken, envdVersion, clientID + + Args: + config: Sandbox creation payload. + request_timeout: Per-request HTTP timeout override in seconds for + this single create call. When *None*, the client-level + default (``http_timeout`` / ``SANDBOX_HTTP_TIMEOUT``) is used. + This is independent of ``config.timeout`` (the sandbox TTL). + """ + payload = config.to_create_payload() + + try: + # 已实测验证:路径无 /api/v1 前缀 + response = await self._http.platform_request( + "POST", "/sandboxes", json=payload, request_timeout=request_timeout + ) + data = response.json() + return self._info(data) + except httpx.HTTPStatusError as exc: + self._handle_create_error(exc) + raise # unreachable but satisfies type checker + + async def list( + self, + *, + status: SandboxStatus | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[SandboxInfo]: + """List sandboxes (v1). + + GET /sandboxes → 200(已实测验证) + """ + params: dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + params["status"] = status.value + + # 已实测验证:路径无 /api/v1 前缀 + response = await self._http.platform_request("GET", "/sandboxes", params=params) + data = response.json() + # API may return list directly or wrapped in a data field + items = data if isinstance(data, list) else data.get("data", data.get("sandboxes", [])) + return [self._info(item) for item in items] + + async def list_v2( + self, + *, + status: SandboxStatus | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[SandboxInfo]: # type: ignore[valid-type] + """List sandboxes (v2). + + GET /v2/sandboxes → 200(已实测验证) + """ + params: dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + params["status"] = status.value + + response = await self._http.platform_request("GET", "/v2/sandboxes", params=params) + data = response.json() + items = data if isinstance(data, list) else data.get("data", data.get("sandboxes", [])) + return [self._info(item) for item in items] + + async def get_info(self, sandbox_id: str) -> SandboxInfo: + """Get sandbox information. + + GET /sandboxes/{sandboxId} → 200(已实测验证) + """ + # 已实测验证 + response = await self._sandbox_request("GET", sandbox_id) + data = response.json() + return self._info(data) + + async def kill(self, sandbox_id: str) -> None: + """Kill/destroy a sandbox. + + DELETE /sandboxes/{sandbox_id} → 204(已实测验证) + """ + try: + # 已实测验证 + await self._http.platform_request("DELETE", f"/sandboxes/{sandbox_id}") + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + logger.warning("Sandbox %s not found (already killed?)", sandbox_id) + return + raise + + async def set_timeout(self, sandbox_id: str, timeout: int) -> None: + """Update sandbox timeout. + + POST /sandboxes/{sandbox_id}/timeout → 204(已实测验证) + """ + # 已实测验证:POST(非 PUT) + await self._sandbox_request("POST", sandbox_id, "/timeout", json={"timeout": timeout}) + + async def connect(self, sandbox_id: str) -> SandboxInfo: + """Connect to an existing sandbox (fetch current info). + + GET /sandboxes/{sandbox_id} → 200(已实测验证) + """ + # 已实测验证 + response = await self._sandbox_request("GET", sandbox_id) + data = response.json() + return self._info(data) + + async def is_running(self, sandbox_id: str) -> bool: + """Check if a sandbox is currently running. + + Calls get_info and checks status. + """ + try: + info = await self.get_info(sandbox_id) + return info.status == SandboxStatus.RUNNING + except Exception: + return False + + async def pause(self, sandbox_id: str) -> None: + """Pause a running sandbox. + + POST /sandboxes/{sandbox_id}/pause + # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 + """ + # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 + await self._sandbox_request("POST", sandbox_id, "/pause") + + async def resume(self, sandbox_id: str) -> None: + """Resume a paused sandbox. + + POST /sandboxes/{sandbox_id}/resume + # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 + """ + # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 + await self._sandbox_request("POST", sandbox_id, "/resume") + + def _handle_create_error(self, exc: httpx.HTTPStatusError) -> None: + """Convert HTTP errors to typed exceptions for create.""" + status = exc.response.status_code + try: + body = exc.response.json() + except Exception: + body = {} + + message = body.get("message", str(exc)) + + if status == 404: + raise TemplateNotFoundError(message) from exc + elif status == 429: + raise QuotaExceededError(message) from exc + elif status >= 400: + raise SandboxCreationError(message) from exc diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/session.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/session.py new file mode 100644 index 0000000..e5320a1 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/session.py @@ -0,0 +1,35 @@ +"""Session assembly for providers that explicitly select the shared E2B wire protocol.""" + +from __future__ import annotations + +from typing import Any + +from .code_interpreter import CodeInterpreterProtocol +from .filesystem import FilesystemProtocol +from .process import ProcessProtocol +from .sandbox import SandboxProtocol + + +def session_protocols(http: Any) -> dict[str, Any]: + return { + "sandbox_protocol": SandboxProtocol(http), + "process_protocol": ProcessProtocol(http), + "filesystem_protocol": FilesystemProtocol(http), + "code_interpreter_protocol": CodeInterpreterProtocol(http), + } + + +async def prepare_session(provider: Any, info: Any) -> dict[str, Any]: + from .auth import EnvdTokenManager + + options: dict[str, Any] = {} + if getattr(provider, "envd_allow_missing", False): + options = { + "allow_missing": True, + "basic_user": getattr(provider, "envd_basic_user", "root") or "root", + } + return { + "envd_token": EnvdTokenManager( + info.envd_access_token, sandbox_id=info.sandbox_id, **options + ) + } diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/terminal.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/terminal.py new file mode 100644 index 0000000..1a041c8 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/terminal.py @@ -0,0 +1,120 @@ +"""PTY terminal protocol — WebSocket bidirectional channel. + +Provides interactive terminal access to sandboxes via WebSocket. +Supports terminal resize, input/output streaming. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from agent_sandbox.transport.ws import WebSocketClient +from agent_sandbox.utils.logging import get_logger + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager + from agent_sandbox.transport.config import TransportConfig + +logger = get_logger("protocol.terminal") + + +class TerminalSession: + """Interactive PTY terminal session over WebSocket. + + Provides bidirectional communication with a pseudo-terminal + in the sandbox. + """ + + def __init__( + self, + ws_client: WebSocketClient, + ) -> None: + self._ws = ws_client + + @classmethod + async def create( + cls, + envd_url: str, + envd_token: EnvdTokenManager, + config: TransportConfig, + *, + cols: int = 80, + rows: int = 24, + shell: str = "/bin/bash", + ) -> TerminalSession: + """Create a new terminal session. + + Establishes WebSocket connection to the sandbox PTY endpoint. + """ + # Convert HTTP URL to WebSocket URL + ws_url = envd_url.replace("https://", "wss://").replace("http://", "ws://") + ws_url = f"{ws_url}/terminal" + + headers = { + **envd_token.get_headers(), + } + + ws_client = WebSocketClient( + ws_url, + config=config, + extra_headers=headers, + ) + await ws_client.connect() + + session = cls(ws_client) + + # Send initial resize + await session.resize(cols, rows) + + logger.debug("Terminal session created (%dx%d, shell=%s)", cols, rows, shell) + return session + + async def send(self, data: str) -> None: + """Send input to the terminal.""" + await self._ws.send(data) + + async def send_bytes(self, data: bytes) -> None: + """Send raw bytes to the terminal.""" + await self._ws.send(data) + + async def receive(self, timeout: float | None = None) -> str | bytes: + """Receive output from the terminal.""" + return await self._ws.receive(timeout=timeout) + + async def output_stream(self) -> AsyncIterator[str | bytes]: + """Iterate over terminal output.""" + async for msg in self._ws.recv_iter(): + yield msg + + async def resize(self, cols: int, rows: int) -> None: + """Resize the terminal. + + Sends a JSON control message to resize the PTY. + """ + control_msg = json.dumps( + { + "type": "resize", + "cols": cols, + "rows": rows, + } + ) + await self._ws.send(control_msg) + logger.debug("Terminal resized to %dx%d", cols, rows) + + @property + def is_connected(self) -> bool: + return self._ws.is_connected + + async def close(self) -> None: + """Close the terminal session.""" + await self._ws.close() + logger.debug("Terminal session closed") + + async def __aenter__(self) -> TerminalSession: + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/url.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/url.py new file mode 100644 index 0000000..e1a74ed --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/url.py @@ -0,0 +1,8 @@ +"""Address convention of the explicitly selected E2B/envd wire adapter.""" + +ENVD_PORT = 49983 + + +def envd_url(sandbox_id: str, domain: str) -> str: + """Build an envd address; only adapters opting into this protocol use it.""" + return f"https://{ENVD_PORT}-{sandbox_id}.{domain}" diff --git a/client-sdk/src/agent_sandbox/transport/__init__.py b/client-sdk/src/agent_sandbox/transport/__init__.py index c6e2aef..279b87f 100644 --- a/client-sdk/src/agent_sandbox/transport/__init__.py +++ b/client-sdk/src/agent_sandbox/transport/__init__.py @@ -1,7 +1,28 @@ -"""L1 Transport and Authentication layer.""" +"""Transport contracts and lazy compatibility exports.""" -from agent_sandbox.transport.auth import AkSkAuth, ApiKeyAuth, AuthProvider -from agent_sandbox.transport.config import TransportConfig -from agent_sandbox.transport.http import HttpClient +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from agent_sandbox.providers.alicloud_express.auth import AkSkAuth as AkSkAuth + from agent_sandbox.transport.auth_contract import ApiKeyAuth as ApiKeyAuth + from agent_sandbox.transport.auth_contract import AuthProvider as AuthProvider + from agent_sandbox.transport.config import TransportConfig as TransportConfig + from agent_sandbox.transport.http import HttpClient as HttpClient __all__ = ["TransportConfig", "HttpClient", "ApiKeyAuth", "AkSkAuth", "AuthProvider"] + + +def __getattr__(name: str) -> Any: + modules = { + "TransportConfig": "agent_sandbox.transport.config", + "HttpClient": "agent_sandbox.transport.http", + "ApiKeyAuth": "agent_sandbox.transport.auth_contract", + "AuthProvider": "agent_sandbox.transport.auth_contract", + "AkSkAuth": "agent_sandbox.providers.alicloud_express.auth", + } + if name not in modules: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(import_module(modules[name]), name) diff --git a/client-sdk/src/agent_sandbox/transport/auth.py b/client-sdk/src/agent_sandbox/transport/auth.py index 9c30af7..2df242e 100644 --- a/client-sdk/src/agent_sandbox/transport/auth.py +++ b/client-sdk/src/agent_sandbox/transport/auth.py @@ -1,267 +1,43 @@ -"""Authentication providers for Platform API and Sandbox envd API. +"""Compatibility aliases; Express owns platform authentication.""" -Sandbox requests use an explicitly configured API key as an Authorization Bearer. -AK/SK is used separately by official control-plane APIs. AkSkAuth remains an -explicit custom-exchange adapter; no default credential-resource creation occurs. +import sys as _sys +from importlib import import_module as _import_module -After sandbox creation, envd API uses envdAccessToken from create response, -sent via multiple headers (X-Access-Token, E2b-Sandbox-Id, etc. 已实测验证). -""" - -from __future__ import annotations - -import base64 -import time -from typing import Any, Protocol - -from agent_sandbox.models.errors import ( - InvalidAPIKeyError, - InvalidCredentialsError, - TokenExchangeError, - TokenExpiredError, +from agent_sandbox.providers.alicloud_express.auth import ( + _ENVD_BASIC_AUTH as _ENVD_BASIC_AUTH, +) +from agent_sandbox.providers.alicloud_express.auth import ( + ENVD_AUTH_HEADER as ENVD_AUTH_HEADER, +) +from agent_sandbox.providers.alicloud_express.auth import ( + ENVD_SANDBOX_ID_HEADER as ENVD_SANDBOX_ID_HEADER, +) +from agent_sandbox.providers.alicloud_express.auth import ( + ENVD_SANDBOX_PORT_HEADER as ENVD_SANDBOX_PORT_HEADER, +) +from agent_sandbox.providers.alicloud_express.auth import ( + PLATFORM_AUTH_HEADER as PLATFORM_AUTH_HEADER, +) +from agent_sandbox.providers.alicloud_express.auth import ( + AkSkAuth as AkSkAuth, +) +from agent_sandbox.providers.alicloud_express.auth import ( + ApiKeyAuth as ApiKeyAuth, +) +from agent_sandbox.providers.alicloud_express.auth import ( + AuthProvider as AuthProvider, +) +from agent_sandbox.providers.alicloud_express.auth import ( + EnvdTokenManager as EnvdTokenManager, +) +from agent_sandbox.providers.alicloud_express.auth import ( + build_envd_headers as build_envd_headers, +) +from agent_sandbox.providers.alicloud_express.auth import ( + create_auth_provider as create_auth_provider, +) +from agent_sandbox.providers.alicloud_express.auth import ( + logger as logger, ) -from agent_sandbox.utils.logging import get_logger - -logger = get_logger("transport.auth") - -# Header names(已实测验证) -PLATFORM_AUTH_HEADER = "Authorization" # 已实测验证:Bearer token 认证 -ENVD_AUTH_HEADER = "X-Access-Token" -ENVD_SANDBOX_ID_HEADER = "E2b-Sandbox-Id" -ENVD_SANDBOX_PORT_HEADER = "E2b-Sandbox-Port" -_ENVD_BASIC_AUTH = base64.b64encode(b"user:").decode("ascii") # 已实测验证 - - -class AuthProvider(Protocol): - """Protocol for authentication providers.""" - - async def get_headers(self) -> dict[str, str]: - """Return authentication headers for Platform API requests.""" - ... - - async def refresh(self) -> None: - """Force refresh credentials if applicable.""" - ... - - -class ApiKeyAuth: - """API Key authentication for Platform API. - - Uses ABX_SANDBOX_API_KEY environment variable or explicit api_key parameter. - Sends an Authorization Bearer header on Platform API requests. - """ - - def __init__(self, api_key: str) -> None: - if not api_key or not api_key.strip(): - raise InvalidAPIKeyError("API key cannot be empty") - self._api_key = api_key.strip() - - async def get_headers(self) -> dict[str, str]: - """Return Authorization: Bearer header (已实测验证).""" - return {PLATFORM_AUTH_HEADER: f"Bearer {self._api_key}"} - - async def refresh(self) -> None: - """No-op for API key auth.""" - pass - - -class AkSkAuth: - """Legacy adapter for an explicitly configured custom token exchange. - - No Alibaba STS/API-key creation endpoint is selected automatically. Callers - must supply their own exchange and set the cache lifetime to that service's - documented token validity. Cached credentials are never persisted. - """ - - TOKEN_TTL = 3600 # Legacy custom-exchange cache policy, not a platform TTL. - REFRESH_BEFORE_EXPIRY = 300 - - def __init__(self, access_key_id: str, access_key_secret: str) -> None: - if not access_key_id or not access_key_secret: - raise InvalidCredentialsError( - "AccessKey ID and Secret cannot be empty", - suggestion=( - "Set ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET" - " environment variables." - ), - ) - self._ak = access_key_id.strip() - self._sk = access_key_secret.strip() - self._cached_token: str | None = None - self._token_expires_at: float = 0.0 - self._exchange_func: Any = None # Set by transport layer - - def _is_token_valid(self) -> bool: - """Check if cached token is still valid (with refresh buffer).""" - if self._cached_token is None: - return False - return time.time() < (self._token_expires_at - self.REFRESH_BEFORE_EXPIRY) - - async def _exchange_token(self) -> str: - """Exchange AK/SK for a temporary API key. - - The caller supplies the exchange implementation and owns its lifecycle. - There is no default network exchange. - """ - if self._exchange_func is None: - raise InvalidCredentialsError( - "Token exchange function not configured. " - "Set an explicit custom callback with set_exchange_func(), " - "or use ApiKeyAuth for sandbox requests.", - ) - token = await self._exchange_func(self._ak, self._sk) - if not isinstance(token, str) or not token.strip(): - raise TokenExchangeError( - "Custom token exchange must return a non-empty token string.", - suggestion="Check the custom exchange contract and token lifetime.", - ) - self._cached_token = token.strip() - self._token_expires_at = time.time() + self.TOKEN_TTL - logger.debug("AK/SK token exchanged, expires at %.0f", self._token_expires_at) - return self._cached_token - - async def get_headers(self) -> dict[str, str]: - """Return Authorization: Bearer header with exchanged token.""" - if not self._is_token_valid(): - await self._exchange_token() - if self._cached_token is None: - raise TokenExpiredError("Failed to obtain API token via AK/SK exchange") - return {PLATFORM_AUTH_HEADER: f"Bearer {self._cached_token}"} - - async def refresh(self) -> None: - """Force refresh the exchanged token.""" - if self._exchange_func is None: - raise InvalidCredentialsError( - "AK/SK token exchange function not configured.", - suggestion=( - "Set an explicit custom callback with set_exchange_func(), " - "or configure ABX_SANDBOX_API_KEY for sandbox requests." - ), - ) - self._cached_token = None - self._token_expires_at = 0.0 - await self._exchange_token() - - def set_exchange_func(self, func: Any) -> None: - """Set the token exchange function (called by transport layer).""" - self._exchange_func = func - - -class EnvdTokenManager: - """Manages envdAccessToken for sandbox envd API requests. - - Each sandbox has its own envdAccessToken returned during creation. - envd 认证需要四个 header(已实测验证): - - X-Access-Token: {envd_access_token} - - E2b-Sandbox-Id: {sandbox_id} - - E2b-Sandbox-Port: 49983 - - Authorization: Basic {base64("user:")} - """ - - def __init__( - self, - token: str | None = None, - sandbox_id: str | None = None, - *, - allow_missing: bool = False, - basic_user: str = "user", - ) -> None: - self._token = token - self._sandbox_id = sandbox_id - self._allow_missing = allow_missing - self._basic_user = basic_user or "user" - - @property - def token(self) -> str | None: - return self._token - - @property - def sandbox_id(self) -> str | None: - return self._sandbox_id - - def set_token(self, token: str, sandbox_id: str | None = None) -> None: - """Set the envd access token (from sandbox create response).""" - self._token = token - if sandbox_id is not None: - self._sandbox_id = sandbox_id - logger.debug("envdAccessToken set for sandbox %s", self._sandbox_id) - - def get_headers(self) -> dict[str, str]: - """Return all envd auth headers (已实测验证). - - Returns dict with X-Access-Token, E2b-Sandbox-Id, E2b-Sandbox-Port, - and Authorization (Basic) headers. - """ - basic = base64.b64encode(f"{self._basic_user}:".encode()).decode("ascii") - headers = {"Authorization": f"Basic {basic}"} - if self._token: - headers[ENVD_AUTH_HEADER] = self._token - elif not self._allow_missing: - raise TokenExpiredError( - "envdAccessToken not available. Sandbox may not be created or connected.", - suggestion="Create or connect to a sandbox first.", - ) - if self._sandbox_id is not None: - headers[ENVD_SANDBOX_ID_HEADER] = self._sandbox_id - headers[ENVD_SANDBOX_PORT_HEADER] = "49983" - return headers - - def clear(self) -> None: - """Clear the cached token.""" - self._token = None - self._sandbox_id = None - - -def build_envd_headers(sandbox_id: str, envd_access_token: str) -> dict[str, str]: - """Build envd authentication headers from sandbox_id and envd_access_token (已实测验证). - - Args: - sandbox_id: Sandbox ID (e.g., "sbx-xxxx"). - envd_access_token: The envdAccessToken from sandbox create response. - - Returns: - Dict with all required envd auth headers. - """ - return { - ENVD_AUTH_HEADER: envd_access_token, - ENVD_SANDBOX_ID_HEADER: sandbox_id, - ENVD_SANDBOX_PORT_HEADER: "49983", - "Authorization": f"Basic {_ENVD_BASIC_AUTH}", - } - - -def create_auth_provider( - api_key: str | None = None, - access_key_id: str | None = None, - access_key_secret: str | None = None, - *, - region: str = "cn-hangzhou", -) -> AuthProvider: - """Create API-key authentication for sandbox data-plane requests. - - Explicit api_key takes precedence over ABX_SANDBOX_API_KEY. The AK/SK and - region arguments remain accepted for signature compatibility, but cannot - select an automatic key-creation/exchange route. Official template and image - control-plane operations use their own AK/SK clients. - """ - if api_key: - logger.debug("Using API Key authentication") - return ApiKeyAuth(api_key) - - import os - - sandbox_key = os.environ.get("ABX_SANDBOX_API_KEY") - if sandbox_key: - logger.debug("Using API Key authentication (via ABX_SANDBOX_API_KEY)") - return ApiKeyAuth(sandbox_key) - - if access_key_id and access_key_secret: - raise InvalidAPIKeyError( - "A sandbox API Key is required; AK/SK credentials are for control-plane operations.", - suggestion="Configure ABX_SANDBOX_API_KEY or pass api_key explicitly.", - ) - raise InvalidAPIKeyError( - "No authentication credentials provided", - suggestion=("Configure ABX_SANDBOX_API_KEY or pass api_key explicitly."), - ) +_sys.modules[__name__] = _import_module("agent_sandbox.providers.alicloud_express.auth") diff --git a/client-sdk/src/agent_sandbox/transport/auth_contract.py b/client-sdk/src/agent_sandbox/transport/auth_contract.py new file mode 100644 index 0000000..fa6fc52 --- /dev/null +++ b/client-sdk/src/agent_sandbox/transport/auth_contract.py @@ -0,0 +1,71 @@ +"""Cloud-independent authentication contracts and explicit bearer credentials.""" + +from __future__ import annotations + +from typing import Protocol + +from agent_sandbox.models.errors import InvalidAPIKeyError + +PLATFORM_AUTH_HEADER = "Authorization" + + +class AuthProvider(Protocol): + """Protocol for authentication providers.""" + + async def get_headers(self) -> dict[str, str]: + """Return authentication headers for Platform API requests.""" + ... + + async def refresh(self) -> None: + """Force refresh credentials if applicable.""" + ... + + +class ApiKeyAuth: + """API Key authentication for Platform API. + + Uses ABX_SANDBOX_API_KEY environment variable or explicit api_key parameter. + Sends an Authorization Bearer header on Platform API requests. + """ + + def __init__(self, api_key: str) -> None: + if not api_key or not api_key.strip(): + raise InvalidAPIKeyError("API key cannot be empty") + self._api_key = api_key.strip() + + async def get_headers(self) -> dict[str, str]: + """Return Authorization: Bearer header (已实测验证).""" + return {PLATFORM_AUTH_HEADER: f"Bearer {self._api_key}"} + + async def refresh(self) -> None: + """No-op for API key auth.""" + pass + + +class SessionAuth: + """Opaque session credentials. A backend chooses how they become wire headers.""" + + def __init__(self, token: str | None, sandbox_id: str | None) -> None: + self.token, self.sandbox_id = token, sandbox_id + + def get_headers(self) -> dict[str, str]: + return {} + + def set_token(self, token: str, sandbox_id: str | None = None) -> None: + self.token = token + if sandbox_id is not None: + self.sandbox_id = sandbox_id + + def clear(self) -> None: + self.token = None + self.sandbox_id = None + + +class DataPlaneAuth(Protocol): + @property + def token(self) -> str | None: ... + @property + def sandbox_id(self) -> str | None: ... + def get_headers(self) -> dict[str, str]: ... + def set_token(self, token: str, sandbox_id: str | None = None) -> None: ... + def clear(self) -> None: ... diff --git a/client-sdk/src/agent_sandbox/transport/config.py b/client-sdk/src/agent_sandbox/transport/config.py index 323e462..6b88ccf 100644 --- a/client-sdk/src/agent_sandbox/transport/config.py +++ b/client-sdk/src/agent_sandbox/transport/config.py @@ -25,12 +25,17 @@ from pathlib import Path from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from agent_sandbox._provider_schema import ProviderSchema as ProviderSchema from agent_sandbox._provider_schema import resolve_provider_schema +from agent_sandbox.utils.bundled_config import ( + BUNDLED_CONFIGS, + BUNDLED_KEYS, + DEFAULT_CONFIG, + bundled_config, +) from agent_sandbox.utils.config_keys import ( - ALICLOUD_EXPRESS_KEYS, BUILTIN_PROVIDER_ID, CORE_KEYS, GENERIC_ENDPOINT_KEYS, @@ -60,13 +65,11 @@ # Module-level cache _cached_config: TransportConfig | None = None -DEFAULT_BASE_URL = "https://api.cn-hangzhou.e2b.fc.aliyuncs.com" -DEFAULT_DOMAIN = "cn-hangzhou.e2b.fc.aliyuncs.com" - -_BUILTIN_DERIVE: dict[str, str] = { - "sandbox_base_url": "https://api.{region}.e2b.fc.aliyuncs.com", - "sandbox_domain": "{region}.e2b.fc.aliyuncs.com", -} +DEFAULT_BASE_URL = DEFAULT_CONFIG["endpoint_defaults"]["sandbox_base_url"] +DEFAULT_DOMAIN = DEFAULT_CONFIG["endpoint_defaults"]["sandbox_domain"] +_DEFAULT_REGION = str( + next((key.default for key in DEFAULT_CONFIG["keys"] if key.key == "region"), "") +) class TransportConfig(BaseModel): @@ -80,17 +83,15 @@ class TransportConfig(BaseModel): # API endpoints sandbox_base_url: str = Field(default=DEFAULT_BASE_URL, description="Platform API URL") sandbox_domain: str = Field(default=DEFAULT_DOMAIN, description="Data-plane domain") - region: str = "cn-hangzhou" + region: str = _DEFAULT_REGION # Authentication sandbox_api_key: str | None = Field( default=None, description="Sandbox API key (ABX_SANDBOX_API_KEY)", repr=False ) - access_key_id: str | None = Field( - default=None, description="Alibaba Cloud AccessKey ID (ALICLOUD_ACCESS_KEY_ID)" - ) + access_key_id: str | None = Field(default=None, description="Provider AccessKey ID") access_key_secret: str | None = Field( - default=None, description="Alibaba Cloud SK (ALICLOUD_ACCESS_KEY_SECRET)", repr=False + default=None, description="Provider AccessKey Secret", repr=False ) # Provider-scoped values of non-built-in providers (and extra built-in keys such as ``acr_*``) @@ -114,6 +115,17 @@ class TransportConfig(BaseModel): model_config = {"extra": "ignore"} + @model_validator(mode="before") + @classmethod + def selected_provider_defaults(cls, value: Any) -> Any: + """An explicit external provider never inherits the distribution's cloud endpoints.""" + if not isinstance(value, dict): + return value + metadata = bundled_config(str(value.get("provider", BUILTIN_PROVIDER_ID))) + if metadata is None: + return {"sandbox_base_url": "", "sandbox_domain": "", "region": "", **value} + return value + def build_envd_url(self, sandbox_id: str) -> str: """构建 envd 数据平面 URL(已实测验证)。 @@ -199,19 +211,28 @@ def project_dotenv_value(names: tuple[str, ...]) -> str | None: # --------------------------------------------------------------------------- -def _builtin_schema() -> ProviderSchema: +def _metadata_schema(metadata: dict[str, Any]) -> ProviderSchema: return ProviderSchema( - BUILTIN_PROVIDER_ID, - ALICLOUD_EXPRESS_KEYS, - frozenset(GENERIC_ENDPOINT_KEYS), - _BUILTIN_DERIVE, + metadata["id"], + metadata["keys"], + metadata["inherit"], + metadata["derive"], + metadata["endpoint_defaults"], + legacy_fields=frozenset(metadata.get("legacy_fields", ())), + omit_default_options=bool(metadata.get("omit_default_options", False)), ) +def _builtin_schema() -> ProviderSchema: + """Compatibility view of the distribution default metadata.""" + return _metadata_schema(DEFAULT_CONFIG) + + def provider_schema(provider_id: str) -> ProviderSchema: - """Schema of *provider_id* (built-in or registered/installed).""" - if provider_id == BUILTIN_PROVIDER_ID: - return _builtin_schema() + """Configuration is supplied by bundled metadata or the selected provider.""" + metadata = bundled_config(provider_id) + if metadata is not None: + return _metadata_schema(metadata) return resolve_provider_schema(provider_id) @@ -251,7 +272,7 @@ def resolve_all( key, sources, scoped_for=scoped, - use_default=schema.builtin or key.key not in GENERIC_ENDPOINT_KEYS, + use_default=key.key not in GENERIC_ENDPOINT_KEYS, ) if res is None and key.key in schema.endpoint_defaults: res = Resolved(key.key, schema.endpoint_defaults[key.key], "default", "") @@ -275,7 +296,7 @@ def _apply_derive(sources: Sources, schema: ProviderSchema, out: dict[str, Resol """ base_key = get_key("sandbox_base_url") assert base_key is not None - if "sandbox_base_url" in schema.inherit or schema.builtin: + if "sandbox_base_url" in schema.inherit: explicit = resolve_key( base_key, Sources(overrides=sources.overrides, environ=sources.environ), @@ -345,19 +366,23 @@ def load_config(**overrides: Any) -> TransportConfig: ) field_names = set(TransportConfig.model_fields) - {"provider", "provider_options"} - field_scoped = {"region", "access_key_id", "access_key_secret"} if schema.builtin else set() + field_scoped = schema.legacy_fields values: dict[str, Any] = {"provider": schema.provider_id} opts: dict[str, Any] = {} for key_name, res in resolved.items(): if key_name in scoped: if key_name in field_scoped: values[key_name] = res.value - elif not schema.builtin or res.layer != "default": + elif not schema.omit_default_options or res.layer != "default": opts[key_name] = res.value elif key_name in field_names: values[key_name] = res.value values["provider_options"] = {**opts, **{k: v for k, v in options.items() if k in scoped}} + # A provider with no endpoint metadata never inherits the distribution default. + values.setdefault("sandbox_base_url", schema.endpoint_defaults.get("sandbox_base_url", "")) + values.setdefault("sandbox_domain", schema.endpoint_defaults.get("sandbox_domain", "")) + values.setdefault("region", "") config = TransportConfig(**values) if not overrides: @@ -427,7 +452,7 @@ def http_timeout_configured() -> bool: def _legacy_env_index() -> dict[str, tuple[ConfigKey, str]]: idx: dict[str, tuple[ConfigKey, str]] = {} - for key in (*CORE_KEYS, *ALICLOUD_EXPRESS_KEYS): + for key in (*CORE_KEYS, *BUNDLED_KEYS): for a in key.aliases: if a.surface == "env" and a.kind == "legacy": idx[a.name] = (key, a.kind) @@ -436,7 +461,7 @@ def _legacy_env_index() -> dict[str, tuple[ConfigKey, str]]: def _toml_legacy_index() -> dict[str, ConfigKey]: idx: dict[str, ConfigKey] = {} - for key in (*CORE_KEYS, *ALICLOUD_EXPRESS_KEYS): + for key in (*CORE_KEYS, *BUNDLED_KEYS): for a in key.aliases: if a.surface in ("config", "kwarg") and a.kind == "legacy": idx.setdefault(a.name, key) @@ -526,20 +551,21 @@ def plan_migration_data(toml: dict[str, Any], env: dict[str, str]) -> list[dict[ } ) providers = toml.get("providers") - prov_table = providers.get(BUILTIN_PROVIDER_ID) if isinstance(providers, dict) else None - for key in ALICLOUD_EXPRESS_KEYS: - p2 = key.storage_phase2 - if key.key in transport and p2 is not None and p2.file == "toml": - exists = isinstance(prov_table, dict) and key.key in prov_table - plan.append( - { - "file": "toml", - "action": "drop" if exists else "rename", - "from": f"transport.{key.key}", - "to": f"providers.{BUILTIN_PROVIDER_ID}.{key.key}", - } - ) - for key in (*CORE_KEYS, *ALICLOUD_EXPRESS_KEYS): + for metadata in BUNDLED_CONFIGS: + prov_table = providers.get(metadata["id"]) if isinstance(providers, dict) else None + for key in metadata["keys"]: + p2 = key.storage_phase2 + if key.key in transport and p2 is not None and p2.file == "toml": + exists = isinstance(prov_table, dict) and key.key in prov_table + plan.append( + { + "file": "toml", + "action": "drop" if exists else "rename", + "from": f"transport.{key.key}", + "to": f"providers.{metadata['id']}.{key.key}", + } + ) + for key in (*CORE_KEYS, *BUNDLED_KEYS): p2 = key.storage_phase2 target_env = p2.name if p2 and p2.file == "env" else key.env # Native names (OPENAI_*, DASHSCOPE_*) are user-owned: only the names that diff --git a/client-sdk/src/agent_sandbox/transport/http.py b/client-sdk/src/agent_sandbox/transport/http.py index 949cab9..c602aff 100644 --- a/client-sdk/src/agent_sandbox/transport/http.py +++ b/client-sdk/src/agent_sandbox/transport/http.py @@ -21,7 +21,6 @@ InvalidAPIKeyError, InvalidCredentialsError, ) -from agent_sandbox.transport.auth import AkSkAuth from agent_sandbox.transport.codec import ( CONNECT_CONTENT_TYPE, ConnectCodec, @@ -33,7 +32,8 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator - from agent_sandbox.transport.auth import AuthProvider, EnvdTokenManager + from agent_sandbox.transport.auth_contract import AuthProvider + from agent_sandbox.transport.auth_contract import DataPlaneAuth as EnvdTokenManager from agent_sandbox.transport.config import TransportConfig logger = get_logger("transport.http") @@ -353,10 +353,11 @@ async def platform_request( def _authentication_error(self, path: str) -> InvalidAPIKeyError | InvalidCredentialsError: """The SDK error for a Platform API 401 (the exception text never includes the URL).""" - if isinstance(self._auth, AkSkAuth): - return InvalidCredentialsError( - f"The Platform API rejected the AK/SK credentials (HTTP 401) for {path}." - ) + handler = getattr(self._auth, "authentication_error", None) + if callable(handler): + error = handler(path) + if isinstance(error, (InvalidAPIKeyError, InvalidCredentialsError)): + return error return InvalidAPIKeyError( f"The Platform API rejected the API key (HTTP 401) for {path}: " "it is invalid, revoked or for another region." diff --git a/client-sdk/src/agent_sandbox/utils/bundled_config.py b/client-sdk/src/agent_sandbox/utils/bundled_config.py new file mode 100644 index 0000000..5b16560 --- /dev/null +++ b/client-sdk/src/agent_sandbox/utils/bundled_config.py @@ -0,0 +1,32 @@ +"""Pure Express configuration metadata. No SDK, engine or cloud calls.""" + +from typing import Any + +from agent_sandbox.utils.config_keys import ( + ALICLOUD_EXPRESS_KEYS, + BUILTIN_PROVIDER_ID, + GENERIC_ENDPOINT_KEYS, +) + +DEFAULT_CONFIG: dict[str, Any] = { + "id": BUILTIN_PROVIDER_ID, + "keys": ALICLOUD_EXPRESS_KEYS, + "inherit": frozenset(GENERIC_ENDPOINT_KEYS), + "derive": { + "sandbox_base_url": "https://api.{region}.e2b.fc.aliyuncs.com", + "sandbox_domain": "{region}.e2b.fc.aliyuncs.com", + }, + "endpoint_defaults": { + "sandbox_base_url": "https://api.cn-hangzhou.e2b.fc.aliyuncs.com", + "sandbox_domain": "cn-hangzhou.e2b.fc.aliyuncs.com", + }, + "legacy_fields": frozenset({"region", "access_key_id", "access_key_secret"}), + "omit_default_options": True, +} + +BUNDLED_CONFIGS: tuple[dict[str, Any], ...] = (DEFAULT_CONFIG,) +BUNDLED_KEYS = tuple(key for entry in BUNDLED_CONFIGS for key in entry["keys"]) + + +def bundled_config(provider_id: str) -> dict[str, Any] | None: + return next((entry for entry in BUNDLED_CONFIGS if entry["id"] == provider_id), None) diff --git a/client-sdk/tests/test_api/conftest.py b/client-sdk/tests/test_api/conftest.py index 4695a9f..9913843 100644 --- a/client-sdk/tests/test_api/conftest.py +++ b/client-sdk/tests/test_api/conftest.py @@ -132,6 +132,7 @@ def mock_process_protocol() -> AsyncMock: def mock_filesystem_protocol() -> AsyncMock: """Mock FilesystemProtocol with sensible defaults.""" proto = AsyncMock(spec=FilesystemProtocol) + proto.transfer_url.side_effect = FilesystemProtocol(None).transfer_url proto.read_text.return_value = "file content" proto.read.return_value = b"file bytes" proto.write.return_value = None diff --git a/client-sdk/tests/test_api/server_e2e/_live.py b/client-sdk/tests/test_api/server_e2e/_live.py index b66db28..6e47384 100644 --- a/client-sdk/tests/test_api/server_e2e/_live.py +++ b/client-sdk/tests/test_api/server_e2e/_live.py @@ -12,6 +12,7 @@ pytest.importorskip("agent_sandbox.server", reason="needs the agent-sandbox-server distribution") from agent_sandbox.server.app import SandboxServer # noqa: E402 +from agent_sandbox.server.endpoints import CapabilityGroup # noqa: E402 from agent_sandbox.server.registry import CommandRegistry # noqa: E402 @@ -47,13 +48,23 @@ def __init__(self, **kwargs: Any) -> None: self.server = SandboxServer(host="127.0.0.1", registry=self.registry, **kwargs) self.thread = threading.Thread(target=self.server.serve, args=(self.port,), daemon=True) self.thread.start() - deadline = time.time() + 5 - while time.time() < deadline: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + # A network intermediary may accept TCP before our listener exists. + # Wait for actual server/thread startup before treating a socket as ready. + pty = self.server._pty_thread + needs_pty = self.server.policy_engine.group_enabled(CapabilityGroup.TERMINAL) + if self.server._httpd is None or (needs_pty and (pty is None or pty.ident is None)): + time.sleep(0.01) + continue try: with socket.create_connection(("127.0.0.1", self.port), timeout=0.2): break except OSError: time.sleep(0.02) + else: + self.server.shutdown() + raise RuntimeError("local client-test server did not start within 5 seconds") self._closed = False @property diff --git a/client-sdk/tests/test_api/test_capability.py b/client-sdk/tests/test_api/test_capability.py index e5305ca..b35eeb2 100644 --- a/client-sdk/tests/test_api/test_capability.py +++ b/client-sdk/tests/test_api/test_capability.py @@ -162,7 +162,7 @@ def test_returns_effective_set(self) -> None: resolved = ResolvedCapabilities(capabilities={"shell", "files"}) sb = Sandbox( info=info, - config=MagicMock(), + config=MagicMock(provider="alicloud-express"), http_client=AsyncMock(), auth=AsyncMock(), envd_token=EnvdTokenManager(TEST_ENVD_TOKEN), @@ -176,7 +176,7 @@ def test_default_when_no_resolved(self) -> None: info = make_sandbox_info() sb = Sandbox( info=info, - config=MagicMock(), + config=MagicMock(provider="alicloud-express"), http_client=AsyncMock(), auth=AsyncMock(), envd_token=EnvdTokenManager(TEST_ENVD_TOKEN), diff --git a/client-sdk/tests/test_api/test_files.py b/client-sdk/tests/test_api/test_files.py index 4547a97..f23569b 100644 --- a/client-sdk/tests/test_api/test_files.py +++ b/client-sdk/tests/test_api/test_files.py @@ -260,3 +260,24 @@ def test_exists_sync_exists(self) -> None: def test_remove_sync_exists(self) -> None: assert hasattr(FilesModule, "remove_sync") + + +@pytest.mark.asyncio +async def test_transfer_urls_require_an_explicit_filesystem_adapter(): + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + + files = FilesModule("https://foreign.example/runtime", None, object(), provider_id="foreign") + with pytest.raises(ProviderFeatureNotSupportedError): + await files.upload_url("/private") + with pytest.raises(ProviderFeatureNotSupportedError): + await files.download_url("/private") + + class CustomFiles: + async def transfer_url(self, url, token, *, path, operation): + return f"https://foreign.example/{operation}/opaque" + + files = FilesModule( + "https://foreign.example/runtime", None, CustomFiles(), provider_id="foreign" + ) + assert await files.upload_url("/private") == "https://foreign.example/upload/opaque" + assert await files.download_url("/private") == "https://foreign.example/download/opaque" diff --git a/client-sdk/tests/test_api/test_lifecycle_without_connect.py b/client-sdk/tests/test_api/test_lifecycle_without_connect.py index 96112b2..6c42cde 100644 --- a/client-sdk/tests/test_api/test_lifecycle_without_connect.py +++ b/client-sdk/tests/test_api/test_lifecycle_without_connect.py @@ -73,8 +73,15 @@ async def test_builtin_direct_lifecycle_closes_http_without_connect(operation, f if fail: target.side_effect = RuntimeError("operation failed") with ( - patch("agent_sandbox.api.sandbox._select_provider", return_value=(None, "existing")), - patch("agent_sandbox.api.sandbox._build_infra", return_value=(None, None, http, protocol)), + patch( + "agent_sandbox.api.sandbox._select_provider", + return_value=(SimpleNamespace(id="alicloud-express"), "existing"), + ), + patch( + "agent_sandbox.api.sandbox._provider_infra", + new_callable=AsyncMock, + return_value=SimpleNamespace(sandbox_protocol=protocol, close=http.close), + ), ): async def run(): @@ -105,8 +112,15 @@ def test_sync_classmethod_aliases_dispatch_and_close(operation, fail): target.side_effect = RuntimeError("operation failed") http = AsyncMock() with ( - patch("agent_sandbox.api.sandbox._select_provider", return_value=(None, "existing")), - patch("agent_sandbox.api.sandbox._build_infra", return_value=(None, None, http, protocol)), + patch( + "agent_sandbox.api.sandbox._select_provider", + return_value=(SimpleNamespace(id="alicloud-express"), "existing"), + ), + patch( + "agent_sandbox.api.sandbox._provider_infra", + new_callable=AsyncMock, + return_value=SimpleNamespace(sandbox_protocol=protocol, close=http.close), + ), ): def run(): diff --git a/client-sdk/tests/test_api/test_sandbox.py b/client-sdk/tests/test_api/test_sandbox.py index adde51e..ff2dcf0 100644 --- a/client-sdk/tests/test_api/test_sandbox.py +++ b/client-sdk/tests/test_api/test_sandbox.py @@ -66,9 +66,11 @@ async def test_create_returns_sandbox(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_load_config, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_create_auth, - patch("agent_sandbox.api.sandbox.HttpClient") as mock_http_cls, - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_proto_cls, + patch( + "agent_sandbox.providers.alicloud_express.auth.create_auth_provider" + ) as mock_create_auth, + patch("agent_sandbox.transport.http.HttpClient") as mock_http_cls, + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(api_key=TEST_API_KEY) mock_create_auth.return_value = mock_auth @@ -91,9 +93,11 @@ async def test_create_passes_config(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_load_config, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_create_auth, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_proto_cls, + patch( + "agent_sandbox.providers.alicloud_express.auth.create_auth_provider" + ) as mock_create_auth, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -121,9 +125,11 @@ async def test_create_default_request_timeout_is_none(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_load_config, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_create_auth, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_proto_cls, + patch( + "agent_sandbox.providers.alicloud_express.auth.create_auth_provider" + ) as mock_create_auth, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -142,9 +148,11 @@ async def test_create_explicit_request_timeout_forwarded(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_load_config, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_create_auth, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_proto_cls, + patch( + "agent_sandbox.providers.alicloud_express.auth.create_auth_provider" + ) as mock_create_auth, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -182,9 +190,11 @@ async def test_connect_returns_sandbox(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_load_config, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_create_auth, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_proto_cls, + patch( + "agent_sandbox.providers.alicloud_express.auth.create_auth_provider" + ) as mock_create_auth, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -431,15 +441,22 @@ async def _create(config: SandboxConfig, **kwargs: Any) -> SandboxInfo: protocol.create.side_effect = _create - def _select(*_args: Any, **_kwargs: Any) -> tuple[None, None]: - return None, None + from agent_sandbox.providers.base import ProviderInfra + from agent_sandbox.providers.registry import get_provider + from agent_sandbox.transport.config import TransportConfig + + provider = get_provider("alicloud-express") + + def _select(*_args: Any, **_kwargs: Any) -> tuple[Any, None]: + return provider, None config = MagicMock() config.sandbox_domain = "example.com" http = MagicMock() - def _infra(**_kwargs: Any) -> tuple[Any, Any, Any, Any]: - return config, MagicMock(), http, protocol + async def _infra(*_args: Any, **_kwargs: Any) -> ProviderInfra: + config = TransportConfig(sandbox_api_key="k") + return ProviderInfra(config, MagicMock(), http, protocol) async def _resolve(*ids: str | None, **_kwargs: Any) -> ResolvedCapabilities: seen["ids"] = ids @@ -447,8 +464,8 @@ async def _resolve(*ids: str | None, **_kwargs: Any) -> ResolvedCapabilities: with ( patch("agent_sandbox.api.sandbox._select_provider", _select), - patch("agent_sandbox.api.sandbox._build_infra", _infra), - patch("agent_sandbox.api.sandbox.resolve_capabilities_for", _resolve), + patch.object(provider, "build_infra", _infra), + patch("agent_sandbox.api.capability.resolve_capabilities_for", _resolve), ): sb = await Sandbox.create(template="unique-alias", api_key="k") @@ -463,15 +480,21 @@ async def test_connect_tries_template_then_alias(self) -> None: info.alias_name = "cached-alias" seen: dict[str, Any] = {} - def _select(*_args: Any, **_kwargs: Any) -> tuple[None, None]: - return None, None + from agent_sandbox.providers.base import ProviderInfra + from agent_sandbox.providers.registry import get_provider + from agent_sandbox.transport.config import TransportConfig + + provider = get_provider("alicloud-express") + + def _select(*_args: Any, **_kwargs: Any) -> tuple[Any, None]: + return provider, None protocol = AsyncMock() protocol.connect.return_value = info http = MagicMock() - def _infra(**_kwargs: Any) -> tuple[Any, Any, Any, Any]: - return MagicMock(), MagicMock(), http, protocol + async def _infra(*_args: Any, **_kwargs: Any) -> ProviderInfra: + return ProviderInfra(TransportConfig(sandbox_api_key="k"), MagicMock(), http, protocol) async def _resolve(*ids: str | None, **_kwargs: Any) -> ResolvedCapabilities: seen["ids"] = ids @@ -479,8 +502,8 @@ async def _resolve(*ids: str | None, **_kwargs: Any) -> ResolvedCapabilities: with ( patch("agent_sandbox.api.sandbox._select_provider", _select), - patch("agent_sandbox.api.sandbox._build_infra", _infra), - patch("agent_sandbox.api.sandbox.resolve_capabilities_for", _resolve), + patch.object(provider, "build_infra", _infra), + patch("agent_sandbox.api.capability.resolve_capabilities_for", _resolve), ): sb = await Sandbox.connect("sbx-123", api_key="k") @@ -499,9 +522,9 @@ async def test_create_with_cpu_memory(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_lc, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_ca, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_pc, + patch("agent_sandbox.providers.alicloud_express.auth.create_auth_provider") as mock_ca, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -531,9 +554,9 @@ async def test_create_with_disk_gpu(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_lc, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_ca, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_pc, + patch("agent_sandbox.providers.alicloud_express.auth.create_auth_provider") as mock_ca, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -563,9 +586,9 @@ async def test_create_without_resources_omits_keys(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_lc, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_ca, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_pc, + patch("agent_sandbox.providers.alicloud_express.auth.create_auth_provider") as mock_ca, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -743,9 +766,9 @@ async def test_create_derives_envd_url_when_missing(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_lc, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_ca, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_pc, + patch("agent_sandbox.providers.alicloud_express.auth.create_auth_provider") as mock_ca, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -774,9 +797,9 @@ async def test_connect_derives_envd_url_when_missing(self) -> None: with ( patch("agent_sandbox.api.sandbox.load_config") as mock_lc, - patch("agent_sandbox.api.sandbox.create_auth_provider") as mock_ca, - patch("agent_sandbox.api.sandbox.HttpClient"), - patch("agent_sandbox.api.sandbox.SandboxProtocol") as mock_pc, + patch("agent_sandbox.providers.alicloud_express.auth.create_auth_provider") as mock_ca, + patch("agent_sandbox.transport.http.HttpClient"), + patch("agent_sandbox.providers.shared.e2b.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() diff --git a/client-sdk/tests/test_api/test_volume_mounts.py b/client-sdk/tests/test_api/test_volume_mounts.py new file mode 100644 index 0000000..7ed85da --- /dev/null +++ b/client-sdk/tests/test_api/test_volume_mounts.py @@ -0,0 +1,129 @@ +"""Volume mount hints must survive creation and fail before provider I/O.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_sandbox.api.sandbox import Sandbox +from agent_sandbox.models.errors import ( + ProviderConfigError, + ProviderFeatureNotSupportedError, + ProviderNotFoundError, +) +from agent_sandbox.models.sandbox import SandboxConfig +from agent_sandbox.providers.base import Provider, ProviderInfra +from agent_sandbox.providers.registry import get_provider +from agent_sandbox.transport.auth_contract import SessionAuth +from tests.test_api.conftest import make_sandbox_info + +INVALID = [ + [], + "volume", + {"relative": "volume"}, + {"/": "volume"}, + {"/mnt//data": "volume"}, + {"/mnt/data/": "volume"}, + {"/mnt/../data": "volume"}, + {"/mnt/./data": "volume"}, + {"/mnt/.cache": "volume"}, + {" /mnt/data": "volume"}, + {"/mnt/data ": "volume"}, + {"/mnt/\x00data": "volume"}, + {"/bin/data": "volume"}, + {"/opt": "volume"}, + {"/var/data": "volume"}, + {"/dev/data": "volume"}, + {"__proto__": "volume"}, + {"constructor": "volume"}, + {"prototype": "volume"}, + {"/mnt/data": ""}, + {"/mnt/data": " "}, + {"/mnt/data": " padded"}, + {"/mnt/data": "volume\x00name"}, + {"/mnt/data": 123}, + {123: "volume"}, +] + + +@pytest.mark.parametrize("mounts", INVALID) +async def test_invalid_mounts_never_select_or_build_provider(mounts: Any) -> None: + with ( + patch("agent_sandbox.api.sandbox._select_provider") as select, + patch("agent_sandbox.api.sandbox._build_infra") as build, + pytest.raises(ProviderConfigError), + ): + await Sandbox.create(volume_mounts=mounts) + select.assert_not_called() + build.assert_not_called() + with pytest.raises(ProviderConfigError): + SandboxConfig(volume_mounts=mounts) + + +async def test_unknown_provider_mounts_fail_before_building() -> None: + with patch("agent_sandbox.api.sandbox._provider_infra") as build: + with pytest.raises(ProviderNotFoundError): + await Sandbox.create( + provider="uninstalled-plugin", volume_mounts={"/mnt/data": "volume"} + ) + build.assert_not_called() + + +async def test_plugin_mounts_require_explicit_create_option() -> None: + class Plugin(Provider): + id = "programmatic-plugin" + + async def build_infra(self, config): + raise AssertionError("must fail before provider I/O") + + with pytest.raises(ProviderFeatureNotSupportedError): + await Sandbox.create(provider=Plugin(), volume_mounts={"/mnt/data": "volume"}) + + +@pytest.mark.parametrize("mounts", [None, {}, {"/mnt/data": "existing-volume"}]) +async def test_builtin_api_forwards_only_nonempty_mounts(mounts: Any) -> None: + http = AsyncMock() + provider = get_provider("alicloud-express") + + async def factory(config): + return ProviderInfra(config, None, http, provider_lifecycle, close=http.close) + + provider_lifecycle = AsyncMock() + provider_lifecycle.create.return_value = make_sandbox_info() + with ( + patch.object(provider, "build_infra", side_effect=factory), + patch.object( + provider, + "prepare_session", + new_callable=AsyncMock, + return_value={"envd_token": SessionAuth(None, "sb-1")}, + ), + ): + sandbox = await Sandbox.create(volume_mounts=mounts) + forwarded = provider_lifecycle.create.call_args.args[0] + assert forwarded.volume_mounts == (mounts or {}) + assert ("volumeMounts" in forwarded.to_create_payload()) is bool(mounts) + await sandbox.close() + http.close.assert_awaited_once() + + +def test_model_exact_wire_and_empty_compatibility() -> None: + mounts = {"/mnt/data": "existing-volume"} + config = SandboxConfig(volume_mounts=mounts) + mounts.clear() + assert config.to_create_payload()["volumeMounts"] == [ + {"name": "existing-volume", "path": "/mnt/data"} + ] + assert "volumeMounts" not in SandboxConfig().to_create_payload() + assert "volumeMounts" not in SandboxConfig(volume_mounts={}).to_create_payload() + config.volume_mounts["/"] = "volume" + with pytest.raises(ProviderConfigError): + config.to_create_payload() + + +def test_retired_native_grpc_bridge_is_not_packaged() -> None: + import importlib.util + + assert importlib.util.find_spec("agent_sandbox.engine") is None diff --git a/client-sdk/tests/test_models/test_errors.py b/client-sdk/tests/test_models/test_errors.py index 5c7839e..ef0454a 100644 --- a/client-sdk/tests/test_models/test_errors.py +++ b/client-sdk/tests/test_models/test_errors.py @@ -106,7 +106,7 @@ def test_invalid_credentials(self): err = InvalidCredentialsError("bad creds") assert isinstance(err, AuthenticationError) assert err.code == "E1003" - assert "ALICLOUD" in err.suggestion + assert "selected provider" in err.suggestion # ---- Creation Errors ---- diff --git a/client-sdk/tests/test_providers/test_commands.py b/client-sdk/tests/test_providers/test_commands.py new file mode 100644 index 0000000..4803841 --- /dev/null +++ b/client-sdk/tests/test_providers/test_commands.py @@ -0,0 +1,328 @@ +"""Provider command manifest, consent and one-shot engine contract regressions.""" + +from __future__ import annotations + +# ruff: noqa: F811 +import copy +from typing import Any + +import httpx +import pytest + +from agent_sandbox.models.errors import ( + ProviderConfigError, + ProviderManifestError, + ProviderProtocolError, + ProviderTrustError, +) +from agent_sandbox.providers import commands, installer, manifest +from agent_sandbox.providers.engine_client import EngineClient, EngineRecord +from tests.iso import iso # noqa: F401 +from tests.test_providers.helpers import write_plugin +from tests.test_providers.test_manifest import BASE + +ENTRY = """ +[commands] +protocol = "provider-commands/1" +[[commands.entries]] +id = "volumes.list" +description = "List volumes" +description_zh = "列出存储卷" +input_schema={type="object",additionalProperties=false,properties={limit={type="integer",minimum=1}}} +output_schema = {type="object"} +effect = "read" +idempotent = true +""" + + +def test_command_manifest_round_trip_and_copy() -> None: + command = manifest.parse_manifest(BASE + ENTRY).commands[0] + assert command.id == "volumes.list" + assert command.effect == "read" and command.idempotent + wire = command.to_dict() + wire["input_schema"]["properties"].clear() + assert "limit" in command.input_schema["properties"] + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ('protocol = "provider-commands/1"', 'protocol = "provider-commands/2"'), + ('id = "volumes.list"', 'id = "../volumes"'), + ('id = "volumes.list"', 'id = "Volumes.list"'), + ('id = "volumes.list"', 'id = "' + "a" * 81 + '"'), + ('effect = "read"', 'effect = "shell"'), + ("idempotent = true", 'idempotent = "true"'), + ("additionalProperties=false", "additionalProperties=true"), + ('type="object",additionalProperties=false', 'type="array",additionalProperties=false'), + ('output_schema = {type="object"}', "output_schema = true"), + ('description = "List volumes"', 'description = ""'), + ('description_zh = "列出存储卷"', 'description_zh = ""'), + ('description = "List volumes"', 'description = " "'), + ('effect = "read"', 'extra = true\neffect = "read"'), + ("minimum=1", "minimum=nan"), + ("minimum=1", "default=2026-10-16"), + ], +) +def test_rejects_invalid_command_descriptors(old: str, new: str) -> None: + with pytest.raises(ProviderManifestError): + manifest.parse_manifest(BASE + ENTRY.replace(old, new)) + + +@pytest.mark.parametrize( + "keyword", + [ + "$ref", + "$dynamicRef", + "$recursiveRef", + "$id", + "$async", + "pattern", + "patternProperties", + "format", + "__proto__", + "prototype", + "constructor", + ], +) +def test_rejects_schema_references_and_async(keyword: str) -> None: + with pytest.raises(ProviderManifestError): + manifest.parse_manifest( + BASE + ENTRY.replace("minimum=1", f'"{keyword}"="https://example.com/schema"') + ) + + +@pytest.mark.parametrize("name", ["format", "pattern", "patternProperties", "$ref", "$id"]) +def test_schema_business_property_names_are_data(name: str) -> None: + source = ENTRY.replace('limit={type="integer",minimum=1}', f'"{name}"={{type="string"}}') + assert name in manifest.parse_manifest(BASE + source).commands[0].input_schema["properties"] + + +def test_explicit_section_requires_entries() -> None: + with pytest.raises(ProviderManifestError): + manifest.parse_manifest(BASE + '\n[commands]\nprotocol="provider-commands/1"\n') + + +def test_duplicate_and_limits() -> None: + with pytest.raises(ProviderManifestError, match="duplicate"): + manifest.parse_manifest(BASE + ENTRY + ENTRY[ENTRY.index("[[commands.entries]]") :]) + with pytest.raises(ProviderManifestError, match="64 KiB"): + manifest.parse_manifest(BASE + ENTRY.replace("List volumes", "x" * (64 * 1024))) + with pytest.raises(ProviderManifestError, match="depth 16"): + nested = "{child=" * 18 + '"value"' + "}" * 18 + manifest.parse_manifest(BASE + ENTRY.replace("minimum=1", f"default={nested}")) + with pytest.raises(ProviderManifestError, match="128"): + entries = "\n".join( + ENTRY[ENTRY.index("[[commands.entries]]") :].replace("volumes.list", f"op{i}") + for i in range(129) + ) + manifest.parse_manifest(BASE + '\n[commands]\nprotocol="provider-commands/1"\n' + entries) + + +def test_rest_rejects_commands_but_accepts_empty_section() -> None: + rest = ( + BASE.replace('kind = "exec"', 'kind = "rest"') + + '\n[endpoint]\nbase_url="https://example.com"\n' + ) + with pytest.raises(ProviderManifestError, match="only node and exec"): + manifest.parse_manifest(rest + ENTRY) + assert ( + manifest.parse_manifest( + rest + '\n[commands]\nprotocol="provider-commands/1"\nentries=[]\n' + ).commands + == () + ) + + +def test_consent_captures_commands_and_old_receipt_defaults_to_empty() -> None: + original = manifest.parse_manifest(BASE) + receipt = {"consented": original.to_summary()} + receipt["consented"].pop("commands") + assert installer._diff_aspects(receipt, original) == [] + next_manifest = manifest.parse_manifest(BASE + ENTRY) + assert installer._diff_aspects(receipt, next_manifest) == ["commands"] + receipt = {"consented": next_manifest.to_summary()} + assert installer._diff_aspects(receipt, next_manifest) == [] + changed = manifest.parse_manifest(BASE + ENTRY.replace('effect = "read"', 'effect = "delete"')) + assert installer._diff_aspects(receipt, changed) == ["commands"] + assert "commands" in installer.RISK_ASPECTS + + +def test_installation_requires_consent_when_commands_change(iso: Any, tmp_path: Any) -> None: + source = write_plugin(tmp_path / "source") + installer.install(str(source), yes=True) + write_plugin(source, version="0.2.0", extra=ENTRY) + plans = [] + with pytest.raises(ProviderTrustError): + installer.install( + str(source), force=True, confirm=lambda plan: plans.append(plan.summary()) or False + ) + assert plans[0]["changes"] == ["commands"] + assert plans[0]["commands"][0]["id"] == "volumes.list" + + +class FakeClient: + instances: list[FakeClient] = [] + response: Any = {} + failure: Exception | None = None + + def __init__(self, connection: dict[str, Any]) -> None: + self.connection = connection + self.calls: list[Any] = [] + self.closed = False + self.instances.append(self) + + async def call(self, *args: Any, **kwargs: Any) -> Any: + self.calls.append((args, kwargs)) + if self.failure: + raise self.failure + return copy.deepcopy(self.response) + + async def close(self) -> None: + self.closed = True + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> type[FakeClient]: + FakeClient.instances = [] + FakeClient.failure = None + FakeClient.response = {} + monkeypatch.setattr(commands, "EngineClient", FakeClient) + return FakeClient + + +@pytest.mark.asyncio +async def test_list_get_run_contract(iso: Any, client: type[FakeClient]) -> None: + descriptor = manifest.parse_manifest(BASE + ENTRY).commands[0].to_dict() + client.response = {"commands": [descriptor]} + assert await commands.list_commands("alicloud-express") == [descriptor] + assert client.instances[-1].calls == [(("GET", "/v1/providers/alicloud-express/commands"), {})] + client.response = descriptor + assert await commands.get_command("alicloud-express", "volumes.list") == descriptor + client.response = {"items": []} + assert await commands.run_command("alicloud-express", "volumes.list", {"limit": 2}) == { + "items": [] + } + args, options = client.instances[-1].calls[0] + assert args == ("POST", "/v1/providers/alicloud-express/commands/volumes.list") + assert options == {"json_body": {"input": {"limit": 2}, "confirm": False}, "retry": False} + await commands.run_command( + "alicloud-express", "volumes.delete", {"id": "owned"}, confirm=True, region="cn-shanghai" + ) + assert client.instances[-1].calls[0][1]["json_body"]["confirm"] is True + assert client.instances[-1].connection["region"] == "cn-shanghai" + assert all(c.closed for c in client.instances) + assert all(c.connection["provider"] == "alicloud-express" for c in client.instances) + + +@pytest.mark.asyncio +async def test_discovery_does_not_resolve_credentials( + iso: Any, client: type[FakeClient], monkeypatch: pytest.MonkeyPatch +) -> None: + def unexpected_config(**kwargs: Any) -> None: + raise AssertionError("metadata discovery must not resolve credentials") + + monkeypatch.setattr(commands, "load_config", unexpected_config) + monkeypatch.setattr(commands, "get_provider", lambda provider: unexpected_config()) + descriptor = manifest.parse_manifest(BASE + ENTRY).commands[0].to_dict() + client.response = {"commands": [descriptor]} + assert await commands.list_commands("demo-prov") == [descriptor] + client.response = descriptor + assert await commands.get_command("demo-prov", "volumes.list") == descriptor + assert all(c.connection == {"provider": "demo-prov"} for c in client.instances) + + +@pytest.mark.asyncio +async def test_invocation_forwards_only_scoped_credentials( + iso: Any, tmp_path: Any, client: type[FakeClient] +) -> None: + source = write_plugin(tmp_path / "node", extra=ENTRY + "\n[config.fields.token]\nsecret=true\n") + path = source / "abx-provider.toml" + path.write_text( + path.read_text().replace('kind = "exec"', 'kind = "node"\nentry = "provider.mjs"') + ) + (source / "provider.mjs").write_text('throw new Error("must not load in SDK");') + installer.install(str(source), yes=True) + client.response = {"items": []} + await commands.run_command( + "demo-prov", + "volumes.list", + provider_options={"token": "placeholder-token"}, + sandbox_api_key="unrelated-placeholder", + ) + assert client.instances[-1].connection == { + "provider": "demo-prov", + "provider_options": {"token": "placeholder-token"}, + } + + +@pytest.mark.asyncio +async def test_releases_client_on_protocol_and_engine_errors( + iso: Any, + client: type[FakeClient], +) -> None: + with pytest.raises(ProviderProtocolError): + await commands.list_commands("alicloud-express") + with pytest.raises(ProviderProtocolError): + await commands.get_command("alicloud-express", "volumes.list") + client.failure = ProviderConfigError("invalid input") + with pytest.raises(ProviderConfigError): + await commands.run_command("alicloud-express", "volumes.list") + assert all(c.closed for c in client.instances) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider,command_id,input,confirm", + [ + ("../demo", "volumes.list", {}, False), + ("alicloud-express", "../list", {}, False), + ("alicloud-express", "volumes.list", [], False), + ("alicloud-express", "volumes.list", {}, "yes"), + ], +) +async def test_invalid_calls_never_reach_engine( + iso: Any, + client: type[FakeClient], + provider: str, + command_id: str, + input: Any, + confirm: Any, +) -> None: + with pytest.raises(ProviderConfigError): + await commands.run_command(provider, command_id, input, confirm=confirm) + assert client.instances == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [False, True]) +async def test_post_not_replayed_after_unauthorized_or_disconnect(failure: bool) -> None: + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if failure: + raise httpx.ConnectError("disconnected", request=request) + return httpx.Response( + 401, json={"error": {"type": "AuthenticationError", "message": "denied"}} + ) + + engine = EngineClient({"provider": "demo-prov"}) + await engine._http.aclose() + engine._http = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + engine._record = EngineRecord(1, "127.0.0.1", 12345, "placeholder") + try: + if failure: + with pytest.raises(ProviderProtocolError): + await engine.call( + "POST", "/v1/providers/demo-prov/commands/volumes.create", retry=False + ) + else: + response = await engine.request( + "POST", "/v1/providers/demo-prov/commands/volumes.create", retry=False + ) + assert response.status_code == 401 + assert calls == 1 + finally: + await engine.close() diff --git a/client-sdk/tests/test_providers/test_commands_engine.py b/client-sdk/tests/test_providers/test_commands_engine.py new file mode 100644 index 0000000..4ea951e --- /dev/null +++ b/client-sdk/tests/test_providers/test_commands_engine.py @@ -0,0 +1,140 @@ +"""Python -> real Node engine -> provider command contract, without cloud requests. + +Set ABX_COMMAND_TEST_BIN to a compatible abx command line to run these tests. +The temporary provider executes in the real engine and owns no external resources. +""" + +from __future__ import annotations + +import contextlib +import os +from typing import TYPE_CHECKING, Any + +import pytest + +from agent_sandbox.models.errors import ProviderConfigError, ProviderTrustError +from agent_sandbox.providers import commands, engine_client, installer +from tests.iso import iso # noqa: F401 + +if TYPE_CHECKING: + from pathlib import Path + +TEST_BIN = os.environ.get("ABX_COMMAND_TEST_BIN") +pytestmark = pytest.mark.skipif( + not TEST_BIN, + reason="set ABX_COMMAND_TEST_BIN to a compatible abx engine command", +) + + +@pytest.fixture +async def real_engine(iso: Any, monkeypatch: pytest.MonkeyPatch): # noqa: F811 + monkeypatch.setenv("HOME", str(iso.home.parent)) + assert TEST_BIN is not None + monkeypatch.setenv("ABX_BIN", TEST_BIN) + yield + if engine_client.read_record() is not None: + client = engine_client.EngineClient({"provider": "alicloud-express"}) + try: + with contextlib.suppress(Exception): + await client.call("POST", "/v1/shutdown", retry=False) + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_builtin_discovery_through_real_engine(real_engine: Any) -> None: + descriptors = await commands.list_commands("alicloud-express") + expected = { + "volume.create", + "volume.get", + "volume.list", + "volume.update", + "volume.delete", + "team.create", + "team.get", + "team.list", + "team.update", + "team.delete", + "api-key.create", + "api-key.get", + "api-key.list", + "api-key.update", + "api-key.reset", + "api-key.delete", + "quota.get", + "quota.list", + "quota.update", + "quota.delete", + } + assert len(descriptors) == 20 + assert {descriptor["id"] for descriptor in descriptors} == expected + descriptor = await commands.get_command("alicloud-express", "volume.list") + assert descriptor["effect"] == "read" and descriptor["idempotent"] + assert descriptor["input_schema"]["additionalProperties"] is False + + +@pytest.mark.asyncio +async def test_node_invocation_and_confirmation_through_real_engine( + real_engine: Any, tmp_path: Path +) -> None: + source = tmp_path / "plugin" + source.mkdir() + schema = ( + '{type="object",properties={message={type="string"}},' + 'required=["message"],additionalProperties=false}' + ) + entry_template = """ +[[commands.entries]] +id = "echo.{effect}" +description = "Echo a value" +description_zh = "返回输入值" +input_schema = {schema} +output_schema = {schema} +effect = "{effect}" +idempotent = true +""" + entries = "\n".join( + entry_template.format(effect=effect, schema=schema) for effect in ("read", "write") + ) + (source / "abx-provider.toml").write_text( + """ +manifest_version = 1 +[provider] +id = "engine-command-fixture" +version = "1.0.0" +[runtime] +kind = "node" +entry = "provider.mjs" +[features] +supported = [] +[commands] +protocol = "provider-commands/1" +""" + + entries + ) + (source / "provider.mjs").write_text(""" +export default () => ({ + async buildInfra() { + return { + sandbox_protocol: {}, + provider_commands: { async run(commandId, input, signal) { + if (signal.aborted) throw new Error("unexpected abort"); + return { message: input.message }; + } } + }; + } +}); +""") + installer.install(str(source), yes=True) + provider = "engine-command-fixture" + assert len(await commands.list_commands(provider)) == 2 + assert await commands.run_command(provider, "echo.read", {"message": "read"}) == { + "message": "read" + } + with pytest.raises(ProviderTrustError): + await commands.run_command(provider, "echo.write", {"message": "write"}) + assert await commands.run_command( + provider, "echo.write", {"message": "write"}, confirm=True + ) == {"message": "write"} + with pytest.raises(ProviderConfigError): + await commands.run_command(provider, "echo.read", {"message": "read", "extra": True}) diff --git a/client-sdk/tests/test_providers/test_complete_isolation.py b/client-sdk/tests/test_providers/test_complete_isolation.py new file mode 100644 index 0000000..883ef2a --- /dev/null +++ b/client-sdk/tests/test_providers/test_complete_isolation.py @@ -0,0 +1,199 @@ +"""Guard backend boundaries and owned/borrowed cleanup through the public lifecycle.""" + +from __future__ import annotations + +import ast +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from agent_sandbox.api.sandbox import Sandbox +from agent_sandbox.models.sandbox import SandboxInfo +from agent_sandbox.providers.base import Provider, ProviderInfra +from agent_sandbox.providers.registry import register_provider +from agent_sandbox.transport.config import TransportConfig, load_config +from tests.iso import iso # noqa: F401 + + +class IsolatedProvider(Provider): + id = "isolation-test" + features = frozenset({"sandbox.lifecycle"}) + + def __init__(self) -> None: + self.killed = AsyncMock() + self.closed = AsyncMock() + + async def build_infra(self, config: TransportConfig) -> ProviderInfra: + info = SandboxInfo(sandbox_id="owned", template="base", envd_url="custom://session") + protocol = type("Lifecycle", (), {})() + protocol.create = AsyncMock(return_value=info) + protocol.connect = AsyncMock(return_value=info) + protocol.kill = self.killed + return ProviderInfra( + config=config, auth=None, http_client=None, sandbox_protocol=protocol, close=self.closed + ) + + async def prepare_session(self, *args: object, **kwargs: object) -> dict: + raise ValueError("binding failed") + + +@pytest.mark.asyncio +async def test_created_resource_is_cleaned_on_binding_failure(iso): # noqa: F811 + provider = IsolatedProvider() + register_provider(provider) + with pytest.raises(ValueError, match="binding failed"): + await Sandbox.create(provider=provider.id) + provider.killed.assert_awaited_once_with("owned") + provider.closed.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_borrowed_resource_is_kept_on_binding_failure(iso): # noqa: F811 + provider = IsolatedProvider() + register_provider(provider) + with pytest.raises(ValueError, match="binding failed"): + await Sandbox.connect("owned", provider=provider.id) + provider.killed.assert_not_awaited() + provider.closed.assert_awaited_once() + + +def test_direct_and_layered_config_do_not_inherit_cloud_defaults(iso): # noqa: F811 + provider = IsolatedProvider() + register_provider(provider) + for config in (TransportConfig(provider=provider.id), load_config(provider=provider.id)): + assert (config.sandbox_base_url, config.sandbox_domain, config.region) == ("", "", "") + explicit = TransportConfig(provider=provider.id, sandbox_base_url="https://provider.example") + assert explicit.sandbox_base_url == "https://provider.example" + + +def test_core_imports_no_concrete_backend_implementation() -> None: + root = Path(__file__).parents[2] / "src" / "agent_sandbox" + violations = [] + for path in root.rglob("*.py"): + relative = path.relative_to(root).as_posix() + if relative.startswith("providers/"): + continue + tree = ast.parse(path.read_text()) + # Historical public modules remain explicit export facades, including monkeypatch identity. + if relative in { + "transport/auth.py", + "transport/__init__.py", + "protocol/__init__.py", + "protocol/sandbox.py", + "protocol/filesystem.py", + "protocol/process.py", + "protocol/code_interpreter.py", + "protocol/terminal.py", + "protocol/port.py", + }: + continue + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if ( + any( + marker in module + for marker in ( + "providers.alicloud_express", + "providers.shared.e2b", + "protocol.sandbox", + "protocol.filesystem", + "protocol.process", + "protocol.terminal", + "protocol.code_interpreter", + "transport.auth", + ) + ) + and "auth_contract" not in module + ): + violations.append(f"{relative}: {module}") + assert violations == [] + + +def test_wire_adapter_owns_url_without_legacy_transport_helper(monkeypatch): + from agent_sandbox.providers.alicloud_express.provider import AlicloudExpressProvider + + def legacy_helper(*args): + raise AssertionError("legacy config URL helper used") + + monkeypatch.setattr(TransportConfig, "build_envd_url", legacy_helper) + provider = AlicloudExpressProvider() + info = SandboxInfo(sandbox_id="owned") + config = TransportConfig(sandbox_domain="protocol.test") + assert provider.resolve_session_url(info, config) == "https://49983-owned.protocol.test" + + +def test_bundled_schema_does_not_discover_registry_in_a_fresh_process(): + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; from agent_sandbox.transport.config import _builtin_schema; " + "schema = _builtin_schema(); assert schema.provider_id == 'alicloud-express'; " + "assert 'agent_sandbox.providers.registry' not in sys.modules", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_core_contract_import_does_not_load_builtin_auth_or_wire(): + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; from agent_sandbox.transport.http import HttpClient; " + "from agent_sandbox.protocol import contracts; " + "assert 'agent_sandbox.providers.alicloud_express.auth' not in sys.modules; " + "assert 'agent_sandbox.providers.shared.e2b.sandbox' not in sys.modules", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.asyncio +async def test_async_provider_ports_never_inherit_native_addresses_or_headers(iso): # noqa: F811 + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + from agent_sandbox.transport.auth_contract import SessionAuth + + class PortProvider(IsolatedProvider): + features = frozenset({"sandbox.lifecycle", "ports.url"}) + + async def prepare_session(self, *args, **kwargs): + return {"envd_token": SessionAuth("opaque-token", "owned")} + + async def build_infra(self, config): + infra = await super().build_infra(config) + infra.sandbox_protocol.has_port_url = True + infra.sandbox_protocol.port_url = AsyncMock( + return_value=( + "https://gateway.example/owned/service", + {"Provider-Gateway": "placeholder"}, + ) + ) + return infra + + sandbox = await Sandbox.create(provider=PortProvider()) + try: + with pytest.raises(ProviderFeatureNotSupportedError): + sandbox.network.get_url(8080) + with pytest.raises(ProviderFeatureNotSupportedError): + sandbox.network.get_access_headers() + assert await sandbox.network.resolve(8080) == ( + "https://gateway.example/owned/service", + {"Provider-Gateway": "placeholder"}, + ) + with pytest.raises(ProviderFeatureNotSupportedError): + sandbox.server_at() + finally: + await sandbox.kill() diff --git a/client-sdk/tests/test_providers/test_host_adapters.py b/client-sdk/tests/test_providers/test_host_adapters.py index 5dfc553..764ba9a 100644 --- a/client-sdk/tests/test_providers/test_host_adapters.py +++ b/client-sdk/tests/test_providers/test_host_adapters.py @@ -376,7 +376,8 @@ async def test_network_resolves_through_ports_url(echo): assert url == f"https://echo.example/{sb.id}/8080" assert headers == {"X-Echo": "1"} assert await sb.network.resolve_url(3000) == f"https://echo.example/{sb.id}/3000" - assert sb.network.get_url(8080).startswith("http") # local computation is unchanged + with pytest.raises(E.ProviderFeatureNotSupportedError): + sb.network.get_url(8080) # async-only providers have no native URL fallback finally: await sb.kill() diff --git a/client-sdk/tests/test_providers/test_installer.py b/client-sdk/tests/test_providers/test_installer.py index 194a5ea..9d4f2ab 100644 --- a/client-sdk/tests/test_providers/test_installer.py +++ b/client-sdk/tests/test_providers/test_installer.py @@ -29,6 +29,30 @@ ) +def test_builtin_update_reports_host_lifecycle_without_plugin_io(iso, monkeypatch): # noqa: F811 + def unexpected(*args, **kwargs): + raise AssertionError("Bundled updates must not read plugin receipts or fetch sources") + + monkeypatch.setattr(store, "read_receipt", unexpected) + result = installer.update("alicloud-express", check=True) + assert result["status"] == "bundled" + assert result["available"] is None + assert result["changes"] == [] + assert "client and server" in result["message"] + assert "abx separately" in result["message"] + with pytest.raises(ProviderInstallError, match="bundled provider"): + installer.update("alicloud-express", ref="main") + + +def test_builtin_update_cli_reports_actionable_json(iso, capsys): # noqa: F811 + from agent_sandbox.providers.__main__ import main + + assert main(["--json", "update", "alicloud-express", "--check"]) == 0 + result = json.loads(capsys.readouterr().out)[0] + assert result["status"] == "bundled" + assert "client and server" in result["message"] + + def _install(src, **kw): kw.setdefault("yes", True) return installer.install(str(src), **kw) diff --git a/client-sdk/tests/test_providers/test_registry_scoping.py b/client-sdk/tests/test_providers/test_registry_scoping.py index 57d034a..3cd2f50 100644 --- a/client-sdk/tests/test_providers/test_registry_scoping.py +++ b/client-sdk/tests/test_providers/test_registry_scoping.py @@ -45,7 +45,12 @@ def test_builtin_identity_and_default(self, iso): # noqa: F811 assert p.display_name == "Alibaba Cloud Express Edition" assert p.display_name_zh == "阿里云极速版" assert p.kind == "builtin" - assert p.supports("files.rw") and p.supports("storage.mount") + assert p.supports("files.rw") + assert not p.supports("storage.mount") + assert not p.supports("ports.domain") + assert not p.supports("network.vpc") + assert not p.supports("templates.deploy") + assert not p.supports("sandbox.pause") def test_register_and_conflicts(self, iso): # noqa: F811 register_provider(_Py()) diff --git a/client-sdk/tests/test_providers/test_sandbox_provider.py b/client-sdk/tests/test_providers/test_sandbox_provider.py index b2141bd..2cdd52d 100644 --- a/client-sdk/tests/test_providers/test_sandbox_provider.py +++ b/client-sdk/tests/test_providers/test_sandbox_provider.py @@ -140,7 +140,7 @@ async def test_explicit_provider_beats_ambient_default(local_process, monkeypatc from agent_sandbox.api import sandbox as sandbox_mod prov, _ = sandbox_mod._select_provider("alicloud-express") - assert prov is None # built-in path + assert prov.id == "alicloud-express" # same provider contract as plugins async def test_tampered_plugin_refuses_to_start(local_process): @@ -194,26 +194,35 @@ async def kill(self, sandbox_id): assert registry.get_provider("fake-py") is fake -async def test_builtin_default_path_is_untouched(iso, monkeypatch): - """No provider named anywhere: the original build path (auth + http + SandboxProtocol) is used.""" - import agent_sandbox.api.sandbox as mod +async def test_builtin_default_uses_registered_provider_factory(iso, monkeypatch): + """The default follows the same provider factory as an explicitly selected plugin.""" + from unittest.mock import AsyncMock - built = {} + from agent_sandbox.models.sandbox import SandboxInfo + from agent_sandbox.providers.base import ProviderInfra + from agent_sandbox.providers.registry import get_provider + from agent_sandbox.transport.auth_contract import SessionAuth - class FakeProto: - def __init__(self, http): - built["http"] = http + provider = get_provider("alicloud-express") + protocol = type("Lifecycle", (), {})() + protocol.create = AsyncMock(return_value=SandboxInfo(sandbox_id="sb-1", envd_url="https://x")) + close = AsyncMock() - async def create(self, cfg, *, request_timeout=None): - from agent_sandbox.models.sandbox import SandboxInfo - - return SandboxInfo(sandbox_id="sb-1", template=cfg.template, envd_url="https://x") + async def factory(config): + return ProviderInfra(config, None, None, protocol, close=close) - monkeypatch.setattr(mod, "SandboxProtocol", FakeProto) + monkeypatch.setattr(provider, "build_infra", factory) + # Session setup has its own backend tests; this verifies generic default routing. + monkeypatch.setattr( + provider, + "prepare_session", + AsyncMock(return_value={"envd_token": SessionAuth(None, "sb-1")}), + ) sb = await Sandbox.create(api_key="k", api_url="https://api.example") - assert sb.provider == "alicloud-express" - assert built["http"] is not None - await built["http"].close() + assert sb.provider == provider.id + protocol.create.assert_awaited_once() + await sb.close() + close.assert_awaited_once() async def test_contract_kit_passes_for_reference_plugin(local_process): diff --git a/docs/en/README.md b/docs/en/README.md index 2506a5e..c6f5ad9 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -44,6 +44,7 @@ The installer fetches the catalog index at `https://registry.agent-sandbox.cn/pr | [Session persistence](guide/session-persistence.md) | Local session storage | | [Migrate from E2B](guide/migrate-from-e2b.md) | Move from the E2B SDK | | [Providers](guide/providers.md) | Choose, install, and write a provider | +| [Provider management commands](guide/provider-commands.md) | Discover and invoke provider resource operations through the shared engine | | [Environment variables](guide/environment-variables.md) | Injection and reads inside the sandbox | | [Troubleshooting](guide/troubleshooting.md) | Common failures | | [Comparison](guide/comparison.md) | Next to E2B, Modal, and Docker Sandbox | @@ -144,3 +145,5 @@ These pages are reviews or target design, not the current specification. ## Related: the abx CLI The `abx` CLI (npm `@agent-sandbox/cli`) is documented in the [`cli` repository](https://github.com/alibaba-agent-sandbox/agent-sandbox): [documentation index](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/README.md), [Getting Started](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/guide/getting-started.md), [CLI Reference](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/reference/cli-reference.md), [CLI Architecture](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/design/architecture.md), [CLI Command System](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/design/cli-design.md), [Templates Catalog](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/design/templates-catalog.md), [Deploy & Build](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/guide/deploy-and-build.md), [Agent Skill Installation](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/guide/agent-skill-installation.md). + +- [Adopt an existing Express account](guide/migrate-from-alicloud-express.md): resource reuse, API/config changes and bundled-provider updates. diff --git a/docs/en/design/architecture.md b/docs/en/design/architecture.md index d5a20bb..ed6b9b1 100644 --- a/docs/en/design/architecture.md +++ b/docs/en/design/architecture.md @@ -249,6 +249,8 @@ The CLI (`abx`) formerly lived in `src/agent_sandbox/cli/` (Click commands under 5. **AI First**: Built-in AI CLI tools in sandboxes and MCP Server are first-class citizens; SDK has zero LLM dependencies 6. **Type Safety**: Comprehensive use of Python Type Hints + Pydantic validation +- [Provider implementation boundaries](provider-isolation.md) + Provider configuration discovery is assembled lazily by the package composition root. Transport consumes a low-level `ProviderSchema` value and an injected resolver from `_provider_schema.py`; it does not import the provider registry. Builtin configuration resolves without provider discovery. Registered, entry-point and installed providers retain the registry selection and derive/default/inheritance rules. The existing `transport.config.ProviderSchema` import remains an alias of the same schema class. Builtin platform/envd HTTP uses one transport attempt. Legacy retry fields are compatibility data, with the exact boundary in [configuration](../reference/configuration.md#legacy-retry-compatibility); engine recovery remains separate. diff --git a/docs/en/design/provider-isolation.md b/docs/en/design/provider-isolation.md new file mode 100644 index 0000000..c3c5237 --- /dev/null +++ b/docs/en/design/provider-isolation.md @@ -0,0 +1,61 @@ +# Provider implementation boundaries + +The host selects a `Provider` for every lifecycle operation, including the bundled +Express backend. There is no null/default-provider bypass. Core API objects consume +structural protocol and authentication contracts; they do not instantiate a vendor +SDK, exchange cloud credentials, derive backend URLs, or select a wire protocol. + +## Ownership + +| Responsibility | Owner | +| --- | --- | +| Provider selection, consent, integrity checks, output, generic configuration precedence, client-resource cleanup | Host | +| Authentication, endpoints, wire mapping, backend errors/statuses, lifecycle and optional create inputs | Selected Provider | +| Template authentication, build/register/poll/delete, Docker/registry specifics | Provider template implementation | +| Template source search/download, project scaffolding and runtime validation | Host, using the selected provider's declared metadata and factories | +| Express SDK, AK/SK signing/exchange, FC/ACR deployment, CLI options/prompts, AI management schemas | `providers/alicloud_express/` | +| Reusable E2B/envd wire adapters and token formatting | `providers/shared/e2b/`, explicitly selected by a provider | + +The distribution composition roots register bundled adapters, configuration descriptors, +CLI extensions and AI extensions. The generated configuration catalog is declarative +compatibility data. Historical `api`, `protocol` and `transport` imports remain export +facades; they do not constitute a second implementation or a core fallback. +The shared HTTP/Connect transport implements framing and connection reuse; providers +choose its authentication and adapters. It never chooses a cloud backend. + +## SDK contract + +`Provider.build_infra(config)` is used for the bundled default and plugins alike. +`session_protocols`, `prepare_session`, `resolve_session_url` and `open_terminal` +assemble provider-owned sessions. Express uses native HTTP/Connect/WebSocket +adapters directly; the retired Python gRPC engine and its lifecycle hooks stay +removed. The external Node engine/EPP client remains a separate provider contract. + +`TransportConfig(provider="external")` and `load_config` leave undeclared endpoint and +region defaults empty. Explicit endpoints remain intact. Failed create binding cleans +up the newly created resource; failed connect preserves the borrowed one. Public import +facades retain module identity for historical monkeypatch users. Missing optional +protocols fail E8004 without selecting Express. + +CLI template factories and AI/CLI discovery live in the CLI owner; no new Python +image deployment API is introduced. See the [CLI boundary contract](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/en/design/provider-isolation.md). +Bundled provider updates still follow matching SDK releases. Adapter isolation does +not promise zero-change migration or capabilities absent from a backend. + +Explicit provider selection wins over an unrelated ambient default. Pure bundled +configuration values live in `utils/bundled_config.py`; transport consumes the +`_provider_schema.py` contract and injected discovery rather than importing a registry. + + +## Port and file URL ownership + +The selected adapter implements synchronous port access and project-server gateway +headers. An adapter exposing only asynchronous port discovery must be called through +`await sandbox.network.resolve(port)`; synchronous helpers fail E8004 instead of +guessing an Express/E2B address or sending a foreign token. Direct standalone network +constructors keep their documented bundled compatibility behavior. File transfer URLs +are optional filesystem adapter hooks; missing hooks fail E8004 even if the session +URL happens to use HTTP. The core never constructs `/files` routes for foreign providers. + +Neutral Python transport/protocol imports do not load built-in authentication or wire +adapters. Public compatibility imports resolve lazily when explicitly requested. diff --git a/docs/en/guide/migrate-from-alicloud-express.md b/docs/en/guide/migrate-from-alicloud-express.md new file mode 100644 index 0000000..f07890c --- /dev/null +++ b/docs/en/guide/migrate-from-alicloud-express.md @@ -0,0 +1,92 @@ +# Adopt Agent Sandbox on an existing Express account + +This changes your client/tooling, not your cloud provider. Keep your account, region, +Team, API key, template IDs/aliases, Volumes and RAM roles. Existing resources do not +need to be recreated just to use the client. There is no implemented zero-change or +one-click migration. Review the API calls your application actually uses. + +This guide covers Python and the CLI. The public +[Node SDK](https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/blob/main/README.md) +currently provides `ServerClient` and configuration names, not cloud sandbox lifecycle. +Changing an E2B JavaScript import to that package is not a supported migration. + +## What changes + +| Existing Express application | Agent Sandbox client | +| --- | --- | +| `from e2b import Sandbox` | `from agent_sandbox import Sandbox` | +| Implicit `E2B_API_KEY`, `E2B_API_URL`, `E2B_DOMAIN` | Explicit `ABX_SANDBOX_API_KEY`, `ABX_SANDBOX_BASE_URL`, `ABX_SANDBOX_DOMAIN`; old names are not silently read. | +| `Sandbox.kill(id)` | `Sandbox.kill_by_id(id)` | +| `sandbox.get_host(port)` | `sandbox.network.get_host(port)` | +| Signed upload/download URL assumptions | Use authenticated file methods; see [E2B migration differences](migrate-from-e2b.md). | +| Existing API/result/error handling | Audit methods, streaming, exceptions and sync/async behavior; matching method names are not full compatibility. | + +For a shell already configured for the **same Express account**, explicitly map +only the selected settings without printing their values: + +```sh +export ABX_SANDBOX_API_KEY="$E2B_API_KEY" # example: use the existing Express value +export ABX_SANDBOX_BASE_URL="$E2B_API_URL" +export ABX_SANDBOX_DOMAIN="$E2B_DOMAIN" +``` + +Do not use shell tracing or copy this mapping from a native E2B account. Use the +existing template ID/alias and the original regional endpoints. No AK/SK is needed +for ordinary sandbox-key lifecycle, exec and file calls. Cloud management/template +publication has separate AK/SK and registry prerequisites. Existing `api_key`, +`api_url` and `domain` API arguments remain compatibility aliases for their +canonical settings; conflicting canonical/alias values are rejected. + +## Verify a new, test-owned sandbox + +After installing a compatible client/server release using the +[installation guide](getting-started.md), use an existing template: + +```python +import asyncio +import os +from agent_sandbox import Sandbox + +async def main(): + sandbox = await Sandbox.create( + provider="alicloud-express", + template=os.environ["EXISTING_TEMPLATE_ID"], + timeout=300, + ) + try: + result = await sandbox.commands.run("printf migration-ok") + assert result.exit_code == 0 and result.stdout == "migration-ok" + finally: + await sandbox.kill() # only the sandbox this example created + +asyncio.run(main()) +``` + +To inspect an existing live sandbox, connect with its ID and same-Team key. Do not +add it to an automatic deletion ledger merely because you connected to it. Preserve +its timeout and workload unless the owner explicitly authorizes changes. + +Ordinary native lifecycle/exec/files do not require installing this project's +port-9000 server into an existing image. Project named commands, agents routes and +their template capabilities are additional runtime requirements; an arbitrary +pre-existing template need not provide them. Provider plugins and management APIs +also need the compatible Node engine, unlike the native Express lifecycle path. + +## Limits, evidence and upgrades + +Create-time `volume_mounts={"/mnt/workspace": "existing-volume-name"}` reuses +same-Team Volume metadata and its storage role. It does not provision storage or +prove permissions. The [recorded real OSS run](../../../.agents/evidence/verify/2026-10-16-provider-volume-live.md) +passed mount/read/write, sandbox recreation and owned-resource cleanup for its +tested base template/region. It is not evidence of all Express capabilities. +Domain management, dynamic mount/attach, STS management tokens, newer JuiceFS fields +and real `volume.update` state transitions remain unsupported or unverified as +documented by the [shared host matrix](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/en/design/alicloud-provider-commands.md). + +External plugins update from their recorded source. The bundled provider follows +the SDK release: `python -m agent_sandbox.providers --json update alicloud-express --check` +returns `bundled` and guidance, not a latest-version check or a software update. +Upgrade matching client/server versions with your original install method; upgrade +abx separately for Node management. Restart only engines you own. The host's +[ownership/update design](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/c5a6a3c75921cfb4359f1c23041ec847ce63c932/docs/en/design/provider-portability-and-updates.md) +defines consent, pinning, rollback and the pending template-adapter work. diff --git a/docs/en/guide/migrate-from-e2b.md b/docs/en/guide/migrate-from-e2b.md index 5dcda10..952ab2a 100644 --- a/docs/en/guide/migrate-from-e2b.md +++ b/docs/en/guide/migrate-from-e2b.md @@ -172,3 +172,5 @@ sandbox = await Sandbox.create( - [Authentication](authentication.md) — Learn about all authentication methods - [SDK Usage Guide](sdk-usage.md) — Complete SDK usage - [E2B Compatibility Explained](../explanation/e2b-compatibility.md) — Design background and rationale + +If the existing E2B client already targets Alibaba Cloud Express, use the [existing Express account migration guide](migrate-from-alicloud-express.md). Resources can stay in place; client/configuration compatibility still needs review. diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md new file mode 100644 index 0000000..4eb1253 --- /dev/null +++ b/docs/en/guide/provider-commands.md @@ -0,0 +1,153 @@ +# Provider management commands + +Provider commands manage resources owned by a selected cloud backend, such as volumes or +custom domains. They are separate from sandbox shell execution and the named commands +registered in `agent-sandbox-server`. A provider can declare its own operations without +adding top-level CLI commands or changing another provider's command set. + +The default provider does not advertise `storage.mount`, `ports.domain` or `network.vpc`: the standard sandbox protocol has no callable operation for those tokens. Provider resource management uses declared commands; a volume management command does not imply live sandbox mounting. + +## Mount an existing Volume when creating a sandbox + +The built-in provider accepts `volume_mounts` in `Sandbox.create()` (and `create_sync`): + +```python +from agent_sandbox import Sandbox + +async with await Sandbox.create( + provider="alicloud-express", + template="my-template", + volume_mounts={"/mnt/data": "existing-volume-name"}, +) as sandbox: + print(await sandbox.files.list("/mnt/data")) +``` + +The dictionary maps absolute sandbox paths to existing **Volume names**, not IDs. The Volume +and API key must use the same Team and region. For OSS, configure the Volume's RAM role; +`metadata["fc.sandbox.auth.role"]` overrides that role when supplied. Check the +[official mount guide](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1) +for storage permissions, template/runtime prerequisites and storage-specific limits. +Volume currently requires invitation access through an Alibaba Cloud support ticket; +confirm access for the target account, region and Team before preparing storage, as +described in the [official OSS Volume guide](https://help.aliyun.com/zh/agent-sandbox/user-guide/create-oss-volume). +An empty successful Volume list does not prove create or mount access. +Custom MicroVM source images must include ossfs2 2.0.10 or newer. Select a template +that meets the official mount guide's runtime prerequisites before running acceptance. + +Paths must be normalized absolute Unix paths other than `/`, with no trailing separator, +empty or hidden component, `.`/`..`, NUL or surrounding whitespace. `/bin`, `/opt`, `/var`, +`/dev` and their descendants are rejected. Names must be nonblank strings without surrounding +whitespace or NUL. Invalid hints fail before provider construction or engine I/O. Nonempty +hints for other providers fail with E8004 before loading their implementation. Omitted/empty +dictionaries preserve existing request payloads. + +This creation hint is separate from management commands and does not add a portable +`storage.mount` protocol or runtime attach API. Python's built-in lifecycle retains its local +gRPC engine: additive `CreateRequest.volume_mounts` carries the dictionary, and the existing +Python protocol serializes `POST /sandboxes` as +`"volumeMounts": [{"name": "existing-volume-name", "path": "/mnt/data"}]`. +For nonempty hints, the client first checks `Health.supported_create_options` contains +`volume_mounts`. An older live engine fails with E8004 and an upgrade/restart suggestion, +before `Create`; it is never replaced automatically. This prevents older protobuf handlers +from silently ignoring a mount request. Calls without mounts retain the existing behavior. +Provider management commands continue using the shared Node HTTP engine. Mount wiring is +covered by local gRPC/HTTP regression tests and real OSS mount/read/write/cleanup acceptance +on the base template in cn-hangzhou. The opt-in Volume E2E suite also checks that data survives +sandbox destruction and recreation. + +## Python API + +```python +from agent_sandbox.providers import commands + +provider = "alicloud-express" +operations = await commands.list_commands(provider) +for operation in operations: + print(operation["id"], operation["effect"], operation["input_schema"]) +``` + +After choosing a declared command and building `payload` according to its schema, call +`await commands.get_command(provider, command_id)` to inspect it and +`await commands.run_command(provider, command_id, input=payload, confirm=False)` to run it. + +All functions are asynchronous and require an explicit provider id. Discovery (`list_commands` +and `get_command`) takes only IDs and never resolves or forwards credentials. Invocation +(`run_command`) accepts `provider_options={...}` and canonical overrides accepted by `load_config`, +including `sandbox_api_key`, `sandbox_base_url`, `region` and `access_key_id` where applicable. +The resolved connection always names the same provider as the URL. Only declared provider +configuration fields and consented generic credentials are forwarded. + +`list_commands` returns descriptor dictionaries, `get_command` returns one descriptor, +and `run_command` returns the command's JSON result. Read the schema before supplying input. +Unknown or unavailable operations fail through the engine's typed error contract, including +E8004 for unsupported commands. The SDK does not create or connect a sandbox for these calls. + +Writes and deletes require `confirm=True`, while the default is `False`. Set it only after +reviewing the selected operation and resource identifiers. This requirement is enforced by +the engine. No interactive prompt is opened by the SDK. + +**The SDK never retries a provider-command POST**, even for an idempotent operation, a lost +connection or HTTP 401. If the response is lost, inspect provider state before deciding to +call again. The `idempotent` descriptor is information for the caller, not permission for +transport replay. GET discovery retains the existing engine reconnection behavior. + +## Manifest contract + +Only `node` and `exec` plugins can declare nonempty command sets. Existing manifests without +a `[commands]` section retain an empty set. An explicit empty section must contain +`protocol = "provider-commands/1"` and `entries = []`; a `rest` provider may use that form. + +```toml +[commands] +protocol = "provider-commands/1" + +[[commands.entries]] +id = "volumes.list" +description = "List provider volumes" +description_zh = "列出提供方存储卷" +input_schema = { type = "object", properties = {}, additionalProperties = false } +output_schema = { type = "object" } +effect = "read" +idempotent = true +``` + +This is a descriptor example, not a promise that a particular installed provider implements +`volumes.list`. IDs use `^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$` with at most 80 characters; +duplicate IDs and unknown descriptor fields are rejected. Both descriptions are required. +`effect` is `read`, `write` or `delete`, and `idempotent` is an explicit boolean. A manifest +has at most 128 commands; each entry is limited to 64 KiB of compact UTF-8 JSON and depth 16. + +Schemas use JSON Schema draft 7. Input roots must specify `type = "object"` and +`additionalProperties = false`; output schemas must be schema objects. Schema references, +`$id`, `$async`, `pattern`, `patternProperties` and `format` are excluded as schema +keywords. Business property names such as `format` remain valid. JSON keys +`__proto__`, `prototype` and `constructor` are excluded at every depth. Finite JSON values +are required. Python validates this structural contract when loading a manifest; the Node +host performs the authoritative strict schema compilation and validates input and output. +The SDK does not maintain another implementation of cloud business logic or schema evaluation. + +Command descriptors, including effects and schemas, are part of the install consent summary. +Changing them during update requires renewed consent. Old receipts without a `commands` +field mean an empty list, so unchanged legacy plugins do not acquire an extra risk change. + +## Engine protocol + +The shared local engine exposes these authenticated routes: + +| Method | Route | Result / body | +| --- | --- | --- | +| GET | `/v1/providers/:id/commands` | `{ "commands": [descriptor, ...] }` | +| GET | `/v1/providers/:id/commands/:command` | One descriptor | +| POST | `/v1/providers/:id/commands/:command` | Body `{ "input": {}, "confirm": false }`; returns command result | + +Every request carries the existing bearer token and `X-Abx-Connection` header. The provider +in that connection must match the route. Existing commands and features remain independent. +A `node` plugin returns `provider_commands.run(commandId, input, signal)` from `buildInfra`; +an `exec` plugin dispatches EPP `provider.commands.run` with `{command, input}`. Hosts dispatch +only manifest-declared IDs. A missing handler remains unsupported. + +The CLI owns the implementation and the command surface. See the +[shared engine specification](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/reference/engine-api.md) +and [provider architecture](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/design/node-provider-architecture.md). +Install a compatible `abx` on `PATH`, or set `ABX_BIN`; older engines without these routes +cannot execute provider commands. Plugin code still runs with the documented host privileges. diff --git a/docs/en/guide/testing-e2e.md b/docs/en/guide/testing-e2e.md index 286c018..7eae911 100644 --- a/docs/en/guide/testing-e2e.md +++ b/docs/en/guide/testing-e2e.md @@ -45,3 +45,12 @@ Outside the test suite, the walkthrough that runs the example scripts on the def ## Local HTTP and PTY fixture ports The client component fixture selects distinct HTTP and PTY port candidates before starting either listener. A released ephemeral candidate may be returned again; selection retries are bounded and preserve an explicit PTY port. These local allocation retries do not replay a request or replace real-cloud acceptance. The deterministic reuse regression starts the actual server and verifies both listeners, then normal fixture cleanup shuts them down. + + +## Local raw HTTP fixtures + +Raw requests identify the actual listener authority. Fixtures wait for their HTTP server +to initialize within the existing deadline and read the complete framed response. An +intermediary can reject intentionally malformed HTTP/WebSocket traffic before it reaches +the handler; keep those failures visible and run the unchanged assertions in hosted CI. +Do not substitute skips, longer timeouts or proxy policy changes for protocol coverage. diff --git a/docs/en/reference/api-reference.md b/docs/en/reference/api-reference.md index 4c4a9c8..b71170c 100644 --- a/docs/en/reference/api-reference.md +++ b/docs/en/reference/api-reference.md @@ -24,6 +24,7 @@ async def create( request_timeout: float | None = None, metadata: dict[str, str] | None = None, envs: dict[str, str] | None = None, + volume_mounts: dict[str, str] | None = None, cpu: int | None = None, memory: int | None = None, disk: int | None = None, @@ -46,6 +47,7 @@ Create a new sandbox. | `request_timeout` | `float` | `None` | Per-request HTTP timeout (seconds) for the create call only. When `None`, uses `SANDBOX_HTTP_TIMEOUT` / `http_timeout`. Aligns with the E2B SDK. Raising it does not work around platform-side create stalls. | | `metadata` | `dict` | `None` | Arbitrary metadata key-value pairs | | `envs` | `dict` | `None` | Injected environment variables | +| `volume_mounts` | `dict[str, str]` | `None` | Built-in provider only: normalized absolute sandbox path → existing Volume name. Omitted/empty means no mounts. Other providers reject nonempty hints with E8004. See [mount requirements and wire schema](../guide/provider-commands.md#mount-an-existing-volume-when-creating-a-sandbox). | | `cpu` | `int` | `None` | Number of CPU cores | | `memory` | `int` | `None` | Memory in MB | | `disk` | `int` | `None` | Disk in MB | diff --git a/docs/zh/README.md b/docs/zh/README.md index 38cdfc9..bff274f 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -44,6 +44,7 @@ Agent Sandbox 是可切换提供方的沙箱 SDK。默认提供方是阿里云 | [会话持久化](guide/session-persistence.md) | 本地会话存储 | | [E2B 迁移](guide/migrate-from-e2b.md) | 从 E2B 迁移 | | [提供方](guide/providers.md) | 选择、安装、编写提供方 | +| [提供方管理命令](guide/provider-commands.md) | 通过共享引擎发现和调用提供方资源操作 | | [环境变量](guide/environment-variables.md) | 注入与沙箱内读取 | | [故障排查](guide/troubleshooting.md) | 常见问题 | | [对比](guide/comparison.md) | 与 E2B、Modal、Docker Sandbox | @@ -144,3 +145,5 @@ Agent Sandbox 是可切换提供方的沙箱 SDK。默认提供方是阿里云 ## 相关:abx CLI `abx` CLI(npm 包 `@agent-sandbox/cli`)的文档位于 [`cli` 仓库](https://github.com/alibaba-agent-sandbox/agent-sandbox):[文档中心](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/README.md)、[快速开始](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/guide/getting-started.md)、[CLI 参考](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/reference/cli-reference.md)、[CLI 架构](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/design/architecture.md)、[CLI 命令体系](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/design/cli-design.md)、[模板目录](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/design/templates-catalog.md)、[部署与构建](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/guide/deploy-and-build.md)、[Agent Skill 安装与分发](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/guide/agent-skill-installation.md)。 + +- [接入已有极速版账号](guide/migrate-from-alicloud-express.md):资源复用、API/配置差异及内置 provider 更新。 diff --git a/docs/zh/design/architecture.md b/docs/zh/design/architecture.md index 25d7f09..0d21fb3 100644 --- a/docs/zh/design/architecture.md +++ b/docs/zh/design/architecture.md @@ -249,6 +249,8 @@ CLI(`abx`)原先位于 `src/agent_sandbox/cli/`(`commands/` 下的 Click 5. **AI First**:沙箱内置 AI CLI 工具、MCP Server 是一等公民,SDK 零 LLM 依赖 6. **类型安全**:全面使用 Python Type Hints + Pydantic 验证 +- [Provider implementation boundaries](provider-isolation.md) + 提供方配置发现由包入口延迟装配。传输配置只消费 `_provider_schema.py` 中的低层 `ProviderSchema` 值与注入的解析器,不导入提供方 registry。内置配置不触发提供方发现;注册、entry-point 和已安装提供方仍遵循原有选择、派生、默认值及继承规则。原 `transport.config.ProviderSchema` 导入保留为同一类的别名。 内置平台/envd HTTP 每次执行一次传输尝试。旧重试字段作为兼容数据保留,边界见[配置说明](../reference/configuration.md#旧重试配置的兼容边界),引擎恢复另行处理。 diff --git a/docs/zh/design/provider-isolation.md b/docs/zh/design/provider-isolation.md new file mode 100644 index 0000000..3c4a63e --- /dev/null +++ b/docs/zh/design/provider-isolation.md @@ -0,0 +1,55 @@ +# Provider 实现边界 + +所有生命周期操作均选择一个 `Provider`,内置极速版也走同一条路径,不再用 null +绕过 Provider。核心 API 依赖结构化协议和认证契约,不实例化云 SDK、不交换云凭据、 +不推导后端地址,也不替后端选择协议。 + +## 职责归属 + +| 职责 | 归属 | +| --- | --- | +| Provider 选择、授权同意、完整性校验、输出、通用配置优先级、客户端资源关闭 | 宿主 | +| 认证、端点、协议映射、后端错误和状态、生命周期、可选创建参数 | 所选 Provider | +| 模板认证、构建、注册、轮询、删除以及 Docker/镜像仓库细节 | Provider 模板实现 | +| 模板源码搜索下载、项目生成、运行时验证 | 宿主,通过所选 Provider 的元数据和工厂调用 | +| 极速版 SDK、AK/SK 签名和换取凭据、FC/ACR 部署、CLI 参数和提示、AI 管理工具定义 | `providers/alicloud_express/` | +| 可复用的 E2B/envd 协议及 token 头格式 | `providers/shared/e2b/`,由 Provider 显式选用 | + +发行包的组装入口注册内置适配器、配置描述、CLI 扩展和 AI 扩展。生成的配置目录属于 +声明式兼容数据。历史 `api`、`protocol`、`transport` 路径保留导出兼容层,不保留第二份 +实现或核心兜底。共享 HTTP/Connect 传输处理帧和连接复用;认证和协议由 Provider 选择, +传输层不选择云后端。 + +## SDK 契约 + +内置默认项和插件均通过 `Provider.build_infra(config)` 构建基础设施。 +`session_protocols`、`prepare_session`、`resolve_session_url`、`open_terminal` +负责各自会话。极速版直接使用原生 HTTP/Connect/WebSocket 适配器;已退役的 +Python gRPC 引擎及生命周期钩子保持删除。外部 Node 引擎/EPP 客户端仍是独立的 +Provider 契约。 + +`TransportConfig(provider="external")` 和 `load_config` 均不补上未声明的域名或 +region,显式端点保持有效。创建绑定失败回收本次新建资源;连接失败保留借用资源。 +公开兼容导出保留历史 monkeypatch 的模块身份。缺失可选协议返回 E8004,不回退极速版。 + +CLI 模板工厂及 CLI/AI 发现归属 CLI 仓库,本次不新增 Python 镜像部署 API,详见 +[CLI 边界契约](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/zh/design/provider-isolation.md)。 +内置 Provider 继续随匹配的 SDK 版本更新。适配器隔离不代表 SDK 零改动迁移,也不会 +产生后端本身没有的能力。 + +调用方显式选择的 Provider 优先于环境默认项。内置配置纯数据归属 +`utils/bundled_config.py`;传输层只消费 `_provider_schema.py` 契约及注入的 +发现函数,不导入可执行的注册表。 + + + +## 端口与文件 URL 的归属 + +同步端口访问和项目服务的网关鉴权头由所选适配器实现。只有异步端口发现能力的 +Provider 应调用 `await sandbox.network.resolve(port)`;同步辅助方法返回 E8004, +不会猜测极速版或 E2B 地址,也不会发送其他后端的令牌。独立构造网络模块时仍保留 +已文档化的内置兼容行为。文件传输 URL 是文件系统适配器的可选钩子;即便会话地址 +使用 HTTP,缺少钩子也返回 E8004。核心不会为其他 Provider 构造 `/files` 路由。 + +Python 中立传输和协议模块的导入不会加载内置认证或具体协议;显式请求历史兼容 +导入时再延迟解析。 diff --git a/docs/zh/guide/migrate-from-alicloud-express.md b/docs/zh/guide/migrate-from-alicloud-express.md new file mode 100644 index 0000000..3a0453e --- /dev/null +++ b/docs/zh/guide/migrate-from-alicloud-express.md @@ -0,0 +1,82 @@ +# 在已有极速版账号上使用 Agent Sandbox + +这里更换的是客户端和工具,不是云后端。原账号、地域、Team、API Key、模板 ID/别名、 +Volume、RAM 角色都可以继续使用,不必仅为接入客户端而重建资源。目前没有已实现的 +零改动或一键迁移;应核对应用实际使用的 API。 + +本指南面向 Python 和 CLI。公开的 +[Node SDK](https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/blob/main/README.md) +当前只有 `ServerClient` 和配置名称,没有云端沙箱生命周期;不支持把 E2B JavaScript +应用仅改导入到该包就完成迁移。 + +## 需要修改的内容 + +| 原极速版应用 | Agent Sandbox 客户端 | +| --- | --- | +| `from e2b import Sandbox` | `from agent_sandbox import Sandbox` | +| 隐式读取 `E2B_API_KEY`、`E2B_API_URL`、`E2B_DOMAIN` | 显式配置 `ABX_SANDBOX_API_KEY`、`ABX_SANDBOX_BASE_URL`、`ABX_SANDBOX_DOMAIN`,不静默读取旧名称。 | +| `Sandbox.kill(id)` | `Sandbox.kill_by_id(id)` | +| `sandbox.get_host(port)` | `sandbox.network.get_host(port)` | +| 假定上传/下载 URL 可凭签名访问 | 使用带认证的文件方法,见 [E2B 迁移差异](migrate-from-e2b.md)。 | +| 原 API、结果和错误处理 | 核对方法、流式输出、异常及同步/异步行为,同名方法不等于完整兼容。 | + +若当前 shell 中确实配置的是**同一个极速版账号**,可以显式映射选定设置,而不打印值: + +```sh +export ABX_SANDBOX_API_KEY="$E2B_API_KEY" # example: use the existing Express value +export ABX_SANDBOX_BASE_URL="$E2B_API_URL" +export ABX_SANDBOX_DOMAIN="$E2B_DOMAIN" +``` + +不要启用 shell 跟踪,也不要从原生 E2B 账号复制这组配置。继续使用原模板 ID/别名和 +原地域端点。普通沙箱密钥生命周期、执行和文件调用无需 AK/SK;云管理、模板发布另需 +AK/SK 及镜像仓库前提。API 的 `api_key`、`api_url`、`domain` 仍是对应规范配置的兼容 +别名,与规范名称同时提供不同值时会拒绝。 + +## 用新建测试沙箱验收 + +按[安装指南](getting-started.md) 安装兼容的 client/server 发布版本后,选择已有模板: + +```python +import asyncio +import os +from agent_sandbox import Sandbox + +async def main(): + sandbox = await Sandbox.create( + provider="alicloud-express", + template=os.environ["EXISTING_TEMPLATE_ID"], + timeout=300, + ) + try: + result = await sandbox.commands.run("printf migration-ok") + assert result.exit_code == 0 and result.stdout == "migration-ok" + finally: + await sandbox.kill() # 只清理本例自己创建的沙箱 + +asyncio.run(main()) +``` + +检查已有存活沙箱时,用其 ID 和同 Team 密钥连接。连接不等于允许加入自动删除清单; +未经所有者授权,不改变其租期和工作负载。 + +普通原生生命周期、执行和文件访问,不要求先给已有镜像安装本项目的 9000 端口服务。 +项目命名命令、Agents 路由和对应模板能力属于额外运行时要求,任意旧模板未必提供。 +Provider 插件和管理 API 还需要兼容的 Node 引擎,与原生极速版生命周期路径不同。 + +## 边界、证据与升级 + +创建时 `volume_mounts={"/mnt/workspace": "existing-volume-name"}` 复用同 Team 的 +Volume 和存储角色,不创建底层存储,也不自动证明权限正确。 +[已有真实 OSS 验收](../../../.agents/evidence/verify/2026-10-16-provider-volume-live.md) +覆盖记录中的 base 模板/地域下挂载、读写、沙箱重建持久化和本次资源清理,不代表极速版 +全部能力已验收。域名管理、动态追加挂载、STS 管理凭据、较新 JuiceFS 字段及 +`volume.update` 的真实状态转换仍不支持或未验证,以 +[共享宿主矩阵](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/zh/design/alicloud-provider-commands.md) 为准。 + +外置插件按记录的来源更新。内置 provider 随 SDK 发布: +`python -m agent_sandbox.providers --json update alicloud-express --check` 返回 +`bundled` 和指引,不检查最新版本、不执行软件升级。按原安装方式同步升级匹配的 +client/server,Node 管理操作还需单独升级 abx。只重启自己管理的引擎。 +[宿主职责/更新设计](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/c5a6a3c75921cfb4359f1c23041ec847ce63c932/docs/zh/design/provider-portability-and-updates.md) +定义确认、固定版本、回滚及待完成的模板适配器工作。 diff --git a/docs/zh/guide/migrate-from-e2b.md b/docs/zh/guide/migrate-from-e2b.md index 473ff0f..2161d72 100644 --- a/docs/zh/guide/migrate-from-e2b.md +++ b/docs/zh/guide/migrate-from-e2b.md @@ -171,3 +171,5 @@ sandbox = await Sandbox.create( - [认证详解](authentication.md) — 了解完整的认证方式 - [SDK 使用指南](sdk-usage.md) — SDK 完整使用方法 - [E2B 兼容性说明](../explanation/e2b-compatibility.md) — 设计背景与原因 + +如果现有 E2B 客户端已经连接阿里云极速版,参阅[已有极速版账号迁移指南](migrate-from-alicloud-express.md)。云端资源可保留,客户端及配置兼容性仍需核对。 diff --git a/docs/zh/guide/provider-commands.md b/docs/zh/guide/provider-commands.md new file mode 100644 index 0000000..e3820f4 --- /dev/null +++ b/docs/zh/guide/provider-commands.md @@ -0,0 +1,138 @@ +# 提供方管理命令 + +提供方命令管理所选云后端拥有的资源,例如存储卷或自定义域名。它们与沙箱 shell 执行、 +`agent-sandbox-server` 注册的具名命令分别使用独立入口。提供方可以声明自己的操作, +无需添加 CLI 顶层命令,也不会改变其他提供方的命令集合。 + +默认提供方不再声明 `storage.mount`、`ports.domain` 或 `network.vpc`:标准沙箱协议没有这些 token 对应的可调用操作。提供方资源管理通过已声明的命令完成;存储卷管理命令不代表支持运行中沙箱挂载。 + +## 创建沙箱时挂载已有 Volume + +内置提供方的 `Sandbox.create()`(以及 `create_sync`)接受 `volume_mounts`: + +```python +from agent_sandbox import Sandbox + +async with await Sandbox.create( + provider="alicloud-express", + template="my-template", + volume_mounts={"/mnt/data": "existing-volume-name"}, +) as sandbox: + print(await sandbox.files.list("/mnt/data")) +``` + +字典表示“沙箱内绝对路径 → 已有 Volume **名称**”,不是 Volume ID。Volume 和 API Key +必须属于同一 Team,使用相同地域。OSS 应配置 Volume 的 RAM 角色;显式传入 +`metadata["fc.sandbox.auth.role"]` 时以该角色为准。存储权限、模板与运行时前提和各存储类型 +的限制见[官方挂载指南](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1)。 +Volume 目前要求提交阿里云工单申请邀测,准备存储前应确认目标账号、地域和 Team +已开通,参见[官方 OSS Volume 指南](https://help.aliyun.com/zh/agent-sandbox/user-guide/create-oss-volume)。 +成功返回空列表不能证明已有创建或挂载权限。 +自定义 MicroVM 源镜像须预装 ossfs2 2.0.10 或以上版本;运行验收前,应选择满足 +官方挂载指南中运行时前提的模板。 + +路径必须是规范化的绝对 Unix 路径,不能为 `/`,不能包含尾部分隔符、空段、隐藏目录段、 +`.`/`..`、NUL 或前后空白。`/bin`、`/opt`、`/var`、`/dev` 及其子路径会被拒绝。 +名称必须是非空字符串,不能包含前后空白或 NUL。无效参数会在构造提供方和引擎 I/O 前失败; +其他提供方的非空挂载参数会在加载插件实现前返回 E8004。省略参数或传空字典不改变原请求。 + +这是创建参数,与资源管理命令独立,不增加可移植的 `storage.mount` 协议或运行时追加挂载 API。 +Python 内置生命周期仍通过本地 gRPC 引擎:新增的 `CreateRequest.volume_mounts` 传递字典, +由现有 Python 协议序列化为 `POST /sandboxes` 的 +`"volumeMounts": [{"name": "existing-volume-name", "path": "/mnt/data"}]`。 +非空参数会先检查 `Health.supported_create_options` 是否包含 `volume_mounts`;旧的运行中引擎 +会在 `Create` 前返回 E8004 和升级、重启提示,不会自动替换引擎,避免旧 protobuf 实现静默 +忽略挂载参数。未请求挂载的调用保留原行为。 +提供方管理命令继续使用共享 Node HTTP 引擎。本地 gRPC/HTTP 回归覆盖参数链路,且已在 +杭州地域的 base 模板完成真实 OSS 挂载、读写和清理验收。显式启用的 Volume E2E 套件还会检查 +销毁并重建沙箱后数据是否保留。 + +## Python API + +```python +from agent_sandbox.providers import commands + +provider = "alicloud-express" +operations = await commands.list_commands(provider) +for operation in operations: + print(operation["id"], operation["effect"], operation["input_schema"]) +``` + +选择已声明的 `command_id` 并按 schema 构造 `payload` 后,使用 +`await commands.get_command(provider, command_id)` 查看描述,使用 +`await commands.run_command(provider, command_id, input=payload, confirm=False)` 调用。 + +这三个函数都是异步函数,必须明确指定提供方 ID。发现接口 `list_commands` 和 `get_command` +只接受 ID,不解析或转发凭据。调用接口 `run_command` 接受 `provider_options={...}` 及 +`load_config` 支持的规范配置覆盖项,例如适用的 `sandbox_api_key`、`sandbox_base_url`、 +`region` 和 `access_key_id`。解析后的连接与 URL 始终指定同一个提供方。只转发该提供方 +声明的配置项及用户已同意继承的通用凭据。 + +`list_commands` 返回描述字典列表,`get_command` 返回单个描述,`run_command` 返回操作的 +JSON 结果。调用前先阅读输入 schema。未知或不可用的操作遵循引擎的类型化错误契约, +不支持的命令包括 E8004。这些函数不会创建或连接沙箱。 + +写入和删除操作必须传 `confirm=True`,默认值是 `False`。应先审阅具体操作和资源标识, +再明确确认。引擎负责执行此要求;SDK 不会弹出交互式提示。 + +**SDK 绝不自动重试提供方命令的 POST**:即使描述声明幂等、连接断开或返回 HTTP 401, +也不会重放。如果响应丢失,应先查看提供方的实际资源状态,再决定是否重新调用。 +`idempotent` 供调用方判断,不代表传输层可以自动重放。GET 发现接口保留现有引擎重连行为。 + +## Manifest 契约 + +只有 `node` 和 `exec` 插件可以声明非空命令集合。没有 `[commands]` 的旧 manifest +继续表示空集合。显式空区块必须包含 `protocol = "provider-commands/1"` 和 +`entries = []`;`rest` 提供方可以使用这种形式。 + +```toml +[commands] +protocol = "provider-commands/1" + +[[commands.entries]] +id = "volumes.list" +description = "List provider volumes" +description_zh = "列出提供方存储卷" +input_schema = { type = "object", properties = {}, additionalProperties = false } +output_schema = { type = "object" } +effect = "read" +idempotent = true +``` + +此例展示描述格式,不保证某个已安装提供方实现了 `volumes.list`。ID 遵循 +`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$`,最多 80 个字符;重复 ID 和未知描述字段会被拒绝。 +两种语言的描述都是必填项。`effect` 为 `read`、`write` 或 `delete`;`idempotent` 必须明确 +使用布尔值。最多声明 128 项,每项压缩 UTF-8 JSON 不超过 64 KiB,深度不超过 16。 + +Schema 使用 JSON Schema draft 7。输入根节点必须指定 `type = "object"` 和 +`additionalProperties = false`,输出 schema 必须是对象。禁止 schema 引用、`$id`、 +`$async`、`pattern`、`patternProperties` 和 `format` 作为 schema 关键字;名为 +`format` 的业务属性仍然有效。所有深度均禁止 JSON 键 +`__proto__`、`prototype`、`constructor`。只接受有限的 JSON 数值。Python 加载 manifest +时验证结构契约;Node 宿主进行最终严格 schema 编译,并验证输入和输出。 +SDK 不重复实现云端业务逻辑或 schema 求值。 + +命令描述(包括 effect 和 schema)纳入安装确认摘要。更新时改变描述会要求重新确认。 +旧安装回执若缺少 `commands`,视为空列表,未变更的旧插件不会因此多出风险差异。 + +## 引擎协议 + +共享本机引擎提供以下已鉴权路由: + +| 方法 | 路由 | 结果或请求体 | +| --- | --- | --- | +| GET | `/v1/providers/:id/commands` | `{ "commands": [descriptor, ...] }` | +| GET | `/v1/providers/:id/commands/:command` | 单个描述 | +| POST | `/v1/providers/:id/commands/:command` | 请求体 `{ "input": {}, "confirm": false }`;返回命令结果 | + +所有请求使用现有 bearer token 和 `X-Abx-Connection` 请求头。连接中的提供方必须与路由 +一致。既有命令和 features 保持独立。`node` 插件从 `buildInfra` 返回 +`provider_commands.run(commandId, input, signal)`;`exec` 插件分发 EPP +`provider.commands.run`,参数为 `{command, input}`。宿主只分发 manifest 声明的 ID; +缺少 handler 继续表示不支持。 + +实现和 CLI 命令入口由 CLI 仓库维护。参阅 +[共享引擎规范](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/reference/engine-api.md) +和[提供方架构](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/design/node-provider-architecture.md)。 +需要将兼容的 `abx` 放入 `PATH`,或设置 `ABX_BIN`。旧引擎没有这些路由时无法执行提供方命令。 +插件仍使用已文档化的宿主权限运行。 diff --git a/docs/zh/guide/testing-e2e.md b/docs/zh/guide/testing-e2e.md index d9c935c..4e5266e 100644 --- a/docs/zh/guide/testing-e2e.md +++ b/docs/zh/guide/testing-e2e.md @@ -45,3 +45,11 @@ make e2e-cleanup # 清理遗留资源的 dry-run janitor;ARGS=--yes 才 ## 本地 HTTP 与 PTY 夹具端口 客户端组件夹具启动监听前会选择不同的 HTTP 与 PTY 端口候选。刚释放的临时端口可能被再次分配,候选重选有次数上限,并保留显式指定的 PTY 端口。这种本地分配重选不重放请求,也不替代真实云验收。确定性复用回归启动真实服务、检查两个监听器,再由正常夹具清理将它们停止。 + + +## 本地原始 HTTP 测试 + +原始请求使用实际监听地址。测试夹具在原有期限内等待 HTTP 服务初始化,并读取完整的 +分帧响应。中间层可能在请求到达服务前拒绝故意构造的畸形 HTTP/WebSocket 流量; +这类失败必须保留,并由托管 CI 执行原有断言。不得用跳过、扩大超时或改变代理策略 +代替协议覆盖。 diff --git a/docs/zh/reference/api-reference.md b/docs/zh/reference/api-reference.md index 78da946..53f150d 100644 --- a/docs/zh/reference/api-reference.md +++ b/docs/zh/reference/api-reference.md @@ -24,6 +24,7 @@ async def create( request_timeout: float | None = None, metadata: dict[str, str] | None = None, envs: dict[str, str] | None = None, + volume_mounts: dict[str, str] | None = None, cpu: int | None = None, memory: int | None = None, disk: int | None = None, @@ -46,6 +47,7 @@ async def create( | `request_timeout` | `float` | `None` | 仅针对本次创建请求的 HTTP 超时(秒)。为 `None` 时使用 `SANDBOX_HTTP_TIMEOUT` / `http_timeout`。与 E2B SDK 对齐。调大它无法绕过平台侧创建挂起。 | | `metadata` | `dict` | `None` | 任意元数据键值对 | | `envs` | `dict` | `None` | 注入的环境变量 | +| `volume_mounts` | `dict[str, str]` | `None` | 仅内置提供方:规范化的沙箱绝对路径 → 已有 Volume 名称。省略或空字典不挂载,其他提供方的非空参数返回 E8004。见[挂载要求与请求 schema](../guide/provider-commands.md#创建沙箱时挂载已有-volume)。 | | `cpu` | `int` | `None` | CPU 核数 | | `memory` | `int` | `None` | 内存 MB | | `disk` | `int` | `None` | 磁盘 MB | diff --git a/e2e/README.md b/e2e/README.md index 47d7ba1..acf75ae 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -47,6 +47,7 @@ Third-party providers are not part of this suite's cloud run. They follow the sa | `test_06_server_sdk.py` | `agent-sandbox-server` inside a real sandbox (template `python-hello`): `/commands`, `/files/*`, `/process/*`, `/capabilities`, `/system/info`, `/env`, `/ports`, custom routes | | `test_07_declarative.py` | `@sandbox` declarative remote execution | | `test_08_providers.py` | Live calls for `opensandbox`, `alicloud-express` (SDK and CLI), `cubesandbox`, `e2b`, and `alicloud-container`. A provider with no backend of its own is skipped and does not receive the Express key. | +| `test_09_volume_mounts.py` | Opt-in existing Volume acceptance: prove an exact kernel mount, exclusively create/read/remove a sentinel, and verify persistence after destroying and recreating a sandbox. All tagged sandboxes are released. Ordinary directories cannot pass. | | `test_09_mcp_lifecycle.py` | Python MCP metadata/TTL/list, command/files and external-instance ownership | Markers: `e2e` (everything), `e2e_slow` (> ~60 s), @@ -95,6 +96,10 @@ valid credential refusals. Cube live acceptance requires an explicitly configure | `E2E_SANDBOX_TTL` | 300 | sandbox lifetime in seconds | | `E2E_SERVER_TEMPLATE` | `python-hello` | template with `agent-sandbox-server` preinstalled | | `E2E_RUN_ID` | generated | tag value (`e2e=`) | +| `ABX_E2E_VOLUME_MOUNT` | unset; skips | Set to `1` to permit the Volume acceptance test. | +| `ABX_E2E_VOLUME_NAME` | unset; skips | Existing writable Volume name in the API key's Team/region, with its storage role configured. No Volume is provisioned or deleted. | +| `ABX_E2E_VOLUME_MOUNT_PATH` | unset; skips | Dedicated canonical sandbox path under `/mnt/abx-e2e/`. This narrower path rule belongs to the test harness. | +| `E2E_VOLUME_TEMPLATE` | unset; skips | Template meeting the storage/runtime requirements in the [official mount guide](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1). | ## Adding a test diff --git a/e2e/README.zh-CN.md b/e2e/README.zh-CN.md index 436ea82..eb1e904 100644 --- a/e2e/README.zh-CN.md +++ b/e2e/README.zh-CN.md @@ -42,6 +42,7 @@ cd e2e && python -m pytest -m "not e2e_slow" -ra # 等同 `make e2 | `test_06_server_sdk.py` | 真实沙箱(模板 `python-hello`)内的 `agent-sandbox-server`:`/commands`、`/files/*`、`/process/*`、`/capabilities`、`/system/info`、`/env`、`/ports`、自定义路由 | | `test_07_declarative.py` | `@sandbox` 声明式远程执行 | | `test_08_providers.py` | 真实后端调用:内置 `alicloud-express`(SDK 和 CLI),以及已安装的目录提供方 `opensandbox`、`cubesandbox`、`e2b`、`alicloud-container`。没有自己的后端时跳过,并且不会把极速版密钥发给它。 | +| `test_09_volume_mounts.py` | 显式启用的已有 Volume 验收:确认内核精确挂载点,独占创建、读取并删除一个测试文件,再释放带标签的沙箱。普通目录不能通过。 | | `test_09_mcp_lifecycle.py` | Python MCP 元数据/续期/列表、命令文件与外部实例归属 | 标记:`e2e`(全部)、`e2e_slow`(> 约 60 秒)、`known_issue`(记录已知缺陷的严格 xfail,见 @@ -75,6 +76,10 @@ cd e2e && python -m pytest -m "not e2e_slow" -ra # 等同 `make e2 | `E2E_SANDBOX_TTL` | 300 | 沙箱存活秒数 | | `E2E_SERVER_TEMPLATE` | `python-hello` | 预装 `agent-sandbox-server` 的模板 | | `E2E_RUN_ID` | 自动生成 | 标签值(`e2e=`) | +| `ABX_E2E_VOLUME_MOUNT` | 未设置则跳过 | 设置为 `1` 才允许运行 Volume 验收。 | +| `ABX_E2E_VOLUME_NAME` | 未设置则跳过 | API Key 同 Team、同地域、已配置存储角色的可写 Volume 名称;不创建或删除 Volume。 | +| `ABX_E2E_VOLUME_MOUNT_PATH` | 未设置则跳过 | `/mnt/abx-e2e/` 下专用的规范沙箱路径;这是测试工具的收窄要求。 | +| `E2E_VOLUME_TEMPLATE` | 未设置则跳过 | 满足[官方挂载指南](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1)存储及运行时前提的模板。 | ## 新增测试 diff --git a/e2e/test_09_volume_mounts.py b/e2e/test_09_volume_mounts.py new file mode 100644 index 0000000..029d94f --- /dev/null +++ b/e2e/test_09_volume_mounts.py @@ -0,0 +1,164 @@ +"""Opt-in existing Volume acceptance; operate only on an exclusive sentinel.""" + +from __future__ import annotations + +import json +import os +import re +import shlex +from uuid import uuid4 + +import pytest + +from agent_sandbox import Sandbox +from agent_sandbox.models.errors import SandboxNotFoundError + +_SCRIPT = r""" +import json, os, re, stat, sys +target, name, payload, mode = json.loads(sys.argv[1]) +if os.path.realpath(target) != target or not os.path.isdir(target): + raise RuntimeError('mount path unavailable or symlinked') +with open('/proc/self/mountinfo', encoding='utf-8') as mounts: + points = [ + re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), line.split()[4]) + for line in mounts + ] +if target not in points: + raise RuntimeError('no exact kernel mount entry') +directory = os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) +try: + if mode in ('roundtrip', 'write'): + fd = os.open( + name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory, + ) + owned = os.fstat(fd) + try: + with os.fdopen(fd, 'wb') as sentinel: + sentinel.write(payload.encode('ascii')) + sentinel.flush() + os.fsync(sentinel.fileno()) + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory) + with os.fdopen(fd, 'rb') as sentinel: + current = os.fstat(sentinel.fileno()) + if (current.st_dev, current.st_ino) != (owned.st_dev, owned.st_ino): + raise RuntimeError('sentinel ownership changed') + if sentinel.read(len(payload) + 1) != payload.encode('ascii'): + raise RuntimeError('sentinel read did not match') + finally: + current = os.stat(name, dir_fd=directory, follow_symlinks=False) + if not stat.S_ISREG(current.st_mode) or ( + current.st_dev, current.st_ino + ) != (owned.st_dev, owned.st_ino): + raise RuntimeError('ownership changed; no deletion attempted') + if mode == 'roundtrip': + os.unlink(name, dir_fd=directory) + elif mode in ('cleanup', 'read-delete'): + try: + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory) + except FileNotFoundError: + if mode == 'read-delete': + raise RuntimeError('persisted sentinel is missing') + pass + else: + with os.fdopen(fd, 'rb') as sentinel: + owned = os.fstat(sentinel.fileno()) + if not stat.S_ISREG(owned.st_mode) or ( + sentinel.read(len(payload) + 1) != payload.encode('ascii') + ): + raise RuntimeError('ownership unproven; no deletion attempted') + current = os.stat(name, dir_fd=directory, follow_symlinks=False) + if (current.st_dev, current.st_ino) != (owned.st_dev, owned.st_ino): + raise RuntimeError('ownership changed; no deletion attempted') + os.unlink(name, dir_fd=directory) + else: + raise RuntimeError('unknown mode') + print('ABX_VOLUME_' + mode.upper()) +finally: + os.close(directory) +""" + + +def _configuration() -> tuple[str, str, str]: + # Resolve prerequisites before requesting sandbox_factory: its tracker finalizer + # makes cloud calls, so even fixture construction must follow the opt-in guard. + volume = os.environ.get("ABX_E2E_VOLUME_NAME", "") + target = os.environ.get("ABX_E2E_VOLUME_MOUNT_PATH", "") + template = os.environ.get("E2E_VOLUME_TEMPLATE", "").strip() + if os.environ.get("ABX_E2E_VOLUME_MOUNT") != "1": + pytest.skip("Volume E2E requires explicit ABX_E2E_VOLUME_MOUNT=1") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", volume) or not template: + pytest.skip("Volume E2E needs an existing ABX_E2E_VOLUME_NAME and E2E_VOLUME_TEMPLATE") + if len(target) > 512 or not re.fullmatch( + r"/mnt/abx-e2e/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", target + ): + pytest.skip("ABX_E2E_VOLUME_MOUNT_PATH must be a dedicated path under /mnt/abx-e2e/") + return volume, target, template + + +def test_existing_volume_mount_read_write_cleanup(request, run): # type: ignore[no-untyped-def] + volume, target, template = _configuration() + factory = request.getfixturevalue("sandbox_factory") + sandbox = factory( + template=template, + provider="alicloud-express", + volume_mounts={target: volume}, + ) + name, payload = f".abx-volume-{uuid4().hex}", f"abx-volume-{uuid4().hex}" + + def verify(mode: str) -> None: + config = json.dumps([target, name, payload, mode]) + command = f"python3 -c {shlex.quote(_SCRIPT)} {shlex.quote(config)}" + try: + result = run(sandbox.commands.run(command, timeout=30)) + except Exception: + raise RuntimeError(f"Volume {mode} failed; raw output omitted") from None + if result.exit_code != 0 or result.stdout.strip() != f"ABX_VOLUME_{mode.upper()}": + raise RuntimeError(f"Volume {mode} did not prove its postcondition; output omitted") + + try: + verify("roundtrip") + finally: + # Independently recover an owned sentinel after an interrupted command. + # The fixture releases the tagged sandbox even if this cleanup fails. + verify("cleanup") + + +def test_volume_persists_after_sandbox_recreation(request, run): # type: ignore[no-untyped-def] + volume, target, template = _configuration() + factory = request.getfixturevalue("sandbox_factory") + first = factory(template=template, provider="alicloud-express", volume_mounts={target: volume}) + name, payload = f".abx-volume-{uuid4().hex}", f"abx-persist-{uuid4().hex}" + + def verify(sandbox, mode: str) -> None: # type: ignore[no-untyped-def] + config = json.dumps([target, name, payload, mode]) + command = f"python3 -c {shlex.quote(_SCRIPT)} {shlex.quote(config)}" + try: + result = run(sandbox.commands.run(command, timeout=30)) + except Exception: + raise RuntimeError(f"Volume {mode} failed; raw output omitted") from None + if result.exit_code != 0 or result.stdout.strip() != f"ABX_VOLUME_{mode.upper()}": + raise RuntimeError(f"Volume {mode} did not prove its postcondition; output omitted") + + second = None + first_deleted = False + try: + verify(first, "write") + run(first.kill()) + with pytest.raises(SandboxNotFoundError): + run(Sandbox.get_info_by_id(first.id)) + first_deleted = True + second = factory( + template=template, provider="alicloud-express", volume_mounts={target: volume} + ) + assert second.id != first.id, "recreation did not create a different sandbox" + verify(second, "read-delete") + finally: + if second is not None: + verify(second, "cleanup") + elif not first_deleted: + verify(first, "cleanup") + else: + raise RuntimeError( + f"Recreation failed; exclusive sentinel {name} needs cleanup in the test Volume" + ) diff --git a/server-sdk/tests/test_server/_live.py b/server-sdk/tests/test_server/_live.py index df2f26b..3752f81 100644 --- a/server-sdk/tests/test_server/_live.py +++ b/server-sdk/tests/test_server/_live.py @@ -10,6 +10,7 @@ from typing import Any from agent_sandbox.server.app import SandboxServer +from agent_sandbox.server.endpoints import CapabilityGroup from agent_sandbox.server.policy import ServerPolicy from agent_sandbox.server.registry import CommandRegistry from agent_sandbox.server.router import default_table @@ -57,11 +58,28 @@ def __init__(self, **kwargs: Any) -> None: self.thread.start() deadline = time.time() + 5 while time.time() < deadline: + # A successful TCP connect alone can precede app initialization. + # Wait for this instance to install its policy and start its PTY + # thread before exposing it to assertions or teardown. + httpd = self.server._httpd + pty = self.server._pty_thread + if ( + httpd is None + or not hasattr(httpd, "policy_engine") + or ( + self.server.policy_engine.group_enabled(CapabilityGroup.TERMINAL) + and (pty is None or pty.ident is None) + ) + ): + time.sleep(0.02) + continue try: with socket.create_connection(("127.0.0.1", self.port), timeout=0.2): break except OSError: time.sleep(0.02) + else: + raise RuntimeError("Test server did not become ready within five seconds") def close(self) -> None: if self._closed: diff --git a/server-sdk/tests/test_server/test_app_agents_transport.py b/server-sdk/tests/test_server/test_app_agents_transport.py index 5593174..8571883 100644 --- a/server-sdk/tests/test_server/test_app_agents_transport.py +++ b/server-sdk/tests/test_server/test_app_agents_transport.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import http.client import json import os import socket @@ -109,12 +110,15 @@ def test_upgrade_without_a_key_is_a_400_not_a_hang( live = live_server() with socket.create_connection(("127.0.0.1", live.port), timeout=10) as sock: sock.sendall( - b"GET /v1/responses HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\n" + f"GET /v1/responses HTTP/1.1\r\nHost: 127.0.0.1:{live.port}\r\n".encode() + + b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" ) - head = sock.recv(4096) - assert head.startswith(b"HTTP/1.0 400") or head.startswith(b"HTTP/1.1 400") - assert b"agents.websocket_handshake_invalid" in head + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 400 + body = response.read() + assert b"agents.websocket_handshake_invalid" in body def test_token_is_required_for_the_upgrade( self, live_server: Callable[..., Live], monkeypatch: pytest.MonkeyPatch @@ -124,7 +128,8 @@ def test_token_is_required_for_the_upgrade( url = f"ws://127.0.0.1:{live.port}/v1/responses" with socket.create_connection(("127.0.0.1", live.port), timeout=10) as sock: sock.sendall( - b"GET /v1/responses HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\n" + f"GET /v1/responses HTTP/1.1\r\nHost: 127.0.0.1:{live.port}\r\n".encode() + + b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n" b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" ) diff --git a/server-sdk/tests/test_server/test_http_hardening.py b/server-sdk/tests/test_server/test_http_hardening.py index a3192ef..c2c7c9a 100644 --- a/server-sdk/tests/test_server/test_http_hardening.py +++ b/server-sdk/tests/test_server/test_http_hardening.py @@ -14,7 +14,6 @@ def raw_exchange(port: int, request: bytes) -> tuple[int, bytes]: with socket.create_connection(("127.0.0.1", port), timeout=10) as sock: sock.sendall(request) - sock.shutdown(socket.SHUT_WR) chunks: list[bytes] = [] while True: data = sock.recv(65536) @@ -28,7 +27,7 @@ def raw_exchange(port: int, request: bytes) -> tuple[int, bytes]: def post_raw(port: int, path: str, headers: list[str], body: bytes = b"{}") -> tuple[int, bytes]: - head = "\r\n".join([f"POST {path} HTTP/1.1", "Host: x", *headers, "", ""]) + head = "\r\n".join([f"POST {path} HTTP/1.1", f"Host: 127.0.0.1:{port}", *headers, "", ""]) return raw_exchange(port, head.encode() + body) diff --git a/server-sdk/tests/test_server/test_policy_pipeline.py b/server-sdk/tests/test_server/test_policy_pipeline.py index 2740187..a6a1992 100644 --- a/server-sdk/tests/test_server/test_policy_pipeline.py +++ b/server-sdk/tests/test_server/test_policy_pipeline.py @@ -76,7 +76,8 @@ def test_gateway_header_plus_bearer_prefers_valid_one(self, live_server: Any) -> def test_ambiguous_x_access_token_rejected(self, live_server: Any) -> None: live: Live = live_server(policy=tokens_policy()) raw = ( - f"GET /capabilities HTTP/1.1\r\nHost: x\r\nX-Access-Token: {READER}\r\n" + f"GET /capabilities HTTP/1.1\r\nHost: 127.0.0.1:{live.port}\r\n" + f"X-Access-Token: {READER}\r\n" f"X-Access-Token: {ADMIN}\r\nConnection: close\r\n\r\n" ).encode() import socket