Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/on-pull-request-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: On Pull Request E2E

# Chat end to end on the stack that scripts/install.sh makes from this pull request. Only the
# model is fake: a scripted Anthropic mock in the Playwright process. A required check must
# always report, so the workflow has no path filter.
on:
pull_request:
branches: [main, "codex/**"]

concurrency:
group: on-pull-request-e2e-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read

jobs:
chat:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The merge commit and its base, to see what the pull request changes.
fetch-depth: 2
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: ghcr.io/czpython/druks:e2e
cache-from: type=gha,scope=backend
- name: Install the stack
env:
DRUKS_REPO: ${{ github.event.pull_request.head.repo.full_name }}
DRUKS_REF: ${{ github.event.pull_request.head.sha }}
DRUKS_TAG: e2e
DRUKS_SKIP_PULL: "1"
run: |
setup=()
# The docker provider pulls a sandbox image first, so the image of this pull
# request takes a tag that no registry has.
if ! git diff --quiet HEAD^ -- deploy/sandbox; then
docker build --load --target sandbox --tag druks-sandbox:e2e deploy/sandbox
setup=(--set sandbox.image=druks-sandbox:e2e)
fi
bash frontend/e2e/install-stack.sh "${setup[@]}"
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npx playwright install --with-deps chromium
working-directory: frontend
# The mock serves api.anthropic.com on port 443.
- run: sudo sysctl -w net.ipv4.ip_unprivileged_port_start=443
- run: npm run test:e2e
working-directory: frontend
- if: failure()
run: cd ~/druks && docker compose logs --no-color --timestamps > "$RUNNER_TEMP/compose.log"
- if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: e2e-chat
path: |
frontend/playwright-report/
frontend/test-results/
${{ runner.temp }}/compose.log
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ npm --prefix frontend test
npm --prefix frontend run build
```

Run the Chat end-to-end lane against the stack that
`frontend/e2e/install-stack.sh` installs. `docs/development.md` describes a
local run:

```bash
npm --prefix frontend run test:e2e
```

Run the calls server's tests from `druks-calls/`:

```bash
Expand Down
7 changes: 6 additions & 1 deletion backend/druks/harnesses/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ def get_acp_session(
"settingSources": [],
"strictMcpConfig": True,
}
env: dict[str, str | None] = {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"}
# The bridge saves the session files as soon as a turn ends. Claude Code buffers its
# transcript, so without an eager flush the files miss the turn.
env: dict[str, str | None] = {
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
"CLAUDE_CODE_EAGER_FLUSH": "1",
}
if identity and include_apps:
# Claude Code loads a person's connectors only from CLAUDE_CODE_OAUTH_TOKEN, and
# ANTHROPIC_API_KEY outranks it. The bridge fills the placeholder and removes a
Expand Down
3 changes: 2 additions & 1 deletion backend/druks/setup_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,9 @@ def _canonical_config(raw: dict[str, Any]) -> dict[str, Any]:

# Tables the runtime reads from druks.toml itself: ``[apps.<name>]`` and
# ``[services.<slug>]`` switch an app or a service off, or make a service managed.
# ``[providers.<id>]`` selects how a person signs in to a provider.
# Setup renders nothing from them and writes them back as they are.
_RUNTIME_TABLES = ("apps", "services")
_RUNTIME_TABLES = ("apps", "services", "providers")


def _provider_tables(sandbox: dict[str, Any]) -> dict[str, Any]:
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ async def follow_turn(session, conversation, message, bridge, naming):
}
}
},
"env": {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"},
"env": {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", "CLAUDE_CODE_EAGER_FLUSH": "1"},
"files": {},
"mode": "bypassPermissions",
"model": "claude-opus-4-7",
Expand Down Expand Up @@ -815,7 +815,7 @@ async def follow_turn(session, conversation, message, bridge, naming):
}
}
},
"env": {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"},
"env": {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", "CLAUDE_CODE_EAGER_FLUSH": "1"},
"files": {},
"mode": "bypassPermissions",
"model": "claude-opus-4-7",
Expand Down Expand Up @@ -955,6 +955,7 @@ def test_a_claude_subscription_moves_its_login_for_the_adapter():
)
assert session["env"] == {
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
"CLAUDE_CODE_EAGER_FLUSH": "1",
"CLAUDE_CODE_OAUTH_TOKEN": "${ANTHROPIC_AUTH_TOKEN}",
"CLAUDE_CODE_OAUTH_SCOPES": "user:inference user:mcp_servers",
"ANTHROPIC_AUTH_TOKEN": None,
Expand Down
15 changes: 11 additions & 4 deletions backend/tests/test_setup_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,9 @@ def test_nested_operator_content_is_refused(tmp_path):


def test_app_and_service_tables_pass_through_setup(tmp_path):
"""The runtime reads ``[apps.<name>]`` and ``[services.<slug>]`` from druks.toml
itself. Setup renders nothing from them and keeps them, so a platform that
writes the file can still render .env with it."""
"""The runtime reads ``[apps.<name>]``, ``[services.<slug>]``, and
``[providers.<id>]`` from druks.toml itself. Setup renders nothing from them and
keeps them, so a platform that writes the file can still render .env with it."""
env_path = tmp_path / ".env"
_run(env_path, provider="docker")
toml_path = tmp_path / "druks.toml"
Expand All @@ -486,11 +486,18 @@ def test_app_and_service_tables_pass_through_setup(tmp_path):
+ "\n[apps.software_factory]\nenabled = false\n\n[services.jira]\nenabled = false\n"
)

_run(env_path, set_values=("urls.webhook_host=hooks.example.com",))
_run(
env_path,
set_values=(
"urls.webhook_host=hooks.example.com",
"providers.anthropic.sign_in=setup_token",
),
)

config = _read_toml(toml_path)
assert config["apps"] == {"software_factory": {"enabled": False}}
assert config["services"] == {"jira": {"enabled": False}}
assert config["providers"] == {"anthropic": {"sign_in": "setup_token"}}
assert not any("JIRA" in key or "SOFTWARE_FACTORY" in key for key in read_env(env_path))


Expand Down
7 changes: 3 additions & 4 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# The Druks stack: web, Postgres, Redis, the drukbox sandbox control plane, its
# secrets exchange, and the secrets proxy. This one file applies to all install
# shapes. install.sh writes COMPOSE_PROFILES to .env. The "hosted" profile
# starts the Caddy edge and the janitor. The "proxy" profile starts the secrets
# starts the Caddy edge. The "proxy" profile starts the secrets
# proxy, on every provider but docker-sbx. The docker-sbx provider adds one
# overlay file, because its drukbox has different mounts. All services use the
# host network. Services connect to Postgres and Redis on 127.0.0.1. drukbox
Expand Down Expand Up @@ -108,11 +108,10 @@ services:

# The periodic janitor. drukbox has no scheduler, and this loop with sleep
# does the work. A real timer belongs in the drukbox service or image, not
# in compose. Only hosted installs run it. A local box reaps on the next
# request.
# in compose. Every shape runs it. Nothing else deletes an expired sandbox
# or fails a template build that a drukbox restart cut off.
drukbox-janitor:
<<: *drukbox
profiles: ["hosted"]
entrypoint:
- sh
- -c
Expand Down
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Caddy. See [Full local](full-local.md).

A **remote** installation uses each other `DRUKS_PROVIDER` value. It enables
`COMPOSE_PROFILES=hosted,proxy`. The `hosted` profile includes a remote Drukbox
control plane, the periodic janitor, and stock Caddy. Caddy supplies the
control plane and stock Caddy. Caddy supplies the
identity edge and proxy with a bind-mounted Caddyfile. The `proxy` profile
includes the secrets proxy.

Expand Down
48 changes: 48 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ Mintlify builds `docs/` directly. Its GitHub App owns deployments and
pull-request previews. The repository does not require a documentation-specific
GitHub Actions workflow.

### Chat end to end

The `On Pull Request E2E` workflow runs Chat end to end with Playwright on every
pull request. It builds the Druks image from the pull request and installs it
with `scripts/install.sh` on the docker provider. Only the model is fake: a
scripted Anthropic API in the Playwright process answers for
`api.anthropic.com`. The box, the secrets proxy, the sandbox image, and the
`claude` CLI are real.

Run the lane on Linux or on macOS with Docker Desktop. The stack uses the host
network and the ports of an installed Druks, so stop any other Druks stack
first, and the development database of `deploy/compose.dev.yaml`. The mock
listens on port 443, and the containers reach it at the Docker bridge address,
`172.17.0.1`.

1. Build the image of your checkout:

```bash
docker build -t ghcr.io/czpython/druks:e2e .
```

2. Install the stack into `~/druks`. `DRUKS_REF` names a pushed commit whose
`deploy/` files match your checkout:

```bash
DRUKS_REF="$(git rev-parse origin/main)" DRUKS_TAG=e2e DRUKS_SKIP_PULL=1 bash frontend/e2e/install-stack.sh
```

3. On Linux, let Node listen on port 443. Then run the suite:

```bash
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=443
npm --prefix frontend ci
npx --prefix frontend playwright install --with-deps chromium
npm --prefix frontend run test:e2e
```

On macOS, enable host networking as
[Docker Desktop](container-runtimes/docker-desktop.md) describes. A container
reaches the mock at the host address of Docker Desktop, not at the bridge
address. Before step 2, run `export E2E_MOCK_ADDRESS=192.168.65.254`, and keep
it set for each `docker compose` command in the install directory. Skip the
`sysctl` command.

Set `DRUKS_INSTALL_DIR` to install elsewhere, and set it for the suite too. The
suite needs no cleanup between runs. To start over, run
`docker compose down --volumes` in the install directory and remove it.

## Working with sandboxes

Backend tests mock most provider boundaries. For a real local sandbox, run
Expand Down
10 changes: 5 additions & 5 deletions docs/full-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ The local shape needs no authored values, so the first run goes all the way:
- It creates `~/.config/druks/harnesses` for optional harness configuration.
- It generates the database password and the stored-secret key.
- It pulls images and applies migrations.
- It starts Druks, Postgres, Redis, Drukbox, the secrets exchange, and the
secrets proxy. Drukbox listens on `127.0.0.1:8780`. The exchange listens on
`127.0.0.1:8781`. The proxy listens on `172.17.0.1:8880`, where sandbox
containers reach it.
- It starts Druks, Postgres, Redis, Drukbox, its janitor, the secrets exchange,
and the secrets proxy. Drukbox listens on `127.0.0.1:8780`. The exchange
listens on `127.0.0.1:8781`. The proxy listens on `172.17.0.1:8880`, where
sandbox containers reach it.
- It uses `COMPOSE_FILE=compose.yaml:compose.override.yaml` and
`COMPOSE_PROFILES=proxy`, without Caddy or the janitor.
`COMPOSE_PROFILES=proxy`, without Caddy.

Drukbox controls sandboxes through the mounted `/var/run/docker.sock`. The
installer records the group ID of the socket in `.env`. This value gives the
Expand Down
4 changes: 4 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?

# Playwright
/test-results/
/playwright-report/
Loading
Loading