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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ opencode serve --hostname 127.0.0.1 --port 4096
Then start the A2A runtime in another terminal:

```bash
A2A_BEARER_TOKEN=dev-token \
A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"dev-token","principal":"automation"}]' \
OPENCODE_BASE_URL=http://127.0.0.1:4096 \
OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
uv run opencode-a2a serve
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ Treat the deployed OpenCode user's HOME/XDG config directories as part of the ru
Then start `opencode-a2a` against that upstream:

```bash
A2A_BEARER_TOKEN=dev-token \
DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \
OPENCODE_BASE_URL=http://127.0.0.1:4096 \
A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \
A2A_HOST=127.0.0.1 \
Expand Down Expand Up @@ -120,7 +121,7 @@ opencode-a2a call http://other-agent:8000 "How are you?"
### Outbound Agent Calls (Tools)
The server can autonomously execute `a2a_call(url, message)` tool calls emitted by the OpenCode runtime. Results are fetched via A2A and returned to the model as tool results, enabling multi-agent orchestration.

When the target peer agent requires bearer auth, configure `A2A_CLIENT_BEARER_TOKEN` for server-side outbound calls. When the target peer agent requires Basic auth, use `A2A_CLIENT_BASIC_AUTH`. These outbound credentials apply to the peer specified by `opencode-a2a call` or `a2a_call(url, message)`, not to this service's inbound `A2A_BEARER_TOKEN`. The CLI intentionally reads outbound credentials from environment variables only, so secrets do not appear in shell history or process arguments.
When the target peer agent requires bearer auth, configure `A2A_CLIENT_BEARER_TOKEN` for server-side outbound calls. When the target peer agent requires Basic auth, use `A2A_CLIENT_BASIC_AUTH`. These outbound credentials apply to the peer specified by `opencode-a2a call` or `a2a_call(url, message)`, not to this service's inbound `A2A_STATIC_AUTH_CREDENTIALS`. The CLI intentionally reads outbound credentials from environment variables only, so secrets do not appear in shell history or process arguments.

Server-side outbound client settings are fully wired through runtime config: `A2A_CLIENT_TIMEOUT_SECONDS`, `A2A_CLIENT_CARD_FETCH_TIMEOUT_SECONDS`, `A2A_CLIENT_USE_CLIENT_PREFERENCE`, `A2A_CLIENT_BEARER_TOKEN`, `A2A_CLIENT_BASIC_AUTH`, and `A2A_CLIENT_SUPPORTED_TRANSPORTS`.

