diff --git a/bridges/kimaki.sh b/bridges/kimaki.sh index c8733be..54cc39b 100644 --- a/bridges/kimaki.sh +++ b/bridges/kimaki.sh @@ -811,9 +811,6 @@ _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) - # Kimaki recreates a general-purpose #kimaki- channel, welcome message, # and tutorial thread on every start. On a wp-coding-agents install the real # project channel is the site channel, so the default is pure noise. Upstream @@ -824,7 +821,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 +$(_kimaki_datamachine_wp_transport_systemd_env) Environment=KIMAKI_NO_DEFAULT_CHANNEL=1" if [ -n "${AGENT_SLUG:-}" ]; then ENV_BLOCK="$ENV_BLOCK @@ -1150,6 +1147,8 @@ 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) + CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" DATAMACHINE_WP_TRANSPORT_JSON) if [ "${KIMAKI_DATA_DIR_EXPLICIT:-false}" = true ]; then CURRENT_ENV=$(_kimaki_remove_systemd_env_key "$CURRENT_ENV" KIMAKI_DATA_DIR) fi @@ -1177,7 +1176,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) +$(_kimaki_datamachine_wp_transport_systemd_env) Environment=KIMAKI_NO_DEFAULT_CHANNEL=1" if [ -n "${KIMAKI_LOCK_PORT:-}" ]; then TEMPLATE_ENV="$TEMPLATE_ENV @@ -1323,8 +1322,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_xml_text "$(_kimaki_datamachine_wp_transport_json)") cat < @@ -1360,8 +1359,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 +1373,38 @@ $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_systemd_env() { + local value escaped + value=$(_kimaki_datamachine_wp_transport_json) + case "$value" in + *[[:space:]]*) + escaped=${value//\\/\\\\} + escaped=${escaped//\"/\\\"} + printf 'Environment=DATAMACHINE_WP_TRANSPORT_JSON="%s"' "$escaped" + ;; + *) + printf 'Environment=DATAMACHINE_WP_TRANSPORT_JSON=%s' "$value" + ;; + esac +} + +_kimaki_xml_text() { + local value="$1" + value=${value//&/\&} + value=${value///\>} + printf '%s' "$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..609f310 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,13 @@ 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 }> { + const [command, ...args] = argv; return new Promise((resolve) => { - const child = spawn("sh", ["-c", command], { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(command, args, { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); let output = ""; let settled = false; const finish = (exitCode: number, timedOut: boolean) => { @@ -120,6 +125,34 @@ function runBoundedCommand(command: string, timeoutMs: number): Promise<{ exitCo }); } +function resolveWpCliTransport(): WpCli | undefined { + const json = process.env.DATAMACHINE_WP_TRANSPORT_JSON; + if (json) { + return parseTransportJson(json); + } + return parseShippedWpCmd(process.env.DATAMACHINE_WP_CMD || process.env.WP_CMD || "wp"); +} + +function parseTransportJson(raw: string): WpCli | undefined { + try { + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + if (value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\0"))) { + return undefined; + } + return value; + } catch { + return undefined; + } +} + +function parseShippedWpCmd(raw: string): WpCli | undefined { + const parts = raw.trim().split(/[ \t]+/).filter((part) => part.length > 0); + return parts.length > 0 ? parts : undefined; +} + function getComposeTimeoutMs(): number { const configured = Number(process.env.DATAMACHINE_COMPOSE_TIMEOUT_MS); return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_COMPOSE_TIMEOUT_MS; @@ -145,8 +178,4 @@ function getAgentSlug(input: { instructions?: string[] }): string { return ""; } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `"'"'`)}'`; -} - 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..44f30e7 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..07f513e 100755 --- a/tests/bridge-render.sh +++ b/tests/bridge-render.sh @@ -101,11 +101,15 @@ 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 transport_json + transport_json=$(wp_cli_transport_json) + transport_json=${transport_json//\\/\\\\} + transport_json=${transport_json//\"/\\\"} 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=\"$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..6a602f1 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, mkdir, 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,7 @@ await withEnv({ await withEnv({ DATAMACHINE_SITE_PATH: sitePath, - DATAMACHINE_WP_CMD: "false", + DATAMACHINE_WP_TRANSPORT_JSON: '["false"]', DATAMACHINE_AGENT_SLUG: "intelligence-chubes4", }, async () => { const run = await loadPlugin() @@ -83,18 +84,45 @@ 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() assert.equal(run.warnings.length, 0) }) +await withEnv({ + DATAMACHINE_SITE_PATH: sitePath, + DATAMACHINE_WP_TRANSPORT_JSON: "[]", + DATAMACHINE_WP_CMD: "false", + DATAMACHINE_AGENT_SLUG: "intelligence-chubes4", +}, async () => { + const run = await loadPlugin() + await run.config() + await run.chat() + assert.equal(run.warnings.length, 0) +}) + +await withEnv({ + DATAMACHINE_SITE_PATH: sitePath, + DATAMACHINE_WP_TRANSPORT_JSON: undefined, + DATAMACHINE_WP_CMD: "true", + DATAMACHINE_AGENT_SLUG: "intelligence-chubes4", +}, 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_COMPOSE_TIMEOUT_MS: "10" }, async () => { const directory = await mkdtemp(join(tmpdir(), "dm-agent-sync-")) const marker = join(directory, "compose-finished") + const sleeper = join(directory, "slow-compose") + await writeFile(sleeper, `#!/bin/sh\nsleep 0.2\ntouch '${marker}'\n`) + await chmod(sleeper, 0o755) 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([sleeper]) }, async () => { await run.config() const startedAt = Date.now() await run.chat() @@ -105,4 +133,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: "false", + DATAMACHINE_AGENT_SLUG: "agent with spaces", +}, async () => { + const directory = await mkdtemp(join(tmpdir(), "dm-agent-sync-argv-")) + const binDir = join(directory, "bin with spaces") + const siteDir = join(directory, "site with spaces") + const dump = join(directory, "argv.json") + const recorder = join(binDir, "wp cli") + await mkdir(binDir) + await writeFile(recorder, `#!/bin/sh +python3 -c 'import json,os,sys; open(os.environ["DM_ARGV_DUMP"],"w").write(json.dumps(sys.argv[1:]))' "$@" +`) + await chmod(recorder, 0o755) + const run = await loadPlugin() + await withEnv({ + DATAMACHINE_SITE_PATH: siteDir, + DATAMACHINE_WP_TRANSPORT_JSON: JSON.stringify([recorder, "--flag with spaces"]), + DM_ARGV_DUMP: dump, + }, async () => { + await run.config() + await run.chat() + }) + const argv = JSON.parse(await readFile(dump, "utf8")) + assert.deepEqual(argv, [ + "--flag with spaces", + "datamachine", + "memory", + "compose", + "--agent=agent with spaces", + `--path=${siteDir}`, + "--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..b4d8ed0 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 -Fq '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" @@ -99,6 +108,21 @@ printf '%s' "$PLIST" | grep -q 'KIMAKI_NO_DEFAULT_CHANNEL' check $? "launchd plist declares the opt-out key" printf '%s' "$PLIST" | grep -A1 'KIMAKI_NO_DEFAULT_CHANNEL' | grep -q '1' check $? "launchd plist sets it to 1" +printf '%s' "$PLIST" | grep -q 'DATAMACHINE_WP_TRANSPORT_JSON' +check $? "launchd plist declares argv-native WordPress transport" +printf '%s' "$PLIST" | grep -A1 'DATAMACHINE_WP_TRANSPORT_JSON' | grep -q '\["wp"\]' +check $? "launchd plist stores the canonical argv JSON" + +WP_CLI_TRANSPORT=("/tmp/wp cli" "--flag with spaces") +PLIST_SPACES="$(SITE_PATH="$TMP/site" KIMAKI_DATA_DIR="$TMP/.kimaki" KIMAKI_BIN=/usr/bin/kimaki \ + bridge_render_launchd com.wp.kimaki 2>/dev/null)" +printf '%s' "$PLIST_SPACES" | grep -q '\["/tmp/wp cli","--flag with spaces"\]' +check $? "launchd plist preserves paths and args with spaces" +[ "$(_kimaki_datamachine_wp_transport_systemd_env)" = 'Environment=DATAMACHINE_WP_TRANSPORT_JSON="[\"/tmp/wp cli\",\"--flag with spaces\"]"' ] +check $? "systemd env quotes argv JSON when paths or args contain spaces" +WP_CLI_TRANSPORT=(wp) +[ "$(_kimaki_datamachine_wp_transport_systemd_env)" = 'Environment=DATAMACHINE_WP_TRANSPORT_JSON=["wp"]' ] +check $? "systemd env keeps compact argv JSON without extra quoting" echo "==> fresh systemd install declares the opt-out"