withruntime 0.8.1__tar.gz → 0.8.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {withruntime-0.8.1/withruntime.egg-info → withruntime-0.8.3}/PKG-INFO +37 -10
- {withruntime-0.8.1 → withruntime-0.8.3}/README.md +36 -9
- {withruntime-0.8.1 → withruntime-0.8.3}/pyproject.toml +1 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_blaxel.py +1 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_daytona.py +11 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_e2b_sandbox.py +3 -0
- withruntime-0.8.3/tests/test_files_upload_parallel.py +76 -0
- withruntime-0.8.3/tests/test_jobs.py +165 -0
- withruntime-0.8.3/tests/test_sandbox_sessions.py +149 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_secrets.py +3 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_vercel.py +2 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/__init__.py +2 -2
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_client.py +75 -4
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/__init__.py +2 -0
- withruntime-0.8.3/withruntime/_async_products/jobs.py +230 -0
- withruntime-0.8.3/withruntime/_async_products/secrets.py +197 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_errors.py +5 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_client.py +75 -4
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/__init__.py +2 -0
- withruntime-0.8.3/withruntime/_sync_products/jobs.py +231 -0
- withruntime-0.8.3/withruntime/_sync_products/secrets.py +198 -0
- withruntime-0.8.3/withruntime/_version.py +1 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/__init__.py +7 -3
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/_async_sandbox.py +81 -3
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/_core.py +83 -3
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/_sync_sandbox.py +81 -3
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/daytona/_async_daytona.py +3 -3
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/daytona/_core.py +2 -2
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/daytona/_sync_daytona.py +3 -3
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/__init__.py +1 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/inspect_ai.py +2 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/_core.py +1 -1
- {withruntime-0.8.1 → withruntime-0.8.3/withruntime.egg-info}/PKG-INFO +37 -10
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime.egg-info/SOURCES.txt +5 -0
- withruntime-0.8.1/withruntime/_async_products/secrets.py +0 -58
- withruntime-0.8.1/withruntime/_sync_products/secrets.py +0 -59
- withruntime-0.8.1/withruntime/_version.py +0 -1
- {withruntime-0.8.1 → withruntime-0.8.3}/LICENSE +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/setup.cfg +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_account_errors.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_async_stream_cleanup.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_audit.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_billing.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_capacity_wait.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_client.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_deepagents.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_e2b_code_interpreter.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_eval_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_exec_stream.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_files_download.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_files_mode.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_harbor.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_http_tls_close.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_identity_token.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_images_v2.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_images_volumes_interpreter.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_inspect_ai.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_late_answer.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_lifecycle_settings.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_limits.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_mounts.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_network_funding.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_network_products.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_observability.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_openai_agents.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_previews_network_desktop.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_proxy.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_referrals.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_snapshot_after_fork.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_sso.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_stream_exit_close.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_switching.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_tools.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_unpack.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_volume_backups.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_wake_awake.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_wallet.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/tests/test_watch.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/audit.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/billing.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/desktop.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/images.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/interpreter.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/limits.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/mcp.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/mounts.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/network.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/network_products.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/observability.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/previews.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/referrals.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/sso.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/switching.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/volumes.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_async_products/watch.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_clock.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_connection.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_eval_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_http.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_proxy.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/audit.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/billing.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/desktop.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/images.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/interpreter.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/limits.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/mcp.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/mounts.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/network.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/network_products.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/observability.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/previews.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/referrals.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/sso.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/switching.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/volumes.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_sync_products/watch.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_tunnel.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_unpack.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/_ws.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/_async_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/_sync_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/agents.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/applications/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/applications/list_application_revisions.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/compute/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/compute/get_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/compute/update_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/functions/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/functions/create_function.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/images.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/api/workspaces.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/client.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/create_job_execution_request.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/create_job_execution_request_env.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/env.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/function.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/function_runtime.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/function_spec.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/metadata.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/models/workspace.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/client/types.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/common.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/drive.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/jobs.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/sandbox/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/core/sandbox/types.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/crewai.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/googleadk.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/langgraph.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/livekit.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/llamaindex.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/openai.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/pydantic.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/blaxel/telemetry.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/daytona/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/daytona/_async_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/daytona/_sync_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/deepagents.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/_async_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/_async_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/_core.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/_sync_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/_sync_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/code_interpreter/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/code_interpreter/_models.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/harbor.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/openai_agents.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/tools.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/_async_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/_async_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/_sync_io.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/_sync_sandbox.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/api.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/sandbox/__init__.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/vercel/sandbox/sync.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/wallet.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime/webhooks.py +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime.egg-info/dependency_links.txt +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime.egg-info/entry_points.txt +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime.egg-info/requires.txt +0 -0
- {withruntime-0.8.1 → withruntime-0.8.3}/withruntime.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withruntime
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
4
4
|
Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://withruntime.com
|
|
@@ -71,11 +71,18 @@ A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `forward_port` (any
|
|
|
71
71
|
TCP port in the sandbox on a local port), `files` (read, write,
|
|
72
72
|
list, glob, stat, move, remove, upload and download directories), `pause`,
|
|
73
73
|
`wake`, `extend`, `update`, `keep_alive`, `fork` and `snapshot`, and the
|
|
74
|
-
`interpreter`, `network`, `previews
|
|
74
|
+
`interpreter`, `network`, `previews`, `desktop`, `mounts` (your S3, R2 or Google
|
|
75
|
+
Cloud Storage bucket as a folder), `metrics` and `mcp` (servers from the MCP
|
|
76
|
+
catalog, run in the sandbox) products. A paused sandbox
|
|
75
77
|
also wakes by itself on the next call, and `Sandbox.get_or_create(name)` returns
|
|
76
|
-
the sandbox with that name or creates it.
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
the sandbox with that name or creates it. A sandbox pauses itself after 60 seconds
|
|
79
|
+
with nothing happening in it, keeping its memory and processes, so an
|
|
80
|
+
idle sandbox costs no compute; `idle_pause_seconds` sets 10 to 86,400, or 0 for
|
|
81
|
+
never. The client has `sandboxes`, `images`, `volumes`, `snapshots`,
|
|
82
|
+
`secrets`, `limits`, `feedback` and `support`; `webhooks`, `events`, `otel` and
|
|
83
|
+
`audit` to watch the account; `domains`, `ports`, `addresses`, `tunnel` and
|
|
84
|
+
`network` to connect sandboxes to your own world (paid accounts); and `mcp`,
|
|
85
|
+
`sso`, `billing`, `referrals` and `switching`. `runtime.limits.get()` (0.3.1 and later) says whether the key is read-only and
|
|
79
86
|
what its agent may still spend today. Every write carries an
|
|
80
87
|
idempotency key, so retries never do anything twice; errors are typed and carry
|
|
81
88
|
`code`, `hint` and `request_id`.
|
|
@@ -159,8 +166,8 @@ How it maps:
|
|
|
159
166
|
(every `/` becomes `-`; the tag is `latest` when none is given), so the
|
|
160
167
|
code keeps Blaxel's image string. A missing one raises `NotSupportedError`
|
|
161
168
|
with the build command, for example `npx withruntime image build
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
--dockerfile Dockerfile --name my-company-agent-image -t
|
|
170
|
+
my-company-agent-image:latest`. Volumes mount Runtime volumes of the same
|
|
164
171
|
name. On the free trial a sandbox has at most 4096 MB of memory (2 vCPUs).
|
|
165
172
|
- **Standby.** A sandbox pauses after a minute with no call (Blaxel: about 15
|
|
166
173
|
seconds). It keeps its memory and processes, and wakes on the next command,
|
|
@@ -200,17 +207,22 @@ Differences you can hit:
|
|
|
200
207
|
(`/workspace`). File calls act as the sandbox user and fall back to `sudo`
|
|
201
208
|
where only root may: they reach files a root process made, including private
|
|
202
209
|
ones, and a file they write is the sandbox user's.
|
|
203
|
-
- A
|
|
204
|
-
|
|
210
|
+
- A key sees the sandboxes it made, and another key's answer 404, unless an
|
|
211
|
+
owner or admin makes the key account-wide, as a Blaxel workspace key is
|
|
205
212
|
([teams](https://withruntime.com/docs/teams#keys-in-a-team)).
|
|
206
213
|
- Public previews need a paid sandbox.
|
|
207
214
|
- A process's `pid` is Runtime's process id, not an operating-system number.
|
|
215
|
+
- `sandbox.sessions` makes a Runtime sandbox session, and
|
|
216
|
+
`SandboxInstance.from_session` drives the sandbox with its token: commands,
|
|
217
|
+
files and previews of that one sandbox. A session lasts a day at most, and
|
|
218
|
+
`Access-Control-Allow-Origin` in `response_headers` names the page that may
|
|
219
|
+
use it.
|
|
208
220
|
|
|
209
221
|
These raise `NotSupportedError` before anything happens, naming what to use:
|
|
210
222
|
regions outside the US, read-only and ephemeral volumes, drives, `extra_args`
|
|
211
223
|
other than `iptables`, egress and subnet settings, preview headers, custom
|
|
212
224
|
domains and preview expiry, restoring a snapshot in place (fork from it
|
|
213
|
-
instead),
|
|
225
|
+
instead), codegen, `system`, schedules, and Blaxel's agents, models,
|
|
214
226
|
tools, jobs and applications. The sync module is generated from the async one
|
|
215
227
|
by `scripts/generate_dropin_sync.py`.
|
|
216
228
|
|
|
@@ -223,6 +235,21 @@ gives LangChain, CrewAI, LlamaIndex, Pydantic AI, Google ADK and any framework
|
|
|
223
235
|
that takes typed functions four sandbox tools. See
|
|
224
236
|
https://withruntime.com/docs/frameworks.
|
|
225
237
|
|
|
238
|
+
## Reliability and support
|
|
239
|
+
|
|
240
|
+
- **Status:** https://withruntime.com/status checks the API from outside every
|
|
241
|
+
two minutes and starts a real sandbox every ten, with every day's record
|
|
242
|
+
kept; also as https://withruntime.com/status.md and
|
|
243
|
+
https://withruntime.com/status.json.
|
|
244
|
+
- **Uptime Promise:** paid accounts are promised 99% API uptime
|
|
245
|
+
each calendar month, and a month below it pays 10% of that
|
|
246
|
+
month's charges back as credit, automatically
|
|
247
|
+
(https://withruntime.com/legal/sla).
|
|
248
|
+
- **Security and compliance:** https://withruntime.com/docs/trust.
|
|
249
|
+
- **What changed:** https://withruntime.com/changelog.
|
|
250
|
+
- **Help:** `npx withruntime support "<question>"`, or
|
|
251
|
+
`npx withruntime feedback "<text>"` for anything that got in your way.
|
|
252
|
+
|
|
226
253
|
Docs: https://withruntime.com/docs/python.
|
|
227
254
|
|
|
228
255
|
The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
|
|
@@ -48,11 +48,18 @@ A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `forward_port` (any
|
|
|
48
48
|
TCP port in the sandbox on a local port), `files` (read, write,
|
|
49
49
|
list, glob, stat, move, remove, upload and download directories), `pause`,
|
|
50
50
|
`wake`, `extend`, `update`, `keep_alive`, `fork` and `snapshot`, and the
|
|
51
|
-
`interpreter`, `network`, `previews
|
|
51
|
+
`interpreter`, `network`, `previews`, `desktop`, `mounts` (your S3, R2 or Google
|
|
52
|
+
Cloud Storage bucket as a folder), `metrics` and `mcp` (servers from the MCP
|
|
53
|
+
catalog, run in the sandbox) products. A paused sandbox
|
|
52
54
|
also wakes by itself on the next call, and `Sandbox.get_or_create(name)` returns
|
|
53
|
-
the sandbox with that name or creates it.
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
the sandbox with that name or creates it. A sandbox pauses itself after 60 seconds
|
|
56
|
+
with nothing happening in it, keeping its memory and processes, so an
|
|
57
|
+
idle sandbox costs no compute; `idle_pause_seconds` sets 10 to 86,400, or 0 for
|
|
58
|
+
never. The client has `sandboxes`, `images`, `volumes`, `snapshots`,
|
|
59
|
+
`secrets`, `limits`, `feedback` and `support`; `webhooks`, `events`, `otel` and
|
|
60
|
+
`audit` to watch the account; `domains`, `ports`, `addresses`, `tunnel` and
|
|
61
|
+
`network` to connect sandboxes to your own world (paid accounts); and `mcp`,
|
|
62
|
+
`sso`, `billing`, `referrals` and `switching`. `runtime.limits.get()` (0.3.1 and later) says whether the key is read-only and
|
|
56
63
|
what its agent may still spend today. Every write carries an
|
|
57
64
|
idempotency key, so retries never do anything twice; errors are typed and carry
|
|
58
65
|
`code`, `hint` and `request_id`.
|
|
@@ -136,8 +143,8 @@ How it maps:
|
|
|
136
143
|
(every `/` becomes `-`; the tag is `latest` when none is given), so the
|
|
137
144
|
code keeps Blaxel's image string. A missing one raises `NotSupportedError`
|
|
138
145
|
with the build command, for example `npx withruntime image build
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
--dockerfile Dockerfile --name my-company-agent-image -t
|
|
147
|
+
my-company-agent-image:latest`. Volumes mount Runtime volumes of the same
|
|
141
148
|
name. On the free trial a sandbox has at most 4096 MB of memory (2 vCPUs).
|
|
142
149
|
- **Standby.** A sandbox pauses after a minute with no call (Blaxel: about 15
|
|
143
150
|
seconds). It keeps its memory and processes, and wakes on the next command,
|
|
@@ -177,17 +184,22 @@ Differences you can hit:
|
|
|
177
184
|
(`/workspace`). File calls act as the sandbox user and fall back to `sudo`
|
|
178
185
|
where only root may: they reach files a root process made, including private
|
|
179
186
|
ones, and a file they write is the sandbox user's.
|
|
180
|
-
- A
|
|
181
|
-
|
|
187
|
+
- A key sees the sandboxes it made, and another key's answer 404, unless an
|
|
188
|
+
owner or admin makes the key account-wide, as a Blaxel workspace key is
|
|
182
189
|
([teams](https://withruntime.com/docs/teams#keys-in-a-team)).
|
|
183
190
|
- Public previews need a paid sandbox.
|
|
184
191
|
- A process's `pid` is Runtime's process id, not an operating-system number.
|
|
192
|
+
- `sandbox.sessions` makes a Runtime sandbox session, and
|
|
193
|
+
`SandboxInstance.from_session` drives the sandbox with its token: commands,
|
|
194
|
+
files and previews of that one sandbox. A session lasts a day at most, and
|
|
195
|
+
`Access-Control-Allow-Origin` in `response_headers` names the page that may
|
|
196
|
+
use it.
|
|
185
197
|
|
|
186
198
|
These raise `NotSupportedError` before anything happens, naming what to use:
|
|
187
199
|
regions outside the US, read-only and ephemeral volumes, drives, `extra_args`
|
|
188
200
|
other than `iptables`, egress and subnet settings, preview headers, custom
|
|
189
201
|
domains and preview expiry, restoring a snapshot in place (fork from it
|
|
190
|
-
instead),
|
|
202
|
+
instead), codegen, `system`, schedules, and Blaxel's agents, models,
|
|
191
203
|
tools, jobs and applications. The sync module is generated from the async one
|
|
192
204
|
by `scripts/generate_dropin_sync.py`.
|
|
193
205
|
|
|
@@ -200,6 +212,21 @@ gives LangChain, CrewAI, LlamaIndex, Pydantic AI, Google ADK and any framework
|
|
|
200
212
|
that takes typed functions four sandbox tools. See
|
|
201
213
|
https://withruntime.com/docs/frameworks.
|
|
202
214
|
|
|
215
|
+
## Reliability and support
|
|
216
|
+
|
|
217
|
+
- **Status:** https://withruntime.com/status checks the API from outside every
|
|
218
|
+
two minutes and starts a real sandbox every ten, with every day's record
|
|
219
|
+
kept; also as https://withruntime.com/status.md and
|
|
220
|
+
https://withruntime.com/status.json.
|
|
221
|
+
- **Uptime Promise:** paid accounts are promised 99% API uptime
|
|
222
|
+
each calendar month, and a month below it pays 10% of that
|
|
223
|
+
month's charges back as credit, automatically
|
|
224
|
+
(https://withruntime.com/legal/sla).
|
|
225
|
+
- **Security and compliance:** https://withruntime.com/docs/trust.
|
|
226
|
+
- **What changed:** https://withruntime.com/changelog.
|
|
227
|
+
- **Help:** `npx withruntime support "<question>"`, or
|
|
228
|
+
`npx withruntime feedback "<text>"` for anything that got in your way.
|
|
229
|
+
|
|
203
230
|
Docs: https://withruntime.com/docs/python.
|
|
204
231
|
|
|
205
232
|
The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "withruntime"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.3"
|
|
8
8
|
description = "Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -1053,7 +1053,7 @@ class Interpreter(Base):
|
|
|
1053
1053
|
class Unsupported(Base):
|
|
1054
1054
|
def test_every_gap_is_importable_and_names_its_alternative(self):
|
|
1055
1055
|
box = self.create()
|
|
1056
|
-
calls = [lambda: box.
|
|
1056
|
+
calls = [lambda: box.codegen.fastapply("a", "b"), lambda: box.system.upgrade(),
|
|
1057
1057
|
lambda: box.drives.mount("d", "/d"), lambda: box.schedules.list(),
|
|
1058
1058
|
lambda: SyncSandboxInstance.from_session({}), lambda: bl.BlAgent("x"), lambda: bl.bl_model("m"),
|
|
1059
1059
|
lambda: bl.settings.region, lambda: bl.SyncVolumeInstance.create({}),
|
|
@@ -238,6 +238,17 @@ class Process(Base):
|
|
|
238
238
|
with self.assertRaises(NotSupportedError):
|
|
239
239
|
sandbox.process.create_pty_session()
|
|
240
240
|
|
|
241
|
+
def test_refusals_point_at_what_runtime_has(self):
|
|
242
|
+
sandbox = self.daytona.create()
|
|
243
|
+
for call, text in ((sandbox.update_secrets, "withruntime secrets set"),
|
|
244
|
+
(sandbox.get_metrics, "sandbox.withruntime.metrics("),
|
|
245
|
+
(sandbox.create_ssh_access, "withruntime sandbox ssh"),
|
|
246
|
+
(lambda: self.daytona.create(CreateSandboxFromSnapshotParams(secrets={"A": "s"})),
|
|
247
|
+
"withruntime secrets set")):
|
|
248
|
+
with self.assertRaises(NotSupportedError) as caught:
|
|
249
|
+
call()
|
|
250
|
+
self.assertIn(text, caught.exception.alternative)
|
|
251
|
+
|
|
241
252
|
|
|
242
253
|
class Files(Base):
|
|
243
254
|
def test_files(self):
|
|
@@ -380,6 +380,9 @@ class ForksPortsAndGaps(Base):
|
|
|
380
380
|
lambda: e2b.Secret.create("s"), lambda: e2b.wait_for_port(3000)):
|
|
381
381
|
with self.assertRaises(NotSupportedException):
|
|
382
382
|
call()
|
|
383
|
+
with self.assertRaises(NotSupportedException) as caught:
|
|
384
|
+
e2b.Secret.create("s")
|
|
385
|
+
self.assertIn("withruntime secrets set", str(caught.exception))
|
|
383
386
|
|
|
384
387
|
|
|
385
388
|
class Async(unittest.TestCase):
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""A large write keeps eight chunks in flight, in both client forms.
|
|
2
|
+
|
|
3
|
+
Each chunk's reply waits on the API and the guest, so the link idles while
|
|
4
|
+
every chunk in flight waits: four in flight took 100 MB 17-18 s from a
|
|
5
|
+
12.6 MB/s home link, eight took 11 s (27 September 2026)."""
|
|
6
|
+
import asyncio
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from withruntime._async_client import AsyncFiles
|
|
11
|
+
from withruntime._sync_client import Files
|
|
12
|
+
|
|
13
|
+
MIB = 1048576
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Counter:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.now = self.most = self.chunks = 0
|
|
19
|
+
self.lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def enter(self):
|
|
22
|
+
with self.lock:
|
|
23
|
+
self.now += 1
|
|
24
|
+
self.chunks += 1
|
|
25
|
+
self.most = max(self.most, self.now)
|
|
26
|
+
|
|
27
|
+
def leave(self):
|
|
28
|
+
with self.lock:
|
|
29
|
+
self.now -= 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Transport:
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.count = Counter()
|
|
35
|
+
|
|
36
|
+
def json(self, method, path, body):
|
|
37
|
+
return {'uploadId': 'upload', 'chunkBytes': MIB} if path.endswith('/uploads') else {}
|
|
38
|
+
|
|
39
|
+
def send(self, method, path, **kwargs):
|
|
40
|
+
self.count.enter()
|
|
41
|
+
time.sleep(0.02)
|
|
42
|
+
self.count.leave()
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def read(self):
|
|
46
|
+
return b''
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsyncTransport(Transport):
|
|
50
|
+
async def json(self, *args, **kwargs):
|
|
51
|
+
return super().json(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
async def send(self, method, path, **kwargs):
|
|
54
|
+
self.count.enter()
|
|
55
|
+
await asyncio.sleep(0.02)
|
|
56
|
+
self.count.leave()
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def read(self):
|
|
60
|
+
return b''
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UploadParallel(unittest.TestCase):
|
|
64
|
+
def test_sync_keeps_eight_chunks_in_flight(self):
|
|
65
|
+
transport = Transport()
|
|
66
|
+
Files(transport, 'sandbox').write('/workspace/big.bin', b'x' * (16 * MIB))
|
|
67
|
+
self.assertEqual((transport.count.chunks, transport.count.most), (16, 8))
|
|
68
|
+
|
|
69
|
+
def test_async_keeps_eight_chunks_in_flight(self):
|
|
70
|
+
transport = AsyncTransport()
|
|
71
|
+
asyncio.run(AsyncFiles(transport, 'sandbox').write('/workspace/big.bin', b'x' * (16 * MIB)))
|
|
72
|
+
self.assertEqual((transport.count.chunks, transport.count.most), (16, 8))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == '__main__':
|
|
76
|
+
unittest.main()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""runtime.jobs and the jobs copy of runtime.secrets against a stub API, sync and
|
|
2
|
+
async. The routes run over Postgres in packages/cloud (product-api.test.ts)."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import unittest
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
|
|
9
|
+
from withruntime import (AsyncRuntime, InvalidRequestError, Runtime, SecretPartlyStoredError,
|
|
10
|
+
ServiceUnavailableError)
|
|
11
|
+
|
|
12
|
+
JOB_ID = "11111111-1111-4111-8111-111111111111"
|
|
13
|
+
RUN_ID = "22222222-2222-4222-8222-222222222222"
|
|
14
|
+
SECRET_ID = "33333333-3333-4333-8333-333333333333"
|
|
15
|
+
JOB = {"id": JOB_ID, "name": "nightly", "state": "active", "nextRunAt": 1}
|
|
16
|
+
RUN = {"id": RUN_ID, "jobId": JOB_ID, "state": "succeeded", "exitCode": 0}
|
|
17
|
+
COPY = {"id": SECRET_ID, "name": "DB_PASSWORD", "version": 2, "deletedAt": None}
|
|
18
|
+
EGRESS = {"name": "API_TOKEN", "hosts": ["api.example.com"], "placeholder": "rtsec_x", "enforced": True}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Stub(BaseHTTPRequestHandler):
|
|
22
|
+
seen: list = []
|
|
23
|
+
mode = "ok"
|
|
24
|
+
copies: list = []
|
|
25
|
+
|
|
26
|
+
def log_message(self, *args):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def answer(self, value, status=200):
|
|
30
|
+
data = json.dumps(value).encode()
|
|
31
|
+
self.send_response(status)
|
|
32
|
+
self.send_header("content-type", "application/json")
|
|
33
|
+
self.send_header("content-length", str(len(data)))
|
|
34
|
+
self.end_headers()
|
|
35
|
+
self.wfile.write(data)
|
|
36
|
+
|
|
37
|
+
def handle_any(self):
|
|
38
|
+
length = int(self.headers.get("content-length") or 0)
|
|
39
|
+
body = json.loads(self.rfile.read(length)) if length else None
|
|
40
|
+
path = self.path.split("?")[0]
|
|
41
|
+
Stub.seen.append((self.command, self.path, body, self.headers.get("idempotency-key")))
|
|
42
|
+
if Stub.mode == "off" or (Stub.mode == "jobs-off" and path.startswith("/v1/secrets")):
|
|
43
|
+
return self.answer({"error": {"code": "unavailable", "message": "This product is not enabled on this "
|
|
44
|
+
"deployment.", "hint": "not yet"}}, 503)
|
|
45
|
+
if path == "/v1/jobs" and self.command == "GET":
|
|
46
|
+
return self.answer({"items": [JOB], "nextCursor": None})
|
|
47
|
+
if path.endswith("/runs"):
|
|
48
|
+
return self.answer({"items": [RUN], "nextCursor": None})
|
|
49
|
+
if path.endswith("/logs"):
|
|
50
|
+
return self.answer({"chunks": [{"offset": 0, "stream": "stdout", "text": "hi\n"}], "nextCursor": 3,
|
|
51
|
+
"truncated": False, "complete": True})
|
|
52
|
+
if path.startswith("/v1/job-runs/"):
|
|
53
|
+
return self.answer(RUN)
|
|
54
|
+
if path.startswith("/v1/jobs"):
|
|
55
|
+
return self.answer(JOB)
|
|
56
|
+
if path == "/v1/secrets" and self.command == "GET":
|
|
57
|
+
return self.answer(Stub.copies)
|
|
58
|
+
if path.endswith(":reveal"):
|
|
59
|
+
return self.answer({"id": SECRET_ID, "version": 2, "value": "hunter2"})
|
|
60
|
+
if path.startswith("/v1/secrets"):
|
|
61
|
+
return self.answer({**COPY, "version": 1})
|
|
62
|
+
if path.startswith("/v1/egress-secrets"):
|
|
63
|
+
return self.answer(EGRESS if self.command == "PUT" else {"name": "x", "deleted": True, "enforced": True})
|
|
64
|
+
self.answer({"error": {"code": "not_found", "message": "no"}}, 404)
|
|
65
|
+
|
|
66
|
+
do_GET = do_POST = do_PUT = do_DELETE = handle_any
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JobsTest(unittest.TestCase):
|
|
70
|
+
@classmethod
|
|
71
|
+
def setUpClass(cls):
|
|
72
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
|
|
73
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
74
|
+
cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def tearDownClass(cls):
|
|
78
|
+
cls.server.shutdown()
|
|
79
|
+
|
|
80
|
+
def setUp(self):
|
|
81
|
+
Stub.seen, Stub.mode, Stub.copies = [], "ok", []
|
|
82
|
+
|
|
83
|
+
def client(self):
|
|
84
|
+
return Runtime(api_key="rk_test", base_url=self.url, max_retries=2)
|
|
85
|
+
|
|
86
|
+
def test_create_fills_defaults_and_every_verb_calls_its_route(self):
|
|
87
|
+
with self.client() as runtime:
|
|
88
|
+
runtime.jobs.create("nightly", cron="0 3 * * *", timezone="Europe/Berlin", command=["echo", "hi"])
|
|
89
|
+
self.assertEqual([j["id"] for j in runtime.jobs.list()], [JOB_ID])
|
|
90
|
+
self.assertEqual(runtime.jobs.runs(JOB_ID).data[0]["id"], RUN_ID)
|
|
91
|
+
self.assertEqual(runtime.jobs.run(RUN_ID)["state"], "succeeded")
|
|
92
|
+
self.assertTrue(runtime.jobs.logs(RUN_ID, cursor=0)["complete"])
|
|
93
|
+
runtime.jobs.pause(JOB_ID)
|
|
94
|
+
runtime.jobs.resume(JOB_ID)
|
|
95
|
+
runtime.jobs.cancel(JOB_ID)
|
|
96
|
+
first = Stub.seen[0]
|
|
97
|
+
self.assertEqual(first[:2], ("POST", "/v1/jobs"))
|
|
98
|
+
self.assertTrue(first[3])
|
|
99
|
+
self.assertEqual(first[2]["compute"], {"region": "us-east-vin", "vcpu": 2, "cpuMode": "shared",
|
|
100
|
+
"cpuFloorMillis": 50, "memoryMiB": 4096, "diskMiB": 4096,
|
|
101
|
+
"durationSeconds": 1860})
|
|
102
|
+
self.assertEqual(first[2]["schedule"], {"kind": "cron", "expression": "0 3 * * *",
|
|
103
|
+
"timezone": "Europe/Berlin"})
|
|
104
|
+
self.assertEqual([s[1].split("?")[0] for s in Stub.seen[1:]], [
|
|
105
|
+
"/v1/jobs", f"/v1/jobs/{JOB_ID}/runs", f"/v1/job-runs/{RUN_ID}", f"/v1/job-runs/{RUN_ID}/logs",
|
|
106
|
+
f"/v1/jobs/{JOB_ID}:pause", f"/v1/jobs/{JOB_ID}:resume", f"/v1/jobs/{JOB_ID}:cancel"])
|
|
107
|
+
|
|
108
|
+
def test_mistakes_are_refused_before_sending(self):
|
|
109
|
+
with self.client() as runtime:
|
|
110
|
+
for kwargs, pattern in [
|
|
111
|
+
({"at": 1, "compute": {"vcpu": 32}}, "at most 16 vCPU"),
|
|
112
|
+
({"at": 1, "compute": {"diskMiB": 512}}, "at least 3072 MiB"),
|
|
113
|
+
({"cron": "0 3 * *"}, "five fields"),
|
|
114
|
+
({"at": "tomorrow"}, "must be a time"),
|
|
115
|
+
({}, "at= for one run or cron="),
|
|
116
|
+
({"at": 1, "timezone": "UTC"}, "timezone goes with cron"),
|
|
117
|
+
]:
|
|
118
|
+
with self.assertRaisesRegex(InvalidRequestError, pattern):
|
|
119
|
+
runtime.jobs.create("x", command=["true"], **kwargs)
|
|
120
|
+
self.assertEqual(Stub.seen, [])
|
|
121
|
+
|
|
122
|
+
def test_switched_off_names_the_product_and_is_not_retried(self):
|
|
123
|
+
Stub.mode = "off"
|
|
124
|
+
with self.client() as runtime:
|
|
125
|
+
with self.assertRaises(ServiceUnavailableError) as caught:
|
|
126
|
+
runtime.jobs.list()
|
|
127
|
+
self.assertEqual(caught.exception.message, "Jobs are not enabled on this Runtime API yet.")
|
|
128
|
+
self.assertIn("retrying will not help", caught.exception.hint)
|
|
129
|
+
self.assertFalse(caught.exception.retryable)
|
|
130
|
+
self.assertEqual(len(Stub.seen), 1)
|
|
131
|
+
|
|
132
|
+
def test_secret_jobs_copy_set_rotate_reveal_delete(self):
|
|
133
|
+
with self.client() as runtime:
|
|
134
|
+
saved = runtime.secrets.set("DB_PASSWORD", value="hunter2", jobs=True)
|
|
135
|
+
self.assertEqual(saved["jobs"]["version"], 1)
|
|
136
|
+
Stub.copies = [COPY]
|
|
137
|
+
runtime.secrets.set("DB_PASSWORD", value="new", jobs=True)
|
|
138
|
+
self.assertEqual(runtime.secrets.reveal("DB_PASSWORD")["value"], "hunter2")
|
|
139
|
+
self.assertEqual(runtime.secrets.delete("DB_PASSWORD")["from"], ["sandboxes", "jobs"])
|
|
140
|
+
lines = [(s[0], s[1].split("?")[0]) for s in Stub.seen]
|
|
141
|
+
self.assertEqual(lines, [
|
|
142
|
+
("GET", "/v1/secrets"), ("POST", "/v1/secrets"),
|
|
143
|
+
("GET", "/v1/secrets"), ("POST", f"/v1/secrets/{SECRET_ID}:rotate"),
|
|
144
|
+
("GET", "/v1/secrets"), ("POST", f"/v1/secrets/{SECRET_ID}:reveal"),
|
|
145
|
+
("GET", "/v1/secrets"), ("DELETE", "/v1/egress-secrets/DB_PASSWORD"),
|
|
146
|
+
("POST", f"/v1/secrets/{SECRET_ID}:delete")])
|
|
147
|
+
self.assertEqual(Stub.seen[3][2], {"value": "new", "expectedVersion": 2})
|
|
148
|
+
|
|
149
|
+
def test_both_copies_report_a_partial_store_and_the_retry_finishes(self):
|
|
150
|
+
Stub.mode = "jobs-off"
|
|
151
|
+
|
|
152
|
+
async def go():
|
|
153
|
+
async with AsyncRuntime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
|
|
154
|
+
with self.assertRaises(SecretPartlyStoredError) as caught:
|
|
155
|
+
await runtime.secrets.set("API_TOKEN", value="t", hosts=["api.example.com"], jobs=True)
|
|
156
|
+
self.assertEqual(caught.exception.details["stored"], ["sandboxes"])
|
|
157
|
+
Stub.mode = "ok"
|
|
158
|
+
saved = await runtime.secrets.set("API_TOKEN", value="t", hosts=["api.example.com"], jobs=True)
|
|
159
|
+
self.assertEqual(saved["placeholder"], "rtsec_x")
|
|
160
|
+
self.assertEqual(saved["jobs"]["version"], 1)
|
|
161
|
+
asyncio.run(go())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
unittest.main()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Sandbox sessions (ARCHITECTURE.md section 3.12) against a stub API: the
|
|
2
|
+
backend makes, lists and revokes them with ``sbx.sessions``; a page's code
|
|
3
|
+
drives the sandbox with ``Sandbox.from_session`` and the token alone; and the
|
|
4
|
+
Blaxel adapter's ``sandbox.sessions`` and ``from_session`` map onto them. The
|
|
5
|
+
routes are tested in packages/cloud (sandbox-sessions-postgres.test.ts)."""
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
import unittest
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
|
|
13
|
+
import withruntime.blaxel as bl
|
|
14
|
+
from withruntime import AsyncSandbox, Runtime, Sandbox
|
|
15
|
+
from withruntime.blaxel import NotSupportedError, SyncSandboxInstance
|
|
16
|
+
|
|
17
|
+
ID = "8a1f9c2e-0d1b-4c3a-9e8f-7a6b5c4d3e2f"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Stub(BaseHTTPRequestHandler):
|
|
21
|
+
seen: list = []
|
|
22
|
+
sessions: list = []
|
|
23
|
+
url = ""
|
|
24
|
+
|
|
25
|
+
def log_message(self, *args):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def answer(self, value, status=200):
|
|
29
|
+
data = json.dumps(value).encode()
|
|
30
|
+
self.send_response(status)
|
|
31
|
+
self.send_header("content-type", "application/json")
|
|
32
|
+
self.send_header("content-length", str(len(data)))
|
|
33
|
+
self.end_headers()
|
|
34
|
+
self.wfile.write(data)
|
|
35
|
+
|
|
36
|
+
def handle_any(self):
|
|
37
|
+
length = int(self.headers.get("content-length") or 0)
|
|
38
|
+
body = json.loads(self.rfile.read(length) or b"null") if length else None
|
|
39
|
+
path = self.path.split("?")[0]
|
|
40
|
+
Stub.seen.append((self.command, path, self.headers.get("authorization"), body))
|
|
41
|
+
if path == f"/v1/sandboxes/{ID}/sessions" and self.command == "POST":
|
|
42
|
+
sid = f"00000000-0000-4000-8000-{len(Stub.sessions):012d}"
|
|
43
|
+
made = {"id": sid, "sandboxId": ID, "name": None, "origins": body.get("origins", []),
|
|
44
|
+
"createdBy": "agent", "createdAt": "2026-09-28T10:00:00Z",
|
|
45
|
+
"expiresAt": (datetime.now(timezone.utc)
|
|
46
|
+
+ timedelta(seconds=body.get("ttlSeconds", 3600))).isoformat(),
|
|
47
|
+
"revokedAt": None, "state": "active"}
|
|
48
|
+
Stub.sessions.insert(0, made)
|
|
49
|
+
return self.answer({**made, "token": f"rtsess_{sid}_{'t' * 43}", "apiUrl": Stub.url})
|
|
50
|
+
if path == f"/v1/sandboxes/{ID}/sessions":
|
|
51
|
+
return self.answer({"data": Stub.sessions, "nextCursor": None})
|
|
52
|
+
if path.endswith(":revoke"):
|
|
53
|
+
sid = path.split("/")[-1].split(":")[0]
|
|
54
|
+
for one in Stub.sessions:
|
|
55
|
+
if one["id"] == sid:
|
|
56
|
+
one.update(state="revoked", revokedAt="2026-09-28T10:30:00Z")
|
|
57
|
+
return self.answer(one)
|
|
58
|
+
return self.answer({"error": {"code": "not_found", "status": 404, "message": "x"}}, 404)
|
|
59
|
+
if path.endswith(":exec"):
|
|
60
|
+
return self.answer({"exitCode": 0, "stdout": "hi\n", "stderr": "", "timedOut": False})
|
|
61
|
+
return self.answer({"id": ID, "name": "web", "state": "running", "labels": {}, "autoWake": True,
|
|
62
|
+
"memoryMiB": 4096, "createdAt": "2026-09-28T10:00:00Z",
|
|
63
|
+
"expiresAt": "2026-09-28T11:00:00Z", "onLeaseEnd": "stop"})
|
|
64
|
+
|
|
65
|
+
do_GET = do_POST = do_PUT = handle_any
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SessionsTest(unittest.TestCase):
|
|
69
|
+
@classmethod
|
|
70
|
+
def setUpClass(cls):
|
|
71
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
|
|
72
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
73
|
+
cls.url = Stub.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def tearDownClass(cls):
|
|
77
|
+
cls.server.shutdown()
|
|
78
|
+
|
|
79
|
+
def setUp(self):
|
|
80
|
+
Stub.seen, Stub.sessions = [], []
|
|
81
|
+
|
|
82
|
+
def test_backend_makes_lists_and_revokes(self):
|
|
83
|
+
with Runtime(api_key="rtcloud_k", base_url=self.url, max_retries=0) as runtime:
|
|
84
|
+
sbx = runtime.sandboxes.get(ID)
|
|
85
|
+
made = sbx.sessions.create(ttl_seconds=900, origins=["https://app.example.com"], name="u1")
|
|
86
|
+
self.assertTrue(made["token"].startswith("rtsess_"))
|
|
87
|
+
self.assertEqual(Stub.seen[-1][3], {"ttlSeconds": 900, "origins": ["https://app.example.com"],
|
|
88
|
+
"name": "u1"})
|
|
89
|
+
self.assertEqual([one["id"] for one in sbx.sessions.list()], [made["id"]])
|
|
90
|
+
self.assertEqual(sbx.sessions.revoke(made["id"])["state"], "revoked")
|
|
91
|
+
|
|
92
|
+
def test_page_uses_the_token_alone(self):
|
|
93
|
+
token = f"rtsess_{'0' * 8}-0000-4000-8000-{'0' * 12}_{'t' * 43}"
|
|
94
|
+
sbx = Sandbox.from_session(token, ID, self.url)
|
|
95
|
+
self.assertEqual(Stub.seen, [])
|
|
96
|
+
self.assertEqual(sbx.exec("echo hi").stdout, "hi\n")
|
|
97
|
+
self.assertEqual(Stub.seen[-1][2], f"Bearer {token}")
|
|
98
|
+
with self.assertRaises(Exception):
|
|
99
|
+
Sandbox.from_session("rtcloud_key", ID, self.url)
|
|
100
|
+
|
|
101
|
+
async def go():
|
|
102
|
+
page = AsyncSandbox.from_session(token, ID, self.url)
|
|
103
|
+
return (await page.exec("echo hi")).stdout
|
|
104
|
+
|
|
105
|
+
self.assertEqual(asyncio.run(go()), "hi\n")
|
|
106
|
+
|
|
107
|
+
def test_blaxel_sessions_and_from_session(self):
|
|
108
|
+
runtime = Runtime(api_key="rtcloud_k", base_url=self.url, max_retries=0)
|
|
109
|
+
bl.use_client(runtime)
|
|
110
|
+
try:
|
|
111
|
+
box = SyncSandboxInstance(_runtime=runtime.sandboxes.get(ID))
|
|
112
|
+
session = box.sessions.create({
|
|
113
|
+
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=15),
|
|
114
|
+
"response_headers": {"Access-Control-Allow-Origin": "https://app.example.com",
|
|
115
|
+
"Access-Control-Allow-Methods": "GET"}})
|
|
116
|
+
self.assertTrue(session.token.startswith("rtsess_"))
|
|
117
|
+
self.assertEqual(session.url, f"{self.url}/v1/sandboxes/{ID}")
|
|
118
|
+
self.assertEqual(Stub.seen[-1][3]["origins"], ["https://app.example.com"])
|
|
119
|
+
self.assertIn(Stub.seen[-1][3]["ttlSeconds"], (899, 900))
|
|
120
|
+
box.sessions.create() # Blaxel's default: a day, Runtime's most.
|
|
121
|
+
self.assertEqual(Stub.seen[-1][3]["ttlSeconds"], 86_400)
|
|
122
|
+
self.assertEqual(len(box.sessions.list()), 2)
|
|
123
|
+
self.assertEqual(box.sessions.get(session.name)["url"], session.url)
|
|
124
|
+
box.sessions.delete(session.name)
|
|
125
|
+
self.assertNotIn(session.name, [one.name for one in box.sessions.list()])
|
|
126
|
+
kept = box.sessions.create_if_expired({}, delta_seconds=60)
|
|
127
|
+
self.assertEqual(box.sessions.create_if_expired({}, delta_seconds=60).token, kept.token)
|
|
128
|
+
|
|
129
|
+
page = SyncSandboxInstance.from_session(session)
|
|
130
|
+
self.assertEqual(page.metadata.name, "web")
|
|
131
|
+
self.assertTrue(any(auth == f"Bearer {session.token}" for _, _, auth, _ in Stub.seen))
|
|
132
|
+
|
|
133
|
+
before = len(Stub.seen)
|
|
134
|
+
for options in ({"response_headers": {"Access-Control-Allow-Origin": "*"}},
|
|
135
|
+
{"response_headers": {"X-Frame-Options": "DENY"}},
|
|
136
|
+
{"request_headers": {"Authorization": "x"}},
|
|
137
|
+
{"expires_at": datetime.now(timezone.utc) + timedelta(days=2)}):
|
|
138
|
+
with self.assertRaises(NotSupportedError):
|
|
139
|
+
box.sessions.create(options)
|
|
140
|
+
self.assertEqual(len(Stub.seen), before)
|
|
141
|
+
with self.assertRaises(NotSupportedError):
|
|
142
|
+
SyncSandboxInstance.from_session({"url": "https://blaxel.dev/x", "token": "bl_x"})
|
|
143
|
+
finally:
|
|
144
|
+
bl.use_client(None)
|
|
145
|
+
runtime.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|
|
@@ -39,7 +39,8 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
39
39
|
|
|
40
40
|
def do_GET(self):
|
|
41
41
|
Stub.seen.append((self.command, self.path, None))
|
|
42
|
-
|
|
42
|
+
# The jobs copies: none here.
|
|
43
|
+
self.answer([] if self.path.startswith("/v1/secrets") else {"secrets": [SECRET]})
|
|
43
44
|
|
|
44
45
|
def do_PUT(self):
|
|
45
46
|
Stub.seen.append((self.command, self.path, self.body()))
|
|
@@ -75,6 +76,7 @@ class SecretsTest(unittest.TestCase):
|
|
|
75
76
|
("PUT", "/v1/egress-secrets/OPENAI_API_KEY", {"value": "sk-live", "hosts": ["api.openai.com"],
|
|
76
77
|
"header": "Authorization", "format": "Bearer {value}"}),
|
|
77
78
|
("GET", "/v1/egress-secrets", None),
|
|
79
|
+
("GET", "/v1/secrets?limit=100", None),
|
|
78
80
|
("DELETE", "/v1/egress-secrets/OPENAI_API_KEY", None),
|
|
79
81
|
])
|
|
80
82
|
|
|
@@ -95,8 +95,9 @@ class Create(Base):
|
|
|
95
95
|
self.assertEqual(network_rules(NetworkPolicy.deny_all()), {"internet": False})
|
|
96
96
|
self.assertEqual(network_rules(NetworkPolicy.allow_all()), {"internet": True})
|
|
97
97
|
self.assertEqual(network_rules(NetworkPolicy.custom()), {"internet": False})
|
|
98
|
-
with self.assertRaises(NotSupportedError):
|
|
98
|
+
with self.assertRaises(NotSupportedError) as caught:
|
|
99
99
|
network_rules(NetworkPolicy.custom(allow={"api.github.com": ["rule"]}))
|
|
100
|
+
self.assertIn("withruntime secrets set NAME --host api.github.com", caught.exception.alternative)
|
|
100
101
|
|
|
101
102
|
|
|
102
103
|
class Keys(unittest.TestCase):
|