Expand All @@ -146,7 +147,7 @@ For client-side integration, prefer [a2a-client-hub](https://github.com/liujuanj

This repository improves the service boundary around OpenCode, but it does not turn OpenCode into a hardened multi-tenant platform.

- `A2A_BEARER_TOKEN` protects the A2A surface.
- `A2A_STATIC_AUTH_CREDENTIALS` protects the A2A surface.
- Provider auth and default model configuration remain on the OpenCode side; deployment-time precedence details and HOME/XDG state impact are documented in [docs/guide.md](docs/guide.md#troubleshooting-provider-auth-state).
- Use `A2A_CLIENT_BEARER_TOKEN` for server-side outbound peer calls initiated by `a2a_call`.
- Deployment supervision is intentionally BYO. Use `systemd`, Docker, Kubernetes, or another supervisor if you need long-running operation.
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This repository wraps OpenCode as an A2A runtime. It exposes A2A HTTP+JSON and J

## Security Boundary

- `A2A_BEARER_TOKEN` protects access to the A2A surface, but it is not a tenant-isolation boundary inside one deployed instance.
- `A2A_STATIC_AUTH_CREDENTIALS` protects access to the A2A surface, but it is not a tenant-isolation boundary inside one deployed instance.
- One `OpenCode + opencode-a2a` instance pair is treated as a single-tenant trust boundary by design.
- Tenant isolation across consumers is expected to come from parameterized self-deployment.
- For mutually untrusted tenants, use separate instance pairs with distinct Linux users, workspace roots, credentials, and runtime ports.
Expand All @@ -21,7 +21,7 @@ This project is currently best suited for trusted or internal environments. Impo

- no per-tenant workspace isolation inside one instance
- no hard guarantee that upstream provider keys are inaccessible to agent logic
- bearer-token auth only by default; stronger identity propagation is still a follow-up hardening area
- static credential auth only by default; stronger identity propagation is still a follow-up hardening area
- operators remain responsible for host hardening, secret rotation, process access controls, and reverse-proxy exposure strategy

## Reporting a Vulnerability
Expand Down
28 changes: 21 additions & 7 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This section keeps only the protocol-relevant variables. For the full runtime va

Key variables to understand protocol behavior:

- `A2A_BEARER_TOKEN`: required for all authenticated runtime requests.
- `A2A_STATIC_AUTH_CREDENTIALS`: required static credential registry in JSON array form. Supports multiple `bearer` / `basic` credentials, bearer-only required `principal`, optional `credential_id`, optional `capabilities`, and optional `enabled=false` for explicit disablement.
- `OPENCODE_BASE_URL`: upstream OpenCode HTTP endpoint. Default: `http://127.0.0.1:4096`. In two-process deployments, set it explicitly.
- `OPENCODE_WORKSPACE_ROOT`: service-level default workspace root exposed to OpenCode when clients do not request a narrower directory override.
- `A2A_ALLOW_DIRECTORY_OVERRIDE`: controls whether clients may pass `metadata.opencode.directory`.
Expand Down Expand Up @@ -54,9 +54,20 @@ Key variables to understand protocol behavior:
- `A2A_CLIENT_SUPPORTED_TRANSPORTS`: ordered outbound transport preference list.
- `A2A_TASK_STORE_BACKEND`: unified lightweight persistence backend for SDK task rows plus adapter-managed session / interrupt state. Supported values: `database`, `memory`. Default: `database`.
- `A2A_TASK_STORE_DATABASE_URL`: database URL used by the unified durable backend when `A2A_TASK_STORE_BACKEND=database`. Default: `sqlite+aiosqlite:///./opencode-a2a.db`.
- Runtime authentication is bearer-token only via `A2A_BEARER_TOKEN`.
- Runtime authentication is configured only through the static credential registry declared by `A2A_STATIC_AUTH_CREDENTIALS`.
- The runtime maps authenticated requests to stable principals rather than credential-derived identities.
- With `A2A_STATIC_AUTH_CREDENTIALS`, every bearer credential must declare an explicit `principal`; Basic credentials always derive their runtime principal from `username`.
- `credential_id`, when provided, is carried as optional runtime metadata for audit, logging, diagnostics, credential-rotation workflows, authorization-denied diagnostics, and interrupt tracking; it does not participate in principal resolution or authorization decisions.
- Individual static credentials can be disabled by removing them from the registry or setting `enabled=false`, then restarting/reloading the deployment.
- High-risk methods require explicitly granted operator-level capabilities:
- `opencode.sessions.shell`
- `opencode.workspaces.create`
- `opencode.workspaces.remove`
- `opencode.worktrees.create`
- `opencode.worktrees.remove`
- `opencode.worktrees.reset`
- Runtime authentication also applies to `/health`; the public unauthenticated discovery surface remains `/.well-known/agent-card.json` and `/.well-known/agent.json`.
- The authenticated extended card endpoint `/agent/authenticatedExtendedCard` is bearer-token protected.
- The authenticated extended card endpoint `/agent/authenticatedExtendedCard` accepts the same configured bearer/basic auth modes.
- The same outbound client flags are also honored by the server-side embedded A2A client used for peer calls and `a2a_call` tool execution:
- `A2A_CLIENT_TIMEOUT_SECONDS`
- `A2A_CLIENT_CARD_FETCH_TIMEOUT_SECONDS`
Expand Down Expand Up @@ -122,8 +133,9 @@ Do not assume startup-script env vars always erase previously persisted OpenCode
Then start `opencode-a2a` against that explicit upstream URL:

```bash
DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
OPENCODE_BASE_URL=http://127.0.0.1:4096 \
A2A_BEARER_TOKEN=dev-token \
A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \
A2A_HOST=127.0.0.1 \
A2A_PORT=8000 \
A2A_PUBLIC_URL=http://127.0.0.1:8000 \
Expand All @@ -134,8 +146,9 @@ opencode-a2a
By default, the service uses a SQLite-backed durable state store:

```bash
DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
OPENCODE_BASE_URL=http://127.0.0.1:4096 \
A2A_BEARER_TOKEN=dev-token \
A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \
A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \
opencode-a2a
```
Expand Down Expand Up @@ -214,7 +227,7 @@ If one deployment works while another fails against the same upstream provider,

## Auth, Limits, and Failure Contract

- Requests require `Authorization: Bearer <token>`; otherwise `401` is returned. Agent Card endpoints are public.
- Requests require either `Authorization: Bearer <token>` or a configured `Authorization: Basic <base64(username:password)>`; otherwise `401` is returned. Agent Card endpoints are public.
- Requests above `A2A_MAX_REQUEST_BODY_BYTES` are rejected with HTTP `413` before transport handling.
- For validation failures, missing context (`task_id` / `context_id`), or internal errors, the service attempts to return standard A2A failure events via `event_queue`.
- Failure events include concrete error details with `failed` state.
Expand Down Expand Up @@ -564,7 +577,7 @@ Minimal stream semantics summary:
This service exposes OpenCode session read, mutation, and control methods via A2A JSON-RPC extension methods (default endpoint: `POST /`). No extra custom REST endpoint is introduced.

- Trigger: call extension methods through A2A JSON-RPC
- Auth: same `Authorization: Bearer <token>`
- Auth: same runtime auth as the main endpoint (`Bearer` or configured `Basic`)
- Privacy guard: when `A2A_LOG_PAYLOADS=true`, request/response bodies are still suppressed for `method=opencode.sessions.*`
- Endpoint discovery: prefer `additional_interfaces[]` with `transport=jsonrpc` from Agent Card
- The runtime still delegates SDK-owned JSON-RPC methods such as `agent/getAuthenticatedExtendedCard` and `tasks/pushNotificationConfig/*` to the base A2A implementation; they are not OpenCode-specific extensions.
Expand Down Expand Up @@ -744,6 +757,7 @@ Validation notes:
- `subtask` parts require `prompt`, `description`, and `agent`; they may also include optional `model` and `command`.
- For `subtask` parts, `request.parts[].agent` is the upstream subagent selector. `opencode-a2a` validates and forwards the shape but does not define a separate subagent discovery or orchestration API.
- Control methods enforce session owner guard based on request identity.
- `opencode.sessions.shell` additionally requires the `session_shell` capability, which may be granted to any explicitly configured credential under `A2A_STATIC_AUTH_CREDENTIALS`.

Example (`opencode.sessions.prompt_async` with a provider-private `subtask` part):

Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke_test_built_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ PY

bearer_token="smoke-test-token"

A2A_BEARER_TOKEN="${bearer_token}" \
A2A_STATIC_AUTH_CREDENTIALS="[{\"scheme\":\"bearer\",\"token\":\"${bearer_token}\",\"principal\":\"automation\"}]" \
A2A_PORT="${port}" \
A2A_HOST="127.0.0.1" \
"${tool_bin_dir}/opencode-a2a" >"${server_log}" 2>&1 &
Expand Down
139 changes: 139 additions & 0 deletions src/opencode_a2a/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import base64
import binascii
from dataclasses import dataclass

from starlette.requests import Request

from .config import Settings

AUTOMATION_PRINCIPAL = "automation"

CAPABILITY_SESSION_SHELL = "session_shell"
CAPABILITY_WORKSPACE_MUTATION = "workspace_mutation"


@dataclass(frozen=True)
class AuthenticatedPrincipal:
identity: str
auth_scheme: str
capabilities: tuple[str, ...] = ()
credential_id: str | None = None


@dataclass(frozen=True)
class StaticAuthCredential:
auth_scheme: str
principal: str
capabilities: tuple[str, ...]
token: str | None = None
username: str | None = None
password: str | None = None
credential_id: str | None = None


def default_capabilities_for_scheme(scheme: str) -> tuple[str, ...]:
if scheme == "basic":
return (
CAPABILITY_SESSION_SHELL,
CAPABILITY_WORKSPACE_MUTATION,
)
return ()


def build_static_auth_credentials(settings: Settings) -> tuple[StaticAuthCredential, ...]:
credentials: list[StaticAuthCredential] = []
for entry in settings.a2a_static_auth_credentials:
if not entry.enabled:
continue
capabilities = entry.capabilities or default_capabilities_for_scheme(entry.scheme)
if entry.scheme == "basic":
principal = entry.username or ""
else:
principal = entry.principal or AUTOMATION_PRINCIPAL
credentials.append(
StaticAuthCredential(
auth_scheme=entry.scheme,
principal=principal,
capabilities=tuple(capabilities),
token=entry.token,
username=entry.username,
password=entry.password,
credential_id=entry.credential_id,
)
)
return tuple(credentials)


def has_configured_auth_scheme(settings: Settings, scheme: str) -> bool:
return any(
credential.auth_scheme == scheme for credential in build_static_auth_credentials(settings)
)


def authenticate_static_credential(
*,
credentials: tuple[StaticAuthCredential, ...],
auth_scheme: str,
auth_value: str,
) -> AuthenticatedPrincipal | None:
normalized_scheme = auth_scheme.lower()
if normalized_scheme == "bearer":
for credential in credentials:
if credential.auth_scheme != "bearer" or credential.token is None:
continue
if auth_value == credential.token:
return AuthenticatedPrincipal(
identity=credential.principal,
auth_scheme="bearer",
capabilities=credential.capabilities,
credential_id=credential.credential_id,
)
return None

if normalized_scheme != "basic":
return None

parsed = decode_basic_credentials(auth_value)
if parsed is None:
return None
username, password = parsed
for credential in credentials:
if credential.auth_scheme != "basic":
continue
if credential.username == username and credential.password == password:
return AuthenticatedPrincipal(
identity=credential.principal,
auth_scheme="basic",
capabilities=credential.capabilities,
credential_id=credential.credential_id,
)
return None


def decode_basic_credentials(value: str) -> tuple[str, str] | None:
try:
decoded = base64.b64decode(value, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return None
if ":" not in decoded:
return None
username, password = decoded.split(":", 1)
if not username or not password:
return None
return username, password


def get_authenticated_principal(request: Request) -> AuthenticatedPrincipal | None:
principal = getattr(request.state, "authenticated_principal", None)
if isinstance(principal, AuthenticatedPrincipal):
return principal
return None


def request_has_capability(request: Request, capability: str) -> bool:
principal = get_authenticated_principal(request)
if principal is None:
return False
return capability in principal.capabilities
Loading