From 178f299b4f2c4e05e2d3a2396837e4658e76f461 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:34:29 +0000 Subject: [PATCH 01/13] feat(agents): add managed session API and private runtime bridge --- .agents/README.md | 2 + .../verify/2026-11-05-managed-agents-api.md | 60 + .../2026-11-05-managed-agents-api.md | 100 ++ .pre-commit-config.yaml | 4 +- CHANGELOG.md | 2 + README.md | 1 + README.zh-CN.md | 2 + client-sdk/README.md | 2 + client-sdk/pyproject.toml | 8 + .../agent_sandbox/managed_agents/__init__.py | 10 + .../agent_sandbox/managed_agents/__main__.py | 81 ++ .../agent_sandbox/managed_agents/backend.py | 72 ++ .../agent_sandbox/managed_agents/dispatch.py | 196 +++ .../src/agent_sandbox/managed_agents/http.py | 406 +++++++ .../agent_sandbox/managed_agents/models.py | 259 ++++ .../agent_sandbox/managed_agents/service.py | 1056 +++++++++++++++++ .../src/agent_sandbox/managed_agents/store.py | 351 ++++++ .../agent_sandbox/managed_agents/worker.py | 649 ++++++++++ .../src/agent_sandbox/providers/__init__.py | 6 + .../providers/alicloud_express/managed.py | 191 +++ client-sdk/tests/fixtures/managed_api_v1.json | 318 +++++ client-sdk/tests/test_managed_agents.py | 974 +++++++++++++++ docs/en/README.md | 2 + docs/en/guide/managed-agents-api.md | 141 +++ docs/zh/README.md | 2 + docs/zh/guide/managed-agents-api.md | 125 ++ e2e/managed_sse_pg.py | 442 +++++++ server-sdk/README.md | 2 + .../src/agent_sandbox/server/managed.py | 663 +++++++++++ .../agent_sandbox/server/managed_process.py | 93 ++ 30 files changed, 6218 insertions(+), 2 deletions(-) create mode 100644 .agents/evidence/verify/2026-11-05-managed-agents-api.md create mode 100644 .agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md create mode 100644 client-sdk/src/agent_sandbox/managed_agents/__init__.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/__main__.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/backend.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/dispatch.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/http.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/models.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/service.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/store.py create mode 100644 client-sdk/src/agent_sandbox/managed_agents/worker.py create mode 100644 client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py create mode 100644 client-sdk/tests/fixtures/managed_api_v1.json create mode 100644 client-sdk/tests/test_managed_agents.py create mode 100644 docs/en/guide/managed-agents-api.md create mode 100644 docs/zh/guide/managed-agents-api.md create mode 100644 e2e/managed_sse_pg.py create mode 100644 server-sdk/src/agent_sandbox/server/managed.py create mode 100644 server-sdk/src/agent_sandbox/server/managed_process.py diff --git a/.agents/README.md b/.agents/README.md index 9644555a..d22716f7 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,5 +1,7 @@ # `.agents/` — engineering record of the python-sdk repository +- [Managed Agents API](notes/proposed/architecture/2026-11-05-managed-agents-api.md) · [verification](evidence/verify/2026-11-05-managed-agents-api.md). + - [Express SDK completion](notes/implemented/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). - [Provider runtime setup](notes/proposed/process/2026-11-05-provider-runtime-setup.md) · [verification](evidence/verify/2026-11-05-provider-runtime-setup.md). diff --git a/.agents/evidence/verify/2026-11-05-managed-agents-api.md b/.agents/evidence/verify/2026-11-05-managed-agents-api.md new file mode 100644 index 00000000..6505465c --- /dev/null +++ b/.agents/evidence/verify/2026-11-05-managed-agents-api.md @@ -0,0 +1,60 @@ +# Verification: Managed Agents API runtime + +- Date: 2026-11-05 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/155 +- Result: PARTIAL — implementation and local component verification; full repository, cloud/model and release gates remain in progress. +- Package under test: isolated feature-branch source; distribution versions remain 0.4.0a6. This does not describe the public 0.4.0a6 artifacts. +- Environment: CPython 3.12.13 on Linux; isolated PID namespace component runner uses CPython 3.12.16. Real PostgreSQL supplied by pgserver 0.1.4; psycopg 3.3.8. No credential values recorded. + +## What was verified + +New managed Gateway, durable resources, Worker and private bridge behavior was exercised through +actual loopback HTTP, disk state, real PostgreSQL and real CLI fixture subprocesses. The fixture +implements deterministic Qwen stream events; it is not a real model or cloud acceptance claim. +Legacy public in-sandbox routes are unchanged. + +## Commands run + +- `python -m pytest client-sdk/tests/test_managed_agents.py -q --disable-warnings --maxfail=1`, inside an isolated PID namespace with source packages and the task virtualenv mounted read-only where appropriate. +- `mypy src/agent_sandbox/server/`, from `server-sdk`. +- `mypy src/agent_sandbox/`, from `client-sdk`. +- `ruff check` and `ruff format` over the new runtime and regression files. + +## Results + +| Scenario / test | Result | Notes | +| --- | --- | --- | +| Managed component regressions | PASS | 29 tests, 17.76 seconds at the recorded checkpoint; later review changes require rerunning | +| Frozen V1 HTTP inventory | PASS | Every one of 39 operations actually called successfully, plus unauthorized rejection per protected operation, on SQLite and real PostgreSQL | +| Files, two turns, independent artifact bytes, close/delete | PASS | Real bridge HTTP, filesystem and CLI subprocess; one reused resource and independent local absence | +| Exact cancellation and durable target replay | PASS | First cancellation never targets the next turn; queued and creation-time cancellation stop submission | +| Cooperative process stopping | PASS | Leader exits while a detached child continues writing; domain cleanup stops it. Unknown host processes force conservative whole-resource cleanup | +| Observed tool deadline | PASS | Real sleep subprocess, start without done, and completed-tool control | +| Limits and retention | PASS | Small output limit, partial HTTP body deadline, bounded GC/pending attachment protection, uncertain resources and more than 10,000 closed tombstones | +| Lost guest response | PASS | Original durable receipt inspected; exactly one input POST and one filesystem effect | +| Type checking | PASS | Server 58 source files and client 112 source files at the earlier checkpoint; rerun required for latest stream changes | +| Full repository checks | IN PROGRESS | Required before merge | +| Real Express/model deployment | IN PROGRESS | Coordinated separately; no success inferred from fixture tests or source-only installation | +| Public package/template installation | NOT RUN | Public 0.4.0a6 lacks the new modules; a new actual release is required | + +## Cleanup + +Every local fixture resource is closed in teardown. PostgreSQL handles stop/delete their temporary +clusters. Detached regression children are explicitly killed in `finally`. The isolated runner is +removed after exit. Cloud resources and independent absence belong to the separate cloud evidence; +no cloud cleanup success is inferred from this local report. + +## Problems found + +Adversarial reviews identified output-limit propagation, cancellation at the final dispatch fence, +heartbeat during preparation, uncertain Session freeze, artifact ancestor traversal, depth scanning, +retention and SSE deletion races. The implementation includes focused regressions; full latest-head +checks and independent stream-gate review remain pending. Early shared-host process-domain tests +correctly refused reuse when unrelated same-UID contributor processes appeared; production behavior +was preserved and coexistence tests moved to an isolated PID namespace. + +## Not verified + +OpenAI SDK interoperability, arbitrary providers, hostile same-UID integrity, steering, functions, +arbitrary MCP/subagents and dynamic Session settings are outside the supported V1 subset. Full +repository CI, real cloud/model business completion and public release/install are not yet complete. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md new file mode 100644 index 00000000..55c9444f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -0,0 +1,100 @@ +# Decision: Managed per-Session Agents API and restart-aware guest bridge + +Status: proposed + +## Problem + +Applications should configure one stable endpoint while coding agents execute in isolated, +reusable per-Session sandboxes. The existing local Agents API has process-local receipts and +events; storing a host submission alone cannot prove guest acceptance or authorize replay after +an uncertain create, submission, restart or cancellation. The older draft application runtime +also models per-run execution rather than the reviewed managed Session contract. + +Issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/155. +Related historical work: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/74 and +https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/75. +Template dependency: https://github.com/Alibaba-Agent-Sandbox/awesome-templates/issues/295. + +## Decision + +Add the optional `agent_sandbox.managed_agents` client-package runtime with Gateway, Worker and +Dispatcher entrypoints. Add the separate `agent_sandbox.server.managed` private guest listener, +reusing `AgentRuntime` and its real Codex/Qwen parsers without changing the legacy public routes. +PostgreSQL is authoritative for owner-scoped acceptance, ordering, public identity, publication, +delete and cleanup. Explicit local SQLite is supported outside FC only. + +A Session lazily allocates one owned Express Sandbox and pins its configuration and Worker +revision. Only actual execution creates a public Turn. Explicit enqueue creates a Submission. +Guest receipts persist before staging/launch, bind the original request digest and runtime epoch, +and cannot authorize replay into a new runtime. Uncertain execution freezes the entire Session; +its owned resources continue occupying capacity until independent provider absence is confirmed. + +Cooperative process-domain observation includes marked children after leader exit, `setsid` and +reparenting. Incomplete attribution refuses reuse and releases the whole Sandbox. This is not an +audit or isolation guarantee against hostile same-UID code. Tool deadlines start at observed +native start events. Guest limits and independent host projection/stream limits remain finite. +Artifacts are immutable regular-file snapshots under `outputs/`, opened through no-follow directory +FDs with bounded enumeration, bytes, depth and time. + +Every SSE frame holds a separate cross-process Session gate while a short application transaction +rechecks visibility/authentication, commits and then performs a bounded write. DELETE takes the +same gate before its application transaction. Socket writes never hold the application lock; +bytes already in the network cannot be withdrawn. PostgreSQL uses a separate advisory lock; +local SQLite uses a process-shared file lock. Unknown epochs and native event gaps are explicit. + +## API Design + +- `ManagedConfig.from_dict(compiled: dict) -> ManagedConfig`: strict non-secret deployment input. +- `ManagedStore(database_url, application_id, local=False)`: shared transactions and stream gates. +- `ManagedService.handle(owner, method, path, body=None, query=None, key='')`: finite business operations. +- `ManagedWorker.run(session_id, submission_id=None) -> bool`: finite claim-fenced execution/observation. +- `ManagedDispatcher.invoke(payload)`: signed dispatch/status/drain with durable admission fences. +- `python -m agent_sandbox.managed_agents gateway|worker|dispatcher`: role-specific HTTP listeners. +- `python -m agent_sandbox.server.managed --port 9000`: authenticated private bridge. + +The frozen independent HTTP inventory is `client-sdk/tests/fixtures/managed_api_v1.json`. +The contract uses selected OpenAI-inspired wire shapes and does not claim OpenAI SDK interoperability. +FILE/JSON configuration sources are mutually exclusive. Only Worker receives model credentials. +Explicit provider endpoint, domain, region and API key avoid HOME, registry or Node-engine selection. +TLS verification stays enabled with an optional explicit CA bundle and without inherited proxies. + +## Alternatives considered + +- **Copy the older run-only draft runtime** — would preserve incompatible per-run ownership and omit + Session reuse, native receipts and durable operation semantics. +- **Treat a database acceptance or lease expiry as permission to retry** — cannot prove native work + did not start; may duplicate filesystem or external side effects. +- **Treat native CLI cancellation as complete process stopping** — a detached child can survive + leader exit. Reuse requires cooperative domain evidence or whole-Sandbox destruction. +- **Hold the global application lock during streaming** — a slow reader would block unrelated + Sessions. A distinct per-Session gate and bounded write preserve deletion ordering. +- **Enable arbitrary provider discovery or HTTP environment inheritance** — risks selecting an + unrelated configured account or proxy. Explicit service configuration is narrower. + +## Dependencies + +The optional `managed-agents` client extra installs psycopg and the official FC Python SDK. The +base server reuses its existing CLI runtime and stdlib HTTP/SQLite. The template repository owns +specification 6, the managed bridge profile and recipes; the CLI owns frozen deployment plans, +credential resolution, FC role/revision routing and public ingress. Both distributions retain +version lockstep; new public template installation requires an actual new matching release. + +## Test Strategy + +Test all 39 frozen operations through real HTTP with positive behavior and authentication +rejection, against both disk SQLite and real PostgreSQL. Exercise real fixture CLI subprocesses, +files, two-turn continuity, tool wall deadlines, detached descendants, response loss, cancellation, +uncertainty freeze, stream revocation and bounded retention. Run normal coexistence in an isolated +PID namespace so unrelated contributor processes cannot create false process-domain confidence. +Keep deterministic fixtures separate from real cloud/model acceptance. Run all repository gates, +build and fresh-install checks, and record full results independently of public artifact release. + +## Acceptance criteria + +- All advertised V1 operations pass their observable HTTP behavior and owner/auth boundaries. +- Cancellation/close/delete never claim confirmed stop without cooperative proof or provider absence. +- Restart/response loss never replay uncertain input; frozen Sessions cannot execute queued successors. +- Limits, retention and cleanup remain bounded even with retained tombstone history. +- Existing local Agents API behavior and required package test/CI gates remain green. +- Real Express/model use, independent artifact consumption, cleanup and published-package template + installation are verified before declaring final delivery or moving this decision to implemented. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d72a6086..a9da7d87 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,9 +26,9 @@ repos: # Linting (ruff — replaces flake8 + isort) - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.9 + rev: v0.17.3 hooks: - - id: ruff + - id: ruff-check args: ['--fix'] - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index 47472287..a3e498b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- Optional managed Agents API Gateway, Worker and Dispatcher with durable Sessions, explicit queue receipts, files/artifacts, safe cancellation and a restart-aware private guest bridge. / 新增可选托管 Agents API Gateway、Worker、Dispatcher,支持持久 Session、显式队列收据、文件与产物、安全取消和可识别重启的私有沙箱桥接。 + - 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 授权指南。 diff --git a/README.md b/README.md index 66275562..e3f7b3fb 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ The server exposes registered application commands over HTTP. Template authors i | Task | Guide | | --- | --- | +| Run agents through one Gateway endpoint | [Managed Agents API](docs/en/guide/managed-agents-api.md) | | 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) | diff --git a/README.zh-CN.md b/README.zh-CN.md index ae12f6a0..f860cf0f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,6 +64,8 @@ finally: ## 文档 +通过统一 Gateway 运行 Agent,见[托管 Agents API](docs/zh/guide/managed-agents-api.md)。 + | 任务 | 指南 | | --- | --- | | 完成第一次 API 调用 | [快速开始](docs/zh/guide/getting-started.md) | diff --git a/client-sdk/README.md b/client-sdk/README.md index 6fc5e89d..311b61ab 100644 --- a/client-sdk/README.md +++ b/client-sdk/README.md @@ -79,3 +79,5 @@ cd client-sdk && pytest -m "not integration" && ruff check src tests && mypy src ``` Licensed under Apache-2.0. + +Managed per-Session Agents API deployment: [English guide](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/en/guide/managed-agents-api.md) / [中文指南](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/zh/guide/managed-agents-api.md). diff --git a/client-sdk/pyproject.toml b/client-sdk/pyproject.toml index 935fdcbf..60530753 100644 --- a/client-sdk/pyproject.toml +++ b/client-sdk/pyproject.toml @@ -48,6 +48,10 @@ dependencies = [ ] [project.optional-dependencies] +managed-agents = [ + "psycopg[binary]>=3.2,<4", + "alibabacloud-fc20230330>=4.8.3,<5", +] mcp = [ "mcp>=1.0.0", "starlette>=1.7.0", @@ -126,6 +130,10 @@ exclude = ["src/agent_sandbox/_standard/", "src/agent_sandbox/code_interpreter/" # ``tomli`` is only present as a fallback on older interpreters (the stdlib ``tomllib`` is # unavailable before Python 3.11). +[[tool.mypy.overrides]] +module = ["psycopg.*", "alibabacloud_credentials.*", "alibabacloud_fc20230330.*", "alibabacloud_tea_openapi.*", "alibabacloud_tea_util.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = [ "tomli", diff --git a/client-sdk/src/agent_sandbox/managed_agents/__init__.py b/client-sdk/src/agent_sandbox/managed_agents/__init__.py new file mode 100644 index 00000000..eb736b50 --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/__init__.py @@ -0,0 +1,10 @@ +"""Optional, durable application Gateway for Agents running in owned sandboxes. + +Importing this module does not start a server, discover providers or create resources. +""" + +from .models import ManagedConfig, ManagedError +from .service import ManagedService +from .store import ManagedStore + +__all__ = ["ManagedConfig", "ManagedError", "ManagedService", "ManagedStore"] diff --git a/client-sdk/src/agent_sandbox/managed_agents/__main__.py b/client-sdk/src/agent_sandbox/managed_agents/__main__.py new file mode 100644 index 00000000..f877314a --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/__main__.py @@ -0,0 +1,81 @@ +"""Explicit command entrypoint for managed Gateway/Worker/Dispatcher containers.""" + +from __future__ import annotations + +import argparse +import json +import os +import ssl +from pathlib import Path +from typing import Any + +from .backend import ExpressBackend +from .dispatch import FCTaskSender, ManagedDispatcher +from .http import TokenAuthenticator, make_server +from .models import ManagedConfig +from .service import ManagedService +from .store import ManagedStore +from .worker import ManagedWorker + + +def load_document(name: str, *, required: bool = True) -> dict[str, Any] | None: + """Load an explicit FILE or JSON document; never consult CWD/HOME configuration.""" + filename, raw = os.getenv(name + "_FILE"), os.getenv(name + "_JSON") + if filename and raw: + raise ValueError("Provide exactly one FILE or JSON managed configuration source") + if not filename and not raw: + if not required: + return None + raise ValueError("Required managed configuration was not supplied") + data = json.loads(Path(filename).read_text() if filename else raw or "{}") + if not isinstance(data, dict): + raise ValueError("Managed configuration must be a JSON object") + return data + + +def main() -> None: + """Start only the explicitly requested role, with resolved service configuration.""" + parser = argparse.ArgumentParser(description="Durable Sandbox-hosted Agents API") + parser.add_argument("mode", choices=("gateway", "worker", "dispatcher")) + parser.add_argument("--port", type=int, default=9000) + parser.add_argument("--host", default="0.0.0.0") # nosec B104 - authenticated container ingress + parser.add_argument("--local", action="store_true") + args = parser.parse_args() + ssl.create_default_context(cafile=os.getenv("ABX_MANAGED_TLS_CA_FILE")) + compiled = load_document("ABX_MANAGED_CONFIG") + assert compiled is not None + config = ManagedConfig.from_dict(compiled) + store = ManagedStore( + os.environ["ABX_MANAGED_DATABASE_URL"], config.application_id, local=args.local + ) + service = ManagedService(store, config) + auth = None + dispatcher = None + token = os.getenv("ABX_MANAGED_INTERNAL_TOKEN", "") + if args.mode == "gateway": + auth = TokenAuthenticator(lambda: load_document("ABX_MANAGED_AUTH")) + auth.source() # fail readiness before binding a public socket + else: + provider = load_document("ABX_MANAGED_PROVIDER_CONFIG") + model = load_document("ABX_MANAGED_MODEL_CONFIG", required=args.mode == "worker") + assert provider is not None + backend = ExpressBackend(provider, model) + worker = ManagedWorker(service, backend) + sender = None + if args.mode == "dispatcher" and not args.local: + sender = FCTaskSender(os.environ["ABX_MANAGED_FC_ENDPOINT"], config.workers, token) + dispatcher = ManagedDispatcher(worker, sender) + server = make_server( + service, + auth, + mode=args.mode, + dispatcher=dispatcher, + internal_token=token, + host=args.host, + port=args.port, + ) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/client-sdk/src/agent_sandbox/managed_agents/backend.py b/client-sdk/src/agent_sandbox/managed_agents/backend.py new file mode 100644 index 00000000..7724f786 --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/backend.py @@ -0,0 +1,72 @@ +"""Explicit native Express lifecycle; no CLI engine, HOME store or registry discovery.""" + +from __future__ import annotations + +import json +import os +import ssl +from typing import Any + +import httpx + +from agent_sandbox.providers import ManagedExpressBackend as ExpressBackend + +from .models import BRIDGE_PROTOCOL, ManagedError + +__all__ = ["ExpressBackend", "bridge_request"] + + +async def bridge_request( + resource: dict[str, Any], method: str, path: str, body: dict[str, Any] | None = None +) -> dict[str, Any]: + """Call a recorded private endpoint, preserving provider headers and app auth.""" + headers = dict(resource.get("headers", {})) + occupied = {k.lower() for k in headers} + if "x-access-token" not in occupied: + headers["X-Access-Token"] = resource["token"] + elif "authorization" not in occupied: + headers["Authorization"] = "Bearer " + resource["token"] + else: + raise ManagedError( + 503, + "authentication_collision", + "Provider headers leave no application authentication channel", + ) + async with httpx.AsyncClient( + headers=headers, + timeout=30, + follow_redirects=False, + trust_env=False, + verify=ssl.create_default_context(cafile=os.getenv("ABX_MANAGED_TLS_CA_FILE")), + ) as client: + base = httpx.URL(resource["url"]) + url = base.copy_with( + path=base.path.rstrip("/") + path.split("?")[0], + query=b"&".join(x for x in (base.query, path.partition("?")[2].encode()) if x) or None, + ) + async with client.stream(method, url, json=body) as response: + if response.status_code >= 400: + raise ManagedError( + 503, + "guest_request_failed", + "The private runtime request could not be completed", + ) + size = 0 + chunks = [] + async for chunk in response.aiter_bytes(chunk_size=65536): + size += len(chunk) + if size > 32000000: + raise ManagedError( + 413, "guest_output_limit", "Private runtime output exceeded its budget" + ) + chunks.append(chunk) + value = json.loads(b"".join(chunks)) + if not isinstance(value, dict): + raise ManagedError( + 503, "invalid_guest_response", "The private runtime response is invalid" + ) + if path == "/capabilities" and value.get("protocol") != BRIDGE_PROTOCOL: + raise ManagedError( + 503, "guest_protocol_mismatch", "The Sandbox lacks the required managed bridge" + ) + return value diff --git a/client-sdk/src/agent_sandbox/managed_agents/dispatch.py b/client-sdk/src/agent_sandbox/managed_agents/dispatch.py new file mode 100644 index 00000000..3934f1d7 --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/dispatch.py @@ -0,0 +1,196 @@ +"""Private signed invocation and finite outbox delivery, including safe drain.""" + +from __future__ import annotations + +import hashlib +import hmac +import io +import json +from typing import TYPE_CHECKING, Any, cast + +from .models import ManagedError, canonical + +if TYPE_CHECKING: + from .worker import ManagedWorker + + +def sign_invocation(payload: dict[str, Any], token: str, role: str) -> dict[str, Any]: + """Sign a role-scoped body; no secret appears in the envelope.""" + if role not in {"worker", "dispatcher"} or len(token) < 32: + raise ValueError("A private role and at least 32-character token are required") + value = {"version": 1, "role": role, "payload": payload} + return { + **value, + "signature": hmac.new( + token.encode(), canonical(value).encode(), hashlib.sha256 + ).hexdigest(), + } + + +def verify_invocation(value: dict[str, Any], token: str, role: str) -> dict[str, Any]: + """Authenticate direct/timer invocation before accessing control-plane state.""" + if role == "dispatcher" and set(value) == {"triggerTime", "triggerName", "payload"}: + try: + value = json.loads(value["payload"]) + except (ValueError, TypeError) as exc: + raise ManagedError(401, "unauthenticated", "Private invocation required") from exc + if ( + not isinstance(value, dict) + or set(value) != {"version", "role", "payload", "signature"} + or value.get("version") != 1 + or value.get("role") != role + or not isinstance(value.get("payload"), dict) + or not isinstance(value.get("signature"), str) + ): + raise ManagedError(401, "unauthenticated", "Private invocation required") + expected = sign_invocation(value["payload"], token, role)["signature"] + if not hmac.compare_digest(value["signature"], expected): + raise ManagedError(401, "unauthenticated", "Private invocation required") + return cast("dict[str, Any]", value["payload"]) + + +class FCTaskSender: + """Use the official Python FC SDK and role credentials, with SDK retries disabled.""" + + def __init__( + self, endpoint: str, workers: dict[str, str], token: str, *, client: Any = None + ) -> None: + if not endpoint or "://" in endpoint or len(token) < 32: + raise ValueError("Explicit FC endpoint and private invocation token are required") + if client is None: + from alibabacloud_credentials.client import Client as Credentials + from alibabacloud_fc20230330.client import Client + from alibabacloud_tea_openapi.models import Config + + client = Client(Config(credential=Credentials(), endpoint=endpoint)) + self.client, self.workers, self.token = client, dict(workers), token + + def __call__(self, payload: dict[str, str]) -> None: + """Deliver to the Session's pinned revision, never silently the newest Worker.""" + from alibabacloud_fc20230330.models import InvokeFunctionHeaders, InvokeFunctionRequest + from alibabacloud_tea_util.models import RuntimeOptions + + function = self.workers.get(payload["revision"]) + if not function: + raise ManagedError( + 503, "worker_revision_unavailable", "The pinned Worker revision is unavailable" + ) + body = {k: payload[k] for k in ("sessionId", "submissionId")} + response = self.client.invoke_function_with_options( + function, + InvokeFunctionRequest( + body=io.BytesIO(canonical(sign_invocation(body, self.token, "worker")).encode()) + ), + InvokeFunctionHeaders( + x_fc_invocation_type="Async", x_fc_async_task_id=payload["deliveryId"] + ), + RuntimeOptions(autoretry=False, connect_timeout=10000, read_timeout=15000), + ) + if response.status_code != 202: + raise ManagedError(503, "task_not_accepted", "FC did not durably accept the task") + + +class ManagedDispatcher: + """Finite dispatch/status/drain operations sharing the normal Session state machine.""" + + def __init__(self, worker: ManagedWorker, sender: Any = None) -> None: + self.worker, self.service, self.store, self.sender = ( + worker, + worker.service, + worker.store, + sender, + ) + + async def dispatch(self, limit: int = 20) -> dict[str, int]: + """Dispatch bounded due work, retaining failures for later outbox attempts.""" + sent, failed = 0, 0 + revisions = ( + set(self.service.config.workers) + if self.sender is not None + else {self.service.config.revision} + ) + for payload in self.worker.due(limit, revisions): + try: + if self.sender is None: + await self.worker.run(payload["sessionId"]) + else: + self.sender(payload) + sent += 1 + except Exception: + failed += 1 + return {"dispatched": sent, "pendingRetry": failed} + + def status(self, revision: str | None = None) -> dict[str, Any]: + """Count all usable logical Sessions and uncertain resources before retirement.""" + with self.store.transaction() as tx: + sessions = [ + r + for r in tx.list("session", deleted=True, work=True) + if revision is None or r["_revision"] == revision + ] + active = [ + r + for r in sessions + if ( + not r.get("_deleted") + and r["x_agent_sandbox"]["lifecycle"] not in {"closed", "expired"} + ) + or r.get("_resource") + or r.get("_creation_started") + ] + pending = [ + r for r in sessions if r.get("_close_requested") and not r.get("_cleanup_confirmed") + ] + uncertain = [ + r for r in sessions if r["x_agent_sandbox"]["lifecycle"] == "requires_reconcile" + ] + control = self.service.control(tx) + return { + "activeSessions": len(active), + "pendingCleanup": len(pending), + "uncertainResources": len(uncertain), + "draining": not control["accepting"] or revision in control["draining_revisions"], + } + + async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: + """Handle signed dispatch/status/drain without introducing a business admin API.""" + if set(payload) - {"operation", "applicationId", "revision", "limit"}: + raise ManagedError(400, "invalid_invocation", "Unknown dispatcher input") + operation, revision = payload.get("operation", "dispatch"), payload.get("revision") + limit = payload.get("limit", 20) + if ( + (not isinstance(limit, int) or isinstance(limit, bool)) + or not 1 <= limit <= 100 + or (revision is not None and not isinstance(revision, str)) + ): + raise ManagedError(400, "invalid_invocation", "Invalid dispatch bounds") + if operation not in {"dispatch", "status", "drain"}: + raise ManagedError(400, "invalid_operation", "Unknown private control operation") + if ( + operation != "dispatch" + and payload.get("applicationId") != self.service.config.application_id + ): + raise ManagedError(403, "application_mismatch", "Private control target does not match") + if operation == "status": + return self.status(revision) + if operation == "drain": + with self.store.transaction() as tx: + control = self.service.control(tx) + if revision: + control["draining_revisions"] = sorted( + set(control["draining_revisions"]) | {revision} + ) + else: + control["accepting"] = False + tx.execute( + "UPDATE managed_control SET body=? WHERE application=?", + (canonical(control), tx.application), + ) + for session in tx.list("session", deleted=True, work=True): + if (revision is None or session["_revision"] == revision) and not session.get( + "_cleanup_confirmed" + ): + self.service._close(tx, session) + await self.dispatch(limit) + return self.status(revision) + return await self.dispatch(limit) diff --git a/client-sdk/src/agent_sandbox/managed_agents/http.py b/client-sdk/src/agent_sandbox/managed_agents/http.py new file mode 100644 index 00000000..b15d273f --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/http.py @@ -0,0 +1,406 @@ +"""Finite authenticated Gateway/control listeners and durable SSE subscriptions.""" + +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import hmac +import json +import math +import threading +import time +import uuid +from email.parser import BytesParser +from email.policy import default +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs, urlsplit + +from .dispatch import ManagedDispatcher, verify_invocation +from .models import PROTOCOL, ManagedError, canonical +from .store import new_object + +if TYPE_CHECKING: + from .service import ManagedService + + +class TokenAuthenticator: + """Validate owner/scopes from token hashes; a file-backed source supports revocation.""" + + def __init__(self, source: Any) -> None: + self.source = source + + def authenticate(self, token: str, write: bool) -> str: + """Return an authenticated owner or a bounded public authorization error.""" + document = self.source() if callable(self.source) else self.source + hashed = hashlib.sha256(token.encode()).hexdigest() + for row in document.get("tokens", []): + if hmac.compare_digest(str(row.get("sha256", "")), hashed): + expiry = row.get("expiresAt") + if expiry is not None and ( + type(expiry) not in {int, float} + or not math.isfinite(expiry) + or expiry <= time.time() + ): + break + if ("agents:write" if write else "agents:read") not in row.get("scopes", []): + raise ManagedError(403, "forbidden", "This operation is not authorized") + owner = row.get("owner") + if not isinstance(owner, str) or not owner: + break + return owner + raise ManagedError(401, "unauthenticated", "A valid business bearer token is required") + + +def make_server( + service: ManagedService, + auth: TokenAuthenticator | None = None, + *, + mode: str = "gateway", + dispatcher: ManagedDispatcher | None = None, + internal_token: str = "", + host: str = "127.0.0.1", + port: int = 9000, + read_timeout: float = 10, + max_connections: int = 64, + write_timeout: float = 1, + max_frame_bytes: int = 1048576, +) -> ThreadingHTTPServer: + """Build a Gateway or private invocation listener; imports never start one.""" + if mode not in {"gateway", "worker", "dispatcher"} or ( + mode != "gateway" and (len(internal_token) < 32 or dispatcher is None) + ): + raise ValueError("A valid listener mode and private control dependencies are required") + + class Server(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, *args: Any) -> None: + self.slots = threading.BoundedSemaphore(max_connections) + super().__init__(*args) + + def get_request(self) -> Any: + connection, address = super().get_request() + connection.settimeout(read_timeout) + return connection, address + + def process_request(self, request: Any, address: Any) -> None: + if not self.slots.acquire(blocking=False): + with contextlib.suppress(OSError): + request.sendall( + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n" + ) + self.shutdown_request(request) + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request: Any, address: Any) -> None: + try: + super().process_request_thread(request, address) + finally: + self.slots.release() + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + return + + def _send(self, status: int, body: Any, headers: dict[str, str] | None = None) -> None: + payload = ( + body + if isinstance(body, bytes) + else b"" + if body is None + else canonical(body).encode() + ) + headers = headers or {} + self.send_response(status) + self.send_header("Content-Type", headers.get("Content-Type", "application/json")) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.send_header("Connection", "close") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Request-Id", self.request_id) + self.send_header( + "Access-Control-Expose-Headers", + "Location, X-Request-Id, X-Agent-Sandbox-Submission-Id, " + "X-Agent-Sandbox-Operation-Id", + ) + if status == 429: + self.send_header("Retry-After", "1") + for key, value in headers.items(): + if key.lower() != "content-type": + self.send_header(key, value) + self.end_headers() + self.wfile.write(payload) + + def _owner(self, write: bool = False) -> str: + supplied = self.headers.get("Authorization", "") + if auth is None or not supplied.startswith("Bearer "): + raise ManagedError( + 401, "unauthenticated", "A valid business bearer token is required" + ) + return auth.authenticate(supplied[7:], write) + + def _raw(self) -> bytes: + if self.headers.get("Transfer-Encoding"): + raise ManagedError(400, "invalid_body", "Chunked request bodies are not supported") + try: + size = int(self.headers.get("Content-Length", "0")) + except ValueError as exc: + raise ManagedError(400, "invalid_body", "Invalid content length") from exc + budget = service.config.storage["maxFileBytes"] + 16384 + if not 0 <= size <= budget: + raise ManagedError(413, "body_too_large", "Request exceeds its byte budget") + raw = self.rfile.read(size) + if len(raw) != size: + raise ManagedError(400, "incomplete_body", "Incomplete request body") + return raw + + def _body(self) -> dict[str, Any]: + raw = self._raw() + if not raw: + return {} + if self.headers.get_content_type() != "application/json": + raise ManagedError(415, "unsupported_media_type", "Use application/json") + try: + value = json.loads(raw) + canonical(value) + except (ValueError, TypeError) as exc: + raise ManagedError(400, "invalid_json", "Expected finite JSON") from exc + if not isinstance(value, dict): + raise ManagedError(400, "invalid_json", "Expected a JSON object") + return value + + def _file(self, owner: str, key: str) -> None: + content_type = self.headers.get("Content-Type", "") + if self.headers.get_content_type() != "multipart/form-data" or len(content_type) > 1024: + raise ManagedError(415, "unsupported_media_type", "Use multipart/form-data") + raw = self._raw() + message = BytesParser(policy=default).parsebytes( + b"Content-Type: " + + content_type.encode("ascii") + + b"\r\nMIME-Version: 1.0\r\n\r\n" + + raw + ) + if not message.is_multipart(): + raise ManagedError( + 400, "invalid_multipart", "Expected a multipart file and purpose" + ) + fields: dict[str, Any] = {} + filename = "" + for part in message.iter_parts(): + name = part.get_param("name", header="content-disposition") + if name not in {"file", "purpose"} or name in fields or part.is_multipart(): + raise ManagedError( + 400, "invalid_multipart", "Expected exactly one file and purpose" + ) + fields[name] = part.get_payload(decode=True) or b"" + if name == "file": + filename = part.get_filename() or "" + if set(fields) != {"file", "purpose"}: + raise ManagedError(400, "invalid_multipart", "Expected file and purpose") + try: + purpose = fields["purpose"].decode("ascii") + except UnicodeError as exc: + raise ManagedError(400, "invalid_purpose", "Invalid file purpose") from exc + self._send(200, service.upload_file(owner, filename, purpose, fields["file"], key)) + + def _events( + self, + owner: str, + sid: str, + query: dict[str, str], + *, + status: int = 200, + headers: dict[str, str] | None = None, + ) -> None: + header, after = self.headers.get("Last-Event-ID"), query.get("after") + if header and after and header != after: + raise ManagedError(400, "invalid_cursor", "Conflicting event cursors") + cursor = header or after + subscription_id = None + with service.store.transaction() as tx: + session = service._session(tx, sid, owner) + if cursor is None: + cursor = f"evt_{sid}_{session.get('_seq', 0)}" + subscriptions = [ + r for r in tx.list("subscription", parent=sid) if r["expires_at"] > time.time() + ] + if len(subscriptions) >= service.config.limits["maxSubscribersPerSession"]: + raise ManagedError( + 429, "subscriber_limit", "Session subscription budget exhausted" + ) + row = new_object("subscription", owner, parent=sid, expires_at=time.time() + 30) + subscription_id = row["id"] + tx.put(row) + try: + pending = service.events(owner, sid, cursor) + + def admit(event_id: str) -> bool: + try: + # This database admission is ordered with DELETE in every + # Gateway process. A single admitted frame is in flight; + # socket I/O never holds the application transaction lock. + with service.store.transaction() as tx: + service._session(tx, sid, owner) + if self._owner() != owner: + return False + current = tx.get(subscription_id, owner) + current["_admitted_event"] = event_id + tx.put(current) + return True + except ManagedError: + return False + + if not admit(cursor): + raise ManagedError(404, "resource_not_found", "Session was removed") + self.send_response(status) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Accel-Buffering", "no") + self.send_header("X-Request-Id", self.request_id) + self.send_header("Connection", "close") + for name, value in (headers or {}).items(): + self.send_header(name, value) + self.end_headers() + end = time.monotonic() + 20 + while time.monotonic() < end: + try: + authorized = self._owner() == owner + except ManagedError: + authorized = False + if not authorized: + break + for event in pending: + with service.store.session_gate(sid): + if not admit(event["event_id"]): + return + payload = event + if len(canonical(payload).encode()) > max_frame_bytes: + payload = { + "event_id": event["event_id"], + "session_id": sid, + "type": "agent.session.events.gap", + "reason": "sse_frame_limit", + } + frame = ( + f"id: {event['event_id']}\nevent: {payload['type']}\n" + f"data: {canonical(payload)}\n\n" + ) + self.connection.settimeout(write_timeout) + self.wfile.write(frame.encode()) + self.wfile.flush() + cursor = event["event_id"] + if not pending: + self.wfile.write(b": heartbeat\n\n") + self.wfile.flush() + time.sleep(0.2) + try: + pending = service.events(owner, sid, cursor) + except ManagedError: + break + finally: + if subscription_id: + with service.store.transaction() as tx: + tx.execute( + "DELETE FROM managed_objects WHERE application=? AND id=?", + (tx.application, subscription_id), + ) + + def _handle(self, method: str) -> None: + self.close_connection = True + self.connection.settimeout(read_timeout) + self.request_id = uuid.uuid4().hex + url = urlsplit(self.path) + if method == "GET" and url.path in {"/health", "/healthz"}: + with service.store.transaction() as tx: + tx.execute("SELECT 1").fetchone() + self._send( + 200, + { + "status": "ok", + "protocol": PROTOCOL, + "mode": mode, + "revision": service.config.revision, + }, + ) + return + if mode != "gateway": + if method != "POST" or url.path != "/invoke" or dispatcher is None: + raise ManagedError( + 404, "endpoint_not_available", "Only private invocation is available" + ) + payload = verify_invocation(self._body(), internal_token, mode) + if mode == "worker": + if set(payload) - {"sessionId", "submissionId"} or not isinstance( + payload.get("sessionId"), str + ): + raise ManagedError(400, "invalid_invocation", "Expected a Session identity") + result = {"claimed": asyncio.run(dispatcher.worker.run(payload["sessionId"]))} + else: + result = asyncio.run(dispatcher.invoke(payload)) + self._send(200, result) + return + owner = self._owner(method != "GET") + key = self.headers.get("Idempotency-Key", "") + if method == "POST" and url.path == "/v1/files": + self._file(owner, key) + return + query_values = parse_qs(url.query, keep_blank_values=True) + if any(len(v) != 1 for v in query_values.values()): + raise ManagedError( + 400, "invalid_query", "Repeated query parameters are not supported" + ) + query = {k: v[0] for k, v in query_values.items()} + segments = url.path.strip("/").split("/") + if ( + method == "GET" + and len(segments) == 5 + and segments[:3] == ["v1", "agents", "sessions"] + and segments[4] == "events" + ): + self._events(owner, segments[3], query) + return + body = self._body() if method in {"POST", "DELETE"} else {} + status, result, headers = service.handle(owner, method, url.path, body, query, key) + if method == "POST" and url.path == "/v1/agents/sessions" and body.get("stream"): + # Creation is committed. Replay its stream; disconnect does not + # cancel or authorize re-execution of the accepted initial input. + self._events(owner, result["id"], {"after": "0"}, status=status, headers=headers) + else: + self._send(status, result, headers) + + def _safe(self, method: str) -> None: + self.request_id = uuid.uuid4().hex + try: + self._handle(method) + except (BrokenPipeError, ConnectionResetError, TimeoutError): + return + except Exception as exc: + error = ( + exc + if isinstance(exc, ManagedError) + else ManagedError( + 503, "service_unavailable", "The application service is unavailable" + ) + ) + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + self._send(error.status, error.body()) + + def do_GET(self) -> None: # noqa: N802 - stdlib HTTP dispatch name + self._safe("GET") + + def do_POST(self) -> None: # noqa: N802 - stdlib HTTP dispatch name + self._safe("POST") + + def do_DELETE(self) -> None: # noqa: N802 - stdlib HTTP dispatch name + self._safe("DELETE") + + return Server((host, port), Handler) diff --git a/client-sdk/src/agent_sandbox/managed_agents/models.py b/client-sdk/src/agent_sandbox/managed_agents/models.py new file mode 100644 index 00000000..b7779746 --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/models.py @@ -0,0 +1,259 @@ +"""Explicit configuration and safe public errors for the managed Agents API.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any + +PROTOCOL = "agent-sandbox-managed-agents/v1" +BRIDGE_PROTOCOL = "agent-sandbox-managed-bridge/v1" +TERMINAL = frozenset({"completed", "failed", "cancelled"}) +SUBMISSION_TERMINAL = TERMINAL | {"uncertain"} + + +def canonical(value: Any) -> str: + """Return stable, finite JSON suitable for immutable request identities.""" + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + ) + + +def digest(value: Any) -> str: + """Hash a JSON request without exposing it in logs.""" + return hashlib.sha256(canonical(value).encode()).hexdigest() + + +class ManagedError(Exception): + """A bounded, explicitly public error, never an arbitrary exception string.""" + + def __init__(self, status: int, code: str, message: str, param: str | None = None) -> None: + super().__init__(message) + self.status, self.code, self.message, self.param = status, code, message, param + + def body(self) -> dict[str, Any]: + """Return the documented error envelope.""" + return { + "error": { + "type": "invalid_request_error", + "code": self.code, + "message": self.message, + "param": self.param, + } + } + + +def reject_unknown(body: dict[str, Any], allowed: set[str], param: str = "") -> None: + """Reject unsupported fields instead of silently accepting an unavailable feature.""" + extra = set(body) - allowed + if extra: + key = sorted(extra)[0] + raise ManagedError(400, "unsupported_field", "This field is not supported", param + key) + + +def metadata(value: Any) -> dict[str, str]: + """Validate the bounded metadata map used by configuration resources.""" + if value is None: + return {} + if ( + not isinstance(value, dict) + or len(value) > 32 + or any( + not isinstance(k, str) or not isinstance(v, str) or len(k) > 64 or len(v) > 1024 + for k, v in value.items() + ) + ): + raise ManagedError(400, "invalid_metadata", "Expected a bounded string map", "metadata") + return dict(value) + + +def text_input(value: Any) -> str: + """Extract the supported user/input_text subset without accepting other input kinds.""" + if isinstance(value, str) and value.strip(): + result = value + elif isinstance(value, list) and value: + parts: list[str] = [] + for message in value: + if not isinstance(message, dict): + raise ManagedError(400, "unsupported_input", "Expected user text", "input") + reject_unknown(message, {"role", "content"}, "input.") + if message.get("role") != "user" or not isinstance(message.get("content"), list): + raise ManagedError(400, "unsupported_input", "Expected user text", "input") + for part in message["content"]: + if ( + not isinstance(part, dict) + or set(part) != {"type", "text"} + or part["type"] != "input_text" + or not isinstance(part["text"], str) + ): + raise ManagedError( + 400, "unsupported_input", "Only input_text is supported", "input" + ) + parts.append(part["text"]) + result = "\n".join(parts) + else: + raise ManagedError(400, "invalid_input", "A non-empty text input is required", "input") + if not result.strip() or len(result.encode()) > 262144: + raise ManagedError( + 413, "input_too_large", "Input is empty or exceeds its byte budget", "input" + ) + return result + + +@dataclass(frozen=True) +class ManagedConfig: + """One immutable deployed revision; all provider/secret resolution is explicit.""" + + revision: str + template: str + models: dict[str, str] + application_id: str = "agents-api" + provider: str = "alicloud-express" + workspace: str = "/workspace" + port: int = 9000 + workers: dict[str, str] = field(default_factory=dict) + model_env_allowlist: list[str] = field(default_factory=list) + sessions: dict[str, int] = field( + default_factory=lambda: { + "idleTimeoutSeconds": 900, + "maxLifetimeSeconds": 3600, + "maxLogicalSessionsPerOwner": 100, + "maxActiveSessions": 20, + "maxQueuedTurnsPerSession": 8, + "resultRetentionSeconds": 86400, + "maxConcurrentTurnsPerSession": 1, + } + ) + limits: dict[str, int] = field( + default_factory=lambda: { + "maxRunSeconds": 600, + "maxToolSeconds": 60, + "maxConcurrentRuns": 10, + "maxQueuedRuns": 80, + "maxRunsPerMinute": 60, + "maxEventsPerRun": 10000, + "maxOutputBytes": 8388608, + "maxSubscribersPerSession": 4, + } + ) + storage: dict[str, int] = field( + default_factory=lambda: { + "maxFileBytes": 10485760, + "maxArtifactBytes": 10485760, + "maxTurnArtifactBytes": 10485760, + "maxRetainedBytesPerOwner": 104857600, + } + ) + + def __post_init__(self) -> None: + if ( + not isinstance(self.revision, str) + or not self.revision + or not isinstance(self.template, str) + or not self.template + or not isinstance(self.models, dict) + or not self.models + or self.provider != "alicloud-express" + ): + raise ValueError( + "A revision, template and explicit supported Express/model " + "configuration are required" + ) + if any( + not isinstance(k, str) or not k or len(k) > 128 or v not in {"codex", "qwen-code"} + for k, v in self.models.items() + ): + raise ValueError("Only tested Codex/Qwen adapters may be configured") + if ( + not isinstance(self.workspace, str) + or not self.workspace.startswith("/") + or ".." in self.workspace.split("/") + or self.workspace == "/" + or "\x00" in self.workspace + ): + raise ValueError("The workspace must be a fixed absolute container directory") + for mapping in (self.sessions, self.limits, self.storage): + if any( + (not isinstance(v, int) or isinstance(v, bool)) or v < 1 for v in mapping.values() + ): + raise ValueError("Managed application budgets must be positive integers") + if ( + not isinstance(self.port, int) or isinstance(self.port, bool) + ) or not 1 <= self.port <= 65535: + raise ValueError("The bridge port must be 1-65535") + if self.sessions["maxConcurrentTurnsPerSession"] != 1: + raise ValueError("Managed Sessions execute one turn at a time") + if ( + not self.limits["maxToolSeconds"] + <= self.limits["maxRunSeconds"] + <= self.sessions["maxLifetimeSeconds"] + ): + raise ValueError("Tool and run budgets must fit the Session lifetime") + if not isinstance(self.workers, dict) or any( + not isinstance(k, str) or not isinstance(v, str) or not k or not v + for k, v in self.workers.items() + ): + raise ValueError("Worker revisions must map to explicit function identities") + if not isinstance(self.model_env_allowlist, list) or any( + v not in {"OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL"} + for v in self.model_env_allowlist + ): + raise ValueError("Only explicit model environment names may be allowed") + + @classmethod + def from_dict(cls, value: dict[str, Any]) -> ManagedConfig: + """Parse compiled, non-secret runtime configuration from the deployer.""" + allowed = { + "revision", + "template", + "models", + "applicationId", + "provider", + "workspace", + "port", + "workers", + "modelEnvAllowlist", + "sessions", + "limits", + "storage", + } + if set(value) - allowed: + raise ValueError("Unknown compiled managed configuration field") + kwargs: dict[str, Any] = { + "revision": value["revision"], + "template": value["template"], + "models": value["models"], + } + for wire, native in ( + ("applicationId", "application_id"), + ("provider", "provider"), + ("workspace", "workspace"), + ("port", "port"), + ("workers", "workers"), + ("modelEnvAllowlist", "model_env_allowlist"), + ): + if wire in value: + kwargs[native] = value[wire] + defaults = cls(**kwargs) + for key in ("sessions", "limits", "storage"): + if key in value: + if not isinstance(value[key], dict): + raise ValueError("Managed budgets must be objects") + permitted = set(getattr(defaults, key)) + policies = ( + { + "closePolicy": "release-compute-retain-results", + "deletePolicy": "remove-public-data-retain-private-cleanup", + "guestRestartPolicy": "freeze-and-reconcile", + } + if key == "sessions" + else {} + ) + if set(value[key]) - permitted - set(policies): + raise ValueError("Unknown managed budget field") + if any(k in value[key] and value[key][k] != v for k, v in policies.items()): + raise ValueError("Unsupported managed lifecycle policy") + mapping = {k: v for k, v in value[key].items() if k in permitted} + kwargs[key] = {**getattr(defaults, key), **mapping} + return cls(**kwargs) diff --git a/client-sdk/src/agent_sandbox/managed_agents/service.py b/client-sdk/src/agent_sandbox/managed_agents/service.py new file mode 100644 index 00000000..5dc853ca --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/service.py @@ -0,0 +1,1056 @@ +"""Owner-scoped managed Agents resources, acceptance and public projections.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import posixpath +import time +from typing import Any, cast + +from .models import ( + PROTOCOL, + SUBMISSION_TERMINAL, + ManagedConfig, + ManagedError, + canonical, + digest, + metadata, + reject_unknown, + text_input, +) +from .store import ManagedStore, Transaction, new_object + +OPERATIONS = ( + ("GET", "/healthz"), + ("GET", "/v1/agent-sandbox/capabilities"), + ("GET", "/v1/models"), + ("GET", "/v1/models/{model_id}"), + ("GET", "/v1/agents/runtimes"), + ("POST", "/v1/agents"), + ("GET", "/v1/agents"), + ("GET", "/v1/agents/{agent_id}"), + ("POST", "/v1/agents/{agent_id}"), + ("DELETE", "/v1/agents/{agent_id}"), + ("POST", "/v1/agents/sessions"), + ("GET", "/v1/agents/sessions"), + ("GET", "/v1/agents/sessions/{session_id}"), + ("POST", "/v1/agents/sessions/{session_id}"), + ("DELETE", "/v1/agents/sessions/{session_id}"), + ("POST", "/v1/agents/sessions/{session_id}/events"), + ("GET", "/v1/agents/sessions/{session_id}/events"), + ("GET", "/v1/agents/sessions/{session_id}/items"), + ("GET", "/v1/agents/sessions/{session_id}/turns"), + ("GET", "/v1/agents/sessions/{session_id}/turns/{turn_id}"), + ("GET", "/v1/agents/sessions/{session_id}/turns/{turn_id}/items"), + ("GET", "/v1/agents/sessions/{session_id}/artifacts"), + ("GET", "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}"), + ("GET", "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}/content"), + ("DELETE", "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}"), + ("POST", "/v1/files"), + ("GET", "/v1/files"), + ("GET", "/v1/files/{file_id}"), + ("GET", "/v1/files/{file_id}/content"), + ("DELETE", "/v1/files/{file_id}"), + ("GET", "/v1/agents/environments"), + ("GET", "/v1/agents/environments/{environment_id}"), + ("POST", "/v1/agent-sandbox/sessions/{session_id}/close"), + ("GET", "/v1/agent-sandbox/operations/{operation_id}"), + ("POST", "/v1/agent-sandbox/sessions/{session_id}/submissions"), + ("GET", "/v1/agent-sandbox/sessions/{session_id}/submissions"), + ("GET", "/v1/agent-sandbox/sessions/{session_id}/submissions/{submission_id}"), + ("POST", "/v1/agent-sandbox/sessions/{session_id}/submissions/{submission_id}/cancel"), + ("POST", "/v1/agent-sandbox/sessions/{session_id}/turns/{turn_id}/cancel"), +) + + +def public(row: dict[str, Any]) -> dict[str, Any]: + """Remove all private control/state fields before serializing a public object.""" + out = {k: v for k, v in row.items() if not k.startswith("_")} + if row.get("_kind") == "session" and isinstance(row.get("environment"), dict): + out["environment"] = { + **row["environment"], + "x_agent_sandbox": { + "lifecycle": row["x_agent_sandbox"]["lifecycle"], + "expires_at": row.get("_deadline"), + }, + } + return out + + +class ManagedService: + """Transactional control plane; no method starts work after HTTP response return.""" + + def __init__(self, store: ManagedStore, config: ManagedConfig) -> None: + self.store, self.config = store, config + + def capabilities(self) -> dict[str, Any]: + """Publish supported operations and honest adapter/stream/lifecycle limits.""" + return { + "protocol": PROTOCOL, + "revision": self.config.revision, + "operations": [{"method": m, "path": p} for m, p in OPERATIONS], + "adapters": sorted(set(self.config.models.values())), + "features": { + "steering": False, + "explicit_queue": True, + "functions": False, + "mcp": False, + "subagents": False, + "session_settings": False, + "streaming": "durable_public_events_with_explicit_native_gaps", + "guest_restart": "freeze_and_reconcile", + "input_types": ["input_text", "managed_file_reference"], + }, + "limits": {**self.config.limits, **self.config.storage, **self.config.sessions}, + } + + def control(self, tx: Transaction) -> dict[str, Any]: + """Read the durable application/revision admission fence.""" + return cast( + "dict[str, Any]", + json.loads( + tx.execute( + "SELECT body FROM managed_control WHERE application=?", (tx.application,) + ).fetchone()["body"] + ), + ) + + def check_admission(self, tx: Transaction, revision: str) -> None: + """Reject admission before resource allocation during global or revision drain.""" + control = self.control(tx) + if not control["accepting"] or revision in control["draining_revisions"]: + raise ManagedError(503, "application_draining", "This application revision is draining") + + def _idem(self, tx: Transaction, owner: str, scope: str, key: str, body: Any) -> Any: + if not key: + return None + if len(key) > 128 or not key.isascii() or any(ord(c) < 33 or ord(c) > 126 for c in key): + raise ManagedError( + 400, + "invalid_idempotency_key", + "Use 1-128 printable ASCII characters", + "Idempotency-Key", + ) + row = tx.execute( + "SELECT digest,result FROM managed_idempotency WHERE application=? AND " + "scope=? AND key=?", + (tx.application, owner + ":" + scope, key), + ).fetchone() + if row is None: + return None + if row["digest"] != digest(body): + raise ManagedError( + 409, + "idempotency.mismatch", + "The key belongs to another request body", + "Idempotency-Key", + ) + result = json.loads(row["result"]) + # A deleted resource must never be resurrected by a retained key. + if isinstance(result, dict) and result.get("id"): + tx.get(result["id"], owner) + return result + + def _remember( + self, tx: Transaction, owner: str, scope: str, key: str, body: Any, result: Any + ) -> None: + if key: + tx.execute( + "INSERT INTO managed_idempotency(application,scope,key,digest,result) " + "VALUES(?,?,?,?,?)", + (tx.application, owner + ":" + scope, key, digest(body), canonical(result)), + ) + + def _page( + self, rows: list[dict[str, Any]], owner: str, scope: str, query: dict[str, str] + ) -> dict[str, Any]: + reject_unknown(query, {"limit", "order", "after", "idempotency_key"}) + try: + limit = int(query.get("limit", "20")) + except ValueError as exc: + raise ManagedError(400, "invalid_limit", "limit must be 1-100", "limit") from exc + order = query.get("order", "desc") + if not 1 <= limit <= 100 or order not in {"asc", "desc"}: + raise ManagedError(400, "invalid_pagination", "Invalid pagination parameters") + rows = list(rows if order == "asc" else reversed(rows)) + after = query.get("after") + if after: + try: + if "." in after: + encoded, signature = after.split(".") + expected = hmac.new( + self.store.cursor_secret.encode(), encoded.encode(), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(signature, expected): + raise ValueError + cursor = json.loads( + base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + ) + if cursor[:3] != [owner, scope, order]: + raise ValueError + last_id = cursor[3] + else: + # SDK paginators pass the public last_id. It must be present + # in this already owner/scope-filtered collection. + last_id = after + index = next(i for i, r in enumerate(rows) if r["id"] == last_id) + rows = rows[index + 1 :] + except (ValueError, StopIteration, IndexError, TypeError) as exc: + raise ManagedError( + 400, "invalid_cursor", "Cursor does not belong to this collection", "after" + ) from exc + selected = rows[:limit] + + def cursor_for(row: dict[str, Any]) -> str: + data = ( + base64.urlsafe_b64encode(canonical([owner, scope, order, row["id"]]).encode()) + .decode() + .rstrip("=") + ) + return ( + data + + "." + + hmac.new( + self.store.cursor_secret.encode(), data.encode(), hashlib.sha256 + ).hexdigest() + ) + + return { + "object": "list", + "data": [public(r) for r in selected], + "first_id": selected[0]["id"] if selected else None, + "last_id": selected[-1]["id"] if selected else None, + "has_more": len(rows) > limit, + "x_agent_sandbox": { + "next_cursor": cursor_for(selected[-1]) if len(rows) > limit else None + }, + } + + def _agent( + self, body: dict[str, Any], existing: dict[str, Any] | None = None + ) -> dict[str, Any]: + reject_unknown( + body, {"name", "model", "instructions", "metadata", "tools", "x_agent_sandbox"} + ) + out = dict(existing or {}) + if "model" in body: + if body["model"] not in self.config.models: + raise ManagedError( + 400, "model_not_available", "Select a model from this deployment", "model" + ) + out["model"] = body["model"] + if not out.get("model"): + raise ManagedError(400, "model_required", "model is required", "model") + for key in ("name", "instructions"): + if key in body: + if body[key] is not None and ( + not isinstance(body[key], str) or len(body[key]) > 32768 + ): + raise ManagedError(400, "invalid_field", "Expected a bounded string", key) + out[key] = body[key] or "" + if "metadata" in body: + out["metadata"] = metadata(body["metadata"]) + out.setdefault("metadata", {}) + if body.get("tools") not in (None, []): + raise ManagedError( + 400, "unsupported_feature", "External tools are not enabled", "tools" + ) + extension = body.get("x_agent_sandbox", {}) + if not isinstance(extension, dict): + raise ManagedError(400, "invalid_extension", "Expected an object", "x_agent_sandbox") + reject_unknown(extension, {"adapter"}, "x_agent_sandbox.") + adapter = self.config.models[out["model"]] + if extension.get("adapter", adapter) != adapter: + raise ManagedError( + 400, + "adapter_mismatch", + "The model is bound to another adapter", + "x_agent_sandbox.adapter", + ) + out["tools"] = [] + out["x_agent_sandbox"] = {"adapter": adapter, "configuration_revision": digest(out)} + return out + + def _session(self, tx: Transaction, sid: str, owner: str) -> dict[str, Any]: + row = tx.get(sid, owner) + if row["_kind"] != "session": + raise ManagedError(404, "resource_not_found", "Session was not found") + if row.get("_retain_until", float("inf")) < time.time(): + raise ManagedError(410, "resource_expired", "Session results have expired") + return row + + def _attachments( + self, tx: Transaction, owner: str, body: dict[str, Any] + ) -> list[dict[str, Any]]: + extension = body.get("x_agent_sandbox", {}) + if not isinstance(extension, dict): + raise ManagedError(400, "invalid_extension", "Expected an object", "x_agent_sandbox") + reject_unknown(extension, {"input_files"}, "x_agent_sandbox.") + files = extension.get("input_files", []) + if not isinstance(files, list) or len(files) > 50: + raise ManagedError( + 400, + "invalid_files", + "Expected at most 50 file references", + "x_agent_sandbox.input_files", + ) + out, paths = [], set() + for ref in files: + if not isinstance(ref, dict) or set(ref) != {"file_id", "path"}: + raise ManagedError(400, "invalid_file_reference", "Expected file_id and path") + path = ref["path"] + root = self.config.workspace.rstrip("/") + "/input/" + if ( + not isinstance(path, str) + or not path.startswith(root) + or posixpath.normpath(path) != path + or "\x00" in path + or "\\" in path + or path in paths + ): + raise ManagedError( + 400, + "invalid_path", + "Use a unique path under the workspace input directory", + "path", + ) + paths.add(path) + file = tx.get(str(ref["file_id"]), owner) + if file["_kind"] != "file" or file.get("expires_at", 0) < time.time(): + raise ManagedError(400, "file_unavailable", "Input file is unavailable", "file_id") + out.append( + {"file_id": file["id"], "path": path, "sha256": file["x_agent_sandbox"]["sha256"]} + ) + return out + + def _enqueue( + self, + tx: Transaction, + session: dict[str, Any], + body: dict[str, Any], + key: str, + *, + queue: bool, + ) -> dict[str, Any]: + owner, sid = session["_owner"], session["id"] + scope = "enqueue:" + sid + old = self._idem(tx, owner, scope, key, body) + if old: + return tx.get(old["id"], owner) + self.check_admission(tx, session["_revision"]) + if session["x_agent_sandbox"]["lifecycle"] not in { + "unallocated", + "provisioning", + "ready", + } or session.get("_close_requested"): + raise ManagedError(409, "session_closed", "This Session cannot accept more work") + active = [ + r + for r in tx.list("submission", parent=sid, pending=True) + if r["status"] not in SUBMISSION_TERMINAL + ] + if active and not queue: + raise ManagedError( + 409, + "active_turn_not_steerable", + "Use explicit enqueue while this Session is busy", + "events", + ) + limits = session["_settings"]["limits"] + if len(active) >= session["_settings"]["sessions"]["maxQueuedTurnsPerSession"]: + raise ManagedError( + 429, "queue_full", "This Session's pending input budget is exhausted" + ) + all_pending = [ + r for r in tx.list("submission", pending=True) if r["status"] not in SUBMISSION_TERMINAL + ] + recent = [r for r in tx.list("submission", owner, recent_after=time.time() - 60)] + if len(all_pending) >= limits["maxQueuedRuns"] or len(recent) >= limits["maxRunsPerMinute"]: + raise ManagedError( + 429, "admission_limit", "The application admission budget is exhausted" + ) + if session.get("_deadline") and session["_deadline"] - time.time() < min( + 30, limits["maxRunSeconds"] + ): + raise ManagedError( + 409, "session_deadline", "Not enough Session lifetime remains for another turn" + ) + prompt = text_input(body.get("input")) + attachments = self._attachments(tx, owner, body) + row = new_object( + "submission", + owner, + parent=sid, + session_id=sid, + kind="message", + status="queued", + accepted_at=time.time(), + turn_id=None, + error=None, + _prompt=prompt, + _files=attachments, + _key=key, + _revision=session["_revision"], + _cancel=False, + ) + tx.put(row) + self._remember(tx, owner, scope, key, body, {"id": row["id"]}) + session["status"] = "in_progress" + session["last_active_at"] = time.time() + tx.event(session, "agent.session.submission.accepted", {"submission_id": row["id"]}) + return row + + def create_session( + self, owner: str, body: dict[str, Any], key: str = "" + ) -> tuple[dict[str, Any], str | None]: + """Atomically create a logical Session and its optional initial submission.""" + reject_unknown( + body, + {"agent_id", "agent", "environment", "metadata", "input", "stream", "x_agent_sandbox"}, + ) + if "stream" in body and not isinstance(body["stream"], bool): + raise ManagedError(400, "invalid_stream", "stream must be boolean", "stream") + with self.store.transaction() as tx: + old = self._idem(tx, owner, "create-session", key, body) + if old: + row = self._session(tx, old["id"], owner) + return public(row), old.get("submission_id") + self.check_admission(tx, self.config.revision) + if len(tx.list("session", owner)) >= self.config.sessions["maxLogicalSessionsPerOwner"]: + raise ManagedError(429, "session_limit", "The logical Session budget is exhausted") + saved = tx.get(str(body["agent_id"]), owner) if body.get("agent_id") else None + if saved and saved["_kind"] != "agent": + raise ManagedError(404, "resource_not_found", "Agent was not found") + agent = self._agent(body.get("agent", {}), public(saved) if saved else None) + environment = body.get("environment", {}) + if not isinstance(environment, dict): + raise ManagedError(400, "invalid_environment", "Expected an environment object") + reject_unknown(environment, {"type", "workspace_directory"}, "environment.") + if ( + environment.get("type", "self_hosted") != "self_hosted" + or environment.get("workspace_directory", self.config.workspace) + != self.config.workspace + ): + raise ManagedError( + 400, + "unsupported_environment", + "Use this deployment's fixed self-hosted workspace", + "environment", + ) + row = new_object( + "session", + owner, + agent=agent, + status="idle", + metadata=metadata(body.get("metadata")), + last_active_at=time.time(), + usage=None, + error=None, + x_agent_sandbox={"lifecycle": "unallocated", "revision": self.config.revision}, + _revision=self.config.revision, + _seq=0, + _resource=None, + _close_requested=False, + _settings={ + "template": self.config.template, + "workspace": self.config.workspace, + "port": self.config.port, + "modelEnvAllowlist": self.config.model_env_allowlist, + "sessions": self.config.sessions, + "limits": self.config.limits, + "storage": self.config.storage, + }, + ) + env = new_object( + "environment", + owner, + parent=row["id"], + type="self_hosted", + workspace_directory=self.config.workspace, + x_agent_sandbox={"lifecycle": "unallocated"}, + ) + row["environment"] = public(env) + tx.put(env) + tx.event(row, "agent.session.created", {"session": public(row)}) + submission = None + if "input" in body: + submission = self._enqueue( + tx, + row, + {"input": body["input"], "x_agent_sandbox": body.get("x_agent_sandbox", {})}, + "", + queue=True, + ) + elif body.get("x_agent_sandbox"): + # Attachments are meaningful only with an immutable accepted input. + raise ManagedError( + 400, "input_required", "File references require initial input", "input" + ) + self._remember( + tx, + owner, + "create-session", + key, + body, + {"id": row["id"], "submission_id": submission["id"] if submission else None}, + ) + return public(row), submission["id"] if submission else None + + def upload_file( + self, owner: str, filename: str, purpose: str, content: bytes, key: str = "" + ) -> dict[str, Any]: + """Seal bounded input bytes; upload never writes an active workspace.""" + if purpose != "user_data" or not filename or len(filename) > 255 or "\x00" in filename: + raise ManagedError(400, "invalid_file", "Use a bounded filename and purpose=user_data") + if len(content) > self.config.storage["maxFileBytes"]: + raise ManagedError(413, "file_too_large", "Input file exceeds the deployment budget") + body = { + "filename": filename, + "purpose": purpose, + "sha256": hashlib.sha256(content).hexdigest(), + } + with self.store.transaction() as tx: + old = self._idem(tx, owner, "upload-file", key, body) + if old: + return public(tx.get(old["id"], owner)) + self.check_storage(tx, owner, len(content)) + row = new_object( + "file", + owner, + filename=filename, + purpose=purpose, + bytes=len(content), + expires_at=time.time() + self.config.sessions["resultRetentionSeconds"], + x_agent_sandbox={"sha256": body["sha256"]}, + _data=base64.b64encode(content).decode(), + ) + row["object"] = "file" + tx.put(row) + self._remember(tx, owner, "upload-file", key, body, {"id": row["id"]}) + return public(row) + + def check_storage(self, tx: Transaction, owner: str, extra: int) -> None: + """Count retained private bytes too, so tombstones cannot bypass quota.""" + total = sum( + r.get("bytes", 0) + for kind in ("file", "artifact") + for r in tx.list(kind, owner, deleted=True, has_blob=True) + if r.get("_has_blob") + ) + if total + extra > self.config.storage["maxRetainedBytesPerOwner"]: + raise ManagedError(429, "storage_limit", "Retained storage budget is exhausted") + + def collect(self, tx: Transaction, limit: int = 100) -> int: + """Collect bounded result batches without scanning retained tombstone history.""" + now = time.time() + pending_files = { + ref["file_id"] + for sub in tx.list("submission", pending=True) + for ref in sub.get("_files", []) + } + rows = tx.execute( + "SELECT body FROM managed_objects " # nosec B608 - fixed fields; values bound + "WHERE application=? AND kind IN ('file','artifact')" + " AND CAST(COALESCE(" + tx.field("_has_blob") + ", 'false') AS TEXT) IN ('true','1')" + " AND (CAST(" + tx.field("expires_at") + " AS DOUBLE PRECISION)?" + " ORDER BY id LIMIT ?", + (tx.application, session["id"], session.get("_collect_cursor", ""), limit), + ).fetchall() + for child in children: + row = json.loads(child["body"]) + tx.purge_blob(row) + for field in ("content", "_prompt", "_files", "agent"): + row.pop(field, None) + row["_expired"], row["_deleted"] = True, True + tx.put(row) + session["_collect_cursor"] = row["id"] + collected += 1 + first = tx.execute( + "SELECT MIN(seq) AS first FROM managed_events WHERE application=? AND session_id=?", + (tx.application, session["id"]), + ).fetchone()["first"] + if first is not None: + tx.execute( + "DELETE FROM managed_events WHERE application=? AND session_id=? AND seq dict[str, Any]: + previous = submission.get("_cancel_operation") + if previous: + return tx.get(previous, session["_owner"]) + op = new_object( + "operation", + session["_owner"], + parent=session["id"], + kind="cancel", + status="queued", + result=None, + error=None, + _submission=submission["id"], + _revision=session["_revision"], + ) + submission["_cancel_operation"] = op["id"] + if submission["status"] in SUBMISSION_TERMINAL: + op.update( + status="requires_reconcile" if submission["status"] == "uncertain" else "succeeded", + result={"stop_confirmed": submission["status"] != "uncertain"}, + ) + elif submission["status"] == "queued": + submission["status"] = "cancelled" + op.update(status="succeeded", result={"stop_confirmed": True}) + tx.event( + session, "agent.session.submission.cancelled", {"submission_id": submission["id"]} + ) + else: + submission["_cancel"] = True + submission["status"] = "cancelling" + tx.put(submission) + tx.put(op) + return op + + def _close(self, tx: Transaction, session: dict[str, Any]) -> dict[str, Any]: + if session.get("_close_operation"): + return tx.get(session["_close_operation"], session["_owner"]) + op = new_object( + "operation", + session["_owner"], + parent=session["id"], + kind="close", + status="queued", + result=None, + error=None, + _revision=session["_revision"], + ) + session["_close_operation"], session["_close_requested"] = op["id"], True + session["x_agent_sandbox"]["lifecycle"] = "closing" + for sub in tx.list("submission", parent=session["id"]): + if sub["status"] not in SUBMISSION_TERMINAL: + self._cancel(tx, session, sub) + tx.put(op) + tx.event(session, "agent.session.closing", {}) + return op + + def handle( + self, + owner: str, + method: str, + path: str, + body: dict[str, Any] | None = None, + query: dict[str, str] | None = None, + key: str = "", + ) -> tuple[int, Any, dict[str, str]]: + """Apply the stream/deletion gate before taking the application transaction.""" + parts = path.strip("/").split("/") + if method == "DELETE" and len(parts) == 4 and parts[:3] == ["v1", "agents", "sessions"]: + with self.store.session_gate(parts[3]): + return self._handle(owner, method, path, body, query, key) + return self._handle(owner, method, path, body, query, key) + + def _handle( + self, + owner: str, + method: str, + path: str, + body: dict[str, Any] | None = None, + query: dict[str, str] | None = None, + key: str = "", + ) -> tuple[int, Any, dict[str, str]]: + """Handle one finite business operation; streaming is separately subscribed.""" + body, query = body or {}, query or {} + parts = path.strip("/").split("/") + if method == "GET" and path == "/v1/agent-sandbox/capabilities": + return 200, self.capabilities(), {} + if method == "GET" and path in {"/v1/models", "/v1/agents/runtimes"}: + data = ( + [ + {"id": model, "object": "model", "owned_by": "agent-sandbox-managed"} + for model in self.config.models + ] + if path == "/v1/models" + else [ + {"id": name, "object": "agent.runtime", "implemented": True, "installed": True} + for name in sorted(set(self.config.models.values())) + ] + ) + return 200, {"object": "list", "data": data}, {} + if method == "GET" and len(parts) == 3 and parts[:2] == ["v1", "models"]: + if parts[2] not in self.config.models: + raise ManagedError(404, "model_not_found", "Model was not found") + return 200, {"id": parts[2], "object": "model", "owned_by": "agent-sandbox-managed"}, {} + if path == "/v1/agents/sessions" and method == "POST": + row, subid = self.create_session(owner, body, key) + headers = {"Location": "/v1/agents/sessions/" + row["id"]} + if subid: + headers["X-Agent-Sandbox-Submission-Id"] = subid + return 201, row, headers + with self.store.transaction() as tx: + if path == "/v1/agents": + if method == "GET": + return 200, self._page(tx.list("agent", owner), owner, "agents", query), {} + if method == "POST": + old = self._idem(tx, owner, "create-agent", key, body) + if old: + return 201, public(tx.get(old["id"], owner)), {} + agent = new_object("agent", owner, **self._agent(body)) + tx.put(agent) + self._remember(tx, owner, "create-agent", key, body, {"id": agent["id"]}) + return 201, public(agent), {} + if ( + len(parts) == 3 + and parts[:2] == ["v1", "agents"] + and parts[2] not in {"sessions", "environments", "runtimes"} + ): + row = tx.get(parts[2], owner) + if row["_kind"] != "agent": + raise ManagedError(404, "resource_not_found", "Agent was not found") + if method == "GET": + return 200, public(row), {} + if method == "POST": + row.update(self._agent(body, row)) + tx.put(row) + return 200, public(row), {} + if method == "DELETE": + row["_deleted"] = True + tx.put(row) + return 200, {"id": row["id"], "object": "agent.deleted", "deleted": True}, {} + if ( + path in {"/v1/agents/sessions", "/v1/agents/environments", "/v1/files"} + and method == "GET" + ): + kind = {"sessions": "session", "environments": "environment", "files": "file"}[ + parts[-1] + ] + rows = tx.list(kind, owner) + if kind == "environment": + rows = [ + r + for r in rows + if not tx.get(r["_parent"], owner, deleted=True).get("_deleted") + ] + for row in rows: + parent = self._session(tx, row["_parent"], owner) + row["x_agent_sandbox"] = { + "lifecycle": parent["x_agent_sandbox"]["lifecycle"], + "expires_at": parent.get("_deadline"), + } + return 200, self._page(rows, owner, kind, query), {} + if len(parts) >= 3 and parts[:2] == ["v1", "files"]: + row = tx.get(parts[2], owner) + if row["_kind"] != "file": + raise ManagedError(404, "resource_not_found", "File was not found") + if row.get("expires_at", 0) <= time.time(): + raise ManagedError(410, "resource_expired", "File content has expired") + if method == "GET" and len(parts) == 3: + return 200, public(row), {} + if method == "GET" and parts[3:] == ["content"]: + blob = tx.get(row["id"], owner, with_data=True) + return ( + 200, + base64.b64decode(blob["_data"]), + {"Content-Type": "application/octet-stream"}, + ) + if method == "DELETE" and len(parts) == 3: + row["_deleted"] = True + tx.put(row) + return 200, {"id": row["id"], "object": "file.deleted", "deleted": True}, {} + if ( + len(parts) == 4 + and parts[:3] == ["v1", "agents", "environments"] + and method == "GET" + ): + row = tx.get(parts[3], owner) + if row["_kind"] != "environment": + raise ManagedError(404, "resource_not_found", "Environment was not found") + parent = self._session(tx, row["_parent"], owner) + row["x_agent_sandbox"] = { + "lifecycle": parent["x_agent_sandbox"]["lifecycle"], + "expires_at": parent.get("_deadline"), + } + return 200, public(row), {} + if ( + len(parts) == 4 + and parts[:3] == ["v1", "agent-sandbox", "operations"] + and method == "GET" + ): + row = tx.get(parts[3], owner) + if row["_kind"] != "operation": + raise ManagedError(404, "resource_not_found", "Operation was not found") + return 200, public(row), {} + managed = parts[:3] == ["v1", "agent-sandbox", "sessions"] + standard = parts[:3] == ["v1", "agents", "sessions"] + if (managed or standard) and len(parts) >= 4: + session = self._session(tx, parts[3], owner) + tail = parts[4:] + if standard and not tail: + if method == "GET": + return 200, public(session), {} + if method == "POST": + reject_unknown(body, {"metadata"}) + if "metadata" in body: + session["metadata"] = metadata(body["metadata"]) + tx.put(session) + return 200, public(session), {} + if method == "DELETE": + pending = [ + r + for r in tx.list("submission", parent=session["id"]) + if r["status"] not in SUBMISSION_TERMINAL + ] + if pending or session["x_agent_sandbox"]["lifecycle"] == "closing": + raise ManagedError( + 409, "active_session", "Cancel or close active work before deletion" + ) + op = self._close(tx, session) + op["kind"] = "delete" + tx.put(op) + session["_deleted"] = True + # Purge public payloads while preserving private ownership/cleanup. + for kind in ("turn", "item", "artifact", "submission", "environment"): + for row in tx.list(kind, owner, parent=session["id"]): + row["_deleted"] = True + if kind in {"artifact", "item"}: + tx.purge_blob(row) + row.pop("content", None) + tx.put(row) + tx.put(session) + return ( + 200, + { + "id": session["id"], + "object": "agent.session.deleted", + "deleted": True, + }, + { + "X-Agent-Sandbox-Operation-Id": op["id"], + "Location": "/v1/agent-sandbox/operations/" + op["id"], + }, + ) + if standard and tail == ["events"] and method == "POST": + reject_unknown(body, {"events", "input", "x_agent_sandbox"}) + if "events" in body: + events = body["events"] + if ( + not isinstance(events, list) + or len(events) != 1 + or not isinstance(events[0], dict) + ): + raise ManagedError( + 400, "invalid_events", "Submit exactly one event", "events" + ) + event = events[0] + reject_unknown(event, {"type", "input"}, "events[0].") + if event.get("type") == "agent.session.input.cancel": + scope = "cancel:" + session["id"] + old = self._idem(tx, owner, scope, key, body) + if old is not None: + return ( + 202, + None, + ( + {"X-Agent-Sandbox-Operation-Id": old["operation_id"]} + if old.get("operation_id") + else {} + ), + ) + pending = [ + r + for r in tx.list("submission", parent=session["id"]) + if r["status"] not in SUBMISSION_TERMINAL and r.get("turn_id") + ] + cancel_op = self._cancel(tx, session, pending[0]) if pending else None + self._remember( + tx, + owner, + scope, + key, + body, + {"operation_id": cancel_op["id"] if cancel_op else None}, + ) + return ( + 202, + None, + {"X-Agent-Sandbox-Operation-Id": cancel_op["id"]} + if cancel_op + else {}, + ) + if event.get("type") != "agent.session.input.message": + raise ManagedError( + 400, + "unsupported_feature", + "This event type is not supported", + "events[0].type", + ) + prompt = event.get("input") + else: + prompt = body.get("input") + sub = self._enqueue( + tx, + session, + {"input": prompt, "x_agent_sandbox": body.get("x_agent_sandbox", {})}, + key, + queue=False, + ) + return ( + 202, + None, + { + "X-Agent-Sandbox-Submission-Id": sub["id"], + "Location": ( + f"/v1/agent-sandbox/sessions/{session['id']}" + f"/submissions/{sub['id']}" + ), + }, + ) + if managed and tail == ["close"] and method == "POST": + reject_unknown(body, set()) + op = self._close(tx, session) + return 202, public(op), {"Location": "/v1/agent-sandbox/operations/" + op["id"]} + if managed and tail == ["submissions"]: + if method == "POST": + reject_unknown(body, {"input", "x_agent_sandbox"}) + if not key: + raise ManagedError( + 400, + "idempotency_key_required", + "Explicit enqueue requires Idempotency-Key", + ) + return 202, public(self._enqueue(tx, session, body, key, queue=True)), {} + if method == "GET": + rows = tx.list("submission", owner, session["id"]) + if "idempotency_key" in query: + rows = [r for r in rows if r.get("_key") == query["idempotency_key"]] + return ( + 200, + self._page(rows, owner, "submissions:" + session["id"], query), + {}, + ) + if managed and len(tail) >= 2 and tail[0] in {"submissions", "turns"}: + row = tx.get(tail[1], owner) + expected = "submission" if tail[0] == "submissions" else "turn" + if row.get("_parent") != session["id"] or row["_kind"] != expected: + raise ManagedError(404, "resource_not_found", "Resource was not found") + if tail[0] == "submissions" and len(tail) == 2 and method == "GET": + return 200, public(row), {} + if tail[2:] == ["cancel"] and method == "POST": + reject_unknown(body, set()) + if tail[0] == "turns": + row = tx.get(row["_submission"], owner) + return 202, public(self._cancel(tx, session, row)), {} + if standard and tail and tail[0] in {"items", "turns", "artifacts"}: + kind = {"items": "item", "turns": "turn", "artifacts": "artifact"}[tail[0]] + if len(tail) == 1 and method == "GET": + return ( + 200, + self._page( + tx.list(kind, owner, session["id"]), + owner, + kind + ":" + session["id"], + query, + ), + {}, + ) + if len(tail) >= 2: + row = tx.get(tail[1], owner) + if row.get("_parent") != session["id"] or row["_kind"] != kind: + raise ManagedError(404, "resource_not_found", "Resource was not found") + if row.get("expires_at", float("inf")) <= time.time(): + raise ManagedError(410, "resource_expired", "Stored result has expired") + if len(tail) == 2 and method == "GET": + return 200, public(row), {} + if kind == "turn" and tail[2:] == ["items"] and method == "GET": + rows = [ + r + for r in tx.list("item", owner, session["id"]) + if r.get("turn_id") == row["id"] + ] + return ( + 200, + self._page(rows, owner, "turn-items:" + row["id"], query), + {}, + ) + if kind == "artifact" and tail[2:] == ["content"] and method == "GET": + blob = tx.get(row["id"], owner, with_data=True) + return ( + 200, + base64.b64decode(blob["_data"]), + { + "Content-Type": "application/octet-stream", + "Content-Disposition": "attachment", + }, + ) + if kind == "artifact" and len(tail) == 2 and method == "DELETE": + row["_deleted"] = True + tx.purge_blob(row) + tx.put(row) + return ( + 200, + { + "id": row["id"], + "object": "agent.session.artifact.deleted", + "deleted": True, + }, + {}, + ) + raise ManagedError(404, "endpoint_not_available", "This endpoint is not available") + + def events(self, owner: str, sid: str, cursor: str | None = None) -> list[dict[str, Any]]: + """Read durable public events with a Session-bound cursor.""" + with self.store.transaction() as tx: + session = self._session(tx, sid, owner) + prefix = "evt_" + sid + "_" + if cursor is None or cursor == "0": + seq = 0 + elif cursor.startswith(prefix) and cursor[len(prefix) :].isdigit(): + seq = int(cursor[len(prefix) :]) + else: + raise ManagedError( + 400, "invalid_cursor", "Event cursor belongs to another Session", "after" + ) + if seq > session.get("_seq", 0): + raise ManagedError( + 400, "invalid_cursor", "Event cursor is ahead of this Session", "after" + ) + return tx.events(sid, seq) diff --git a/client-sdk/src/agent_sandbox/managed_agents/store.py b/client-sdk/src/agent_sandbox/managed_agents/store.py new file mode 100644 index 00000000..622738d9 --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/store.py @@ -0,0 +1,351 @@ +"""Transactional managed state. SQLite is explicitly local; FC requires PostgreSQL.""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import secrets +import sqlite3 +import time +import uuid +from typing import TYPE_CHECKING, Any + +from .models import ManagedError, canonical + +if TYPE_CHECKING: + import builtins + from collections.abc import Iterator + + +class Transaction: + """A transaction holding the application admission/ownership lock.""" + + def __init__(self, db: Any, sqlite: bool, application: str) -> None: + self.db, self.sqlite, self.application = db, sqlite, application + + def execute(self, sql: str, args: tuple[Any, ...] = ()) -> Any: + """Execute portable bound SQL, never interpolating business values.""" + return self.db.execute(sql if self.sqlite else sql.replace("?", "%s"), args) + + def field(self, name: str) -> str: + """Build fixed internal JSON field projections for indexed/bounded queries.""" + if not all(part.replace("_", "").isalnum() for part in name.split(".")): + raise ValueError("Invalid internal field") + if self.sqlite: + return "json_extract(body,'$." + name + "')" + parts = name.split(".") + return "body::jsonb" + "".join( + ("->>" if index == len(parts) - 1 else "->") + "'" + part + "'" + for index, part in enumerate(parts) + ) + + def get( + self, rid: str, owner: str | None = None, *, deleted: bool = False, with_data: bool = False + ) -> dict[str, Any]: + """Read one application-owned object, concealing foreign resource identities.""" + row = self.execute( + "SELECT body FROM managed_objects WHERE application=? AND id=?", (self.application, rid) + ).fetchone() + if row is None: + raise ManagedError(404, "resource_not_found", "Resource was not found") + data: dict[str, Any] = json.loads(row["body"]) + if (owner is not None and data["_owner"] != owner) or ( + data.get("_deleted") and not deleted + ): + raise ManagedError(404, "resource_not_found", "Resource was not found") + if with_data and data.get("_has_blob"): + blob = self.execute( + "SELECT data FROM managed_blobs WHERE application=? AND id=?", + (self.application, rid), + ).fetchone() + if blob is None: + raise ManagedError(503, "content_unavailable", "The stored content is unavailable") + data["_data"] = blob["data"] + return data + + def put(self, row: dict[str, Any]) -> None: + """Create or atomically update an owned JSON record.""" + row["_updated"] = time.time() + encoded = row.pop("_data", None) + if encoded is not None: + self.execute( + "INSERT INTO managed_blobs(application,id,data) VALUES(?,?,?) ON " + "CONFLICT(application,id) DO UPDATE SET data=excluded.data", + (self.application, row["id"], encoded), + ) + row["_has_blob"] = True + self.execute( + "INSERT INTO managed_objects(application,id,kind,parent,owner,body) " + "VALUES(?,?,?,?,?,?) ON CONFLICT(application,id) DO UPDATE SET " + "body=excluded.body", + ( + self.application, + row["id"], + row["_kind"], + row.get("_parent", ""), + row["_owner"], + canonical(row), + ), + ) + + def purge_blob(self, row: dict[str, Any]) -> None: + """Remove private bytes without removing the ownership tombstone.""" + self.execute( + "DELETE FROM managed_blobs WHERE application=? AND id=?", (self.application, row["id"]) + ) + row.pop("_data", None) + row["_has_blob"] = False + + def list( + self, + kind: str, + owner: str | None = None, + parent: str | None = None, + *, + deleted: bool = False, + pending: bool = False, + work: bool = False, + occupied: bool = False, + has_blob: bool = False, + recent_after: float | None = None, + ) -> list[dict[str, Any]]: + """Read a bounded application resource family under the same lock.""" + sql = "SELECT body FROM managed_objects WHERE application=? AND kind=?" + args: list[Any] = [self.application, kind] + if owner is not None: + sql += " AND owner=?" + args.append(owner) + if parent is not None: + sql += " AND parent=?" + args.append(parent) + if not deleted: + sql += ( + " AND COALESCE(" + self.field("_deleted") + ", 'false') NOT IN ('true', '1', 1)" + if self.sqlite + else (" AND COALESCE(" + self.field("_deleted") + ", 'false') <> 'true'") + ) + if pending: + sql += ( + " AND " + + self.field("status") + + " NOT IN ('completed','failed','cancelled','uncertain')" + ) + if not deleted: + sql += ( + " AND CAST(COALESCE(" + + self.field("_expired") + + ", 'false') AS TEXT) NOT IN ('true','1')" + ) + if work: + sql += ( + " AND (" + self.field("x_agent_sandbox.lifecycle") + " NOT IN ('closed','expired')" + ) + sql += " OR " + self.field("_resource.sandbox_id") + " IS NOT NULL)" + if occupied: + sql += " AND (" + self.field("_resource.sandbox_id") + " IS NOT NULL OR " + sql += ( + "CAST(COALESCE(" + + self.field("_creation_started") + + ", 'false') AS TEXT) IN ('true','1'))" + ) + if has_blob: + sql += ( + " AND CAST(COALESCE(" + + self.field("_has_blob") + + ", 'false') AS TEXT) IN ('true','1')" + ) + if recent_after is not None: + sql += " AND CAST(" + self.field("created_at") + " AS DOUBLE PRECISION)>=?" + args.append(recent_after) + rows = self.execute(sql + " LIMIT 10001", tuple(args)).fetchall() + if len(rows) > 10000: + raise ManagedError( + 429, "collection_limit", "Collection exceeds the configured local query budget" + ) + out = [json.loads(r["body"]) for r in rows] + return sorted( + ( + r + for r in out + if (owner is None or r["_owner"] == owner) + and (parent is None or r.get("_parent") == parent) + and (deleted or not r.get("_deleted")) + ), + key=lambda r: (r["created_at"], r["id"]), + ) + + def event(self, session: dict[str, Any], kind: str, data: dict[str, Any]) -> None: + """Commit an immutable public event before it can be delivered to SSE.""" + session["_seq"] = session.get("_seq", 0) + 1 + event = { + "type": kind, + "event_id": f"evt_{session['id']}_{session['_seq']}", + "session_id": session["id"], + "created_at": time.time(), + **data, + } + self.execute( + "INSERT INTO managed_events(application,session_id,seq,body) VALUES(?,?,?,?)", + (self.application, session["id"], session["_seq"], canonical(event)), + ) + self.put(session) + + def events(self, sid: str, after: int) -> builtins.list[dict[str, Any]]: + """Read the already committed event sequence.""" + rows = self.execute( + "SELECT body FROM managed_events WHERE application=? AND session_id=? AND " + "seq>? ORDER BY seq LIMIT 256", + (self.application, sid, after), + ).fetchall() + return [json.loads(r["body"]) for r in rows] + + +def new_object( + resource_kind: str, owner: str, *, parent: str = "", **fields: Any +) -> dict[str, Any]: + """Build a private record; public projection must explicitly remove private fields.""" + prefix = {"session": "sess", "submission": "sub", "operation": "op", "environment": "env"}.get( + resource_kind, resource_kind + ) + return { + "id": f"{prefix}_{uuid.uuid4().hex}", + "object": { + "agent": "agent", + "turn": "agent.session.turn", + "artifact": "agent.session.artifact", + "file": "file", + }.get(resource_kind, f"agent.{resource_kind}"), + "created_at": time.time(), + "_kind": resource_kind, + "_owner": owner, + "_parent": parent, + **fields, + } + + +class ManagedStore: + """A fixed application database with a cross-process admission lock.""" + + def __init__(self, url: str, application: str = "agents-api", *, local: bool = False) -> None: + self.url, self.application = url, application + self.sqlite = url.startswith("sqlite:///") + if self.sqlite: + if ( + not local + or os.getenv("FC_FUNCTION_NAME") + or os.getenv("FC_INSTANCE_ID") + or url == "sqlite:///:memory:" + ): + raise ValueError("File-backed SQLite requires explicit non-FC local mode") + elif not url.startswith(("postgres://", "postgresql://")): + raise ValueError("Managed storage requires PostgreSQL or explicit local SQLite") + with self.transaction(initialize=True) as tx: + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_meta(application TEXT PRIMARY KEY, " + "secret TEXT NOT NULL)" + ) + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_control(application TEXT PRIMARY " + "KEY, body TEXT NOT NULL)" + ) + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_objects(application TEXT NOT " + "NULL,id TEXT NOT NULL,kind TEXT NOT NULL,parent TEXT NOT NULL,owner " + "TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(application,id))" + ) + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_events(application TEXT NOT " + "NULL,session_id TEXT NOT NULL,seq INTEGER NOT NULL,body TEXT NOT " + "NULL,PRIMARY KEY(application,session_id,seq))" + ) + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_blobs(application TEXT NOT NULL,id " + "TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(application,id))" + ) + tx.execute( + "CREATE INDEX IF NOT EXISTS managed_objects_owner_parent ON " + "managed_objects(application,kind,owner,parent)" + ) + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_idempotency(application TEXT NOT " + "NULL,scope TEXT NOT NULL,key TEXT NOT NULL,digest TEXT NOT NULL,result" + " TEXT NOT NULL,PRIMARY KEY(application,scope,key))" + ) + tx.execute( + "INSERT INTO managed_meta(application,secret) VALUES(?,?) ON " + "CONFLICT(application) DO NOTHING", + (application, secrets.token_hex(32)), + ) + tx.execute( + "INSERT INTO managed_control(application,body) VALUES(?,?) ON " + "CONFLICT(application) DO NOTHING", + (application, canonical({"accepting": True, "draining_revisions": []})), + ) + self.cursor_secret = tx.execute( + "SELECT secret FROM managed_meta WHERE application=?", (application,) + ).fetchone()["secret"] + + @contextlib.contextmanager + def session_gate(self, sid: str) -> Iterator[None]: + """Order frame writes and deletion across processes without holding the app lock.""" + identity = hashlib.sha256((self.application + ":stream:" + sid).encode()).digest() + if self.sqlite: + import fcntl + + path = self.url[len("sqlite:///") :] + ".stream-gate" + descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + deadline = time.monotonic() + 10 + while True: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise ManagedError( + 503, "stream_gate_busy", "Try the operation again" + ) from None + time.sleep(0.01) + yield + finally: + os.close(descriptor) + else: + import psycopg + + with psycopg.connect(self.url, autocommit=True, connect_timeout=10) as db: + db.execute("SET statement_timeout='10000ms'") + key = int.from_bytes(identity[:8], "big", signed=True) + db.execute("SELECT pg_advisory_lock(%s)", (key,)) + try: + yield + finally: + db.execute("SELECT pg_advisory_unlock(%s)", (key,)) + + @contextlib.contextmanager + def transaction(self, *, initialize: bool = False) -> Iterator[Transaction]: + """Serialize application mutation across Gateway, Worker and Dispatcher.""" + if self.sqlite: + db: Any = sqlite3.connect(self.url[len("sqlite:///") :], timeout=30) + db.row_factory = sqlite3.Row + db.execute("BEGIN IMMEDIATE") + else: + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as exc: + raise RuntimeError("Install agent-sandbox-client[managed-agents]") from exc + db = psycopg.connect(self.url, row_factory=dict_row, connect_timeout=10) + lock = int.from_bytes( + hashlib.sha256(self.application.encode()).digest()[:8], "big", signed=True + ) + db.execute("SELECT pg_advisory_xact_lock(%s)", (lock,)) + try: + yield Transaction(db, self.sqlite, self.application) + db.commit() + except BaseException: + db.rollback() + raise + finally: + db.close() diff --git a/client-sdk/src/agent_sandbox/managed_agents/worker.py b/client-sdk/src/agent_sandbox/managed_agents/worker.py new file mode 100644 index 00000000..a5ce6d9e --- /dev/null +++ b/client-sdk/src/agent_sandbox/managed_agents/worker.py @@ -0,0 +1,649 @@ +"""Bounded, claim-fenced Session execution, observation and owned cleanup.""" + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +import hashlib +import secrets +import time +from typing import Any + +from .backend import bridge_request +from .models import SUBMISSION_TERMINAL, ManagedError, canonical +from .service import ManagedService, public +from .store import new_object + + +class ManagedWorker: + """Run finite invocations; replacing an observer never authorizes input replay.""" + + def __init__( + self, + service: ManagedService, + backend: Any, + *, + poll_seconds: float = 0.25, + lease_seconds: float = 45, + ) -> None: + self.service, self.store, self.config = service, service.store, service.config + self.backend, self.poll_seconds = backend, poll_seconds + if lease_seconds < 0.1: + raise ValueError("The observation lease must be at least 0.1 seconds") + self.lease_seconds = lease_seconds + + def _owned(self, sid: str, claim: str) -> dict[str, Any]: + with self.store.transaction() as tx: + row = tx.get(sid, deleted=True) + if row.get("_claim") != claim or row.get("_claim_until", 0) <= time.time(): + raise ManagedError(409, "claim_changed", "Observation ownership changed") + if row["x_agent_sandbox"]["lifecycle"] == "requires_reconcile" and not row.get( + "_close_requested" + ): + raise ManagedError(409, "claim_changed", "The Session is frozen for reconciliation") + return row + + async def _heartbeat(self, sid: str, claim: str) -> None: + while True: + await asyncio.sleep(self.lease_seconds / 3) + with self.store.transaction() as tx: + row = tx.get(sid, deleted=True) + if row.get("_claim") != claim or row.get("_claim_until", 0) <= time.time(): + return + row["_claim_until"] = time.time() + self.lease_seconds + tx.put(row) + + def _uncertain(self, sid: str, subid: str | None, claim: str, code: str) -> None: + with self.store.transaction() as tx: + row = tx.get(sid, deleted=True) + if row.get("_claim") != claim: + return + row["x_agent_sandbox"]["lifecycle"] = "requires_reconcile" + row["status"], row["error"] = ( + "failed", + {"code": code, "message": "Execution ownership requires reconciliation"}, + ) + if subid: + sub = tx.get(subid, deleted=True) + sub.update(status="uncertain", error={"code": code}) + tx.put(sub) + tx.event(row, "agent.session.failed", {"error": row["error"]}) + + async def run(self, sid: str, submission_id: str | None = None) -> bool: + """Claim one Session, execute/observe one input, or advance its close operation.""" + claim = secrets.token_hex(16) + sub: dict[str, Any] | None = None + with self.store.transaction() as tx: + session = tx.get(sid, deleted=True) + if session["_revision"] != self.config.revision: + raise ManagedError( + 409, + "worker_revision_mismatch", + "This Session belongs to another Worker revision", + ) + if session["x_agent_sandbox"]["lifecycle"] in { + "requires_reconcile", + "closed", + "expired", + } and not session.get("_close_requested"): + return False + if session.get("_claim_until", 0) > time.time(): + return False + concurrent = [ + r + for r in tx.list("session", deleted=True, work=True) + if r.get("_claim_until", 0) > time.time() + ] + if len(concurrent) >= self.config.limits["maxConcurrentRuns"]: + return False + if ( + session.get("_claim") + and session.get("_phase") in {"creating", "submitting"} + and not session.get("_close_requested") + ): + session["x_agent_sandbox"]["lifecycle"] = "requires_reconcile" + tx.put(session) + return False + session["_claim"], session["_claim_until"] = claim, time.time() + self.lease_seconds + if session.get("_close_requested"): + session["_phase"] = "closing" + else: + pending = [ + r + for r in tx.list("submission", parent=sid, pending=True) + if r["status"] not in SUBMISSION_TERMINAL + ] + if submission_id: + pending = [r for r in pending if r["id"] == submission_id] + if not pending: + return False + sub = pending[0] + if sub["status"] == "queued": + sub["status"] = "dispatching" + session["_active_submission"] = sub["id"] + tx.put(sub) + tx.put(session) + heartbeat = asyncio.create_task(self._heartbeat(sid, claim)) + try: + if session.get("_close_requested"): + await self._close(sid, claim) + return True + assert sub is not None + await self._work(sid, sub["id"], claim) + return True + except Exception as exc: + if isinstance(exc, ManagedError) and exc.code == "claim_changed": + return False + self._uncertain(sid, sub["id"] if sub else None, claim, "execution_result_uncertain") + return False + finally: + heartbeat.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat + with self.store.transaction() as tx: + row = tx.get(sid, deleted=True) + if row.get("_claim") == claim: + row["_claim_until"] = 0 + row["_claim"] = None + tx.put(row) + + async def _prepare(self, sid: str, claim: str) -> dict[str, Any]: + session = self._owned(sid, claim) + if session.get("_resource"): + return session + with self.store.transaction() as tx: + session = tx.get(sid, deleted=True) + allocated = [r for r in tx.list("session", deleted=True, occupied=True)] + if session.get("_claim") != claim or session.get("_claim_until", 0) <= time.time(): + raise ManagedError(409, "claim_changed", "Observation ownership changed") + if len(allocated) >= self.config.sessions["maxActiveSessions"]: + raise ManagedError( + 429, "sandbox_capacity", "Allocated or uncertain resources occupy capacity" + ) + token = secrets.token_urlsafe(32) + session.update( + _creation_started=True, + _phase="creating", + _app_token=token, + _deadline=time.time() + session["_settings"]["sessions"]["maxLifetimeSeconds"], + ) + session["x_agent_sandbox"]["lifecycle"] = "provisioning" + tx.event(session, "agent.session.environment.pending", {}) + + def created(resource: dict[str, Any]) -> None: + with self.store.transaction() as tx: + current = tx.get(sid, deleted=True) + # Preserve a late create result even if close/claim changes. + current["_resource"] = resource + current["_cleanup_confirmed"] = False + tx.put(current) + if current.get("_claim") != claim or current.get("_claim_until", 0) <= time.time(): + raise ManagedError(409, "claim_changed", "Observation ownership changed") + + self._owned(sid, claim) + resource = await self.backend.create(session, token, created) + self._owned(sid, claim) + deadline = time.monotonic() + 60 + while True: + try: + self._owned(sid, claim) + caps = await bridge_request(resource, "GET", "/capabilities") + self._owned(sid, claim) + if session["agent"]["x_agent_sandbox"]["adapter"] not in caps.get("adapters", []): + raise ManagedError( + 503, "guest_adapter_unavailable", "The selected adapter is unavailable" + ) + break + except Exception as exc: + if isinstance(exc, ManagedError) and exc.code == "claim_changed": + raise + if time.monotonic() >= deadline: + raise + await asyncio.sleep(0.5) + self._owned(sid, claim) + local = await bridge_request( + resource, + "POST", + "/managed/session", + { + "nonce": sid, + "agent": { + "adapter": session["agent"]["x_agent_sandbox"]["adapter"], + "model": session["agent"]["model"], + "instructions": session["agent"].get("instructions"), + }, + }, + ) + self._owned(sid, claim) + if local.get("epoch") != caps["epoch"]: + raise ManagedError(409, "runtime_epoch_changed", "The runtime restarted during setup") + with self.store.transaction() as tx: + current = tx.get(sid, deleted=True) + current.update( + _resource=resource, + _guest_session=local["session_id"], + _guest_epoch=local["epoch"], + _phase="ready", + ) + if not current.get("_close_requested"): + current["x_agent_sandbox"]["lifecycle"] = "ready" + tx.event(current, "agent.session.environment.connected", {}) + return current + + async def _work(self, sid: str, subid: str, claim: str) -> None: + session = await self._prepare(sid, claim) + with self.store.transaction() as tx: + sub = tx.get(subid) + current = tx.get(sid, deleted=True) + if current.get("_claim") != claim or current.get("_claim_until", 0) <= time.time(): + raise ManagedError(409, "claim_changed", "Observation ownership changed") + if not sub.get("_sent") and (sub.get("_cancel") or current.get("_close_requested")): + sub["status"] = "cancelled" + tx.put(sub) + if sub.get("_cancel_operation"): + op = tx.get(sub["_cancel_operation"]) + op.update(status="succeeded", result={"stop_confirmed": True}) + tx.put(op) + if current.get("_close_requested"): + # Leave normal owned-resource cleanup to the same claim. + current["_phase"] = "closing" + else: + current["status"], current["_phase"] = "idle", "ready" + tx.put(current) + cancelled_before_send = True + else: + cancelled_before_send = False + files = [] + for ref in sub["_files"]: + source = tx.get(ref["file_id"], sub["_owner"], deleted=True, with_data=True) + files.append( + { + "path": ref["path"], + "sha256": ref["sha256"], + "data_base64": source["_data"], + } + ) + if not sub.get("_deadline"): + sub["_deadline"] = min( + time.time() + session["_settings"]["limits"]["maxRunSeconds"], + session["_deadline"] - 5, + ) + already_sent = bool(sub.get("_sent")) + if not already_sent: + sub.update(_sent=True, status="submitted") + current["_phase"] = "submitting" + tx.put(sub) + tx.put(current) + if cancelled_before_send: + if current.get("_close_requested"): + await self._close(sid, claim) + return + if not already_sent: + try: + self._owned(sid, claim) + receipt = await bridge_request( + session["_resource"], + "POST", + "/managed/submissions", + { + "submission_id": subid, + "session_id": session["_guest_session"], + "input": sub["_prompt"], + "files": files, + "deadline": sub["_deadline"], + "max_tool_seconds": session["_settings"]["limits"]["maxToolSeconds"], + "max_output_bytes": session["_settings"]["limits"]["maxOutputBytes"], + "max_events": session["_settings"]["limits"]["maxEventsPerRun"], + }, + ) + except Exception: + # Inspect the original receipt. A 404/timeout is uncertain, never + # permission to submit the same input into a fresh runtime. + receipt = await bridge_request( + session["_resource"], "GET", "/managed/submissions/" + subid + ) + else: + receipt = await bridge_request( + session["_resource"], "GET", "/managed/submissions/" + subid + ) + while True: + current = self._owned(sid, claim) + if ( + receipt.get("epoch") != current["_guest_epoch"] + or receipt.get("current_epoch") != current["_guest_epoch"] + or receipt.get("status") == "uncertain" + ): + raise ManagedError( + 409, "runtime_epoch_changed", "Receipt identity is no longer authoritative" + ) + with self.store.transaction() as tx: + current, sub = tx.get(sid, deleted=True), tx.get(subid, deleted=True) + current["_claim_until"] = time.time() + self.lease_seconds + current["_phase"] = "observing" + cancel = ( + sub.get("_cancel") + or current.get("_close_requested") + or time.time() > sub["_deadline"] + ) + if cancel: + sub["_cancel"] = True + cancel = cancel or sub.get("_output_exceeded", False) + tx.put(current) + tx.put(sub) + if cancel and receipt.get("status") not in SUBMISSION_TERMINAL: + receipt = await bridge_request( + current["_resource"], + "POST", + "/managed/submissions/" + subid + "/cancel", + {"epoch": current["_guest_epoch"], "session_id": current["_guest_session"]}, + ) + self._ingest(sid, subid, claim, receipt) + if receipt.get("status") in {"completed", "failed", "cancelled"} and receipt.get( + "returned_cursor", 0 + ) >= receipt.get("latest_cursor", 0): + if receipt.get("stop_confirmed") is not True: + with self.store.transaction() as tx: + row = tx.get(sid, deleted=True) + previous_cancel = tx.get(subid, deleted=True).get("_cancel", False) + self.service._close(tx, row) + await self._close(sid, claim) + if not self._owned(sid, claim).get("_cleanup_confirmed"): + raise ManagedError(409, "stop_unconfirmed", "Sandbox stop is unconfirmed") + with self.store.transaction() as tx: + stopped_sub = tx.get(subid, deleted=True) + stopped_sub["_cancel"] = previous_cancel + tx.put(stopped_sub) + self._finish(sid, subid, claim, receipt) + if self._owned(sid, claim).get("_close_requested"): + await self._close(sid, claim) + return + if time.time() > sub["_deadline"] + 20: + raise ManagedError(409, "stop_unconfirmed", "The turn did not confirm stopping") + await asyncio.sleep(self.poll_seconds) + with self.store.transaction() as tx: + sub = tx.get(subid, deleted=True) + receipt = await bridge_request( + current["_resource"], + "GET", + "/managed/submissions/" + subid + "?after=" + str(sub.get("_native_seq", 0)), + ) + + def _ingest(self, sid: str, subid: str, claim: str, receipt: dict[str, Any]) -> None: + with self.store.transaction() as tx: + session, sub = tx.get(sid, deleted=True), tx.get(subid, deleted=True) + if session.get("_claim") != claim: + raise ManagedError(409, "claim_changed", "Observation ownership changed") + if session.get("_deleted"): + return + wires = receipt.get("events", []) + started = receipt.get("turn") or any( + e.get("wire", {}).get("type") == "agent.session.turn.in_progress" for e in wires + ) + if not sub.get("turn_id") and started: + turn = new_object( + "turn", + sub["_owner"], + parent=sid, + session_id=sid, + status="in_progress", + completed_at=None, + usage=None, + error=None, + _submission=subid, + ) + sub["turn_id"] = turn["id"] + sub["status"] = "running" + tx.put(turn) + tx.event( + session, + "agent.session.turn.created", + {"turn_id": turn["id"], "turn": public(turn), "submission_id": subid}, + ) + seen: set[str] = set() + frontier = sub.get("_native_seq", 0) + for event in wires: + if event.get("seq", 0) <= frontier: + continue + wire = event.get("wire", {}) + native_id = wire.get("event_id") + if native_id in seen: + continue + seen.add(native_id) + kind = wire.get("type", "") + if ( + kind.endswith((".completed", ".failed", ".cancelled", ".idle")) + or "reasoning" in kind + ): + continue + sub["_native_count"] = sub.get("_native_count", 0) + 1 + if sub["_native_count"] > session["_settings"]["limits"]["maxEventsPerRun"]: + sub["_output_exceeded"] = True + continue + value = dict(wire) + value.pop("event_id", None) + value.pop("session", None) + value.pop("turn", None) + value["session_id"], value["turn_id"] = sid, sub.get("turn_id") + if isinstance(value.get("item"), dict): + value["item"] = self._item(sid, sub.get("turn_id"), value["item"]) + if "item_id" in value: + value["item_id"] = self._item_id(sid, str(value["item_id"])) + value.pop("type", None) + size = len(canonical(value).encode()) + sub["_stream_bytes"] = sub.get("_stream_bytes", 0) + size + if sub["_stream_bytes"] > session["_settings"]["limits"]["maxOutputBytes"]: + sub["_output_exceeded"] = True + continue + tx.event(session, kind, value) + if receipt.get("gap") and not sub.get("_gap"): + tx.event(session, "agent.session.events.gap", {"submission_id": subid}) + sub["_gap"] = True + sub["_native_seq"] = receipt.get("returned_cursor", sub.get("_native_seq", 0)) + tx.put(sub) + + @staticmethod + def _item_id(sid: str, native_id: str) -> str: + return "item_" + hashlib.sha256((sid + ":" + native_id).encode()).hexdigest()[:32] + + def _item(self, sid: str, tid: str | None, value: dict[str, Any]) -> dict[str, Any]: + return {**value, "id": self._item_id(sid, str(value["id"])), "turn_id": tid} + + def _finish(self, sid: str, subid: str, claim: str, receipt: dict[str, Any]) -> None: + with self.store.transaction() as tx: + session, sub = tx.get(sid, deleted=True), tx.get(subid, deleted=True) + if session.get("_claim") != claim: + raise ManagedError(409, "claim_changed", "Observation ownership changed") + if not sub.get("turn_id"): + raise ManagedError( + 409, "missing_turn_receipt", "The native turn identity was not observed" + ) + turn = tx.get(sub["turn_id"], deleted=True) + if session.get("_deleted"): + return + session["_phase"] = "collecting" + native = receipt["turn"] + errors = list(receipt.get("artifact_errors", [])) + total = 0 + items = [i for i in receipt.get("items", []) if i.get("type") != "reasoning"] + if len(canonical(items).encode()) > session["_settings"]["limits"]["maxOutputBytes"]: + sub["_output_exceeded"] = True + for item in [] if sub.get("_output_exceeded") else items: + if item.get("type") == "reasoning": + continue + projected = self._item(sid, turn["id"], item) + projected.update( + _owner=sub["_owner"], _kind="item", _parent=sid, created_at=time.time() + ) + tx.put(projected) + for artifact in receipt.get("artifacts", []): + raw = base64.b64decode(artifact["data_base64"], validate=True) + total += len(raw) + limits = session["_settings"]["storage"] + if ( + len(raw) > limits["maxArtifactBytes"] + or total > limits["maxTurnArtifactBytes"] + or hashlib.sha256(raw).hexdigest() != artifact["sha256"] + ): + raise ManagedError( + 413, "artifact_limit", "The output artifact exceeds its declared budget" + ) + self.service.check_storage(tx, sub["_owner"], len(raw)) + row = new_object( + "artifact", + sub["_owner"], + parent=sid, + session_id=sid, + turn_id=turn["id"], + path=artifact["path"], + bytes=len(raw), + expires_at=time.time() + + session["_settings"]["sessions"]["resultRetentionSeconds"], + x_agent_sandbox={"sha256": artifact["sha256"]}, + _data=artifact["data_base64"], + ) + tx.put(row) + tx.event( + session, + "agent.session.artifact.created", + {"artifact": public(row), "turn_id": turn["id"]}, + ) + status = ( + "failed" + if sub.get("_output_exceeded") + else "cancelled" + if sub.get("_cancel") + else receipt["status"] + ) + turn.update( + status=status, + completed_at=time.time(), + usage=native.get("usage"), + error=( + {"code": "output_budget_exceeded"} + if sub.get("_output_exceeded") + else receipt.get("error") or native.get("error") + ), + x_agent_sandbox={ + "publication": "partial" if errors else "complete", + "artifact_errors": errors, + }, + ) + sub.update(status=status, completed_at=time.time()) + tx.put(turn) + tx.put(sub) + if sub.get("_cancel_operation"): + op = tx.get(sub["_cancel_operation"], deleted=True) + op.update(status="succeeded", result={"stop_confirmed": True}) + tx.put(op) + session["last_active_at"] = time.time() + session["_phase"] = "closed" if session.get("_cleanup_confirmed") else "ready" + pending = [ + r + for r in tx.list("submission", parent=sid, pending=True) + if r["status"] not in SUBMISSION_TERMINAL + ] + session["status"] = "in_progress" if pending else "idle" + tx.event( + session, + "agent.session.turn." + status, + {"turn_id": turn["id"], "turn": public(turn)}, + ) + if not pending: + tx.event(session, "agent.session.idle", {"session": public(session)}) + + async def _close(self, sid: str, claim: str) -> None: + session = self._owned(sid, claim) + confirmed = False + if session.get("_resource"): + confirmed = await self.backend.kill(session["_resource"]) + elif not session.get("_creation_started"): + confirmed = True + with self.store.transaction() as tx: + current = tx.get(sid, deleted=True) + if current.get("_claim") != claim: + return + current["x_agent_sandbox"]["lifecycle"] = ( + "closed" if confirmed else "requires_reconcile" + ) + current["status"] = "idle" if confirmed else "failed" + if confirmed: + current.update( + _resource=None, + _creation_started=False, + _cleanup_confirmed=True, + _retain_until=time.time() + + current["_settings"]["sessions"]["resultRetentionSeconds"], + ) + for sub in tx.list("submission", parent=sid, deleted=True, pending=True): + if sub["status"] not in SUBMISSION_TERMINAL: + sub["status"] = "cancelled" + tx.put(sub) + op = tx.get(current["_close_operation"], deleted=True) + op.update( + status="succeeded" if confirmed else "requires_reconcile", + result={ + "stop_confirmed": confirmed, + "compute_released": confirmed, + "public_removed": bool(current.get("_deleted")), + }, + ) + tx.put(op) + if not current.get("_deleted"): + tx.event( + current, + "agent.session.closed" if confirmed else "agent.session.cleanup.uncertain", + {}, + ) + else: + tx.put(current) + + def due(self, limit: int = 20, revisions: set[str] | None = None) -> list[dict[str, str]]: + """Claim bounded outbox deliveries and apply Session lifetime fences.""" + if not 1 <= limit <= 100: + raise ManagedError(400, "invalid_limit", "Dispatch limit must be 1-100") + out = [] + with self.store.transaction() as tx: + self.service.collect(tx) + for session in tx.list("session", deleted=True, work=True): + if revisions is not None and session["_revision"] not in revisions: + continue + pending = [ + r + for r in tx.list("submission", parent=session["id"], pending=True) + if r["status"] not in SUBMISSION_TERMINAL + ] + lifetime = session.get("_deadline", float("inf")) <= time.time() + idle = ( + not pending + and session.get("_resource") + and time.time() - session["last_active_at"] + >= session["_settings"]["sessions"]["idleTimeoutSeconds"] + ) + if (lifetime or idle) and not session.get("_close_requested"): + self.service._close(tx, session) + if session.get("_close_requested") and not session.get("_cleanup_confirmed"): + identifier = session["_close_operation"] + elif pending: + identifier = pending[0]["id"] + else: + continue + if ( + session.get("_claim_until", 0) > time.time() + or session.get("_dispatch_after", 0) > time.time() + ): + continue + session["_dispatch_after"] = time.time() + 15 + session["_dispatch_attempt"] = session.get("_dispatch_attempt", 0) + 1 + tx.put(session) + out.append( + { + "sessionId": session["id"], + "submissionId": identifier, + "revision": session["_revision"], + "deliveryId": identifier + "-" + str(session["_dispatch_attempt"]), + } + ) + if len(out) >= limit: + break + return out diff --git a/client-sdk/src/agent_sandbox/providers/__init__.py b/client-sdk/src/agent_sandbox/providers/__init__.py index b440bdc2..e1a79e65 100644 --- a/client-sdk/src/agent_sandbox/providers/__init__.py +++ b/client-sdk/src/agent_sandbox/providers/__init__.py @@ -11,6 +11,9 @@ if TYPE_CHECKING: from agent_sandbox.providers import commands as commands + from agent_sandbox.providers.alicloud_express.managed import ( + ExpressBackend as ManagedExpressBackend, + ) from agent_sandbox.providers.base import FEATURES as FEATURES from agent_sandbox.providers.base import ConfigField as ConfigField from agent_sandbox.providers.base import Provider as Provider @@ -28,6 +31,7 @@ from agent_sandbox.utils.config_keys import BUILTIN_PROVIDER_ID as BUILTIN_PROVIDER_ID __all__ = [ + "ManagedExpressBackend", "BUILTIN_PROVIDER_ID", "FEATURES", "ConfigField", @@ -49,6 +53,8 @@ def __getattr__(name: str) -> Any: """Keep public exports lazy so reading bundled metadata does not discover providers.""" + if name == "ManagedExpressBackend": + return import_module("agent_sandbox.providers.alicloud_express.managed").ExpressBackend if name == "commands": return import_module("agent_sandbox.providers.commands") if name == "BUILTIN_PROVIDER_ID": diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py new file mode 100644 index 00000000..3de1e347 --- /dev/null +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py @@ -0,0 +1,191 @@ +"""Explicit managed Express lifecycle; no ambient provider discovery.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit + +from agent_sandbox.managed_agents.models import ManagedError + +if TYPE_CHECKING: + from collections.abc import Callable + + +class ExpressBackend: + """Create only owned Express sandboxes using fully resolved service configuration.""" + + def __init__(self, provider: dict[str, Any], model: dict[str, Any] | None = None) -> None: + if ( + set(provider) != {"provider", "apiKey", "baseUrl", "domain", "region"} + or provider["provider"] != "alicloud-express" + ): + raise ValueError( + "Explicit Express provider, Key, API URL, domain and region are required" + ) + if model is not None and ( + set(model) != {"apiKey", "baseUrl", "model", "adapter"} + or model["adapter"] not in {"codex", "qwen-code"} + ): + raise ValueError("Explicit model credentials, endpoint, model and adapter are required") + if any( + not isinstance(v, str) or not v.strip() + for v in (*provider.values(), *(model or {}).values()) + ): + raise ValueError("Service credentials and endpoint values cannot be empty") + for url in [provider["baseUrl"], *([model["baseUrl"]] if model else [])]: + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.fragment + ): + raise ValueError( + "Service endpoints must be explicit HTTPS URLs without embedded credentials" + ) + self.provider, self.model = dict(provider), dict(model) if model else None + + async def _infra(self) -> Any: + from agent_sandbox.providers.alicloud_express.provider import AlicloudExpressProvider + from agent_sandbox.transport.config import TransportConfig + + # Construct the immutable value directly; do not call load_config or + # discover a provider from environment/CWD/HOME/global entrypoints. + config = TransportConfig( + provider="alicloud-express", + sandbox_api_key=self.provider["apiKey"], + sandbox_base_url=self.provider["baseUrl"], + sandbox_domain=self.provider["domain"], + region=self.provider["region"], + access_key_id=None, + access_key_secret=None, + security_token=None, + http_timeout=30, + provider_options={}, + ) + return await AlicloudExpressProvider().build_infra(config) + + async def create( + self, session: dict[str, Any], token: str, on_created: Callable[[dict[str, Any]], None] + ) -> dict[str, Any]: + """Prepare native lifecycle and app/gateway auth, persisting ownership immediately.""" + from agent_sandbox.models.sandbox import SandboxConfig + from agent_sandbox.protocol.port_contract import PortContext + from agent_sandbox.providers.alicloud_express.provider import AlicloudExpressProvider + + if ( + self.model is None + or session["agent"]["model"] != self.model["model"] + or session["agent"]["x_agent_sandbox"]["adapter"] != self.model["adapter"] + ): + raise ManagedError( + 400, "model_binding_mismatch", "This Worker has no matching model binding" + ) + settings = session["_settings"] + if set(settings.get("modelEnvAllowlist", [])) != { + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + }: + raise ManagedError( + 400, "model_env_not_allowed", "Required model variables are not allowed" + ) + infra = await self._infra() + try: + config = SandboxConfig( + templateID=settings["template"], + timeout=settings["sessions"]["maxLifetimeSeconds"], + secure=True, + metadata={ + "abx-managed": "true", + "abx-session": session["id"], + "abx-revision": session["_revision"], + }, + envVars={ + "ABX_SERVER_TOKEN": token, + "ABX_MANAGED_WORKSPACE": settings["workspace"], + "OPENAI_API_KEY": self.model["apiKey"], + "OPENAI_BASE_URL": self.model["baseUrl"], + "OPENAI_MODEL": self.model["model"], + }, + ) + info = await infra.sandbox_protocol.create(config, request_timeout=30) + resource = {"sandbox_id": info.sandbox_id, "token": token, "ownership": "owned"} + on_created(resource) + context = PortContext( + info.sandbox_id, + self.provider["domain"], + True, + info.envd_access_token, + info.traffic_access_token, + ) + endpoint = await AlicloudExpressProvider().application_endpoint( + context, settings["port"] + ) + resource.update(url=endpoint["url"], headers=endpoint["headers"]) + on_created(resource) + provider = AlicloudExpressProvider() + binding = await provider.prepare_session( + infra, info, operation="create", template=settings["template"] + ) + process_env = { + **config.env_vars, + "ABX_MANAGED_PORT": str(settings["port"]), + "ABX_MANAGED_STATE_DIR": "/home/user/.agent-sandbox/managed-bridge", + } + # Fixed trusted bootstrap, with all instance credentials in env only. + # A prepared template does not imply a running application process. + script = ( + 'umask 077; mkdir -p "$ABX_MANAGED_STATE_DIR" "$ABX_MANAGED_WORKSPACE"; ' + 'nohup python3 -m agent_sandbox.server.managed --port "$ABX_MANAGED_PORT" ' + '>"$ABX_MANAGED_STATE_DIR/bridge.log" 2>&1 bool: + """Delete only a recorded owned Sandbox and independently confirm NotFound.""" + from agent_sandbox.models.errors import SandboxNotFoundError + + if resource.get("ownership") != "owned" or not resource.get("sandbox_id"): + return False + infra = await self._infra() + try: + with contextlib.suppress(SandboxNotFoundError): + await infra.sandbox_protocol.kill(resource["sandbox_id"]) + try: + await infra.sandbox_protocol.get_info(resource["sandbox_id"]) + except SandboxNotFoundError: + return True + return False + finally: + await infra.http_client.close() diff --git a/client-sdk/tests/fixtures/managed_api_v1.json b/client-sdk/tests/fixtures/managed_api_v1.json new file mode 100644 index 00000000..e5f9cbe2 --- /dev/null +++ b/client-sdk/tests/fixtures/managed_api_v1.json @@ -0,0 +1,318 @@ +{ + "contract": "agent-sandbox-managed-agents/v1", + "source": "2026-11-05 reviewed V1 contract", + "operations": [ + { + "operation_id": "health_get", + "method": "GET", + "path": "/healthz", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "capabilities_get", + "method": "GET", + "path": "/v1/agent-sandbox/capabilities", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "models_list", + "method": "GET", + "path": "/v1/models", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "models_get", + "method": "GET", + "path": "/v1/models/{model_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "runtimes_list", + "method": "GET", + "path": "/v1/agents/runtimes", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "agents_create", + "method": "POST", + "path": "/v1/agents", + "success_codes": [ + 201 + ] + }, + { + "operation_id": "agents_list", + "method": "GET", + "path": "/v1/agents", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "agents_get", + "method": "GET", + "path": "/v1/agents/{agent_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "agents_update", + "method": "POST", + "path": "/v1/agents/{agent_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "agents_delete", + "method": "DELETE", + "path": "/v1/agents/{agent_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "sessions_create", + "method": "POST", + "path": "/v1/agents/sessions", + "success_codes": [ + 201 + ] + }, + { + "operation_id": "sessions_list", + "method": "GET", + "path": "/v1/agents/sessions", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "sessions_get", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "sessions_update", + "method": "POST", + "path": "/v1/agents/sessions/{session_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "sessions_delete", + "method": "DELETE", + "path": "/v1/agents/sessions/{session_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "session_events_create", + "method": "POST", + "path": "/v1/agents/sessions/{session_id}/events", + "success_codes": [ + 202 + ] + }, + { + "operation_id": "session_events_stream", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/events", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "session_items_list", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/items", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "turns_list", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/turns", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "turns_get", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/turns/{turn_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "turn_items_list", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/turns/{turn_id}/items", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "artifacts_list", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/artifacts", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "artifacts_get", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "artifacts_content", + "method": "GET", + "path": "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}/content", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "artifacts_delete", + "method": "DELETE", + "path": "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "files_create", + "method": "POST", + "path": "/v1/files", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "files_list", + "method": "GET", + "path": "/v1/files", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "files_get", + "method": "GET", + "path": "/v1/files/{file_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "files_content", + "method": "GET", + "path": "/v1/files/{file_id}/content", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "files_delete", + "method": "DELETE", + "path": "/v1/files/{file_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "environments_list", + "method": "GET", + "path": "/v1/agents/environments", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "environments_get", + "method": "GET", + "path": "/v1/agents/environments/{environment_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "sessions_close", + "method": "POST", + "path": "/v1/agent-sandbox/sessions/{session_id}/close", + "success_codes": [ + 202 + ] + }, + { + "operation_id": "operations_get", + "method": "GET", + "path": "/v1/agent-sandbox/operations/{operation_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "submissions_enqueue", + "method": "POST", + "path": "/v1/agent-sandbox/sessions/{session_id}/submissions", + "success_codes": [ + 202 + ] + }, + { + "operation_id": "submissions_list", + "method": "GET", + "path": "/v1/agent-sandbox/sessions/{session_id}/submissions", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "submissions_get", + "method": "GET", + "path": "/v1/agent-sandbox/sessions/{session_id}/submissions/{submission_id}", + "success_codes": [ + 200 + ] + }, + { + "operation_id": "turns_cancel", + "method": "POST", + "path": "/v1/agent-sandbox/sessions/{session_id}/turns/{turn_id}/cancel", + "success_codes": [ + 202 + ] + }, + { + "operation_id": "submissions_cancel", + "method": "POST", + "path": "/v1/agent-sandbox/sessions/{session_id}/submissions/{submission_id}/cancel", + "success_codes": [ + 202 + ] + } + ] +} diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py new file mode 100644 index 00000000..74162478 --- /dev/null +++ b/client-sdk/tests/test_managed_agents.py @@ -0,0 +1,974 @@ +"""Managed business behavior over real HTTP/files/subprocesses; no cloud/model claims.""" + +from __future__ import annotations + +import asyncio +import base64 +import copy +import hashlib +import json +import os +import sys +import threading +import time +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from agent_sandbox.managed_agents import ManagedConfig, ManagedError, ManagedService, ManagedStore +from agent_sandbox.managed_agents.dispatch import ( + ManagedDispatcher, + sign_invocation, + verify_invocation, +) +from agent_sandbox.managed_agents.http import TokenAuthenticator, make_server +from agent_sandbox.managed_agents.worker import ManagedWorker + +# isort: split +from agent_sandbox.server.agents.runtime import AgentRuntime +from agent_sandbox.server.managed import ManagedBridge, make_bridge_server + + +class LocalBackend: + """Real private HTTP bridge and CLI fixture, with one isolated directory per Sandbox.""" + + def __init__(self, root: Path, binary: Path) -> None: + self.root, self.binary = root, binary + self.resources: dict[str, tuple[Any, Any, Any]] = {} + self.created = 0 + + async def create(self, session: dict[str, Any], token: str, on_created: Any) -> dict[str, Any]: + self.created += 1 + directory = self.root / session["id"] + workspace = directory / "workspace" + logical = session["_settings"]["workspace"] + + class TestBridge(ManagedBridge): + def submit(self, body: dict[str, Any]) -> dict[str, Any]: + body = copy.deepcopy(body) + for file in body["files"]: + file["path"] = str(workspace) + file["path"][len(logical) :] + return super().submit(body) + + environment = { + "PATH": str(self.binary) + os.pathsep + os.environ.get("PATH", ""), + "HOME": str(directory), + "ABX_AGENTS_ALLOW_COMMAND": "0", + } + runtime = AgentRuntime( + persist=False, + environ=environment, + max_concurrent_turns=1, + kill_grace_s=0.1, + term_grace_s=0.1, + ) + bridge = TestBridge(str(directory / "journal"), str(workspace), runtime=runtime) + server = make_bridge_server(bridge, token, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + resource = { + "sandbox_id": session["id"], + "ownership": "owned", + "token": token, + "headers": {}, + "url": f"http://127.0.0.1:{server.server_port}", + } + self.resources[session["id"]] = bridge, server, thread + on_created(resource) + return resource + + async def kill(self, resource: dict[str, Any]) -> bool: + entry = self.resources.pop(resource["sandbox_id"], None) + if entry: + bridge, server, thread = entry + for session in bridge.runtime.list_sessions(): + bridge.runtime.delete_session(session.id) + server.shutdown() + server.server_close() + thread.join(timeout=3) + return resource["sandbox_id"] not in self.resources + + +@pytest.fixture +def components(tmp_path: Path, request: Any) -> Any: + binary = tmp_path / "bin" + binary.mkdir() + script = binary / "qwen" + script.write_text( + f"#!{sys.executable}\n" + + """ +import json,pathlib,subprocess,sys,time +if '--version' in sys.argv: + print('qwen-fixture-1');sys.exit(0) +print(json.dumps({'type':'system','subtype':'init','session_id':'native-fixture-thread'}),flush=True) +if 'slow' in sys.argv[1]:time.sleep(15) +if 'tool-' in sys.argv[1]: + print(json.dumps({'type':'assistant','message':{'id':'tool-message','content':[ + {'type':'tool_use','id':'sleep-tool','name':'Bash','input':{'command':'sleep'}}]}}),flush=True) + subprocess.run([sys.executable,'-c','import time;time.sleep(15)' + if 'tool-long' in sys.argv[1] else 'import time;time.sleep(.05)'],check=True) + print(json.dumps({'type':'user','message':{'content':[ + {'type':'tool_result','tool_use_id':'sleep-tool','content':'done'}]}}),flush=True) + time.sleep(1.2) +root=pathlib.Path.cwd();out=root/'outputs';out.mkdir(exist_ok=True) +counter=root/'counter' +n=int(counter.read_text())+1 if counter.exists() else 1;counter.write_text(str(n)) +source=root/'input'/'sales.csv' +text=source.read_text() if source.exists() else 'no input' +(out/'result.txt').write_text(str(n)+'|'+text) +print(json.dumps({'type':'assistant','message':{'id':'message-'+str(n), + 'content':[{'type':'text','text':'completed '+str(n)}]}}),flush=True) +print(json.dumps({'type':'result','subtype':'success','is_error':False, + 'result':'completed '+str(n)}),flush=True) +""" + ) + script.chmod(0o755) + config = ManagedConfig.from_dict( + { + "revision": "revision-a", + "template": "fixture-template", + "models": {"fixture-model": "qwen-code"}, + "modelEnvAllowlist": ["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL"], + "sessions": {"maxLifetimeSeconds": 60, "idleTimeoutSeconds": 30}, + "limits": {"maxRunSeconds": 20, "maxToolSeconds": 5}, + } + ) + postgres = None + if getattr(request, "param", "sqlite") == "postgres": + pgserver = pytest.importorskip("pgserver", reason="real PostgreSQL component extra") + postgres = pgserver.get_server(tmp_path / "postgres", cleanup_mode="delete") + url = postgres.get_uri() + else: + url = "sqlite:///" + str(tmp_path / "state.db") + service = ManagedService(ManagedStore(url, local=True), config) + backend = LocalBackend(tmp_path / "sandboxes", binary) + worker = ManagedWorker(service, backend, poll_seconds=0.01) + dispatcher = ManagedDispatcher(worker) + yield service, backend, worker, dispatcher + for sid in list(backend.resources): + asyncio.run(backend.kill({"sandbox_id": sid})) + if postgres: + postgres.cleanup() + + +def create(service: ManagedService, text: str | None = None, key: str = "") -> dict[str, Any]: + body: dict[str, Any] = {"agent": {"model": "fixture-model"}} + if text is not None: + body["input"] = text + return service.handle("owner", "POST", "/v1/agents/sessions", body, key=key)[1] + + +def test_create_replay_busy_queue_and_owner_boundaries(components: Any) -> None: + service, backend, _, _ = components + session = create(service, "first", "initial") + assert create(service, "first", "initial")["id"] == session["id"] + assert backend.created == 0 + with pytest.raises(ManagedError, match="another request"): + create(service, "different", "initial") + with pytest.raises(ManagedError) as busy: + service.handle( + "owner", "POST", f"/v1/agents/sessions/{session['id']}/events", {"input": "next"} + ) + assert busy.value.status == 409 and busy.value.code == "active_turn_not_steerable" + status, queued, _ = service.handle( + "owner", + "POST", + f"/v1/agent-sandbox/sessions/{session['id']}/submissions", + {"input": "later"}, + key="later", + ) + assert status == 202 and queued["turn_id"] is None + op = service.handle( + "owner", + "POST", + f"/v1/agent-sandbox/sessions/{session['id']}/submissions/{queued['id']}/cancel", + )[1] + assert op["result"]["stop_confirmed"] is True + with pytest.raises(ManagedError) as foreign: + service.handle("foreign", "GET", f"/v1/agents/sessions/{session['id']}") + assert foreign.value.status == 404 + + +@pytest.mark.asyncio +async def test_real_bridge_two_turns_files_artifacts_close_and_delete(components: Any) -> None: + service, backend, worker, dispatcher = components + source = service.upload_file("owner", "sales.csv", "user_data", b"month,amount\nJan,3\n") + status, session, _ = service.handle( + "owner", + "POST", + "/v1/agents/sessions", + { + "agent": {"model": "fixture-model"}, + "input": "summarize the CSV", + "x_agent_sandbox": { + "input_files": [{"file_id": source["id"], "path": "/workspace/input/sales.csv"}] + }, + }, + ) + assert status == 201 + sid = session["id"] + assert await worker.run(sid) + turns = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns")[1]["data"] + assert len(turns) == 1 and turns[0]["status"] == "completed" + turn = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns/{turns[0]['id']}")[1] + assert turn["object"] == "agent.session.turn" + items = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns/{turn['id']}/items")[ + 1 + ]["data"] + assert items[0]["content"][0]["text"] == "completed 1" + artifacts = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/artifacts")[1]["data"] + original = artifacts[0]["id"] + content = service.handle( + "owner", "GET", f"/v1/agents/sessions/{sid}/artifacts/{original}/content" + )[1] + assert content == b"1|month,amount\nJan,3\n" + service.handle( + "owner", "POST", f"/v1/agents/sessions/{sid}/events", {"input": "continue"}, key="second" + ) + assert await worker.run(sid) + assert backend.created == 1 + assert service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/items")[1]["data"] + env = service.handle("owner", "GET", f"/v1/agents/environments/{session['environment']['id']}")[ + 1 + ] + assert env["x_agent_sandbox"]["lifecycle"] == "ready" + closed = service.handle("owner", "POST", f"/v1/agent-sandbox/sessions/{sid}/close")[1] + assert await worker.run(sid) + assert not backend.resources + assert service.handle("owner", "GET", f"/v1/agent-sandbox/operations/{closed['id']}")[1][ + "result" + ]["compute_released"] + assert ( + service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/artifacts/{original}/content")[1] + == content + ) + assert dispatcher.status()["activeSessions"] == 0 + assert service.handle("owner", "DELETE", f"/v1/agents/sessions/{sid}")[0] == 200 + for tail in ("", "/items", "/artifacts", f"/artifacts/{original}/content"): + with pytest.raises(ManagedError) as gone: + service.handle("owner", "GET", f"/v1/agents/sessions/{sid}" + tail) + assert gone.value.status == 404 + + +def test_raw_sdk_pagination_and_cross_owner_cursor(components: Any) -> None: + service = components[0] + for i in range(3): + service.handle("owner", "POST", "/v1/agents", {"model": "fixture-model", "name": str(i)}) + first = service.handle("owner", "GET", "/v1/agents", query={"limit": "1"})[1] + second = service.handle( + "owner", "GET", "/v1/agents", query={"limit": "1", "after": first["last_id"]} + )[1] + assert first["data"][0]["id"] != second["data"][0]["id"] + with pytest.raises(ManagedError): + service.handle( + "foreign", "GET", "/v1/agents", query={"after": first["x_agent_sandbox"]["next_cursor"]} + ) + + +@pytest.mark.parametrize( + "body", + [ + {"limits": {"unknown": 1}}, + {"sessions": {"guestRestartPolicy": "replay"}}, + {"limits": {"maxToolSeconds": 601}}, + {"port": True}, + {"models": {"bad": "missing"}}, + ], +) +def test_compiled_configuration_rejects_unknown_or_invalid_budgets(body: dict[str, Any]) -> None: + with pytest.raises(ValueError): + ManagedConfig.from_dict( + {"revision": "r", "template": "t", "models": {"m": "qwen-code"}, **body} + ) + + +def test_metadata_queries_do_not_load_binary_and_deleted_bytes_still_count(components: Any) -> None: + service = components[0] + file = service.upload_file("owner", "x", "user_data", b"binary canary") + with service.store.transaction() as tx: + rows = tx.list("file", "owner") + assert "_data" not in rows[0] and rows[0]["_has_blob"] is True + raw = tx.execute("SELECT body FROM managed_objects WHERE id=?", (file["id"],)).fetchone()[ + "body" + ] + assert base64.b64encode(b"binary canary").decode() not in raw + service.handle("owner", "DELETE", "/v1/files/" + file["id"]) + with service.store.transaction() as tx: + assert tx.get(file["id"], "owner", deleted=True)["_has_blob"] + + +@pytest.mark.asyncio +async def test_drain_counts_idle_logical_sessions_and_unknown_revisions(components: Any) -> None: + service, _, _, dispatcher = components + session = create(service) + assert dispatcher.status()["activeSessions"] == 1 + assert not dispatcher.worker.due(revisions={"other-revision"}) + response = await dispatcher.invoke({"operation": "drain", "applicationId": "agents-api"}) + assert response["activeSessions"] == 0 and response["pendingCleanup"] == 0 + assert ( + service.handle("owner", "GET", f"/v1/agents/sessions/{session['id']}")[1][ + "x_agent_sandbox" + ]["lifecycle"] + == "closed" + ) + with pytest.raises(ManagedError) as rejected: + create(service) + assert rejected.value.code == "application_draining" + + +def test_auth_expiry_and_role_body_signature() -> None: + key = "b" * 40 + doc = { + "tokens": [ + { + "sha256": hashlib.sha256(key.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read"], + "expiresAt": time.time() + 60, + } + ] + } + auth = TokenAuthenticator(doc) + assert auth.authenticate(key, False) == "owner" + with pytest.raises(ManagedError) as denied: + auth.authenticate(key, True) + assert denied.value.status == 403 + doc["tokens"][0]["expiresAt"] = time.time() - 1 + with pytest.raises(ManagedError) as expired: + auth.authenticate(key, False) + assert expired.value.status == 401 + envelope = sign_invocation({"limit": 1}, "i" * 40, "dispatcher") + assert verify_invocation(envelope, "i" * 40, "dispatcher") == {"limit": 1} + with pytest.raises(ManagedError): + verify_invocation(envelope, "i" * 40, "worker") + + +def test_real_http_multipart_and_safe_errors(components: Any) -> None: + service = components[0] + token = "b" * 40 + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + server = make_server(service, auth, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", + headers={"Authorization": "Bearer " + token}, + trust_env=False, + ) as client: + assert client.get("/healthz").json()["status"] == "ok" + response = client.post( + "/v1/files", + data={"purpose": "user_data"}, + files={"file": ("sales.csv", b"x,y\n1,2\n")}, + ) + assert response.status_code == 200 + file = response.json() + assert client.get("/v1/files/" + file["id"] + "/content").content == b"x,y\n1,2\n" + result = client.post("/v1/agents/sessions", json={"agent": {"model": "fixture-model"}}) + assert result.status_code == 201 and "Location" in result.headers + sid = result.json()["id"] + accepted = client.post( + f"/v1/agents/sessions/{sid}/events", + json={"input": "hello"}, + headers={"Idempotency-Key": "message-1"}, + ) + assert accepted.status_code == 202 and not accepted.content + assert client.get(accepted.headers["Location"]).json()["turn_id"] is None + forbidden = client.post("/invoke", json={"token": "private-value"}) + assert forbidden.status_code == 404 and "private-value" not in forbidden.text + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +@pytest.mark.asyncio +async def test_active_cancel_exact_target_replays_without_cancelling_next_turn( + components: Any, +) -> None: + service, backend, worker, _ = components + sid = create(service, "slow")["id"] + running = asyncio.create_task(worker.run(sid)) + for _ in range(200): + await asyncio.sleep(0.02) + turns = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns")[1]["data"] + if turns: + break + assert turns and turns[0]["status"] == "in_progress" + body = {"events": [{"type": "agent.session.input.cancel"}]} + original = service.handle( + "owner", "POST", f"/v1/agents/sessions/{sid}/events", body, key="stop-a" + ) + assert await running + operation = service.handle( + "owner", + "GET", + "/v1/agent-sandbox/operations/" + original[2]["X-Agent-Sandbox-Operation-Id"], + )[1] + assert operation["result"]["stop_confirmed"] is True + assert backend.created == 1 and backend.resources + service.handle("owner", "POST", f"/v1/agents/sessions/{sid}/events", {"input": "continue"}) + # A new process sees the same durable cancel target, even after A completed. + reloaded = ManagedService(ManagedStore(service.store.url, local=True), service.config) + replay = reloaded.handle( + "owner", "POST", f"/v1/agents/sessions/{sid}/events", body, key="stop-a" + ) + assert replay == original + with reloaded.store.transaction() as tx: + pending = [r for r in tx.list("submission", parent=sid) if r["status"] == "queued"] + assert len(pending) == 1 and not pending[0]["_cancel"] + assert await worker.run(sid) + assert backend.created == 1 + + +@pytest.mark.asyncio +async def test_cancel_during_create_never_sends_input(components: Any) -> None: + service, backend, worker, _ = components + sid = create(service, "first")["id"] + original_create = backend.create + + async def cancelling_create( + session: dict[str, Any], token: str, created: Any + ) -> dict[str, Any]: + resource = await original_create(session, token, created) + with service.store.transaction() as tx: + sub = tx.list("submission", parent=sid)[0] + service._cancel(tx, tx.get(sid), sub) + return resource + + backend.create = cancelling_create + assert await worker.run(sid) + bridge = backend.resources[sid][0] + assert not bridge.runtime.get_session(bridge._session_record()["session_id"]).turns + with service.store.transaction() as tx: + sub = tx.list("submission", parent=sid)[0] + assert sub["status"] == "cancelled" and not sub.get("_sent") + + +def test_real_detached_child_survives_leader_but_process_domain_stops_it(tmp_path: Path) -> None: + import secrets + import subprocess + + from agent_sandbox.server.managed_process import ProcessDomain + + scope = secrets.token_hex(20) + domain = ProcessDomain(scope) + output = tmp_path / "ticks" + # Use a real detached executable rather than treating leader exit as evidence. + child_script = tmp_path / "child.py" + child_script.write_text( + "import pathlib,sys,time\np=pathlib.Path(sys.argv[1])\n" + "while True:\n p.write_text(str(time.time())); time.sleep(.02)\n" + ) + leader = subprocess.Popen( + [ + sys.executable, + "-c", + "import subprocess,sys; p=subprocess.Popen(sys.argv[1:],start_new_session=True," + "stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);print(p.pid)", + sys.executable, + str(child_script), + str(output), + ], + env={**os.environ, "ABX_MANAGED_TURN_SCOPE": scope}, + stdout=subprocess.PIPE, + ) + child_pid = int(leader.communicate(timeout=5)[0]) + try: + assert leader.returncode == 0 + for _ in range(100): + if output.exists(): + break + time.sleep(0.02) + before = output.read_text() + time.sleep(0.08) + assert output.read_text() != before # escaped child remains active after leader exit + domain.quiesce() # unknown concurrent host processes may conservatively refuse reuse + stopped = output.read_text() + time.sleep(0.08) + assert output.read_text() == stopped + finally: + import contextlib + import signal + + with contextlib.suppress(ProcessLookupError): + os.kill(child_pid, signal.SIGKILL) + + +def test_output_snapshot_rejects_ancestor_swapped_to_symlink( + tmp_path: Path, monkeypatch: Any +) -> None: + workspace = tmp_path / "workspace" + nested = workspace / "outputs" / "nested" + nested.mkdir(parents=True) + (nested / "report.txt").write_text("owned") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "report.txt").write_text("private outside") + bridge = ManagedBridge(str(tmp_path / "journal"), str(workspace)) + original_open = os.open + + def swapping_open(path: Any, *args: Any, **kwargs: Any) -> Any: + if path == "nested" and kwargs.get("dir_fd") is not None: + nested.rename(workspace / "saved") + nested.symlink_to(outside, target_is_directory=True) + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(os, "open", swapping_open) + artifacts, errors = bridge._snapshots() + assert artifacts == [] and errors + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prompt,expected", [("tool-long", "failed"), ("tool-done", "completed")]) +async def test_observed_real_tool_wall_deadline( + components: Any, prompt: str, expected: str +) -> None: + service, backend, worker, _ = components + service.config.limits["maxToolSeconds"] = 1 + sid = create(service, prompt)["id"] + assert await worker.run(sid) + turn = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns")[1]["data"][0] + assert turn["status"] == expected + if expected == "failed": + assert turn["error"]["code"] == "tool_deadline_exceeded" + with service.store.transaction() as tx: + current = tx.get(sid) + # Dedicated cooperative domains may be reused; unknown concurrent host + # processes require provider destruction before stop can be confirmed. + assert backend.resources or current.get("_cleanup_confirmed") is True + + +@pytest.mark.asyncio +async def test_preparation_heartbeat_prevents_second_create_after_original_lease( + components: Any, +) -> None: + service, backend, _, _ = components + worker = ManagedWorker(service, backend, poll_seconds=0.01, lease_seconds=0.15) + sid = create(service, "first")["id"] + original_create = backend.create + entered = asyncio.Event() + + async def slow_create(session: dict[str, Any], token: str, created: Any) -> dict[str, Any]: + entered.set() + await asyncio.sleep(0.5) + return await original_create(session, token, created) + + backend.create = slow_create + first = asyncio.create_task(worker.run(sid)) + await entered.wait() + await asyncio.sleep(0.3) + assert await worker.run(sid) is False + assert worker.due() == [] + assert await first + assert backend.created == 1 + with service.store.transaction() as tx: + assert tx.get(sid)["x_agent_sandbox"]["lifecycle"] == "ready" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +async def test_all_39_http_operations_against_frozen_inventory(components: Any) -> None: + from agent_sandbox.managed_agents.service import OPERATIONS + + service, backend, worker, _ = components + inventory = json.loads((Path(__file__).parent / "fixtures" / "managed_api_v1.json").read_text()) + definitions = {item["operation_id"]: item for item in inventory["operations"]} + assert {(o["method"], o["path"]) for o in definitions.values()} == set(OPERATIONS) + token = "inventory-test-business-token" + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + server = make_server(service, auth, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + values = {"model_id": "fixture-model"} + observed: set[str] = set() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", + headers={"Authorization": "Bearer " + token}, + trust_env=False, + ) as client: + + def call(name: str, body: Any = None, **kwargs: Any) -> Any: + definition = definitions[name] + path = definition["path"].format(**values) + response = client.request(definition["method"], path, json=body, **kwargs) + assert response.status_code in definition["success_codes"], (name, response.text) + observed.add(name) + return ( + response.json() + if response.content and "json" in response.headers.get("content-type", "") + else response + ) + + call("health_get") + call("capabilities_get") + call("models_list") + call("models_get") + call("runtimes_list") + agent = call("agents_create", {"model": "fixture-model", "instructions": "Be precise"}) + values["agent_id"] = agent["id"] + call("agents_list") + call("agents_get") + call("agents_update", {"name": "Reusable agent"}) + uploaded = call( + "files_create", + data={"purpose": "user_data"}, + files={"file": ("sales.csv", b"x,y\n1,2\n")}, + ) + values["file_id"] = uploaded["id"] + call("files_list") + call("files_get") + call("files_content") + session = call("sessions_create", {"agent_id": agent["id"]}) + values["session_id"] = session["id"] + values["environment_id"] = session["environment"]["id"] + call("sessions_list") + call("sessions_get") + call("sessions_update", {"metadata": {"label": "inventory"}}) + call("environments_list") + call("environments_get") + call( + "session_events_create", + {"input": "generate report"}, + headers={"Idempotency-Key": "first"}, + ) + assert await worker.run(session["id"]) + turns = call("turns_list")["data"] + values["turn_id"] = turns[0]["id"] + call("turns_get") + call("turn_items_list") + call("session_items_list") + artifacts = call("artifacts_list")["data"] + values["artifact_id"] = artifacts[0]["id"] + call("artifacts_get") + call("artifacts_content") + assert client.get("/v1/agents/environments/" + values["artifact_id"]).status_code == 404 + assert ( + client.get( + f"/v1/agent-sandbox/sessions/{session['id']}/submissions/{values['turn_id']}" + ).status_code + == 404 + ) + with client.stream( + "GET", f"/v1/agents/sessions/{session['id']}/events?after=0" + ) as stream: + assert stream.status_code == 200 + assert any(line.startswith("data:") for line in stream.iter_lines()) + observed.add("session_events_stream") + queued = call( + "submissions_enqueue", {"input": "queued"}, headers={"Idempotency-Key": "queued"} + ) + values["submission_id"] = queued["id"] + call("submissions_list") + call("submissions_get") + call("submissions_cancel") + call("turns_cancel") + operation = call("sessions_close") + values["operation_id"] = operation["id"] + assert await worker.run(session["id"]) + assert call("operations_get")["result"]["compute_released"] + call("artifacts_delete") + call("files_delete") + call("agents_delete") + call("sessions_delete") + assert observed == set(definitions) + # Every protected route authenticates before parsing or touching resources. + for name, definition in definitions.items(): + path = definition["path"].format(**values) + if name == "health_get": + assert client.get("/healthz/missing").status_code == 404 + else: + response = client.request( + definition["method"], path, headers={"Authorization": "Bearer invalid"} + ) + assert response.status_code == 401, (name, response.text) + assert client.get("/v1/agents/sessions/" + session["id"]).status_code == 404 + assert ( + client.post( + "/v1/agents", + json={ + "model": "fixture-model", + "tools": [{"type": "function", "name": "shell"}], + }, + ).status_code + == 400 + ) + assert not backend.resources + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +async def test_retention_gc_keeps_pending_files_and_uncertain_resources(components: Any) -> None: + from agent_sandbox.managed_agents.store import new_object + + service, _, worker, dispatcher = components + uploaded = service.upload_file("owner", "input.txt", "user_data", b"pending input") + session = service.handle( + "owner", + "POST", + "/v1/agents/sessions", + { + "agent": {"model": "fixture-model"}, + "input": "later", + "x_agent_sandbox": { + "input_files": [{"file_id": uploaded["id"], "path": "/workspace/input/input.txt"}] + }, + }, + )[1] + sid = session["id"] + with service.store.transaction() as tx: + file = tx.get(uploaded["id"]) + file["expires_at"] = time.time() - 1 + tx.put(file) + service.collect(tx, limit=1) + assert tx.get(uploaded["id"], with_data=True)["_data"] + sub = tx.list("submission", parent=sid)[0] + service._cancel(tx, tx.get(sid), sub) + service.collect(tx, limit=1) + assert not tx.get(uploaded["id"]).get("_has_blob") + service.handle("owner", "POST", f"/v1/agent-sandbox/sessions/{sid}/close") + assert await worker.run(sid) + with service.store.transaction() as tx: + closed = tx.get(sid) + closed["_retain_until"] = time.time() - 1 + tx.put(closed) + for _i in range(5): + tx.put( + new_object( + "artifact", + "owner", + parent=sid, + bytes=1, + _data="eA==", + expires_at=time.time() + 10, + ) + ) + uncertain = new_object( + "session", + "owner", + status="failed", + x_agent_sandbox={"lifecycle": "requires_reconcile"}, + _creation_started=True, + _revision=service.config.revision, + _retain_until=time.time() - 1, + _cleanup_confirmed=False, + ) + tx.put(uncertain) + for _ in range(20): + with service.store.transaction() as tx: + service.collect(tx, limit=1) + with service.store.transaction() as tx: + assert tx.get(sid, deleted=True).get("_collected") + assert tx.get(uncertain["id"], deleted=True)["_creation_started"] + assert not tx.events(sid, 0) + assert dispatcher.status()["uncertainResources"] == 1 + with pytest.raises(ManagedError) as expired: + service.handle("owner", "GET", f"/v1/agents/sessions/{sid}") + assert expired.value.status == 410 + + +def test_closed_tombstone_history_does_not_block_dispatch_or_admission(components: Any) -> None: + from agent_sandbox.managed_agents.store import new_object + + service, _, worker, dispatcher = components + with service.store.transaction() as tx: + for _ in range(10002): + tx.put( + new_object( + "session", + "historical", + _deleted=True, + _cleanup_confirmed=True, + _revision=service.config.revision, + x_agent_sandbox={"lifecycle": "closed"}, + ) + ) + assert worker.due() == [] + assert dispatcher.status()["activeSessions"] == 0 + assert create(service)["id"] + + +def test_partial_body_times_out_and_other_connections_remain_usable(components: Any) -> None: + import socket + + service = components[0] + token = "partial-body-test-key" + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:write", "agents:read"], + } + ] + } + ) + server = make_server(service, auth, port=0, read_timeout=0.15, max_connections=2) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with socket.create_connection(("127.0.0.1", server.server_port), timeout=2) as client: + client.sendall( + ( + f"POST /v1/agents HTTP/1.1\r\nHost: localhost\r\n" + f"Authorization: Bearer {token}\r\nContent-Length: 100\r\n\r\n{{" + ).encode() + ) + assert client.recv(100) == b"" + assert ( + httpx.get(f"http://127.0.0.1:{server.server_port}/healthz", trust_env=False).status_code + == 200 + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +@pytest.mark.asyncio +async def test_lost_submit_response_inspects_original_receipt_without_replay( + components: Any, monkeypatch: Any +) -> None: + import agent_sandbox.managed_agents.worker as implementation + from agent_sandbox.managed_agents.backend import bridge_request + + service, backend, worker, _ = components + sid = create(service, "one effect")["id"] + submits = 0 + + async def lost_response(resource: Any, method: str, path: str, body: Any = None) -> Any: + nonlocal submits + result = await bridge_request(resource, method, path, body) + if method == "POST" and path == "/managed/submissions": + submits += 1 + raise ManagedError(503, "connection_lost", "response was lost after acceptance") + return result + + monkeypatch.setattr(implementation, "bridge_request", lost_response) + assert await worker.run(sid) + assert submits == 1 + assert (backend.root / sid / "workspace" / "counter").read_text() == "1" + + +def test_deep_artifact_tree_reports_partial_and_preserves_valid_sibling(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + (workspace / "outputs" / "a" / "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9").mkdir( + parents=True + ) + (workspace / "outputs" / "z").mkdir() + (workspace / "outputs" / "z" / "expected.txt").write_text("expected") + bridge = ManagedBridge(str(tmp_path / "journal"), str(workspace)) + artifacts, errors = bridge._snapshots() + assert [a["path"] for a in artifacts] == ["outputs/z/expected.txt"] + assert any(e["code"] == "artifact_depth_limit" for e in errors) + + +@pytest.mark.asyncio +async def test_small_output_budget_reaches_real_cli_and_bounds_public_items( + components: Any, +) -> None: + service, _, worker, _ = components + service.config.limits["maxOutputBytes"] = 16 + sid = create(service, "output")["id"] + assert await worker.run(sid) + turns = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns")[1]["data"] + assert turns[0]["status"] == "failed" + assert service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/items")[1]["data"] == [] + with service.store.transaction() as tx: + submission = tx.list("submission", parent=sid)[0] + assert not submission.get("_native_seen") + + +@pytest.mark.asyncio +async def test_uncertain_session_never_executes_queued_successor(components: Any) -> None: + service, backend, worker, _ = components + sid = create(service, "first")["id"] + queued = service.handle( + "owner", + "POST", + f"/v1/agent-sandbox/sessions/{sid}/submissions", + {"input": "must not execute"}, + key="next", + )[1] + with service.store.transaction() as tx: + first = tx.list("submission", parent=sid)[0] + worker._uncertain(sid, first["id"], None, "injected_setup_failure") + operation = service.handle( + "owner", "POST", f"/v1/agent-sandbox/sessions/{sid}/submissions/{first['id']}/cancel" + )[1] + assert operation["status"] == "requires_reconcile" + assert operation["result"]["stop_confirmed"] is False + assert await worker.run(sid) is False + assert backend.created == 0 and not (backend.root / sid / "workspace" / "counter").exists() + assert ( + service.handle( + "owner", "GET", f"/v1/agent-sandbox/sessions/{sid}/submissions/{queued['id']}" + )[1]["status"] + == "queued" + ) + + +def test_private_bridge_preserves_provider_header_and_authenticates_bearer(tmp_path: Path) -> None: + bridge = ManagedBridge(str(tmp_path / "journal"), str(tmp_path / "workspace")) + token = "test-application-" + "a" * 32 + server = make_bridge_server(bridge, token, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", trust_env=False + ) as client: + assert ( + client.get( + "/capabilities", + headers={ + "X-Access-Token": "platform-test-token", + "Authorization": "Bearer " + token, + }, + ).status_code + == 200 + ) + assert ( + client.get( + "/capabilities", + headers={ + "X-Access-Token": "platform-test-token", + "Authorization": "Bearer wrong-test-token", + }, + ).status_code + == 401 + ) + assert client.get("/capabilities", headers={"Authorization": token}).status_code == 401 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) diff --git a/docs/en/README.md b/docs/en/README.md index e30813a1..c1939b64 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -147,3 +147,5 @@ The `abx` CLI (npm `@agent-sandbox/cli`) is documented in the [`cli` repository] The [E2B migration contract](guide/migrate-from-e2b.md) defines synchronous Sandbox, asynchronous AsyncSandbox and the interpreter API; provider/server extensions use ProviderSandbox. - [Local conditional acceptance](guide/conditional-acceptance.md) + +- [Managed Agents API / 托管 Agents API](guide/managed-agents-api.md) diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md new file mode 100644 index 00000000..a1b892a6 --- /dev/null +++ b/docs/en/guide/managed-agents-api.md @@ -0,0 +1,141 @@ +# Managed Agents API + +[中文](../../zh/guide/managed-agents-api.md) + +Use one Gateway URL and a business bearer token to run coding agents in isolated sandboxes. +A Session creates its sandbox on the first input and reuses its workspace for subsequent turns. +The optional runtime supports the built-in Express provider and explicitly configured Codex or +Qwen Code models. It implements selected OpenAI-inspired resource shapes; it does not claim +OpenAI SDK compatibility or OpenAI-managed harness behavior. + +Install the matching client and server packages with the managed deployment dependencies: + +```bash +python -m pip install --pre 'agent-sandbox-client[managed-agents]' +``` + +The [agents-api template](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/tree/main/agents-api) +provides the application configuration. Deploy it with the matching +[abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox). Applications connect to the +returned Gateway endpoint; they do not configure individual sandbox endpoints. + +## Send input and read results + +Set `AGENTS_API_URL` to the Gateway URL and `AGENTS_API_KEY` to your business token. The token is +issued by your deployment operator. It is separate from the Sandbox API Key, cloud credentials, +model API Key and sandbox application token. The template deployment prepares the private sandbox +application token automatically. + +```python +import os +import httpx + +with httpx.Client( + base_url=os.environ["AGENTS_API_URL"], + headers={"Authorization": "Bearer " + os.environ["AGENTS_API_KEY"]}, +) as api: + response = api.post( + "/v1/agents/sessions", + json={ + "agent": {"model": "your-deployment-model"}, + "input": "Write outputs/report.txt containing a short summary.", + }, + headers={"Idempotency-Key": "report-session-1"}, + ) + response.raise_for_status() + session_id = response.json()["id"] + print(session_id) + print(response.headers["X-Agent-Sandbox-Submission-Id"]) +``` + +Creation returns `201`. Input reception returns a durable Submission, while a public Turn appears +when execution starts. Poll the Session and its `/turns` and `/artifacts` collections. Download a +published artifact through `/v1/agents/sessions/{id}/artifacts/{artifact_id}/content`. +A subsequent `POST /v1/agents/sessions/{id}/events` with `{"input":"Continue"}` returns `202`, +an empty body, a Submission ID header and its `Location`. Repeat the same idempotency key and body +to retrieve the original acceptance; use a different key for a new input. + +Input during an active turn returns `409 active_turn_not_steerable`. Explicit queuing uses +`POST /v1/agent-sandbox/sessions/{id}/submissions`, requires `Idempotency-Key`, and returns `202` +with a Submission object. Queuing schedules another turn; it does not steer the active turn. + +## Files and events + +Upload files with multipart `POST /v1/files`, fields `purpose=user_data` and `file`. Attach an +uploaded file to input using `x_agent_sandbox.input_files: [{"file_id":"file_...", +"path":"/workspace/input/data.csv"}]`. Paths must be normalized beneath the deployment's +`input` directory. Existing files cannot be overwritten. The input snapshot is fixed when the +submission is accepted; deleting the public File does not revoke an already accepted attachment. +Only regular files under `outputs/` are published as immutable artifacts. Symbolic links, +unreadable or changing files are rejected and reported as partial publication. + +Subscribe with `GET /v1/agents/sessions/{id}/events`. Supply `Last-Event-ID` or `after` to resume; +`after=0` replays from the beginning, while omission tails new events. Streams close periodically; +reconnect using the last received event ID. Public events are durable during result retention. Deletion stops new frame writes across Gateway instances; a frame already in the network may finish arriving. Slow readers are disconnected, and oversized frames report a gap; use the durable items API for full content. +A native event gap is reported explicitly. Raw reasoning and provider secrets are not exposed. + +## Close, cancel and delete + +Cancel a queued Submission through its `/cancel` endpoint. Cancel active work by its exact Turn +or Submission ID, then poll the returned Operation until `stop_confirmed` is true. A cancellation +request is not proof that execution has stopped. Cooperative process observation may allow Session +reuse. If stopping cannot be established, the runtime releases the whole sandbox and closes the +Session. An unresolved provider result remains `requires_reconcile` and occupies capacity. + +`POST /v1/agent-sandbox/sessions/{id}/close` returns `202` and an Operation. It stops admission for +that Session, releases compute and retains published results until their retention deadline. +`DELETE /v1/agents/sessions/{id}` returns `200` and immediately removes public access; active +execution returns `409` until cancelled or closed. Physical cleanup may continue asynchronously. +Closing and deleting have different effects. Always close Sessions when finished and inspect +Operation results before treating resource cleanup as complete. + +## Supported resources and limits + +| Resource | Operations | +| --- | --- | +| Platform | Health, capabilities, model list/detail, runtime list | +| Agents | Create, list, read, update, delete saved configurations | +| Sessions | Create, list, read, metadata update, delete, input/events, items | +| Turns | List, read, per-turn items, exact cancellation | +| Artifacts | List, metadata, byte download, delete published snapshot | +| Files | Multipart upload, list, metadata, byte download, delete | +| Environments | Read-only list and detail | +| Managed lifecycle | Close, Operations, Submission enqueue/list/detail/cancel | + +`GET /v1/agent-sandbox/capabilities` returns the supported routes and deployment budgets. +Lists accept `limit`, `order` and `after`; use `last_id` or the scoped `next_cursor` for the next page. +Session updates support metadata only. Function tools, arbitrary MCP configuration, subagents, +steering, image/audio input and dynamic model/settings changes are rejected. Shell/file tools +provided by the selected CLI remain available inside its sandbox. Tool wall deadlines begin at +observed native tool-start events. Unknown runtime epochs or uncertain submission receipts freeze +execution; they never automatically replay an input. + +The Gateway requires PostgreSQL for FC deployment. SQLite is restricted to explicit local mode. +Storage, queues, execution time, events and subscribers have finite deployment budgets; quota +rejection returns `429`. Expired results return `410`; deleted resources return `404`. Cleanup +preserves private ownership records required to resolve uncertain resources. + +## Operator configuration + +Run `python -m agent_sandbox.managed_agents gateway|worker|dispatcher`. The guest image runs +`python -m agent_sandbox.server.managed --port 9000`; it requires Codex or Qwen Code installed. + +Each configuration document accepts exactly one `_FILE` or `_JSON` source: + +| Variable prefix | Document | +| --- | --- | +| `ABX_MANAGED_CONFIG` | Compiled revision, application ID, template, model/adapter allowlist, `modelEnvAllowlist`, Session/storage/execution budgets and retained Worker mapping | +| `ABX_MANAGED_AUTH` | `tokens: [{sha256,owner,scopes,expiresAt?}]`; scopes are `agents:read` and `agents:write` | +| `ABX_MANAGED_MODEL_CONFIG` | Worker-only `{apiKey,baseUrl,model,adapter}` | +| `ABX_MANAGED_PROVIDER_CONFIG` | Worker/Dispatcher `{provider:"alicloud-express",apiKey,baseUrl,domain,region}` | + +`ABX_MANAGED_DATABASE_URL` identifies PostgreSQL. `ABX_MANAGED_INTERNAL_TOKEN` authenticates private +role invocations and must remain stable while retained revisions drain. +`ABX_MANAGED_FC_ENDPOINT` selects the explicit FC service host for dispatch. +`modelEnvAllowlist` must explicitly allow `OPENAI_API_KEY`, `OPENAI_BASE_URL` and `OPENAI_MODEL`. +Credentials are passed only to roles which need them, and never put in images or public resources. +File-backed business authentication is reread for revocation; optional token expiry is enforced. + +TLS certificate verification remains enabled. The runtime uses the system trust store without +inheriting HTTP proxy settings. To trust a private CA, set `ABX_MANAGED_TLS_CA_FILE` to a readable +PEM CA bundle. Invalid CA configuration fails; it does not disable verification. diff --git a/docs/zh/README.md b/docs/zh/README.md index b4e7565f..707f0a3a 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -147,3 +147,5 @@ Agent Sandbox 是可切换提供方的沙箱 SDK。默认提供方是阿里云 [E2B 迁移契约](guide/migrate-from-e2b.md)定义同步 Sandbox、异步 AsyncSandbox 和解释器接口;Provider/Server 扩展使用 ProviderSandbox。 - [本地暂停与快照验收](guide/conditional-acceptance.md) + +- [Managed Agents API / 托管 Agents API](guide/managed-agents-api.md) diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md new file mode 100644 index 00000000..922c9dd9 --- /dev/null +++ b/docs/zh/guide/managed-agents-api.md @@ -0,0 +1,125 @@ +# 托管 Agents API + +[English](../../en/guide/managed-agents-api.md) + +应用只配置一个 Gateway 地址和业务 Bearer Token,即可让编码 Agent 在独立沙箱中运行。 +Session 在首次输入时创建沙箱,后续轮次复用工作区。可选托管运行时支持内置极速版提供方与明确配置的 +Codex、Qwen Code 模型。它采用部分 OpenAI 风格资源结构,不声明 OpenAI SDK 互操作或官方托管 harness 等价能力。 + +安装匹配版本的客户端、服务端与托管部署依赖: + +```bash +python -m pip install --pre 'agent-sandbox-client[managed-agents]' +``` + +[agents-api 模板](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/tree/main/agents-api) +提供应用配置;使用匹配的 [abx CLI](https://github.com/Alibaba-Agent-Sandbox/agent-sandbox) 部署。 +应用连接返回的 Gateway endpoint,无需配置每个沙箱的 endpoint。 + +## 提交输入与读取结果 + +将 `AGENTS_API_URL` 设为 Gateway 地址,`AGENTS_API_KEY` 设为部署方签发的业务令牌。 +它与 Sandbox API Key、云凭据、模型 Key、沙箱应用令牌分别使用。模板部署会自动准备私有沙箱应用令牌。 + +```python +import os +import httpx + +with httpx.Client( + base_url=os.environ["AGENTS_API_URL"], + headers={"Authorization": "Bearer " + os.environ["AGENTS_API_KEY"]}, +) as api: + response = api.post( + "/v1/agents/sessions", + json={ + "agent": {"model": "your-deployment-model"}, + "input": "在 outputs/report.txt 中写一份简短总结。", + }, + headers={"Idempotency-Key": "report-session-1"}, + ) + response.raise_for_status() + session_id = response.json()["id"] + print(session_id) + print(response.headers["X-Agent-Sandbox-Submission-Id"]) +``` + +创建返回 `201`。接收输入后生成持久 Submission,真正开始执行时才出现公开 Turn。 +轮询 Session、`/turns` 和 `/artifacts` 集合,通过 +`/v1/agents/sessions/{id}/artifacts/{artifact_id}/content` 下载已发布产物。 +后续向 `POST /v1/agents/sessions/{id}/events` 提交 `{"input":"继续"}`,返回 `202`、空正文、 +Submission ID 响应头和其 `Location`。相同幂等键与正文返回首次接收结果;新输入使用新键。 + +执行中直接输入返回 `409 active_turn_not_steerable`。显式排队使用 +`POST /v1/agent-sandbox/sessions/{id}/submissions`,必须提供 `Idempotency-Key`,返回 `202` 与 +Submission 对象。排队会创建下一轮,不会引导当前执行。 + +## 文件与事件 + +使用 multipart `POST /v1/files` 上传文件,字段为 `purpose=user_data` 与 `file`。 +提交输入时附带 `x_agent_sandbox.input_files: [{"file_id":"file_...", +"path":"/workspace/input/data.csv"}]`。路径必须规范化且处于部署工作区的 `input` 目录内, +已有文件不能覆盖。提交接收时固定输入快照,删除公开 File 不撤销已经接收的附件。 +只发布 `outputs/` 中普通文件的不可变产物快照;符号链接、不可读或读取期间变化的文件会被拒绝, +并报告部分发布结果。 + +通过 `GET /v1/agents/sessions/{id}/events` 订阅事件,用 `Last-Event-ID` 或 `after` 恢复。 +`after=0` 从头回放;省略时只接收新事件。流会定期结束,客户端用最后收到的事件 ID 重连。 +公开事件在结果保留期内持久保存。删除会阻止各 Gateway 实例开始发送新帧,已经进入网络的帧仍可能到达;慢读连接会断开,过大帧明确报告缺口,完整内容通过持久 items 接口读取。原生事件缺口会明确报告,不公开原始推理和提供方秘密。 + +## 取消、关闭与删除 + +通过 Submission 的 `/cancel` 取消排队输入。执行中按明确的 Turn 或 Submission ID 取消, +持续查询返回的 Operation,直到 `stop_confirmed` 为 true。接受取消请求并不证明执行已经停止。 +合作进程观测能够确认停止时允许复用 Session;无法确认时销毁整个沙箱并关闭 Session。 +提供方结果仍不确定时保留 `requires_reconcile`,并继续占用容量。 + +`POST /v1/agent-sandbox/sessions/{id}/close` 返回 `202` 与 Operation,停止该 Session 接收输入、 +释放计算资源,结果保留至截止时间。`DELETE /v1/agents/sessions/{id}` 返回 `200` 后立即撤销公开访问; +执行中返回 `409`,应先取消或关闭。物理清理可能异步继续。关闭与删除含义不同。 +使用完毕应关闭 Session,并检查 Operation 结果后再将资源清理视为完成。 + +## 支持范围与限制 + +| 资源 | 操作 | +| --- | --- | +| 平台 | 健康检查、能力、模型列表及详情、运行时列表 | +| Agent | 保存配置的创建、列表、读取、更新与删除 | +| Session | 创建、列表、读取、metadata 更新、删除、输入及事件、items | +| Turn | 列表、详情、所属 items、精确取消 | +| Artifact | 列表、元数据、下载、删除已发布快照 | +| File | multipart 上传、列表、元数据、下载与删除 | +| Environment | 只读列表与详情 | +| 托管生命周期 | 关闭、Operation、Submission 排队、列表、详情与取消 | + +`GET /v1/agent-sandbox/capabilities` 返回支持路由与部署预算。 +列表接受 `limit`、`order`、`after`,下一页使用 `last_id` 或限定范围的 `next_cursor`。 +Session 仅支持更新 metadata。Function tools、任意 MCP 配置、subagents、steering、图像/音频输入、 +动态模型和 settings 更新会明确拒绝;所选 CLI 自带的 shell、文件工具仍在沙箱内可用。 +工具墙钟期限从观察到原生工具开始事件起计算。运行时 epoch 改变或提交收据不确定时冻结执行, +不会自动重放输入。 + +FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、队列、执行时间、事件与订阅者均有有限预算, +额度不足返回 `429`。过期结果返回 `410`,已删除资源返回 `404`。 +清理会保留解决不确定资源所需的私有归属记录。 + +## 部署方配置 + +入口为 `python -m agent_sandbox.managed_agents gateway|worker|dispatcher`。 +沙箱镜像运行 `python -m agent_sandbox.server.managed --port 9000`,并需安装 Codex 或 Qwen Code。 + +以下每种配置只能选择一个 `_FILE` 或 `_JSON` 来源: + +| 环境变量前缀 | 配置正文 | +| --- | --- | +| `ABX_MANAGED_CONFIG` | 编译后的 revision、application ID、模板、模型/adapter 白名单、`modelEnvAllowlist`、Session/存储/执行预算与保留 Worker 映射 | +| `ABX_MANAGED_AUTH` | `tokens: [{sha256,owner,scopes,expiresAt?}]`,scope 为 `agents:read`、`agents:write` | +| `ABX_MANAGED_MODEL_CONFIG` | 仅 Worker 使用 `{apiKey,baseUrl,model,adapter}` | +| `ABX_MANAGED_PROVIDER_CONFIG` | Worker/Dispatcher 使用 `{provider:"alicloud-express",apiKey,baseUrl,domain,region}` | + +`ABX_MANAGED_DATABASE_URL` 指向 PostgreSQL。`ABX_MANAGED_INTERNAL_TOKEN` 认证私有角色调用, +保留旧 revision 排空期间应保持稳定。`ABX_MANAGED_FC_ENDPOINT` 指定派发所用 FC 服务主机。 +`modelEnvAllowlist` 必须明确允许 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`。 +凭据仅交给需要它的角色,不写入镜像或公开资源。文件来源的业务认证每次重读以支持撤权,并执行可选到期限制。 + +TLS 始终校验证书,默认采用系统信任库且不继承 HTTP 代理设置。 +私有 CA 使用 `ABX_MANAGED_TLS_CA_FILE` 指向可读 PEM CA bundle;CA 配置错误会失败,不会关闭证书校验。 diff --git a/e2e/managed_sse_pg.py b/e2e/managed_sse_pg.py new file mode 100644 index 00000000..4cc12c66 --- /dev/null +++ b/e2e/managed_sse_pg.py @@ -0,0 +1,442 @@ +"""Independent cross-process PostgreSQL/SSE deletion and slow-reader checks. + +No cloud/model calls. Barriers only suspend real event fetch or socket writes; +all ownership, authorization, transactions and session gates are product code. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import select +import socket +import subprocess # nosec B404 - owns fixture Gateway child processes +import sys +import tempfile +import threading +import time +from pathlib import Path + +import httpx +import pgserver + +import agent_sandbox.managed_agents.service as managed_service +from agent_sandbox.managed_agents import ManagedConfig, ManagedService, ManagedStore +from agent_sandbox.managed_agents.http import TokenAuthenticator, make_server + +SOURCE = Path(managed_service.__file__).parent + + +def hashes(): + return { + name: hashlib.sha256((SOURCE / name).read_bytes()).hexdigest() + for name in ("store.py", "service.py", "http.py") + } + + +def record(path, kind, **extra): + with Path(path).open("a") as stream: + stream.write(json.dumps({"kind": kind, "ns": time.monotonic_ns(), **extra}) + "\n") + + +def wait_file(path, timeout=4): + end = time.monotonic() + timeout + while not Path(path).exists(): + if time.monotonic() > end: + raise RuntimeError("barrier_deadline") + time.sleep(0.01) + + +def child(file): + doc = json.loads(Path(file).read_text()) + service = ManagedService( + ManagedStore(doc["database"], doc["application"]), ManagedConfig.from_dict(doc["config"]) + ) + barrier = doc.get("barrier") + original_events = service.events + first_fetch = True + + def events(*args, **kwargs): + nonlocal first_fetch + rows = original_events(*args, **kwargs) + record( + doc["log"], + "event_fetch", + count=len(rows), + text_bytes=sum(len(r.get("delta", "")) for r in rows), + ) + if barrier == "fetch" and first_fetch: + first_fetch = False + Path(doc["ready"]).touch() + wait_file(doc["release"], 8) + return rows + + service.events = events + original_handle = service.handle + + def handle(owner, method, path, *args, **kwargs): + result = original_handle(owner, method, path, *args, **kwargs) + if method == "DELETE" and path.startswith("/v1/agents/sessions/"): + record(doc["log"], "delete_committed", status=result[0]) + return result + + service.handle = handle + auth = TokenAuthenticator(lambda: json.loads(Path(doc["auth"]).read_text())) + server = make_server(service, auth, port=0, write_timeout=0.5, max_frame_bytes=1048576) + handler = server.RequestHandlerClass + first_write = True + + class Writer: + def __init__(self, wrapped): + self.wrapped = wrapped + + def __getattr__(self, name): + return getattr(self.wrapped, name) + + def write(self, data): + nonlocal first_write + frame = data.startswith(b"id: ") + if frame and barrier == "write" and first_write: + first_write = False + Path(doc["ready"]).touch() + wait_file(doc["release"], 8) + if frame: + record(doc["log"], "frame_write_start", bytes=len(data)) + try: + result = self.wrapped.write(data) + except OSError: + if frame: + record(doc["log"], "frame_write_failed", bytes=len(data)) + raise + if frame: + record(doc["log"], "frame_enqueued", bytes=len(data)) + return result + + original_setup = handler.setup + + def setup(self): + original_setup(self) + self.wfile = Writer(self.wfile) + + handler.setup = setup + print(json.dumps({"port": server.server_port}), flush=True) + server.serve_forever() + + +def rows(path): + return ( + [json.loads(line) for line in Path(path).read_text().splitlines()] + if Path(path).exists() + else [] + ) + + +def main(output=None): + result = { + "scope": "independent_real_postgresql_two_gateway_processes", + "cloud": False, + "model": False, + "tests": [], + "source_before": hashes(), + } + processes = [] + pg = None + with tempfile.TemporaryDirectory(prefix="managed-sse-pg-review-") as raw: + root = Path(raw) + try: + pg = pgserver.get_server(root / "pgdata", cleanup_mode="delete") + database = pg.get_uri() + application = "independent-sse-review" + config = { + "revision": "review", + "applicationId": application, + "template": "unused", + "models": {"review-model": "qwen-code"}, + } + service = ManagedService( + ManagedStore(database, application), ManagedConfig.from_dict(config) + ) + token = os.urandom(32).hex() + auth = root / "auth.json" + auth.write_text( + json.dumps( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "review-owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + ) + auth.chmod(0o600) + headers = {"Authorization": "Bearer " + token} + + def session(): + return service.handle( + "review-owner", + "POST", + "/v1/agents/sessions", + {"agent": {"model": "review-model"}}, + )[1]["id"] + + def spawn(label, barrier=None): + document = { + "database": database, + "application": application, + "config": config, + "auth": str(auth), + "log": str(root / (label + ".log")), + "barrier": barrier, + "ready": str(root / (label + ".ready")), + "release": str(root / (label + ".release")), + } + file = root / (label + ".json") + file.write_text(json.dumps(document)) + file.chmod(0o600) + errors = (root / (label + ".stderr")).open("w") + proc = subprocess.Popen( # nosec B603 - this script, Python argv, no shell + [sys.executable, __file__, "--child", str(file)], + stdout=subprocess.PIPE, + stderr=errors, + text=True, + ) + processes.append(proc) + if not select.select([proc.stdout], [], [], 10)[0]: + raise RuntimeError("gateway_start_deadline") + port = json.loads(proc.stdout.readline())["port"] + return "http://127.0.0.1:" + str(port), document + + controller, control_doc = spawn("controller") + + def delete(sid): + with httpx.Client(timeout=5, trust_env=False) as client: + return client.delete(controller + "/v1/agents/sessions/" + sid, headers=headers) + + def consume(base, sid, out): + try: + with ( + httpx.Client(timeout=4, trust_env=False) as client, + client.stream( + "GET", + base + "/v1/agents/sessions/" + sid + "/events?after=0", + headers=headers, + ) as response, + ): + out["status"] = response.status_code + for line in response.iter_lines(): + if line.startswith("data:"): + out.setdefault("events", []).append(json.loads(line[5:])["type"]) + except Exception as error: + out["closed_as"] = type(error).__name__ + + # Real fetch precedes DELETE, but frame admission occurs after deletion. + sid = session() + base, doc = spawn("prefetch", "fetch") + out = {} + stream = threading.Thread(target=consume, args=(base, sid, out), daemon=True) + stream.start() + wait_file(doc["ready"]) + response = delete(sid) + Path(doc["release"]).touch() + stream.join(3) + writes = rows(doc["log"]) + ok = response.status_code == 200 and not any( + r["kind"] == "frame_enqueued" for r in writes + ) + result["tests"].append( + { + "name": "prefetched_batch_deleted_in_other_process", + "passed": ok, + "delete_status": response.status_code, + "enqueued_frames": sum(r["kind"] == "frame_enqueued" for r in writes), + "stream_status": out.get("status"), + } + ) + # Pause an admitted frame while holding only its Session gate. + sid = session() + other = session() + base, doc = spawn("admitted", "write") + out = {} + stream = threading.Thread(target=consume, args=(base, sid, out), daemon=True) + stream.start() + wait_file(doc["ready"]) + deleted = {} + + def deleting(): + start = time.monotonic() + response = delete(sid) + deleted.update(status=response.status_code, seconds=time.monotonic() - start) + + deletion = threading.Thread(target=deleting) + deletion.start() + time.sleep(0.15) + pending = deletion.is_alive() + start = time.monotonic() + with httpx.Client(timeout=2, trust_env=False) as client: + other_status = client.get( + controller + "/v1/agents/sessions/" + other, headers=headers + ).status_code + other_seconds = time.monotonic() - start + Path(doc["release"]).touch() + deletion.join(4) + stream.join(3) + committed = [ + r["ns"] for r in rows(control_doc["log"]) if r["kind"] == "delete_committed" + ][-1] + enqueued = [r["ns"] for r in rows(doc["log"]) if r["kind"] == "frame_enqueued"] + ok = ( + pending + and deleted.get("status") == 200 + and other_status == 200 + and other_seconds < 1 + and bool(enqueued) + and max(enqueued) < committed + ) + result["tests"].append( + { + "name": "inflight_frame_orders_with_cross_process_delete", + "passed": ok, + "delete_waited_for_session_gate": pending, + "delete_status": deleted.get("status"), + "unrelated_session_status": other_status, + "unrelated_session_seconds": round(other_seconds, 3), + "all_enqueues_before_delete_commit": bool(enqueued) + and max(enqueued) < committed, + } + ) + # A real TCP peer accepts headers and then stops consuming large frames. + sid = session() + other = session() + with service.store.transaction() as tx: + current = tx.get(sid) + for index in range(12): + tx.event( + current, + "agent.session.output_text.delta", + {"delta": "x" * 900000, "test_index": index}, + ) + base, doc = spawn("slow") + port = int(base.rsplit(":", 1)[1]) + peer = socket.socket() + peer.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024) + peer.settimeout(3) + peer.connect(("127.0.0.1", port)) + peer.sendall( + ( + "GET /v1/agents/sessions/" + + sid + + "/events?after=0 HTTP/1.1\r\nHost: 127.0.0.1:" + + str(port) + + "\r\nAuthorization: Bearer " + + token + + "\r\nUser-Agent: independent-sse-review\r\n\r\n" + ).encode() + ) + header = b"" + while not header.endswith(b"\r\n\r\n"): + header += peer.recv(1) + # Wait for a genuinely blocked large socket write, rather than racing + # deletion against the initial multi-megabyte database read. + blocked = False + until = time.monotonic() + 4 + while time.monotonic() < until: + records = rows(doc["log"]) + started = sum( + r["kind"] == "frame_write_start" and r["bytes"] > 800000 for r in records + ) + ended = sum( + r["kind"] in {"frame_enqueued", "frame_write_failed"} and r["bytes"] > 800000 + for r in records + ) + if started > ended: + time.sleep(0.08) + records = rows(doc["log"]) + now_ended = sum( + r["kind"] in {"frame_enqueued", "frame_write_failed"} + and r["bytes"] > 800000 + for r in records + ) + if started > now_ended: + blocked = True + break + time.sleep(0.01) + start = time.monotonic() + with httpx.Client(timeout=2, trust_env=False) as client: + other_status = client.get( + controller + "/v1/agents/sessions/" + other, headers=headers + ).status_code + other_seconds = time.monotonic() - start + start = time.monotonic() + response = delete(sid) + delete_seconds = time.monotonic() - start + time.sleep(0.6) + peer.close() + records = rows(doc["log"]) + failed = sum(r["kind"] == "frame_write_failed" for r in records) + committed = [ + r["ns"] for r in rows(control_doc["log"]) if r["kind"] == "delete_committed" + ][-1] + after = sum(r["kind"] == "frame_enqueued" and r["ns"] > committed for r in records) + ok = ( + blocked + and response.status_code == 200 + and delete_seconds < 3 + and other_status == 200 + and other_seconds < 1 + and failed > 0 + and after == 0 + ) + result["tests"].append( + { + "name": "saturated_reader_is_bounded_and_other_session_progresses", + "passed": ok, + "blocked_large_write_observed": blocked, + "stream_status_line": header.split(b"\r\n", 1)[0].decode(), + "delete_status": response.status_code, + "delete_seconds": round(delete_seconds, 3), + "unrelated_session_seconds": round(other_seconds, 3), + "failed_bounded_writes": failed, + "enqueues_after_delete_commit": after, + "trace": records, + } + ) + except BaseException as error: + result["failure"] = { + "exception": type(error).__name__, + "label": str(error) if isinstance(error, RuntimeError) else None, + } + finally: + for proc in processes: + proc.terminate() + try: + proc.wait(3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if pg: + pg.cleanup() + result["source_after"] = hashes() + result["same_source_snapshot"] = result["source_before"] == result["source_after"] + result["passed"] = len(result["tests"]) == 3 and all( + t["passed"] for t in result["tests"] + ) + if output: + Path(output).write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--child") + parser.add_argument("--output") + args = parser.parse_args() + if args.child: + child(args.child) + else: + raise SystemExit(main(args.output)) diff --git a/server-sdk/README.md b/server-sdk/README.md index 96fb63dc..a78cbec9 100644 --- a/server-sdk/README.md +++ b/server-sdk/README.md @@ -51,3 +51,5 @@ cd server-sdk && pytest && ruff check src tests && mypy src/agent_sandbox/server ``` Licensed under Apache-2.0. + +Managed per-Session Agents API deployment: [English guide](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/en/guide/managed-agents-api.md) / [中文指南](https://github.com/Alibaba-Agent-Sandbox/python-sdk/blob/main/docs/zh/guide/managed-agents-api.md). diff --git a/server-sdk/src/agent_sandbox/server/managed.py b/server-sdk/src/agent_sandbox/server/managed.py new file mode 100644 index 00000000..3f656ec4 --- /dev/null +++ b/server-sdk/src/agent_sandbox/server/managed.py @@ -0,0 +1,663 @@ +"""Private, restart-aware bridge for one Gateway-owned Sandbox Session. + +This listener is separate from the public local Agents API. It reuses the real +CLI runtime, but never exposes arbitrary shell/files/provider administration. +The journal handles cooperative runtime failures, not hostile same-UID code. +""" + +from __future__ import annotations + +import argparse +import base64 +import contextlib +import hashlib +import hmac +import json +import math +import os +import secrets +import shutil +import sqlite3 +import stat +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from .agents.config import AgentSpec, LimitsSpec, ProviderSpec, WorkspaceSpec +from .agents.runtime import AgentRuntime +from .agents.wire_sessions import SessionsEventMapper, item_object, turn_object +from .managed_process import ProcessDomain + +PROTOCOL = "agent-sandbox-managed-bridge/v1" +_TERMINAL = frozenset({"completed", "failed", "cancelled", "uncertain"}) + + +class BridgeError(Exception): + """A private protocol error with no arbitrary exception disclosure.""" + + def __init__(self, status: int, code: str) -> None: + super().__init__(code) + self.status, self.code = status, code + + +def _json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +class ManagedBridge: + """Own one local Agent Session and durable accepted-submission identities.""" + + def __init__( + self, + state_dir: str, + workspace: str, + *, + runtime: AgentRuntime | None = None, + max_file_bytes: int = 10485760, + max_output_bytes: int = 8388608, + ) -> None: + self.state_dir = Path(state_dir) + self.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + self.workspace = os.path.abspath(workspace) + os.makedirs(self.workspace, exist_ok=True) + self.max_file_bytes, self.max_output_bytes = max_file_bytes, max_output_bytes + self.epoch = secrets.token_hex(16) + self.lock = threading.RLock() + self.domains: dict[str, ProcessDomain] = {} + self.runtime = runtime or AgentRuntime( + persist=False, max_sessions=1, max_concurrent_turns=1, event_buffer=20000 + ) + self.path = self.state_dir / "receipts.sqlite3" + with self._db() as db: + db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY,body TEXT NOT NULL)") + db.execute( + "CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY,body TEXT NOT NULL)" + ) + for rid, raw in db.execute("SELECT id,body FROM receipts").fetchall(): + row = json.loads(raw) + if row["status"] not in _TERMINAL: + row.update(status="uncertain", error={"code": "runtime_restarted"}) + db.execute("UPDATE receipts SET body=? WHERE id=?", (_json(row), rid)) + os.chmod(self.path, 0o600) + + def _db(self) -> sqlite3.Connection: + db = sqlite3.connect(self.path, timeout=30) + db.execute("PRAGMA synchronous=FULL") + return db + + def _session_record(self) -> dict[str, Any] | None: + with self._db() as db: + row = db.execute("SELECT body FROM meta WHERE key='session'").fetchone() + return json.loads(row[0]) if row else None + + def create_session(self, body: dict[str, Any]) -> dict[str, Any]: + """Persist a stable creation nonce; a second mapping is never silently created.""" + if set(body) != {"nonce", "agent"} or not isinstance(body["nonce"], str): + raise BridgeError(400, "invalid_session") + with self.lock: + saved = self._session_record() + request_digest = hashlib.sha256(_json(body).encode()).hexdigest() + if saved: + if saved["nonce"] != body["nonce"] or saved["digest"] != request_digest: + raise BridgeError(409, "session_identity_conflict") + return {k: saved[k] for k in ("session_id", "epoch")} + agent = body["agent"] + if not isinstance(agent, dict) or set(agent) - {"adapter", "model", "instructions"}: + raise BridgeError(400, "invalid_agent") + adapter = agent.get("adapter") + if adapter not in {"codex", "qwen-code"}: + raise BridgeError(400, "unsupported_adapter") + spec = AgentSpec( + name="managed", + runtime=adapter, + model=agent.get("model"), + instructions=agent.get("instructions"), + workspace=WorkspaceSpec(path=self.workspace), + provider=ProviderSpec( + base_url=os.getenv("OPENAI_BASE_URL"), + api_key=os.getenv("OPENAI_API_KEY"), + wire_api="responses" if adapter == "codex" else "chat_completions", + ), + permissions="full_access", + limits=LimitsSpec(max_output_bytes=self.max_output_bytes), + ) + self.runtime.store.put(spec) + local = self.runtime.create_session(spec.name) + saved = { + "nonce": body["nonce"], + "digest": request_digest, + "session_id": local.id, + "epoch": self.epoch, + } + with self._db() as db: + db.execute("INSERT INTO meta(key,body) VALUES('session',?)", (_json(saved),)) + return {k: saved[k] for k in ("session_id", "epoch")} + + def _write_input(self, file: dict[str, Any]) -> None: + if set(file) != {"path", "sha256", "data_base64"}: + raise BridgeError(400, "invalid_file") + path = file["path"] + prefix = self.workspace.rstrip("/") + "/input/" + if ( + not isinstance(path, str) + or not path.startswith(prefix) + or os.path.normpath(path) != path + or "\x00" in path + or "\\" in path + ): + raise BridgeError(400, "invalid_file_path") + try: + content = base64.b64decode(file["data_base64"], validate=True) + except (ValueError, TypeError) as exc: + raise BridgeError(400, "invalid_file_data") from exc + if ( + len(content) > self.max_file_bytes + or hashlib.sha256(content).hexdigest() != file["sha256"] + ): + raise BridgeError(400, "file_digest_or_size") + relative = path[len(self.workspace.rstrip("/")) + 1 :].split("/") + fd = os.open(self.workspace, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for component in relative[:-1]: + if not component or component in {".", ".."}: + raise BridgeError(400, "invalid_file_path") + with contextlib.suppress(FileExistsError): + os.mkdir(component, mode=0o700, dir_fd=fd) + child = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = child + name = relative[-1] + try: + target = os.open( + name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd + ) + except FileExistsError as exc: + raise BridgeError(409, "input_path_exists") from exc + with os.fdopen(target, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + except OSError as exc: + raise BridgeError(409, "input_path_unavailable") from exc + finally: + os.close(fd) + + def submit(self, body: dict[str, Any]) -> dict[str, Any]: + """Persist acceptance before starting one real CLI turn, without replay after restart.""" + if set(body) - { + "submission_id", + "session_id", + "input", + "files", + "deadline", + "max_tool_seconds", + "max_output_bytes", + "max_events", + } or not {"submission_id", "session_id", "input", "files", "deadline"} <= set(body): + raise BridgeError(400, "invalid_submission") + tool_seconds = body.get("max_tool_seconds", 60) + if type(tool_seconds) not in {int, float} or not 0 < tool_seconds <= 3600: + raise BridgeError(400, "invalid_tool_deadline") + output_bytes, max_events = ( + body.get("max_output_bytes", self.max_output_bytes), + body.get("max_events", 10000), + ) + if ( + (not isinstance(output_bytes, int) or isinstance(output_bytes, bool)) + or output_bytes < 1 + or (not isinstance(max_events, int) or isinstance(max_events, bool)) + or max_events < 1 + ): + raise BridgeError(400, "invalid_output_budget") + rid = body["submission_id"] + if ( + not isinstance(rid, str) + or not re_identifier(rid) + or not isinstance(body["input"], str) + or len(body["input"].encode()) > 262144 + ): + raise BridgeError(400, "invalid_submission") + if ( + not isinstance(body["files"], list) + or len(body["files"]) > 50 + or type(body["deadline"]) not in {int, float} + or not math.isfinite(body["deadline"]) + ): + raise BridgeError(400, "invalid_submission") + identity = hashlib.sha256(_json(body).encode()).hexdigest() + with self.lock: + with self._db() as db: + raw = db.execute("SELECT body FROM receipts WHERE id=?", (rid,)).fetchone() + if raw: + row = json.loads(raw[0]) + if row["digest"] != identity: + raise BridgeError(409, "submission_mismatch") + return self.get(rid) + saved = self._session_record() + if ( + not saved + or saved["session_id"] != body["session_id"] + or saved["epoch"] != self.epoch + ): + raise BridgeError(409, "runtime_epoch_changed") + session = self.runtime.get_session(saved["session_id"]) + if session.status != "idle": + raise BridgeError(409, "session_busy") + remaining = body["deadline"] - time.time() + if remaining < 1: + raise BridgeError(409, "turn_deadline") + spec = self.runtime.store.require(session.agent_name) + spec.limits.turn_timeout_s = remaining + spec.limits.idle_timeout_s = min(300, remaining) + spec.limits.max_output_bytes = output_bytes + scope = secrets.token_hex(24) + spec.env["ABX_MANAGED_TURN_SCOPE"] = scope + self.domains[rid] = ProcessDomain(scope) + row = { + "submission_id": rid, + "session_id": session.id, + "epoch": self.epoch, + "turn_id": "turn_" + rid, + "digest": identity, + "status": "accepted", + "created_at": time.time(), + "error": None, + } + # FULL synchronous journal commit occurs before staging or process launch. + self._put(row) + try: + for file in body["files"]: + self._write_input(file) + self.runtime.submit( + session, body["input"], idempotency_key=rid, turn_id=row["turn_id"] + ) + row["status"] = "running" + self._put(row) + threading.Thread( + target=self._watch_tools, args=(rid, float(tool_seconds)), daemon=True + ).start() + except Exception: + # Some staging or launch effects may already exist. Never retry the + # input merely because a request failed between two journal writes. + row.update(status="uncertain", error={"code": "submission_result_uncertain"}) + self._put(row) + raise BridgeError(409, "submission_result_uncertain") from None + return self.get(rid) + + def _watch_tools(self, rid: str, seconds: float) -> None: + """Enforce wall deadlines from real parser start/done events, independent of polling.""" + with self.lock: + with self._db() as db: + raw = db.execute("SELECT body FROM receipts WHERE id=?", (rid,)).fetchone() + assert raw is not None + row = json.loads(raw[0]) + session, turn = self.runtime.find_turn(row["turn_id"]) + cursor = 0 + active: dict[str, float] = {} + while not turn.done.wait(0.02): + for event in session.events_after(cursor): + cursor = event.seq + if event.turn_id != turn.id: + continue + item = event.data.get("item", {}) + if item.get("type") not in {"command_execution", "mcp_call"}: + continue + if event.type == "item.added": + active[item["id"]] = time.monotonic() + elif event.type == "item.done": + active.pop(item["id"], None) + if any(time.monotonic() - start >= seconds for start in active.values()): + with self.lock: + with self._db() as db: + raw = db.execute("SELECT body FROM receipts WHERE id=?", (rid,)).fetchone() + assert raw is not None + row = json.loads(raw[0]) + row["error"] = {"code": "tool_deadline_exceeded"} + self._put(row) + self.runtime.cancel_turn(turn) + return + + def _put(self, row: dict[str, Any]) -> None: + with self._db() as db: + db.execute( + "INSERT INTO receipts(id,body) VALUES(?,?) ON CONFLICT(id) DO UPDATE " + "SET body=excluded.body", + (row["submission_id"], _json(row)), + ) + + def _output_candidates(self, errors: list[dict[str, str]]) -> Any: + stack = [(os.path.join(self.workspace, "outputs"), 0)] + inspected, deadline = 0, time.monotonic() + 5 + while stack: + directory, depth = stack.pop() + try: + with os.scandir(directory) as entries: + for entry in entries: + inspected += 1 + if inspected > 4096 or time.monotonic() > deadline: + errors.append({"path": "outputs", "code": "artifact_scan_limit"}) + return + if entry.is_dir(follow_symlinks=False): + if depth >= 8: + errors.append( + { + "path": os.path.relpath(entry.path, self.workspace), + "code": "artifact_depth_limit", + } + ) + else: + stack.append((entry.path, depth + 1)) + else: + yield entry.path + except OSError: + errors.append( + { + "path": os.path.relpath(directory, self.workspace), + "code": "artifact_scan_failed", + } + ) + + def _snapshots(self) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + artifacts: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + output = os.path.join(self.workspace, "outputs") + if not os.path.isdir(output) or os.path.islink(output): + return artifacts, errors + total = 0 + for raw in self._output_candidates(errors): + if len(artifacts) >= 100: + errors.append({"path": "outputs", "code": "artifact_count_limit"}) + break + relative = os.path.relpath(raw, self.workspace).replace(os.sep, "/") + try: + # Enumeration only supplies candidates. Every ancestor is opened + # relative to a held workspace fd, without following links. + parent_fd = os.open(self.workspace, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + components = relative.split("/") + for component in components[:-1]: + child = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + os.close(parent_fd) + parent_fd = child + descriptor = os.open( + components[-1], + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=parent_fd, + ) + finally: + os.close(parent_fd) + try: + first = os.fstat(descriptor) + if not stat.S_ISREG(first.st_mode) or first.st_size > self.max_file_bytes: + raise ValueError + with os.fdopen(descriptor, "rb", closefd=False) as stream: + content = stream.read(self.max_file_bytes + 1) + last = os.fstat(descriptor) + finally: + os.close(descriptor) + if ( + len(content) > self.max_file_bytes + or (first.st_size, first.st_mtime_ns, first.st_ino) + != (last.st_size, last.st_mtime_ns, last.st_ino) + or total + len(content) > self.max_file_bytes + ): + raise ValueError + total += len(content) + artifacts.append( + { + "path": relative, + "sha256": hashlib.sha256(content).hexdigest(), + "bytes": len(content), + "data_base64": base64.b64encode(content).decode(), + } + ) + except (OSError, ValueError): + errors.append({"path": relative, "code": "artifact_capture_failed"}) + return artifacts, errors + + def get(self, rid: str, after: int = 0) -> dict[str, Any]: + """Return the stable receipt, current native state and explicitly gapped live events.""" + with self.lock: + with self._db() as db: + saved = db.execute("SELECT body FROM receipts WHERE id=?", (rid,)).fetchone() + if not saved: + raise BridgeError(404, "submission_not_found") + row = json.loads(saved[0]) + if row["epoch"] != self.epoch or row["status"] == "uncertain": + return {**row, "current_epoch": self.epoch, "events": [], "gap": True} + session, turn = self.runtime.find_turn(row["turn_id"]) + mapper = SessionsEventMapper(session, self.runtime.store.get(session.agent_name)) + events = [] + earliest = session._base + selected = session.events_after(after)[:256] + for event in selected: + for wire in mapper.map(event): + # Host assigns public IDs and independently controls terminal + # publication only after its result/artifact transaction. + events.append({"seq": event.seq, "wire": wire}) + if turn.done.is_set() and row["status"] not in _TERMINAL: + domain = self.domains.pop(rid, None) + stopped = domain.quiesce() if domain else False + artifacts, errors = self._snapshots() + row.update( + status="failed" if row.get("error") else turn.status, + turn=turn_object(turn), + items=[item_object(i) for i in turn.items], + artifacts=artifacts, + artifact_errors=errors, + completed_at=time.time(), + stop_confirmed=stopped, + ) + self._put(row) + returned = selected[-1].seq if selected else after + response = { + **row, + "current_epoch": self.epoch, + "events": events, + "returned_cursor": returned, + "latest_cursor": session.last_seq, + "earliest_cursor": earliest, + "gap": after + 1 < earliest, + } + if returned < session.last_seq: + response.pop("artifacts", None) + response.pop("items", None) + return response + + def cancel(self, rid: str, body: dict[str, Any]) -> dict[str, Any]: + """Cancel the exact receipt turn, never the next active turn.""" + if set(body) != {"epoch", "session_id"}: + raise BridgeError(400, "invalid_cancel") + with self.lock: + row = self.get(rid) + if row["epoch"] != body["epoch"] or row["session_id"] != body["session_id"]: + raise BridgeError(409, "cancel_identity_conflict") + if row["status"] not in _TERMINAL: + _, turn = self.runtime.find_turn(row["turn_id"]) + self.runtime.cancel_turn(turn) + return self.get(rid) + + +def re_identifier(value: str) -> bool: + """Accept only opaque protocol IDs, never paths.""" + return 1 <= len(value) <= 128 and all(c.isascii() and (c.isalnum() or c in "_-") for c in value) + + +def make_bridge_server( + bridge: ManagedBridge, + token: str, + *, + host: str = "127.0.0.1", + port: int = 9000, + read_timeout: float = 10, + max_connections: int = 16, +) -> ThreadingHTTPServer: + """Build the bounded private listener; the caller owns TLS/provider ingress.""" + if len(token) < 32: + raise ValueError("Managed bridge requires an application token of at least 32 characters") + + class Server(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, *args: Any) -> None: + self.slots = threading.BoundedSemaphore(max_connections) + super().__init__(*args) + + def get_request(self) -> Any: + connection, address = super().get_request() + connection.settimeout(read_timeout) + return connection, address + + def process_request(self, request: Any, address: Any) -> None: + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request: Any, address: Any) -> None: + try: + super().process_request_thread(request, address) + finally: + self.slots.release() + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + return + + def _send(self, status: int, body: Any) -> None: + data = _json(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) + + def _handle(self, method: str) -> None: + self.close_connection = True + self.connection.settimeout(read_timeout) + url = urlsplit(self.path) + if method == "GET" and url.path in {"/health", "/healthz"}: + self._send(200, {"status": "ok", "protocol": PROTOCOL, "epoch": bridge.epoch}) + return + authorization = self.headers.get("Authorization", "") + scheme, _, bearer = authorization.partition(" ") + candidates = [self.headers.get("X-Access-Token", "")] + if scheme.lower() == "bearer": + candidates.append(bearer) + if not any( + supplied + and supplied.isascii() + and len(supplied) <= 4096 + and hmac.compare_digest(supplied, token) + for supplied in candidates + ): + raise BridgeError(401, "unauthenticated") + if method == "GET" and url.path == "/capabilities": + adapters = [ + name + for name, binary in (("codex", "codex"), ("qwen-code", "qwen")) + if shutil.which(binary, path=bridge.runtime._environ.get("PATH")) + ] + self._send( + 200, + { + "protocol": PROTOCOL, + "epoch": bridge.epoch, + "adapters": adapters, + "streaming": "bounded_native_events_with_gaps", + "durable_receipts": True, + }, + ) + return + body: dict[str, Any] = {} + if method == "POST": + if self.headers.get("Transfer-Encoding"): + raise BridgeError(400, "invalid_body") + length = int(self.headers.get("Content-Length", "0")) + if length < 1 or length > 16000000: + raise BridgeError(413, "body_too_large") + value = json.loads(self.rfile.read(length)) + if not isinstance(value, dict): + raise BridgeError(400, "invalid_body") + body = value + if url.path == "/managed/session": + self._send( + 201 if method == "POST" else 200, + bridge.create_session(body) if method == "POST" else bridge._session_record(), + ) + return + if url.path == "/managed/submissions" and method == "POST": + self._send(201, bridge.submit(body)) + return + parts = url.path.strip("/").split("/") + if ( + len(parts) >= 3 + and parts[:2] == ["managed", "submissions"] + and re_identifier(parts[2]) + ): + if method == "GET" and len(parts) == 3: + query = parse_qs(url.query) + after = int(query.get("after", ["0"])[0]) + if after < 0: + raise BridgeError(400, "invalid_cursor") + self._send(200, bridge.get(parts[2], after)) + return + if method == "POST" and parts[3:] == ["cancel"]: + self._send(202, bridge.cancel(parts[2], body)) + return + raise BridgeError(404, "endpoint_not_available") + + def _safe(self, method: str) -> None: + try: + self._handle(method) + except (BrokenPipeError, ConnectionResetError): + return + except Exception as exc: + code = exc.code if isinstance(exc, BridgeError) else "bridge_unavailable" + status = exc.status if isinstance(exc, BridgeError) else 503 + self._send(status, {"error": {"code": code}}) + + def do_GET(self) -> None: # noqa: N802 - stdlib HTTP dispatch name + self._safe("GET") + + def do_POST(self) -> None: # noqa: N802 - stdlib HTTP dispatch name + self._safe("POST") + + return Server((host, port), Handler) + + +def main() -> None: + """Run the dedicated bridge explicitly; importing it has no startup side effect.""" + parser = argparse.ArgumentParser(description="Private managed Agent bridge") + parser.add_argument("--port", type=int, default=9000) + args = parser.parse_args() + directory = os.getenv( + "ABX_MANAGED_STATE_DIR", os.path.expanduser("~/.agent-sandbox/managed-bridge") + ) + bridge = ManagedBridge(directory, os.getenv("ABX_MANAGED_WORKSPACE", "/workspace")) + server = make_bridge_server( + bridge, + os.environ.get("ABX_SERVER_TOKEN", ""), + host="0.0.0.0", # nosec B104 - private authenticated Sandbox ingress + port=args.port, + ) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/server-sdk/src/agent_sandbox/server/managed_process.py b/server-sdk/src/agent_sandbox/server/managed_process.py new file mode 100644 index 00000000..d23fe97e --- /dev/null +++ b/server-sdk/src/agent_sandbox/server/managed_process.py @@ -0,0 +1,93 @@ +"""Conservative cooperative process-domain observation for the private bridge. + +This is not isolation from hostile same-UID code. Any unreadable or unowned new +process prevents Session reuse; only provider destruction can then confirm stop. +""" + +from __future__ import annotations + +import contextlib +import os +import signal +import time +from pathlib import Path + +_SCOPE = b"ABX_MANAGED_TURN_SCOPE=" + + +class ProcessDomain: + """Observe PID identities, including children which changed session or parent.""" + + def __init__(self, scope: str) -> None: + self.scope = scope.encode() + self.baseline: dict[int, tuple[str, str, bytes]] | None + try: + self.baseline = self._scan(environments=False) + except OSError: + self.baseline = None + + @staticmethod + def _identity(path: Path) -> tuple[str, str]: + # comm may contain spaces and ')'; fields after its final ')' are stable. + fields = (path / "stat").read_text().rsplit(")", 1)[1].split() + return fields[19], fields[0] # kernel starttime and process state + + @classmethod + def _scan( + cls, + *, + environments: bool = True, + baseline: dict[int, tuple[str, str, bytes]] | None = None, + ) -> dict[int, tuple[str, str, bytes]]: + result = {} + entries = list(Path("/proc").iterdir()) + if len(entries) > 10000: + raise OSError("process_observation_budget") + for path in entries: + if not path.name.isdigit(): + continue + try: + if path.stat().st_uid != os.getuid(): + continue + start, state = cls._identity(path) + if state == "Z": + continue + pid = int(path.name) + known = baseline is not None and pid in baseline and baseline[pid][0] == start + environment = (path / "environ").read_bytes() if environments and not known else b"" + if len(environment) > 1048576: + raise OSError("process_environment_budget") + result[pid] = start, state, environment + except FileNotFoundError: + continue # exited before inspection + return result + + def quiesce(self) -> bool: + """Kill positively marked residuals; refuse reuse on incomplete attribution.""" + if self.baseline is None: + return False + try: + current = self._scan(baseline=self.baseline) + marker = _SCOPE + self.scope + owned = {pid: row[0] for pid, row in current.items() if marker in row[2].split(b"\0")} + for pid, start in owned.items(): + # Guard against PID reuse between enumeration and signal. + with contextlib.suppress(ProcessLookupError, FileNotFoundError): + if self._identity(Path("/proc") / str(pid))[0] == start: + os.kill(pid, signal.SIGKILL) + deadline = time.monotonic() + 2 + while True: + current = self._scan(baseline=self.baseline) + residual = any(marker in row[2].split(b"\0") for row in current.values()) + unknown = any( + pid not in self.baseline or self.baseline[pid][0] != row[0] + for pid, row in current.items() + if pid not in owned + ) + if not residual: + return not unknown + if time.monotonic() >= deadline: + return False + time.sleep(0.02) + except (OSError, ValueError, IndexError): + return False From 0974b1aa538c1cda0d6a4bab32f39d95c2491ebd Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:54:15 +0000 Subject: [PATCH 02/13] fix(agents): pin durable state and database trust before deployment --- .../2026-11-05-managed-agents-api.md | 6 + .../agent_sandbox/managed_agents/__main__.py | 42 ++++- .../agent_sandbox/managed_agents/dispatch.py | 1 + .../agent_sandbox/managed_agents/models.py | 9 ++ .../agent_sandbox/managed_agents/service.py | 5 + .../src/agent_sandbox/managed_agents/store.py | 62 +++++++- .../agent_sandbox/managed_agents/worker.py | 1 + client-sdk/tests/test_managed_agents.py | 149 ++++++++++++++++++ docs/en/guide/managed-agents-api.md | 8 +- docs/zh/guide/managed-agents-api.md | 8 +- 10 files changed, 279 insertions(+), 12 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 55c9444f..982c957e 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -20,6 +20,8 @@ Template dependency: https://github.com/Alibaba-Agent-Sandbox/awesome-templates/ Add the optional `agent_sandbox.managed_agents` client-package runtime with Gateway, Worker and Dispatcher entrypoints. Add the separate `agent_sandbox.server.managed` private guest listener, reusing `AgentRuntime` and its real Codex/Qwen parsers without changing the legacy public routes. +The Store persists a random non-secret state identity. An expected identity must exist and match before bootstrap; every subsequent connection verifies the frozen identity. Empty databases and warm-instance backend replacement are rejected. Private database CA input is bounded to 64 KiB/eight certificates, materialized with mode 0600 and passed explicitly to libpq without lowering verify-full. + PostgreSQL is authoritative for owner-scoped acceptance, ordering, public identity, publication, delete and cleanup. Explicit local SQLite is supported outside FC only. @@ -98,3 +100,7 @@ build and fresh-install checks, and record full results independently of public - Existing local Agents API behavior and required package test/CI gates remain green. - Real Express/model use, independent artifact consumption, cleanup and published-package template installation are verified before declaring final delivery or moving this decision to implemented. + +## Implementation + +The pre-commit Ruff pin is aligned to v0.17.3 and its official `ruff-check` hook, matching the declared client dev toolchain. The old v0.4.9 falsely required eager runtime imports for the existing lazy provider facade. All rule selections, secret checks and Bandit remain enabled; required native Express implementation lives under its provider, with a lazy explicit facade export. diff --git a/client-sdk/src/agent_sandbox/managed_agents/__main__.py b/client-sdk/src/agent_sandbox/managed_agents/__main__.py index f877314a..065bbee8 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/__main__.py +++ b/client-sdk/src/agent_sandbox/managed_agents/__main__.py @@ -3,11 +3,14 @@ from __future__ import annotations import argparse +import contextlib import json import os +import re import ssl +import tempfile from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from .backend import ExpressBackend from .dispatch import FCTaskSender, ManagedDispatcher @@ -17,6 +20,9 @@ from .store import ManagedStore from .worker import ManagedWorker +if TYPE_CHECKING: + from collections.abc import Iterator + def load_document(name: str, *, required: bool = True) -> dict[str, Any] | None: """Load an explicit FILE or JSON document; never consult CWD/HOME configuration.""" @@ -42,11 +48,43 @@ def main() -> None: parser.add_argument("--local", action="store_true") args = parser.parse_args() ssl.create_default_context(cafile=os.getenv("ABX_MANAGED_TLS_CA_FILE")) + with database_ca_file(os.getenv("ABX_MANAGED_DATABASE_CA_PEM")) as ca_file: + serve(args, ca_file) + + +@contextlib.contextmanager +def database_ca_file(pem: str | None) -> Iterator[str | None]: + """Materialize a bounded CA-only bundle privately for libpq, never in guest config.""" + if pem is None: + yield None + return + pattern = re.compile(r"-----BEGIN CERTIFICATE-----[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----") + certificates = pattern.findall(pem) + if len(pem.encode()) > 65536 or not 1 <= len(certificates) <= 8 or pattern.sub("", pem).strip(): + raise ValueError("Database CA must contain only 1-8 PEM certificates within 64 KiB") + try: + ssl.create_default_context().load_verify_locations(cadata=pem) + except ssl.SSLError: + raise ValueError("Database CA certificates are invalid") from None + with tempfile.TemporaryDirectory(prefix="abx-managed-database-ca-") as directory: + path = os.path.join(directory, "ca.pem") + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as stream: + stream.write(pem) + yield path + + +def serve(args: argparse.Namespace, ca_file: str | None) -> None: + """Serve one role while its explicit database trust material remains alive.""" compiled = load_document("ABX_MANAGED_CONFIG") assert compiled is not None config = ManagedConfig.from_dict(compiled) store = ManagedStore( - os.environ["ABX_MANAGED_DATABASE_URL"], config.application_id, local=args.local + os.environ["ABX_MANAGED_DATABASE_URL"], + config.application_id, + local=args.local, + expected_state_id=config.expected_state_id, + database_ca_file=ca_file, ) service = ManagedService(store, config) auth = None diff --git a/client-sdk/src/agent_sandbox/managed_agents/dispatch.py b/client-sdk/src/agent_sandbox/managed_agents/dispatch.py index 3934f1d7..69496d6d 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/dispatch.py +++ b/client-sdk/src/agent_sandbox/managed_agents/dispatch.py @@ -146,6 +146,7 @@ def status(self, revision: str | None = None) -> dict[str, Any]: ] control = self.service.control(tx) return { + "stateInstanceId": self.store.state_instance_id, "activeSessions": len(active), "pendingCleanup": len(pending), "uncertainResources": len(uncertain), diff --git a/client-sdk/src/agent_sandbox/managed_agents/models.py b/client-sdk/src/agent_sandbox/managed_agents/models.py index b7779746..4153c048 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/models.py +++ b/client-sdk/src/agent_sandbox/managed_agents/models.py @@ -114,6 +114,7 @@ class ManagedConfig: port: int = 9000 workers: dict[str, str] = field(default_factory=dict) model_env_allowlist: list[str] = field(default_factory=list) + expected_state_id: str | None = None sessions: dict[str, int] = field( default_factory=lambda: { "idleTimeoutSeconds": 900, @@ -200,6 +201,12 @@ def __post_init__(self) -> None: for v in self.model_env_allowlist ): raise ValueError("Only explicit model environment names may be allowed") + if self.expected_state_id is not None and ( + not isinstance(self.expected_state_id, str) + or len(self.expected_state_id) != 32 + or any(c not in "0123456789abcdef" for c in self.expected_state_id) + ): + raise ValueError("Expected state identity must be a lowercase 32-character hex ID") @classmethod def from_dict(cls, value: dict[str, Any]) -> ManagedConfig: @@ -214,6 +221,7 @@ def from_dict(cls, value: dict[str, Any]) -> ManagedConfig: "port", "workers", "modelEnvAllowlist", + "expectedStateId", "sessions", "limits", "storage", @@ -232,6 +240,7 @@ def from_dict(cls, value: dict[str, Any]) -> ManagedConfig: ("port", "port"), ("workers", "workers"), ("modelEnvAllowlist", "model_env_allowlist"), + ("expectedStateId", "expected_state_id"), ): if wire in value: kwargs[native] = value[wire] diff --git a/client-sdk/src/agent_sandbox/managed_agents/service.py b/client-sdk/src/agent_sandbox/managed_agents/service.py index 5dc853ca..c73a07e2 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/service.py +++ b/client-sdk/src/agent_sandbox/managed_agents/service.py @@ -85,6 +85,11 @@ class ManagedService: def __init__(self, store: ManagedStore, config: ManagedConfig) -> None: self.store, self.config = store, config + if ( + config.expected_state_id is not None + and config.expected_state_id != store.state_instance_id + ): + raise ValueError("Managed state identity does not match the pinned deployment") def capabilities(self) -> dict[str, Any]: """Publish supported operations and honest adapter/stream/lifecycle limits.""" diff --git a/client-sdk/src/agent_sandbox/managed_agents/store.py b/client-sdk/src/agent_sandbox/managed_agents/store.py index 622738d9..fad5244b 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/store.py +++ b/client-sdk/src/agent_sandbox/managed_agents/store.py @@ -228,10 +228,21 @@ def new_object( class ManagedStore: """A fixed application database with a cross-process admission lock.""" - def __init__(self, url: str, application: str = "agents-api", *, local: bool = False) -> None: + def __init__( + self, + url: str, + application: str = "agents-api", + *, + local: bool = False, + expected_state_id: str | None = None, + database_ca_file: str | None = None, + ) -> None: self.url, self.application = url, application + self.database_ca_file = database_ca_file self.sqlite = url.startswith("sqlite:///") if self.sqlite: + if database_ca_file is not None: + raise ValueError("Database CA configuration requires PostgreSQL") if ( not local or os.getenv("FC_FUNCTION_NAME") @@ -239,9 +250,40 @@ def __init__(self, url: str, application: str = "agents-api", *, local: bool = F or url == "sqlite:///:memory:" ): raise ValueError("File-backed SQLite requires explicit non-FC local mode") + if expected_state_id is not None and not os.path.isfile(url[len("sqlite:///") :]): + raise ValueError("Expected managed state is absent; refusing initialization") elif not url.startswith(("postgres://", "postgresql://")): raise ValueError("Managed storage requires PostgreSQL or explicit local SQLite") with self.transaction(initialize=True) as tx: + if expected_state_id is not None: + if self.sqlite: + found = tx.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name='managed_identity'" + ).fetchone() + else: + found = tx.execute("SELECT to_regclass('managed_identity') AS name").fetchone() + found = found if found and found["name"] else None + if not found: + raise ValueError("Expected managed state is absent; refusing initialization") + identity = tx.execute( + "SELECT state_id FROM managed_identity WHERE application=?", (application,) + ).fetchone() + if identity is None or identity["state_id"] != expected_state_id: + raise ValueError("Managed state identity does not match the pinned deployment") + tx.execute( + "CREATE TABLE IF NOT EXISTS managed_identity(application TEXT PRIMARY KEY," + " state_id TEXT NOT NULL)" + ) + tx.execute( + "INSERT INTO managed_identity(application,state_id) VALUES(?,?) " + "ON CONFLICT(application) DO NOTHING", + (application, secrets.token_hex(16)), + ) + self.state_instance_id = tx.execute( + "SELECT state_id FROM managed_identity WHERE application=?", + (application,), + ).fetchone()["state_id"] tx.execute( "CREATE TABLE IF NOT EXISTS managed_meta(application TEXT PRIMARY KEY, " "secret TEXT NOT NULL)" @@ -314,7 +356,8 @@ def session_gate(self, sid: str) -> Iterator[None]: else: import psycopg - with psycopg.connect(self.url, autocommit=True, connect_timeout=10) as db: + options = {"sslrootcert": self.database_ca_file} if self.database_ca_file else {} + with psycopg.connect(self.url, autocommit=True, connect_timeout=10, **options) as db: db.execute("SET statement_timeout='10000ms'") key = int.from_bytes(identity[:8], "big", signed=True) db.execute("SELECT pg_advisory_lock(%s)", (key,)) @@ -336,13 +379,24 @@ def transaction(self, *, initialize: bool = False) -> Iterator[Transaction]: from psycopg.rows import dict_row except ImportError as exc: raise RuntimeError("Install agent-sandbox-client[managed-agents]") from exc - db = psycopg.connect(self.url, row_factory=dict_row, connect_timeout=10) + options = {"sslrootcert": self.database_ca_file} if self.database_ca_file else {} + db = psycopg.connect(self.url, row_factory=dict_row, connect_timeout=10, **options) lock = int.from_bytes( hashlib.sha256(self.application.encode()).digest()[:8], "big", signed=True ) db.execute("SELECT pg_advisory_xact_lock(%s)", (lock,)) try: - yield Transaction(db, self.sqlite, self.application) + transaction = Transaction(db, self.sqlite, self.application) + if not initialize: + actual = transaction.execute( + "SELECT state_id FROM managed_identity WHERE application=?", + (self.application,), + ).fetchone() + if actual is None or actual["state_id"] != self.state_instance_id: + raise ManagedError( + 503, "state_identity_changed", "The deployment state identity changed" + ) + yield transaction db.commit() except BaseException: db.rollback() diff --git a/client-sdk/src/agent_sandbox/managed_agents/worker.py b/client-sdk/src/agent_sandbox/managed_agents/worker.py index a5ce6d9e..b3648a94 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/worker.py +++ b/client-sdk/src/agent_sandbox/managed_agents/worker.py @@ -413,6 +413,7 @@ def _ingest(self, sid: str, subid: str, claim: str, receipt: dict[str, Any]) -> kind = wire.get("type", "") if ( kind.endswith((".completed", ".failed", ".cancelled", ".idle")) + or kind in {"agent.session.created", "agent.session.turn.created"} or "reasoning" in kind ): continue diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index 74162478..ace30798 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -38,6 +38,7 @@ def __init__(self, root: Path, binary: Path) -> None: self.root, self.binary = root, binary self.resources: dict[str, tuple[Any, Any, Any]] = {} self.created = 0 + self.block_turns = False async def create(self, session: dict[str, Any], token: str, on_created: Any) -> dict[str, Any]: self.created += 1 @@ -64,6 +65,8 @@ def submit(self, body: dict[str, Any]) -> dict[str, Any]: kill_grace_s=0.1, term_grace_s=0.1, ) + if self.block_turns: + runtime._slots.acquire() bridge = TestBridge(str(directory / "journal"), str(workspace), runtime=runtime) server = make_bridge_server(bridge, token, port=0) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -972,3 +975,149 @@ def test_private_bridge_preserves_provider_header_and_authenticates_bearer(tmp_p server.shutdown() server.server_close() thread.join(timeout=3) + + +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +def test_pinned_state_identity_rejects_empty_store_before_initialization( + components: Any, tmp_path: Path +) -> None: + from urllib.parse import urlsplit + + service = components[0] + original = service.store + identity = original.state_instance_id + reopened = ManagedStore( + original.url, original.application, local=True, expected_state_id=identity + ) + assert reopened.state_instance_id == identity + with pytest.raises(ValueError, match="identity"): + ManagedStore(original.url, original.application, local=True, expected_state_id="0" * 32) + if original.sqlite: + import sqlite3 + + empty_url = "sqlite:///" + str(tmp_path / "empty.db") + with pytest.raises(ValueError, match="absent"): + ManagedStore(empty_url, original.application, local=True, expected_state_id=identity) + assert not (tmp_path / "empty.db").exists() + with sqlite3.connect(tmp_path / "empty.db") as db: + assert not db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + else: + import psycopg + + with psycopg.connect(original.url, autocommit=True) as db: + db.execute("CREATE DATABASE managed_empty_state") + empty_url = urlsplit(original.url)._replace(path="/managed_empty_state").geturl() + with pytest.raises(ValueError, match="absent"): + ManagedStore(empty_url, original.application, expected_state_id=identity) + with psycopg.connect(empty_url) as db: + assert db.execute("SELECT to_regclass('managed_identity')").fetchone()[0] is None + if original.sqlite: + database_path = Path(original.url[len("sqlite:///") :]) + database_path.rename(tmp_path / "retained-state.db") + else: + maintenance = urlsplit(original.url)._replace(path="/template1").geturl() + with psycopg.connect(maintenance, autocommit=True) as db: + db.execute("ALTER DATABASE postgres RENAME TO managed_retained_state") + db.execute("CREATE DATABASE postgres") + replacement = ManagedStore(original.url, original.application, local=True) + assert replacement.state_instance_id != identity + with pytest.raises(ManagedError) as changed, original.transaction(): + pass + assert changed.value.code == "state_identity_changed" + + +def test_database_ca_is_bounded_private_and_removed() -> None: + import ssl + import stat + + from agent_sandbox.managed_agents.__main__ import database_ca_file + + certificate = ssl.DER_cert_to_PEM_cert( + ssl.create_default_context().get_ca_certs(binary_form=True)[0] + ) + with database_ca_file(certificate) as path: + assert path is not None and Path(path).read_text() == certificate + assert stat.S_IMODE(Path(path).stat().st_mode) == 0o600 + retained = Path(path) + assert not retained.exists() + for invalid in ( + "not a certificate", + "x" * 65537, + certificate * 9, + certificate + "unexpected text", + ): + with pytest.raises(ValueError), database_ca_file(invalid): + pass + + +@pytest.mark.parametrize("components", ["postgres"], indirect=True) +def test_database_ca_preserves_libpq_verification_on_all_connections( + components: Any, monkeypatch: Any +) -> None: + import ssl + from urllib.parse import parse_qsl, urlencode, urlsplit + + import psycopg + + from agent_sandbox.managed_agents.__main__ import database_ca_file + + service = components[0] + certificate = ssl.DER_cert_to_PEM_cert( + ssl.create_default_context().get_ca_certs(binary_form=True)[0] + ) + original_connect = psycopg.connect + connections = [] + with database_ca_file(certificate) as path: + + def connect(url: str, **options: Any) -> Any: + assert options["sslrootcert"] == path + connections.append(options) + return original_connect(url, **options) + + monkeypatch.setattr(psycopg, "connect", connect) + parsed = urlsplit(service.store.url) + url = parsed._replace( + query=urlencode([*parse_qsl(parsed.query), ("sslmode", "verify-full")]) + ).geturl() + store = ManagedStore( + url, + service.store.application, + expected_state_id=service.store.state_instance_id, + database_ca_file=path, + ) + with store.transaction() as tx: + assert tx.execute("SELECT 1 AS value").fetchone()["value"] == 1 + with store.session_gate("example-session"): + pass + assert len(connections) == 3 and all(c["sslrootcert"] == path for c in connections) + assert dict(parse_qsl(urlsplit(store.url).query))["sslmode"] == "verify-full" + + +@pytest.mark.asyncio +async def test_guest_queue_is_not_a_public_turn_before_real_execution(components: Any) -> None: + service, backend, worker, _ = components + backend.block_turns = True + sid = create(service, "held before native execution")["id"] + running = asyncio.create_task(worker.run(sid)) + runtime = None + try: + for _ in range(200): + await asyncio.sleep(0.01) + if sid in backend.resources: + runtime = backend.resources[sid][0].runtime + sessions = runtime.list_sessions() + if sessions and sessions[0].turns: + break + assert runtime is not None and sessions[0].turns[0].status == "queued" + await asyncio.sleep(0.05) + assert service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns")[1]["data"] == [] + assert not any( + e["type"] == "agent.session.turn.created" for e in service.events("owner", sid, "0") + ) + finally: + if runtime is not None: + runtime._slots.release() + assert await running + events = service.events("owner", sid, "0") + assert sum(e["type"] == "agent.session.turn.created" for e in events) == 1 + assert all(e.get("turn_id") for e in events if e["type"] == "agent.session.turn.created") diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index a1b892a6..16be8ddf 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -124,18 +124,20 @@ Each configuration document accepts exactly one `_FILE` or `_JSON` source: | Variable prefix | Document | | --- | --- | -| `ABX_MANAGED_CONFIG` | Compiled revision, application ID, template, model/adapter allowlist, `modelEnvAllowlist`, Session/storage/execution budgets and retained Worker mapping | +| `ABX_MANAGED_CONFIG` | Compiled revision, application ID, template, model/adapter allowlist, `modelEnvAllowlist`, Session/storage/execution budgets, `expectedStateId` and retained Worker mapping | | `ABX_MANAGED_AUTH` | `tokens: [{sha256,owner,scopes,expiresAt?}]`; scopes are `agents:read` and `agents:write` | | `ABX_MANAGED_MODEL_CONFIG` | Worker-only `{apiKey,baseUrl,model,adapter}` | | `ABX_MANAGED_PROVIDER_CONFIG` | Worker/Dispatcher `{provider:"alicloud-express",apiKey,baseUrl,domain,region}` | -`ABX_MANAGED_DATABASE_URL` identifies PostgreSQL. `ABX_MANAGED_INTERNAL_TOKEN` authenticates private +`ABX_MANAGED_DATABASE_URL` identifies PostgreSQL. The deployment pins its persistent state identity; replacing the database with empty state is rejected, including by warm instances. Credential and CA rotation on the same state remains supported. `ABX_MANAGED_INTERNAL_TOKEN` authenticates private role invocations and must remain stable while retained revisions drain. `ABX_MANAGED_FC_ENDPOINT` selects the explicit FC service host for dispatch. `modelEnvAllowlist` must explicitly allow `OPENAI_API_KEY`, `OPENAI_BASE_URL` and `OPENAI_MODEL`. Credentials are passed only to roles which need them, and never put in images or public resources. File-backed business authentication is reread for revocation; optional token expiry is enforced. -TLS certificate verification remains enabled. The runtime uses the system trust store without +TLS certificate verification remains enabled. The private bridge HTTPS client uses the system trust store without inheriting HTTP proxy settings. To trust a private CA, set `ABX_MANAGED_TLS_CA_FILE` to a readable PEM CA bundle. Invalid CA configuration fails; it does not disable verification. + +For a database which requires a private CA, supply `ABX_MANAGED_DATABASE_CA_PEM` to the three Server roles. It accepts only 1–8 PEM certificates, at most 64 KiB. The runtime writes a private temporary CA file and passes it to libpq while preserving the connection URI’s `sslmode=verify-full`. This setting is separate from the guest HTTPS CA setting and is never sent to the sandbox. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index 922c9dd9..e05809c1 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -111,15 +111,17 @@ FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、 | 环境变量前缀 | 配置正文 | | --- | --- | -| `ABX_MANAGED_CONFIG` | 编译后的 revision、application ID、模板、模型/adapter 白名单、`modelEnvAllowlist`、Session/存储/执行预算与保留 Worker 映射 | +| `ABX_MANAGED_CONFIG` | 编译后的 revision、application ID、模板、模型/adapter 白名单、`modelEnvAllowlist`、Session/存储/执行预算、`expectedStateId` 与保留 Worker 映射 | | `ABX_MANAGED_AUTH` | `tokens: [{sha256,owner,scopes,expiresAt?}]`,scope 为 `agents:read`、`agents:write` | | `ABX_MANAGED_MODEL_CONFIG` | 仅 Worker 使用 `{apiKey,baseUrl,model,adapter}` | | `ABX_MANAGED_PROVIDER_CONFIG` | Worker/Dispatcher 使用 `{provider:"alicloud-express",apiKey,baseUrl,domain,region}` | -`ABX_MANAGED_DATABASE_URL` 指向 PostgreSQL。`ABX_MANAGED_INTERNAL_TOKEN` 认证私有角色调用, +`ABX_MANAGED_DATABASE_URL` 指向 PostgreSQL。部署会固定持久状态身份,替换为空数据库会被拒绝,已有暖实例也会校验;同一状态库的凭据与 CA 轮换仍可使用。`ABX_MANAGED_INTERNAL_TOKEN` 认证私有角色调用, 保留旧 revision 排空期间应保持稳定。`ABX_MANAGED_FC_ENDPOINT` 指定派发所用 FC 服务主机。 `modelEnvAllowlist` 必须明确允许 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`。 凭据仅交给需要它的角色,不写入镜像或公开资源。文件来源的业务认证每次重读以支持撤权,并执行可选到期限制。 -TLS 始终校验证书,默认采用系统信任库且不继承 HTTP 代理设置。 +沙箱私有桥接 HTTPS 客户端始终校验证书,默认采用系统信任库且不继承 HTTP 代理设置。 私有 CA 使用 `ABX_MANAGED_TLS_CA_FILE` 指向可读 PEM CA bundle;CA 配置错误会失败,不会关闭证书校验。 + +数据库需要私有 CA 时,为三个 Server 角色提供 `ABX_MANAGED_DATABASE_CA_PEM`。它只接受 1–8 个 PEM 证书,总大小不超过 64 KiB。运行时生成私有临时 CA 文件并显式传给 libpq,保留连接地址中的 `sslmode=verify-full`。它与沙箱 HTTPS 的 CA 配置分别使用,不会发送到沙箱。 From 4203b49e73630354f37386493e572988ee55c9aa Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:57:14 +0000 Subject: [PATCH 03/13] chore(release): prepare matching 0.4.0a7 candidates --- CHANGELOG.md | 2 ++ client-sdk/pyproject.toml | 2 +- client-sdk/src/agent_sandbox/_version.py | 2 +- docs/en/guide/managed-agents-api.md | 2 +- docs/zh/guide/managed-agents-api.md | 2 +- server-sdk/src/agent_sandbox/server/_version.py | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e498b8..ab6f5d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +Release candidate: matching client/server **0.4.0a7**. / 候选版本:客户端与服务端均为 **0.4.0a7**。 + ### Added - Optional managed Agents API Gateway, Worker and Dispatcher with durable Sessions, explicit queue receipts, files/artifacts, safe cancellation and a restart-aware private guest bridge. / 新增可选托管 Agents API Gateway、Worker、Dispatcher,支持持久 Session、显式队列收据、文件与产物、安全取消和可识别重启的私有沙箱桥接。 diff --git a/client-sdk/pyproject.toml b/client-sdk/pyproject.toml index 60530753..b85740a2 100644 --- a/client-sdk/pyproject.toml +++ b/client-sdk/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "tomli>=2.0; python_version < '3.11'", # The client uses the matching local server runtime for its server/declarative helpers. # Keep the required distribution version equal to ``_version.py``. - "agent-sandbox-server==0.4.0a6", + "agent-sandbox-server==0.4.0a7", ] [project.optional-dependencies] diff --git a/client-sdk/src/agent_sandbox/_version.py b/client-sdk/src/agent_sandbox/_version.py index 48b9a7eb..3861d95e 100644 --- a/client-sdk/src/agent_sandbox/_version.py +++ b/client-sdk/src/agent_sandbox/_version.py @@ -1 +1 @@ -__version__ = "0.4.0a6" +__version__ = "0.4.0a7" diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index 16be8ddf..466c1db3 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -11,7 +11,7 @@ OpenAI SDK compatibility or OpenAI-managed harness behavior. Install the matching client and server packages with the managed deployment dependencies: ```bash -python -m pip install --pre 'agent-sandbox-client[managed-agents]' +python -m pip install --pre 'agent-sandbox-client[managed-agents]==0.4.0a7' ``` The [agents-api template](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/tree/main/agents-api) diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index e05809c1..ddb894fa 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -9,7 +9,7 @@ Codex、Qwen Code 模型。它采用部分 OpenAI 风格资源结构,不声明 安装匹配版本的客户端、服务端与托管部署依赖: ```bash -python -m pip install --pre 'agent-sandbox-client[managed-agents]' +python -m pip install --pre 'agent-sandbox-client[managed-agents]==0.4.0a7' ``` [agents-api 模板](https://github.com/Alibaba-Agent-Sandbox/awesome-templates/tree/main/agents-api) diff --git a/server-sdk/src/agent_sandbox/server/_version.py b/server-sdk/src/agent_sandbox/server/_version.py index 48b9a7eb..3861d95e 100644 --- a/server-sdk/src/agent_sandbox/server/_version.py +++ b/server-sdk/src/agent_sandbox/server/_version.py @@ -1 +1 @@ -__version__ = "0.4.0a6" +__version__ = "0.4.0a7" From 3ba9bd3c8cdb46a7ce8366c4058221d11d4c11f0 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:58:57 +0000 Subject: [PATCH 04/13] fix(agents): bound attachment admission and execution I/O deadlines --- .../2026-11-05-managed-agents-api.md | 1 + .../agent_sandbox/managed_agents/backend.py | 26 +- .../agent_sandbox/managed_agents/service.py | 18 +- .../src/agent_sandbox/managed_agents/store.py | 63 ++-- .../agent_sandbox/managed_agents/worker.py | 66 +++- client-sdk/tests/test_managed_agents.py | 287 +++++++++++++++++- docs/en/guide/managed-agents-api.md | 3 + docs/zh/guide/managed-agents-api.md | 3 + .../src/agent_sandbox/server/managed.py | 2 +- 9 files changed, 442 insertions(+), 27 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 982c957e..4d620163 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -35,6 +35,7 @@ Cooperative process-domain observation includes marked children after leader exi reparenting. Incomplete attribution refuses reuse and releases the whole Sandbox. This is not an audit or isolation guarantee against hostile same-UID code. Tool deadlines start at observed native start events. Guest limits and independent host projection/stream limits remain finite. +Attachment admission has a 10 MiB aggregate ceiling, including repeated references, before acceptance. Guest RPCs have whole-context wall deadlines clipped to the remaining turn budget. Budget exhaustion preserves unknown outcomes and advances owned cleanup; PostgreSQL statement/lock acquisition have explicit finite server-side timeouts and all acquisition failures close their connections. Artifacts are immutable regular-file snapshots under `outputs/`, opened through no-follow directory FDs with bounded enumeration, bytes, depth and time. diff --git a/client-sdk/src/agent_sandbox/managed_agents/backend.py b/client-sdk/src/agent_sandbox/managed_agents/backend.py index 7724f786..2ab660b5 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/backend.py +++ b/client-sdk/src/agent_sandbox/managed_agents/backend.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import json +import math import os import ssl from typing import Any @@ -17,7 +19,29 @@ async def bridge_request( - resource: dict[str, Any], method: str, path: str, body: dict[str, Any] | None = None + resource: dict[str, Any], + method: str, + path: str, + body: dict[str, Any] | None = None, + *, + timeout_seconds: float = 30, +) -> dict[str, Any]: + """Bound the entire authenticated exchange, including a continuously dripping body.""" + if not math.isfinite(timeout_seconds) or timeout_seconds <= 0: + raise ManagedError(504, "guest_request_timeout", "The private request deadline expired") + try: + return await asyncio.wait_for(_request(resource, method, path, body), timeout_seconds) + except asyncio.TimeoutError: + raise ManagedError( + 504, "guest_request_timeout", "The private request deadline expired" + ) from None + + +async def _request( + resource: dict[str, Any], + method: str, + path: str, + body: dict[str, Any] | None, ) -> dict[str, Any]: """Call a recorded private endpoint, preserving provider headers and app auth.""" headers = dict(resource.get("headers", {})) diff --git a/client-sdk/src/agent_sandbox/managed_agents/service.py b/client-sdk/src/agent_sandbox/managed_agents/service.py index c73a07e2..09f12bb4 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/service.py +++ b/client-sdk/src/agent_sandbox/managed_agents/service.py @@ -23,6 +23,8 @@ ) from .store import ManagedStore, Transaction, new_object +MAX_TOTAL_INPUT_FILE_BYTES = 10485760 + OPERATIONS = ( ("GET", "/healthz"), ("GET", "/v1/agent-sandbox/capabilities"), @@ -109,7 +111,12 @@ def capabilities(self) -> dict[str, Any]: "guest_restart": "freeze_and_reconcile", "input_types": ["input_text", "managed_file_reference"], }, - "limits": {**self.config.limits, **self.config.storage, **self.config.sessions}, + "limits": { + **self.config.limits, + **self.config.storage, + **self.config.sessions, + "max_total_input_file_bytes": MAX_TOTAL_INPUT_FILE_BYTES, + }, } def control(self, tx: Transaction) -> dict[str, Any]: @@ -303,6 +310,7 @@ def _attachments( "x_agent_sandbox.input_files", ) out, paths = [], set() + total = 0 for ref in files: if not isinstance(ref, dict) or set(ref) != {"file_id", "path"}: raise ManagedError(400, "invalid_file_reference", "Expected file_id and path") @@ -326,6 +334,14 @@ def _attachments( file = tx.get(str(ref["file_id"]), owner) if file["_kind"] != "file" or file.get("expires_at", 0) < time.time(): raise ManagedError(400, "file_unavailable", "Input file is unavailable", "file_id") + total += file["bytes"] + if total > MAX_TOTAL_INPUT_FILE_BYTES: + raise ManagedError( + 413, + "input_files_too_large", + "Total attachment bytes exceed the 10 MiB budget", + "x_agent_sandbox.input_files", + ) out.append( {"file_id": file["id"], "path": path, "sha256": file["x_agent_sandbox"]["sha256"]} ) diff --git a/client-sdk/src/agent_sandbox/managed_agents/store.py b/client-sdk/src/agent_sandbox/managed_agents/store.py index fad5244b..72fec1be 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/store.py +++ b/client-sdk/src/agent_sandbox/managed_agents/store.py @@ -11,6 +11,7 @@ import time import uuid from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qsl, urlsplit from .models import ManagedError, canonical @@ -236,9 +237,29 @@ def __init__( local: bool = False, expected_state_id: str | None = None, database_ca_file: str | None = None, + statement_timeout_ms: int = 10000, + lock_timeout_ms: int = 5000, ) -> None: self.url, self.application = url, application self.database_ca_file = database_ca_file + if any( + not isinstance(v, int) or isinstance(v, bool) or not 1 <= v <= bound + for v, bound in ((statement_timeout_ms, 10000), (lock_timeout_ms, 5000)) + ): + raise ValueError( + "Database statement/lock deadlines must be positive bounded milliseconds" + ) + if any(key.lower() == "options" for key, _ in parse_qsl(urlsplit(url).query)): + raise ValueError( + "Database URI options are not supported; runtime deadlines are explicit" + ) + self.pg_options = { + "options": ( + f"-c statement_timeout={statement_timeout_ms} -c lock_timeout={lock_timeout_ms}" + ) + } + if database_ca_file: + self.pg_options["sslrootcert"] = database_ca_file self.sqlite = url.startswith("sqlite:///") if self.sqlite: if database_ca_file is not None: @@ -356,9 +377,8 @@ def session_gate(self, sid: str) -> Iterator[None]: else: import psycopg - options = {"sslrootcert": self.database_ca_file} if self.database_ca_file else {} + options = self.pg_options with psycopg.connect(self.url, autocommit=True, connect_timeout=10, **options) as db: - db.execute("SET statement_timeout='10000ms'") key = int.from_bytes(identity[:8], "big", signed=True) db.execute("SELECT pg_advisory_lock(%s)", (key,)) try: @@ -369,23 +389,24 @@ def session_gate(self, sid: str) -> Iterator[None]: @contextlib.contextmanager def transaction(self, *, initialize: bool = False) -> Iterator[Transaction]: """Serialize application mutation across Gateway, Worker and Dispatcher.""" - if self.sqlite: - db: Any = sqlite3.connect(self.url[len("sqlite:///") :], timeout=30) - db.row_factory = sqlite3.Row - db.execute("BEGIN IMMEDIATE") - else: - try: - import psycopg - from psycopg.rows import dict_row - except ImportError as exc: - raise RuntimeError("Install agent-sandbox-client[managed-agents]") from exc - options = {"sslrootcert": self.database_ca_file} if self.database_ca_file else {} - db = psycopg.connect(self.url, row_factory=dict_row, connect_timeout=10, **options) - lock = int.from_bytes( - hashlib.sha256(self.application.encode()).digest()[:8], "big", signed=True - ) - db.execute("SELECT pg_advisory_xact_lock(%s)", (lock,)) + db: Any = None try: + if self.sqlite: + db = sqlite3.connect(self.url[len("sqlite:///") :], timeout=30) + db.row_factory = sqlite3.Row + db.execute("BEGIN IMMEDIATE") + else: + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as exc: + raise RuntimeError("Install agent-sandbox-client[managed-agents]") from exc + options = self.pg_options + db = psycopg.connect(self.url, row_factory=dict_row, connect_timeout=10, **options) + lock = int.from_bytes( + hashlib.sha256(self.application.encode()).digest()[:8], "big", signed=True + ) + db.execute("SELECT pg_advisory_xact_lock(%s)", (lock,)) transaction = Transaction(db, self.sqlite, self.application) if not initialize: actual = transaction.execute( @@ -399,7 +420,9 @@ def transaction(self, *, initialize: bool = False) -> Iterator[Transaction]: yield transaction db.commit() except BaseException: - db.rollback() + if db is not None: + db.rollback() raise finally: - db.close() + if db is not None: + db.close() diff --git a/client-sdk/src/agent_sandbox/managed_agents/worker.py b/client-sdk/src/agent_sandbox/managed_agents/worker.py index b3648a94..279482e6 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/worker.py +++ b/client-sdk/src/agent_sandbox/managed_agents/worker.py @@ -68,6 +68,24 @@ def _uncertain(self, sid: str, subid: str | None, claim: str, code: str) -> None sub = tx.get(subid, deleted=True) sub.update(status="uncertain", error={"code": code}) tx.put(sub) + if sub.get("turn_id"): + turn = tx.get(sub["turn_id"], deleted=True) + if turn["status"] == "in_progress": + turn.update( + status="failed", + completed_at=time.time(), + error={ + "code": "execution_result_uncertain", + "message": "Gateway could not confirm execution outcome", + }, + x_agent_sandbox={"execution_status": "uncertain"}, + ) + tx.put(turn) + tx.event( + row, + "agent.session.turn.failed", + {"turn_id": turn["id"], "turn": public(turn)}, + ) tx.event(row, "agent.session.failed", {"error": row["error"]}) async def run(self, sid: str, submission_id: str | None = None) -> bool: @@ -132,10 +150,31 @@ async def run(self, sid: str, submission_id: str | None = None) -> bool: assert sub is not None await self._work(sid, sub["id"], claim) return True + except asyncio.CancelledError: + with self.store.transaction() as tx: + interrupted = tx.get(sid, deleted=True) + if interrupted.get("_phase") in {"creating", "submitting"}: + self._uncertain(sid, sub["id"] if sub else None, claim, "observer_cancelled") + raise except Exception as exc: if isinstance(exc, ManagedError) and exc.code == "claim_changed": return False self._uncertain(sid, sub["id"] if sub else None, claim, "execution_result_uncertain") + expired = False + if sub: + with self.store.transaction() as tx: + current_sub, current = ( + tx.get(sub["id"], deleted=True), + tx.get(sid, deleted=True), + ) + expired = current_sub.get("_deadline", float("inf")) <= time.time() + if expired: + self.service._close(tx, current) + if expired: + try: + await asyncio.wait_for(self._close(sid, claim), 10) + except Exception: + self._uncertain(sid, sub["id"] if sub else None, claim, "stop_unconfirmed") return False finally: heartbeat.cancel() @@ -148,6 +187,13 @@ async def run(self, sid: str, submission_id: str | None = None) -> bool: row["_claim"] = None tx.put(row) + @staticmethod + def _rpc_timeout(sub: dict[str, Any], cap: float = 30) -> float: + remaining = sub["_deadline"] - time.time() + if remaining <= 0: + raise ManagedError(504, "turn_deadline", "The host execution deadline expired") + return min(cap, remaining) + async def _prepare(self, sid: str, claim: str) -> dict[str, Any]: session = self._owned(sid, claim) if session.get("_resource"): @@ -296,16 +342,23 @@ async def _work(self, sid: str, subid: str, claim: str) -> None: "max_output_bytes": session["_settings"]["limits"]["maxOutputBytes"], "max_events": session["_settings"]["limits"]["maxEventsPerRun"], }, + timeout_seconds=self._rpc_timeout(sub), ) except Exception: # Inspect the original receipt. A 404/timeout is uncertain, never # permission to submit the same input into a fresh runtime. receipt = await bridge_request( - session["_resource"], "GET", "/managed/submissions/" + subid + session["_resource"], + "GET", + "/managed/submissions/" + subid, + timeout_seconds=self._rpc_timeout(sub), ) else: receipt = await bridge_request( - session["_resource"], "GET", "/managed/submissions/" + subid + session["_resource"], + "GET", + "/managed/submissions/" + subid, + timeout_seconds=self._rpc_timeout(sub), ) while True: current = self._owned(sid, claim) @@ -337,6 +390,7 @@ async def _work(self, sid: str, subid: str, claim: str) -> None: "POST", "/managed/submissions/" + subid + "/cancel", {"epoch": current["_guest_epoch"], "session_id": current["_guest_session"]}, + timeout_seconds=min(5, max(0.001, sub["_deadline"] - time.time())), ) self._ingest(sid, subid, claim, receipt) if receipt.get("status") in {"completed", "failed", "cancelled"} and receipt.get( @@ -367,6 +421,7 @@ async def _work(self, sid: str, subid: str, claim: str) -> None: current["_resource"], "GET", "/managed/submissions/" + subid + "?after=" + str(sub.get("_native_seq", 0)), + timeout_seconds=self._rpc_timeout(sub), ) def _ingest(self, sid: str, subid: str, claim: str, receipt: dict[str, Any]) -> None: @@ -580,6 +635,13 @@ async def _close(self, sid: str, claim: str) -> None: if sub["status"] not in SUBMISSION_TERMINAL: sub["status"] = "cancelled" tx.put(sub) + for cancellation in tx.list("operation", parent=sid, deleted=True): + if ( + cancellation.get("kind") == "cancel" + and cancellation.get("status") != "succeeded" + ): + cancellation.update(status="succeeded", result={"stop_confirmed": True}) + tx.put(cancellation) op = tx.get(current["_close_operation"], deleted=True) op.update( status="succeeded" if confirmed else "requires_reconcile", diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index ace30798..61e140db 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -868,9 +868,11 @@ async def test_lost_submit_response_inspects_original_receipt_without_replay( sid = create(service, "one effect")["id"] submits = 0 - async def lost_response(resource: Any, method: str, path: str, body: Any = None) -> Any: + async def lost_response( + resource: Any, method: str, path: str, body: Any = None, **options: Any + ) -> Any: nonlocal submits - result = await bridge_request(resource, method, path, body) + result = await bridge_request(resource, method, path, body, **options) if method == "POST" and path == "/managed/submissions": submits += 1 raise ManagedError(503, "connection_lost", "response was lost after acceptance") @@ -1121,3 +1123,284 @@ async def test_guest_queue_is_not_a_public_turn_before_real_execution(components events = service.events("owner", sid, "0") assert sum(e["type"] == "agent.session.turn.created" for e in events) == 1 assert all(e.get("turn_id") for e in events if e["type"] == "agent.session.turn.created") + + +@pytest.mark.asyncio +async def test_http_attachment_total_budget_is_atomic_and_exact_limit_executes( + components: Any, +) -> None: + service, _, worker, _ = components + token = "test-attachment-budget-token" + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + server = make_server(service, auth, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", + headers={"Authorization": "Bearer " + token}, + trust_env=False, + timeout=30, + ) as client: + + def upload(size: int, fill: bytes) -> str: + response = client.post( + "/v1/files", + data={"purpose": "user_data"}, + files={"file": ("input.bin", fill * size)}, + ) + assert response.status_code == 200 + return response.json()["id"] + + first = upload(6 * 1048576, b"a") + second = upload(6 * 1048576, b"b") + + def body(ids: list[str]) -> dict[str, Any]: + return { + "agent": {"model": "fixture-model"}, + "input": "consume accepted inputs", + "x_agent_sandbox": { + "input_files": [ + {"file_id": file, "path": f"/workspace/input/{i}.bin"} + for i, file in enumerate(ids) + ] + }, + } + + for ids in ([first, second], [first, first]): + response = client.post("/v1/agents/sessions", json=body(ids)) + assert ( + response.status_code == 413 + and response.json()["error"]["code"] == "input_files_too_large" + ) + assert client.get("/v1/agents/sessions").json()["data"] == [] + with service.store.transaction() as tx: + assert tx.list("submission") == [] and tx.list("environment") == [] + assert ( + tx.execute("SELECT COUNT(*) AS total FROM managed_events").fetchone()["total"] + == 0 + ) + exact = [upload(5 * 1048576, b"c"), upload(5 * 1048576, b"d")] + response = client.post("/v1/agents/sessions", json=body(exact)) + assert response.status_code == 201 + sid = response.json()["id"] + assert await worker.run(sid) + assert ( + client.get(f"/v1/agents/sessions/{sid}/turns").json()["data"][0]["status"] + == "completed" + ) + assert ( + client.get("/v1/agent-sandbox/capabilities").json()["limits"][ + "max_total_input_file_bytes" + ] + == 10485760 + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +@pytest.mark.asyncio +async def test_total_http_deadline_stops_drip_and_preserves_external_cancellation() -> None: + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + from agent_sandbox.managed_agents.backend import bridge_request + + closed = threading.Event() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args: Any) -> None: + return + + def do_GET(self) -> None: + raw = b'{"status":"running","padding":"continuous drip"}' + self.send_response(200) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + try: + for character in raw: + self.wfile.write(bytes([character])) + self.wfile.flush() + time.sleep(0.03) + except OSError: + closed.set() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + resource = { + "url": f"http://127.0.0.1:{server.server_port}", + "headers": {}, + "token": "test-drip-token", + } + try: + start = time.monotonic() + with pytest.raises(ManagedError) as deadline: + await bridge_request(resource, "GET", "/receipt", timeout_seconds=0.15) + assert deadline.value.code == "guest_request_timeout" and time.monotonic() - start < 0.6 + for _ in range(50): + if closed.is_set(): + break + await asyncio.sleep(0.02) + assert closed.is_set() + task = asyncio.create_task(bridge_request(resource, "GET", "/receipt", timeout_seconds=5)) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +@pytest.mark.asyncio +async def test_expired_observer_drip_releases_owned_compute_and_preserves_uncertain_turn( + components: Any, +) -> None: + service, backend, worker, _ = components + service.config.limits["maxRunSeconds"] = 1 + service.config.limits["maxToolSeconds"] = 1 + original_create, original_kill = backend.create, backend.kill + killed_at = [] + + async def create_dripping(session: Any, token: str, created: Any) -> Any: + resource = await original_create(session, token, created) + bridge, server, _ = backend.resources[session["id"]] + original_send = server.RequestHandlerClass._send + + def send(handler: Any, status: int, body: Any) -> None: + if handler.path == "/managed/submissions" and status < 400: + until = time.monotonic() + 0.5 + while time.monotonic() < until: + _, turn = bridge.runtime.find_turn(body["turn_id"]) + if turn.started_at: + break + time.sleep(0.005) + return original_send(handler, status, bridge.get(body["submission_id"])) + if handler.path.startswith("/managed/submissions/") and handler.command == "GET": + raw = json.dumps(body).encode() + handler.send_response(status) + handler.send_header("Content-Length", str(len(raw))) + handler.end_headers() + try: + for character in raw: + handler.wfile.write(bytes([character])) + handler.wfile.flush() + time.sleep(0.03) + except OSError: + pass + return None + return original_send(handler, status, body) + + server.RequestHandlerClass._send = send + return resource + + async def kill(resource: Any) -> bool: + killed_at.append(time.monotonic()) + return await original_kill(resource) + + backend.create, backend.kill = create_dripping, kill + sid = create(service, "slow")["id"] + start = time.monotonic() + assert await worker.run(sid) is False + assert killed_at and killed_at[0] - start < 1.6 + assert not backend.resources + with service.store.transaction() as tx: + submission = tx.list("submission", parent=sid)[0] + assert submission["status"] == "uncertain" + assert tx.get(sid)["_cleanup_confirmed"] is True + turn = tx.get(submission["turn_id"]) + assert ( + turn["status"] == "failed" + and turn["x_agent_sandbox"]["execution_status"] == "uncertain" + ) + assert turn["error"]["code"] == "execution_result_uncertain" + + +@pytest.mark.parametrize("components", ["postgres"], indirect=True) +def test_real_postgres_statement_and_advisory_timeouts_close_connections( + components: Any, monkeypatch: Any +) -> None: + from urllib.parse import parse_qsl, urlencode, urlsplit + + import psycopg + + service = components[0] + store = ManagedStore( + service.store.url, + service.store.application, + expected_state_id=service.store.state_instance_id, + statement_timeout_ms=200, + lock_timeout_ms=100, + ) + raw_connect = psycopg.connect + blocker = raw_connect(store.url, autocommit=True) + lock = int.from_bytes( + hashlib.sha256(store.application.encode()).digest()[:8], "big", signed=True + ) + opened = [] + + def connect(*args: Any, **kwargs: Any) -> Any: + connection = raw_connect(*args, **kwargs) + opened.append(connection) + return connection + + monkeypatch.setattr(psycopg, "connect", connect) + try: + blocker.execute("SELECT pg_advisory_lock(%s)", (lock,)) + start = time.monotonic() + with pytest.raises(psycopg.errors.LockNotAvailable), store.transaction(): + pass + assert time.monotonic() - start < 1 and opened[-1].closed + blocker.execute("SELECT pg_advisory_unlock(%s)", (lock,)) + with store.transaction() as tx: + assert tx.execute("SHOW statement_timeout").fetchone()["statement_timeout"] == "200ms" + assert tx.execute("SHOW lock_timeout").fetchone()["lock_timeout"] == "100ms" + start = time.monotonic() + with pytest.raises(psycopg.errors.QueryCanceled), store.transaction() as tx: + tx.execute("SELECT pg_sleep(1)") + assert time.monotonic() - start < 1 and opened[-1].closed + parsed = urlsplit(store.url) + unsafe = parsed._replace( + query=urlencode([*parse_qsl(parsed.query), ("options", "-c statement_timeout=0")]) + ).geturl() + with pytest.raises(ValueError, match="options"): + ManagedStore(unsafe, store.application) + finally: + blocker.close() + + +def test_guest_never_starts_an_already_expired_submission(tmp_path: Path) -> None: + from agent_sandbox.server.managed import BridgeError + + bridge = ManagedBridge(str(tmp_path / "journal"), str(tmp_path / "workspace")) + local = bridge.create_session( + {"nonce": "expired-test", "agent": {"adapter": "qwen-code", "model": "fixture-model"}} + ) + with pytest.raises(BridgeError) as expired: + bridge.submit( + { + "submission_id": "expired-submission", + "session_id": local["session_id"], + "input": "must not execute", + "files": [], + "deadline": time.time() - 1, + } + ) + assert expired.value.code == "turn_deadline" + assert not bridge.runtime.get_session(local["session_id"]).turns + with bridge._db() as db: + assert db.execute("SELECT COUNT(*) FROM receipts").fetchone()[0] == 0 diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index 466c1db3..c3f28db9 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -66,6 +66,7 @@ uploaded file to input using `x_agent_sandbox.input_files: [{"file_id":"file_... "path":"/workspace/input/data.csv"}]`. Paths must be normalized beneath the deployment's `input` directory. Existing files cannot be overwritten. The input snapshot is fixed when the submission is accepted; deleting the public File does not revoke an already accepted attachment. +A submission accepts at most 10 MiB of attachments in total (`max_total_input_file_bytes`), counting every reference even when the same File is reused. An oversized batch returns `413` before a Session or Submission is accepted. Only regular files under `outputs/` are published as immutable artifacts. Symbolic links, unreadable or changing files are rejected and reported as partial publication. @@ -141,3 +142,5 @@ inheriting HTTP proxy settings. To trust a private CA, set `ABX_MANAGED_TLS_CA_F PEM CA bundle. Invalid CA configuration fails; it does not disable verification. For a database which requires a private CA, supply `ABX_MANAGED_DATABASE_CA_PEM` to the three Server roles. It accepts only 1–8 PEM certificates, at most 64 KiB. The runtime writes a private temporary CA file and passes it to libpq while preserving the connection URI’s `sslmode=verify-full`. This setting is separate from the guest HTTPS CA setting and is never sent to the sandbox. + +Guest HTTPS exchanges have a total wall deadline of 30 seconds, clipped to the remaining execution budget during a turn. If that budget expires with an unknown result, the runtime attempts owned compute cleanup and retains an uncertain Submission; a public Turn reports the Gateway failure separately from the unconfirmed agent outcome. PostgreSQL connections use a 10-second connection/statement timeout and a 5-second lock timeout. URI `options` overrides are rejected. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index ddb894fa..b0338c13 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -59,6 +59,7 @@ Submission 对象。排队会创建下一轮,不会引导当前执行。 提交输入时附带 `x_agent_sandbox.input_files: [{"file_id":"file_...", "path":"/workspace/input/data.csv"}]`。路径必须规范化且处于部署工作区的 `input` 目录内, 已有文件不能覆盖。提交接收时固定输入快照,删除公开 File 不撤销已经接收的附件。 +每次提交的附件总大小最多 10 MiB(`max_total_input_file_bytes`),重复引用同一个 File 也逐次计数。超限返回 `413`,不会留下已接收的 Session 或 Submission。 只发布 `outputs/` 中普通文件的不可变产物快照;符号链接、不可读或读取期间变化的文件会被拒绝, 并报告部分发布结果。 @@ -125,3 +126,5 @@ FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、 私有 CA 使用 `ABX_MANAGED_TLS_CA_FILE` 指向可读 PEM CA bundle;CA 配置错误会失败,不会关闭证书校验。 数据库需要私有 CA 时,为三个 Server 角色提供 `ABX_MANAGED_DATABASE_CA_PEM`。它只接受 1–8 个 PEM 证书,总大小不超过 64 KiB。运行时生成私有临时 CA 文件并显式传给 libpq,保留连接地址中的 `sslmode=verify-full`。它与沙箱 HTTPS 的 CA 配置分别使用,不会发送到沙箱。 + +沙箱 HTTPS 请求的总墙钟期限为 30 秒,执行期间还会收紧到本轮剩余预算。预算耗尽而结果未知时,运行时尝试清理自有计算资源,Submission 保持 uncertain;已创建的 Turn 会标记 Gateway 失败,并另行说明 Agent 执行结果尚未确认。PostgreSQL 连接与单语句超时为 10 秒,锁等待超时为 5 秒;连接地址中的 `options` 覆盖会被拒绝。 diff --git a/server-sdk/src/agent_sandbox/server/managed.py b/server-sdk/src/agent_sandbox/server/managed.py index 3f656ec4..42e1edd0 100644 --- a/server-sdk/src/agent_sandbox/server/managed.py +++ b/server-sdk/src/agent_sandbox/server/managed.py @@ -247,7 +247,7 @@ def submit(self, body: dict[str, Any]) -> dict[str, Any]: if session.status != "idle": raise BridgeError(409, "session_busy") remaining = body["deadline"] - time.time() - if remaining < 1: + if remaining <= 0: raise BridgeError(409, "turn_deadline") spec = self.runtime.store.require(session.agent_name) spec.limits.turn_timeout_s = remaining From 2ee960cb919d640c282cbbeff24d15aa051b7495 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:31:33 +0000 Subject: [PATCH 05/13] fix(agents): preserve import boundaries and verify release candidates --- .agents/README.md | 2 +- ...-11-06-managed-agents-release-candidate.md | 111 ++++++++++++++++++ .../2026-11-05-managed-agents-api.md | 15 +++ CHANGELOG.md | 2 + .../agent_sandbox/managed_agents/models.py | 21 +--- .../src/agent_sandbox/managed_agents/store.py | 7 +- .../agent_sandbox/managed_agents/worker.py | 2 +- .../agent_sandbox/models/managed_agents.py | 26 ++++ .../providers/alicloud_express/managed.py | 2 +- .../test_managed_import_boundary.py | 38 ++++++ e2e/test_08_providers.py | 11 +- 11 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-release-candidate.md create mode 100644 client-sdk/src/agent_sandbox/models/managed_agents.py create mode 100644 client-sdk/tests/test_providers/test_managed_import_boundary.py diff --git a/.agents/README.md b/.agents/README.md index d22716f7..c306e4fe 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,6 +1,6 @@ # `.agents/` — engineering record of the python-sdk repository -- [Managed Agents API](notes/proposed/architecture/2026-11-05-managed-agents-api.md) · [verification](evidence/verify/2026-11-05-managed-agents-api.md). +- [Managed Agents API](notes/proposed/architecture/2026-11-05-managed-agents-api.md) · [initial verification](evidence/verify/2026-11-05-managed-agents-api.md) · [release-candidate verification](evidence/verify/2026-11-06-managed-agents-release-candidate.md). - [Express SDK completion](notes/implemented/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). - [Provider runtime setup](notes/proposed/process/2026-11-05-provider-runtime-setup.md) · [verification](evidence/verify/2026-11-05-provider-runtime-setup.md). diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-release-candidate.md b/.agents/evidence/verify/2026-11-06-managed-agents-release-candidate.md new file mode 100644 index 00000000..5ffead8e --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-release-candidate.md @@ -0,0 +1,111 @@ +# Verification: Managed Agents release candidate continuation + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156; base candidate `3ba9bd3c8cdb46a7ce8366c4058221d11d4c11f0` plus the scoped import/type-boundary corrections and conditional E2E contract update. +- Result: PARTIAL — local gates and the recorded cloud scenarios passed; remote CI, public release and complete managed FC/template delivery remain separate gates. +- Package under test: matching client/server 0.4.0a7 source candidate; no public artifact overwritten. +- Environment: CPython 3.12.13 on Linux, isolated task environment and PID namespace; pytest 9.2.0, Ruff 0.17.4, mypy 2.4.1, psycopg 3.3.8 and pgserver 0.1.4. Credential values are not recorded. + +## What was verified + +The continuation reuses the existing managed Session implementation rather than replacing the +older run-only draft. The current main commit `c6351c7d6bf1654ae590c9eda7e410cb21d56764` is already +an ancestor of the feature branch. Its original worktree and historical evidence remain intact. + +A fresh interpreter importing `ManagedExpressBackend` loaded the upper managed service/store +through the error import. Cross-review accepted the narrow correction: define `ManagedError` +in the models layer and retain the exact same public class through the old import path. +No error code, HTTP envelope or exception inheritance changes. + +Fresh dependency installation also exposed existing type errors with psycopg 3.3.8/mypy 2.4.1. +The PostgreSQL kwargs now have an exact `TypedDict` for `options` and optional `sslrootcert`; +the internal numeric timeout intermediate has a `float` annotation. The executed expressions, +libpq options and rejection of malformed deadlines are unchanged. Cross-review rejected a +runtime float conversion because it would also accept previously rejected string deadlines. + +The generic provider E2E still asserted that `base.pause()` must fail locally with E8004, +although main already implements conditional pause. Its actual cloud HTTP 400 was preserved. +Cross-review accepted removing that obsolete assertion; eligible-template pause/resume remains +the responsibility of the existing opt-in test and was not verified by this run. + +## Commands run + +`SDK_VERIFY_ENV` identifies the isolated task environment; it is not a required user path. + +- `uv venv --python 3.12 "$SDK_VERIFY_ENV"` +- `uv pip install --python "$SDK_VERIFY_ENV/bin/python" --index-url https://pypi.org/simple -e './server-sdk[dev]' -e './client-sdk[dev,managed-agents]' build twine pre-commit 'pgserver==0.1.4'` +- `python -m pytest client-sdk/tests/test_providers/test_managed_import_boundary.py -q --disable-warnings` +- Scoped `ruff check` and `ruff format --check` over the four import-boundary files. +- `PATH="$SDK_VERIFY_ENV/bin:$PATH" bwrap --unshare-pid --bind / / --dev /dev --proc /proc make ci` +- From `client-sdk/`: `pytest tests/test_managed_agents.py tests/test_providers/test_managed_import_boundary.py -q` +- From `client-sdk/`: `pytest tests/integration/test_e2e.py -m integration -q` +- From `e2e/`, with existing Sandbox credentials loaded only into the child: `python -m pytest -m 'e2e and not e2e_slow' --ignore=test_managed_api_key.py -vv -ra` +- Focused actual-cloud replay after the reviewed test correction and invoking the candidate CLI with Node: `python -m pytest test_08_providers.py::test_alicloud_express_sdk_live test_08_providers.py::test_alicloud_express_cli_live -vv -ra -s` +- `python scripts/e2e-cleanup.py --run-id "$E2E_RUN_ID"` (dry run only), for each run; independent exact-ID GET and successful list readback followed. +- `python scripts/check_release.py candidate --tag v0.4.0a7` and `python scripts/check_release.py preflight` (read-only PyPI comparison, no upload). +- Fresh CPython 3.10.20 environment: install both final wheels with the managed extra, `uv pip check`, isolated import/exception identity and real SQLite transaction probes. +- `bash scripts/check-repo-health.sh --quiet`, `bash scripts/check-no-local-paths.sh`, `git diff --check`. + +## Results + +| Scenario / test | Result | Notes | +| --- | --- | --- | +| Import-boundary regression on original source | FAIL, expected | The real fresh process loaded managed application modules before the fix. | +| Import-boundary regression after correction | PASS | One test; same old/new error class, unchanged catch behavior/status/code/param/envelope. | +| Scoped Ruff and format | PASS | Four scoped files. | +| Initial namespace environment | FAIL, environmental | Reusing the host device mount prevented random initialization and access to the null device. It is not counted as a product pass. | +| Corrected namespace environment | PASS | An isolated device mount provides random/null access without changing host configuration. | +| First complete local CI attempt | FAIL | Server 875 passed/1 skipped; client mypy found nine errors in existing PostgreSQL kwargs and numeric return inference. The original failure remains recorded. | +| Final complete local CI | PASS | Server 875 passed/1 skipped; client 2426 passed/27 skipped/10 deselected. Both packages pass Ruff, format, mypy, build, twine, typed-marker and fresh combined-install checks. | +| Managed API focused regression | PASS | 41 tests, 24.92s; real SQLite/PostgreSQL, private bridge, public API and fresh provider import. | +| Integration-marked mock scenarios | PASS | All 10 tests, 2.74s; these are mock tests, not additional cloud evidence. | +| Initial real fast E2E | FAIL | 131 passed, 10 skipped, 2 deselected, 2 failed in 234.79s. Failures were the stale pause refusal assertion and a non-executable borrowed source CLI entry. | +| Reviewed real provider/CLI replay | PASS | Two tests, 32.88s, using `node` to launch the unchanged candidate CLI. The base pause HTTP 400 remains a recorded failed capability observation, not a pause pass. | +| E2E exclusions/skips | NOT VERIFIED | Management-key creation explicitly excluded from collection; it requires separate consent. Slow cases deselected. Missing optional providers, storage, eligible pause/Snapshot template and optional CLI-native entry account for the recorded skips. No skip is counted as a pass. | +| Independent cloud cleanup | PASS, observed scope | Both run-specific janitor dry runs list zero candidates. Independent exact GET confirms both recorded provider IDs absent; successful account listing returns zero sandboxes and zero matching run tags. No shared-tag deletion performed. | +| Frozen a7 package candidates | PASS | Four distributions pass twine; final CI rebuild is byte-identical to the frozen candidates used for downstream validation. Client requires server `==0.4.0a7`. | +| Fresh installed wheels, Python 3.10.20 | PASS | Dependency consistency, lower/old error identity, no upper application import from provider, real SQLite transaction and unchanged malformed-deadline rejection. This is a local wheel install, not a public PyPI install. | +| Release candidate/preflight | PASS | Correct tag and matching versions; no conflicting existing same-name artifact found by the release check. No artifact uploaded. | +| Repository gates | PASS with warning | Health: 106 PASS, 1 WARN, 0 FAIL; warning is the pre-existing missing maintainer release guide. Hygiene and diff checks pass. | +| Remote CI, public release/install, full managed FC deployment | PENDING | Coordinated as separate gates; source-only or general E2E success is not publication or FC qualification. | + +### Frozen wheel identity + +| Distribution | Version | SHA-256 | +| --- | --- | --- | +| `agent_sandbox_client-0.4.0a7-py3-none-any.whl` | 0.4.0a7 | `7b1d64f1b00dab453127f6d0c8450de75b90dfee55bf116761076e0d5cdaa4a9` | +| `agent_sandbox_server-0.4.0a7-py3-none-any.whl` | 0.4.0a7 | `a7787cd7632f771109cb08da6e71283a8500372c6d670537aab16cedf67ab185` | + +### Release path reviewed, not executed + +The existing publishing workflow runs the full CI, builds both packages, checks the exact tag, +rejects different bytes under an existing artifact name, uploads server before client and verifies +public metadata plus fresh installation afterward. It uses the protected `pypi` environment and +`PYPI_API_TOKEN`; it is not an OIDC upload. Manual dry-run is the default and requires the exact +`v0.4.0a7` tag. Publication, tag creation and merging were not performed by this verifier. + +## Cleanup + +The import regression creates only a bounded interpreter subprocess. Component tests use an +isolated PID namespace and own their temporary database/process teardown. No cloud resource is +created by the local unit checks. The separately authorized actual E2E uses only the existing +Sandbox API key and temporary sandbox resources. Private intent/logs remain on the local private +filesystem; the shared workspace does not provide Unix file-mode enforcement. Returned IDs and +run tags are not copied into public evidence. The suite's scoped finalizers ran, followed by +read-only janitor and independent cloud queries. No owned test process remains running. + +The first independent probe used a nonexistent native `get_info` name and recorded `AttributeError`; +it was not treated as absence. The corrected public `get_info_by_id` query confirmed typed absence. + +## Problems found + +The native provider had an upper-layer import dependency. It is corrected without changing the +public error contract. Fresh dependency type failures and the outdated conditional pause assertion +were corrected after independent review. No performance conclusion is inferred from the import probe. + +## Not verified + +No new package has been published and no management key was created or renewed by this reviewer. +No OpenAI SDK interoperability claim is made. Remote CI, public installed-template model completion, +complete managed FC lifecycle and its own independent cleanup remain required. General sandbox E2E +does not substitute for those gates or for skipped conditional provider capabilities. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 4d620163..96324aeb 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -105,3 +105,18 @@ build and fresh-install checks, and record full results independently of public ## Implementation The pre-commit Ruff pin is aligned to v0.17.3 and its official `ruff-check` hook, matching the declared client dev toolchain. The old v0.4.9 falsely required eager runtime imports for the existing lazy provider facade. All rule selections, secret checks and Bandit remain enabled; required native Express implementation lives under its provider, with a lazy explicit facade export. + +The 2026-11-06 continuation moves the shared `ManagedError` definition into the dependency-free +models layer. Its original `managed_agents.models` path re-exports the same class, preserving +exception identity and the HTTP envelope. The native provider no longer imports the upper +managed application package merely to report a bounded error. A fresh-interpreter regression +demonstrated the previous eager application imports and verifies their absence after the change. +Latest-candidate verification is recorded separately in +[the continuation evidence](../../../evidence/verify/2026-11-06-managed-agents-release-candidate.md); +earlier evidence remains unchanged. + +The same continuation makes the existing libpq kwargs precise to the current psycopg type +contract and annotates the host-generated numeric timeout intermediate without changing runtime +expressions. The real provider E2E no longer asserts the obsolete claim that pause is unimplemented; +eligible-template pause/resume remains separately opt-in and is not inferred from base-template +HTTP rejection. Initial failures and corrected replays are retained in the new evidence. diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6f5d07..3e00077e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Release candidate: matching client/server **0.4.0a7**. / 候选版本:客户 ### Fixed +- Keep the native managed Express backend independent of the application runtime at import time while preserving the public `ManagedError` identity and HTTP error envelope. / 原生托管极速版后端导入时不再加载上层应用运行时,保留公开 `ManagedError` 的同一异常类型与 HTTP 错误结构。 + - 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 显式传入应用认证。 diff --git a/client-sdk/src/agent_sandbox/managed_agents/models.py b/client-sdk/src/agent_sandbox/managed_agents/models.py index 4153c048..d9adfbe5 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/models.py +++ b/client-sdk/src/agent_sandbox/managed_agents/models.py @@ -7,6 +7,8 @@ from dataclasses import dataclass, field from typing import Any +from agent_sandbox.models.managed_agents import ManagedError as ManagedError + PROTOCOL = "agent-sandbox-managed-agents/v1" BRIDGE_PROTOCOL = "agent-sandbox-managed-bridge/v1" TERMINAL = frozenset({"completed", "failed", "cancelled"}) @@ -25,25 +27,6 @@ def digest(value: Any) -> str: return hashlib.sha256(canonical(value).encode()).hexdigest() -class ManagedError(Exception): - """A bounded, explicitly public error, never an arbitrary exception string.""" - - def __init__(self, status: int, code: str, message: str, param: str | None = None) -> None: - super().__init__(message) - self.status, self.code, self.message, self.param = status, code, message, param - - def body(self) -> dict[str, Any]: - """Return the documented error envelope.""" - return { - "error": { - "type": "invalid_request_error", - "code": self.code, - "message": self.message, - "param": self.param, - } - } - - def reject_unknown(body: dict[str, Any], allowed: set[str], param: str = "") -> None: """Reject unsupported fields instead of silently accepting an unavailable feature.""" extra = set(body) - allowed diff --git a/client-sdk/src/agent_sandbox/managed_agents/store.py b/client-sdk/src/agent_sandbox/managed_agents/store.py index 72fec1be..54cadaf2 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/store.py +++ b/client-sdk/src/agent_sandbox/managed_agents/store.py @@ -18,6 +18,11 @@ if TYPE_CHECKING: import builtins from collections.abc import Iterator + from typing import TypedDict + + class _PostgresOptions(TypedDict, total=False): + options: str + sslrootcert: str class Transaction: @@ -253,7 +258,7 @@ def __init__( raise ValueError( "Database URI options are not supported; runtime deadlines are explicit" ) - self.pg_options = { + self.pg_options: _PostgresOptions = { "options": ( f"-c statement_timeout={statement_timeout_ms} -c lock_timeout={lock_timeout_ms}" ) diff --git a/client-sdk/src/agent_sandbox/managed_agents/worker.py b/client-sdk/src/agent_sandbox/managed_agents/worker.py index 279482e6..e8c2561f 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/worker.py +++ b/client-sdk/src/agent_sandbox/managed_agents/worker.py @@ -189,7 +189,7 @@ async def run(self, sid: str, submission_id: str | None = None) -> bool: @staticmethod def _rpc_timeout(sub: dict[str, Any], cap: float = 30) -> float: - remaining = sub["_deadline"] - time.time() + remaining: float = sub["_deadline"] - time.time() if remaining <= 0: raise ManagedError(504, "turn_deadline", "The host execution deadline expired") return min(cap, remaining) diff --git a/client-sdk/src/agent_sandbox/models/managed_agents.py b/client-sdk/src/agent_sandbox/models/managed_agents.py new file mode 100644 index 00000000..fa900a65 --- /dev/null +++ b/client-sdk/src/agent_sandbox/models/managed_agents.py @@ -0,0 +1,26 @@ +"""Dependency-free public errors shared by managed providers and applications.""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["ManagedError"] + + +class ManagedError(Exception): + """A bounded, explicitly public error, never an arbitrary exception string.""" + + def __init__(self, status: int, code: str, message: str, param: str | None = None) -> None: + super().__init__(message) + self.status, self.code, self.message, self.param = status, code, message, param + + def body(self) -> dict[str, Any]: + """Return the documented error envelope.""" + return { + "error": { + "type": "invalid_request_error", + "code": self.code, + "message": self.message, + "param": self.param, + } + } diff --git a/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py b/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py index 3de1e347..65206b61 100644 --- a/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py +++ b/client-sdk/src/agent_sandbox/providers/alicloud_express/managed.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit -from agent_sandbox.managed_agents.models import ManagedError +from agent_sandbox.models.managed_agents import ManagedError if TYPE_CHECKING: from collections.abc import Callable diff --git a/client-sdk/tests/test_providers/test_managed_import_boundary.py b/client-sdk/tests/test_providers/test_managed_import_boundary.py new file mode 100644 index 00000000..003abf1c --- /dev/null +++ b/client-sdk/tests/test_providers/test_managed_import_boundary.py @@ -0,0 +1,38 @@ +"""Native provider imports stay below the optional managed application layer.""" + +from __future__ import annotations + +import subprocess +import sys + + +def test_managed_express_backend_keeps_error_identity_without_loading_application() -> None: + """Exercise the real import order in a fresh interpreter, then the public re-export.""" + source = """ +import sys +from agent_sandbox.providers import ManagedExpressBackend + +assert ManagedExpressBackend.__module__ == 'agent_sandbox.providers.alicloud_express.managed' +assert not any(name.startswith('agent_sandbox.managed_agents') for name in sys.modules) + +from agent_sandbox.models.managed_agents import ManagedError as LowerError +from agent_sandbox.managed_agents.models import ManagedError as PublicError + +assert PublicError is LowerError +error = LowerError(409, 'example_conflict', 'A safe message', 'input') +try: + raise error +except PublicError as caught: + assert caught is error + assert caught.status == 409 + assert caught.body() == {'error': { + 'type': 'invalid_request_error', 'code': 'example_conflict', + 'message': 'A safe message', 'param': 'input'}} +""" + result = subprocess.run( + [sys.executable, "-I", "-c", source], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr diff --git a/e2e/test_08_providers.py b/e2e/test_08_providers.py index 6d8a2485..ce0aaf39 100644 --- a/e2e/test_08_providers.py +++ b/e2e/test_08_providers.py @@ -82,13 +82,12 @@ def test_alicloud_express_sdk_live() -> None: expect_pass=_EXPRESS_PASS, expect_refused={"code.execute", "code.default-context.execute"}, ) - # The base template has no in-sandbox command server. Pause is refused - # by the adapter before a platform call, while the envd - # terminal is not implemented by this project adapter. StartRequest.stdin - # is false, and this envd still keeps the stdin pipe, so cat waits. + # The base template has no in-sandbox command server, and its envd terminal + # is not implemented by this project adapter. StartRequest.stdin is false, + # and this envd still keeps the stdin pipe, so cat waits. Conditional + # pause/resume is verified separately with E2E_PAUSE_TEMPLATE in + # test_standard_contract.py; the base template does not prove admission. assert live.row(report, "sessions").status == "blocked" - assert live.row(report, "sandbox.pause").status == "refused" - assert "E8004" in live.row(report, "sandbox.pause").detail assert live.row(report, "pty").status == "fail" assert live.row(report, "exec.stdin").status == "fail" assert "did not finish" in live.row(report, "exec.stdin").detail From 3aafa9855e92cac36750b1f2102ee49779007252 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:47:31 +0000 Subject: [PATCH 06/13] docs(agents): record exact CI release artifacts --- .agents/README.md | 2 +- .../2026-11-06-managed-agents-ci-artifacts.md | 76 +++++++++++++++++++ .../2026-11-05-managed-agents-api.md | 5 ++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-ci-artifacts.md diff --git a/.agents/README.md b/.agents/README.md index c306e4fe..64f4f954 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,6 +1,6 @@ # `.agents/` — engineering record of the python-sdk repository -- [Managed Agents API](notes/proposed/architecture/2026-11-05-managed-agents-api.md) · [initial verification](evidence/verify/2026-11-05-managed-agents-api.md) · [release-candidate verification](evidence/verify/2026-11-06-managed-agents-release-candidate.md). +- [Managed Agents API](notes/proposed/architecture/2026-11-05-managed-agents-api.md) · [initial verification](evidence/verify/2026-11-05-managed-agents-api.md) · [release-candidate verification](evidence/verify/2026-11-06-managed-agents-release-candidate.md) · [CI artifacts](evidence/verify/2026-11-06-managed-agents-ci-artifacts.md). - [Express SDK completion](notes/implemented/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). - [Provider runtime setup](notes/proposed/process/2026-11-05-provider-runtime-setup.md) · [verification](evidence/verify/2026-11-05-provider-runtime-setup.md). diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-ci-artifacts.md b/.agents/evidence/verify/2026-11-06-managed-agents-ci-artifacts.md new file mode 100644 index 00000000..3e19928a --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-ci-artifacts.md @@ -0,0 +1,76 @@ +# Verification: Managed Agents CI artifacts + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156, runtime commit `2ee960cb919d640c282cbbeff24d15aa051b7495`. +- Result: PARTIAL — required remote CI and exact CI-wheel installation passed; publication and managed FC acceptance are coordinated separately. +- Package under test: matching client/server 0.4.0a7, downloaded unchanged from the successful GitHub Actions artifact. +- Environment: GitHub Ubuntu matrix on Python 3.10/3.11/3.12; fresh local CPython 3.12.13 wheel-install environment. No credential values recorded. + +## What was verified + +The [complete CI run](https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/42157232742) +and [documentation-link run](https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/42157232731) +both completed successfully at the exact runtime commit. The CI distribution archive was +downloaded and its SHA-256 compared with GitHub's artifact digest before inspecting its members. +The exact CI wheels were then installed together in a new environment outside the source tree. + +The preceding [local/component/cloud checkpoint](2026-11-06-managed-agents-release-candidate.md) +is preserved. Its locally built packages and the CI packages have identical uncompressed wheel +members, including code, `METADATA`, `WHEEL` and `RECORD`, but different ZIP external mode attributes: +local files are `100755`, while CI files are `100644`. The shared development filesystem does +not preserve normal file modes. These archives therefore have different bytes and must not be +described as the same tested artifact. The CI originals are the selected publication candidates; +earlier local artifacts remain a separate comparison, without overwriting either set. + +## Commands run + +- Fetch the exact commit's Actions runs/jobs and distribution artifact through GitHub. +- Verify archive SHA-256 against the returned artifact digest. +- Compare wheel members and ZIP metadata without executing downloaded code. +- Create a new Python 3.12 environment and install both exact CI wheels, selecting the managed extra. +- `uv pip check` in that environment. +- Isolated interpreter probe: matching versions, native provider import without upper managed application imports, identical lower/old public `ManagedError`, server import, and a real SQLite transaction. + +## Results + +| Check | Result | Scope | +| --- | --- | --- | +| Six client/server matrix jobs | PASS | Python 3.10, 3.11 and 3.12; lint, formatting, mypy and complete non-integration tests. | +| Pinned E2B callable contract | PASS | Public signatures and sync/async shapes. | +| Python to Node provider engine | PASS | Required cross-language scenarios using the pinned CLI/provider fixtures. | +| Public runtime fixtures | PASS | Both `python-hello` and `agents-cli` build, run actual HTTP checks and clean up their owned containers. | +| Hygiene, build and distributions | PASS | Complete CI artifact checks. | +| Documentation links | PASS | Independent workflow. | +| Artifact identity | PASS | Artifact 13475860199; archive SHA-256 `26548e9e5a11a59fda47bd664dcf3acec97c773125fa492f198decaf76e2dd61`. | +| CI versus local wheel payload | SAME FILE BYTES, DIFFERENT ARCHIVE | All 368 client and 65 server uncompressed entries match; only ZIP file-mode metadata differs. | +| Fresh exact-CI-wheel install | PASS | CPython 3.12.13, consistent dependencies, same error class, no upper application imports from native provider, real SQLite transaction. | +| PyPI upload/readback and joint managed FC deployment | PENDING | No publication or FC success is inferred from CI. | + +### Selected exact CI distributions + +| File | SHA-256 | +| --- | --- | +| `agent_sandbox_client-0.4.0a7-py3-none-any.whl` | `1fa30ec182465753d9b538b3837d625325da9b4340ba220cfbc0764de6fbca00` | +| `agent_sandbox_server-0.4.0a7-py3-none-any.whl` | `210db5e796f4fc63e84156d19076c946d261b48c47ec6c8c1db82b8b60691f08` | +| `agent_sandbox_client-0.4.0a7.tar.gz` | `7e2c9c33d7efa0cc7c89f417ef853089af4fb70e48a25e1661fca761980a1539` | +| `agent_sandbox_server-0.4.0a7.tar.gz` | `f2e1f4fb6e8e17d272ad35e52e02df26a640c7adcb6e909515b501de517ac063` | + +## Cleanup + +This continuation performs read-only GitHub access and a bounded local installation/SQLite +probe. It creates no cloud resource, management key, tag or public release. Temporary database +files are removed by their context manager. The CI fixture jobs independently confirm deletion +of their owned containers. The exact distribution archives remain as review artifacts. + +## Problems found + +Shared-filesystem mode normalization makes local archive hashes differ from canonical CI builds. +No source or package-metadata content difference was found. Downstream validation must select +and record one exact distribution set rather than silently treating the two archives as identical. + +## Not verified + +Public PyPI installation, active managed FC deployment and model business acceptance are not +certified by this record. The management-key preparation E2E remains intentionally excluded +pending its separate explicit consent. The generic cloud E2E checkpoint records its actual skips +and corrected replays independently. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 96324aeb..271e88f4 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -120,3 +120,8 @@ contract and annotates the host-generated numeric timeout intermediate without c expressions. The real provider E2E no longer asserts the obsolete claim that pause is unimplemented; eligible-template pause/resume remains separately opt-in and is not inferred from base-template HTTP rejection. Initial failures and corrected replays are retained in the new evidence. + +The exact runtime commit passes the complete remote matrix and both public runtime fixtures. +The selected CI distribution identities and the local-filesystem archive-mode difference are +recorded in [the CI artifact evidence](../../../evidence/verify/2026-11-06-managed-agents-ci-artifacts.md). +Public release and managed FC qualification remain separate acceptance conditions. From 5061fd3b6ad5b3fea6f641daf49f62efd55acc22 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:55:15 +0000 Subject: [PATCH 07/13] fix(agents): accept bounded public database CA startup argument --- ...-06-managed-agents-database-ca-argument.md | 59 ++++++++ .../2026-11-05-managed-agents-api.md | 7 + CHANGELOG.md | 2 + .../agent_sandbox/managed_agents/__main__.py | 26 +++- client-sdk/tests/test_managed_entrypoint.py | 137 ++++++++++++++++++ docs/en/guide/managed-agents-api.md | 7 + docs/zh/guide/managed-agents-api.md | 5 + 7 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-database-ca-argument.md create mode 100644 client-sdk/tests/test_managed_entrypoint.py diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-database-ca-argument.md b/.agents/evidence/verify/2026-11-06-managed-agents-database-ca-argument.md new file mode 100644 index 00000000..937a4df2 --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-database-ca-argument.md @@ -0,0 +1,59 @@ +# Verification: Managed database CA startup argument + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156; continuation of runtime `2ee960cb919d640c282cbbeff24d15aa051b7495`. +- Result: PARTIAL — focused local validation passed; latest full gates and actual FC argument readback are in progress. +- Package under test: matching 0.4.0a7 source candidate, not yet published. +- Environment: CPython 3.12.13, psycopg 3.3.8, real local PostgreSQL and SQLite, PID-isolated local CLI fixtures. No credential values recorded. + +## What was verified + +The deployment continuation identified environment configuration larger than the target FC budget. +Independent review accepted a narrow public-CA startup argument rather than weakening TLS. +`--database-ca-base64` decodes canonical Base64 ASCII with both encoded and decoded size bounds, +rejects a simultaneous existing `ABX_MANAGED_DATABASE_CA_PEM` source, and passes the result to the +existing certificate-only/X509 validator and private temporary CA-file context. No role credential +is added to arguments. Existing environment-only usage remains supported. + +The official [CustomContainerConfig reference](https://www.alibabacloud.com/help/en/functioncompute/api-fc-2023-03-30-struct-customcontainerconfig) +defines array startup arguments without a documented size guarantee. The local 64 KiB decode bound +therefore does not establish FC support for a 64 KiB certificate argument. Actual accepted sizes +must be recorded from the specific deployment/readback. + +## Commands run + +- Client Ruff and format checks for the entrypoint and new command tests. +- `mypy src/agent_sandbox/` in `client-sdk/`. +- `pytest tests/test_managed_entrypoint.py tests/test_managed_agents.py tests/test_providers/test_managed_import_boundary.py -q` in an isolated PID namespace. + +## Results + +| Scenario | Result | Scope | +| --- | --- | --- | +| Actual command parsing with public CA | PASS | Gateway, Worker and Dispatcher all reach the existing validator and a real 0600 temporary file, removed after startup returns. | +| Environment compatibility and conflict | PASS | Existing environment source still works; a present source, including an empty one, conflicts with the argument. | +| Malformed encoding/ASCII/size | PASS | Static parser errors without echoing the supplied value; noncanonical Base64, non-ASCII content and oversized input rejected. | +| Invalid PEM/X509 and cleanup on startup failure | PASS | Other PEM types and invalid certificates rejected; an exception during startup removes the temporary file. | +| Focused command/managed API/import regression | PASS | 57 tests, zero skipped, 26.81s. Includes 16 new command cases and the preceding 41 scenarios. | +| Full client mypy | PASS | 114 files. | +| Latest complete local/remote CI | IN PROGRESS | Separate results will identify the exact tested commit. | +| Actual FC creation/startup argument readback | PENDING | No cloud argv-size or successful joint deployment claim is made here. | + +## Cleanup + +The new command tests start no cloud resource. Their public certificate files are created in +private temporary directories and are checked absent after both normal return and startup error. +The real local database and CLI fixture teardown remains unchanged. + +## Problems found + +The former CA environment-only transport can exceed a constrained deployment's environment +budget. This optional argument changes transport only; `sslmode=verify-full`, explicit libpq +CA configuration and certificate validation remain intact. + +## Not verified + +No new package is published. The preceding CI wheels predate this additional flag and cannot +stand in for its final distribution. A new exact CI artifact and actual FC readback are required. +Models/providers in the local regression remain fixtures; its real PostgreSQL/HTTP/process +components do not imply a real-model FC active-cancellation acceptance. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 271e88f4..8ee4f8ed 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -125,3 +125,10 @@ The exact runtime commit passes the complete remote matrix and both public runti The selected CI distribution identities and the local-filesystem archive-mode difference are recorded in [the CI artifact evidence](../../../evidence/verify/2026-11-06-managed-agents-ci-artifacts.md). Public release and managed FC qualification remain separate acceptance conditions. + +The FC continuation adds only `--database-ca-base64` to the explicit managed role command. +It carries public CA certificates, decodes canonical ASCII Base64 within 64 KiB, rejects an +existing PEM environment source, and reuses the existing CA/X509 validation and private temporary +file. Role credentials stay in their original configuration. Actual cloud acceptance of a +particular startup-argument size is a separate gate; no undocumented FC argument maximum is assumed. +See [the argument verification](../../../evidence/verify/2026-11-06-managed-agents-database-ca-argument.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e00077e..22560cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Release candidate: matching client/server **0.4.0a7**. / 候选版本:客户 ### Fixed +- Accept a bounded public database CA argument for managed role startup while preserving certificate validation and the existing environment source. / 托管角色启动支持有界的公开数据库 CA 参数,保留证书校验与原有环境变量来源。 + - Keep the native managed Express backend independent of the application runtime at import time while preserving the public `ManagedError` identity and HTTP error envelope. / 原生托管极速版后端导入时不再加载上层应用运行时,保留公开 `ManagedError` 的同一异常类型与 HTTP 错误结构。 - Implement typed Express pause/resume with session credential rebinding, preserve private traffic tokens without implicit reconnection, and keep legacy application command keyword behavior. / 完善极速版类型化暂停恢复和会话凭据更新,保留私有流量 token 且不隐式重连,并兼容原应用命令关键字行为。 diff --git a/client-sdk/src/agent_sandbox/managed_agents/__main__.py b/client-sdk/src/agent_sandbox/managed_agents/__main__.py index 065bbee8..22ca54fa 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/__main__.py +++ b/client-sdk/src/agent_sandbox/managed_agents/__main__.py @@ -3,6 +3,8 @@ from __future__ import annotations import argparse +import base64 +import binascii import contextlib import json import os @@ -46,12 +48,34 @@ def main() -> None: parser.add_argument("--port", type=int, default=9000) parser.add_argument("--host", default="0.0.0.0") # nosec B104 - authenticated container ingress parser.add_argument("--local", action="store_true") + parser.add_argument("--database-ca-base64", type=decode_database_ca, metavar="BASE64_PEM") args = parser.parse_args() + pem = os.getenv("ABX_MANAGED_DATABASE_CA_PEM") + if args.database_ca_base64 is not None: + if pem is not None: + parser.error("Provide database CA through exactly one argument or environment source") + pem = args.database_ca_base64 ssl.create_default_context(cafile=os.getenv("ABX_MANAGED_TLS_CA_FILE")) - with database_ca_file(os.getenv("ABX_MANAGED_DATABASE_CA_PEM")) as ca_file: + with database_ca_file(pem) as ca_file: serve(args, ca_file) +def decode_database_ca(value: str) -> str: + """Decode bounded public CA material without reflecting argument values in errors.""" + try: + if not value or len(value) > 4 * ((65536 + 2) // 3): + raise ValueError + encoded = value.encode("ascii") + raw = base64.b64decode(encoded, validate=True) + if len(raw) > 65536 or base64.b64encode(raw) != encoded: + raise ValueError + return raw.decode("ascii") + except (ValueError, UnicodeError, binascii.Error): + raise argparse.ArgumentTypeError( + "Database CA must be canonical Base64 of at most 64 KiB ASCII PEM" + ) from None + + @contextlib.contextmanager def database_ca_file(pem: str | None) -> Iterator[str | None]: """Materialize a bounded CA-only bundle privately for libpq, never in guest config.""" diff --git a/client-sdk/tests/test_managed_entrypoint.py b/client-sdk/tests/test_managed_entrypoint.py new file mode 100644 index 00000000..6a5912fb --- /dev/null +++ b/client-sdk/tests/test_managed_entrypoint.py @@ -0,0 +1,137 @@ +"""Actual managed command parsing preserves bounded database trust material.""" + +from __future__ import annotations + +import base64 +import os +import ssl +import stat +import sys +from pathlib import Path +from typing import Any + +import pytest + +from agent_sandbox.managed_agents import __main__ as entrypoint + + +@pytest.fixture +def ca_pem() -> str: + certificates = ssl.create_default_context().get_ca_certs(binary_form=True) + assert certificates, "The actual CA-validation regression needs the system trust store" + return ssl.DER_cert_to_PEM_cert(certificates[0]) + + +def run_main(monkeypatch: Any, args: list[str], callback: Any = None) -> None: + monkeypatch.setattr(sys, "argv", ["managed-agents", *args]) + monkeypatch.setattr(entrypoint, "serve", callback or (lambda *_: pytest.fail("serve called"))) + entrypoint.main() + + +@pytest.mark.parametrize("mode", ["gateway", "worker", "dispatcher"]) +def test_base64_ca_main_validates_materializes_and_removes( + monkeypatch: Any, ca_pem: str, mode: str +) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + paths: list[Path] = [] + + def serve(args: Any, ca_file: str | None) -> None: + assert args.mode == mode + assert ca_file is not None + path = Path(ca_file) + assert path.read_text() == ca_pem + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + paths.append(path) + + encoded = base64.b64encode(ca_pem.encode("ascii")).decode("ascii") + run_main(monkeypatch, [mode, "--database-ca-base64", encoded], serve) + assert paths and not paths[0].exists() and not paths[0].parent.exists() + + +@pytest.mark.parametrize("environment", ["", "other CA source"]) +def test_base64_ca_main_rejects_conflicting_environment( + monkeypatch: Any, capsys: Any, ca_pem: str, environment: str +) -> None: + monkeypatch.setenv("ABX_MANAGED_DATABASE_CA_PEM", environment) + encoded = base64.b64encode(ca_pem.encode("ascii")).decode("ascii") + with pytest.raises(SystemExit) as error: + run_main(monkeypatch, ["gateway", "--database-ca-base64", encoded]) + assert error.value.code == 2 + output = capsys.readouterr().err + assert "exactly one argument or environment source" in output + assert encoded not in output + if environment: + assert environment not in output + + +@pytest.mark.parametrize( + "invalid", + ["not_base64!", "é", "QUJD====", base64.b64encode(b"\xff").decode("ascii")], +) +def test_base64_ca_main_rejects_invalid_encoding_without_echo( + monkeypatch: Any, capsys: Any, invalid: str +) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + with pytest.raises(SystemExit) as error: + run_main(monkeypatch, ["worker", "--database-ca-base64", invalid]) + assert error.value.code == 2 + output = capsys.readouterr().err + assert "canonical Base64" in output and invalid not in output + + +@pytest.mark.parametrize("raw", [b"x" * 65537, b"x" * 100000]) +def test_base64_ca_main_rejects_oversize_before_serve( + monkeypatch: Any, capsys: Any, raw: bytes +) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + encoded = base64.b64encode(raw).decode("ascii") + with pytest.raises(SystemExit) as error: + run_main(monkeypatch, ["dispatcher", "--database-ca-base64", encoded]) + assert error.value.code == 2 + output = capsys.readouterr().err + assert "64 KiB ASCII PEM" in output and encoded not in output + + +@pytest.mark.parametrize( + "invalid_pem", + [ + "not a certificate", + "-----BEGIN PUBLIC KEY-----\nQQ==\n-----END PUBLIC KEY-----", + "-----BEGIN CERTIFICATE-----\nQQ==\n-----END CERTIFICATE-----", + ], +) +def test_base64_ca_main_still_requires_valid_certificate( + monkeypatch: Any, invalid_pem: str +) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + encoded = base64.b64encode(invalid_pem.encode("ascii")).decode("ascii") + with pytest.raises(ValueError, match="Database CA"): + run_main(monkeypatch, ["gateway", "--database-ca-base64", encoded]) + + +def test_existing_environment_ca_main_remains_supported(monkeypatch: Any, ca_pem: str) -> None: + monkeypatch.setenv("ABX_MANAGED_DATABASE_CA_PEM", ca_pem) + paths: list[Path] = [] + + def serve(args: Any, ca_file: str | None) -> None: + assert ca_file is not None and Path(ca_file).read_text() == ca_pem + paths.append(Path(ca_file)) + + run_main(monkeypatch, ["gateway"], serve) + assert not paths[0].exists() + assert os.environ["ABX_MANAGED_DATABASE_CA_PEM"] == ca_pem + + +def test_base64_ca_main_removes_file_after_startup_failure(monkeypatch: Any, ca_pem: str) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + paths: list[Path] = [] + + def serve(args: Any, ca_file: str | None) -> None: + assert ca_file is not None + paths.append(Path(ca_file)) + raise RuntimeError("example startup failure") + + encoded = base64.b64encode(ca_pem.encode("ascii")).decode("ascii") + with pytest.raises(RuntimeError, match="example startup failure"): + run_main(monkeypatch, ["worker", "--database-ca-base64", encoded], serve) + assert not paths[0].exists() and not paths[0].parent.exists() diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index c3f28db9..c7529337 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -143,4 +143,11 @@ PEM CA bundle. Invalid CA configuration fails; it does not disable verification. For a database which requires a private CA, supply `ABX_MANAGED_DATABASE_CA_PEM` to the three Server roles. It accepts only 1–8 PEM certificates, at most 64 KiB. The runtime writes a private temporary CA file and passes it to libpq while preserving the connection URI’s `sslmode=verify-full`. This setting is separate from the guest HTTPS CA setting and is never sent to the sandbox. +Deployments with limited environment-variable space can pass the public CA bundle as +`--database-ca-base64 BASE64_PEM` after the role name. Use canonical Base64 of ASCII PEM; this +argument and `ABX_MANAGED_DATABASE_CA_PEM` are mutually exclusive. Keep database credentials, +model keys and internal tokens in their existing credential configuration. The same certificate +validation and private temporary file apply. The runtime's 64 KiB decoded bound does not guarantee +that a deployment platform accepts startup arguments of that size; platform limits still apply. + Guest HTTPS exchanges have a total wall deadline of 30 seconds, clipped to the remaining execution budget during a turn. If that budget expires with an unknown result, the runtime attempts owned compute cleanup and retains an uncertain Submission; a public Turn reports the Gateway failure separately from the unconfirmed agent outcome. PostgreSQL connections use a 10-second connection/statement timeout and a 5-second lock timeout. URI `options` overrides are rejected. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index b0338c13..5921fcaa 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -127,4 +127,9 @@ FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、 数据库需要私有 CA 时,为三个 Server 角色提供 `ABX_MANAGED_DATABASE_CA_PEM`。它只接受 1–8 个 PEM 证书,总大小不超过 64 KiB。运行时生成私有临时 CA 文件并显式传给 libpq,保留连接地址中的 `sslmode=verify-full`。它与沙箱 HTTPS 的 CA 配置分别使用,不会发送到沙箱。 +环境变量空间有限的部署可在角色名后传入 `--database-ca-base64 BASE64_PEM`,值为 ASCII PEM +公开 CA bundle 的标准 Base64 编码。它与 `ABX_MANAGED_DATABASE_CA_PEM` 互斥;数据库凭据、模型 +Key 与内部令牌继续使用原有凭据配置。证书验证与私有临时文件流程相同。运行时解码后 64 KiB 的 +上限不表示部署平台一定接受同等大小的启动参数,仍须满足平台自身限制。 + 沙箱 HTTPS 请求的总墙钟期限为 30 秒,执行期间还会收紧到本轮剩余预算。预算耗尽而结果未知时,运行时尝试清理自有计算资源,Submission 保持 uncertain;已创建的 Turn 会标记 Gateway 失败,并另行说明 Agent 执行结果尚未确认。PostgreSQL 连接与单语句超时为 10 秒,锁等待超时为 5 秒;连接地址中的 `options` 覆盖会被拒绝。 From 7b017f1067c26785d103a8dc7001bfa322303234 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:33:37 +0000 Subject: [PATCH 08/13] fix(agents): stream HTTP chunks and segment public CA arguments --- ...6-managed-agents-streaming-and-recovery.md | 68 +++++++++ .../2026-11-05-managed-agents-api.md | 9 ++ CHANGELOG.md | 4 +- .../agent_sandbox/managed_agents/__main__.py | 17 ++- .../src/agent_sandbox/managed_agents/http.py | 38 +++-- client-sdk/tests/test_managed_agents.py | 137 ++++++++++++++++++ client-sdk/tests/test_managed_entrypoint.py | 35 +++++ docs/en/guide/managed-agents-api.md | 4 + docs/zh/guide/managed-agents-api.md | 3 + e2e/managed_sse_pg.py | 10 +- 10 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md b/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md new file mode 100644 index 00000000..d6da07d8 --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md @@ -0,0 +1,68 @@ +# Verification: Managed streaming and process recovery + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156; continuation of `5061fd3b6ad5b3fea6f641daf49f62efd55acc22`. +- Result: PARTIAL — the recorded local regressions and real process faults passed; latest CI artifacts and FC/model delivery remain separate gates. +- Package under test: matching client/server 0.4.0a7 candidate, not published by this verifier. +- Environment: CPython 3.12.13, real local PostgreSQL 16.2, SQLite, actual private HTTP, owned PID namespaces and deterministic CLI subprocesses. No credential values recorded. + +## What was verified + +The [FC streaming reference](https://help.aliyun.com/zh/functioncompute/does-function-compute-support-sse-streaming-response) +identifies chunked response encoding as the Web function's streaming signal. The former Gateway +wrote bare SSE bytes with connection-close framing. Independent review accepted real HTTP/1.1 +chunk framing for every event and heartbeat, a normal zero terminator, and connection closure +without another HTTP status when an exception occurs after streaming headers. Existing PostgreSQL +subscription records and the cross-process event/deletion gates are unchanged. + +Separately, actual FC argument validation rejected the earlier single CA argument with a maximum +length of 2048. The runtime option now accepts one or more Base64 segments, bounds their total +encoded length before joining, and validates canonical ASCII Base64 once. Certificate validation, +the 64 KiB decoded bound, environment-source conflict and temporary-file cleanup are unchanged. +The consumer splits arguments to satisfy the observed per-argument limit; the runtime bound does +not promise that FC accepts every possible total argument size. + +## Commands run + +- Client Ruff/format and `mypy src/agent_sandbox/`. +- `pytest tests/test_managed_entrypoint.py tests/test_managed_agents.py tests/test_providers/test_managed_import_boundary.py -q` in an owned PID namespace. +- Task-only `pytest_generate_tests` plugin: add real PostgreSQL only to otherwise unparameterized `components` cases; retain every product assertion and existing explicit SQLite/PostgreSQL parameterization. +- Actual local PostgreSQL stop/restart against a warm HTTP Gateway, without a Worker/model. +- Independent observer SIGKILL and bridge SIGKILL/restart probes using persistent PostgreSQL and the actual private bridge/CLI parser. + +## Results + +| Scenario | Result | Evidence boundary | +| --- | --- | --- | +| Combined managed/argument/import regressions | PASS | 66 cases, zero skipped, 27.68s. Includes single/multiple CA segments, combined-size and padding rejection, real raw-socket streaming and existing HTTP decoder behavior. | +| Live chunk framing | PASS | Raw HTTP/1.1 headers have chunked encoding without Content-Length; Unicode uses byte lengths, heartbeat chunks decode, and normal revocation ends with a zero chunk. The actual CLI Turn is still in progress when bytes arrive. | +| Midstream exception | PASS | Real socket receives no second HTTP response or false normal terminator; its durable subscription is removed. | +| Real PostgreSQL expansion | PASS | Before this streaming change: 40 cases, 24 real PostgreSQL, 3 explicit SQLite, 13 without components, zero skipped, 40.46s. Includes 19 original default-SQLite scenarios newly executed on PostgreSQL. Provider/model remain fixtures. | +| Database unavailable before admission | PASS | Stopping only the owned PG yields four 503 results: readiness, existing Session read, old-key replay and fresh-key creation. Restarting the same database restores the warm Gateway's original Session/state identity; object and idempotency counts do not increase. Cleanup confirmed. | +| Observer process killed | PASS | Actual SIGKILL after the CLI side effect and persisted acceptance; a new process waits for the real two-second lease and observes the original receipt. Public Turn completes; CLI counter, real submission POST count and backend create count all remain one. 12.175s. | +| Guest bridge process restarted | PASS | Actual bridge SIGKILL followed by the same journal/workspace/port and a new epoch. Native gap and uncertain host outcome are visible; queued successor remains frozen. Original CLI is allowed to continue until after the assertions. Counter/POST/create counts remain one. 12.07s. | +| Initial fault-probe topology | FAIL, harness | The first two attempts placed replacement host observers inside the guest PID view, correctly causing conservative stop uncertainty. No duplicate execution occurred. The corrected fixture gives the guest its own PID namespace and fixed supervisor, matching the intended topology; assertions were not relaxed. | +| Latest complete CI and exact artifacts | PENDING | Previous CI wheels do not contain this additional transport/argument continuation. | +| Real FC live streaming/model workflow | PENDING | Source/official-contract evidence and local sockets are not claimed as cloud reproduction or complete FC qualification. | + +## Cleanup + +Fault probes own all bridge/observer subprocesses and their guest namespace. The parent tracks +PID creation identity, kills/reaps only its own tree after assertions, and removes its exact PG +data. The database stop/restart probe verifies HTTP thread termination and PG-data removal. +No cloud resource, management Key or real model request is created by these extra component probes. +Failed and corrected attempts are retained separately without credentials or cloud resource IDs. + +## Problems found + +The former close-framed SSE response did not supply FC's documented streaming signal, and a +single public CA argument exceeded the observed platform limit. Both narrow corrections were +independently reviewed before implementation. The guest process observer correctly rejects +unknown same-UID processes, which requires an appropriately isolated test topology. + +## Not verified + +Local PostgreSQL connection refusal is not a cloud RDS outage or an in-flight transaction failure. +The deterministic CLI is not a real model or cloud Provider failure. No hostile same-UID process +attestation is claimed. Final FC/model/public-package acceptance and its independent cleanup are +coordinated separately; no publication or merge is inferred from these local results. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 8ee4f8ed..9254af00 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -132,3 +132,12 @@ existing PEM environment source, and reuses the existing CA/X509 validation and file. Role credentials stay in their original configuration. Actual cloud acceptance of a particular startup-argument size is a separate gate; no undocumented FC argument maximum is assumed. See [the argument verification](../../../evidence/verify/2026-11-06-managed-agents-database-ca-argument.md). + +Actual FC validation then established a 2048-character limit on one startup argument. The CA +option now accepts multiple segments, bounds their combined encoding before allocation, and +validates the joined canonical Base64 exactly once. Single-argument use remains supported. +The HTTP Gateway now emits actual chunked SSE frames and a normal terminator; after headers, +stream failure closes the connection rather than appending a second HTTP response. Shared +PostgreSQL subscription admission and frame/deletion gates remain unchanged. Real PG, HTTP and +process-kill recovery evidence is recorded in +[the streaming/recovery continuation](../../../evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 22560cff..248b7ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,9 @@ Release candidate: matching client/server **0.4.0a7**. / 候选版本:客户 ### Fixed -- Accept a bounded public database CA argument for managed role startup while preserving certificate validation and the existing environment source. / 托管角色启动支持有界的公开数据库 CA 参数,保留证书校验与原有环境变量来源。 +- Accept bounded public database CA startup arguments in one or more segments while preserving certificate validation and the existing environment source. / 托管角色启动支持分段传递有界的公开数据库 CA,保留证书校验与原有环境变量来源。 + +- Frame managed SSE responses with HTTP/1.1 chunks and close failed streams without appending another HTTP response. / 托管 SSE 响应采用 HTTP/1.1 分块编码,流中途失败时关闭连接,不追加第二个 HTTP 响应。 - Keep the native managed Express backend independent of the application runtime at import time while preserving the public `ManagedError` identity and HTTP error envelope. / 原生托管极速版后端导入时不再加载上层应用运行时,保留公开 `ManagedError` 的同一异常类型与 HTTP 错误结构。 diff --git a/client-sdk/src/agent_sandbox/managed_agents/__main__.py b/client-sdk/src/agent_sandbox/managed_agents/__main__.py index 22ca54fa..51d589ff 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/__main__.py +++ b/client-sdk/src/agent_sandbox/managed_agents/__main__.py @@ -48,24 +48,31 @@ def main() -> None: parser.add_argument("--port", type=int, default=9000) parser.add_argument("--host", default="0.0.0.0") # nosec B104 - authenticated container ingress parser.add_argument("--local", action="store_true") - parser.add_argument("--database-ca-base64", type=decode_database_ca, metavar="BASE64_PEM") + parser.add_argument("--database-ca-base64", nargs="+", metavar="BASE64_PEM") args = parser.parse_args() pem = os.getenv("ABX_MANAGED_DATABASE_CA_PEM") if args.database_ca_base64 is not None: if pem is not None: parser.error("Provide database CA through exactly one argument or environment source") - pem = args.database_ca_base64 + try: + pem = decode_database_ca(args.database_ca_base64) + except argparse.ArgumentTypeError as error: + parser.error(str(error)) ssl.create_default_context(cafile=os.getenv("ABX_MANAGED_TLS_CA_FILE")) with database_ca_file(pem) as ca_file: serve(args, ca_file) -def decode_database_ca(value: str) -> str: +def decode_database_ca(parts: list[str]) -> str: """Decode bounded public CA material without reflecting argument values in errors.""" try: - if not value or len(value) > 4 * ((65536 + 2) // 3): + if ( + not parts + or any(not part for part in parts) + or sum(map(len, parts)) > 4 * ((65536 + 2) // 3) + ): raise ValueError - encoded = value.encode("ascii") + encoded = "".join(parts).encode("ascii") raw = base64.b64decode(encoded, validate=True) if len(raw) > 65536 or base64.b64encode(raw) != encoded: raise ValueError diff --git a/client-sdk/src/agent_sandbox/managed_agents/http.py b/client-sdk/src/agent_sandbox/managed_agents/http.py index b15d273f..ae9f3648 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/http.py +++ b/client-sdk/src/agent_sandbox/managed_agents/http.py @@ -140,6 +140,11 @@ def _send(self, status: int, body: Any, headers: dict[str, str] | None = None) - self.end_headers() self.wfile.write(payload) + def _chunk(self, payload: bytes) -> None: + self.connection.settimeout(write_timeout) + self.wfile.write(f"{len(payload):X}\r\n".encode("ascii") + payload + b"\r\n") + self.wfile.flush() + def _owner(self, write: bool = False) -> str: supplied = self.headers.get("Authorization", "") if auth is None or not supplied.startswith("Bearer "): @@ -226,6 +231,7 @@ def _events( raise ManagedError(400, "invalid_cursor", "Conflicting event cursors") cursor = header or after subscription_id = None + stream_started = stream_aborted = False with service.store.transaction() as tx: session = service._session(tx, sid, owner) if cursor is None: @@ -263,12 +269,14 @@ def admit(event_id: str) -> bool: raise ManagedError(404, "resource_not_found", "Session was removed") self.send_response(status) self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") self.send_header("Cache-Control", "no-store") self.send_header("X-Accel-Buffering", "no") self.send_header("X-Request-Id", self.request_id) self.send_header("Connection", "close") for name, value in (headers or {}).items(): self.send_header(name, value) + self._stream_started = stream_started = True self.end_headers() end = time.monotonic() + 20 while time.monotonic() < end: @@ -294,25 +302,29 @@ def admit(event_id: str) -> bool: f"id: {event['event_id']}\nevent: {payload['type']}\n" f"data: {canonical(payload)}\n\n" ) - self.connection.settimeout(write_timeout) - self.wfile.write(frame.encode()) - self.wfile.flush() + self._chunk(frame.encode()) cursor = event["event_id"] if not pending: - self.wfile.write(b": heartbeat\n\n") - self.wfile.flush() + self._chunk(b": heartbeat\n\n") time.sleep(0.2) try: pending = service.events(owner, sid, cursor) except ManagedError: break + except BaseException: + stream_aborted = True + raise finally: - if subscription_id: - with service.store.transaction() as tx: - tx.execute( - "DELETE FROM managed_objects WHERE application=? AND id=?", - (tx.application, subscription_id), - ) + try: + if stream_started and not stream_aborted: + self._chunk(b"") + finally: + if subscription_id: + with service.store.transaction() as tx: + tx.execute( + "DELETE FROM managed_objects WHERE application=? AND id=?", + (tx.application, subscription_id), + ) def _handle(self, method: str) -> None: self.close_connection = True @@ -379,11 +391,15 @@ def _handle(self, method: str) -> None: def _safe(self, method: str) -> None: self.request_id = uuid.uuid4().hex + self._stream_started = False try: self._handle(method) except (BrokenPipeError, ConnectionResetError, TimeoutError): return except Exception as exc: + if self._stream_started: + self.close_connection = True + return error = ( exc if isinstance(exc, ManagedError) diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index 61e140db..618052c8 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -8,6 +8,7 @@ import hashlib import json import os +import socket import sys import threading import time @@ -1404,3 +1405,139 @@ def test_guest_never_starts_an_already_expired_submission(tmp_path: Path) -> Non assert not bridge.runtime.get_session(local["session_id"]).turns with bridge._db() as db: assert db.execute("SELECT COUNT(*) FROM receipts").fetchone()[0] == 0 + + +@pytest.mark.asyncio +async def test_sse_http11_chunks_arrive_during_turn_and_close_cleanly(components: Any) -> None: + service, _, worker, _ = components + session = service.handle( + "owner", + "POST", + "/v1/agents/sessions", + {"agent": {"model": "fixture-model"}, "input": "slow", "metadata": {"note": "你好"}}, + )[1] + sid = session["id"] + running = asyncio.create_task(worker.run(sid)) + token = "test-stream-token-" + "x" * 32 + document = { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + server = make_server(service, TokenAuthenticator(lambda: document), port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + turns = [] + for _ in range(200): + turns = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns")[1]["data"] + if turns: + break + await asyncio.sleep(0.02) + assert turns and turns[0]["status"] == "in_progress" + + def receive() -> list[bytes]: + with socket.create_connection( + ("127.0.0.1", server.server_port), timeout=3 + ) as connection: + connection.sendall( + ( + f"GET /v1/agents/sessions/{sid}/events?after=0 HTTP/1.1\r\n" + f"Host: localhost\r\nAuthorization: Bearer {token}\r\n\r\n" + ).encode() + ) + with connection.makefile("rb") as stream: + assert stream.readline().startswith(b"HTTP/1.1 200 ") + headers = {} + while (line := stream.readline()) != b"\r\n": + name, value = line.decode().strip().split(":", 1) + headers[name.lower()] = value.strip() + assert headers["transfer-encoding"] == "chunked" + assert "content-length" not in headers + frames = [] + for _ in range(100): + size = int(stream.readline().strip(), 16) + payload = stream.read(size) + assert len(payload) == size and stream.read(2) == b"\r\n" + if not size: + assert any(frame.startswith(b"id:") for frame in frames) + assert any("你好".encode() in frame for frame in frames) + assert b": heartbeat\n\n" in frames + return frames + frames.append(payload) + if payload == b": heartbeat\n\n": + document["tokens"] = [] + raise AssertionError("bounded chunk reader did not receive terminator") + + frames = await asyncio.to_thread(receive) + assert frames + assert ( + service.handle("owner", "GET", f"/v1/agents/sessions/{sid}/turns/{turns[0]['id']}")[1][ + "status" + ] + == "in_progress" + ) + finally: + running.cancel() + await asyncio.gather(running, return_exceptions=True) + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +def test_sse_failure_after_headers_does_not_write_another_http_response( + components: Any, monkeypatch: Any +) -> None: + service = components[0] + sid = create(service)["id"] + original = service.events + fetches = 0 + + def events(*args: Any, **kwargs: Any) -> Any: + nonlocal fetches + fetches += 1 + if fetches > 1: + raise RuntimeError("example stream failure after headers") + return original(*args, **kwargs) + + monkeypatch.setattr(service, "events", events) + token = "test-failed-stream-token-" + "x" * 32 + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + server = make_server(service, auth, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with socket.create_connection(("127.0.0.1", server.server_port), timeout=3) as connection: + connection.sendall( + ( + f"GET /v1/agents/sessions/{sid}/events?after=0 HTTP/1.1\r\n" + f"Host: localhost\r\nAuthorization: Bearer {token}\r\n\r\n" + ).encode() + ) + with connection.makefile("rb") as stream: + assert stream.readline().startswith(b"HTTP/1.1 200 ") + while stream.readline() != b"\r\n": + pass + body = stream.read() + assert b"id:" in body and b"HTTP/1.1" not in body + assert not body.endswith(b"0\r\n\r\n") + with service.store.transaction() as tx: + assert tx.list("subscription", parent=sid) == [] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) diff --git a/client-sdk/tests/test_managed_entrypoint.py b/client-sdk/tests/test_managed_entrypoint.py index 6a5912fb..9003d2cd 100644 --- a/client-sdk/tests/test_managed_entrypoint.py +++ b/client-sdk/tests/test_managed_entrypoint.py @@ -135,3 +135,38 @@ def serve(args: Any, ca_file: str | None) -> None: with pytest.raises(RuntimeError, match="example startup failure"): run_main(monkeypatch, ["worker", "--database-ca-base64", encoded], serve) assert not paths[0].exists() and not paths[0].parent.exists() + + +@pytest.mark.parametrize("mode", ["gateway", "worker", "dispatcher"]) +def test_base64_ca_main_accepts_multiple_segments(monkeypatch: Any, ca_pem: str, mode: str) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + encoded = base64.b64encode(ca_pem.encode("ascii")).decode("ascii") + pieces = [encoded[index : index + 192] for index in range(0, len(encoded), 192)] + assert len(pieces) > 1 + + def serve(args: Any, ca_file: str | None) -> None: + assert args.mode == mode and ca_file is not None + assert Path(ca_file).read_text() == ca_pem + + run_main(monkeypatch, [mode, "--database-ca-base64", *pieces], serve) + + +@pytest.mark.parametrize("pieces", [["QQ==", "Qg=="], ["QUJD", "===="], ["", "QQ=="]]) +def test_base64_ca_main_rejects_invalid_joined_segments( + monkeypatch: Any, capsys: Any, pieces: list[str] +) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + with pytest.raises(SystemExit) as error: + run_main(monkeypatch, ["worker", "--database-ca-base64", *pieces]) + assert error.value.code == 2 + assert "canonical Base64" in capsys.readouterr().err + + +def test_base64_ca_main_bounds_the_combined_segments(monkeypatch: Any, capsys: Any) -> None: + monkeypatch.delenv("ABX_MANAGED_DATABASE_CA_PEM", raising=False) + encoded = base64.b64encode(b"x" * 65537).decode("ascii") + pieces = [encoded[index : index + 1920] for index in range(0, len(encoded), 1920)] + with pytest.raises(SystemExit) as error: + run_main(monkeypatch, ["gateway", "--database-ca-base64", *pieces]) + assert error.value.code == 2 + assert "64 KiB ASCII PEM" in capsys.readouterr().err diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index c7529337..aa7af587 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -74,6 +74,8 @@ Subscribe with `GET /v1/agents/sessions/{id}/events`. Supply `Last-Event-ID` or `after=0` replays from the beginning, while omission tails new events. Streams close periodically; reconnect using the last received event ID. Public events are durable during result retention. Deletion stops new frame writes across Gateway instances; a frame already in the network may finish arriving. Slow readers are disconnected, and oversized frames report a gap; use the durable items API for full content. A native event gap is reported explicitly. Raw reasoning and provider secrets are not exposed. +The Gateway sends HTTP/1.1 chunked event-stream responses. An FC deployment needs a Web Gateway +which supports streaming responses; events received only after completion do not establish live streaming. ## Close, cancel and delete @@ -149,5 +151,7 @@ argument and `ABX_MANAGED_DATABASE_CA_PEM` are mutually exclusive. Keep database model keys and internal tokens in their existing credential configuration. The same certificate validation and private temporary file apply. The runtime's 64 KiB decoded bound does not guarantee that a deployment platform accepts startup arguments of that size; platform limits still apply. +The argument accepts multiple Base64 segments, concatenated before validation. The FC deployer +splits the public certificate value into bounded arguments to satisfy its per-argument limit. Guest HTTPS exchanges have a total wall deadline of 30 seconds, clipped to the remaining execution budget during a turn. If that budget expires with an unknown result, the runtime attempts owned compute cleanup and retains an uncertain Submission; a public Turn reports the Gateway failure separately from the unconfirmed agent outcome. PostgreSQL connections use a 10-second connection/statement timeout and a 5-second lock timeout. URI `options` overrides are rejected. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index 5921fcaa..dc042694 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -66,6 +66,8 @@ Submission 对象。排队会创建下一轮,不会引导当前执行。 通过 `GET /v1/agents/sessions/{id}/events` 订阅事件,用 `Last-Event-ID` 或 `after` 恢复。 `after=0` 从头回放;省略时只接收新事件。流会定期结束,客户端用最后收到的事件 ID 重连。 公开事件在结果保留期内持久保存。删除会阻止各 Gateway 实例开始发送新帧,已经进入网络的帧仍可能到达;慢读连接会断开,过大帧明确报告缺口,完整内容通过持久 items 接口读取。原生事件缺口会明确报告,不公开原始推理和提供方秘密。 +Gateway 使用 HTTP/1.1 chunked 事件流响应;FC 部署需要支持流式响应的 Web Gateway。 +只在执行完成后收到历史事件,不能证明实时流式传输可用。 ## 取消、关闭与删除 @@ -131,5 +133,6 @@ FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、 公开 CA bundle 的标准 Base64 编码。它与 `ABX_MANAGED_DATABASE_CA_PEM` 互斥;数据库凭据、模型 Key 与内部令牌继续使用原有凭据配置。证书验证与私有临时文件流程相同。运行时解码后 64 KiB 的 上限不表示部署平台一定接受同等大小的启动参数,仍须满足平台自身限制。 +参数支持多个 Base64 段,先拼接再统一校验;FC 部署方会将公开证书拆成有界参数,以满足单参数限制。 沙箱 HTTPS 请求的总墙钟期限为 30 秒,执行期间还会收紧到本轮剩余预算。预算耗尽而结果未知时,运行时尝试清理自有计算资源,Submission 保持 uncertain;已创建的 Turn 会标记 Gateway 失败,并另行说明 Agent 执行结果尚未确认。PostgreSQL 连接与单语句超时为 10 秒,锁等待超时为 5 秒;连接地址中的 `options` 覆盖会被拒绝。 diff --git a/e2e/managed_sse_pg.py b/e2e/managed_sse_pg.py index 4cc12c66..d8cf5f37 100644 --- a/e2e/managed_sse_pg.py +++ b/e2e/managed_sse_pg.py @@ -97,7 +97,15 @@ def __getattr__(self, name): def write(self, data): nonlocal first_write - frame = data.startswith(b"id: ") + prefix, separator, body = data.partition(b"\r\n") + frame = bool( + separator + and prefix + and all(byte in b"0123456789abcdefABCDEF" for byte in prefix) + and body.endswith(b"\r\n") + and int(prefix, 16) == len(body) - 2 + and body.startswith(b"id: ") + ) if frame and barrier == "write" and first_write: first_write = False Path(doc["ready"]).touch() From 218ad48141ff44913cd4a4a5cffd59209a4f96a0 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:38:36 +0000 Subject: [PATCH 09/13] fix(agents): distinguish failed streams from normal endings --- ...6-managed-agents-streaming-and-recovery.md | 6 ++++ .../src/agent_sandbox/managed_agents/http.py | 34 +++++++++++-------- client-sdk/tests/test_managed_agents.py | 5 ++- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md b/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md index d6da07d8..208ead59 100644 --- a/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md +++ b/.agents/evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md @@ -60,6 +60,12 @@ single public CA argument exceeded the observed platform limit. Both narrow corr independently reviewed before implementation. The guest process observer correctly rejects unknown same-UID processes, which requires an appropriately isolated test topology. +Final source review also identified that the previous late-event exception handler classified +every `ManagedError` as a normal slice ending. Only authentication/resource removal now ends +normally; a service-unavailable error propagates and truncates the stream. Subscription cleanup +must succeed before the normal terminator is written. The raw-socket failure regression also +exercises an explicit 503 `ManagedError`, without treating it as a successful end. + ## Not verified Local PostgreSQL connection refusal is not a cloud RDS outage or an in-flight transaction failure. diff --git a/client-sdk/src/agent_sandbox/managed_agents/http.py b/client-sdk/src/agent_sandbox/managed_agents/http.py index ae9f3648..727fcac6 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/http.py +++ b/client-sdk/src/agent_sandbox/managed_agents/http.py @@ -262,8 +262,10 @@ def admit(event_id: str) -> bool: current["_admitted_event"] = event_id tx.put(current) return True - except ManagedError: - return False + except ManagedError as error: + if error.status in {401, 403, 404, 410}: + return False + raise if not admit(cursor): raise ManagedError(404, "resource_not_found", "Session was removed") @@ -282,7 +284,9 @@ def admit(event_id: str) -> bool: while time.monotonic() < end: try: authorized = self._owner() == owner - except ManagedError: + except ManagedError as error: + if error.status not in {401, 403}: + raise authorized = False if not authorized: break @@ -309,22 +313,22 @@ def admit(event_id: str) -> bool: time.sleep(0.2) try: pending = service.events(owner, sid, cursor) - except ManagedError: - break + except ManagedError as error: + if error.status in {404, 410}: + break + raise except BaseException: stream_aborted = True raise finally: - try: - if stream_started and not stream_aborted: - self._chunk(b"") - finally: - if subscription_id: - with service.store.transaction() as tx: - tx.execute( - "DELETE FROM managed_objects WHERE application=? AND id=?", - (tx.application, subscription_id), - ) + if subscription_id: + with service.store.transaction() as tx: + tx.execute( + "DELETE FROM managed_objects WHERE application=? AND id=?", + (tx.application, subscription_id), + ) + if stream_started and not stream_aborted: + self._chunk(b"") def _handle(self, method: str) -> None: self.close_connection = True diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index 618052c8..9a0869d2 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -1489,8 +1489,9 @@ def receive() -> list[bytes]: thread.join(timeout=3) +@pytest.mark.parametrize("failure", ["runtime", "managed"]) def test_sse_failure_after_headers_does_not_write_another_http_response( - components: Any, monkeypatch: Any + components: Any, monkeypatch: Any, failure: str ) -> None: service = components[0] sid = create(service)["id"] @@ -1501,6 +1502,8 @@ def events(*args: Any, **kwargs: Any) -> Any: nonlocal fetches fetches += 1 if fetches > 1: + if failure == "managed": + raise ManagedError(503, "service_unavailable", "Example service unavailable") raise RuntimeError("example stream failure after headers") return original(*args, **kwargs) From d74a26d945924ff3afdba4af55a5d065f6f57b16 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:01:16 +0000 Subject: [PATCH 10/13] docs(agents): explain deployment recreation state requirements --- .../proposed/architecture/2026-11-05-managed-agents-api.md | 5 +++++ docs/en/guide/managed-agents-api.md | 6 ++++++ docs/zh/guide/managed-agents-api.md | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 9254af00..0fdf6fec 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -141,3 +141,8 @@ stream failure closes the connection rather than appending a second HTTP respons PostgreSQL subscription admission and frame/deletion gates remain unchanged. Real PG, HTTP and process-kill recovery evidence is recorded in [the streaming/recovery continuation](../../../evidence/verify/2026-11-06-managed-agents-streaming-and-recovery.md). + +Complete destruction leaves its application admission fence in retained external state. A new +application identifier or empty database establishes a new deployment; ordinary upgrades retain +the same ledger via the preceding receipt. No automatic reactivation or deletion of the old +control record is introduced. Both operator guides document this lifecycle boundary. diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index aa7af587..57d46c42 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -139,6 +139,12 @@ role invocations and must remain stable while retained revisions drain. Credentials are passed only to roles which need them, and never put in images or public resources. File-backed business authentication is reread for revocation; optional token expiry is enforced. +Normal revision updates reuse the existing application and database through the previous +deployment receipt. Destroying the complete deployment retains externally managed PostgreSQL +data and leaves that application identifier closed. To create another deployment afterward, +use a new template/application name or a new empty PostgreSQL database. Retain the original +receipt for cleanup and reconciliation. + TLS certificate verification remains enabled. The private bridge HTTPS client uses the system trust store without inheriting HTTP proxy settings. To trust a private CA, set `ABX_MANAGED_TLS_CA_FILE` to a readable PEM CA bundle. Invalid CA configuration fails; it does not disable verification. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index dc042694..bdcc379d 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -124,6 +124,10 @@ FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、 `modelEnvAllowlist` 必须明确允许 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`。 凭据仅交给需要它的角色,不写入镜像或公开资源。文件来源的业务认证每次重读以支持撤权,并执行可选到期限制。 +正常版本更新通过上一份部署收据复用现有应用与数据库。完整销毁部署后,外部管理的 PostgreSQL +数据仍会保留,该应用标识保持关闭。之后重新创建部署时,应使用新的模板/应用名称,或新的空 +PostgreSQL 数据库;原收据继续用于清理与核对资源。 + 沙箱私有桥接 HTTPS 客户端始终校验证书,默认采用系统信任库且不继承 HTTP 代理设置。 私有 CA 使用 `ABX_MANAGED_TLS_CA_FILE` 指向可读 PEM CA bundle;CA 配置错误会失败,不会关闭证书校验。 From cd43e9e40ef5c90212b8ea6a1cc293c10f71ad2e Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:31:37 +0000 Subject: [PATCH 11/13] fix(agents): accept signed FC invocation media --- ...11-06-managed-agents-private-invocation.md | 54 ++++++++ .../2026-11-05-managed-agents-api.md | 9 ++ CHANGELOG.md | 2 + .../src/agent_sandbox/managed_agents/http.py | 11 +- client-sdk/tests/test_managed_agents.py | 115 ++++++++++++++++++ docs/en/guide/managed-agents-api.md | 2 + docs/zh/guide/managed-agents-api.md | 2 + 7 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-private-invocation.md diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-private-invocation.md b/.agents/evidence/verify/2026-11-06-managed-agents-private-invocation.md new file mode 100644 index 00000000..aa0de16f --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-private-invocation.md @@ -0,0 +1,54 @@ +# Verification: FC private invocation media conversion + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156, continuation of `d74a26d945924ff3afdba4af55a5d065f6f57b16`. +- Result: PARTIAL — the new real HTTP regressions pass; complete new CI, final package identity and cloud acceptance remain separate gates. +- Package: matching client/server 0.4.0a7 candidate; no publication by this verifier. + +## Evidence and reviewed scope + +After a separately reviewed CLI configuration repair, one exact owned Dispatcher native STATUS +call returned transport status 200 containing `unsupported_media_type`, `invalid_request_error` +and the static message `Use application/json`. The private tail has no traceback. This establishes +that the HTTP handler was reached, not that the database or downstream execution succeeded. +The original private response and prior configuration failure remain preserved by the controller; +no credential, function identity or raw cloud log is copied here. + +The official [FC Web-function conversion reference](https://help.aliyun.com/en/functioncompute/web-functions) +specifies `POST /invoke` with `Content-Type: application/octet-stream` for InvokeFunction; +the underlying HTTP status and headers are not preserved. The locked Node and Python generated +OpenAPI SDKs also set that type for streamed request bodies. Changing the CLI request header +would not establish compatibility with this conversion or the Python Worker sender. + +Two independent reviewers accepted a default-false media option on the existing JSON body +parser. Only the existing non-Gateway exact POST `/invoke` branch enables it. JSON object and +finite-value validation, byte budget, complete length, rejection of chunked request bodies, +canonical role-scoped HMAC and application identity checks are unchanged. Existing query routing +is unchanged. Public resource requests still require JSON, and other private routes remain 404. + +## Commands and actual results + +- New focused command: `python -m pytest tests/test_managed_agents.py -k 'private_fc_octet or gateway_still_requires' -q`. +- PASS: 5 cases, zero skipped, 6.12 seconds. Dispatcher and Worker are each exercised over actual + localhost HTTP against both SQLite and real PostgreSQL 16.2; public Gateway media rejection is + exercised separately. A blank owned logical Session avoids any model or Provider operation. +- Accepted media includes existing JSON, octet-stream, and mixed-case MIME with a charset. + Wrong role and signature return 401; malformed, non-object and non-finite JSON return 400; + unrelated route/method return 404; unrelated media returns 415; declared oversize returns 413 + before reading the absent payload. Public octet-stream resource input remains 415. +- The first harness run had 3 passing and 2 failing cases because its Dispatcher STATUS omitted + the required application identity; the existing 403 guard worked correctly. The fixture was + corrected, not the guard. That result is not presented as the product's pre-fix reproduction. +- Ruff on the changed source and tests passes. Complete local and remote gates run on the new + revision; their results will be recorded separately from the preceding frozen wheel set. + +## Cleanup and limits + +Every local HTTP server is shut down and its thread joined. Each real PostgreSQL fixture owns +its exact temporary data directory and deletes it. No Sandbox, cloud function, management Key, +real model task or external state is created by these regression cases. The original cloud +deployment remains under its controller's receipt and normal drain/cleanup process. + +This change does not turn transport 200 into business success. Callers must still reject an +error envelope. It does not prove final FC end-to-end execution, live cloud SSE or public package +installation; the older CI wheels remain historical and do not contain this correction. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 0fdf6fec..436ae93e 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -146,3 +146,12 @@ Complete destruction leaves its application admission fence in retained external application identifier or empty database establishes a new deployment; ordinary upgrades retain the same ledger via the preceding receipt. No automatic reactivation or deletion of the old control record is introduced. Both operator guides document this lifecycle boundary. + +An actual native FC invocation reached the private HTTP handler but returned the bounded +`unsupported_media_type` error inside a transport-level 200 response. FC's documented +InvokeFunction conversion uses `POST /invoke` with `application/octet-stream`, even when the +payload contains JSON. Only this existing non-Gateway role branch now accepts that media type; +bounded finite JSON-object parsing, role-scoped HMAC and application identity checks are retained. +The public Gateway still requires JSON for resource requests. The separate +[private invocation evidence](../../../evidence/verify/2026-11-06-managed-agents-private-invocation.md) +preserves the cloud failure and new component checks without treating a native 200 as success. diff --git a/CHANGELOG.md b/CHANGELOG.md index 248b7ab8..55e9a478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Release candidate: matching client/server **0.4.0a7**. / 候选版本:客户 ### Fixed +- Accept FC's binary media type only for signed private managed invocations, preserving JSON validation and the public Gateway's media requirements. / 托管私有签名调用兼容 FC 的二进制媒体类型,保留 JSON 校验与公开 Gateway 的媒体类型要求。 + - Accept bounded public database CA startup arguments in one or more segments while preserving certificate validation and the existing environment source. / 托管角色启动支持分段传递有界的公开数据库 CA,保留证书校验与原有环境变量来源。 - Frame managed SSE responses with HTTP/1.1 chunks and close failed streams without appending another HTTP response. / 托管 SSE 响应采用 HTTP/1.1 分块编码,流中途失败时关闭连接,不追加第二个 HTTP 响应。 diff --git a/client-sdk/src/agent_sandbox/managed_agents/http.py b/client-sdk/src/agent_sandbox/managed_agents/http.py index 727fcac6..fa7b93cc 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/http.py +++ b/client-sdk/src/agent_sandbox/managed_agents/http.py @@ -168,11 +168,14 @@ def _raw(self) -> bytes: raise ManagedError(400, "incomplete_body", "Incomplete request body") return raw - def _body(self) -> dict[str, Any]: + def _body(self, *, allow_octet_stream: bool = False) -> dict[str, Any]: raw = self._raw() if not raw: return {} - if self.headers.get_content_type() != "application/json": + content_type = self.headers.get_content_type() + if content_type != "application/json" and not ( + allow_octet_stream and content_type == "application/octet-stream" + ): raise ManagedError(415, "unsupported_media_type", "Use application/json") try: value = json.loads(raw) @@ -353,7 +356,9 @@ def _handle(self, method: str) -> None: raise ManagedError( 404, "endpoint_not_available", "Only private invocation is available" ) - payload = verify_invocation(self._body(), internal_token, mode) + payload = verify_invocation( + self._body(allow_octet_stream=True), internal_token, mode + ) if mode == "worker": if set(payload) - {"sessionId", "submissionId"} or not isinstance( payload.get("sessionId"), str diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index 9a0869d2..e624f309 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -399,6 +399,121 @@ def test_real_http_multipart_and_safe_errors(components: Any) -> None: thread.join(timeout=3) +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +@pytest.mark.parametrize("mode", ["dispatcher", "worker"]) +def test_private_fc_octet_stream_keeps_json_and_role_boundaries(components: Any, mode: str) -> None: + service, backend, _, dispatcher = components + sid = create(service)["id"] + token = "test-internal-token-" * 3 + payload = ( + {"operation": "status", "applicationId": service.config.application_id} + if mode == "dispatcher" + else {"sessionId": sid} + ) + envelope = sign_invocation(payload, token, mode) + server = make_server( + service, None, mode=mode, dispatcher=dispatcher, internal_token=token, port=0 + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", trust_env=False + ) as client: + for media_type in ( + "application/json", + "application/octet-stream", + "Application/Octet-Stream; charset=utf-8", + ): + response = client.post( + "/invoke", content=json.dumps(envelope), headers={"Content-Type": media_type} + ) + assert response.status_code == 200 + if mode == "dispatcher": + assert response.json()["activeSessions"] == 1 + else: + assert response.json() == {"claimed": False} + octet = {"Content-Type": "application/octet-stream"} + invalid = {**envelope, "signature": "0" * 64} + assert ( + client.post("/invoke", content=json.dumps(invalid), headers=octet).status_code + == 401 + ) + other_role = "worker" if mode == "dispatcher" else "dispatcher" + wrong_role = sign_invocation(payload, token, other_role) + assert ( + client.post("/invoke", content=json.dumps(wrong_role), headers=octet).status_code + == 401 + ) + for raw in (b"{", b"[]", b'{"value":NaN}'): + response = client.post("/invoke", content=raw, headers=octet) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "invalid_json" + assert client.post("/invoke/other", content=b"{}", headers=octet).status_code == 404 + assert client.get("/invoke", headers=octet).status_code == 404 + assert ( + client.post( + "/invoke", content=json.dumps(envelope), headers={"Content-Type": "text/plain"} + ).status_code + == 415 + ) + with socket.create_connection(("127.0.0.1", server.server_port), timeout=3) as connection: + size = service.config.storage["maxFileBytes"] + 16385 + connection.sendall( + ( + "POST /invoke HTTP/1.1\r\nHost: localhost\r\n" + "Content-Type: application/octet-stream\r\n" + f"Content-Length: {size}\r\nConnection: close\r\n\r\n" + ).encode() + ) + assert connection.makefile("rb").readline().startswith(b"HTTP/1.1 413 ") + assert backend.created == 0 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +def test_gateway_still_requires_json_for_octet_stream_envelopes(components: Any) -> None: + service = components[0] + token = "test-business-token-" * 3 + auth = TokenAuthenticator( + { + "tokens": [ + { + "sha256": hashlib.sha256(token.encode()).hexdigest(), + "owner": "owner", + "scopes": ["agents:read", "agents:write"], + } + ] + } + ) + server = make_server(service, auth, port=0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", + headers={"Authorization": "Bearer " + token}, + trust_env=False, + ) as client: + envelope = sign_invocation( + {"operation": "status"}, "test-private-token-" * 3, "dispatcher" + ) + response = client.post( + "/v1/agents/sessions", + content=json.dumps(envelope), + headers={"Content-Type": "application/octet-stream"}, + ) + assert response.status_code == 415 + assert response.json()["error"]["code"] == "unsupported_media_type" + assert client.post("/invoke", json=envelope).status_code == 404 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + @pytest.mark.asyncio async def test_active_cancel_exact_target_replays_without_cancelling_next_turn( components: Any, diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index 57d46c42..6f61facf 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -61,6 +61,8 @@ with a Submission object. Queuing schedules another turn; it does not steer the ## Files and events +Resource POST requests require `Content-Type: application/json`. File uploads use multipart form data. + Upload files with multipart `POST /v1/files`, fields `purpose=user_data` and `file`. Attach an uploaded file to input using `x_agent_sandbox.input_files: [{"file_id":"file_...", "path":"/workspace/input/data.csv"}]`. Paths must be normalized beneath the deployment's diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index bdcc379d..eb89b97b 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -55,6 +55,8 @@ Submission 对象。排队会创建下一轮,不会引导当前执行。 ## 文件与事件 +资源 POST 请求必须使用 `Content-Type: application/json`;文件上传使用 multipart 表单。 + 使用 multipart `POST /v1/files` 上传文件,字段为 `purpose=user_data` 与 `file`。 提交输入时附带 `x_agent_sandbox.input_files: [{"file_id":"file_...", "path":"/workspace/input/data.csv"}]`。路径必须规范化且处于部署工作区的 `input` 目录内, From 8df1ed77c1262719d2c0a12cc846a855fde638e8 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:37:06 +0000 Subject: [PATCH 12/13] fix(agents): reject empty private control revisions --- ...6-managed-agents-empty-private-revision.md | 58 ++++++++++++++ .../2026-11-05-managed-agents-api.md | 9 +++ CHANGELOG.md | 1 + .../agent_sandbox/managed_agents/dispatch.py | 2 +- client-sdk/tests/test_managed_agents.py | 79 +++++++++++++++++++ docs/en/guide/managed-agents-api.md | 2 + docs/zh/guide/managed-agents-api.md | 1 + 7 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md b/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md new file mode 100644 index 00000000..b3a6f3df --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md @@ -0,0 +1,58 @@ +# Verification: reject empty private control revision + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156; https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/155. +- Result: PARTIAL — new real HTTP/SQLite/PostgreSQL regressions pass; full current-head CI and final public-package acceptance remain in progress. +- Environment: isolated CPython 3.12.13 environment, Linux PID namespace, SQLite and real local PostgreSQL 16.2 (`pgserver` 0.1.4). No cloud resource or management Key is created by these regressions. + +## Problem and review + +The private Dispatcher accepted `revision: ""`. Its drain branch treated that value as a global +admission fence, while closing and status filtered it as a specific revision. A caller could +therefore receive zero scoped counts while existing logical Sessions remained open. + +Independent runtime and CLI review established that normal deployment controllers omit the field +for global control and use frozen nonempty revisions for retirement. The currently running cloud +source candidate `cd43e9e40ef5c90212b8ea6a1cc293c10f71ad2e` does not exercise this invalid input. +The defect does not reclassify that candidate's valid-input results. It is corrected before the +first public 0.4.0a7 package so trusted custom controllers cannot encounter this ambiguity. + +## Change and commands + +Only the existing Dispatcher input-bounds predicate changes: an explicitly empty string receives +HTTP 400 `invalid_invocation` before any status, transaction, admission fence, close or dispatch +branch. Omitted/None and all previously accepted nonempty strings retain their behavior; there +is no new revision normalization, character policy or control mode. + +```bash +python -m pytest client-sdk/tests/test_managed_agents.py -k empty_private_revision -q --tb=short +python -m pytest client-sdk/tests/test_managed_agents.py -k 'empty_private_revision or private_fc_octet_stream or drain_counts' -q --tb=short +make ci +``` + +The controller executes these commands with its dedicated development environment and PID +namespace. Exact workstation paths and raw private inputs are not repository documentation. + +| Check | Actual result | Meaning | +| --- | --- | --- | +| New regressions before the code correction | FAIL: 4 failed, 48 deselected, 11.07 s | Signed real HTTP empty-revision status/drain each return 200 under SQLite and PostgreSQL, rather than the required 400. The original failure log is retained. | +| New cases plus existing media/global-drain controls after correction | PASS: 9 passed, 43 deselected, 10.34 s | Both databases reject the empty revision. Full control/object/event/idempotency/meta snapshots remain equal, active count remains one and global draining remains false. A nonempty scoped status and omitted-revision global drain still work. | +| Scoped Ruff | PASS | The runtime and regression file pass the existing rule set. | +| Complete local `make ci` | RUNNING | Both packages, types, lint, format, distribution builds and install checks are executing. | +| Latest-head remote CI/distribution | NOT YET VERIFIED | The final independent commit and immutable artifact identity will be recorded after they exist. | + +The tests use a blank logical Session. They intentionally prove validation and global-drain +semantics without creating a provider Sandbox or making a model request. They do not claim a new +active-cancellation or complete cloud/model/business result. + +## Cleanup and delivery boundary + +Every test HTTP server closes its socket and joins its thread in `finally`. The component fixture +removes its owned temporary PostgreSQL server/data directory; the local SQLite directory belongs +to the test's temporary directory. The fixture's provider create counter stays zero. None of the +controller's live cloud applications, databases, receipts, images or original CI wheels is changed. + +The previous runtime and CI distribution remain historical source evidence. This continuation +requires a newly frozen CI wheel, normal publication/readback and final public-recipe acceptance; +the old source-only cloud run cannot be relabeled as having used the new package. The ADR remains +proposed pending the complete delivery criteria. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index 436ae93e..b74c5093 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -155,3 +155,12 @@ bounded finite JSON-object parsing, role-scoped HMAC and application identity ch The public Gateway still requires JSON for resource requests. The separate [private invocation evidence](../../../evidence/verify/2026-11-06-managed-agents-private-invocation.md) preserves the cloud failure and new component checks without treating a native 200 as success. + +The final input-boundary continuation rejects an explicitly empty private `revision` before +Dispatcher state access. Omitting the field still addresses the complete application; every +previously accepted nonempty revision keeps its behavior. This avoids treating the empty value +as global for admission fencing while filtering it as a specific revision during close/counts. +Real HTTP regressions against SQLite and PostgreSQL preserve complete control/resource/event +snapshots after rejection and retain the normal global-drain control. The original frozen cloud +candidate remains a separate source identity; its valid-input results are not relabeled as tests +of this new package. See [the input-boundary evidence](../../../evidence/verify/2026-11-06-managed-agents-empty-private-revision.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e9a478..5cbf8083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Release candidate: matching client/server **0.4.0a7**. / 候选版本:客户 ### Fixed +- Reject an empty private control revision before state access, preserving omitted-revision global drain and nonempty revision behavior. / 私有控制调用在访问状态前拒绝空 revision,保留省略 revision 时的全局排空及非空 revision 的既有行为。 - Accept FC's binary media type only for signed private managed invocations, preserving JSON validation and the public Gateway's media requirements. / 托管私有签名调用兼容 FC 的二进制媒体类型,保留 JSON 校验与公开 Gateway 的媒体类型要求。 - Accept bounded public database CA startup arguments in one or more segments while preserving certificate validation and the existing environment source. / 托管角色启动支持分段传递有界的公开数据库 CA,保留证书校验与原有环境变量来源。 diff --git a/client-sdk/src/agent_sandbox/managed_agents/dispatch.py b/client-sdk/src/agent_sandbox/managed_agents/dispatch.py index 69496d6d..2b124f39 100644 --- a/client-sdk/src/agent_sandbox/managed_agents/dispatch.py +++ b/client-sdk/src/agent_sandbox/managed_agents/dispatch.py @@ -162,7 +162,7 @@ async def invoke(self, payload: dict[str, Any]) -> dict[str, Any]: if ( (not isinstance(limit, int) or isinstance(limit, bool)) or not 1 <= limit <= 100 - or (revision is not None and not isinstance(revision, str)) + or (revision is not None and (not isinstance(revision, str) or not revision)) ): raise ManagedError(400, "invalid_invocation", "Invalid dispatch bounds") if operation not in {"dispatch", "status", "drain"}: diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index e624f309..74aa34eb 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -474,6 +474,85 @@ def test_private_fc_octet_stream_keeps_json_and_role_boundaries(components: Any, thread.join(timeout=3) +@pytest.mark.parametrize("components", ["sqlite", "postgres"], indirect=True) +@pytest.mark.parametrize("operation", ["status", "drain"]) +def test_empty_private_revision_rejects_without_state_changes( + components: Any, operation: str +) -> None: + service, backend, _, dispatcher = components + sid = create(service)["id"] + token = "test-private-control-token-" * 3 + + def snapshot() -> dict[str, Any]: + queries = { + "control": "SELECT * FROM managed_control WHERE application=? ORDER BY 1,2", + "objects": "SELECT * FROM managed_objects WHERE application=? ORDER BY 1,2", + "events": "SELECT * FROM managed_events WHERE application=? ORDER BY 1,2,3", + "idempotency": "SELECT * FROM managed_idempotency WHERE application=? ORDER BY 1,2,3", + "meta": "SELECT * FROM managed_meta WHERE application=? ORDER BY 1,2", + } + with service.store.transaction() as tx: + return { + label: [dict(row) for row in tx.execute(query, (tx.application,)).fetchall()] + for label, query in queries.items() + } + + before = snapshot() + assert dispatcher.status()["activeSessions"] == 1 + server = make_server( + service, None, mode="dispatcher", dispatcher=dispatcher, internal_token=token, port=0 + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with httpx.Client( + base_url=f"http://127.0.0.1:{server.server_port}", trust_env=False + ) as client: + payload = { + "operation": operation, + "applicationId": service.config.application_id, + "revision": "", + } + invalid = client.post( + "/invoke", + content=json.dumps(sign_invocation(payload, token, "dispatcher")), + headers={"Content-Type": "application/octet-stream"}, + ) + assert invalid.status_code == 400 + assert invalid.json()["error"]["code"] == "invalid_invocation" + assert snapshot() == before + assert dispatcher.status()["activeSessions"] == 1 + assert dispatcher.status()["draining"] is False + assert backend.created == 0 + scoped = client.post( + "/invoke", + json=sign_invocation( + {**payload, "operation": "status", "revision": service.config.revision}, + token, + "dispatcher", + ), + ) + assert scoped.status_code == 200 and scoped.json()["activeSessions"] == 1 + drained = client.post( + "/invoke", + json=sign_invocation( + {"operation": "drain", "applicationId": service.config.application_id}, + token, + "dispatcher", + ), + ) + assert drained.status_code == 200 + assert drained.json()["draining"] is True + for field in ("activeSessions", "pendingCleanup", "uncertainResources"): + assert drained.json()[field] == 0 + session = service.handle("owner", "GET", f"/v1/agents/sessions/{sid}")[1] + assert session["x_agent_sandbox"]["lifecycle"] == "closed" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + def test_gateway_still_requires_json_for_octet_stream_envelopes(components: Any) -> None: service = components[0] token = "test-business-token-" * 3 diff --git a/docs/en/guide/managed-agents-api.md b/docs/en/guide/managed-agents-api.md index 6f61facf..bce0f89a 100644 --- a/docs/en/guide/managed-agents-api.md +++ b/docs/en/guide/managed-agents-api.md @@ -137,6 +137,8 @@ Each configuration document accepts exactly one `_FILE` or `_JSON` source: `ABX_MANAGED_DATABASE_URL` identifies PostgreSQL. The deployment pins its persistent state identity; replacing the database with empty state is rejected, including by warm instances. Credential and CA rotation on the same state remains supported. `ABX_MANAGED_INTERNAL_TOKEN` authenticates private role invocations and must remain stable while retained revisions drain. `ABX_MANAGED_FC_ENDPOINT` selects the explicit FC service host for dispatch. +Private control requests may omit `revision` to address the whole application or supply a nonempty +revision. The private HTTP listener rejects an empty string with HTTP 400 before accessing or changing application state. `modelEnvAllowlist` must explicitly allow `OPENAI_API_KEY`, `OPENAI_BASE_URL` and `OPENAI_MODEL`. Credentials are passed only to roles which need them, and never put in images or public resources. File-backed business authentication is reread for revocation; optional token expiry is enforced. diff --git a/docs/zh/guide/managed-agents-api.md b/docs/zh/guide/managed-agents-api.md index eb89b97b..b9cf8945 100644 --- a/docs/zh/guide/managed-agents-api.md +++ b/docs/zh/guide/managed-agents-api.md @@ -123,6 +123,7 @@ FC 部署要求 PostgreSQL;SQLite 仅用于明确的本地模式。存储、 `ABX_MANAGED_DATABASE_URL` 指向 PostgreSQL。部署会固定持久状态身份,替换为空数据库会被拒绝,已有暖实例也会校验;同一状态库的凭据与 CA 轮换仍可使用。`ABX_MANAGED_INTERNAL_TOKEN` 认证私有角色调用, 保留旧 revision 排空期间应保持稳定。`ABX_MANAGED_FC_ENDPOINT` 指定派发所用 FC 服务主机。 +私有控制请求可省略 `revision` 来操作整个应用,或提供非空 revision。私有 HTTP 监听端会在访问或修改应用状态前拒绝空字符串,并返回 HTTP 400。 `modelEnvAllowlist` 必须明确允许 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`。 凭据仅交给需要它的角色,不写入镜像或公开资源。文件来源的业务认证每次重读以支持撤权,并执行可选到期限制。 From 906f317cd3743340f4dca5a1fb7d6b3a84301d07 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:05:42 +0000 Subject: [PATCH 13/13] test(agents): bound preparation heartbeat verification --- .agents/README.md | 1 + ...6-managed-agents-empty-private-revision.md | 14 ++- ...11-06-managed-agents-fc-business-source.md | 71 ++++++++++++ ...6-11-06-managed-agents-fc-private-drain.md | 103 ++++++++++++++++++ .../2026-11-05-managed-agents-api.md | 8 ++ client-sdk/tests/test_managed_agents.py | 20 ++-- 6 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-fc-business-source.md create mode 100644 .agents/evidence/verify/2026-11-06-managed-agents-fc-private-drain.md diff --git a/.agents/README.md b/.agents/README.md index 64f4f954..6655a1c4 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,6 +1,7 @@ # `.agents/` — engineering record of the python-sdk repository - [Managed Agents API](notes/proposed/architecture/2026-11-05-managed-agents-api.md) · [initial verification](evidence/verify/2026-11-05-managed-agents-api.md) · [release-candidate verification](evidence/verify/2026-11-06-managed-agents-release-candidate.md) · [CI artifacts](evidence/verify/2026-11-06-managed-agents-ci-artifacts.md). +- Managed Agents API cloud continuations: [private invocation/drain](evidence/verify/2026-11-06-managed-agents-fc-private-drain.md), [source business operations](evidence/verify/2026-11-06-managed-agents-fc-business-source.md), [private input boundary](evidence/verify/2026-11-06-managed-agents-empty-private-revision.md). - [Express SDK completion](notes/implemented/feature/2026-11-05-express-sdk-completion.md) · [verification](evidence/verify/2026-11-05-express-sdk-completion.md). - [Provider runtime setup](notes/proposed/process/2026-11-05-provider-runtime-setup.md) · [verification](evidence/verify/2026-11-05-provider-runtime-setup.md). diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md b/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md index b3a6f3df..6c5973a0 100644 --- a/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md +++ b/.agents/evidence/verify/2026-11-06-managed-agents-empty-private-revision.md @@ -38,13 +38,23 @@ namespace. Exact workstation paths and raw private inputs are not repository doc | New regressions before the code correction | FAIL: 4 failed, 48 deselected, 11.07 s | Signed real HTTP empty-revision status/drain each return 200 under SQLite and PostgreSQL, rather than the required 400. The original failure log is retained. | | New cases plus existing media/global-drain controls after correction | PASS: 9 passed, 43 deselected, 10.34 s | Both databases reject the empty revision. Full control/object/event/idempotency/meta snapshots remain equal, active count remains one and global draining remains false. A nonempty scoped status and omitted-revision global drain still work. | | Scoped Ruff | PASS | The runtime and regression file pass the existing rule set. | -| Complete local `make ci` | RUNNING | Both packages, types, lint, format, distribution builds and install checks are executing. | -| Latest-head remote CI/distribution | NOT YET VERIFIED | The final independent commit and immutable artifact identity will be recorded after they exist. | +| First complete local `make ci`, `8df1ed7` | FAIL | Server 875 passed/1 skipped; client 2460 passed, 1 failed, 27 skipped, 10 deselected. An existing 150-ms heartbeat fixture fails at its final completion assertion. New invalid-revision cases pass. | +| First remote CI, `8df1ed7` | FAIL in client Python 3.10 | [Run 42189130012](https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/42189130012), job 127796524669 preserves the original failure: the same existing heartbeat fixture sees a due item during its timing window. Python 3.11/3.12 client jobs pass. | +| Bounded heartbeat fixture continuation | PASS: 5 focused cases, 47 deselected, 9.11 s | A real two-second lease and an explicit preparation barrier preserve all previous assertions and keep the observation past the original lease. New invalid-revision regressions remain green. | +| Final complete local/remote CI and distribution | IN PROGRESS | A new independent test-only continuation requires the full latest-head gates and a separately frozen artifact. | The tests use a blank logical Session. They intentionally prove validation and global-drain semantics without creating a provider Sandbox or making a model request. They do not claim a new active-cancellation or complete cloud/model/business result. +The pre-existing heartbeat fixture combined a 150-ms lease, a fixed 500-ms preparation sleep +and a 300-ms observation sleep. The two observed failures do not prove a new product regression +or a duplicate Sandbox creation. Independent review accepted a test-only correction: use a real +two-second lease, wait 2.2 seconds, and keep preparation behind an Event until after the existing +second-worker refusal and empty-due assertions. Entry, barrier and task waits all have finite +deadlines; `finally` releases and waits for the original task. The completion/create-count/ready +assertions remain. No product heartbeat, lease rule, clock, queue or cleanup behavior changes. + ## Cleanup and delivery boundary Every test HTTP server closes its socket and joins its thread in `finally`. The component fixture diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-fc-business-source.md b/.agents/evidence/verify/2026-11-06-managed-agents-fc-business-source.md new file mode 100644 index 00000000..2f4c6ea0 --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-fc-business-source.md @@ -0,0 +1,71 @@ +# Verification: managed FC source Gateway and independent business artifacts + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156; source runtime `cd43e9e40ef5c90212b8ea6a1cc293c10f71ad2e`. +- Result: PARTIAL overall — normal source deployment, all 39 positive operations, all 38 protected-route authentication rejections, two-turn business checks and live idle-Session SSE pass. Active-cancellation, completed revision upgrade and public-package recipe remain separate gates. +- Environment: Alibaba Cloud FC Gateway/Worker/Dispatcher, private RDS PostgreSQL 17 with explicit CA and `verify-full`, real Express Sandboxes and Qwen Code/model tools. Existing credentials remain private. +- Packages: unpublished matching client/server 0.4.0a7 from exact CI run 42169013251; client wheel SHA-256 `1ac32d508980297ec80629b6c9e2fb5fd0b268db354d13d39120dfbaada8d4e0`, server wheel SHA-256 `210db5e796f4fc63e84156d19076c946d261b48c47ec6c8c1db82b8b60691f08`. + +## Executed behavior + +The normal deployment reaches ready in 351 seconds. Unlike the earlier interrupted source +experiments, its normal Dispatcher timer is enabled by the deployment state machine; this +acceptance run does not use the temporary manual-dispatch controller or rewrite a receipt to ready. + +The independently frozen HTTP inventory observes 39 positive operations and HTTP 401 for all +38 protected operations. Health is intentionally public. The report's inventory/authentication, +complete HTTP trace, independent-source checks and public cleanup gates all pass. The full +business run takes 412 seconds. The business HTTP client uses the single Gateway endpoint; +engineering source inspection uses separate private provider credentials. + +The real model first fixes the seeded CSV aggregation program and generates its source, summary +CSV and report. The second turn uses the same Session/workspace to add share calculations. +The controller downloads the actual bytes before independent checking. A pinned helper runs only +inside the exactly matched owned Sandbox: original test bytes remain unchanged, original tests +pass, four first-turn and eight second-turn held-out cases pass, and the tested program hash equals +the downloaded source artifact. The helper's exit and process completion are confirmed before the +next turn. No generated program executes on the host; this is cooperative same-UID evidence, +not adversarial guest attestation or a direct PostgreSQL ownership query. + +The independent live SSE check creates an idle logical Session without starting a model turn. +Its first frame arrives in 0.169 seconds, normal EOF in 20.369 seconds, and a concurrent Session +read completes during the stream. This proves FC transport flush and concurrency for that finite +stream; it is not described as real-time model-token streaming. + +## Evidence chain and cleanup + +The controller retains exact private receipts, native identity proof, HTTP identity ledger, +artifact bytes/manifests, helper results and raw command captures in restricted local storage +with authenticated encrypted recovery. Repository evidence contains no credentials, endpoints, +cloud IDs or raw model responses. The safe capture/report labels are: + +| Label | Actual result | Scope | +| --- | --- | --- | +| `source-final-public-39` | PASS | 39 positive operations, 38 authentication failures, two real model/tool turns, both independent source checks and public resource cleanup. | +| `source-final-live-sse` | PASS | First frame/normal finite EOF/concurrent read and public cleanup, without a model turn. | +| `source-final-active-cancel` | FAIL | `FileOperationError` before busy/queue/cancel checks. Subsequent private diagnostics locate HTTP 500 while downloading `/proc/PID/stat` after stat succeeded; that observer failure is not treated as process absence or a cancellation result. Both public and direct native cleanup are confirmed; no active-stop or reuse pass is claimed. Original scripts/reports remain. | +| `source-final-upgrade-prepare` | PASS | An actual old Qwen Session and workspace marker are prepared and intentionally retained for the controlled update. This is not completed upgrade acceptance. | + +The public-39 and live-SSE reports explicitly leave independent provider-wide absence false. +That final check belongs to the owning controller after all ongoing upgrade work and application +destruction. An intentionally retained upgrade Session is not reported absent. The earlier +window's signed drain and exact cleanup are recorded separately in the private-invocation/drain +evidence; they do not certify cleanup of this live window. + +## Preserved failures and next candidate + +Earlier source runs stop at final production-credential verification, and their partial manual +business runs stop at template name/ID comparison. The new engineering proof resolves the native +display name only after same-account/team exhaustive uniqueness and repeated created-ID/image +readback; it does not relax metadata, Session, revision or native identity gates. Those old failures +remain historical evidence. + +The first attempted Qwen-to-Codex update subsequently fails its candidate-health planned-revision +gate in 37.566 seconds. Its cause remains under investigation; normal source Qwen ready/39 results +are retained, and no update replay or successful upgrade is inferred. + +The later runtime input-boundary commit `8df1ed77c1262719d2c0a12cc846a855fde638e8` is a different +candidate. Its SQLite/PostgreSQL empty-revision regressions and new CI artifacts are recorded +separately. This source run does not claim it used that new wheel. Public publication, public +installation/readback and the final recipe must execute before whole-template delivery is declared; +the architecture decision remains proposed. diff --git a/.agents/evidence/verify/2026-11-06-managed-agents-fc-private-drain.md b/.agents/evidence/verify/2026-11-06-managed-agents-fc-private-drain.md new file mode 100644 index 00000000..f9ea494b --- /dev/null +++ b/.agents/evidence/verify/2026-11-06-managed-agents-fc-private-drain.md @@ -0,0 +1,103 @@ +# Verification: managed FC private invocation and normal drain + +- Date: 2026-11-06 +- Change: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/156; runtime `cd43e9e40ef5c90212b8ea6a1cc293c10f71ad2e`. +- Result: PARTIAL — real FC private invocation, PostgreSQL drain and this failed deployment's cleanup pass. Complete business/model, upgrade and public-package acceptance remain separate. +- Environment: Alibaba Cloud FC custom containers, RDS PostgreSQL with an explicit public CA and `sslmode=verify-full`; existing authorized credentials, never copied into this record. +- Package under test: matching unpublished client/server 0.4.0a7, installed in the image from exact GitHub CI wheels. + +## What was verified + +A real InvokeFunction call to the existing Dispatcher first reached the private HTTP handler but +returned the bounded `unsupported_media_type` error. FC transported that response with native +status 200. The [private media continuation](2026-11-06-managed-agents-private-invocation.md) +records the official conversion contract, scoped correction and real local HTTP regressions. +The earlier build and private response are preserved separately; a native transport 200 was +never counted as application success. + +The controller then used the new exact CI package image to repair only this already-owned +Dispatcher for cleanup. It retained the original application ID, PostgreSQL state, complete +environment, role, VPC, startup arguments and native creation identity. It did not replay apply, +publish a control trigger, activate admission or replace the database. Normal CLI destruction +subsequently invoked the signed private Dispatcher drain and completed resource cleanup. + +## Exact package identity + +The previous frozen runtime `218ad48141ff44913cd4a4a5cffd59209a4f96a0` used client wheel SHA-256 +`e8759959597510803a372dcebf0b88a67a948f63d5d950a22891b7cfc074afe5` and does not contain the +private media correction. Its original failure and package files remain historical evidence. + +The replacement uses [CI run 42169013251](https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/42169013251) +and Docs run 42169013242, both successful, at runtime `cd43e9e40ef5c90212b8ea6a1cc293c10f71ad2e`. +Artifact 13480710650 has ZIP SHA-256 +`d32c203c8b63b8b4488e27ca5c3362388cd625e0f6a02adb68b729bb9bde52b1`. + +| Distribution | Bytes | SHA-256 | +| --- | ---: | --- | +| Client wheel | 692232 | `1ac32d508980297ec80629b6c9e2fb5fd0b268db354d13d39120dfbaada8d4e0` | +| Server wheel | 226017 | `210db5e796f4fc63e84156d19076c946d261b48c47ec6c8c1db82b8b60691f08` | +| Client sdist | 705564 | `289dc0e3c9f7648830556737cecd4095f152e77efaf40adba594b0ad0e5cdaec` | +| Server sdist | 272580 | `f2e1f4fb6e8e17d272ad35e52e02df26a640c7adcb6e909515b501de517ac063` | + +An independent local read of the owned build's actual source wheel files matches both wheel +hashes above. A fresh installation of these exact CI wheels passes dependency checks and actual +HTTP Dispatcher/Worker octet-stream and public Gateway JSON-boundary checks; its installed HTTP +module bytes match the tested source. Local shared-filesystem build archives are not substituted. + +## Commands and capture chain + +The controller's private recorder retains original stdout/stderr, exact receipts and full native +readbacks under encrypted recovery storage. The following labels identify that chain without +copying credentials, endpoints, cloud IDs or raw cloud logs into the repository. + +| Recorder label | Actual result | Meaning | +| --- | --- | --- | +| `third-cleanup-status-after-repair` | Native status 200; `unsupported_media_type`, `invalid_request_error` | Original media failure after the separately reviewed CLI storage-descriptor correction. No successful status/DB round trip is inferred from the error envelope. | +| `third-cleanup-dispatcher-image` and `third-cleanup-dispatcher-image-diagnostic` | Exit 1 | Early local recovery failures retained. No apply replay is performed. | +| `third-cleanup-dispatcher-image-after-dead-lock` | Exit 1, 37.796 s | Admission and protected build use were persisted, but the original complete three-line journal has no native-update intent. Its exact failure cause remains unproven. | +| `third-cleanup-stage-compare` | Exit 0, 5.397 s | One independent native read: original creation identity, all baseline fields, environment and container match; state Active and update Successful. No native mutation. | +| `third-cleanup-exited-proof` | Exit 0 | Recorder waited for the original mutator to exit; frozen source and complete journal establish that no UpdateFunction had been called. No PID was guessed. | +| `third-cleanup-complete-admission` | Exit 0, 60.305 s | A new fixed, one-shot completion admission precedes the sole image update; native identity/full environment and exact new image digest are confirmed by readback. | +| `third-cleanup-destroy-plan` | Exit 0 | Normal read-only destruction planning. | +| `third-cleanup-app-destroy` | Exit 0, 15.025 s | Normal `abx app destroy` completes with status/phase `destroyed` and `drainConfirmed: true`. | +| `third-cleanup-native-absence` | Exit 0, 1.217 s | Independent native reads report Worker, Dispatcher and Gateway all absent. | +| `third-cleanup-guest-images-apply` | Exit 0 | Three exact owned guest-image targets have independent absence; original records are unchanged. | + +The standard CLI command shapes are `abx app plan --destroy RECEIPT --out DESTROY_PLAN` followed by +`abx app destroy DESTROY_PLAN --bindings PRIVATE_BINDINGS --yes`. Actual private arguments remain +with their controller; no secret-bearing binding file is reproduced here. + +The recorded destruction receipt independently shows three confirmed function cleanup entries, +one confirmed prepared-template entry and one confirmed original Server-image entry. The CLI +sets `drainConfirmed` only after the native signed control result explicitly reports draining and +zero active Sessions, pending cleanup and uncertain resources. Thus this is a real PostgreSQL +control/drain round trip, not a transport-200 or local fake result. + +## Recovery and cleanup boundary + +All recovery changes passed independent review before execution. The new image is protected by +the normal build-use guard before its function update. Unknown results never authorize replay; +the narrowly reviewed completion uses a separate admission only after original process exit, +complete no-update-intent evidence and a fresh full native baseline. Any existing update intent +would permit observation only. The original plan, receipts and failed journals remain historical. + +Private-backup time bounds were increased while retaining locks, byte budgets, authenticated +encryption, fsync and atomic replacement. The original 37.796-second failure was not reclassified +as a proven backup timeout. Frozen dependency caches were not deleted to bypass identity checks. + +The failed deployment's three FC roles, prepared template and original Server image are confirmed +cleaned. At that receipt's completion, its externally managed database and drained application +ledger are retained, as designed. The replacement Server image is separately owned and protected +for the controller's next deployment; this receipt does not claim to delete that different resource. +The owning controller later destroys the remaining original-window deployments and independently +releases that test database before admitting a new test window. Those are later lifecycle results, +not an implication that application destruction deletes external PostgreSQL or all task resources. + +## Not verified by this scope + +This failed deployment had no published business Gateway or completed model workflow. Empty +drain does not prove cancellation of active remote work. This record does not certify the 39 +public operations on FC, real model tool execution, artifacts, multi-turn continuity, live cloud +SSE, revision updates, public PyPI installation or final cleanup of the ongoing deployment. +Those results must be recorded after they actually execute. No merge, release or whole-template +qualification is inferred; the architecture decision remains proposed pending its acceptance gates. diff --git a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md index b74c5093..19e42fdd 100644 --- a/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md +++ b/.agents/notes/proposed/architecture/2026-11-05-managed-agents-api.md @@ -164,3 +164,11 @@ Real HTTP regressions against SQLite and PostgreSQL preserve complete control/re snapshots after rejection and retain the normal global-drain control. The original frozen cloud candidate remains a separate source identity; its valid-input results are not relabeled as tests of this new package. See [the input-boundary evidence](../../../evidence/verify/2026-11-06-managed-agents-empty-private-revision.md). + +The source `cd43e9e` deployment subsequently reaches normal FC ready and passes the complete +39-operation/38-protected-route real HTTP inventory, both real model/tool turns, independent +original/held-out program checks and the finite live SSE flush test. Active cancellation and +revision update have separate failures under investigation, rather than being inferred from +that business pass. The runtime/package identities and boundaries are retained in +[the source business evidence](../../../evidence/verify/2026-11-06-managed-agents-fc-business-source.md) +and [the prior private drain evidence](../../../evidence/verify/2026-11-06-managed-agents-fc-private-drain.md). diff --git a/client-sdk/tests/test_managed_agents.py b/client-sdk/tests/test_managed_agents.py index 74aa34eb..99f56894 100644 --- a/client-sdk/tests/test_managed_agents.py +++ b/client-sdk/tests/test_managed_agents.py @@ -755,23 +755,29 @@ async def test_preparation_heartbeat_prevents_second_create_after_original_lease components: Any, ) -> None: service, backend, _, _ = components - worker = ManagedWorker(service, backend, poll_seconds=0.01, lease_seconds=0.15) + lease_seconds = 2 + worker = ManagedWorker(service, backend, poll_seconds=0.01, lease_seconds=lease_seconds) sid = create(service, "first")["id"] original_create = backend.create entered = asyncio.Event() + release = asyncio.Event() async def slow_create(session: dict[str, Any], token: str, created: Any) -> dict[str, Any]: entered.set() - await asyncio.sleep(0.5) + await asyncio.wait_for(release.wait(), timeout=15) return await original_create(session, token, created) backend.create = slow_create first = asyncio.create_task(worker.run(sid)) - await entered.wait() - await asyncio.sleep(0.3) - assert await worker.run(sid) is False - assert worker.due() == [] - assert await first + try: + await asyncio.wait_for(entered.wait(), timeout=5) + await asyncio.sleep(lease_seconds + 0.2) + assert await asyncio.wait_for(worker.run(sid), timeout=5) is False + assert worker.due() == [] + finally: + release.set() + completed = await asyncio.wait_for(first, timeout=15) + assert completed assert backend.created == 1 with service.store.transaction() as tx: assert tx.get(sid)["x_agent_sandbox"]["lifecycle"] == "ready"