Raised: $0
0% of monthly goal Help us cross the finish line!
Goal: $12,000
Raised: $0 Goal: $12,000
0% of monthly goal Help us cross the finish line!
Sponsor DDEV

If you find this add-on useful, please star it on GitHub — stars show appreciation and help maintainers know their work matters.

ddev-cli-relay

tests DDEV add-on

A DDEV add-on that starts a lightweight HTTP relay server on the host machine, allowing code running inside a DDEV container to execute local CLI tools on the host.

Usage

Once installed, the relay is available inside the container at http://host.docker.internal:8765.

Every /execute request must carry the relay’s token. The token is created on first start at .ddev/cli-relay/token (git-ignored, mode 600) and is visible inside the container at /var/www/html/.ddev/cli-relay/token.

Check the relay is running:

# From inside the container
curl http://host.docker.internal:8765/health
# {"status":"ok","port":8765,"auth":"token"}

Execute a CLI tool from inside the container:

curl -s -X POST http://host.docker.internal:8765/execute \
  -H "Authorization: Bearer $(cat /var/www/html/.ddev/cli-relay/token)" \
  -H 'Content-Type: application/json' \
  -d '{
    "binary":  "claude",
    "args":    ["--print"],
    "prompt":  "User: Hello",
    "timeout": 30
  }'
# {"stdout":"Hello! How can I help?","stderr":"","exit_code":0}

The prompt is passed to the process via stdin — never as a shell argument.

Start and stop the relay manually (without a full ddev restart):

ddev cli-relay:start
ddev cli-relay:stop

The relay starts automatically on ddev start and stops on ddev stop.

Why

CLI tools like claude, codex, and gemini-cli use browser-based OAuth authentication — they must run on the host where auth already happened. PHP running inside a DDEV container has no direct way to exec host binaries. DDEV’s .ddev/commands/host/ scripts are triggered by the developer in a terminal, not programmatically from application code. This add-on bridges the gap with a minimal HTTP server that runs on the host and proxies execution requests from the container.

Requirements

Installation

ddev add-on get Performant-Labs/ddev-cli-relay
ddev restart

Configuration

Environment variable Default Description
AI_RELAY_PORT 8765 Port the relay listens on
AI_RELAY_BIND macOS: 127.0.0.1; Linux: the ddev_default network gateway Address the relay listens on
AI_RELAY_ALLOWED_BINARIES (none) Extra binaries to allow, comma-separated (names or absolute paths)

Export these in your shell profile before running ddev start.

Allowed binaries. The relay only runs these CLI tools: claude, codex, gemini, gemini-cli, opencode, ollama, lms, llm. A bare name is resolved through the host’s PATH; an absolute path is accepted only if it is where an allowed name resolves, or if it is listed explicitly. To allow more, either set AI_RELAY_ALLOWED_BINARIES or list them one per line in .ddev/cli-relay/allowed-binaries.txt (comments with #), then run ddev cli-relay:stop && ddev cli-relay:start.

Rotating the token. Delete .ddev/cli-relay/token and run ddev restart.

API Reference

GET /health

Returns 200 when the relay is running. Needs no token.

{
  "status": "ok",
  "version": "1.1.0",
  "port": 8765,
  "auth": "token",
  "token_id": "3f2a9c41d0be",
  "features": ["files"],
  "binaries": ["claude", "codex", "llm"]
}

token_id is the first 12 hex characters of the token’s SHA-256, so a client can check it reached its own project’s relay without the token being revealed. features lists optional request fields the relay understands, and binaries the allowed tools it found on the host.

POST /execute

Executes an allowed binary on the host with the given arguments. The prompt value is written to the process’s stdin.

Required headers: Authorization: Bearer <token> and Content-Type: application/json. Requests carrying an Origin header (i.e. from a web browser) are refused.

Request:

{
  "binary":  "claude",
  "args":    ["--flag", "--other-flag"],
  "prompt":  "text sent to stdin",
  "timeout": 120,
  "files":   [{ "name": "photo.png", "base64": "iVBORw0KGgo..." }]
}
Field Type Required Description
binary string yes Allowed binary name, or its absolute path on the host
args array of strings no CLI flags passed to the binary
prompt string no Text written to stdin
timeout integer no Seconds before the process is killed (default 120, max 3600)
files array of objects no Attachments, each {"name": "...", "base64": "..."} (at most 20)

Attachments. The relay writes files to a new temp directory readable only by you, then replaces these placeholders in args and prompt:

Placeholder Replaced with
,, … The path of that file (numbered from 0)
`` The directory holding the files

For example, "args": ["exec", "-i", ""] passes the first file to Codex. Only each name’s base name is kept (other characters become _), and the relay prefixes it with the file’s position, so photo.png becomes 1-photo.png. The directory is deleted when the tool exits, including on a timeout. A placeholder that refers to a file that was not sent is a 400.

Errors (HTTP status, JSON body {"error": "..."}):

Status Meaning
400 Malformed request body, invalid files, or a placeholder without its file
401 Missing or wrong token
403 Browser request (has Origin), or binary not allowed
413 Body larger than 32 MiB (about 24 MiB of attachments)
415 Content-Type is not application/json

Response:

{
  "stdout":    "output from the process",
  "stderr":    "",
  "exit_code": 0
}
Exit code Meaning
0 Success
124 Process timed out
127 Binary not found
other Process returned an error — check stderr

Security

The relay runs programs on your machine as you. It is locked down against the two outside threats that matter for a development laptop:

What it does not protect against: anything that holds the token. Treat the token as equivalent to a shell on your machine. The caller chooses the arguments and the prompt, and several allowed tools have flags that run arbitrary commands. Claude Code’s --settings hooks and --mcp-config, and Codex’s --dangerously-bypass-approvals-and-sandbox, are examples. Code in the web container can read the token, and with DDEV’s file sync it can also edit .ddev/ scripts that run on the host. So the relay is no security boundary between your containers and your machine. Run only code you trust in a project that has the relay installed.

More than one project. Every project uses port 8765 unless you set AI_RELAY_PORT. If a second project’s relay finds the port taken, ddev start reports it and fails rather than letting Drupal reach the wrong project’s relay. Give each project that runs at the same time its own port.

Upgrading from an earlier release. Releases before this change listened on 0.0.0.0 with no token. Upgrade every project that has the relay installed, or stop the old relays; an old relay left running in another project stays reachable from your network. To find any still running, run lsof -nP -iTCP:8765 -sTCP:LISTEN. A *:8765 address means an old relay. Update with ddev add-on get Performant-Labs/ddev-cli-relay && ddev restart.

Why it is set up this way: ADR 0001. This add-on is intended for local development only.

Contributing

Pull requests are welcome. For significant changes please open an issue first.

Run the tests with python3 -m unittest discover -s tests -v (Python 3.12+, no dependencies).

License

MIT

If you find this add-on useful, please star it on GitHub — stars show appreciation and help maintainers know their work matters.