diff --git a/.agents/README.md b/.agents/README.md index 03503527..85480878 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,5 +1,21 @@ # `.agents/` — engineering record of the python-sdk repository +## Skill storage/state acceptance — 2026-10-26 + +[Real storage/state evidence](evidence/verify/2026-10-26-skill-storage-state-acceptance.md) +records owned OSS RW/RO recreation, mounted MicroVM pause/resume, Snapshot isolation, +LangGraph conversation boundaries, repository/preview/third-party MCP execution and +retained initial failures. The [proposed acceptance note](notes/proposed/process/2026-10-26-skill-storage-state-acceptance.md) +defines the scoped proof. The [borrowed-connection ownership correction](notes/proposed/feature/2026-10-26-mcp-borrowed-sandbox-ownership.md) +separates manager-created instances from cached connections; it is newer than +public a6, whose historical receipts remain unchanged. Repository-wide server +check failures and delivery gates remain explicit. + +[Desktop handover/replay acceptance](evidence/verify/2026-10-26-desktop-handover.md) +and its [proposed note](notes/proposed/process/2026-10-26-desktop-handover-acceptance.md) +record the separate pinned desktop adapter, private ingress, real screenshot/input, +simulated operator handover and replay controls with owned cleanup. + ## Current public pair — 2026-10-25 Client/server **0.4.0a6** are public. [Public delivery verification](evidence/verify/2026-10-25-public-a6-delivery.md) diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover.md b/.agents/evidence/verify/2026-10-26-desktop-handover.md new file mode 100644 index 00000000..9769936f --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover.md @@ -0,0 +1,58 @@ +# Verification: real desktop, private stream and simulated operator handover + +Result: PASS for the recorded GUI, private ingress, password-authenticated VNC, simulated operator handover and screenshot replay. Real human login, hosted takeover service and full-motion video recording remain NOT_RUN. + +The optional fixture uses the official compatibility SDK `e2b-desktop==2.4.1` with `e2b==2.59.0`, Playwright 1.58.0, aiohttp 3.13.4 and the full exact dependency set in `e2e/desktop-requirements.txt`. It does not add desktop methods to the project client or MCP tool catalog. Python 3.11.15 and local Chromium 150.0.7871.114 were used. [Tested source hashes](2026-10-26-desktop-handover/tested-files.json) identify the exact permanent runner and fixtures. + +## Real preparation and resource ownership + +A unique test-owned generation-2 template was built through the actual official FC management SDK using the configured region's `fc-e2b-registry..cr.aliyuncs.com/runtime/desktop:v0.0.44`, CPU 4 and memory 8192 MB. No startup/readiness command was invented, no existing template was changed, and no local Docker daemon was needed for this image-registration path. Independent template metadata showed `Ready`, generation 2 and the requested resources. [Build observations](2026-10-26-desktop-handover/build-state.json) preserve the actual progress. + +The permanent source was then executed with a prepared template ID and a new output directory: + +```bash +python e2e/desktop_acceptance.py --template TEST_OWNED_TEMPLATE_ID --output-dir NEW_EVIDENCE_DIRECTORY +``` + +The script created its own 600-second sandbox with `secure=True` and `network={"allow_public_traffic": False}`. Independent sandbox metadata confirmed 4 CPU / 8192 MB. The caller-supplied template was left for its separate owner to delete. + +## Input, return and observed effect + +| Action | Actual return / independent observation | Result | +| --- | --- | --- | +| Open a synthetic local application in real desktop Chrome | Actual 1024×768 PNG; browser page and editable form visible | PASS | +| Agent mouse focus, keyboard input and commit | Downloaded application state recorded `agent-start` | PASS | +| Enable private public-facing ports and inspect traffic token availability | SDK issued a nonempty `traffic_access_token`; no token was logged | PASS | +| HTTP/WS ingress without traffic token | HTML HTTP 403 and WebSocket HTTP 403 | PASS | +| Inject the correct traffic token in the backend proxy | HTML HTTP 200, actual live WebSocket data and rendered desktop canvas | PASS | +| Use the wrong VNC password after reaching the VNC service | Explicit password-check failure and no connected client | PASS | +| Connect with the correct VNC password | Actual rendered frames; 93,910 transferred bytes across the two VNC client probes by stream shutdown | PASS | +| Transfer input to a separate noVNC controller | Actual keyboard/mouse actions changed the independently downloaded state to `operator-step` | PASS simulated operator | +| Return input to the original SDK agent on the same desktop | Independently downloaded exact sequence `agent-start → operator-step → agent-resumed` | PASS | +| Stop stream and delete the test-owned sandbox | Stream stopped, delete succeeded, separate typed not-found query | PASS | +| Reopen offline replay in a real local browser | Initial, last and previous controls selected the expected actual frame files | PASS | + +[Final machine-readable report](2026-10-26-desktop-handover/acceptance.json), [actual downloaded application state](2026-10-26-desktop-handover/gui-state.json), [timestamped screenshot hashes](2026-10-26-desktop-handover/timeline.json), [offline player](2026-10-26-desktop-handover/replay.html) and [portable replay archive](2026-10-26-desktop-handover/desktop-handover-replay.zip) preserve input-to-effect evidence. The [player check](2026-10-26-desktop-handover/replay-check.json) was a separate browser run. Six timeline entries include the diagnostic and final operator screenshot sharing the same content hash; this is not six independently different screens or a video recording. + +## Authentication findings and preserved failed attempts + +The successful private-ingress run kept three credentials separate: + +- SDK control operations use `envdAccessToken` in `X-Access-Token`. +- Private application-port traffic uses `Sandbox.traffic_access_token` in `e2b-traffic-access-token`. +- The VNC session separately checks the password created by `stream.start(require_auth=True)`. + +These are the [documented network layers](https://help.aliyun.com/en/agent-sandbox/network-access-control). The [official Desktop image guide](https://help.aliyun.com/zh/functioncompute/desktop-template-example) provides image and SDK preparation. The [Computer Use guide](https://help.aliyun.com/zh/functioncompute/use-the-computer-use-sandbox) describes GUI/stream actions; its abbreviated gateway paragraph must be read together with the actual network mode and ingress credential contract. + +[Initial attempt reports](2026-10-26-desktop-handover/initial-attempts/) retain failures rather than replacing them with passes: the Chrome testing banner moved the initial coordinates; the connection configuration's public header accessor had to be used correctly; public-default HTML and an RFB banner were insufficient authentication evidence; the FC gateway added `Content-Disposition: attachment`; the first connected event preceded actual framebuffer delivery; and fast VNC shortcut/text entry did not fully apply. The final fixture used inspected screenshot coordinates, an appropriate MIME-preserving controlled proxy, actual frame checks and paced physical key events before independent state verification. + +A separate [private run using the wrong token category](2026-10-26-desktop-handover/private-wrong-token-type.json) correctly returned 403 even with an envd token. The final run used the documented traffic token and returned 200. The earlier [public-default successful handover](2026-10-26-desktop-handover/public-default-handover.json) remains its own observation, not proof of private ingress enforcement. + +## Checks and cleanup + +- Scoped `ruff check e2e/desktop_acceptance.py` and `ruff format --check`: PASS. +- Permanent optional runner against the actual prepared cloud template: exit 0, all its assertions PASS. +- Real local-browser offline-player control check: PASS. +- Full SDK product checks are tracked by the coordinating acceptance task; no full-repository or remote CI success is claimed here. + +All 11 exact test-created desktop sandbox IDs across exploratory and final runs were independently queried and returned typed not found: [cleanup observations](2026-10-26-desktop-handover/sandbox-cleanup.json). The unique owned desktop template's name was checked before deletion; a separate management `GetTemplate` returned HTTP 404: [template cleanup](2026-10-26-desktop-handover/template-cleanup.json). No borrowed sandbox, existing template or user account state was changed. The screenshot workflow used synthetic content and a simulated second controller; no actual user login was attempted. diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/01-browser-ready.png b/.agents/evidence/verify/2026-10-26-desktop-handover/01-browser-ready.png new file mode 100644 index 00000000..6da08e00 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/01-browser-ready.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/02-agent-state.png b/.agents/evidence/verify/2026-10-26-desktop-handover/02-agent-state.png new file mode 100644 index 00000000..f95ba2b1 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/02-agent-state.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/03-live-stream-view.png b/.agents/evidence/verify/2026-10-26-desktop-handover/03-live-stream-view.png new file mode 100644 index 00000000..21fb3cd0 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/03-live-stream-view.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/04-operator-state.png b/.agents/evidence/verify/2026-10-26-desktop-handover/04-operator-state.png new file mode 100644 index 00000000..d458d336 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/04-operator-state.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/05-agent-resumed.png b/.agents/evidence/verify/2026-10-26-desktop-handover/05-agent-resumed.png new file mode 100644 index 00000000..64e638c5 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/05-agent-resumed.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/acceptance.json b/.agents/evidence/verify/2026-10-26-desktop-handover/acceptance.json new file mode 100644 index 00000000..bd6c4a6b --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/acceptance.json @@ -0,0 +1,178 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "traffic-token-issued", + "result": "PASS" + }, + { + "name": "stream-static-resource-access", + "result": "PASS", + "unauthenticated_static_status": 403, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": false, + "http_status": 403 + }, + { + "name": "gateway-token-enforcement", + "result": "PASS", + "connected_without_gateway_token": false, + "http_status": 403 + }, + { + "name": "vnc-password-rejected", + "result": "PASS", + "connected": false, + "status": "New connection has been rejected with reason: password check failed!" + }, + { + "name": "live-stream-renders", + "result": "PASS", + "websocket_sessions": 2, + "transported_bytes": 33380, + "canvas_width": 1100 + }, + { + "name": "operator-handover-effect", + "result": "PASS", + "actor": "simulated operator through independent VNC client", + "sequence": [ + "agent-start", + "operator-step" + ] + }, + { + "name": "agent-resume-same-state", + "result": "PASS", + "sequence": [ + "agent-start", + "operator-step", + "agent-resumed" + ] + }, + { + "name": "stream-stopped", + "result": "PASS", + "websocket_sessions": 2, + "transported_bytes": 93910 + }, + { + "name": "replay-artifacts", + "result": "PASS", + "screenshots": 6, + "format": "offline screenshot timeline with SHA-256; not full-motion video" + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.86, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 20.13, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 35.15, + "actor": "simulated-operator", + "action": "Observe authenticated live stream through controlled backend proxy", + "file": "03-live-stream-view.png", + "sha256": "3ab4de0a27d070385ef42cecf196668bff4963b7f3f8766507e14be0e9c8158e" + }, + { + "at_seconds": 42.91, + "actor": "simulated-operator", + "action": "Independent screenshot after VNC input before checking state", + "file": "operator-diagnostic.png", + "sha256": "c68c23a466c467052d70720d6c5da2aa916af4cf68ba11bb2c6b33f94547787d", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 43.48, + "actor": "simulated-operator", + "action": "Type and commit through the second controller", + "file": "04-operator-state.png", + "sha256": "c68c23a466c467052d70720d6c5da2aa916af4cf68ba11bb2c6b33f94547787d", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 45.94, + "actor": "agent", + "action": "Resume on the same desktop and preserve prior operator state", + "file": "05-agent-resumed.png", + "sha256": "c9ef3fc538e3576c83f0dbebf8b80d4c9820654e0e752c952bb3f44b1e608409", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "template_id": "iallftl5zi2vdi0u0ht0", + "creation_controls": { + "secure": true, + "network": { + "allow_public_traffic": false + } + }, + "sandbox_id": "sbx-ff3cae53-31f3-4e13-bdc2-cdd3a48ac298", + "gateway_observation": { + "unauthenticated_static_status": 403, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": false, + "http_status": 403 + }, + "proxy": { + "bind": "127.0.0.1", + "gateway_token_stays_backend": true + }, + "canvas_observation": { + "bounding_box": { + "x": 0, + "y": 12.5, + "width": 1100, + "height": 825 + }, + "dimensions": { + "width": 1024, + "height": 768 + }, + "view_only_setting": false + }, + "result": "PASS", + "result_scope": "GUI, password-authenticated VNC, simulated handover and screenshot replay only; gateway token enforcement is separately observed" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/build-state.json b/.agents/evidence/verify/2026-10-26-desktop-handover/build-state.json new file mode 100644 index 00000000..7f4cf243 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/build-state.json @@ -0,0 +1,36 @@ +{ + "name": "skills-desktop-00c77839d5fd", + "image": "fc-e2b-registry.cn-hangzhou.cr.aliyuncs.com/runtime/desktop:v0.0.44", + "cpu": 4, + "memory": 8192, + "checks": [ + { + "stage": "template-submitted", + "template_id": "iallftl5zi2vdi0u0ht0" + }, + { + "stage": "build-progress", + "state": "building", + "elapsed_seconds": 0 + }, + { + "stage": "build-progress", + "state": "building", + "elapsed_seconds": 16 + }, + { + "stage": "build-progress", + "state": "building", + "elapsed_seconds": 31 + }, + { + "stage": "template-ready", + "state": "ready", + "cpu": 4, + "memory": 8192, + "generation": 2 + } + ], + "owned": true, + "template_id": "iallftl5zi2vdi0u0ht0" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/desktop-handover-replay.zip b/.agents/evidence/verify/2026-10-26-desktop-handover/desktop-handover-replay.zip new file mode 100644 index 00000000..5f0f6993 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/desktop-handover-replay.zip differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/gui-state.json b/.agents/evidence/verify/2026-10-26-desktop-handover/gui-state.json new file mode 100644 index 00000000..72224001 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/gui-state.json @@ -0,0 +1 @@ +{"events": [{"value": "agent-start", "at": 1792987242.7279108}, {"value": "operator-step", "at": 1792987264.8013532}, {"value": "agent-resumed", "at": 1792987268.4544063}], "latest": "agent-resumed"} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-1.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-1.json new file mode 100644 index 00000000..81c783c3 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-1.json @@ -0,0 +1,34 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.48, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-91e9ff49-6cdd-46ef-ab43-cd20bcfb287f", + "failure": { + "type": "AssertionError", + "message": "GUI commit did not produce agent-start" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-2.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-2.json new file mode 100644 index 00000000..18fea57e --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-2.json @@ -0,0 +1,50 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.35, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.57, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-0f83659e-9b64-4120-8a81-4b2e906eaa20", + "failure": { + "type": "AttributeError", + "message": "'ConnectionConfig' object has no attribute 'extra_sandbox_headers'" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-3.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-3.json new file mode 100644 index 00000000..d4c1d08c --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-3.json @@ -0,0 +1,56 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "gateway-stream-auth", + "result": "FAIL", + "without_gateway_token": 200, + "with_gateway_token": 200 + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.36, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.66, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-ba26d969-9837-4fa4-9dff-4b9162ec8144", + "failure": { + "type": "AssertionError", + "message": "gateway-stream-auth" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-4.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-4.json new file mode 100644 index 00000000..b9a1026d --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-4.json @@ -0,0 +1,74 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "stream-static-resource-access", + "result": "PASS", + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "gateway-token-enforcement", + "result": "NOT_CONFIRMED", + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.48, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.71, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-3eacd02b-8299-49b6-a860-0a2e01281a79", + "gateway_observation": { + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + "proxy": { + "bind": "127.0.0.1", + "gateway_token_stays_backend": true + }, + "failure": { + "type": "Error", + "message": "Page.goto: Download is starting\nCall log:\n - navigating to \"http://127.0.0.1:46859/vnc.html?autoconnect=true&resize=scale&password=deliberately-wrong\", waiting until \"domcontentloaded\"\n" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-5.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-5.json new file mode 100644 index 00000000..538170fb --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-5.json @@ -0,0 +1,93 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "stream-static-resource-access", + "result": "PASS", + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "gateway-token-enforcement", + "result": "NOT_CONFIRMED", + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "vnc-password-rejected", + "result": "PASS", + "connected": false, + "status": "New connection has been rejected with reason: password check failed!" + }, + { + "name": "live-stream-renders", + "result": "FAIL", + "websocket_sessions": 2, + "transported_bytes": 325, + "canvas_width": 1100 + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.37, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.56, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-4fab2127-b83d-4904-bb0a-21cc0f10789f", + "gateway_observation": { + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + "proxy": { + "bind": "127.0.0.1", + "gateway_token_stays_backend": true + }, + "failure": { + "type": "AssertionError", + "message": "live-stream-renders" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-6.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-6.json new file mode 100644 index 00000000..84d8689e --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-6.json @@ -0,0 +1,108 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "stream-static-resource-access", + "result": "PASS", + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "gateway-token-enforcement", + "result": "NOT_CONFIRMED", + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "vnc-password-rejected", + "result": "PASS", + "connected": false, + "status": "New connection has been rejected with reason: password check failed!" + }, + { + "name": "live-stream-renders", + "result": "PASS", + "websocket_sessions": 2, + "transported_bytes": 33320, + "canvas_width": 1100 + }, + { + "name": "operator-handover-effect", + "result": "FAIL", + "actor": "simulated operator through independent VNC client", + "sequence": [ + "agent-start" + ] + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.55, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.99, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 32.25, + "actor": "simulated-operator", + "action": "Observe authenticated live stream through controlled backend proxy", + "file": "03-live-stream-view.png", + "sha256": "a2a0cec3617c0a657b5c205a1de810b0aa803423f117cb032dca2718ccff1c59" + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-27eb36ca-3fa4-4684-8f79-a2a873ee33e1", + "gateway_observation": { + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + "proxy": { + "bind": "127.0.0.1", + "gateway_token_stays_backend": true + }, + "failure": { + "type": "AssertionError", + "message": "operator-handover-effect" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-7.json b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-7.json new file mode 100644 index 00000000..8a204c13 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/initial-attempts/attempt-7.json @@ -0,0 +1,130 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "stream-static-resource-access", + "result": "PASS", + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "gateway-token-enforcement", + "result": "NOT_CONFIRMED", + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "vnc-password-rejected", + "result": "PASS", + "connected": false, + "status": "New connection has been rejected with reason: password check failed!" + }, + { + "name": "live-stream-renders", + "result": "PASS", + "websocket_sessions": 2, + "transported_bytes": 33380, + "canvas_width": 1100 + }, + { + "name": "operator-handover-effect", + "result": "FAIL", + "actor": "simulated operator through independent VNC client", + "sequence": [ + "agent-start" + ] + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.63, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.93, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 35.11, + "actor": "simulated-operator", + "action": "Observe authenticated live stream through controlled backend proxy", + "file": "03-live-stream-view.png", + "sha256": "3ab4de0a27d070385ef42cecf196668bff4963b7f3f8766507e14be0e9c8158e" + }, + { + "at_seconds": 38.8, + "actor": "simulated-operator", + "action": "Independent screenshot after VNC input before checking state", + "file": "operator-diagnostic.png", + "sha256": "5a027bd7ba25d97c0e00e7edc608eefb2a109c5def18257535c5d17912b28f95", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-b45cef0c-507b-43c3-a605-990faab478ae", + "gateway_observation": { + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + "proxy": { + "bind": "127.0.0.1", + "gateway_token_stays_backend": true + }, + "canvas_observation": { + "bounding_box": { + "x": 0, + "y": 12.5, + "width": 1100, + "height": 825 + }, + "dimensions": { + "width": 1024, + "height": 768 + }, + "view_only_setting": false + }, + "failure": { + "type": "AssertionError", + "message": "operator-handover-effect" + }, + "result": "FAIL" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/operator-diagnostic.png b/.agents/evidence/verify/2026-10-26-desktop-handover/operator-diagnostic.png new file mode 100644 index 00000000..d458d336 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/operator-diagnostic.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/private-wrong-token-type.json b/.agents/evidence/verify/2026-10-26-desktop-handover/private-wrong-token-type.json new file mode 100644 index 00000000..4d3ef845 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/private-wrong-token-type.json @@ -0,0 +1,78 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "stream-static-resource-access", + "result": "FAIL", + "unauthenticated_static_status": 403, + "authenticated_static_status": 403, + "upstream_content_type": "application/json", + "upstream_disposition": null, + "html_document_observed": false, + "connected_without_gateway_token": false, + "http_status": 403 + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.68, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 20.02, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "template_id": "iallftl5zi2vdi0u0ht0", + "creation_controls": { + "secure": true, + "network": { + "allow_public_traffic": false + } + }, + "sandbox_id": "sbx-29e3ef0f-864c-4139-a272-437341c1656c", + "gateway_observation": { + "unauthenticated_static_status": 403, + "authenticated_static_status": 403, + "upstream_content_type": "application/json", + "upstream_disposition": null, + "html_document_observed": false, + "connected_without_gateway_token": false, + "http_status": 403 + }, + "failure": { + "type": "AssertionError", + "message": "stream-static-resource-access" + }, + "result": "FAIL", + "result_scope": "GUI, password-authenticated VNC, simulated handover and screenshot replay only; gateway token enforcement is separately observed" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/public-default-handover.json b/.agents/evidence/verify/2026-10-26-desktop-handover/public-default-handover.json new file mode 100644 index 00000000..7d8b7c09 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/public-default-handover.json @@ -0,0 +1,166 @@ +{ + "steps": [ + { + "name": "desktop-created-correct-spec", + "result": "PASS", + "cpu": 4, + "memory": 8192 + }, + { + "name": "agent-mouse-keyboard-effect", + "result": "PASS", + "sequence": [ + "agent-start" + ] + }, + { + "name": "stream-static-resource-access", + "result": "PASS", + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "gateway-token-enforcement", + "result": "NOT_CONFIRMED", + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + { + "name": "vnc-password-rejected", + "result": "PASS", + "connected": false, + "status": "New connection has been rejected with reason: password check failed!" + }, + { + "name": "live-stream-renders", + "result": "PASS", + "websocket_sessions": 2, + "transported_bytes": 33380, + "canvas_width": 1100 + }, + { + "name": "operator-handover-effect", + "result": "PASS", + "actor": "simulated operator through independent VNC client", + "sequence": [ + "agent-start", + "operator-step" + ] + }, + { + "name": "agent-resume-same-state", + "result": "PASS", + "sequence": [ + "agent-start", + "operator-step", + "agent-resumed" + ] + }, + { + "name": "stream-stopped", + "result": "PASS", + "websocket_sessions": 2, + "transported_bytes": 89238 + }, + { + "name": "replay-artifacts", + "result": "PASS", + "screenshots": 6, + "format": "offline screenshot timeline with SHA-256; not full-motion video" + }, + { + "name": "desktop-cleanup", + "result": "PASS", + "deleted": true, + "independently_absent": true + } + ], + "timeline": [ + { + "at_seconds": 17.59, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 19.94, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 35.13, + "actor": "simulated-operator", + "action": "Observe authenticated live stream through controlled backend proxy", + "file": "03-live-stream-view.png", + "sha256": "3ab4de0a27d070385ef42cecf196668bff4963b7f3f8766507e14be0e9c8158e" + }, + { + "at_seconds": 42.89, + "actor": "simulated-operator", + "action": "Independent screenshot after VNC input before checking state", + "file": "operator-diagnostic.png", + "sha256": "c68c23a466c467052d70720d6c5da2aa916af4cf68ba11bb2c6b33f94547787d", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 43.46, + "actor": "simulated-operator", + "action": "Type and commit through the second controller", + "file": "04-operator-state.png", + "sha256": "c68c23a466c467052d70720d6c5da2aa916af4cf68ba11bb2c6b33f94547787d", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 45.9, + "actor": "agent", + "action": "Resume on the same desktop and preserve prior operator state", + "file": "05-agent-resumed.png", + "sha256": "c9ef3fc538e3576c83f0dbebf8b80d4c9820654e0e752c952bb3f44b1e608409", + "width": 1024, + "height": 768 + } + ], + "scope": "Real official desktop image; operator is simulated by a second controller, not a human/login session.", + "sandbox_id": "sbx-224fa321-2ad3-4e53-a48e-7b2f4fe26f54", + "gateway_observation": { + "unauthenticated_static_status": 200, + "authenticated_static_status": 200, + "upstream_content_type": "text/html", + "upstream_disposition": "attachment", + "html_document_observed": true, + "connected_without_gateway_token": true, + "rfb_banner_received": true + }, + "proxy": { + "bind": "127.0.0.1", + "gateway_token_stays_backend": true + }, + "canvas_observation": { + "bounding_box": { + "x": 0, + "y": 12.5, + "width": 1100, + "height": 825 + }, + "dimensions": { + "width": 1024, + "height": 768 + }, + "view_only_setting": false + }, + "result": "PASS" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/replay-check.json b/.agents/evidence/verify/2026-10-26-desktop-handover/replay-check.json new file mode 100644 index 00000000..6421613a --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/replay-check.json @@ -0,0 +1,8 @@ +{ + "result": "PASS", + "first_frame": "01-browser-ready.png", + "last_frame": "05-agent-resumed.png", + "previous_frame": "04-operator-state.png", + "frame_count": 6, + "host_browser_version": "150.0.7871.114" +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/replay-player.png b/.agents/evidence/verify/2026-10-26-desktop-handover/replay-player.png new file mode 100644 index 00000000..bba6b5a9 Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/replay-player.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/replay.html b/.agents/evidence/verify/2026-10-26-desktop-handover/replay.html new file mode 100644 index 00000000..8bbb681b --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/replay.html @@ -0,0 +1,36 @@ + + +Desktop handover replay + +

Desktop handover acceptance replay

+

Actual screenshot timeline. Operator was simulated; no human login occurred.

+ + + +

