Skip to content
Merged
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
43 changes: 43 additions & 0 deletions .github/workflows/12-check-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- 'sdks/python/**'
- 'api/**'
- 'services/**'
- 'hosting/kubernetes/helm/**'
- '.github/workflows/12-check-unit-tests.yml'
workflow_dispatch:
inputs:
Expand Down Expand Up @@ -388,6 +389,48 @@ jobs:
check_name: Agent Runner Unit Test Results
comment_mode: off

run-helm-render-tests:
# Rendered-chart regression guard: the agent runner Deployment must render with a narrow
# environment (no platform DB/auth/crypt/license/Redis/store secrets, no unrelated provider
# keys, no static AGENTA_API_KEY). A self-hosted local harness shares the runner container, so
# a widened env is a credential-exposure regression. Renders with `helm template` and asserts.
if: |
github.event_name == 'workflow_dispatch' ||
!github.event.pull_request.draft
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6

- name: Skip when package selection excludes services
if: github.event_name == 'workflow_dispatch' && !contains(fromJSON('["all","services-only"]'), inputs.packages)
run: exit 0

- name: Set up Helm
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages)
uses: azure/setup-helm@v4
with:
version: v3.18.4

- name: Set up Python
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages)
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install uv
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages)
run: python -m pip install "uv==${UV_VERSION}"

- name: Lint the Helm chart
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages)
run: helm lint hosting/kubernetes/helm

- name: Assert the runner Deployment renders a narrow environment
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all","services-only"]'), inputs.packages)
run: uv run hosting/kubernetes/helm/tests/test_runner_secret_absence.py

run-runner-integration-tests:
if: |
github.event_name == 'workflow_dispatch' ||
Expand Down
12 changes: 7 additions & 5 deletions docs/design/agent-workflows/documentation/running-the-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,17 @@ instead of building from source.
The dev compose overrides the image CMD with a shell command (around line 600):

```sh
mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true;
node scripts/build-extension.mjs &&
exec node_modules/.bin/tsx src/server.ts
```

It does three things. It copies the read-only mounted Pi login into a writable path so OAuth
refresh stays in the container. It rebuilds the Pi extension from the mounted `src`, because
`dist/` is not bind-mounted and a restart would otherwise keep a stale bundle and silently
drop custom tools. It then starts the server with `tsx`.
It does two things. It rebuilds the Pi extension from the mounted `src`, because `dist/` is not
bind-mounted and a restart would otherwise keep a stale bundle and silently drop custom tools. It
then starts the server with `tsx`.

The Pi login itself is bind-mounted read-write at `/pi-agent` and the harness runs directly out of
it, so an OAuth token the harness refreshes mid-run persists back to the host login instead of
dying with the container.

## Ports

