diff --git a/README.md b/README.md index 7504616..6bbab41 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,88 @@ # opencode-a2a-serve -> Turn OpenCode into a stateful A2A service with a clear security boundary and production-friendly deployment workflow. +> Turn OpenCode into a stateful A2A service with a clear runtime boundary and production-friendly deployment workflow. + +`opencode-a2a-serve` exposes OpenCode through standard A2A interfaces and adds +the operational pieces that raw agent runtimes usually do not provide by +default: authentication, session continuity, streaming contracts, interrupt +handling, deployment tooling, and explicit security guidance. + +## Why This Project Exists + +OpenCode is useful as an interactive runtime, but applications and gateways +need a stable service layer around it. This repository provides that layer by: + +- bridging A2A transport contracts to OpenCode session/message/event APIs +- making session and interrupt behavior explicit and auditable +- packaging deployment scripts and operational guidance for long-running use + +## What It Already Provides + +- A2A HTTP+JSON endpoints (`/v1/message:send`, `/v1/message:stream`, + `GET /v1/tasks/{task_id}:subscribe`) +- A2A JSON-RPC endpoint (`POST /`) for standard methods and OpenCode-oriented + extensions +- SSE streaming with normalized `text`, `reasoning`, and `tool_call` blocks +- session continuation via `metadata.shared.session.id` +- request-scoped model selection via `metadata.shared.model` +- OpenCode session query/control extensions and provider/model discovery +- systemd multi-instance deployment and lightweight current-user deployment + +## Design Principle + +One `OpenCode + opencode-a2a-serve` instance pair is treated as a +single-tenant trust boundary. + +- OpenCode may manage multiple projects/directories, but one deployed instance + is not a secure multi-tenant runtime. +- Shared-instance identity/session checks are best-effort coordination, not + hard tenant isolation. +- For mutually untrusted tenants, deploy separate instance pairs with isolated + Linux users or containers, isolated workspace roots, and isolated + credentials. + +## Logical Components + +```mermaid +flowchart TD + Hub["A2A client / a2a-client-hub / app"] --> Api["opencode-a2a-serve transport"] + Api --> Mapping["Task / session / interrupt mapping"] + Mapping --> Runtime["OpenCode HTTP runtime"] + + Api --> Auth["Bearer auth + request logging controls"] + Api --> Deploy["systemd and lightweight deployment scripts"] + Runtime --> Workspace["Shared workspace / environment boundary"] +``` + +This repository wraps OpenCode in a service layer. It does not change OpenCode +into a hard multi-tenant isolation platform. + +## Recommended Client Side -## Vision +If you need a client-side integration layer to consume this service, prefer +[a2a-client-hub](https://github.com/liujuanjuan1984/a2a-client-hub). -Provide a practical adapter layer that lets individuals and small teams expose OpenCode through standard A2A interfaces (REST + JSON-RPC) while keeping operations, auth, and session behavior explicit and auditable. +It is a better place for client concerns such as A2A consumption, upstream +adapter normalization, and application-facing integration, while +`opencode-a2a-serve` stays focused on the server/runtime boundary around +OpenCode. -## Core Value +## Security Model -- Protocol bridge: map A2A message/task semantics to OpenCode session/message/event APIs. -- Stateful interaction: support session continuation and reconnection workflows. -- Operational readiness: include systemd multi-instance deployment scripts and guardrails. -- Security baseline: enforce bearer-token auth and document key risk boundaries. +This project improves the service boundary around OpenCode, but it is not a +hard multi-tenant isolation layer. -## Core Capabilities +- `A2A_BEARER_TOKEN` protects the A2A surface, but it is not a tenant + isolation boundary inside one deployed instance. +- LLM provider keys are consumed by the OpenCode process. Prompt injection or + indirect exfiltration attempts may still expose sensitive values. +- systemd deploy defaults use operator-provisioned root-only secret files + unless `ENABLE_SECRET_PERSISTENCE=true` is explicitly enabled. -- A2A HTTP+JSON endpoints (`/v1/message:send`, `/v1/message:stream`, `GET /v1/tasks/{task_id}:subscribe`). -- A2A JSON-RPC endpoint (`POST /`) for standard methods and OpenCode-oriented extensions. -- Streaming with incremental task artifacts and terminal status events. -- Session continuation via `metadata.shared.session.id`. -- Request-scoped model selection via `metadata.shared.model`. -- OpenCode session query/control (`opencode.sessions.*`) and provider/model discovery (`opencode.providers.*`, `opencode.models.*`) extension methods. -- Shared interrupt callback methods. +Read before deployment: + +- [SECURITY.md](SECURITY.md) +- [scripts/deploy_readme.md](scripts/deploy_readme.md) ## Quick Start & Development @@ -45,33 +106,26 @@ A2A_BEARER_TOKEN=dev-token uv run opencode-a2a-serve Default address: `http://127.0.0.1:8000` -Development & validation baseline: +Baseline validation: ```bash uv run pre-commit run --all-files -uv run mypy src/opencode_a2a_serve uv run pytest ``` -For deployment and operations scripts, see [`scripts/README.md`](scripts/README.md). - ## Documentation Map -- Product/protocol behavior: - - [`docs/guide.md`](docs/guide.md) -- Script entry and operations: - - [`scripts/README.md`](scripts/README.md) - - [`scripts/deploy_readme.md`](scripts/deploy_readme.md) - - [`scripts/deploy_light_readme.md`](scripts/deploy_light_readme.md) - - [`scripts/init_system_readme.md`](scripts/init_system_readme.md) - - [`scripts/start_services_readme.md`](scripts/start_services_readme.md) - - [`scripts/uninstall_readme.md`](scripts/uninstall_readme.md) - -## Security Boundary - -- `A2A_BEARER_TOKEN` is required for startup. -- LLM provider keys are consumed by the OpenCode process. This model is best suited for trusted/internal environments unless stronger credential isolation is introduced. -- Within one service instance, consumers share the same underlying OpenCode workspace/environment (not tenant-isolated by default). +- [docs/guide.md](docs/guide.md) + Product behavior, API contracts, streaming/session/interrupt details. +- [scripts/README.md](scripts/README.md) + Entry points for init, deploy, lightweight deploy, local start, and + uninstall scripts. +- [scripts/deploy_readme.md](scripts/deploy_readme.md) + systemd deployment, runtime secret strategy, and operations guidance. +- [scripts/deploy_light_readme.md](scripts/deploy_light_readme.md) + current-user lightweight deployment without systemd. +- [SECURITY.md](SECURITY.md) + threat model, deployment caveats, and vulnerability disclosure guidance. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cce854c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# Security Policy + +## Scope + +This repository is an adapter layer that exposes OpenCode through A2A +HTTP+JSON and JSON-RPC interfaces. It adds authentication, task/session +contracts, streaming, interrupt handling, and deployment tooling, but it does +not fully isolate upstream model credentials from OpenCode runtime behavior. + +## Security Boundary + +- `A2A_BEARER_TOKEN` protects access to the A2A surface, but it is not a + tenant-isolation boundary inside one deployed instance. +- One `OpenCode + opencode-a2a-serve` instance pair is treated as a + single-tenant trust boundary by design. +- Within one instance, consumers share the same underlying OpenCode + workspace/environment by default. +- LLM provider keys are consumed by the `opencode` process. Prompt injection or + indirect exfiltration attempts may still expose sensitive values. +- Payload logging is opt-in. When `A2A_LOG_PAYLOADS=true`, operators should + treat logs as potentially sensitive operational data. +- In systemd deployment mode, secret persistence is opt-in. The deploy scripts + should not write `GH_TOKEN`, `A2A_BEARER_TOKEN`, or provider keys to disk + unless `ENABLE_SECRET_PERSISTENCE=true` is explicitly set. + +## Threat Model + +This project is currently best suited for trusted or internal environments. +Important limits: + +- 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 +- operators remain responsible for host hardening, secret rotation, process + access controls, and reverse-proxy exposure strategy + +## Reporting a Vulnerability + +Please avoid posting active secrets, bearer tokens, or reproduction payloads +that contain private data in public issues. + +Preferred disclosure order: + +1. Use GitHub private vulnerability reporting if it is available for this + repository. +2. If private reporting is unavailable, contact the repository maintainer + directly through GitHub before opening a public issue. +3. For low-risk hardening ideas that do not expose private data, a normal + GitHub issue is acceptable. + +## Supported Branches + +Security fixes are expected to land on the active `main` branch first. diff --git a/scripts/README.md b/scripts/README.md index 827f475..ab6bb01 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,6 +6,8 @@ Executable scripts live in this directory. This file is the entry index for scri - Product/API behavior (transport, protocol contracts, extension semantics): - [`../docs/guide.md`](../docs/guide.md) +- Security boundary and disclosure guidance: + - [`../SECURITY.md`](../SECURITY.md) - Script operational details (how to run and operate each script): - kept in this `scripts/` directory as `*_readme.md` diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cfb1e53..a7adebf 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Docs: scripts/deploy_readme.md # Deploy one isolated OpenCode + A2A systemd instance. +# Secret env vars are only required when persisting them during deploy or when +# setup actions need them. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -9,8 +11,6 @@ source "${SCRIPT_DIR}/deploy/provider_secret_env_keys.sh" PROVIDER_SECRET_ENV_LIST="$(join_provider_secret_env_keys " | ")" PROJECT_NAME="" -GH_TOKEN="${GH_TOKEN:-}" -A2A_BEARER_TOKEN="${A2A_BEARER_TOKEN:-}" A2A_PORT_INPUT="" A2A_HOST_INPUT="" A2A_PUBLIC_URL_INPUT="" @@ -32,6 +32,7 @@ OPENCODE_TIMEOUT_INPUT="" OPENCODE_TIMEOUT_STREAM_INPUT="" GIT_IDENTITY_NAME_INPUT="" GIT_IDENTITY_EMAIL_INPUT="" +ENABLE_SECRET_PERSISTENCE_INPUT="" UPDATE_A2A_INPUT="" FORCE_RESTART_INPUT="" @@ -119,6 +120,9 @@ for arg in "$@"; do git_identity_email) GIT_IDENTITY_EMAIL_INPUT="$value" ;; + enable_secret_persistence) + ENABLE_SECRET_PERSISTENCE_INPUT="$value" + ;; update_a2a) UPDATE_A2A_INPUT="$value" ;; @@ -136,17 +140,17 @@ for arg in "$@"; do esac done -if [[ -z "$PROJECT_NAME" || -z "$GH_TOKEN" || -z "$A2A_BEARER_TOKEN" ]]; then +if [[ -z "$PROJECT_NAME" ]]; then cat >&2 < A2A_BEARER_TOKEN= [=] \ + [GH_TOKEN=] [A2A_BEARER_TOKEN=] [=] \ ./scripts/deploy.sh project= [data_root=] [a2a_port=] [a2a_host=] [a2a_public_url=] \ [a2a_streaming=] [a2a_log_level=] [a2a_otel_instrumentation_enabled=] \ [a2a_log_payloads=] [a2a_log_body_limit=] [a2a_cancel_abort_timeout_seconds=] \ [a2a_enable_session_shell=] \ [opencode_provider_id=] [opencode_model_id=] [opencode_lsp=] [opencode_log_level=] \ [repo_url=] [repo_branch=] \ - [opencode_timeout=] [opencode_timeout_stream=] [git_identity_name=] \ + [opencode_timeout=] [opencode_timeout_stream=] [git_identity_name=] [enable_secret_persistence=] \ [git_identity_email=] [update_a2a=true] [force_restart=true] Provider secret env vars: @@ -185,6 +189,7 @@ export OPENCODE_BIND_HOST="${OPENCODE_BIND_HOST:-127.0.0.1}" export OPENCODE_LOG_LEVEL="${OPENCODE_LOG_LEVEL:-WARNING}" export OPENCODE_EXTRA_ARGS="${OPENCODE_EXTRA_ARGS:-}" export OPENCODE_LSP="${OPENCODE_LSP:-false}" +export ENABLE_SECRET_PERSISTENCE="${ENABLE_SECRET_PERSISTENCE:-false}" if [[ -n "$A2A_HOST_INPUT" ]]; then export A2A_HOST="$A2A_HOST_INPUT" @@ -224,6 +229,7 @@ export_if_present "A2A_LOG_PAYLOADS" "$A2A_LOG_PAYLOADS_INPUT" export_if_present "A2A_LOG_BODY_LIMIT" "$A2A_LOG_BODY_LIMIT_INPUT" export_if_present "A2A_CANCEL_ABORT_TIMEOUT_SECONDS" "$A2A_CANCEL_ABORT_TIMEOUT_SECONDS_INPUT" export_if_present "A2A_ENABLE_SESSION_SHELL" "$A2A_ENABLE_SESSION_SHELL_INPUT" +export_if_present "ENABLE_SECRET_PERSISTENCE" "$ENABLE_SECRET_PERSISTENCE_INPUT" is_truthy() { case "${1,,}" in diff --git a/scripts/deploy/install_units.sh b/scripts/deploy/install_units.sh index dd834f4..f207e40 100755 --- a/scripts/deploy/install_units.sh +++ b/scripts/deploy/install_units.sh @@ -30,6 +30,7 @@ Environment=OPENCODE_A2A_DIR=${OPENCODE_A2A_DIR} Environment=UV_PYTHON_DIR=${UV_PYTHON_DIR} Environment=PATH=${OPENCODE_CORE_DIR}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin EnvironmentFile=${DATA_ROOT}/%i/config/opencode.env +EnvironmentFile=-${DATA_ROOT}/%i/config/opencode.auth.env EnvironmentFile=-${DATA_ROOT}/%i/config/opencode.secret.env Environment=HOME=${DATA_ROOT}/%i @@ -66,6 +67,7 @@ Environment=OPENCODE_A2A_DIR=${OPENCODE_A2A_DIR} Environment=OPENCODE_CORE_DIR=${OPENCODE_CORE_DIR} Environment=UV_PYTHON_DIR=${UV_PYTHON_DIR} EnvironmentFile=${DATA_ROOT}/%i/config/a2a.env +EnvironmentFile=-${DATA_ROOT}/%i/config/a2a.secret.env Environment=HOME=${DATA_ROOT}/%i ExecStart=${OPENCODE_A2A_DIR}/scripts/deploy/run_a2a.sh diff --git a/scripts/deploy/setup_instance.sh b/scripts/deploy/setup_instance.sh index 1e5d0ab..ec2234b 100755 --- a/scripts/deploy/setup_instance.sh +++ b/scripts/deploy/setup_instance.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Create project user, directories, and env files for systemd services. -# Usage: GH_TOKEN= A2A_BEARER_TOKEN= ./setup_instance.sh +# Usage: [GH_TOKEN=] [A2A_BEARER_TOKEN=] [ENABLE_SECRET_PERSISTENCE=true] ./setup_instance.sh # Requires env: DATA_ROOT, OPENCODE_BIND_HOST, OPENCODE_BIND_PORT, OPENCODE_LOG_LEVEL, # A2A_HOST, A2A_PORT, A2A_PUBLIC_URL. # Optional provider secret env: see scripts/deploy/provider_secret_env_keys.sh -# All provided keys are persisted into config/opencode.secret.env. +# Secret persistence is opt-in via ENABLE_SECRET_PERSISTENCE=true. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -14,13 +14,10 @@ source "${SCRIPT_DIR}/provider_secret_env_keys.sh" PROJECT_NAME="${1:-}" if [[ "$#" -ne 1 || -z "$PROJECT_NAME" ]]; then - echo "Usage: GH_TOKEN= A2A_BEARER_TOKEN= $0 " >&2 + echo "Usage: [GH_TOKEN=] [A2A_BEARER_TOKEN=] [ENABLE_SECRET_PERSISTENCE=true] $0 " >&2 exit 1 fi -: "${GH_TOKEN:?GH_TOKEN is required}" -: "${A2A_BEARER_TOKEN:?A2A_BEARER_TOKEN is required}" - : "${DATA_ROOT:?}" : "${OPENCODE_BIND_HOST:?}" : "${OPENCODE_BIND_PORT:?}" @@ -32,11 +29,14 @@ fi : "${A2A_OTEL_INSTRUMENTATION_ENABLED:=false}" : "${A2A_CANCEL_ABORT_TIMEOUT_SECONDS:=2.0}" : "${A2A_ENABLE_SESSION_SHELL:=false}" +: "${ENABLE_SECRET_PERSISTENCE:=false}" PROJECT_DIR="${DATA_ROOT}/${PROJECT_NAME}" WORKSPACE_DIR="${PROJECT_DIR}/workspace" CONFIG_DIR="${PROJECT_DIR}/config" +OPENCODE_AUTH_ENV_FILE="${CONFIG_DIR}/opencode.auth.env" OPENCODE_SECRET_ENV_FILE="${CONFIG_DIR}/opencode.secret.env" +A2A_SECRET_ENV_FILE="${CONFIG_DIR}/a2a.secret.env" LOG_DIR="${PROJECT_DIR}/logs" RUN_DIR="${PROJECT_DIR}/run" ASKPASS_SCRIPT="${RUN_DIR}/git-askpass.sh" @@ -48,6 +48,37 @@ OPENCODE_BIN_DIR="${OPENCODE_LOCAL_SHARE_DIR}/bin" DATA_DIR="${PROJECT_DIR}/.local/share/opencode/storage/session" SECRET_ENV_KEYS=("${PROVIDER_SECRET_ENV_KEYS[@]}") +is_truthy() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +PERSIST_SECRETS="false" # pragma: allowlist secret +if is_truthy "${ENABLE_SECRET_PERSISTENCE}"; then + PERSIST_SECRETS="true" # pragma: allowlist secret +fi + +require_envfile_safe_value() { + local key="$1" + local value="$2" + case "$value" in + *$'\n'*|*$'\r'*) + echo "Value for ${key} contains a newline or carriage return, which is not allowed in EnvironmentFile entries." >&2 + exit 1 + ;; + esac +} + +append_env_line() { + local file="$1" + local key="$2" + local value="$3" + require_envfile_safe_value "$key" "$value" + printf '%s=%s\n' "$key" "$value" >>"$file" +} + # DATA_ROOT must be traversable by the per-project system user. In hardened # deployments, using a non-traversable DATA_ROOT (missing o+x) will break # OpenCode writes to $HOME/.cache, $HOME/.local/share, and $HOME/.local/state. @@ -130,6 +161,35 @@ sudo install -d -m 700 -o "$PROJECT_NAME" -g "$PROJECT_NAME" \ # fix it to avoid EACCES when opencode tries to mkdir under opencode/. sudo chown -R "$PROJECT_NAME:$PROJECT_NAME" "$CACHE_DIR" "$STATE_DIR" "$OPENCODE_LOCAL_SHARE_DIR" +opencode_auth_example_tmp="$(mktemp)" +cat <<'EOF' >"$opencode_auth_example_tmp" +# Root-only runtime secret file for opencode@.service. +# Populate GH_TOKEN here if ENABLE_SECRET_PERSISTENCE is not enabled during deploy. +GH_TOKEN= +EOF +sudo install -m 600 -o root -g root "$opencode_auth_example_tmp" "$CONFIG_DIR/opencode.auth.env.example" +rm -f "$opencode_auth_example_tmp" + +a2a_secret_example_tmp="$(mktemp)" +cat <<'EOF' >"$a2a_secret_example_tmp" +# Root-only runtime secret file for opencode-a2a@.service. +# Populate A2A_BEARER_TOKEN here if ENABLE_SECRET_PERSISTENCE is not enabled during deploy. +A2A_BEARER_TOKEN= +EOF +sudo install -m 600 -o root -g root "$a2a_secret_example_tmp" "$CONFIG_DIR/a2a.secret.env.example" +rm -f "$a2a_secret_example_tmp" + +opencode_secret_example_tmp="$(mktemp)" +{ + echo "# Optional root-only provider secret file for opencode@.service." + echo "# Populate only the provider keys your deployment actually uses." + for key in "${SECRET_ENV_KEYS[@]}"; do + echo "${key}=" + done +} >"$opencode_secret_example_tmp" +sudo install -m 600 -o root -g root "$opencode_secret_example_tmp" "$CONFIG_DIR/opencode.secret.env.example" +rm -f "$opencode_secret_example_tmp" + askpass_tmp="$(mktemp)" cat <<'SCRIPT' >"$askpass_tmp" #!/usr/bin/env bash @@ -153,83 +213,139 @@ fi opencode_env_tmp="$(mktemp)" { - echo "OPENCODE_LOG_LEVEL=${OPENCODE_LOG_LEVEL}" - echo "OPENCODE_BIND_HOST=${OPENCODE_BIND_HOST}" - echo "OPENCODE_BIND_PORT=${OPENCODE_BIND_PORT}" - echo "OPENCODE_EXTRA_ARGS=${OPENCODE_EXTRA_ARGS:-}" - echo "OPENCODE_LSP=${OPENCODE_LSP:-false}" - echo "GH_TOKEN=${GH_TOKEN}" - echo "GIT_ASKPASS=${ASKPASS_SCRIPT}" - echo "GIT_ASKPASS_REQUIRE=force" - echo "GIT_TERMINAL_PROMPT=0" - echo "GIT_AUTHOR_NAME=${git_author_name}" - echo "GIT_COMMITTER_NAME=${git_author_name}" - echo "GIT_AUTHOR_EMAIL=${git_author_email}" - echo "GIT_COMMITTER_EMAIL=${git_author_email}" + append_env_line "$opencode_env_tmp" "OPENCODE_LOG_LEVEL" "${OPENCODE_LOG_LEVEL}" + append_env_line "$opencode_env_tmp" "OPENCODE_BIND_HOST" "${OPENCODE_BIND_HOST}" + append_env_line "$opencode_env_tmp" "OPENCODE_BIND_PORT" "${OPENCODE_BIND_PORT}" + append_env_line "$opencode_env_tmp" "OPENCODE_EXTRA_ARGS" "${OPENCODE_EXTRA_ARGS:-}" + append_env_line "$opencode_env_tmp" "OPENCODE_LSP" "${OPENCODE_LSP:-false}" + append_env_line "$opencode_env_tmp" "GIT_ASKPASS" "${ASKPASS_SCRIPT}" + append_env_line "$opencode_env_tmp" "GIT_ASKPASS_REQUIRE" "force" + append_env_line "$opencode_env_tmp" "GIT_TERMINAL_PROMPT" "0" + append_env_line "$opencode_env_tmp" "GIT_AUTHOR_NAME" "${git_author_name}" + append_env_line "$opencode_env_tmp" "GIT_COMMITTER_NAME" "${git_author_name}" + append_env_line "$opencode_env_tmp" "GIT_AUTHOR_EMAIL" "${git_author_email}" + append_env_line "$opencode_env_tmp" "GIT_COMMITTER_EMAIL" "${git_author_email}" if [[ -n "${OPENCODE_PROVIDER_ID:-}" ]]; then - echo "OPENCODE_PROVIDER_ID=${OPENCODE_PROVIDER_ID}" + append_env_line "$opencode_env_tmp" "OPENCODE_PROVIDER_ID" "${OPENCODE_PROVIDER_ID}" fi if [[ -n "${OPENCODE_MODEL_ID:-}" ]]; then - echo "OPENCODE_MODEL_ID=${OPENCODE_MODEL_ID}" + append_env_line "$opencode_env_tmp" "OPENCODE_MODEL_ID" "${OPENCODE_MODEL_ID}" fi -} >"$opencode_env_tmp" +} sudo install -m 600 -o root -g root "$opencode_env_tmp" "$CONFIG_DIR/opencode.env" rm -f "$opencode_env_tmp" -opencode_secret_env_tmp="$(mktemp)" -has_secret_entry=0 -for key in "${SECRET_ENV_KEYS[@]}"; do - value="${!key:-}" - if [[ -z "$value" && -f "$OPENCODE_SECRET_ENV_FILE" ]]; then - value="$(sed -n "s/^${key}=//p" "$OPENCODE_SECRET_ENV_FILE" | head -n 1)" - fi - if [[ -n "$value" ]]; then - printf '%s=%s\n' "$key" "$value" >>"$opencode_secret_env_tmp" - has_secret_entry=1 +if [[ "$PERSIST_SECRETS" == "true" ]]; then # pragma: allowlist secret + : "${GH_TOKEN:?GH_TOKEN is required when ENABLE_SECRET_PERSISTENCE=true}" + : "${A2A_BEARER_TOKEN:?A2A_BEARER_TOKEN is required when ENABLE_SECRET_PERSISTENCE=true}" + + opencode_auth_env_tmp="$(mktemp)" + append_env_line "$opencode_auth_env_tmp" "GH_TOKEN" "${GH_TOKEN}" + sudo install -m 600 -o root -g root "$opencode_auth_env_tmp" "$OPENCODE_AUTH_ENV_FILE" + rm -f "$opencode_auth_env_tmp" + + opencode_secret_env_tmp="$(mktemp)" + has_secret_entry=0 + for key in "${SECRET_ENV_KEYS[@]}"; do + value="${!key:-}" + if [[ -z "$value" && -f "$OPENCODE_SECRET_ENV_FILE" ]]; then + value="$(sed -n "s/^${key}=//p" "$OPENCODE_SECRET_ENV_FILE" | head -n 1)" + fi + if [[ -n "$value" ]]; then + append_env_line "$opencode_secret_env_tmp" "$key" "$value" + has_secret_entry=1 + fi + done + if [[ "$has_secret_entry" -eq 1 ]]; then + sudo install -m 600 -o root -g root "$opencode_secret_env_tmp" "$OPENCODE_SECRET_ENV_FILE" fi -done -if [[ "$has_secret_entry" -eq 1 ]]; then - sudo install -m 600 -o root -g root "$opencode_secret_env_tmp" "$OPENCODE_SECRET_ENV_FILE" + rm -f "$opencode_secret_env_tmp" +else + echo "ENABLE_SECRET_PERSISTENCE is disabled; deploy will not write GH_TOKEN, A2A_BEARER_TOKEN, or provider keys to disk." >&2 + echo "Provision root-only runtime secret files under ${CONFIG_DIR} before starting services:" >&2 + echo " - opencode.auth.env (required: GH_TOKEN)" >&2 + echo " - a2a.secret.env (required: A2A_BEARER_TOKEN)" >&2 + echo " - opencode.secret.env (optional provider keys, if your OpenCode provider requires them)" >&2 + echo "Templates were generated as *.example files in ${CONFIG_DIR}." >&2 fi -rm -f "$opencode_secret_env_tmp" a2a_env_tmp="$(mktemp)" { - echo "A2A_HOST=${A2A_HOST}" - echo "A2A_PORT=${A2A_PORT}" - echo "A2A_PUBLIC_URL=${A2A_PUBLIC_URL}" - echo "A2A_PROJECT=${PROJECT_NAME}" - echo "A2A_BEARER_TOKEN=${A2A_BEARER_TOKEN}" - echo "A2A_STREAMING=${A2A_STREAMING}" - echo "A2A_LOG_LEVEL=${A2A_LOG_LEVEL:-WARNING}" - echo "OTEL_INSTRUMENTATION_A2A_SDK_ENABLED=${A2A_OTEL_INSTRUMENTATION_ENABLED:-false}" - echo "A2A_LOG_PAYLOADS=${A2A_LOG_PAYLOADS:-false}" - echo "A2A_LOG_BODY_LIMIT=${A2A_LOG_BODY_LIMIT:-0}" - echo "A2A_CANCEL_ABORT_TIMEOUT_SECONDS=${A2A_CANCEL_ABORT_TIMEOUT_SECONDS}" - echo "A2A_ENABLE_SESSION_SHELL=${A2A_ENABLE_SESSION_SHELL}" - echo "OPENCODE_BASE_URL=http://${OPENCODE_BIND_HOST}:${OPENCODE_BIND_PORT}" - echo "OPENCODE_DIRECTORY=${WORKSPACE_DIR}" - echo "OPENCODE_TIMEOUT=${OPENCODE_TIMEOUT:-300}" + append_env_line "$a2a_env_tmp" "A2A_HOST" "${A2A_HOST}" + append_env_line "$a2a_env_tmp" "A2A_PORT" "${A2A_PORT}" + append_env_line "$a2a_env_tmp" "A2A_PUBLIC_URL" "${A2A_PUBLIC_URL}" + append_env_line "$a2a_env_tmp" "A2A_PROJECT" "${PROJECT_NAME}" + append_env_line "$a2a_env_tmp" "A2A_STREAMING" "${A2A_STREAMING}" + append_env_line "$a2a_env_tmp" "A2A_LOG_LEVEL" "${A2A_LOG_LEVEL:-WARNING}" + append_env_line "$a2a_env_tmp" "OTEL_INSTRUMENTATION_A2A_SDK_ENABLED" "${A2A_OTEL_INSTRUMENTATION_ENABLED:-false}" + append_env_line "$a2a_env_tmp" "A2A_LOG_PAYLOADS" "${A2A_LOG_PAYLOADS:-false}" + append_env_line "$a2a_env_tmp" "A2A_LOG_BODY_LIMIT" "${A2A_LOG_BODY_LIMIT:-0}" + append_env_line "$a2a_env_tmp" "A2A_CANCEL_ABORT_TIMEOUT_SECONDS" "${A2A_CANCEL_ABORT_TIMEOUT_SECONDS}" + append_env_line "$a2a_env_tmp" "A2A_ENABLE_SESSION_SHELL" "${A2A_ENABLE_SESSION_SHELL}" + append_env_line "$a2a_env_tmp" "OPENCODE_BASE_URL" "http://${OPENCODE_BIND_HOST}:${OPENCODE_BIND_PORT}" + append_env_line "$a2a_env_tmp" "OPENCODE_DIRECTORY" "${WORKSPACE_DIR}" + append_env_line "$a2a_env_tmp" "OPENCODE_TIMEOUT" "${OPENCODE_TIMEOUT:-300}" if [[ -n "${OPENCODE_TIMEOUT_STREAM:-}" ]]; then - echo "OPENCODE_TIMEOUT_STREAM=${OPENCODE_TIMEOUT_STREAM}" + append_env_line "$a2a_env_tmp" "OPENCODE_TIMEOUT_STREAM" "${OPENCODE_TIMEOUT_STREAM}" fi if [[ -n "${OPENCODE_PROVIDER_ID:-}" ]]; then - echo "OPENCODE_PROVIDER_ID=${OPENCODE_PROVIDER_ID}" + append_env_line "$a2a_env_tmp" "OPENCODE_PROVIDER_ID" "${OPENCODE_PROVIDER_ID}" fi if [[ -n "${OPENCODE_MODEL_ID:-}" ]]; then - echo "OPENCODE_MODEL_ID=${OPENCODE_MODEL_ID}" + append_env_line "$a2a_env_tmp" "OPENCODE_MODEL_ID" "${OPENCODE_MODEL_ID}" fi -} >"$a2a_env_tmp" +} sudo install -m 600 -o root -g root "$a2a_env_tmp" "$CONFIG_DIR/a2a.env" rm -f "$a2a_env_tmp" +if [[ "$PERSIST_SECRETS" == "true" ]]; then # pragma: allowlist secret + a2a_secret_env_tmp="$(mktemp)" + append_env_line "$a2a_secret_env_tmp" "A2A_BEARER_TOKEN" "${A2A_BEARER_TOKEN}" + sudo install -m 600 -o root -g root "$a2a_secret_env_tmp" "$A2A_SECRET_ENV_FILE" + rm -f "$a2a_secret_env_tmp" +fi + +require_runtime_secret_file() { + local file="$1" + local key="$2" + local example="$3" + if ! sudo test -f "$file"; then + echo "Missing required runtime secret file: ${file}" >&2 + echo "Copy and edit the template: ${example}" >&2 + exit 1 + fi + if ! sudo grep -q "^${key}=" "$file"; then + echo "Runtime secret file does not define ${key}: ${file}" >&2 + echo "See template: ${example}" >&2 + exit 1 + fi +} + +read_runtime_secret_value() { + local file="$1" + local key="$2" + sudo sed -n "s/^${key}=//p" "$file" | head -n 1 +} + +require_runtime_secret_file "$OPENCODE_AUTH_ENV_FILE" "GH_TOKEN" "$CONFIG_DIR/opencode.auth.env.example" +require_runtime_secret_file "$A2A_SECRET_ENV_FILE" "A2A_BEARER_TOKEN" "$CONFIG_DIR/a2a.secret.env.example" + +GH_TOKEN_FOR_SETUP="${GH_TOKEN:-}" +if [[ -z "$GH_TOKEN_FOR_SETUP" ]]; then + GH_TOKEN_FOR_SETUP="$(read_runtime_secret_value "$OPENCODE_AUTH_ENV_FILE" "GH_TOKEN")" +fi + if command -v gh >/dev/null 2>&1; then sudo install -d -m 700 -o "$PROJECT_NAME" -g "$PROJECT_NAME" \ "${PROJECT_DIR}/.config" "${PROJECT_DIR}/.config/gh" - if ! printf '%s' "$GH_TOKEN" | sudo -u "$PROJECT_NAME" -H \ - gh auth login --hostname github.com --with-token >/dev/null 2>&1; then - echo "gh auth login failed for ${PROJECT_NAME}" >&2 - exit 1 + if [[ -n "$GH_TOKEN_FOR_SETUP" ]]; then + if ! printf '%s' "$GH_TOKEN_FOR_SETUP" | sudo -u "$PROJECT_NAME" -H \ + gh auth login --hostname github.com --with-token >/dev/null 2>&1; then + echo "gh auth login failed for ${PROJECT_NAME}" >&2 + exit 1 + fi + else + echo "GH_TOKEN not available during deploy; skipping gh auth login for ${PROJECT_NAME}." >&2 fi else echo "gh not found; skipping gh auth setup." >&2 @@ -245,11 +361,15 @@ if [[ -n "${REPO_URL:-}" ]]; then if [[ -n "${REPO_BRANCH:-}" ]]; then clone_args=(--branch "$REPO_BRANCH" --single-branch "${clone_args[@]}") fi - sudo -u "$PROJECT_NAME" -H env \ - GH_TOKEN="$GH_TOKEN" \ - GIT_ASKPASS="$ASKPASS_SCRIPT" \ - GIT_ASKPASS_REQUIRE=force \ - GIT_TERMINAL_PROMPT=0 \ - git clone "${clone_args[@]}" + if [[ -n "$GH_TOKEN_FOR_SETUP" ]]; then + sudo -u "$PROJECT_NAME" -H env \ + GH_TOKEN="$GH_TOKEN_FOR_SETUP" \ + GIT_ASKPASS="$ASKPASS_SCRIPT" \ + GIT_ASKPASS_REQUIRE=force \ + GIT_TERMINAL_PROMPT=0 \ + git clone "${clone_args[@]}" + else + sudo -u "$PROJECT_NAME" -H git clone "${clone_args[@]}" + fi fi fi diff --git a/scripts/deploy_readme.md b/scripts/deploy_readme.md index 0e7d49d..8c26835 100644 --- a/scripts/deploy_readme.md +++ b/scripts/deploy_readme.md @@ -1,18 +1,20 @@ # Deploy Script Guide (`deploy.sh`) -This document explains `scripts/deploy.sh` and helper scripts under `scripts/deploy/`. +This document explains `scripts/deploy.sh` and helper scripts under +`scripts/deploy/`. Scope: - systemd multi-instance deployment flow - deploy inputs, precedence, generated runtime files -- deployment operations and script-layer security notes +- runtime secret strategy and operational caveats Out of scope: - product/API transport contract and JSON-RPC semantics For product/protocol behavior, see [`../docs/guide.md`](../docs/guide.md). +For the overall threat model, see [`../SECURITY.md`](../SECURITY.md). ## Prerequisites @@ -24,71 +26,94 @@ For product/protocol behavior, see [`../docs/guide.md`](../docs/guide.md). For one-time host bootstrap, see [`init_system_readme.md`](./init_system_readme.md). +## Directory Layout + +Each project instance gets an isolated directory under `DATA_ROOT` +(default `/data/opencode-a2a/`): + +- `workspace/`: writable OpenCode workspace +- `config/`: root-only config directory for env files +- `logs/`: service logs +- `run/`: runtime files + +Default permissions: + +- `DATA_ROOT`: `711` (traversable, not listable) +- project root + `workspace` + `logs` + `run`: `700` +- `config/`: `700` (root-only), env files `600` + ## Quick Deploy +Default behavior: + +- `ENABLE_SECRET_PERSISTENCE=false` by default. +- In that default mode, deploy scripts do **not** write `GH_TOKEN`, + `A2A_BEARER_TOKEN`, or provider keys to disk. +- The script expects operators to pre-provision root-only runtime secret files: + - `config/opencode.auth.env` + - `config/a2a.secret.env` + - `config/opencode.secret.env` (optional provider keys) +- If those files are missing, the first deploy attempt creates `*.example` + templates under `config/` and stops before services are started. + +Recommended secure workflow: + +1. Bootstrap project directories and example files: + ```bash -GH_TOKEN='' A2A_BEARER_TOKEN='' \ ./scripts/deploy.sh project=alpha a2a_port=8010 a2a_host=127.0.0.1 ``` -HTTPS public URL example: +2. Populate runtime secret files as `root` using the generated templates: ```bash -GH_TOKEN='' A2A_BEARER_TOKEN='' \ -./scripts/deploy.sh project=alpha a2a_port=8010 a2a_public_url=https://a2a.example.com +sudo cp /data/opencode-a2a/alpha/config/opencode.auth.env.example /data/opencode-a2a/alpha/config/opencode.auth.env +sudo cp /data/opencode-a2a/alpha/config/a2a.secret.env.example /data/opencode-a2a/alpha/config/a2a.secret.env +sudoedit /data/opencode-a2a/alpha/config/opencode.auth.env +sudoedit /data/opencode-a2a/alpha/config/a2a.secret.env ``` -Upgrade existing instance after shared-code update: +3. Re-run deploy: ```bash -GH_TOKEN='' A2A_BEARER_TOKEN='' \ -./scripts/deploy.sh project=alpha update_a2a=true force_restart=true +./scripts/deploy.sh project=alpha a2a_port=8010 a2a_host=127.0.0.1 ``` -## Input Model - -### Precedence - -For values that support both env and CLI: - -`CLI key=value` > process env > built-in default. +Explicit persistence opt-in (legacy-style one-step deploy): -### Required Secrets +```bash +read -rsp 'GH_TOKEN: ' GH_TOKEN; echo +read -rsp 'A2A_BEARER_TOKEN: ' A2A_BEARER_TOKEN; echo +GH_TOKEN="${GH_TOKEN}" A2A_BEARER_TOKEN="${A2A_BEARER_TOKEN}" ENABLE_SECRET_PERSISTENCE=true \ +./scripts/deploy.sh project=alpha a2a_port=8010 a2a_host=127.0.0.1 +``` -- `GH_TOKEN` -- `A2A_BEARER_TOKEN` +HTTPS public URL example: -### CLI Keys +```bash +GH_TOKEN="${GH_TOKEN}" A2A_BEARER_TOKEN="${A2A_BEARER_TOKEN}" ENABLE_SECRET_PERSISTENCE=true \ +./scripts/deploy.sh project=alpha a2a_port=8010 a2a_public_url=https://a2a.example.com +``` -Supported keys (case-insensitive): +## Input Model -- `project` / `project_name` -- `data_root` -- `a2a_port`, `a2a_host`, `a2a_public_url` -- `a2a_streaming`, `a2a_log_level`, `a2a_otel_instrumentation_enabled` -- `a2a_log_payloads`, `a2a_log_body_limit` -- `a2a_cancel_abort_timeout_seconds`, `a2a_enable_session_shell` -- `opencode_provider_id`, `opencode_model_id`, `opencode_lsp`, `opencode_log_level` -- `opencode_timeout`, `opencode_timeout_stream` -- `repo_url`, `repo_branch` -- `git_identity_name`, `git_identity_email` -- `update_a2a`, `force_restart` +### Precedence -Sensitive values are blocked from CLI keys by design. +For values that support both environment variables and CLI keys: -## Configuration Details +`CLI key=value` > process env > built-in default. ### Secret Variables | ENV Name | Required | Default | CLI Support | Notes | | --- | --- | --- | --- | --- | -| `GH_TOKEN` | Yes | None | No | Used by OpenCode and `gh auth login`. | -| `A2A_BEARER_TOKEN` | Yes | None | No | Written to `a2a.env`. | -| `GOOGLE_GENERATIVE_AI_API_KEY` | Optional | None | No | Persisted to `opencode.secret.env` when provided. | -| `OPENAI_API_KEY` | Optional | None | No | Persisted to `opencode.secret.env` when provided. | -| `ANTHROPIC_API_KEY` | Optional | None | No | Persisted to `opencode.secret.env` when provided. | -| `AZURE_OPENAI_API_KEY` | Optional | None | No | Persisted to `opencode.secret.env` when provided. | -| `OPENROUTER_API_KEY` | Optional | None | No | Persisted to `opencode.secret.env` when provided. | +| `GH_TOKEN` | Conditionally | None | No | Required when `ENABLE_SECRET_PERSISTENCE=true`; otherwise provide it through `opencode.auth.env`. | +| `A2A_BEARER_TOKEN` | Conditionally | None | No | Required when `ENABLE_SECRET_PERSISTENCE=true`; otherwise provide it through `a2a.secret.env`. | +| `GOOGLE_GENERATIVE_AI_API_KEY` | Optional | None | No | Persisted only when `ENABLE_SECRET_PERSISTENCE=true`. | +| `OPENAI_API_KEY` | Optional | None | No | Persisted only when `ENABLE_SECRET_PERSISTENCE=true`. | +| `ANTHROPIC_API_KEY` | Optional | None | No | Persisted only when `ENABLE_SECRET_PERSISTENCE=true`. | +| `AZURE_OPENAI_API_KEY` | Optional | None | No | Persisted only when `ENABLE_SECRET_PERSISTENCE=true`. | +| `OPENROUTER_API_KEY` | Optional | None | No | Persisted only when `ENABLE_SECRET_PERSISTENCE=true`. | ### Non-Secret Input Variables @@ -100,7 +125,7 @@ Sensitive values are blocked from CLI keys by design. | `UV_PYTHON_DIR_GROUP` | - | Optional | `opencode` | Optional shared-group access control. | | `DATA_ROOT` | `data_root` | Optional | `/data/opencode-a2a` | Instance root directory. | | `OPENCODE_BIND_HOST` | - | Optional | `127.0.0.1` | OpenCode bind host. | -| `OPENCODE_BIND_PORT` | - | Optional | `A2A_PORT + 1` (fallback `4096`) | OpenCode bind port. | +| `OPENCODE_BIND_PORT` | - | Optional | `A2A_PORT + 1` fallback to `4096` | Multi-instance should use unique port. | | `OPENCODE_LOG_LEVEL` | `opencode_log_level` | Optional | `WARNING` | OpenCode log level. `WARNING` is normalized to `WARN` before launch. | | `OPENCODE_EXTRA_ARGS` | - | Optional | empty | Extra OpenCode startup args. | | `OPENCODE_PROVIDER_ID` | `opencode_provider_id` | Optional | None | Written to `a2a.env`. | @@ -110,6 +135,7 @@ Sensitive values are blocked from CLI keys by design. | `OPENCODE_TIMEOUT_STREAM` | `opencode_timeout_stream` | Optional | None | OpenCode stream timeout. | | `GIT_IDENTITY_NAME` | `git_identity_name` | Optional | `OpenCode-` | Git name for instance user. | | `GIT_IDENTITY_EMAIL` | `git_identity_email` | Optional | `@example.com` | Git email for instance user. | +| `ENABLE_SECRET_PERSISTENCE` | `enable_secret_persistence` | Optional | `false` | Explicitly allow deploy to write root-only secret env files. | | `REPO_URL` | `repo_url` | Optional | None | Optional repository URL to auto-clone into `workspace/` on first deploy. | | `REPO_BRANCH` | `repo_branch` | Optional | None | Optional branch used with `REPO_URL` during first clone. | | `A2A_HOST` | `a2a_host` | Optional | `127.0.0.1` | A2A bind host. | @@ -129,20 +155,21 @@ Sensitive values are blocked from CLI keys by design. | --- | --- | --- | --- | | `A2A_PROJECT` | derived from `project=` | `config/a2a.env` | Generated by `setup_instance.sh`; not direct deploy input. | -## Generated Layout and Files - -Per project instance (default: `/data/opencode-a2a/`): +## Generated Config Files -- `workspace/` -- `config/` -- `logs/` -- `run/` +For each project (`/data/opencode-a2a//config/`): -Generated config files: +- `opencode.env`: OpenCode-only non-secret settings +- `opencode.auth.env`: root-only runtime secret file for `GH_TOKEN` +- `opencode.secret.env`: optional provider secret file for OpenCode runtime +- `a2a.env`: A2A-only non-secret settings +- `a2a.secret.env`: root-only runtime secret file for `A2A_BEARER_TOKEN` +- `*.example`: root-only templates generated by deploy for secret provisioning -- `config/opencode.env` -- `config/opencode.secret.env` -- `config/a2a.env` +When `ENABLE_SECRET_PERSISTENCE=true`, deploy writes these secret files as +`600 root:root` and systemd loads them via `EnvironmentFile`. When the flag is +not enabled, operators are expected to provision the real secret files +themselves from the generated templates. ## Provider Coverage (Deploy Script Layer) @@ -154,7 +181,22 @@ Generated config files: | Azure OpenAI | `AZURE_OPENAI_API_KEY` | No explicit provider-specific check | | OpenRouter | `OPENROUTER_API_KEY` | No explicit provider-specific check | -Known gap: provider/model validation is partial in deploy scripts. +Known gaps: + +- provider/model validation is still partial in deploy scripts +- deploy scripts do not replace OpenCode's own provider configuration rules + +## Security Notes + +- `a2a_enable_session_shell=true` enables `opencode.sessions.shell`, a + high-risk capability that can execute shell commands in workspace context. +- Enable shell control only for trusted operators/internal use with strong + token governance and audit controls. +- This architecture does not provide hard guarantees that provider keys are + inaccessible to agents. +- Deploy writes EnvironmentFile entries with single-line validation to reduce + newline-based injection risk, but operators should still treat env files as + privileged configuration surfaces. ## Service Operations @@ -187,9 +229,3 @@ Remove one instance: ``` See [`uninstall_readme.md`](./uninstall_readme.md) for safety behavior. - -## Security Notes - -- `a2a_enable_session_shell=true` enables `opencode.sessions.shell`, a high-risk capability that can execute shell commands in workspace context. -- Enable shell control only for trusted operators/internal use with strong token governance and audit controls. -- This architecture does not provide hard credential isolation from agent behavior. diff --git a/tests/test_deploy_security_contract.py b/tests/test_deploy_security_contract.py new file mode 100644 index 0000000..3307087 --- /dev/null +++ b/tests/test_deploy_security_contract.py @@ -0,0 +1,47 @@ +from pathlib import Path + + +DEPLOY_SH_TEXT = Path("scripts/deploy.sh").read_text() +SETUP_INSTANCE_TEXT = Path("scripts/deploy/setup_instance.sh").read_text() +INSTALL_UNITS_TEXT = Path("scripts/deploy/install_units.sh").read_text() +README_TEXT = Path("README.md").read_text() +SECURITY_TEXT = Path("SECURITY.md").read_text() +DEPLOY_README_TEXT = Path("scripts/deploy_readme.md").read_text() + + +def test_deploy_defaults_to_operator_provisioned_runtime_secrets() -> None: + assert 'export ENABLE_SECRET_PERSISTENCE="${ENABLE_SECRET_PERSISTENCE:-false}"' in DEPLOY_SH_TEXT + assert 'enable_secret_persistence)' in DEPLOY_SH_TEXT + assert 'if [[ -z "$PROJECT_NAME" ]]; then' in DEPLOY_SH_TEXT + assert 'GH_TOKEN= A2A_BEARER_TOKEN=' not in DEPLOY_SH_TEXT + + +def test_systemd_units_split_secret_and_non_secret_env_files() -> None: + assert "EnvironmentFile=${DATA_ROOT}/%i/config/opencode.env" in INSTALL_UNITS_TEXT + assert "EnvironmentFile=-${DATA_ROOT}/%i/config/opencode.auth.env" in INSTALL_UNITS_TEXT + assert "EnvironmentFile=-${DATA_ROOT}/%i/config/opencode.secret.env" in INSTALL_UNITS_TEXT + assert "EnvironmentFile=${DATA_ROOT}/%i/config/a2a.env" in INSTALL_UNITS_TEXT + assert "EnvironmentFile=-${DATA_ROOT}/%i/config/a2a.secret.env" in INSTALL_UNITS_TEXT + + +def test_setup_instance_generates_examples_and_requires_runtime_secret_files() -> None: + assert ': "${ENABLE_SECRET_PERSISTENCE:=false}"' in SETUP_INSTANCE_TEXT + assert 'opencode.auth.env.example' in SETUP_INSTANCE_TEXT + assert 'a2a.secret.env.example' in SETUP_INSTANCE_TEXT + assert 'opencode.secret.env.example' in SETUP_INSTANCE_TEXT + assert 'require_runtime_secret_file "$OPENCODE_AUTH_ENV_FILE" "GH_TOKEN"' in SETUP_INSTANCE_TEXT + assert 'require_runtime_secret_file "$A2A_SECRET_ENV_FILE" "A2A_BEARER_TOKEN"' in SETUP_INSTANCE_TEXT + assert "deploy will not write GH_TOKEN, A2A_BEARER_TOKEN, or provider keys to disk" in SETUP_INSTANCE_TEXT + assert "Value for ${key} contains a newline or carriage return" in SETUP_INSTANCE_TEXT + + +def test_security_docs_emphasize_single_tenant_boundary_and_secret_strategy() -> None: + assert "single-tenant trust boundary" in README_TEXT + assert "a2a-client-hub" in README_TEXT + assert "```mermaid" in README_TEXT + assert "[SECURITY.md](SECURITY.md)" in README_TEXT + assert "secret persistence is opt-in" in SECURITY_TEXT + assert "single-tenant trust boundary" in SECURITY_TEXT + assert "ENABLE_SECRET_PERSISTENCE=false" in DEPLOY_README_TEXT + assert "opencode.auth.env" in DEPLOY_README_TEXT + assert "a2a.secret.env" in DEPLOY_README_TEXT