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