From 1aef5f67ab54131ab1e6a060034a3c1f8b5c6e5d Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:07:24 +0000 Subject: [PATCH 1/3] fix(sdk): complete Express lifecycle and application authentication --- .agents/README.md | 2 + .../2026-11-05-express-sdk-completion.md | 84 ++++ .../2026-11-05-express-sdk-completion.md | 92 +++++ .env.example | 3 + CHANGELOG.md | 4 + client-sdk/src/agent_sandbox/api/sandbox.py | 131 +++++- .../src/agent_sandbox/api/server_client.py | 29 +- .../src/agent_sandbox/models/network.py | 76 ++++ .../src/agent_sandbox/models/sandbox.py | 19 + .../src/agent_sandbox/protocol/contracts.py | 2 +- .../agent_sandbox/protocol/port_contract.py | 3 +- .../alicloud_express/config_metadata.py | 4 +- .../alicloud_express/managed_api_key.py | 1 + .../providers/alicloud_express/provider.py | 12 +- .../providers/alicloud_express/sandbox.py | 68 ++++ .../src/agent_sandbox/providers/commands.py | 2 +- .../providers/shared/e2b/ports.py | 6 +- .../providers/shared/e2b/sandbox.py | 3 +- .../src/agent_sandbox/transport/config.py | 3 + .../src/agent_sandbox/utils/config_keys.py | 10 + client-sdk/tests/fixtures/config-keys.json | 22 + .../server_e2e/test_server_client_e2e.py | 74 ++++ .../tests/test_api/test_express_completion.py | 376 ++++++++++++++++++ client-sdk/tests/test_api/test_sandbox.py | 30 +- client-sdk/tests/test_models/test_sandbox.py | 5 +- .../test_providers/test_registry_scoping.py | 2 +- docs/en/guide/authentication.md | 21 + docs/en/guide/provider-commands.md | 29 +- docs/en/guide/sdk-usage.md | 8 + docs/en/reference/api-reference.md | 63 ++- docs/en/reference/configuration.md | 2 + docs/zh/guide/authentication.md | 21 + docs/zh/guide/provider-commands.md | 29 +- docs/zh/guide/sdk-usage.md | 8 + docs/zh/reference/api-reference.md | 61 ++- docs/zh/reference/configuration.md | 2 + 36 files changed, 1243 insertions(+), 64 deletions(-) create mode 100644 .agents/evidence/verify/2026-11-05-express-sdk-completion.md create mode 100644 .agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md create mode 100644 client-sdk/src/agent_sandbox/models/network.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py create mode 100644 client-sdk/tests/test_api/test_express_completion.py diff --git a/.agents/README.md b/.agents/README.md index b62d4624..567cfd27 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,5 +1,7 @@ # `.agents/` — engineering record of the python-sdk repository +- [Express SDK completion](notes/proposed/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). + - [Defensive complexity cleanup](notes/implemented/architecture/2026-10-29-defensive-complexity-cleanup.md) · [verification](evidence/verify/2026-10-29-defensive-complexity-cleanup.md). ## Skill storage/state acceptance — 2026-10-26 diff --git a/.agents/evidence/verify/2026-11-05-express-sdk-completion.md b/.agents/evidence/verify/2026-11-05-express-sdk-completion.md new file mode 100644 index 00000000..13fc5cdd --- /dev/null +++ b/.agents/evidence/verify/2026-11-05-express-sdk-completion.md @@ -0,0 +1,84 @@ +# Verification: Express SDK completion + +- Date: 2026-11-05 +- Change: proposed/feature/2026-11-05-express-sdk-completion; + https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/152 +- Result: PARTIAL +- Package under test: client/server 0.4.0a6 source pair on task branch, based on + daabf3cc12fbbc877cb2942c35578d8424f6cc3b; no release or remote merge claimed. +- Environment: Linux, CPython 3.11.15; isolated editable client/server installation with + declared dev extras and build/twine. No Python dependency lock exists in this repository. + No cloud credentials were read or cloud mutations performed by this implementation agent. + +## What was verified + +Regressions exercise explicit application argument objects, retained private traffic +credentials, read-only attach, STS resolution and engine handoff, network creation wire +values, documented typed lifecycle routes and rebinding previously used provider objects. +Existing standard clients and unrelated provider behavior retain their own contracts. + +## Commands run + +From the client package: + +```sh +python -m pytest tests/test_api/test_express_completion.py -q +python -m pytest tests/test_api/server_e2e/test_server_client_e2e.py::TestCoreAndDiscovery -q +python -m pytest tests/test_api/test_express_completion.py tests/test_api/test_volume_mounts.py tests/test_api/test_lifecycle_without_connect.py tests/test_providers/test_managed_api_key.py tests/test_providers/test_commands.py tests/test_providers/test_sandbox_provider.py -q +python tests/fixtures/generate.py +mypy src/agent_sandbox/ +python -m pytest tests/ -m 'not integration' +``` + +From the owning repository: + +```sh +make ci +make build dist-check +bash scripts/check-repo-health.sh --quiet +``` + +## Results + +| Scenario / check | Result | Scope | +| --- | --- | --- | +| Initial candidate behavior regressions | FAIL, preserved | Missing APIs/fields/gates demonstrated before implementation; candidate alias expectations were later rejected in favor of the existing single-env contract. A mistaken test cleanup call was corrected before counting product failures. | +| Focused related suite | PASS, 133 tests | Local model/protocol/provider adapter checks, not cloud acceptance | +| Expanded compatibility suite | PASS, 220 tests; 5 skipped | Corrected owning protocol mock targets and current contract expectations; skip is absent sibling CLI checkout | +| Real local HTTP command suite | PASS, 7 tests | Running SandboxServer, including business timeout/request_timeout/arguments/name fields | +| Final explicit API regressions | PASS, 21 tests | Includes private create through application-client credentials without hidden POST | +| Final complete client suite | PASS, 2383 tests; 26 skipped, 10 deselected | Complete non-integration suite after compatibility corrections; the additional final private-create regression passed separately | +| Build and distribution checks | PASS | Both wheels/sdists, twine, py.typed and clean combined installation | +| Client mypy | PASS, 103 source files | Correct package configuration; an earlier repository-root invocation did not load that configuration and is not the gate result | +| Client lint / format | PASS | No broadened formatting changes | +| Health | PASS, 106 passes, 1 existing warning | Missing optional release runbook remains a warning | +| First full client run | FAIL: 2365 passed, 15 failed, 26 skipped, 10 deselected | Stale protocol mocks, fixtures and old contract assertions plus refresh URL derivation; these findings were corrected and the complete run repeated | +| First make ci server suite | FAIL: 878 passed, 2 failed | Existing malformed-WebSocket request intercepted locally; missing pip in the fresh uv environment | +| Exact fixed-HEAD server comparison | PARTIAL | Same malformed-WebSocket 501 on untouched source. Installing pip fixed the package-inventory prerequisite case. | + +The independent raw socket response for the malformed WebSocket request was HTTP501 with +`Content-Type: text/plain` and the body meaning "project network auditing is disabled". +This is not the server's expected JSON error. The unchanged server implementation explicitly +returns HTTP400 for this handshake. No product assertion was weakened to accept the local +network-layer response. Required remote gates remain independent. + +Final full-suite/build and real Express results are appended by the coordinating task when +completed. These local results do not certify all cloud APIs or service admission. + +## Cleanup + +Local SandboxServer fixtures own and close their listeners. No cloud sandbox, template, +Volume or API Key was created by this agent. Cloud cleanup evidence belongs to the separate +coordinated real-service run. + +## Problems found + +Complete the final independent review, containing CLI compatibility and real Express +same-object pause/resume/private-application verification. Existing official descriptions +of conditional network/metrics behavior are not resolved by local unit tests. + +## Not verified + +No complete cloud E2E, credential creation/renewal, storage backend or package publication +was performed in this local implementation slice. The ADR remains proposed pending the +coordinating task's required acceptance and merge gates. diff --git a/.agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md b/.agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md new file mode 100644 index 00000000..14cf3b72 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md @@ -0,0 +1,92 @@ +# Decision: Complete Express session and application command interfaces + +Status: proposed + +## Problem + +Express's standard client already supports the main E2B runtime surface, but the typed +provider client rejects pause/resume, loses the independent public-traffic credential and +cannot pass explicit network creation settings. Application command keywords conflict +with transport options, and STS identities lose their SecurityToken when forwarded to the +shared management engine. The preceding audit was challenged by three independent agents +before implementation. Refs https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/152. + +## Decision + +Keep standard and provider client responsibilities distinct. Implement the documented +Express pause/connect behavior in an Express-owned protocol adapter, with returned session +information rebinding the existing object's credentials, URL and cached modules. Keep +ProviderSandbox.connect read-only and never acquire credentials through an implicit +application-client reconnect. Retain traffic credentials in memory, exclude them from +default serialization and allow explicit attachment/client overrides. + +Add an explicit application invocation method without changing any existing run keyword. +Add supported provider network creation inputs and the Python-owned STS configuration key. +Resource-management and runtime extension commands continue to use the existing shared +engine client; Python does not duplicate the native management API implementation. + +## API Design + +- `ServerClient.commands.invoke(name, arguments, /, *, request_timeout=None)` and + `ProviderSandbox.invoke(name, arguments, /, *, server_port=9000, application_token=None, request_timeout=None)`, + with matching `invoke_sync` helpers. Existing run keyword semantics remain unchanged. +- `ProviderSandbox.create(..., network=None, allow_internet_access=None)` supports the + declared Express network creation fields. Unsupported providers fail before I/O. +- `ProviderSandbox.connect(..., traffic_access_token=None)` and + `application_client(..., traffic_access_token=None)` accept independent gateway credentials. + The latter override affects only that client. +- Provider lifecycle methods accepting cloud AccessKeys also accept `security_token`. + Its only environment name is `ALIBABA_CLOUD_SECURITY_TOKEN`; it is a secret credential. +- `SandboxProtocol.resume()` may return `SandboxInfo` or None. Existing providers returning + None retain their behavior. Express returns new session information through documented + POST connect and uses a 300-second lifetime, preserving the existing no-argument API. + +## Alternatives considered + +- **Add arguments as a run keyword** — rejected because it steals a currently valid business + argument. A new invoke method creates an unambiguous boundary. +- **Change attach to POST connect or reconnect inside application_client** — rejected because + that silently resumes a sandbox or renews its lifetime. +- **Only enable the pause operation gate** — rejected because the old generic adapter uses + a stale resume route and does not rebind rotated credentials. +- **Return live credentials through generic provider commands** — rejected because command + results are routinely printed; session credentials stay in the typed session path. +- **Add a second complete cloud SDK or restore configuration aliases** — rejected because + cloud mappings already belong to the shared engine and each current key has one env name. + +## Dependencies + +The configuration registry in `utils/config_keys.py` owns the JSON export consumed by CLI +and Node. Consumers synchronize that schema before using new credentials. New runtime +commands need a compatible abx installation and remain provider-declared operations. +The Node application's matching HTTP invocation work is tracked at +https://github.com/Alibaba-Agent-Sandbox/nodejs-sdk/issues/23. Native cloud mapping belongs +to https://github.com/Alibaba-Agent-Sandbox/agent-sandbox. + +## Test Strategy + +Add failing regressions for STS resolution/handoff, invocation field collisions, traffic +credential retention and headers, explicit attach without POST, network request fields, +typed pause/connect routes and same-object session rebinding. Exercise invoke against the +real local SandboxServer. Run client/server required checks and record local environment +failures separately from remote CI and real Express acceptance. Cloud mutations and owned +cleanup are coordinated by the root task, not silently triggered by these unit tests. + +## Acceptance criteria + +- All explicit business fields reach the server while HTTP options stay separate; old run + calls preserve their meaning and command errors retain their typed mapping. +- STS uses the complete triplet in the shared engine and the official single environment + name, with no unrelated provider inheritance or plaintext display. +- Create, attach, refresh and resume preserve the correct independent credentials; no + application operation performs an implicit cloud lifecycle mutation. +- After actual pause/resume the same provider object can execute commands and read/write + files with refreshed credentials and addressing. +- Network inputs preserve explicit false/empty values and require secure envd for private + public traffic; unsupported providers reject them before construction. +- Required local checks, real-component verification, bilingual documentation and independent + review are complete before this decision moves to implemented. + +## Evidence + +- [Verification](../../../evidence/verify/2026-11-05-express-sdk-completion.md). diff --git a/.env.example b/.env.example index 83cccb9b..424903a2 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,6 @@ ABX_SANDBOX_API_KEY=your_api_key_here # ACR_USERNAME= # ACR_PASSWORD= # ACREE_INSTANCE_ID= + +# Optional STS SecurityToken, paired with temporary ALICLOUD_ACCESS_KEY_ID/SECRET. +# ALIBABA_CLOUD_SECURITY_TOKEN=REPLACE_ME diff --git a/CHANGELOG.md b/CHANGELOG.md index d422c95c..5873e3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- Add explicit application command `invoke` arguments, Express network creation and STS SecurityToken handoff. / 新增应用命令 invoke 显式业务参数、极速版创建时网络配置和 STS SecurityToken 传递。 + - Express AK/SK managed-key preparation with explicit consent, private shared storage, expiry renewal and feature-scoped RAM permission guidance. / 极速版支持经确认的 AK/SK 管理 Key 准备、私有共享存储、到期续期及按功能划分的 RAM 授权指南。 ### Tests @@ -37,6 +39,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Implement typed Express pause/resume with session credential rebinding, preserve private traffic tokens without implicit reconnection, and keep legacy application command keyword behavior. / 完善极速版类型化暂停恢复和会话凭据更新,保留私有流量 token 且不隐式重连,并兼容原应用命令关键字行为。 + - Reuse a persistent loop for synchronous ProviderSandbox calls, preserve background shell execution and quoted arguments, release borrowed decorator clients, and retain session records when deletion fails. Authentication and transport failures now propagate from is_running. / 同步 ProviderSandbox 调用复用事件循环,修复后台 shell 和引号参数,释放装饰器借用连接;删除失败保留会话记录,is_running 向调用方抛出认证和传输错误。 - Verify explicit Python MCP CPU/memory requests against actual platform metadata before success; reject unknown/mismatched resources, roll back only the new sandbox and report independently verified cleanup or a recovery handle. Direct SDK creation is unchanged. / Python MCP 对显式 CPU/内存请求独立核验平台实际配置,无法核验或不匹配时仅回滚新建沙箱,返回清理确认或恢复句柄;直接 SDK 创建行为不变。 diff --git a/client-sdk/src/agent_sandbox/api/sandbox.py b/client-sdk/src/agent_sandbox/api/sandbox.py index 42bc8917..c7da84f3 100644 --- a/client-sdk/src/agent_sandbox/api/sandbox.py +++ b/client-sdk/src/agent_sandbox/api/sandbox.py @@ -120,6 +120,7 @@ def _endpoint_overrides( access_key_id: str | None, access_key_secret: str | None, region: str | None, + security_token: str | None = None, ) -> dict[str, Any]: overrides: dict[str, Any] = {} for name, value in ( @@ -127,6 +128,7 @@ def _endpoint_overrides( ("sandbox_base_url", sandbox_base_url), ("access_key_id", access_key_id), ("access_key_secret", access_key_secret), + ("security_token", security_token), ("region", region), ): if value is not None: @@ -340,6 +342,7 @@ def network(self) -> NetworkModule: self._domain, self._secure, self._envd_token.token if self._envd_token else None, + traffic_access_token=self._info.traffic_access_token, ), self._sandbox_protocol, ), @@ -403,12 +406,19 @@ def server_at( ) async def application_client( - self, port: int = 9000, *, token: str | None = None, timeout: float = 30.0 + self, + port: int = 9000, + *, + token: str | None = None, + traffic_access_token: str | None = None, + timeout: float = 30.0, ) -> ServerClient: """Resolve application HTTP independently of the sandbox control/data transport. The provider supplies this sandbox's port URL and minimal gateway headers. Application credentials are explicit and never inherited from API keys. + traffic_access_token overrides the stored gateway token for this client + only. No connect, resume or lifetime renewal is performed. """ from datetime import datetime, timezone @@ -422,6 +432,11 @@ async def application_client( self._domain, self._secure, self._envd_token.token if self._envd_token else None, + traffic_access_token=( + traffic_access_token + if traffic_access_token is not None + else self._info.traffic_access_token + ), ), port, self._sandbox_protocol, @@ -585,6 +600,8 @@ async def create( metadata: dict[str, str] | None = None, envs: dict[str, str] | None = None, volume_mounts: dict[str, str] | None = None, + network: dict[str, Any] | None = None, + allow_internet_access: bool | None = None, cpu: int | None = None, memory: int | None = None, disk: int | None = None, @@ -592,6 +609,7 @@ async def create( secure: bool | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, @@ -619,6 +637,8 @@ async def create( 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. + network: Explicit snake-case network creation settings, supported by Express. + allow_internet_access: Optional sandbox egress switch, supported by Express. 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 @@ -637,6 +657,7 @@ async def create( applies. Pass ``True``/``False`` to send an explicit request. access_key_id: AK/SK access key ID. access_key_secret: AK/SK access key secret. + security_token: STS token paired with temporary AccessKey credentials. sandbox_api_key: Sandbox platform API key override. sandbox_base_url: Sandbox platform API base URL override. sandbox_domain: Provider data-plane domain. @@ -662,11 +683,25 @@ async def create( ) mounts = validate_volume_mounts(volume_mounts) + if network is not None: + from agent_sandbox.models.network import network_create_payload + + network_create_payload(network) + if allow_internet_access is not None and type(allow_internet_access) is not bool: + raise ValueError("allow_internet_access must be a boolean.") + if network and network.get("allow_public_traffic") is False and secure is False: + raise ValueError("Disabling public traffic requires secure=True.") prov, _ = _select_provider(provider) if mounts and "volume_mounts" not in prov.create_options: from agent_sandbox.models.errors import ProviderFeatureNotSupportedError raise ProviderFeatureNotSupportedError(prov.id, "volume_mounts") + if ( + network is not None or allow_internet_access is not None + ) and "network" not in prov.create_options: + from agent_sandbox.models.errors import ProviderFeatureNotSupportedError + + raise ProviderFeatureNotSupportedError(prov.id, "network") return await cls._create_via_provider( prov, template=template, @@ -679,6 +714,8 @@ async def create( disk=disk, gpu=gpu, volume_mounts=mounts, + network=network, + allow_internet_access=allow_internet_access, secure=secure, domain=sandbox_domain, overrides=_endpoint_overrides( @@ -686,6 +723,7 @@ async def create( sandbox_base_url=sandbox_base_url, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, region=region, ), options=provider_options, @@ -697,8 +735,10 @@ async def connect( cls, sandbox_id: str, *, + traffic_access_token: str | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, @@ -712,6 +752,7 @@ async def connect( sandbox_id: The ID of the sandbox to connect to. access_key_id: AK/SK access key ID. access_key_secret: AK/SK access key secret. + security_token: STS token paired with temporary AccessKey credentials. sandbox_api_key: Sandbox platform API key override. sandbox_base_url: Sandbox platform API base URL override. sandbox_domain: Provider data-plane domain. @@ -734,9 +775,11 @@ async def connect( sandbox_base_url=sandbox_base_url, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, region=region, ), options=provider_options, + traffic_access_token=traffic_access_token, ) @classmethod @@ -748,6 +791,7 @@ async def list( offset: int = 0, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, @@ -763,6 +807,7 @@ async def list( offset: Pagination offset. access_key_id: AK/SK access key ID. access_key_secret: AK/SK access key secret. + security_token: STS token paired with temporary AccessKey credentials. sandbox_api_key: Sandbox platform API key override. sandbox_base_url: Sandbox platform API base URL override. sandbox_domain: Provider data-plane domain. @@ -783,6 +828,7 @@ async def list( sandbox_base_url=sandbox_base_url, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, region=region, ), provider_options, @@ -799,6 +845,7 @@ async def get_info_by_id( *, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, region: str | None = None, @@ -819,6 +866,7 @@ async def get_info_by_id( sandbox_id, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, sandbox_api_key=sandbox_api_key, sandbox_base_url=sandbox_base_url, region=region, @@ -836,6 +884,7 @@ async def set_timeout_by_id( *, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, region: str | None = None, @@ -854,6 +903,7 @@ async def set_timeout_by_id( sandbox_id, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, sandbox_api_key=sandbox_api_key, sandbox_base_url=sandbox_base_url, region=region, @@ -872,6 +922,7 @@ async def _control_plane_by_id( timeout: int | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, region: str | None = None, @@ -888,6 +939,7 @@ async def _control_plane_by_id( sandbox_base_url=sandbox_base_url, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, region=region, ), provider_options, @@ -909,6 +961,7 @@ async def kill_by_id( *, access_key_id: str | None = None, access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, region: str | None = None, @@ -921,6 +974,7 @@ async def kill_by_id( sandbox_id: The ID of the sandbox to kill. access_key_id: AK/SK access key ID. access_key_secret: AK/SK access key secret. + security_token: STS token paired with temporary AccessKey credentials. sandbox_api_key: Sandbox platform API key override. sandbox_base_url: Sandbox platform API base URL override. region: Built-in provider region (derives the API URL and domain). @@ -938,6 +992,7 @@ async def kill_by_id( sandbox_base_url=sandbox_base_url, access_key_id=access_key_id, access_key_secret=access_key_secret, + security_token=security_token, region=region, ), provider_options, @@ -964,6 +1019,8 @@ async def _create_via_provider( disk: int | None, gpu: str | None, volume_mounts: dict[str, str], + network: dict[str, Any] | None, + allow_internet_access: bool | None, secure: bool | None, domain: str | None, overrides: dict[str, Any], @@ -984,6 +1041,8 @@ async def _create_via_provider( disk=disk, gpu=gpu, volume_mounts=volume_mounts, + network=network, + allow_internet_access=allow_internet_access, secure=secure, provider_options=dict(options or {}), ) @@ -1043,11 +1102,14 @@ async def _connect_via_provider( domain: str | None, overrides: dict[str, Any], options: dict[str, Any] | None, + traffic_access_token: str | None = None, ) -> Sandbox: infra = await _provider_infra(prov, overrides, options) closer = _InfraCloser(infra) try: info = await infra.sandbox_protocol.connect(sandbox_id) + if traffic_access_token is not None: + info.traffic_access_token = traffic_access_token binding = await prov.prepare_session( infra, info, operation="connect", template=info.template ) @@ -1126,7 +1188,7 @@ async def is_running(self) -> bool: async def pause(self) -> None: """Pause the sandbox. - # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 + Express retains memory and requires the account's pause admission. """ await self._sandbox_protocol.pause(self.id) logger.info("Sandbox paused: %s", self.id) @@ -1134,20 +1196,47 @@ async def pause(self) -> None: async def resume(self) -> None: """Resume a paused sandbox. - # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 + Express explicitly connects with a 300-second lifetime and refreshes + this object's returned session credentials. Read-only attach does not + perform this operation. """ - await self._sandbox_protocol.resume(self.id) + info = await self._sandbox_protocol.resume(self.id) + if isinstance(info, SandboxInfo): + await self._rebind_session(info) logger.info("Sandbox resumed: %s", self.id) async def refresh_info(self) -> SandboxInfo: """Refresh sandbox info from the platform.""" - 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: + info = await self._sandbox_protocol.get_info(self.id) + if not info.envd_url: + info.envd_url = get_provider(self._provider_id).resolve_session_url(info, self._config) + await self._rebind_session(info) + return self._info + + async def _rebind_session(self, info: SandboxInfo) -> None: + """Apply returned session fields without erasing credentials omitted by GET.""" + if info.sandbox_id != self.id: + raise ValueError("Session refresh returned a different sandbox identity.") + previous_binding = (self.url, self._envd_token.token, self._info.traffic_access_token) + changes = {name: getattr(info, name) for name in info.model_fields_set} + self._info = self._info.model_copy(update=changes) + if not self._info.envd_url: self._info.envd_url = get_provider(self._provider_id).resolve_session_url( self._info, self._config ) - return self._info + if "envd_access_token" in changes: + if self._info.envd_access_token is None: + self._envd_token.clear() + else: + self._envd_token.set_token(self._info.envd_access_token, self.id) + if previous_binding == (self.url, self._envd_token.token, self._info.traffic_access_token): + return + await self._close_server_streams() + previous_server = self.__dict__.get("server") + for name in ("commands", "files", "network", "code", "server"): + self.__dict__.pop(name, None) + if previous_server is not None: + await previous_server.aclose() # ---- Convenience methods ---- @@ -1277,6 +1366,31 @@ async def run( async with await self.application_client(server_port) as client: return await client.commands.run(name, timeout=timeout, **kwargs) + async def invoke( + self, + name: str, + arguments: dict[str, Any], + /, + *, + server_port: int = 9000, + application_token: str | None = None, + request_timeout: float | None = None, + ) -> Any: + """Invoke a registered application command with explicit business arguments. + + Args: + name: Registered command name. + arguments: JSON argument object; all names are business fields. + server_port: Application HTTP port. + application_token: Explicit application authentication token. + request_timeout: HTTP timeout in seconds, independent of arguments. + + Returns: + The command's JSON result value. + """ + async with await self.application_client(server_port, token=application_token) as client: + return await client.commands.invoke(name, arguments, request_timeout=request_timeout) + # ---- E2B-shaped URL stubs ---- async def upload_url(self, path: str, **_kwargs: object) -> str: @@ -1332,6 +1446,7 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: get_upload_url_sync = make_sync(get_upload_url) get_download_url_sync = make_sync(get_download_url) run_sync = make_sync(run) + invoke_sync = make_sync(invoke) list_commands_sync = make_sync(list_commands) def __repr__(self) -> str: diff --git a/client-sdk/src/agent_sandbox/api/server_client.py b/client-sdk/src/agent_sandbox/api/server_client.py index ba272035..694240b7 100644 --- a/client-sdk/src/agent_sandbox/api/server_client.py +++ b/client-sdk/src/agent_sandbox/api/server_client.py @@ -1048,13 +1048,35 @@ async def run( CommandArgumentError: The arguments failed validation (HTTP 400). CommandExecutionError: The command function raised (HTTP 500). """ + return await self.invoke(name, kwargs, request_timeout=timeout) + + async def invoke( + self, + name: str, + arguments: dict[str, Any], + /, + *, + request_timeout: float | None = None, + ) -> Any: + """Invoke a command with an explicit business-argument object. + + Args: + name: Registered command name. + arguments: Command arguments, including names such as ``timeout``. + request_timeout: HTTP timeout in seconds, independent of arguments. + + Returns: + The command's JSON result value. + """ + if not isinstance(arguments, dict) or any(not isinstance(key, str) for key in arguments): + raise TypeError("Command arguments must be an object with string keys.") try: data = await self._c._request( "run_command", "POST", f"/commands/{quote(name, safe='')}", - json_body=kwargs, - timeout=timeout, + json_body=dict(arguments), + timeout=request_timeout, ) except (EndpointNotAvailableError, PolicyDeniedError): raise @@ -1075,6 +1097,7 @@ async def run( list_sync = make_sync(list) run_sync = make_sync(run) + invoke_sync = make_sync(invoke) # --------------------------------------------------------------------------- # @@ -1109,7 +1132,7 @@ class ServerClient: "capabilities": ("discover",), "capabilities_check": ("check",), "list_commands": ("commands.list",), - "run_command": ("commands.run",), + "run_command": ("commands.run", "commands.invoke"), "shell": ("process.shell",), "files_list": ("files.list",), "files_stat": ("files.stat", "files.exists"), diff --git a/client-sdk/src/agent_sandbox/models/network.py b/client-sdk/src/agent_sandbox/models/network.py new file mode 100644 index 00000000..a7173421 --- /dev/null +++ b/client-sdk/src/agent_sandbox/models/network.py @@ -0,0 +1,76 @@ +"""Validation and wire conversion for explicit sandbox network creation settings.""" + +from __future__ import annotations + +import copy +import json +import re +from typing import Any + +from agent_sandbox.models.errors import ProviderConfigError + + +def network_create_payload(network: dict[str, Any]) -> dict[str, Any]: + """Convert supported snake-case network settings without rewriting rule data.""" + names = { + "allow_out": "allowOut", + "deny_out": "denyOut", + "allow_public_traffic": "allowPublicTraffic", + "mask_request_host": "maskRequestHost", + "rules": "rules", + } + if not isinstance(network, dict) or set(network) - names.keys(): + raise ProviderConfigError( + "network supports allow_out, deny_out, allow_public_traffic, " + "mask_request_host and rules." + ) + for key, value in network.items(): + if key in {"allow_out", "deny_out"} and ( + not isinstance(value, list) + or any(not isinstance(target, str) or not target.strip() for target in value) + ): + raise ProviderConfigError("Network allow_out and deny_out must be lists of targets.") + if key == "allow_public_traffic" and type(value) is not bool: + raise ProviderConfigError("Network allow_public_traffic must be a boolean.") + if key == "mask_request_host" and not isinstance(value, str): + raise ProviderConfigError("Network mask_request_host must be a string.") + if key == "rules" and not isinstance(value, dict): + raise ProviderConfigError("Network rules must be an object.") + return {names[key]: copy.deepcopy(value) for key, value in network.items()} + + +def network_secret_values(network: dict[str, Any] | None) -> set[str]: + """Find credential-bearing header values without logging or rewriting rules.""" + secrets: set[str] = set() + + def add(value: object) -> None: + if isinstance(value, str) and value: + secrets.add(value) + bearer = re.match(r"^Bearer\s+(.+)$", value, re.IGNORECASE) + if bearer: + secrets.add(bearer[1]) + + rules = network.get("rules", {}) if isinstance(network, dict) else {} + if not isinstance(rules, dict): + return secrets + for entries in rules.values(): + if not isinstance(entries, list): + continue + for rule in entries: + transform = rule.get("transform") if isinstance(rule, dict) else None + headers = transform.get("headers") if isinstance(transform, dict) else None + if not isinstance(headers, dict): + continue + for name, value in headers.items(): + add(value) + if str(name).lower() == "fc.sandbox.network.header-value-replacements": + try: + replacements = json.loads(value) + except (TypeError, ValueError): + continue + if isinstance(replacements, list): + for item in replacements: + if isinstance(item, dict): + add(item.get("value")) + # Cloud errors may reflect a JSON-escaped value rather than the literal value. + return secrets | {json.dumps(value, ensure_ascii=True)[1:-1] for value in secrets} diff --git a/client-sdk/src/agent_sandbox/models/sandbox.py b/client-sdk/src/agent_sandbox/models/sandbox.py index 98b2bb7d..ee90c801 100644 --- a/client-sdk/src/agent_sandbox/models/sandbox.py +++ b/client-sdk/src/agent_sandbox/models/sandbox.py @@ -50,6 +50,8 @@ class SandboxStatus(str, enum.Enum): RUNNING = "running" # 已实测验证 PAUSED = "paused" PAUSING = "pausing" # CubeSandbox while a pause is in progress + RESUMING = "resuming" + SNAPSHOTTING = "snapshotting" STOPPING = "stopping" # SDK 自定义过渡态 STOPPED = "stopped" ERROR = "error" @@ -90,6 +92,8 @@ def _mounts(cls, value: Any) -> dict[str, str]: exclude=True, description="User-requested secure mode; None = not specified", ) + network: dict[str, Any] | None = Field(default=None, exclude=True, repr=False) + allow_internet_access: bool | None = Field(default=None, exclude=True) provider_options: dict[str, Any] = Field( default_factory=dict, exclude=True, @@ -120,6 +124,18 @@ def to_create_payload(self) -> dict[str, Any]: payload["volumeMounts"] = [ {"name": name, "path": path} for path, name in mounts.items() ] + if self.network is not None: + from agent_sandbox.models.network import network_create_payload + + payload["network"] = network_create_payload(self.network) + if self.network.get("allow_public_traffic") is False: + if self.secure is False: + raise ProviderConfigError("Disabling public traffic requires secure=True.") + payload["secure"] = True + if self.secure is not None: + payload["secure"] = self.secure + if self.allow_internet_access is not None: + payload["allowInternetAccess"] = self.allow_internet_access return payload @@ -137,6 +153,9 @@ class SandboxInfo(BaseModel): # 认证与版本信息(已实测验证) envd_access_token: str | None = Field(default=None, alias="envdAccessToken") + traffic_access_token: str | None = Field( + default=None, alias="trafficAccessToken", repr=False, exclude=True + ) envd_version: str | None = Field(default=None, alias="envdVersion") client_id: str | None = Field(default=None, alias="clientID") account_id: str | None = Field(default=None, alias="accountID") diff --git a/client-sdk/src/agent_sandbox/protocol/contracts.py b/client-sdk/src/agent_sandbox/protocol/contracts.py index 61acc2a0..66634e5a 100644 --- a/client-sdk/src/agent_sandbox/protocol/contracts.py +++ b/client-sdk/src/agent_sandbox/protocol/contracts.py @@ -41,7 +41,7 @@ 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: ... + async def resume(self, sandbox_id: str) -> SandboxInfo | None: ... class ProcessProtocol(Protocol): diff --git a/client-sdk/src/agent_sandbox/protocol/port_contract.py b/client-sdk/src/agent_sandbox/protocol/port_contract.py index 8acf31b0..4e1b45e7 100644 --- a/client-sdk/src/agent_sandbox/protocol/port_contract.py +++ b/client-sdk/src/agent_sandbox/protocol/port_contract.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import NoReturn, Protocol from agent_sandbox.models.errors import ProviderFeatureNotSupportedError @@ -14,6 +14,7 @@ class PortContext: domain: str secure: bool access_token: str | None + traffic_access_token: str | None = field(default=None, repr=False) class PortAccess(Protocol): diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/config_metadata.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/config_metadata.py index 16a14282..ba0f7ac0 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/config_metadata.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/config_metadata.py @@ -20,6 +20,8 @@ "sandbox_base_url": "https://api.cn-hangzhou.e2b.fc.aliyuncs.com", "sandbox_domain": "cn-hangzhou.e2b.fc.aliyuncs.com", }, - "transport_fields": frozenset({"region", "access_key_id", "access_key_secret"}), + "transport_fields": frozenset( + {"region", "access_key_id", "access_key_secret", "security_token"} + ), "omit_default_options": True, } diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/managed_api_key.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/managed_api_key.py index e16d963c..0109dab5 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/managed_api_key.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/managed_api_key.py @@ -41,6 +41,7 @@ async def resolve_sandbox_api_key(config: TransportConfig) -> str: "api-key.resolve", access_key_id=config.access_key_id, access_key_secret=config.access_key_secret, + security_token=config.security_token, region=config.region, http_timeout=config.http_timeout, ) 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 ecf23657..a2bf8f2d 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/provider.py @@ -53,6 +53,7 @@ class AlicloudExpressProvider(Provider): { "sandbox.lifecycle", "sandbox.timeout", + "sandbox.pause", "exec.run", "exec.stream", "process.manage", @@ -73,6 +74,8 @@ class AlicloudExpressProvider(Provider): "sandbox.list", "sandbox.delete", "sandbox.timeout", + "sandbox.pause", + "sandbox.resume", "exec.run", "exec.stream", "files.read", @@ -103,12 +106,15 @@ def standard_api_key(self) -> str | None: return standard_api_key() - create_options = frozenset({"volume_mounts"}) + create_options = frozenset({"volume_mounts", "network"}) def session_protocols(self, http: Any) -> dict[str, Any]: + from agent_sandbox.providers.alicloud_express.sandbox import SandboxProtocol from agent_sandbox.providers.shared.e2b.session import session_protocols - return session_protocols(http, provider_id=self.id) + protocols = session_protocols(http, provider_id=self.id) + protocols["sandbox_protocol"] = SandboxProtocol(http) + return protocols def create_port_access(self, context: PortContext, protocol: Any = None) -> PortAccess: from agent_sandbox.providers.shared.e2b.ports import WirePortAccess @@ -167,10 +173,10 @@ async def open_terminal(self, sandbox: Any, **options: Any) -> Any: async def build_infra(self, config: TransportConfig) -> ProviderInfra: from agent_sandbox.providers.alicloud_express.auth import create_auth_provider from agent_sandbox.providers.alicloud_express.managed_api_key import resolve_sandbox_api_key + from agent_sandbox.providers.alicloud_express.sandbox import SandboxProtocol from agent_sandbox.providers.shared.e2b.code_interpreter import UnsupportedCodeProtocol from agent_sandbox.providers.shared.e2b.filesystem import FilesystemProtocol from agent_sandbox.providers.shared.e2b.process import ProcessProtocol - from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol from agent_sandbox.transport.http import HttpClient auth = create_auth_provider( diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py new file mode 100644 index 00000000..34fe3679 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py @@ -0,0 +1,68 @@ +"""Express-only lifecycle behavior over the documented E2B-compatible API.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from agent_sandbox.models.errors import ProviderProtocolError, SandboxCreationError +from agent_sandbox.models.network import network_secret_values +from agent_sandbox.models.sandbox import SandboxStatus +from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol as E2bSandboxProtocol + +if TYPE_CHECKING: + from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo + + +class SandboxProtocol(E2bSandboxProtocol): + """Retain read-only attach while using connect explicitly for resume.""" + + async def create( + self, config: SandboxConfig, *, request_timeout: float | None = None + ) -> SandboxInfo: + """Keep reflected network credentials out of public errors and chains.""" + secrets = network_secret_values(config.network) + try: + return await super().create(config, request_timeout=request_timeout) + except SandboxCreationError as error: + if not secrets: + raise + + def redact(value: str) -> str: + for secret in sorted(secrets, key=len, reverse=True): + value = value.replace(secret, "[redacted]") + return value + + safe = type(error)( + redact(error.message), + code=error.code, + suggestion=redact(error.suggestion), + docs_url=redact(error.docs_url), + ) + response = getattr(error.__cause__, "response", None) + status = getattr(error, "status_code", getattr(response, "status_code", None)) + if isinstance(status, int): + safe.status_code = status # type: ignore[attr-defined] + # Raise after leaving the handler: even a suppressed __context__ would + # retain the raw HTTP exception, whose request carries the rule secrets. + raise safe + + async def pause(self, sandbox_id: str) -> None: + """Pause with memory retained; service admission remains account-specific.""" + await self._sandbox_request("POST", sandbox_id, "/pause", json={"memory": True}) + + async def resume(self, sandbox_id: str) -> SandboxInfo: + """Resume explicitly and return the new session credentials for rebinding.""" + response = await self._sandbox_request( + "POST", sandbox_id, "/connect", json={"timeout": 300} + ) + info = self._info(response.json()) + if info.sandbox_id != sandbox_id: + raise ProviderProtocolError("Resume returned a different sandbox identity.") + if "status" not in info.model_fields_set: + info.status = SandboxStatus.RUNNING + info.timeout = 300 + if info.envd_version: + from agent_sandbox.transport.http import stream_envelope_for_envd + + self._http._stream_envelope = stream_envelope_for_envd(info.envd_version) + return info diff --git a/client-sdk/src/agent_sandbox/providers/commands.py b/client-sdk/src/agent_sandbox/providers/commands.py index 7ecfd2b6..40347793 100644 --- a/client-sdk/src/agent_sandbox/providers/commands.py +++ b/client-sdk/src/agent_sandbox/providers/commands.py @@ -52,7 +52,7 @@ def _client( connection[wire] = value if provider == BUILTIN_PROVIDER_ID: connection["region"] = resolved.region - for name in ("access_key_id", "access_key_secret"): + for name in ("access_key_id", "access_key_secret", "security_token"): if value := getattr(resolved, name, None): connection[name] = value return EngineClient(connection) diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py index 7e0bf56b..0c64960c 100644 --- a/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/ports.py @@ -37,4 +37,8 @@ def get_access_headers(self) -> dict[str, str]: return {} def get_server_headers(self) -> dict[str, str]: - return self.get_access_headers() if self._server_auth else {} + if self._server_auth: + return self.get_access_headers() + if self._context.traffic_access_token: + return {"e2b-traffic-access-token": self._context.traffic_access_token} + return {} 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 fb8ef6c6..1ebea0ee 100644 --- a/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py @@ -207,7 +207,7 @@ async def pause(self, sandbox_id: str) -> None: # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 await self._sandbox_request("POST", sandbox_id, "/pause") - async def resume(self, sandbox_id: str) -> None: + async def resume(self, sandbox_id: str) -> SandboxInfo | None: """Resume a paused sandbox. POST /sandboxes/{sandbox_id}/resume @@ -215,6 +215,7 @@ async def resume(self, sandbox_id: str) -> None: """ # 注意:REST 路径基于 E2B SDK 逆向推断,未经官方文档确认 await self._sandbox_request("POST", sandbox_id, "/resume") + return None def _handle_create_error(self, exc: httpx.HTTPStatusError) -> None: """Convert HTTP errors to typed exceptions for create.""" diff --git a/client-sdk/src/agent_sandbox/transport/config.py b/client-sdk/src/agent_sandbox/transport/config.py index e9255584..e563d2ec 100644 --- a/client-sdk/src/agent_sandbox/transport/config.py +++ b/client-sdk/src/agent_sandbox/transport/config.py @@ -87,6 +87,9 @@ class TransportConfig(BaseModel): access_key_secret: str | None = Field( default=None, description="Provider AccessKey Secret", repr=False ) + security_token: str | None = Field( + default=None, description="Provider STS SecurityToken", repr=False + ) # Provider-scoped values of non-built-in providers (and extra built-in keys such as ``acr_*``) provider_options: dict[str, Any] = Field(default_factory=dict) diff --git a/client-sdk/src/agent_sandbox/utils/config_keys.py b/client-sdk/src/agent_sandbox/utils/config_keys.py index b817f15b..564ed6c0 100644 --- a/client-sdk/src/agent_sandbox/utils/config_keys.py +++ b/client-sdk/src/agent_sandbox/utils/config_keys.py @@ -308,6 +308,16 @@ def _ali( description="Alibaba Cloud AccessKey secret", description_zh="阿里云 AccessKey Secret", ), + _ali( + "security_token", + secret=True, + credential=True, + group=_G_ALI, + env_override="ALIBABA_CLOUD_SECURITY_TOKEN", + storage=Storage("env", "ALIBABA_CLOUD_SECURITY_TOKEN"), + description="Alibaba Cloud STS security token (with temporary AccessKey credentials)", + description_zh="阿里云 STS SecurityToken(与临时 AccessKey 凭据配套使用)", + ), ) _BY_KEY: dict[tuple[str, str], ConfigKey] = {} diff --git a/client-sdk/tests/fixtures/config-keys.json b/client-sdk/tests/fixtures/config-keys.json index c4eb21fe..92f9755c 100644 --- a/client-sdk/tests/fixtures/config-keys.json +++ b/client-sdk/tests/fixtures/config-keys.json @@ -483,6 +483,28 @@ "choices": [], "group": "Alibaba Cloud credentials", "description": "Alibaba Cloud AccessKey secret" + }, + { + "key": "security_token", + "scope": "alicloud-express", + "type": "str", + "secret": true, + "credential": true, + "settable": true, + "env": "ALIBABA_CLOUD_SECURITY_TOKEN", + "aliases": [], + "env_names": [ + "ALIBABA_CLOUD_SECURITY_TOKEN" + ], + "toml_section": null, + "storage": { + "file": "env", + "name": "ALIBABA_CLOUD_SECURITY_TOKEN" + }, + "default": null, + "choices": [], + "group": "Alibaba Cloud credentials", + "description": "Alibaba Cloud STS security token (with temporary AccessKey credentials)" } ] } diff --git a/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py b/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py index f339448b..8a37d629 100644 --- a/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py +++ b/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py @@ -48,6 +48,80 @@ def greet(name: str) -> str: class TestCoreAndDiscovery: + async def test_provider_invoke_authenticates_real_application( + self, server_factory: Any, sandbox: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + from agent_sandbox.server.registry import CommandRegistry + + from agent_sandbox.providers.registry import get_provider + + registry = CommandRegistry() + + @registry.command("reserved") + def echo(timeout: int, application_token: str) -> dict[str, Any]: + return {"timeout": timeout, "application_token": application_token} + + monkeypatch.setenv("ABX_SERVER_TOKEN", "placeholder-application-auth") + live, unused_client = server_factory(registry=registry) + + async def endpoint(context: Any, port: int, protocol: Any = None) -> dict[str, Any]: + return { + "provider_id": sandbox.provider, + "sandbox_id": sandbox.id, + "port": port, + "url": live.url, + "headers": {"e2b-traffic-access-token": "placeholder-gateway"}, + "authentication_audience": "gateway", + "path_mode": "prefix", + } + + monkeypatch.setattr(get_provider(sandbox.provider), "application_endpoint", endpoint) + arguments = {"timeout": 5, "application_token": "business-value"} + try: + with pytest.raises(ServerAPIError) as rejected: + await sandbox.invoke("reserved", arguments) + assert rejected.value.status_code == 401 + assert ( + await sandbox.invoke( + "reserved", arguments, application_token="placeholder-application-auth" + ) + == arguments + ) + finally: + await unused_client.aclose() + + async def test_explicit_command_arguments_on_real_server(self, server_factory: Any) -> None: + from agent_sandbox.server.registry import CommandArg, CommandRegistry + + registry = CommandRegistry() + + def echo(timeout: int, request_timeout: int, arguments: str, name: str) -> dict[str, Any]: + return { + "timeout": timeout, + "request_timeout": request_timeout, + "arguments": arguments, + "name": name, + } + + registry.register( + "reserved", + echo, + args=[ + CommandArg(name="timeout", type="integer", required=True), + CommandArg(name="request_timeout", type="integer", required=True), + CommandArg(name="arguments", type="string", required=True), + CommandArg(name="name", type="string", required=True), + ], + ) + _, client = server_factory(registry=registry) + arguments = {"timeout": 5, "request_timeout": 7, "arguments": "body", "name": "Ada"} + try: + assert ( + await client.commands.invoke("reserved", arguments, request_timeout=10) == arguments + ) + finally: + await client.aclose() + async def test_health(self, server: ServerClient) -> None: assert await server.health() is True diff --git a/client-sdk/tests/test_api/test_express_completion.py b/client-sdk/tests/test_api/test_express_completion.py new file mode 100644 index 00000000..ccc49521 --- /dev/null +++ b/client-sdk/tests/test_api/test_express_completion.py @@ -0,0 +1,376 @@ +"""Regression coverage for Express session credentials and collision-free commands.""" + +from __future__ import annotations + +import json +import traceback +from types import SimpleNamespace + +# ruff: noqa: F811 +from typing import Any +from unittest.mock import AsyncMock + +import httpx +import pytest + +from agent_sandbox.api.sandbox import Sandbox +from agent_sandbox.api.server_client import ServerClient +from agent_sandbox.models.errors import ( + QuotaExceededError, + SandboxCreationError, + TemplateNotFoundError, +) +from agent_sandbox.models.sandbox import SandboxConfig, SandboxInfo +from agent_sandbox.protocol.port_contract import PortContext +from agent_sandbox.providers import commands +from agent_sandbox.providers.alicloud_express.provider import AlicloudExpressProvider +from agent_sandbox.transport.config import explain_config, load_config +from agent_sandbox.utils.config_keys import get_key +from tests.iso import iso # noqa: F401 + + +def test_sts_token_resolves_as_secret(iso: Any, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ALIBABA_CLOUD_SECURITY_TOKEN", "placeholder-session-secret") + config = load_config(provider="alicloud-express") + assert config.security_token == "placeholder-session-secret" + assert "placeholder-session-secret" not in repr(config) + key = get_key("security_token", "alicloud-express") + assert key is not None and key.secret and key.credential + explanation = explain_config(provider="alicloud-express")["security_token"] + assert explanation["secret"] is True + assert explanation["value"] != "placeholder-session-secret" + + +def test_sts_does_not_restore_retired_environment_aliases( + iso: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ALICLOUD_SECURITY_TOKEN", "placeholder-not-configured") + monkeypatch.setenv("ABX_ALICLOUD_EXPRESS_SECURITY_TOKEN", "placeholder-not-configured") + assert load_config(provider="alicloud-express").security_token is None + + +def test_sts_token_reaches_command_engine(iso: Any, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(commands, "EngineClient", lambda connection: connection) + connection = commands._client( + "alicloud-express", + None, + { + "access_key_id": "placeholder-ak", + "access_key_secret": "placeholder-sk", + "security_token": "placeholder-sts", + }, + ) + assert connection["security_token"] == "placeholder-sts" + + +async def test_explicit_command_arguments_keep_reserved_business_names() -> None: + client = ServerClient("http://127.0.0.1:9000") + request = AsyncMock(return_value={"result": "ok"}) + client._request = request + arguments = {"timeout": 5, "request_timeout": 7, "arguments": [1], "name": "business"} + try: + assert await client.commands.invoke("probe", arguments, request_timeout=20) == "ok" + assert request.call_args.kwargs == {"json_body": arguments, "timeout": 20} + finally: + await client.aclose() + + +async def test_legacy_command_keywords_keep_their_meaning() -> None: + client = ServerClient("http://127.0.0.1:9000") + request = AsyncMock(return_value={"result": "ok"}) + client._request = request + try: + assert ( + await client.commands.run("probe", timeout=20, arguments=1, request_timeout=2) == "ok" + ) + assert request.call_args.kwargs == { + "json_body": {"arguments": 1, "request_timeout": 2}, + "timeout": 20, + } + finally: + await client.aclose() + + +@pytest.mark.parametrize("arguments", [None, [], "value", {1: "value"}]) +async def test_invoke_requires_object_with_string_keys(arguments: Any) -> None: + client = ServerClient("http://127.0.0.1:9000") + request = AsyncMock() + client._request = request + try: + with pytest.raises((TypeError, ValueError)): + await client.commands.invoke("probe", arguments) + request.assert_not_awaited() + finally: + await client.aclose() + + +def test_traffic_token_is_retained_but_never_default_serialized() -> None: + info = SandboxInfo(sandboxID="example", trafficAccessToken="placeholder-traffic") + assert info.traffic_access_token == "placeholder-traffic" + assert "placeholder-traffic" not in repr(info) + assert "placeholder-traffic" not in info.model_dump_json(by_alias=True) + + +async def test_express_application_uses_only_traffic_gateway_token() -> None: + provider = AlicloudExpressProvider() + context = PortContext( + "example", + "example.invalid", + True, + "placeholder-envd", + traffic_access_token="placeholder-traffic", + ) + descriptor = await provider.application_endpoint(context, 9000) + assert descriptor["headers"] == {"e2b-traffic-access-token": "placeholder-traffic"} + assert "placeholder-envd" not in str(descriptor) + + +def test_create_network_fields_survive_wire_conversion() -> None: + config = SandboxConfig( + network={"allow_out": [], "allow_public_traffic": False, "rules": {}}, + allow_internet_access=False, + ) + payload = config.to_create_payload() + assert payload["network"] == {"allowOut": [], "allowPublicTraffic": False, "rules": {}} + assert payload["allowInternetAccess"] is False + assert payload["secure"] is True + + +async def test_typed_express_pause_resume_uses_documented_routes() -> None: + provider = AlicloudExpressProvider() + infra = await provider.build_infra(load_config(sandbox_api_key="placeholder-key")) + http = infra.http_client + http.platform_request = AsyncMock( + side_effect=[ + httpx.Response(204), + httpx.Response( + 201, + json={ + "sandboxID": "example", + "envdAccessToken": "placeholder-new-envd", + "trafficAccessToken": "placeholder-new-traffic", + }, + ), + ] + ) + try: + await infra.sandbox_protocol.pause("example") + info = await infra.sandbox_protocol.resume("example") + assert http.platform_request.call_args_list[0].args == ("POST", "/sandboxes/example/pause") + assert http.platform_request.call_args_list[0].kwargs["json"] == {"memory": True} + assert http.platform_request.call_args_list[1].args == ( + "POST", + "/sandboxes/example/connect", + ) + assert info.envd_access_token == "placeholder-new-envd" + assert info.traffic_access_token == "placeholder-new-traffic" + finally: + await infra.http_client.close() + + +async def test_resume_rebinds_existing_object_and_cached_modules(sandbox: Sandbox) -> None: + previous_files, previous_commands, previous_network = ( + sandbox.files, + sandbox.commands, + sandbox.network, + ) + sandbox._sandbox_protocol.resume.return_value = SandboxInfo( + sandboxID=sandbox.id, + envdUrl="https://new-envd.example.invalid", + envdAccessToken="placeholder-new-envd", + trafficAccessToken="placeholder-new-traffic", + ) + original_template = sandbox._info.template + await sandbox.resume() + assert sandbox._info.template == original_template + assert sandbox.url == "https://new-envd.example.invalid" + assert sandbox._envd_token.token == "placeholder-new-envd" + assert sandbox._info.traffic_access_token == "placeholder-new-traffic" + assert sandbox.files is not previous_files + assert sandbox.commands is not previous_commands + assert sandbox.network is not previous_network + assert sandbox.files._envd_url == sandbox.url + assert sandbox.commands._envd_token.token == "placeholder-new-envd" + + +async def test_refresh_preserves_traffic_token_omitted_by_get(sandbox: Sandbox) -> None: + sandbox._info.traffic_access_token = "placeholder-existing" + previous_files = sandbox.files + sandbox._sandbox_protocol.get_info.return_value = SandboxInfo( + sandboxID=sandbox.id, envdUrl=sandbox.url + ) + await sandbox.refresh_info() + assert sandbox._info.traffic_access_token == "placeholder-existing" + assert sandbox.files is previous_files + + +async def test_attach_and_application_client_never_post_to_connect( + iso: Any, httpx_mock: Any +) -> None: + httpx_mock.add_response( + method="GET", + url="https://api.cn-hangzhou.e2b.fc.aliyuncs.com/sandboxes/example", + json={"sandboxID": "example", "envdAccessToken": "placeholder-envd"}, + ) + sandbox = await Sandbox.connect( + "example", sandbox_api_key="placeholder-key", traffic_access_token="placeholder-explicit" + ) + try: + async with await sandbox.application_client(token="placeholder-app") as application: + assert application.auth_headers()["e2b-traffic-access-token"] == "placeholder-explicit" + assert application.auth_headers()["Authorization"] == "Bearer placeholder-app" + assert [request.method for request in httpx_mock.get_requests()] == ["GET"] + finally: + await sandbox.close() + + +async def test_application_token_override_does_not_mutate_session(sandbox: Sandbox) -> None: + sandbox._info.traffic_access_token = "placeholder-session" + async with await sandbox.application_client( + traffic_access_token="placeholder-override" + ) as application: + assert application.auth_headers()["e2b-traffic-access-token"] == "placeholder-override" + assert sandbox._info.traffic_access_token == "placeholder-session" + + +async def test_provider_create_private_network_keeps_gateway_credentials( + iso: Any, httpx_mock: Any +) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.cn-hangzhou.e2b.fc.aliyuncs.com/sandboxes", + status_code=201, + json={ + "sandboxID": "example", + "envdAccessToken": "placeholder-envd", + "trafficAccessToken": "placeholder-created-traffic", + }, + ) + sandbox = await Sandbox.create( + sandbox_api_key="placeholder-key", + network={"allow_public_traffic": False}, + allow_internet_access=False, + ) + try: + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["network"] == {"allowPublicTraffic": False} + assert body["secure"] is True and body["allowInternetAccess"] is False + async with await sandbox.application_client() as application: + assert application.auth_headers()["e2b-traffic-access-token"] == ( + "placeholder-created-traffic" + ) + assert len(httpx_mock.get_requests()) == 1 + finally: + await sandbox.close() + + +async def test_provider_invoke_keeps_transport_names_in_business_arguments( + sandbox: Sandbox, +) -> None: + client = ServerClient("http://127.0.0.1:9000") + request = AsyncMock(return_value={"result": 42}) + client._request = request + sandbox.application_client = AsyncMock(return_value=client) + arguments = {"server_port": 123, "timeout": 5, "request_timeout": 7} + assert ( + await sandbox.invoke( + "probe", arguments, application_token="placeholder-app", request_timeout=30 + ) + == 42 + ) + sandbox.application_client.assert_awaited_once_with(9000, token="placeholder-app") + assert request.call_args.kwargs == {"json_body": arguments, "timeout": 30} + + +@pytest.mark.parametrize("operation", ["get_info_by_id", "set_timeout_by_id"]) +async def test_sts_reaches_direct_control_plane_calls( + operation: str, monkeypatch: pytest.MonkeyPatch +) -> None: + protocol = AsyncMock() + protocol.get_info.return_value = SandboxInfo(sandboxID="example") + build = AsyncMock(return_value=SimpleNamespace(sandbox_protocol=protocol, close=AsyncMock())) + monkeypatch.setattr("agent_sandbox.api.sandbox._provider_infra", build) + args = ("example", 600) if operation == "set_timeout_by_id" else ("example",) + await getattr(Sandbox, operation)(*args, security_token="placeholder-sts") + assert build.call_args.args[1]["security_token"] == "placeholder-sts" + + +async def test_fallback_session_uses_express_lifecycle_protocol() -> None: + from agent_sandbox.providers.alicloud_express.sandbox import SandboxProtocol + + http = SimpleNamespace( + platform_request=AsyncMock(return_value=httpx.Response(201, json={"sandboxID": "example"})) + ) + protocol = AlicloudExpressProvider().session_protocols(http)["sandbox_protocol"] + assert isinstance(protocol, SandboxProtocol) + await protocol.resume("example") + assert http.platform_request.call_args.args == ("POST", "/sandboxes/example/connect") + + +@pytest.mark.parametrize( + "status, error_type", + [(400, SandboxCreationError), (404, TemplateNotFoundError), (429, QuotaExceededError)], +) +async def test_network_create_errors_redact_reflection_and_detach_raw_exception( + status: int, error_type: type[SandboxCreationError] +) -> None: + from agent_sandbox.providers.alicloud_express.sandbox import SandboxProtocol + + replacement = json.dumps([{"key": "x-model-key", "value": "placeholder-replacement-secret"}]) + network = { + "rules": { + "example.invalid": [ + { + "transform": { + "headers": { + "Authorization": "Bearer placeholder-header-secret", + "fc.sandbox.network.header-value-replacements": replacement, + } + } + } + ] + } + } + config = SandboxConfig(network=network) + request = httpx.Request( + "POST", "https://api.example.invalid/sandboxes", json=config.to_create_payload() + ) + response = httpx.Response( + status, + request=request, + json={ + "message": ( + "Invalid header: placeholder-header-secret; " + "replacement placeholder-replacement-secret" + ) + }, + ) + http = SimpleNamespace( + platform_request=AsyncMock( + side_effect=httpx.HTTPStatusError("Cloud rejection", request=request, response=response) + ) + ) + with pytest.raises(error_type) as caught: + await SandboxProtocol(http).create(config) + error = caught.value + assert "Invalid header" in error.message + assert getattr(error, "status_code", None) == status + assert error.__context__ is None and error.__cause__ is None + rendered = "".join(traceback.format_exception(error)) + repr(error.to_dict()) + assert "placeholder-header-secret" not in rendered + assert "placeholder-replacement-secret" not in rendered + + +async def test_network_create_keeps_nonsecret_error_detail() -> None: + from agent_sandbox.providers.alicloud_express.sandbox import SandboxProtocol + + request = httpx.Request("POST", "https://api.example.invalid/sandboxes") + response = httpx.Response(400, request=request, json={"message": "Unsupported instance size"}) + http = SimpleNamespace( + platform_request=AsyncMock( + side_effect=httpx.HTTPStatusError("Cloud rejection", request=request, response=response) + ) + ) + with pytest.raises(SandboxCreationError, match="Unsupported instance size") as caught: + await SandboxProtocol(http).create(SandboxConfig(network={"allow_out": []})) + assert caught.value.__cause__ is not None diff --git a/client-sdk/tests/test_api/test_sandbox.py b/client-sdk/tests/test_api/test_sandbox.py index 4b7d35f6..806f90d4 100644 --- a/client-sdk/tests/test_api/test_sandbox.py +++ b/client-sdk/tests/test_api/test_sandbox.py @@ -70,7 +70,9 @@ async def test_create_returns_sandbox(self) -> None: "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, + patch( + "agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol" + ) as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_create_auth.return_value = mock_auth @@ -97,7 +99,9 @@ async def test_create_passes_config(self) -> None: "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, + patch( + "agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol" + ) as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -129,7 +133,9 @@ async def test_create_default_request_timeout_is_none(self) -> None: "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, + patch( + "agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol" + ) as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -152,7 +158,9 @@ async def test_create_explicit_request_timeout_forwarded(self) -> None: "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, + patch( + "agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol" + ) as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -194,7 +202,9 @@ async def test_connect_returns_sandbox(self) -> None: "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, + patch( + "agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol" + ) as mock_proto_cls, ): mock_load_config.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_create_auth.return_value = AsyncMock() @@ -524,7 +534,7 @@ async def test_create_with_cpu_memory(self) -> None: patch("agent_sandbox.api.sandbox.load_config") as mock_lc, 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, + patch("agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -556,7 +566,7 @@ async def test_create_with_disk_gpu(self) -> None: patch("agent_sandbox.api.sandbox.load_config") as mock_lc, 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, + patch("agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -588,7 +598,7 @@ async def test_create_without_resources_omits_keys(self) -> None: patch("agent_sandbox.api.sandbox.load_config") as mock_lc, 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, + patch("agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -763,7 +773,7 @@ async def test_create_derives_envd_url_when_missing(self) -> None: patch("agent_sandbox.api.sandbox.load_config") as mock_lc, 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, + patch("agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() @@ -794,7 +804,7 @@ async def test_connect_derives_envd_url_when_missing(self) -> None: patch("agent_sandbox.api.sandbox.load_config") as mock_lc, 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, + patch("agent_sandbox.providers.alicloud_express.sandbox.SandboxProtocol") as mock_pc, ): mock_lc.return_value = TransportConfig(sandbox_api_key=TEST_API_KEY) mock_ca.return_value = AsyncMock() diff --git a/client-sdk/tests/test_models/test_sandbox.py b/client-sdk/tests/test_models/test_sandbox.py index 9dfad0c6..65cf1e06 100644 --- a/client-sdk/tests/test_models/test_sandbox.py +++ b/client-sdk/tests/test_models/test_sandbox.py @@ -22,8 +22,9 @@ def test_enum_values(self): assert SandboxStatus.STOPPED == "stopped" assert SandboxStatus.ERROR == "error" - def test_enum_count(self): - assert len(SandboxStatus) == 7 + @pytest.mark.parametrize("state", ["resuming", "snapshotting"]) + def test_provider_transition_states_are_readable(self, state): + assert SandboxInfo(sandboxID="example", state=state).status.value == state class TestSandboxConfig: diff --git a/client-sdk/tests/test_providers/test_registry_scoping.py b/client-sdk/tests/test_providers/test_registry_scoping.py index 33d6c0f9..cdc75e66 100644 --- a/client-sdk/tests/test_providers/test_registry_scoping.py +++ b/client-sdk/tests/test_providers/test_registry_scoping.py @@ -50,7 +50,7 @@ def test_builtin_identity_and_default(self, iso): # noqa: F811 assert not p.supports("ports.domain") assert not p.supports("network.vpc") assert not p.supports("templates.deploy") - assert not p.supports("sandbox.pause") + assert p.supports("sandbox.pause") def test_register_and_conflicts(self, iso): # noqa: F811 register_provider(_Py()) diff --git a/docs/en/guide/authentication.md b/docs/en/guide/authentication.md index fc03e288..69f3e4bd 100644 --- a/docs/en/guide/authentication.md +++ b/docs/en/guide/authentication.md @@ -167,3 +167,24 @@ envd authentication requires the following HTTP headers (set automatically by th ## Related: the abx CLI Setting credentials from the command line (`abx config set` / `list` / `get`, `abx create --env`) is documented in the CLI repository: [CLI Authentication](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/guide/authentication.md). + +## STS temporary identities and private application access + +When your cloud identity uses STS, obtain the temporary AccessKey ID, AccessKey Secret and SecurityToken together from your RAM role or CI credential issuer. Set `ALICLOUD_ACCESS_KEY_ID`, `ALICLOUD_ACCESS_KEY_SECRET` and `ALIBABA_CLOUD_SECURITY_TOKEN` for the same identity and validity period. The canonical Python override is `security_token`; it is a secret provider setting. Native provider management forwards the complete triplet. The SDK does not acquire or refresh STS credentials. + +Express uses separate credentials for the platform API, envd, public application traffic and the application server. When public traffic requires authentication, the gateway uses `e2b-traffic-access-token`; `ABX_SERVER_TOKEN` remains the application's bearer token. A newly created `ProviderSandbox` retains its traffic token in memory. To attach to an existing private instance, supply the token obtained from its original create or an explicit standard-client connect: + +```python +from agent_sandbox import ProviderSandbox + +sandbox = await ProviderSandbox.connect( + sandbox_id, traffic_access_token=traffic_token, +) +try: + async with await sandbox.application_client(9000, token=application_token) as client: + print(await client.health()) +finally: + await sandbox.close() +``` + +`ProviderSandbox.connect` only reads instance information; it does not resume the sandbox or change its lifetime. `application_client` also performs no hidden connect or renewal. You may instead pass `traffic_access_token` directly to `application_client` for that client only. GET information does not recover this token. Keep it private; it is excluded from default model serialization. diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md index d4b087ee..b2a784c8 100644 --- a/docs/en/guide/provider-commands.md +++ b/docs/en/guide/provider-commands.md @@ -42,7 +42,7 @@ hints for other providers fail with E8004 before loading their implementation. O 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. The client validates the selected +`storage.mount` protocol or a portable runtime attach API. The client validates the selected provider's `create_options` before creating a sandbox. The built-in provider sends `"volumeMounts": [{"name": "existing-volume-name", "path": "/mnt/data"}]` in `POST /sandboxes` using its direct HTTP transport. Provider management commands @@ -252,3 +252,30 @@ a checksum-verified ossfs2 2.0.10 installation and the that API is not a new shared Provider command. The tested backend rejected volume/mount overrides on Snapshot restoration with HTTP400. Do not use a Snapshot ID as an image-template replacement when changing mount configuration. + +## Express network configuration and runtime extensions + +`ProviderSandbox.create` accepts `network` with `allow_out`, `deny_out`, `allow_public_traffic`, `mask_request_host` and `rules`, plus the separate `allow_internet_access` boolean. These inputs require a provider that declares network creation support. Setting `allow_public_traffic=False` requires secure mode. Rules can contain secrets; keep them out of logs and saved examples. + +Runtime extensions use the existing public provider-command client and require a compatible abx installation: + +```python +from agent_sandbox.providers import commands + +await commands.run_command( + "alicloud-express", "sandbox.mounts.append", + {"sandbox_id": sandbox.id, "named_volume_mounts": [ + {"name": "existing-volume-name", "path": "/mnt/extra"}, + ]}, + confirm=True, +) +await commands.run_command( + "alicloud-express", "sandbox.network.update", + {"sandbox_id": sandbox.id, "network": { + "allow_out": ["api.example.com"], "deny_out": ["0.0.0.0/0"], + }}, + confirm=True, +) +``` + +Inspect each command's input schema before using direct storage configurations. Appending mounts retains existing mounts, role and VPC settings; it cannot detach or replace them. A lost response must be reconciled before retrying. Network updates replace the configuration rather than merge it; `network={}` clears runtime rules. Public-traffic authentication and host masking are creation-only settings. Storage clients, service admission, same-Team resources and the required role/network must already be available. diff --git a/docs/en/guide/sdk-usage.md b/docs/en/guide/sdk-usage.md index 38239b96..df68d03c 100644 --- a/docs/en/guide/sdk-usage.md +++ b/docs/en/guide/sdk-usage.md @@ -487,3 +487,11 @@ print(result) ``` --- + +### Explicit application command arguments + +Use `await sandbox.invoke("command", {"timeout": 5}, request_timeout=30)` or `await client.commands.invoke("command", {"timeout": 5}, request_timeout=30)` when business fields overlap request options. Every key in the argument object reaches the registered function. `invoke_sync` is available for synchronous ProviderSandbox and ServerClient command use. Existing `run(..., timeout=...)` keeps its HTTP-timeout meaning and existing keyword payloads. + +For an authenticated application service, pass its application token explicitly: `await sandbox.invoke("command", {"timeout": 5}, application_token=application_token)`. This is the service's `ABX_SERVER_TOKEN`, separate from the sandbox API key and gateway traffic token. + +On Express, `await sandbox.pause()` retains memory; `await sandbox.resume()` explicitly reconnects with a 300-second lifetime and refreshes the same object's session credentials. Both require service admission. Read-only `ProviderSandbox.connect` does not resume. See [authentication](authentication.md) for private application traffic tokens and [provider commands](provider-commands.md) for network and append-mount extensions. diff --git a/docs/en/reference/api-reference.md b/docs/en/reference/api-reference.md index 9070baf4..68387df9 100644 --- a/docs/en/reference/api-reference.md +++ b/docs/en/reference/api-reference.md @@ -25,17 +25,24 @@ async def create( metadata: dict[str, str] | None = None, envs: dict[str, str] | None = None, volume_mounts: dict[str, str] | None = None, + network: dict[str, Any] | None = None, + allow_internet_access: bool | None = None, cpu: int | None = None, memory: int | None = None, disk: int | None = None, gpu: str | None = None, secure: bool | None = None, + access_key_id: str | None = None, + access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, - access_key_id: str | None = None, - access_key_secret: str | None = None, -) -> Sandbox + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, + on_created: Callable[[str], None] | None = None, +) -> Sandbox: ``` Create a new sandbox. @@ -47,6 +54,8 @@ Create a new sandbox. | `request_timeout` | `float` | `None` | Per-request HTTP timeout (seconds) for the create call only. When `None`, uses `ABX_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 | +| `network` | `dict` | `None` | Explicit snake-case network creation settings | +| `allow_internet_access` | `bool` | `None` | Explicit egress switch | | `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 | @@ -58,6 +67,7 @@ Create a new sandbox. | `sandbox_domain` | `str` | `None` | Domain override | | `access_key_id` | `str` | `None` | Alibaba Cloud AK | | `access_key_secret` | `str` | `None` | Alibaba Cloud SK | +| `security_token` | `str` | `None` | STS SecurityToken paired with temporary AccessKeys | **Returns**: `Sandbox` instance @@ -71,12 +81,17 @@ async def connect( cls, sandbox_id: str, *, + traffic_access_token: str | None = None, + access_key_id: str | None = None, + access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, - access_key_id: str | None = None, - access_key_secret: str | None = None, -) -> Sandbox + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, +) -> Sandbox: ``` Connect to an existing sandbox. @@ -95,11 +110,16 @@ async def list( status: SandboxStatus | None = None, limit: int = 100, offset: int = 0, - sandbox_api_key: str | None = None, - sandbox_base_url: str | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, -) -> list[SandboxInfo] + security_token: str | None = None, + sandbox_api_key: str | None = None, + sandbox_base_url: str | None = None, + sandbox_domain: str | None = None, + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, +) -> list[SandboxInfo]: ``` List sandboxes (class method, no instance required). @@ -116,11 +136,15 @@ async def kill_by_id( cls, sandbox_id: str, *, - sandbox_api_key: str | None = None, - sandbox_base_url: str | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, -) -> None + security_token: str | None = None, + sandbox_api_key: str | None = None, + sandbox_base_url: str | None = None, + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, +) -> None: ``` Destroy a sandbox by ID (class method, no prior connection required). @@ -211,7 +235,7 @@ async def run( ) -> Any ``` -Call a named command registered on the sandbox's SandboxServer: `sandbox.run("name", x=1)` is `POST /commands/name` with `kwargs` as the JSON body. This is the only way to call a named command. For a raw shell command use `sandbox.commands.run(cmd, ...)`. +Call a named command registered on the sandbox's SandboxServer: `sandbox.run("name", x=1)` is `POST /commands/name` with `kwargs` as the JSON body. For an explicit argument object, use `sandbox.invoke(name, arguments, request_timeout=...)`. For a raw shell command use `sandbox.commands.run(cmd, ...)`. **Returns**: The command's result value @@ -343,8 +367,8 @@ you want policy-scoped, scope-checked access, or need endpoints envd lacks (`fil | Member | Signature | Description | |--------|-----------|-------------| -| `ServerClient()` | `(base_url, *, token=None, headers=None, pty_url=None, timeout=30.0, http_client=None, expires_at=None)` | `token` is sent as `Authorization: Bearer`; `headers` carry the gateway `X-Access-Token` | -| `ProviderSandbox.application_client()` | `async (port=9000, *, token=None, timeout=30.0) -> ServerClient` | Resolves the provider application gateway descriptor, preserves its prefix/query and gateway headers, and uses a separate application token. Recommended across providers | +| `ServerClient()` | `(base_url, *, token=None, headers=None, pty_url=None, timeout=30.0, http_client=None, expires_at=None)` | `token` is sent as `Authorization: Bearer`; `headers` carry the provider gateway credentials | +| `ProviderSandbox.application_client()` | `async (port=9000, *, token=None, traffic_access_token=None, timeout=30.0) -> ServerClient` | Resolves the provider application gateway descriptor, preserves its prefix/query and gateway headers, and uses a separate application token. Recommended across providers | | `Sandbox.server` | `cached_property -> ServerClient` | Client for port 9000. Native local URL helper; use application_client for a provider gateway descriptor | | `Sandbox.server_at()` | `(port=9000, *, token=None, pty_port=9001, timeout=30.0) -> ServerClient` | A fresh client. Native local URL helper; use application_client for a provider gateway descriptor; `token` is `ABX_SERVER_TOKEN` | | `Sandbox.start_server()` | `async (*, port=9000, commands=None, policy=None, token=None, env=None, command=None, wait=True, timeout=30.0) -> ServerClient` | Starts `agent-sandbox-server serve` as a background envd command and polls `/health`; `policy` is a dict (`ABX_SERVER_POLICY`) or a preset name; raises `ConnectionError_` on timeout | @@ -408,6 +432,9 @@ class SandboxStatus(str, Enum): CREATING = "creating" RUNNING = "running" PAUSED = "paused" + PAUSING = "pausing" + RESUMING = "resuming" + SNAPSHOTTING = "snapshotting" STOPPING = "stopping" STOPPED = "stopped" ERROR = "error" @@ -480,3 +507,9 @@ class SandboxStatus(str, Enum): All exceptions inherit from `SandboxError` and carry `code`, `message`, `suggestion`, and `docs_url` attributes. See [Error Codes Reference](./error-codes.md) for the complete error code listing. + +## Explicit application invocation and Express options + +`ServerClient.commands.invoke(name, arguments, /, *, request_timeout=None)` and `ProviderSandbox.invoke(name, arguments, /, *, server_port=9000, application_token=None, request_timeout=None)` send `arguments` as the command JSON body. Both expose `invoke_sync`; legacy `run` keeps its keyword contract. Set `application_token` to the application's `ABX_SERVER_TOKEN` when its service requires authentication. + +Provider lifecycle methods that accept cloud AccessKeys also accept `security_token`; the environment name is `ALIBABA_CLOUD_SECURITY_TOKEN`. `ProviderSandbox.create` additionally accepts `network` and `allow_internet_access`. `ProviderSandbox.connect(..., traffic_access_token=...)` retains GET-only attach semantics, and `application_client(..., traffic_access_token=...)` provides a per-client gateway credential override. See [provider commands](../guide/provider-commands.md) and [authentication](../guide/authentication.md). diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index bc252204..2e7f8fee 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -40,6 +40,7 @@ Each key has one name. Product settings use `ABX_`. A vendor that already p | Data-plane domain | `sandbox_domain` | `ABX_SANDBOX_DOMAIN` | | Region | `region` | `ALIBABA_CLOUD_REGION_ID` | | Alibaba Cloud AK / SK | `access_key_id` / `access_key_secret` | `ALICLOUD_ACCESS_KEY_ID` / `ALICLOUD_ACCESS_KEY_SECRET` | +| STS SecurityToken | `security_token` | `ALIBABA_CLOUD_SECURITY_TOKEN` | | GitHub token | `github_token` | `GITHUB_TOKEN` | | Default provider | `provider` | `ABX_PROVIDER` | @@ -89,6 +90,7 @@ Each row is the only environment variable for that key. `abx config set` writes |---|---|---| | `ALICLOUD_ACCESS_KEY_ID` | `access_key_id` | Alibaba Cloud AccessKey ID | | `ALICLOUD_ACCESS_KEY_SECRET` | `access_key_secret` | Alibaba Cloud AccessKey Secret | +| `ALIBABA_CLOUD_SECURITY_TOKEN` | `security_token` | STS token paired with temporary AccessKeys | ### GitHub, templates, MCP diff --git a/docs/zh/guide/authentication.md b/docs/zh/guide/authentication.md index c815c751..ab3c1c63 100644 --- a/docs/zh/guide/authentication.md +++ b/docs/zh/guide/authentication.md @@ -166,3 +166,24 @@ envd 认证需要以下 HTTP 头(已由 SDK 自动设置): ## 相关:abx CLI 通过命令行设置凭证(`abx config set` / `list` / `get`、`abx create --env`)的说明位于 CLI 仓库:[CLI 认证配置](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/guide/authentication.md)。 + +## STS 临时身份与私有应用访问 + +使用 STS 时,从 RAM 角色或 CI 凭据提供方获取配套的临时 AccessKey ID、AccessKey Secret 和 SecurityToken,并在同一有效期内设置 `ALICLOUD_ACCESS_KEY_ID`、`ALICLOUD_ACCESS_KEY_SECRET`、`ALIBABA_CLOUD_SECURITY_TOKEN`。Python 规范覆盖参数为 `security_token`,属于提供方秘密配置。原生管理调用会传递完整三元组;SDK 不负责获取或刷新 STS 凭据。 + +极速版的平台 API、envd、公网应用流量和应用服务分别使用不同凭据。公网流量鉴权使用 `e2b-traffic-access-token`,`ABX_SERVER_TOKEN` 仍是应用服务的 Bearer token。新建的 `ProviderSandbox` 在内存中保留 traffic token。连接已有私有实例时,显式传入原创建响应或一次明确的标准客户端 connect 返回的 token: + +```python +from agent_sandbox import ProviderSandbox + +sandbox = await ProviderSandbox.connect( + sandbox_id, traffic_access_token=traffic_token, +) +try: + async with await sandbox.application_client(9000, token=application_token) as client: + print(await client.health()) +finally: + await sandbox.close() +``` + +`ProviderSandbox.connect` 只读取实例信息,不恢复沙箱或延长生命周期;`application_client` 也不会隐式连接或续期。也可仅为一个应用客户端向 `application_client` 显式传入 `traffic_access_token`。GET 信息接口无法取回该 token;它属于秘密,不进入模型默认序列化结果。 diff --git a/docs/zh/guide/provider-commands.md b/docs/zh/guide/provider-commands.md index 9c5597a8..d04ee994 100644 --- a/docs/zh/guide/provider-commands.md +++ b/docs/zh/guide/provider-commands.md @@ -36,7 +36,7 @@ Volume 目前要求提交阿里云工单申请邀测,准备存储前应确认 名称必须是非空字符串,不能包含前后空白或 NUL。无效参数会在构造提供方和引擎 I/O 前失败; 其他提供方的非空挂载参数会在加载插件实现前返回 E8004。省略参数或传空字典不改变原请求。 -这是创建参数,与资源管理命令独立,不增加可移植的 `storage.mount` 协议或运行时追加挂载 API。 +这是创建参数,与资源管理和下文的运行时追加挂载命令独立。 客户端先检查所选提供方的 `create_options`,再创建沙箱。内置提供方通过直接 HTTP 传输, 在 `POST /sandboxes` 中发送 `"volumeMounts": [{"name": "existing-volume-name", "path": "/mnt/data"}]`。 @@ -225,3 +225,30 @@ Python 客户端验证真实 OSS 跨沙箱重建持久化和只读访问;仅 [官方追加挂载 API](https://www.alibabacloud.com/help/zh/agent-sandbox/append-mount-volume), 这不代表新增了共享 Provider 命令。被测后端在快照恢复时拒绝修改 Volume 或挂载配置, 返回 HTTP400;需要改变挂载配置时,不应把快照 ID 当作重新构建的镜像模板。 + +## 极速版网络配置与运行时扩展 + +`ProviderSandbox.create` 接受 `network` 中的 `allow_out`、`deny_out`、`allow_public_traffic`、`mask_request_host` 和 `rules`,另有独立的布尔参数 `allow_internet_access`。这些参数要求提供方声明创建时网络配置支持。`allow_public_traffic=False` 要求启用安全模式。规则可能含秘密,不能写入日志或保存到示例。 + +运行时扩展通过现有公共提供方命令客户端调用,需要兼容的 abx 安装: + +```python +from agent_sandbox.providers import commands + +await commands.run_command( + "alicloud-express", "sandbox.mounts.append", + {"sandbox_id": sandbox.id, "named_volume_mounts": [ + {"name": "existing-volume-name", "path": "/mnt/extra"}, + ]}, + confirm=True, +) +await commands.run_command( + "alicloud-express", "sandbox.network.update", + {"sandbox_id": sandbox.id, "network": { + "allow_out": ["api.example.com"], "deny_out": ["0.0.0.0/0"], + }}, + confirm=True, +) +``` + +直接传存储配置前应先查看命令输入 schema。追加挂载保留现有挂载、角色和 VPC,不支持解绑或替换;响应丢失后先确认实际状态再决定是否重试。网络更新整体替换原配置,不做合并;`network={}` 清空运行时规则。公网流量鉴权和 Host 改写只能在创建时配置。使用前需具备存储客户端、服务开通资格、同 Team 资源以及所需角色与网络。 diff --git a/docs/zh/guide/sdk-usage.md b/docs/zh/guide/sdk-usage.md index 97dce908..5ab1ea6f 100644 --- a/docs/zh/guide/sdk-usage.md +++ b/docs/zh/guide/sdk-usage.md @@ -486,3 +486,11 @@ print(result) ``` --- + +### 显式应用命令参数 + +业务字段与请求选项重名时,使用 `await sandbox.invoke("command", {"timeout": 5}, request_timeout=30)` 或 `await client.commands.invoke("command", {"timeout": 5}, request_timeout=30)`。参数对象内的全部键都会传给已注册函数;ProviderSandbox 和 ServerClient 命令均有 `invoke_sync`。旧 `run(..., timeout=...)` 继续把 timeout 解释为 HTTP 超时,其余已有关键字参数语义保持不变。 + +应用服务要求认证时,显式传入其应用令牌:`await sandbox.invoke("command", {"timeout": 5}, application_token=application_token)`。该值是服务的 `ABX_SERVER_TOKEN`,与 Sandbox API Key、网关 traffic token 分开。 + +极速版 `await sandbox.pause()` 保留内存;`await sandbox.resume()` 明确重新连接,将生命周期设为 300 秒,并更新同一对象的会话凭据。两者均需服务开通资格。只读的 `ProviderSandbox.connect` 不恢复沙箱。私有应用流量 token 见[认证指南](authentication.md),网络和追加挂载扩展见[提供方命令](provider-commands.md)。 diff --git a/docs/zh/reference/api-reference.md b/docs/zh/reference/api-reference.md index 78b6e8c0..fc8274ef 100644 --- a/docs/zh/reference/api-reference.md +++ b/docs/zh/reference/api-reference.md @@ -25,17 +25,24 @@ async def create( metadata: dict[str, str] | None = None, envs: dict[str, str] | None = None, volume_mounts: dict[str, str] | None = None, + network: dict[str, Any] | None = None, + allow_internet_access: bool | None = None, cpu: int | None = None, memory: int | None = None, disk: int | None = None, gpu: str | None = None, secure: bool | None = None, + access_key_id: str | None = None, + access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, - access_key_id: str | None = None, - access_key_secret: str | None = None, -) -> Sandbox + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, + on_created: Callable[[str], None] | None = None, +) -> Sandbox: ``` 创建新沙箱。 @@ -47,6 +54,8 @@ async def create( | `request_timeout` | `float` | `None` | 仅针对本次创建请求的 HTTP 超时(秒)。为 `None` 时使用 `ABX_HTTP_TIMEOUT` / `http_timeout`。与 E2B SDK 对齐。调大它无法绕过平台侧创建挂起。 | | `metadata` | `dict` | `None` | 任意元数据键值对 | | `envs` | `dict` | `None` | 注入的环境变量 | +| `network` | `dict` | `None` | 创建时显式传入的 snake_case 网络配置 | +| `allow_internet_access` | `bool` | `None` | 显式出网开关 | | `volume_mounts` | `dict[str, str]` | `None` | 仅内置提供方:规范化的沙箱绝对路径 → 已有 Volume 名称。省略或空字典不挂载,其他提供方的非空参数返回 E8004。见[挂载要求与请求 schema](../guide/provider-commands.md#创建沙箱时挂载已有-volume)。 | | `cpu` | `int` | `None` | CPU 核数 | | `memory` | `int` | `None` | 内存 MB | @@ -58,6 +67,7 @@ async def create( | `sandbox_domain` | `str` | `None` | 域名覆盖 | | `access_key_id` | `str` | `None` | 阿里云 AK | | `access_key_secret` | `str` | `None` | 阿里云 SK | +| `security_token` | `str` | `None` | 与临时 AccessKey 配套的 STS SecurityToken | **返回**:`Sandbox` 实例 @@ -71,12 +81,17 @@ async def connect( cls, sandbox_id: str, *, + traffic_access_token: str | None = None, + access_key_id: str | None = None, + access_key_secret: str | None = None, + security_token: str | None = None, sandbox_api_key: str | None = None, sandbox_base_url: str | None = None, sandbox_domain: str | None = None, - access_key_id: str | None = None, - access_key_secret: str | None = None, -) -> Sandbox + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, +) -> Sandbox: ``` 连接到已有沙箱。 @@ -95,11 +110,16 @@ async def list( status: SandboxStatus | None = None, limit: int = 100, offset: int = 0, - sandbox_api_key: str | None = None, - sandbox_base_url: str | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, -) -> list[SandboxInfo] + security_token: str | None = None, + sandbox_api_key: str | None = None, + sandbox_base_url: str | None = None, + sandbox_domain: str | None = None, + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, +) -> list[SandboxInfo]: ``` 列出沙箱(类方法,不需要实例)。 @@ -116,11 +136,15 @@ async def kill_by_id( cls, sandbox_id: str, *, - sandbox_api_key: str | None = None, - sandbox_base_url: str | None = None, access_key_id: str | None = None, access_key_secret: str | None = None, -) -> None + security_token: str | None = None, + sandbox_api_key: str | None = None, + sandbox_base_url: str | None = None, + region: str | None = None, + provider: str | Provider | None = None, + provider_options: dict[str, Any] | None = None, +) -> None: ``` 按 ID 销毁沙箱(类方法,不需要先连接)。 @@ -211,7 +235,7 @@ async def run( ) -> Any ``` -调用沙箱内 SandboxServer 上注册的命名命令:`sandbox.run("name", x=1)` 即以 `kwargs` 作为 JSON body 的 `POST /commands/name`。这是调用命名命令的唯一方式。裸 shell 命令请使用 `sandbox.commands.run(cmd, ...)`。 +调用沙箱内 SandboxServer 上注册的命名命令:`sandbox.run("name", x=1)` 即以 `kwargs` 作为 JSON body 的 `POST /commands/name`。需要显式参数对象时,使用 `sandbox.invoke(name, arguments, request_timeout=...)`。裸 shell 命令请使用 `sandbox.commands.run(cmd, ...)`。 **返回**:命令的返回值 @@ -343,7 +367,7 @@ from agent_sandbox.api import ServerClient, ServerCapabilities | 成员 | 签名 | 说明 | |------|------|------| | `ServerClient()` | `(base_url, *, token=None, headers=None, pty_url=None, timeout=30.0, http_client=None, expires_at=None)` | `token` 以 `Authorization: Bearer` 发送;`headers` 携带网关的 `X-Access-Token` | -| `ProviderSandbox.application_client()` | `async (port=9000, *, token=None, timeout=30.0) -> ServerClient` | 解析 Provider 应用网关描述符,保留其路径前缀、查询参数及网关头,应用令牌单独传入;跨 Provider 推荐使用 | +| `ProviderSandbox.application_client()` | `async (port=9000, *, token=None, traffic_access_token=None, timeout=30.0) -> ServerClient` | 解析 Provider 应用网关描述符,保留其路径前缀、查询参数及网关头,应用令牌单独传入;跨 Provider 推荐使用 | | `Sandbox.server` | `cached_property -> ServerClient` | 9000 端口原生本地 URL 辅助入口;跨 Provider 使用 application_client | | `Sandbox.server_at()` | `(port=9000, *, token=None, pty_port=9001, timeout=30.0) -> ServerClient` | 原生本地 URL 辅助入口;需 Provider 网关描述符时使用 application_client;`token` 是应用令牌 | | `Sandbox.start_server()` | `async (*, port=9000, commands=None, policy=None, token=None, env=None, command=None, wait=True, timeout=30.0) -> ServerClient` | 以 envd 后台命令启动 `agent-sandbox-server serve` 并轮询 `/health`;`policy` 为字典(`ABX_SERVER_POLICY`)或预设名;超时抛 `ConnectionError_` | @@ -405,6 +429,9 @@ class SandboxStatus(str, Enum): CREATING = "creating" RUNNING = "running" PAUSED = "paused" + PAUSING = "pausing" + RESUMING = "resuming" + SNAPSHOTTING = "snapshotting" STOPPING = "stopping" STOPPED = "stopped" ERROR = "error" @@ -477,3 +504,9 @@ class SandboxStatus(str, Enum): 所有异常继承自 `SandboxError`,携带 `code`、`message`、`suggestion`、`docs_url` 属性。 详细错误码列表参见 [错误码参考](./error-codes.md)。 + +## 显式应用调用与极速版选项 + +`ServerClient.commands.invoke(name, arguments, /, *, request_timeout=None)` 和 `ProviderSandbox.invoke(name, arguments, /, *, server_port=9000, application_token=None, request_timeout=None)` 将 arguments 作为命令 JSON 请求体;均有 `invoke_sync`,旧 `run` 保留关键字参数契约。应用服务要求认证时,将其 `ABX_SERVER_TOKEN` 通过 `application_token` 传入。 + +接受云 AccessKey 的 Provider 生命周期方法同时接受 `security_token`,环境变量为 `ALIBABA_CLOUD_SECURITY_TOKEN`。`ProviderSandbox.create` 另接受 `network`、`allow_internet_access`。`ProviderSandbox.connect(..., traffic_access_token=...)` 保留 GET 只读连接语义;`application_client(..., traffic_access_token=...)` 仅为该应用客户端覆盖网关凭据。详见[提供方命令](../guide/provider-commands.md)和[认证](../guide/authentication.md)。 diff --git a/docs/zh/reference/configuration.md b/docs/zh/reference/configuration.md index 14b7f6ca..e51c3043 100644 --- a/docs/zh/reference/configuration.md +++ b/docs/zh/reference/configuration.md @@ -40,6 +40,7 @@ | 数据平面域名 | `sandbox_domain` | `ABX_SANDBOX_DOMAIN` | | 地域 | `region` | `ALIBABA_CLOUD_REGION_ID` | | 阿里云 AK / SK | `access_key_id` / `access_key_secret` | `ALICLOUD_ACCESS_KEY_ID` / `ALICLOUD_ACCESS_KEY_SECRET` | +| STS SecurityToken | `security_token` | `ALIBABA_CLOUD_SECURITY_TOKEN` | | GitHub token | `github_token` | `GITHUB_TOKEN` | | 默认提供方 | `provider` | `ABX_PROVIDER` | @@ -89,6 +90,7 @@ |---|---|---| | `ALICLOUD_ACCESS_KEY_ID` | `access_key_id` | 阿里云 AccessKey ID | | `ALICLOUD_ACCESS_KEY_SECRET` | `access_key_secret` | 阿里云 AccessKey Secret | +| `ALIBABA_CLOUD_SECURITY_TOKEN` | `security_token` | 与临时 AccessKey 配套的 STS SecurityToken | ### GitHub、模板、MCP From 09f0e39a598a05244bfdd841ce1253fcdb1deb7e Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:26:07 +0000 Subject: [PATCH 2/3] fix(sdk): preserve safe errors and record real Express acceptance --- .agents/README.md | 2 +- .../2026-11-05-express-sdk-completion.md | 85 ++- .../2026-11-05-express-sdk-completion.md | 2 +- CHANGELOG.md | 2 + README.md | 1 + README.zh-CN.md | 1 + .../src/agent_sandbox/models/network.py | 33 +- .../providers/alicloud_express/sandbox.py | 28 +- .../tests/test_api/test_express_completion.py | 68 ++- docs/en/guide/sdk-usage.md | 2 + docs/zh/guide/sdk-usage.md | 2 + e2e/README.md | 6 + e2e/README.zh-CN.md | 6 + e2e/express_api_completion.py | 485 ++++++++++++++++++ 14 files changed, 676 insertions(+), 47 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-11-05-express-sdk-completion.md (99%) create mode 100644 e2e/express_api_completion.py diff --git a/.agents/README.md b/.agents/README.md index 567cfd27..bb636dd8 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,6 +1,6 @@ # `.agents/` — engineering record of the python-sdk repository -- [Express SDK completion](notes/proposed/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). +- [Express SDK completion](notes/implemented/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). - [Defensive complexity cleanup](notes/implemented/architecture/2026-10-29-defensive-complexity-cleanup.md) · [verification](evidence/verify/2026-10-29-defensive-complexity-cleanup.md). diff --git a/.agents/evidence/verify/2026-11-05-express-sdk-completion.md b/.agents/evidence/verify/2026-11-05-express-sdk-completion.md index 13fc5cdd..fb6a6f23 100644 --- a/.agents/evidence/verify/2026-11-05-express-sdk-completion.md +++ b/.agents/evidence/verify/2026-11-05-express-sdk-completion.md @@ -1,14 +1,15 @@ # Verification: Express SDK completion - Date: 2026-11-05 -- Change: proposed/feature/2026-11-05-express-sdk-completion; +- Change: implemented/feature/2026-11-05-express-sdk-completion; https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/152 - Result: PARTIAL - Package under test: client/server 0.4.0a6 source pair on task branch, based on daabf3cc12fbbc877cb2942c35578d8424f6cc3b; no release or remote merge claimed. - Environment: Linux, CPython 3.11.15; isolated editable client/server installation with declared dev extras and build/twine. No Python dependency lock exists in this repository. - No cloud credentials were read or cloud mutations performed by this implementation agent. + Initial implementation checks used no cloud credentials. The later explicitly authorized + real-service diagnosis below used existing project credentials only in child processes. ## What was verified @@ -82,3 +83,83 @@ of conditional network/metrics behavior are not resolved by local unit tests. No complete cloud E2E, credential creation/renewal, storage backend or package publication was performed in this local implementation slice. The ADR remains proposed pending the coordinating task's required acceptance and merge gates. + +## Independent review follow-up + +The runtime reviewer found three concrete gaps: fallback session construction still selected +the generic lifecycle adapter; cloud creation failures could reflect newly supported network +header credentials through both messages and the raw HTTP exception chain; and the new +ProviderSandbox.invoke convenience method could not authenticate a normal application service. +Five regressions failed before correction and one ordinary-error control passed. After the +fixes, the focused model/protocol suite plus actual local HTTP suite passed **34 tests**. +The authenticated local server rejects an omitted application token with HTTP401 and accepts +the explicit token without consuming a business field named application_token. + +A further actual-HttpClient HTTP401 regression first failed: authentication conversion +precedes the creation adapter and still retained the raw HTTP request through its cause. +The targeted correction now reconstructs any SDK SandboxError when network header secrets +exist, including InvalidAPIKeyError, preserving its typed class and status. The expanded +focused suite passed **35 tests**, and mypy/lint/format passed again. Ordinary nonsecret +errors remain unmodified. + +Creation errors with network-header credentials retain their existing typed class, HTTP +status and safe cloud detail. Known literal/Bearer/replacement values are removed, and the +new exception is raised outside the handler so no raw __cause__ or __context__ retains the +credential-bearing HTTP request. Ordinary nonsecret error details and behavior stay intact. + +Final review source checks passed: mypy **103 files**, complete client lint and **246** +formatted files. The repeated complete client non-integration suite passed **2390 tests**, +with **26 skips and 10 deselections**, in 189.86 seconds. Repository health remains +**106 PASS / 1 existing WARN / 0 FAIL**; diff and manual-script lint checks pass. + +## Real private application diagnosis + +The coordinator's initial application failure was preserved. Independent source inspection +showed its fixture discarded an unconsumed background StreamReader: the lazy envd Start +request had not run. Health alone therefore did not identify the registered fixture service. +The corrected manual fixture uses an owned listener on port9017, actively consumes the +stream and first verifies the current command over localhost through native envd. + +An independent owned sandbox run then passed: localhost command discovery/invocation, +public direct HTTP command discovery/invocation, Python SDK discovery/invocation with +distinct application/traffic credentials, anonymous gateway HTTP403, and DELETE followed +by independent GET404 with zero remaining resources. Safe result summaries live in the +coordinating artifact private-app-diagnosis.jsonl; no token or raw cloud response was logged. + +The combined CLI-binary/Python/Node acceptance is recorded separately. Its first attempt +preserves a remaining controller assertion and intermittent network-command E8020 rather +than reporting all cloud APIs passed. Storage append still needs a real supplied mount fixture. + +The second combined run used the actual standalone CLI binary (SHA256 +`12279a0bbeee72ceb35185e9a097eb5e314cef6e1a9ab0e22a9adcf1284fdfc2`). Private creation, +same-object pause/resume with subsequent exec/files, localhost application registration, +direct HTTP, ProviderSandbox.invoke with explicit application authentication, read-only +attach with explicit traffic credentials, anonymous HTTP403, provider network replacement +and clearing, and provider timeout all passed. Independent owned deletion was confirmed +by GET404 with **zero remaining**. The optional Node subprocess controller did not return +its expected JSON summary; that is recorded as a separate unresolved cross-component +check, not a Python failure or proof of a Node product defect. No storage backend fixture +was supplied. Earlier network E8020 results remain recorded and are not relabelled as stable. + +The final combined real-service run also passed the optional Node application's actual +command list/invoke through the same private gateway, with result equality checked by +deep value comparison. Its prior connection failure was independently reproduced with +raw Node fetch as `UNABLE_TO_VERIFY_LEAF_SIGNATURE`: the controller's minimal child +environment had dropped the project's configured NODE_EXTRA_CA_CERTS/SSL_CERT_FILE. +Inheriting only those trust settings fixed the controller; TLS verification was never +disabled. Python native exec/files, typed lifecycle, explicit authenticated invoke, +read-only attach, anonymous HTTP403 and owned cleanup GET404 passed in this run as well. + +Moving network replacement immediately after lifecycle excluded the independent app +scenario's duration from that test. The service still returned **HTTP409** on that run; +this is preserved as a service/state failure, not reinterpreted as TTL expiry or a 204 +parsing defect. A prior real run passed replacement and clearing. Stable network mutation +acceptance and storage-append backend evidence remain separate unresolved gates. + +## Coordinating acceptance and remote checks + +The initial product commit 1aef5f67ab54131ab1e6a060034a3c1f8b5c6e5d passed the complete GitHub Python3.10/3.11/3.12 client/server matrix, public runtime Docker fixtures, pinned E2B contract, provider engine and documentation workflows in [CI run 41992672991](https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/41992672991). This separately resolves the environment comparison; the local malformed-WebSocket failure remains recorded above. + +Final independent review confirmed normal/fallback protocol construction, credential rebinding, explicit application authentication, legacy keyword compatibility and STS propagation. It found the reflected network-rule error path also needed to handle authentication errors and JSON-escaped/short header values. Actual HttpClient regressions failed first, then passed. Secret-bearing create failures now use fixed safe diagnostics while preserving typed code and numeric status, with no raw exception chain/request/response; ordinary errors retain their diagnostics. The focused final set passed all 36 cases. The complete client run including authentication coverage passed 2391 tests, with 26 skips and 10 deselections; the extra escaped-value regression passed in the focused set and is included in the subsequent complete/remote checks. + +The implemented decision concerns SDK lifecycle, application calls and configuration. Real mount data access, stable cloud network mutation and cache template admission belong to the companion CLI acceptance. This record does not turn their partial or blocked results into passes. The final follow-up head must pass its own required remote checks before merge. No package release is included. diff --git a/.agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md b/.agents/notes/implemented/feature/2026-11-05-express-sdk-completion.md similarity index 99% rename from .agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md rename to .agents/notes/implemented/feature/2026-11-05-express-sdk-completion.md index 14cf3b72..27396f0a 100644 --- a/.agents/notes/proposed/feature/2026-11-05-express-sdk-completion.md +++ b/.agents/notes/implemented/feature/2026-11-05-express-sdk-completion.md @@ -1,6 +1,6 @@ # Decision: Complete Express session and application command interfaces -Status: proposed +Status: implemented ## Problem diff --git a/CHANGELOG.md b/CHANGELOG.md index 5873e3e4..b75aefa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Implement typed Express pause/resume with session credential rebinding, preserve private traffic tokens without implicit reconnection, and keep legacy application command keyword behavior. / 完善极速版类型化暂停恢复和会话凭据更新,保留私有流量 token 且不隐式重连,并兼容原应用命令关键字行为。 +- Use Express lifecycle behavior in fallback session construction, redact reflected network header credentials from creation errors and their exception chains, and accept explicit application authentication in ProviderSandbox.invoke. / 回退会话构造使用极速版生命周期实现,创建失败时对网络请求头凭据及异常链定向脱敏,并支持 ProviderSandbox.invoke 显式传入应用认证。 + - Reuse a persistent loop for synchronous ProviderSandbox calls, preserve background shell execution and quoted arguments, release borrowed decorator clients, and retain session records when deletion fails. Authentication and transport failures now propagate from is_running. / 同步 ProviderSandbox 调用复用事件循环,修复后台 shell 和引号参数,释放装饰器借用连接;删除失败保留会话记录,is_running 向调用方抛出认证和传输错误。 - Verify explicit Python MCP CPU/memory requests against actual platform metadata before success; reject unknown/mismatched resources, roll back only the new sandbox and report independently verified cleanup or a recovery handle. Direct SDK creation is unchanged. / Python MCP 对显式 CPU/内存请求独立核验平台实际配置,无法核验或不匹配时仅回滚新建沙箱,返回清理确认或恢复句柄;直接 SDK 创建行为不变。 diff --git a/README.md b/README.md index ca2c85d6..66275562 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The server exposes registered application commands over HTTP. Template authors i | Make your first API calls | [Getting started](docs/en/guide/getting-started.md) | | Look up methods and models | [API reference](docs/en/reference/api-reference.md) | | Configure credentials and endpoints | [Authentication](docs/en/guide/authentication.md) | +| Invoke registered application functions | [Explicit arguments and authentication](docs/en/guide/provider-commands.md) | | Configure file and process behavior | [File and command controls](docs/en/guide/file-and-command-controls.md) | | Move existing E2B code | [Migration guide](docs/en/guide/migrate-from-e2b.md) | | Resolve errors | [Troubleshooting](docs/en/guide/troubleshooting.md) | diff --git a/README.zh-CN.md b/README.zh-CN.md index b4a07eb6..ae12f6a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -69,6 +69,7 @@ finally: | 完成第一次 API 调用 | [快速开始](docs/zh/guide/getting-started.md) | | 查询方法与数据模型 | [API 参考](docs/zh/reference/api-reference.md) | | 配置凭据与端点 | [认证](docs/zh/guide/authentication.md) | +| 调用已注册应用函数 | [显式参数与认证](docs/zh/guide/provider-commands.md) | | 配置文件与进程行为 | [文件与命令控制](docs/zh/guide/file-and-command-controls.md) | | 迁移已有 E2B 代码 | [迁移指南](docs/zh/guide/migrate-from-e2b.md) | | 处理错误 | [故障排查](docs/zh/guide/troubleshooting.md) | diff --git a/client-sdk/src/agent_sandbox/models/network.py b/client-sdk/src/agent_sandbox/models/network.py index a7173421..9e1741cd 100644 --- a/client-sdk/src/agent_sandbox/models/network.py +++ b/client-sdk/src/agent_sandbox/models/network.py @@ -3,8 +3,6 @@ from __future__ import annotations import copy -import json -import re from typing import Any from agent_sandbox.models.errors import ProviderConfigError @@ -39,20 +37,11 @@ def network_create_payload(network: dict[str, Any]) -> dict[str, Any]: return {names[key]: copy.deepcopy(value) for key, value in network.items()} -def network_secret_values(network: dict[str, Any] | None) -> set[str]: - """Find credential-bearing header values without logging or rewriting rules.""" - secrets: set[str] = set() - - def add(value: object) -> None: - if isinstance(value, str) and value: - secrets.add(value) - bearer = re.match(r"^Bearer\s+(.+)$", value, re.IGNORECASE) - if bearer: - secrets.add(bearer[1]) - +def network_has_header_values(network: dict[str, Any] | None) -> bool: + """Identify credential-bearing rules without copying their secret values.""" rules = network.get("rules", {}) if isinstance(network, dict) else {} if not isinstance(rules, dict): - return secrets + return False for entries in rules.values(): if not isinstance(entries, list): continue @@ -61,16 +50,6 @@ def add(value: object) -> None: headers = transform.get("headers") if isinstance(transform, dict) else None if not isinstance(headers, dict): continue - for name, value in headers.items(): - add(value) - if str(name).lower() == "fc.sandbox.network.header-value-replacements": - try: - replacements = json.loads(value) - except (TypeError, ValueError): - continue - if isinstance(replacements, list): - for item in replacements: - if isinstance(item, dict): - add(item.get("value")) - # Cloud errors may reflect a JSON-escaped value rather than the literal value. - return secrets | {json.dumps(value, ensure_ascii=True)[1:-1] for value in secrets} + if any(isinstance(value, str) and value for value in headers.values()): + return True + return False diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py index 34fe3679..2901508b 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/sandbox.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING -from agent_sandbox.models.errors import ProviderProtocolError, SandboxCreationError -from agent_sandbox.models.network import network_secret_values +from agent_sandbox.models.errors import ProviderProtocolError, SandboxError +from agent_sandbox.models.network import network_has_header_values from agent_sandbox.models.sandbox import SandboxStatus from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol as E2bSandboxProtocol @@ -20,26 +20,24 @@ async def create( self, config: SandboxConfig, *, request_timeout: float | None = None ) -> SandboxInfo: """Keep reflected network credentials out of public errors and chains.""" - secrets = network_secret_values(config.network) + has_credentials = network_has_header_values(config.network) try: return await super().create(config, request_timeout=request_timeout) - except SandboxCreationError as error: - if not secrets: + except SandboxError as error: + if not has_credentials: raise - def redact(value: str) -> str: - for secret in sorted(secrets, key=len, reverse=True): - value = value.replace(secret, "[redacted]") - return value - + response = getattr(error.__cause__, "response", None) + status = getattr(error, "status_code", getattr(response, "status_code", None)) + status_text = f" (HTTP{status})" if isinstance(status, int) else "" safe = type(error)( - redact(error.message), + f"Express sandbox creation failed{status_text}. " + "Network header credentials were omitted from error details.", code=error.code, - suggestion=redact(error.suggestion), - docs_url=redact(error.docs_url), + suggestion="Check template, quota, account policy and declared network fields. " + "No retry was made.", + docs_url="", ) - response = getattr(error.__cause__, "response", None) - status = getattr(error, "status_code", getattr(response, "status_code", None)) if isinstance(status, int): safe.status_code = status # type: ignore[attr-defined] # Raise after leaving the handler: even a suppressed __context__ would diff --git a/client-sdk/tests/test_api/test_express_completion.py b/client-sdk/tests/test_api/test_express_completion.py index ccc49521..c5acc4d2 100644 --- a/client-sdk/tests/test_api/test_express_completion.py +++ b/client-sdk/tests/test_api/test_express_completion.py @@ -16,6 +16,7 @@ from agent_sandbox.api.sandbox import Sandbox from agent_sandbox.api.server_client import ServerClient from agent_sandbox.models.errors import ( + InvalidAPIKeyError, QuotaExceededError, SandboxCreationError, TemplateNotFoundError, @@ -353,7 +354,8 @@ async def test_network_create_errors_redact_reflection_and_detach_raw_exception( with pytest.raises(error_type) as caught: await SandboxProtocol(http).create(config) error = caught.value - assert "Invalid header" in error.message + assert f"HTTP{status}" in error.message + assert "Network header credentials were omitted" in error.message assert getattr(error, "status_code", None) == status assert error.__context__ is None and error.__cause__ is None rendered = "".join(traceback.format_exception(error)) + repr(error.to_dict()) @@ -374,3 +376,67 @@ async def test_network_create_keeps_nonsecret_error_detail() -> None: with pytest.raises(SandboxCreationError, match="Unsupported instance size") as caught: await SandboxProtocol(http).create(SandboxConfig(network={"allow_out": []})) assert caught.value.__cause__ is not None + + +async def test_network_authentication_failure_detaches_raw_http_request( + iso: Any, httpx_mock: Any +) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.cn-hangzhou.e2b.fc.aliyuncs.com/sandboxes", + status_code=401, + json={"message": "Rejected placeholder-network-secret"}, + ) + provider = AlicloudExpressProvider() + infra = await provider.build_infra(load_config(sandbox_api_key="placeholder-key")) + config = SandboxConfig( + network={ + "rules": { + "example.invalid": [ + { + "transform": { + "headers": {"Authorization": "Bearer placeholder-network-secret"} + } + } + ] + } + } + ) + try: + with pytest.raises(InvalidAPIKeyError) as caught: + await infra.sandbox_protocol.create(config) + assert caught.value.__context__ is None and caught.value.__cause__ is None + assert getattr(caught.value, "status_code", None) == 401 + assert "placeholder-network-secret" not in "".join(traceback.format_exception(caught.value)) + finally: + await infra.http_client.close() + + +async def test_network_create_omits_encoded_credential_reflections( + iso: Any, httpx_mock: Any +) -> None: + network = { + "rules": { + "example.invalid": [ + {"transform": {"headers": {"X-Private-Key": 'placeholder-quoted-"\\-secret'}}} + ] + } + } + reflected = json.dumps(json.dumps({"request": network})) + httpx_mock.add_response( + method="POST", + url="https://api.cn-hangzhou.e2b.fc.aliyuncs.com/sandboxes", + status_code=400, + json={"message": reflected}, + ) + infra = await AlicloudExpressProvider().build_infra( + load_config(sandbox_api_key="placeholder-key") + ) + try: + with pytest.raises(SandboxCreationError) as caught: + await infra.sandbox_protocol.create(SandboxConfig(network=network)) + assert "placeholder-quoted" not in "".join(traceback.format_exception(caught.value)) + assert caught.value.__context__ is None and caught.value.__cause__ is None + assert getattr(caught.value, "status_code", None) == 400 + finally: + await infra.http_client.close() diff --git a/docs/en/guide/sdk-usage.md b/docs/en/guide/sdk-usage.md index df68d03c..8153b10f 100644 --- a/docs/en/guide/sdk-usage.md +++ b/docs/en/guide/sdk-usage.md @@ -161,6 +161,8 @@ You can also use the `background=True` parameter of `run`: reader = await sandbox.commands.run("sleep 60", background=True) ``` +Retain and asynchronously consume the returned reader to start the execution and receive its output. For a long-running service, consume it in a task that stays alive while you use the service, and set an execution timeout long enough for that use. + ### Process Management ```python diff --git a/docs/zh/guide/sdk-usage.md b/docs/zh/guide/sdk-usage.md index 5ab1ea6f..fd7b4c2a 100644 --- a/docs/zh/guide/sdk-usage.md +++ b/docs/zh/guide/sdk-usage.md @@ -161,6 +161,8 @@ reader = await sandbox.commands.start("python server.py") reader = await sandbox.commands.run("sleep 60", background=True) ``` +保留并异步消费返回的 reader,执行才会启动并输出结果。运行长期服务时,使用持续存活的任务消费 reader,并为服务使用期设置足够的执行超时。 + ### 进程管理 ```python diff --git a/e2e/README.md b/e2e/README.md index ddfc9ac3..28097a25 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -212,3 +212,9 @@ its isolated pinned requirements, then run This is separate from the standard/Provider client suites; real desktop template, private ingress, traffic token, VNC password and handover/replay prerequisites remain explicit in the guide. + +## Express lifecycle and application acceptance + +With the candidate CLI selected by `ABX_BIN` and existing same-region Sandbox/AccessKey configuration, run `python e2e/express_api_completion.py` from the repository root. It selects an accessible ready generation-2 template, or accepts `ABX_E2E_GEN2_TEMPLATE`, creates only owned temporary sandboxes and independently confirms their deletion. The separate application fixture defaults to `code-interpreter-v1`; `ABX_E2E_APPLICATION_TEMPLATE` overrides it. + +`ABX_E2E_MOUNTS_FILE` optionally points to private structured mount inputs backed by real existing storage/role/network resources. Without it, mount acceptance is reported BLOCKED, not passed. No management Key is prepared or renewed. The script exits 0 for complete success, 1 for failure and 2 for blocked acceptance. Keep fixture secrets in private files; do not include them in reports. diff --git a/e2e/README.zh-CN.md b/e2e/README.zh-CN.md index a1d90fab..c627cae8 100644 --- a/e2e/README.zh-CN.md +++ b/e2e/README.zh-CN.md @@ -163,3 +163,9 @@ python e2e/provider_conditional.py --abx "$ABX_BIN" \ `python e2e/desktop_acceptance.py --template "$E2B_DESKTOP_TEMPLATE" --output-dir NEWDIR`。 它独立于标准及 Provider 客户端套件;真实桌面模板、私有入口、流量令牌、VNC 密码与 接管/回放前提均由指南明确列出。 + +## 极速版生命周期与应用验收 + +通过 `ABX_BIN` 选择候选 CLI,配置已有的同地域 Sandbox/AccessKey 凭据后,在仓库根目录执行 `python e2e/express_api_completion.py`。脚本选择有访问权限且已就绪的二代模板,也可通过 `ABX_E2E_GEN2_TEMPLATE` 指定;只创建本轮拥有的临时沙箱,并独立确认删除。单独的应用场景默认使用 `code-interpreter-v1`,可通过 `ABX_E2E_APPLICATION_TEMPLATE` 覆盖。 + +可选的 `ABX_E2E_MOUNTS_FILE` 指向私有结构化挂载输入文件,使用真实已有存储、角色和网络。未提供时,挂载验收明确为 BLOCKED。脚本不创建或续期管理 Key;全部成功退出 0,失败退出 1,受阻退出 2。含秘密的配置保存在私有文件中,不写入报告。 diff --git a/e2e/express_api_completion.py b/e2e/express_api_completion.py new file mode 100644 index 00000000..9ba4acad --- /dev/null +++ b/e2e/express_api_completion.py @@ -0,0 +1,485 @@ +"""Manual real-service acceptance for Express lifecycle, traffic and runtime extensions. + +Requires matched client/server source and ABX_BIN pointing at the candidate CLI. +Uses an existing ready generation-2 template; creates and deletes only its own +sandbox. Optional ABX_E2E_MOUNTS_FILE supplies real append-mount fixtures. No +management credential is created or renewed, and no user storage is deleted. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import re +import shutil +import signal +import tempfile +import time +import uuid +from pathlib import Path + +import httpx + + +async def main() -> int: + # A private test home isolates the candidate engine from existing user sessions. + test_home = Path(tempfile.mkdtemp(prefix="express-api-acceptance-")) + os.environ["HOME"] = str(test_home) + from agent_sandbox import ProviderSandbox + from agent_sandbox.providers.commands import run_command + + api = (os.environ.get("ABX_SANDBOX_BASE_URL") or os.environ["E2B_API_URL"]).rstrip("/") + key = os.environ.get("ABX_SANDBOX_API_KEY") or os.environ["E2B_API_KEY"] + domain = os.environ.get("ABX_SANDBOX_DOMAIN") or os.environ["E2B_DOMAIN"] + run_id = uuid.uuid4().hex + results: list[dict[str, object]] = [] + owned: set[str] = set() + sandbox = None + local_clients = [] + recovery = Path(tempfile.gettempdir()) / f"express-api-owned-{run_id}.json" + + def persist() -> None: + recovery.write_text(json.dumps({"sandbox_ids": sorted(owned)})) + recovery.chmod(0o600) + + def created(sandbox_id: str) -> None: + owned.add(sandbox_id) + persist() + + async def command(name: str, value: dict[str, object]) -> object: + return await run_command("alicloud-express", name, value, confirm=True) + + async def stage(name: str, action) -> bool: + try: + await action() + result: dict[str, object] = {"scenario": name, "result": "PASS"} + except Exception as error: + # Raw cloud exceptions may contain headers, resource identifiers or rules. + result = {"scenario": name, "result": "FAIL", "error_type": type(error).__name__} + code = getattr(error, "code", None) + if isinstance(code, str) and code.startswith("E") and code[1:].isdigit(): + result["error_code"] = code + status = getattr(error, "status_code", None) + if not isinstance(status, int): + matched = re.search(r"\bHTTP\s?(\d{3})\b", str(getattr(error, "message", ""))) + status = int(matched[1]) if matched else None + if isinstance(status, int): + result["http_status"] = status + endpoint = getattr(error, "endpoint", None) + if endpoint in {"capabilities", "commands", "commands_run", "health"}: + result["endpoint"] = endpoint + results.append(result) + print(json.dumps(result), flush=True) + return result["result"] == "PASS" + + async def until(check, timeout: float = 90) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if await check(): + return + await asyncio.sleep(2) + raise TimeoutError("Service did not reach its expected state") + + async def choose_template() -> str: + explicit = os.environ.get("ABX_E2E_GEN2_TEMPLATE") + if explicit: + return explicit + async with httpx.AsyncClient(timeout=30) as http: + response = await http.get(api + "/templates", headers={"X-API-Key": key}) + response.raise_for_status() + data = response.json() + entries = data if isinstance(data, list) else data["templates"] + allowed = {item.get("templateID", item.get("templateId")) for item in entries} + page = await run_command("alicloud-express", "team.list", {"page_size": 100}) + for team in page["data"]["teams"]: + cursor = None + while True: + inputs = {"team_id": team["team_id"], "max_results": 100} + if cursor: + inputs["next_token"] = cursor + page = await run_command("alicloud-express", "template.list", inputs) + for item in page["data"]["templates"]: + config = item.get("runtime_config", {}).get("sandbox_config", {}) + if ( + item["template_id"] in allowed + and config.get("generation") == 2 + and item.get("status", {}).get("state") == "ready" + ): + return item["template_id"] + cursor = page["data"].get("next_token") + if not cursor: + break + raise RuntimeError("No ready generation-2 template belongs to this configured key") + + try: + template = await choose_template() + + async def create_private() -> None: + nonlocal sandbox + sandbox = await ProviderSandbox.create( + template, + timeout=900, + secure=True, + network={"allow_public_traffic": False}, + metadata={"abx_api_review": run_id}, + sandbox_api_key=key, + sandbox_base_url=api, + sandbox_domain=domain, + on_created=created, + ) + local_clients.append(sandbox) + assert sandbox._info.traffic_access_token + await sandbox.files.write("/tmp/express-api-marker", "before-resume") + assert ( + await sandbox.commands.run("cat /tmp/express-api-marker") + ).stdout == "before-resume" + + if not await stage("private create, command and file", create_private): + return 1 + assert sandbox is not None + + async def lifecycle() -> None: + await sandbox.pause() + await sandbox.resume() + + async def readable() -> bool: + # This explicitly repeats an idempotent read while the backend resumes. + try: + result = await sandbox.commands.run("cat /tmp/express-api-marker") + return result.stdout == "before-resume" + except Exception: + return False + + await until(readable) + await sandbox.files.write("/tmp/express-api-marker", "after-resume") + assert await sandbox.files.read("/tmp/express-api-marker") == "after-resume" + + await stage("same-object pause/resume and subsequent execution/files", lifecycle) + + async def application() -> None: + # Lifecycle fixtures may intentionally omit a language runtime. Use + # the documented interpreter template for the application scenario. + app_sandbox = await ProviderSandbox.create( + os.environ.get("ABX_E2E_APPLICATION_TEMPLATE", "code-interpreter-v1"), + timeout=600, + secure=True, + network={"allow_public_traffic": False}, + metadata={"abx_api_review": run_id}, + sandbox_api_key=key, + sandbox_base_url=api, + sandbox_domain=domain, + on_created=created, + ) + local_clients.append(app_sandbox) + available = await app_sandbox.commands.run( + 'python3 -c "import importlib.util; ' + "print('installed' if importlib.util.find_spec('agent_sandbox') " + "else 'missing')\"" + ) + if available.stdout.strip() != "installed": + installed = await app_sandbox.commands.run( + "python3 -m pip install --index-url https://pypi.org/simple " + "agent-sandbox-server==0.4.0a6", + timeout=180, + ) + assert installed.exit_code == 0 + source = """from agent_sandbox.server import command +@command("review_inputs") +def review_inputs(timeout: int, name: str, arguments: str) -> dict: + return {"timeout": timeout, "name": name, "arguments": arguments} +""" + await app_sandbox.files.write("/tmp/express-api-commands.py", source) + application_token = uuid.uuid4().hex + app_port = 9017 + service_stream = await app_sandbox.commands.run( + f"python3 -m agent_sandbox.server serve --host 0.0.0.0 --port {app_port} " + "--commands /tmp/express-api-commands.py", + timeout=480, + background=True, + env={"ABX_SERVER_TOKEN": application_token}, + ) + + async def drain_service() -> None: + # envd streaming execution starts on iteration. Keep the stream + # alive and consume it without printing potentially sensitive logs. + async for _ in service_stream: + pass + + service_task = asyncio.create_task(drain_service()) + client = await app_sandbox.application_client(app_port, token=application_token) + try: + local_probe = """import json, os, urllib.request +payload = {"timeout": 5, "name": "review", "arguments": "preserved"} +checks = [] +for method, suffix in [("GET", "/commands"), ("POST", "/commands/review_inputs")]: + request = urllib.request.Request("http://127.0.0.1:9017" + suffix, + data=json.dumps(payload).encode() if method == "POST" else None, + headers={"Authorization": "Bearer " + os.environ["ABX_DIAG_APP_TOKEN"], + "Content-Type": "application/json"}, method=method) + with urllib.request.urlopen(request, timeout=10) as response: + data = json.load(response) + matched = (any(item.get("name") == "review_inputs" for item in data.get("commands", [])) + if method == "GET" else data.get("result") == payload) + checks.append(response.status == 200 and matched) +print(json.dumps({"registered_and_invoked": all(checks)})) +""" + await app_sandbox.files.write("/tmp/express-api-local-probe.py", local_probe) + + async def local_ready() -> bool: + result = await app_sandbox.commands.run( + "python3 /tmp/express-api-local-probe.py", + env={"ABX_DIAG_APP_TOKEN": application_token}, + timeout=20, + ) + return ( + result.exit_code == 0 + and json.loads(result.stdout).get("registered_and_invoked") is True + ) + + await until(local_ready, 60) + print( + json.dumps({"checkpoint": "envd localhost registered and invoked"}), flush=True + ) + await until(client.health, 60) + print(json.dumps({"checkpoint": "application health ready"}), flush=True) + payload = {"timeout": 5, "name": "review", "arguments": "preserved"} + assert any(item.name == "review_inputs" for item in await client.commands.list()) + assert ( + await app_sandbox.invoke( + "review_inputs", + payload, + server_port=app_port, + application_token=application_token, + ) + == payload + ) + print(json.dumps({"checkpoint": "application first invoke"}), flush=True) + async with httpx.AsyncClient(timeout=20) as http: + url = app_sandbox.network.get_url(app_port) + direct = await http.post( + url + "/commands/review_inputs", + json=payload, + headers={ + "Authorization": "Bearer " + application_token, + "e2b-traffic-access-token": app_sandbox._info.traffic_access_token, + }, + ) + assert direct.status_code == 200 and direct.json().get("result") == payload + anonymous = await http.get(url + "/commands") + assert anonymous.status_code == 403 + print(json.dumps({"checkpoint": "anonymous denied"}), flush=True) + attached = await ProviderSandbox.connect( + app_sandbox.id, + sandbox_api_key=key, + sandbox_base_url=api, + sandbox_domain=domain, + traffic_access_token=app_sandbox._info.traffic_access_token, + ) + try: + assert ( + await attached.invoke( + "review_inputs", + payload, + server_port=app_port, + application_token=application_token, + ) + == payload + ) + print(json.dumps({"checkpoint": "GET attach application invoke"}), flush=True) + finally: + await attached.close() + node_module = os.environ.get("ABX_E2E_NODE_MODULE") + if node_module: + node = await asyncio.create_subprocess_exec( + "node", + "--input-type=module", + "-", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={ + "PATH": os.environ.get("PATH", ""), + **{ + name: os.environ[name] + for name in ("NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR") + if name in os.environ + }, + "ABX_DIAG_MODULE": Path(node_module).resolve().as_uri(), + "ABX_DIAG_URL": app_sandbox.network.get_url(app_port), + "ABX_DIAG_APP_TOKEN": application_token, + "ABX_DIAG_TRAFFIC": app_sandbox._info.traffic_access_token, + }, + ) + node_source = b""" +const { ServerClient } = await import(process.env.ABX_DIAG_MODULE); +const { isDeepStrictEqual } = await import("node:util"); +const client = new ServerClient(process.env.ABX_DIAG_URL, { + token: process.env.ABX_DIAG_APP_TOKEN, + gatewayHeaders: { "e2b-traffic-access-token": process.env.ABX_DIAG_TRAFFIC } +}); +try { + const commands = await client.commands.list(); + const payload = { timeout: 5, name: "review", arguments: "preserved" }; + const result = await client.commands.invoke("review_inputs", payload, { requestTimeout: 30 }); + const listed = commands.some(item => item.name === "review_inputs"); + const matched = isDeepStrictEqual(result, payload); + console.log(JSON.stringify({ listed, matched })); + if (!listed || !matched) process.exitCode = 1; +} catch (error) { + console.log(JSON.stringify({ listed: false, matched: false, + error_type: error.name, status: error.statusCode ?? null })); + process.exitCode = 1; +} finally { await client.close(); } +""" + try: + node_stdout, _ = await asyncio.wait_for( + node.communicate(node_source), timeout=60 + ) + summary = json.loads(node_stdout) + print( + json.dumps( + { + "checkpoint": "Node command response", + "listed": summary.get("listed"), + "matched": summary.get("matched"), + "error_type": summary.get("error_type"), + "http_status": summary.get("status"), + } + ), + flush=True, + ) + assert node.returncode == 0 + finally: + if node.returncode is None: + node.kill() + await node.wait() + print( + json.dumps({"checkpoint": "Node private gateway list and invoke"}), + flush=True, + ) + finally: + service_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await service_task + await client.aclose() + await app_sandbox.close() + + async def network() -> None: + await command( + "sandbox.network.update", + { + "sandbox_id": sandbox.id, + "network": {"allow_out": ["example.com"], "deny_out": ["0.0.0.0/0"]}, + }, + ) + async with httpx.AsyncClient(timeout=30) as http: + response = await http.get( + api + "/sandboxes/" + sandbox.id, headers={"X-API-Key": key} + ) + response.raise_for_status() + assert "example.com" in response.json().get("network", {}).get("allowOut", []) + await command("sandbox.network.update", {"sandbox_id": sandbox.id, "network": {}}) + + await stage("provider network replacement and clearing", network) + + async def lifetime() -> None: + await command("sandbox.timeout", {"sandbox_id": sandbox.id, "timeout": 600}) + + await stage("provider runtime timeout", lifetime) + await stage( + "private gateway authentication and collision-free application invocation", application + ) + mounts_file = os.environ.get("ABX_E2E_MOUNTS_FILE") + if mounts_file: + + async def mounts() -> None: + inputs = json.loads(Path(mounts_file).read_text()) + inputs["sandbox_id"] = sandbox.id + await command("sandbox.mounts.append", inputs) + for mount in inputs.get("named_volume_mounts", []): + await sandbox.files.list(mount["path"]) + for storage in inputs.get("inline_volume_mounts", {}).values(): + for mount in storage.get("mount_points", []): + await sandbox.files.list(mount["mount_dir"]) + + await stage("runtime append mounts and independent directory read", mounts) + else: + results.append( + { + "scenario": "runtime append mounts", + "result": "BLOCKED", + "reason": "No real storage fixture supplied", + } + ) + print(json.dumps(results[-1]), flush=True) + finally: + for local in local_clients: + await local.close() + async with httpx.AsyncClient(timeout=30) as http: + # Reconcile a create whose response was lost before on_created ran. + response = await http.get(api + "/sandboxes", headers={"X-API-Key": key}) + response.raise_for_status() + listed = response.json() + entries = listed if isinstance(listed, list) else listed.get("sandboxes", []) + for item in entries: + if item.get("metadata", {}).get("abx_api_review") == run_id: + owned.add(item["sandboxID"]) + persist() + for sandbox_id in list(owned): + response = await http.delete( + api + "/sandboxes/" + sandbox_id, headers={"X-API-Key": key} + ) + assert response.status_code in (200, 204, 404), ( + "Owned sandbox deletion was not confirmed" + ) + response = await http.get( + api + "/sandboxes/" + sandbox_id, headers={"X-API-Key": key} + ) + assert response.status_code == 404, "Owned sandbox is still present" + owned.remove(sandbox_id) + persist() + record = test_home / ".abx" / "engine.json" + if record.exists(): + value = json.loads(record.read_text()) + with contextlib.suppress(ProcessLookupError): + os.kill(value["pid"], signal.SIGTERM) + if not owned: + recovery.unlink(missing_ok=True) + shutil.rmtree(test_home) + print( + json.dumps( + { + "scenario": "independent owned cleanup", + "result": "PASS" if not owned else "FAIL", + "remaining": len(owned), + } + ), + flush=True, + ) + return ( + 1 + if any(item["result"] == "FAIL" for item in results) + else 2 + if any(item["result"] == "BLOCKED" for item in results) + else 0 + ) + + +if __name__ == "__main__": + try: + code = asyncio.run(main()) + except Exception as error: + print( + json.dumps( + { + "scenario": "acceptance controller", + "result": "FAIL", + "error_type": type(error).__name__, + } + ), + flush=True, + ) + code = 1 + raise SystemExit(code) From a9704f0f4b05b59606e163bee2f925796903436e Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:30:38 +0000 Subject: [PATCH 3/3] test: confirm Express pause and resume states before execution --- e2e/express_api_completion.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/e2e/express_api_completion.py b/e2e/express_api_completion.py index 9ba4acad..a8a6d889 100644 --- a/e2e/express_api_completion.py +++ b/e2e/express_api_completion.py @@ -141,8 +141,21 @@ async def create_private() -> None: assert sandbox is not None async def lifecycle() -> None: + async def state_is(expected: str) -> bool: + async with httpx.AsyncClient(timeout=20) as http: + response = await http.get( + api + "/sandboxes/" + sandbox.id, headers={"X-API-Key": key} + ) + response.raise_for_status() + data = response.json() + return str(data.get("state", data.get("status", ""))).lower() == expected + await sandbox.pause() + await until(lambda: state_is("paused")) + print(json.dumps({"checkpoint": "control plane paused confirmed"}), flush=True) await sandbox.resume() + await until(lambda: state_is("running")) + print(json.dumps({"checkpoint": "control plane running confirmed"}), flush=True) async def readable() -> bool: # This explicitly repeats an idempotent read while the backend resumes.