diff --git a/bridges/_dispatch.sh b/bridges/_dispatch.sh index c735050f..70edb30e 100644 --- a/bridges/_dispatch.sh +++ b/bridges/_dispatch.sh @@ -226,6 +226,28 @@ _ensure_systemd_path_contains() { ' <<< "$current_env" } +# _ensure_systemd_path_first +# Remove duplicate occurrences and make the managed directory the first PATH +# entry. Unlike _ensure_systemd_path_contains, precedence matters for binaries +# that also exist in legacy per-user bin directories. +_ensure_systemd_path_first() { + local current_env="$1" preferred_dir="$2" + [ -n "$preferred_dir" ] || { printf '%s\n' "$current_env"; return 0; } + awk -v dir="$preferred_dir" ' + /^Environment=PATH=/ { + value = substr($0, length("Environment=PATH=") + 1) + count = split(value, entries, ":") + path = dir + for (i = 1; i <= count; i++) { + if (entries[i] != "" && entries[i] != dir) path = path ":" entries[i] + } + print "Environment=PATH=" path + next + } + { print } + ' <<< "$current_env" +} + # _systemd_unit_user # # Print the User= value from an existing systemd unit. Empty output + diff --git a/bridges/kimaki.sh b/bridges/kimaki.sh index dcdfc5b1..1d6ac4fd 100644 --- a/bridges/kimaki.sh +++ b/bridges/kimaki.sh @@ -928,10 +928,15 @@ _kimaki_install_systemd() { KIMAKI_BIN=$(_kimaki_resolve_service_bin "/usr/bin/kimaki") - local KIMAKI_BIN_DIR NODE_BIN_DIR PATH_VALUE + local KIMAKI_BIN_DIR NODE_BIN_DIR HOMEBOY_BIN_DIR PATH_VALUE KIMAKI_BIN_DIR=$(dirname "$KIMAKI_BIN") NODE_BIN_DIR=$(_resolve_node_bin_dir "$KIMAKI_BIN") - PATH_VALUE=$(_compose_path_value "$KIMAKI_BIN_DIR" "$NODE_BIN_DIR" /usr/local/bin /usr/bin /bin) + HOMEBOY_BIN_DIR="" + if [ "${LOCAL_MODE:-false}" != true ] && [ "${EXTERNAL_WORDPRESS:-false}" != true ] \ + && [ -n "${SERVICE_USER:-}" ] && [ "$SERVICE_USER" != root ]; then + HOMEBOY_BIN_DIR="$(dirname "${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/lib/wp-coding-agents/bin/homeboy}")" + fi + PATH_VALUE=$(_compose_path_value "$HOMEBOY_BIN_DIR" "$KIMAKI_BIN_DIR" "$NODE_BIN_DIR" /usr/local/bin /usr/bin /bin) _kimaki_assert_bin_identity "$KIMAKI_BIN" "$PATH_VALUE" # Kimaki recreates a general-purpose #kimaki- channel, welcome message, # and tutorial thread on every start. On a wp-coding-agents install the real @@ -1310,11 +1315,17 @@ bridge_update_systemd() { local KIMAKI_BIN KIMAKI_BIN=$(_kimaki_resolve_service_bin "/usr/bin/kimaki") local KIMAKI_CONFIG_DIR="/opt/kimaki-config" - local KIMAKI_BIN_DIR NODE_BIN_DIR PATH_VALUE + local KIMAKI_BIN_DIR NODE_BIN_DIR HOMEBOY_BIN_DIR PATH_VALUE KIMAKI_BIN_DIR=$(dirname "$KIMAKI_BIN") NODE_BIN_DIR=$(_resolve_node_bin_dir "$KIMAKI_BIN") - PATH_VALUE=$(_compose_path_value "$KIMAKI_BIN_DIR" "$NODE_BIN_DIR" /usr/local/bin /usr/bin /bin) + HOMEBOY_BIN_DIR="" + if [ "${LOCAL_MODE:-false}" != true ] && [ "${EXTERNAL_WORDPRESS:-false}" != true ] \ + && [ -n "${SERVICE_USER:-}" ] && [ "$SERVICE_USER" != root ]; then + HOMEBOY_BIN_DIR="$(dirname "${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/lib/wp-coding-agents/bin/homeboy}")" + fi + PATH_VALUE=$(_compose_path_value "$HOMEBOY_BIN_DIR" "$KIMAKI_BIN_DIR" "$NODE_BIN_DIR" /usr/local/bin /usr/bin /bin) _kimaki_assert_bin_identity "$KIMAKI_BIN" "$PATH_VALUE" + CURRENT_ENV=$(_ensure_systemd_path_first "$CURRENT_ENV" "$HOMEBOY_BIN_DIR") CURRENT_ENV=$(_ensure_systemd_path_contains "$CURRENT_ENV" "$KIMAKI_BIN_DIR") if [ -n "$NODE_BIN_DIR" ]; then CURRENT_ENV=$(_ensure_systemd_path_contains "$CURRENT_ENV" "$NODE_BIN_DIR") diff --git a/guidance/homeboy.sh b/guidance/homeboy.sh index 19cea2eb..8233ec20 100644 --- a/guidance/homeboy.sh +++ b/guidance/homeboy.sh @@ -39,7 +39,7 @@ guidance_freshness() { printf 'live'; } # Overridable so tests never touch the real path, and so a host whose # homeboy-upgrade helper installs somewhere else can still be recognized. _guidance_homeboy_managed_bin_path() { - printf '%s' "${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/bin/homeboy}" + printf '%s' "${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/lib/wp-coding-agents/bin/homeboy}" } _guidance_homeboy_service_bin_path() { @@ -47,16 +47,15 @@ _guidance_homeboy_service_bin_path() { [ "${EXTERNAL_WORDPRESS:-false}" != true ] || return 0 [ -n "${SERVICE_USER:-}" ] && [ "$SERVICE_USER" != root ] || return 0 [ -n "${SERVICE_HOME:-}" ] || return 0 - printf '%s/.local/bin/homeboy' "$SERVICE_HOME" + _guidance_homeboy_managed_bin_path } # _guidance_homeboy_bin_candidates — ordered, de-duplicated candidate paths. # # 1. WP_CODING_AGENTS_HOMEBOY_BIN — explicit sync-time override, for an # operator with a nonstandard install. -# 2. The managed system install location (see above): root-owned and -# world-executable by convention, stable across every identity on the -# box, unlike a per-user PATH entry. +# 2. The managed shared install location (see above), reachable to the +# composing web identity and writable by the service identity. # 3. `type -P homeboy` — the syncing user's PATH (the original #575 # probe), kept as a last resort for hosts that run homeboy from # somewhere else but still keep it web-reachable. diff --git a/lib/homeboy.sh b/lib/homeboy.sh index 141ee32f..bd930a8d 100644 --- a/lib/homeboy.sh +++ b/lib/homeboy.sh @@ -21,7 +21,61 @@ homeboy_uses_service_owned_bin() { } homeboy_service_bin() { - printf '%s/.local/bin/homeboy' "$SERVICE_HOME" + printf '%s' "${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/lib/wp-coding-agents/bin/homeboy}" +} + +# Provisioning may mutate only the dedicated wp-coding-agents prefix. In +# particular, a full-path override must never turn SERVICE_HOME/.local or an +# arbitrary operator-owned directory into a root-chowned prefix. +homeboy_path_has_no_symlink_components() { + local path="$1" current="" component + [[ "$path" = /* ]] || return 1 + local -a components=() + IFS='/' read -r -a components <<< "${path#/}" + for component in "${components[@]}"; do + [ -n "$component" ] || continue + current="$current/$component" + [ ! -L "$current" ] || return 1 + done +} + +homeboy_managed_prefix_safe() { + local target="$1" service_home target_dir prefix parent owner mode mode_value + [[ "$target" = /* ]] || return 1 + target="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$target")" || return 1 + homeboy_path_has_no_symlink_components "$target" || return 1 + [ "$(basename "$target")" = homeboy ] || return 1 + target_dir="$(dirname "$target")" + [ "$(basename "$target_dir")" = bin ] || return 1 + prefix="$(dirname "$target_dir")" + [ "$(basename "$prefix")" = wp-coding-agents ] || return 1 + [ ! -L "$target" ] && [ ! -L "$target_dir" ] && [ ! -L "$prefix" ] || return 1 + + service_home="${SERVICE_HOME:-}" + if [ -n "$service_home" ]; then + service_home="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$service_home")" || return 1 + case "$target" in "$service_home"/*) return 1 ;; esac + fi + + parent="$(dirname "$prefix")" + [ -d "$parent" ] || return 1 + owner="$(file_owner "$parent" 2>/dev/null)" || return 1 + [ "$owner" = root ] || return 1 + mode="$(file_mode "$parent" 2>/dev/null)" || return 1 + mode_value="$(python3 -c 'import sys; print(int(sys.argv[1], 8))' "$mode" 2>/dev/null)" || return 1 + if (( mode_value & 0022 )); then + # A root-owned sticky parent such as /tmp is safe for a root-created child; + # writable non-sticky parents are not trusted for managed prefix creation. + (( mode_value & 01000 )) || return 1 + fi + if [ -e "$prefix" ]; then + [ -d "$prefix" ] || return 1 + owner="$(file_owner "$prefix" 2>/dev/null)" || return 1 + [ "$owner" = root ] || return 1 + mode="$(file_mode "$prefix" 2>/dev/null)" || return 1 + mode_value="$(python3 -c 'import sys; print(int(sys.argv[1], 8))' "$mode" 2>/dev/null)" || return 1 + (( (mode_value & 0022) == 0 )) || return 1 + fi } homeboy_system_bin() { @@ -34,26 +88,84 @@ homeboy_system_bin() { command -v homeboy 2>/dev/null || true } +homeboy_service_bin_ready() { + local target target_dir target_owner dir_owner dir_mode dir_mode_value + target="$(homeboy_service_bin)" + target_dir="$(dirname "$target")" + [ -f "$target" ] && [ ! -L "$target" ] && [ -x "$target" ] || return 1 + target_owner="$(file_owner "$target" 2>/dev/null)" || return 1 + [ "$target_owner" = "$SERVICE_USER" ] || return 1 + dir_owner="$(file_owner "$target_dir" 2>/dev/null)" || return 1 + [ "$dir_owner" = "$SERVICE_USER" ] || return 1 + dir_mode="$(file_mode "$target_dir" 2>/dev/null)" || return 1 + dir_mode_value="$(python3 -c 'import sys; print(int(sys.argv[1], 8))' "$dir_mode" 2>/dev/null)" || return 1 + (( (dir_mode_value & 0300) == 0300 )) +} + homeboy_bin() { - if homeboy_uses_service_owned_bin && [ -x "$(homeboy_service_bin)" ]; then + if homeboy_uses_service_owned_bin && homeboy_service_bin_ready; then homeboy_service_bin else homeboy_system_bin fi } +homeboy_service_install_managed_binary() { + local target_dir="$1" target="$2" seed="$3" target_mode="$4" target_owner="$5" + local service_script='set -eu +directory=$1 target=$2 seed=$3 target_mode=$4 +service_user=$5 target_owner=$6 +chmod 0755 "$directory" +if [ -f "$target" ] && [ ! -L "$target" ] && [ -x "$target" ] && [ "$target_mode" = 755 ] && [ "$target_owner" = "$service_user" ]; then + exit 0 +fi +source=$seed +if [ -f "$target" ] && [ ! -L "$target" ] && [ -x "$target" ] && [ "$target_owner" != "$service_user" ]; then + [ -r "$target" ] || { echo "Existing Homeboy is not readable by $service_user: $target" >&2; exit 1; } + source=$target +elif [ -f "$target" ] && [ ! -L "$target" ] && [ -x "$target" ] && [ -r "$target" ] && [ "$target_mode" != 755 ]; then + source=$target +fi +[ -n "$source" ] && [ -r "$source" ] || exit 0 +temporary=$(mktemp "$directory/.homeboy.XXXXXX") +trap '\''rm -f "$temporary"'\'' EXIT +install -m 0755 "$source" "$temporary" +mv -f "$temporary" "$target" +' + local service_path="$target_dir:$PATH" + + if [ "$(id -u)" -eq 0 ]; then + command -v sudo >/dev/null 2>&1 || error "Cannot converge Homeboy as '$SERVICE_USER': sudo is unavailable." + sudo -n -H -u "$SERVICE_USER" env HOME="$SERVICE_HOME" PATH="$service_path" \ + /bin/bash -c "$service_script" homeboy-service-install "$target_dir" "$target" "$seed" "$target_mode" "$SERVICE_USER" "$target_owner" + else + [ "$(id -un)" = "$SERVICE_USER" ] || error "Cannot converge Homeboy as $(id -un): expected '$SERVICE_USER'." + HOME="$SERVICE_HOME" PATH="$service_path" /bin/bash -c "$service_script" homeboy-service-install "$target_dir" "$target" "$seed" "$target_mode" "$SERVICE_USER" "$target_owner" + fi +} + homeboy_provision_service_bin() { homeboy_uses_service_owned_bin || return 0 - local target source target_dir local_dir group + local target source target_dir managed_prefix owner group target_mode target_owner prefix_mode target="$(homeboy_service_bin)" target_dir="$(dirname "$target")" - local_dir="$(dirname "$target_dir")" - source="$(homeboy_system_bin)" + managed_prefix="$(dirname "$target_dir")" + homeboy_managed_prefix_safe "$target" || error "Refusing unsafe managed Homeboy path '$target'; expected a non-symlinked bin/homeboy under a root-owned wp-coding-agents prefix outside SERVICE_HOME." + + if [ -e "$managed_prefix" ]; then + prefix_mode="$(file_mode "$managed_prefix" 2>/dev/null || true)" + [ "$prefix_mode" = 755 ] || error "Refusing existing managed Homeboy prefix '$managed_prefix' with mode ${prefix_mode:-unknown}; inspect its contents and have an administrator set this dedicated prefix to root:root 0755 before retrying." + fi + + source="" + local legacy="$SERVICE_HOME/.local/bin/homeboy" + if [ -x "$legacy" ] && [ "$legacy" != "$target" ]; then source="$legacy"; else source="$(homeboy_system_bin)"; fi + [ -n "$source" ] || [ -x "$target" ] || return 0 if [ "${DRY_RUN:-false}" = true ]; then [ -x "$target" ] || [ -n "$source" ] || return 0 - [ -x "$target" ] || echo -e "${BLUE}[dry-run]${NC} install -D -m 0755 '$source' '$target' as service-owned Homeboy" - echo -e "${BLUE}[dry-run]${NC} chown '$SERVICE_USER' '$local_dir' '$target_dir' '$target'" + [ -x "$target" ] || echo -e "${BLUE}[dry-run]${NC} seed managed Homeboy from '$source' into '$target' as '$SERVICE_USER'" + echo -e "${BLUE}[dry-run]${NC} validate/create root-owned prefix '$managed_prefix'; create or hand off '$target_dir' to '$SERVICE_USER'" return 0 fi @@ -61,18 +173,42 @@ homeboy_provision_service_bin() { error "Cannot provision service-owned Homeboy as $(id -un): expected '$SERVICE_USER' or root." fi - if [ ! -x "$target" ]; then - [ -n "$source" ] || return 0 - [ "$source" != "$target" ] || return 0 - run_cmd mkdir -p "$target_dir" - run_cmd install -m 0755 "$source" "$target" - fi - if [ "$(id -u)" -eq 0 ] && id -u "$SERVICE_USER" >/dev/null 2>&1; then + if [ "$(id -u)" -eq 0 ]; then + if [ ! -e "$managed_prefix" ]; then + run_cmd mkdir -m 0700 "$managed_prefix" || error "Could not exclusively create fresh managed Homeboy prefix '$managed_prefix'; inspect the path and retry." + owner="$(file_owner "$managed_prefix" 2>/dev/null || true)" + [ "$owner" = root ] || error "New managed Homeboy prefix is not root-owned: $managed_prefix" + run_cmd chmod 0700 "$managed_prefix" + [ "$(file_mode "$managed_prefix" 2>/dev/null || true)" = 700 ] || error "Fresh managed Homeboy prefix did not retain restrictive creation mode before setup: $managed_prefix" + run_cmd chmod 0755 "$managed_prefix" + fi + + if [ ! -e "$target_dir" ]; then + run_cmd mkdir -m 0700 "$target_dir" + owner="root" + else + [ -d "$target_dir" ] && [ ! -L "$target_dir" ] || error "Managed Homeboy bin is not a real directory: $target_dir" + owner="$(file_owner "$target_dir" 2>/dev/null || true)" + fi group="$(id -gn "$SERVICE_USER" 2>/dev/null || printf '%s' "$SERVICE_USER")" - # Converge only this managed path, never the wider SERVICE_HOME tree. Both - # parents need service ownership for subsequent atomic binary upgrades. - run_cmd chown "$SERVICE_USER:$group" "$local_dir" "$target_dir" "$target" - fi + case "$owner" in + "$SERVICE_USER") ;; + root) run_cmd chown "$SERVICE_USER:$group" "$target_dir" ;; + *) error "Managed Homeboy bin has unexpected owner '$owner': $target_dir" ;; + esac + else + [ -d "$managed_prefix" ] && [ -d "$target_dir" ] || error "Managed Homeboy prefix/bin must be provisioned by root before service-user setup." + [ "$(file_mode "$managed_prefix" 2>/dev/null || true)" = 755 ] || error "Managed Homeboy prefix is not mode 755: $managed_prefix" + owner="$(file_owner "$target_dir" 2>/dev/null || true)" + [ "$owner" = "$SERVICE_USER" ] || error "Managed Homeboy bin is not service-owned: $target_dir" + fi + + [ -n "$source" ] || source="$target" + target_mode="$(file_mode "$target" 2>/dev/null || true)" + target_owner="$(file_owner "$target" 2>/dev/null || true)" + homeboy_service_install_managed_binary "$target_dir" "$target" "$source" "$target_mode" "$target_owner" || error "Could not install or repair managed Homeboy as '$SERVICE_USER': $target" + [ "$(file_owner "$target" 2>/dev/null || true)" = "$SERVICE_USER" ] || error "Managed Homeboy is not owned by '$SERVICE_USER' after provisioning: $target" + homeboy_service_bin_ready || error "Managed Homeboy is not executable or its service-owned bin is not replaceable by '$SERVICE_USER': $target" log "Provisioned service-owned Homeboy: $target" } diff --git a/tests/__snapshots__/bridges/kimaki-systemd b/tests/__snapshots__/bridges/kimaki-systemd index 44f30e77..f23ef030 100644 --- a/tests/__snapshots__/bridges/kimaki-systemd +++ b/tests/__snapshots__/bridges/kimaki-systemd @@ -7,7 +7,7 @@ Type=simple User=chubes WorkingDirectory=/var/www/site Environment=HOME=/home/chubes -Environment=PATH=/usr/bin:/usr/local/bin:/bin +Environment=PATH=/usr/local/lib/wp-coding-agents/bin:/usr/bin:/usr/local/bin:/bin Environment=KIMAKI_DATA_DIR=/home/chubes/.kimaki Environment=DATAMACHINE_SITE_PATH=/var/www/site Environment=DATAMACHINE_WP_TRANSPORT_JSON="[\"wp\"]" diff --git a/tests/bridge-render.sh b/tests/bridge-render.sh index 699aa8dd..1db7ea14 100755 --- a/tests/bridge-render.sh +++ b/tests/bridge-render.sh @@ -98,10 +98,14 @@ if echo "$REDACTED_DIFF" | grep -q 'secret-token'; then fi kimaki_env_block() { - local kimaki_bin_dir node_bin_dir path_value + local kimaki_bin_dir node_bin_dir homeboy_bin_dir path_value kimaki_bin_dir=$(dirname "$KIMAKI_BIN") node_bin_dir=$(_resolve_node_bin_dir "$KIMAKI_BIN") - path_value=$(_compose_path_value "$kimaki_bin_dir" "$node_bin_dir" /usr/local/bin /usr/bin /bin) + homeboy_bin_dir="" + if [ "$LOCAL_MODE" != true ] && [ "$SERVICE_USER" != root ]; then + homeboy_bin_dir=$(dirname "${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/lib/wp-coding-agents/bin/homeboy}") + fi + path_value=$(_compose_path_value "$homeboy_bin_dir" "$kimaki_bin_dir" "$node_bin_dir" /usr/local/bin /usr/bin /bin) local transport_json transport_json=$(wp_cli_transport_json) transport_json=${transport_json//\\/\\\\} @@ -155,6 +159,11 @@ echo "==> rendering snapshots" # systemd --------------------------------------------------------------- render_with_bridge kimaki render_systemd kimaki.service "$(kimaki_env_block)" > "$TMPDIR_NEW/kimaki-systemd" +if ! grep -Fq 'Environment=PATH=/usr/local/lib/wp-coding-agents/bin:/usr/bin:/usr/local/bin:/bin' "$TMPDIR_NEW/kimaki-systemd"; then + echo "FAIL: Kimaki systemd PATH does not include managed Homeboy directory" + exit 1 +fi + render_with_bridge cc-connect render_systemd cc-connect.service "$(cc_connect_env_block)" > "$TMPDIR_NEW/cc-connect-systemd" render_with_bridge telegram render_systemd opencode-serve.service "$(telegram_env_block)" > "$TMPDIR_NEW/telegram-serve-systemd" render_with_bridge telegram render_systemd opencode-telegram.service "$(telegram_env_block)" > "$TMPDIR_NEW/telegram-bot-systemd" @@ -233,6 +242,39 @@ fi echo echo "OK: all snapshots match" +# A real command lookup must pick managed Homeboy ahead of the legacy service +# home bin that also contains a `homeboy` executable. +mkdir -p "$TMPDIR_NEW/managed" "$TMPDIR_NEW/legacy" +PLATFORM="linux" +LOCAL_MODE=false +SERVICE_USER="chubes" +export PLATFORM LOCAL_MODE SERVICE_USER +printf '#!/bin/sh\nexit 0\n' > "$TMPDIR_NEW/managed/homeboy" +printf '#!/bin/sh\nexit 0\n' > "$TMPDIR_NEW/legacy/homeboy" +chmod 0755 "$TMPDIR_NEW/managed/homeboy" "$TMPDIR_NEW/legacy/homeboy" +export WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN="$TMPDIR_NEW/managed/homeboy" +KIMAKI_BIN="$TMPDIR_NEW/legacy/kimaki" +KIMAKI_DATA_DIR="$SERVICE_HOME/.kimaki" +KIMAKI_CONFIG_DIR="/opt/kimaki-config" +KIMAKI_ENV="$(kimaki_env_block)" +KIMAKI_RENDERED="$(render_with_bridge kimaki render_systemd kimaki.service "$KIMAKI_ENV")" +RENDERED_PATH="$(printf '%s\n' "$KIMAKI_RENDERED" | sed -n 's/^Environment=PATH=//p' | sed -n '1p')" +RESOLVED_HOMEBOY="$(env PATH="$RENDERED_PATH" /bin/sh -c 'command -v homeboy')" +if [ "$RESOLVED_HOMEBOY" != "$TMPDIR_NEW/managed/homeboy" ]; then + echo "FAIL: Kimaki systemd PATH '$RENDERED_PATH' resolves $RESOLVED_HOMEBOY instead of managed Homeboy" + exit 1 +fi +echo " ok Kimaki PATH resolves the managed binary ahead of the legacy home bin" +UPGRADE_ENV="$(_ensure_systemd_path_first "Environment=PATH=$TMPDIR_NEW/legacy:$TMPDIR_NEW/managed:/usr/bin:$TMPDIR_NEW/legacy" "$TMPDIR_NEW/managed")" +UPGRADE_PATH="$(printf '%s\n' "$UPGRADE_ENV" | sed -n 's/^Environment=PATH=//p' | sed -n '1p')" +RESOLVED_HOMEBOY="$(env PATH="$UPGRADE_PATH" /bin/sh -c 'command -v homeboy')" +if [ "$RESOLVED_HOMEBOY" != "$TMPDIR_NEW/managed/homeboy" ] || [ "${UPGRADE_PATH%%:*}" != "$TMPDIR_NEW/managed" ]; then + echo "FAIL: upgraded Kimaki PATH '$UPGRADE_PATH' resolves $RESOLVED_HOMEBOY instead of managed Homeboy" + exit 1 +fi +echo " ok upgrade moves managed Homeboy ahead of legacy PATH entries" +unset WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN + # --------------------------------------------------------------------------- echo "==> effective-prompt runner resolution" # --------------------------------------------------------------------------- diff --git a/tests/homeboy-service-identity.sh b/tests/homeboy-service-identity.sh index 910e69ec..b75e307b 100644 --- a/tests/homeboy-service-identity.sh +++ b/tests/homeboy-service-identity.sh @@ -4,11 +4,30 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" TMP="$(mktemp -d)" +TMP="$(cd "$TMP" && pwd -P)" trap 'rm -rf "$TMP"' EXIT source "$ROOT/lib/common.sh" source "$ROOT/lib/homeboy.sh" source "$ROOT/guidance/homeboy.sh" +eval "$(declare -f file_owner | sed '1s/file_owner/_homeboy_test_real_file_owner/')" +MOCK_ROOT_PREFIX_OWNER=true +MOCK_TARGET_OWNER="" +file_owner() { + case "$1" in + "$TMP"|"$TMP/wp-coding-agents"|"$TMP/untrusted-system"|"$TMP/unsafe-mode-parent") + if [ "$MOCK_ROOT_PREFIX_OWNER" = true ]; then printf 'root\n'; else _homeboy_test_real_file_owner "$@"; fi + ;; + "$TMP/restrict-parent"|"$TMP/restrict-parent/wp-coding-agents") printf 'root\n' ;; + "$TMP/wp-coding-agents/bin") + if [ "$MOCK_ROOT_PREFIX_OWNER" = true ]; then printf '%s\n' "$SERVICE_USER"; else _homeboy_test_real_file_owner "$@"; fi + ;; + "$WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN") + if [ -n "$MOCK_TARGET_OWNER" ]; then printf '%s\n' "$MOCK_TARGET_OWNER"; else _homeboy_test_real_file_owner "$@"; fi + ;; + *) _homeboy_test_real_file_owner "$@" ;; + esac +} PASS=0 fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } @@ -16,6 +35,9 @@ ok() { printf ' ok %s\n' "$1"; PASS=$((PASS + 1)); } SERVICE_HOME="$TMP/service" SERVICE_USER="$(command id -un)" +[ "$SERVICE_USER" != root ] || SERVICE_USER="opencode" +WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN="$TMP/wp-coding-agents/bin/homeboy" +export WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN LOCAL_MODE=false EXTERNAL_WORDPRESS=false DRY_RUN=false @@ -24,6 +46,25 @@ export WP_CODING_AGENTS_HOMEBOY_SYSTEM_BIN="$SYSTEM_BIN" mkdir -p "$TMP" printf '#!/bin/sh\nprintf service\n' > "$SYSTEM_BIN" chmod 0755 "$SYSTEM_BIN" +UNSAFE_HOME_BIN="$SERVICE_HOME/.local/bin/homeboy" +mkdir -p "$(dirname "$UNSAFE_HOME_BIN")" +if homeboy_managed_prefix_safe "$UNSAFE_HOME_BIN"; then fail "accepted managed Homeboy inside SERVICE_HOME"; fi +mkdir -p "$TMP/private/wp-coding-agents/bin" +if homeboy_managed_prefix_safe "$TMP/private/wp-coding-agents/bin/homeboy"; then fail "accepted a prefix below an untrusted user-owned parent"; fi +mkdir -p "$TMP/untrusted-system/wp-coding-agents/bin" +if [ "$(command id -u)" -eq 0 ]; then + NONROOT_OWNER="nobody" + command id -u "$NONROOT_OWNER" >/dev/null 2>&1 || NONROOT_OWNER="daemon" + command chown "$NONROOT_OWNER" "$TMP/untrusted-system/wp-coding-agents" +fi +if homeboy_managed_prefix_safe "$TMP/untrusted-system/wp-coding-agents/bin/homeboy"; then fail "accepted an existing user-owned managed prefix"; fi +mkdir -p "$TMP/unsafe-mode-parent/wp-coding-agents/bin" +chmod 0777 "$TMP/unsafe-mode-parent/wp-coding-agents" +if homeboy_managed_prefix_safe "$TMP/unsafe-mode-parent/wp-coding-agents/bin/homeboy"; then fail "accepted a writable managed prefix"; fi +mkdir -p "$TMP/symlink-prefix" +ln -s "$TMP/wp-coding-agents" "$TMP/symlink-prefix/wp-coding-agents" +if homeboy_managed_prefix_safe "$TMP/symlink-prefix/wp-coding-agents/bin/homeboy"; then fail "accepted a symlinked managed prefix"; fi +ok "unsafe SERVICE_HOME, untrusted ownership/modes, and symlinked paths are rejected before mutation" # Keep the fixture independent of the account running the test. id() { @@ -37,41 +78,138 @@ id() { install() { cp "$3" "$4"; chmod 0755 "$4"; } CHOWN_ARGS="" CHOWN_CALLS=0 -chown() { CHOWN_ARGS="$*"; CHOWN_CALLS=$((CHOWN_CALLS + 1)); :; } -run_cmd() { "$@"; } +CHOWN_LOG="" +ROOT_MUTATION_LOG="" +SUDO_LOG="" +chown() { + CHOWN_ARGS="$*" + CHOWN_LOG+="$*"$'\n' + CHOWN_CALLS=$((CHOWN_CALLS + 1)) + if [ "$(command id -u)" -eq 0 ]; then + case "$1" in + root:root) command chown "$@" ;; + *) command id -u "${1%%:*}" >/dev/null 2>&1 && command chown "$@" || true ;; + esac + fi +} +run_cmd() { + ROOT_MUTATION_LOG+="$*"$'\n' + "$@" +} +sudo() { + SUDO_LOG+="$*"$'\n' + while [ "$#" -gt 0 ] && [ "$1" != env ]; do shift; done + [ "${1:-}" = env ] || return 1 + shift + while [ "$#" -gt 0 ] && [[ "$1" == *=* ]]; do shift; done + "$@" + local status=$? + MOCK_TARGET_OWNER="$SERVICE_USER" + return "$status" +} log() { :; } error() { fail "$*"; } +RESTRICTED_PREFIX="$TMP/restrict-parent/wp-coding-agents" +mkdir -p "$RESTRICTED_PREFIX" +chmod 0700 "$RESTRICTED_PREFIX" +RESTRICTED_MESSAGE="$( (WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN="$RESTRICTED_PREFIX/bin/homeboy"; export WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN; homeboy_provision_service_bin) 2>&1 || true)" +case "$RESTRICTED_MESSAGE" in *"$RESTRICTED_PREFIX"*"inspect its contents"*"root:root 0755"*) ;; *) fail "restrictive existing prefix did not fail with exact remediation: $RESTRICTED_MESSAGE" ;; esac +[ ! -e "$RESTRICTED_PREFIX/bin" ] || fail "restrictive existing prefix was mutated" +ok "existing restrictive prefix is refused without chmod or child creation" + +mkdir -p "$SERVICE_HOME/.local/bin" +printf '#!/bin/sh\nprintf legacy\n' > "$SERVICE_HOME/.local/bin/homeboy" +chmod 0755 "$SERVICE_HOME/.local/bin/homeboy" +OLD_UMASK="$(umask)" +umask 077 homeboy_provision_service_bin -TARGET="$SERVICE_HOME/.local/bin/homeboy" -[ -x "$TARGET" ] || fail "system Homeboy was not copied to SERVICE_HOME/.local/bin" -[ -d "$SERVICE_HOME/.local/bin" ] || fail "service-owned bin parent was not created" -[ -d "$SERVICE_HOME/.local" ] || fail "service-owned .local parent was not created" -case " $CHOWN_ARGS " in *" $SERVICE_USER:$SERVICE_USER $SERVICE_HOME/.local $SERVICE_HOME/.local/bin $TARGET"*) ;; *) fail "ownership convergence missed owner, parent, bin, or executable: $CHOWN_ARGS" ;; esac -[ "$CHOWN_CALLS" -eq 1 ] || fail "new installation did not converge ownership" +umask "$OLD_UMASK" +TARGET="$WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN" +[ -x "$TARGET" ] || fail "service Homeboy was not seeded into managed bin" +[ -d "$(dirname "$TARGET")" ] || fail "managed bin parent was not created" +case " $ROOT_MUTATION_LOG " in *"mkdir -m 0700 $(dirname "$(dirname "$TARGET")")"*) ;; *) fail "root did not create the managed prefix before handoff: $ROOT_MUTATION_LOG" ;; esac +case " $CHOWN_LOG " in *"$SERVICE_USER:$SERVICE_USER $(dirname "$TARGET")"*) ;; *) fail "ownership convergence missed the service-owned bin: $CHOWN_LOG" ;; esac +[ "$CHOWN_CALLS" -eq 1 ] || fail "new installation did not hand off only the managed bin directory" +case "$ROOT_MUTATION_LOG" in *"$TARGET"*) fail "root mutation reached the service-writable executable: $ROOT_MUTATION_LOG" ;; esac +case "$ROOT_MUTATION_LOG" in *"$(dirname "$TARGET")"*) ;; *) fail "root did not establish ownership of the managed bin before handoff" ;; esac +case "$SUDO_LOG" in *"-u $SERVICE_USER env HOME=$SERVICE_HOME"*"/bin/bash -c"*) ;; *) fail "binary install/permission convergence did not run through the service identity: $SUDO_LOG" ;; esac +[ "$(file_mode "$(dirname "$(dirname "$TARGET")")")" = 755 ] || fail "freshly created managed prefix is not traversable after umask 077" +if [ "$(command id -u)" -eq 0 ]; then + [ "$(_homeboy_test_real_file_owner "$(dirname "$(dirname "$TARGET")")")" = root ] || fail "managed prefix ancestor is not root-owned" +fi +[ "$(cat "$TARGET")" = "$(cat "$SERVICE_HOME/.local/bin/homeboy")" ] || fail "legacy service binary was not preferred as seed" +[ "$(file_mode "$(dirname "$TARGET")")" = 755 ] || fail "managed bin is not world-traversable" +[ "$(file_mode "$TARGET")" = 755 ] || fail "managed binary mode is not 755" +if [ "$(command id -u)" -eq 0 ] && command id -u "$SERVICE_USER" >/dev/null 2>&1; then + su -s /bin/sh "$SERVICE_USER" -c "test -w '$(dirname "$TARGET")'" 2>/dev/null || fail "service user cannot replace the managed executable" +else + [ -w "$(dirname "$TARGET")" ] || fail "service user cannot replace the managed executable" +fi [ "$(homeboy_bin)" = "$TARGET" ] || fail "service-owned Homeboy was not preferred" -ok "new install assigns service ownership to .local, bin, and executable" +ok "new install seeds a shared executable directory with bounded ownership" -[ "$(_guidance_homeboy_service_bin_path)" = "$TARGET" ] || fail "guidance did not resolve SERVICE_HOME/.local/bin" +[ "$(_guidance_homeboy_service_bin_path)" = "$TARGET" ] || fail "guidance did not resolve managed bin" +COMPOSE_USER="www-data" +if [ "$COMPOSE_USER" = "$SERVICE_USER" ]; then COMPOSE_USER="nobody"; fi +if command -v getent >/dev/null 2>&1 && ! getent passwd "$COMPOSE_USER" >/dev/null; then COMPOSE_USER="nobody"; fi +if [ "$(uname -s)" = Linux ]; then + chmod o+x "$TMP" +else + COMPOSE_USER="$(command id -un)" +fi +unset -f id +MOCK_ROOT_PREFIX_OWNER=false +WP_CODING_AGENTS_COMPOSE_USER="$COMPOSE_USER" +export WP_CODING_AGENTS_COMPOSE_USER +_guidance_homeboy_reachable_by_compose "$TARGET" || fail "compose identity cannot reach managed Homeboy" +id() { + case "${1:-}" in + -u) [ "${2:-}" = "$SERVICE_USER" ] && printf '1001' || printf '0' ;; + -gn) printf '%s' "$SERVICE_USER" ;; + -un) printf 'root' ;; + *) printf '0' ;; + esac +} +MOCK_ROOT_PREFIX_OWNER=true first_guidance_candidate="" while IFS= read -r first_guidance_candidate; do break; done < <(_guidance_homeboy_bin_candidates) [ "$first_guidance_candidate" = "$TARGET" ] || fail "guidance did not prefer the service-owned binary" ok "dynamic guidance follows the service-owned binary" -printf 'newer-service-copy\n' > "$TARGET" +printf 'older-root-system-copy\n' > "$SYSTEM_BIN" +printf 'current-root-owned-managed-copy\n' > "$TARGET" chmod 0755 "$TARGET" +MOCK_TARGET_OWNER=root +ROOT_MUTATION_LOG="" +homeboy_provision_service_bin +[ "$(cat "$TARGET")" = current-root-owned-managed-copy ] || fail "root-owned current binary was replaced with an older seed" +[ "$(file_owner "$TARGET")" = "$SERVICE_USER" ] || fail "existing root-owned executable was not atomically taken into service ownership" +case "$ROOT_MUTATION_LOG" in *"$TARGET"*|*"$(dirname "$TARGET")"*) fail "root mutated the existing service bin/executable: $ROOT_MUTATION_LOG" ;; esac +ok "root-owned mode-755 binary is atomically replaced as the service user with identical content" + +printf 'newer-managed-copy\n' > "$TARGET" +chmod 0755 "$TARGET" +MOCK_TARGET_OWNER="$SERVICE_USER" CHOWN_CALLS=0 +ROOT_MUTATION_LOG="" homeboy_provision_service_bin -[ "$(cat "$TARGET")" = newer-service-copy ] || fail "newer service-owned copy was overwritten" -case " $CHOWN_ARGS " in *" $SERVICE_USER:$SERVICE_USER $SERVICE_HOME/.local $SERVICE_HOME/.local/bin $TARGET"*) ;; *) fail "existing executable ownership was not repaired" ;; esac -[ "$CHOWN_CALLS" -eq 1 ] || fail "existing executable path was not re-converged" -ok "existing executable and both parents are re-owned without replacing its contents" +[ "$(cat "$TARGET")" = newer-managed-copy ] || fail "managed binary was overwritten" +case "$ROOT_MUTATION_LOG" in *"$(dirname "$TARGET")"*|*"$TARGET"*) fail "repeat root provisioning mutated service-writable bin contents: $ROOT_MUTATION_LOG" ;; esac +[ "$CHOWN_CALLS" -eq 0 ] || fail "repeat provisioning chowned the service-owned bin" +ok "newer service-owned managed executable remains untouched on repeat provisioning" + +source "$ROOT/bridges/_dispatch.sh" +SERVICE_PATH="$(_compose_path_value "$(dirname "$TARGET")" "$SERVICE_HOME/.local/bin" /usr/bin /bin)" +RESOLVED_HOMEBoy="$(PATH="$SERVICE_PATH" command -v homeboy)" +[ "$RESOLVED_HOMEBoy" = "$TARGET" ] || fail "service PATH resolves $RESOLVED_HOMEBoy instead of the managed binary" +ok "managed binary wins PATH over legacy service and older system copies" sudo() { printf '%s\n' "$*" > "$TMP/sudo-argv"; return 0; } export WP_CODING_AGENTS_TEST_ASSUME_ROOT=true homeboy_run --version grep -Fq -- "$TARGET --version" "$TMP/sudo-argv" || fail "homeboy_run did not use the service-owned absolute path" -grep -Fq -- "PATH=$SERVICE_HOME/.local/bin:$PATH" "$TMP/sudo-argv" || fail "service-user Homeboy child PATH does not prefer the service bin" +grep -Fq -- "PATH=$(dirname "$TARGET"):$PATH" "$TMP/sudo-argv" || fail "service-user Homeboy child PATH does not prefer managed bin" ok "homeboy_run drops to the service identity before execution" SERVICE_USER=root diff --git a/tests/verify.sh b/tests/verify.sh index 8ca9869d..c357ca3e 100755 --- a/tests/verify.sh +++ b/tests/verify.sh @@ -29,6 +29,10 @@ refute_contains() { # A fake install. wp is stubbed so this needs no database. SITE="$TMP/site" mkdir -p "$SITE/wp-content/mu-plugins" "$TMP/units" "$TMP/manifest/site" +HOMEBOY_FIXTURE_BIN="$TMP/managed homeboy path/homeboy" +mkdir -p "$(dirname "$HOMEBOY_FIXTURE_BIN")" +printf '#!/bin/sh\nexit 0\n' > "$HOMEBOY_FIXTURE_BIN" +chmod 0755 "$HOMEBOY_FIXTURE_BIN" # When running as root the manifest-writability check is live, so the fixture # has to model a correctly-provisioned directory. if [ "$(id -u)" -eq 0 ] && id -u www-data >/dev/null 2>&1; then @@ -87,13 +91,14 @@ run_verify() { PATH="$TMP:$PATH" \ SYSTEMD_UNIT_DIR="$TMP/units" \ SOURCE_POLICY_MANIFEST_ROOT="$TMP/manifest" \ + WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN="$HOMEBOY_FIXTURE_BIN" \ bash verify.sh --site-path "$SITE" 2>&1 || true } # Baseline: everything agrees. write_manifest wp-content/plugins/acme-core wp-content/themes/acme write_opencode wp-content/plugins/acme-core wp-content/themes/acme -write_unit kimaki.service opencode /home/opencode +write_unit kimaki.service opencode /home/opencode "Environment=PATH=$(dirname "$HOMEBOY_FIXTURE_BIN"):/usr/local/bin:/usr/bin:/bin" source lib/agents-md-guidance.sh CURRENT_PRODUCER="version=$(agents_md_guidance_producer_version) source=$(agents_md_guidance_producer_source)" printf '%s\n' "" > "$SITE/wp-content/mu-plugins/wp-coding-agents-agents-md.php" @@ -104,6 +109,8 @@ OUT="$(run_verify)" refute_contains "$OUT" "FAIL" "no complaints when every seam agrees" assert_contains "$OUT" "permission.edit allows exactly the declared set" "checks the permission seam" assert_contains "$OUT" "manifest agrees with the recorded set" "checks the manifest seam" +assert_contains "$OUT" "managed Homeboy executable is mode 755 at $HOMEBOY_FIXTURE_BIN" "uses the full managed-binary override path" +assert_contains "$OUT" "kimaki.service PATH includes managed Homeboy directory" "checks managed Homeboy service PATH agreement" OUT="$(WP_STUB_NOISE=1 run_verify)" assert_contains "$OUT" "source mode: owned" "ignores WP-CLI deprecation output" diff --git a/verify.sh b/verify.sh index 8ffe458b..70edb66e 100755 --- a/verify.sh +++ b/verify.sh @@ -388,6 +388,26 @@ done [ "$UNITS_CHECKED" -eq 0 ] && skip "no agent systemd units on this host" +HOMEBOY_MANAGED_BIN="${WP_CODING_AGENTS_HOMEBOY_MANAGED_BIN:-/usr/local/lib/wp-coding-agents/bin/homeboy}" +if [ -x "$HOMEBOY_MANAGED_BIN" ]; then + HOMEBOY_BIN_DIR="$(dirname "$HOMEBOY_MANAGED_BIN")" + HOMEBOY_MODE="$(file_mode "$HOMEBOY_MANAGED_BIN" 2>/dev/null || true)" + case "$HOMEBOY_MODE" in + 755) pass "managed Homeboy executable is mode 755 at $HOMEBOY_MANAGED_BIN" ;; + *) fail "managed Homeboy executable is mode ${HOMEBOY_MODE:-unknown}, expected 755 at $HOMEBOY_MANAGED_BIN" ;; + esac + while IFS= read -r unit; do + [ -f "$unit" ] || continue + if grep '^Environment=PATH=' "$unit" | grep -Fq "$HOMEBOY_BIN_DIR"; then + pass "$(basename "$unit") PATH includes managed Homeboy directory" + else + fail "$(basename "$unit") PATH does not include $HOMEBOY_BIN_DIR — service and guidance may select different Homeboy binaries" + fi + done < <(printf '%s\n' "$UNIT_DIR"/kimaki*.service) +else + skip "no executable managed Homeboy at $HOMEBOY_MANAGED_BIN" +fi + # --------------------------------------------------------------------------- # Seam 3: the pieces owned mode requires must actually be installed # ---------------------------------------------------------------------------