feat(dify-agent): add OpenShell runtime backend (#41076)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jinsong Zhou
2026-09-14 13:05:16 +00:00
committed by GitHub
co-authored by Claude Fable 5 Yunlu Wen autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent a5bc77eaa7
commit 6afe07f944
24 changed files with 2931 additions and 25 deletions
+1
View File
@@ -185,6 +185,7 @@ docker/volumes/minio/*
docker/volumes/milvus/*
docker/volumes/chroma/*
docker/volumes/opensearch/data/*
docker/volumes/openshell-mtls/*
docker/volumes/myscale/data/*
docker/volumes/myscale/log/*
docker/volumes/unstructured/*
Generated
+4 -2
View File
@@ -1357,11 +1357,13 @@ requires-dist = [
{ name = "e2b", marker = "extra == 'server'", specifier = ">=2.38.0,<3.0.0" },
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" },
{ name = "grpcio", marker = "extra == 'server'", specifier = ">=1.60.0,<2.0.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "httpx2", specifier = ">=2.5.0,<3.0.0" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
{ name = "openshell", marker = "extra == 'server'", specifier = ">=0.0.106,<0.1.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-harness", specifier = ">=0.20.0,<0.21.0" },
{ name = "pydantic-ai-slim", specifier = ">=2.30.0,<3.0.0" },
@@ -3353,8 +3355,8 @@ name = "httpcore2"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
{ name = "h11", marker = "sys_platform != 'emscripten'" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
+1
View File
@@ -35,6 +35,7 @@ RUN apt-get update \
curl \
file \
git \
iproute2 \
jq \
less \
openssh-client \
+7 -4
View File
@@ -143,10 +143,13 @@ func TestDockerfileBuildsAndCopiesAllBinaries(t *testing.T) {
"CMD": `CMD ["shellctl", "serve", "--listen", "0.0.0.0:5004"]`,
"EXPOSE": "EXPOSE 5004",
"USER": "USER dify",
"bash": "bash",
"git": "git",
"jq": "jq",
"tmux": "tmux",
"bash": "bash",
"git": "git",
// iproute2 is required by the OpenShell runtime backend: its sandbox
// supervisor needs the ip helper for network-namespace isolation.
"iproute2": "iproute2",
"jq": "jq",
"tmux": "tmux",
}
for name, expected := range assertions {
if !strings.Contains(content, expected) {
+12 -1
View File
@@ -36,7 +36,7 @@ DIFY_AGENT_INNER_API_URL=http://localhost:5001
DIFY_AGENT_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
# Runtime resources
# Select one coherent Home Snapshot + Execution Binding backend: local, enterprise, or e2b.
# Select one coherent Home Snapshot + Execution Binding backend: local, enterprise, e2b, or openshell.
DIFY_AGENT_RUNTIME_BACKEND=local
# Local backend: shellctl data-plane URL and optional bearer token.
# Leave the endpoint empty when this server will not provide dify.runtime or resource endpoints.
@@ -56,6 +56,17 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox
# This is not a retention TTL for paused resources or immutable snapshots.
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
# Minimal OpenShell configuration subset for local debugging.
# Full configuration reference: docker/envs/core-services/dify-agent.env.example
DIFY_AGENT_OPENSHELL_GATEWAY_ENDPOINT=localhost:17670
DIFY_AGENT_OPENSHELL_TLS_CA_PATH=${HOME}/.config/openshell/gateways/openshell/mtls/ca.crt
DIFY_AGENT_OPENSHELL_TLS_CLIENT_CERT_PATH=${HOME}/.config/openshell/gateways/openshell/mtls/tls.crt
DIFY_AGENT_OPENSHELL_TLS_CLIENT_KEY_PATH=${HOME}/.config/openshell/gateways/openshell/mtls/tls.key
DIFY_AGENT_OPENSHELL_SANDBOX_IMAGE=docker.io/library/dify-agent-runtime:latest
DIFY_AGENT_OPENSHELL_DRIVER_CONFIG={"docker":{"mounts":[{"type":"volume","source":"dify-agent-shared","target":"/mnt/dify-agent-shared","read_only":false}]}}
DIFY_AGENT_OPENSHELL_EGRESS_ALLOW=host.docker.internal:5050,host.docker.internal:5001
DIFY_AGENT_OPENSHELL_SHELLCTL_AUTH_TOKEN=dify-agent-openshell-shellctl-dev-token
# JSON array of regex patterns to redact from shell output shown to the agent.
DIFY_AGENT_SHELL_REDACT_PATTERNS=
+3 -1
View File
@@ -11,7 +11,9 @@
# actually start. dify-api is intentionally left lean.
# base image
FROM python:3.12-slim-bookworm AS base
# trixie (glibc 2.41): the `openshell` wheel bundles a Rust CLI binary and
# only ships manylinux_2_39 wheels, which bookworm's glibc 2.36 cannot load.
FROM python:3.12-slim-trixie AS base
WORKDIR /app/api
@@ -209,12 +209,15 @@ Home plus the shared Workspace.
| Local | Supported | Supported, including default empty Homes and attaching multiple Bindings to one Workspace | Snapshot directory, per-Binding materialized Home, and Workspace directory are separate. |
| E2B | Supported | Supported with template-backed default Homes, without shared-Workspace attachment | Binding and Workspace refs map to the same E2B resource; checkpoints use E2B snapshots. |
| Enterprise | Not implemented | Default-Home Binding creation, acquire, and coupled destroy are supported | Binding and Workspace refs map to one Gateway sandbox. Explicit Home Snapshot materialization fails fast. |
| OpenShell | Supported via directory copies on an operator-provided shared volume | Supported with image-backed default Homes, without shared-Workspace attachment | Binding and Workspace refs map to the same OpenShell sandbox, addressed by its stable name; snapshots live under `home-snapshots/<tenant-digest>/` on the shared volume. Production deployments must use one workspace and one dedicated volume per tenant. |
Local creates a new Home for every Binding id. Destroying one Binding without
the Workspace leaves sibling Homes and the shared Workspace intact. Current E2B
rejects `existing_workspace_ref` with `shared_workspace_unsupported`, because
its Binding and Workspace are one Sandbox. It also rejects binding-only destroy.
Neither path creates a fallback Workspace or switches backends.
OpenShell shares the E2B shape: one sandbox per Binding, no
`existing_workspace_ref`, no binding-only destroy. Neither path creates a
fallback Workspace or switches backends.
`DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` limits continuous active time for an E2B
resource to one hour. The limit covers the complete Agent run held by one
@@ -225,5 +228,6 @@ resource setting does not own the Agent run terminal state. It is not a retentio
TTL and does not delete paused resources or immutable snapshots.
See the [Shell layer](../../user-manual/shell-layer/index.md) for request
composition and the [Operations Guide](../../guide/index.md) for Local and E2B
validation.
composition, the [Operations Guide](../../guide/index.md) for Local and E2B
validation, and the [OpenShell guide](../../guide/openshell.md) for OpenShell
configuration and validation.
+13 -2
View File
@@ -29,6 +29,9 @@ run.
`ServerSettings` loads environment variables with the `DIFY_AGENT_` prefix. It
also reads `.env` and `dify-agent/.env` when present.
OpenShell-specific settings are listed in the
[OpenShell configuration reference](openshell.md#configuration).
| Environment variable | Default | Description |
| --- | --- | --- |
| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. |
@@ -46,7 +49,7 @@ also reads `.env` and `dify-agent/.env` when present.
| `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. |
| `DIFY_AGENT_INNER_API_URL` | `http://localhost:5001` | Dify API service root used when dify-agent calls `/inner/api/...` endpoints. |
| `DIFY_AGENT_INNER_API_KEY` | empty | API key sent to Dify API inner plugin endpoints. Set this to Dify API `INNER_API_KEY_FOR_PLUGIN` (Docker: `PLUGIN_DIFY_INNER_API_KEY`). |
| `DIFY_AGENT_RUNTIME_BACKEND` | `local` | Selects one coherent `local`, `enterprise`, or `e2b` Home Snapshot + Execution Binding backend profile. |
| `DIFY_AGENT_RUNTIME_BACKEND` | `local` | Selects one coherent `local`, `enterprise`, `e2b`, or `openshell` Home Snapshot + Execution Binding backend profile. |
| `DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT` | empty | Local shellctl data-plane URL. With the default Local selection, leaving it empty disables `dify.runtime` and resource endpoints. |
| `DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN` | empty | Optional bearer token sent to Local shellctl. |
| `DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT` | `/home/dify` | Root directory, on the Local shellctl filesystem, for per-Binding materialized Homes. |
@@ -237,6 +240,12 @@ provider `RuntimeError` observed first becomes a tool observation. In contrast,
run-deadline cancellation propagates through the Shell boundary; only the Dify
Agent run deadline owns the terminal `agent_run_limit_exceeded` failure.
## OpenShell backend
See the [OpenShell Runtime Backend guide](openshell.md) for its configuration
reference, runtime image build instructions, gateway and shared-volume setup,
and deployment validation.
## Run runtime-backend integration contracts
Run the disposable Local contract from the `dify-agent` directory. The script
@@ -273,9 +282,11 @@ DIFY_AGENT_TEST_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox \
-k e2b -q -rs
```
For OpenShell, see [Run the OpenShell integration contract](openshell.md#run-the-openshell-integration-contract).
The Local auth token is optional when shellctl has authentication disabled.
The E2B contract uses the one-hour `E2B_MAX_ACTIVE_TIMEOUT_SECONDS` RuntimeLease
limit. This is continuous active test time, not a post-test retention TTL. Both
limit. This is continuous active test time, not a post-test retention TTL. All
contracts create unique resources and perform explicit cleanup in `finally`
blocks.
@@ -0,0 +1,269 @@
# OpenShell Runtime Backend
This guide covers OpenShell-specific configuration, deployment, and validation.
See the [Operations Guide](index.md) for shared server configuration and
scheduling behavior.
## Configuration
`ServerSettings` loads environment variables with the `DIFY_AGENT_` prefix. It
also reads `.env` and `dify-agent/.env` when present. Select
`DIFY_AGENT_RUNTIME_BACKEND=openshell` to use this backend. Shared settings,
including Agent Stub and file-service URLs, are documented in the
[server configuration reference](index.md#configuration).
| Environment variable | Default | Description |
| --- | --- | --- |
| `DIFY_AGENT_OPENSHELL_GATEWAY_ENDPOINT` | empty | OpenShell gateway gRPC endpoint as `host:port` (no scheme); required for OpenShell. Prefer `localhost` over an IPv6 literal when the gateway listens on loopback. |
| `DIFY_AGENT_OPENSHELL_WORKSPACE` | `default` | OpenShell workspace that owns the sandboxes. Production multi-tenant deployments must use one workspace and one dedicated shared volume per tenant. |
| `DIFY_AGENT_OPENSHELL_BEARER_TOKEN` | empty | Static OIDC bearer token sent to the gateway. Combines with the mTLS bundle. The token is not refreshed in-process; restart `agent_backend` or switch to a long-lived token when it expires. |
| `DIFY_AGENT_OPENSHELL_TLS_CA_PATH` | empty | Custom gateway CA certificate path. Empty uses the system trust store. |
| `DIFY_AGENT_OPENSHELL_TLS_CLIENT_CERT_PATH` | empty | mTLS client certificate path; set together with the key path. |
| `DIFY_AGENT_OPENSHELL_TLS_CLIENT_KEY_PATH` | empty | mTLS client key path; set together with the certificate path. |
| `DIFY_AGENT_OPENSHELL_INSECURE` | `false` | Use a plaintext gRPC channel. Local development only. |
| `DIFY_AGENT_OPENSHELL_SANDBOX_IMAGE` | `langgenius/dify-agent-local-sandbox:latest` | Build the runtime image yourself from this checkout and explicitly set this to the resulting image reference. Do not rely on the code fallback or a published `latest` tag: older images may lack `iproute2`, which the OpenShell supervisor requires alongside shellctl. The env templates use the self-built `docker.io/library/dify-agent-runtime:latest`; see the build command below. |
| `DIFY_AGENT_OPENSHELL_DRIVER_CONFIG` | empty | JSON `SandboxTemplate.driver_config`; required for OpenShell and must be a non-empty object. Must mount the shared Home Snapshot volume at the shared mount path in every sandbox. |
| `DIFY_AGENT_OPENSHELL_SHARED_MOUNT_PATH` | `/mnt/dify-agent-shared` | In-sandbox mount path of the shared volume; Home Snapshots live under `home-snapshots/<tenant-digest>/`. |
| `DIFY_AGENT_OPENSHELL_EGRESS_ALLOW` | empty | Optional comma-separated `host:port` egress allowlist (no scheme or path). Empty sends no network policy (gateway/driver default egress). When set, sandbox egress is enforced to exactly these endpoints — include the Agent Stub and files endpoints. |
| `DIFY_AGENT_OPENSHELL_SHELLCTL_AUTH_TOKEN` | empty | Required bearer token the exec-bootstrapped shellctl expects on its data plane. Empty is rejected at startup. |
| `DIFY_AGENT_OPENSHELL_SHELLCTL_PORT` | `5004` | Sandbox-loopback port shellctl listens on; reached through the gateway `ForwardTcp` tunnel. |
| `DIFY_AGENT_OPENSHELL_READY_TIMEOUT_SECONDS` | `300` | Maximum wait for a sandbox to reach the READY phase. |
| `DIFY_AGENT_OPENSHELL_EXEC_TIMEOUT_SECONDS` | `120` | Timeout for gateway exec calls (bootstrap and maintenance scripts). |
## Deploy with the OpenShell backend
The OpenShell backend runs each Binding in its own sandbox on a self-hosted
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) gateway. Dify Agent
only talks to the gateway gRPC API: it creates sandboxes from the configured
image, bootstraps shellctl through gateway exec, and reaches the shellctl data
plane through an authenticated `ForwardTcp` tunnel.
New Bindings are initialized and then stopped. Acquire starts a stopped sandbox
when needed, verifies its Home and Workspace, ensures shellctl is running, and
opens a fresh lease-local tunnel. Release closes only that lease's HTTP client
and tunnel; it does not stop the sandbox or shellctl. Subsequent operations can
reuse the running sandbox, and releasing one lease does not interrupt another
lease on the same Binding.
There is no automatic idle-stop or sandbox TTL in this adapter. After a
successful acquire/release cycle, the sandbox remains running until explicitly
stopped or destroyed, or a runtime failure stops it. Operators must account for
these running resources. Binding creation and failed-acquire cleanup retain
their existing stop behavior; this change only makes lease release lightweight.
Explicitly stopped sandboxes are restarted on the next acquire.
Backend selection lives in `docker/.env`; create it from the template first if
this deployment does not have one yet (`cp docker/.env.example docker/.env`).
Deployment prerequisites:
1. An OpenShell gateway, version 0.0.106 or newer (validated against
0.0.106–0.0.110; the bundled SDK is pinned `>=0.0.106,<0.1.0`), reachable
from `agent_backend` at `DIFY_AGENT_OPENSHELL_GATEWAY_ENDPOINT`, with
credentials via `DIFY_AGENT_OPENSHELL_BEARER_TOKEN` or the mTLS bundle
paths. Prefer `localhost:17670` when the gateway listens on loopback.
Sandboxes reconnect to the gateway through `host.openshell.internal`; on
macOS local development bind the gateway to `127.0.0.1:17670` so that
alias can reach it.
2. Sandboxes must reach `DIFY_AGENT_STUB_API_BASE_URL` and
`DIFY_AGENT_SANDBOX_FILES_BASE_URL`. When the gateway runs outside this
Compose stack, set both to externally reachable URLs; compose-internal
hostnames are not resolvable from OpenShell sandboxes. When the
deployment restricts sandbox egress, set
`DIFY_AGENT_OPENSHELL_EGRESS_ALLOW` so the sandbox policy carries an
enforced allowlist covering at least those two endpoints:
```text
DIFY_AGENT_OPENSHELL_EGRESS_ALLOW=agent.example.com:443,dify.example.com:443
```
Left empty (the default), the policy carries no network rules and egress
follows the gateway/driver default — a stock Docker driver leaves sandbox
outbound unrestricted, which agent-run tools (package installs, web
access) may rely on. When set, egress is restricted to exactly the listed
endpoints, from any in-sandbox binary.
3. **Build the runtime image yourself from the current checkout.** It must
bundle shellctl and `iproute2`. A published or cached
`langgenius/dify-agent-local-sandbox:latest` is not a substitute: older images
lack `iproute2`, causing the OpenShell supervisor to exit during startup.
4. A required `DIFY_AGENT_OPENSHELL_SHELLCTL_AUTH_TOKEN`. shellctl is
bootstrapped with `SHELLCTL_ENABLE_PATH_ISOLATION=false` because the
sandbox Landlock policy is authoritative; stacking both would require
re-granting every device path twice. The token still authenticates the
in-sandbox command plane.
5. A shared Home Snapshot volume mounted into every sandbox via
`DIFY_AGENT_OPENSHELL_DRIVER_CONFIG`. Production multi-tenant deployments
must use one OpenShell workspace and one dedicated volume per tenant:
Landlock is best-effort and every sandbox runs as the same image UID, so a
shared volume is a shared trust boundary. Snapshots are stored under
`home-snapshots/<tenant-digest>/`; the sandbox policy grants only that
tenant directory.
Build from the repository root (the directory containing `dify-agent-runtime/`):
```bash
docker build -f dify-agent-runtime/docker/Dockerfile \
-t dify-agent-runtime:latest dify-agent-runtime
```
Set the **exact built image reference** in `dify-agent/.env` for a local Agent
Backend process, or in `docker/.env` for Docker Compose:
```ini
DIFY_AGENT_OPENSHELL_SANDBOX_IMAGE=docker.io/library/dify-agent-runtime:latest
```
The gateway's Docker driver must use the same Docker daemon containing this
image. For a remote gateway or a multi-node deployment, tag and push the image
to a registry accessible to the compute nodes and configure that registry
reference instead. A local build on your laptop is not available to a remote
gateway automatically. If the configured tag is absent, the driver attempts to
pull it; a mismatched name can fail with `ImagePullFailed` / `pull access denied`.
Initialize the volume once. It is operator-owned and shared with the OpenShell
gateway, so create it with these commands rather than declaring it in a compose
file — `docker compose down -v` on a managing stack would delete every Home
Snapshot. Docker named volume (single-tenant local development; `1777` is not
a multi-tenant control):
```bash
docker volume create dify-agent-shared
docker run --rm -v dify-agent-shared:/mnt busybox \
sh -c "mkdir -p /mnt/home-snapshots && chmod 1777 /mnt /mnt/home-snapshots"
```
Kubernetes: provision one ReadWriteMany PVC per tenant trust zone and mount
it read-write at `DIFY_AGENT_OPENSHELL_SHARED_MOUNT_PATH` through that
driver's `driver_config` (shape is driver-specific; the Docker example
above is the local-dev analogue). Initialize the volume with an
initContainer or `fsGroup` so the sandbox UID can write
`home-snapshots/` — do not rely on `chmod 1777` as isolation.
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dify-agent-shared-tenant-a
spec:
accessModes: ["ReadWriteMany"]
resources:
requests:
storage: 20Gi
```
Configure the backend in `docker/.env`. The full `DIFY_AGENT_OPENSHELL_*`
variable reference lives in `docker/envs/core-services/dify-agent.env.example`;
values may also be set in the `envs/core-services/dify-agent.env` env_file,
but `docker/.env` is loaded last into `agent_backend` and overrides it. These
lines are `.env` file content, not shell commands — values are taken verbatim,
so the `driver_config` JSON needs no quoting:
```ini
# docker/.env
DIFY_AGENT_RUNTIME_BACKEND=openshell
DIFY_AGENT_OPENSHELL_GATEWAY_ENDPOINT=gateway.example.com:17670
DIFY_AGENT_OPENSHELL_BEARER_TOKEN=replace-with-gateway-token
DIFY_AGENT_OPENSHELL_SANDBOX_IMAGE=docker.io/library/dify-agent-runtime:latest
DIFY_AGENT_OPENSHELL_DRIVER_CONFIG={"docker":{"mounts":[{"type":"volume","source":"dify-agent-shared","target":"/mnt/dify-agent-shared","read_only":false}]}}
DIFY_AGENT_OPENSHELL_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token
```
Then start the stack normally:
```bash
docker compose -f docker/docker-compose.yaml up -d
```
The Local backend's `local_sandbox` container and its dedicated SSRF proxy
stay in the stack unused, exactly as they do in an E2B deployment. The standard
Compose file does not build the OpenShell runtime image or provision its
gateway; complete the prerequisites above and the deployment-specific
networking and certificate setup below before starting the stack.
Home Snapshots are directory copies under
`<shared mount>/home-snapshots/<tenant-digest>/`; snapshot deletion runs a
short-lived maintenance sandbox granted only that tenant's snapshot root
(unlinking the snapshot directory itself requires write on its parent).
## Validate the OpenShell deployment
For local development, run the API and Agent Backend from the current checkout.
Copy `dify-agent/.example.env` to `dify-agent/.env`, select
`DIFY_AGENT_RUNTIME_BACKEND=openshell`, and build the runtime image as described
above. The local template targets `localhost:17670` and the Homebrew gateway's
mTLS bundle; adjust those paths if your gateway registration has a different
name. Install Agent Backend dependencies with `uv sync --extra server` from
`dify-agent/` before starting the server.
For Docker Compose, use `docker/docker-compose.yaml` and explicitly configure
your deployment:
- Use API and Agent Backend images containing the OpenShell integration. If your
images predate it, build them from the checkout using `api/Dockerfile` and
`dify-agent/Dockerfile`, and update the corresponding Compose service image
references. Building the runtime image alone does not update these services.
Keep the Agent Backend's glibc ≥ 2.39 base (`python:3.12-slim-trixie` in its
Dockerfile): the bundled OpenShell wheel requires it.
- The gateway endpoint must be reachable from the `agent_backend` container.
For a gateway on the Docker host, use `host.docker.internal:17670`, not
`localhost:17670`. On Linux Docker, add
`extra_hosts: ["host.docker.internal:host-gateway"]` to that service and ensure
the gateway listens on a container-reachable address. Verify that the gateway
certificate covers the hostname you use.
- When using mTLS, add a read-only bind mount for the gateway client bundle to
`agent_backend` and set `DIFY_AGENT_OPENSHELL_TLS_*_PATH` to the paths **inside
the container**. There is no automatic certificate mount. Ensure the service
user can traverse the mounted directory and read the files; copy the bundle
to a dedicated directory with appropriate permissions rather than mounting
the CLI's private gateway directory directly.
- Set sandbox-reachable Agent Stub and file-service URLs. Any required service
port publishing must be configured explicitly; the Agent Backend control
plane should remain private.
The runtime image must be available to the gateway's compute driver, not merely
in the Docker daemon used to build the API or Agent Backend images.
Smoke-test through the Agent Backend control plane. Set `AGENT_BACKEND_BASE_URL`
to its reachable service root (`http://localhost:5050` for a local process on
port 5050), and export the matching `DIFY_AGENT_API_TOKEN` from your deployment.
Use a disposable test tenant and binding; the destroy request deletes the test
sandbox and its workspace.
```bash
curl -s -w '\n%{http_code}\n' -X POST "$AGENT_BACKEND_BASE_URL/execution-bindings" \
-H "Authorization: Bearer $DIFY_AGENT_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tenant_id":"smoke-tenant","agent_id":"smoke-agent","binding_id":"smoke-binding-1","workspace_id":"smoke-ws-1"}'
# → {"binding_ref":"dify-<digest>","workspace_ref":"dify-<digest>"} then 201
curl -s -w '\n%{http_code}\n' -X POST "$AGENT_BACKEND_BASE_URL/execution-bindings/destroy" \
-H "Authorization: Bearer $DIFY_AGENT_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"binding_ref":"<binding_ref>","destroy_workspace":true,"workspace_ref":"<binding_ref>"}'
# → 204
```
A created ref verifies gateway authentication, sandbox startup from the
configured runtime image, and Binding initialization. Home Snapshot operations
also require the initialized shared volume described above.
## Run the OpenShell integration contract
Run the real OpenShell contract against a reachable gateway. The driver config
must mount an initialized shared Home Snapshot volume (see the OpenShell
deployment section above); pass the gateway credential through the bearer-token
or mTLS-path variables that match your gateway:
```bash
cd dify-agent
DIFY_AGENT_TEST_OPENSHELL_GATEWAY_ENDPOINT=gateway.example.com:17670 \
DIFY_AGENT_TEST_OPENSHELL_SANDBOX_IMAGE=docker.io/library/dify-agent-runtime:latest \
DIFY_AGENT_TEST_OPENSHELL_DRIVER_CONFIG='{"docker":{"mounts":[{"type":"volume","source":"dify-agent-shared","target":"/mnt/dify-agent-shared","read_only":false}]}}' \
DIFY_AGENT_TEST_OPENSHELL_BEARER_TOKEN=replace-with-gateway-token \
uv run --extra server pytest --import-mode=importlib \
tests/integration/dify_agent/runtime_backend/test_working_environment.py \
-k openshell -q -rs
```
The contract creates unique resources and performs explicit cleanup in `finally`
blocks.
@@ -39,10 +39,10 @@ the opaque Binding ref belongs to `DifyRuntimeLayerConfig`.
## Runtime requirements
The server constructs one coherent runtime backend profile. Local and E2B
implement Home Snapshot and Execution Binding operations. Enterprise implements
default-Home Binding creation, acquisition, and coupled destruction, while
immutable Home Snapshot operations fail fast; there is no compatibility
The server constructs one coherent runtime backend profile. Local, E2B, and
OpenShell implement Home Snapshot and Execution Binding operations. Enterprise
implements default-Home Binding creation, acquisition, and coupled destruction,
while immutable Home Snapshot operations fail fast; there is no compatibility
fallback to the retired Sandbox protocol.
```python
@@ -272,10 +272,11 @@ only its canonical reference to Dify API.
On Local, multiple Bindings may share a Workspace while each receives a
separate materialized Home. Those directories may be siblings in one shellctl
namespace; path isolation restricts a lease to its Home and Workspace. On E2B,
one physical E2B resource currently represents both Binding and Workspace, so
shared Workspace attachment is unsupported.
namespace; path isolation restricts a lease to its Home and Workspace. On E2B
and OpenShell, one physical sandbox currently represents both Binding and
Workspace, so shared Workspace attachment is unsupported.
See [Runtime resources](../../concepts/runtime-resources/index.md) for the
ledger and lifecycle contract. The [Operations Guide](../../guide/index.md)
covers Local and E2B validation.
covers Local and E2B validation; the [OpenShell guide](../../guide/openshell.md)
covers OpenShell configuration and validation.
+3 -1
View File
@@ -27,7 +27,9 @@ nav:
- Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md
- History Layer: dify-agent/user-manual/history-layer/index.md
- Structured Output Layer: dify-agent/user-manual/structured-output-layer/index.md
- Operations Guide: dify-agent/guide/index.md
- Operations Guide:
- Overview: dify-agent/guide/index.md
- OpenShell Runtime Backend: dify-agent/guide/openshell.md
- Examples: dify-agent/examples/index.md
theme:
+4
View File
@@ -22,9 +22,13 @@ server = [
"e2b>=2.38.0,<3.0.0",
"fastapi==0.136.0",
"graphon==0.5.2",
# grpcio is a runtime requirement of the openshell SDK that its wheel
# does not declare; keep it pinned here until upstream fixes the metadata.
"grpcio>=1.60.0,<2.0.0",
"jsonschema>=4.23.0,<5.0.0",
"jwcrypto>=1.5.6,<2",
"logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0",
"openshell>=0.0.106,<0.1.0",
"pydantic-ai-slim[anthropic,google,openai]>=2.30.0,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.4.0,<8.0.0",
@@ -0,0 +1,840 @@
"""OpenShell backend adapters with shellctl as the command data plane.
One OpenShell sandbox represents both a Binding and its Workspace (the E2B
shape), addressed by its stable sandbox *name*; sandbox ids change across
stop/start cycles and are re-resolved on every operation. The gateway replaces
the image entrypoint with its supervisor, so shellctl is started through an
idempotent exec bootstrap on acquire, and the shellctl HTTP data plane is
reached through a per-lease ``ForwardTcp`` tunnel.
Home Snapshots require an operator-provided shared volume mounted into every
sandbox via ``SandboxTemplate.driver_config``: snapshots are directory copies
under ``<shared_mount_path>/home-snapshots/<tenant-digest>/`` because OpenShell
has no native snapshot capability. Landlock is best-effort, so production
deployments must use one OpenShell workspace and one dedicated volume per
tenant. Snapshot deletion runs in a short-lived maintenance sandbox that is
granted that tenant's snapshot root — unlinking the snapshot directory itself
requires write on its parent under Landlock.
The sandbox policy sent with every create REPLACES OpenShell's built-in
restrictive default (``restrictive_default_policy()``) instead of merging with
it, so it restates the default path set and adds ``/dev/pts``
(shellctl's tmux allocates PTYs via forkpty) plus the tenant snapshot
directory — not the whole shared mount.
Network egress control is opt-in: when ``egress_allow`` is configured, the
policy carries one enforced allowlist rule per ``(host, port)`` endpoint and
sandbox egress is restricted to exactly those endpoints; when empty, the
policy carries no network rules and egress follows the gateway/driver default.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
import shlex
import threading
import time
import uuid
from collections.abc import Awaitable, Callable, Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, NoReturn, Protocol, cast
import httpx2 as httpx
from shellctl.client import ShellctlClientError
from dify_agent.adapters.shell.protocols import ShellCommandProtocol
from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol
from dify_agent.runtime_backend.errors import (
BindingAcquireError,
BindingCreateError,
BindingDestroyError,
BindingLostError,
HomeSnapshotCreateError,
SharedWorkspaceUnsupportedError,
WorkspacePreservationUnsupportedError,
)
from dify_agent.runtime_backend.openshell_tunnel import ForwardTcpCall, ForwardTcpTunnel
from dify_agent.runtime_backend.protocols import (
ExecutionBindingAllocation,
ExecutionBindingCreateSpec,
ExecutionBindingDestroySpec,
HomeSnapshotCreateSpec,
RuntimeLayout,
RuntimeLease,
)
from dify_agent.runtime_backend.shellctl import (
ShellctlRuntimeLease,
create_owned_shellctl_lease,
run_shellctl_control_command,
)
if TYPE_CHECKING:
from openshell import SandboxClient
from openshell._proto import openshell_pb2, sandbox_pb2
logger = logging.getLogger(__name__)
DEFAULT_OPENSHELL_SANDBOX_IMAGE = "langgenius/dify-agent-local-sandbox:latest"
DEFAULT_OPENSHELL_SHARED_MOUNT_PATH = "/mnt/dify-agent-shared"
_SNAPSHOT_SUBDIR = "home-snapshots"
_SAFE_REF_PART = re.compile(r"^[A-Za-z0-9._-]+$")
_SHELLCTL_READY_MAX_ATTEMPTS = 3
_SHELLCTL_READY_RETRY_INTERVAL_SECONDS = 0.5
# The gateway hard-caps ForwardTcp at 3 concurrent streams per session token.
_TUNNEL_MAX_CONNECTIONS = 3
# The gateway rejects sandbox names longer than 19 characters, so Binding ids
# (uuids) cannot appear verbatim; names and labels carry a deterministic
# digest instead of product identifiers.
_SANDBOX_NAME_DIGEST_LENGTH = 14
_SSH_SESSION_RENEWAL_MARGIN_MS = 60_000
_POLICY_READ_ONLY = ("/usr", "/lib", "/proc", "/dev/urandom", "/app", "/etc", "/var/log")
_POLICY_READ_WRITE = ("/tmp", "/dev/null", "/dev/pts")
class OpenShellNotFoundError(RuntimeError):
"""Confirmed-loss boundary error every ``OpenShellControlPlane`` must raise.
Implementations raise this for gateway resources that no longer exist so
the backends can map confirmed loss to ``BindingLostError`` and treat
deletes as idempotent.
"""
def _now_ms() -> int:
return time.time_ns() // 1_000_000
@dataclass(slots=True)
class _SshSessionTokenSupplier:
"""Thread-safe mint-and-renew supplier for ForwardTcp session tokens.
The gateway reaps SSH sessions after ``expires_at_ms``; every new tunnel
connection presents the current token, so the supplier re-mints one
renewal-margin ahead of expiry. A zero ``expires_at_ms`` means the gateway
set no expiry and the first token is kept for the tunnel's lifetime.
A failed re-mint raises: a tunnel must not present a token that the
gateway may already have reaped. Renewal covers new tunnel connections
only; streams already established with an earlier token live until the
gateway reaps that session.
"""
mint: Callable[[], tuple[str, int]]
clock_ms: Callable[[], int] = _now_ms
renewal_margin_ms: int = _SSH_SESSION_RENEWAL_MARGIN_MS
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
_token: str = field(default="", init=False, repr=False)
_expires_at_ms: int = field(default=0, init=False, repr=False)
def __call__(self) -> str:
with self._lock:
if not self._token or self._renewal_due():
self._token, self._expires_at_ms = self.mint()
return self._token
def _renewal_due(self) -> bool:
return self._expires_at_ms > 0 and self.clock_ms() >= self._expires_at_ms - self.renewal_margin_ms
@dataclass(slots=True)
class _ForwardTcpFrameCodec:
"""Builds ForwardTcp frames for one sandbox port; every connection's init
frame presents the supplier's current session token."""
supplier: _SshSessionTokenSupplier
sandbox_id: str
port: int
def init_frame(self) -> object:
from openshell._proto import openshell_pb2
return openshell_pb2.TcpForwardFrame(
init=openshell_pb2.TcpForwardInit(
sandbox_id=self.sandbox_id,
tcp=openshell_pb2.TcpRelayTarget(host="127.0.0.1", port=self.port),
authorization_token=self.supplier(),
)
)
def data_frame(self, data: bytes) -> object:
from openshell._proto import openshell_pb2
return openshell_pb2.TcpForwardFrame(data=data)
class OpenShellTunnelHandle(Protocol):
"""One open data-plane tunnel to shellctl inside a sandbox."""
@property
def base_url(self) -> str: ...
async def close(self) -> None: ...
class OpenShellControlPlane(Protocol):
"""Gateway operations the backends need, at deployment-config granularity.
Image, policy, driver config, and workspace are deployment constants owned
by the implementation; backends pass only per-resource identity.
"""
async def create_sandbox(
self,
*,
name: str,
labels: dict[str, str],
extra_read_write: tuple[str, ...] = (),
) -> None: ...
async def wait_ready(self, name: str) -> str:
"""Wait for READY and return the sandbox's *current* id."""
...
async def start_sandbox(self, name: str) -> None:
"""Start the sandbox when stopped; no-op for running phases."""
...
async def stop_sandbox(self, name: str) -> None: ...
async def delete_sandbox(self, name: str) -> None: ...
async def exec_script(self, sandbox_id: str, script: str) -> tuple[int, str]: ...
async def open_tunnel(self, sandbox_id: str, port: int) -> OpenShellTunnelHandle: ...
@dataclass(slots=True)
class _SdkTunnelHandle:
tunnel: ForwardTcpTunnel
base_url: str
async def close(self) -> None:
await asyncio.to_thread(self.tunnel.close)
@dataclass(slots=True)
class OpenShellSDKControlPlane:
"""Deployment-scoped OpenShell SDK boundary.
Holds only deployment constants plus a lazily created, cached gRPC client;
no per-resource state lives here. The synchronous SDK runs inside
``asyncio.to_thread``. gRPC NOT_FOUND is normalized to
``OpenShellNotFoundError`` so backends can distinguish confirmed resource
loss from transient failures.
"""
endpoint: str
workspace: str = "default"
bearer_token: str | None = None
tls_ca_path: str | None = None
tls_client_cert_path: str | None = None
tls_client_key_path: str | None = None
insecure: bool = False
image: str = DEFAULT_OPENSHELL_SANDBOX_IMAGE
driver_config: dict[str, object] = field(default_factory=dict)
shared_mount_path: str = DEFAULT_OPENSHELL_SHARED_MOUNT_PATH
# Opt-in egress allowlist as (host, port) pairs; empty sends no network
# policy, leaving sandbox egress to the gateway/driver default.
egress_allow: tuple[tuple[str, int], ...] = ()
ready_timeout_seconds: float = 300.0
exec_timeout_seconds: int = 120
_client_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
_client_cache: SandboxClient | None = field(default=None, init=False, repr=False)
def _client(self) -> SandboxClient:
with self._client_lock:
if self._client_cache is None:
import pathlib
from openshell import SandboxClient, TlsConfig
tls: TlsConfig | None
if self.insecure:
tls = None
else:
tls = TlsConfig(
ca_path=pathlib.Path(self.tls_ca_path) if self.tls_ca_path else None,
cert_path=pathlib.Path(self.tls_client_cert_path) if self.tls_client_cert_path else None,
key_path=pathlib.Path(self.tls_client_key_path) if self.tls_client_key_path else None,
)
self._client_cache = SandboxClient(
self.endpoint,
tls=tls,
bearer_token=self.bearer_token or None,
)
return self._client_cache
def _sandbox_spec(self, extra_read_write: tuple[str, ...] = ()) -> openshell_pb2.SandboxSpec:
from google.protobuf.json_format import ParseDict
from google.protobuf.struct_pb2 import Struct
from openshell._proto import openshell_pb2, sandbox_pb2
driver_config = ParseDict(self.driver_config, Struct())
policy = sandbox_pb2.SandboxPolicy(
version=1,
filesystem=sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=list(_POLICY_READ_ONLY),
read_write=[*_POLICY_READ_WRITE, *extra_read_write],
),
landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"),
network_policies=self._network_policies(),
)
return openshell_pb2.SandboxSpec(
template=openshell_pb2.SandboxTemplate(image=self.image, driver_config=driver_config),
policy=policy,
)
def _network_policies(self) -> dict[str, sandbox_pb2.NetworkPolicyRule]:
from openshell._proto import sandbox_pb2
policies: dict[str, sandbox_pb2.NetworkPolicyRule] = {}
for index, (host, port) in enumerate(self.egress_allow):
# The map key doubles as the rule name; the index keeps entries
# unique even when sanitized endpoints would collide.
name = f"allow_{index}_" + re.sub(r"[^0-9A-Za-z]", "_", f"{host}_{port}")
policies[name] = sandbox_pb2.NetworkPolicyRule(
name=name,
endpoints=[
sandbox_pb2.NetworkEndpoint(
host=host,
port=port,
protocol="rest",
enforcement="enforce",
rules=[sandbox_pb2.L7Rule(allow=sandbox_pb2.L7Allow(method="*", path="/**"))],
)
],
# OpenShell also gates which in-sandbox binaries may open the
# connection; "/**" allows any (the Agent Stub client plus
# tools the agent runs) — the endpoint list is the allowlist.
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
)
return policies
async def create_sandbox(
self,
*,
name: str,
labels: dict[str, str],
extra_read_write: tuple[str, ...] = (),
) -> None:
def call() -> None:
client = self._client()
_ = client.create(
workspace=self.workspace,
spec=self._sandbox_spec(extra_read_write),
name=name,
labels=labels,
)
await asyncio.to_thread(self._run_normalizing_not_found, call)
async def wait_ready(self, name: str) -> str:
def call() -> str:
ref = self._client().wait_ready(
name,
workspace=self.workspace,
timeout_seconds=self.ready_timeout_seconds,
)
return str(ref.id)
return await asyncio.to_thread(self._run_normalizing_not_found, call)
async def start_sandbox(self, name: str) -> None:
def call() -> None:
from openshell._proto import openshell_pb2
client = self._client()
sandbox = client.get(name, workspace=self.workspace)
phase = sandbox.status.phase
if phase in (openshell_pb2.SANDBOX_PHASE_STOPPED, openshell_pb2.SANDBOX_PHASE_STOPPING):
_ = client.start(name, workspace=self.workspace)
await asyncio.to_thread(self._run_normalizing_not_found, call)
async def stop_sandbox(self, name: str) -> None:
def call() -> None:
_ = self._client().stop(name, workspace=self.workspace)
await asyncio.to_thread(self._run_normalizing_not_found, call)
async def delete_sandbox(self, name: str) -> None:
def call() -> None:
_ = self._client().delete(name, workspace=self.workspace)
await asyncio.to_thread(self._run_normalizing_not_found, call)
async def exec_script(self, sandbox_id: str, script: str) -> tuple[int, str]:
def call() -> tuple[int, str]:
# The gateway logs a preview of the assembled exec argv; scripts
# can embed the shellctl token, so they travel via stdin (the
# gateway logs only its length) and the argv stays secret-free.
result = self._client().exec(
sandbox_id,
["/bin/sh", "-s"],
stdin=script.encode(),
timeout_seconds=self.exec_timeout_seconds,
)
output = f"{result.stdout}{result.stderr}"
return int(result.exit_code), output
return await asyncio.to_thread(self._run_normalizing_not_found, call)
async def open_tunnel(self, sandbox_id: str, port: int) -> OpenShellTunnelHandle:
def call() -> _SdkTunnelHandle:
from openshell._proto import openshell_pb2
client = self._client()
# The Python SDK exposes CreateSshSession/ForwardTcp only through
# its raw generated stub; sessions authorize ForwardTcp streams.
stub = client._stub # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
def mint() -> tuple[str, int]:
# Bounded: mint runs under the supplier lock inside tunnel
# connection threads, so a hung call must not stall them all.
session = stub.CreateSshSession(
openshell_pb2.CreateSshSessionRequest(sandbox_id=sandbox_id),
timeout=30.0,
)
return str(session.token), int(session.expires_at_ms)
token_supplier = _SshSessionTokenSupplier(mint=mint)
_ = token_supplier() # mint eagerly so acquire fails here, not mid-run
def stream_factory(request_iterator: Iterator[object]) -> ForwardTcpCall:
return cast(ForwardTcpCall, stub.ForwardTcp(request_iterator))
tunnel = ForwardTcpTunnel(
stream_factory=stream_factory,
frame_codec=_ForwardTcpFrameCodec(supplier=token_supplier, sandbox_id=sandbox_id, port=port),
)
return _SdkTunnelHandle(tunnel=tunnel, base_url=tunnel.open())
return await asyncio.to_thread(self._run_normalizing_not_found, call)
def _run_normalizing_not_found[ResultT](self, call: Callable[[], ResultT]) -> ResultT:
import grpc
try:
return call()
except grpc.RpcError as exc:
code = exc.code() if isinstance(exc, grpc.Call) else None
if code == grpc.StatusCode.NOT_FOUND:
raise OpenShellNotFoundError(str(exc)) from exc
raise
@dataclass(slots=True)
class OpenShellHomeSnapshotBackend:
"""Directory-copy Home Snapshots on the operator-provided shared volume."""
control_plane: OpenShellControlPlane
shared_mount_path: str = DEFAULT_OPENSHELL_SHARED_MOUNT_PATH
async def create_from_runtime(self, *, spec: HomeSnapshotCreateSpec, source: RuntimeLease) -> str:
if not isinstance(source, OpenShellRuntimeLease):
raise HomeSnapshotCreateError("OpenShell Home Snapshot requires an OpenShell RuntimeLease")
snapshot_ref = _snapshot_ref_for(spec.tenant_id, spec.home_snapshot_id)
target = _snapshot_dir(self.shared_mount_path, snapshot_ref, error=HomeSnapshotCreateError)
script = "\n".join(
[
"set -eu",
f"test -d {shlex.quote(source.layout.home_dir)}",
f"mkdir -p {shlex.quote(target)}",
f"cp -a {shlex.quote(source.layout.home_dir)}/. {shlex.quote(target)}/",
f"chmod 700 {shlex.quote(target)}",
]
)
try:
result = await run_shellctl_control_command(source.commands, script)
if result.exit_code != 0:
raise HomeSnapshotCreateError(result.output)
return snapshot_ref
except BaseException as exc:
await _remove_partial_snapshot(source.commands, target=target, snapshot_ref=snapshot_ref)
_reraise_as(exc, error=HomeSnapshotCreateError)
async def delete(self, snapshot_ref: str) -> None:
"""Remove one snapshot directory through a short-lived maintenance sandbox.
The sandbox is granted the tenant snapshot root rather than the target
alone: Landlock requires write on the parent directory to unlink the
snapshot directory itself, and the root stays a single-tenant trust
boundary.
"""
target = _snapshot_dir(self.shared_mount_path, snapshot_ref, error=BindingDestroyError)
tenant_root = target.rsplit("/", 1)[0]
gc_name = f"gc-{uuid.uuid4().hex[:16]}"
created = False
try:
await self.control_plane.create_sandbox(
name=gc_name,
labels={"dify.resource": "snapshot-gc"},
extra_read_write=(tenant_root,),
)
created = True
sandbox_id = await self.control_plane.wait_ready(gc_name)
exit_code, output = await self.control_plane.exec_script(
sandbox_id,
f"rm -rf -- {shlex.quote(target)}",
)
if exit_code != 0:
raise BindingDestroyError(output)
except BindingDestroyError:
raise
except Exception as exc:
raise BindingDestroyError(str(exc)) from exc
finally:
if created:
await _best_effort(
lambda: self.control_plane.delete_sandbox(gc_name),
message="failed to delete OpenShell snapshot maintenance sandbox",
sandbox_name=gc_name,
)
@dataclass(slots=True)
class OpenShellExecutionBindingBackend:
"""Manage OpenShell sandboxes as coupled physical Bindings and Workspaces."""
control_plane: OpenShellControlPlane
shellctl_auth_token: str = ""
shellctl_port: int = 5004
shared_mount_path: str = DEFAULT_OPENSHELL_SHARED_MOUNT_PATH
layout: RuntimeLayout = field(
default_factory=lambda: RuntimeLayout(home_dir="/home/dify", workspace_dir="/home/dify/workspace")
)
async def create_binding(self, spec: ExecutionBindingCreateSpec) -> ExecutionBindingAllocation:
"""Create one stopped sandbox, optionally materializing a Home Snapshot."""
if spec.existing_workspace_ref is not None:
raise SharedWorkspaceUnsupportedError("current OpenShell backend cannot attach to an existing Workspace")
name = _binding_sandbox_name(spec.binding_id)
tenant_root = _tenant_snapshot_root(self.shared_mount_path, spec.tenant_id, error=BindingCreateError)
created = False
try:
await self.control_plane.create_sandbox(
name=name,
labels={
"dify.resource": "runtime-sandbox",
"dify.binding": _opaque_id(spec.binding_id, error=BindingCreateError),
"dify.workspace": _opaque_id(spec.workspace_id, error=BindingCreateError),
"dify.tenant": _opaque_id(spec.tenant_id, error=BindingCreateError),
"dify.agent": _opaque_id(spec.agent_id, error=BindingCreateError),
},
extra_read_write=(tenant_root,),
)
created = True
sandbox_id = await self.control_plane.wait_ready(name)
exit_code, output = await self.control_plane.exec_script(
sandbox_id,
self._create_script(spec.home_snapshot_ref),
)
if exit_code != 0:
raise BindingCreateError(output)
await self.control_plane.stop_sandbox(name)
return ExecutionBindingAllocation(binding_ref=name, workspace_ref=name)
except BaseException as exc:
if created:
await _best_effort(
lambda: self.control_plane.delete_sandbox(name),
message="failed to delete OpenShell sandbox after Binding creation failed",
binding_ref=name,
)
_reraise_as(exc, error=BindingCreateError)
async def acquire(self, binding_ref: str) -> RuntimeLease:
"""Start the sandbox when needed, bootstrap shellctl, and open the tunnel."""
tunnel: OpenShellTunnelHandle | None = None
data_plane: ShellctlRuntimeLease | None = None
try:
await self.control_plane.start_sandbox(binding_ref)
sandbox_id = await self.control_plane.wait_ready(binding_ref)
layout_code, _ = await self.control_plane.exec_script(
sandbox_id,
"\n".join(
[
"set -eu",
f"test -d {shlex.quote(self.layout.home_dir)}",
f"test -d {shlex.quote(self.layout.workspace_dir)}",
]
),
)
if layout_code != 0:
raise BindingLostError(f"OpenShell Binding {binding_ref!r} no longer contains its Home or Workspace")
exit_code, output = await self.control_plane.exec_script(sandbox_id, self._bootstrap_script())
if exit_code != 0:
raise BindingAcquireError(f"shellctl bootstrap failed: {output}")
tunnel = await self.control_plane.open_tunnel(sandbox_id, self.shellctl_port)
data_plane = await self._data_plane(binding_ref, tunnel.base_url)
await _wait_for_shellctl_ready(data_plane.client)
return OpenShellRuntimeLease(tunnel=tunnel, data_plane=data_plane)
except OpenShellNotFoundError as exc:
raise BindingLostError(f"OpenShell Binding {binding_ref!r} no longer exists") from exc
except BaseException as exc:
if data_plane is not None:
await _best_effort(
data_plane.close,
message="failed to close OpenShell RuntimeLease after acquisition failed",
binding_ref=binding_ref,
)
if tunnel is not None:
await _best_effort(
tunnel.close,
message="failed to close OpenShell tunnel after acquisition failed",
binding_ref=binding_ref,
)
await _best_effort(
lambda: self.control_plane.stop_sandbox(binding_ref),
message="failed to stop OpenShell sandbox after acquisition failed",
binding_ref=binding_ref,
)
_reraise_as(exc, error=BindingAcquireError, passthrough=(BindingLostError,))
async def release(self, lease: RuntimeLease) -> None:
"""Close lease-owned connections without stopping the shared physical sandbox."""
# TODO: Revisit idle resource reclamation when upstream sandbox expiration leases are available:
# https://github.com/NVIDIA/OpenShell/issues/2591
# Expiration deletes the sandbox/workspace, so account for active leases and retention before adopting it.
if not isinstance(lease, OpenShellRuntimeLease):
raise TypeError("OpenShellExecutionBindingBackend can only release its own RuntimeLease")
close_error: Exception | None = None
try:
await lease.data_plane.close()
except Exception as exc:
close_error = exc
finally:
try:
await lease.tunnel.close()
except Exception as exc:
close_error = close_error or exc
if close_error is not None:
raise BindingAcquireError(str(close_error)) from close_error
async def destroy_binding(self, spec: ExecutionBindingDestroySpec) -> None:
"""Destroy the coupled physical Binding and Workspace idempotently."""
if not spec.destroy_workspace:
raise WorkspacePreservationUnsupportedError(
"current OpenShell backend cannot destroy a Binding while preserving its Workspace"
)
if spec.workspace_ref != spec.binding_ref:
raise BindingDestroyError("OpenShell Workspace ref must equal its Binding ref")
try:
await self.control_plane.delete_sandbox(spec.binding_ref)
except OpenShellNotFoundError:
return
except Exception as exc:
raise BindingDestroyError(str(exc)) from exc
def _create_script(self, home_snapshot_ref: str | None) -> str:
home = shlex.quote(self.layout.home_dir)
workspace = shlex.quote(self.layout.workspace_dir)
lines = ["set -eu"]
if home_snapshot_ref is not None:
snapshot_dir = shlex.quote(
_snapshot_dir(self.shared_mount_path, home_snapshot_ref, error=BindingCreateError)
)
lines.extend(
[
# Fail (not fall back) when the immutable snapshot is missing.
f"test -d {snapshot_dir}",
f"cp -a {snapshot_dir}/. {home}/",
# The snapshot carries the source's shellctl runtime state
# (SQLite job db, tmux socket); a new Binding must not
# resume it.
f"rm -rf -- {home}/.local/share/shellctl",
]
)
lines.extend(
[
f"rm -rf -- {workspace}",
f"mkdir -p {workspace}",
f"chmod 700 {home} {workspace}",
]
)
return "\n".join(lines)
def _bootstrap_script(self) -> str:
"""Idempotently start shellctl on the sandbox loopback.
The supervisor replaces the image entrypoint, so shellctl never starts
on its own. Landlock path isolation inside shellctl stays off: the
sandbox policy's Landlock layer is authoritative here and stacking the
two would require re-granting every device path twice.
"""
listen = f"127.0.0.1:{self.shellctl_port}"
health = f"curl -fsS -m 2 http://{listen}/healthz >/dev/null 2>&1"
return "\n".join(
[
"set -eu",
f"export HOME={shlex.quote(self.layout.home_dir)}",
f"export SHELLCTL_AUTH_TOKEN={shlex.quote(self.shellctl_auth_token)}",
"export SHELLCTL_ENABLE_PATH_ISOLATION=false",
f"if {health}; then exit 0; fi",
f"setsid /usr/local/bin/shellctl serve --listen {listen} </dev/null >>/tmp/shellctl.log 2>&1 &",
"i=0",
f"until {health}; do",
" i=$((i+1))",
' [ "$i" -ge 10 ] && exit 1',
" sleep 1",
"done",
]
)
async def _data_plane(self, binding_ref: str, base_url: str) -> ShellctlRuntimeLease:
http_client = httpx.AsyncClient(
base_url=base_url,
follow_redirects=True,
timeout=httpx.Timeout(60.0),
# trust_env would route the loopback tunnel through HTTP(S)_PROXY.
trust_env=False,
limits=httpx.Limits(
max_connections=_TUNNEL_MAX_CONNECTIONS,
max_keepalive_connections=_TUNNEL_MAX_CONNECTIONS,
),
)
def client_factory() -> ShellctlClientProtocol:
from shellctl.client import ShellctlClient
return cast(
ShellctlClientProtocol,
cast(object, ShellctlClient(base_url, token=self.shellctl_auth_token, client=http_client)),
)
return await create_owned_shellctl_lease(
handle=binding_ref,
layout=self.layout,
entrypoint=base_url,
token=self.shellctl_auth_token,
client_factory=client_factory,
owned_transport=http_client,
)
@dataclass(slots=True)
class OpenShellRuntimeLease:
"""Invocation-local ForwardTcp tunnel plus the owned shellctl data plane."""
tunnel: OpenShellTunnelHandle
data_plane: ShellctlRuntimeLease
@property
def handle(self) -> str:
return self.data_plane.handle
@property
def layout(self) -> RuntimeLayout:
return self.data_plane.layout
@property
def commands(self) -> ShellCommandProtocol:
return self.data_plane.commands
def _binding_sandbox_name(binding_id: str) -> str:
return f"dify-{_opaque_id(binding_id, error=BindingCreateError)}"
def _opaque_id(value: str, *, error: type[Exception]) -> str:
digest = hashlib.sha256(_validated_ref_part(value, error=error).encode()).hexdigest()
return digest[:_SANDBOX_NAME_DIGEST_LENGTH]
def _snapshot_ref_for(tenant_id: str, home_snapshot_id: str) -> str:
tenant = _opaque_id(tenant_id, error=HomeSnapshotCreateError)
name = f"home-{_validated_ref_part(home_snapshot_id, error=HomeSnapshotCreateError)}"
return f"{tenant}--{name}"
def _split_snapshot_ref(snapshot_ref: str, *, error: type[Exception]) -> tuple[str, str]:
normalized = _validated_ref_part(snapshot_ref, error=error)
tenant, separator, name = normalized.partition("--")
if not separator or not tenant or not name:
raise error("runtime backend ref must be a safe path segment")
return _validated_ref_part(tenant, error=error), _validated_ref_part(name, error=error)
def _tenant_snapshot_root(shared_mount_path: str, tenant_id: str, *, error: type[Exception]) -> str:
return f"{shared_mount_path.rstrip('/')}/{_SNAPSHOT_SUBDIR}/{_opaque_id(tenant_id, error=error)}"
def _snapshot_dir(shared_mount_path: str, snapshot_ref: str, *, error: type[Exception]) -> str:
tenant, name = _split_snapshot_ref(snapshot_ref, error=error)
return f"{shared_mount_path.rstrip('/')}/{_SNAPSHOT_SUBDIR}/{tenant}/{name}"
def _validated_ref_part(value: str, *, error: type[Exception]) -> str:
if value in {"", ".", ".."} or _SAFE_REF_PART.fullmatch(value) is None:
raise error("runtime backend ref must be a safe path segment")
return value
async def _wait_for_shellctl_ready(client: ShellctlClientProtocol) -> None:
for attempt in range(_SHELLCTL_READY_MAX_ATTEMPTS):
try:
_ = await client.health()
return
except (httpx.TimeoutException, httpx.RequestError):
if attempt == _SHELLCTL_READY_MAX_ATTEMPTS - 1:
raise
except ShellctlClientError as exc:
if not 500 <= exc.status_code < 600 or attempt == _SHELLCTL_READY_MAX_ATTEMPTS - 1:
raise
await asyncio.sleep(_SHELLCTL_READY_RETRY_INTERVAL_SECONDS)
async def _remove_partial_snapshot(commands: ShellCommandProtocol, *, target: str, snapshot_ref: str) -> None:
try:
result = await run_shellctl_control_command(commands, f"rm -rf -- {shlex.quote(target)}")
if result.exit_code != 0:
logger.warning("failed to remove partial OpenShell snapshot", extra={"snapshot_ref": snapshot_ref})
except BaseException:
logger.warning(
"failed to remove partial OpenShell snapshot",
exc_info=True,
extra={"snapshot_ref": snapshot_ref},
)
def _reraise_as(
exc: BaseException,
*,
error: type[Exception],
passthrough: tuple[type[Exception], ...] = (),
) -> NoReturn:
"""Re-raise domain errors as-is and wrap any other ``Exception`` in ``error``.
Non-``Exception`` ``BaseException``s (cancellation, exit signals) propagate
unwrapped.
"""
if isinstance(exc, (error, *passthrough)):
raise exc
if isinstance(exc, Exception):
raise error(str(exc)) from exc
raise exc
async def _best_effort(action: Callable[[], Awaitable[object]], *, message: str, **extra: str) -> None:
"""Run one cleanup step, downgrading any failure to a warning log."""
try:
_ = await action()
except BaseException:
logger.warning(message, exc_info=True, extra=dict(extra))
__all__ = [
"DEFAULT_OPENSHELL_SANDBOX_IMAGE",
"DEFAULT_OPENSHELL_SHARED_MOUNT_PATH",
"OpenShellControlPlane",
"OpenShellExecutionBindingBackend",
"OpenShellHomeSnapshotBackend",
"OpenShellNotFoundError",
"OpenShellRuntimeLease",
"OpenShellSDKControlPlane",
"OpenShellTunnelHandle",
]
@@ -0,0 +1,192 @@
"""Local TCP listener bridging shellctl HTTP to an OpenShell ForwardTcp tunnel.
OpenShell exposes services running inside a sandbox only through its gateway
gRPC API. ``ForwardTcp`` is an authenticated bidirectional byte stream to one
loopback port inside the sandbox, so plain HTTP clients cannot use it
directly. This module runs a loopback TCP listener and pipes every accepted
connection through its own ``ForwardTcp`` stream, which lets the unmodified
shellctl HTTP client talk to the in-sandbox shellctl daemon.
The gateway caps concurrent streams at 3 per SSH-session token; callers must
bound their HTTP connection pool accordingly.
"""
from __future__ import annotations
import logging
import queue
import socket
import threading
from collections.abc import Iterator
from typing import Protocol
logger = logging.getLogger(__name__)
_RECV_CHUNK_BYTES = 65536
_LISTEN_BACKLOG = 16
def _shutdown_and_close(sock: socket.socket) -> None:
"""Wake any thread blocked on this socket, then close it.
On Linux ``close()`` alone neither interrupts a thread blocked in
``recv()``/``accept()`` nor sends FIN while that syscall pins the open
file description, so teardown must ``shutdown()`` first. macOS wakes
blocked callers on ``close()`` and rejects ``shutdown()`` on a listening
socket with ENOTCONN, hence the best-effort handling of both calls.
"""
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
sock.close()
except OSError:
pass
class ForwardTcpFrame(Protocol):
data: bytes
class ForwardTcpCall(Protocol):
"""One live ForwardTcp stream: response iterator plus cancellation."""
def __iter__(self) -> Iterator[ForwardTcpFrame]: ...
def cancel(self) -> bool: ...
class ForwardTcpStreamFactory(Protocol):
"""Open one ForwardTcp stream from an outgoing frame iterator."""
def __call__(self, request_iterator: Iterator[object]) -> ForwardTcpCall: ...
class ForwardTcpFrameCodec(Protocol):
"""Build the wire frames for one tunnel (init routing plus data)."""
def init_frame(self) -> object: ...
def data_frame(self, data: bytes) -> object: ...
class ForwardTcpTunnel:
"""Loopback listener that opens one ForwardTcp stream per TCP connection."""
def __init__(self, *, stream_factory: ForwardTcpStreamFactory, frame_codec: ForwardTcpFrameCodec) -> None:
self._stream_factory = stream_factory
self._frame_codec = frame_codec
self._listener: socket.socket | None = None
self._closed = threading.Event()
self._connections: set[socket.socket] = set()
self._lock = threading.Lock()
def open(self) -> str:
"""Bind a loopback listener and return its ``http://`` base URL."""
if self._listener is not None:
raise RuntimeError("ForwardTcpTunnel is already open")
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(_LISTEN_BACKLOG)
self._listener = listener
port = listener.getsockname()[1]
threading.Thread(target=self._accept_loop, name="openshell-tunnel-accept", daemon=True).start()
return f"http://127.0.0.1:{port}"
def close(self) -> None:
"""Stop accepting and tear down every live connection. Idempotent."""
self._closed.set()
listener = self._listener
if listener is not None:
_shutdown_and_close(listener)
with self._lock:
connections = list(self._connections)
self._connections.clear()
for connection in connections:
_shutdown_and_close(connection)
def _accept_loop(self) -> None:
listener = self._listener
if listener is None:
return
while not self._closed.is_set():
try:
connection, _ = listener.accept()
except OSError:
return
connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
with self._lock:
# Re-check under the lock: a connection accepted concurrently
# with close() must not slip past its teardown snapshot and
# leak one of the gateway's per-token stream slots.
if self._closed.is_set():
try:
connection.close()
except OSError:
pass
return
self._connections.add(connection)
threading.Thread(
target=self._pump_connection,
args=(connection,),
name="openshell-tunnel-conn",
daemon=True,
).start()
def _pump_connection(self, connection: socket.socket) -> None:
outgoing: queue.Queue[bytes | None] = queue.Queue()
def request_iterator() -> Iterator[object]:
yield self._frame_codec.init_frame()
while True:
item = outgoing.get()
if item is None:
return
yield self._frame_codec.data_frame(item)
try:
call = self._stream_factory(request_iterator())
except Exception:
logger.warning("failed to open OpenShell ForwardTcp stream", exc_info=True)
self._discard_connection(connection)
return
def pump_up() -> None:
try:
while True:
data = connection.recv(_RECV_CHUNK_BYTES)
if not data:
break
outgoing.put(data)
except OSError:
pass
finally:
outgoing.put(None)
threading.Thread(target=pump_up, name="openshell-tunnel-up", daemon=True).start()
try:
for frame in call:
if frame.data:
connection.sendall(frame.data)
except Exception:
if not self._closed.is_set():
logger.warning("OpenShell ForwardTcp stream ended with an error", exc_info=True)
finally:
_ = call.cancel()
self._discard_connection(connection)
def _discard_connection(self, connection: socket.socket) -> None:
with self._lock:
self._connections.discard(connection)
_shutdown_and_close(connection)
__all__ = [
"ForwardTcpCall",
"ForwardTcpFrame",
"ForwardTcpFrameCodec",
"ForwardTcpStreamFactory",
"ForwardTcpTunnel",
]
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import posixpath
from typing import ClassVar, Literal, Self
from urllib.parse import urlparse
@@ -17,6 +18,13 @@ from dify_agent.runtime_backend.e2b import (
)
from dify_agent.runtime_backend.enterprise import EnterpriseExecutionBindingBackend, EnterpriseHomeSnapshotBackend
from dify_agent.runtime_backend.local import LocalExecutionBindingBackend, LocalHomeSnapshotBackend
from dify_agent.runtime_backend.openshell import (
DEFAULT_OPENSHELL_SANDBOX_IMAGE,
DEFAULT_OPENSHELL_SHARED_MOUNT_PATH,
OpenShellExecutionBindingBackend,
OpenShellHomeSnapshotBackend,
OpenShellSDKControlPlane,
)
from dify_agent.runtime_backend.protocols import RuntimeBackendProfile
DEFAULT_E2B_TEMPLATE = "difys-default-team/dify-agent-local-sandbox"
@@ -28,7 +36,7 @@ DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT = "/home/dify/.snapshots"
class RuntimeBackendSettings(BaseSettings):
"""Server-private credentials and endpoints for one coherent backend profile."""
runtime_backend: Literal["local", "enterprise", "e2b"] = "local"
runtime_backend: Literal["local", "enterprise", "e2b", "openshell"] = "local"
local_sandbox_endpoint: str | None = Field(
default=None,
@@ -63,6 +71,22 @@ class RuntimeBackendSettings(BaseSettings):
)
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
openshell_gateway_endpoint: str | None = None
openshell_workspace: str = "default"
openshell_bearer_token: str | None = None
openshell_tls_ca_path: str | None = None
openshell_tls_client_cert_path: str | None = None
openshell_tls_client_key_path: str | None = None
openshell_insecure: bool = False
openshell_sandbox_image: str = DEFAULT_OPENSHELL_SANDBOX_IMAGE
openshell_driver_config: str | None = None
openshell_shared_mount_path: str = DEFAULT_OPENSHELL_SHARED_MOUNT_PATH
openshell_egress_allow: str = ""
openshell_shellctl_auth_token: str = ""
openshell_shellctl_port: int = Field(default=5004, ge=1, le=65535)
openshell_ready_timeout_seconds: float = Field(default=300.0, gt=0)
openshell_exec_timeout_seconds: int = Field(default=120, ge=1)
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
env_prefix="DIFY_AGENT_",
env_file=(".env", "dify-agent/.env"),
@@ -101,6 +125,33 @@ class RuntimeBackendSettings(BaseSettings):
raise ValueError("e2b_api_key is required for the e2b runtime backend")
if not self.e2b_template.strip():
raise ValueError("e2b_template must not be blank")
case "openshell":
if not self.openshell_gateway_endpoint or not self.openshell_gateway_endpoint.strip():
raise ValueError("openshell_gateway_endpoint is required for the openshell runtime backend")
if not self.openshell_driver_config or not self.openshell_driver_config.strip():
raise ValueError(
"openshell_driver_config is required for the openshell runtime backend: "
"it must mount the shared Home Snapshot volume into every sandbox"
)
_ = _parse_openshell_driver_config(self.openshell_driver_config)
_ = _parse_openshell_egress_allow(self.openshell_egress_allow)
if not self.openshell_shellctl_auth_token.strip():
raise ValueError("openshell_shellctl_auth_token is required for the openshell runtime backend")
_validate_absolute_posix_path(
self.openshell_shared_mount_path,
field_name="openshell_shared_mount_path",
)
if not self.openshell_sandbox_image.strip():
raise ValueError("openshell_sandbox_image must not be blank")
# Compose injects empty strings for unset variables; treat
# blank and None alike so a half-configured mTLS pair fails
# here instead of at the first gateway call.
cert_path = (self.openshell_tls_client_cert_path or "").strip()
key_path = (self.openshell_tls_client_key_path or "").strip()
if bool(cert_path) != bool(key_path):
raise ValueError(
"openshell_tls_client_cert_path and openshell_tls_client_key_path must be set together"
)
return self
@@ -154,6 +205,34 @@ def create_runtime_backend_profile(settings: RuntimeBackendSettings) -> RuntimeB
shellctl_port=settings.e2b_shellctl_port,
),
)
case "openshell":
openshell_control_plane = OpenShellSDKControlPlane(
endpoint=(settings.openshell_gateway_endpoint or "").strip(),
workspace=settings.openshell_workspace,
bearer_token=settings.openshell_bearer_token or None,
tls_ca_path=settings.openshell_tls_ca_path or None,
tls_client_cert_path=settings.openshell_tls_client_cert_path or None,
tls_client_key_path=settings.openshell_tls_client_key_path or None,
insecure=settings.openshell_insecure,
image=settings.openshell_sandbox_image,
driver_config=_parse_openshell_driver_config(settings.openshell_driver_config or ""),
shared_mount_path=settings.openshell_shared_mount_path,
egress_allow=_parse_openshell_egress_allow(settings.openshell_egress_allow),
ready_timeout_seconds=settings.openshell_ready_timeout_seconds,
exec_timeout_seconds=settings.openshell_exec_timeout_seconds,
)
return RuntimeBackendProfile(
home_snapshots=OpenShellHomeSnapshotBackend(
control_plane=openshell_control_plane,
shared_mount_path=settings.openshell_shared_mount_path,
),
execution_bindings=OpenShellExecutionBindingBackend(
control_plane=openshell_control_plane,
shellctl_auth_token=settings.openshell_shellctl_auth_token,
shellctl_port=settings.openshell_shellctl_port,
shared_mount_path=settings.openshell_shared_mount_path,
),
)
def _validate_http_url(value: str, *, field_name: str) -> None:
@@ -167,6 +246,32 @@ def _validate_absolute_posix_path(value: str, *, field_name: str) -> None:
raise ValueError(f"{field_name} must be an absolute POSIX path")
def _parse_openshell_egress_allow(value: str) -> tuple[tuple[str, int], ...]:
"""Parse a comma-separated ``host:port`` list into ``(host, port)`` pairs."""
endpoints: list[tuple[str, int]] = []
for raw_entry in value.split(","):
entry = raw_entry.strip()
if not entry:
continue
host, _, port_text = entry.rpartition(":")
if not host or "/" in entry or not port_text.isdigit() or not 1 <= int(port_text) <= 65535:
raise ValueError(f"openshell_egress_allow entries must be host:port (no scheme or path), got: {entry!r}")
endpoints.append((host, int(port_text)))
return tuple(endpoints)
def _parse_openshell_driver_config(value: str) -> dict[str, object]:
try:
parsed: object = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("openshell_driver_config must be valid JSON") from exc
if not isinstance(parsed, dict) or not parsed:
raise ValueError(
"openshell_driver_config must be a non-empty JSON object that must mount the shared Home Snapshot volume"
)
return {str(key): item for key, item in parsed.items()}
__all__ = [
"DEFAULT_E2B_TEMPLATE",
"DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT",
+35 -1
View File
@@ -28,6 +28,10 @@ from dify_agent.runtime.event_coalescer import (
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS
from dify_agent.runtime_backend import RuntimeBackendProfile
from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS
from dify_agent.runtime_backend.openshell import (
DEFAULT_OPENSHELL_SANDBOX_IMAGE,
DEFAULT_OPENSHELL_SHARED_MOUNT_PATH,
)
from dify_agent.runtime_backend.profile import (
DEFAULT_LOCAL_HOME_SNAPSHOT_ROOT,
DEFAULT_LOCAL_MATERIALIZED_HOME_ROOT,
@@ -59,7 +63,7 @@ class ServerSettings(BaseSettings):
plugin_daemon_api_key: str = ""
inner_api_url: str = "http://localhost:5001"
inner_api_key: str | None = None
runtime_backend: Literal["local", "enterprise", "e2b"] = "local"
runtime_backend: Literal["local", "enterprise", "e2b", "openshell"] = "local"
local_sandbox_endpoint: str | None = Field(
default=None,
validation_alias=AliasChoices("DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT", "DIFY_AGENT_SHELLCTL_ENTRYPOINT"),
@@ -84,6 +88,21 @@ class ServerSettings(BaseSettings):
le=E2B_MAX_ACTIVE_TIMEOUT_SECONDS,
)
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
openshell_gateway_endpoint: str | None = None
openshell_workspace: str = "default"
openshell_bearer_token: str | None = None
openshell_tls_ca_path: str | None = None
openshell_tls_client_cert_path: str | None = None
openshell_tls_client_key_path: str | None = None
openshell_insecure: bool = False
openshell_sandbox_image: str = DEFAULT_OPENSHELL_SANDBOX_IMAGE
openshell_driver_config: str | None = None
openshell_shared_mount_path: str = DEFAULT_OPENSHELL_SHARED_MOUNT_PATH
openshell_egress_allow: str = ""
openshell_shellctl_auth_token: str = ""
openshell_shellctl_port: int = Field(default=5004, ge=1, le=65535)
openshell_ready_timeout_seconds: float = Field(default=300.0, gt=0)
openshell_exec_timeout_seconds: int = Field(default=120, ge=1)
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
sandbox_files_base_url: str | None = Field(
default=None,
@@ -225,6 +244,21 @@ class ServerSettings(BaseSettings):
e2b_template=self.e2b_template,
e2b_active_timeout_seconds=self.e2b_active_timeout_seconds,
e2b_shellctl_port=self.e2b_shellctl_port,
openshell_gateway_endpoint=self.openshell_gateway_endpoint,
openshell_workspace=self.openshell_workspace,
openshell_bearer_token=self.openshell_bearer_token,
openshell_tls_ca_path=self.openshell_tls_ca_path,
openshell_tls_client_cert_path=self.openshell_tls_client_cert_path,
openshell_tls_client_key_path=self.openshell_tls_client_key_path,
openshell_insecure=self.openshell_insecure,
openshell_sandbox_image=self.openshell_sandbox_image,
openshell_driver_config=self.openshell_driver_config,
openshell_shared_mount_path=self.openshell_shared_mount_path,
openshell_egress_allow=self.openshell_egress_allow,
openshell_shellctl_auth_token=self.openshell_shellctl_auth_token,
openshell_shellctl_port=self.openshell_shellctl_port,
openshell_ready_timeout_seconds=self.openshell_ready_timeout_seconds,
openshell_exec_timeout_seconds=self.openshell_exec_timeout_seconds,
)
)
@@ -1,7 +1,8 @@
"""Opt-in integration contracts for final Local and E2B working environments."""
"""Opt-in integration contracts for final Local, E2B, and OpenShell working environments."""
from __future__ import annotations
import json
import os
import shlex
import sys
@@ -21,6 +22,11 @@ from dify_agent.runtime_backend.e2b import (
E2BSDKControlPlane,
)
from dify_agent.runtime_backend.local import LocalExecutionBindingBackend
from dify_agent.runtime_backend.openshell import (
OpenShellExecutionBindingBackend,
OpenShellHomeSnapshotBackend,
OpenShellSDKControlPlane,
)
from dify_agent.runtime.command_runner import execute_complete_with_commands
pytestmark = pytest.mark.integration
@@ -220,3 +226,150 @@ async def test_e2b_binding_checkpoint_and_collection() -> None:
cleanup_errors.append(exc)
if cleanup_errors and not primary_error:
raise cleanup_errors[0]
@pytest.mark.anyio
async def test_openshell_binding_checkpoint_and_collection() -> None:
endpoint = _required_env("DIFY_AGENT_TEST_OPENSHELL_GATEWAY_ENDPOINT", "real OpenShell gateway")
image = _required_env("DIFY_AGENT_TEST_OPENSHELL_SANDBOX_IMAGE", "real OpenShell gateway")
driver_config_json = _required_env("DIFY_AGENT_TEST_OPENSHELL_DRIVER_CONFIG", "real OpenShell gateway")
driver_config = json.loads(driver_config_json)
assert isinstance(driver_config, dict)
shared_mount_path = os.environ.get(
"DIFY_AGENT_TEST_OPENSHELL_SHARED_MOUNT_PATH",
"/mnt/dify-agent-shared",
)
shellctl_token = os.environ.get("DIFY_AGENT_TEST_OPENSHELL_SHELLCTL_AUTH_TOKEN", "")
marker = uuid.uuid4().hex
control = OpenShellSDKControlPlane(
endpoint=endpoint,
workspace=os.environ.get("DIFY_AGENT_TEST_OPENSHELL_WORKSPACE", "default"),
bearer_token=os.environ.get("DIFY_AGENT_TEST_OPENSHELL_BEARER_TOKEN") or None,
tls_ca_path=os.environ.get("DIFY_AGENT_TEST_OPENSHELL_TLS_CA_PATH") or None,
tls_client_cert_path=os.environ.get("DIFY_AGENT_TEST_OPENSHELL_TLS_CLIENT_CERT_PATH") or None,
tls_client_key_path=os.environ.get("DIFY_AGENT_TEST_OPENSHELL_TLS_CLIENT_KEY_PATH") or None,
insecure=os.environ.get("DIFY_AGENT_TEST_OPENSHELL_INSECURE", "").lower() == "true",
image=image,
driver_config=driver_config,
shared_mount_path=shared_mount_path,
)
snapshots = OpenShellHomeSnapshotBackend(control_plane=control, shared_mount_path=shared_mount_path)
bindings = OpenShellExecutionBindingBackend(
control_plane=control,
shellctl_auth_token=shellctl_token,
shared_mount_path=shared_mount_path,
)
checkpoint_ref: str | None = None
allocation = None
checkpoint_allocation = None
lease = None
checkpoint_lease = None
try:
allocation = await bindings.create_binding(
ExecutionBindingCreateSpec(
tenant_id="integration-tenant",
agent_id="integration-agent",
binding_id=marker,
workspace_id=marker,
existing_workspace_ref=None,
home_snapshot_ref=None,
)
)
lease = await bindings.acquire(allocation.binding_ref)
await _run(lease, "printf openshell > probe.txt", cwd=lease.layout.workspace_dir)
await _run(lease, "printf checkpoint-home > .checkpoint-probe", cwd=lease.layout.home_dir)
assert await _run(lease, "cat probe.txt", cwd=lease.layout.workspace_dir) == "openshell"
long_wait = await execute_complete_with_commands(
lease.commands,
"sleep 35; printf waited",
cwd=lease.layout.workspace_dir,
env={"HOME": lease.layout.home_dir},
timeout=45.0,
max_output_bytes=4096,
)
assert long_wait.exit_code == 0
assert long_wait.output_complete
assert long_wait.output.endswith("waited")
checkpoint_ref = await snapshots.create_from_runtime(
spec=HomeSnapshotCreateSpec(
tenant_id="integration-tenant",
agent_id="integration-agent",
home_snapshot_id=f"checkpoint-{marker}",
),
source=lease,
)
sandbox_id = await control.wait_ready(allocation.binding_ref)
await bindings.release(lease)
lease = None
assert await control.exec_script(sandbox_id, "true") == (0, "")
lease = await bindings.acquire(allocation.binding_ref)
assert await _run(lease, "cat probe.txt", cwd=lease.layout.workspace_dir) == "openshell"
await bindings.release(lease)
lease = None
await control.stop_sandbox(allocation.binding_ref)
# The stopped sandbox must restart and re-bootstrap shellctl on acquire.
lease = await bindings.acquire(allocation.binding_ref)
assert await _run(lease, "cat probe.txt", cwd=lease.layout.workspace_dir) == "openshell"
await bindings.release(lease)
lease = None
checkpoint_allocation = await bindings.create_binding(
ExecutionBindingCreateSpec(
tenant_id="integration-tenant",
agent_id="integration-agent",
binding_id=f"checkpoint-{marker}",
workspace_id=f"checkpoint-{marker}",
existing_workspace_ref=None,
home_snapshot_ref=checkpoint_ref,
)
)
checkpoint_lease = await bindings.acquire(checkpoint_allocation.binding_ref)
checkpoint_path = shlex.quote(f"{checkpoint_lease.layout.home_dir}/.checkpoint-probe")
restored = await _run(checkpoint_lease, f"cat {checkpoint_path}", cwd=checkpoint_lease.layout.workspace_dir)
assert restored == "checkpoint-home"
await bindings.release(checkpoint_lease)
checkpoint_lease = None
finally:
primary_error = sys.exc_info()[0] is not None
cleanup_errors: list[BaseException] = []
if lease is not None:
try:
await bindings.release(lease)
except BaseException as exc:
cleanup_errors.append(exc)
if checkpoint_lease is not None:
try:
await bindings.release(checkpoint_lease)
except BaseException as exc:
cleanup_errors.append(exc)
if checkpoint_allocation is not None:
try:
await bindings.destroy_binding(
ExecutionBindingDestroySpec(
binding_ref=checkpoint_allocation.binding_ref,
workspace_ref=checkpoint_allocation.workspace_ref,
destroy_workspace=True,
)
)
except BaseException as exc:
cleanup_errors.append(exc)
if allocation is not None:
try:
await bindings.destroy_binding(
ExecutionBindingDestroySpec(
binding_ref=allocation.binding_ref,
workspace_ref=allocation.workspace_ref,
destroy_workspace=True,
)
)
except BaseException as exc:
cleanup_errors.append(exc)
if checkpoint_ref is not None:
try:
await snapshots.delete(checkpoint_ref)
except BaseException as exc:
cleanup_errors.append(exc)
if cleanup_errors and not primary_error:
raise cleanup_errors[0]
@@ -0,0 +1,740 @@
from __future__ import annotations
import asyncio
import hashlib
from collections.abc import Callable
from dataclasses import dataclass, field
import httpx2 as httpx
import pytest
from httpx2 import AsyncClient
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellCommandStatus, ShellExecutionMode
from dify_agent.runtime_backend import (
BindingAcquireError,
BindingCreateError,
BindingDestroyError,
BindingLostError,
ExecutionBindingCreateSpec,
ExecutionBindingDestroySpec,
HomeSnapshotCreateSpec,
SharedWorkspaceUnsupportedError,
WorkspacePreservationUnsupportedError,
)
from dify_agent.runtime_backend import openshell as openshell_module
from dify_agent.runtime_backend.errors import HomeSnapshotCreateError
from dify_agent.runtime_backend.openshell import (
OpenShellExecutionBindingBackend,
OpenShellHomeSnapshotBackend,
OpenShellNotFoundError,
OpenShellRuntimeLease,
_SshSessionTokenSupplier, # pyright: ignore[reportPrivateUsage]
)
from dify_agent.runtime_backend.protocols import RuntimeLayout
from dify_agent.runtime_backend.shellctl import ShellctlRuntimeLease
# The gateway caps sandbox names at 19 chars, so Binding names carry a
# sha256 digest of the Binding id; restated here independently of the
# implementation to pin the naming contract.
_BINDING_NAME = f"dify-{hashlib.sha256(b'binding-1').hexdigest()[:14]}"
_OPAQUE_TENANT = hashlib.sha256(b"tenant-1").hexdigest()[:14]
_OPAQUE_AGENT = hashlib.sha256(b"agent-1").hexdigest()[:14]
_OPAQUE_BINDING = hashlib.sha256(b"binding-1").hexdigest()[:14]
_OPAQUE_WORKSPACE = hashlib.sha256(b"workspace-1").hexdigest()[:14]
_SNAPSHOT_REF = f"{_OPAQUE_TENANT}--home-snap-1"
_TENANT_SNAPSHOT_ROOT = f"/mnt/dify-agent-shared/home-snapshots/{_OPAQUE_TENANT}"
_SNAPSHOT_DIR = f"{_TENANT_SNAPSHOT_ROOT}/home-snap-1"
@dataclass(slots=True)
class _FakeTunnel:
base_url: str = "http://tunnel.invalid"
closed: int = 0
close_error: BaseException | None = None
async def close(self) -> None:
self.closed += 1
if self.close_error is not None:
raise self.close_error
@dataclass(slots=True)
class _ControlPlane:
created: list[tuple[str, dict[str, str]]] = field(default_factory=list)
extra_read_write: list[tuple[str, ...]] = field(default_factory=list)
exec_calls: list[tuple[str, str]] = field(default_factory=list)
started: list[str] = field(default_factory=list)
stopped: list[str] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
tunnels: list[_FakeTunnel] = field(default_factory=list)
generation: dict[str, int] = field(default_factory=dict)
missing: set[str] = field(default_factory=set)
exec_results: list[tuple[int, str]] = field(default_factory=list)
stop_error: Exception | None = None
async def create_sandbox(
self,
*,
name: str,
labels: dict[str, str],
extra_read_write: tuple[str, ...] = (),
) -> None:
self.created.append((name, labels))
self.extra_read_write.append(extra_read_write)
self.generation[name] = 1
async def wait_ready(self, name: str) -> str:
self._require(name)
return f"{name}-id-{self.generation[name]}"
async def start_sandbox(self, name: str) -> None:
self._require(name)
self.started.append(name)
# Ids change across stop/start cycles; acquire must re-resolve them.
self.generation[name] += 1
async def stop_sandbox(self, name: str) -> None:
self._require(name)
if self.stop_error is not None:
raise self.stop_error
self.stopped.append(name)
async def delete_sandbox(self, name: str) -> None:
self._require(name)
self.deleted.append(name)
self.generation.pop(name, None)
async def exec_script(self, sandbox_id: str, script: str) -> tuple[int, str]:
self.exec_calls.append((sandbox_id, script))
if self.exec_results:
return self.exec_results.pop(0)
return (0, "")
async def open_tunnel(self, sandbox_id: str, port: int) -> _FakeTunnel:
del sandbox_id, port
tunnel = _FakeTunnel()
self.tunnels.append(tunnel)
return tunnel
def _require(self, name: str) -> None:
if name in self.missing or name not in self.generation:
raise OpenShellNotFoundError(name)
def _mock_http(
monkeypatch: pytest.MonkeyPatch,
handler: Callable[[httpx.Request], httpx.Response],
) -> None:
original_async_client = httpx.AsyncClient
transport = httpx.MockTransport(handler)
def create_client(*args: object, **kwargs: object) -> httpx.AsyncClient:
_ = kwargs.setdefault("transport", transport)
return original_async_client(*args, **kwargs)
monkeypatch.setattr(openshell_module.httpx, "AsyncClient", create_client)
def _healthy_handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/healthz":
return httpx.Response(200, json={"status": "ok"})
return httpx.Response(404, json={"error": {"code": "not_found", "message": "missing"}})
def _backend(control: _ControlPlane) -> OpenShellExecutionBindingBackend:
return OpenShellExecutionBindingBackend(
control_plane=control,
shellctl_auth_token="shellctl-token",
)
def _create_spec(**overrides: object) -> ExecutionBindingCreateSpec:
values: dict[str, object] = {
"tenant_id": "tenant-1",
"agent_id": "agent-1",
"binding_id": "binding-1",
"workspace_id": "workspace-1",
"existing_workspace_ref": None,
"home_snapshot_ref": None,
}
values.update(overrides)
return ExecutionBindingCreateSpec(**values) # pyright: ignore[reportArgumentType]
@dataclass(slots=True)
class _RecordingCommands:
scripts: list[str] = field(default_factory=list)
exit_codes: list[int] = field(default_factory=list)
async def run(
self,
script: str,
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float,
mode: ShellExecutionMode = "pty",
) -> ShellCommandResult:
del cwd, env, timeout, mode
self.scripts.append(script)
exit_code = self.exit_codes.pop(0) if self.exit_codes else 0
return ShellCommandResult(
job_id=f"job-{len(self.scripts)}",
status="exited",
done=True,
exit_code=exit_code,
output="output",
offset=0,
truncated=False,
)
async def wait(self, job_id: str, *, offset: int, timeout: float) -> ShellCommandResult:
raise AssertionError("control commands complete on run in this fake")
async def read_output(self, job_id: str, *, offset: int) -> ShellCommandResult:
raise AssertionError("unused")
async def input(self, job_id: str, text: str, *, offset: int, timeout: float) -> ShellCommandResult:
raise AssertionError("unused")
async def interrupt(self, job_id: str, *, grace_seconds: float) -> ShellCommandStatus:
raise AssertionError("unused")
async def tail(self, job_id: str) -> ShellCommandResult:
raise AssertionError("unused")
async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None) -> None:
del job_id, force, grace_seconds
@dataclass(slots=True)
class _FakeShellctlClient:
closed: int = 0
close_error: BaseException | None = None
async def health(self) -> object:
return object()
async def close(self) -> None:
self.closed += 1
if self.close_error is not None:
raise self.close_error
def _source_lease(commands: _RecordingCommands) -> OpenShellRuntimeLease:
data_plane = ShellctlRuntimeLease(
handle=_BINDING_NAME,
layout=RuntimeLayout(home_dir="/home/dify", workspace_dir="/home/dify/workspace"),
client=_FakeShellctlClient(), # pyright: ignore[reportArgumentType]
commands=commands, # pyright: ignore[reportArgumentType]
)
return OpenShellRuntimeLease(tunnel=_FakeTunnel(), data_plane=data_plane)
@pytest.mark.anyio
async def test_openshell_binding_create_initializes_layout_then_stops() -> None:
control = _ControlPlane()
allocation = await _backend(control).create_binding(_create_spec())
assert allocation.binding_ref == _BINDING_NAME
assert allocation.workspace_ref == allocation.binding_ref
((name, labels),) = control.created
assert name == _BINDING_NAME
assert labels == {
"dify.resource": "runtime-sandbox",
"dify.binding": _OPAQUE_BINDING,
"dify.workspace": _OPAQUE_WORKSPACE,
"dify.tenant": _OPAQUE_TENANT,
"dify.agent": _OPAQUE_AGENT,
}
assert control.extra_read_write == [(_TENANT_SNAPSHOT_ROOT,)]
((sandbox_id, script),) = control.exec_calls
assert sandbox_id == f"{_BINDING_NAME}-id-1"
assert "rm -rf -- /home/dify/workspace" in script
assert "mkdir -p /home/dify/workspace" in script
assert "cp -a" not in script
assert control.stopped == [_BINDING_NAME]
assert control.deleted == []
@pytest.mark.anyio
async def test_openshell_binding_create_materializes_snapshot_without_fallback() -> None:
control = _ControlPlane()
_ = await _backend(control).create_binding(_create_spec(home_snapshot_ref=_SNAPSHOT_REF))
((_, script),) = control.exec_calls
lines = script.splitlines()
snapshot_dir = _SNAPSHOT_DIR
# The snapshot existence check must precede the copy: missing snapshots
# fail the whole create instead of falling back to a default Home.
assert lines.index(f"test -d {snapshot_dir}") < lines.index(f"cp -a {snapshot_dir}/. /home/dify/")
assert "rm -rf -- /home/dify/.local/share/shellctl" in lines
@pytest.mark.anyio
async def test_openshell_binding_create_rejects_shared_workspace() -> None:
control = _ControlPlane()
with pytest.raises(SharedWorkspaceUnsupportedError):
_ = await _backend(control).create_binding(_create_spec(existing_workspace_ref="workspace-1"))
assert control.created == []
@pytest.mark.anyio
async def test_openshell_binding_create_deletes_sandbox_when_initialization_fails() -> None:
control = _ControlPlane()
control.exec_results = [(1, "boom")]
with pytest.raises(BindingCreateError, match="boom"):
_ = await _backend(control).create_binding(_create_spec())
assert control.deleted == [_BINDING_NAME]
@pytest.mark.anyio
async def test_openshell_acquire_bootstraps_shellctl_and_opens_tunnel(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_http(monkeypatch, _healthy_handler)
control = _ControlPlane()
backend = _backend(control)
_ = await backend.create_binding(_create_spec())
lease = await backend.acquire(_BINDING_NAME)
assert isinstance(lease, OpenShellRuntimeLease)
assert control.started == [_BINDING_NAME]
# start bumps the sandbox generation; the bootstrap must target the new id.
bootstrap_sandbox_id, bootstrap = control.exec_calls[-1]
assert bootstrap_sandbox_id == f"{_BINDING_NAME}-id-2"
assert "shellctl serve --listen 127.0.0.1:5004" in bootstrap
assert "SHELLCTL_AUTH_TOKEN=shellctl-token" in bootstrap
assert "SHELLCTL_ENABLE_PATH_ISOLATION=false" in bootstrap
assert len(control.tunnels) == 1
assert lease.layout.home_dir == "/home/dify"
await backend.release(lease)
assert isinstance(lease.data_plane.owned_transport, AsyncClient)
assert lease.data_plane.owned_transport.is_closed
assert control.tunnels[0].closed == 1
assert control.stopped == [_BINDING_NAME]
@pytest.mark.anyio
async def test_openshell_acquire_maps_missing_sandbox_to_binding_lost() -> None:
control = _ControlPlane()
with pytest.raises(BindingLostError):
_ = await _backend(control).acquire("dify-binding-9")
@pytest.mark.anyio
async def test_openshell_acquire_cleans_up_when_bootstrap_fails() -> None:
control = _ControlPlane()
backend = _backend(control)
_ = await backend.create_binding(_create_spec())
control.exec_results = [(0, ""), (1, "no shellctl")]
with pytest.raises(BindingAcquireError, match="no shellctl"):
_ = await backend.acquire(_BINDING_NAME)
# Sandbox is stopped again after the failed acquisition; no tunnel leaks.
assert control.stopped == [_BINDING_NAME, _BINDING_NAME]
assert control.tunnels == []
@pytest.mark.anyio
async def test_openshell_release_leaves_other_leases_open(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_http(monkeypatch, _healthy_handler)
control = _ControlPlane()
backend = _backend(control)
_ = await backend.create_binding(_create_spec())
first = await backend.acquire(_BINDING_NAME)
second = await backend.acquire(_BINDING_NAME)
assert isinstance(second, OpenShellRuntimeLease)
await backend.release(first)
assert control.stopped == [_BINDING_NAME]
assert control.tunnels[0].closed == 1
assert control.tunnels[1].closed == 0
assert isinstance(second.data_plane.owned_transport, AsyncClient)
assert not second.data_plane.owned_transport.is_closed
await second.data_plane.client.health()
await backend.release(second)
assert second.data_plane.owned_transport.is_closed
assert control.tunnels[1].closed == 1
assert control.stopped == [_BINDING_NAME]
@pytest.mark.anyio
async def test_openshell_release_does_not_require_existing_sandbox(monkeypatch: pytest.MonkeyPatch) -> None:
_mock_http(monkeypatch, _healthy_handler)
control = _ControlPlane()
backend = _backend(control)
_ = await backend.create_binding(_create_spec())
lease = await backend.acquire(_BINDING_NAME)
assert isinstance(lease, OpenShellRuntimeLease)
control.missing.add(_BINDING_NAME)
await backend.release(lease)
assert isinstance(lease.data_plane.owned_transport, AsyncClient)
assert lease.data_plane.owned_transport.is_closed
assert control.tunnels[0].closed == 1
assert control.stopped == [_BINDING_NAME]
@pytest.mark.anyio
@pytest.mark.parametrize("failure_stage", ["client", "tunnel", "both"])
async def test_openshell_release_closes_both_resources_on_error(failure_stage: str) -> None:
control = _ControlPlane()
lease = _source_lease(_RecordingCommands())
client = lease.data_plane.client
tunnel = lease.tunnel
assert isinstance(client, _FakeShellctlClient)
assert isinstance(tunnel, _FakeTunnel)
transport = httpx.AsyncClient(transport=httpx.MockTransport(_healthy_handler))
lease.data_plane.owned_transport = transport
if failure_stage in {"client", "both"}:
client.close_error = RuntimeError("client close failed")
if failure_stage in {"tunnel", "both"}:
tunnel.close_error = RuntimeError("tunnel close failed")
expected_error = client.close_error or tunnel.close_error
with pytest.raises(BindingAcquireError, match=str(expected_error)) as exc_info:
await _backend(control).release(lease)
assert exc_info.value.__cause__ is expected_error
assert client.closed == 1
assert tunnel.closed == 1
assert transport.is_closed
assert control.stopped == []
@pytest.mark.anyio
@pytest.mark.parametrize("failure_stage", ["client", "tunnel"])
async def test_openshell_release_preserves_cancellation_and_closes_resources(failure_stage: str) -> None:
control = _ControlPlane()
lease = _source_lease(_RecordingCommands())
client = lease.data_plane.client
tunnel = lease.tunnel
assert isinstance(client, _FakeShellctlClient)
assert isinstance(tunnel, _FakeTunnel)
transport = httpx.AsyncClient(transport=httpx.MockTransport(_healthy_handler))
lease.data_plane.owned_transport = transport
cancellation = asyncio.CancelledError()
if failure_stage == "client":
client.close_error = cancellation
tunnel.close_error = RuntimeError("tunnel close failed")
else:
tunnel.close_error = cancellation
with pytest.raises(asyncio.CancelledError) as exc_info:
await _backend(control).release(lease)
assert exc_info.value is cancellation
assert client.closed == 1
assert tunnel.closed == 1
assert transport.is_closed
assert control.stopped == []
@pytest.mark.anyio
async def test_openshell_release_rejects_foreign_lease() -> None:
control = _ControlPlane()
with pytest.raises(TypeError):
await _backend(control).release(
_source_lease(_RecordingCommands()).data_plane,
)
@pytest.mark.anyio
async def test_openshell_destroy_requires_workspace_destruction_and_is_idempotent() -> None:
control = _ControlPlane()
backend = _backend(control)
_ = await backend.create_binding(_create_spec())
with pytest.raises(WorkspacePreservationUnsupportedError):
await backend.destroy_binding(
ExecutionBindingDestroySpec(
binding_ref=_BINDING_NAME,
destroy_workspace=False,
)
)
with pytest.raises(BindingDestroyError):
await backend.destroy_binding(
ExecutionBindingDestroySpec(
binding_ref=_BINDING_NAME,
destroy_workspace=True,
workspace_ref="dify-other",
)
)
await backend.destroy_binding(
ExecutionBindingDestroySpec(
binding_ref=_BINDING_NAME,
destroy_workspace=True,
workspace_ref=_BINDING_NAME,
)
)
# A second destroy observes NOT_FOUND and returns without raising.
await backend.destroy_binding(
ExecutionBindingDestroySpec(
binding_ref=_BINDING_NAME,
destroy_workspace=True,
workspace_ref=_BINDING_NAME,
)
)
assert control.deleted == [_BINDING_NAME]
@pytest.mark.anyio
async def test_openshell_snapshot_copies_home_to_shared_volume() -> None:
commands = _RecordingCommands()
snapshots = OpenShellHomeSnapshotBackend(control_plane=_ControlPlane())
snapshot_ref = await snapshots.create_from_runtime(
spec=HomeSnapshotCreateSpec(tenant_id="tenant-1", agent_id="agent-1", home_snapshot_id="snap-1"),
source=_source_lease(commands),
)
assert snapshot_ref == _SNAPSHOT_REF
(script,) = commands.scripts
assert f"cp -a /home/dify/. {_SNAPSHOT_DIR}/" in script
assert f"chmod 700 {_SNAPSHOT_DIR}" in script
@pytest.mark.anyio
async def test_openshell_snapshot_failure_removes_partial_copy() -> None:
commands = _RecordingCommands(exit_codes=[1])
snapshots = OpenShellHomeSnapshotBackend(control_plane=_ControlPlane())
with pytest.raises(HomeSnapshotCreateError):
_ = await snapshots.create_from_runtime(
spec=HomeSnapshotCreateSpec(tenant_id="tenant-1", agent_id="agent-1", home_snapshot_id="snap-1"),
source=_source_lease(commands),
)
assert len(commands.scripts) == 2
assert commands.scripts[1].startswith("rm -rf -- ")
@pytest.mark.anyio
async def test_openshell_snapshot_rejects_foreign_lease() -> None:
snapshots = OpenShellHomeSnapshotBackend(control_plane=_ControlPlane())
foreign = _source_lease(_RecordingCommands()).data_plane
with pytest.raises(HomeSnapshotCreateError):
_ = await snapshots.create_from_runtime(
spec=HomeSnapshotCreateSpec(tenant_id="tenant-1", agent_id="agent-1", home_snapshot_id="snap-1"),
source=foreign,
)
@pytest.mark.anyio
async def test_openshell_snapshot_delete_uses_short_lived_maintenance_sandbox() -> None:
control = _ControlPlane()
snapshots = OpenShellHomeSnapshotBackend(control_plane=control)
await snapshots.delete(_SNAPSHOT_REF)
((name, labels),) = control.created
assert name.startswith("gc-")
assert labels == {"dify.resource": "snapshot-gc"}
# Unlinking the snapshot dir itself needs write on its parent under
# Landlock, so the sandbox is granted the tenant's snapshot root.
assert control.extra_read_write == [(_TENANT_SNAPSHOT_ROOT,)]
((_, script),) = control.exec_calls
assert script == f"rm -rf -- {_SNAPSHOT_DIR}"
assert control.deleted == [name]
@pytest.mark.anyio
async def test_openshell_snapshot_delete_still_removes_maintenance_sandbox_on_failure() -> None:
control = _ControlPlane()
control.exec_results = [(1, "device busy")]
snapshots = OpenShellHomeSnapshotBackend(control_plane=control)
with pytest.raises(BindingDestroyError, match="device busy"):
await snapshots.delete(_SNAPSHOT_REF)
assert len(control.deleted) == 1
@pytest.mark.anyio
async def test_openshell_snapshot_refs_must_be_safe_path_segments() -> None:
snapshots = OpenShellHomeSnapshotBackend(control_plane=_ControlPlane())
with pytest.raises(BindingDestroyError, match="safe path segment"):
await snapshots.delete("../escape")
@pytest.mark.anyio
async def test_openshell_binding_create_rejects_unsafe_binding_id() -> None:
control = _ControlPlane()
with pytest.raises(BindingCreateError, match="safe path segment"):
_ = await _backend(control).create_binding(_create_spec(binding_id="../escape"))
assert control.created == []
@pytest.mark.anyio
async def test_openshell_acquire_maps_missing_layout_to_binding_lost(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_mock_http(monkeypatch, _healthy_handler)
control = _ControlPlane()
backend = _backend(control)
_ = await backend.create_binding(_create_spec())
control.exec_results = [(1, "missing home")]
with pytest.raises(BindingLostError, match="Home or Workspace"):
_ = await backend.acquire(_BINDING_NAME)
assert control.tunnels == []
assert control.stopped == [_BINDING_NAME, _BINDING_NAME]
def test_ssh_session_token_supplier_renews_ahead_of_expiry() -> None:
minted: list[str] = []
clock = {"now": 0}
def mint() -> tuple[str, int]:
token = f"token-{len(minted)}"
minted.append(token)
# every token expires 100_000 ms after it is minted
return token, clock["now"] + 100_000
supplier = _SshSessionTokenSupplier(mint=mint, clock_ms=lambda: clock["now"], renewal_margin_ms=60_000)
assert supplier() == "token-0"
clock["now"] = 30_000
assert supplier() == "token-0" # well before the renewal window
clock["now"] = 45_000
assert supplier() == "token-1" # inside expiry - margin: re-minted
clock["now"] = 50_000
assert supplier() == "token-1" # new token still fresh
assert minted == ["token-0", "token-1"]
def test_ssh_session_token_supplier_keeps_token_without_expiry() -> None:
minted: list[str] = []
def mint() -> tuple[str, int]:
minted.append("x")
return "token", 0 # gateway set no expiry
supplier = _SshSessionTokenSupplier(mint=mint, clock_ms=lambda: 10**15)
assert supplier() == "token"
assert supplier() == "token"
assert len(minted) == 1
def test_ssh_session_token_supplier_fails_closed_when_renewal_fails() -> None:
clock = {"now": 0}
fail = {"on": False}
def mint() -> tuple[str, int]:
if fail["on"]:
raise RuntimeError("gateway unavailable")
return "token-0", 100_000
supplier = _SshSessionTokenSupplier(mint=mint, clock_ms=lambda: clock["now"], renewal_margin_ms=60_000)
assert supplier() == "token-0"
fail["on"] = True
clock["now"] = 45_000
with pytest.raises(RuntimeError, match="gateway unavailable"):
_ = supplier()
@pytest.mark.anyio
async def test_openshell_sdk_exec_script_sends_script_via_stdin_not_argv() -> None:
# The fake pins the adapter's own outgoing call shape (a security
# contract: no script text in argv), not gateway behavior — the real
# gateway is exercised by the integration contract in
# tests/integration/.../test_working_environment.py.
from types import SimpleNamespace
from typing import cast
from dify_agent.runtime_backend.openshell import OpenShellSDKControlPlane
calls: dict[str, object] = {}
class _FakeSdkClient:
def exec(
self,
sandbox_id: str,
command: list[str],
*,
stdin: bytes | None = None,
timeout_seconds: int | None = None,
) -> SimpleNamespace:
calls["sandbox_id"] = sandbox_id
calls["command"] = list(command)
calls["stdin"] = stdin
calls["timeout_seconds"] = timeout_seconds
return SimpleNamespace(exit_code=3, stdout="out", stderr="err")
plane = OpenShellSDKControlPlane(endpoint="localhost:1", insecure=True)
plane._client_cache = cast( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
"openshell_module.SandboxClient", cast(object, _FakeSdkClient())
)
script = "set -eu\nexport SHELLCTL_AUTH_TOKEN=super-secret\n"
exit_code, output = await plane.exec_script("sb-1", script)
assert exit_code == 3
assert output == "outerr"
assert calls["sandbox_id"] == "sb-1"
# The gateway logs a preview of the assembled exec argv; scripts can embed
# the shellctl token, so they must travel only via stdin (never logged).
assert calls["command"] == ["/bin/sh", "-s"]
assert calls["stdin"] == script.encode()
assert calls["timeout_seconds"] == plane.exec_timeout_seconds
@pytest.mark.anyio
async def test_openshell_sdk_create_sends_enforced_egress_allowlist() -> None:
# The fake pins the adapter's own outgoing create-spec shape: opt-in
# egress_allow becomes one enforced allowlist rule per endpoint, and an
# empty egress_allow sends no network policy at all so sandbox egress
# stays on the gateway/driver default. Real gateway enforcement is
# covered by the opt-in integration contract.
from types import SimpleNamespace
from typing import cast
from dify_agent.runtime_backend.openshell import OpenShellSDKControlPlane
specs: list[object] = []
class _FakeSdkClient:
def create(
self,
*,
workspace: str,
spec: object,
name: str,
labels: dict[str, str],
) -> SimpleNamespace:
specs.append(spec)
return SimpleNamespace(id="sb-1")
def plane_with(egress_allow: tuple[tuple[str, int], ...]) -> OpenShellSDKControlPlane:
plane = OpenShellSDKControlPlane(endpoint="localhost:1", insecure=True, egress_allow=egress_allow)
plane._client_cache = cast( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
"openshell_module.SandboxClient", cast(object, _FakeSdkClient())
)
return plane
allowing = plane_with((("agent.example.com", 5050), ("dify.example.com", 443)))
await allowing.create_sandbox(name="dify-a", labels={})
policies = specs[0].policy.network_policies # pyright: ignore[reportAttributeAccessIssue]
assert len(policies) == 2
by_endpoint = {(rule.endpoints[0].host, rule.endpoints[0].port): (key, rule) for key, rule in policies.items()}
assert set(by_endpoint) == {("agent.example.com", 5050), ("dify.example.com", 443)}
for key, rule in by_endpoint.values():
assert rule.name == key
endpoint = rule.endpoints[0]
assert endpoint.protocol == "rest"
assert endpoint.enforcement == "enforce"
assert endpoint.rules[0].allow.method == "*"
assert endpoint.rules[0].allow.path == "/**"
# Endpoints are the allowlist; any in-sandbox binary may connect.
assert [binary.path for binary in rule.binaries] == ["/**"]
default = plane_with(())
await default.create_sandbox(name="dify-b", labels={})
assert not specs[1].policy.network_policies # pyright: ignore[reportAttributeAccessIssue]
@@ -0,0 +1,210 @@
from __future__ import annotations
import socket
import threading
from collections.abc import Iterator
from dataclasses import dataclass, field
import pytest
from dify_agent.runtime_backend.openshell_tunnel import ForwardTcpTunnel
@dataclass(slots=True)
class _Frame:
data: bytes = b""
init: object | None = None
@dataclass(slots=True)
class _Codec:
init_frames: list[_Frame] = field(default_factory=list)
def init_frame(self) -> _Frame:
frame = _Frame(init=object())
self.init_frames.append(frame)
return frame
def data_frame(self, data: bytes) -> _Frame:
return _Frame(data=data)
@dataclass(slots=True)
class _EchoCall:
"""Fake ForwardTcp stream: echoes each data frame back uppercased."""
request_iterator: Iterator[object]
cancelled: int = 0
saw_init_first: bool = False
def __iter__(self) -> Iterator[_Frame]:
for index, frame in enumerate(self.request_iterator):
assert isinstance(frame, _Frame)
if index == 0:
self.saw_init_first = frame.init is not None
continue
yield _Frame(data=frame.data.upper())
def cancel(self) -> bool:
self.cancelled += 1
return True
@dataclass(slots=True)
class _StreamFactory:
calls: list[_EchoCall] = field(default_factory=list)
def __call__(self, request_iterator: Iterator[object]) -> _EchoCall:
call = _EchoCall(request_iterator=request_iterator)
self.calls.append(call)
return call
def _roundtrip(base_url: str, payload: bytes) -> bytes:
host, port = base_url.removeprefix("http://").split(":")
with socket.create_connection((host, int(port)), timeout=5) as connection:
connection.sendall(payload)
connection.shutdown(socket.SHUT_WR)
received = b""
while True:
chunk = connection.recv(65536)
if not chunk:
return received
received += chunk
def test_tunnel_opens_one_stream_per_connection_and_pipes_bytes() -> None:
factory = _StreamFactory()
codec = _Codec()
tunnel = ForwardTcpTunnel(stream_factory=factory, frame_codec=codec)
try:
base_url = tunnel.open()
assert base_url.startswith("http://127.0.0.1:")
assert _roundtrip(base_url, b"hello") == b"HELLO"
assert _roundtrip(base_url, b"again") == b"AGAIN"
assert len(factory.calls) == 2
assert all(call.saw_init_first for call in factory.calls)
assert len(codec.init_frames) == 2
finally:
tunnel.close()
def test_tunnel_handles_concurrent_connections() -> None:
factory = _StreamFactory()
tunnel = ForwardTcpTunnel(stream_factory=factory, frame_codec=_Codec())
try:
base_url = tunnel.open()
results: list[bytes] = [b""] * 4
def worker(index: int) -> None:
results[index] = _roundtrip(base_url, f"msg-{index}".encode())
threads = [threading.Thread(target=worker, args=(index,)) for index in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
assert results == [b"MSG-0", b"MSG-1", b"MSG-2", b"MSG-3"]
assert len(factory.calls) == 4
finally:
tunnel.close()
def test_tunnel_close_is_idempotent_and_rejects_new_connections() -> None:
tunnel = ForwardTcpTunnel(stream_factory=_StreamFactory(), frame_codec=_Codec())
base_url = tunnel.open()
tunnel.close()
tunnel.close()
host, port = base_url.removeprefix("http://").split(":")
with pytest.raises(OSError):
connection = socket.create_connection((host, int(port)), timeout=1)
# macOS may accept into the closed listener's backlog; a read still
# observes the shutdown.
connection.settimeout(1)
try:
if connection.recv(1) != b"":
raise AssertionError("expected EOF from closed tunnel")
raise ConnectionResetError("closed")
finally:
connection.close()
def test_tunnel_half_close_still_delivers_remaining_bytes() -> None:
"""Client write-half close must not drop bytes already queued from the stream."""
@dataclass(slots=True)
class _HalfCloseCall:
request_iterator: Iterator[object]
cancelled: int = 0
def __iter__(self) -> Iterator[_Frame]:
for frame in self.request_iterator:
assert isinstance(frame, _Frame)
if frame.init is not None:
continue
yield _Frame(data=frame.data.upper())
yield _Frame(data=b"-TAIL")
def cancel(self) -> bool:
self.cancelled += 1
return True
def factory(request_iterator: Iterator[object]) -> _HalfCloseCall:
return _HalfCloseCall(request_iterator)
tunnel = ForwardTcpTunnel(stream_factory=factory, frame_codec=_Codec())
try:
base_url = tunnel.open()
host, port = base_url.removeprefix("http://").split(":")
with socket.create_connection((host, int(port)), timeout=5) as connection:
connection.sendall(b"hi")
connection.shutdown(socket.SHUT_WR)
received = b""
while True:
chunk = connection.recv(65536)
if not chunk:
break
received += chunk
assert received == b"HI-TAIL"
finally:
tunnel.close()
def test_tunnel_stream_error_closes_the_client_connection() -> None:
@dataclass(slots=True)
class _ErrorCall:
request_iterator: Iterator[object]
def __iter__(self) -> Iterator[_Frame]:
_ = next(self.request_iterator)
raise RuntimeError("stream reset")
def cancel(self) -> bool:
return True
def factory(request_iterator: Iterator[object]) -> _ErrorCall:
return _ErrorCall(request_iterator)
tunnel = ForwardTcpTunnel(stream_factory=factory, frame_codec=_Codec())
try:
base_url = tunnel.open()
host, port = base_url.removeprefix("http://").split(":")
with socket.create_connection((host, int(port)), timeout=5) as connection:
connection.sendall(b"ping")
connection.settimeout(2)
assert connection.recv(1) == b""
finally:
tunnel.close()
def test_tunnel_open_twice_is_an_error() -> None:
tunnel = ForwardTcpTunnel(stream_factory=_StreamFactory(), frame_codec=_Codec())
try:
_ = tunnel.open()
with pytest.raises(RuntimeError, match="already open"):
_ = tunnel.open()
finally:
tunnel.close()
@@ -1,10 +1,17 @@
from __future__ import annotations
import os
import pytest
from pydantic import ValidationError
from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS
from dify_agent.runtime_backend.local import LocalExecutionBindingBackend, LocalHomeSnapshotBackend
from dify_agent.runtime_backend.openshell import (
OpenShellExecutionBindingBackend,
OpenShellHomeSnapshotBackend,
OpenShellSDKControlPlane,
)
from dify_agent.runtime_backend.profile import (
DEFAULT_E2B_TEMPLATE,
RuntimeBackendSettings,
@@ -12,6 +19,15 @@ from dify_agent.runtime_backend.profile import (
)
@pytest.fixture(autouse=True)
def _isolated_backend_env(monkeypatch: pytest.MonkeyPatch) -> None:
# RuntimeBackendSettings reads the process environment, so a developer's
# exported DIFY_AGENT_* / E2B_* values must not leak into these tests.
for name in list(os.environ):
if name.startswith("DIFY_AGENT_") or name in ("E2B_API_KEY", "E2B_API_TOKEN"):
monkeypatch.delenv(name)
def test_e2b_backend_uses_prepared_dify_template_and_one_hour_lease_by_default() -> None:
settings = RuntimeBackendSettings(runtime_backend="e2b", e2b_api_key="secret")
@@ -79,3 +95,154 @@ def test_local_backend_rejects_relative_roots() -> None:
local_sandbox_endpoint="http://shellctl.example",
local_sandbox_workspace_root="relative/workspaces",
)
_OPENSHELL_DRIVER_CONFIG = (
'{"docker": {"mounts": [{"type": "volume", "source": "dify-agent-shared", "target": "/mnt/dify-agent-shared"}]}}'
)
def test_openshell_backend_requires_gateway_endpoint_and_driver_config() -> None:
with pytest.raises(ValidationError, match="openshell_gateway_endpoint"):
_ = RuntimeBackendSettings(runtime_backend="openshell")
with pytest.raises(ValidationError, match="openshell_driver_config"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
)
def test_openshell_backend_rejects_invalid_driver_config_json() -> None:
with pytest.raises(ValidationError, match="valid JSON"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config="not-json",
)
with pytest.raises(ValidationError, match="JSON object"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config='["a-list"]',
)
def test_openshell_backend_rejects_empty_driver_config_object() -> None:
with pytest.raises(ValidationError, match="must mount the shared Home Snapshot volume"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config="{}",
openshell_shellctl_auth_token="token-1",
)
def test_openshell_backend_requires_shellctl_auth_token() -> None:
with pytest.raises(ValidationError, match="openshell_shellctl_auth_token"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
)
def test_openshell_backend_requires_paired_tls_client_material() -> None:
with pytest.raises(ValidationError, match="set together"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shellctl_auth_token="token-1",
openshell_tls_client_cert_path="/etc/dify/tls.crt",
)
# Compose injects "" for unset variables; blank must count as unset so a
# half-configured pair still fails at startup validation.
with pytest.raises(ValidationError, match="set together"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shellctl_auth_token="token-1",
openshell_tls_client_cert_path="/etc/dify/tls.crt",
openshell_tls_client_key_path="",
)
def test_openshell_backend_rejects_relative_shared_mount_path() -> None:
with pytest.raises(ValidationError, match="absolute POSIX path"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shellctl_auth_token="token-1",
openshell_shared_mount_path="relative/shared",
)
def test_openshell_backend_wires_shared_deployment_config_into_both_drivers() -> None:
settings = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shared_mount_path="/mnt/shared",
openshell_shellctl_auth_token="token-1",
openshell_shellctl_port=6006,
)
profile = create_runtime_backend_profile(settings)
assert isinstance(profile.execution_bindings, OpenShellExecutionBindingBackend)
assert isinstance(profile.home_snapshots, OpenShellHomeSnapshotBackend)
control_plane = profile.execution_bindings.control_plane
assert isinstance(control_plane, OpenShellSDKControlPlane)
assert control_plane.endpoint == "gateway.example:17670"
assert control_plane.driver_config == {
"docker": {"mounts": [{"type": "volume", "source": "dify-agent-shared", "target": "/mnt/dify-agent-shared"}]}
}
assert control_plane.shared_mount_path == "/mnt/shared"
assert profile.home_snapshots.control_plane is control_plane
assert profile.home_snapshots.shared_mount_path == "/mnt/shared"
assert profile.execution_bindings.shared_mount_path == "/mnt/shared"
assert profile.execution_bindings.shellctl_auth_token == "token-1"
assert profile.execution_bindings.shellctl_port == 6006
# Egress control defaults to opt-out: no allowlist reaches the gateway.
assert control_plane.egress_allow == ()
def test_openshell_backend_parses_egress_allow_into_control_plane() -> None:
settings = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shellctl_auth_token="token-1",
openshell_egress_allow=" agent.example.com:5050, dify.example.com:443 ,",
)
profile = create_runtime_backend_profile(settings)
assert isinstance(profile.execution_bindings, OpenShellExecutionBindingBackend)
control_plane = profile.execution_bindings.control_plane
assert isinstance(control_plane, OpenShellSDKControlPlane)
assert control_plane.egress_allow == (("agent.example.com", 5050), ("dify.example.com", 443))
@pytest.mark.parametrize(
"egress_allow",
[
"agent.example.com", # no port
"http://agent.example.com:5050", # scheme
"agent.example.com:5050/agent-stub", # path
":5050", # no host
"agent.example.com:0", # port out of range
"agent.example.com:notaport",
],
)
def test_openshell_backend_rejects_malformed_egress_allow(egress_allow: str) -> None:
with pytest.raises(ValidationError, match="host:port"):
_ = RuntimeBackendSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shellctl_auth_token="token-1",
openshell_egress_allow=egress_allow,
)
@@ -17,6 +17,11 @@ from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS
from dify_agent.runtime_backend.e2b import E2B_MAX_ACTIVE_TIMEOUT_SECONDS, E2BExecutionBindingBackend
from dify_agent.runtime_backend.enterprise import EnterpriseExecutionBindingBackend, EnterpriseHomeSnapshotBackend
from dify_agent.runtime_backend.local import LocalExecutionBindingBackend, LocalHomeSnapshotBackend
from dify_agent.runtime_backend.openshell import (
OpenShellExecutionBindingBackend,
OpenShellHomeSnapshotBackend,
OpenShellSDKControlPlane,
)
def _base64url_secret(value: bytes) -> str:
@@ -426,6 +431,48 @@ def test_build_runtime_backend_profile_passes_e2b_active_timeout() -> None:
assert profile.execution_bindings.template == "difys-default-team/dify-agent-local-sandbox"
_OPENSHELL_DRIVER_CONFIG = (
'{"docker": {"mounts": [{"type": "volume", "source": "dify-agent-shared", "target": "/mnt/dify-agent-shared"}]}}'
)
def test_build_runtime_backend_profile_returns_openshell_drivers_when_selected() -> None:
settings = ServerSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config=_OPENSHELL_DRIVER_CONFIG,
openshell_shared_mount_path="/mnt/shared",
openshell_shellctl_auth_token="token-1",
openshell_shellctl_port=6006,
openshell_exec_timeout_seconds=90,
openshell_egress_allow="agent.example.com:5050",
)
profile = settings.build_runtime_backend_profile()
assert profile is not None
assert isinstance(profile.execution_bindings, OpenShellExecutionBindingBackend)
assert isinstance(profile.home_snapshots, OpenShellHomeSnapshotBackend)
control_plane = profile.execution_bindings.control_plane
assert isinstance(control_plane, OpenShellSDKControlPlane)
assert control_plane.endpoint == "gateway.example:17670"
assert control_plane.shared_mount_path == "/mnt/shared"
assert control_plane.exec_timeout_seconds == 90
assert control_plane.egress_allow == (("agent.example.com", 5050),)
assert profile.execution_bindings.shellctl_auth_token == "token-1"
assert profile.execution_bindings.shellctl_port == 6006
def test_build_runtime_backend_profile_rejects_empty_openshell_driver_config() -> None:
with pytest.raises(ValidationError, match="must mount the shared Home Snapshot volume"):
_ = ServerSettings(
runtime_backend="openshell",
openshell_gateway_endpoint="gateway.example:17670",
openshell_driver_config="{}",
openshell_shellctl_auth_token="token-1",
).build_runtime_backend_profile()
def test_build_runtime_backend_profile_rejects_missing_enterprise_endpoint(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+2
View File
@@ -19,9 +19,11 @@ SERVER_RUNTIME_DEPENDENCIES = {
"e2b>=2.38.0,<3.0.0",
"fastapi==0.136.0",
"graphon==0.5.2",
"grpcio>=1.60.0,<2.0.0",
"jsonschema>=4.23.0,<5.0.0",
"jwcrypto>=1.5.6,<2",
"logfire[fastapi,httpx,redis]>=4.37.0,<5.0.0",
"openshell>=0.0.106,<0.1.0",
"pydantic-ai-slim[anthropic,google,openai]>=2.30.0,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.4.0,<8.0.0",
+70
View File
@@ -364,6 +364,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8a/c4bb04426d608be4a3171efa2e233d2c59a5c8937850c10d098e126df18e/cloudpathlib-0.23.0-py3-none-any.whl", hash = "sha256:8520b3b01468fee77de37ab5d50b1b524ea6b4a8731c35d1b7407ac0cd716002", size = 62755, upload-time = "2025-10-07T22:47:54.905Z" },
]
[[package]]
name = "cloudpickle"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -604,9 +613,11 @@ server = [
{ name = "e2b" },
{ name = "fastapi" },
{ name = "graphon" },
{ name = "grpcio" },
{ name = "jsonschema" },
{ name = "jwcrypto" },
{ name = "logfire", extra = ["fastapi", "httpx", "redis"] },
{ name = "openshell" },
{ name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] },
{ name = "pydantic-settings" },
{ name = "redis" },
@@ -634,11 +645,13 @@ requires-dist = [
{ name = "e2b", marker = "extra == 'server'", specifier = ">=2.38.0,<3.0.0" },
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" },
{ name = "grpcio", marker = "extra == 'server'", specifier = ">=1.60.0,<2.0.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "httpx2", specifier = ">=2.5.0,<3.0.0" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
{ name = "openshell", marker = "extra == 'server'", specifier = ">=0.0.106,<0.1.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-harness", specifier = ">=0.20.0,<0.21.0" },
{ name = "pydantic-ai-slim", specifier = ">=2.30.0,<3.0.0" },
@@ -899,6 +912,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
]
[[package]]
name = "grpcio"
version = "1.83.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" },
{ url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" },
{ url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" },
{ url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" },
{ url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" },
{ url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" },
{ url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" },
{ url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" },
{ url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" },
{ url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" },
{ url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" },
{ url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" },
{ url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" },
{ url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" },
{ url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" },
{ url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" },
{ url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" },
{ url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" },
{ url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" },
{ url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" },
{ url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -1834,6 +1888,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "openshell"
version = "0.0.110"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cloudpickle" },
{ name = "grpcio" },
{ name = "httpx" },
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/bd/ca4ea5311e5f7537a9cce09ef6cd759409a1f3b427e3ed14f8a24c733ad6/openshell-0.0.110-py3-none-macosx_13_0_arm64.whl", hash = "sha256:6c0133bb5a96cb11522700c07c8147948ee8f2a6ff99648b1a43728d953c4105", size = 8577190, upload-time = "2026-08-20T19:06:27.076Z" },
{ url = "https://files.pythonhosted.org/packages/23/11/b2201f5f7b54a35ef838232b422b28fdce37ef672f71351ccc62c8c96fb8/openshell-0.0.110-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:eedea0fe0ceba6f57fc63d62d5238ce6af1795edf2aa479182d50240728a5dbd", size = 8649486, upload-time = "2026-08-20T19:06:46.764Z" },
{ url = "https://files.pythonhosted.org/packages/76/8f/7019dc20f01f59cbb78eb74cf8a471238badb2b602d14d41050404c11afd/openshell-0.0.110-py3-none-manylinux_2_39_x86_64.whl", hash = "sha256:7715f2efb77f7d0078fed1621ab8140750fae755619b7f3bd2d66f156974934c", size = 9142871, upload-time = "2026-08-20T19:07:15.89Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.42.1"
@@ -48,6 +48,41 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox
# RuntimeLease active limit; its default matches the independently configurable Agent run deadline.
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
# OpenShell backend (DIFY_AGENT_RUNTIME_BACKEND=openshell): sandboxes run on a
# self-hosted NVIDIA OpenShell gateway. Setup and validation:
# dify-agent/docs/dify-agent/guide/openshell.md
# gRPC endpoint as host:port (no scheme), e.g. localhost:17670.
DIFY_AGENT_OPENSHELL_GATEWAY_ENDPOINT=
# One workspace and one dedicated shared volume per tenant in production.
DIFY_AGENT_OPENSHELL_WORKSPACE=default
# Auth: an OIDC bearer token, or mTLS bundle paths mounted into agent_backend.
DIFY_AGENT_OPENSHELL_BEARER_TOKEN=
DIFY_AGENT_OPENSHELL_TLS_CA_PATH=
DIFY_AGENT_OPENSHELL_TLS_CLIENT_CERT_PATH=
DIFY_AGENT_OPENSHELL_TLS_CLIENT_KEY_PATH=
# Set true only for plaintext local-dev gateways.
DIFY_AGENT_OPENSHELL_INSECURE=false
# Required: build the runtime image yourself from this checkout; do not rely on a published latest tag.
# From the repository root (the directory containing dify-agent-runtime/):
# docker build -f dify-agent-runtime/docker/Dockerfile -t dify-agent-runtime:latest dify-agent-runtime
# The image must include shellctl and iproute2. This value must match the built image tag.
# Build into the gateway's Docker daemon, or push to a registry it can pull from and use that image reference.
DIFY_AGENT_OPENSHELL_SANDBOX_IMAGE=docker.io/library/dify-agent-runtime:latest
# Required JSON driver_config mounting the operator-owned shared volume;
# one-time volume initialization and driver examples live in the guide.
DIFY_AGENT_OPENSHELL_DRIVER_CONFIG=
DIFY_AGENT_OPENSHELL_SHARED_MOUNT_PATH=/mnt/dify-agent-shared
# Optional comma-separated host:port egress allowlist (no scheme or path).
# Empty sends no network policy (gateway/driver default egress); when set,
# sandbox egress is enforced to exactly these endpoints — include the Agent
# Stub and files endpoints, e.g. agent.example.com:443,dify.example.com:443.
DIFY_AGENT_OPENSHELL_EGRESS_ALLOW=
# Required. shellctl path isolation stays off (sandbox Landlock is authoritative).
DIFY_AGENT_OPENSHELL_SHELLCTL_AUTH_TOKEN=
DIFY_AGENT_OPENSHELL_SHELLCTL_PORT=5004
DIFY_AGENT_OPENSHELL_READY_TIMEOUT_SECONDS=300
DIFY_AGENT_OPENSHELL_EXEC_TIMEOUT_SECONDS=120
# Sandbox-reachable Dify API base for signed /files/* transfers.
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://api:5001
# Maximum Agent Stub upload size in MiB; forwarded to Dify API as a signed byte limit.