From 9f37af77a36bf4163c7ca10ca82d860b7d37195e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 29 Aug 2026 20:46:18 -0400 Subject: [PATCH] fix: preserve WordPress argv through Kimaki --- bridges/kimaki.sh | 45 +++++++++++++++------ bridges/kimaki/plugins/dm-agent-sync.ts | 41 ++++++++++++++----- tests/__snapshots__/bridges/kimaki-launchd | 4 +- tests/__snapshots__/bridges/kimaki-systemd | 2 +- tests/bridge-render.sh | 4 +- tests/dm-agent-sync.mjs | 47 +++++++++++++++++++--- tests/kimaki-no-default-channel.sh | 9 +++++ 7 files changed, 120 insertions(+), 32 deletions(-) diff --git a/bridges/kimaki.sh b/bridges/kimaki.sh index c8733be..932abd4 100644 --- a/bridges/kimaki.sh +++ b/bridges/kimaki.sh @@ -811,8 +811,8 @@ _kimaki_install_systemd() { 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) _kimaki_assert_bin_identity "$KIMAKI_BIN" "$PATH_VALUE" - local DATAMACHINE_WP_CMD - DATAMACHINE_WP_CMD=$(_kimaki_datamachine_wp_cmd) + local DATAMACHINE_WP_TRANSPORT_JSON + DATAMACHINE_WP_TRANSPORT_JSON=$(_kimaki_datamachine_wp_transport_json_systemd) # Kimaki recreates a general-purpose #kimaki- channel, welcome message, # and tutorial thread on every start. On a wp-coding-agents install the real @@ -824,7 +824,7 @@ _kimaki_install_systemd() { Environment=PATH=$PATH_VALUE Environment=KIMAKI_DATA_DIR=$KIMAKI_DATA_DIR Environment=DATAMACHINE_SITE_PATH=$SITE_PATH -Environment=DATAMACHINE_WP_CMD=$DATAMACHINE_WP_CMD +Environment=DATAMACHINE_WP_TRANSPORT_JSON=$DATAMACHINE_WP_TRANSPORT_JSON Environment=KIMAKI_NO_DEFAULT_CHANNEL=1" if [ -n "${AGENT_SLUG:-}" ]; then ENV_BLOCK="$ENV_BLOCK @@ -1150,6 +1150,7 @@ bridge_update_systemd() { local CURRENT_ENV CURRENT_ENV=$(grep '^Environment=' "$UNIT_FILE" || true) + CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" DATAMACHINE_WP_CMD) if [ "${KIMAKI_DATA_DIR_EXPLICIT:-false}" = true ]; then CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" KIMAKI_DATA_DIR) fi @@ -1177,7 +1178,7 @@ bridge_update_systemd() { Environment=PATH=$PATH_VALUE Environment=KIMAKI_DATA_DIR=$KIMAKI_DATA_DIR Environment=DATAMACHINE_SITE_PATH=$SITE_PATH -Environment=DATAMACHINE_WP_CMD=$(_kimaki_datamachine_wp_cmd) +Environment=DATAMACHINE_WP_TRANSPORT_JSON=$(_kimaki_datamachine_wp_transport_json_systemd) Environment=KIMAKI_NO_DEFAULT_CHANNEL=1" if [ -n "${KIMAKI_LOCK_PORT:-}" ]; then TEMPLATE_ENV="$TEMPLATE_ENV @@ -1323,8 +1324,8 @@ bridge_render_launchd() { skill_filter_plist_args="$(_kimaki_skill_filter_args_plist)" local launchd_start launchd_start="${KIMAKI_DATA_DIR}/kimaki-config/launchd-start.sh" - local datamachine_wp_cmd - datamachine_wp_cmd=$(_kimaki_datamachine_wp_cmd) + local datamachine_wp_transport_json + datamachine_wp_transport_json=$(_kimaki_datamachine_wp_transport_json_xml) cat < @@ -1360,8 +1361,8 @@ $skill_filter_plist_args ${KIMAKI_DATA_DIR}/kimaki-config DATAMACHINE_SITE_PATH $SITE_PATH - DATAMACHINE_WP_CMD - $datamachine_wp_cmd + DATAMACHINE_WP_TRANSPORT_JSON + $datamachine_wp_transport_json KIMAKI_NO_DEFAULT_CHANNEL 1$(if [ -n "${AGENT_SLUG:-}" ]; then echo " DATAMACHINE_AGENT_SLUG @@ -1374,14 +1375,32 @@ $skill_filter_plist_args EOF } -_kimaki_datamachine_wp_cmd() { +_kimaki_datamachine_wp_transport_json() { if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then - external_wordpress_control_command + python3 - "$(external_wordpress_control_command)" <<'PY' +import json, sys +print(json.dumps(sys.argv[1:], separators=(",", ":"))) +PY return 0 fi - wp_cli_transport_ensure - wp_cli_transport_display - printf '\n' + wp_cli_transport_json +} + +_kimaki_datamachine_wp_transport_json_systemd() { + local value + value=$(_kimaki_datamachine_wp_transport_json) + value=${value//\\/\\\\} + value=${value//\"/\\\"} + printf '%s\n' "$value" +} + +_kimaki_datamachine_wp_transport_json_xml() { + local value + value=$(_kimaki_datamachine_wp_transport_json) + value=${value//&/\&} + value=${value///\>} + printf '%s\n' "$value" } _kimaki_skill_filter_mode() { diff --git a/bridges/kimaki/plugins/dm-agent-sync.ts b/bridges/kimaki/plugins/dm-agent-sync.ts index 3585020..f002d9f 100644 --- a/bridges/kimaki/plugins/dm-agent-sync.ts +++ b/bridges/kimaki/plugins/dm-agent-sync.ts @@ -8,7 +8,7 @@ import { spawn } from "node:child_process"; import type { Plugin } from "@opencode-ai/plugin"; -type WpCli = string; +type WpCli = string[]; const DEFAULT_COMPOSE_TIMEOUT_MS = 10_000; const OUTPUT_LIMIT = 16 * 1024; @@ -25,8 +25,12 @@ const dmAgentSync: Plugin = async () => { // Config runs for every CLI command. Capture only local state here; // invoking WordPress belongs to the real chat lifecycle below. + const wpCli = resolveWpCliTransport(); + if (!wpCli) { + return; + } sessionConfig = { - wpCli: process.env.DATAMACHINE_WP_CMD || process.env.WP_CMD || "wp", + wpCli, sitePath: getSitePath(), agentSlug: getAgentSlug(input), }; @@ -48,7 +52,7 @@ const dmAgentSync: Plugin = async () => { async function composeMemory(wpCli: WpCli, sitePath: string, agentSlug: string): Promise { const startedAt = Date.now(); - const result = await runBoundedCommand(datamachineCommand(wpCli, sitePath, agentSlug), getComposeTimeoutMs()); + const result = await runBoundedCommand(datamachineArgv(wpCli, sitePath, agentSlug), getComposeTimeoutMs()); const durationMs = Date.now() - startedAt; if (result.timedOut) { @@ -67,8 +71,8 @@ async function composeMemory(wpCli: WpCli, sitePath: string, agentSlug: string): console.warn(`[dm-agent-sync] recomposed Data Machine memory in ${durationMs}ms`); } -function datamachineCommand(wpCli: WpCli, sitePath: string, agentSlug: string): string { - const args = ["datamachine", "memory", "compose"]; +function datamachineArgv(wpCli: WpCli, sitePath: string, agentSlug: string): string[] { + const args = [...wpCli, "datamachine", "memory", "compose"]; if (agentSlug) { args.push(`--agent=${agentSlug}`); } @@ -76,12 +80,12 @@ function datamachineCommand(wpCli: WpCli, sitePath: string, agentSlug: string): args.push(`--path=${sitePath}`); } args.push("--allow-root"); - return [wpCli, ...args.map(shellQuote)].join(" "); + return args; } -function runBoundedCommand(command: string, timeoutMs: number): Promise<{ exitCode: number; output: string; timedOut: boolean }> { +function runBoundedCommand(argv: string[], timeoutMs: number): Promise<{ exitCode: number; output: string; timedOut: boolean }> { return new Promise((resolve) => { - const child = spawn("sh", ["-c", command], { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(argv[0], argv.slice(1), { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); let output = ""; let settled = false; const finish = (exitCode: number, timedOut: boolean) => { @@ -145,8 +149,25 @@ function getAgentSlug(input: { instructions?: string[] }): string { return ""; } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `"'"'`)}'`; +function resolveWpCliTransport(): WpCli | undefined { + const json = process.env.DATAMACHINE_WP_TRANSPORT_JSON; + if (json) { + try { + const value = JSON.parse(json) as unknown; + if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0 && !item.includes("\0"))) { + return value; + } + } catch { + return undefined; + } + return undefined; + } + + // Shipped service files used a whitespace-delimited command string. Keep + // that input boundary until upgraded services restart with canonical JSON. + const legacy = process.env.DATAMACHINE_WP_CMD || process.env.WP_CMD || "wp"; + const parts = legacy.trim().split(/[ \t]+/).filter(Boolean); + return parts.length > 0 ? parts : undefined; } export default dmAgentSync; diff --git a/tests/__snapshots__/bridges/kimaki-launchd b/tests/__snapshots__/bridges/kimaki-launchd index 5314828..16e9128 100644 --- a/tests/__snapshots__/bridges/kimaki-launchd +++ b/tests/__snapshots__/bridges/kimaki-launchd @@ -33,8 +33,8 @@ /home/chubes/.kimaki/kimaki-config DATAMACHINE_SITE_PATH /var/www/site - DATAMACHINE_WP_CMD - wp + DATAMACHINE_WP_TRANSPORT_JSON + ["wp"] KIMAKI_NO_DEFAULT_CHANNEL 1 DATAMACHINE_AGENT_SLUG diff --git a/tests/__snapshots__/bridges/kimaki-systemd b/tests/__snapshots__/bridges/kimaki-systemd index 5fb20cb..5de3163 100644 --- a/tests/__snapshots__/bridges/kimaki-systemd +++ b/tests/__snapshots__/bridges/kimaki-systemd @@ -10,7 +10,7 @@ Environment=HOME=/home/chubes Environment=PATH=/usr/bin:/usr/local/bin:/bin Environment=KIMAKI_DATA_DIR=/home/chubes/.kimaki Environment=DATAMACHINE_SITE_PATH=/var/www/site -Environment=DATAMACHINE_WP_CMD=wp +Environment=DATAMACHINE_WP_TRANSPORT_JSON=["wp"] Environment=DATAMACHINE_AGENT_SLUG=intelligence-chubes4 # Reap stray opencode-serve children left behind by the previous kimaki # process before starting a fresh one. Each kimaki session spawns its own diff --git a/tests/bridge-render.sh b/tests/bridge-render.sh index 4ddb4ac..03ef26f 100755 --- a/tests/bridge-render.sh +++ b/tests/bridge-render.sh @@ -101,11 +101,13 @@ kimaki_env_block() { 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) + local wp_transport_json + printf -v wp_transport_json '[\"%s\"]' "$WP_CMD" local out="Environment=HOME=$SERVICE_HOME Environment=PATH=$path_value Environment=KIMAKI_DATA_DIR=$KIMAKI_DATA_DIR Environment=DATAMACHINE_SITE_PATH=$SITE_PATH -Environment=DATAMACHINE_WP_CMD=$WP_CMD +Environment=DATAMACHINE_WP_TRANSPORT_JSON=$wp_transport_json Environment=DATAMACHINE_AGENT_SLUG=$AGENT_SLUG" if [ -n "${KIMAKI_BOT_TOKEN:-}" ]; then out="$out diff --git a/tests/dm-agent-sync.mjs b/tests/dm-agent-sync.mjs index e133ae7..4f70fee 100644 --- a/tests/dm-agent-sync.mjs +++ b/tests/dm-agent-sync.mjs @@ -1,7 +1,7 @@ // tests/dm-agent-sync.mjs — lifecycle tests for the Kimaki DM memory sync plugin. import assert from "node:assert/strict" -import { access, mkdtemp } from "node:fs/promises" +import { access, chmod, mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import dmAgentSync from "../bridges/kimaki/plugins/dm-agent-sync.ts" @@ -52,7 +52,8 @@ async function loadPlugin(config = {}) { await withEnv({ DATAMACHINE_SITE_PATH: sitePath, - DATAMACHINE_WP_CMD: "true", + DATAMACHINE_WP_TRANSPORT_JSON: '["true"]', + DATAMACHINE_WP_CMD: undefined, DATAMACHINE_AGENT_SLUG: "intelligence-chubes4", EXTERNAL_WORDPRESS: undefined, }, async () => { @@ -74,7 +75,8 @@ await withEnv({ await withEnv({ DATAMACHINE_SITE_PATH: sitePath, - DATAMACHINE_WP_CMD: "false", + DATAMACHINE_WP_TRANSPORT_JSON: '["false"]', + DATAMACHINE_WP_CMD: undefined, DATAMACHINE_AGENT_SLUG: "intelligence-chubes4", }, async () => { const run = await loadPlugin() @@ -83,7 +85,7 @@ await withEnv({ assert.ok(run.warnings.some((line) => line.includes("memory compose failed"))) }) -await withEnv({ EXTERNAL_WORDPRESS: "true", DATAMACHINE_WP_CMD: "false" }, async () => { +await withEnv({ EXTERNAL_WORDPRESS: "true", DATAMACHINE_WP_TRANSPORT_JSON: '["false"]' }, async () => { const run = await loadPlugin() await run.config() await run.chat() @@ -94,7 +96,7 @@ await withEnv({ DATAMACHINE_COMPOSE_TIMEOUT_MS: "10" }, async () => { const directory = await mkdtemp(join(tmpdir(), "dm-agent-sync-")) const marker = join(directory, "compose-finished") const run = await loadPlugin() - await withEnv({ DATAMACHINE_WP_CMD: `sh -c 'sleep 0.2; touch ${marker}'` }, async () => { + await withEnv({ DATAMACHINE_WP_TRANSPORT_JSON: JSON.stringify(["sh", "-c", `sleep 0.2; touch ${marker}`]) }, async () => { await run.config() const startedAt = Date.now() await run.chat() @@ -105,4 +107,39 @@ await withEnv({ DATAMACHINE_COMPOSE_TIMEOUT_MS: "10" }, async () => { assert.ok(run.warnings.some((line) => line.includes("memory compose timed out"))) }) +await withEnv({ DATAMACHINE_WP_CMD: "true", DATAMACHINE_WP_TRANSPORT_JSON: undefined }, async () => { + const run = await loadPlugin() + await run.config() + await run.chat() + assert.ok(run.warnings.some((line) => line.includes("recomposed Data Machine memory in"))) +}) + +await withEnv({ DATAMACHINE_SITE_PATH: sitePath, DATAMACHINE_WP_CMD: undefined }, async () => { + const directory = await mkdtemp(join(tmpdir(), "dm agent sync spaces ")) + const executable = join(directory, "wp cli") + const output = join(directory, "received args") + await writeFile(executable, `#!/bin/sh\nprintf '%s\\n' "$@" > "$DM_SYNC_TEST_OUTPUT"\n`) + await chmod(executable, 0o755) + + await withEnv({ + DATAMACHINE_WP_TRANSPORT_JSON: JSON.stringify([executable, "fixed argument with spaces"]), + DM_SYNC_TEST_OUTPUT: output, + }, async () => { + const run = await loadPlugin() + await run.config() + await run.chat() + assert.ok(run.warnings.some((line) => line.includes("recomposed Data Machine memory in"))) + }) + + const args = (await readFile(output, "utf8")).trim().split("\n") + assert.deepEqual(args, [ + "fixed argument with spaces", + "datamachine", + "memory", + "compose", + `--path=${sitePath}`, + "--allow-root", + ]) +}) + console.log("OK: dm-agent-sync runs bounded WordPress composition only for the first chat message per session") diff --git a/tests/kimaki-no-default-channel.sh b/tests/kimaki-no-default-channel.sh index a67cee5..f4812a4 100755 --- a/tests/kimaki-no-default-channel.sh +++ b/tests/kimaki-no-default-channel.sh @@ -74,11 +74,20 @@ DRY_RUN=false TIMESTAMP="test" UPDATED_ITEMS=() WP_CMD=wp +WP_CLI_TRANSPORT=(wp) IS_STUDIO=false systemctl() { :; } bridge_update_systemd +grep -q '^Environment=DATAMACHINE_WP_TRANSPORT_JSON=\[\\"wp\\"\]$' "$SYSTEMD_UNIT_DIR/kimaki.service" +check $? "upgrade writes the argv-native WordPress transport" +if grep -q '^Environment=DATAMACHINE_WP_CMD=' "$SYSTEMD_UNIT_DIR/kimaki.service"; then + check 1 "upgrade removes the legacy WordPress command" +else + check 0 "upgrade removes the legacy WordPress command" +fi + UNIT="$SYSTEMD_UNIT_DIR/kimaki.service" [ "$(grep -c '^Environment=KIMAKI_NO_DEFAULT_CHANNEL=1$' "$UNIT")" -eq 1 ] check $? "upgrade adds the opt-out exactly once"