From ae185c2d85724f3fc4d2cd87c233682184b6a2af Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:03:40 +0000 Subject: [PATCH 01/14] feat: add shared provider management commands --- ...2026-10-16-provider-management-commands.md | 92 +++++ ...2026-10-16-provider-management-commands.md | 87 +++++ CHANGELOG.md | 9 + README.md | 2 + README.zh-CN.md | 2 + client-sdk/README.md | 5 + .../src/agent_sandbox/providers/__init__.py | 4 + .../src/agent_sandbox/providers/builtin.py | 3 +- .../src/agent_sandbox/providers/commands.py | 150 ++++++++ .../agent_sandbox/providers/engine_client.py | 12 +- .../src/agent_sandbox/providers/installer.py | 10 +- .../src/agent_sandbox/providers/manifest.py | 188 +++++++++- .../tests/test_providers/test_commands.py | 328 ++++++++++++++++++ .../test_providers/test_commands_engine.py | 140 ++++++++ .../test_providers/test_registry_scoping.py | 5 +- docs/en/README.md | 1 + docs/en/guide/provider-commands.md | 105 ++++++ docs/zh/README.md | 1 + docs/zh/guide/provider-commands.md | 97 ++++++ 19 files changed, 1230 insertions(+), 11 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-16-provider-management-commands.md create mode 100644 .agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md create mode 100644 client-sdk/src/agent_sandbox/providers/commands.py create mode 100644 client-sdk/tests/test_providers/test_commands.py create mode 100644 client-sdk/tests/test_providers/test_commands_engine.py create mode 100644 docs/en/guide/provider-commands.md create mode 100644 docs/zh/guide/provider-commands.md 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..2a82896 --- /dev/null +++ b/.agents/evidence/verify/2026-10-16-provider-management-commands.md @@ -0,0 +1,92 @@ +# 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. +- Base revision: `bb7881d`; uncommitted scoped implementation reviewed on `feat/provider-commands-20261016`. +- 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 + +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 inherits three unrelated +commits after `origin/main`; exclude that inherited range when preparing the scoped PR. + +## 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. 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..13f5f25 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md @@ -0,0 +1,87 @@ +# 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. + +## 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 + +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. + +## 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. + +## Evidence + +[Verification record](../../../evidence/verify/2026-10-16-provider-management-commands.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index dccdef6..ed1014b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Changed — provider management capability reporting + +- 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. + +### Added — provider management commands + +- `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. + ## [0.3.0] - 2026-10-15 Both distributions are **0.3.0**. The client on PyPI is `agent-sandbox-client` (import `agent_sandbox`). `agent-sandbox` is an unrelated project, so do not `pip install agent-sandbox`. The server distribution is `agent-sandbox-server`. Directories, the repository, and the CLI are unchanged. diff --git a/README.md b/README.md index 719f7e9..7743dc0 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,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 436993d..79bf028 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,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 54ead2c..fda6afd 100644 --- a/client-sdk/README.md +++ b/client-sdk/README.md @@ -48,6 +48,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/providers/__init__.py b/client-sdk/src/agent_sandbox/providers/__init__.py index 5ca0e25..11c418e 100644 --- a/client-sdk/src/agent_sandbox/providers/__init__.py +++ b/client-sdk/src/agent_sandbox/providers/__init__.py @@ -6,6 +6,7 @@ ``ABX_PROVIDER`` or ``[core] provider`` in ``~/.abx/config.toml``. """ +from agent_sandbox.providers import commands from agent_sandbox.providers.base import ( FEATURES, ConfigField, @@ -13,6 +14,7 @@ ProviderFeature, ProviderInfra, ) +from agent_sandbox.providers.manifest import ProviderCommand from agent_sandbox.providers.registry import ( ambient_provider_id, get_provider, @@ -32,6 +34,8 @@ "Provider", "ProviderFeature", "ProviderInfra", + "ProviderCommand", + "commands", "ambient_provider_id", "get_provider", "list_providers", diff --git a/client-sdk/src/agent_sandbox/providers/builtin.py b/client-sdk/src/agent_sandbox/providers/builtin.py index 6fa72a8..597a5d0 100644 --- a/client-sdk/src/agent_sandbox/providers/builtin.py +++ b/client-sdk/src/agent_sandbox/providers/builtin.py @@ -41,7 +41,8 @@ class AlicloudExpressProvider(Provider): display_name = BUILTIN_PROVIDER_NAME display_name_zh = BUILTIN_PROVIDER_NAME_ZH kind = "builtin" - features = frozenset(FEATURES) + # Management commands are separate from the fixed sandbox protocol features. + features = frozenset(set(FEATURES) - {"ports.domain", "storage.mount", "network.vpc"}) inherit = frozenset(GENERIC_ENDPOINT_KEYS) def __init__(self) -> 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 67bcdbf..784a33f 100644 --- a/client-sdk/src/agent_sandbox/providers/engine_client.py +++ b/client-sdk/src/agent_sandbox/providers/engine_client.py @@ -15,7 +15,7 @@ import os import shlex import shutil -import subprocess +import subprocess # nosec B404 - audited argv-only subprocess boundary; no shell import time import weakref from dataclasses import dataclass @@ -136,7 +136,8 @@ async def _spawn() -> EngineRecord: directory.mkdir(parents=True, exist_ok=True) with (directory / "engine.log").open("ab") as log: try: - subprocess.Popen( # noqa: S603 - the launcher is the user's own abx + # ABX_BIN/PATH selects the user's trusted local engine, never a remote command. + subprocess.Popen( # noqa: S603 # nosec B603 - argv only; shell defaults to False argv, stdin=subprocess.DEVNULL, stdout=log, @@ -218,15 +219,16 @@ async def request( params: dict[str, Any] | None = None, timeout: float | None = None, connection: dict[str, Any] | None = None, + retry: bool = True, ) -> httpx.Response: - """Send one request; an engine that went away is found or started again once.""" + """Send one request; disable retries for mutations that must never be replayed.""" extra: dict[str, str] = {} if connection is not None: extra["X-Abx-Connection"] = encode_connection(connection) 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( @@ -241,7 +243,7 @@ async def request( except _GONE as exc: last = exc continue - if res.status_code == 401 and attempt == 0: + if retry and res.status_code == 401 and attempt == 0: continue return res raise ProviderProtocolError(f"The provider engine is not reachable: {last}") diff --git a/client-sdk/src/agent_sandbox/providers/installer.py b/client-sdk/src/agent_sandbox/providers/installer.py index b41e013..2b738f8 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 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/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_registry_scoping.py b/client-sdk/tests/test_providers/test_registry_scoping.py index 57d034a..f0c7c81 100644 --- a/client-sdk/tests/test_providers/test_registry_scoping.py +++ b/client-sdk/tests/test_providers/test_registry_scoping.py @@ -45,7 +45,10 @@ 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") def test_register_and_conflicts(self, iso): # noqa: F811 register_provider(_Py()) diff --git a/docs/en/README.md b/docs/en/README.md index 151d8c9..a0e41df 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -43,6 +43,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 | diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md new file mode 100644 index 0000000..47b2ebf --- /dev/null +++ b/docs/en/guide/provider-commands.md @@ -0,0 +1,105 @@ +# 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. + +## 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/zh/README.md b/docs/zh/README.md index 218cc86..8f78edf 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -43,6 +43,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 | diff --git a/docs/zh/guide/provider-commands.md b/docs/zh/guide/provider-commands.md new file mode 100644 index 0000000..9ebcb30 --- /dev/null +++ b/docs/zh/guide/provider-commands.md @@ -0,0 +1,97 @@ +# 提供方管理命令 + +提供方命令管理所选云后端拥有的资源,例如存储卷或自定义域名。它们与沙箱 shell 执行、 +`agent-sandbox-server` 注册的具名命令分别使用独立入口。提供方可以声明自己的操作, +无需添加 CLI 顶层命令,也不会改变其他提供方的命令集合。 + +默认提供方不再声明 `storage.mount`、`ports.domain` 或 `network.vpc`:标准沙箱协议没有这些 token 对应的可调用操作。提供方资源管理通过已声明的命令完成;存储卷管理命令不代表支持运行中沙箱挂载。 + +## 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`。旧引擎没有这些路由时无法执行提供方命令。 +插件仍使用已文档化的宿主权限运行。 From 25fd4b6b0cab267a255235685b5ce00a454d6d19 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:09:52 +0000 Subject: [PATCH 02/14] docs: record rebased provider command verification --- ...2026-10-16-provider-management-commands.md | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/.agents/evidence/verify/2026-10-16-provider-management-commands.md b/.agents/evidence/verify/2026-10-16-provider-management-commands.md index 2a82896..81ddb70 100644 --- a/.agents/evidence/verify/2026-10-16-provider-management-commands.md +++ b/.agents/evidence/verify/2026-10-16-provider-management-commands.md @@ -3,7 +3,8 @@ - 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. -- Base revision: `bb7881d`; uncommitted scoped implementation reviewed on `feat/provider-commands-20261016`. +- 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. @@ -22,7 +23,7 @@ a descriptor, executed read/write handlers, required mutation confirmation, and extra input properties. The plugin made no cloud requests. This proves the local process contract, not Alibaba Cloud resource support. -## Commands run +## 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, @@ -75,8 +76,8 @@ no shell; the engine launches the user's explicitly selected trusted local ABX_B 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 inherits three unrelated -commits after `origin/main`; exclude that inherited range when preparing the scoped PR. +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 @@ -90,3 +91,49 @@ No claim about unrelated pre-existing cloud resources is made. 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. From 29544dd11067288abb55865b636ea2ada0c554d9 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:08:33 +0000 Subject: [PATCH 03/14] fix: carry volume mounts through Python sandbox creation --- .agents/evidence/README.md | 1 + ...-10-16-provider-management-final-review.md | 125 ++++++++++++++ ...2026-10-16-provider-management-commands.md | 29 ++++ CHANGELOG.md | 1 + client-sdk/src/agent_sandbox/api/sandbox.py | 26 ++- client-sdk/src/agent_sandbox/engine/client.py | 31 +++- .../src/agent_sandbox/engine/engine.proto | 3 + .../src/agent_sandbox/engine/engine_pb2.py | 96 ++++++----- client-sdk/src/agent_sandbox/engine/server.py | 31 ++-- .../src/agent_sandbox/models/sandbox.py | 52 +++++- .../tests/test_api/test_volume_mounts.py | 159 ++++++++++++++++++ client-sdk/tests/test_engine/test_engine.py | 13 +- docs/en/guide/provider-commands.md | 41 +++++ docs/en/reference/api-reference.md | 2 + docs/zh/guide/provider-commands.md | 35 ++++ docs/zh/reference/api-reference.md | 2 + e2e/README.md | 5 + e2e/README.zh-CN.md | 5 + e2e/test_09_volume_mounts.py | 113 +++++++++++++ 19 files changed, 701 insertions(+), 69 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-16-provider-management-final-review.md create mode 100644 client-sdk/tests/test_api/test_volume_mounts.py create mode 100644 e2e/test_09_volume_mounts.py diff --git a/.agents/evidence/README.md b/.agents/evidence/README.md index ab8506c..e8f2536 100644 --- a/.agents/evidence/README.md +++ b/.agents/evidence/README.md @@ -18,6 +18,7 @@ Evidence of the command-line interface lives in the ## `verify/` — test and E2E runs +- [`verify/2026-10-16-provider-management-final-review.md`](verify/2026-10-16-provider-management-final-review.md) - [`verify/2026-09-04-full-suite.md`](verify/2026-09-04-full-suite.md) - [`verify/2026-09-04-wave3-full-suite.md`](verify/2026-09-04-wave3-full-suite.md) - [`verify/2026-09-05-server-framework-full-suite.md`](verify/2026-09-05-server-framework-full-suite.md) 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/notes/proposed/architecture/2026-10-16-provider-management-commands.md b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md index 13f5f25..f844b47 100644 --- a/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md +++ b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md @@ -30,6 +30,23 @@ no callable standard sandbox protocol exists for those tokens. Resource commands 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`, @@ -72,6 +89,14 @@ no replay after disconnect or HTTP 401. Existing manifest/installer/registry tes 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. @@ -81,7 +106,11 @@ E2E; record unavailable prerequisites and pre-existing gate failures separately. 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. + A real Volume mount/read/write/cleanup run is still an outstanding acceptance gate. ## 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). diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1014b..d02bf0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added — provider management commands +- `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 storage acceptance is still pending. - `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. diff --git a/client-sdk/src/agent_sandbox/api/sandbox.py b/client-sdk/src/agent_sandbox/api/sandbox.py index f3b87de..3892d73 100644 --- a/client-sdk/src/agent_sandbox/api/sandbox.py +++ b/client-sdk/src/agent_sandbox/api/sandbox.py @@ -22,7 +22,12 @@ ResolvedCapabilities, resolve_capabilities_for, ) -from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo, SandboxStatus +from agent_sandbox.models.sandbox import ( + SandboxConfig, + SandboxInfo, + SandboxStatus, + validate_volume_mounts, +) from agent_sandbox.models.template import DEFAULT_CAPABILITIES from agent_sandbox.protocol.code_interpreter import CodeInterpreterProtocol from agent_sandbox.protocol.filesystem import FilesystemProtocol @@ -500,6 +505,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, @@ -533,6 +539,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 @@ -578,6 +587,17 @@ async def create( f"request_timeout must be a positive number of seconds, got {request_timeout!r}" ) + mounts = validate_volume_mounts(volume_mounts) + if mounts: + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + from agent_sandbox.providers.base import Provider + from agent_sandbox.providers.registry import resolve_provider_id + + provider_id = resolve_provider_id( + provider.id if isinstance(provider, Provider) else provider + ) + if provider_id != BUILTIN_PROVIDER_ID: + raise ProviderFeatureNotSupportedError(provider_id, "volume_mounts") prov, _ = _select_provider(provider) if prov is not None: return await cls._create_via_provider( @@ -619,6 +639,7 @@ async def create( try: from agent_sandbox.engine.client import Engine + mount_options: dict[str, Any] = {"volume_mounts": mounts} if mounts else {} # The built-in provider's platform HTTP call runs inside the engine. info = await Engine().create( template, @@ -635,6 +656,7 @@ async def create( memory=memory, disk=disk, gpu=gpu, + **mount_options, ) envd_token = EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id) @@ -1162,7 +1184,7 @@ async def get_terminal( config=self._config, cols=cols, rows=rows, - shell=shell, + shell=shell, # nosec B604 - terminal executable argument, not subprocess shell=True ) # ---- Named commands (registered on the in-sandbox SandboxServer) ---- diff --git a/client-sdk/src/agent_sandbox/engine/client.py b/client-sdk/src/agent_sandbox/engine/client.py index 6eb9b49..7ff7afc 100644 --- a/client-sdk/src/agent_sandbox/engine/client.py +++ b/client-sdk/src/agent_sandbox/engine/client.py @@ -5,7 +5,7 @@ import asyncio import os import stat -import subprocess +import subprocess # nosec B404 - fixed local Python module launcher, no shell import sys import threading from pathlib import Path @@ -21,7 +21,7 @@ from agent_sandbox.engine import engine_pb2_grpc as pb2_grpc from agent_sandbox.engine.rpcutil import reraise from agent_sandbox.engine.server import ProviderEngine -from agent_sandbox.models.sandbox import SandboxInfo, SandboxStatus +from agent_sandbox.models.sandbox import SandboxInfo, SandboxStatus, validate_volume_mounts _pb2: Any = pb2 _grpc_mod: Any = pb2_grpc @@ -41,7 +41,8 @@ def socket_path() -> Path: if override: return Path(override) if os.environ.get("PYTEST_CURRENT_TEST"): - return Path(f"/tmp/abx-pytest-{os.getpid()}.sock") + # Test-only Unix socket; _harden applies owner-only permissions after binding. + return Path(f"/tmp/abx-pytest-{os.getpid()}.sock") # nosec B108 return Path.home() / ".abx" / "engine.sock" @@ -136,7 +137,8 @@ def _spawn(path: Path) -> None: } env["ABX_ENGINE_SOCKET"] = str(path) env["ABX_ENGINE_PARENT"] = "1" - subprocess.Popen( # noqa: S603 + # Fixed local module argv and a constrained environment; no shell interpretation. + subprocess.Popen( # noqa: S603 # nosec B603 - fixed Python module argv, no shell [sys.executable, "-m", "agent_sandbox.engine"], env=env, stdin=subprocess.DEVNULL, @@ -199,8 +201,14 @@ async def create( disk: int | None = None, gpu: str | None = None, provider: str | None = None, + volume_mounts: dict[str, str] | None = None, ) -> SandboxInfo: """Create a sandbox. The engine performs the platform HTTP call.""" + mounts = validate_volume_mounts(volume_mounts) + if mounts and provider not in {None, "", "alicloud-express"}: + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + + raise ProviderFeatureNotSupportedError(provider, "volume_mounts") request = _pb2.CreateRequest( template=template, timeout=timeout, @@ -217,10 +225,23 @@ async def create( disk=disk or 0, gpu=gpu or "", provider=provider or "", + **({"volume_mounts": mounts} if mounts else {}), ) try: async with _channel() as channel: - state = await _grpc_mod.ProviderEngineStub(channel).Create(request) + stub = _grpc_mod.ProviderEngineStub(channel) + if mounts: + health = await stub.Health(_pb2.Empty(), timeout=5.0) + if "volume_mounts" not in getattr(health, "supported_create_options", ()): + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + + raise ProviderFeatureNotSupportedError( + "alicloud-express", + "volume_mounts", + message="The running Python engine does not support volume_mounts.", + suggestion="Upgrade and restart the local Python provider engine.", + ) + state = await stub.Create(request) except grpc.aio.AioRpcError as exc: reraise(exc) return _state(state) diff --git a/client-sdk/src/agent_sandbox/engine/engine.proto b/client-sdk/src/agent_sandbox/engine/engine.proto index b04af8f..7620a5b 100644 --- a/client-sdk/src/agent_sandbox/engine/engine.proto +++ b/client-sdk/src/agent_sandbox/engine/engine.proto @@ -25,6 +25,7 @@ message HealthStatus { string protocol = 1; int32 pid = 2; string version = 3; + repeated string supported_create_options = 4; } message CreateRequest { @@ -45,6 +46,8 @@ message CreateRequest { // Empty or "alicloud-express" uses the platform API. Any other id is rejected. // Catalog providers, including opensandbox, are not engine adapters. string provider = 15; + // Existing Volume names indexed by absolute sandbox mount path. + map volume_mounts = 16; } message SandboxState { diff --git a/client-sdk/src/agent_sandbox/engine/engine_pb2.py b/client-sdk/src/agent_sandbox/engine/engine_pb2.py index e0c1520..d2ed236 100644 --- a/client-sdk/src/agent_sandbox/engine/engine_pb2.py +++ b/client-sdk/src/agent_sandbox/engine/engine_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!agent_sandbox/engine/engine.proto\x12\x14\x61gent_sandbox.engine\">\n\x0cHealthStatus\x12\x10\n\x08protocol\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\x05\x12\x0f\n\x07version\x18\x03 \x01(\t\"\xd9\x03\n\rCreateRequest\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x0f\n\x07timeout\x18\x02 \x01(\x05\x12\x43\n\x08metadata\x18\x03 \x03(\x0b\x32\x31.agent_sandbox.engine.CreateRequest.MetadataEntry\x12;\n\x04\x65nvs\x18\x04 \x03(\x0b\x32-.agent_sandbox.engine.CreateRequest.EnvsEntry\x12\x0f\n\x07\x61pi_key\x18\x05 \x01(\t\x12\x0f\n\x07\x61pi_url\x18\x06 \x01(\t\x12\x15\n\raccess_key_id\x18\x07 \x01(\t\x12\x19\n\x11\x61\x63\x63\x65ss_key_secret\x18\x08 \x01(\t\x12\x0e\n\x06region\x18\t \x01(\t\x12\x17\n\x0frequest_timeout\x18\n \x01(\x01\x12\x0b\n\x03\x63pu\x18\x0b \x01(\x05\x12\x0e\n\x06memory\x18\x0c \x01(\x05\x12\x0c\n\x04\x64isk\x18\r \x01(\x05\x12\x0b\n\x03gpu\x18\x0e \x01(\t\x12\x10\n\x08provider\x18\x0f \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a+\n\tEnvsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x0cSandboxState\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x10\n\x08\x65nvd_url\x18\x04 \x01(\t\x12\x19\n\x11\x65nvd_access_token\x18\x05 \x01(\t\x12\x14\n\x0c\x65nvd_version\x18\x06 \x01(\t\"\x96\x01\n\nSandboxRef\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x02 \x01(\t\x12\x0f\n\x07\x61pi_url\x18\x03 \x01(\t\x12\x15\n\raccess_key_id\x18\x04 \x01(\t\x12\x19\n\x11\x61\x63\x63\x65ss_key_secret\x18\x05 \x01(\t\x12\x0e\n\x06region\x18\x06 \x01(\t\x12\x10\n\x08provider\x18\x07 \x01(\t\"\x07\n\x05\x45mpty\"\x1e\n\x0b\x45xecRequest\x12\x0f\n\x07sse_url\x18\x01 \x01(\t\"\x19\n\tExecChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\t\"4\n\rTerminalFrame\x12\x15\n\rwebsocket_url\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"\xd7\x01\n\x10\x45xecStartRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0b\n\x03\x63md\x18\x02 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x03 \x03(\t\x12<\n\x03\x65nv\x18\x04 \x03(\x0b\x32/.agent_sandbox.engine.ExecStartRequest.EnvEntry\x12\x0b\n\x03\x63wd\x18\x05 \x01(\t\x12\x0c\n\x04user\x18\x06 \x01(\t\x12\x0f\n\x07timeout\x18\x07 \x01(\x05\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"Y\n\tExecEvent\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x11\n\texit_code\x18\x03 \x01(\x05\x12\x0b\n\x03pid\x18\x04 \x01(\x05\x12\x10\n\x08has_exit\x18\x05 \x01(\x08\",\n\x08\x46ilePath\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"H\n\tFileChunk\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0b\n\x03\x65of\x18\x04 \x01(\x08\"R\n\rFileOpRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\n\n\x02op\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x04 \x01(\t\"C\n\tFileEntry\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x0c\n\x04size\x18\x04 \x01(\x03\"@\n\x0c\x46ileOpResult\x12\x30\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x1f.agent_sandbox.engine.FileEntry\"=\n\rWatchEventMsg\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x10\n\x08old_path\x18\x03 \x01(\t\"T\n\x08PtyFrame\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\n\n\x02op\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0c\n\x04\x63ols\x18\x04 \x01(\x05\x12\x0c\n\x04rows\x18\x05 \x01(\x05\"m\n\tOpRequest\x12\n\n\x02op\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x04 \x01(\t\x12\x0f\n\x07\x61pi_url\x18\x05 \x01(\t\x12\x0c\n\x04json\x18\x06 \x01(\t\"\x18\n\x08OpResult\x12\x0c\n\x04json\x18\x01 \x01(\t2\xc9\x07\n\x0eProviderEngine\x12I\n\x06Health\x12\x1b.agent_sandbox.engine.Empty\x1a\".agent_sandbox.engine.HealthStatus\x12Q\n\x06\x43reate\x12#.agent_sandbox.engine.CreateRequest\x1a\".agent_sandbox.engine.SandboxState\x12\x45\n\x04Kill\x12 .agent_sandbox.engine.SandboxRef\x1a\x1b.agent_sandbox.engine.Empty\x12L\n\x04\x45xec\x12!.agent_sandbox.engine.ExecRequest\x1a\x1f.agent_sandbox.engine.ExecChunk0\x01\x12X\n\x08Terminal\x12#.agent_sandbox.engine.TerminalFrame\x1a#.agent_sandbox.engine.TerminalFrame(\x01\x30\x01\x12V\n\tExecStart\x12&.agent_sandbox.engine.ExecStartRequest\x1a\x1f.agent_sandbox.engine.ExecEvent0\x01\x12M\n\x08\x46ileRead\x12\x1e.agent_sandbox.engine.FilePath\x1a\x1f.agent_sandbox.engine.FileChunk0\x01\x12K\n\tFileWrite\x12\x1f.agent_sandbox.engine.FileChunk\x1a\x1b.agent_sandbox.engine.Empty(\x01\x12Q\n\x06\x46ileOp\x12#.agent_sandbox.engine.FileOpRequest\x1a\".agent_sandbox.engine.FileOpResult\x12Q\n\x08WatchDir\x12\x1e.agent_sandbox.engine.FilePath\x1a#.agent_sandbox.engine.WatchEventMsg0\x01\x12I\n\x03Pty\x12\x1e.agent_sandbox.engine.PtyFrame\x1a\x1e.agent_sandbox.engine.PtyFrame(\x01\x30\x01\x12\x45\n\x02Op\x12\x1f.agent_sandbox.engine.OpRequest\x1a\x1e.agent_sandbox.engine.OpResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!agent_sandbox/engine/engine.proto\x12\x14\x61gent_sandbox.engine\"`\n\x0cHealthStatus\x12\x10\n\x08protocol\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\x05\x12\x0f\n\x07version\x18\x03 \x01(\t\x12 \n\x18supported_create_options\x18\x04 \x03(\t\"\xdc\x04\n\rCreateRequest\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x0f\n\x07timeout\x18\x02 \x01(\x05\x12\x43\n\x08metadata\x18\x03 \x03(\x0b\x32\x31.agent_sandbox.engine.CreateRequest.MetadataEntry\x12;\n\x04\x65nvs\x18\x04 \x03(\x0b\x32-.agent_sandbox.engine.CreateRequest.EnvsEntry\x12\x0f\n\x07\x61pi_key\x18\x05 \x01(\t\x12\x0f\n\x07\x61pi_url\x18\x06 \x01(\t\x12\x15\n\raccess_key_id\x18\x07 \x01(\t\x12\x19\n\x11\x61\x63\x63\x65ss_key_secret\x18\x08 \x01(\t\x12\x0e\n\x06region\x18\t \x01(\t\x12\x17\n\x0frequest_timeout\x18\n \x01(\x01\x12\x0b\n\x03\x63pu\x18\x0b \x01(\x05\x12\x0e\n\x06memory\x18\x0c \x01(\x05\x12\x0c\n\x04\x64isk\x18\r \x01(\x05\x12\x0b\n\x03gpu\x18\x0e \x01(\t\x12\x10\n\x08provider\x18\x0f \x01(\t\x12L\n\rvolume_mounts\x18\x10 \x03(\x0b\x32\x35.agent_sandbox.engine.CreateRequest.VolumeMountsEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a+\n\tEnvsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x33\n\x11VolumeMountsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x0cSandboxState\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x10\n\x08\x65nvd_url\x18\x04 \x01(\t\x12\x19\n\x11\x65nvd_access_token\x18\x05 \x01(\t\x12\x14\n\x0c\x65nvd_version\x18\x06 \x01(\t\"\x96\x01\n\nSandboxRef\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x02 \x01(\t\x12\x0f\n\x07\x61pi_url\x18\x03 \x01(\t\x12\x15\n\raccess_key_id\x18\x04 \x01(\t\x12\x19\n\x11\x61\x63\x63\x65ss_key_secret\x18\x05 \x01(\t\x12\x0e\n\x06region\x18\x06 \x01(\t\x12\x10\n\x08provider\x18\x07 \x01(\t\"\x07\n\x05\x45mpty\"\x1e\n\x0b\x45xecRequest\x12\x0f\n\x07sse_url\x18\x01 \x01(\t\"\x19\n\tExecChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\t\"4\n\rTerminalFrame\x12\x15\n\rwebsocket_url\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"\xd7\x01\n\x10\x45xecStartRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0b\n\x03\x63md\x18\x02 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x03 \x03(\t\x12<\n\x03\x65nv\x18\x04 \x03(\x0b\x32/.agent_sandbox.engine.ExecStartRequest.EnvEntry\x12\x0b\n\x03\x63wd\x18\x05 \x01(\t\x12\x0c\n\x04user\x18\x06 \x01(\t\x12\x0f\n\x07timeout\x18\x07 \x01(\x05\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"Y\n\tExecEvent\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x11\n\texit_code\x18\x03 \x01(\x05\x12\x0b\n\x03pid\x18\x04 \x01(\x05\x12\x10\n\x08has_exit\x18\x05 \x01(\x08\",\n\x08\x46ilePath\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"H\n\tFileChunk\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0b\n\x03\x65of\x18\x04 \x01(\x08\"R\n\rFileOpRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\n\n\x02op\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x04 \x01(\t\"C\n\tFileEntry\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x0c\n\x04size\x18\x04 \x01(\x03\"@\n\x0c\x46ileOpResult\x12\x30\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x1f.agent_sandbox.engine.FileEntry\"=\n\rWatchEventMsg\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x10\n\x08old_path\x18\x03 \x01(\t\"T\n\x08PtyFrame\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\n\n\x02op\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0c\n\x04\x63ols\x18\x04 \x01(\x05\x12\x0c\n\x04rows\x18\x05 \x01(\x05\"m\n\tOpRequest\x12\n\n\x02op\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x04 \x01(\t\x12\x0f\n\x07\x61pi_url\x18\x05 \x01(\t\x12\x0c\n\x04json\x18\x06 \x01(\t\"\x18\n\x08OpResult\x12\x0c\n\x04json\x18\x01 \x01(\t2\xc9\x07\n\x0eProviderEngine\x12I\n\x06Health\x12\x1b.agent_sandbox.engine.Empty\x1a\".agent_sandbox.engine.HealthStatus\x12Q\n\x06\x43reate\x12#.agent_sandbox.engine.CreateRequest\x1a\".agent_sandbox.engine.SandboxState\x12\x45\n\x04Kill\x12 .agent_sandbox.engine.SandboxRef\x1a\x1b.agent_sandbox.engine.Empty\x12L\n\x04\x45xec\x12!.agent_sandbox.engine.ExecRequest\x1a\x1f.agent_sandbox.engine.ExecChunk0\x01\x12X\n\x08Terminal\x12#.agent_sandbox.engine.TerminalFrame\x1a#.agent_sandbox.engine.TerminalFrame(\x01\x30\x01\x12V\n\tExecStart\x12&.agent_sandbox.engine.ExecStartRequest\x1a\x1f.agent_sandbox.engine.ExecEvent0\x01\x12M\n\x08\x46ileRead\x12\x1e.agent_sandbox.engine.FilePath\x1a\x1f.agent_sandbox.engine.FileChunk0\x01\x12K\n\tFileWrite\x12\x1f.agent_sandbox.engine.FileChunk\x1a\x1b.agent_sandbox.engine.Empty(\x01\x12Q\n\x06\x46ileOp\x12#.agent_sandbox.engine.FileOpRequest\x1a\".agent_sandbox.engine.FileOpResult\x12Q\n\x08WatchDir\x12\x1e.agent_sandbox.engine.FilePath\x1a#.agent_sandbox.engine.WatchEventMsg0\x01\x12I\n\x03Pty\x12\x1e.agent_sandbox.engine.PtyFrame\x1a\x1e.agent_sandbox.engine.PtyFrame(\x01\x30\x01\x12\x45\n\x02Op\x12\x1f.agent_sandbox.engine.OpRequest\x1a\x1e.agent_sandbox.engine.OpResultb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -35,52 +35,56 @@ _globals['_CREATEREQUEST_METADATAENTRY']._serialized_options = b'8\001' _globals['_CREATEREQUEST_ENVSENTRY']._loaded_options = None _globals['_CREATEREQUEST_ENVSENTRY']._serialized_options = b'8\001' + _globals['_CREATEREQUEST_VOLUMEMOUNTSENTRY']._loaded_options = None + _globals['_CREATEREQUEST_VOLUMEMOUNTSENTRY']._serialized_options = b'8\001' _globals['_EXECSTARTREQUEST_ENVENTRY']._loaded_options = None _globals['_EXECSTARTREQUEST_ENVENTRY']._serialized_options = b'8\001' _globals['_HEALTHSTATUS']._serialized_start=59 - _globals['_HEALTHSTATUS']._serialized_end=121 - _globals['_CREATEREQUEST']._serialized_start=124 - _globals['_CREATEREQUEST']._serialized_end=597 - _globals['_CREATEREQUEST_METADATAENTRY']._serialized_start=505 - _globals['_CREATEREQUEST_METADATAENTRY']._serialized_end=552 - _globals['_CREATEREQUEST_ENVSENTRY']._serialized_start=554 - _globals['_CREATEREQUEST_ENVSENTRY']._serialized_end=597 - _globals['_SANDBOXSTATE']._serialized_start=600 - _globals['_SANDBOXSTATE']._serialized_end=735 - _globals['_SANDBOXREF']._serialized_start=738 - _globals['_SANDBOXREF']._serialized_end=888 - _globals['_EMPTY']._serialized_start=890 - _globals['_EMPTY']._serialized_end=897 - _globals['_EXECREQUEST']._serialized_start=899 - _globals['_EXECREQUEST']._serialized_end=929 - _globals['_EXECCHUNK']._serialized_start=931 - _globals['_EXECCHUNK']._serialized_end=956 - _globals['_TERMINALFRAME']._serialized_start=958 - _globals['_TERMINALFRAME']._serialized_end=1010 - _globals['_EXECSTARTREQUEST']._serialized_start=1013 - _globals['_EXECSTARTREQUEST']._serialized_end=1228 - _globals['_EXECSTARTREQUEST_ENVENTRY']._serialized_start=1186 - _globals['_EXECSTARTREQUEST_ENVENTRY']._serialized_end=1228 - _globals['_EXECEVENT']._serialized_start=1230 - _globals['_EXECEVENT']._serialized_end=1319 - _globals['_FILEPATH']._serialized_start=1321 - _globals['_FILEPATH']._serialized_end=1365 - _globals['_FILECHUNK']._serialized_start=1367 - _globals['_FILECHUNK']._serialized_end=1439 - _globals['_FILEOPREQUEST']._serialized_start=1441 - _globals['_FILEOPREQUEST']._serialized_end=1523 - _globals['_FILEENTRY']._serialized_start=1525 - _globals['_FILEENTRY']._serialized_end=1592 - _globals['_FILEOPRESULT']._serialized_start=1594 - _globals['_FILEOPRESULT']._serialized_end=1658 - _globals['_WATCHEVENTMSG']._serialized_start=1660 - _globals['_WATCHEVENTMSG']._serialized_end=1721 - _globals['_PTYFRAME']._serialized_start=1723 - _globals['_PTYFRAME']._serialized_end=1807 - _globals['_OPREQUEST']._serialized_start=1809 - _globals['_OPREQUEST']._serialized_end=1918 - _globals['_OPRESULT']._serialized_start=1920 - _globals['_OPRESULT']._serialized_end=1944 - _globals['_PROVIDERENGINE']._serialized_start=1947 - _globals['_PROVIDERENGINE']._serialized_end=2916 + _globals['_HEALTHSTATUS']._serialized_end=155 + _globals['_CREATEREQUEST']._serialized_start=158 + _globals['_CREATEREQUEST']._serialized_end=762 + _globals['_CREATEREQUEST_METADATAENTRY']._serialized_start=617 + _globals['_CREATEREQUEST_METADATAENTRY']._serialized_end=664 + _globals['_CREATEREQUEST_ENVSENTRY']._serialized_start=666 + _globals['_CREATEREQUEST_ENVSENTRY']._serialized_end=709 + _globals['_CREATEREQUEST_VOLUMEMOUNTSENTRY']._serialized_start=711 + _globals['_CREATEREQUEST_VOLUMEMOUNTSENTRY']._serialized_end=762 + _globals['_SANDBOXSTATE']._serialized_start=765 + _globals['_SANDBOXSTATE']._serialized_end=900 + _globals['_SANDBOXREF']._serialized_start=903 + _globals['_SANDBOXREF']._serialized_end=1053 + _globals['_EMPTY']._serialized_start=1055 + _globals['_EMPTY']._serialized_end=1062 + _globals['_EXECREQUEST']._serialized_start=1064 + _globals['_EXECREQUEST']._serialized_end=1094 + _globals['_EXECCHUNK']._serialized_start=1096 + _globals['_EXECCHUNK']._serialized_end=1121 + _globals['_TERMINALFRAME']._serialized_start=1123 + _globals['_TERMINALFRAME']._serialized_end=1175 + _globals['_EXECSTARTREQUEST']._serialized_start=1178 + _globals['_EXECSTARTREQUEST']._serialized_end=1393 + _globals['_EXECSTARTREQUEST_ENVENTRY']._serialized_start=1351 + _globals['_EXECSTARTREQUEST_ENVENTRY']._serialized_end=1393 + _globals['_EXECEVENT']._serialized_start=1395 + _globals['_EXECEVENT']._serialized_end=1484 + _globals['_FILEPATH']._serialized_start=1486 + _globals['_FILEPATH']._serialized_end=1530 + _globals['_FILECHUNK']._serialized_start=1532 + _globals['_FILECHUNK']._serialized_end=1604 + _globals['_FILEOPREQUEST']._serialized_start=1606 + _globals['_FILEOPREQUEST']._serialized_end=1688 + _globals['_FILEENTRY']._serialized_start=1690 + _globals['_FILEENTRY']._serialized_end=1757 + _globals['_FILEOPRESULT']._serialized_start=1759 + _globals['_FILEOPRESULT']._serialized_end=1823 + _globals['_WATCHEVENTMSG']._serialized_start=1825 + _globals['_WATCHEVENTMSG']._serialized_end=1886 + _globals['_PTYFRAME']._serialized_start=1888 + _globals['_PTYFRAME']._serialized_end=1972 + _globals['_OPREQUEST']._serialized_start=1974 + _globals['_OPREQUEST']._serialized_end=2083 + _globals['_OPRESULT']._serialized_start=2085 + _globals['_OPRESULT']._serialized_end=2109 + _globals['_PROVIDERENGINE']._serialized_start=2112 + _globals['_PROVIDERENGINE']._serialized_end=3081 # @@protoc_insertion_point(module_scope) diff --git a/client-sdk/src/agent_sandbox/engine/server.py b/client-sdk/src/agent_sandbox/engine/server.py index 3f1e6e9..63a57dc 100644 --- a/client-sdk/src/agent_sandbox/engine/server.py +++ b/client-sdk/src/agent_sandbox/engine/server.py @@ -103,11 +103,18 @@ async def Health(self, request: Any, context: Any) -> Any: # noqa: N802 del request, context from agent_sandbox._version import __version__ - return _pb2.HealthStatus(protocol=PROTOCOL, pid=os.getpid(), version=__version__) + return _pb2.HealthStatus( + protocol=PROTOCOL, + pid=os.getpid(), + version=__version__, + supported_create_options=["volume_mounts"], + ) async def Create(self, request: Any, context: Any) -> Any: # noqa: N802 try: provider = (getattr(request, "provider", "") or "").strip() + if getattr(request, "volume_mounts", {}) and provider not in {"", "alicloud-express"}: + raise ProviderFeatureNotSupportedError(provider, "volume_mounts") if provider not in {"", "alicloud-express"}: from agent_sandbox.models.errors import ProviderNotFoundError @@ -314,6 +321,17 @@ async def _alicloud_create(request: Any) -> tuple[Any, Session]: from agent_sandbox.transport.auth import EnvdTokenManager from agent_sandbox.transport.http import stream_envelope_for_envd + sandbox_config = SandboxConfig( + template=request.template or "base", + timeout=request.timeout or 300, + metadata=dict(request.metadata), + env_vars=dict(request.envs), + cpu=request.cpu or None, + memory=request.memory or None, + disk=request.disk or None, + gpu=request.gpu or None, + volume_mounts=dict(getattr(request, "volume_mounts", {})), + ) _config, _auth, http_client, protocol = _build_infra( api_key=_opt(request.api_key), api_url=_opt(request.api_url), @@ -323,16 +341,7 @@ async def _alicloud_create(request: Any) -> tuple[Any, Session]: ) try: info = await protocol.create( - SandboxConfig( - template=request.template or "base", - timeout=request.timeout or 300, - metadata=dict(request.metadata), - env_vars=dict(request.envs), - cpu=request.cpu or None, - memory=request.memory or None, - disk=request.disk or None, - gpu=request.gpu or None, - ), + sandbox_config, request_timeout=request.request_timeout or None, ) except Exception: 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/tests/test_api/test_volume_mounts.py b/client-sdk/tests/test_api/test_volume_mounts.py new file mode 100644 index 0000000..1554100 --- /dev/null +++ b/client-sdk/tests/test_api/test_volume_mounts.py @@ -0,0 +1,159 @@ +"""Volume mount hints must survive creation and fail before provider I/O.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agent_sandbox.api.sandbox import Sandbox +from agent_sandbox.engine.client import Engine +from agent_sandbox.engine.engine_pb2 import CreateRequest, HealthStatus +from agent_sandbox.engine.server import ProviderEngine +from agent_sandbox.models.errors import ProviderConfigError, ProviderFeatureNotSupportedError +from agent_sandbox.models.sandbox import SandboxConfig +from agent_sandbox.providers.builtin import AlicloudExpressProvider +from agent_sandbox.transport.config import TransportConfig +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) + with patch("agent_sandbox.engine.client._channel") as channel: + with pytest.raises(ProviderConfigError): + await Engine().create(volume_mounts=mounts) + channel.assert_not_called() + + +@pytest.mark.parametrize("provider", ["uninstalled-plugin", AlicloudExpressProvider()]) +async def test_plugin_mounts_rejected_before_loading_or_building(provider: Any) -> None: + if not isinstance(provider, str): + provider.id = "programmatic-plugin" + with ( + patch("agent_sandbox.api.sandbox._select_provider") as select, + patch("agent_sandbox.api.sandbox._build_infra") as build, + pytest.raises(ProviderFeatureNotSupportedError) as caught, + ): + await Sandbox.create(provider=provider, volume_mounts={"/mnt/data": "existing-volume"}) + assert caught.value.code == "E8004" + select.assert_not_called() + build.assert_not_called() + + +@pytest.mark.parametrize("mounts", [None, {}, {"/mnt/data": "existing-volume"}]) +async def test_builtin_api_forwards_only_nonempty_mounts(mounts: Any) -> None: + http = AsyncMock() + with ( + patch("agent_sandbox.api.sandbox._select_provider", return_value=(None, None)), + patch( + "agent_sandbox.api.sandbox._build_infra", + return_value=(TransportConfig(), AsyncMock(), http, AsyncMock()), + ), + patch("agent_sandbox.engine.client.Engine.create", new_callable=AsyncMock) as create, + ): + create.return_value = make_sandbox_info() + sandbox = await Sandbox.create(volume_mounts=mounts) + if mounts: + assert create.call_args.kwargs["volume_mounts"] == mounts + else: + assert "volume_mounts" not in create.call_args.kwargs + 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() + + +async def test_engine_nonbuiltin_mounts_never_open_channel() -> None: + with patch("agent_sandbox.engine.client._channel") as channel: + with pytest.raises(ProviderFeatureNotSupportedError): + await Engine().create(provider="plugin", volume_mounts={"/mnt/data": "volume"}) + channel.assert_not_called() + + +@pytest.mark.parametrize("provider,path", [("plugin", "/mnt/data"), ("", "/")]) +async def test_raw_grpc_request_cannot_bypass_provider_or_path_gate( + provider: str, path: str +) -> None: + request = CreateRequest(provider=provider, volume_mounts={path: "volume"}) + context = AsyncMock() + with patch("agent_sandbox.api.sandbox._build_infra") as build: + await ProviderEngine().Create(request, context) + build.assert_not_called() + error = json.loads(context.abort.call_args.args[1]) + assert error["code"] == ("E8004" if provider else "E8002") + + +@pytest.mark.parametrize("mounts", [None, {}, {"/mnt/data": "volume"}]) +async def test_old_live_engine_requires_mount_capability_only_for_nonempty_hints( + mounts: Any, +) -> None: + stub = MagicMock() + stub.Health = AsyncMock(return_value=HealthStatus(protocol="abx.engine.v1", version="0.3.0")) + stub.Create = AsyncMock() + with ( + patch("agent_sandbox.engine.client._channel", return_value=AsyncMock()), + patch("agent_sandbox.engine.client._grpc_mod.ProviderEngineStub", return_value=stub), + ): + if mounts: + with pytest.raises(ProviderFeatureNotSupportedError) as caught: + await Engine().create(volume_mounts=mounts) + assert caught.value.feature == "volume_mounts" + stub.Health.assert_awaited_once() + stub.Create.assert_not_awaited() + else: + with patch("agent_sandbox.engine.client._state", return_value=make_sandbox_info()): + await Engine().create(volume_mounts=mounts) + stub.Health.assert_not_awaited() + stub.Create.assert_awaited_once() diff --git a/client-sdk/tests/test_engine/test_engine.py b/client-sdk/tests/test_engine/test_engine.py index e27320d..51b0d47 100644 --- a/client-sdk/tests/test_engine/test_engine.py +++ b/client-sdk/tests/test_engine/test_engine.py @@ -30,8 +30,9 @@ async def _read_http(reader: asyncio.StreamReader) -> tuple[bytes, bytes]: @pytest.mark.asyncio +@pytest.mark.parametrize("mounts", [None, {}, {"/mnt/data": "existing-volume"}]) async def test_builtin_create_goes_through_the_engine( - tmp_path: Any, monkeypatch: pytest.MonkeyPatch + tmp_path: Any, monkeypatch: pytest.MonkeyPatch, mounts: dict[str, str] | None ) -> None: monkeypatch.setenv("ABX_ENGINE_SOCKET", str(tmp_path / "engine.sock")) seen: dict[str, Any] = {} @@ -41,6 +42,7 @@ async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> assert head.startswith(b"POST /sandboxes") payload = json.loads(body) seen["template"] = payload["templateID"] + seen["payload"] = payload raw = json.dumps( {"sandboxID": "sbx-engine", "templateID": payload["templateID"], "state": "running"} ).encode() @@ -59,12 +61,19 @@ async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> "python-base", api_key="test-key", api_url=f"http://127.0.0.1:{port}", + volume_mounts=mounts, ) finally: + if "template" in seen: + await Engine().op("release", sandbox_id="sbx-engine") server.close() await server.wait_closed() assert seen["template"] == "python-base" assert info.sandbox_id == "sbx-engine" + if mounts: + assert seen["payload"]["volumeMounts"] == [{"name": "existing-volume", "path": "/mnt/data"}] + else: + assert "volumeMounts" not in seen["payload"] @pytest.mark.asyncio @@ -76,7 +85,7 @@ async def test_engine_rejects_catalog_provider( await Engine().create( "base", provider="opensandbox", - api_key="not-sent", + api_key="test-not-sent", api_url="http://127.0.0.1:9", ) diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md index 47b2ebf..b1b34cc 100644 --- a/docs/en/guide/provider-commands.md +++ b/docs/en/guide/provider-commands.md @@ -7,6 +7,47 @@ 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. + +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; a real OSS mount/read/write/cleanup run remains +required before claiming cloud acceptance. + ## Python API ```python 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/guide/provider-commands.md b/docs/zh/guide/provider-commands.md index 9ebcb30..ebc9f98 100644 --- a/docs/zh/guide/provider-commands.md +++ b/docs/zh/guide/provider-commands.md @@ -6,6 +6,41 @@ 默认提供方不再声明 `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)。 + +路径必须是规范化的绝对 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 回归覆盖参数链路;真实 OSS +挂载、读写及清理尚需验证,不能据此声称云端验收通过。 + ## Python API ```python 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 cbadfec..79df53a 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 one sentinel, then release the tagged sandbox. Ordinary directories cannot pass. | Markers: `e2e` (everything), `e2e_slow` (> ~60 s), `known_issue` (strict xfail documenting a recorded bug, see [KNOWN-ISSUES.md](KNOWN-ISSUES.md)). @@ -73,6 +74,10 @@ Markers: `e2e` (everything), `e2e_slow` (> ~60 s), | `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 7454837..884186e 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 验收:确认内核精确挂载点,独占创建、读取并删除一个测试文件,再释放带标签的沙箱。普通目录不能通过。 | 标记:`e2e`(全部)、`e2e_slow`(> 约 60 秒)、`known_issue`(记录已知缺陷的严格 xfail,见 [KNOWN-ISSUES.zh-CN.md](KNOWN-ISSUES.zh-CN.md))。 @@ -63,6 +64,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..7c26398 --- /dev/null +++ b/e2e/test_09_volume_mounts.py @@ -0,0 +1,113 @@ +"""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 + +_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 == 'roundtrip': + 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') + os.unlink(name, dir_fd=directory) + elif mode == 'cleanup': + try: + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory) + except FileNotFoundError: + 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 test_existing_volume_mount_read_write_cleanup(request, run): # type: ignore[no-untyped-def] + # 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/") + 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") From ec595c81030b30f0669de8989c0c432a74d136a0 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:40:23 +0000 Subject: [PATCH 04/14] docs: record provider recheck and cloud prerequisites --- .agents/evidence/README.md | 1 + .../2026-10-16-provider-management-recheck.md | 66 +++++++++++++++++++ ...2026-10-16-provider-management-commands.md | 1 + CHANGELOG.md | 2 + docs/en/guide/provider-commands.md | 4 ++ docs/zh/guide/provider-commands.md | 3 + 6 files changed, 77 insertions(+) create mode 100644 .agents/evidence/verify/2026-10-16-provider-management-recheck.md diff --git a/.agents/evidence/README.md b/.agents/evidence/README.md index d531cf7..c4c6260 100644 --- a/.agents/evidence/README.md +++ b/.agents/evidence/README.md @@ -18,6 +18,7 @@ Evidence of the command-line interface lives in the ## `verify/` — test and E2E runs +- [`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) 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/notes/proposed/architecture/2026-10-16-provider-management-commands.md b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md index 09c67a5..723067a 100644 --- a/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md +++ b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md @@ -115,3 +115,4 @@ and never kill or replace an unknown process to obtain a pass. [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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4935c..3e1880b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- 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 diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md index b1b34cc..be55360 100644 --- a/docs/en/guide/provider-commands.md +++ b/docs/en/guide/provider-commands.md @@ -27,6 +27,10 @@ and API key must use the same Team and region. For OSS, configure the Volume's R `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. Paths must be normalized absolute Unix paths other than `/`, with no trailing separator, empty or hidden component, `.`/`..`, NUL or surrounding whitespace. `/bin`, `/opt`, `/var`, diff --git a/docs/zh/guide/provider-commands.md b/docs/zh/guide/provider-commands.md index ebc9f98..fd0c271 100644 --- a/docs/zh/guide/provider-commands.md +++ b/docs/zh/guide/provider-commands.md @@ -25,6 +25,9 @@ async with await Sandbox.create( 必须属于同一 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)。 +成功返回空列表不能证明已有创建或挂载权限。 路径必须是规范化的绝对 Unix 路径,不能为 `/`,不能包含尾部分隔符、空段、隐藏目录段、 `.`/`..`、NUL 或前后空白。`/bin`、`/opt`、`/var`、`/dev` 及其子路径会被拒绝。 From 452b5e53379a4d16a1fb95b8b677707a6ae59ed8 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:52:51 +0000 Subject: [PATCH 05/14] test: verify volume persistence after sandbox recreation --- .agents/evidence/README.md | 2 + .../verify/2026-10-16-provider-volume-live.md | 114 ++++++++++++++++++ ...2026-10-16-provider-management-commands.md | 9 +- CHANGELOG.md | 4 +- docs/en/guide/provider-commands.md | 7 +- docs/zh/guide/provider-commands.md | 7 +- e2e/README.md | 2 +- e2e/test_09_volume_mounts.py | 59 ++++++++- 8 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-16-provider-volume-live.md diff --git a/.agents/evidence/README.md b/.agents/evidence/README.md index c4c6260..ea81f6f 100644 --- a/.agents/evidence/README.md +++ b/.agents/evidence/README.md @@ -18,6 +18,8 @@ Evidence of the command-line interface lives in the ## `verify/` — test and E2E runs +- [`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) 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/notes/proposed/architecture/2026-10-16-provider-management-commands.md b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md index 723067a..1b274f5 100644 --- a/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md +++ b/.agents/notes/proposed/architecture/2026-10-16-provider-management-commands.md @@ -81,6 +81,10 @@ 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 @@ -108,7 +112,8 @@ and never kill or replace an unknown process to obtain a pass. 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. - A real Volume mount/read/write/cleanup run is still an outstanding acceptance gate. + Real Volume mount/read/write/cleanup is verified using authorized temporary OSS storage; + complete required aggregates and shared-host landing remain outstanding. ## Evidence @@ -116,3 +121,5 @@ and never kill or replace an unknown process to obtain a pass. [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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1880b..2ac03f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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 storage acceptance is still pending. +- `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. @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- 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. diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md index be55360..4eb1253 100644 --- a/docs/en/guide/provider-commands.md +++ b/docs/en/guide/provider-commands.md @@ -31,6 +31,8 @@ Volume currently requires invitation access through an Alibaba Cloud support tic 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`, @@ -49,8 +51,9 @@ For nonempty hints, the client first checks `Health.supported_create_options` co 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; a real OSS mount/read/write/cleanup run remains -required before claiming cloud acceptance. +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 diff --git a/docs/zh/guide/provider-commands.md b/docs/zh/guide/provider-commands.md index fd0c271..e3820f4 100644 --- a/docs/zh/guide/provider-commands.md +++ b/docs/zh/guide/provider-commands.md @@ -28,6 +28,8 @@ async with await Sandbox.create( 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` 及其子路径会被拒绝。 @@ -41,8 +43,9 @@ Python 内置生命周期仍通过本地 gRPC 引擎:新增的 `CreateRequest. 非空参数会先检查 `Health.supported_create_options` 是否包含 `volume_mounts`;旧的运行中引擎 会在 `Create` 前返回 E8004 和升级、重启提示,不会自动替换引擎,避免旧 protobuf 实现静默 忽略挂载参数。未请求挂载的调用保留原行为。 -提供方管理命令继续使用共享 Node HTTP 引擎。本地 gRPC/HTTP 回归覆盖参数链路;真实 OSS -挂载、读写及清理尚需验证,不能据此声称云端验收通过。 +提供方管理命令继续使用共享 Node HTTP 引擎。本地 gRPC/HTTP 回归覆盖参数链路,且已在 +杭州地域的 base 模板完成真实 OSS 挂载、读写和清理验收。显式启用的 Volume E2E 套件还会检查 +销毁并重建沙箱后数据是否保留。 ## Python API diff --git a/e2e/README.md b/e2e/README.md index 79eecd5..acf75ae 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -47,7 +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 one sentinel, then release the tagged sandbox. Ordinary directories cannot pass. | +| `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), diff --git a/e2e/test_09_volume_mounts.py b/e2e/test_09_volume_mounts.py index 7c26398..029d94f 100644 --- a/e2e/test_09_volume_mounts.py +++ b/e2e/test_09_volume_mounts.py @@ -10,6 +10,9 @@ 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]) @@ -24,7 +27,7 @@ raise RuntimeError('no exact kernel mount entry') directory = os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: - if mode == 'roundtrip': + 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, @@ -48,11 +51,14 @@ 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) - elif mode == 'cleanup': + 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: @@ -73,7 +79,7 @@ """ -def test_existing_volume_mount_read_write_cleanup(request, run): # type: ignore[no-untyped-def] +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", "") @@ -87,6 +93,11 @@ def test_existing_volume_mount_read_write_cleanup(request, run): # type: ignore 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, @@ -111,3 +122,43 @@ def verify(mode: str) -> None: # 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" + ) From 31c8b5c60badba9436e7400f75b32f6d13253a5c Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:37:01 +0000 Subject: [PATCH 06/14] fix: explain bundled provider updates and Express migration --- .agents/README.md | 2 + .agents/evidence/README.md | 2 + ...2026-10-18-provider-portability-updates.md | 23 +++++ ...2026-10-18-provider-portability-updates.md | 37 ++++++++ CHANGELOG.md | 2 + .../src/agent_sandbox/providers/__main__.py | 9 +- .../src/agent_sandbox/providers/installer.py | 22 ++++- .../tests/test_providers/test_installer.py | 24 +++++ docs/en/README.md | 2 + .../en/guide/migrate-from-alicloud-express.md | 87 +++++++++++++++++++ docs/en/guide/migrate-from-e2b.md | 2 + docs/zh/README.md | 2 + .../zh/guide/migrate-from-alicloud-express.md | 77 ++++++++++++++++ docs/zh/guide/migrate-from-e2b.md | 2 + 14 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-18-provider-portability-updates.md create mode 100644 .agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md create mode 100644 docs/en/guide/migrate-from-alicloud-express.md create mode 100644 docs/zh/guide/migrate-from-alicloud-express.md diff --git a/.agents/README.md b/.agents/README.md index acc75cc..f70c5ca 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -45,3 +45,5 @@ Move the file and edit `Status:` in the same commit. Required sections and the t `scripts/check-repo-health.sh` enforces ADR naming, the `Status:`/directory match, and that no slug dangles. - [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) diff --git a/.agents/evidence/README.md b/.agents/evidence/README.md index 0a1ce98..588732b 100644 --- a/.agents/evidence/README.md +++ b/.agents/evidence/README.md @@ -32,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-18-provider-portability-updates.md b/.agents/evidence/verify/2026-10-18-provider-portability-updates.md new file mode 100644 index 0000000..d827d95 --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-provider-portability-updates.md @@ -0,0 +1,23 @@ +# 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. 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..78941f3 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md @@ -0,0 +1,37 @@ +# 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 + +Report the SDK-bundled release lifecycle without fetching or installing software. Document explicit config/API changes, ownership-safe adoption, native lifecycle versus Node management and coordinated client/server upgrades. + +## 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. + +## 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/main/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. Historical cloud storage evidence is linked without claiming another live run. + +## 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. + +## Evidence + +[Verification](../../../evidence/verify/2026-10-18-provider-portability-updates.md). + +Owner issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/52. diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c6e1a..97237b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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. + - **Real-cloud E2E acceptance.** The server fixture keeps envd credentials separate from the unconfigured template server; provider reports reject missing checks and the CLI scenario resolves `ABX_BIN` or an installed `abx` instead of assuming a sibling checkout. - **MCP timeout typing.** Keep the explicit managed-instance guard valid under the CI type checker while preserving shutdown ownership and renewal cleanup. 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/installer.py b/client-sdk/src/agent_sandbox/providers/installer.py index 2b738f8..abe365a 100644 --- a/client-sdk/src/agent_sandbox/providers/installer.py +++ b/client-sdk/src/agent_sandbox/providers/installer.py @@ -684,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/tests/test_providers/test_installer.py b/client-sdk/tests/test_providers/test_installer.py index 25ff12a..50ab658 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/docs/en/README.md b/docs/en/README.md index a0e41df..5e214c3 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -144,3 +144,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/guide/migrate-from-alicloud-express.md b/docs/en/guide/migrate-from-alicloud-express.md new file mode 100644 index 0000000..7301038 --- /dev/null +++ b/docs/en/guide/migrate-from-alicloud-express.md @@ -0,0 +1,87 @@ +# 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. + +## 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/main/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 ea8ebe5..acdba61 100644 --- a/docs/en/guide/migrate-from-e2b.md +++ b/docs/en/guide/migrate-from-e2b.md @@ -170,3 +170,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/zh/README.md b/docs/zh/README.md index 8f78edf..5badb3c 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -144,3 +144,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/guide/migrate-from-alicloud-express.md b/docs/zh/guide/migrate-from-alicloud-express.md new file mode 100644 index 0000000..8a37930 --- /dev/null +++ b/docs/zh/guide/migrate-from-alicloud-express.md @@ -0,0 +1,77 @@ +# 在已有极速版账号上使用 Agent Sandbox + +这里更换的是客户端和工具,不是云后端。原账号、地域、Team、API Key、模板 ID/别名、 +Volume、RAM 角色都可以继续使用,不必仅为接入客户端而重建资源。目前没有已实现的 +零改动或一键迁移;应核对应用实际使用的 API。 + +## 需要修改的内容 + +| 原极速版应用 | 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/main/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 5b68abb..8b7a434 100644 --- a/docs/zh/guide/migrate-from-e2b.md +++ b/docs/zh/guide/migrate-from-e2b.md @@ -169,3 +169,5 @@ sandbox = await Sandbox.create( - [认证详解](authentication.md) — 了解完整的认证方式 - [SDK 使用指南](sdk-usage.md) — SDK 完整使用方法 - [E2B 兼容性说明](../explanation/e2b-compatibility.md) — 设计背景与原因 + +如果现有 E2B 客户端已经连接阿里云极速版,参阅[已有极速版账号迁移指南](migrate-from-alicloud-express.md)。云端资源可保留,客户端及配置兼容性仍需核对。 From 4f71da69e59938978022c4032eea3bf489ffcfa2 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:52:11 +0000 Subject: [PATCH 07/14] docs: clarify Node migration scope and pin design references --- .../2026-10-18-provider-portability-updates.md | 2 +- docs/en/guide/migrate-from-alicloud-express.md | 7 ++++++- docs/zh/guide/migrate-from-alicloud-express.md | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) 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 index 78941f3..e78dcd4 100644 --- a/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md @@ -20,7 +20,7 @@ Separate built-in plugin updates would split the SDK release contract. Automatic ## Dependencies -Shared host design: https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/en/design/provider-portability-and-updates.md; Node management requires compatible abx. +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 diff --git a/docs/en/guide/migrate-from-alicloud-express.md b/docs/en/guide/migrate-from-alicloud-express.md index 7301038..f07890c 100644 --- a/docs/en/guide/migrate-from-alicloud-express.md +++ b/docs/en/guide/migrate-from-alicloud-express.md @@ -5,6 +5,11 @@ Team, API key, template IDs/aliases, Volumes and RAM roles. Existing resources d 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 | @@ -83,5 +88,5 @@ the SDK release: `python -m agent_sandbox.providers --json update alicloud-expre 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/main/docs/en/design/provider-portability-and-updates.md) +[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/zh/guide/migrate-from-alicloud-express.md b/docs/zh/guide/migrate-from-alicloud-express.md index 8a37930..3a0453e 100644 --- a/docs/zh/guide/migrate-from-alicloud-express.md +++ b/docs/zh/guide/migrate-from-alicloud-express.md @@ -4,6 +4,11 @@ Volume、RAM 角色都可以继续使用,不必仅为接入客户端而重建资源。目前没有已实现的 零改动或一键迁移;应核对应用实际使用的 API。 +本指南面向 Python 和 CLI。公开的 +[Node SDK](https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/blob/main/README.md) +当前只有 `ServerClient` 和配置名称,没有云端沙箱生命周期;不支持把 E2B JavaScript +应用仅改导入到该包就完成迁移。 + ## 需要修改的内容 | 原极速版应用 | Agent Sandbox 客户端 | @@ -73,5 +78,5 @@ Volume 和存储角色,不创建底层存储,也不自动证明权限正确 `python -m agent_sandbox.providers --json update alicloud-express --check` 返回 `bundled` 和指引,不检查最新版本、不执行软件升级。按原安装方式同步升级匹配的 client/server,Node 管理操作还需单独升级 abx。只重启自己管理的引擎。 -[宿主职责/更新设计](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/zh/design/provider-portability-and-updates.md) +[宿主职责/更新设计](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/c5a6a3c75921cfb4359f1c23041ec847ce63c932/docs/zh/design/provider-portability-and-updates.md) 定义确认、固定版本、回滚及待完成的模板适配器工作。 From 4bf2f7d75dfcb45b24f55b5aa50704bf18bea8e5 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:02:14 +0000 Subject: [PATCH 08/14] docs: record integrated provider acceptance and remaining gates --- .../verify/2026-10-18-provider-portability-updates.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/evidence/verify/2026-10-18-provider-portability-updates.md b/.agents/evidence/verify/2026-10-18-provider-portability-updates.md index ef0d4eb..e63af2b 100644 --- a/.agents/evidence/verify/2026-10-18-provider-portability-updates.md +++ b/.agents/evidence/verify/2026-10-18-provider-portability-updates.md @@ -25,3 +25,7 @@ The independent complete client run used a unique test-owned Unix socket: `cd cl ## 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**. From 6149076e9a4f9866b0a6a52578ac05aac90cd95b Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:06:09 +0000 Subject: [PATCH 09/14] refactor: isolate provider authentication and lifecycle adapters --- .agents/README.md | 2 + .../2026-10-18-provider-complete-isolation.md | 57 ++ ...2026-10-18-provider-portability-updates.md | 17 +- CHANGELOG.md | 8 + client-sdk/src/agent_sandbox/api/code.py | 5 +- client-sdk/src/agent_sandbox/api/commands.py | 4 +- client-sdk/src/agent_sandbox/api/files.py | 5 +- client-sdk/src/agent_sandbox/api/sandbox.py | 550 +++++++----------- client-sdk/src/agent_sandbox/engine/client.py | 9 +- client-sdk/src/agent_sandbox/engine/server.py | 176 +----- client-sdk/src/agent_sandbox/models/config.py | 12 +- client-sdk/src/agent_sandbox/models/errors.py | 4 +- .../src/agent_sandbox/protocol/__init__.py | 10 +- .../protocol/code_interpreter.py | 244 ++------ .../src/agent_sandbox/protocol/contracts.py | 228 ++++++++ .../src/agent_sandbox/protocol/filesystem.py | 488 ++-------------- .../src/agent_sandbox/protocol/process.py | 428 ++------------ .../src/agent_sandbox/protocol/sandbox.py | 227 +------- .../src/agent_sandbox/protocol/terminal.py | 127 +--- .../providers/alicloud_express/__init__.py | 1 + .../providers/alicloud_express/auth.py | 341 +++++++++++ .../providers/alicloud_express/engine.py | 93 +++ .../providers/alicloud_express/lifecycle.py | 47 ++ .../providers/alicloud_express/metadata.py | 23 + .../providers/alicloud_express/provider.py | 199 +++++++ .../src/agent_sandbox/providers/base.py | 73 ++- .../src/agent_sandbox/providers/builtin.py | 101 +--- .../agent_sandbox/providers/bundled_config.py | 13 + .../providers/engine_contract.py | 21 + .../providers/engine_provider.py | 9 +- .../agent_sandbox/providers/exec_provider.py | 13 +- .../src/agent_sandbox/providers/registry.py | 2 +- .../agent_sandbox/providers/rest_provider.py | 31 +- .../providers/shared/__init__.py | 1 + .../providers/shared/e2b/__init__.py | 1 + .../providers/shared/e2b/auth.py | 97 +++ .../providers/shared/e2b/code_interpreter.py | 210 +++++++ .../providers/shared/e2b/engine_terminal.py | 78 +++ .../providers/shared/e2b/filesystem.py | 441 ++++++++++++++ .../providers/shared/e2b/process.py | 390 +++++++++++++ .../providers/shared/e2b/sandbox.py | 228 ++++++++ .../providers/shared/e2b/session.py | 35 ++ .../providers/shared/e2b/terminal.py | 120 ++++ .../src/agent_sandbox/transport/__init__.py | 2 +- .../src/agent_sandbox/transport/auth.py | 482 ++------------- .../agent_sandbox/transport/auth_contract.py | 71 +++ .../src/agent_sandbox/transport/config.py | 115 ++-- .../src/agent_sandbox/transport/http.py | 13 +- client-sdk/tests/test_api/test_capability.py | 4 +- .../test_lifecycle_without_connect.py | 22 +- client-sdk/tests/test_api/test_sandbox.py | 109 ++-- .../tests/test_api/test_volume_mounts.py | 63 +- client-sdk/tests/test_engine/test_engine.py | 4 +- client-sdk/tests/test_models/test_errors.py | 2 +- .../test_providers/test_complete_isolation.py | 110 ++++ .../test_providers/test_sandbox_provider.py | 41 +- docs/en/design/architecture.md | 2 + docs/en/design/provider-isolation.md | 43 ++ docs/zh/design/architecture.md | 2 + docs/zh/design/provider-isolation.md | 37 ++ 60 files changed, 3718 insertions(+), 2573 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-18-provider-complete-isolation.md create mode 100644 client-sdk/src/agent_sandbox/protocol/contracts.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/__init__.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/auth.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/engine.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py create mode 100644 client-sdk/src/agent_sandbox/providers/bundled_config.py create mode 100644 client-sdk/src/agent_sandbox/providers/engine_contract.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/__init__.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/__init__.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/auth.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/code_interpreter.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/engine_terminal.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/process.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/session.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/terminal.py create mode 100644 client-sdk/src/agent_sandbox/transport/auth_contract.py create mode 100644 client-sdk/tests/test_providers/test_complete_isolation.py create mode 100644 docs/en/design/provider-isolation.md create mode 100644 docs/zh/design/provider-isolation.md diff --git a/.agents/README.md b/.agents/README.md index af22415..41714dd 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -48,3 +48,5 @@ Move the file and edit `Status:` in the same commit. Required sections and the t - [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) 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/notes/proposed/architecture/2026-10-18-provider-portability-updates.md b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md index e78dcd4..47c027b 100644 --- a/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md @@ -8,11 +8,11 @@ Python update treats the built-in provider as an uninstalled plugin. Existing Ex ## Decision -Report the SDK-bundled release lifecycle without fetching or installing software. Document explicit config/API changes, ownership-safe adoption, native lifecycle versus Node management and coordinated client/server upgrades. +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. +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 @@ -24,14 +24,25 @@ Shared host design: https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/ ## Test Strategy -Existing installer suite plus offline no-receipt and actual CLI regressions, both-package lint/format/typecheck, scoped links and required full aggregate. Historical cloud storage evidence is linked without claiming another live run. +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). diff --git a/CHANGELOG.md b/CHANGELOG.md index c284dc8..5baf846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Provider isolation + +- Route bundled and external backends through the same Provider contract. Move Express authentication, + cloud SDK/template deployment and native session binding into provider modules; preserve public import facades. +- Resolve provider-owned CLI/AI/config metadata per selection, reject undeclared native defaults/options, + and clean up newly created resources when session binding fails without deleting borrowed sandboxes. + + ### 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. 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..b37aeee 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") diff --git a/client-sdk/src/agent_sandbox/api/sandbox.py b/client-sdk/src/agent_sandbox/api/sandbox.py index d71a372..b0a92c5 100644 --- a/client-sdk/src/agent_sandbox/api/sandbox.py +++ b/client-sdk/src/agent_sandbox/api/sandbox.py @@ -20,7 +20,6 @@ from agent_sandbox.api.capability import ( ResolvedCapabilities, - resolve_capabilities_for, ) from agent_sandbox.models.sandbox import ( SandboxConfig, @@ -29,19 +28,9 @@ validate_volume_mounts, ) 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.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: @@ -57,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") @@ -78,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: @@ -115,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). @@ -141,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 @@ -152,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() @@ -159,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: @@ -243,27 +218,54 @@ def __init__( 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, engine_data: bool = False, ) -> 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._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 @@ -588,126 +590,37 @@ async def create( ) mounts = validate_volume_mounts(volume_mounts) - if mounts: - from agent_sandbox.models.errors import ProviderFeatureNotSupportedError - from agent_sandbox.providers.base import Provider - from agent_sandbox.providers.registry import resolve_provider_id - - provider_id = resolve_provider_id( - provider.id if isinstance(provider, Provider) else provider - ) - if provider_id != BUILTIN_PROVIDER_ID: - raise ProviderFeatureNotSupportedError(provider_id, "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: - from agent_sandbox.engine.client import Engine - - mount_options: dict[str, Any] = {"volume_mounts": mounts} if mounts else {} - # The built-in provider's platform HTTP call runs inside the engine. - info = await Engine().create( - template, - timeout=timeout, - metadata=metadata or {}, - envs=envs or {}, - api_key=config.sandbox_api_key, - api_url=config.sandbox_base_url, - access_key_id=config.access_key_id, - access_key_secret=config.access_key_secret, - region=config.region or region, - request_timeout=request_timeout, - cpu=cpu, - memory=memory, - disk=disk, - gpu=gpu, - **mount_options, - ) - - 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, - ) - - from agent_sandbox.engine.protocols import ( - EngineFilesystemProtocol, - EngineProcessProtocol, - ) - - return cls( - info=info, - config=config, - http_client=http_client, - auth=auth, - envd_token=envd_token, - sandbox_protocol=sandbox_protocol, - process_protocol=EngineProcessProtocol(info.sandbox_id), # type: ignore[arg-type] - filesystem_protocol=EngineFilesystemProtocol( - info.sandbox_id, bounded_reader=FilesystemProtocol(http_client) - ), # type: ignore[arg-type] - domain=domain or config.sandbox_domain, - secure=True if secure is None else secure, - resolved_capabilities=resolved, - engine_data=True, - ) - 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) - try: - from agent_sandbox.engine.client import Engine - - await Engine().op("release", sandbox_id=info.sandbox_id) - except Exception: - logger.debug("engine session release failed for %s", info.sandbox_id) - await http_client.close() - raise - @classmethod async def connect( cls, @@ -748,58 +661,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, @@ -841,37 +718,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( @@ -979,32 +842,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) @@ -1048,48 +900,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: - # Built-in create holds an engine session. Release is a no-op when this - # id was never registered with the local engine. - from agent_sandbox.engine.client import Engine - - try: - await Engine().op("release", sandbox_id=sandbox_id) - except Exception: - logger.debug("engine session release failed for %s", sandbox_id) - await http_client.close() + await _InfraCloser(infra).close() + return @classmethod async def _create_via_provider( @@ -1105,6 +934,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], @@ -1112,6 +942,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, @@ -1122,13 +953,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()) @@ -1138,19 +971,34 @@ 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, - engine_data=bool(getattr(prov, "uses_engine_data", False)), + engine_data=binding.pop( + "engine_data", bool(getattr(prov, "uses_engine_data", False)) + ), + **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 @@ -1168,7 +1016,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()) @@ -1178,16 +1028,23 @@ 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, - engine_data=bool(getattr(prov, "uses_engine_data", False)), + engine_data=binding.pop( + "engine_data", bool(getattr(prov, "uses_engine_data", False)) + ), + **binding, ) except Exception: await closer.close() @@ -1253,7 +1110,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 ---- @@ -1311,22 +1170,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] - if self._engine_data: - from agent_sandbox.engine.protocols import open_terminal - - return await open_terminal(self.id, cols=cols, rows=rows) # type: ignore[return-value] - 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, # nosec B604 - terminal executable argument, not subprocess shell=True + 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/engine/client.py b/client-sdk/src/agent_sandbox/engine/client.py index 7ff7afc..82313b4 100644 --- a/client-sdk/src/agent_sandbox/engine/client.py +++ b/client-sdk/src/agent_sandbox/engine/client.py @@ -205,10 +205,13 @@ async def create( ) -> SandboxInfo: """Create a sandbox. The engine performs the platform HTTP call.""" mounts = validate_volume_mounts(volume_mounts) - if mounts and provider not in {None, "", "alicloud-express"}: + from agent_sandbox.providers.registry import get_provider, resolve_provider_id + + selected = get_provider(resolve_provider_id(provider)) + if mounts and "volume_mounts" not in selected.create_options: from agent_sandbox.models.errors import ProviderFeatureNotSupportedError - raise ProviderFeatureNotSupportedError(provider, "volume_mounts") + raise ProviderFeatureNotSupportedError(selected.id, "volume_mounts") request = _pb2.CreateRequest( template=template, timeout=timeout, @@ -236,7 +239,7 @@ async def create( from agent_sandbox.models.errors import ProviderFeatureNotSupportedError raise ProviderFeatureNotSupportedError( - "alicloud-express", + selected.id, "volume_mounts", message="The running Python engine does not support volume_mounts.", suggestion="Upgrade and restart the local Python provider engine.", diff --git a/client-sdk/src/agent_sandbox/engine/server.py b/client-sdk/src/agent_sandbox/engine/server.py index 63a57dc..0921fbb 100644 --- a/client-sdk/src/agent_sandbox/engine/server.py +++ b/client-sdk/src/agent_sandbox/engine/server.py @@ -7,13 +7,14 @@ import ipaddress import json import os -from dataclasses import dataclass from typing import TYPE_CHECKING, Any from urllib.parse import urlparse if TYPE_CHECKING: from collections.abc import AsyncIterator + from agent_sandbox.providers.engine_contract import Session + import grpc import httpx import websockets @@ -60,20 +61,6 @@ def loopback_url(url: str) -> bool: return bool(address.is_loopback) -@dataclass -class Session: - """Backend connections for one sandbox. Tokens stay in this process.""" - - sandbox_id: str - provider: str - envd_url: str - token: Any - http: Any - process: Any - files: Any - sandbox_protocol: Any - - SESSIONS: dict[str, Session] = {} @@ -112,20 +99,16 @@ async def Health(self, request: Any, context: Any) -> Any: # noqa: N802 async def Create(self, request: Any, context: Any) -> Any: # noqa: N802 try: - provider = (getattr(request, "provider", "") or "").strip() - if getattr(request, "volume_mounts", {}) and provider not in {"", "alicloud-express"}: - raise ProviderFeatureNotSupportedError(provider, "volume_mounts") - if provider not in {"", "alicloud-express"}: - from agent_sandbox.models.errors import ProviderNotFoundError - - raise ProviderNotFoundError( - "The local engine only creates alicloud-express sandboxes.", - suggestion=( - "Catalog providers run in the Python SDK. Install with " - "'python -m agent_sandbox.providers install registry: --yes'." - ), - ) - state, session = await _alicloud_create(request) + from agent_sandbox.providers.registry import get_provider, resolve_provider_id + + provider = get_provider(resolve_provider_id(getattr(request, "provider", None))) + if ( + getattr(request, "volume_mounts", {}) + and "volume_mounts" not in provider.create_options + ): + raise ProviderFeatureNotSupportedError(provider.id, "volume_mounts") + info, session = await provider.create_engine_session(request) + state = _state(info) SESSIONS[state.sandbox_id] = session return state except Exception as exc: @@ -294,7 +277,9 @@ async def Pty( # noqa: N802 except Exception as exc: await abort_exc(context, exc) return - async for frame in _ali_pty(session, first, request_iterator): + if session.terminal is None: + raise ProviderFeatureNotSupportedError(session.provider, "pty") + async for frame in session.terminal(session, first, request_iterator): yield frame async def Op(self, request: Any, context: Any) -> Any: # noqa: N802 @@ -314,74 +299,15 @@ async def _drain(incoming: Any) -> None: return -async def _alicloud_create(request: Any) -> tuple[Any, Session]: - from agent_sandbox.api.sandbox import SandboxConfig, _build_infra - from agent_sandbox.protocol.filesystem import FilesystemProtocol - from agent_sandbox.protocol.process import ProcessProtocol - from agent_sandbox.transport.auth import EnvdTokenManager - from agent_sandbox.transport.http import stream_envelope_for_envd - - sandbox_config = SandboxConfig( - template=request.template or "base", - timeout=request.timeout or 300, - metadata=dict(request.metadata), - env_vars=dict(request.envs), - cpu=request.cpu or None, - memory=request.memory or None, - disk=request.disk or None, - gpu=request.gpu or None, - volume_mounts=dict(getattr(request, "volume_mounts", {})), - ) - _config, _auth, http_client, protocol = _build_infra( - api_key=_opt(request.api_key), - api_url=_opt(request.api_url), - access_key_id=_opt(request.access_key_id), - access_key_secret=_opt(request.access_key_secret), - region=_opt(request.region), - ) - try: - info = await protocol.create( - sandbox_config, - request_timeout=request.request_timeout or None, - ) - except Exception: - await _close(http_client) - raise - if not info.envd_url and info.sandbox_id: - info.envd_url = _config.build_envd_url(info.sandbox_id) - http_client._stream_envelope = stream_envelope_for_envd(info.envd_version) - token = EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id) - session = Session( - sandbox_id=info.sandbox_id, - provider="alicloud-express", - envd_url=info.envd_url or "", - token=token, - http=http_client, - process=ProcessProtocol(http_client), - files=FilesystemProtocol(http_client), - sandbox_protocol=protocol, - ) - return _state(info), session - - async def _kill(request: Any) -> None: session = SESSIONS.get(request.sandbox_id) if session is not None and session.sandbox_protocol is not None: await session.sandbox_protocol.kill(request.sandbox_id) else: - from agent_sandbox.api.sandbox import _build_infra - - _config, _auth, http_client, protocol = _build_infra( - api_key=_opt(request.api_key), - api_url=_opt(request.api_url), - access_key_id=_opt(request.access_key_id), - access_key_secret=_opt(request.access_key_secret), - region=_opt(request.region), - ) - try: - await protocol.kill(request.sandbox_id) - finally: - await _close(http_client) + from agent_sandbox.providers.registry import get_provider, resolve_provider_id + + provider = get_provider(resolve_provider_id(getattr(request, "provider", None))) + await provider.kill_engine_resource(request) await _drop(request.sandbox_id) @@ -421,7 +347,7 @@ async def _file_op(request: Any) -> list[dict[str, Any]]: if op == "move": await files.move(url, token, source=request.path, destination=request.destination) return [] - raise ProviderFeatureNotSupportedError("alicloud-express", op or "files.rw") + raise ProviderFeatureNotSupportedError(session.provider, op or "files.rw") def _entry(info: Any) -> dict[str, Any]: @@ -456,67 +382,7 @@ async def _op(request: Any, payload: dict[str, Any]) -> dict[str, Any]: data=str(payload.get("data") or ""), ) return {} - raise ProviderFeatureNotSupportedError("alicloud-express", op or "sandbox.lifecycle") - - -async def _ali_pty( - session: Session, first: Any, request_iterator: AsyncIterator[Any] -) -> AsyncIterator[Any]: - ws_url = session.envd_url.replace("https://", "wss://").replace("http://", "ws://") - ws_url = f"{ws_url}/terminal" - headers: dict[str, str] = {} - if session.token is not None and getattr(session.token, "token", None): - headers = session.token.get_headers() - async with websockets.connect(ws_url, additional_headers=headers) as socket: - if first.cols or first.rows: - await socket.send( - json.dumps({"type": "resize", "cols": first.cols, "rows": first.rows}) - ) - yield _pb2.PtyFrame(op="open") - outgoing: asyncio.Queue[Any] = asyncio.Queue(maxsize=4) - - async def from_socket() -> None: - try: - async for message in socket: - data = message if isinstance(message, bytes) else message.encode() - await outgoing.put(_pb2.PtyFrame(op="data", data=data)) - finally: - await outgoing.put(None) - - async def from_client() -> None: - async for frame in request_iterator: - if frame.op == "resize": - text = json.dumps({"type": "resize", "cols": frame.cols, "rows": frame.rows}) - await socket.send(text) - elif frame.op == "close": - return - elif frame.data: - await socket.send(frame.data) - - reader = asyncio.create_task(from_socket()) - writer = asyncio.create_task(from_client()) - try: - async for item in _until_client_closes(outgoing, writer): - yield item - finally: - writer.cancel() - reader.cancel() - - -async def _until_client_closes( - outgoing: asyncio.Queue[Any], writer: asyncio.Task[None] -) -> AsyncIterator[Any]: - """Yield socket frames until the client closes the RPC.""" - while True: - incoming = asyncio.create_task(outgoing.get()) - done, _pending = await asyncio.wait({incoming, writer}, return_when=asyncio.FIRST_COMPLETED) - if writer in done and incoming not in done: - incoming.cancel() - return - item = incoming.result() - if item is None: - return - yield item + raise ProviderFeatureNotSupportedError(session.provider, op or "sandbox.lifecycle") def _grpc_status() -> Any: 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/protocol/__init__.py b/client-sdk/src/agent_sandbox/protocol/__init__.py index 9fa0f59..2e9b54e 100644 --- a/client-sdk/src/agent_sandbox/protocol/__init__.py +++ b/client-sdk/src/agent_sandbox/protocol/__init__.py @@ -2,12 +2,12 @@ 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 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.providers.shared.e2b.terminal import TerminalSession __all__ = [ "SandboxProtocol", 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/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/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..4f34ba2 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/auth.py @@ -0,0 +1,341 @@ +"""Authentication providers for Platform API and Sandbox envd API. + +Two authentication modes: +1. API Key: ABX_SANDBOX_API_KEY -> Authorization: Bearer {api_key} header (Platform API, 已实测验证) +2. AK/SK: Exchange for temporary API key (TTL=3600s, auto-refresh 5min before expiry) + +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 hashlib +import hmac as hmac_mod +import time +import urllib.parse +import uuid +from datetime import datetime, timezone +from typing import Any + +import httpx + +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: + """AK/SK authentication — exchanges AccessKey for temporary API key. + + Uses ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET. + Exchanges for a temporary token with TTL=3600s. + Auto-refreshes 5 minutes (300s) before expiry. + Token is cached in memory only (never persisted to disk). + """ + + def authentication_error(self, path: str) -> InvalidCredentialsError: + return InvalidCredentialsError( + f"The Platform API rejected the AK/SK credentials (HTTP 401) for {path}." + ) + + TOKEN_TTL = 3600 # 1 hour # 假设值,官方文档未给出 token 交换参数,需实测验证 + REFRESH_BEFORE_EXPIRY = 300 # 5 minutes # 假设值,官方文档未给出 token 交换参数,需实测验证 + + 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. + + This calls the Alibaba Cloud STS-like endpoint to get a temporary token. + The actual HTTP call is delegated to the exchange function set by the transport layer. + """ + if self._exchange_func is None: + raise InvalidCredentialsError( + "Token exchange function not configured. " + "AK/SK auth requires the transport layer to be initialized first.", + ) + token = await self._exchange_func(self._ak, self._sk) + self._cached_token = token + self._token_expires_at = time.time() + self.TOKEN_TTL + logger.debug("AK/SK token exchanged, expires at %.0f", self._token_expires_at) + return str(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. " + "Use create_auth_provider() to get a properly initialised AkSkAuth.", + suggestion=( + "Call create_auth_provider(access_key_id=..., access_key_secret=...) " + "instead of constructing AkSkAuth directly, or set the exchange " + "function via set_exchange_func()." + ), + ) + 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 + + +# ── Alibaba Cloud API signing helpers ────────────────────────────────────── +# Minimal subset needed for token exchange. + + +def _percent_encode(s: str) -> str: + """RFC 3986 percent-encoding (Alibaba Cloud signature convention).""" + return ( + urllib.parse.quote(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~") + ) + + +def _sign_rpc(params: dict[str, str], secret: str, method: str = "GET") -> str: + """Compute Alibaba Cloud RPC (POP) V1 HMAC-SHA1 signature.""" + sorted_params = sorted(params.items()) + canonical = "&".join(f"{_percent_encode(k)}={_percent_encode(v)}" for k, v in sorted_params) + sts = f"{method}&{_percent_encode('/')}&{_percent_encode(canonical)}" + key = (secret + "&").encode("utf-8") + return base64.b64encode(hmac_mod.new(key, sts.encode("utf-8"), hashlib.sha1).digest()).decode( + "utf-8" + ) + + +# ── Token exchange endpoint ──────────────────────────────────────────────── +# TODO(fcsandbox-api): The actual FCSandbox OpenAPI action for exchanging +# AK/SK to a temporary Platform API key has NOT been confirmed with the +# FC Agent Sandbox team. The implementation below uses a *reasonable* +# pattern modelled after ACR GetAuthorizationToken. +# Replace ``_TOKEN_EXCHANGE_ACTION`` and the response-parsing logic when +# the real endpoint is documented. +# +# See: AGENTS.md rule #5 — "Never fabricate FC/envd API endpoints." +_TOKEN_EXCHANGE_ACTION = "CreateApiKey" # TODO: confirm actual action name +_TOKEN_EXCHANGE_API_VERSION = "2026-05-09" # matches alibabacloud-fcsandbox SDK + + +async def _exchange_aksk_for_api_key( + access_key_id: str, + access_key_secret: str, + *, + region: str = "cn-hangzhou", +) -> str: + """Exchange Alibaba Cloud AK/SK for a temporary FCSandbox Platform API key. + + Uses Alibaba Cloud RPC (POP) V1 HMAC-SHA1 signing to call the FCSandbox + API. + + Args: + access_key_id: Alibaba Cloud AccessKey ID. + access_key_secret: Alibaba Cloud AccessKey Secret. + region: Region ID (default ``cn-hangzhou``). + + Returns: + Temporary API key string. + + Raises: + TokenExchangeError: If the exchange fails. + """ + endpoint = f"https://fcsandbox.{region}.aliyuncs.com" + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + params: dict[str, str] = { + "Action": _TOKEN_EXCHANGE_ACTION, + "Format": "JSON", + "Version": _TOKEN_EXCHANGE_API_VERSION, + "AccessKeyId": access_key_id, + "SignatureMethod": "HMAC-SHA1", + "Timestamp": ts, + "SignatureVersion": "1.0", + "SignatureNonce": str(uuid.uuid4()), + } + params["Signature"] = _sign_rpc(params, access_key_secret) + + logger.info( + "Exchanging AK/SK for Platform API key (region=%s, action=%s)", + region, + _TOKEN_EXCHANGE_ACTION, + ) + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client: + resp = await client.get(endpoint, params=params) + except httpx.ConnectError as exc: + raise TokenExchangeError( + f"Failed to connect to FCSandbox API for token exchange: {exc}", + suggestion=( + "Check network connectivity to fcsandbox." + f"{region}.aliyuncs.com. " + "Or use ABX_SANDBOX_API_KEY directly." + ), + ) from exc + except httpx.TimeoutException as exc: + raise TokenExchangeError( + f"Token exchange request timed out: {exc}", + suggestion="Check network connectivity or try again later.", + ) from exc + + if resp.status_code != 200: + body_text = resp.text[:500] + logger.warning("Token exchange failed (HTTP %s): %s", resp.status_code, body_text) + raise TokenExchangeError( + f"Token exchange failed (HTTP {resp.status_code}): {body_text}", + suggestion=( + "Verify AK/SK credentials have FCSandbox API access. " + "Or use ABX_SANDBOX_API_KEY directly." + ), + ) + + try: + data = resp.json() + except Exception as exc: + raise TokenExchangeError( + f"Invalid JSON response from token exchange: {resp.text[:200]}", + ) from exc + + # TODO(fcsandbox-api): Adjust response field names to match the actual + # API response when confirmed. Common patterns in Alibaba Cloud APIs: + # {"ApiKey": "ek-xxx", "ExpireTime": "...", "RequestId": "..."} + # {"Data": {"ApiKey": "ek-xxx", ...}, "RequestId": "..."} + api_key = ( + data.get("ApiKey") + or data.get("apiKey") + or (data.get("Data") or {}).get("ApiKey") + or (data.get("data") or {}).get("apiKey") + ) + + if not api_key: + raise TokenExchangeError( + f"Token exchange response did not contain an API key: {list(data.keys())}", + suggestion=( + "The FCSandbox token exchange API response format may have changed. " + "Use ABX_SANDBOX_API_KEY directly as a workaround." + ), + ) + + logger.debug("AK/SK token exchange succeeded (region=%s)", region) + return str(api_key) + + +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 the appropriate auth provider based on available credentials. + + Priority: api_key > ABX_SANDBOX_API_KEY (fallback) > AK/SK + + When the AK/SK path is selected, a token exchange function is + automatically injected into the :class:`AkSkAuth` instance so that + credentials are exchanged for a temporary Platform API key on first + use (and auto-refreshed before expiry). + + Args: + api_key: Direct API key. + access_key_id: Alibaba Cloud AccessKey ID. + access_key_secret: Alibaba Cloud AccessKey Secret. + region: Alibaba Cloud region for token exchange (default ``cn-hangzhou``). + + Returns: + An AuthProvider instance. + + Raises: + InvalidAPIKeyError: If no credentials provided. + """ + 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: + logger.debug("Using AK/SK authentication (with token exchange)") + auth = AkSkAuth(access_key_id, access_key_secret) + + # Bind region into the exchange function via closure + _region = region + + async def _exchange(ak: str, sk: str) -> str: + return await _exchange_aksk_for_api_key(ak, sk, region=_region) + + auth.set_exchange_func(_exchange) + return auth + + raise InvalidAPIKeyError( + "No authentication credentials provided", + suggestion=( + "Set ABX_SANDBOX_API_KEY or both ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET." + ), + ) diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/engine.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/engine.py new file mode 100644 index 0000000..19bbf25 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/engine.py @@ -0,0 +1,93 @@ +"""Express control-plane and session assembly for the generic local engine.""" + +from __future__ import annotations + +import inspect +from typing import Any + +from agent_sandbox.providers.engine_contract import Session + + +def _opt(value: str) -> str | None: + return value or None + + +async def _close(http: Any) -> None: + result = http.close() + if inspect.isawaitable(result): + await result + + +async def create_engine_session(request: Any) -> tuple[Any, Session]: + from agent_sandbox.models.sandbox import SandboxConfig + + from .provider import AlicloudExpressProvider + + _build_infra = AlicloudExpressProvider().build_legacy_infra + from agent_sandbox.providers.shared.e2b.auth import EnvdTokenManager + from agent_sandbox.providers.shared.e2b.filesystem import FilesystemProtocol + from agent_sandbox.providers.shared.e2b.process import ProcessProtocol + from agent_sandbox.transport.http import stream_envelope_for_envd + + sandbox_config = SandboxConfig.model_validate( + { + "template": request.template or "base", + "timeout": request.timeout or 300, + "metadata": dict(request.metadata), + "env_vars": dict(request.envs), + "cpu": request.cpu or None, + "memory": request.memory or None, + "disk": request.disk or None, + "gpu": request.gpu or None, + "volume_mounts": dict(getattr(request, "volume_mounts", {})), + } + ) + _config, _auth, http_client, protocol = _build_infra( + api_key=_opt(request.api_key), + api_url=_opt(request.api_url), + access_key_id=_opt(request.access_key_id), + access_key_secret=_opt(request.access_key_secret), + region=_opt(request.region), + ) + try: + info = await protocol.create( + sandbox_config, + request_timeout=request.request_timeout or None, + ) + except Exception: + await _close(http_client) + raise + if not info.envd_url and info.sandbox_id: + info.envd_url = _config.build_envd_url(info.sandbox_id) + http_client._stream_envelope = stream_envelope_for_envd(info.envd_version) + token = EnvdTokenManager(info.envd_access_token, sandbox_id=info.sandbox_id) + from agent_sandbox.providers.shared.e2b.engine_terminal import stream_terminal + + session = Session( + sandbox_id=info.sandbox_id, + provider="alicloud-express", + envd_url=info.envd_url or "", + token=token, + http=http_client, + process=ProcessProtocol(http_client), + files=FilesystemProtocol(http_client), + sandbox_protocol=protocol, + terminal=stream_terminal, + ) + return info, session + + +async def kill_engine_resource(request: Any) -> None: + from .provider import AlicloudExpressProvider + + config, auth, http, protocol = AlicloudExpressProvider().build_legacy_infra( + api_key=_opt(request.api_key), + api_url=_opt(request.api_url), + access_key_id=_opt(request.access_key_id), + access_key_secret=_opt(request.access_key_secret), + region=_opt(request.region), + ) + try: + await protocol.kill(request.sandbox_id) + finally: + await _close(http) diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py new file mode 100644 index 0000000..1490fcc --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py @@ -0,0 +1,47 @@ +"""Express creation uses its engine; the public Sandbox never selects this transport.""" + +from __future__ import annotations + +import contextlib +from typing import Any + + +class EngineLifecycle: + def __init__(self, http: Any, config: Any) -> None: + from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol + + self.native = SandboxProtocol(http) + self.config = config + + def __getattr__(self, name: str) -> Any: + return getattr(self.native, name) + + async def create(self, config: Any, *, request_timeout: float | None = None) -> Any: + from agent_sandbox.engine.client import Engine + + return await Engine().create( + config.template, + timeout=config.timeout, + metadata=config.metadata, + envs=config.env_vars, + api_key=self.config.sandbox_api_key, + api_url=self.config.sandbox_base_url, + access_key_id=self.config.access_key_id, + access_key_secret=self.config.access_key_secret, + region=self.config.region, + request_timeout=request_timeout, + cpu=config.cpu, + memory=config.memory, + disk=config.disk, + gpu=config.gpu, + **({"volume_mounts": config.volume_mounts} if config.volume_mounts else {}), + ) + + async def kill(self, sandbox_id: str) -> None: + from agent_sandbox.engine.client import Engine + + try: + await self.native.kill(sandbox_id) + finally: + with contextlib.suppress(Exception): + await Engine().op("release", sandbox_id=sandbox_id) 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..28ed649 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py @@ -0,0 +1,23 @@ +"""Pure Express configuration metadata. No SDK, engine or cloud calls.""" + +from agent_sandbox.utils.config_keys import ( + ALICLOUD_EXPRESS_KEYS, + BUILTIN_PROVIDER_ID, + GENERIC_ENDPOINT_KEYS, +) + +CONFIG_METADATA = { + "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, +} 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..e510179 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py @@ -0,0 +1,199 @@ +"""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.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" + # 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 resolve_session_url(self, info: Any, config: Any) -> str: + return str(info.envd_url or config.build_envd_url(info.sandbox_id)) + + 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), + } + if operation == "create": + if http is None: + raise RuntimeError("Express session is missing its HTTP client") + from agent_sandbox.engine.protocols import ( + EngineFilesystemProtocol, + EngineProcessProtocol, + ) + from agent_sandbox.providers.shared.e2b.filesystem import FilesystemProtocol + + binding.update( + process_protocol=EngineProcessProtocol(info.sandbox_id), + filesystem_protocol=EngineFilesystemProtocol( + info.sandbox_id, bounded_reader=FilesystemProtocol(http) + ), + engine_data=True, + ) + return binding + + async def open_terminal(self, sandbox: Any, **options: Any) -> Any: + if sandbox._engine_data: + from agent_sandbox.engine.protocols import open_terminal + + return await open_terminal( + sandbox.id, cols=options.get("cols", 80), rows=options.get("rows", 24) + ) + 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 kill_engine_resource(self, request: Any) -> None: + from .engine import kill_engine_resource + + await kill_engine_resource(request) + + async def create_engine_session(self, request: Any) -> Any: + from .engine import create_engine_session + + return await create_engine_session(request) + + 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.transport.http import HttpClient + + from .lifecycle import EngineLifecycle + + 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=EngineLifecycle(http, config), + 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..34120d2 100644 --- a/client-sdk/src/agent_sandbox/providers/base.py +++ b/client-sdk/src/agent_sandbox/providers/base.py @@ -21,11 +21,13 @@ 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.transport.auth_contract import AuthProvider from agent_sandbox.transport.config import TransportConfig from agent_sandbox.transport.http import HttpClient @@ -233,6 +235,67 @@ 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 "") + + 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)) + + async def create_engine_session(self, request: Any) -> Any: + """Optional host-engine entry point, implemented by each participating backend.""" + raise ProviderFeatureNotSupportedError(self.id, "engine.create") + + async def kill_engine_resource(self, request: Any) -> None: + """Control-plane deletion without an engine-held session is backend owned.""" + raise ProviderFeatureNotSupportedError(self.id, "engine.kill") + 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 d24ff88..dd79ed1 100644 --- a/client-sdk/src/agent_sandbox/providers/builtin.py +++ b/client-sdk/src/agent_sandbox/providers/builtin.py @@ -1,96 +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 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" - # 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 - - 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/engine_contract.py b/client-sdk/src/agent_sandbox/providers/engine_contract.py new file mode 100644 index 0000000..eeb33e6 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/engine_contract.py @@ -0,0 +1,21 @@ +"""Backend session contract for the provider engine.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Session: + """Backend connections for one sandbox. Tokens stay in this process.""" + + sandbox_id: str + provider: str + envd_url: str + token: Any + http: Any + process: Any + files: Any + sandbox_protocol: Any + terminal: Any = None 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/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..93dda42 100644 --- a/client-sdk/src/agent_sandbox/providers/rest_provider.py +++ b/client-sdk/src/agent_sandbox/providers/rest_provider.py @@ -64,11 +64,34 @@ 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 resolve_session_url(self, info: Any, config: Any) -> str: + return str(info.envd_url or config.build_envd_url(info.sandbox_id)) + + 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/engine_terminal.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/engine_terminal.py new file mode 100644 index 0000000..2475fda --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/engine_terminal.py @@ -0,0 +1,78 @@ +"""Engine PTY frames for providers choosing the E2B terminal wire format.""" + +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any + +import websockets + +from agent_sandbox.engine import engine_pb2 as pb2 + +_pb2: Any = pb2 + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from agent_sandbox.providers.engine_contract import Session + + +async def stream_terminal( + session: Session, first: Any, request_iterator: AsyncIterator[Any] +) -> AsyncIterator[Any]: + ws_url = session.envd_url.replace("https://", "wss://").replace("http://", "ws://") + ws_url = f"{ws_url}/terminal" + headers: dict[str, str] = {} + if session.token is not None and getattr(session.token, "token", None): + headers = session.token.get_headers() + async with websockets.connect(ws_url, additional_headers=headers) as socket: + if first.cols or first.rows: + await socket.send( + json.dumps({"type": "resize", "cols": first.cols, "rows": first.rows}) + ) + yield _pb2.PtyFrame(op="open") + outgoing: asyncio.Queue[Any] = asyncio.Queue(maxsize=4) + + async def from_socket() -> None: + try: + async for message in socket: + data = message if isinstance(message, bytes) else message.encode() + await outgoing.put(_pb2.PtyFrame(op="data", data=data)) + finally: + await outgoing.put(None) + + async def from_client() -> None: + async for frame in request_iterator: + if frame.op == "resize": + text = json.dumps({"type": "resize", "cols": frame.cols, "rows": frame.rows}) + await socket.send(text) + elif frame.op == "close": + return + elif frame.data: + await socket.send(frame.data) + + reader = asyncio.create_task(from_socket()) + writer = asyncio.create_task(from_client()) + try: + async for item in _until_client_closes(outgoing, writer): + yield item + finally: + writer.cancel() + reader.cancel() + + +async def _until_client_closes( + outgoing: asyncio.Queue[Any], writer: asyncio.Task[None] +) -> AsyncIterator[Any]: + """Yield socket frames until the client closes the RPC.""" + while True: + incoming = asyncio.create_task(outgoing.get()) + done, _pending = await asyncio.wait({incoming, writer}, return_when=asyncio.FIRST_COMPLETED) + if writer in done and incoming not in done: + incoming.cancel() + return + item = incoming.result() + if item is None: + return + yield item 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..2d35c5f --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py @@ -0,0 +1,441 @@ +"""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 _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/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..eda28e1 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py @@ -0,0 +1,228 @@ +"""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.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 __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/transport/__init__.py b/client-sdk/src/agent_sandbox/transport/__init__.py index c6e2aef..0bb0a1d 100644 --- a/client-sdk/src/agent_sandbox/transport/__init__.py +++ b/client-sdk/src/agent_sandbox/transport/__init__.py @@ -1,6 +1,6 @@ """L1 Transport and Authentication layer.""" -from agent_sandbox.transport.auth import AkSkAuth, ApiKeyAuth, AuthProvider +from agent_sandbox.providers.alicloud_express.auth import AkSkAuth, ApiKeyAuth, AuthProvider from agent_sandbox.transport.config import TransportConfig from agent_sandbox.transport.http import HttpClient diff --git a/client-sdk/src/agent_sandbox/transport/auth.py b/client-sdk/src/agent_sandbox/transport/auth.py index 4ea3cf3..7d95e5b 100644 --- a/client-sdk/src/agent_sandbox/transport/auth.py +++ b/client-sdk/src/agent_sandbox/transport/auth.py @@ -1,432 +1,58 @@ -"""Authentication providers for Platform API and Sandbox envd API. +"""Compatibility exports; backend implementation lives in its provider.""" -Two authentication modes: -1. API Key: ABX_SANDBOX_API_KEY -> Authorization: Bearer {api_key} header (Platform API, 已实测验证) -2. AK/SK: Exchange for temporary API key (TTL=3600s, auto-refresh 5min before expiry) +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 hashlib -import hmac as hmac_mod -import time -import urllib.parse -import uuid -from datetime import datetime, timezone -from typing import Any, Protocol - -import httpx - -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 ( + _TOKEN_EXCHANGE_ACTION as _TOKEN_EXCHANGE_ACTION, +) +from agent_sandbox.providers.alicloud_express.auth import ( + _TOKEN_EXCHANGE_API_VERSION as _TOKEN_EXCHANGE_API_VERSION, +) +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 ( + _exchange_aksk_for_api_key as _exchange_aksk_for_api_key, +) +from agent_sandbox.providers.alicloud_express.auth import ( + _percent_encode as _percent_encode, +) +from agent_sandbox.providers.alicloud_express.auth import ( + _sign_rpc as _sign_rpc, +) +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 X-API-KEY 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: - """AK/SK authentication — exchanges AccessKey for temporary API key. - - Uses ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET. - Exchanges for a temporary token with TTL=3600s. - Auto-refreshes 5 minutes (300s) before expiry. - Token is cached in memory only (never persisted to disk). - """ - - TOKEN_TTL = 3600 # 1 hour # 假设值,官方文档未给出 token 交换参数,需实测验证 - REFRESH_BEFORE_EXPIRY = 300 # 5 minutes # 假设值,官方文档未给出 token 交换参数,需实测验证 - - 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. - - This calls the Alibaba Cloud STS-like endpoint to get a temporary token. - The actual HTTP call is delegated to the exchange function set by the transport layer. - """ - if self._exchange_func is None: - raise InvalidCredentialsError( - "Token exchange function not configured. " - "AK/SK auth requires the transport layer to be initialized first.", - ) - token = await self._exchange_func(self._ak, self._sk) - self._cached_token = token - self._token_expires_at = time.time() + self.TOKEN_TTL - logger.debug("AK/SK token exchanged, expires at %.0f", self._token_expires_at) - return str(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. " - "Use create_auth_provider() to get a properly initialised AkSkAuth.", - suggestion=( - "Call create_auth_provider(access_key_id=..., access_key_secret=...) " - "instead of constructing AkSkAuth directly, or set the exchange " - "function via set_exchange_func()." - ), - ) - 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}", - } - - -# ── Alibaba Cloud API signing helpers ────────────────────────────────────── -# Minimal subset needed for token exchange. - - -def _percent_encode(s: str) -> str: - """RFC 3986 percent-encoding (Alibaba Cloud signature convention).""" - return ( - urllib.parse.quote(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~") - ) - - -def _sign_rpc(params: dict[str, str], secret: str, method: str = "GET") -> str: - """Compute Alibaba Cloud RPC (POP) V1 HMAC-SHA1 signature.""" - sorted_params = sorted(params.items()) - canonical = "&".join(f"{_percent_encode(k)}={_percent_encode(v)}" for k, v in sorted_params) - sts = f"{method}&{_percent_encode('/')}&{_percent_encode(canonical)}" - key = (secret + "&").encode("utf-8") - return base64.b64encode(hmac_mod.new(key, sts.encode("utf-8"), hashlib.sha1).digest()).decode( - "utf-8" - ) - - -# ── Token exchange endpoint ──────────────────────────────────────────────── -# TODO(fcsandbox-api): The actual FCSandbox OpenAPI action for exchanging -# AK/SK to a temporary Platform API key has NOT been confirmed with the -# FC Agent Sandbox team. The implementation below uses a *reasonable* -# pattern modelled after ACR GetAuthorizationToken. -# Replace ``_TOKEN_EXCHANGE_ACTION`` and the response-parsing logic when -# the real endpoint is documented. -# -# See: AGENTS.md rule #5 — "Never fabricate FC/envd API endpoints." -_TOKEN_EXCHANGE_ACTION = "CreateApiKey" # TODO: confirm actual action name -_TOKEN_EXCHANGE_API_VERSION = "2026-05-09" # matches alibabacloud-fcsandbox SDK - - -async def _exchange_aksk_for_api_key( - access_key_id: str, - access_key_secret: str, - *, - region: str = "cn-hangzhou", -) -> str: - """Exchange Alibaba Cloud AK/SK for a temporary FCSandbox Platform API key. - - Uses Alibaba Cloud RPC (POP) V1 HMAC-SHA1 signing to call the FCSandbox - API. - - Args: - access_key_id: Alibaba Cloud AccessKey ID. - access_key_secret: Alibaba Cloud AccessKey Secret. - region: Region ID (default ``cn-hangzhou``). - - Returns: - Temporary API key string. - - Raises: - TokenExchangeError: If the exchange fails. - """ - endpoint = f"https://fcsandbox.{region}.aliyuncs.com" - ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - params: dict[str, str] = { - "Action": _TOKEN_EXCHANGE_ACTION, - "Format": "JSON", - "Version": _TOKEN_EXCHANGE_API_VERSION, - "AccessKeyId": access_key_id, - "SignatureMethod": "HMAC-SHA1", - "Timestamp": ts, - "SignatureVersion": "1.0", - "SignatureNonce": str(uuid.uuid4()), - } - params["Signature"] = _sign_rpc(params, access_key_secret) - - logger.info( - "Exchanging AK/SK for Platform API key (region=%s, action=%s)", - region, - _TOKEN_EXCHANGE_ACTION, - ) - - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client: - resp = await client.get(endpoint, params=params) - except httpx.ConnectError as exc: - raise TokenExchangeError( - f"Failed to connect to FCSandbox API for token exchange: {exc}", - suggestion=( - "Check network connectivity to fcsandbox." - f"{region}.aliyuncs.com. " - "Or use ABX_SANDBOX_API_KEY directly." - ), - ) from exc - except httpx.TimeoutException as exc: - raise TokenExchangeError( - f"Token exchange request timed out: {exc}", - suggestion="Check network connectivity or try again later.", - ) from exc - - if resp.status_code != 200: - body_text = resp.text[:500] - logger.warning("Token exchange failed (HTTP %s): %s", resp.status_code, body_text) - raise TokenExchangeError( - f"Token exchange failed (HTTP {resp.status_code}): {body_text}", - suggestion=( - "Verify AK/SK credentials have FCSandbox API access. " - "Or use ABX_SANDBOX_API_KEY directly." - ), - ) - - try: - data = resp.json() - except Exception as exc: - raise TokenExchangeError( - f"Invalid JSON response from token exchange: {resp.text[:200]}", - ) from exc - - # TODO(fcsandbox-api): Adjust response field names to match the actual - # API response when confirmed. Common patterns in Alibaba Cloud APIs: - # {"ApiKey": "ek-xxx", "ExpireTime": "...", "RequestId": "..."} - # {"Data": {"ApiKey": "ek-xxx", ...}, "RequestId": "..."} - api_key = ( - data.get("ApiKey") - or data.get("apiKey") - or (data.get("Data") or {}).get("ApiKey") - or (data.get("data") or {}).get("apiKey") - ) - - if not api_key: - raise TokenExchangeError( - f"Token exchange response did not contain an API key: {list(data.keys())}", - suggestion=( - "The FCSandbox token exchange API response format may have changed. " - "Use ABX_SANDBOX_API_KEY directly as a workaround." - ), - ) - - logger.debug("AK/SK token exchange succeeded (region=%s)", region) - return str(api_key) - - -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 the appropriate auth provider based on available credentials. - - Priority: api_key > ABX_SANDBOX_API_KEY (fallback) > AK/SK - - When the AK/SK path is selected, a token exchange function is - automatically injected into the :class:`AkSkAuth` instance so that - credentials are exchanged for a temporary Platform API key on first - use (and auto-refreshed before expiry). - - Args: - api_key: Direct API key. - access_key_id: Alibaba Cloud AccessKey ID. - access_key_secret: Alibaba Cloud AccessKey Secret. - region: Alibaba Cloud region for token exchange (default ``cn-hangzhou``). - - Returns: - An AuthProvider instance. - - Raises: - InvalidAPIKeyError: If no credentials provided. - """ - 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: - logger.debug("Using AK/SK authentication (with token exchange)") - auth = AkSkAuth(access_key_id, access_key_secret) - - # Bind region into the exchange function via closure - _region = region - - async def _exchange(ak: str, sk: str) -> str: - return await _exchange_aksk_for_api_key(ak, sk, region=_region) - - auth.set_exchange_func(_exchange) - return auth - raise InvalidAPIKeyError( - "No authentication credentials provided", - suggestion=( - "Set ABX_SANDBOX_API_KEY or both ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET." - ), - ) +_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..e142661 --- /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 X-API-KEY 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 9164c55..0202004 100644 --- a/client-sdk/src/agent_sandbox/transport/config.py +++ b/client-sdk/src/agent_sandbox/transport/config.py @@ -25,10 +25,15 @@ from pathlib import Path from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator +from agent_sandbox.providers.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, @@ -58,13 +63,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): @@ -78,17 +81,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_*``) @@ -112,6 +113,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(已实测验证)。 @@ -213,25 +225,35 @@ def __init__( self.inherit = inherit self.derive = derive or {} self.endpoint_defaults = endpoint_defaults or {} + metadata = bundled_config(provider_id) or {} + self.legacy_fields = frozenset(metadata.get("legacy_fields", ())) + self.omit_default_options = bool(metadata.get("omit_default_options", False)) @property def builtin(self) -> bool: - return self.provider_id == BUILTIN_PROVIDER_ID + return bundled_config(self.provider_id) is not 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"], ) +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) from agent_sandbox.providers.registry import get_provider provider = get_provider(provider_id) @@ -240,7 +262,7 @@ def provider_schema(provider_id: str) -> ProviderSchema: provider.config_keys(), frozenset(provider.inherit), dict(provider.derive), - dict(getattr(provider, "endpoint_defaults", {}) or {}), + dict(provider.endpoint_defaults), ) @@ -280,7 +302,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", "") @@ -304,7 +326,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), @@ -374,19 +396,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: @@ -456,7 +482,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) @@ -465,7 +491,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) @@ -555,20 +581,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/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_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 4424e12..2f069f0 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() @@ -428,15 +438,24 @@ async def create(self, template: str, **kwargs: Any) -> SandboxInfo: seen["template"] = template return info - 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, MagicMock() + async def _infra(*_args: Any, **_kwargs: Any) -> ProviderInfra: + from agent_sandbox.providers.alicloud_express.lifecycle import EngineLifecycle + + config = TransportConfig(sandbox_api_key="k") + return ProviderInfra(config, MagicMock(), http, EngineLifecycle(http, config)) async def _resolve(*ids: str | None, **_kwargs: Any) -> ResolvedCapabilities: seen["ids"] = ids @@ -444,9 +463,9 @@ 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.object(provider, "build_infra", _infra), patch("agent_sandbox.engine.client.Engine", _FakeEngine), - patch("agent_sandbox.api.sandbox.resolve_capabilities_for", _resolve), + patch("agent_sandbox.api.capability.resolve_capabilities_for", _resolve), ): sb = await Sandbox.create(template="unique-alias", api_key="k") @@ -461,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 @@ -477,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") @@ -497,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() @@ -529,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() @@ -561,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() @@ -741,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() @@ -772,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 index 1554100..97303f0 100644 --- a/client-sdk/tests/test_api/test_volume_mounts.py +++ b/client-sdk/tests/test_api/test_volume_mounts.py @@ -12,9 +12,15 @@ from agent_sandbox.engine.client import Engine from agent_sandbox.engine.engine_pb2 import CreateRequest, HealthStatus from agent_sandbox.engine.server import ProviderEngine -from agent_sandbox.models.errors import ProviderConfigError, ProviderFeatureNotSupportedError +from agent_sandbox.models.errors import ( + ProviderConfigError, + ProviderFeatureNotSupportedError, + ProviderNotFoundError, +) from agent_sandbox.models.sandbox import SandboxConfig -from agent_sandbox.providers.builtin import AlicloudExpressProvider +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 agent_sandbox.transport.config import TransportConfig from tests.test_api.conftest import make_sandbox_info @@ -65,29 +71,44 @@ async def test_invalid_mounts_never_select_or_build_provider(mounts: Any) -> Non channel.assert_not_called() -@pytest.mark.parametrize("provider", ["uninstalled-plugin", AlicloudExpressProvider()]) -async def test_plugin_mounts_rejected_before_loading_or_building(provider: Any) -> None: - if not isinstance(provider, str): - provider.id = "programmatic-plugin" - with ( - patch("agent_sandbox.api.sandbox._select_provider") as select, - patch("agent_sandbox.api.sandbox._build_infra") as build, - pytest.raises(ProviderFeatureNotSupportedError) as caught, - ): - await Sandbox.create(provider=provider, volume_mounts={"/mnt/data": "existing-volume"}) - assert caught.value.code == "E8004" - select.assert_not_called() - build.assert_not_called() +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) + + from agent_sandbox.providers.alicloud_express.lifecycle import EngineLifecycle + + provider_lifecycle = EngineLifecycle(http, TransportConfig()) with ( - patch("agent_sandbox.api.sandbox._select_provider", return_value=(None, None)), - patch( - "agent_sandbox.api.sandbox._build_infra", - return_value=(TransportConfig(), AsyncMock(), http, AsyncMock()), + patch.object(provider, "build_infra", side_effect=factory), + patch.object( + provider, + "prepare_session", + new_callable=AsyncMock, + return_value={"envd_token": SessionAuth(None, "sb-1")}, ), patch("agent_sandbox.engine.client.Engine.create", new_callable=AsyncMock) as create, ): @@ -117,7 +138,7 @@ def test_model_exact_wire_and_empty_compatibility() -> None: async def test_engine_nonbuiltin_mounts_never_open_channel() -> None: with patch("agent_sandbox.engine.client._channel") as channel: - with pytest.raises(ProviderFeatureNotSupportedError): + with pytest.raises(ProviderNotFoundError): await Engine().create(provider="plugin", volume_mounts={"/mnt/data": "volume"}) channel.assert_not_called() @@ -132,7 +153,7 @@ async def test_raw_grpc_request_cannot_bypass_provider_or_path_gate( await ProviderEngine().Create(request, context) build.assert_not_called() error = json.loads(context.abort.call_args.args[1]) - assert error["code"] == ("E8004" if provider else "E8002") + assert error["code"] == ("E8001" if provider else "E8002") @pytest.mark.parametrize("mounts", [None, {}, {"/mnt/data": "volume"}]) diff --git a/client-sdk/tests/test_engine/test_engine.py b/client-sdk/tests/test_engine/test_engine.py index 51b0d47..5e5349d 100644 --- a/client-sdk/tests/test_engine/test_engine.py +++ b/client-sdk/tests/test_engine/test_engine.py @@ -77,11 +77,11 @@ async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> @pytest.mark.asyncio -async def test_engine_rejects_catalog_provider( +async def test_engine_reports_uninstalled_catalog_provider( tmp_path: Any, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("ABX_ENGINE_SOCKET", str(tmp_path / "engine.sock")) - with pytest.raises(ProviderNotFoundError, match="only creates alicloud-express"): + with pytest.raises(ProviderNotFoundError, match="not installed"): await Engine().create( "base", provider="opensandbox", 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_complete_isolation.py b/client-sdk/tests/test_providers/test_complete_isolation.py new file mode 100644 index 0000000..e4657fb --- /dev/null +++ b/client-sdk/tests/test_providers/test_complete_isolation.py @@ -0,0 +1,110 @@ +"""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", + }: + 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 == [] 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/design/architecture.md b/docs/en/design/architecture.md index f5d5c11..64501d9 100644 --- a/docs/en/design/architecture.md +++ b/docs/en/design/architecture.md @@ -248,3 +248,5 @@ The CLI (`abx`) formerly lived in `src/agent_sandbox/cli/` (Click commands under 4. **Alibaba Cloud Native**: Built for the Alibaba Cloud FC sandbox service 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) diff --git a/docs/en/design/provider-isolation.md b/docs/en/design/provider-isolation.md new file mode 100644 index 0000000..fa71137 --- /dev/null +++ b/docs/en/design/provider-isolation.md @@ -0,0 +1,43 @@ +# 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. The optional `create_engine_session` and +`kill_engine_resource` hooks remove vendor selection from the gRPC engine server. +The Express provider retains its existing engine-backed create behavior and wire format. + +`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. diff --git a/docs/zh/design/architecture.md b/docs/zh/design/architecture.md index 7ca21e3..ea1f42f 100644 --- a/docs/zh/design/architecture.md +++ b/docs/zh/design/architecture.md @@ -248,3 +248,5 @@ CLI(`abx`)原先位于 `src/agent_sandbox/cli/`(`commands/` 下的 Click 4. **阿里云原生**:为阿里云 FC 沙箱服务而做 5. **AI First**:沙箱内置 AI CLI 工具、MCP Server 是一等公民,SDK 零 LLM 依赖 6. **类型安全**:全面使用 Python Type Hints + Pydantic 验证 + +- [Provider implementation boundaries](provider-isolation.md) diff --git a/docs/zh/design/provider-isolation.md b/docs/zh/design/provider-isolation.md new file mode 100644 index 0000000..65ec8e5 --- /dev/null +++ b/docs/zh/design/provider-isolation.md @@ -0,0 +1,37 @@ +# 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` +负责各自会话。可选 `create_engine_session`、`kill_engine_resource` 钩子消除了 +核心 gRPC 引擎对具体云后端的选择;极速版保留原有引擎创建行为和协议格式。 + +`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 零改动迁移,也不会 +产生后端本身没有的能力。 From 622dd4fa8ee2ccb36b2f1ad5949b418a0aecc2c1 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:22:56 +0000 Subject: [PATCH 10/14] fix: preserve explicit provider through native engine creation --- .agents/README.md | 2 + ...-10-18-provider-explicit-engine-routing.md | 64 +++++++++++++++++++ ...2026-10-18-provider-portability-updates.md | 4 ++ CHANGELOG.md | 15 ++--- .../providers/alicloud_express/lifecycle.py | 1 + .../test_providers/test_complete_isolation.py | 16 +++++ docs/en/design/provider-isolation.md | 2 + docs/zh/design/provider-isolation.md | 2 + 8 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-18-provider-explicit-engine-routing.md diff --git a/.agents/README.md b/.agents/README.md index 41714dd..3f8d596 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -50,3 +50,5 @@ Move the file and edit `Status:` in the same commit. Required sections and the t - [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) 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/notes/proposed/architecture/2026-10-18-provider-portability-updates.md b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md index 47c027b..14e6e25 100644 --- a/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md @@ -46,3 +46,7 @@ The full-isolation follow-up removes the earlier guard-only limitation. Authenti 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5baf846..e9e2111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] -### Provider isolation - -- Route bundled and external backends through the same Provider contract. Move Express authentication, - cloud SDK/template deployment and native session binding into provider modules; preserve public import facades. -- Resolve provider-owned CLI/AI/config metadata per selection, reject undeclared native defaults/options, - and clean up newly created resources when session binding fails without deleting borrowed sandboxes. - - ### 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. @@ -31,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### 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. @@ -39,6 +36,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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. - **Real-cloud E2E acceptance.** The server fixture keeps envd credentials separate from the unconfigured template server; provider reports reject missing checks and the CLI scenario resolves `ABX_BIN` or an installed `abx` instead of assuming a sibling checkout. diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py index 1490fcc..2c34a53 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/lifecycle.py @@ -21,6 +21,7 @@ async def create(self, config: Any, *, request_timeout: float | None = None) -> return await Engine().create( config.template, + provider=self.config.provider, timeout=config.timeout, metadata=config.metadata, envs=config.env_vars, diff --git a/client-sdk/tests/test_providers/test_complete_isolation.py b/client-sdk/tests/test_providers/test_complete_isolation.py index e4657fb..aa55545 100644 --- a/client-sdk/tests/test_providers/test_complete_isolation.py +++ b/client-sdk/tests/test_providers/test_complete_isolation.py @@ -108,3 +108,19 @@ def test_core_imports_no_concrete_backend_implementation() -> None: ): violations.append(f"{relative}: {module}") assert violations == [] + + +@pytest.mark.asyncio +async def test_native_engine_keeps_explicit_provider_over_ambient(iso, monkeypatch): # noqa: F811 + from unittest.mock import patch + + from agent_sandbox.engine.client import Engine + from agent_sandbox.models.sandbox import SandboxConfig + from agent_sandbox.providers.alicloud_express.lifecycle import EngineLifecycle + + monkeypatch.setenv("ABX_PROVIDER", "another-provider") + lifecycle = EngineLifecycle(None, TransportConfig(provider="alicloud-express")) + info = SandboxInfo(sandbox_id="owned", template="base") + with patch.object(Engine, "create", new_callable=AsyncMock, return_value=info) as create: + assert await lifecycle.create(SandboxConfig()) is info + assert create.await_args.kwargs.get("provider") == "alicloud-express" diff --git a/docs/en/design/provider-isolation.md b/docs/en/design/provider-isolation.md index fa71137..8c8dc64 100644 --- a/docs/en/design/provider-isolation.md +++ b/docs/en/design/provider-isolation.md @@ -41,3 +41,5 @@ 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. + +The Express wrapper forwards the selected provider to its internal engine, so an explicit native selection wins over an unrelated ambient default. diff --git a/docs/zh/design/provider-isolation.md b/docs/zh/design/provider-isolation.md index 65ec8e5..30f5327 100644 --- a/docs/zh/design/provider-isolation.md +++ b/docs/zh/design/provider-isolation.md @@ -35,3 +35,5 @@ CLI 模板工厂及 CLI/AI 发现归属 CLI 仓库,本次不新增 Python 镜 [CLI 边界契约](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/blob/main/docs/zh/design/provider-isolation.md)。 内置 Provider 继续随匹配的 SDK 版本更新。适配器隔离不代表 SDK 零改动迁移,也不会 产生后端本身没有的能力。 + +极速版适配器向内部引擎显式传递所选 Provider,因此环境默认项不会覆盖调用方明确选择的后端。 From fdefc9e52ad14987eae0d87c932712a36f9bd3e0 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:20:34 +0000 Subject: [PATCH 11/14] test(providers): record zero-residue Express acceptance --- .../evidence/verify/2026-10-18-provider-main-integration.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.agents/evidence/verify/2026-10-18-provider-main-integration.md b/.agents/evidence/verify/2026-10-18-provider-main-integration.md index 6fc1e59..79e9201 100644 --- a/.agents/evidence/verify/2026-10-18-provider-main-integration.md +++ b/.agents/evidence/verify/2026-10-18-provider-main-integration.md @@ -32,3 +32,6 @@ 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. From 937eb5edc5330c9afe7577604c1078f22c17f079 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:25:07 +0000 Subject: [PATCH 12/14] refactor: isolate provider port and file addressing --- .agents/README.md | 2 + ...2026-10-18-provider-port-url-boundaries.md | 52 ++++++++++++++++ ...2026-10-18-provider-portability-updates.md | 6 ++ CHANGELOG.md | 3 + client-sdk/src/agent_sandbox/__init__.py | 2 + .../src/agent_sandbox/_provider_schema.py | 4 ++ client-sdk/src/agent_sandbox/api/files.py | 28 +++------ client-sdk/src/agent_sandbox/api/network.py | 61 ++++++++----------- client-sdk/src/agent_sandbox/api/sandbox.py | 22 ++++--- .../src/agent_sandbox/api/server_client.py | 18 +++--- .../src/agent_sandbox/protocol/__init__.py | 34 ++++++++--- client-sdk/src/agent_sandbox/protocol/port.py | 60 +----------------- .../agent_sandbox/protocol/port_contract.py | 47 ++++++++++++++ .../providers/alicloud_express/metadata.py | 15 +++++ .../providers/alicloud_express/provider.py | 6 ++ .../src/agent_sandbox/providers/base.py | 12 +++- .../agent_sandbox/providers/rest_provider.py | 6 ++ .../providers/shared/e2b/filesystem.py | 6 ++ .../providers/shared/e2b/ports.py | 56 +++++++++++++++++ .../providers/shared/e2b/sandbox.py | 6 ++ .../src/agent_sandbox/transport/__init__.py | 29 +++++++-- client-sdk/tests/test_api/conftest.py | 1 + client-sdk/tests/test_api/server_e2e/_live.py | 15 ++++- client-sdk/tests/test_api/test_files.py | 21 +++++++ .../test_providers/test_complete_isolation.py | 58 ++++++++++++++++++ .../test_providers/test_host_adapters.py | 3 +- docs/en/design/provider-isolation.md | 13 ++++ docs/zh/design/provider-isolation.md | 11 ++++ 28 files changed, 453 insertions(+), 144 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-18-provider-port-url-boundaries.md create mode 100644 client-sdk/src/agent_sandbox/protocol/port_contract.py create mode 100644 client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py diff --git a/.agents/README.md b/.agents/README.md index 022b63b..e46fca1 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -67,3 +67,5 @@ Builtin transport: [direct HTTP](notes/implemented/architecture/2026-10-18-nativ - [Current paired source installation](notes/proposed/process/2026-10-18-current-source-installation.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/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/notes/proposed/architecture/2026-10-18-provider-portability-updates.md b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md index 77b83d6..8cd25a6 100644 --- a/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md +++ b/.agents/notes/proposed/architecture/2026-10-18-provider-portability-updates.md @@ -58,3 +58,9 @@ replace core wire-format logic. Python schema composition remains lazy and injec 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/CHANGELOG.md b/CHANGELOG.md index dfb2569..d7cd0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### 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. diff --git a/client-sdk/src/agent_sandbox/__init__.py b/client-sdk/src/agent_sandbox/__init__.py index cee2d42..c39bb40 100644 --- a/client-sdk/src/agent_sandbox/__init__.py +++ b/client-sdk/src/agent_sandbox/__init__.py @@ -64,6 +64,8 @@ def _resolve_provider_schema(provider_id: str) -> Any: 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 diff --git a/client-sdk/src/agent_sandbox/_provider_schema.py b/client-sdk/src/agent_sandbox/_provider_schema.py index 9a8f260..6c9a289 100644 --- a/client-sdk/src/agent_sandbox/_provider_schema.py +++ b/client-sdk/src/agent_sandbox/_provider_schema.py @@ -28,6 +28,8 @@ def __init__( 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 @@ -37,6 +39,8 @@ def __init__( 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/files.py b/client-sdk/src/agent_sandbox/api/files.py index b37aeee..9fc2c92 100644 --- a/client-sdk/src/agent_sandbox/api/files.py +++ b/client-sdk/src/agent_sandbox/api/files.py @@ -228,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 1fac381..3aaa2e8 100644 --- a/client-sdk/src/agent_sandbox/api/sandbox.py +++ b/client-sdk/src/agent_sandbox/api/sandbox.py @@ -214,7 +214,7 @@ 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, @@ -235,7 +235,7 @@ def __init__( 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 defaults = ( get_provider(self._provider_id).session_protocols(http_client) @@ -358,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, @@ -366,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 @@ -420,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, ) 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/protocol/__init__.py b/client-sdk/src/agent_sandbox/protocol/__init__.py index 2e9b54e..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.port import PortClient -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.providers.shared.e2b.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/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/providers/alicloud_express/metadata.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py index 7f8a4d1..c29bd49 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/metadata.py @@ -9,9 +9,24 @@ GENERIC_ENDPOINT_KEYS, ) + +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: dict[str, Any] = { "id": BUILTIN_PROVIDER_ID, "legacy_url_builder": envd_url, + "legacy_port_factory": _legacy_ports, + "legacy_pty_url_builder": _legacy_pty_url, "keys": ALICLOUD_EXPRESS_KEYS, "inherit": frozenset(GENERIC_ENDPOINT_KEYS), "derive": { diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py index 1ffa9e4..8b117ca 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py @@ -15,6 +15,7 @@ ) if TYPE_CHECKING: + from agent_sandbox.protocol.port_contract import PortAccess, PortContext from agent_sandbox.transport.config import TransportConfig @@ -113,6 +114,11 @@ def session_protocols(self, http: Any) -> dict[str, Any]: 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 diff --git a/client-sdk/src/agent_sandbox/providers/base.py b/client-sdk/src/agent_sandbox/providers/base.py index 3e10890..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, @@ -27,6 +27,7 @@ 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 @@ -270,6 +271,15 @@ def build_legacy_infra(self, **options: Any) -> Any: 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]: diff --git a/client-sdk/src/agent_sandbox/providers/rest_provider.py b/client-sdk/src/agent_sandbox/providers/rest_provider.py index e7729f7..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 @@ -76,6 +77,11 @@ def session_protocols(self, http: Any) -> dict[str, Any]: 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 diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py index 2d35c5f..5068682 100644 --- a/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/filesystem.py @@ -185,6 +185,12 @@ class FilesystemProtocol: 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, 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/sandbox.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py index eda28e1..95d40de 100644 --- a/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py @@ -22,6 +22,7 @@ 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") @@ -36,6 +37,11 @@ async def prepare_session(self, info: SandboxInfo) -> dict[str, Any]: 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: diff --git a/client-sdk/src/agent_sandbox/transport/__init__.py b/client-sdk/src/agent_sandbox/transport/__init__.py index 0bb0a1d..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.providers.alicloud_express.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/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_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_providers/test_complete_isolation.py b/client-sdk/tests/test_providers/test_complete_isolation.py index dfb2fb2..883ef2a 100644 --- a/client-sdk/tests/test_providers/test_complete_isolation.py +++ b/client-sdk/tests/test_providers/test_complete_isolation.py @@ -85,6 +85,7 @@ def test_core_imports_no_concrete_backend_implementation() -> None: "protocol/process.py", "protocol/code_interpreter.py", "protocol/terminal.py", + "protocol/port.py", }: continue for node in ast.walk(tree): @@ -139,3 +140,60 @@ def test_bundled_schema_does_not_discover_registry_in_a_fresh_process(): 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/docs/en/design/provider-isolation.md b/docs/en/design/provider-isolation.md index 9bb631b..072a806 100644 --- a/docs/en/design/provider-isolation.md +++ b/docs/en/design/provider-isolation.md @@ -53,3 +53,16 @@ Configuration schema discovery is injected from the package composition root. Re the bundled schema loads pure provider metadata without discovering the registry; provider package exports remain lazy. Compatibility names/constants and generated configuration declarations remain public data, with one runtime implementation per adapter. + +## 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/zh/design/provider-isolation.md b/docs/zh/design/provider-isolation.md index d325c10..6a99d70 100644 --- a/docs/zh/design/provider-isolation.md +++ b/docs/zh/design/provider-isolation.md @@ -45,3 +45,14 @@ Volume 参数。会话 URL 由所选协议适配器构建;历史 TransportConf 配置 Schema 解析由包的组合入口注入。读取内置 Schema 只加载纯元数据,不发现注册表; Provider 包的公共导出保持延迟加载。兼容名称、常量和生成的配置声明保留为公共数据, 每项后端行为只在适配器中保留一份运行时实现。 + +## 端口与文件 URL 的归属 + +同步端口访问和项目服务的网关鉴权头由所选适配器实现。只有异步端口发现能力的 +Provider 应调用 `await sandbox.network.resolve(port)`;同步辅助方法返回 E8004, +不会猜测极速版或 E2B 地址,也不会发送其他后端的令牌。独立构造网络模块时仍保留 +已文档化的内置兼容行为。文件传输 URL 是文件系统适配器的可选钩子;即便会话地址 +使用 HTTP,缺少钩子也返回 E8004。核心不会为其他 Provider 构造 `/files` 路由。 + +Python 中立传输和协议模块的导入不会加载内置认证或具体协议;显式请求历史兼容 +导入时再延迟解析。 From 8157810619a0e156970fdc6064f793cecb6d01b9 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:33:52 +0000 Subject: [PATCH 13/14] fix(providers): compose native compatibility factories lazily --- .../2026-10-18-provider-main-integration.md | 8 ++++++++ .../agent_sandbox/providers/bundled_config.py | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.agents/evidence/verify/2026-10-18-provider-main-integration.md b/.agents/evidence/verify/2026-10-18-provider-main-integration.md index 5cf2bce..00cfa24 100644 --- a/.agents/evidence/verify/2026-10-18-provider-main-integration.md +++ b/.agents/evidence/verify/2026-10-18-provider-main-integration.md @@ -45,3 +45,11 @@ 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. diff --git a/client-sdk/src/agent_sandbox/providers/bundled_config.py b/client-sdk/src/agent_sandbox/providers/bundled_config.py index e454f92..4f9895f 100644 --- a/client-sdk/src/agent_sandbox/providers/bundled_config.py +++ b/client-sdk/src/agent_sandbox/providers/bundled_config.py @@ -1,6 +1,13 @@ -"""Compatibility exports for pure distribution composition metadata.""" +"""Distribution composition for bundled provider metadata; no cloud implementation import.""" -from agent_sandbox.utils.bundled_config import BUNDLED_CONFIGS as BUNDLED_CONFIGS -from agent_sandbox.utils.bundled_config import BUNDLED_KEYS as BUNDLED_KEYS -from agent_sandbox.utils.bundled_config import DEFAULT_CONFIG as DEFAULT_CONFIG -from agent_sandbox.utils.bundled_config import bundled_config as bundled_config +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) From 1857c5cbd7c0a67cf9042f4e6cc2283395705df3 Mon Sep 17 00:00:00 2001 From: Anycodes <21079031+anycodes@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:36:33 +0000 Subject: [PATCH 14/14] test(providers): record complete integrated client acceptance --- .../evidence/verify/2026-10-18-provider-main-integration.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.agents/evidence/verify/2026-10-18-provider-main-integration.md b/.agents/evidence/verify/2026-10-18-provider-main-integration.md index 00cfa24..86c8f46 100644 --- a/.agents/evidence/verify/2026-10-18-provider-main-integration.md +++ b/.agents/evidence/verify/2026-10-18-provider-main-integration.md @@ -53,3 +53,9 @@ URL/port/PTY factories. Restoring that separation fixes the actual public networ 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.