Skip to content

Commit b55a5b3

Browse files
authored
fix: register a web-traversable kimaki binary for CLI dispatch (#198) (#200)
The kimaki CLI-channel command is shelled by the Data Machine Code CLI transport from agents/dispatch-message, which runs inside PHP-FPM as the www-data web user on WP-cron / Action Scheduler fires — not as the kimaki.service user. On a RUN_AS_ROOT install the binary resolves under /root/.kimaki/bin and the data dir under /root (mode 0700). www-data cannot traverse 0700 /root, so proc_open fails with EACCES and every scheduled dispatch dies as datamachine_code_cli_dispatch_spawn_failed, while manual-as-root runs succeed. The opencode service-user home (/home/opencode, 0750) is the same trap. Add _kimaki_path_is_web_traversable() which asserts every ancestor dir of a candidate binary carries the world-execute bit (o+x), and use it in both resolution paths: _kimaki_register_cli_channel now ignores a KIMAKI_BIN trapped under a private home, and _kimaki_find_native_binary skips executable-but-unreachable PATH entries in favor of a reachable system-prefix binary (e.g. /usr/bin/kimaki, the npm-global symlink). This is the targeted stopgap; the durable fix is migrating off RUN_AS_ROOT to the opencode service user (#93). Making the registered command web-reachable regardless of service user is a prerequisite for that migration, since /home/opencode is also non-traversable by www-data. Adds tests/cli-channel-binary-path.sh (+ CI job) covering the 0700/0750 rejection, reachable-preference, and trapped-KIMAKI_BIN fallback. Updates tests/kimaki-agent-fallback.sh fixtures (mktemp -d defaults to 0700) to simulate web-reachable installs.
1 parent affa6a7 commit b55a5b3

4 files changed

Lines changed: 268 additions & 2 deletions

File tree

.github/workflows/shell.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ jobs:
4848
- name: Run tests/kimaki-agent-fallback.sh
4949
run: ./tests/kimaki-agent-fallback.sh
5050

51+
cli-channel-binary-path:
52+
name: CLI channel binary-path web-reachability regression (#198)
53+
runs-on: ubuntu-latest
54+
steps:
55+
- uses: actions/checkout@v4
56+
- name: Run tests/cli-channel-binary-path.sh
57+
run: ./tests/cli-channel-binary-path.sh
58+
5159
wp-codebox-subtree:
5260
name: WP Codebox subtree updater
5361
runs-on: ubuntu-latest

bridges/kimaki.sh

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,24 @@ bridge_install() {
6666
# Register the native Kimaki binary. Kimaki 0.13 validates requested agents and
6767
# falls back to the default/build agent when a requested agent is unavailable,
6868
# so wp-coding-agents must not rewrite `--agent` itself.
69+
#
70+
# The command we register here is shelled by the Data Machine Code CLI transport
71+
# from the `agents/dispatch-message` ability, which runs inside PHP-FPM as the
72+
# WordPress web user (www-data) on WP-cron / Action Scheduler fires — NOT as the
73+
# kimaki.service user. On a RUN_AS_ROOT install the kimaki binary resolves under
74+
# /root/.kimaki/bin (and the data dir under /root, mode 0700); www-data cannot
75+
# traverse 0700 /root, so proc_open fails with EACCES and every scheduled
76+
# dispatch dies as datamachine_code_cli_dispatch_spawn_failed. The opencode
77+
# service-user home (/home/opencode, mode 0750) is the same trap. We therefore
78+
# only register a path whose ancestor directories are world-traversable
79+
# (`o+x`), preferring a system-prefix binary (e.g. /usr/bin/kimaki, the
80+
# npm-global symlink) over any private-home wrapper. See #198 / #93.
6981
_kimaki_register_cli_channel() {
7082
local cmd
71-
if [ -n "${KIMAKI_BIN:-}" ] && [ -x "$KIMAKI_BIN" ] && ! _kimaki_is_legacy_adapter_file "$KIMAKI_BIN"; then
83+
if [ -n "${KIMAKI_BIN:-}" ] \
84+
&& [ -x "$KIMAKI_BIN" ] \
85+
&& ! _kimaki_is_legacy_adapter_file "$KIMAKI_BIN" \
86+
&& _kimaki_path_is_web_traversable "$KIMAKI_BIN"; then
7287
cmd="$KIMAKI_BIN"
7388
else
7489
cmd="$(_kimaki_find_native_binary)"
@@ -87,8 +102,66 @@ _kimaki_is_legacy_adapter_file() {
87102
[ -e "$file" ] && grep -q 'wp-coding-agents datamachine-kimaki adapter' "$file" 2>/dev/null
88103
}
89104

105+
# _kimaki_path_is_web_traversable <path>
106+
#
107+
# Return 0 if every ancestor directory of <path> carries the world-execute
108+
# bit (`o+x`), i.e. an unrelated user (the PHP-FPM web user that runs the
109+
# CLI-dispatch transport) can traverse to it. Return 1 if any ancestor is
110+
# missing `o+x` (e.g. /root at 0700, /home/opencode at 0750) — such a path is
111+
# unreachable by www-data and must not be registered as the dispatch command.
112+
#
113+
# Symlinks are resolved first (via realpath when available) so the real
114+
# target's ancestors are checked, not the link's. If the path cannot be
115+
# resolved or stat'd, err on the side of "not traversable" (return 1) so the
116+
# caller falls back to PATH resolution.
117+
_kimaki_path_is_web_traversable() {
118+
local path="$1"
119+
[ -n "$path" ] || return 1
120+
121+
local resolved
122+
if command -v realpath >/dev/null 2>&1; then
123+
resolved="$(realpath -e "$path" 2>/dev/null)" || return 1
124+
elif command -v readlink >/dev/null 2>&1; then
125+
resolved="$(readlink -f "$path" 2>/dev/null)" || return 1
126+
else
127+
resolved="$path"
128+
fi
129+
[ -n "$resolved" ] || return 1
130+
131+
# Walk every ancestor directory from the binary up to /, asserting o+x.
132+
local dir="${resolved%/*}"
133+
[ -n "$dir" ] || dir="/"
134+
while :; do
135+
local perms
136+
perms="$(stat -c '%a' "$dir" 2>/dev/null)" || perms="$(stat -f '%Lp' "$dir" 2>/dev/null)" || return 1
137+
# Last octal digit is the "other" triad; its execute bit is value 1.
138+
local other="${perms: -1}"
139+
case "$other" in
140+
1|3|5|7) ;; # o+x present
141+
*) return 1 ;; # o+x missing — not traversable by www-data
142+
esac
143+
[ "$dir" = "/" ] && break
144+
dir="${dir%/*}"
145+
[ -n "$dir" ] || dir="/"
146+
done
147+
148+
return 0
149+
}
150+
151+
# _kimaki_find_native_binary
152+
#
153+
# Resolve the kimaki binary to register as the CLI-dispatch command. Walks
154+
# $PATH and returns the first candidate that is (a) executable, (b) not the
155+
# legacy adapter shim, and (c) web-traversable (every ancestor dir is `o+x`,
156+
# so the www-data CLI-dispatch transport can reach it — see #198).
157+
#
158+
# A candidate that is executable but trapped under a private home
159+
# (/root/.kimaki/bin, /home/opencode/.kimaki/bin) is skipped in favor of a
160+
# later, reachable candidate (typically the /usr/bin npm-global symlink). If
161+
# nothing on $PATH is reachable, fall back to the bare name "kimaki" and let
162+
# the runtime resolve it via its own PATH at dispatch time.
90163
_kimaki_find_native_binary() {
91-
local dir candidate
164+
local dir candidate first_unreachable=""
92165
IFS=: read -r -a path_entries <<< "${PATH:-}"
93166
for dir in "${path_entries[@]}"; do
94167
[ -n "$dir" ] || continue
@@ -97,9 +170,21 @@ _kimaki_find_native_binary() {
97170
if _kimaki_is_legacy_adapter_file "$candidate"; then
98171
continue
99172
fi
173+
if ! _kimaki_path_is_web_traversable "$candidate"; then
174+
# Remember the first executable-but-unreachable candidate as a
175+
# last-resort over the bare name, but keep looking for a reachable one.
176+
[ -n "$first_unreachable" ] || first_unreachable="$candidate"
177+
continue
178+
fi
100179
printf '%s\n' "$candidate"
101180
return 0
102181
done
182+
183+
if [ -n "$first_unreachable" ]; then
184+
printf '%s\n' "$first_unreachable"
185+
return 0
186+
fi
187+
103188
printf '%s\n' "kimaki"
104189
}
105190

tests/cli-channel-binary-path.sh

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env bash
2+
# tests/cli-channel-binary-path.sh — Regression coverage for issue #198.
3+
#
4+
# The command registered for the kimaki CLI channel is shelled by the Data
5+
# Machine Code CLI transport from `agents/dispatch-message`, which runs inside
6+
# PHP-FPM as the WordPress web user (www-data) on WP-cron / Action Scheduler
7+
# fires. That user is NOT the kimaki.service user.
8+
#
9+
# On a RUN_AS_ROOT install the kimaki binary resolves under /root/.kimaki/bin
10+
# (and the data dir under /root, mode 0700). www-data cannot traverse 0700
11+
# /root, so proc_open fails with EACCES and every scheduled dispatch dies as
12+
# `datamachine_code_cli_dispatch_spawn_failed`. The opencode service-user home
13+
# (/home/opencode, mode 0750) is the same trap.
14+
#
15+
# The resolver (_kimaki_find_native_binary) and the KIMAKI_BIN short-circuit in
16+
# _kimaki_register_cli_channel must therefore only register a binary whose
17+
# ancestor directories are world-traversable (`o+x`), preferring a reachable
18+
# system-prefix path over any private-home wrapper.
19+
#
20+
# Asserts:
21+
# 1. _kimaki_path_is_web_traversable accepts a 0755-ancestor path and
22+
# rejects a 0700- and a 0750-ancestor path (the /root and /home/opencode
23+
# traps).
24+
# 2. _kimaki_find_native_binary skips an executable-but-unreachable PATH
25+
# entry (private-home wrapper) in favor of a later reachable one.
26+
# 3. _kimaki_register_cli_channel ignores a KIMAKI_BIN that is executable but
27+
# trapped under a non-traversable home, falling back to the reachable
28+
# PATH binary.
29+
# 4. The registered command is never a path under a non-traversable home.
30+
31+
set -euo pipefail
32+
33+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
34+
TMP="$(mktemp -d)"
35+
trap 'chmod -R u+rwx "$TMP" 2>/dev/null || true; rm -rf "$TMP"' EXIT
36+
37+
# mktemp -d creates the dir at 0700 by design. This test simulates a WEB-
38+
# REACHABLE install prefix, so make the temp root world-traversable; the
39+
# individual "trap" dirs below re-tighten their own permissions to 0700/0750.
40+
# (/tmp itself is 1777, and the temp root's parent chain is world-traversable
41+
# in CI, so this gives us a clean 0755 base to build reachable paths under.)
42+
chmod 0755 "$TMP"
43+
44+
# Silence helper logs; capture cli_channel_register args for assertions.
45+
log() { :; }
46+
warn() { printf 'WARN: %s\n' "$1" >&2; }
47+
cli_channel_register() {
48+
printf '%s\0' "$@" > "$TMP/cli-channel.args"
49+
}
50+
51+
DRY_RUN=false
52+
UPDATED_ITEMS=()
53+
54+
# shellcheck disable=SC1091
55+
source "$ROOT/bridges/kimaki.sh"
56+
57+
FAILED=0
58+
fail() { echo " FAIL $1"; FAILED=$((FAILED + 1)); }
59+
ok() { echo " ok $1"; }
60+
61+
# A fake kimaki binary the resolver can find.
62+
make_kimaki() {
63+
local dir="$1"
64+
mkdir -p "$dir"
65+
printf '#!/bin/sh\nexit 0\n' > "$dir/kimaki"
66+
chmod 0755 "$dir/kimaki"
67+
}
68+
69+
# Read the command (2nd arg) that _kimaki_register_cli_channel passed through.
70+
registered_command() {
71+
python3 - "$TMP/cli-channel.args" <<'PY'
72+
import sys
73+
with open(sys.argv[1], 'rb') as handle:
74+
parts = [p.decode() for p in handle.read().split(b'\0') if p]
75+
# cli_channel_register "kimaki" "<command>" "<args_json>" "<detach>" "<timeout>"
76+
print(parts[1] if len(parts) > 1 else '')
77+
PY
78+
}
79+
80+
# --- 1. _kimaki_path_is_web_traversable on hostile vs friendly ancestors ----
81+
echo "==> _kimaki_path_is_web_traversable ancestor checks"
82+
83+
reachable_dir="$TMP/reachable/bin" # all ancestors 0755 by default
84+
make_kimaki "$reachable_dir"
85+
if _kimaki_path_is_web_traversable "$reachable_dir/kimaki"; then
86+
ok "accepts 0755-ancestor path"
87+
else
88+
fail "should accept 0755-ancestor path"
89+
fi
90+
91+
# Simulate /root (0700) trap.
92+
root_trap="$TMP/roothome" # stands in for /root
93+
mkdir -p "$root_trap/.kimaki/bin"
94+
make_kimaki "$root_trap/.kimaki/bin"
95+
chmod 0700 "$root_trap"
96+
if _kimaki_path_is_web_traversable "$root_trap/.kimaki/bin/kimaki"; then
97+
fail "should REJECT 0700-ancestor (/root) path"
98+
else
99+
ok "rejects 0700-ancestor (/root) path"
100+
fi
101+
102+
# Simulate /home/opencode (0750) trap.
103+
home_trap="$TMP/opencodehome" # stands in for /home/opencode
104+
mkdir -p "$home_trap/.kimaki/bin"
105+
make_kimaki "$home_trap/.kimaki/bin"
106+
chmod 0750 "$home_trap"
107+
if _kimaki_path_is_web_traversable "$home_trap/.kimaki/bin/kimaki"; then
108+
fail "should REJECT 0750-ancestor (/home/opencode) path"
109+
else
110+
ok "rejects 0750-ancestor (/home/opencode) path"
111+
fi
112+
113+
# --- 2. resolver skips unreachable PATH entry for a reachable one -----------
114+
echo "==> _kimaki_find_native_binary prefers reachable PATH entry"
115+
116+
# PATH puts the 0700-trapped wrapper FIRST (mirrors root's $PATH ordering),
117+
# then a reachable system-style dir.
118+
unset KIMAKI_BIN || true
119+
resolved="$(PATH="$root_trap/.kimaki/bin:$reachable_dir:/usr/bin:/bin" _kimaki_find_native_binary)"
120+
if [ "$resolved" = "$reachable_dir/kimaki" ]; then
121+
ok "skips 0700 wrapper, returns reachable binary"
122+
else
123+
fail "expected $reachable_dir/kimaki, got '$resolved'"
124+
fi
125+
126+
# --- 3. register ignores trapped KIMAKI_BIN, falls back to reachable PATH ----
127+
echo "==> _kimaki_register_cli_channel ignores trapped KIMAKI_BIN"
128+
129+
rm -f "$TMP/cli-channel.args"
130+
KIMAKI_BIN="$root_trap/.kimaki/bin/kimaki" # executable but trapped under 0700
131+
PATH="$reachable_dir:/usr/bin:/bin" _kimaki_register_cli_channel
132+
got="$(registered_command)"
133+
if [ "$got" = "$reachable_dir/kimaki" ]; then
134+
ok "trapped KIMAKI_BIN ignored; registered reachable $got"
135+
else
136+
fail "expected reachable $reachable_dir/kimaki, got '$got'"
137+
fi
138+
139+
# --- 4. registered command is never under a non-traversable home ------------
140+
echo "==> registered command is web-traversable"
141+
142+
if _kimaki_path_is_web_traversable "$got"; then
143+
ok "registered command '$got' is web-traversable"
144+
else
145+
fail "registered command '$got' is NOT web-traversable"
146+
fi
147+
148+
# Sanity: a reachable KIMAKI_BIN is still honored (no regression).
149+
rm -f "$TMP/cli-channel.args"
150+
reachable_bin_dir="$TMP/reachable2/bin"
151+
make_kimaki "$reachable_bin_dir"
152+
KIMAKI_BIN="$reachable_bin_dir/kimaki"
153+
PATH="/usr/bin:/bin" _kimaki_register_cli_channel
154+
got2="$(registered_command)"
155+
if [ "$got2" = "$reachable_bin_dir/kimaki" ]; then
156+
ok "reachable KIMAKI_BIN still honored (no regression)"
157+
else
158+
fail "expected $reachable_bin_dir/kimaki, got '$got2'"
159+
fi
160+
161+
echo
162+
if [ "$FAILED" -gt 0 ]; then
163+
echo "FAILED: $FAILED assertion(s)"
164+
exit 1
165+
fi
166+
echo "OK: all cli-channel binary-path assertions passed"

tests/kimaki-agent-fallback.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
55
TMP="$(mktemp -d)"
66
trap 'rm -rf "$TMP"' EXIT
77

8+
# mktemp -d creates the dir at 0700. The CLI-channel resolver now refuses to
9+
# register a binary whose ancestor dirs are not world-traversable (the #198
10+
# fix: a /root- or /home/opencode-trapped binary is unreachable by the
11+
# www-data CLI-dispatch transport). These fixtures simulate normally-installed,
12+
# web-reachable binaries, so make the temp root 0755 to match that reality.
13+
chmod 0755 "$TMP"
14+
815
if [ -e "$ROOT/bridges/kimaki/bin/datamachine-kimaki" ]; then
916
echo "FAIL: datamachine-kimaki adapter should not be shipped"
1017
exit 1

0 commit comments

Comments
 (0)