diff --git a/lib/cli-channel.sh b/lib/cli-channel.sh index e24172d..aa67d12 100644 --- a/lib/cli-channel.sh +++ b/lib/cli-channel.sh @@ -98,6 +98,9 @@ cli_channel_ensure_mu_plugin_file() { mkdir -p "$dir" + # Write the scaffold, then force mode 0644 regardless of the caller's + # umask (root cron/systemd contexts default to 0077 which strips the + # world-read bit PHP-FPM needs — see issue #133). cat > "$file" <<'PHP' "$tmp" mv "$tmp" "$file" + chmod 0644 "$file" log " Unregistered CLI channel '$name' from $file" if [ -n "${UPDATED_ITEMS+x}" ]; then UPDATED_ITEMS+=("CLI channel removed: $name") diff --git a/lib/runtime-signature.sh b/lib/runtime-signature.sh index a0ff15e..06460c5 100644 --- a/lib/runtime-signature.sh +++ b/lib/runtime-signature.sh @@ -107,6 +107,9 @@ runtime_signature_ensure_mu_plugin_file() { mkdir -p "$dir" + # Write the scaffold, then force mode 0644 regardless of the caller's + # umask (root cron/systemd contexts default to 0077 which strips the + # world-read bit PHP-FPM needs — see issue #133). cat > "$file" <<'PHP' "$tmp" mv "$tmp" "$file" + chmod 0644 "$file" log " Unregistered runtime signature '$runtime_id' from $file" if [ -n "${UPDATED_ITEMS+x}" ]; then UPDATED_ITEMS+=("runtime signature removed: $runtime_id") diff --git a/tests/cli-channel-perms.sh b/tests/cli-channel-perms.sh new file mode 100755 index 0000000..3ea3397 --- /dev/null +++ b/tests/cli-channel-perms.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# tests/cli-channel-perms.sh — Regression coverage for issue #133 in +# lib/cli-channel.sh. +# +# The mu-plugin file written by cli_channel_ensure_mu_plugin_file / +# cli_channel_register must land at mode 0644 regardless of the caller's +# umask, because PHP-FPM (running as www-data) needs world-read to load it. +# Root cron/systemd contexts default to umask 0077 → 0600, which broke +# every install that ran setup.sh / upgrade.sh from a daemon context. +# +# Asserts the three write paths each produce mode 0644: +# 1. Fresh scaffold (cli_channel_ensure_mu_plugin_file) +# 2. Subsequent register replacing/inserting a block (mktemp + mv) +# 3. Unregister removing a block (mktemp + mv) +# +# Also asserts the self-heal behavior: if a legacy file is mode 0600 +# (left by a pre-#133 install), the next register call must restore 0644. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$SCRIPT_DIR" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/wp-content/mu-plugins" +export SITE_PATH="$TMP" +export DRY_RUN=false + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/cli-channel.sh" +UPDATED_ITEMS=() + +# Silence helper logs unless --verbose. +VERBOSE=false +for arg in "$@"; do + case "$arg" in + --verbose|-v) VERBOSE=true ;; + esac +done +if [ "$VERBOSE" = false ]; then + log() { :; } + warn() { echo "WARN: $1" >&2; } +fi + +MU_FILE="$TMP/wp-content/mu-plugins/wp-coding-agents-channels.php" +FAILED=0 + +assert_mode_0644() { + local file="$1" name="$2" + local got + got=$(stat -c %a "$file") + if [ "$got" = "644" ]; then + echo " ok $name" + else + echo " FAIL $name" + echo " got: $got" + echo " want: 644" + FAILED=$((FAILED + 1)) + fi +} + +# --- 1. Fresh scaffold under hostile umask --------------------------------- +echo "==> register kimaki (fresh scaffold, umask 077)" +( + umask 077 + cli_channel_register "kimaki" \ + "/usr/local/bin/kimaki" \ + '["send","--channel","{recipient}","--prompt","{message}"]' +) +assert_mode_0644 "$MU_FILE" "fresh scaffold lands as 0644 under umask 077" + +# --- 2. Sibling register exercises mktemp + mv path; self-heal from 0600 --- +echo "==> simulate legacy 0600 file and verify self-heal on next register" +chmod 0600 "$MU_FILE" +( + umask 077 + cli_channel_register "opencode-telegram" \ + "/usr/local/bin/opencode-telegram" \ + '["dispatch","--chat","{recipient}","--text","{message}"]' +) +assert_mode_0644 "$MU_FILE" "mu-plugin self-healed to 0644 after sibling register" + +# --- 3. Unregister also lands as 0644 -------------------------------------- +echo "==> unregister opencode-telegram" +chmod 0600 "$MU_FILE" +( + umask 077 + cli_channel_unregister "opencode-telegram" +) +assert_mode_0644 "$MU_FILE" "mu-plugin mode 0644 after unregister" + +# --- Done ------------------------------------------------------------------ +echo +if [ "$FAILED" -gt 0 ]; then + echo "FAILED: $FAILED assertion(s)" + exit 1 +fi +echo "OK: all cli-channel perms assertions passed" diff --git a/tests/effective-prompt/__snapshots__/.gitignore b/tests/effective-prompt/__snapshots__/.gitignore new file mode 100644 index 0000000..a287088 --- /dev/null +++ b/tests/effective-prompt/__snapshots__/.gitignore @@ -0,0 +1,8 @@ +# Diff-intermediate files written by tests/effective-prompt/run.mjs when +# a snapshot mismatch occurs. They mirror the canonical .baseline.txt / +# .filtered.txt / .raw.txt and are only useful for local inspection. +# +# The harness already cleans these up on a successful run; this file +# keeps them out of git when a run fails and they are left in place +# for debugging. See issue #134. +*.actual diff --git a/tests/effective-prompt/run.mjs b/tests/effective-prompt/run.mjs index c043480..b440c0b 100644 --- a/tests/effective-prompt/run.mjs +++ b/tests/effective-prompt/run.mjs @@ -42,7 +42,7 @@ // // Exit code: 0 on success, 1 on any assertion failure. -import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs" +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, unlinkSync } from "node:fs" import { execSync } from "node:child_process" import { dirname, join } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" @@ -325,6 +325,17 @@ for (const r of results) { if (!r.skipped && r.failures.length > 0) failed++ } +// Clean up .actual diff intermediates on a fully successful run so they do +// not litter the working tree (issue #134). On any failure, leave them in +// place so the reviewer can diff against the canonical snapshot. +if (failed === 0) { + for (const f of readdirSync(SNAPSHOT_DIR)) { + if (f.endsWith(".actual")) { + try { unlinkSync(join(SNAPSHOT_DIR, f)) } catch {} + } + } +} + console.log(`\n${"=".repeat(72)}`) if (failed === 0) { console.log(`OK — ${results.filter((r) => !r.skipped).length} scenario(s) passed`) diff --git a/tests/runtime-signature.sh b/tests/runtime-signature.sh index db7d6bd..809bd4c 100755 --- a/tests/runtime-signature.sh +++ b/tests/runtime-signature.sh @@ -83,12 +83,32 @@ assert_php_lint() { fi } +assert_mode_0644() { + local file="$1" name="$2" + local got + got=$(stat -c %a "$file") + if [ "$got" = "644" ]; then + echo " ok $name" + else + echo " FAIL $name" + echo " got: $got" + echo " want: 644" + FAILED=$((FAILED + 1)) + fi +} + # --- 1. Fresh scaffold + register kimaki ----------------------------------- -echo "==> register kimaki (fresh scaffold)" -runtime_signature_register "kimaki" \ - '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL"}' +# Use a hostile umask (matches root cron/systemd default 0077) to prove the +# helper forces 0644 regardless of caller umask — see issue #133. +echo "==> register kimaki (fresh scaffold, umask 077)" +( + umask 077 + runtime_signature_register "kimaki" \ + '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL"}' +) assert_file_exists "$MU_FILE" "mu-plugin created" assert_php_lint "$MU_FILE" "scaffold parses with php -l" +assert_mode_0644 "$MU_FILE" "mu-plugin mode 0644 after fresh write under umask 077" if grep -q "BEGIN runtime:kimaki" "$MU_FILE"; then echo " ok kimaki block present" @@ -106,10 +126,14 @@ HASH_AFTER=$(md5sum "$MU_FILE" | cut -d' ' -f1) assert_eq "$HASH_AFTER" "$HASH_BEFORE" "file unchanged on re-register" # --- 3. Add opencode without disturbing kimaki ----------------------------- -echo "==> register opencode (sibling block)" +# Simulate a legacy 0600 file from a pre-#133 install. The next register call +# must self-heal it back to 0644 via the mktemp+mv path. +echo "==> simulate legacy 0600 file and verify self-heal on next register" +chmod 0600 "$MU_FILE" runtime_signature_register "opencode" \ '{"session_id":"OPENCODE_SESSION_ID","run_id":"OPENCODE_RUN_ID"}' assert_php_lint "$MU_FILE" "two-runtime file parses with php -l" +assert_mode_0644 "$MU_FILE" "mu-plugin mode self-healed to 0644 after sibling register" if grep -q "BEGIN runtime:kimaki" "$MU_FILE" && grep -q "BEGIN runtime:opencode" "$MU_FILE"; then echo " ok both runtime blocks present" @@ -151,6 +175,7 @@ else FAILED=$((FAILED + 1)) fi assert_php_lint "$MU_FILE" "post-unregister file parses with php -l" +assert_mode_0644 "$MU_FILE" "mu-plugin mode 0644 after unregister" # --- 6. Filter-shape end-to-end (php execution) ---------------------------- echo "==> apply_filters returns the expected shape"