Expand Down
14 changes: 8 additions & 6 deletions hosting/docker-compose/ee/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,16 +377,16 @@ services:
# === IMAGE ================================================ #
image: agenta-ee-dev-runner:latest
# === EXECUTION ============================================ #
# No file watcher (the box's inotify limit is shared across stacks). Copy the
# read-only mounted Pi login into a writable path so OAuth refresh stays
# in-container. This command replaces the image CMD, so the Pi extension rebuild
# No file watcher (the box's inotify limit is shared across stacks). The Pi login is
# bind-mounted read-write at /pi-agent and the harness runs directly out of it, so a
# refreshed OAuth token persists to the host login instead of dying with the container.
# This command replaces the image CMD, so the Pi extension rebuild
# has to live here too: dist/ is not bind-mounted and src/extensions/agenta.ts is,
# so without this a restart keeps a stale bundle and custom tools silently stop
# being delivered on the runner path. Rebuild from the mounted
# src on start; fail loud if it cannot build rather than run a stale bundle.
command: >
sh -c "mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true;
node scripts/build-extension.mjs &&
sh -c "node scripts/build-extension.mjs &&
exec node_modules/.bin/tsx src/server.ts"
# === CONFIGURATION ======================================== #
# Deliberately no env_file: the harness sandbox must not inherit the stack's
Expand Down Expand Up @@ -425,7 +425,9 @@ services:
# sandbox per run (resolved from /app/skills). Bind-mounted like src so edits are
# live; the prod image bakes them with `COPY skills ./skills`.
- ../../../services/runner/skills:/app/skills
- ${HOME}/.pi/agent:/pi-agent-ro:ro
# Read-write on purpose: the harness refreshes its own OAuth token here and the new
# token must persist back to the host login (a copy would discard it).
- ${HOME}/.pi/agent:/pi-agent:rw
# === NETWORK ============================================== #
networks:
- agenta-network
Expand Down
14 changes: 14 additions & 0 deletions hosting/docker-compose/ee/docker-compose.gh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,20 @@ services:
AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-}
AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-}
AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-}
# === SUBSCRIPTION MOUNTS (opt-in) ========================= #
# Use your own harness subscription for LOCAL runs instead of a managed API
# key. Mount the login READ-WRITE: the harness runs directly out of the mount and
# refreshes its own OAuth token there, so a rotated token persists and you never
# have to log in again by hand. Single-operator convenience only — this state is
# never shipped to a Daytona sandbox, and concurrent local subscription runs share
# the dir (same as two local harness sessions). A runtime_provided local run
# without the matching mount fails with a clear error.
# volumes:
# - ~/.pi/agent:/agenta/harness/pi:rw # Pi / Codex login
# - ~/.claude:/agenta/harness/claude:rw # Claude login
# Then point the harness config var at the mount (override the defaults above):
# PI_CODING_AGENT_DIR: /agenta/harness/pi
# CLAUDE_CONFIG_DIR: /agenta/harness/claude
# === NETWORK ============================================== #
networks:
- agenta-ee-gh-network
Expand Down
10 changes: 7 additions & 3 deletions hosting/docker-compose/oss/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,11 @@ services:
# === IMAGE ================================================ #
image: agenta-oss-dev-runner:latest
# === EXECUTION ============================================ #
# The Pi login is bind-mounted read-write at /pi-agent and the harness runs directly out
# of it, so a refreshed OAuth token persists to the host login instead of dying with the
# container.
command: >
sh -c "mkdir -p /pi-agent && cp -a /pi-agent-ro/. /pi-agent/ 2>/dev/null || true;
node scripts/build-extension.mjs &&
sh -c "node scripts/build-extension.mjs &&
exec node_modules/.bin/tsx watch src/server.ts"
# === CONFIGURATION ======================================== #
# Deliberately no env_file: harness processes must not inherit the full stack
Expand Down Expand Up @@ -406,7 +408,9 @@ services:
volumes:
- ../../../services/runner/src:/app/src
- ../../../services/runner/skills:/app/skills
- ${HOME}/.pi/agent:/pi-agent-ro:ro
# Read-write on purpose: the harness refreshes its own OAuth token here and the new
# token must persist back to the host login (a copy would discard it).
- ${HOME}/.pi/agent:/pi-agent:rw
# === NETWORK ============================================== #
networks:
- agenta-network
Expand Down
14 changes: 14 additions & 0 deletions hosting/docker-compose/oss/docker-compose.gh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,20 @@ services:
AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-}
AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-}
AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-}
# === SUBSCRIPTION MOUNTS (opt-in) ========================= #
# Use your own harness subscription for LOCAL runs instead of a managed API
# key. Mount the login READ-WRITE: the harness runs directly out of the mount and
# refreshes its own OAuth token there, so a rotated token persists and you never
# have to log in again by hand. Single-operator convenience only — this state is
# never shipped to a Daytona sandbox, and concurrent local subscription runs share
# the dir (same as two local harness sessions). A runtime_provided local run
# without the matching mount fails with a clear error.
# volumes:
# - ~/.pi/agent:/agenta/harness/pi:rw # Pi / Codex login
# - ~/.claude:/agenta/harness/claude:rw # Claude login
# Then point the harness config var at the mount (override the defaults above):
# PI_CODING_AGENT_DIR: /agenta/harness/pi
# CLAUDE_CONFIG_DIR: /agenta/harness/claude
Comment on lines +303 to +316

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add CLAUDE_CONFIG_DIR to the environment block.

