From 2be8be9c7ffe575cdb458dde0981669f3ff17cbf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 17 May 2026 20:17:52 +0000 Subject: [PATCH 1/2] fix(lib): chmod 0644 mu-plugin files written by cli-channel and runtime-signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli_channel_ensure_mu_plugin_file / cli_channel_register / cli_channel_unregister and the runtime-signature equivalents wrote their mu-plugin files using the caller's umask. Root cron/systemd contexts default to umask 0077, producing mode 0600 files that PHP-FPM (www-data) cannot read — every WordPress request then logs 'Permission denied' from wp-settings.php and the datamachine_code_cli_channels / datamachine_code_worktree_runtime_signatures filters return empty arrays, breaking dispatch-message and worktree origin attribution respectively. Force mode 0644 after every write path in both helpers: * Initial scaffold write (cat > $file) * Block register (mktemp + mv — mktemp creates at 0600, mv preserves) * Block unregister (same mktemp + mv path) The chmod after mktemp+mv also self-heals legacy installs that already have a 0600 file on disk — the next register/unregister upgrades it. Add tests/cli-channel-perms.sh covering all three write paths under a hostile umask (umask 077) plus the self-heal scenario. Extend tests/runtime-signature.sh with the same assertions: fresh write under umask 077 lands as 0644, sibling register self-heals a 0600 file, and unregister preserves 0644. Closes #133 --- lib/cli-channel.sh | 9 ++++ lib/runtime-signature.sh | 9 ++++ tests/cli-channel-perms.sh | 101 +++++++++++++++++++++++++++++++++++++ tests/runtime-signature.sh | 33 ++++++++++-- 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100755 tests/cli-channel-perms.sh 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/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" From ef1ce89c1e0c89c3e3b1ef271088e36f02810889 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 17 May 2026 20:18:57 +0000 Subject: [PATCH 2/2] fix(tests): gitignore .actual snapshot files and clean up after effective-prompt runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/effective-prompt/run.mjs writes .actual diff-intermediates next to the canonical .baseline.txt / .filtered.txt / .raw.txt snapshots whenever a scenario fails snapshot comparison, but never removes them. They accumulate in tests/effective-prompt/__snapshots__/ and dirty the working tree, which blocks 'homeboy release wp-coding-agents' on its preflight.working_tree check. Two-part fix matching the issue's recommendation: 1. Add tests/effective-prompt/__snapshots__/.gitignore ignoring *.actual. Canonical snapshots stay tracked; diff intermediates become invisible to git but still land on disk for reviewer inspection when a test fails. Snapshot drift still surfaces loudly because run.mjs prints 'snapshot drift — run with --update to refresh' in the FAIL block and exits non-zero; the .gitignore only hides the intermediate copies of the new content, not the failure signal itself. 2. In run.mjs, after the main loop completes with zero failures, unlink every *.actual file in __snapshots__/. On any failure, leave them in place so the reviewer can inspect the diff. This means a clean run produces a clean working tree even without the .gitignore, and the .gitignore handles the failure case. Verified end-to-end: drifted snapshots produce a FAIL with .actual files left on disk but absent from 'git status'; a passing run after --update clears the .actual files entirely. Closes #134 --- tests/effective-prompt/__snapshots__/.gitignore | 8 ++++++++ tests/effective-prompt/run.mjs | 13 ++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/effective-prompt/__snapshots__/.gitignore 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`)