From e14b81f5935df771c3f68451d375dd0181917851 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 17 Oct 2026 15:55:09 +0000 Subject: [PATCH] feat(wordpress-chat): opt-in --wordpress-chat-agent activation for the WordPress runtime-chat pipeline (#779) --- .github/workflows/shell.yml | 8 + bridges/roadie.sh | 39 +- lib/wordpress-chat.sh | 436 ++++++++++++++++++ setup.sh | 20 +- ...gents-md-failure-does-not-abort-upgrade.sh | 3 +- tests/wordpress-chat-activation.sh | 252 ++++++++++ upgrade.sh | 26 +- 7 files changed, 779 insertions(+), 5 deletions(-) create mode 100644 lib/wordpress-chat.sh create mode 100755 tests/wordpress-chat-activation.sh diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 87156c1c..a92de4c8 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -172,6 +172,14 @@ jobs: - name: Run tests/ai-gateway.sh run: ./tests/ai-gateway.sh + wordpress-chat-activation: + name: WordPress chat opt-in activation (#779) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run tests/wordpress-chat-activation.sh + run: ./tests/wordpress-chat-activation.sh + cli-channel-binary-path: name: Roadie CLI channel reachable by the web user (#198) runs-on: ubuntu-latest diff --git a/bridges/roadie.sh b/bridges/roadie.sh index 07283e22..4596eaa6 100644 --- a/bridges/roadie.sh +++ b/bridges/roadie.sh @@ -1653,6 +1653,13 @@ EOF if [ -n "${AGENT_SLUG:-}" ]; then echo "Environment=DATAMACHINE_AGENT_SLUG=$AGENT_SLUG" fi + # WordPress chat opt-in (#779): the platform list gains `wordpress` + # (roadie#297 comma list) only when the activation flag or its durable env + # file enables it; otherwise this renders nothing and output stays + # byte-identical. + if declare -F wordpress_chat_enabled >/dev/null && wordpress_chat_enabled; then + printf 'Environment=ROADIE_PLATFORM=%s\n' "$(wordpress_chat_roadie_platform_value)" + fi } _roadie_append_env_files() { @@ -1667,6 +1674,14 @@ $gateway_env_line" local codebox_db_env_line="EnvironmentFile=-$CODEBOX_DATABASE_ENV_FILE" grep -qF "$codebox_db_env_line" <<< "$env_block" || env_block="$env_block $codebox_db_env_line" + fi + # The shared WordPress chat signing secret (#779): the same 0600 env file + # the connector unit loads, so the HMAC secret matches on both processes. + if declare -F wordpress_chat_enabled >/dev/null && wordpress_chat_enabled; then + local wordpress_chat_env_line + wordpress_chat_env_line="EnvironmentFile=-$(wordpress_chat_env_file)" + grep -qF "$wordpress_chat_env_line" <<< "$env_block" || env_block="$env_block +$wordpress_chat_env_line" fi printf '%s\n' "$env_block" } @@ -2029,7 +2044,7 @@ bridge_render_launchd() { ROADIE_POOL_KEY_FILE $(xml_escape "$(_roadie_pool_key_file)")"; fi)$(if _roadie_remote_pool_admin_ready; then echo " ROADIE_POOL_ADMIN_KEY_FILE - $(xml_escape "$(_roadie_pool_admin_key_file)")"; fi)$(_roadie_ai_gateway_launchd_env_xml) + $(xml_escape "$(_roadie_pool_admin_key_file)")"; fi)$(_roadie_wordpress_chat_launchd_env_xml)$(_roadie_ai_gateway_launchd_env_xml) EOF } @@ -2098,6 +2113,28 @@ _roadie_datamachine_wp_transport_systemd_env() { printf 'Environment=DATAMACHINE_WP_TRANSPORT_JSON="%s"\n' "$value" } +# WordPress chat opt-in (#779) on launchd: the platform list plus the ingress +# delivery env inlined from the shared 0600 env file (launchd has no +# EnvironmentFile). Renders nothing unless the activation is enabled. +_roadie_wordpress_chat_launchd_env_xml() { + declare -F wordpress_chat_enabled >/dev/null || return 0 + wordpress_chat_enabled || return 0 + local env_file key value + env_file="$(wordpress_chat_env_file)" + echo " + ROADIE_PLATFORM + $(xml_escape "$(wordpress_chat_roadie_platform_value)")" + for key in WP_CODING_AGENTS_INBOUND_WORDPRESS_ENABLED \ + WP_CODING_AGENTS_INBOUND_WORDPRESS_ENDPOINT \ + WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET_ENV \ + "$WORDPRESS_CHAT_SECRET_ENV_NAME"; do + value="$(wordpress_chat_read_env_value "$key" "$env_file")" + [ -n "$value" ] || continue + echo " $key + $(xml_escape "$value")" + done +} + _roadie_ai_gateway_launchd_env_xml() { declare -F ai_gateway_enabled >/dev/null || return 0 ai_gateway_enabled || return 0 diff --git a/lib/wordpress-chat.sh b/lib/wordpress-chat.sh new file mode 100644 index 00000000..7c631a4d --- /dev/null +++ b/lib/wordpress-chat.sh @@ -0,0 +1,436 @@ +#!/bin/bash +# Opt-in WordPress-native chat activation (#779). +# +# The pieces from #772/#773 (durable agents/chat ingress through the inbound +# queue, the Roadie `wordpress` chat platform) are switched on as one unit by +# `--wordpress-chat-agent ` on setup/upgrade: +# +# 1. runtime-chat adapter config — an installer-written mu-plugin maps the +# agent slug to the runtime ID that claims its queued events, read by +# WpCodingAgents_Runtime_Chat through adapter_config('runtime-chat'). +# 2. shared signing secret — minted once into a 0600 env file loaded by +# both processes (the Roadie unit via EnvironmentFile, the connector +# unit directly); reused, never regenerated, on upgrade. +# 3. managed connector service — scripts/inbound-event-connector.py runs +# next to Roadie (systemd unit on a VPS, launchd plist on local macOS), +# claiming queued agents-chat events over a generated wp-control wrapper +# and delivering them to the wordpress-platform loopback ingress. +# 4. Roadie platform list + channel — the rendered Roadie service carries +# ROADIE_PLATFORM=discord,wordpress (roadie#297 comma list) and +# ROADIE_CHANNELS_CONFIG gains a `wordpress:` entry so +# ensureRuntime() admits the bound context instead of refusing with +# "Channel … is not configured". +# +# Once opted in, the env file is the durable record: upgrades converge without +# repeating the flag. Without the flag and without that file, every hook here +# is inert and rendered service output stays byte-identical to today's. + +WORDPRESS_CHAT_AGENT="${WORDPRESS_CHAT_AGENT:-}" +WORDPRESS_CHAT_SECRET_ENV_NAME="WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET" + +# Instance suffix derived from the Roadie unit, so a second instance +# (roadie-.service) gets its own env file, connector dir, and unit. +wordpress_chat_instance_suffix() { + local stem="${ROADIE_UNIT:-roadie.service}" + stem="${stem%.service}" + [ "$stem" = roadie ] || printf -- '-%s' "${stem#roadie-}" +} + +# The 0600 env file both processes load. Lives with the other Roadie secrets. +wordpress_chat_env_file() { + if [ -n "${WORDPRESS_CHAT_ENV_FILE:-}" ]; then + printf '%s\n' "$WORDPRESS_CHAT_ENV_FILE" + elif [ "${LOCAL_MODE:-false}" = true ]; then + printf '%s/secrets/wordpress-chat.env\n' "${ROADIE_DATA_DIR:-$HOME/.roadie}" + else + printf '%s/roadie%s/wordpress-chat.env\n' "${ROADIE_SECRETS_ROOT:-/etc/wp-coding-agents}" "$(wordpress_chat_instance_suffix)" + fi +} + +wordpress_chat_read_env_value() { + local key="$1" file="$2" + [ -f "$file" ] || return 0 + sed -n "s/^${key}=//p" "$file" | tail -n 1 +} + +# Enabled by the flag this run, or durably by the env file a previous opt-in +# wrote. The second branch is what keeps upgrades converging without the flag. +wordpress_chat_enabled() { + [ -n "${WORDPRESS_CHAT_AGENT:-}" ] && return 0 + [ "$(wordpress_chat_read_env_value WP_CODING_AGENTS_INBOUND_WORDPRESS_ENABLED "$(wordpress_chat_env_file)")" = 1 ] +} + +wordpress_chat_agent_slug() { + if [ -n "${WORDPRESS_CHAT_AGENT:-}" ]; then + printf '%s\n' "$WORDPRESS_CHAT_AGENT" + return 0 + fi + wordpress_chat_read_env_value WP_CODING_AGENTS_WORDPRESS_CHAT_AGENT "$(wordpress_chat_env_file)" +} + +# The runtime ID the queued events are claimed under. It must match on the +# WordPress side (runtime-chat config) and the connector (WP_CODING_AGENTS_ +# RUNTIME_ID), so both read the same resolution: explicit override, the env +# file a previous run recorded, else the Roadie instance name. +wordpress_chat_runtime_id() { + local value="${WORDPRESS_CHAT_RUNTIME_ID:-}" + [ -n "$value" ] || value="$(wordpress_chat_read_env_value WP_CODING_AGENTS_RUNTIME_ID "$(wordpress_chat_env_file)")" + [ -n "$value" ] || value="roadie$(wordpress_chat_instance_suffix)" + printf '%s\n' "$value" +} + +wordpress_chat_endpoint() { + printf '%s\n' "${WORDPRESS_CHAT_ENDPOINT:-http://127.0.0.1:3712/wordpress/events}" +} + +# Roadie accepts several platforms via --platform/ROADIE_PLATFORM (roadie#297). +wordpress_chat_roadie_platform_value() { + printf '%s\n' "${WORDPRESS_CHAT_ROADIE_PLATFORMS:-discord,wordpress}" +} + +# The slug and runtime ID are interpolated into a PHP mu-plugin and a systemd +# unit, so they are validated against the exact patterns the runtime-chat +# handler and the connector enforce before they go anywhere. +wordpress_chat_validate() { + local slug runtime_id + slug="$(wordpress_chat_agent_slug)" + [ -n "$slug" ] || error "--wordpress-chat-agent requires an agent slug" + printf '%s' "$slug" | grep -Eq '^[a-z0-9][a-z0-9_-]{0,190}$' \ + || error "Invalid --wordpress-chat-agent '$slug' (expected a lowercase agent slug)" + runtime_id="$(wordpress_chat_runtime_id)" + printf '%s' "$runtime_id" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,190}$' \ + || error "Invalid WordPress chat runtime id '$runtime_id'" +} + +# Mint the shared signing secret once; every later run (upgrade) reuses it and +# only reconciles the non-secret keys around it. 0600, service-user-owned. +wordpress_chat_write_env() { + wordpress_chat_enabled || return 0 + wordpress_chat_validate + local env_file secret + env_file="$(wordpress_chat_env_file)" + secret="$(wordpress_chat_read_env_value "$WORDPRESS_CHAT_SECRET_ENV_NAME" "$env_file")" + if [ "${DRY_RUN:-false}" = true ]; then + if [ -n "$secret" ]; then + echo -e "${BLUE}[dry-run]${NC} Would reuse the WordPress chat signing secret in $env_file" + else + echo -e "${BLUE}[dry-run]${NC} Would mint a WordPress chat signing secret into $env_file (0600)" + fi + return 0 + fi + if [ -z "$secret" ]; then + secret="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + [ -z "${UPDATED_ITEMS+x}" ] || UPDATED_ITEMS+=("WordPress chat signing secret ($env_file)") + fi + mkdir -p "$(dirname "$env_file")" + local tmp="$env_file.tmp.$$" + ( umask 077 && { + printf 'WP_CODING_AGENTS_INBOUND_WORDPRESS_ENABLED=1\n' + printf 'WP_CODING_AGENTS_INBOUND_WORDPRESS_ENDPOINT=%s\n' "$(wordpress_chat_endpoint)" + printf 'WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET_ENV=%s\n' "$WORDPRESS_CHAT_SECRET_ENV_NAME" + printf '%s=%s\n' "$WORDPRESS_CHAT_SECRET_ENV_NAME" "$secret" + printf 'WP_CODING_AGENTS_RUNTIME_ID=%s\n' "$(wordpress_chat_runtime_id)" + printf 'WP_CODING_AGENTS_WORDPRESS_CHAT_AGENT=%s\n' "$(wordpress_chat_agent_slug)" + } > "$tmp" ) + chmod 0600 "$tmp" + if [ "${LOCAL_MODE:-false}" != true ] && [ -n "${SERVICE_USER:-}" ]; then + chown "$SERVICE_USER:$SERVICE_USER" "$tmp" 2>/dev/null || true + fi + mv -f "$tmp" "$env_file" +} + +wordpress_chat_adapter_config_path() { + printf '%s/wp-content/mu-plugins/wp-coding-agents-runtime-chat-config.php\n' "$SITE_PATH" +} + +# The installer-written runtime-chat config: agent slug -> runtime ID, through +# the generic adapter-config filter the inbound bridge already reads. Values +# were validated above, so interpolation into single-quoted PHP is safe. +wordpress_chat_write_adapter_config() { + wordpress_chat_enabled || return 0 + [ -n "${SITE_PATH:-}" ] || { warn " wordpress_chat: SITE_PATH not set — skipping the runtime-chat config"; return 0; } + local file slug runtime_id content + file="$(wordpress_chat_adapter_config_path)" + slug="$(wordpress_chat_agent_slug)" + runtime_id="$(wordpress_chat_runtime_id)" + content=" $runtime_id)" + return 0 + fi + mkdir -p "$(dirname "$file")" + if [ ! -f "$file" ] || ! printf '%s\n' "$content" | cmp -s - "$file"; then + printf '%s\n' "$content" > "$file" + log " Wrote runtime-chat adapter config: $file ($slug -> $runtime_id)" + [ -z "${UPDATED_ITEMS+x}" ] || UPDATED_ITEMS+=("Runtime chat binding ($slug -> $runtime_id)") + fi + service_file_normalize_perms "$file" +} + +wordpress_chat_config_dir() { + if declare -F roadie_config_dir >/dev/null; then + roadie_config_dir + return 0 + fi + printf '%s\n' "${ROADIE_CONFIG_DIR:-/opt/roadie-config}" +} + +# The Roadie context serving this agent: the wordpress-context.json context +# whose agentSlug matches, falling back to the slug itself when the binding +# file does not exist yet (the operator writes it; the channel entry then +# already matches once they name the context after the agent). +wordpress_chat_context_key() { + local slug config + slug="$(wordpress_chat_agent_slug)" + config="$(wordpress_chat_config_dir)/wordpress-context.json" + if [ -f "$config" ]; then + python3 - "$config" "$slug" <<'PY' +import json, sys +key = sys.argv[2] +try: + config = json.load(open(sys.argv[1])) + for context, binding in (config.get("contexts") or {}).items(): + if isinstance(binding, dict) and binding.get("agentSlug") == sys.argv[2]: + key = context + break +except (OSError, ValueError, AttributeError): + pass +print(key) +PY + return 0 + fi + printf '%s\n' "$slug" +} + +wordpress_chat_channels_file() { + printf '%s\n' "${ROADIE_CHANNELS_CONFIG:-$(wordpress_chat_config_dir)/channels.yaml}" +} + +# Ensure the `wordpress:` channel resolves in ROADIE_CHANNELS_CONFIG +# (ensureRuntime refuses an unconfigured channel). channels.yaml is +# operator-owned policy, so the edit is text-preserving: a missing file is +# created, a block- or flow-style `channels:` mapping gains one entry, and +# anything this cannot edit safely becomes a pending item instead of a rewrite. +wordpress_chat_ensure_channel() { + wordpress_chat_enabled || return 0 + local file channel outcome status=0 + file="$(wordpress_chat_channels_file)" + channel="wordpress:$(wordpress_chat_context_key)" + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would ensure channel \"$channel\" in $file" + return 0 + fi + outcome="$(python3 - "$file" "$channel" <<'PY' +import re, sys +from pathlib import Path + +file = Path(sys.argv[1]) +channel = sys.argv[2] +entry = '"%s": {}' % channel +if not file.exists(): + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text("channels:\n %s\n" % entry, encoding="utf-8") + print("created") + raise SystemExit +text = file.read_text(encoding="utf-8") +if channel in text: + print("present") + raise SystemExit +if not text.strip(): + file.write_text("channels:\n %s\n" % entry, encoding="utf-8") + print("created") + raise SystemExit +lines = text.splitlines(keepends=True) +for index, line in enumerate(lines): + if re.match(r"^channels:\s*(#.*)?$", line): + lines.insert(index + 1, " %s\n" % entry) + file.write_text("".join(lines), encoding="utf-8") + print("inserted") + raise SystemExit +flow = re.search(r"^channels:\s*\{", text, flags=re.M) +if flow: + position = flow.end() + separator = "" if text[position:].lstrip().startswith("}") else ", " + file.write_text(text[:position] + entry + separator + text[position:], encoding="utf-8") + print("inserted") + raise SystemExit +print("manual") +raise SystemExit(3) +PY +)" || status=$? + case "$outcome" in + created|inserted) + log " channels.yaml: $outcome \"$channel\" in $file" + [ -z "${UPDATED_ITEMS+x}" ] || UPDATED_ITEMS+=("Roadie channel $channel ($file)") + ;; + present) ;; + *) + warn " Could not add \"$channel\" to $file automatically (status $status)." + warn " Add it under channels: so Roadie admits the bound WordPress context." + [ -z "${PENDING_ITEMS+x}" ] || PENDING_ITEMS+=("Roadie channels.yaml entry \"$channel\" in $file") + ;; + esac + return 0 +} + +wordpress_chat_connector_dir() { + if [ -n "${WORDPRESS_CHAT_CONNECTOR_DIR:-}" ]; then + printf '%s\n' "$WORDPRESS_CHAT_CONNECTOR_DIR" + elif [ "${LOCAL_MODE:-false}" = true ]; then + printf '%s/inbound-connector\n' "${ROADIE_DATA_DIR:-$HOME/.roadie}" + else + printf '/usr/local/lib/wp-coding-agents/inbound-connector%s\n' "$(wordpress_chat_instance_suffix)" + fi +} + +wordpress_chat_connector_unit() { + printf 'wp-coding-agents-inbound-connector%s.service\n' "$(wordpress_chat_instance_suffix)" +} + +wordpress_chat_connector_label() { + printf 'com.wp.wp-coding-agents-inbound-connector\n' +} + +# The connector script plus the wp-control wrapper it polls through (it execs +# a sibling named wp-control; on a managed host that is this run's WP-CLI +# transport pinned to the site path). +wordpress_chat_install_connector_files() { + local dir command + dir="$(wordpress_chat_connector_dir)" + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would install the inbound-event-connector and wp-control wrapper into $dir" + return 0 + fi + mkdir -p "$dir" + cp "$SCRIPT_DIR/scripts/inbound-event-connector.py" "$dir/inbound-event-connector" + command="$(wp_cli_transport_display)" + printf '#!/bin/sh\n# Managed by wp-coding-agents: WP-CLI control transport for the inbound connector.\nexec %s "$@" --path=%q --allow-root\n' \ + "$command" "$SITE_PATH" > "$dir/wp-control" + chmod 0755 "$dir/inbound-event-connector" "$dir/wp-control" +} + +wordpress_chat_render_connector_systemd() { + cat <" + done + plist_document <Label + $(xml_escape "$label") + ProgramArguments + + /usr/bin/env + python3 + $(xml_escape "$(wordpress_chat_connector_dir)/inbound-event-connector") + + WorkingDirectory + $(xml_escape "$SITE_PATH") + KeepAlive + + StandardOutPath + $(xml_escape "$data_dir/inbound-connector.log") + StandardErrorPath + $(xml_escape "$data_dir/inbound-connector.error.log") + EnvironmentVariables + + HOME + $(xml_escape "${SERVICE_HOME:-$HOME}") + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin$env_xml + +EOF +} + +wordpress_chat_install_connector_service() { + if [ "${LOCAL_MODE:-false}" = true ] && [ "${PLATFORM:-}" = mac ]; then + local label plist_dir plist + label="$(wordpress_chat_connector_label)" + plist_dir="${WORDPRESS_CHAT_LAUNCHD_DIR:-$HOME/Library/LaunchAgents}" + plist="$plist_dir/$label.plist" + run_cmd mkdir -p "$plist_dir" + write_file "$plist" "$(wordpress_chat_render_connector_launchd "$label")" + if [ "${DRY_RUN:-false}" = false ]; then + launchctl bootout "gui/$(id -u)" "$plist" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$plist" + fi + log "WordPress chat connector: $label" + return 0 + fi + if [ "${LOCAL_MODE:-false}" = true ]; then + log "Local mode: run the inbound connector manually with:" + log " set -a; . $(wordpress_chat_env_file); set +a; python3 $(wordpress_chat_connector_dir)/inbound-event-connector" + return 0 + fi + local unit_dir unit + unit_dir="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}" + unit="$(wordpress_chat_connector_unit)" + write_file "$unit_dir/$unit" "$(wordpress_chat_render_connector_systemd)" + run_cmd systemctl daemon-reload + run_cmd systemctl enable "$unit" + run_cmd systemctl restart "$unit" + log "WordPress chat connector: $unit" +} + +# One idempotent pass, called from setup and upgrade. No-op unless opted in. +wordpress_chat_apply() { + wordpress_chat_enabled || return 0 + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + warn " --wordpress-chat-agent is not managed on the external WordPress profile; start the installed inbound-event-connector from the runtime environment instead" + return 0 + fi + log "WordPress chat: agent $(wordpress_chat_agent_slug) -> runtime $(wordpress_chat_runtime_id)" + wordpress_chat_write_env + wordpress_chat_write_adapter_config + wordpress_chat_ensure_channel + wordpress_chat_install_connector_files + wordpress_chat_install_connector_service +} diff --git a/setup.sh b/setup.sh index e100ea31..26d2be9a 100755 --- a/setup.sh +++ b/setup.sh @@ -22,7 +22,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source shared modules -for lib in common grants detect install-source source-policy owned-source-discovery agent-state-ownership desired-state-reconciler convergence-orchestrator integration-adapters runtime-guidance-desired-state bridge-service-adapters wordpress external-wordpress infrastructure data-machine carried-plugins homeboy ai-gateway skills summary cli-transport inbound-event-bridge cli-channel runtime-signature runtime-guard source-reconcile agents-md-guidance opencode-subagents systems-capabilities self-upgrade codebox-database composer-provision; do +for lib in common grants detect install-source source-policy owned-source-discovery agent-state-ownership desired-state-reconciler convergence-orchestrator integration-adapters runtime-guidance-desired-state bridge-service-adapters wordpress external-wordpress infrastructure data-machine carried-plugins homeboy ai-gateway skills summary cli-transport inbound-event-bridge wordpress-chat cli-channel runtime-signature runtime-guard source-reconcile agents-md-guidance opencode-subagents systems-capabilities self-upgrade codebox-database composer-provision; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -250,6 +250,10 @@ while [[ $# -gt 0 ]]; do ROTATE_AI_GATEWAY_TOKEN=true shift ;; + --wordpress-chat-agent) + WORDPRESS_CHAT_AGENT="$2" + shift 2 + ;; --no-homeboy) HOMEBOY_MODE="disabled" shift @@ -494,6 +498,15 @@ OPTIONS: --rotate-ai-gateway-token Mint a new gateway token instead of reusing the existing .opencode/wp-ai-gateway.env value + --wordpress-chat-agent + Opt in to WordPress-native chat for one agent: writes the + runtime-chat adapter config (agent slug -> runtime ID), + mints and reuses a shared signing secret in a 0600 env + file, runs the inbound-event-connector as a managed + service next to Roadie, adds wordpress to the Roadie + platform list, and ensures a wordpress: channel + entry. Recorded durably; upgrades converge without + repeating the flag. --no-claude-code-auth Skip the managed direct OpenCode Claude Pro/Max OAuth auth plugin. Installed by default for direct OpenCode @@ -659,6 +672,9 @@ if [ "$RUNTIME_ONLY" != true ] && [ "$EXTERNAL_WORDPRESS" != true ]; then source_policy_record_writable_paths source_policy_record_log_paths ai_gateway_apply + # The shared WordPress chat secret must exist before any bridge render + # references its env file or inlines its values (#779). + wordpress_chat_write_env fi [ "$RUNTIME_ONLY" != true ] && systems_capabilities_apply @@ -677,7 +693,7 @@ fi [ "$RUNTIME_ONLY" != true ] && ai_gateway_configure_opencode [ "$RUNTIME_ONLY" != true ] && opencode_project_subagents_optional [ "$RUNTIME_ONLY" != true ] && remove_retired_skills -if [ "$RUNTIME_ONLY" != true ]; then if [ "$EXTERNAL_WORDPRESS" != true ]; then cli_transport_install; inbound_event_bridge_install; else inbound_event_connector_install; fi; fi +if [ "$RUNTIME_ONLY" != true ]; then if [ "$EXTERNAL_WORDPRESS" != true ]; then cli_transport_install; inbound_event_bridge_install; wordpress_chat_apply; else inbound_event_connector_install; fi; fi # Install the reconciler, then run it once so a fresh install converges the same # way a live change will. if [ "$RUNTIME_ONLY" != true ] && [ "$EXTERNAL_WORDPRESS" != true ]; then source_reconcile_sync; source_reconcile_run; fi diff --git a/tests/agents-md-failure-does-not-abort-upgrade.sh b/tests/agents-md-failure-does-not-abort-upgrade.sh index 4dc83375..a57b7674 100755 --- a/tests/agents-md-failure-does-not-abort-upgrade.sh +++ b/tests/agents-md-failure-does-not-abort-upgrade.sh @@ -57,7 +57,8 @@ PHASES_AFTER=( # Phases before the failing one, plus the unconditional ones. for phase in reconcile_provider_and_service_state sync_cli_transport_runtime \ - update_ai_gateway sync_chat_bridge_config systems_capabilities_apply self_upgrade_apply \ + wordpress_chat_write_env update_ai_gateway update_wordpress_chat \ + sync_chat_bridge_config systems_capabilities_apply self_upgrade_apply \ check_opencode_json_drift ai_gateway_configure_opencode sync_skills \ "${PHASES_AFTER[@]}"; do echo "${phase}() { echo \"RAN ${phase}\"; }" diff --git a/tests/wordpress-chat-activation.sh b/tests/wordpress-chat-activation.sh new file mode 100755 index 00000000..39897f1f --- /dev/null +++ b/tests/wordpress-chat-activation.sh @@ -0,0 +1,252 @@ +#!/bin/bash +# WordPress chat activation (#779) — the opt-in flag turns the merged +# #772/#773 pieces on as one unit, and stays inert without it. +# +# Covered here: +# 1. Disabled is a no-op: no env file, no connector unit, and the Roadie +# env/render carries no ROADIE_PLATFORM or wordpress-chat lines. The +# enabled render is the disabled render plus EXACTLY the two added lines +# (byte-level), which is the "byte-identical without the flag" guarantee +# from the other direction. +# 2. The signing secret is minted once into a 0600 env file and REUSED on a +# second (upgrade) pass — including a pass that does not repeat the flag, +# because the env file is the durable opt-in record. +# 3. The runtime-chat adapter config mu-plugin maps agent slug -> runtime ID +# through the adapter_config filter (and the mapping actually resolves +# when PHP is available). +# 4. channels.yaml gains a wordpress: entry: created when missing, +# inserted text-preservingly into an existing operator file (block and +# flow style), idempotent, and keyed by the wordpress-context.json +# context bound to the agent when that file exists. +# 5. The connector service unit is rendered/installed only when enabled, +# loads the same env file the Roadie unit references, and execs the +# installed connector next to its generated wp-control wrapper. +# +# tests/bridge-render.sh keeps the committed golden snapshots (rendered +# without the flag) honest; this file is separate because it needs its own +# hermetic temp config root (#776) rather than the host-managed roadie-config. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$SCRIPT_DIR" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +fail() { echo "FAIL: $1"; exit 1; } +ok() { PASS=$((PASS + 1)); echo " ok: $1"; } + +# --------------------------------------------------------------------------- +# Deterministic mock host under a temp config root — nothing host-managed. +# --------------------------------------------------------------------------- +unset HOMEBOY_DATA_DIR ROADIE_CHANNELS_CONFIG ROADIE_PI_MODEL ROADIE_PI_BIN \ + ROADIE_POOL_URL WORDPRESS_CHAT_AGENT WORDPRESS_CHAT_RUNTIME_ID 2>/dev/null || true + +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/grants.sh" +source "$SCRIPT_DIR/bridges/_dispatch.sh" +source "$SCRIPT_DIR/bridges/roadie.sh" +source "$SCRIPT_DIR/lib/wordpress-chat.sh" + +_resolve_node_bin_dir() { printf '/usr/bin'; } +systemctl() { printf '%s\n' "$*" >> "$TMP/systemctl-calls"; } +launchctl() { printf '%s\n' "$*" >> "$TMP/launchctl-calls"; } + +LOCAL_MODE=false +DRY_RUN=false +PLATFORM=linux +SERVICE_USER=agent +SERVICE_HOME="$TMP/home" +SITE_PATH="$TMP/site" +ROADIE_DATA_DIR="$SERVICE_HOME/.roadie" +ROADIE_BIN=/usr/bin/roadie +ROADIE_CONFIG_DIR="$TMP/roadie-config" +ROADIE_SECRETS_ROOT="$TMP/secrets-root" +SYSTEMD_UNIT_DIR="$TMP/systemd" +WORDPRESS_CHAT_ENV_FILE="$TMP/secrets/wordpress-chat.env" +WORDPRESS_CHAT_CONNECTOR_DIR="$TMP/connector" +WORDPRESS_CHAT_LAUNCHD_DIR="$TMP/launchagents" +AGENT_SLUG=intelligence +WP_CMD=wp +UPDATED_ITEMS=() +PENDING_ITEMS=() +mkdir -p "$SERVICE_HOME" "$SITE_PATH" "$SYSTEMD_UNIT_DIR" "$ROADIE_CONFIG_DIR" + +# --------------------------------------------------------------------------- +# 1. Without the flag everything is inert +# --------------------------------------------------------------------------- +wordpress_chat_enabled && fail "activation reports enabled without the flag or env file" +wordpress_chat_apply +[ -e "$WORDPRESS_CHAT_ENV_FILE" ] && fail "disabled apply wrote the env file" +[ -n "$(ls -A "$SYSTEMD_UNIT_DIR")" ] && fail "disabled apply installed a connector unit" +[ -e "$ROADIE_CONFIG_DIR/channels.yaml" ] && fail "disabled apply touched channels.yaml" +ok "disabled apply is a no-op (no env file, no connector unit, no channels.yaml)" + +disabled_env="$(_roadie_append_env_files "$(_roadie_template_env /usr/bin)")" +grep -q 'ROADIE_PLATFORM' <<<"$disabled_env" && fail "disabled env block leaks ROADIE_PLATFORM" +grep -q 'wordpress-chat' <<<"$disabled_env" && fail "disabled env block leaks the wordpress-chat env file" +disabled_unit="$(bridge_render_systemd roadie.service "$disabled_env")" +grep -q 'ROADIE_PLATFORM' <<<"$disabled_unit" && fail "disabled Roadie unit carries ROADIE_PLATFORM" +grep -q 'wordpress-chat' <<<"$disabled_unit" && fail "disabled Roadie unit references the wordpress-chat env file" +ok "disabled Roadie render carries no wordpress-chat lines" + +# --------------------------------------------------------------------------- +# 2. Secret minted once, 0600, reused on upgrade (flag repeated or not) +# --------------------------------------------------------------------------- +WORDPRESS_CHAT_AGENT=intelligence +wordpress_chat_write_env +[ -f "$WORDPRESS_CHAT_ENV_FILE" ] || fail "env file was not written" +mode="$(python3 -c 'import os,sys; print(oct(os.stat(sys.argv[1]).st_mode & 0o777))' "$WORDPRESS_CHAT_ENV_FILE")" +[ "$mode" = "0o600" ] || fail "env file mode is $mode, expected 0600" +secret1="$(wordpress_chat_read_env_value WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET "$WORDPRESS_CHAT_ENV_FILE")" +[ -n "$secret1" ] || fail "no signing secret minted" +grep -q '^WP_CODING_AGENTS_INBOUND_WORDPRESS_ENABLED=1$' "$WORDPRESS_CHAT_ENV_FILE" || fail "env file missing ENABLED=1" +grep -q '^WP_CODING_AGENTS_INBOUND_WORDPRESS_ENDPOINT=http://127.0.0.1:3712/wordpress/events$' "$WORDPRESS_CHAT_ENV_FILE" || fail "env file missing endpoint" +grep -q '^WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET_ENV=WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET$' "$WORDPRESS_CHAT_ENV_FILE" || fail "env file missing secret env name" +grep -q '^WP_CODING_AGENTS_RUNTIME_ID=roadie$' "$WORDPRESS_CHAT_ENV_FILE" || fail "env file missing runtime id" +ok "env file minted with 0600 mode, secret, endpoint, and runtime id" + +# Upgrade pass with the flag repeated: secret unchanged. +wordpress_chat_write_env +[ "$(wordpress_chat_read_env_value WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET "$WORDPRESS_CHAT_ENV_FILE")" = "$secret1" ] \ + || fail "secret regenerated on upgrade with the flag" +# Upgrade pass WITHOUT the flag: env file is the durable record. +WORDPRESS_CHAT_AGENT="" +wordpress_chat_enabled || fail "env file does not re-enable activation on upgrade" +[ "$(wordpress_chat_agent_slug)" = intelligence ] || fail "agent slug not recovered from the env file" +[ "$(wordpress_chat_runtime_id)" = roadie ] || fail "runtime id not recovered from the env file" +wordpress_chat_write_env +[ "$(wordpress_chat_read_env_value WP_CODING_AGENTS_INBOUND_WORDPRESS_SIGNING_SECRET "$WORDPRESS_CHAT_ENV_FILE")" = "$secret1" ] \ + || fail "secret regenerated on upgrade without the flag" +ok "signing secret reused across upgrades, with and without the flag" + +# --------------------------------------------------------------------------- +# 3. Enabled render = disabled render + exactly the two wordpress-chat lines +# --------------------------------------------------------------------------- +enabled_env="$(_roadie_append_env_files "$(_roadie_template_env /usr/bin)")" +grep -qx 'Environment=ROADIE_PLATFORM=discord,wordpress' <<<"$enabled_env" \ + || fail "enabled env block lacks ROADIE_PLATFORM=discord,wordpress" +grep -qxF "EnvironmentFile=-$WORDPRESS_CHAT_ENV_FILE" <<<"$enabled_env" \ + || fail "enabled env block lacks EnvironmentFile for the shared secret" +stripped="$(grep -vx 'Environment=ROADIE_PLATFORM=discord,wordpress' <<<"$enabled_env" \ + | grep -vxF "EnvironmentFile=-$WORDPRESS_CHAT_ENV_FILE")" +diff <(printf '%s\n' "$disabled_env") <(printf '%s\n' "$stripped") >/dev/null \ + || fail "enabled env block differs from disabled beyond the two wordpress-chat lines" +enabled_unit="$(bridge_render_systemd roadie.service "$enabled_env")" +stripped_unit="$(grep -vx 'Environment=ROADIE_PLATFORM=discord,wordpress' <<<"$enabled_unit" \ + | grep -vxF "EnvironmentFile=-$WORDPRESS_CHAT_ENV_FILE")" +diff <(printf '%s\n' "$disabled_unit") <(printf '%s\n' "$stripped_unit") >/dev/null \ + || fail "enabled Roadie unit differs from disabled beyond the two wordpress-chat lines" +ok "enabled Roadie render adds exactly ROADIE_PLATFORM and the EnvironmentFile line" + +# launchd render inlines the platform list and the delivery env (incl. secret). +launchd_xml="$(_roadie_wordpress_chat_launchd_env_xml)" +grep -q 'ROADIE_PLATFORM' <<<"$launchd_xml" || fail "launchd env XML lacks ROADIE_PLATFORM" +grep -q 'discord,wordpress' <<<"$launchd_xml" || fail "launchd env XML lacks the platform list" +grep -qF "$secret1" <<<"$launchd_xml" || fail "launchd env XML does not inline the shared secret" +saved_agent="$WORDPRESS_CHAT_AGENT"; WORDPRESS_CHAT_AGENT="" +saved_env_file="$WORDPRESS_CHAT_ENV_FILE"; WORDPRESS_CHAT_ENV_FILE="$TMP/nonexistent.env" +[ -z "$(_roadie_wordpress_chat_launchd_env_xml)" ] || fail "launchd env XML rendered while disabled" +WORDPRESS_CHAT_AGENT="$saved_agent"; WORDPRESS_CHAT_ENV_FILE="$saved_env_file" +ok "launchd env XML inlines the shared secret only when enabled" + +# --------------------------------------------------------------------------- +# 4. runtime-chat adapter config: agent slug -> runtime ID +# --------------------------------------------------------------------------- +wordpress_chat_write_adapter_config +adapter_file="$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-runtime-chat-config.php" +[ -f "$adapter_file" ] || fail "adapter config mu-plugin not written" +grep -qF "\$agents['intelligence'] = 'roadie';" "$adapter_file" || fail "adapter config lacks the slug -> runtime mapping" +grep -qF "wp_coding_agents_inbound_event_adapter_config" "$adapter_file" || fail "adapter config does not use the adapter-config filter" +if command -v php >/dev/null 2>&1; then + php -l "$adapter_file" >/dev/null || fail "adapter config is not valid PHP" + resolved="$(php -r ' + define( "ABSPATH", "/tmp/" ); + $filters = array(); + function add_filter( $hook, $callback ) { global $filters; $filters[ $hook ][] = $callback; } + require $argv[1]; + $config = array( "runtime-chat" => array( "existing" => true ) ); + foreach ( $filters["wp_coding_agents_inbound_event_adapter_config"] as $callback ) { $config = $callback( $config ); } + echo $config["runtime-chat"]["agents"]["intelligence"] . "|" . var_export( $config["runtime-chat"]["existing"], true ); + ' "$adapter_file")" + [ "$resolved" = "roadie|true" ] || fail "adapter config filter resolved '$resolved', expected 'roadie|true'" +fi +ok "runtime-chat adapter config maps intelligence -> roadie through adapter_config()" + +# --------------------------------------------------------------------------- +# 5. channels.yaml: created, inserted (block + flow), idempotent, context-bound +# --------------------------------------------------------------------------- +channels="$ROADIE_CONFIG_DIR/channels.yaml" +wordpress_chat_ensure_channel +grep -qF '"wordpress:intelligence": {}' "$channels" || fail "missing channels.yaml was not created with the channel" +before="$(cat "$channels")" +wordpress_chat_ensure_channel +[ "$before" = "$(cat "$channels")" ] || fail "second ensure-channel pass rewrote channels.yaml" +ok "channels.yaml created with wordpress:intelligence and left alone when present" + +printf '# operator file\napplication:\n channel: "123"\nchannels:\n "123": {}\n' > "$channels" +wordpress_chat_ensure_channel +grep -qF '"wordpress:intelligence": {}' "$channels" || fail "channel not inserted into a block-style channels map" +grep -qF '"123": {}' "$channels" || fail "existing block-style entry lost" +grep -qF '# operator file' "$channels" || fail "operator comment lost" +printf 'channels: {"123": {}}\n' > "$channels" +wordpress_chat_ensure_channel +grep -qF '"wordpress:intelligence": {}, "123": {}' "$channels" || fail "channel not inserted into a flow-style channels map" +ok "channel inserted text-preservingly into block- and flow-style operator files" + +printf '{"version":1,"contexts":{"prod":{"agentSlug":"intelligence","sitePath":"%s"}}}\n' "$SITE_PATH" \ + > "$ROADIE_CONFIG_DIR/wordpress-context.json" +[ "$(wordpress_chat_context_key)" = prod ] || fail "context key not resolved from wordpress-context.json" +rm -f "$channels" +wordpress_chat_ensure_channel +grep -qF '"wordpress:prod": {}' "$channels" || fail "channel entry not keyed by the bound context" +rm -f "$ROADIE_CONFIG_DIR/wordpress-context.json" +ok "channel entry follows the wordpress-context.json binding for the agent" + +# --------------------------------------------------------------------------- +# 6. Connector: files + systemd unit only when enabled +# --------------------------------------------------------------------------- +wordpress_chat_install_connector_files +[ -x "$WORDPRESS_CHAT_CONNECTOR_DIR/inbound-event-connector" ] || fail "connector script not installed" +cmp -s "$WORDPRESS_CHAT_CONNECTOR_DIR/inbound-event-connector" "$SCRIPT_DIR/scripts/inbound-event-connector.py" \ + || fail "installed connector drifts from scripts/inbound-event-connector.py" +[ -x "$WORDPRESS_CHAT_CONNECTOR_DIR/wp-control" ] || fail "wp-control wrapper not installed" +grep -qF -- "--path=$SITE_PATH" "$WORDPRESS_CHAT_CONNECTOR_DIR/wp-control" || fail "wp-control wrapper not pinned to the site path" +ok "connector and wp-control wrapper installed next to each other" + +wordpress_chat_install_connector_service +unit_file="$SYSTEMD_UNIT_DIR/wp-coding-agents-inbound-connector.service" +[ -f "$unit_file" ] || fail "connector systemd unit not written" +grep -qxF "EnvironmentFile=$WORDPRESS_CHAT_ENV_FILE" "$unit_file" || fail "connector unit does not load the shared env file" +grep -qxF "ExecStart=/usr/bin/env python3 $WORDPRESS_CHAT_CONNECTOR_DIR/inbound-event-connector" "$unit_file" \ + || fail "connector unit ExecStart wrong" +grep -qxF "User=agent" "$unit_file" || fail "connector unit does not run as the service user" +grep -qF 'After=network.target roadie.service' "$unit_file" || fail "connector unit not ordered after Roadie" +grep -q 'enable wp-coding-agents-inbound-connector.service' "$TMP/systemctl-calls" || fail "connector unit not enabled" +ok "connector systemd unit present, env-file-backed, ordered next to Roadie" + +# Both processes read the same secret: the Roadie unit's EnvironmentFile and +# the connector unit's EnvironmentFile are the same path. +roadie_env_path="$(grep -ox "EnvironmentFile=-$WORDPRESS_CHAT_ENV_FILE" <<<"$enabled_env" | sed 's/^EnvironmentFile=-//')" +connector_env_path="$(sed -n 's/^EnvironmentFile=//p' "$unit_file")" +[ "$roadie_env_path" = "$connector_env_path" ] || fail "Roadie and connector read different env files" +ok "Roadie and connector share one signing-secret env file" + +# Named Roadie instance renders a suffixed unit name and env path default. +suffixed_unit="$(ROADIE_UNIT=roadie-extrachill.service wordpress_chat_connector_unit)" +[ "$suffixed_unit" = "wp-coding-agents-inbound-connector-extrachill.service" ] || fail "instance suffix not applied to connector unit" +default_env="$(WORDPRESS_CHAT_ENV_FILE= ROADIE_UNIT=roadie-extrachill.service wordpress_chat_env_file)" +[ "$default_env" = "$ROADIE_SECRETS_ROOT/roadie-extrachill/wordpress-chat.env" ] || fail "instance suffix not applied to the env file path" +ok "named Roadie instances get suffixed connector unit and env file paths" + +# launchd variant (local mac): plist written, env inlined, parseable. +LOCAL_MODE=true PLATFORM=mac wordpress_chat_install_connector_service +plist="$WORDPRESS_CHAT_LAUNCHD_DIR/com.wp.wp-coding-agents-inbound-connector.plist" +[ -f "$plist" ] || fail "connector launchd plist not written" +grep -qF "$secret1" "$plist" || fail "connector plist does not inline the shared secret" +python3 -c 'import plistlib,sys; plistlib.load(open(sys.argv[1],"rb"))' "$plist" || fail "connector plist is not valid" +grep -q 'bootstrap' "$TMP/launchctl-calls" || fail "connector plist not bootstrapped" +ok "connector launchd plist rendered with the inlined delivery env" + +echo "wordpress-chat-activation: $PASS checks passed" diff --git a/upgrade.sh b/upgrade.sh index c3cb1763..257d2ccb 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -59,7 +59,7 @@ TIMESTAMP="$(date +%Y%m%d-%H%M%S)" # Source shared modules (common, detect needed for environment resolution; # wordpress is needed for wp_cmd helper used by compose and plugin updates). -for lib in common grants detect install-source source-policy owned-source-discovery service-migration agent-state-ownership plugin-upgrade desired-state-reconciler convergence-orchestrator integration-adapters runtime-guidance-desired-state bridge-service-adapters wordpress data-machine carried-plugins wp-codebox homeboy ai-gateway skills cli-transport inbound-event-bridge cli-channel runtime-signature runtime-guard source-reconcile agents-md-guidance webroot-backup-hygiene opencode-subagents systems-capabilities self-upgrade codebox-database composer-provision; do +for lib in common grants detect install-source source-policy owned-source-discovery service-migration agent-state-ownership plugin-upgrade desired-state-reconciler convergence-orchestrator integration-adapters runtime-guidance-desired-state bridge-service-adapters wordpress data-machine carried-plugins wp-codebox homeboy ai-gateway skills cli-transport inbound-event-bridge wordpress-chat cli-channel runtime-signature runtime-guard source-reconcile agents-md-guidance webroot-backup-hygiene opencode-subagents systems-capabilities self-upgrade codebox-database composer-provision; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -173,6 +173,7 @@ while [[ $# -gt 0 ]]; do --ai-gateway-opencode-model) AI_GATEWAY_MODEL_ID="$2"; shift 2 ;; --ai-gateway-api-model) AI_GATEWAY_API_MODEL_ID="$2"; shift 2 ;; --rotate-ai-gateway-token) ROTATE_AI_GATEWAY_TOKEN=true; shift ;; + --wordpress-chat-agent) WORDPRESS_CHAT_AGENT="$2"; shift 2 ;; --source-mode|--posture) SOURCE_MODE="$2"; SOURCE_MODE_EXPLICIT=true; shift 2 ;; --workspace-repository) source_policy_begin_workspace_repository_declarations; source_policy_add_workspace_repository "$2"; shift 2 ;; --workspace-repository-clone) source_policy_begin_workspace_repository_declarations; source_policy_add_workspace_repository_clone "$2" "$3"; shift 3 ;; @@ -305,6 +306,16 @@ USAGE: opencode.json gains the same provider. Without the flag, Pi stays on Roadie's credential pool. + ./upgrade.sh --wordpress-chat-agent + Opt in to WordPress-native chat for one agent: + writes the runtime-chat adapter config (slug -> + runtime ID), mints/reuses a shared signing + secret in a 0600 env file, runs the + inbound-event-connector as a managed service + next to Roadie, adds wordpress to the Roadie + platform list, and ensures a wordpress: + channel entry. Once enabled, later upgrades + converge without repeating the flag. ./upgrade.sh --with-datamachine-worker Opt in to the managed bounded Data Machine worker. The choice persists across upgrades. @@ -813,6 +824,12 @@ update_ai_gateway() { ai_gateway_apply } +update_wordpress_chat() { + _run_filter_active reconciliation || return 0 + [ "${EXTERNAL_WORDPRESS:-false}" != true ] || return 0 + wordpress_chat_apply +} + # ============================================================================ # Phase 3: Sync chat-bridge config (Roadie: release, secrets, managed config) # ============================================================================ @@ -1550,6 +1567,12 @@ OPENCODE_PROJECTION_EXIT_STATUS=0 # not apply — and the restart hint must be withheld (#725). BRIDGE_RECONCILE_FAILED=false update_data_machine_plugins || PLUGIN_ONLY_EXIT_STATUS=$? +if [ "$PLUGINS_ONLY" != true ] && [ "$SKILLS_ONLY" != true ] && [ "${EXTERNAL_WORDPRESS:-false}" != true ]; then + # The shared WordPress chat secret must exist before the bridge renders + # reference its env file or inline its values (#779). No-op unless the + # activation flag was passed or a previous opt-in wrote the env file. + wordpress_chat_write_env +fi if [ "$PLUGINS_ONLY" != true ]; then CONVERGENCE_ENTRYPOINT="$SCRIPT_DIR/upgrade.sh" CONVERGENCE_REPLAY_ARGUMENTS="--wp-path $(printf '%q' "$SITE_PATH")" @@ -1570,6 +1593,7 @@ fi reconcile_provider_and_service_state sync_cli_transport_runtime update_ai_gateway +update_wordpress_chat sync_chat_bridge_config if _run_filter_active systemd; then systems_capabilities_apply