+Recorded desktop frame + diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/requirements-resolved.txt b/.agents/evidence/verify/2026-10-26-desktop-handover/requirements-resolved.txt new file mode 100644 index 00000000..f0c317f8 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/requirements-resolved.txt @@ -0,0 +1,43 @@ +aiohappyeyeballs==2.7.1 +aiohttp==3.13.4 +aiosignal==1.4.0 +anyio==4.15.1 +attrs==26.1.0 +bracex==3.2 +certifi==2026.10.23 +charset-normalizer==3.5.2 +connectrpc==0.12.1 +e2b==2.59.0 +e2b-desktop==2.4.1 +e2b-dockerfile-utils==0.1.1 +frozenlist==1.8.0 +greenlet==3.5.6 +h11==0.16.0 +h2==4.4.1 +hpack==4.2.0 +httpcore==1.0.9 +httpx==0.28.1 +hyperframe==6.1.0 +idna==3.20 +markdown-it-py==4.2.0 +mdurl==0.1.2 +multidict==6.9.1 +opentelemetry-api==1.45.1 +packaging==26.3 +pillow==12.3.0 +playwright==1.58.0 +propcache==0.5.4 +protobuf-py==0.6.0 +protobuf-py-ext==0.6.0 +pyee==13.0.1 +pygments==2.21.0 +pyqwest==0.12.0 +python-dateutil==3.0.0 +requests==2.34.2 +rich==15.0.0 +six==1.17.0 +typing-extensions==4.16.0 +tzdata==2026.5 +urllib3==2.8.0 +wcmatch==11.2 +yarl==1.25.1 diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/sandbox-cleanup.json b/.agents/evidence/verify/2026-10-26-desktop-handover/sandbox-cleanup.json new file mode 100644 index 00000000..7e783e45 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/sandbox-cleanup.json @@ -0,0 +1,50 @@ +{ + "count": 11, + "all_absent": true, + "rows": [ + { + "sandbox_id": "sbx-0f83659e-9b64-4120-8a81-4b2e906eaa20", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-224fa321-2ad3-4e53-a48e-7b2f4fe26f54", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-27eb36ca-3fa4-4684-8f79-a2a873ee33e1", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-29e3ef0f-864c-4139-a272-437341c1656c", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-3eacd02b-8299-49b6-a860-0a2e01281a79", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-4fab2127-b83d-4904-bb0a-21cc0f10789f", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-91e9ff49-6cdd-46ef-ab43-cd20bcfb287f", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-b45cef0c-507b-43c3-a605-990faab478ae", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-ba26d969-9837-4fa4-9dff-4b9162ec8144", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-ec4ff483-de6d-4919-9311-6162447d4737", + "independent_typed_not_found": true + }, + { + "sandbox_id": "sbx-ff3cae53-31f3-4e13-bdc2-cdd3a48ac298", + "independent_typed_not_found": true + } + ] +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/stream-canvas.png b/.agents/evidence/verify/2026-10-26-desktop-handover/stream-canvas.png new file mode 100644 index 00000000..10e9935a Binary files /dev/null and b/.agents/evidence/verify/2026-10-26-desktop-handover/stream-canvas.png differ diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/template-cleanup.json b/.agents/evidence/verify/2026-10-26-desktop-handover/template-cleanup.json new file mode 100644 index 00000000..695e1648 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/template-cleanup.json @@ -0,0 +1,5 @@ +{ + "template_id": "iallftl5zi2vdi0u0ht0", + "owned_name_verified": true, + "independent_get_http404": true +} \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/tested-files.json b/.agents/evidence/verify/2026-10-26-desktop-handover/tested-files.json new file mode 100644 index 00000000..1453f50e --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/tested-files.json @@ -0,0 +1,6 @@ +{ + "e2e/desktop_acceptance.py": "8f50ab9b1f1aca5af6957af88408bc1f87b9592559e35193d6885856cbd178ff", + "e2e/desktop-requirements.txt": "e2d14184602602ad966fc1059123d5af0f6e6845b1a4f9625214c42f8803c34c", + "e2e/desktop-handover.html": "2ba5a8921e64294166944af14f7a187bc5a34c16192d5876edf10deea0a1aef0", + "e2e/desktop-replay.html": "307d6c3d8f9f58d0a9d6959aa002aef959142d8c794639f9c406ec37bbc51aa8" +} diff --git a/.agents/evidence/verify/2026-10-26-desktop-handover/timeline.json b/.agents/evidence/verify/2026-10-26-desktop-handover/timeline.json new file mode 100644 index 00000000..3ca72986 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-desktop-handover/timeline.json @@ -0,0 +1,54 @@ +[ + { + "at_seconds": 17.86, + "actor": "agent", + "action": "Open the synthetic local acceptance page in the real desktop browser", + "file": "01-browser-ready.png", + "sha256": "869764cbe433fc11986032712610e68d6de132b0a8b694cba3c0ae1f54a24c0a", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 20.13, + "actor": "agent", + "action": "Mouse focus, keyboard text and mouse commit", + "file": "02-agent-state.png", + "sha256": "2e599f95b3c886b45255af27c3994826e3068dcfd14d741570635e8a138079b4", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 35.15, + "actor": "simulated-operator", + "action": "Observe authenticated live stream through controlled backend proxy", + "file": "03-live-stream-view.png", + "sha256": "3ab4de0a27d070385ef42cecf196668bff4963b7f3f8766507e14be0e9c8158e" + }, + { + "at_seconds": 42.91, + "actor": "simulated-operator", + "action": "Independent screenshot after VNC input before checking state", + "file": "operator-diagnostic.png", + "sha256": "c68c23a466c467052d70720d6c5da2aa916af4cf68ba11bb2c6b33f94547787d", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 43.48, + "actor": "simulated-operator", + "action": "Type and commit through the second controller", + "file": "04-operator-state.png", + "sha256": "c68c23a466c467052d70720d6c5da2aa916af4cf68ba11bb2c6b33f94547787d", + "width": 1024, + "height": 768 + }, + { + "at_seconds": 45.94, + "actor": "agent", + "action": "Resume on the same desktop and preserve prior operator state", + "file": "05-agent-resumed.png", + "sha256": "c9ef3fc538e3576c83f0dbebf8b80d4c9820654e0e752c952bb3f44b1e608409", + "width": 1024, + "height": 768 + } +] \ No newline at end of file diff --git a/.agents/evidence/verify/2026-10-26-skill-storage-state-acceptance.md b/.agents/evidence/verify/2026-10-26-skill-storage-state-acceptance.md new file mode 100644 index 00000000..8aa0ddb2 --- /dev/null +++ b/.agents/evidence/verify/2026-10-26-skill-storage-state-acceptance.md @@ -0,0 +1,188 @@ +# Verification: Skill storage, retained state and MCP ownership + +- Date: 2026-10-26 +- Change: base `8959e4f` plus this task's scoped source, tests, Skills and documentation patch. +- Result: PARTIAL — real storage/state scenarios pass; repository-wide server checks retain two malformed-HTTP failures, the complete client run also retains two environment-blocked engine/mock failures. +- Package under test: editable matching client/server `0.4.0a6` from this checkout. This is source validation, not a new public release or a claim that public a6 contains the ownership correction. +- Environment: Python 3.11.15, Linux, real Alibaba Cloud Express in the configured region. Credentials were supplied locally through environment variables; values are omitted. + +## What was verified + +Real cloud data-plane effects establish owned OSS persistence and read-only access, +MicroVM memory/service/mount continuity, Snapshot clone isolation, and LangGraph +conversation reuse with separate user namespaces. The maintained opt-in pytest +scenario is distinct from the local fixture provisioner. New MCP unit and real +cloud regressions cover the independently discovered cross-session shutdown bug. +Initial failures remain separate receipts rather than being rewritten as passes. + +## Commands run + +Credentials and account-specific identifiers were injected from local configuration, +not included in command output or committed files. Equivalent repository invocations: + +```sh +E2E_PAUSE_TEMPLATE= E2E_SNAPSHOT_TEMPLATE= \ + python -m pytest -q e2e/test_standard_contract.py -k 'pause_resume or snapshot_restore' --tb=short +E2E_SNAPSHOT_TEMPLATE= \ + python -m pytest -q e2e/test_standard_contract.py -k snapshot_restore --tb=short +python -m pytest -q e2e/test_09_volume_mounts.py e2e/test_skill_storage_state.py --tb=short +python -m pytest -q e2e/test_09_mcp_lifecycle.py --tb=short +python -m pytest -q client-sdk/tests/test_agent --tb=short +python e2e/skill_tools_acceptance.py --output +make test +make lint +make typecheck +``` + +The Volume run supplied the exact explicit-owned fixture variables documented in +`e2e/README.md`; it did not select a pre-existing borrowed Volume. The Provider +runs redirected engine discovery/configuration to an isolated test directory using +existing configuration seams. The test engine was the actual Node CLI build; +`HOME` was not changed. Full unit checks ran without project cloud credentials. + +## Results + +| Scenario / test | Result | Actual observation | +| --- | --- | --- | +| Initial standard pause/Snapshot subset | PARTIAL | 1 passed, 1 failed in 27.38 seconds. Pause preserved the random HTTP service value and file. Snapshot create/list/restore, file read and process-memory continuity succeeded, but direct `/tmp` SDK overwrite failed with HTTP500 permission denied. | +| Independent pre-Snapshot file diagnostic | FAIL retained | The same second SDK write directly under `/tmp` fails before any Snapshot. Ordinary-user command overwrite works; `/home/user` SDK overwrite works. UID and file owner remained 1000 before/after restoration. No ownership or permission relaxation was used to hide the observation. | +| Corrected standard Snapshot regression | PASS | 1 passed, 4 deselected in 14.53 seconds. Same memory-only token, retained file, clone overwrite with unchanged source, Snapshot indexing/deletion and owned sandbox cleanup. The mutation file is inside an owned non-sticky directory. | +| Initial custom MicroVM mount fixture | FAIL retained | Volume metadata reached AVAILABLE, but mount creation returned HTTP502. A separate owned runtime probe found no `ossfs2`; `/dev/fuse` and x86_64 existed. No mount/persistence pass is inferred from that fixture. | +| Owned OSS on eligible generation-1 runtime | PASS | Exact mount entry; exclusively created sentinel, fsync and read; first sandbox killed with404; fresh second sandbox reads persisted bytes; separate read-only Volume can read and refuses write. | +| Snapshot-origin mount override attempt | FAIL retained | An owned MicroVM snapshot containing verified ossfs2 was created; the backend rejected new mount overrides with HTTP400, `creating from a snapshot does not allow volume or mount overrides`. No mutation replay. | +| Owned MicroVM append mount and pause/resume | PASS | Only newly owned sandboxes were prepared with official ossfs2 2.0.10; SHA256 `72e864b8087d6f889c5017339c625154619f626bbc13c9afcb70403544ad9b70` verified. The documented append endpoint returned204. Exact mount, persisted sentinel, process and HTTP service memory token survived observed PAUSED state and reconnect. RW recreation and RO rejection also passed. | +| Maintained Provider Volume tests plus new standard RW/RO test | PASS | 3 passed in17.33 seconds against newly owned OSS/Volumes. The earlier standard-only run also passed, including explicit typed404 cleanup, in9.75 seconds. The existing Provider test's imported class was corrected to `ProviderSandbox`. | +| LangGraph1.2.17 session boundary | PASS, scoped | Same user/conversation reconnected the same real sandbox across turns. A distinct user with the same conversation name got another sandbox; file values remained separate. Both sandboxes independently absent after cleanup. No login/authentication boundary, model execution or durable multi-process framework checkpoint store is claimed. | +| SDK repository/preview/third-party MCP runner | PASS | 20 real checks: exact public Git commit, actual Node application, HMAC preview200, unsigned/tampered403, expired403, two pinned filesystem MCP versions, real initialize/14-tool discovery/read_file result385, logs, process cleanup and sandbox typed absence. Application signing is separate from provider gateway signing. | +| Earlier final-copy repository transfer | FAIL retained | GitHub clone transferred less than1 byte/sec for30 seconds. Sandbox cleanup passed. The runner now fetches the exact immutable commit, with at most two bounded read-only fetch attempts; the later complete run passed. | +| MCP two-manager regression before fix | FAIL as expected | 1 failed,1 passed. Borrower shutdown did not close its borrowed handle; the old implementation killed it. Explicit borrowed kill already passed. | +| First ownership-patch MCP unit suite | PASS, historical scope | 261 passed in8.07 seconds. This preceded the independent namespace/lifecycle adversarial review. | +| Independent namespace/lifecycle adversarial regressions | FAIL before correction | Six failures reproduced provider raw-ID collision, owned-handle replacement, alias replacement, create/connect completing after shutdown, and new work on an already closed manager. The separate reviewer additionally exercised a queued shutdown holding the real asyncio lock. | +| Task namespace/submission regressions | FAIL before correction | Cached non-default-provider task reuse, ambiguous bare-ID refusal and late-connect submission failed. An additional assertion exposed missing provider context in the returned task handle. | +| Final task/ownership affected MCP unit suite | PASS, historical scope | 270 passed in7.92 seconds. Canonical provider identity, aliases, late lifecycle completion, borrowed cleanup, explicit kill, task submission/reconnect, binary files and retention remain covered. | +| Maintained real-cloud MCP lifecycle suite, first ownership patch | PASS, historical scope | 3 passed in14.04 seconds. Borrower closes without killing the owner; owner shutdown then gives typed not-found. Namespace-patch repeat:3 passed in11.96 seconds. | +| Task/ownership maintained real-cloud MCP lifecycle suite | PASS, historical scope | 4 passed in19.49 seconds. Includes actual qualified task handle, owner disconnect, fresh-session status/log lookup, `beforeafter` stdout, succeeded state, exit0 and explicit final sandbox deletion. | +| Final resource verification regressions, before helper | FAIL as expected | Four scenarios/five parametrized cases fail: no independent resource read, mismatches accepted, unknown observation accepted, ambiguous cleanup not reported. | +| First resource-helper complete affected MCP suite | PASS, historical scope | 275 passed in7.92 seconds. Includes resource match, mismatch/new-only rollback, unknown metadata redaction, and failed-delete/readback reconciliation with a provider-qualified recovery ID. | +| First resource-helper real-cloud lifecycle/resource/task suite | PASS, historical scope | 5 passed in25.15 seconds. Includes matching actual CPU/memory and mismatch rollback, original sandbox preservation, independently confirmed not-found, plus owner/borrower/task cases. | +| Independent endpoint-context adversarial regression | FAIL before correction | After a regional delete timeout, the first helper read metadata through the default region and mistook that region's404 for deletion. A routing mock reproduces the false cleanup confirmation. | +| Frozen-endpoint affected MCP suite | PASS, historical scope | 279 passed in7.95 seconds. Resource cleanup freezes the actual instance's resolved provider/endpoint/region/credentials/options, builds a fresh control plane and reads the raw ID. Query not-found confirms absence; setup/close failures cannot. | +| Frozen-endpoint real-cloud suite | PASS, historical scope | 5 passed in25.88 seconds. Lifecycle, borrowed cleanup, retained tasks and explicit resource match/rejection run against the final helper. The separate before/after/matching campaign repeats successfully and independently confirms all4 new resources absent. | +| Final resource handle race regressions before correction | FAIL as expected | Two rollback cases selected a static default endpoint or a replacement cache handle after metadata awaited. Two initial-verification cases falsely accepted matching metadata from a default endpoint/replacement. | +| Final captured-handle affected MCP suite | PASS | 283 passed in8.10 seconds.13 resource cases include exact-handle verification and deletion across cache clearing/replacement, identity-guarded local disposal, frozen endpoint cleanup and target-query-only absence confirmation. | +| Final captured-handle real-cloud suite | PASS | 5 passed in23.68 seconds. Actual lifecycle, borrowed cleanup, retained task continuation, matching resource acceptance and mismatch cleanup. The separate before/after/matching campaign repeats successfully, preserving the original sandbox and independently confirming all4 owned resources absent. | +| Actual resource defect and corrected acceptance | PASS, before failure preserved | Base metadata is2 CPU/2048 MiB. Bypassing only the new helper reproduces the old false-success request3/2304 with actual2/2048. Restored helper accepts matching2/2048, rejects3/2304, reports independent cleanup true, preserves the existing sandbox and confirms all4 owned IDs absent. | +| Private v3 hosted HTTPS final acceptance | PASS, exact v3 artifact | 9 checks: missing private-ingress token403, bearer401, separate sessions/15tools, owned create/commands, same-credential borrowed access, borrower DELETE preserves owner, owner DELETE cleans, and retained task owner disconnect/fresh-session qualified-handle result/succeeded0/final kill.4 owned resources independently not-found. This is private source validation, not a public-a6 claim or a v4 whole-wheel HTTPS run. | +| Initial new task E2E assertion | FAIL retained | 3 passed,1 failed in18.56 seconds. The new test incorrectly expected `completed`; the existing task contract and actual backend returned `succeeded`. The assertion was corrected to that established contract; no runtime success/failure condition was weakened. | +| `make lint` | PASS | Both packages. Scoped new/changed Python files also pass Ruff; both modified Skills pass `quick_validate.py`. | +| `make format-check` | PASS | 92 server files and240 client files. The initial invocation lacked the venv PATH; the configured environment run passed. | +| `make typecheck` | PASS | 56 server files and100 client files; an existing unused mypy section note remains. | +| `make test` initial full server phase | FAIL retained | 872 passed,1 skipped,3 failed in316.53 seconds. The Make target stopped before the client phase. Two raw malformed-HTTP checks received501/502 instead of400. Package inventory lacked pytest because the fresh uv environment had no pip. | +| Focused server rerun after environment correction | PARTIAL | Installing pip26.2.2 in the test environment fixed inventory:1 passed. The two raw malformed-HTTP failures reproduce:2 failed in1.78 seconds. Their root cause is unconfirmed; server runtime was not changed or tests bypassed. | +| Complete client suite, first ownership patch | PARTIAL | 2317 passed,21 skipped,2 failed in133.04 seconds. Cube-through-engine mock RPC returned HTTP501 with `项目网络审计已关闭`; engine-restart follow-up returned502. No cloud account was involved. The fixture already clears inherited proxies; no audit bypass or unrelated runtime change was made. | +| Complete client suite, namespace/lifecycle patch | PARTIAL | 2323 passed,21 skipped,2 identical environment-blocked engine/mock failures in129.03 seconds. The final task/resource follow-ups are covered by the283-test affected suite and real5-test lifecycle/resource/task suite; a later full CI matrix remains required. | + +The MicroVM append API was taken from the +[official append-Volume documentation](https://www.alibabacloud.com/help/zh/agent-sandbox/append-mount-volume): +`POST /sandboxes/{sandboxID}/fc-volume-mounts` with `namedVolumeMounts`. +It is a backend acceptance call, not a claim that a new shared Provider command +or Python convenience method was implemented. Runtime preparation follows the +[official OSS mount guide](https://www.alibabacloud.com/help/zh/agent-sandbox/mount-oss-volume-1) +and [RAM role guide](https://www.alibabacloud.com/help/zh/agent-sandbox/create-ram-role). + +## Cleanup + +Every storage fixture used a fresh private empty OSS bucket, fresh FC-trust RAM +role and custom policy limited to that one bucket, two fresh Volume definitions, +and tagged sandboxes with bounded TTLs. The tests never modified a borrowed bucket, +role, policy, template or Volume. Successful sandbox deletion was independently +checked with404/typed not-found. The uncertain initial502 create was reconciled +by its unique metadata over five reads; no owned sandbox remained. Volume lists +confirmed absence; owned objects, buckets, policy attachments, policies and roles +were removed. Source Snapshots and restored sandboxes were deleted separately. +The new pytest test independently removes only its verified exclusive sentinel, +then checks every tracked sandbox's typed not-found. + +Final independent readback found all seven owned RAM roles, all seven policies and +all seven OSS buckets absent, with zero owned Volume definitions and zero tagged +owned sandboxes remaining. [Cleanup receipt](skill-storage-state/final-storage-cleanup.json). + +The framework check records zero remaining sandboxes. Both failed and successful +repository/MCP-tool runs independently confirm the owned sandbox is absent. No +account credentials were placed in sandbox files, Issues, outputs or receipts. + +## Receipts + +Derived sanitized receipts are stored in [skill-storage-state](skill-storage-state/oss-pytest-final.json): + +- [Generation-1 OSS persistence/RO](skill-storage-state/oss-gen1-persistence-ro.json) +- [MicroVM mounted memory/service resume](skill-storage-state/oss-microvm-mounted-resume.json) +- [Final maintained Volume pytest](skill-storage-state/oss-pytest-final.json) +- [Initial missing ossfs2 fixture](skill-storage-state/initial-microvm-missing-ossfs2.json) +- [Snapshot mount override rejection](skill-storage-state/snapshot-mount-override-rejected.json) +- [Original Snapshot `/tmp` diagnostic](skill-storage-state/snapshot-tmp-initial-diagnostic.json) +- [Owned-directory Snapshot diagnostic](skill-storage-state/snapshot-owned-directory-diagnostic.json) +- [Pre-Snapshot `/tmp` overwrite reproduction](skill-storage-state/source-tmp-overwrite.json) +- [LangGraph isolation](skill-storage-state/langgraph-session-isolation.json) +- [Earlier GitHub transfer failure](skill-storage-state/tools-github-transfer-failure.json) +- [Final exact-fetch/preview/MCP20 checks](skill-storage-state/tools-pinned-fetch-preview-mcp.json) + +The first private client wheel has SHA256 +`e392045f50b39fd3ba094dbbb6df16d687e47f4e912cbc4ca61ae42622c31587`; +its initial seven-check hosted HTTPS pass is historical evidence that predates the +independent identity/race findings. The namespace/lifecycle wheel has SHA256 +`32841e230ebf8c2c1544fc75083225d2c0b30b1074c82d4ff0558782a6cd4c28`. +The v3 client wheel, including task namespace/submission corrections, has SHA256 +`e6f803f0e367381f62e968d6103616b9be9c35ed9fc8772289b93ca41b3a6d43`. +Both ownership/task runtime modules were compared byte-for-byte against that v3 wheel. +The historical v4 client wheel first added the explicit-resource helper and its tools.py call; +its SHA256 is `4a1a6899fcc550471aac5b4a6eaec9d6fe357687ea75da0c56febaf62dad0906`. +Its endpoint-context cleanup defect was found independently before delivery. +The historical v5 client wheel has SHA256 +`d2c7f1675d170c241634dadfd28e8fdfd8f5d941065dc212866eb590e8d1852c`. +Final parent/independent review found that ID-based first observation/rollback +could switch handles after session/cache changes. Intermediate v6 fixed rollback; +the final v7 fixes both by retaining the captured created handle. The final wheel +has SHA256 `cbd1710159090133099cfde61f2d17fe511ca730d47c74838218d105e7d36746`. +All four affected runtime modules match v7 byte-for-byte; mcp.py/tasks.py remain +byte-identical to the v3 hosted artifact. Final actual resource validation, +283 affected unit tests and5 real lifecycle/resource/task tests are separate +from the exact v3 transport campaign. No whole-wheel v7 hosted HTTPS run is claimed. +The unchanged matching server wheel has SHA256 +`85b937438acf943b3d92736dd6a07c4829cd80416336da35c0b25f2e5679850d`. +These are private source-test artifacts bearing a6 metadata, not replacements for +immutable public a6 files. The recorded v3 hosted transport acceptance is exact-version evidence; any eventual +release must be recorded separately by the owning delivery. + +- [Original public-a6 hosted failure](skill-storage-state/remote-mcp-public-a6-failure.json) +- [First source patch HTTPS pass, before red-team findings](skill-storage-state/remote-mcp-first-patch-pass.json) +- [Final v3 hosted HTTPS and retained-task pass](skill-storage-state/remote-mcp-v3-final-pass.json) +- [First CPU/memory verification pass, before endpoint review](skill-storage-state/mcp-explicit-resource-verification.json) +- [Frozen-endpoint CPU/memory pass, before handle-race review](skill-storage-state/mcp-resources-frozen-endpoint-final.json) +- [Final captured-handle CPU/memory before/after and cleanup](skill-storage-state/mcp-resources-captured-handle-final.json) + +## Problems found + +- [Issue #140](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/140): Python MCP accepted CPU/memory requests without confirming actual resources. The new helper checks independent metadata and rolls back only the unverified new sandbox. Missing/failed cleanup observations remain explicit recovery outcomes. The follow-up uses the created instance's frozen endpoint context and distinguishes query404 from initialization/close failures. Direct SDK creation is unchanged. + +- [Issue #139](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/139): public-a6 Python MCP cached borrowed connections were implicitly destroyed on + shutdown. The scoped source fix separates creation ownership from cached + handles, using actual provider-qualified keys and closed-session guards. Tasks + retain the actual provider in returned opaque handles; clients parsing raw IDs + must update. This does not create a tenant authorization boundary. +- Direct sticky-`/tmp` SDK overwrite fails independently of Snapshots on the tested + envd image; ordinary non-sticky workspace writes and Snapshot isolation pass. +- MicroVM source images need the documented ossfs2 prerequisite. A Snapshot restore + cannot accept new mount overrides; it is not a rebuilt image template. +- A Provider Skill retained the CLI-rejected `templates.deploy` feature. It now + uses the exact consumer-specific `template.prepare` operation guidance. +- The existing Provider Volume E2E imported root standard Sandbox for a + Provider-only method; the import now explicitly names `ProviderSandbox`. + +## Not verified + +No blanket certification of all providers, storage types or accounts follows from +this OSS/Express run. Costs, budgets, native signed previews, external VM snapshot +import, full Agent model behavior and durable multi-worker framework state are +outside these particular receipts. Full server and client checks retain the failures above; +no CI pass, PR merge, deployment or new package publication is asserted here. diff --git a/.agents/evidence/verify/skill-storage-state/final-storage-cleanup.json b/.agents/evidence/verify/skill-storage-state/final-storage-cleanup.json new file mode 100644 index 00000000..a9c07ed7 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/final-storage-cleanup.json @@ -0,0 +1,9 @@ +{ + "roles_absent": 7, + "policies_absent": 7, + "buckets_absent": 7, + "owned_volumes_remaining": 0, + "owned_sandboxes_remaining": 0, + "errors": [], + "result": "PASS" +} \ No newline at end of file diff --git a/.agents/evidence/verify/skill-storage-state/initial-microvm-missing-ossfs2.json b/.agents/evidence/verify/skill-storage-state/initial-microvm-missing-ossfs2.json new file mode 100644 index 00000000..5299ed87 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/initial-microvm-missing-ossfs2.json @@ -0,0 +1,74 @@ +{ + "run": "abx-accept-95a15bee785b", + "steps": [ + { + "scenario": "owned private empty OSS bucket created", + "result": "PASS" + }, + { + "scenario": "owned FC trust role scoped only to owned OSS bucket", + "result": "PASS" + }, + { + "scenario": "RW and RO Volume configurations reached AVAILABLE", + "result": "PASS" + } + ], + "cleanup": [ + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "bucket", + "result": "PASS" + }, + { + "kind": "policy-detach", + "result": "PASS" + }, + { + "kind": "policy", + "result": "PASS" + }, + { + "kind": "role", + "result": "PASS" + } + ], + "sources": [ + "https://www.alibabacloud.com/help/zh/agent-sandbox/mount-oss-volume-1", + "https://www.alibabacloud.com/help/zh/agent-sandbox/create-ram-role" + ], + "failure": { + "type": "SandboxException", + "message": "502: b'\\n\\n 502 Bad Gateway\\n\\n\\n

502 Bad Gateway

\\n

stream reset by client (INTERNAL_ERROR)

\\n\\n'", + "frames": [ + { + "file": "mount_acceptance.py", + "line": 34, + "name": "new" + }, + { + "file": "main.py", + "line": 212, + "name": "create" + }, + { + "file": "main.py", + "line": 939, + "name": "_create" + }, + { + "file": "sandbox_api.py", + "line": 272, + "name": "_create_sandbox" + } + ] + }, + "result": "FAIL" +} diff --git a/.agents/evidence/verify/skill-storage-state/langgraph-session-isolation.json b/.agents/evidence/verify/skill-storage-state/langgraph-session-isolation.json new file mode 100644 index 00000000..c4359836 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/langgraph-session-isolation.json @@ -0,0 +1,20 @@ +{ + "run": "framework-a18a5274f63d", + "framework": "langgraph1.2.17", + "steps": [ + { + "scenario": "same application-user namespace+conversation reconnects original cloud sandbox across graph turns", + "result": "PASS" + }, + { + "scenario": "distinct user namespace with same conversation name gets distinct sandbox and isolated files", + "result": "PASS" + } + ], + "model_execution": false, + "cleanup": { + "remaining": 0, + "errors": [] + }, + "result": "PASS" +} diff --git a/.agents/evidence/verify/skill-storage-state/mcp-explicit-resource-verification.json b/.agents/evidence/verify/skill-storage-state/mcp-explicit-resource-verification.json new file mode 100644 index 00000000..109eb314 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/mcp-explicit-resource-verification.json @@ -0,0 +1,65 @@ +{ + "source": "private local SDK", + "checks": [], + "actual_base_resources": { + "cpu": 2, + "memory": 2048 + }, + "before": { + "claimed_success": true, + "sandbox_id": "sbx-4e069b9e-3e45-4ded-84ac-c3c39b505af5", + "requested": { + "cpu": 3, + "memory": 2304, + "timeout": 300 + }, + "observed": { + "cpu": 2, + "memory": 2048 + } + }, + "matching": { + "sandbox_id": "sbx-e29cb1a0-4086-4ca7-a263-de76a915eb20", + "observed": { + "cpu": 2, + "memory": 2048 + }, + "passed": true + }, + "rejected": { + "error": "The provider did not apply the requested sandbox resources.", + "category": "resource_configuration", + "verified": false, + "requested_resources": { + "cpu": 3, + "memory": 2304 + }, + "observed_resources": { + "cpu": 2, + "memory": 2048 + }, + "sandbox_id": "alicloud-express:sbx-19dd350d-395a-46fd-8fe2-d3c72d1b27f9", + "cleanup_verified": true, + "suggestion": "Select or build a template with the required resources, then verify its sandbox metadata. Retry cleanup of this sandbox ID if cleanup_verified is false." + }, + "existing_preserved": true, + "cleanup": [ + { + "sandbox_id": "sbx-7bad902b-70d5-4fa7-86cd-2499d3659936", + "not_found": true + }, + { + "sandbox_id": "sbx-4e069b9e-3e45-4ded-84ac-c3c39b505af5", + "not_found": true + }, + { + "sandbox_id": "sbx-e29cb1a0-4086-4ca7-a263-de76a915eb20", + "not_found": true + }, + { + "sandbox_id": "alicloud-express:sbx-19dd350d-395a-46fd-8fe2-d3c72d1b27f9", + "not_found": true + } + ], + "passed": true +} diff --git a/.agents/evidence/verify/skill-storage-state/mcp-resources-captured-handle-final.json b/.agents/evidence/verify/skill-storage-state/mcp-resources-captured-handle-final.json new file mode 100644 index 00000000..bc49a0ec --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/mcp-resources-captured-handle-final.json @@ -0,0 +1,67 @@ +{ + "source": "private local SDK", + "checks": [], + "actual_base_resources": { + "cpu": 2, + "memory": 2048 + }, + "before": { + "claimed_success": true, + "sandbox_id": "sbx-62ed4e2f-0556-4102-9430-40d758aa6619", + "requested": { + "cpu": 3, + "memory": 2304, + "timeout": 300 + }, + "observed": { + "cpu": 2, + "memory": 2048 + } + }, + "matching": { + "sandbox_id": "sbx-4cc36f2e-6de1-4850-97e9-7c2ab009f540", + "observed": { + "cpu": 2, + "memory": 2048 + }, + "passed": true + }, + "rejected": { + "error": "The provider did not apply the requested sandbox resources.", + "category": "resource_configuration", + "verified": false, + "requested_resources": { + "cpu": 3, + "memory": 2304 + }, + "observed_resources": { + "cpu": 2, + "memory": 2048 + }, + "sandbox_id": "alicloud-express:sbx-c33a31cc-30ff-47d6-9e59-065d86a16fbf", + "cleanup_verified": true, + "suggestion": "Select or build a template with the required resources, then verify its sandbox metadata. Retry cleanup of this sandbox ID if cleanup_verified is false." + }, + "existing_preserved": true, + "cleanup": [ + { + "sandbox_id": "sbx-fbddecff-3dfa-4528-a4b9-5c8e7aae7207", + "not_found": true + }, + { + "sandbox_id": "sbx-62ed4e2f-0556-4102-9430-40d758aa6619", + "not_found": true + }, + { + "sandbox_id": "sbx-4cc36f2e-6de1-4850-97e9-7c2ab009f540", + "not_found": true + }, + { + "sandbox_id": "alicloud-express:sbx-c33a31cc-30ff-47d6-9e59-065d86a16fbf", + "not_found": true + } + ], + "passed": true, + "source_test_client_sha256": "cbd1710159090133099cfde61f2d17fe511ca730d47c74838218d105e7d36746", + "release_status": "private source-test wheel; public 0.4.0a6 is not fixed" +} diff --git a/.agents/evidence/verify/skill-storage-state/mcp-resources-frozen-endpoint-final.json b/.agents/evidence/verify/skill-storage-state/mcp-resources-frozen-endpoint-final.json new file mode 100644 index 00000000..064aac78 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/mcp-resources-frozen-endpoint-final.json @@ -0,0 +1,67 @@ +{ + "source": "private local SDK", + "checks": [], + "actual_base_resources": { + "cpu": 2, + "memory": 2048 + }, + "before": { + "claimed_success": true, + "sandbox_id": "sbx-2900c24e-4b7a-418a-a7d3-8e2d7c68b0f5", + "requested": { + "cpu": 3, + "memory": 2304, + "timeout": 300 + }, + "observed": { + "cpu": 2, + "memory": 2048 + } + }, + "matching": { + "sandbox_id": "sbx-2398f1d0-b810-49dd-9fac-d131f1ea7275", + "observed": { + "cpu": 2, + "memory": 2048 + }, + "passed": true + }, + "rejected": { + "error": "The provider did not apply the requested sandbox resources.", + "category": "resource_configuration", + "verified": false, + "requested_resources": { + "cpu": 3, + "memory": 2304 + }, + "observed_resources": { + "cpu": 2, + "memory": 2048 + }, + "sandbox_id": "alicloud-express:sbx-26bf0f22-699a-4052-b125-db539d5394cf", + "cleanup_verified": true, + "suggestion": "Select or build a template with the required resources, then verify its sandbox metadata. Retry cleanup of this sandbox ID if cleanup_verified is false." + }, + "existing_preserved": true, + "cleanup": [ + { + "sandbox_id": "sbx-a62d41b8-ed9a-44b4-b0bd-a3698e454ab6", + "not_found": true + }, + { + "sandbox_id": "sbx-2900c24e-4b7a-418a-a7d3-8e2d7c68b0f5", + "not_found": true + }, + { + "sandbox_id": "sbx-2398f1d0-b810-49dd-9fac-d131f1ea7275", + "not_found": true + }, + { + "sandbox_id": "alicloud-express:sbx-26bf0f22-699a-4052-b125-db539d5394cf", + "not_found": true + } + ], + "passed": true, + "source_test_client_sha256": "d2c7f1675d170c241634dadfd28e8fdfd8f5d941065dc212866eb590e8d1852c", + "release_status": "private source-test wheel; public 0.4.0a6 is not fixed" +} diff --git a/.agents/evidence/verify/skill-storage-state/oss-gen1-persistence-ro.json b/.agents/evidence/verify/skill-storage-state/oss-gen1-persistence-ro.json new file mode 100644 index 00000000..544432fc --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/oss-gen1-persistence-ro.json @@ -0,0 +1,69 @@ +{ + "run": "abx-accept-bca1a3a7ad08", + "steps": [ + { + "scenario": "owned private empty OSS bucket created", + "result": "PASS" + }, + { + "scenario": "owned FC trust role scoped only to owned OSS bucket", + "result": "PASS" + }, + { + "scenario": "RW and RO Volume configurations reached AVAILABLE", + "result": "PASS" + }, + { + "scenario": "exact kernel mount and exclusive RW sentinel fsync/read", + "result": "PASS" + }, + { + "scenario": "first sandbox killed and404 verified", + "result": "PASS" + }, + { + "scenario": "fresh recreated sandbox reads persistent sentinel", + "result": "PASS" + }, + { + "scenario": "RO mount reads persisted object and rejects writes", + "result": "PASS" + }, + { + "scenario": "owned sentinel removed from OSS", + "result": "PASS" + } + ], + "cleanup": [ + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "bucket", + "result": "PASS" + }, + { + "kind": "policy-detach", + "result": "PASS" + }, + { + "kind": "policy", + "result": "PASS" + }, + { + "kind": "role", + "result": "PASS" + } + ], + "sources": [ + "https://www.alibabacloud.com/help/zh/agent-sandbox/mount-oss-volume-1", + "https://www.alibabacloud.com/help/zh/agent-sandbox/create-ram-role" + ], + "readonly_error_type": "SandboxException", + "result": "PASS" +} diff --git a/.agents/evidence/verify/skill-storage-state/oss-microvm-mounted-resume.json b/.agents/evidence/verify/skill-storage-state/oss-microvm-mounted-resume.json new file mode 100644 index 00000000..1d1badc8 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/oss-microvm-mounted-resume.json @@ -0,0 +1,73 @@ +{ + "run": "abx-accept-02a351fd8c07", + "steps": [ + { + "scenario": "owned private empty OSS bucket created", + "result": "PASS" + }, + { + "scenario": "owned FC trust role scoped only to owned OSS bucket", + "result": "PASS" + }, + { + "scenario": "RW and RO Volume configurations reached AVAILABLE", + "result": "PASS" + }, + { + "scenario": "exact kernel mount and exclusive RW sentinel fsync/read", + "result": "PASS" + }, + { + "scenario": "first sandbox killed and404 verified", + "result": "PASS" + }, + { + "scenario": "fresh recreated sandbox reads persistent sentinel", + "result": "PASS" + }, + { + "scenario": "pause/resume preserves mounted data, exact mount, process memory and HTTP service", + "result": "PASS" + }, + { + "scenario": "RO mount reads persisted object and rejects writes", + "result": "PASS" + }, + { + "scenario": "owned sentinel removed from OSS", + "result": "PASS" + } + ], + "cleanup": [ + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "bucket", + "result": "PASS" + }, + { + "kind": "policy-detach", + "result": "PASS" + }, + { + "kind": "policy", + "result": "PASS" + }, + { + "kind": "role", + "result": "PASS" + } + ], + "sources": [ + "https://www.alibabacloud.com/help/zh/agent-sandbox/mount-oss-volume-1", + "https://www.alibabacloud.com/help/zh/agent-sandbox/create-ram-role" + ], + "readonly_error_type": "SandboxException", + "result": "PASS" +} diff --git a/.agents/evidence/verify/skill-storage-state/oss-pytest-final.json b/.agents/evidence/verify/skill-storage-state/oss-pytest-final.json new file mode 100644 index 00000000..aa27a7a3 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/oss-pytest-final.json @@ -0,0 +1,77 @@ +{ + "run": "abx-accept-3e45c39dacb0", + "steps": [ + { + "scenario": "owned private empty OSS bucket created", + "result": "PASS" + }, + { + "scenario": "owned FC trust role scoped only to owned OSS bucket", + "result": "PASS" + }, + { + "scenario": "RW and RO Volume configurations reached AVAILABLE", + "result": "PASS" + }, + { + "scenario": "repository opt-in pytest real OSS persistence/RO acceptance", + "result": "PASS" + }, + { + "scenario": "exact kernel mount and exclusive RW sentinel fsync/read", + "result": "PASS" + }, + { + "scenario": "first sandbox killed and404 verified", + "result": "PASS" + }, + { + "scenario": "fresh recreated sandbox reads persistent sentinel", + "result": "PASS" + }, + { + "scenario": "RO mount reads persisted object and rejects writes", + "result": "PASS" + }, + { + "scenario": "owned sentinel removed from OSS", + "result": "PASS" + } + ], + "cleanup": [ + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "bucket", + "result": "PASS" + }, + { + "kind": "policy-detach", + "result": "PASS" + }, + { + "kind": "policy", + "result": "PASS" + }, + { + "kind": "role", + "result": "PASS" + } + ], + "sources": [ + "https://www.alibabacloud.com/help/zh/agent-sandbox/mount-oss-volume-1", + "https://www.alibabacloud.com/help/zh/agent-sandbox/create-ram-role" + ], + "pytest": { + "exit": 0, + "output": "... [100%]\n3 passed in 17.33s\n" + }, + "readonly_error_type": "SandboxException", + "result": "PASS" +} diff --git a/.agents/evidence/verify/skill-storage-state/remote-mcp-first-patch-pass.json b/.agents/evidence/verify/skill-storage-state/remote-mcp-first-patch-pass.json new file mode 100644 index 00000000..b27168b0 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/remote-mcp-first-patch-pass.json @@ -0,0 +1,58 @@ +{ + "checks": [ + { + "id": "remote-unauthorized-denied", + "result": "PASS", + "status": 401 + }, + { + "id": "separate-client-sessions", + "result": "PASS" + }, + { + "id": "remote-tool-discovery", + "result": "PASS", + "tool_count": 15 + }, + { + "id": "remote-cloud-create-command-isolation", + "result": "PASS", + "sandbox_ids": [ + "sbx-90d2038b-d61b-4244-a0ba-16593b0d6a58", + "sbx-03c64e2e-6009-4668-991b-00a503950d06" + ] + }, + { + "id": "same-credential-manual-access", + "result": "PASS", + "scope": "same authority; not distinct-user isolation" + }, + { + "id": "borrower-session-preserves-owner", + "result": "PASS" + }, + { + "id": "remote-session-delete-auto-cleanup", + "result": "PASS" + } + ], + "cleanup": [ + { + "sandbox_id": "sbx-90d2038b-d61b-4244-a0ba-16593b0d6a58", + "session_delete_absence_verified": true + }, + { + "sandbox_id": "sbx-03c64e2e-6009-4668-991b-00a503950d06", + "session_delete_absence_verified": true + }, + { + "host_sandbox_id": "sbx-1184412c-03f8-4e85-a1f4-65d00201f1e5", + "absence_verified": true + } + ], + "scope": "real HTTPS hosted test MCP process in its own sandbox; not a permanent managed production service", + "host_sandbox_id": "sbx-1184412c-03f8-4e85-a1f4-65d00201f1e5", + "source_test_client_sha256": "e392045f50b39fd3ba094dbbb6df16d687e47f4e912cbc4ca61ae42622c31587", + "release_status": "private source-test wheel; public 0.4.0a6 is not fixed", + "result": "PASS" +} diff --git a/.agents/evidence/verify/skill-storage-state/remote-mcp-public-a6-failure.json b/.agents/evidence/verify/skill-storage-state/remote-mcp-public-a6-failure.json new file mode 100644 index 00000000..62695af0 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/remote-mcp-public-a6-failure.json @@ -0,0 +1,46 @@ +{ + "checks": [ + { + "id": "remote-unauthorized-denied", + "result": "PASS", + "status": 401 + }, + { + "id": "separate-client-sessions", + "result": "PASS" + }, + { + "id": "remote-tool-discovery", + "result": "PASS", + "tool_count": 15 + }, + { + "id": "remote-cloud-create-command-isolation", + "result": "PASS", + "sandbox_ids": [ + "sbx-51609a4c-c9cc-4329-ad8b-71ce5997f47b", + "sbx-645a1cd3-964f-4c61-af6a-29e422ada16c" + ] + }, + { + "id": "same-credential-manual-access", + "result": "PASS", + "scope": "same authority; not distinct-user isolation" + }, + { + "id": "remote-mcp-workflow", + "result": "FAIL", + "error_type": "SandboxNotFoundException", + "message": "Sandbox sbx-51609a4c-c9cc-4329-ad8b-71ce5997f47b not found" + } + ], + "cleanup": [ + { + "host_sandbox_id": "sbx-a823ca63-299e-4c84-bb21-51f7189b1a74", + "absence_verified": true + } + ], + "scope": "real HTTPS hosted test MCP process in its own sandbox; not a permanent managed production service", + "host_sandbox_id": "sbx-a823ca63-299e-4c84-bb21-51f7189b1a74", + "result": "FAIL" +} diff --git a/.agents/evidence/verify/skill-storage-state/remote-mcp-v3-final-pass.json b/.agents/evidence/verify/skill-storage-state/remote-mcp-v3-final-pass.json new file mode 100644 index 00000000..20458b2d --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/remote-mcp-v3-final-pass.json @@ -0,0 +1,75 @@ +{ + "checks": [ + { + "id": "gateway-no-access-token-denied", + "result": "PASS", + "status": 403 + }, + { + "id": "remote-unauthorized-denied", + "result": "PASS", + "status": 401 + }, + { + "id": "separate-client-sessions", + "result": "PASS" + }, + { + "id": "remote-tool-discovery", + "result": "PASS", + "tool_count": 15 + }, + { + "id": "remote-cloud-create-command-isolation", + "result": "PASS", + "sandbox_ids": [ + "sbx-234e6d36-a897-476b-b65c-f4aeb30e4e42", + "sbx-b733829d-5f61-43f1-9d68-601ada256927", + "sbx-7e59ec45-be37-4e37-8358-ff9c5943d1b1" + ] + }, + { + "id": "same-credential-manual-access", + "result": "PASS", + "scope": "same authority; not distinct-user isolation" + }, + { + "id": "borrower-session-preserves-owner", + "result": "PASS" + }, + { + "id": "remote-session-delete-auto-cleanup", + "result": "PASS" + }, + { + "id": "remote-qualified-task-reconnect", + "result": "PASS", + "sandbox_id": "alicloud-express:sbx-7e59ec45-be37-4e37-8358-ff9c5943d1b1", + "state": "succeeded", + "exit_code": 0 + } + ], + "cleanup": [ + { + "sandbox_id": "sbx-234e6d36-a897-476b-b65c-f4aeb30e4e42", + "session_delete_absence_verified": true + }, + { + "sandbox_id": "sbx-b733829d-5f61-43f1-9d68-601ada256927", + "session_delete_absence_verified": true + }, + { + "sandbox_id": "sbx-7e59ec45-be37-4e37-8358-ff9c5943d1b1", + "explicit_task_cleanup_absence_verified": true + }, + { + "host_sandbox_id": "sbx-d9fd5d92-e93f-40b0-b722-70856188e959", + "absence_verified": true + } + ], + "scope": "real HTTPS hosted test MCP process in its own sandbox; not a permanent managed production service", + "host_sandbox_id": "sbx-d9fd5d92-e93f-40b0-b722-70856188e959", + "source_test_client_sha256": "e6f803f0e367381f62e968d6103616b9be9c35ed9fc8772289b93ca41b3a6d43", + "release_status": "private source-test wheel; public 0.4.0a6 is not fixed", + "result": "PASS" +} \ No newline at end of file diff --git a/.agents/evidence/verify/skill-storage-state/snapshot-mount-override-rejected.json b/.agents/evidence/verify/skill-storage-state/snapshot-mount-override-rejected.json new file mode 100644 index 00000000..cc524bdf --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/snapshot-mount-override-rejected.json @@ -0,0 +1,99 @@ +{ + "steps": [ + { + "scenario": "owned source sandbox installed official pinned ossfs2 deb with SHA256 verified", + "result": "PASS", + "stdout": "/tmp/abx-owned-ossfs2.deb: OK\n/usr/local/bin/ossfs2\nossfs2\t2.0.10" + }, + { + "scenario": "snapshot ready as owned prepared gen2 fixture", + "result": "PASS" + } + ], + "cleanup": [ + { + "kind": "source-sandbox", + "deleted": true + }, + { + "kind": "fixture-snapshot", + "deleted": true + } + ], + "mount_subprocess_exit": 0, + "mount": { + "run": "abx-accept-a9265ac52b3a", + "steps": [ + { + "scenario": "owned private empty OSS bucket created", + "result": "PASS" + }, + { + "scenario": "owned FC trust role scoped only to owned OSS bucket", + "result": "PASS" + }, + { + "scenario": "RW and RO Volume configurations reached AVAILABLE", + "result": "PASS" + } + ], + "cleanup": [ + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "volume", + "result": "PASS" + }, + { + "kind": "bucket", + "result": "PASS" + }, + { + "kind": "policy-detach", + "result": "PASS" + }, + { + "kind": "policy", + "result": "PASS" + }, + { + "kind": "role", + "result": "PASS" + } + ], + "sources": [ + "https://www.alibabacloud.com/help/zh/agent-sandbox/mount-oss-volume-1", + "https://www.alibabacloud.com/help/zh/agent-sandbox/create-ram-role" + ], + "failure": { + "type": "SandboxException", + "message": "400: creating from a snapshot does not allow volume or mount overrides", + "frames": [ + { + "file": "mount_acceptance.py", + "line": 34, + "name": "new" + }, + { + "file": "main.py", + "line": 212, + "name": "create" + }, + { + "file": "main.py", + "line": 939, + "name": "_create" + }, + { + "file": "sandbox_api.py", + "line": 272, + "name": "_create_sandbox" + } + ] + }, + "result": "FAIL" + }, + "result": "FAIL" +} diff --git a/.agents/evidence/verify/skill-storage-state/snapshot-owned-directory-diagnostic.json b/.agents/evidence/verify/skill-storage-state/snapshot-owned-directory-diagnostic.json new file mode 100644 index 00000000..2daa9b8a --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/snapshot-owned-directory-diagnostic.json @@ -0,0 +1,67 @@ +{ + "steps": [ + { + "uid": 1000, + "euid": 1000, + "stat": [ + 33188, + 1573971, + 65024, + 1, + 1000, + 1000, + 6, + 1792984865, + 1792984865, + 1792984865 + ], + "data": "source", + "write": "PASS", + "path_kind": "home" + }, + { + "path_kind": "home", + "files_write": "PASS" + }, + { + "uid": 1000, + "euid": 1000, + "stat": [ + 33188, + 2097165, + 65024, + 1, + 1000, + 1000, + 6, + 1792984865, + 1792984865, + 1792984865 + ], + "data": "source", + "write": "PASS", + "path_kind": "tmp" + }, + { + "path_kind": "tmp", + "files_write": "FAIL", + "error": "500: error opening file: open /tmp/abx-owned-3e0485d7: permission denied" + } + ], + "cleanup": [ + { + "kind": "sandbox", + "deleted": true + }, + { + "kind": "sandbox", + "deleted": true + }, + { + "kind": "snapshot", + "deleted": true + } + ], + "new_file_isolation": "PASS", + "same_user_chown_result": "success\n" +} diff --git a/.agents/evidence/verify/skill-storage-state/snapshot-tmp-initial-diagnostic.json b/.agents/evidence/verify/skill-storage-state/snapshot-tmp-initial-diagnostic.json new file mode 100644 index 00000000..993cd034 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/snapshot-tmp-initial-diagnostic.json @@ -0,0 +1,36 @@ +{ + "scenarios": [ + { + "user": null, + "result": "FAIL", + "error": "500: error opening file: open /tmp/abx-owned-snapshot-diagnostic: permission denied" + }, + { + "user": "root", + "result": "PASS", + "source_unchanged": true + }, + { + "user": "user", + "result": "FAIL", + "error": "500: error opening file: open /tmp/abx-owned-snapshot-diagnostic: permission denied" + } + ], + "cleanup": [ + { + "kind": "sandbox", + "deleted": true + }, + { + "kind": "sandbox", + "deleted": true + }, + { + "kind": "snapshot", + "deleted": true + } + ], + "source_before": "uid=1000(user) gid=1000(user) groups=1000(user),27(sudo)\nuser user 644 1000 1000\n/dev/root / ext4 rw,relatime,discard 0 0\n", + "clone_after": "uid=1000(user) gid=1000(user) groups=1000(user),27(sudo)\nuser user 644 1000 1000\n/dev/root / ext4 rw,relatime,discard 0 0\n", + "clone_new_write": "success\n" +} diff --git a/.agents/evidence/verify/skill-storage-state/source-tmp-overwrite.json b/.agents/evidence/verify/skill-storage-state/source-tmp-overwrite.json new file mode 100644 index 00000000..d8a14833 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/source-tmp-overwrite.json @@ -0,0 +1,14 @@ +[ + { + "folder": "/tmp/", + "overwrite": "FAIL", + "error": "500: error opening file: open /tmp/abx-owned-af726c18c61844e18d822e5f3b703a6e: permission denied" + }, + { + "folder": "/home/user/", + "overwrite": "PASS" + }, + { + "cleanup_deleted": true + } +] diff --git a/.agents/evidence/verify/skill-storage-state/tools-github-transfer-failure.json b/.agents/evidence/verify/skill-storage-state/tools-github-transfer-failure.json new file mode 100644 index 00000000..a615f593 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/tools-github-transfer-failure.json @@ -0,0 +1,29 @@ +{ + "checks": [ + { + "name": "prepare-owned-workdir", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "workload-exception", + "passed": false, + "error": "Command exited with code 128 and error:\nCloning into '/home/user/skills-tools-live/repo'...\nfatal: unable to access 'https://github.com/alibaba-agent-sandbox/awesome-templates.git/': Operation too slow. Less than 1 bytes/sec transferred the last 30 seconds\n" + }, + { + "name": "owned-sandbox-cleanup-independent-readback", + "passed": true + } + ], + "resources": [ + { + "id": "sbx-5372c0ed-361d-4f00-97af-be85af2b59d5", + "kind": "sandbox" + } + ], + "scope": "Real SDK-controlled sandbox; actual public repository, MCP npm packages and application-level expiring preview", + "failure": "Command exited with code 128 and error:\nCloning into '/home/user/skills-tools-live/repo'...\nfatal: unable to access 'https://github.com/alibaba-agent-sandbox/awesome-templates.git/': Operation too slow. Less than 1 bytes/sec transferred the last 30 seconds\n", + "sandbox_absent": true +} diff --git a/.agents/evidence/verify/skill-storage-state/tools-pinned-fetch-preview-mcp.json b/.agents/evidence/verify/skill-storage-state/tools-pinned-fetch-preview-mcp.json new file mode 100644 index 00000000..41722f69 --- /dev/null +++ b/.agents/evidence/verify/skill-storage-state/tools-pinned-fetch-preview-mcp.json @@ -0,0 +1,147 @@ +{ + "checks": [ + { + "name": "prepare-owned-workdir", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "public-repository-checkout-pinned", + "passed": true, + "exit_code": 0, + "stdout": "1a8c81f59d22fe10b987ffe5f9b59d15b5ed0ded\n", + "stderr": "From https://github.com/alibaba-agent-sandbox/awesome-templates\n * branch 1a8c81f59d22fe10b987ffe5f9b59d15b5ed0ded -> FETCH_HEAD\nHEAD is now at 1a8c81f docs: record published template acceptance and remaining limits (#234)\n" + }, + { + "name": "repository-app-syntax-test", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "repository-app-start", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "repository-app-business-response", + "passed": true, + "exit_code": 0, + "stdout": "HTTP200 exact node-web response\n", + "stderr": "" + }, + { + "name": "preview-start", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "external-application-signed-preview-valid", + "passed": true, + "status": 200, + "body_sha256": "87d9bb2b82cfe46edb132fc9f2062e8ce5857a5b9ee75353d7d50117d54491fe", + "expiry_seconds": 20, + "contract": "application HMAC; provider gateway-native signing is not asserted" + }, + { + "name": "external-preview-unsigned-and-tampered-rejected", + "passed": true, + "unsigned_status": 403, + "tampered_status": 403 + }, + { + "name": "external-preview-expired-rejected", + "passed": true, + "status": 403 + }, + { + "name": "repository-source-hash-readback", + "passed": true, + "sha256": "4b161d1c09cf94809c48ef88ab1cb79247d76da02a746e17cf0f4b4e76ca966b", + "commit": "1a8c81f59d22fe10b987ffe5f9b59d15b5ed0ded" + }, + { + "name": "mcp-fixture-data", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "third-party-mcp-install-2026.7.10", + "passed": true, + "exit_code": 0, + "stdout": "\nadded 135 packages in 6s\n", + "stderr": "npm warn deprecated glob@10.5.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me\nnpm notice\nnpm notice New major version of npm available! 10.8.2 -> 12.2.1\nnpm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.1\nnpm notice To update run: npm install -g npm@12.2.1\nnpm notice\n" + }, + { + "name": "third-party-mcp-initialize-discover-business-tool-2026.7.10", + "passed": true, + "exit_code": 0, + "stdout": "{\"version\": \"2026.7.10\", \"tool_count\": 14, \"business_output\": {\"sum_of_squares\": 385}, \"protocol\": \"2025-03-26\"}\n", + "stderr": "" + }, + { + "name": "third-party-mcp-log-readback-2026.7.10", + "passed": true, + "exit_code": 0, + "stdout": "Secure MCP Filesystem Server running on stdio\nClient does not support MCP Roots, using allowed directories set from server args: [ '/home/user/skills-tools-live/data' ]\n", + "stderr": "" + }, + { + "name": "third-party-mcp-install-2026.8.31", + "passed": true, + "exit_code": 0, + "stdout": "\nremoved 31 packages, and changed 4 packages in 672ms\n", + "stderr": "" + }, + { + "name": "third-party-mcp-initialize-discover-business-tool-2026.8.31", + "passed": true, + "exit_code": 0, + "stdout": "{\"version\": \"2026.8.31\", \"tool_count\": 14, \"business_output\": {\"sum_of_squares\": 385}, \"protocol\": \"2025-03-26\"}\n", + "stderr": "" + }, + { + "name": "third-party-mcp-log-readback-2026.8.31", + "passed": true, + "exit_code": 0, + "stdout": "Secure MCP Filesystem Server running on stdio\nClient does not support MCP Roots, using allowed directories set from server args: [ '/home/user/skills-tools-live/data' ]\n", + "stderr": "" + }, + { + "name": "cleanup-owned-app-and-preview-processes", + "passed": true, + "exit_code": 0, + "stdout": "", + "stderr": "" + }, + { + "name": "third-party-tools-process-cleanup-check", + "passed": true, + "exit_code": 0, + "stdout": "no MCP server remains\n", + "stderr": "" + }, + { + "name": "owned-sandbox-cleanup-independent-readback", + "passed": true + } + ], + "resources": [ + { + "id": "sbx-e9fd0a4f-4652-4cb1-9c3b-6565311eaa5b", + "kind": "sandbox" + } + ], + "scope": "Real SDK-controlled sandbox; actual public repository, MCP npm packages and application-level expiring preview", + "workload_passed": true, + "sandbox_absent": true +} diff --git a/.agents/notes/proposed/feature/2026-10-26-mcp-borrowed-sandbox-ownership.md b/.agents/notes/proposed/feature/2026-10-26-mcp-borrowed-sandbox-ownership.md new file mode 100644 index 00000000..1ee42f4f --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-26-mcp-borrowed-sandbox-ownership.md @@ -0,0 +1,122 @@ +# Decision: Keep borrowed MCP connections out of shutdown deletion + +Status: proposed + +## Problem + +Real hosted Streamable HTTP acceptance reproduced a destructive ownership bug: +[Issue #139](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/139) tracks the defect. +Session A created a sandbox, session B used its explicit ID with the same account +authority, then deleting B killed A's sandbox. Cached handles did not distinguish +creation ownership from a borrowed connection. Public 0.4.0a6 has this behavior. + +## Decision + +Track manager-created IDs separately from cached handles. Add ownership only +after successful creation. On shutdown, kill only manager-owned instances; +close borrowed connections and explicitly retained background-task handles. +Use provider-qualified IDs for cache, creation ownership, TTL and retained tasks; +an alias reuses an existing canonical handle instead of replacing it. Bare IDs +are accepted only when unambiguous. Mark a manager closed at shutdown. A create +completing afterwards deletes its newly owned instance; a late borrowed connect +only closes its client handle. Neither can repopulate the terminated session. +Remove creation ownership during explicit kill and shutdown. Explicit authorized +`kill_sandbox` remains destructive for both owned and borrowed IDs. + +Update binary-file and MCP lifecycle documentation in both languages to describe +this source correction and distinguish it from public 0.4.0a6. Tests using a +manually constructed owned manager must mark that ownership explicitly. + +## API Design + +No public signatures or MCP tools are added. `SandboxManager` gains private +provider-qualified ownership keys and closed-session guards. Background-task +handles now return the actual `provider:id`; this output representation changes, +so clients must return the complete opaque handle rather than parse a raw ID. +Historical unqualified references remain usable within the documented unique +cache/default-provider scope. This is a cleanup-ownership boundary, not tenant authorization; +sessions sharing account credentials retain the same explicit-access authority. + +## Alternatives considered + +- Kill every cached handle — reproduced the cross-session deletion. +- Reject every explicit external ID — removes legitimate same-authority access. +- Close every handle without deleting owned sandboxes — leaks resources and + changes ordinary synchronous-session cleanup. + +## Dependencies + +The existing Python MCP manager, ProviderSandbox close/kill behavior, STDIO and +Streamable HTTP session finalizers. No new runtime dependencies are introduced. + +## Test Strategy + +Independent adversarial review reproduced cross-provider raw-ID collision and +create/connect completion races after the first ownership patch. Six regressions +fail before the namespace/lifecycle correction and pass afterwards. Three further +task regressions cover cached non-default-provider reuse, ambiguous bare-ID +refusal, late-connect submission, and provider-preserving reconnect handles. + +A two-manager regression must fail on the old implementation: borrower shutdown +closes the borrowed handle without killing it, the owner stays usable, then owner +shutdown kills its own creation. Preserve explicit borrowed-kill behavior, binary +file tools, retained background tasks and full existing MCP tests. Build private +source wheels for real hosted HTTP revalidation; public 0.4.0a6 is not relabeled +as fixed by those private test artifacts. + +## Acceptance criteria + +- Borrower close/HTTP DELETE never implicitly kills another manager's creation. +- Owner cleanup, explicit borrowed kill and retained-task cleanup remain correct. +- Identical raw IDs from separate providers cannot change ownership, and aliases + cannot replace the original owned handle. +- Lifecycle work completing after shutdown is cleaned up without registering state. +- Regression fails before the change and passes afterwards. +- Hosted real-cloud regression, repository checks, bilingual documentation and + sanitized cleanup evidence are recorded before completion is claimed. + +## Evidence + +- [State/storage and local regression evidence](../../../evidence/verify/2026-10-26-skill-storage-state-acceptance.md) + +## Explicit resource verification + +[Issue #140](https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/140) +tracks a separate creation acceptance defect found in the same campaign. MCP +accepted explicit CPU/memory overrides without reading their actual values. +The new resource helper refreshes platform metadata, rejects unknown or mismatched +values, rolls back only the new provider-qualified resource, and independently +checks absence even if the deletion response failed. A non-confirmed deletion +retains a recovery handle. Direct SDK creation remains unchanged. Four regression +scenarios (five parametrized cases) failed before the helper and pass afterwards. +Real base-template acceptance observes 2 CPU / 2048 MiB: a requested 3 / 2304 was +previously reported successful, then the corrected handler rejects and deletes +only the new sandbox; the matching request succeeds and an existing sandbox +continues executing. All four campaign sandboxes have independent not-found +readbacks. This correction adds no dependencies or tool names. + +Independent red-team review found that the first resource helper's cleanup read +lost per-create regional endpoint context after removing the cached handle. A +wrong-region 404 could falsely confirm deletion. The final helper freezes the +created instance's resolved TransportConfig, builds a fresh infrastructure using +the actual provider, reads the exact raw sandbox ID, and closes that fresh client. +Only get_info's not-found is evidence of absence; infrastructure setup/close +failures remain unknown with a recovery handle. The regional routing regression +failed before correction. Separate setup/query/close tests enforce that boundary. +The final affected unit suite passes 279 cases; maintained real lifecycle/resource/ +task acceptance passes 5. The real matching/mismatch campaign was repeated after +this correction, including independent absence of all four newly owned resources. + +Final parent/independent review extended the same identity boundary across both +resource verification and rollback. Metadata awaits can overlap session shutdown +or replacement of a cached handle. Two rollback regressions failed before the +correction (missing cache selected the default endpoint; replacement selected a +different handle). Two initial-verification regressions also failed when a +replacement/default-endpoint read happened to report the requested resources. +The final helper reads only the captured new handle's refresh_info, removes local +entries only while that exact object still owns them, and kills only that captured +handle. It never resolves the rollback by ID through the manager. Cleanup still +uses the frozen control-plane configuration and independent target get_info. +The resulting affected suite passes 283 cases; all original before-fail cases +remain documented. This does not expand the contract to hostile host code +replacing the provider registry implementation while the server is running. diff --git a/.agents/notes/proposed/process/2026-10-26-desktop-handover-acceptance.md b/.agents/notes/proposed/process/2026-10-26-desktop-handover-acceptance.md new file mode 100644 index 00000000..85efc919 --- /dev/null +++ b/.agents/notes/proposed/process/2026-10-26-desktop-handover-acceptance.md @@ -0,0 +1,32 @@ +# Decision: verify optional desktop control through observable handover effects +Status: proposed + +## Problem + +A desktop feature claim needs actual screenshots, input effects, stream authentication, control transfer, replay and cleanup. A launch acknowledgement, public HTML or an RFB banner cannot establish those effects. A synthetic controller must not be reported as a real human login. + +## Decision + +Maintain a separate optional real-cloud compatibility fixture using the pinned official Desktop SDK and image. The fixture creates only its own sandbox, independently checks resources, drives a deterministic local application, transfers control to a separate VNC client, returns control to the original agent and downloads the resulting state. Record actual screenshot timestamps and hashes with an offline player. Enforce private public-facing ports using the traffic token, retain envd and VNC credential separation, and verify rejected and accepted access. Preserve initial failed attempts and exact cleanup evidence. + +## API Design + +No project client or MCP API changes. The optional runner accepts `--template`, `--output-dir` and `--browser-executable`; it refuses to overwrite evidence. Its caller-supplied template remains caller-owned. Optional fully pinned dependencies are isolated in `e2e/desktop-requirements.txt`. + +## Alternatives considered + +- Declare support from a provider feature or launch response: rejected because the requested observable work was not established. +- Treat public HTML or an RFB greeting as authenticated streaming: rejected; actual frames and independent application changes are required. +- Claim a full video or human acceptance from automated screenshots: rejected; the report explicitly labels the simulated operator and screenshot timeline. + +## Dependencies + +Official Desktop image `v0.0.44`, real same-region template/resources, `e2b-desktop==2.4.1`, `e2b==2.59.0`, Playwright 1.58.0, aiohttp 3.13.4 and the recorded full optional dependency set. A local Chromium executable is required before cloud creation. + +## Implementation + +New `e2e/desktop_acceptance.py`, exact optional requirements and two HTML fixtures; English/Chinese desktop acceptance guides; a linked `agent-sandbox-sdk/references/desktop-workflows.md` instruction. The proxy is a loopback acceptance fixture, not a hosted end-user authorization service. Public controls, private ingress and VNC passwords remain separate. + +## Verification + +[Real cloud evidence](../../../evidence/verify/2026-10-26-desktop-handover.md) records passing private HTTP/WS ingress tests, actual frames, wrong VNC password rejection, independent operator/agent state, actual screenshot playback and typed absence for all 11 test-owned desktop sandboxes. The single owned template was deleted and independently returned management HTTP 404. Scoped formatting/lint checks passed; complete SDK and remote gates remain separately recorded. The record stays proposed until accepted and merged. diff --git a/.agents/notes/proposed/process/2026-10-26-skill-storage-state-acceptance.md b/.agents/notes/proposed/process/2026-10-26-skill-storage-state-acceptance.md new file mode 100644 index 00000000..711f9bd8 --- /dev/null +++ b/.agents/notes/proposed/process/2026-10-26-skill-storage-state-acceptance.md @@ -0,0 +1,70 @@ +# Decision: Verify storage and retained sandbox state with real effects + +Status: proposed + +## Problem + +A Skill description or registered operation cannot prove persistent mounts, +process-memory preservation, Snapshot clone isolation or conversation reuse. The +initial Snapshot test also placed its overwrite marker directly under sticky +`/tmp`; the tested envd image rejects that same SDK overwrite before any Snapshot. + +## Decision + +Add a standard-client, explicit-owned-fixture OSS acceptance test covering an +exact kernel mount, exclusive fsynced sentinel, sandbox destruction/recreation, +read-only access and typed cleanup. Keep the Snapshot overwrite marker inside an +owned non-sticky directory so Snapshot isolation and the independent envd `/tmp` +overwrite failure have separate evidence. Document the exact opt-in prerequisites +and retain initial failures as well as subsequent real-cloud passes. + +The additional local acceptance uses only newly owned buckets, scoped RAM +roles/policies, Volume definitions, tagged sandboxes and Snapshots, with cleanup. +MicroVM mounts require a suitable ossfs2 runtime. The documented append endpoint +is a backend check, not a newly registered Provider operation. LangGraph reuse is +a real framework/cloud boundary check without model execution. + +## API Design + +N/A — this decision does not involve API changes. + +## Alternatives considered + +- Metadata-only Volume creation — cannot prove a mount or persisted data. +- Chmod or root-write workaround in the Snapshot regression — would conceal the + separate envd behavior and weaken the expected ordinary-user isolation check. +- Selecting an arbitrary existing Volume — unnecessary risk and unrepeatable + ownership; explicit owned RW/RO fixtures are required. + +## Dependencies + +The existing standard Python client, pytest suite and cloud credentials. The +opt-in test requires two owned OSS Volume definitions pointing to one dedicated +storage path, in the API key's Team/region, and an eligible runtime. The manual +fixture provisioning additionally used official FC/RAM SDKs and oss2; these are +not added as SDK runtime dependencies. + +## Test Strategy + +Run the existing real pause/resume and Snapshot tests, the new opt-in OSS test, +and real framework session reuse/isolation. Observe random in-memory service +state across pause and Snapshot restoration. Prove persisted bytes only after +killing the first sandbox and creating a distinct second one. Verify read-only +write rejection, marker ownership and cleanup. Keep full repository checks +separate from these scoped cloud results. + +## Acceptance criteria + +- Standard Snapshot clone can read retained files and the in-memory service value; + ordinary-user overwrite changes the clone without changing its source. +- Owned OSS data persists after actual sandbox deletion and recreation; a distinct + read-only mount can read it and rejects a write. +- Mounted MicroVM pause/resume retains the exact mount, file and process-memory + service value; required image preparation is recorded. +- Framework conversation reuse and distinct-user isolation use real sandboxes. +- Initial failures, skipped checks, cleanup and unrun repository gates remain + explicit in the verification evidence. + +## Evidence + +- [State/storage acceptance](../../../evidence/verify/2026-10-26-skill-storage-state-acceptance.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c31684..4992c410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- Normalize Python MCP background-task `sandbox_id` handles to the actual `provider:id`. Return the opaque handle unchanged when querying/cancelling; callers parsing a raw ID must update. Legacy raw IDs still resolve within a unique lifecycle cache or the SDK's default-provider scope. / Python MCP 后台任务的 `sandbox_id` 句柄规范为实际 `provider:id`;查询和取消须原样回传,拆分原始 ID 的调用者需要更新。历史原始 ID 仍可在无歧义的生命周期缓存或 SDK 默认提供方范围内解析。 + +- Add an optional real desktop acceptance runner with isolated pinned dependencies, private ingress, screenshot/input, user handover and replay documentation. / 新增可选真实桌面验收运行器、隔离固定依赖及私有入口、画面输入、用户接管和回放说明。 + +- Add opt-in standard-client acceptance for owned OSS persistence across sandbox recreation and read-only enforcement; keep Snapshot mutation inside an owned non-sticky directory and preserve real mounted pause/resume evidence. / 新增标准客户端自有 OSS 跨沙箱重建持久化及只读约束验收;快照修改使用自有非 sticky 目录,并保留真实挂载暂停恢复证据;仅新增验收夹具。 + - Correct bilingual environment guidance for ProviderSandbox ownership, Express terminal-only env flags and authenticated application environment calls; retain provider-specific interpreter and exec initialization contracts. Documentation only, without runtime or version changes. / 修正双语环境说明,明确 ProviderSandbox 资源归属、Express 终端环境参数和带认证的应用环境调用,保留提供方解释器与 exec 初始化契约;仅改文档,不改运行行为或版本。 - Clarify CLI-started application connection versus Python-owned prepared-reference startup in both template guides; correct the SDK Skill's specification number to 5. No runtime, API or package version change. / 双语模板指南区分 CLI 启动后连接与 Python 根据已准备引用启动,避免重复启动同一个服务;SDK 技能的规范编号更正为 5,不改运行行为、接口或版本。 @@ -18,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Verify explicit Python MCP CPU/memory requests against actual platform metadata before success; reject unknown/mismatched resources, roll back only the new sandbox and report independently verified cleanup or a recovery handle. Direct SDK creation is unchanged. / Python MCP 对显式 CPU/内存请求独立核验平台实际配置,无法核验或不匹配时仅回滚新建沙箱,返回清理确认或恢复句柄;直接 SDK 创建行为不变。 + +- Track Python MCP sandbox creation ownership separately from borrowed connections. Closing a borrowing session now closes its client handle without deleting another session's sandbox; explicit kills and retained-task behavior remain supported. Correct the Provider Skill to use `template.prepare` instead of the CLI-rejected `templates.deploy` feature. / Python MCP 分开记录沙箱创建归属与借用连接;关闭借用会话不再删除其他会话的沙箱,保留显式删除及后台任务保留行为;Provider 技能改用 `template.prepare`,删除 CLI 拒绝的 `templates.deploy` 特性声明。 + - Correct current bilingual API/lifecycle examples to use actual base commands/files, canonical Provider parameters and typed timeout cleanup; remove unimplemented global cleanup and speculative persistence/billing guarantees. / 修正当前双语 API/生命周期示例,使用真实 base 命令/文件、规范 Provider 参数与类型化超时清理;删除未实现全局策略及推测的持久化/计费承诺。 - Correct four remaining public Sandbox class-help imports, preserving the distinct standard and interpreter entries. Audit all shipped Python docstrings and maintained Markdown/Skills; retain explicit third-party and migration examples. Prepare lockstep client/server 0.4.0a6 without runtime/API changes. / 修正四处剩余沙箱类帮助导入,区分标准入口与解释器入口;全量审查源码文档及维护中的 Markdown/技能,保留明确的第三方与迁移示例;准备同版本双包 0.4.0a6,运行行为与 API 不变。 diff --git a/agent-sandbox-provider/SKILL.md b/agent-sandbox-provider/SKILL.md index a1e08f95..12517ac4 100644 --- a/agent-sandbox-provider/SKILL.md +++ b/agent-sandbox-provider/SKILL.md @@ -94,7 +94,6 @@ files.watch files.watch files.transfer upload_url / download_url templates.read template list templates.build legacy template build -templates.deploy opt-in image deployment via a provider template factory (CLI owned) ports.url network.resolve_url ports.domain a separate domain feature storage.mount volume mount @@ -102,6 +101,11 @@ network.vpc VPC sessions named commands on the in-sandbox server ``` +Do not declare `templates.deploy`: the current CLI rejects that feature id. Image +preparation is the exact `template.prepare` operation in the consumer-specific +`[operations]` contract; declare it only for a transport/consumer that actually +implements preparation. The SDK does not build source images. + Interpreter and workflow calls use the separate exact `[operations]` contract, with `operation_contract_version = 1` and explicit `cli`/`python` arrays. `code.interpret` is not an accepted feature id. Declare `code.execute`, `code.default-context.execute` or context/stream operations only for implemented consumer paths. Python exec/rest bridges do not implement interpreter operations. `[workflow]` declares supported input/startup kinds; declarations do not implement the transport. See the [shared contract](https://github.com/Alibaba-Agent-Sandbox/python-sdk/tree/main/contracts). Declare a feature only when the backend actually implements that call. A diff --git a/agent-sandbox-sdk/SKILL.md b/agent-sandbox-sdk/SKILL.md index 5971cfac..49992498 100644 --- a/agent-sandbox-sdk/SKILL.md +++ b/agent-sandbox-sdk/SKILL.md @@ -11,6 +11,8 @@ This file is instructions for a coding agent. It is not executed by the SDK. Operating a sandbox with `abx` or MCP is the `agent-sandbox` skill. This skill only covers the code you write. +For optional desktop/browser control, simulated operator handover and screenshot replay, read [desktop workflows](references/desktop-workflows.md) before writing the integration. + Two packages share one version: | Package | Import | Where it runs | @@ -98,6 +100,11 @@ This binding preserves the provider gateway's headers and full URL/query. Explic - `sb.commands.run` wraps an unquoted pipe, redirect, or `&&` in `sh -c`. A variable, backtick, or glob still needs an explicit `sh -c '...'`. Its `timeout` defaults to 60 seconds. `send_signal` accepts only 15 (SIGTERM) and 9 (SIGKILL). - `sb.run_code(...)` requires the exact provider operation: `code.execute` for stateless mode, `code.default-context.execute` for explicit `mode="default-context"`, or `code.context.execute` for a context ID. An installed interpreter package does not implement these operations. Native `alicloud-express` currently refuses interpreter calls; use the supported standard `agent_sandbox.code_interpreter` client for its separate backend contract. - Files on `sb.files`: `write`, `read`, `read_bytes`, `list`, `exists`, `remove`, `make_dir`, `upload`, `download`. On Express, `sb.files.upload_url` and `sb.files.download_url` return an envd URL that still needs header `X-Access-Token`. A provider that does not declare `files.transfer` raises `E8004`. `sb.upload_url` and `sb.download_url` always refuse. Do not invent a signature-only URL. + When the user leaves the path open, use an owned task directory for files that + will be overwritten. The tested Express envd image denied SDK overwrites + directly under sticky `/tmp`, independently of Snapshots; an owned non-sticky + subdirectory passed. Preserve specified paths and report permission failures. + - A ProviderSandbox context manager kills the sandbox. For a newly created instance use `kill()` and `close()` in cleanup; for a borrowed connection use only `close()` unless deletion was authorized. - ProviderSandbox defaults to alicloud-express. Use its configured provider transport; do not hardcode a local engine socket or spawn a separate provider implementation. - The only built-in provider is `alicloud-express`. `opensandbox`, `alicloud-container`, `cubesandbox`, and `e2b` are not part of this package. Install them from the catalog with `python -m agent_sandbox.providers install registry: --yes`, then pass that id to `Sandbox.create`. Their notes are in `awesome-providers//docs/README.md`. Native ProviderSandbox reads canonical `ABX_SANDBOX_API_KEY`; only the standard entry maps `E2B_*` settings. diff --git a/agent-sandbox-sdk/references/desktop-workflows.md b/agent-sandbox-sdk/references/desktop-workflows.md new file mode 100644 index 00000000..a51a8fba --- /dev/null +++ b/agent-sandbox-sdk/references/desktop-workflows.md @@ -0,0 +1,11 @@ +# Desktop and browser interaction + +Use the separately pinned optional `e2b_desktop.Sandbox` workflow in the [desktop acceptance guide](../../docs/en/guide/desktop-acceptance.md). Confirm the official image, same region, template readiness and actual resources before launching. Load `E2B_API_KEY`, `E2B_API_URL` and `E2B_DOMAIN` through local configuration; retain only the test-owned sandbox ID. + +1. Create a disposable desktop with explicit lifetime, `secure=True` and `network={"allow_public_traffic": False}`. The verified fixture uses `runtime/desktop:v0.0.44`, 4 CPU, 8192 MB and 1024×768. +2. Capture and inspect the actual screenshot before mouse/keyboard actions. Chrome banners and window decorations can change coordinates. Independently query a resulting file or application state; an accepted key event does not prove the requested work happened. +3. Start the stream with `require_auth=True`. Keep `Sandbox.traffic_access_token` in a controlled backend and inject it as `e2b-traffic-access-token` on HTTP and WebSocket requests. `X-Access-Token` authenticates SDK control operations; the VNC password is a third credential. Verify absent/wrong credentials are rejected and the authorized stream actually renders frames. +4. Pause agent input before handing control to the authorized operator. Verify the operator's change through an independent application observation before returning control to the agent. State whether the operator was real or simulated; never claim a user login was tested using synthetic text. +5. Preserve timestamped actual screenshots, actions, hashes and delivered outputs. A screenshot timeline is not full-motion video. Stop the stream and delete only test-owned sandboxes; confirm absence independently. Borrowed desktops and caller-owned templates keep their original owner. + +The reusable runner and fixed optional dependencies live in `e2e/desktop_acceptance.py` and `e2e/desktop-requirements.txt`. Its acceptance scope and raw results remain explicit; it does not add desktop methods to the project client or its MCP tool catalog. diff --git a/client-sdk/src/agent_sandbox/agent/create_resources.py b/client-sdk/src/agent_sandbox/agent/create_resources.py new file mode 100644 index 00000000..898c417e --- /dev/null +++ b/client-sdk/src/agent_sandbox/agent/create_resources.py @@ -0,0 +1,102 @@ +"""Verify explicit MCP resource requests against independent platform metadata.""" + +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +import httpx + +from agent_sandbox.models.errors import ProviderResourceNotFoundError, SandboxNotFoundError +from agent_sandbox.transport.config import TransportConfig + +if TYPE_CHECKING: + from agent_sandbox.agent.mcp import SandboxManager + + +async def verify_created_resources( + manager: SandboxManager, + sandbox: Any, + requested: dict[str, int], +) -> dict[str, Any] | None: + """Reject unverified overrides and reconcile deletion of only this new resource.""" + if not requested: + return None + sandbox_id = manager._sandbox_key(sandbox) + config = getattr(sandbox, "_config", None) + frozen_config = config.model_copy(deep=True) if isinstance(config, TransportConfig) else None + observed: dict[str, int | None] = {"cpu": None, "memory": None} + try: + info = await sandbox.refresh_info() + for field, attribute in (("cpu", "cpu_count"), ("memory", "memory_mb")): + value = getattr(info, attribute, None) + if type(value) is int and value > 0: + observed[field] = value + if all(observed[field] == value for field, value in requested.items()): + return None + except Exception: + # Provider exceptions can contain URLs, credentials, or private metadata. + pass + + # Metadata awaits can overlap session shutdown. Never resolve this rollback + # again by ID: cache removal could fall back to another endpoint, and a + # replacement handle could belong to different work. + async with manager._state_lock: + if manager._sandboxes.get(sandbox_id) is sandbox: + manager._sandboxes.pop(sandbox_id) + manager._lifetimes.pop(sandbox_id, None) + manager._owned_sandbox_ids.discard(sandbox_id) + manager._retained_task_sandboxes.discard(sandbox_id) + if manager._default_sandbox is sandbox: + manager._default_sandbox = None + # Kill only the captured newly created handle, with its original provider + # connection. A failed response can follow a committed delete. Read back. + with suppress(Exception): + await sandbox.kill() + cleanup_verified = False + with suppress(Exception): + cleanup_verified = await _read_created_metadata(sandbox, frozen_config) + unknown = any(observed[field] is None for field in requested) + result: dict[str, Any] = { + "error": ( + "The created sandbox's requested resources could not be verified." + if unknown + else "The provider did not apply the requested sandbox resources." + ), + "category": "resource_configuration", + "verified": False, + "requested_resources": requested, + "observed_resources": observed, + "sandbox_id": sandbox_id, + "cleanup_verified": cleanup_verified, + "suggestion": ( + "Select or build a template with the required resources, then verify its sandbox " + "metadata. Retry cleanup of this sandbox ID if cleanup_verified is false." + ), + } + if not cleanup_verified: + result["recovery_sandbox_id"] = sandbox_id + return result + + +async def _read_created_metadata(sandbox: Any, config: TransportConfig | None) -> bool: + """Use a fresh control plane with the exact created instance's resolved config.""" + from agent_sandbox.api.sandbox import _InfraCloser + from agent_sandbox.providers.registry import get_provider + + if config is None: + raise RuntimeError("Created sandbox control-plane configuration is unavailable") + infra = await get_provider(sandbox.provider).build_infra(config) + absent = False + try: + try: + await infra.sandbox_protocol.get_info(sandbox.id) + except (SandboxNotFoundError, ProviderResourceNotFoundError): + absent = True + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 404: + raise + absent = True + finally: + await _InfraCloser(infra).close() + return absent diff --git a/client-sdk/src/agent_sandbox/agent/mcp.py b/client-sdk/src/agent_sandbox/agent/mcp.py index e9ff46f1..52ba3d55 100644 --- a/client-sdk/src/agent_sandbox/agent/mcp.py +++ b/client-sdk/src/agent_sandbox/agent/mcp.py @@ -67,6 +67,8 @@ class SandboxManager: - An explicit ``sandbox_id`` addresses that sandbox and does not change the default. - Concurrent callers share one lazy create. + - Session shutdown kills only sandboxes created by this manager. Explicit-ID + connections are borrowed and only their client handles are closed. """ def __init__( @@ -81,11 +83,37 @@ def __init__( self._domain = sandbox_domain self._default_template = template self._default_sandbox: Any = None # Sandbox instance - self._sandboxes: dict[str, Any] = {} # sandbox_id -> Sandbox + self._sandboxes: dict[str, Any] = {} # sandbox_id -> cached Sandbox handle + self._owned_sandbox_ids: set[str] = set() self._lifetimes: dict[str, int] = {} # sandbox_id -> timeout seconds self._retained_task_sandboxes: set[str] = set() self._state_lock = asyncio.Lock() self._default_lock = asyncio.Lock() + self._closed = False + + @staticmethod + def _sandbox_key(sandbox: Any) -> str: + """Keep provider identity when different backends reuse a raw ID.""" + provider = getattr(sandbox, "provider", None) + return ( + f"{provider}:{sandbox.id}" + if isinstance(provider, str) and provider + else str(sandbox.id) + ) + + def _require_open(self) -> None: + if self._closed: + raise _ToolInputError("MCP session is closed.") + + def _cached_sandbox(self, reference: str) -> Any: + """Resolve an exact reference or a unique bare ID while holding the state lock.""" + cached = self._sandboxes.get(reference) + if cached is not None or ":" in reference: + return cached + matches = [sb for sb in self._sandboxes.values() if sb.id == reference] + if len(matches) > 1: + raise _ToolInputError("Ambiguous sandbox ID; qualify it with its provider.") + return matches[0] if matches else None def _connect_kwargs(self, **extra: Any) -> dict[str, Any]: """Build SDK kwargs shared by create, connect, and kill.""" @@ -110,7 +138,7 @@ async def _refresh_lifetime(self, sandbox: Any) -> None: if setter is None: return sandbox_id = getattr(sandbox, "id", "") - timeout = self._lifetimes.get(sandbox_id, 300) + timeout = self._lifetimes.get(self._sandbox_key(sandbox), 300) try: result = setter(int(timeout)) if asyncio.iscoroutine(result) or asyncio.isfuture(result): @@ -133,6 +161,8 @@ async def create_sandbox( provider: str | None = None, ) -> Any: """Create a sandbox and make it the session default.""" + async with self._state_lock: + self._require_open() from agent_sandbox.api.sandbox import Sandbox kwargs = self._connect_kwargs( @@ -150,9 +180,18 @@ async def create_sandbox( kwargs[name] = value sandbox = await Sandbox.create(**kwargs) async with self._state_lock: - self._sandboxes[sandbox.id] = sandbox - self._lifetimes[sandbox.id] = timeout - self._default_sandbox = sandbox + closed = self._closed + if not closed: + key = self._sandbox_key(sandbox) + self._sandboxes[key] = sandbox + self._owned_sandbox_ids.add(key) + self._lifetimes[key] = timeout + self._default_sandbox = sandbox + if closed: + # Shutdown may win while the backend is creating. This instance is + # ours, but must never be registered into the terminated session. + await sandbox.kill() + raise _ToolInputError("MCP session is closed.") logger.info("Created sandbox: %s", sandbox.id) return sandbox @@ -166,7 +205,8 @@ async def get_sandbox( """Get a sandbox by ID, or create/return the default sandbox.""" if sandbox_id: async with self._state_lock: - cached = self._sandboxes.get(sandbox_id) + self._require_open() + cached = self._cached_sandbox(sandbox_id) if cached is not None: if refresh_lifetime: await self._refresh_lifetime(cached) @@ -175,24 +215,40 @@ async def get_sandbox( sb = await Sandbox.connect(**self._connect_kwargs(sandbox_id=sandbox_id)) async with self._state_lock: - self._sandboxes[sb.id] = sb - self._lifetimes.setdefault(sb.id, 300) + closed = self._closed + key = self._sandbox_key(sb) + selected = self._sandboxes.get(key) + if not closed and selected is None: + self._sandboxes[key] = sb + self._lifetimes.setdefault(key, 300) + selected = sb + # An alias or concurrent connect must not overwrite an owned handle. + # A connect completing after shutdown is borrowed and only closed. + if closed or selected is not sb: + await sb.close() + if closed: + raise _ToolInputError("MCP session is closed.") if refresh_lifetime: - await self._refresh_lifetime(sb) - return sb + await self._refresh_lifetime(selected) + return selected # One lazy create even when two tool calls arrive together. async with self._default_lock: - if self._default_sandbox is None: + async with self._state_lock: + self._require_open() + sandbox = self._default_sandbox + if sandbox is None: if not create_default: raise _ToolInputError( "No default sandbox. Call create_sandbox or specify sandbox_id." ) created = await self.create_sandbox() - if self._default_sandbox is None: - self._default_sandbox = created - logger.info("Auto-created default sandbox: %s", self._default_sandbox.id) - sandbox = self._default_sandbox + async with self._state_lock: + self._require_open() + if self._default_sandbox is None: + self._default_sandbox = created + sandbox = self._default_sandbox + logger.info("Auto-created default sandbox: %s", sandbox.id) if refresh_lifetime: await self._refresh_lifetime(sandbox) return sandbox @@ -228,7 +284,7 @@ async def inspect_sandbox(self, sandbox_id: str | None = None) -> Any: """Refresh metadata without creating a sandbox or extending its TTL.""" if sandbox_id: async with self._state_lock: - cached = self._sandboxes.get(sandbox_id) + cached = self._cached_sandbox(sandbox_id) if cached is None: from agent_sandbox.api.sandbox import Sandbox @@ -246,7 +302,7 @@ async def set_sandbox_timeout(self, timeout: int, sandbox_id: str | None = None) """Set the platform TTL and use it for subsequent session refreshes.""" if sandbox_id: async with self._state_lock: - cached = self._sandboxes.get(sandbox_id) + cached = self._cached_sandbox(sandbox_id) if cached is None: from agent_sandbox.api.sandbox import Sandbox @@ -261,9 +317,10 @@ async def set_sandbox_timeout(self, timeout: int, sandbox_id: str | None = None) ) await sandbox.set_timeout(timeout) async with self._state_lock: - managed = self._sandboxes.get(sandbox.id) + key = self._sandbox_key(sandbox) + managed = self._sandboxes.get(key) if managed is not None and managed is sandbox: - self._lifetimes[managed.id] = timeout + self._lifetimes[key] = timeout return str(sandbox.id) async def kill_sandbox(self, sandbox_id: str | None = None) -> str | None: @@ -275,11 +332,15 @@ async def kill_sandbox(self, sandbox_id: str | None = None) -> str | None: """ if sandbox_id: async with self._state_lock: - sb = self._sandboxes.pop(sandbox_id, None) - self._lifetimes.pop(sandbox_id, None) - self._retained_task_sandboxes.discard(sandbox_id) - if self._default_sandbox is not None and self._default_sandbox.id == sandbox_id: - self._default_sandbox = None + sb = self._cached_sandbox(sandbox_id) + if sb is not None: + key = self._sandbox_key(sb) + self._sandboxes.pop(key, None) + self._lifetimes.pop(key, None) + self._retained_task_sandboxes.discard(key) + self._owned_sandbox_ids.discard(key) + if self._default_sandbox is sb: + self._default_sandbox = None if sb is not None: await sb.kill() logger.info("Killed sandbox: %s", sandbox_id) @@ -298,9 +359,11 @@ async def kill_sandbox(self, sandbox_id: str | None = None) -> str | None: sb = self._default_sandbox self._default_sandbox = None if sb is not None: - self._sandboxes.pop(sb.id, None) - self._lifetimes.pop(sb.id, None) - self._retained_task_sandboxes.discard(sb.id) + key = self._sandbox_key(sb) + self._sandboxes.pop(key, None) + self._lifetimes.pop(key, None) + self._retained_task_sandboxes.discard(key) + self._owned_sandbox_ids.discard(key) if sb is None: return None sid = sb.id @@ -316,31 +379,34 @@ async def retain_task_sandbox(self, sandbox: Any) -> bool: """ if sandbox is None: return False - sandbox_id = str(sandbox.id) + key = self._sandbox_key(sandbox) async with self._state_lock: - if self._sandboxes.get(sandbox_id) is not sandbox: + if self._closed or self._sandboxes.get(key) is not sandbox: return False - self._retained_task_sandboxes.add(sandbox_id) + self._retained_task_sandboxes.add(key) return True async def shutdown(self) -> None: - """Clean session sandboxes, preserving only explicitly retained tasks.""" + """Kill owned sandboxes, closing borrowed handles and retained-task handles.""" async with self._state_lock: + self._closed = True items = list(self._sandboxes.items()) retained = self._retained_task_sandboxes.copy() + owned = self._owned_sandbox_ids.copy() + self._owned_sandbox_ids.clear() self._retained_task_sandboxes.clear() self._sandboxes.clear() self._lifetimes.clear() self._default_sandbox = None for sid, sb in items: try: - if sid in retained: + if sid not in owned or sid in retained: await sb.close() continue await sb.kill() logger.info("Shutdown: killed sandbox %s", sid) except Exception: - logger.warning("Failed to kill sandbox %s during shutdown", sid, exc_info=True) + logger.warning("Failed to clean sandbox %s during shutdown", sid, exc_info=True) # --------------------------------------------------------------------------- diff --git a/client-sdk/src/agent_sandbox/agent/tasks.py b/client-sdk/src/agent_sandbox/agent/tasks.py index c923d003..4012f013 100644 --- a/client-sdk/src/agent_sandbox/agent/tasks.py +++ b/client-sdk/src/agent_sandbox/agent/tasks.py @@ -124,7 +124,9 @@ async def start_task(params: dict[str, Any], manager: SandboxManager) -> dict[st "runtime": _RUNTIME, } _command(request) # Validate expansion before even connecting to a sandbox. - temporary = sandbox_id is not None and sandbox_id not in manager._sandboxes + async with manager._state_lock: + manager._require_open() + temporary = sandbox_id is not None and manager._cached_sandbox(sandbox_id) is None if temporary: from agent_sandbox.api.sandbox import Sandbox @@ -134,6 +136,8 @@ async def start_task(params: dict[str, Any], manager: SandboxManager) -> dict[st sandbox_id, refresh_lifetime=False, create_default=False ) try: + async with manager._state_lock: + manager._require_open() try: result = await _operate(sandbox, request, manager) except _ToolInputError: @@ -151,11 +155,11 @@ async def start_task(params: dict[str, Any], manager: SandboxManager) -> dict[st # Qualified provider IDs can connect to a sandbox already owned under # its canonical SDK ID. Retain that original owner, not the temporary # connection, so disconnect cannot destroy accepted background work. - owned = manager._sandboxes.get(sandbox.id) + owned = manager._sandboxes.get(manager._sandbox_key(sandbox)) retained = await manager.retain_task_sandbox(owned) if owned is not None else temporary return { **result, - "sandbox_id": sandbox_id or sandbox.id, + "sandbox_id": manager._sandbox_key(sandbox), "retained_until_sandbox_ttl": retained, } finally: diff --git a/client-sdk/src/agent_sandbox/agent/tools.py b/client-sdk/src/agent_sandbox/agent/tools.py index 8112203b..bd3890f3 100644 --- a/client-sdk/src/agent_sandbox/agent/tools.py +++ b/client-sdk/src/agent_sandbox/agent/tools.py @@ -13,6 +13,7 @@ from agent_sandbox.agent.mcp import SandboxManager from agent_sandbox.models.sandbox import SandboxInfo +from agent_sandbox.agent.create_resources import verify_created_resources from agent_sandbox.utils.logging import get_logger logger = get_logger("agent.tools") @@ -396,6 +397,11 @@ async def handle_create_sandbox( envs=envs, **options, ) + failure = await verify_created_resources( + manager, sandbox, {name: options[name] for name in ("cpu", "memory") if name in options} + ) + if failure is not None: + return failure return { "sandbox_id": sandbox.id, "status": sandbox.status.value, diff --git a/client-sdk/tests/test_agent/test_mcp.py b/client-sdk/tests/test_agent/test_mcp.py index fd0b4e9e..851bdccd 100644 --- a/client-sdk/tests/test_agent/test_mcp.py +++ b/client-sdk/tests/test_agent/test_mcp.py @@ -130,6 +130,7 @@ async def test_shutdown(self): sb2.id = "sb2" sb2.kill = AsyncMock() mgr._sandboxes = {"sb1": sb1, "sb2": sb2} + mgr._owned_sandbox_ids.update({"sb1", "sb2"}) mgr._default_sandbox = sb1 await mgr.shutdown() diff --git a/client-sdk/tests/test_agent/test_mcp_binary_files.py b/client-sdk/tests/test_agent/test_mcp_binary_files.py index 06ec13f6..0b2057f3 100644 --- a/client-sdk/tests/test_agent/test_mcp_binary_files.py +++ b/client-sdk/tests/test_agent/test_mcp_binary_files.py @@ -327,9 +327,7 @@ async def test_bounded_stream_http_error_closes_without_reading_body(status): @pytest.mark.parametrize("tool", ["upload_file", "download_file"]) -async def test_binary_file_tools_preserve_explicit_external_adoption_and_shutdown( - tool, monkeypatch -): +async def test_binary_file_tools_borrow_external_sandbox_until_shutdown(tool, monkeypatch): from agent_sandbox.agent.mcp import SandboxManager files = SimpleNamespace(write=AsyncMock(), read_bytes=AsyncMock(return_value=b"x")) @@ -346,7 +344,8 @@ async def test_binary_file_tools_preserve_explicit_external_adoption_and_shutdow result = await dispatch_tool(tool, args, owner) assert "error" not in result and owner._sandboxes["external"] is sandbox await owner.shutdown() - sandbox.kill.assert_awaited_once() + sandbox.kill.assert_not_awaited() + sandbox.close.assert_awaited_once() async def test_encoded_size_rejection_precedes_base64_allocation(monkeypatch): diff --git a/client-sdk/tests/test_agent/test_mcp_create_resources.py b/client-sdk/tests/test_agent/test_mcp_create_resources.py new file mode 100644 index 00000000..ff92562e --- /dev/null +++ b/client-sdk/tests/test_agent/test_mcp_create_resources.py @@ -0,0 +1,261 @@ +"""MCP creation must verify requested resources before reporting success.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_sandbox.agent.mcp import SandboxManager +from agent_sandbox.agent.tools import dispatch_tool +from agent_sandbox.models.errors import SandboxNotFoundError +from agent_sandbox.models.sandbox import SandboxInfo, SandboxStatus +from agent_sandbox.transport.config import TransportConfig + + +def sandbox(sid): + result = AsyncMock() + result.id = sid + result.provider = "alicloud-express" + result.status = SandboxStatus.RUNNING + result.url = "https://example.invalid" + result._config = TransportConfig(sandbox_base_url="https://example.invalid") + result.refresh_info.return_value = SandboxInfo( + sandbox_id=sid, status=SandboxStatus.RUNNING, cpu_count=2, memory_mb=1024 + ) + return result + + +async def test_matching_resources_require_actual_metadata_read(): + manager = SandboxManager() + created = sandbox("new") + with patch("agent_sandbox.api.sandbox.Sandbox.create", return_value=created): + result = await dispatch_tool("create_sandbox", {"cpu": 2, "memory": 1024}, manager) + assert "error" not in result + created.refresh_info.assert_awaited_once_with() + created.kill.assert_not_awaited() + await manager.shutdown() + + +async def test_mismatch_rolls_back_only_new_sandbox_and_reports_observation(): + manager = SandboxManager() + existing = sandbox("existing") + created = sandbox("new") + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", side_effect=[existing, created]), + patch( + "agent_sandbox.agent.create_resources._read_created_metadata", + return_value=True, + ), + ): + await manager.create_sandbox() + result = await dispatch_tool("create_sandbox", {"cpu": 3, "memory": 1280}, manager) + assert result["category"] == "resource_configuration" + assert result["requested_resources"] == {"cpu": 3, "memory": 1280} + assert result["observed_resources"] == {"cpu": 2, "memory": 1024} + assert result["cleanup_verified"] is True + created.kill.assert_awaited_once_with() + existing.kill.assert_not_awaited() + assert manager._sandboxes == {"alicloud-express:existing": existing} + await manager.shutdown() + + +async def test_unknown_metadata_is_not_success_and_does_not_expose_provider_error(): + manager = SandboxManager() + created = sandbox("new") + created.refresh_info.side_effect = RuntimeError("secret-provider-token") + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", return_value=created), + patch( + "agent_sandbox.agent.create_resources._read_created_metadata", + return_value=True, + ), + ): + result = await dispatch_tool("create_sandbox", {"cpu": 2}, manager) + assert result["category"] == "resource_configuration" + assert result["observed_resources"] == {"cpu": None, "memory": None} + assert result["cleanup_verified"] is True + assert "secret-provider-token" not in str(result) + created.kill.assert_awaited_once_with() + await manager.shutdown() + + +@pytest.mark.parametrize("deleted", [False, True]) +async def test_ambiguous_delete_requires_independent_absence_and_preserves_recovery_id(deleted): + manager = SandboxManager() + created = sandbox("new") + created.kill.side_effect = RuntimeError("secret-delete-response") + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", return_value=created), + patch( + "agent_sandbox.agent.create_resources._read_created_metadata", return_value=deleted + ) as observe, + ): + result = await dispatch_tool("create_sandbox", {"memory": 2048}, manager) + assert result["category"] == "resource_configuration" + assert result["cleanup_verified"] is deleted + assert result["sandbox_id"] == "alicloud-express:new" + if not deleted: + assert result["recovery_sandbox_id"] == "alicloud-express:new" + observe.assert_awaited_once() + assert observe.call_args.args[0] is created + assert observe.call_args.args[1].sandbox_base_url == "https://example.invalid" + assert "secret-" not in str(result) + await manager.shutdown() + + +async def test_cleanup_never_uses_default_endpoint_after_regional_delete_failure(): + manager = SandboxManager() + created = sandbox("new-region") + created._config = TransportConfig( + region="cn-shanghai", + sandbox_base_url="https://shanghai.example.invalid", + sandbox_api_key="private-config-key", + provider_options={"scope": "private-options"}, + ) + created.kill.side_effect = TimeoutError("delete outcome unknown") + fresh_protocol = SimpleNamespace( + get_info=AsyncMock(return_value=created.refresh_info.return_value) + ) + fresh = SimpleNamespace(sandbox_protocol=fresh_protocol, close=AsyncMock()) + provider = SimpleNamespace(build_infra=AsyncMock(return_value=fresh)) + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", return_value=created), + patch( + "agent_sandbox.api.sandbox.Sandbox.get_info_by_id", + side_effect=SandboxNotFoundError("default region has no such sandbox"), + ) as default_read, + patch("agent_sandbox.providers.registry.get_provider", return_value=provider), + ): + result = await dispatch_tool("create_sandbox", {"cpu": 3, "region": "cn-shanghai"}, manager) + assert result["cleanup_verified"] is False + assert result["recovery_sandbox_id"] == "alicloud-express:new-region" + default_read.assert_not_awaited() + observed_config = provider.build_infra.call_args.args[0] + assert observed_config is not created._config + assert observed_config.region == "cn-shanghai" + assert observed_config.sandbox_base_url == "https://shanghai.example.invalid" + assert observed_config.sandbox_api_key == "private-config-key" + assert observed_config.provider_options == {"scope": "private-options"} + fresh_protocol.get_info.assert_awaited_once_with("new-region") + fresh.close.assert_awaited_once_with() + assert "private-" not in str(result) + await manager.shutdown() + + +@pytest.mark.parametrize("phase", ["setup", "query", "close"]) +async def test_non_query_not_found_does_not_claim_sandbox_absence(phase): + from agent_sandbox.agent.create_resources import verify_created_resources + + created = sandbox("new") + manager = SandboxManager() + error = SandboxNotFoundError("not a sandbox read") + fresh = SimpleNamespace( + sandbox_protocol=SimpleNamespace( + get_info=AsyncMock( + return_value=created.refresh_info.return_value, + side_effect=error if phase == "query" else None, + ) + ), + close=AsyncMock(side_effect=error if phase == "close" else None), + ) + provider = SimpleNamespace( + build_infra=AsyncMock(return_value=fresh, side_effect=error if phase == "setup" else None) + ) + with ( + patch("agent_sandbox.providers.registry.get_provider", return_value=provider), + ): + result = await verify_created_resources(manager, created, {"cpu": 3}) + assert result["cleanup_verified"] is (phase == "query") + if phase != "query": + assert result["recovery_sandbox_id"] == "alicloud-express:new" + await manager.shutdown() + + +@pytest.mark.parametrize("replacement", [False, True]) +async def test_rollback_keeps_new_handle_identity_after_metadata_await(replacement): + """Session cleanup/cache replacement cannot redirect deletion to a default endpoint.""" + manager = SandboxManager() + created = sandbox("regional-new") + created._config = TransportConfig( + region="cn-shanghai", sandbox_base_url="https://shanghai.example.invalid" + ) + other = sandbox("regional-new") + key = manager._sandbox_key(created) + + async def metadata_after_concurrent_cleanup(*args): + async with manager._state_lock: + manager._sandboxes.clear() + manager._lifetimes.clear() + manager._owned_sandbox_ids.clear() + manager._default_sandbox = None + if replacement: + manager._sandboxes[key] = other + manager._lifetimes[key] = 900 + manager._owned_sandbox_ids.add(key) + manager._default_sandbox = other + return created.refresh_info.return_value + + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", return_value=created), + patch.object( + created, + "refresh_info", + side_effect=metadata_after_concurrent_cleanup, + return_value=created.refresh_info.return_value, + ), + patch("agent_sandbox.api.sandbox.Sandbox.kill_by_id") as fallback, + patch("agent_sandbox.agent.create_resources._read_created_metadata", return_value=True), + ): + result = await dispatch_tool("create_sandbox", {"cpu": 3, "region": "cn-shanghai"}, manager) + created.kill.assert_awaited_once_with() + other.kill.assert_not_awaited() + fallback.assert_not_awaited() + assert result["cleanup_verified"] is True + if replacement: + assert manager._sandboxes[key] is other + assert manager._lifetimes[key] == 900 + assert key in manager._owned_sandbox_ids + assert manager._default_sandbox is other + else: + assert not manager._sandboxes + await manager.shutdown() + + +@pytest.mark.parametrize("replacement", [False, True]) +async def test_initial_verification_cannot_accept_other_handle_or_default_endpoint(replacement): + manager = SandboxManager() + created = sandbox("regional-new") + other = sandbox("regional-new") + other.refresh_info.return_value.cpu_count = 3 + key = manager._sandbox_key(created) + actual_create = manager.create_sandbox + + async def create_then_concurrent_cleanup(**kwargs): + result = await actual_create(**kwargs) + async with manager._state_lock: + manager._sandboxes.clear() + manager._lifetimes.clear() + manager._owned_sandbox_ids.clear() + manager._default_sandbox = None + if replacement: + manager._sandboxes[key] = other + manager._owned_sandbox_ids.add(key) + return result + + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", return_value=created), + patch.object(manager, "create_sandbox", side_effect=create_then_concurrent_cleanup), + patch( + "agent_sandbox.api.sandbox.Sandbox.get_info_by_id", + return_value=other.refresh_info.return_value, + ) as fallback, + patch("agent_sandbox.agent.create_resources._read_created_metadata", return_value=True), + ): + result = await dispatch_tool("create_sandbox", {"cpu": 3}, manager) + assert result["category"] == "resource_configuration" + assert result["observed_resources"]["cpu"] == 2 + created.refresh_info.assert_awaited_once_with() + other.refresh_info.assert_not_awaited() + fallback.assert_not_awaited() + other.kill.assert_not_awaited() + await manager.shutdown() diff --git a/client-sdk/tests/test_agent/test_mcp_lifecycle.py b/client-sdk/tests/test_agent/test_mcp_lifecycle.py index 4c836f96..eb82dd49 100644 --- a/client-sdk/tests/test_agent/test_mcp_lifecycle.py +++ b/client-sdk/tests/test_agent/test_mcp_lifecycle.py @@ -416,3 +416,139 @@ async def pending_timeout(_seconds): assert manager._sandboxes == {} assert manager._lifetimes == {} assert manager._default_sandbox is None + + +async def test_borrower_shutdown_preserves_another_session_owned_sandbox(): + owner = SandboxManager() + borrower = SandboxManager() + created = _sandbox("shared-authority-sandbox") + borrowed = _sandbox("shared-authority-sandbox") + with ( + patch( + "agent_sandbox.api.sandbox.Sandbox.create", new_callable=AsyncMock, return_value=created + ), + patch( + "agent_sandbox.api.sandbox.Sandbox.connect", + new_callable=AsyncMock, + return_value=borrowed, + ), + ): + await owner.create_sandbox() + assert await borrower.get_sandbox(created.id, refresh_lifetime=False) is borrowed + await borrower.shutdown() + borrowed.close.assert_awaited_once() + borrowed.kill.assert_not_awaited() + created.kill.assert_not_awaited() + assert await owner.get_sandbox(refresh_lifetime=False) is created + await owner.shutdown() + created.kill.assert_awaited_once() + created.close.assert_not_awaited() + + +async def test_explicit_kill_of_borrowed_sandbox_remains_authorized(): + manager = SandboxManager() + borrowed = _sandbox("explicit-external") + with patch( + "agent_sandbox.api.sandbox.Sandbox.connect", new_callable=AsyncMock, return_value=borrowed + ): + await manager.get_sandbox(borrowed.id, refresh_lifetime=False) + assert await manager.kill_sandbox(borrowed.id) == borrowed.id + await manager.shutdown() + borrowed.kill.assert_awaited_once() + borrowed.close.assert_not_awaited() + + +async def test_qualified_borrow_cannot_replace_another_provider_owned_handle(): + manager = SandboxManager() + owned = _sandbox("same-id") + owned.provider = "alicloud-express" + borrowed = _sandbox("same-id") + borrowed.provider = "e2b" + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", AsyncMock(return_value=owned)), + patch("agent_sandbox.api.sandbox.Sandbox.connect", AsyncMock(return_value=borrowed)), + ): + await manager.create_sandbox() + assert await manager.get_sandbox("e2b:same-id", refresh_lifetime=False) is borrowed + await manager.shutdown() + owned.kill.assert_awaited_once() + borrowed.kill.assert_not_awaited() + borrowed.close.assert_awaited_once() + + +async def test_same_raw_id_from_two_providers_keeps_both_owned_handles(): + manager = SandboxManager() + first, second = _sandbox("same-id"), _sandbox("same-id") + first.provider, second.provider = "alicloud-express", "e2b" + with patch("agent_sandbox.api.sandbox.Sandbox.create", AsyncMock(side_effect=[first, second])): + await manager.create_sandbox(provider="alicloud-express") + await manager.create_sandbox(provider="e2b") + with pytest.raises(ValueError, match="qualif"): + await manager.get_sandbox("same-id", refresh_lifetime=False) + await manager.shutdown() + first.kill.assert_awaited_once() + second.kill.assert_awaited_once() + + +async def test_connect_alias_reuses_owned_handle_without_replacing_it(): + manager = SandboxManager() + owned, alias = _sandbox("same-id"), _sandbox("same-id") + owned.provider = alias.provider = "alicloud-express" + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", AsyncMock(return_value=owned)), + patch("agent_sandbox.api.sandbox.Sandbox.connect", AsyncMock(return_value=alias)), + ): + await manager.create_sandbox() + assert await manager.get_sandbox("alias:same-id", refresh_lifetime=False) is owned + await manager.shutdown() + alias.close.assert_awaited_once() + alias.kill.assert_not_awaited() + owned.kill.assert_awaited_once() + + +@pytest.mark.parametrize("operation", ["create", "connect"]) +async def test_lifecycle_completion_after_shutdown_cannot_restore_ownership(operation): + manager = SandboxManager() + late = _sandbox("late-owned" if operation == "create" else "late-borrowed") + late.provider = "alicloud-express" + entered, release = asyncio.Event(), asyncio.Event() + + async def complete_late(**_kwargs): + entered.set() + await release.wait() + return late + + with patch("agent_sandbox.api.sandbox.Sandbox." + operation, complete_late): + pending = asyncio.create_task( + manager.create_sandbox() + if operation == "create" + else manager.get_sandbox(late.id, refresh_lifetime=False) + ) + await entered.wait() + await manager.shutdown() + release.set() + with pytest.raises(ValueError, match="closed"): + await pending + assert not manager._sandboxes and not manager._lifetimes + assert not manager._owned_sandbox_ids and not manager._retained_task_sandboxes + if operation == "create": + late.kill.assert_awaited_once() + late.close.assert_not_awaited() + else: + late.close.assert_awaited_once() + late.kill.assert_not_awaited() + + +async def test_closed_manager_cannot_begin_new_lifecycle_calls(): + manager = SandboxManager() + await manager.shutdown() + with ( + patch("agent_sandbox.api.sandbox.Sandbox.create", AsyncMock()) as create, + patch("agent_sandbox.api.sandbox.Sandbox.connect", AsyncMock()) as connect, + ): + with pytest.raises(ValueError, match="closed"): + await manager.create_sandbox() + with pytest.raises(ValueError, match="closed"): + await manager.get_sandbox("external", refresh_lifetime=False) + create.assert_not_awaited() + connect.assert_not_awaited() diff --git a/client-sdk/tests/test_agent/test_mcp_tasks.py b/client-sdk/tests/test_agent/test_mcp_tasks.py index fe18e215..8338a635 100644 --- a/client-sdk/tests/test_agent/test_mcp_tasks.py +++ b/client-sdk/tests/test_agent/test_mcp_tasks.py @@ -54,9 +54,11 @@ async def run(command, **kwargs): def owner(sandbox): manager = SandboxManager() - manager._sandboxes[sandbox.id] = sandbox + key = manager._sandbox_key(sandbox) + manager._sandboxes[key] = sandbox + manager._owned_sandbox_ids.add(key) manager._default_sandbox = sandbox - manager._lifetimes[sandbox.id] = 300 + manager._lifetimes[key] = 300 return manager @@ -452,3 +454,68 @@ async def test_qualified_handle_retains_existing_canonical_owner(local_sandbox): local_sandbox.kill.assert_not_awaited() handle = {k: started[k] for k in ("sandbox_id", "task_id")} assert (await terminal(handle, SandboxManager()))["stdout"] == "qualified\n" + + +async def test_explicit_bare_task_id_reuses_qualified_cached_owner(local_sandbox, monkeypatch): + local_sandbox.provider = "e2b" + manager = owner(local_sandbox) + + connect = AsyncMock(side_effect=AssertionError("owned provider must reuse its cached handle")) + monkeypatch.setattr("agent_sandbox.api.sandbox.Sandbox.connect", connect) + started = await dispatch_tool( + "start_task", {"sandbox_id": local_sandbox.id, "command": "echo cached-owner"}, manager + ) + assert "error" not in started + assert started["retained_until_sandbox_ttl"] is True + assert started["sandbox_id"] == "e2b:" + local_sandbox.id + connect.assert_not_awaited() + # Query uses a temporary connection and must carry the original provider. + reconnect = AsyncMock(return_value=local_sandbox) + monkeypatch.setattr("agent_sandbox.api.sandbox.Sandbox.connect", reconnect) + handle = {key: started[key] for key in ("sandbox_id", "task_id")} + assert (await terminal(handle, manager))["stdout"] == "cached-owner\n" + assert reconnect.call_args.kwargs["sandbox_id"] == handle["sandbox_id"] + await manager.shutdown() + local_sandbox.kill.assert_not_awaited() + + +async def test_bare_task_id_collision_is_rejected_before_connect(local_sandbox, monkeypatch): + first = SimpleNamespace(id="same-id", provider="alicloud-express") + second = SimpleNamespace(id="same-id", provider="e2b") + manager = SandboxManager() + manager._sandboxes = { + manager._sandbox_key(first): first, + manager._sandbox_key(second): second, + } + connect = AsyncMock(return_value=local_sandbox) + monkeypatch.setattr("agent_sandbox.api.sandbox.Sandbox.connect", connect) + result = await dispatch_tool( + "start_task", {"sandbox_id": "same-id", "command": "echo ambiguous"}, manager + ) + assert "qualify" in result.get("error", "") + connect.assert_not_awaited() + + +async def test_task_connect_finishing_after_shutdown_does_not_submit(local_sandbox, monkeypatch): + manager = SandboxManager() + entered, release = asyncio.Event(), asyncio.Event() + + async def late_connect(**_kwargs): + entered.set() + await release.wait() + return local_sandbox + + execute = AsyncMock(return_value=SimpleNamespace(stdout="{}", stderr="", exit_code=0)) + local_sandbox.commands = SimpleNamespace(run=execute) + monkeypatch.setattr("agent_sandbox.api.sandbox.Sandbox.connect", late_connect) + submission = asyncio.create_task( + dispatch_tool("start_task", {"sandbox_id": "external", "command": "echo late"}, manager) + ) + await entered.wait() + await manager.shutdown() + release.set() + result = await submission + assert "closed" in result.get("error", "") + execute.assert_not_awaited() + local_sandbox.close.assert_awaited_once() + local_sandbox.kill.assert_not_awaited() diff --git a/docs/en/guide/desktop-acceptance.md b/docs/en/guide/desktop-acceptance.md new file mode 100644 index 00000000..0246ff94 --- /dev/null +++ b/docs/en/guide/desktop-acceptance.md @@ -0,0 +1,28 @@ +# Desktop interaction and simulated operator handover + +This optional workflow uses `e2b_desktop.Sandbox` directly. It runs a real browser on a disposable Linux desktop, transfers control through a second noVNC client, and records an offline screenshot timeline. The operator in the acceptance test is simulated. It does not certify a real user's login or provide a hosted takeover service. + +Prepare a private named template using the same-region official Desktop image `runtime/desktop:v0.0.44`, generation 2, 4 CPU and 8192 MB, and independently confirm `Ready`. The [official template guide](https://help.aliyun.com/zh/functioncompute/desktop-template-example) describes the image and build preparation. Preserve the actual template ID; the test never changes or deletes that caller-supplied template. + +From this repository, install the isolated optional fixture and run it with your configured `E2B_API_KEY`, `E2B_API_URL` and `E2B_DOMAIN`: + +```bash +uv venv --python 3.11 .venv-desktop +uv pip install --python .venv-desktop/bin/python -r e2e/desktop-requirements.txt +.venv-desktop/bin/python e2e/desktop_acceptance.py \ + --template "$E2B_DESKTOP_TEMPLATE" --output-dir ./desktop-evidence-new +``` + +A local Chromium executable must be available. Use `--browser-executable` when needed. The output directory must be new. This fixture is pinned to the tested desktop image, resolution and synthetic page; inspect the initial screenshot before adapting coordinates to another image. + +## Three independent credentials + +| Layer | Credential / header | +| --- | --- | +| SDK control API, commands and files | `envdAccessToken`, automatically carried as `X-Access-Token` | +| Private public-facing application port | `Sandbox.traffic_access_token`, carried as `e2b-traffic-access-token` | +| VNC session | Password created by `stream.start(require_auth=True)` | + +Create with `secure=True` and `network={"allow_public_traffic": False}`. Keep the traffic token in the controlled backend proxy; the browser connects to its loopback endpoint. The [network access guide](https://help.aliyun.com/en/agent-sandbox/network-access-control) explains the separate control and ingress tokens. Inspect the actual negative and positive probes instead of treating a public HTML response or RFB banner as a successful authentication check. + +The test checks screenshot PNGs, mouse and keyboard effects, wrong VNC password rejection, actual live WebSocket data, and the independently downloaded sequence `agent-start → operator-step → agent-resumed`. `replay.html`, `timeline.json`, screenshot SHA-256 values and `gui-state.json` preserve the run. Replay is a screenshot sequence, not a full-motion video recording. Only the newly created sandbox is deleted, with a separate typed not-found check. Delete your test template separately through its owning workflow. diff --git a/docs/en/guide/mcp-background-tasks.md b/docs/en/guide/mcp-background-tasks.md index 77bd0d57..4f2ebda8 100644 --- a/docs/en/guide/mcp-background-tasks.md +++ b/docs/en/guide/mcp-background-tasks.md @@ -104,3 +104,14 @@ print them. Use separately authorized sandboxes for separate trust domains. The real Express verification records native Start/Connect replay limitations and start → disconnect → reconnect → saved exit/logs → cancellation → cleanup. Other providers, Windows and hosted multi-user deployment are not accepted by that run. + +## Qualified handle compatibility + +This source revision returns `start_task.sandbox_id` as the actual `provider:id`, +so a fresh session reconnects to the same backend even when the task was started +on a non-default provider. Treat the complete returned handle as opaque and pass +it unchanged to `get_task`/`cancel_task`; clients that strip or parse the raw ID +must update. There are no new parameters, but the returned string representation +changes. Legacy unqualified IDs work when the SDK's default provider identifies +the original resource; lifecycle cache lookup also accepts a unique bare ID. A +bare ID shared by multiple cached providers is rejected rather than guessed. diff --git a/docs/en/guide/mcp-binary-files.md b/docs/en/guide/mcp-binary-files.md index 0ec4522c..9d11f9fc 100644 --- a/docs/en/guide/mcp-binary-files.md +++ b/docs/en/guide/mcp-binary-files.md @@ -5,12 +5,13 @@ STDIO and Streamable HTTP. The CLI's independent MCP implementation is unchanged These tools operate on the selected sandbox's files; they never open a path on the MCP host. Keep `read_file`/`write_file` for text. -**Ownership:** like existing file tools, either binary tool adopts an explicitly -selected external sandbox into that MCP session. Session shutdown/HTTP session -delete destroys adopted sandboxes, even after a read-only download. Finish all -uses before closing that session. This differs from metadata inspection and TTL -updates, which do not adopt external resources. Explicit retained background -tasks keep their separate documented retention rules. +**Ownership in this source revision:** an explicit external `sandbox_id` is a +borrowed connection. Session shutdown/HTTP session deletion closes that client +handle and does not destroy the external sandbox. Only sandboxes created by the +same MCP manager are deleted automatically; explicit retained background tasks +keep their separate retention rules. An explicit authorized `kill_sandbox` still +deletes its selected sandbox. This correction is newer than public 0.4.0a6; use +the verified fixed source/build until a release containing it is available. An upload accepts standard Base64 and overwrites the remote path: diff --git a/docs/en/guide/mcp-integration.md b/docs/en/guide/mcp-integration.md index 621ad6e3..10d2c1c0 100644 --- a/docs/en/guide/mcp-integration.md +++ b/docs/en/guide/mcp-integration.md @@ -61,7 +61,7 @@ The Python MCP server provides 15 tools. The Node.js CLI has its own MCP impleme > **Naming clarification**: The MCP tool `run_command` runs the command via `sh -c` (`sandbox.commands.run(..., shell=True)` in the SDK). It is not named-command dispatch; to call a command registered on the sandbox's server, use `Sandbox.run(name, **kwargs)`. > -> **Default sandbox**: `create_sandbox` makes that sandbox the default, so a later `write_file` / `run_code` / `kill_sandbox` that omits `sandbox_id` uses the sandbox just created. If there is no default yet, the first execution or file tool call that omits `sandbox_id` lazily creates one. Metadata and timeout tools never create a sandbox. An omitted `template` uses the server template (`base` for the Python server, `code-interpreter-v1` for the CLI's `abx mcp start`). Execution and file operations renew the lifetime using the session policy, initially the creation timeout and updated by `set_sandbox_timeout`. Metadata queries never renew it. Sandboxes created or adopted by execution/file tools in the session are destroyed when the IDE closes the server (STDIO) or on `DELETE /mcp` / idle timeout (HTTP). +> **Default sandbox**: `create_sandbox` makes that sandbox the default, so a later `write_file` / `run_code` / `kill_sandbox` that omits `sandbox_id` uses the sandbox just created. If there is no default yet, the first execution or file tool call that omits `sandbox_id` lazily creates one. Metadata and timeout tools never create a sandbox. An omitted `template` uses the server template (`base` for the Python server, `code-interpreter-v1` for the CLI's `abx mcp start`). Execution and file operations renew the lifetime using the session policy, initially the creation timeout and updated by `set_sandbox_timeout`. Metadata queries never renew it. In this source revision, sandboxes created by that session are destroyed when the IDE closes the server (STDIO) or on `DELETE /mcp` / idle timeout (HTTP). Explicit-ID connections to other sandboxes are borrowed: only their client handles are closed. This ownership correction is newer than public 0.4.0a6. Explicit authorized `kill_sandbox` still deletes its selected sandbox. Explicit `start_task` is the exception to session destruction: it retains that sandbox until platform TTL, while other sandboxes keep normal cleanup. Save the diff --git a/docs/en/guide/mcp-lifecycle.md b/docs/en/guide/mcp-lifecycle.md index 8a8ed6fb..f4700355 100644 --- a/docs/en/guide/mcp-lifecycle.md +++ b/docs/en/guide/mcp-lifecycle.md @@ -48,9 +48,19 @@ enforces resource combinations; passing input validation does not guarantee avai Team selection is not exposed because the SDK has no corresponding parameter. ```json -{"name":"create_sandbox","arguments":{"template":"base","timeout":600,"cpu":2,"memory":1024}} +{"name":"create_sandbox","arguments":{"template":"base","timeout":600,"cpu":2,"memory":2048}} ``` +When `cpu` or `memory` is explicit, the current source verifies each requested value +against newly refreshed platform metadata before reporting success. This correction +is newer than public 0.4.0a6. Unknown or mismatched values return a tool error with +`category: "resource_configuration"`, `requested_resources`, `observed_resources` +and `cleanup_verified`. Both verification and rollback keep the exact newly created handle even if the session closes or its cache changes. Only that newly created sandbox is rolled back. Cleanup uses a fresh control plane with the created instance's resolved provider, endpoint, region and credentials; a different default region cannot confirm absence. A typed +not-found or HTTP404 readback confirms cleanup; an ambiguous response retains a +provider-qualified `recovery_sandbox_id` for explicit cleanup. This does not change +the direct SDK `Sandbox.create` contract. `region` still follows provider endpoint +configuration; it is not a verified CPU/memory override. + Creation makes that sandbox the default. Keep secrets in the MCP process's authorized configuration; tool arguments and results may be visible to the connected model. @@ -97,3 +107,22 @@ SDK delegation, default selection, renewal failures, and metadata disclosure che Mocked tests do not establish real provider support; cloud results must be recorded separately in repository evidence. See the [official MCP tools specification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). + +## Borrowed connections and session shutdown + +This source revision tracks creation ownership separately from cached connections. +Execution/file tools may connect to an explicit external ID with the configured +account authority. That connection is borrowed: deleting the borrowing HTTP +session, closing STDIO or reaching idle timeout closes only its client handle. +The manager deletes its own creations unless an explicit background task retained +them until platform TTL. Explicit authorized `kill_sandbox` remains destructive. + +This correction is newer than public 0.4.0a6. Do not infer that sessions sharing +one account credential provide a tenant authorization boundary; use separate +credentials/Teams or application authorization for distinct users. + +The manager keys cached ownership, TTL and retained tasks by the actual provider +and sandbox ID. If two providers return the same raw ID, explicit access must use +`provider:id`; an ambiguous bare ID is rejected. Aliases cannot replace an owned +handle. A session closed during creation/connection stays closed: a late created +sandbox is deleted and a late borrowed connection is closed. diff --git a/docs/en/guide/provider-commands.md b/docs/en/guide/provider-commands.md index cfe7114e..d4b087ee 100644 --- a/docs/en/guide/provider-commands.md +++ b/docs/en/guide/provider-commands.md @@ -242,3 +242,13 @@ real provider with memory/file continuity, clone isolation and owned-resource cl cloud/model acceptance is added to Actions. Official constraints: [pause/resume](https://help.aliyun.com/zh/agent-sandbox/pause-and-resume), [Snapshot](https://help.aliyun.com/zh/agent-sandbox/snapshot-invited-test). + +The [owned storage acceptance](../../../e2e/README.md#owned-oss-persistence-and-read-only-acceptance) +separately verifies real OSS persistence after sandbox recreation and read-only +access with the standard Python client. Volume registration alone cannot establish +those data-plane guarantees. The recorded MicroVM mounted pause/resume check uses +a checksum-verified ossfs2 2.0.10 installation and the +[documented append-mount API](https://www.alibabacloud.com/help/zh/agent-sandbox/append-mount-volume); +that API is not a new shared Provider command. The tested backend rejected +volume/mount overrides on Snapshot restoration with HTTP400. Do not use a Snapshot +ID as an image-template replacement when changing mount configuration. diff --git a/docs/zh/guide/desktop-acceptance.md b/docs/zh/guide/desktop-acceptance.md new file mode 100644 index 00000000..64ee69cc --- /dev/null +++ b/docs/zh/guide/desktop-acceptance.md @@ -0,0 +1,28 @@ +# 桌面操作和模拟接管验收 + +此可选流程直接使用 `e2b_desktop.Sandbox`,在真实 Linux 桌面内运行浏览器,通过另一个 noVNC 客户端接管,再交回 Agent,保存可离线查看的截图时间线。测试中的操作员由程序模拟,不代表真实用户登录验收,也不是托管接管服务。 + +使用同地域官方 Desktop 镜像 `runtime/desktop:v0.0.44` 创建独立命名模板,选择第二代沙箱、4 CPU、8192 MB,并独立确认 `Ready`。镜像和构建准备见[官方模板指南](https://help.aliyun.com/zh/functioncompute/desktop-template-example)。保留实际 Template ID;验收脚本不会修改或删除传入的模板。 + +在本仓库中安装隔离的可选依赖,配置好 `E2B_API_KEY`、`E2B_API_URL` 和 `E2B_DOMAIN` 后运行: + +```bash +uv venv --python 3.11 .venv-desktop +uv pip install --python .venv-desktop/bin/python -r e2e/desktop-requirements.txt +.venv-desktop/bin/python e2e/desktop_acceptance.py \ + --template "$E2B_DESKTOP_TEMPLATE" --output-dir ./desktop-evidence-new +``` + +本地需要 Chromium 可执行文件;必要时通过 `--browser-executable` 指定。输出目录必须是新目录。此测试固定了已验证的镜像、分辨率和模拟页面;适配其他镜像前先看实际截图,再调整操作坐标。 + +## 三种独立凭证 + +| 层级 | 凭证和请求头 | +| --- | --- | +| SDK 控制接口、命令和文件 | `envdAccessToken`,SDK 自动发送 `X-Access-Token` | +| 禁止匿名访问的应用端口 | `Sandbox.traffic_access_token`,发送 `e2b-traffic-access-token` | +| VNC 会话 | `stream.start(require_auth=True)` 生成的口令 | + +创建时显式设置 `secure=True`、`network={"allow_public_traffic": False}`。应用入口 Token 留在受控后端代理中,浏览器仅连接其回环地址。凭证区别见[网络访问指南](https://help.aliyun.com/en/agent-sandbox/network-access-control)。核对实际的拒绝和成功请求;公开 HTML 或 RFB 握手横幅本身不能证明鉴权已经成功。 + +脚本检查真实 PNG、鼠标键盘的实际效果、错误 VNC 口令被拒绝、实时 WebSocket 数据,并独立下载、核对 `agent-start → operator-step → agent-resumed` 的状态序列。`replay.html`、`timeline.json`、截图 SHA-256 和 `gui-state.json` 保留执行过程。回放是截图序列,不是完整视频。脚本只删除本次新建的沙箱,并通过独立的类型化不存在错误确认清理;测试模板由其所属流程另行删除。 diff --git a/docs/zh/guide/mcp-background-tasks.md b/docs/zh/guide/mcp-background-tasks.md index 52a89045..8f289aa0 100644 --- a/docs/zh/guide/mcp-background-tasks.md +++ b/docs/zh/guide/mcp-background-tasks.md @@ -87,3 +87,11 @@ SECRET、PASSWORD 的已知值做精确脱敏,覆盖跨管道读取分片的 真实极速版验证覆盖原生 Start/Connect 的重放限制,以及启动→断开→重连→ 读取退出码和完整日志→取消→资源清理。该记录不代表其他 provider、Windows 或托管多用户部署已通过验收。 + +## 限定句柄的兼容性 + +本源码版本将 `start_task.sandbox_id` 返回为实际 `provider:id`,使新会话仍连接任务 +原先使用的后端,包括非默认提供方。完整返回句柄是不透明值,须原样传给 `get_task` +和 `cancel_task`;拆分或仅提取原始 ID 的客户端需要更新。没有新增参数,但返回字符串 +的表示发生变化。历史非限定 ID 在 SDK 默认提供方对应原资源时仍可使用;生命周期缓存 +也接受唯一的原始 ID。多个缓存提供方使用同一原始 ID 时会拒绝推测,要求限定提供方。 diff --git a/docs/zh/guide/mcp-binary-files.md b/docs/zh/guide/mcp-binary-files.md index 10159805..e6a71c61 100644 --- a/docs/zh/guide/mcp-binary-files.md +++ b/docs/zh/guide/mcp-binary-files.md @@ -4,10 +4,11 @@ Python MCP 的 STDIO 与 Streamable HTTP 共用 `upload_file` 和 `download_file CLI 的独立 MCP 实现不随此变更。工具只操作选中沙箱的文件,不打开 MCP 主机路径; 文本继续使用 `read_file` / `write_file`。 -**资源归属:** 与已有文件工具一致,两种二进制工具都会把显式选中的外部沙箱纳入 -MCP 会话。会话关闭/HTTP session delete 会销毁接管的沙箱,即使只读下载也是如此; -务必在关闭会话前完成所有使用。这与不接管外部资源的元数据查询、TTL 更新不同; -明确保留的后台任务仍按独立保留规则处理。 +**本源码版本的资源归属:** 显式指定外部 `sandbox_id` 时借用连接。会话关闭或 HTTP +session delete 只关闭该客户端连接,不销毁外部沙箱。仅由同一 MCP manager 创建的沙箱 +会自动删除;明确保留的后台任务继续遵循独立保留规则。显式、已授权的 `kill_sandbox` +仍会删除选中的沙箱。此修正晚于公开的 0.4.0a6;包含修正的新版本发布前,须使用已验证 +的修正源码或构建产物。 上传使用标准 Base64,覆盖远端路径: diff --git a/docs/zh/guide/mcp-integration.md b/docs/zh/guide/mcp-integration.md index a397eda6..d607964b 100644 --- a/docs/zh/guide/mcp-integration.md +++ b/docs/zh/guide/mcp-integration.md @@ -61,7 +61,7 @@ Python MCP server 提供 15 个工具。Node.js CLI 使用独立 MCP 实现;Py > **命名说明**:MCP 工具 `run_command` 通过 `sh -c` 执行命令(SDK 中 `sandbox.commands.run(..., shell=True)`),它不是命名命令调度;要调用注册在沙箱 server 上的命令,请使用 `Sandbox.run(name, **kwargs)`。 > -> **默认沙箱**:`create_sandbox` 的结果会成为默认沙箱,所以随后省略 `sandbox_id` 的 `write_file` / `run_code` / `kill_sandbox` 都作用在刚创建的那只沙箱上。还没有默认沙箱时,第一次省略 `sandbox_id` 的执行或文件工具调用会懒创建一只。元数据和续期工具不会创建沙箱。省略 `template` 时使用 server 配置的模板(Python server 默认为 `base`,CLI 的 `abx mcp start` 默认为 `code-interpreter-v1`)。执行与文件操作会按会话策略续期,初值为创建时的 `timeout`,可由 `set_sandbox_timeout` 更新。元数据查询不续期。IDE 关闭 server(STDIO)或 `DELETE /mcp` / 空闲超时(HTTP)时会销毁会话创建或由执行、文件工具接管的沙箱;只查询或续期的外部实例不接管。 +> **默认沙箱**:`create_sandbox` 的结果会成为默认沙箱,所以随后省略 `sandbox_id` 的 `write_file` / `run_code` / `kill_sandbox` 都作用在刚创建的那只沙箱上。还没有默认沙箱时,第一次省略 `sandbox_id` 的执行或文件工具调用会懒创建一只。元数据和续期工具不会创建沙箱。省略 `template` 时使用 server 配置的模板(Python server 默认为 `base`,CLI 的 `abx mcp start` 默认为 `code-interpreter-v1`)。执行与文件操作会按会话策略续期,初值为创建时的 `timeout`,可由 `set_sandbox_timeout` 更新。元数据查询不续期。本源码版本在 IDE 关闭 server(STDIO)或 `DELETE /mcp` / 空闲超时(HTTP)时,仅销毁本会话创建的沙箱;显式 ID 访问的其他沙箱属于借用,只关闭客户端连接。此归属修正晚于公开的 0.4.0a6。显式、已授权的 `kill_sandbox` 仍可删除选中的沙箱。 显式 `start_task` 是会话销毁规则的例外:保留该沙箱到平台 TTL,其他沙箱仍正常清理。 请保存句柄,结束后显式 `kill_sandbox`。分页、取消和持久性边界见 diff --git a/docs/zh/guide/mcp-lifecycle.md b/docs/zh/guide/mcp-lifecycle.md index d98e44f1..cc2060aa 100644 --- a/docs/zh/guide/mcp-lifecycle.md +++ b/docs/zh/guide/mcp-lifecycle.md @@ -42,9 +42,17 @@ metadata、任意 lifecycle 对象或端点 URL。列表结果的 `sandboxes` 可用。SDK 没有对应 Team 参数,因此不提供 Team 选择。 ```json -{"name":"create_sandbox","arguments":{"template":"base","timeout":600,"cpu":2,"memory":1024}} +{"name":"create_sandbox","arguments":{"template":"base","timeout":600,"cpu":2,"memory":2048}} ``` +当前源码在显式指定 `cpu` 或 `memory` 时,会读取最新平台元数据,逐项核验实际配置后才 +返回成功;此修复晚于公开的 0.4.0a6。无法核验或配置不符时返回工具错误,包含 +`category: "resource_configuration"`、`requested_resources`、`observed_resources` 和 +`cleanup_verified`,核验与回滚始终使用本次新建实例的原句柄,即使会话已关闭或缓存已变化,也不会按 ID 改查其他实例,并且只回滚本次新建沙箱。清理回读使用该实例实际解析的提供方、端点、地域和凭据建立新的控制面连接,不会用另一默认地域的 404 作为删除证明。只有类型化的不存在错误或 HTTP404 回读 +才确认清理完成;结果不明时保留带提供方前缀的 `recovery_sandbox_id`,供显式清理。 +直接 SDK 的 `Sandbox.create` 合同保持原样。`region` 仍遵循提供方端点配置,不属于这项 +CPU/内存核验。 + 创建后该沙箱成为默认沙箱。秘密应保存在 MCP 进程已有授权配置中;工具参数和结果可能对 连接的模型可见。 @@ -81,3 +89,18 @@ metadata、任意 lifecycle 对象或端点 URL。列表结果的 `sandboxes` 默认沙箱解析、续期失败和元数据泄漏检查。Mock 测试不能证明真实 provider 支持;云端结果 必须另行记录在仓库验证证据中。接口参考 [官方 MCP tools 规范](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)。 + +## 借用连接与会话关闭 + +本源码版本分别记录创建归属和缓存连接。执行或文件工具可通过已配置账号权限,访问 +显式指定的外部 ID;该连接属于借用。删除借用方 HTTP 会话、关闭 STDIO 或空闲到期时, +只关闭客户端连接。manager 仅删除自己的创建实例,明确提交的后台任务可保留至平台 TTL。 +显式、已授权的 `kill_sandbox` 仍执行删除。 + +此修正晚于公开的 0.4.0a6。同一账号凭证下的不同会话不构成租户授权隔离;不同用户 +应使用独立凭证、Team 或应用层授权。 + +manager 按实际提供方和沙箱 ID 组合记录缓存归属、TTL 与任务保留。两个提供方返回相同 +原始 ID 时,显式访问须使用 `provider:id`;有歧义的原始 ID 会被拒绝。别名不会替换已有 +自有连接。创建或连接期间关闭会话后,会话保持关闭:晚到的创建实例被删除,晚到的 +借用连接被关闭,不重新登记资源。 diff --git a/docs/zh/guide/provider-commands.md b/docs/zh/guide/provider-commands.md index 36611c0a..9c5597a8 100644 --- a/docs/zh/guide/provider-commands.md +++ b/docs/zh/guide/provider-commands.md @@ -218,3 +218,10 @@ Python 仓库的手动验收控制器通过两端访问同一个真实 Provider [快照](https://help.aliyun.com/zh/agent-sandbox/snapshot-invited-test)。 真实验收中,恢复后立即创建快照曾返回 HTTP503;先确认运行状态并等待服务准入稳定,再首次创建。最终验收使用30秒静置间隔,该值是验收观察,不是服务就绪 SLA。失败写入不自动重试。 + +[自有存储验收](../../../e2e/README.zh-CN.md#自有-oss-持久化与只读验收)另行通过标准 +Python 客户端验证真实 OSS 跨沙箱重建持久化和只读访问;仅注册 Volume 不能证明数据面 +已经可用。已记录的 MicroVM 挂载暂停/恢复检查使用经摘要校验的 ossfs2 2.0.10 和 +[官方追加挂载 API](https://www.alibabacloud.com/help/zh/agent-sandbox/append-mount-volume), +这不代表新增了共享 Provider 命令。被测后端在快照恢复时拒绝修改 Volume 或挂载配置, +返回 HTTP400;需要改变挂载配置时,不应把快照 ID 当作重新构建的镜像模板。 diff --git a/e2e/README.md b/e2e/README.md index 04d5efc8..bea52532 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -62,6 +62,7 @@ Third-party providers are not part of this suite's cloud run. They follow the sa | `test_07_declarative.py` | `@sandbox` declarative remote execution | | `test_08_providers.py` | Live calls for `opensandbox`, `alicloud-express` (SDK and CLI), `cubesandbox`, `e2b`, and `alicloud-container`. A provider with no backend of its own is skipped and does not receive the Express key. | | `test_09_volume_mounts.py` | Opt-in existing Volume acceptance: prove an exact kernel mount, exclusively create/read/remove a sentinel, and verify persistence after destroying and recreating a sandbox. All tagged sandboxes are released. Ordinary directories cannot pass. | +| `test_skill_storage_state.py` | Explicitly owned RW/RO OSS fixtures: standard-client persistence after sandbox destruction/recreation, exact kernel mounts, read-only enforcement and typed cleanup. | | `test_09_mcp_lifecycle.py` | Python MCP metadata/TTL/list, command/files and external-instance ownership | Markers: `e2e` (everything), `e2e_slow` (> ~60 s), @@ -115,6 +116,57 @@ valid credential refusals. Cube live acceptance requires an explicitly configure | `ABX_E2E_VOLUME_MOUNT_PATH` | unset; skips | Dedicated canonical sandbox path under `/mnt/abx-e2e/`. This narrower path rule belongs to the test harness. | | `E2E_VOLUME_TEMPLATE` | unset; skips | Template meeting the storage/runtime requirements in the [official mount guide](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1). | +## Owned OSS persistence and read-only acceptance + +`test_skill_storage_state.py` uses the standard synchronous client against two +explicitly owned OSS Volume configurations pointing at the same dedicated storage +path: one writable and one read-only. Provision them separately, in the configured +API key's Team/region, with a RAM role limited to that owned storage. No Volume, +role or bucket is provisioned or deleted by this test. + +Set `E2E_VOLUME_ACCEPTANCE=owned-only`, `E2E_VOLUME_OWNER_RUN` to your owned run name +(`abx-accept-` followed by 12 lowercase letters/digits), and +`E2E_VOLUME_RW_NAME`/`E2E_VOLUME_RO_NAME` to that run name plus `-rw`/`-ro`. +Also set `E2E_VOLUME_TEMPLATE` explicitly to an eligible runtime. MicroVM images +must already contain ossfs2 2.0.10 or newer; see the +[official mount prerequisites](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1). + +Run `python -m pytest e2e/test_skill_storage_state.py -ra`. The test verifies the +exact kernel mount before touching a uniquely named, exclusively created sentinel, +fsyncs it, destroys the first sandbox and checks typed not-found, then creates a +new sandbox and reads the persisted data. A read-only mount must read that data +and reject a separate exclusive write with a permission/read-only errno. Cleanup +only removes the verified owned sentinel and tracked sandboxes. The fixture owner +must separately clean up its Volume definitions, role and backing storage. +Missing opt-in skips before constructing cloud fixtures; incomplete explicit +prerequisites fail rather than selecting an arbitrary Volume. + +The standard Snapshot regression keeps its mutable marker inside an owned, +non-sticky directory. The tested envd image rejects SDK overwrite directly under +sticky `/tmp` even before a Snapshot; that independent behavior is retained in +[the state/storage verification record](../.agents/evidence/verify/2026-10-26-skill-storage-state-acceptance.md). + +## Repository, expiring preview and third-party MCP runner + +Run `python e2e/skill_tools_acceptance.py --output NEW_JSON_PATH` in an environment +with this checkout's matching client/server pair installed. Configure the standard +sandbox credentials locally; no account key is sent into the sandbox. The runtime +must provide Python, Node.js, git and outbound access to GitHub/npm. +`ABX_SKILL_RUNTIME_TEMPLATE` selects the eligible runtime (default +`code-interpreter-v1`); `ABX_SKILL_RUN_PREFIX` sets the owned fixture's purpose tag. +The new evidence output must not already exist. + +The runner fetches the exact pinned public repository commit with at most two +bounded read-only attempts, verifies and starts its Node application, then checks +an application HMAC preview: valid response, unsigned/tampered rejection and +20-second expiry. This temporary fixture uses an application proxy with +`secure=False`; it does not prove provider gateway-native signed links. It then +installs pinned filesystem MCP releases `2026.7.10` and `2026.8.31`, performs actual +initialize/tools-list/read-file calls, reads logs, removes tools and verifies no +owned MCP process or sandbox remains. It is a manual real-cloud runner, not an +implicitly enabled unit/Actions test. Failures and cleanup are saved separately +from successful checks. + ## Adding a test 1. Use the fixtures (`base_sandbox`, `code_sandbox`, `server_sandbox`, `sandbox_factory`, `workdir`); @@ -149,3 +201,12 @@ because platform health can answer before port forwarding is ready. 可执行文件,可启用 `test_19_named_string_array.py` 的原生 JSON 数组回归。 该用例上传并校验当前 checkout 的服务器源码,使用独立、受跟踪的沙箱, 不会假定预置服务器已更新;以命令目录的就绪作为异步端口转发生效的依据。 + +## Optional real desktop acceptance + +Follow the [desktop acceptance guide](../docs/en/guide/desktop-acceptance.md) and +its isolated pinned requirements, then run +`python e2e/desktop_acceptance.py --template "$E2B_DESKTOP_TEMPLATE" --output-dir NEWDIR`. +This is separate from the standard/Provider client suites; real desktop template, +private ingress, traffic token, VNC password and handover/replay prerequisites +remain explicit in the guide. diff --git a/e2e/README.zh-CN.md b/e2e/README.zh-CN.md index 03812cd7..b9f8339a 100644 --- a/e2e/README.zh-CN.md +++ b/e2e/README.zh-CN.md @@ -54,6 +54,7 @@ cd e2e && python -m pytest -m "not e2e_slow" -ra # 等同 `make e2 | `test_07_declarative.py` | `@sandbox` 声明式远程执行 | | `test_08_providers.py` | 真实后端调用:内置 `alicloud-express`(SDK 和 CLI),以及已安装的目录提供方 `opensandbox`、`cubesandbox`、`e2b`、`alicloud-container`。没有自己的后端时跳过,并且不会把极速版密钥发给它。 | | `test_09_volume_mounts.py` | 显式启用的已有 Volume 验收:确认内核精确挂载点,独占创建、读取并删除一个测试文件,再释放带标签的沙箱。普通目录不能通过。 | +| `test_skill_storage_state.py` | 显式自有 RW/RO OSS 夹具:标准客户端释放并重建后的持久化、内核精确挂载点、只读约束及类型化清理。 | | `test_09_mcp_lifecycle.py` | Python MCP 元数据/续期/列表、命令文件与外部实例归属 | 标记:`e2e`(全部)、`e2e_slow`(> 约 60 秒)、`known_issue`(记录已知缺陷的严格 xfail,见 @@ -92,6 +93,44 @@ cd e2e && python -m pytest -m "not e2e_slow" -ra # 等同 `make e2 | `ABX_E2E_VOLUME_MOUNT_PATH` | 未设置则跳过 | `/mnt/abx-e2e/` 下专用的规范沙箱路径;这是测试工具的收窄要求。 | | `E2E_VOLUME_TEMPLATE` | 未设置则跳过 | 满足[官方挂载指南](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1)存储及运行时前提的模板。 | +## 自有 OSS 持久化与只读验收 + +`test_skill_storage_state.py` 使用标准同步客户端,验证两个显式自有的 OSS Volume +配置:两者指向同一专用存储路径,一个可读写,一个只读。请在当前 API Key 的 +Team 和地域预先创建这些配置,并将 RAM 角色权限限制为该自有存储。此测试不会 +创建或删除 Volume、角色或 Bucket。 + +设置 `E2E_VOLUME_ACCEPTANCE=owned-only`;`E2E_VOLUME_OWNER_RUN` 为本次自有运行名 +(`abx-accept-` 后接 12 位小写字母或数字);`E2E_VOLUME_RW_NAME` 和 +`E2E_VOLUME_RO_NAME` 分别等于运行名加 `-rw` 和 `-ro`。必须显式设置满足挂载前提的 +`E2E_VOLUME_TEMPLATE`。MicroVM 镜像须预装 ossfs2 2.0.10 或更高版本,详见 +[官方挂载前提](https://help.aliyun.com/zh/agent-sandbox/mount-oss-volume-1)。 + +运行 `python -m pytest e2e/test_skill_storage_state.py -ra`。测试先验证内核精确挂载点, +再独占创建唯一测试文件、fsync,释放首个沙箱并断言类型化 not-found,随后新建沙箱 +读取保留的数据。只读挂载须能读取该文件,且另一独占写入须返回只读或权限错误号。 +仅清理已验证归属的测试文件和已登记沙箱;夹具所有者另行清理 Volume、角色及底层存储。 +未显式启用时,在构造云端夹具前跳过;启用后前提缺失则失败,不自动挑选其他 Volume。 + +标准快照回归把可修改标记放在自有、非 sticky 的目录中。被测 envd 镜像在创建快照前, +就会拒绝 SDK 直接覆盖 sticky `/tmp` 下的文件;该独立行为保留在 +[状态及存储验证记录](../.agents/evidence/verify/2026-10-26-skill-storage-state-acceptance.md)。 + +## 代码仓库、到期预览及第三方 MCP 运行器 + +在已安装本 checkout 配套客户端/服务端的环境执行 +`python e2e/skill_tools_acceptance.py --output NEW_JSON_PATH`。沙箱凭据只配置于本地, +不向沙箱发送账号密钥。运行时须具备 Python、Node.js、git 以及 GitHub/npm 出网能力。 +`ABX_SKILL_RUNTIME_TEMPLATE` 指定合适模板(默认 `code-interpreter-v1`), +`ABX_SKILL_RUN_PREFIX` 指定自有夹具 purpose 标签。证据输出文件必须尚不存在。 + +运行器最多用两次有界、只读的获取操作拉取指定公开提交,验证并启动 Node 应用, +随后检查应用 HMAC 预览:有效请求成功、未签名及篡改请求被拒绝、20 秒后到期。 +该临时夹具使用 `secure=False` 和应用代理,不代表提供方网关原生签名链接通过。 +随后依次安装固定的文件系统 MCP `2026.7.10`、`2026.8.31`,执行真实 initialize、 +工具发现和 read_file,回读日志,删除工具并核实无自有 MCP 进程及沙箱残留。 +这是手动实云运行器,不会自动启用单测或 Actions;失败和清理结果与成功检查分开记录。 + ## 新增测试 1. 使用 fixture(`base_sandbox`、`code_sandbox`、`server_sandbox`、`sandbox_factory`、`workdir`);不要直接调用 `Sandbox.create`,否则沙箱不会被追踪。 @@ -115,3 +154,10 @@ python e2e/provider_conditional.py --abx "$ABX_BIN" \ ``` 控制器启动隔离的真实引擎,比较两端发现结果,双向验证暂停恢复、快照创建/列表/恢复/删除、内存文件连续性、克隆隔离和独立清理。不会创建或删除传入的模板。二进制应放在不会被另一轮构建清理的独立位置。报告不包含凭据及资源标识;该手动控制器不会被 pytest 收集,也不从 Actions 调度。 + +## 可选的真实桌面验收 + +按照[桌面验收指南](../docs/zh/guide/desktop-acceptance.md)准备隔离环境和固定依赖,运行 +`python e2e/desktop_acceptance.py --template "$E2B_DESKTOP_TEMPLATE" --output-dir NEWDIR`。 +它独立于标准及 Provider 客户端套件;真实桌面模板、私有入口、流量令牌、VNC 密码与 +接管/回放前提均由指南明确列出。 diff --git a/e2e/desktop-handover.html b/e2e/desktop-handover.html new file mode 100644 index 00000000..b5775a6d --- /dev/null +++ b/e2e/desktop-handover.html @@ -0,0 +1,59 @@ + + +Sandbox handover acceptance + +

Shared desktop handover

+

Agent → simulated operator → agent. Synthetic acceptance data only.

+ + +

Saved: none

+Sequence: 0 + + diff --git a/e2e/desktop-replay.html b/e2e/desktop-replay.html new file mode 100644 index 00000000..884aa552 --- /dev/null +++ b/e2e/desktop-replay.html @@ -0,0 +1,36 @@ + + +Desktop handover replay + +

Desktop handover acceptance replay

+

Actual screenshot timeline. Operator was simulated; no human login occurred.

+ + + +

+Recorded desktop frame + diff --git a/e2e/desktop-requirements.txt b/e2e/desktop-requirements.txt new file mode 100644 index 00000000..f3a7ec23 --- /dev/null +++ b/e2e/desktop-requirements.txt @@ -0,0 +1,44 @@ +# Optional isolated compatibility fixture, Python 3.11; resolved and live-tested 2026-10-26. +aiohappyeyeballs==2.7.1 +aiohttp==3.13.4 +aiosignal==1.4.0 +anyio==4.15.1 +attrs==26.1.0 +bracex==3.2 +certifi==2026.10.23 +charset-normalizer==3.5.2 +connectrpc==0.12.1 +e2b==2.59.0 +e2b-desktop==2.4.1 +e2b-dockerfile-utils==0.1.1 +frozenlist==1.8.0 +greenlet==3.5.6 +h11==0.16.0 +h2==4.4.1 +hpack==4.2.0 +httpcore==1.0.9 +httpx==0.28.1 +hyperframe==6.1.0 +idna==3.20 +markdown-it-py==4.2.0 +mdurl==0.1.2 +multidict==6.9.1 +opentelemetry-api==1.45.1 +packaging==26.3 +pillow==12.3.0 +playwright==1.58.0 +propcache==0.5.4 +protobuf-py==0.6.0 +protobuf-py-ext==0.6.0 +pyee==13.0.1 +pygments==2.21.0 +pyqwest==0.12.0 +python-dateutil==3.0.0 +requests==2.34.2 +rich==15.0.0 +six==1.17.0 +typing-extensions==4.16.0 +tzdata==2026.5 +urllib3==2.8.0 +wcmatch==11.2 +yarl==1.25.1 diff --git a/e2e/desktop_acceptance.py b/e2e/desktop_acceptance.py new file mode 100644 index 00000000..7956485a --- /dev/null +++ b/e2e/desktop_acceptance.py @@ -0,0 +1,559 @@ +"""Real desktop GUI, authenticated stream, simulated operator handover and screenshot replay.""" + +import argparse +import asyncio +import hashlib +import io +import json +import logging +import mimetypes +import os +import shutil +import sys +import threading +import time +from contextlib import suppress +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import aiohttp +import httpx +from aiohttp import web +from e2b.exceptions import NotFoundException +from e2b_desktop import Sandbox +from PIL import Image +from playwright.sync_api import sync_playwright + + +# Credentials are inherited from the caller; this optional compatibility test does not load dotenv. +def sanitize(text: str) -> str: + for key, value in os.environ.items(): + if value and any( + word in key.upper() for word in ("TOKEN", "KEY", "SECRET", "PASSWORD", "CREDENTIAL") + ): + text = text.replace(value, "[REDACTED]") + return text + + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "--template", + required=True, + help="Existing prepared Desktop template ID; never changed by this runner", +) +parser.add_argument( + "--output-dir", type=Path, required=True, help="New artifact directory; must not exist" +) +parser.add_argument( + "--browser-executable", default=shutil.which("google-chrome") or shutil.which("chromium") +) +args = parser.parse_args() +if args.output_dir.exists(): + parser.error("Evidence directory already exists; choose a new path") +if not args.browser_executable: + parser.error("A Chromium executable is required before creating cloud resources") +logging.disable(logging.CRITICAL) + +OUT = args.output_dir +OUT.mkdir(parents=True) +REPORT: dict[str, Any] = { + "steps": [], + "timeline": [], + "scope": ( + "Real official desktop image; operator is simulated by a second controller, " + "not a human/login session." + ), +} +SECRETS = [] + + +def safe(s: str) -> str: + s = sanitize(s) + for secret in SECRETS: + if secret: + s = s.replace(secret, "[REDACTED]") + return s + + +def save() -> None: + (OUT / "acceptance.json").write_text(safe(json.dumps(REPORT, indent=2))) + + +def check(name: str, ok: bool, **details: Any) -> None: + row = {"name": name, "result": "PASS" if ok else "FAIL", **details} + REPORT["steps"].append(row) + save() + print(safe(json.dumps(row)), flush=True) + if not ok: + raise AssertionError(name) + + +def shot(desktop: Sandbox, name: str, actor: str, action: str) -> None: + data = desktop.screenshot() + image = Image.open(io.BytesIO(data)) + assert image.format == "PNG" and image.size == (1024, 768) + path = OUT / (name + ".png") + path.write_bytes(data) + row = { + "at_seconds": round(time.monotonic() - START, 2), + "actor": actor, + "action": action, + "file": path.name, + "sha256": hashlib.sha256(data).hexdigest(), + "width": 1024, + "height": 768, + } + REPORT["timeline"].append(row) + save() + print(json.dumps({"screenshot": path.name, "sha256": row["sha256"]}), flush=True) + + +APP = r"""from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +import json +import time +PAGE = Path('/tmp/abx-gui.html').read_text() +state = {'events': [], 'latest': None} +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + def do_GET(self): + data = PAGE.encode() + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(data) + def do_POST(self): + data = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + value = data['value'] + assert value in ('agent-start', 'operator-step', 'agent-resumed') + state['latest'] = value + state['events'].append({'value': value, 'at': time.time()}) + Path('/tmp/abx-gui-state.json').write_text(json.dumps(state)) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(state).encode()) +HTTPServer(('127.0.0.1', 8088), Handler).serve_forever() +""" + + +class Proxy: + def __init__(self, origin: str, token: str) -> None: + self.origin = origin + self.token = token + self.port = None + self.ready = threading.Event() + self.ws_open = 0 + self.ws_bytes = 0 + self.http_requests = 0 + + def start(self) -> None: + def worker() -> None: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(self.setup()) + self.ready.set() + self.loop.run_forever() + self.loop.run_until_complete(self.runner.cleanup()) + self.loop.run_until_complete(self.session.close()) + self.loop.close() + + self.thread = threading.Thread(target=worker, daemon=True) + self.thread.start() + assert self.ready.wait(15) + + async def setup(self) -> None: + self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=40)) + app = web.Application() + app.router.add_route("*", "/{tail:.*}", self.forward) + self.runner = web.AppRunner(app, access_log=None) + await self.runner.setup() + site = web.TCPSite(self.runner, "127.0.0.1", 0) + await site.start() + self.port = self.runner.addresses[0][1] + + async def forward(self, request: web.Request) -> web.StreamResponse: + headers = {"e2b-traffic-access-token": self.token} + target = self.origin + request.path_qs + if request.headers.get("Upgrade", "").lower() == "websocket": + protocols = [ + p.strip() + for p in request.headers.get("Sec-WebSocket-Protocol", "").split(",") + if p.strip() + ] + upstream = await self.session.ws_connect( + target.replace("https://", "wss://"), + headers=headers, + protocols=protocols, + max_msg_size=16 * 1024 * 1024, + ) + client = web.WebSocketResponse(protocols=protocols, max_msg_size=16 * 1024 * 1024) + await client.prepare(request) + self.ws_open += 1 + + async def pump(source: Any, destination: Any) -> None: + async for msg in source: + if msg.type == aiohttp.WSMsgType.BINARY: + self.ws_bytes += len(msg.data) + await destination.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.TEXT: + await destination.send_str(msg.data) + else: + break + + tasks = [ + asyncio.create_task(pump(client, upstream)), + asyncio.create_task(pump(upstream, client)), + ] + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in tasks: + task.cancel() + await upstream.close() + await client.close() + return client + async with self.session.request( + request.method, target, headers=headers, data=await request.read() + ) as upstream: + self.http_requests += 1 + body = await upstream.read() + response_headers = { + k: v + for k, v in upstream.headers.items() + if k.lower() + not in ( + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + "content-disposition", + "content-type", + ) + } + mime = mimetypes.guess_type(request.path)[0] or upstream.headers.get( + "Content-Type", "application/octet-stream" + ) + response_headers["Content-Type"] = mime + return web.Response(status=upstream.status, body=body, headers=response_headers) + + def close(self) -> None: + self.loop.call_soon_threadsafe(self.loop.stop) + self.thread.join(timeout=10) + + +def state(desktop: Sandbox) -> dict[str, Any]: + return json.loads(desktop.files.read("/tmp/abx-gui-state.json")) + + +def commit(desktop: Sandbox, value: str) -> dict[str, Any]: + desktop.move_mouse(300, 250) + desktop.left_click() + desktop.press(["ctrl", "a"]) + desktop.write(value) + desktop.move_mouse(190, 322) + desktop.left_click() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + s = state(desktop) + if s.get("latest") == value: + return s + except Exception: + pass + time.sleep(0.3) + raise AssertionError("GUI commit did not produce " + value) + + +desktop = None +proxy = None +stream = None +START = time.monotonic() +try: + template = args.template + REPORT["template_id"] = template + REPORT["creation_controls"] = {"secure": True, "network": {"allow_public_traffic": False}} + desktop = Sandbox.create( + template=template, + timeout=600, + secure=True, + network={"allow_public_traffic": False}, + resolution=(1024, 768), + metadata={"acceptance": "desktop-handover-20261026"}, + ) + REPORT["sandbox_id"] = desktop.sandbox_id + save() + info = desktop.get_info() + check( + "desktop-created-correct-spec", + info.cpu_count == 4 and info.memory_mb == 8192, + cpu=info.cpu_count, + memory=info.memory_mb, + ) + desktop.files.write("/tmp/abx-gui-server.py", APP) + desktop.files.write( + "/tmp/abx-gui.html", Path(__file__).with_name("desktop-handover.html").read_text() + ) + h = desktop.commands.run("python3 /tmp/abx-gui-server.py", background=True, timeout=0) + h.disconnect() + # A fresh profile avoids stale first-run/restore UI in an upstream image. + h = desktop.commands.run( + "google-chrome --user-data-dir=/tmp/abx-owned-gui-profile " + "--kiosk --no-first-run --disable-session-crashed-bubble --test-type " + "http://127.0.0.1:8088", + background=True, + timeout=0, + ) + h.disconnect() + time.sleep(14) + shot( + desktop, + "01-browser-ready", + "agent", + "Open the synthetic local acceptance page in the real desktop browser", + ) + s = commit(desktop, "agent-start") + check( + "agent-mouse-keyboard-effect", + s["latest"] == "agent-start", + sequence=[x["value"] for x in s["events"]], + ) + shot(desktop, "02-agent-state", "agent", "Mouse focus, keyboard text and mouse commit") + stream = desktop.stream + stream.start(require_auth=True) + password = stream.get_auth_key() + SECRETS.append(password) + remote = stream.get_url(auth_key=password) + parsed = urlsplit(remote) + origin = parsed.scheme + "://" + parsed.netloc + SECRETS.extend([origin, parsed.netloc]) + token = desktop.traffic_access_token + check("traffic-token-issued", isinstance(token, str) and bool(token)) + SECRETS.append(token) + unauth = httpx.get(origin + "/vnc.html", timeout=20) + auth = httpx.get(origin + "/vnc.html", headers={"e2b-traffic-access-token": token}, timeout=20) + + async def gateway_probe() -> dict[str, Any]: + async with aiohttp.ClientSession() as session: + try: + async with session.ws_connect( + origin.replace("https://", "wss://") + "/websockify", + protocols=["binary"], + timeout=10, + ) as ws: + msg = await ws.receive(timeout=8) + return { + "connected_without_gateway_token": True, + "rfb_banner_received": msg.type == aiohttp.WSMsgType.BINARY + and msg.data.startswith(b"RFB "), + } + except aiohttp.ClientResponseError as error: + return {"connected_without_gateway_token": False, "http_status": error.status} + except Exception as error: + return { + "connected_without_gateway_token": False, + "error_type": type(error).__name__, + } + + probe = asyncio.run(gateway_probe()) + REPORT["gateway_observation"] = { + "unauthenticated_static_status": unauth.status_code, + "authenticated_static_status": auth.status_code, + "upstream_content_type": auth.headers.get("content-type"), + "upstream_disposition": auth.headers.get("content-disposition"), + "html_document_observed": b"({width:c.width,height:c.height})"), + "view_only_setting": page.locator("#noVNC_setting_view_only").is_checked(), + } + save() + canvas.screenshot(path=str(OUT / "stream-canvas.png")) + check( + "live-stream-renders", + bool(box and box["width"] > 100 and proxy.ws_open > 0 and proxy.ws_bytes > 1000), + websocket_sessions=proxy.ws_open, + transported_bytes=proxy.ws_bytes, + canvas_width=round(box["width"]), + ) + page.screenshot(path=str(OUT / "03-live-stream-view.png")) + REPORT["timeline"].append( + { + "at_seconds": round(time.monotonic() - START, 2), + "actor": "simulated-operator", + "action": "Observe authenticated live stream through controlled backend proxy", + "file": "03-live-stream-view.png", + "sha256": hashlib.sha256( + (OUT / "03-live-stream-view.png").read_bytes() + ).hexdigest(), + } + ) + save() + + # A second controller uses the real noVNC canvas; it has no sandbox gateway token. + def point(x: float, y: float) -> tuple[float, float]: + return (box["x"] + x * box["width"] / 1024, box["y"] + y * box["height"] / 768) + + x, y = point(300, 250) + page.mouse.click(x, y) + page.wait_for_timeout(400) + page.keyboard.press("End") + for _ in range(16): + page.keyboard.press("Backspace") + page.wait_for_timeout(100) + for character in "operator-step": + page.keyboard.press(character) + page.wait_for_timeout(200) + page.wait_for_timeout(1500) + x, y = point(190, 322) + page.mouse.click(x, y) + page.wait_for_timeout(1000) + shot( + desktop, + "operator-diagnostic", + "simulated-operator", + "Independent screenshot after VNC input before checking state", + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + s = state(desktop) + if s["latest"] == "operator-step": + break + time.sleep(0.3) + check( + "operator-handover-effect", + s["latest"] == "operator-step", + actor="simulated operator through independent VNC client", + sequence=[x["value"] for x in s["events"]], + ) + shot( + desktop, + "04-operator-state", + "simulated-operator", + "Type and commit through the second controller", + ) + s = commit(desktop, "agent-resumed") + (OUT / "gui-state.json").write_text(desktop.files.read("/tmp/abx-gui-state.json")) + check( + "agent-resume-same-state", + [x["value"] for x in s["events"]] == ["agent-start", "operator-step", "agent-resumed"], + sequence=[x["value"] for x in s["events"]], + ) + shot( + desktop, + "05-agent-resumed", + "agent", + "Resume on the same desktop and preserve prior operator state", + ) + browser.close() + stream.stop() + stream = None + check( + "stream-stopped", True, websocket_sessions=proxy.ws_open, transported_bytes=proxy.ws_bytes + ) + proxy.close() + proxy = None + # Replay is an offline sequence of actual screenshots, not a claimed video recorder. + timeline = REPORT["timeline"] + (OUT / "timeline.json").write_text(json.dumps(timeline, indent=2)) + data = json.dumps(timeline).replace("= 5, + screenshots=len(timeline), + format="offline screenshot timeline with SHA-256; not full-motion video", + ) +except Exception as e: + REPORT["failure"] = {"type": type(e).__name__, "message": safe(str(e))[:1000]} + save() + print(json.dumps({"failure": REPORT["failure"]}), flush=True) +finally: + if proxy: + with suppress(Exception): + proxy.close() + if stream: + with suppress(Exception): + stream.stop() + if desktop: + try: + sid = desktop.sandbox_id + deleted = desktop.kill() + try: + Sandbox.get_info(sid) + absent = False + except NotFoundException: + absent = True + check( + "desktop-cleanup", deleted and absent, deleted=deleted, independently_absent=absent + ) + except Exception as e: + REPORT["cleanup_error"] = {"type": type(e).__name__, "message": safe(str(e))[:400]} + REPORT["result"] = ( + "PASS" if not REPORT.get("failure") and not REPORT.get("cleanup_error") else "FAIL" + ) + REPORT["result_scope"] = ( + "GUI, password-authenticated VNC, simulated handover and screenshot replay only; " + "gateway token enforcement is separately observed" + ) + save() + print(json.dumps({"result": REPORT["result"]}), flush=True) + if REPORT["result"] != "PASS": + sys.exit(1) diff --git a/e2e/skill_tools_acceptance.py b/e2e/skill_tools_acceptance.py new file mode 100644 index 00000000..909f334b --- /dev/null +++ b/e2e/skill_tools_acceptance.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Real SDK repository/preview/MCP acceptance; creates and deletes one owned sandbox. + +Install the source-compatible agent-sandbox-client in this Python environment. +Credentials are read only from the environment. No account keys enter the sandbox. +""" + +import argparse +import hashlib +import json +import logging +import os +import secrets +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +from agent_sandbox import Sandbox + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "--output", type=Path, required=True, help="New sanitized JSON evidence file (must not exist)" +) +args = parser.parse_args() +if args.output.exists(): + parser.error("Evidence already exists; choose a new output file") +args.output.parent.mkdir(parents=True, exist_ok=True) +logging.disable(logging.CRITICAL) + + +def sanitize(text): + for key, value in os.environ.items(): + if value and any( + word in key.upper() for word in ("TOKEN", "KEY", "SECRET", "PASSWORD", "CREDENTIAL") + ): + text = text.replace(value, "[REDACTED]") + return text + + +report = { + "checks": [], + "resources": [], + "scope": ( + "Real SDK-controlled sandbox; actual public repository, MCP npm packages " + "and application-level expiring preview" + ), +} + + +def save(): + args.output.write_text(sanitize(json.dumps(report, indent=2))) + + +def record(name, ok, **detail): + row = {"name": name, "passed": bool(ok), **detail} + report["checks"].append(row) + save() + print(sanitize(json.dumps(row)), flush=True) + + +sb = None +try: + sb = Sandbox.create( + template=os.environ.get("ABX_SKILL_RUNTIME_TEMPLATE", "code-interpreter-v1"), + timeout=1200, + secure=False, + metadata={ + "purpose": os.environ.get("ABX_SKILL_RUN_PREFIX", "skills-tools-live")[:64], + "run": secrets.token_hex(8), + }, + request_timeout=45, + ) + report["resources"].append({"id": sb.sandbox_id, "kind": "sandbox"}) + save() + + def run(name, cmd, timeout=120, expect=None): + r = sb.commands.run(cmd, timeout=timeout) + ok = r.exit_code == 0 and (expect is None or expect in r.stdout) + record( + name, + ok, + exit_code=r.exit_code, + stdout=sanitize(r.stdout[-4000:]), + stderr=sanitize(r.stderr[-2000:]), + ) + if not ok: + raise RuntimeError(name + " failed") + return r.stdout + + remote = "/home/user/skills-tools-live" + run("prepare-owned-workdir", f"mkdir -p {remote}") + run( + "public-repository-checkout-pinned", + f"git init -q {remote}/repo && " + f"git -C {remote}/repo remote add origin " + "https://github.com/alibaba-agent-sandbox/awesome-templates.git && " + "fetched=0; for attempt in 1 2; do " + f"if timeout 90s git -C {remote}/repo -c http.version=HTTP/1.1 " + "-c http.lowSpeedLimit=1 -c http.lowSpeedTime=30 fetch --depth 1 origin " + "1a8c81f59d22fe10b987ffe5f9b59d15b5ed0ded; then fetched=1; break; fi; done; " + 'test "$fetched" = 1 && ' + f"git -C {remote}/repo checkout --detach FETCH_HEAD && " + f"git -C {remote}/repo rev-parse HEAD", + 220, + expect="1a8c81f59d22fe10b987ffe5f9b59d15b5ed0ded", + ) + run("repository-app-syntax-test", f"node --check {remote}/repo/node-web/index.js") + run( + "repository-app-start", + f"PORT=3000 nohup node {remote}/repo/node-web/index.js >{remote}/app.log 2>&1 " + f"{remote}/app.pid", + ) + run( + "repository-app-business-response", + 'python3 -c "import urllib.request;assert ' + "urllib.request.urlopen('http://127.0.0.1:3000').read()==b'node-web ready\\n';" + " print('HTTP200 exact node-web response')\"", + expect="HTTP200", + ) + proxy = r"""import http.server, urllib.parse, urllib.request, secrets, time +import hmac, hashlib, json, os + +secret = secrets.token_bytes(32) + + +def signature(t): + return hmac.new(secret, str(t).encode(), hashlib.sha256).hexdigest() + + +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + q = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) + try: + t = int(q.get("expires", ["0"])[0]) + sig = q.get("signature", [""])[0] + if t <= time.time() or not hmac.compare_digest(sig, signature(t)): + raise ValueError() + data = urllib.request.urlopen("http://127.0.0.1:3000").read() + self.send_response(200) + self.end_headers() + self.wfile.write(data) + except ValueError: + self.send_response(403) + self.end_headers() + + def log_message(self, *a): + pass + + +server = http.server.HTTPServer(("0.0.0.0", 3080), H) +t = int(time.time()) + 20 +with open("/home/user/skills-tools-live/preview.json", "w") as f: + json.dump({"path": "/?expires=" + str(t) + "&signature=" + signature(t), "expires": t}, f) +os.chmod("/home/user/skills-tools-live/preview.json", 0o600) +server.serve_forever() +""" + sb.files.write(remote + "/preview.py", proxy) + run( + "preview-start", + f"nohup python3 {remote}/preview.py >{remote}/preview.log 2>&1 {remote}/preview.pid", + ) + time.sleep(1) + descriptor = json.loads(sb.files.read(remote + "/preview.json")) + host = sb.get_host(3080) + url = "https://" + host + descriptor["path"] + + def status(target): + try: + with urllib.request.urlopen(target, timeout=15) as response: + return response.status, response.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + unsigned = status("https://" + host + "/") + valid = status(url) + tampered = status(url + "x") + record( + "external-application-signed-preview-valid", + valid == (200, b"node-web ready\n"), + status=valid[0], + body_sha256=hashlib.sha256(valid[1]).hexdigest(), + expiry_seconds=20, + contract="application HMAC; provider gateway-native signing is not asserted", + ) + record( + "external-preview-unsigned-and-tampered-rejected", + unsigned[0] == 403 and tampered[0] == 403, + unsigned_status=unsigned[0], + tampered_status=tampered[0], + ) + while time.time() <= descriptor["expires"] + 1: + time.sleep(1) + expired = status(url) + record("external-preview-expired-rejected", expired[0] == 403, status=expired[0]) + readback = sb.files.read(remote + "/repo/node-web/index.js") + record( + "repository-source-hash-readback", + True, + sha256=hashlib.sha256(readback.encode()).hexdigest(), + commit="1a8c81f59d22fe10b987ffe5f9b59d15b5ed0ded", + ) + mcp_client = r"""import subprocess, json, sys, pathlib + +root = pathlib.Path(sys.argv[1]) +version = sys.argv[2] +log = open(root / ("mcp-" + version + ".log"), "w") +p = subprocess.Popen( + [ + "node", + str(root / "tools/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js"), + str(root / "data"), + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=log, + text=True, +) + + +def req(id, method, params): + p.stdin.write( + json.dumps({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + "\n" + ) + p.stdin.flush() + while True: + line = p.stdout.readline() + if not line: + raise RuntimeError("MCP server exited") + msg = json.loads(line) + if msg.get("id") == id: + if "error" in msg: + raise RuntimeError(str(msg["error"])) + return msg["result"] + + +try: + init = req( + 1, + "initialize", + { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "skills-acceptance", "version": "1.0"}, + }, + ) + p.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") + p.stdin.flush() + tools = req(2, "tools/list", {}) + assert any(t["name"] == "read_file" for t in tools["tools"]) + result = req( + 3, + "tools/call", + {"name": "read_file", "arguments": {"path": str(root / "data/business.json")}}, + ) + text = "\n".join(x.get("text", "") for x in result["content"]) + assert "385" in text and not result.get("isError") + print( + json.dumps( + { + "version": version, + "tool_count": len(tools["tools"]), + "business_output": json.loads(text), + "protocol": init["protocolVersion"], + } + ) + ) +finally: + p.terminate() + p.wait(timeout=10) + log.close() +""" + sb.files.write(remote + "/mcp_client.py", mcp_client) + run( + "mcp-fixture-data", + f"mkdir -p {remote}/data && printf '%s' '{{\"sum_of_squares\":385}}' " + f">{remote}/data/business.json", + ) + for version in ["2026.7.10", "2026.8.31"]: + run( + "third-party-mcp-install-" + version, + f"timeout 240s npm install --prefix {remote}/tools --ignore-scripts --no-audit" + f" --no-fund @modelcontextprotocol/server-filesystem@{version}", + 240, + ) + run( + "third-party-mcp-initialize-discover-business-tool-" + version, + f"python3 {remote}/mcp_client.py {remote} {version}", + 90, + expect="385", + ) + run( + "third-party-mcp-log-readback-" + version, + f"test -s {remote}/mcp-{version}.log && tail -5 {remote}/mcp-{version}.log", + ) + run( + "cleanup-owned-app-and-preview-processes", + f'kill "$(cat {remote}/app.pid)" "$(cat {remote}/preview.pid)" && ' + f"rm -rf {remote}/tools {remote}/data && " + f"test ! -e {remote}/tools && test ! -e {remote}/data", + ) + run( + "third-party-tools-process-cleanup-check", + "python3 -c \"import pathlib; bad=[]; own=str(__import__('os').getpid()); " + "[(bad.append(p.name)) for p in pathlib.Path('/proc').iterdir() if " + "p.name.isdigit() and p.name != own and b'server-filesystem/dist/index.js' in" + " (p/'cmdline').read_bytes()]; assert not bad,bad;print('no MCP server " + "remains')\"", + expect="no MCP", + ) + report["workload_passed"] = all(x["passed"] for x in report["checks"]) +except Exception as e: + report["failure"] = sanitize(str(e)) + record("workload-exception", False, error=report["failure"]) +finally: + if sb: + try: + sb.kill(request_timeout=30) + try: + sb.get_info(request_timeout=15) + except Exception as e: + report["sandbox_absent"] = type(e).__name__ in ( + "NotFoundException", + "SandboxNotFoundException", + ) + record( + "owned-sandbox-cleanup-independent-readback", report.get("sandbox_absent", False) + ) + except Exception as e: + record("owned-sandbox-cleanup", False, error=sanitize(str(e))) + save() +sys.exit(0 if report.get("workload_passed") and report.get("sandbox_absent") else 1) diff --git a/e2e/test_09_mcp_lifecycle.py b/e2e/test_09_mcp_lifecycle.py index 318a2c32..00f406a1 100644 --- a/e2e/test_09_mcp_lifecycle.py +++ b/e2e/test_09_mcp_lifecycle.py @@ -33,8 +33,6 @@ async def scenario() -> str: { "template": helpers.OFFICIAL_BASE, "timeout": 600, - "cpu": 2, - "memory": 1024, }, owner, ) @@ -60,12 +58,13 @@ async def scenario() -> str: assert renewed.get("success") is True assert not observer._sandboxes await observer.shutdown() - refreshed = await owner._sandboxes[sandbox_id].refresh_info() + owner_sandbox = await owner.get_sandbox(refresh_lifetime=False) + refreshed = await owner_sandbox.refresh_info() assert refreshed.status.value == "running" assert refreshed.end_at is not None and refreshed.end_at.isoformat() != before updated = await dispatch_tool("set_sandbox_timeout", {"timeout": 600}, owner) assert updated.get("success") is True - assert owner._lifetimes[sandbox_id] == 600 + assert owner._lifetimes[owner._sandbox_key(owner_sandbox)] == 600 path = helpers.unique_dir() + "-mcp.txt" written = await dispatch_tool("write_file", {"path": path, "content": "mcp-e2e"}, owner) assert written.get("success") is True @@ -107,3 +106,141 @@ def listed() -> bool: # Existing lifecycle tests document this control-plane operation as eventually # consistent; retry only the list visibility assertion, never creation. helpers.eventually(listed, timeout=40, interval=2, what="MCP sandbox list visibility") + + +def test_mcp_borrower_shutdown_preserves_other_session_sandbox(run, tracker): # type: ignore[no-untyped-def] + import pytest + + from agent_sandbox import ProviderSandbox + from agent_sandbox.agent.mcp import SandboxManager + from agent_sandbox.agent.tools import dispatch_tool + from agent_sandbox.models.errors import SandboxNotFoundError + + owner = SandboxManager(template=helpers.OFFICIAL_BASE) + borrower = SandboxManager() + + async def scenario() -> None: + sandbox_id = None + try: + created = await dispatch_tool("create_sandbox", {"timeout": 300}, owner) + assert "error" not in created + sandbox_id = created["sandbox_id"] + tracker.sandbox(sandbox_id) + borrowed = await dispatch_tool( + "run_command", + {"sandbox_id": sandbox_id, "command": "printf borrowed-ok"}, + borrower, + ) + assert borrowed.get("stdout") == "borrowed-ok" + assert borrower._sandboxes + await borrower.shutdown() + # This command must still reach the original cloud sandbox after + # the other session has closed its borrowed client connection. + surviving = await dispatch_tool("run_command", {"command": "printf owner-alive"}, owner) + assert surviving.get("stdout") == "owner-alive" + finally: + await borrower.shutdown() + await owner.shutdown() + with pytest.raises(SandboxNotFoundError): + await ProviderSandbox.get_info_by_id(sandbox_id) + + run(scenario()) + + +def test_mcp_task_qualified_handle_survives_session_replacement(run, tracker): # type: ignore[no-untyped-def] + import pytest + + from agent_sandbox import ProviderSandbox + from agent_sandbox.agent.mcp import SandboxManager + from agent_sandbox.agent.tools import dispatch_tool + from agent_sandbox.models.errors import SandboxNotFoundError + + owner = SandboxManager(template=helpers.OFFICIAL_BASE) + observer = SandboxManager() + + async def scenario() -> None: + sandbox_id = None + qualified_id = None + try: + created = await dispatch_tool("create_sandbox", {"timeout": 300}, owner) + assert "error" not in created + sandbox_id = created["sandbox_id"] + tracker.sandbox(sandbox_id) + sandbox = await owner.get_sandbox(refresh_lifetime=False) + qualified_id = sandbox.provider + ":" + sandbox_id + started = await dispatch_tool( + "start_task", + {"command": "printf before; sleep 2; printf after", "timeout": 15}, + owner, + ) + assert "error" not in started and started["retained_until_sandbox_ttl"] + assert started["sandbox_id"] == qualified_id + handle = {key: started[key] for key in ("sandbox_id", "task_id")} + await owner.shutdown() + + async def completed(): + result = await dispatch_tool("get_task", handle, observer) + assert "error" not in result + return result if result["state"] != "running" else None + + result = await helpers.eventually_async(completed, timeout=30, interval=1) + assert result["state"] == "succeeded" and result["exit_code"] == 0 + assert result["stdout"] == "beforeafter" + assert not observer._sandboxes and observer._default_sandbox is None + finally: + await observer.shutdown() + await owner.shutdown() + if sandbox_id is not None: + await ProviderSandbox.kill_by_id(qualified_id or sandbox_id) + with pytest.raises(SandboxNotFoundError): + await ProviderSandbox.get_info_by_id(qualified_id or sandbox_id) + + run(scenario()) + + +def test_mcp_explicit_resources_verified_or_new_sandbox_rolled_back(run, tracker): # type: ignore[no-untyped-def] + """The base template currently fixes resources; verify actual matching and rejection.""" + from agent_sandbox import ProviderSandbox + from agent_sandbox.agent.mcp import SandboxManager + from agent_sandbox.agent.tools import dispatch_tool + from agent_sandbox.models.errors import SandboxNotFoundError + + owner = SandboxManager(template=helpers.OFFICIAL_BASE) + + async def scenario() -> None: + try: + baseline = await dispatch_tool("create_sandbox", {"timeout": 300}, owner) + assert "error" not in baseline + tracker.sandbox(baseline["sandbox_id"]) + original = await owner.get_sandbox(refresh_lifetime=False) + info = await original.refresh_info() + assert type(info.cpu_count) is int and info.cpu_count > 0 + assert type(info.memory_mb) is int and info.memory_mb > 0 + matching = {"cpu": info.cpu_count, "memory": info.memory_mb, "timeout": 300} + created = await dispatch_tool("create_sandbox", matching, owner) + assert "error" not in created + tracker.sandbox(created["sandbox_id"]) + actual = await owner.inspect_sandbox(created["sandbox_id"]) + assert (actual.cpu_count, actual.memory_mb) == (info.cpu_count, info.memory_mb) + # Bounded differences from this actual template; never guess a large shape. + rejected = await dispatch_tool( + "create_sandbox", + {"cpu": info.cpu_count + 1, "memory": info.memory_mb + 256, "timeout": 300}, + owner, + ) + tracker.sandbox(rejected["sandbox_id"]) + assert rejected["category"] == "resource_configuration", rejected + assert rejected["cleanup_verified"] is True, rejected + try: + await ProviderSandbox.get_info_by_id(rejected["sandbox_id"]) + except SandboxNotFoundError: + pass + else: + raise AssertionError("Mismatched new sandbox still exists") + assert ( + await original.commands.run("printf existing-survives") + ).stdout == "existing-survives" + finally: + await owner.shutdown() + + run(scenario()) diff --git a/e2e/test_09_volume_mounts.py b/e2e/test_09_volume_mounts.py index 029d94f1..0ab97d3c 100644 --- a/e2e/test_09_volume_mounts.py +++ b/e2e/test_09_volume_mounts.py @@ -10,7 +10,7 @@ import pytest -from agent_sandbox import Sandbox +from agent_sandbox import ProviderSandbox as Sandbox from agent_sandbox.models.errors import SandboxNotFoundError _SCRIPT = r""" diff --git a/e2e/test_skill_storage_state.py b/e2e/test_skill_storage_state.py new file mode 100644 index 00000000..768cbe17 --- /dev/null +++ b/e2e/test_skill_storage_state.py @@ -0,0 +1,160 @@ +"""Opt-in real OSS persistence/RO acceptance using explicitly owned fixtures. + +Provisioning is deliberately outside pytest. See e2e/README.md for the exact +prerequisites; no existing Volume is selected automatically. +""" + +from __future__ import annotations + +import errno +import json +import os +import re +import shlex +import uuid +from typing import Any + +import e2e_helpers as helpers +import pytest + +from agent_sandbox import Sandbox, SandboxNotFoundException + + +def _fixtures() -> tuple[str, str, str]: + owner = os.environ.get("E2E_VOLUME_OWNER_RUN", "") + rw = os.environ.get("E2E_VOLUME_RW_NAME", "") + ro = os.environ.get("E2E_VOLUME_RO_NAME", "") + if os.environ.get("E2E_VOLUME_ACCEPTANCE") != "owned-only": + pytest.skip("requires E2E_VOLUME_ACCEPTANCE=owned-only and owned RW/RO OSS fixtures") + if not re.fullmatch(r"abx-accept-[a-z0-9]{12}", owner): + pytest.fail("E2E_VOLUME_OWNER_RUN must identify this invocation's owned abx-accept fixture") + if rw != owner + "-rw" or ro != owner + "-ro": + pytest.fail("RW/RO Volume names must exactly match E2E_VOLUME_OWNER_RUN + -rw/-ro") + template = os.environ.get("E2E_VOLUME_TEMPLATE", "").strip() + if not template: + pytest.fail("E2E_VOLUME_TEMPLATE must explicitly select an eligible mount runtime") + return rw, ro, template + + +def _command(sandbox: Any, script: str, settings: dict[str, str]) -> dict[str, Any]: + command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(json.dumps(settings)) + return json.loads(sandbox.commands.run(command, timeout=30).stdout) + + +# Never open/delete a sentinel until an exact kernel mount has been verified. +# O_EXCL and O_NOFOLLOW prevent overwriting another file, including a symlink. +_MOUNT = """import os,json,re,sys,stat,errno +c=json.loads(sys.argv[1]); p=c['path']; name=c['name']; expected=c['payload'] +assert os.path.realpath(p)==p and os.path.isdir(p) +points=[re.sub(r'\\\\([0-7]{3})',lambda m:chr(int(m[1],8)),x.split()[4]) + for x in open('/proc/self/mountinfo')] +assert p in points +d=os.open(p,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW) +""" +_WRITE = ( + _MOUNT + + """try: + f=os.open(name,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=d) + with os.fdopen(f,'w') as out:out.write(expected);out.flush();os.fsync(out.fileno()) + print(json.dumps({'written':True})) +finally:os.close(d) +""" +) +_READ = ( + _MOUNT + + """try: + f=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=d) + with os.fdopen(f) as source:assert source.read(len(expected)+1)==expected + print(json.dumps({'persisted':True})) +finally:os.close(d) +""" +) +_READ_ONLY = ( + _MOUNT + + """try: + try: + f=os.open(name+'-forbidden',os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=d) + except OSError as e:print(json.dumps({'write_errno':e.errno})) + else: + os.close(f);os.unlink(name+'-forbidden',dir_fd=d) + raise AssertionError('RO fixture permits writes') +finally:os.close(d) +""" +) +_CLEAN = ( + _MOUNT + + """try: + try:f=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=d) + except FileNotFoundError:pass + else: + with os.fdopen(f) as source: + before=os.fstat(source.fileno());assert stat.S_ISREG(before.st_mode) + assert source.read(len(expected)+1)==expected + now=os.stat(name,dir_fd=d,follow_symlinks=False) + assert (before.st_dev,before.st_ino)==(now.st_dev,now.st_ino) + os.unlink(name,dir_fd=d) + print(json.dumps({'clean':True})) +finally:os.close(d) +""" +) + + +def test_owned_oss_volume_persists_across_recreation_and_enforces_read_only( + request: pytest.FixtureRequest, +) -> None: + rw, ro, template = _fixtures() + tracker = request.getfixturevalue("tracker") + mount = "/mnt/abx-e2e/acceptance" + settings = { + "path": mount, + "name": ".abx-owned-" + uuid.uuid4().hex, + "payload": "abx-owned-persistence-" + uuid.uuid4().hex, + } + owned: list[Any] = [] + writer = None + sentinel_attempted = False + sentinel_clean = False + + def create(volume: str) -> Any: + sandbox = Sandbox.create( + template=template, + timeout=300, + metadata=helpers.tag_metadata("owned-oss-persistence-ro"), + volume_mounts={mount: volume}, + ) + tracker.sandbox(sandbox.sandbox_id) + owned.append(sandbox) + return sandbox + + try: + writer = create(rw) + sentinel_attempted = True + assert _command(writer, _WRITE, settings) == {"written": True} + first_id = writer.sandbox_id + assert writer.kill() + writer = None + with pytest.raises(SandboxNotFoundException): + Sandbox.get_info(first_id) + # A fresh creation, rather than reconnecting the same instance, proves + # data outlives the first sandbox's actual deletion. + writer = create(rw) + assert _command(writer, _READ, settings) == {"persisted": True} + reader = create(ro) + assert _command(reader, _READ, settings) == {"persisted": True} + result = _command(reader, _READ_ONLY, settings) + assert result["write_errno"] in (errno.EROFS, errno.EACCES, errno.EPERM) + assert _command(writer, _CLEAN, settings) == {"clean": True} + sentinel_clean = True + finally: + try: + if sentinel_attempted and not sentinel_clean: + # Recover only our exclusive sentinel through an owned sandbox; + # never remove a mismatched or symlinked object. + if writer is None: + writer = create(rw) + assert _command(writer, _CLEAN, settings) == {"clean": True} + finally: + for sandbox in owned: + Sandbox.kill(sandbox.sandbox_id) + with pytest.raises(SandboxNotFoundException): + Sandbox.get_info(sandbox.sandbox_id) diff --git a/e2e/test_standard_contract.py b/e2e/test_standard_contract.py index d415bb45..042bb64a 100644 --- a/e2e/test_standard_contract.py +++ b/e2e/test_standard_contract.py @@ -184,7 +184,10 @@ def test_standard_snapshot_restore(tracker): snapshot = None restored = None try: - sandbox.files.write("/tmp/snapshot-contract", "snapshot") + # Keep overwrite assertions inside an owned non-sticky directory. + # Some envd images reject rewriting user files directly under sticky /tmp + # even before snapshot creation; that is a separate file-service behavior. + sandbox.files.write("/tmp/standard-snapshot-contract/state", "snapshot") probe, token = _start_memory_probe(sandbox) snapshot = sandbox.create_snapshot(request_timeout=300) # Creation and list indexing are separate, eventually consistent operations. @@ -193,10 +196,10 @@ def test_standard_snapshot_restore(tracker): restored = Sandbox.create(template=snapshot.snapshot_id, timeout=300, metadata=H.tag_metadata("standard-restored")) tracker.sandbox(restored.sandbox_id) - assert restored.files.read("/tmp/snapshot-contract") == "snapshot" + assert restored.files.read("/tmp/standard-snapshot-contract/state") == "snapshot" assert restored.commands.run(probe).stdout.strip() == token - restored.files.write("/tmp/snapshot-contract", "restored-only") - assert sandbox.files.read("/tmp/snapshot-contract") == "snapshot" + restored.files.write("/tmp/standard-snapshot-contract/state", "restored-only") + assert sandbox.files.read("/tmp/standard-snapshot-contract/state") == "snapshot" finally: if restored is not None: _kill_settled(restored.sandbox_id)