The comment instructs users to "override the defaults above" for both PI_CODING_AGENT_DIR and CLAUDE_CONFIG_DIR. However, unlike PI_CODING_AGENT_DIR, CLAUDE_CONFIG_DIR is missing from the environment section above. Since the runner service lacks an env_file directive, Docker Compose will drop this variable if set on the host environment or a global .env file, breaking the documented feature.

💻 Proposed fix

Declare CLAUDE_CONFIG_DIR in the environment section of the runner service (around line 293):

             AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local}
             PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent}
+            CLAUDE_CONFIG_DIR: ${CLAUDE_CONFIG_DIR:-}
             AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-}

# === NETWORK ============================================== #
networks:
- agenta-oss-gh-network
Expand Down
10 changes: 10 additions & 0 deletions hosting/kubernetes/helm/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,21 @@ http://{{ include "agenta.agentRunner.serviceName" . }}:{{ include "agenta.agent

{{- define "agenta.agentRunner.servicesEnv" -}}
{{- $runner := default dict .Values.agentRunner -}}
{{- $auth := default dict $runner.auth -}}
{{- $url := include "agenta.agentRunner.url" . -}}
{{- if $url }}
- name: AGENTA_RUNNER_INTERNAL_URL
value: {{ $url | quote }}
{{- end }}
{{- /* Services sends the shared runner protocol credential the runner verifies (interface.md
section 2). Same secret ref as runner-deployment.yaml, from the caller side. */ -}}
{{- if $auth.tokenSecretRef }}
- name: AGENTA_RUNNER_TOKEN
valueFrom:
secretKeyRef:
name: {{ $auth.tokenSecretRef.name | quote }}
key: {{ $auth.tokenSecretRef.key | quote }}
{{- end }}
{{- end }}

