diff --git a/.gitignore b/.gitignore index 7b6a29809a6..fe70c9ac867 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/api/uv.lock b/api/uv.lock index abc155899e2..701309c91f3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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 = [ diff --git a/dify-agent-runtime/docker/Dockerfile b/dify-agent-runtime/docker/Dockerfile index a8a25659e02..96eb696a76b 100644 --- a/dify-agent-runtime/docker/Dockerfile +++ b/dify-agent-runtime/docker/Dockerfile @@ -35,6 +35,7 @@ RUN apt-get update \ curl \ file \ git \ + iproute2 \ jq \ less \ openssh-client \ diff --git a/dify-agent-runtime/packaging_test.go b/dify-agent-runtime/packaging_test.go index acea15db010..9c656eaa00c 100644 --- a/dify-agent-runtime/packaging_test.go +++ b/dify-agent-runtime/packaging_test.go @@ -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) { diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 24d3390f7ec..c54af515e45 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -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= diff --git a/dify-agent/Dockerfile b/dify-agent/Dockerfile index 0e0b47b12a5..2881e5b2f53 100644 --- a/dify-agent/Dockerfile +++ b/dify-agent/Dockerfile @@ -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 diff --git a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md index c675898bd60..8f2ae9fc2b5 100644 --- a/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md +++ b/dify-agent/docs/dify-agent/concepts/runtime-resources/index.md @@ -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//` 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. diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index bc6eacee02f..ae5613d836d 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -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. diff --git a/dify-agent/docs/dify-agent/guide/openshell.md b/dify-agent/docs/dify-agent/guide/openshell.md new file mode 100644 index 00000000000..d5829fd90d4 --- /dev/null +++ b/dify-agent/docs/dify-agent/guide/openshell.md @@ -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//`. | +| `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//`; 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 +`/home-snapshots//`; 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-","workspace_ref":"dify-"} 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":"","destroy_workspace":true,"workspace_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. diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md index b04d2f7360c..d6c5011637e 100644 --- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -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. diff --git a/dify-agent/mkdocs.yml b/dify-agent/mkdocs.yml index 34ba0e2c92d..5842702d8d3 100644 --- a/dify-agent/mkdocs.yml +++ b/dify-agent/mkdocs.yml @@ -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: diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 6a6c2651ffd..58f8812770d 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -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", diff --git a/dify-agent/src/dify_agent/runtime_backend/openshell.py b/dify-agent/src/dify_agent/runtime_backend/openshell.py new file mode 100644 index 00000000000..9f2d1ca584f --- /dev/null +++ b/dify-agent/src/dify_agent/runtime_backend/openshell.py @@ -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 ``/home-snapshots//`` 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} >/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", +] diff --git a/dify-agent/src/dify_agent/runtime_backend/openshell_tunnel.py b/dify-agent/src/dify_agent/runtime_backend/openshell_tunnel.py new file mode 100644 index 00000000000..18e2bf90a00 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime_backend/openshell_tunnel.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/runtime_backend/profile.py b/dify-agent/src/dify_agent/runtime_backend/profile.py index 411f4d3c3e5..dc006435ce2 100644 --- a/dify-agent/src/dify_agent/runtime_backend/profile.py +++ b/dify-agent/src/dify_agent/runtime_backend/profile.py @@ -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", diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 80669d6bf19..a7722a67f37 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -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, ) ) diff --git a/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py b/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py index 41deb4dfa3d..1abc609e505 100644 --- a/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py +++ b/dify-agent/tests/integration/dify_agent/runtime_backend/test_working_environment.py @@ -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] diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_openshell.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_openshell.py new file mode 100644 index 00000000000..b5bf60e7f79 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_openshell.py @@ -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] diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_openshell_tunnel.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_openshell_tunnel.py new file mode 100644 index 00000000000..235eb10b56f --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_openshell_tunnel.py @@ -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() diff --git a/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py b/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py index 1ae8b70005a..532a2a552b6 100644 --- a/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py +++ b/dify-agent/tests/local/dify_agent/runtime_backend/test_profile.py @@ -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, + ) diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index 465c6a3b304..3f975355579 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -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, diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index c2b82a21d01..ee54057fcc6 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -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", diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 54cd269fec4..fa5295a58c6 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -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" diff --git a/docker/envs/core-services/dify-agent.env.example b/docker/envs/core-services/dify-agent.env.example index c7ac2458c4e..ab26fbe46b7 100644 --- a/docker/envs/core-services/dify-agent.env.example +++ b/docker/envs/core-services/dify-agent.env.example @@ -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.