{{/* ================================================================
Expand Down
13 changes: 13 additions & 0 deletions hosting/kubernetes/helm/templates/runner-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{{- $providers := default dict $runner.providers -}}
{{- $enabledProviders := default (list "local") $providers.enabled -}}
{{- $daytona := default dict $providers.daytona -}}
{{- $auth := default dict $runner.auth -}}
{{- if eq (include "agenta.agentRunner.enabled" .) "true" }}
apiVersion: apps/v1
kind: Deployment
Expand Down Expand Up @@ -102,6 +103,18 @@ spec:
key: {{ $daytona.apiKeySecretRef.key | quote }}
optional: true
{{- end }}
{{- /* Shared runner protocol credential (interface.md section 2): the runner verifies
it and Services sends the same value (see agentRunner.servicesEnv). Rendered only
when a secret ref is set, so an unauthenticated single-operator deployment stays
valid. */ -}}
{{- if $auth.tokenSecretRef }}
- name: AGENTA_RUNNER_TOKEN
valueFrom:
secretKeyRef:
name: {{ $auth.tokenSecretRef.name | quote }}
key: {{ $auth.tokenSecretRef.key | quote }}
optional: true
{{- end }}
{{- range $key, $val := $runner.env }}
- name: {{ $key }}
value: {{ $val | quote }}
Expand Down
161 changes: 161 additions & 0 deletions hosting/kubernetes/helm/tests/test_runner_secret_absence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["PyYAML>=6"]
# ///
"""Rendered-chart regression guard for the runner's narrow environment.

The agent runner must run with a deliberately narrow environment (interface.md sections 2, 9,
and the runner-selfhosting-cleanup design): a local harness process shares the runner container,
so anything on the runner's process environment is readable from /proc by user code. This test
renders the Helm chart and asserts the runner Deployment's container env contains ONLY runner and
provider-registry variables — never the platform's database, auth, crypt, license, Redis, object
store, or unrelated provider secrets, and never a static AGENTA_API_KEY. It is the guard that keeps
a future `agenta.commonEnv` include (or any broad env block) from silently re-widening the runner.

Run: uv run hosting/kubernetes/helm/tests/test_runner_secret_absence.py
Requires the `helm` binary on PATH.
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import yaml

CHART_DIR = Path(__file__).resolve().parents[1]

# Minimum values needed for the chart to render (URLs are required by the chart's own guard).
BASE_ARGS = [
"--set",
"agenta.webUrl=https://agenta.example.com",
"--set",
"agenta.apiUrl=https://agenta.example.com/api",
"--set",
"agenta.servicesUrl=https://agenta.example.com/services",
"--set",
"agentRunner.enabled=true",
]

TOKEN_ARGS = [
"--set",
"agentRunner.auth.tokenSecretRef.name=agenta-runner",
"--set",
"agentRunner.auth.tokenSecretRef.key=token",
]

# Exact names the runner container env must NEVER contain.
FORBIDDEN_EXACT = {
"AGENTA_AUTH_KEY",
"AGENTA_CRYPT_KEY",
"AGENTA_LICENSE",
"AGENTA_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"COHERE_API_KEY",
"MISTRAL_API_KEY",
}

# Prefixes the runner container env must NEVER contain (databases, cache, object store).
FORBIDDEN_PREFIXES = (
"POSTGRES_",
"REDIS_",
"AGENTA_REDIS_",
"AGENTA_STORE_",
)

# Names the runner container env MUST contain (its own configuration).
REQUIRED = {
"AGENTA_RUNNER_PORT",
"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS",
}


def render(extra_args: list[str]) -> list[dict]:
result = subprocess.run(
[
"helm",
"template",
"runner-env-test",
str(CHART_DIR),
*BASE_ARGS,
*extra_args,
],
capture_output=True,
text=True,
check=True,
)
return [doc for doc in yaml.safe_load_all(result.stdout) if doc]


def runner_container_env_names(docs: list[dict]) -> list[str]:
"""The env var NAMES on the runner Deployment's `runner` container."""
for doc in docs:
if doc.get("kind") != "Deployment":
continue
labels = doc.get("metadata", {}).get("labels", {})
if labels.get("app.kubernetes.io/component") != "runner":
continue
containers = doc["spec"]["template"]["spec"]["containers"]
runner = next(c for c in containers if c["name"] == "runner")
return [entry["name"] for entry in runner.get("env", [])]
raise AssertionError("no runner Deployment found in the rendered chart")


def check(names: list[str], *, expect_token: bool) -> list[str]:
failures: list[str] = []
present = set(names)

for forbidden in sorted(FORBIDDEN_EXACT):
if forbidden in present:
failures.append(f"runner env must not contain {forbidden}")

for name in names:
for prefix in FORBIDDEN_PREFIXES:
if name.startswith(prefix):
failures.append(f"runner env must not contain {name} (prefix {prefix})")

for required in sorted(REQUIRED):
if required not in present:
failures.append(f"runner env must contain {required}")

if expect_token and "AGENTA_RUNNER_TOKEN" not in present:
failures.append(
"runner env must contain AGENTA_RUNNER_TOKEN when auth.tokenSecretRef is set"
)
if not expect_token and "AGENTA_RUNNER_TOKEN" in present:
failures.append(
"runner env must not contain AGENTA_RUNNER_TOKEN when auth.tokenSecretRef is unset"
)

return failures


def main() -> int:
failures: list[str] = []

# Default deployment (no token ref): the narrow env, no runner token.
names = runner_container_env_names(render([]))
failures += check(names, expect_token=False)

# Token configured: AGENTA_RUNNER_TOKEN appears, everything else stays narrow.
names_with_token = runner_container_env_names(render(TOKEN_ARGS))
failures += check(names_with_token, expect_token=True)

if failures:
print("FAIL: runner environment is not narrow:", file=sys.stderr)
for line in failures:
print(f" - {line}", file=sys.stderr)
return 1

print(
"OK: runner Deployment env is narrow (no platform secrets, provider keys, or API key)."
)
print(f" default env: {sorted(names)}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading