diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 5f2c49d..89eba87 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -66,6 +66,8 @@ jobs: run: bun tests/dm-agent-sync.mjs - name: Run tests/kimaki-session-attribution.mjs run: bun tests/kimaki-session-attribution.mjs + - name: Run tests/setup-profile-compiler.mjs + run: node tests/setup-profile-compiler.mjs cli-transport: name: CLI dispatch transport runtime @@ -221,6 +223,7 @@ jobs: - codex-runtime - datamachine-worker - detect-site-domain + - external-wordpress-runtime - dm-workspace-discovery - homeboy-codebox-canary - homeboy-components diff --git a/README.md b/README.md index 0ca79b0..437d196 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,46 @@ cd wp-coding-agents SITE_DOMAIN=example.com ./setup.sh ``` +### External WordPress Runtime + +Run the coding runtime on a host without a mounted WordPress tree. The control +transport is a JSON argv array so paths and arguments are preserved without +shell parsing. It is runtime input only; setup never writes it into generated +files. Keep credentials out of argv: the transport executable should resolve +them from its process environment or credential store. + +```bash +RUNTIME_PROJECT_ROOT=/srv/agent \ +WP_CONTROL_TRANSPORT_JSON='["/usr/local/bin/wp-control","--target","site-a"]' \ +./setup.sh --external-wordpress --wordpress-path '/srv/wordpress site' \ + --wordpress-user agent --runtime opencode --chat kimaki +``` + +Setup validates `core is-installed` through the transport, then projects Data +Machine injectable files below `$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context`. +OpenCode, Kimaki state, skills, `AGENTS.md`, and `CLAUDE.md` stay below the +runtime root. The transport must provide WordPress commands and must not be +stored in project files. + +Start Kimaki from the same runtime environment so the generated +`.wp-coding-agents/bin/wp-control` wrapper can read the transport variables. +External profiles deliberately avoid writing those variables to launchd or +systemd units. Re-run setup to refresh projected Data Machine context. + +```bash +WP_CONTROL_TRANSPORT_JSON='["/usr/local/bin/wp-control","--target","site-a"]' \ + /srv/agent/.wp-coding-agents/bin/kimaki +``` + +The setup profile compiler emits this credential-bearing start command +separately from the one-shot setup command. The first portable profile supports +OpenCode; select `runtime.selection=opencode` explicitly. + +Use the same transport input when validating an external profile. `verify.sh` +continues to validate colocated WordPress installs; external runtime validation +is the transport's `core is-installed` check performed by setup plus inspection +of the runtime-local config and projected context. + For agent-assisted setup, give your local coding agent the setup entrypoint: ```text diff --git a/bridges/kimaki.sh b/bridges/kimaki.sh index f0ba740..c4cc274 100644 --- a/bridges/kimaki.sh +++ b/bridges/kimaki.sh @@ -168,7 +168,10 @@ bridge_install() { log "Kimaki already installed: $(kimaki --version 2>/dev/null | head -1)" fi - if [ "$LOCAL_MODE" = true ] && [ "$PLATFORM" = "mac" ]; then + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + log "External WordPress profile: Kimaki installed. Start it from the runtime environment with:" + log " WP_CONTROL_TRANSPORT_JSON='' $(external_wordpress_kimaki_command)" + elif [ "$LOCAL_MODE" = true ] && [ "$PLATFORM" = "mac" ]; then _kimaki_install_launchd elif [ "$LOCAL_MODE" = true ]; then log "Local mode: Kimaki installed. Run manually with:" @@ -178,6 +181,7 @@ bridge_install() { fi _kimaki_sync_bin_helpers + [ "${EXTERNAL_WORDPRESS:-false}" != true ] || return 0 _kimaki_register_cli_channel _kimaki_register_runtime_signature } @@ -1340,6 +1344,10 @@ EOF } _kimaki_datamachine_wp_cmd() { + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + external_wordpress_control_command + return 0 + fi if [ "${IS_STUDIO:-false}" = true ]; then printf '%s\n' "studio wp" return 0 diff --git a/bridges/kimaki/plugins/dm-agent-sync.ts b/bridges/kimaki/plugins/dm-agent-sync.ts index c37dfc5..b22fa1e 100644 --- a/bridges/kimaki/plugins/dm-agent-sync.ts +++ b/bridges/kimaki/plugins/dm-agent-sync.ts @@ -35,6 +35,12 @@ interface InjectableFile { const dmAgentSync: Plugin = async ({ $ }) => { return { config: async (input) => { + if (process.env.EXTERNAL_WORDPRESS === "true") { + // Portable profiles project remote memory during setup. Replacing the + // configured local paths with WordPress-host paths would make every + // instruction unreachable from this runtime. + return; + } const wpCli = await resolveWpCli($); if (!wpCli) { return; diff --git a/lib/detect.sh b/lib/detect.sh index 36b0588..8d3127e 100644 --- a/lib/detect.sh +++ b/lib/detect.sh @@ -114,7 +114,13 @@ detect_environment() { detect_php_version # Configuration - if [ "$MODE" = "existing" ]; then + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + # SITE_PATH remains the legacy runtime destination for modules that have not + # needed a distinct root. It never denotes the remote WordPress filesystem. + SITE_PATH="$RUNTIME_PROJECT_ROOT" + SITE_DOMAIN="${SITE_DOMAIN:-$(basename "$WORDPRESS_PATH")}" + log "External WordPress at: $WORDPRESS_PATH (runtime: $RUNTIME_PROJECT_ROOT)" + elif [ "$MODE" = "existing" ]; then if [ -z "$EXISTING_WP" ]; then error "EXISTING_WP must be set when using --existing mode or --wp-path" fi @@ -178,6 +184,9 @@ detect_environment() { WP_ADMIN_EMAIL="${WP_ADMIN_EMAIL:-admin@$SITE_DOMAIN}" detect_service_identity + if [ "${EXTERNAL_WORDPRESS:-false}" = true ] && [ "${KIMAKI_DATA_DIR_EXPLICIT:-false}" != true ]; then + KIMAKI_DATA_DIR="$RUNTIME_PROJECT_ROOT/.kimaki" + fi } # Derive SERVICE_USER / SERVICE_HOME / KIMAKI_DATA_DIR / DM_WORKSPACE_DIR from diff --git a/lib/external-wordpress.sh b/lib/external-wordpress.sh new file mode 100644 index 0000000..5637c8c --- /dev/null +++ b/lib/external-wordpress.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# External WordPress control transport for runtimes that do not mount the site. + +runtime_project_root() { + printf '%s' "${RUNTIME_PROJECT_ROOT:-$SITE_PATH}" +} + +external_wordpress_control_command() { + printf '%s' "$(runtime_project_root)/.wp-coding-agents/bin/wp-control" +} + +external_wordpress_kimaki_command() { + printf '%s' "$(runtime_project_root)/.wp-coding-agents/bin/kimaki" +} + +external_wordpress_prepare_transport() { + [ "${EXTERNAL_WORDPRESS:-false}" = true ] || return 0 + [ -n "${RUNTIME_PROJECT_ROOT:-}" ] || error "--external-wordpress requires --runtime-project-root or RUNTIME_PROJECT_ROOT" + [ -n "${WORDPRESS_PATH:-}" ] || error "--external-wordpress requires --wordpress-path or WORDPRESS_PATH" + [ -n "${WP_CONTROL_TRANSPORT_JSON:-}" ] || error "--external-wordpress requires WP_CONTROL_TRANSPORT_JSON, a JSON argv array" + python3 - "$WP_CONTROL_TRANSPORT_JSON" <<'PY' || error "WP_CONTROL_TRANSPORT_JSON must be a non-empty JSON array of non-empty strings without NUL bytes" +import json, sys +value = json.loads(sys.argv[1]) +if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item or "\0" in item for item in value): + raise SystemExit(1) +PY + mapfile -d '' -t WP_CONTROL_TRANSPORT < <(python3 - "$WP_CONTROL_TRANSPORT_JSON" <<'PY' +import json, sys +for item in json.loads(sys.argv[1]): + sys.stdout.buffer.write(item.encode() + b"\0") +PY +) + + if [ "${DRY_RUN:-false}" != true ]; then + mkdir -p "$RUNTIME_PROJECT_ROOT" + RUNTIME_PROJECT_ROOT=$(cd "$RUNTIME_PROJECT_ROOT" && pwd) + local control_dir control_command kimaki_command profile_file + control_command="$(external_wordpress_control_command)" + kimaki_command="$(external_wordpress_kimaki_command)" + control_dir="${control_command%/*}" + profile_file="$(runtime_project_root)/.wp-coding-agents/wordpress.json" + if [ -L "$(runtime_project_root)/.wp-coding-agents" ]; then + error "Refusing external runtime state through a symlink: $(runtime_project_root)/.wp-coding-agents" + fi + mkdir -p "$control_dir" + cp "$SCRIPT_DIR/scripts/wp-control-transport.py" "$control_command" + cp "$SCRIPT_DIR/scripts/external-wordpress-kimaki.py" "$kimaki_command" + chmod 0755 "$control_command" + chmod 0755 "$kimaki_command" + python3 - "$profile_file" "$WORDPRESS_PATH" "${WORDPRESS_USER:-}" <<'PY' +import json, sys +path, wordpress_path, wordpress_user = sys.argv[1:] +with open(path, "w", encoding="utf-8") as stream: + json.dump({"wordpress_path": wordpress_path, "wordpress_user": wordpress_user}, stream, indent=2) + stream.write("\n") +PY + fi +} + +external_wordpress_validate() { + [ "${EXTERNAL_WORDPRESS:-false}" = true ] || return 0 + [ "${DRY_RUN:-false}" = true ] && return 0 + wp_cmd core is-installed >/dev/null 2>&1 || error "External WordPress validation failed through the supplied control transport" +} + +# Materialize remote Data Machine files locally. Validated paths cannot escape +# the runtime project, and the transport value is never rendered into files. +external_wordpress_project_context() { + [ "${EXTERNAL_WORDPRESS:-false}" = true ] || return 0 + [ "${DRY_RUN:-false}" = true ] && return 0 + local root parent generations lock lock_owner attempts staging generation raw json record filename layer destination content agent_args=() + [ -z "${AGENT_SLUG:-}" ] || agent_args=("--agent=$AGENT_SLUG") + root="$(runtime_project_root)/.wp-coding-agents/context" + parent="${root%/*}" + generations="$parent/context-generations" + lock="$parent/context.lock" + if [ -L "$parent" ] || [ -L "$generations" ]; then + error "Refusing projected context through a symlinked runtime state directory" + fi + if [ -e "$root" ] && [ ! -L "$root" ]; then + error "Refusing to replace non-managed projected context: $root" + fi + mkdir -p "$parent" + lock_owner="${BASHPID:-$$}" + attempts=0 + while ! mkdir "$lock" 2>/dev/null; do + if [ -L "$lock" ]; then + error "Refusing projected context lock through a symlink: $lock" + fi + local recorded_owner="" + [ ! -f "$lock/pid" ] || recorded_owner=$(<"$lock/pid") + if [ -n "$recorded_owner" ] && ! kill -0 "$recorded_owner" 2>/dev/null; then + rm -f "$lock/pid" + rmdir "$lock" 2>/dev/null || true + continue + fi + attempts=$((attempts + 1)) + [ "$attempts" -lt 200 ] || error "Timed out waiting for projected context lock" + sleep 0.05 + done + printf '%s\n' "$lock_owner" > "$lock/pid" + + raw="$(wp_cmd datamachine memory injectable-files --format=json "${agent_args[@]}" 2>/dev/null)" || error "Could not list injectable Data Machine context through the external control transport" + json="$(printf '%s\n' "$raw" | sed -n '/^\[/,/^\]/p')" + [ -n "$json" ] || error "External Data Machine context listing returned no JSON" + mkdir -p "$generations" + staging=$(mktemp -d "$generations/.tmp.XXXXXX") || error "Could not create projected context staging directory" + DM_AGENT_FILES="" + while IFS= read -r record; do + [ -n "$record" ] || continue + filename="${record%%$'\t'*}" + layer="${record#*$'\t'}" + case "$filename" in ''|*/*|*'..'*) error "Unsafe injectable context filename: $filename" ;; esac + case "$layer" in ''|*[!A-Za-z0-9_-]*) error "Unsafe injectable context layer: $layer" ;; esac + destination="$staging/$layer/$filename" + mkdir -p "$(dirname "$destination")" + content="$(wp_cmd datamachine memory read "$filename" "${agent_args[@]}" 2>/dev/null)" || error "Could not read injectable Data Machine context '$filename' through the external control transport" + printf '%s\n' "$content" > "$destination" + DM_AGENT_FILES="${DM_AGENT_FILES}${DM_AGENT_FILES:+$'\n'}.wp-coding-agents/context/$layer/$filename" + done < <(printf '%s' "$json" | python3 -c 'import json,sys; [print(item["filename"] + "\t" + item["layer"]) for item in json.load(sys.stdin) if isinstance(item, dict) and isinstance(item.get("filename"), str) and isinstance(item.get("layer"), str)]') + [ -n "$DM_AGENT_FILES" ] || error "External Data Machine context listing contains no file paths" + generation="${staging##*/}" + python3 - "$root" "$parent" "$generations" "$generation" <<'PY' || error "Could not atomically activate projected Data Machine context" +import os +import shutil +import sys + +root, parent, generations, generation = sys.argv[1:] +if os.path.islink(parent) or os.path.islink(generations): + raise SystemExit(1) +if os.path.lexists(root) and not os.path.islink(root): + raise SystemExit(1) + +temporary_link = os.path.join(parent, f".context-link.{os.getpid()}") +try: + os.symlink(os.path.join("context-generations", generation), temporary_link) + os.replace(temporary_link, root) +finally: + if os.path.lexists(temporary_link): + os.unlink(temporary_link) + +for entry in os.scandir(generations): + if entry.name == generation: + continue + if entry.is_symlink() or not entry.is_dir(follow_symlinks=False): + os.unlink(entry.path) + else: + shutil.rmtree(entry.path) +PY + rm -f "$lock/pid" + rmdir "$lock" +} diff --git a/lib/repair-opencode-json.py b/lib/repair-opencode-json.py index acb7f17..3fbf28f 100755 --- a/lib/repair-opencode-json.py +++ b/lib/repair-opencode-json.py @@ -76,6 +76,7 @@ MANAGED_KIMAKI_PLUGIN_NAMES = {"dm-context-filter.ts", "dm-agent-sync.ts", "kimaki-session-attribution.ts"} OBSOLETE_KIMAKI_PLUGIN_NAMES = {"homeboy-notification-context.ts"} DM_MEMORY_MARKER = "/datamachine-files/" +PROJECTED_MEMORY_MARKER = "/.wp-coding-agents/context/" # Every installed path wp-coding-agents manages, as ready-made edit patterns in # canonical order. Denied under BOTH modes. # @@ -353,7 +354,10 @@ def read_managed_instructions(path: str) -> List[str]: def is_dm_managed_instruction(value: object) -> bool: - return isinstance(value, str) and DM_MEMORY_MARKER in value.replace("\\", "/") + if not isinstance(value, str): + return False + normalized = value.replace("\\", "/") + return DM_MEMORY_MARKER in normalized or PROJECTED_MEMORY_MARKER in normalized def check_instruction_sync(data: dict, desired: List[str]) -> dict: diff --git a/lib/skills.sh b/lib/skills.sh index 12b2243..a73aa1a 100644 --- a/lib/skills.sh +++ b/lib/skills.sh @@ -79,7 +79,7 @@ install_skills_to_persistent_source() { return fi - if [ "$(id -u)" -ne 0 ] && [ ! -w "$persistent_dir" ]; then + if [ "$(id -u)" -ne 0 ] && [ -e "$persistent_dir" ] && [ ! -w "$persistent_dir" ]; then local skill_dir skill_name for skill_dir in "$SCRIPT_DIR/skills"/*/; do [ -d "$skill_dir" ] || continue diff --git a/lib/source-policy.sh b/lib/source-policy.sh index 5d8ad15..3a257e3 100644 --- a/lib/source-policy.sh +++ b/lib/source-policy.sh @@ -77,6 +77,10 @@ SOURCE_POLICY_LEGACY_WRITABLE_OPTION="wp_coding_agents_managed_writable" _source_policy_option_read() { local key="$1" [ -n "${SITE_PATH:-}" ] || return 0 + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + wp_cmd option get "$key" 2>/dev/null || true + return 0 + fi if [ "${IS_STUDIO:-false}" = true ]; then studio wp option get "$key" --path="$SITE_PATH" 2>/dev/null || true return 0 diff --git a/lib/wordpress.sh b/lib/wordpress.sh index 2afb794..2de62f9 100644 --- a/lib/wordpress.sh +++ b/lib/wordpress.sh @@ -3,6 +3,12 @@ # Run a WP-CLI command with the correct flags for the current platform. wp_cmd() { + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + local user_args=() + [ -z "${WORDPRESS_USER:-}" ] || user_args=("--user=$WORDPRESS_USER") + run_cmd "${WP_CONTROL_TRANSPORT[@]}" "$@" "${user_args[@]}" "--path=$WORDPRESS_PATH" + return + fi if [ "$IS_STUDIO" = true ]; then # shellcheck disable=SC2086 run_cmd studio wp "$@" --path="$SITE_PATH" diff --git a/operator-entrypoints/wp-coding-agents-setup/interview.md b/operator-entrypoints/wp-coding-agents-setup/interview.md index eae68c0..ad0f7a8 100644 --- a/operator-entrypoints/wp-coding-agents-setup/interview.md +++ b/operator-entrypoints/wp-coding-agents-setup/interview.md @@ -10,6 +10,7 @@ Collect the facts needed to install wp-coding-agents. Do not build commands, run - `fresh-vps` — new VPS, new WordPress site. - `existing-vps` — WordPress already exists on a server. - `migration` — site exists elsewhere and is moving before setup. + - `external-runtime` — the runtime reaches WordPress through an operator-supplied command transport without mounting its filesystem. 2. **Target details** Collect only the fields relevant to the selected target: @@ -17,6 +18,7 @@ Collect the facts needed to install wp-coding-agents. Do not build commands, run - Fresh VPS: server host/IP, SSH user, domain, and whether SSL should be skipped. - Existing VPS: server host/IP, SSH user, WordPress root path, and domain if known. - Migration: source backup status, destination server host/IP, SSH user, final WordPress root path, and domain. + - External runtime: runtime project root, WordPress-side root path, optional WordPress user, and control transport argv. Keep credentials in the runtime environment or credential store rather than argv. 3. **Runtime axis** Ask which runtime or runtimes the user wants: @@ -26,6 +28,7 @@ Collect the facts needed to install wp-coding-agents. Do not build commands, run - `multiple` If they choose multiple, collect the desired runtime list. If they are unsure, record `auto` so `setup.sh` can auto-detect. + External runtimes currently require an explicit `opencode` selection. 4. **Chat bridge axis** Ask how the user wants to communicate with the agent: @@ -66,12 +69,15 @@ Return the profile as JSON in this shape so the compiler script can map it deter ```json { - "install_target": "local | fresh-vps | existing-vps | migration", + "install_target": "local | fresh-vps | existing-vps | migration | external-runtime", "target": { "ssh_host": "", "ssh_user": "", "domain": "", "wordpress_path": "", + "runtime_project_root": "", + "wordpress_user": "", + "control_transport_argv": [], "wordpress_studio": false, "migration_backups_ready": false }, diff --git a/operator-entrypoints/wp-coding-agents-setup/verify.md b/operator-entrypoints/wp-coding-agents-setup/verify.md index 24c6217..67b2995 100644 --- a/operator-entrypoints/wp-coding-agents-setup/verify.md +++ b/operator-entrypoints/wp-coding-agents-setup/verify.md @@ -36,6 +36,30 @@ wp --allow-root plugin list --path=/path/to/site | grep data-machine ## Target Overlays +### `verify-external-wordpress-transport` + +Run from the runtime environment that supplies `WP_CONTROL_TRANSPORT_JSON`, +`WORDPRESS_PATH`, and optional `WORDPRESS_USER`: + +```bash +/path/to/runtime/.wp-coding-agents/bin/wp-control core is-installed +``` + +### `verify-data-machine-projection` + +```bash +test -s /path/to/runtime/opencode.json +test -s /path/to/runtime/AGENTS.md +test -d /path/to/runtime/.wp-coding-agents/context +test -d /path/to/runtime/.opencode/skills +test ! -e /path/to/runtime/wp-content +``` + +Every `instructions` entry in `opencode.json` must resolve below the runtime +root. Start Kimaki from the same credential-bearing process environment; +external profiles intentionally do not persist the transport in launchd or +systemd configuration. + ### `verify-wordpress-studio` ```bash diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index 499c471..8706ff0 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -66,6 +66,7 @@ opencode_install_claude_code_auth_plugin() { # is idempotent and only mutates the mu-plugin file when the env-var map # actually differs. _opencode_register_runtime_signature() { + [ "${EXTERNAL_WORDPRESS:-false}" != true ] || return 0 if ! declare -F runtime_signature_register >/dev/null; then # The helper lives in lib/runtime-signature.sh, sourced by setup.sh and # upgrade.sh. When this function is invoked outside those entry points @@ -196,8 +197,8 @@ runtime_generate_config() { KIMAKI_PLUGINS_DIR="" if [ "$CHAT_BRIDGE" = "kimaki" ]; then if [ "$LOCAL_MODE" = true ]; then - KIMAKI_PLUGINS_DIR="${KIMAKI_DATA_DIR:-$HOME/.kimaki}/kimaki-config/plugins" - if [ "$DRY_RUN" = false ] && [ -n "$KIMAKI_PLUGINS_DIR" ] && [ -d "$(dirname "$KIMAKI_PLUGINS_DIR")" ]; then + KIMAKI_PLUGINS_DIR="${KIMAKI_DATA_DIR:-$(runtime_project_root)/.kimaki}/kimaki-config/plugins" + if [ "$DRY_RUN" = false ] && [ -n "$KIMAKI_PLUGINS_DIR" ]; then mkdir -p "$KIMAKI_PLUGINS_DIR" cp "$SCRIPT_DIR/bridges/kimaki/plugins/dm-context-filter.ts" "$KIMAKI_PLUGINS_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/bridges/kimaki/plugins/dm-agent-sync.ts" "$KIMAKI_PLUGINS_DIR/" 2>/dev/null || true @@ -397,7 +398,13 @@ _runtime_repair_opencode_json_additive() { local managed_args=() if [ -n "${DM_AGENT_FILES:-}" ]; then MANAGED_INSTRUCTIONS_FILE=$(mktemp) - printf '%s\n' "$DM_AGENT_FILES" > "$MANAGED_INSTRUCTIONS_FILE" + while IFS= read -r managed_instruction; do + [ -n "$managed_instruction" ] || continue + case "$managed_instruction" in + /*|http://*|https://*|~/*) printf '%s\n' "$managed_instruction" ;; + *) printf './%s\n' "$managed_instruction" ;; + esac + done <<< "$DM_AGENT_FILES" > "$MANAGED_INSTRUCTIONS_FILE" managed_args=(--managed-instructions-file "$MANAGED_INSTRUCTIONS_FILE") fi if opencode_claude_code_auth_enabled; then @@ -412,7 +419,7 @@ _runtime_repair_opencode_json_additive() { --file "$SITE_PATH/opencode.json" \ --runtime opencode \ --chat-bridge "$BRIDGE_ARG" \ - --posture "${SOURCE_MODE:-workspace}" \ + --source-mode "${SOURCE_MODE:-workspace}" \ "${_managed_source_args[@]}" \ --kimaki-plugins-dir "$PLUGINS_DIR" \ "${claude_code_auth_args[@]}" \ @@ -456,7 +463,7 @@ runtime_generate_instructions() { # Compose from Data Machine's SectionRegistry. DM is mandatory, and compose # handles WP-CLI prefix resolution, multisite detection, and plugin sections # (intelligence, etc.) automatically at runtime. - if [ "$DRY_RUN" = false ]; then + if [ "$DRY_RUN" = false ] && [ "${EXTERNAL_WORDPRESS:-false}" != true ]; then sync_homeboy_availability if wp_cmd datamachine memory compose AGENTS.md 2>/dev/null; then service_file_normalize_perms "$SITE_PATH/AGENTS.md" @@ -469,12 +476,19 @@ runtime_generate_instructions() { # Fallback for dry-run or compose failure: ship a minimal static template. local agents_tmpl="$SCRIPT_DIR/workspace/AGENTS.md" + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + agents_tmpl="$SCRIPT_DIR/workspace/AGENTS.external.md" + fi if [ ! -f "$agents_tmpl" ]; then error "AGENTS.md template not found at $agents_tmpl" fi local wp_cli_display="wp" - if [ "$IS_STUDIO" = true ]; then + if [ "${EXTERNAL_WORDPRESS:-false}" = true ]; then + # This checked-in wrapper reads the operator-owned transport from process + # environment; no transport argv or credential is rendered into guidance. + wp_cli_display="./.wp-coding-agents/bin/wp-control" + elif [ "$IS_STUDIO" = true ]; then wp_cli_display="studio wp" elif [ "$LOCAL_MODE" = false ]; then wp_cli_display="wp $WP_ROOT_FLAG --path=$SITE_PATH" diff --git a/scripts/compile-setup-profile.mjs b/scripts/compile-setup-profile.mjs index 46a2730..041e19f 100755 --- a/scripts/compile-setup-profile.mjs +++ b/scripts/compile-setup-profile.mjs @@ -139,6 +139,19 @@ function compile(profile) { env.EXISTING_WP = target.wordpress_path addFlag(command, "--existing") break + case "external-runtime": + if (!target.runtime_project_root) throw new Error("External runtime setup requires target.runtime_project_root") + if (!target.wordpress_path) throw new Error("External runtime setup requires target.wordpress_path") + if (!target.control_transport_argv) throw new Error("External runtime setup requires target.control_transport_argv") + if (!Array.isArray(target.control_transport_argv) || !target.control_transport_argv.length || target.control_transport_argv.some((value) => typeof value !== "string" || !value)) { + throw new Error("target.control_transport_argv must be a non-empty argv array") + } + env.RUNTIME_PROJECT_ROOT = target.runtime_project_root + env.WP_CONTROL_TRANSPORT_JSON = JSON.stringify(target.control_transport_argv) + addFlag(command, "--external-wordpress") + addFlag(command, "--wordpress-path", target.wordpress_path) + if (target.wordpress_user) addFlag(command, "--wordpress-user", target.wordpress_user) + break default: throw new Error(`Unknown install_target: ${installTarget}`) } @@ -148,6 +161,9 @@ function compile(profile) { } const runtime = normalizeRuntime(profile, availableRuntimes, warnings) + if (installTarget === "external-runtime" && runtime.selection !== "opencode") { + throw new Error("External runtime setup currently requires runtime.selection=opencode") + } if (runtime.flag) { addFlag(command, "--runtime", runtime.flag) } @@ -185,7 +201,7 @@ function compile(profile) { } } - const verification = new Set(["verify-wordpress", "verify-data-machine"]) + const verification = new Set(installTarget === "external-runtime" ? ["verify-external-wordpress-transport", "verify-data-machine-projection"] : ["verify-wordpress", "verify-data-machine"]) if (target.wordpress_studio || overlays.wordpress_studio) verification.add("verify-wordpress-studio") if (installTarget === "fresh-vps" || installTarget === "existing-vps") verification.add("verify-vps-reachable") @@ -220,6 +236,14 @@ function compile(profile) { commands: { dry_run: formatCommand(env, command, true), apply: formatCommand(env, command, false), + ...(installTarget === "external-runtime" + ? { + start: formatCommand( + { WP_CONTROL_TRANSPORT_JSON: env.WP_CONTROL_TRANSPORT_JSON }, + [`${target.runtime_project_root}/.wp-coding-agents/bin/kimaki`] + ), + } + : {}), }, verification: { overlays: [...verification], diff --git a/scripts/external-wordpress-kimaki.py b/scripts/external-wordpress-kimaki.py new file mode 100644 index 0000000..1f5ed50 --- /dev/null +++ b/scripts/external-wordpress-kimaki.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Start Kimaki with the non-secret external WordPress site mapping.""" + +import json +import os +import sys + + +if not os.environ.get("WP_CONTROL_TRANSPORT_JSON"): + print("kimaki: WP_CONTROL_TRANSPORT_JSON is required", file=sys.stderr) + raise SystemExit(2) + +profile_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "wordpress.json") +try: + with open(profile_path, encoding="utf-8") as stream: + profile = json.load(stream) +except (OSError, json.JSONDecodeError) as error: + print(f"kimaki: external WordPress profile is unavailable: {error}", file=sys.stderr) + raise SystemExit(2) + +environment = os.environ.copy() +environment["EXTERNAL_WORDPRESS"] = "true" +environment["WORDPRESS_PATH"] = profile.get("wordpress_path", "") +environment["WORDPRESS_USER"] = profile.get("wordpress_user", "") +os.execvpe("kimaki", ["kimaki", *sys.argv[1:]], environment) diff --git a/scripts/wp-control-transport.py b/scripts/wp-control-transport.py new file mode 100644 index 0000000..287f948 --- /dev/null +++ b/scripts/wp-control-transport.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Execute the environment-supplied external WordPress control transport.""" + +import json +import os +import sys + + +def fail(message: str) -> None: + print(f"wp-control: {message}", file=sys.stderr) + raise SystemExit(2) + + +try: + transport = json.loads(os.environ.get("WP_CONTROL_TRANSPORT_JSON", "")) +except json.JSONDecodeError: + fail("WP_CONTROL_TRANSPORT_JSON is not valid JSON") + +if not isinstance(transport, list) or not transport or any(not isinstance(value, str) or not value for value in transport): + fail("WP_CONTROL_TRANSPORT_JSON must be a non-empty argv array") + +profile_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "wordpress.json") +try: + with open(profile_path, encoding="utf-8") as stream: + profile = json.load(stream) +except (OSError, json.JSONDecodeError): + profile = {} + +wordpress_path = os.environ.get("WORDPRESS_PATH", "") or profile.get("wordpress_path", "") +if not wordpress_path: + fail("WORDPRESS_PATH is required") + +command = [*transport, *sys.argv[1:]] +wordpress_user = os.environ.get("WORDPRESS_USER", "") or profile.get("wordpress_user", "") +if wordpress_user: + command.append(f"--user={wordpress_user}") +command.append(f"--path={wordpress_path}") +os.execvpe(command[0], command, os.environ) diff --git a/setup.sh b/setup.sh index 3773ab3..982c200 100755 --- a/setup.sh +++ b/setup.sh @@ -23,7 +23,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source shared modules -for lib in common detect source-policy owned-source-discovery wordpress infrastructure data-machine carried-plugins homeboy ai-gateway skills summary cli-transport cli-channel runtime-signature runtime-guard source-reconcile agents-md-guidance opencode-subagents; do +for lib in common detect source-policy owned-source-discovery wordpress external-wordpress infrastructure data-machine carried-plugins homeboy ai-gateway skills summary cli-transport cli-channel runtime-signature runtime-guard source-reconcile agents-md-guidance opencode-subagents; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -83,6 +83,7 @@ SOURCE_LOG_PATHS_EXPLICIT=false HOMEBOY_PROJECT_ID="${HOMEBOY_PROJECT_ID:-}" DETECTED_RUNTIMES=() IS_STUDIO=false +EXTERNAL_WORDPRESS=false initialize_kimaki_overrides while [[ $# -gt 0 ]]; do @@ -101,6 +102,27 @@ while [[ $# -gt 0 ]]; do EXISTING_WP="$2" shift 2 ;; + --external-wordpress) + EXTERNAL_WORDPRESS=true + MODE="existing" + LOCAL_MODE=true + SKIP_DEPS=true + SKIP_SSL=true + RUN_AS_ROOT=false + shift + ;; + --runtime-project-root) + RUNTIME_PROJECT_ROOT="$2" + shift 2 + ;; + --wordpress-path) + WORDPRESS_PATH="$2" + shift 2 + ;; + --wordpress-user) + WORDPRESS_USER="$2" + shift 2 + ;; --local) LOCAL_MODE=true MODE="existing" @@ -279,6 +301,13 @@ USAGE: OPTIONS: --existing Add agent to existing WordPress (skip WP install) --wp-path Path to WordPress root (implies --existing) + --external-wordpress Attach a local runtime to WordPress through a supplied control transport + --runtime-project-root + Local runtime root for config, skills, and projected context + --wordpress-path + WordPress-side path passed only to the control transport + --wordpress-user + Optional WordPress user passed only to the control transport --local Local machine mode (skip infrastructure: no apt, nginx, systemd, SSL, service users). Works with any local WordPress install (Studio, MAMP, manual, etc.) @@ -386,6 +415,7 @@ ENVIRONMENT VARIABLES: EXTRA_PLUGINS Space-separated slug:url pairs for additional plugins MCP_SERVERS JSON object merged into runtime config (requires jq) WP_CMD Override WP-CLI command (default: wp; e.g., "studio wp") + WP_CONTROL_TRANSPORT_JSON JSON argv array used by --external-wordpress HOMEBOY_EXTENSIONS_SOURCE Homeboy extensions git URL/path (default: https://github.com/Extra-Chill/homeboy-extensions.git) @@ -461,11 +491,17 @@ if [ "$RUNTIME" = "codex" ] && [ "$INSTALL_CHAT" = true ]; then CHAT_BRIDGE="" fi +if [ "$EXTERNAL_WORDPRESS" = true ] && [ "$RUNTIME" != "opencode" ]; then + error "--external-wordpress currently requires --runtime opencode" +fi + # ============================================================================ # Execute # ============================================================================ +external_wordpress_prepare_transport detect_environment +external_wordpress_validate # The source mode must resolve BEFORE anything that enforces it: the plugin set, the # runtime permission surfaces, and the AGENTS.md guidance all derive from it. @@ -495,47 +531,52 @@ fi # --runtime-only skips infrastructure phases (plugins, database, agent creation). # Use when adding a runtime to an existing agent that already has plugins installed. if [ "$RUNTIME_ONLY" != true ]; then - install_system_deps - setup_database - install_wordpress - setup_multisite - create_service_user - install_data_machine - create_dm_agent - sync_carried_plugins - install_extra_plugins - setup_homeboy_project - configure_homeboy_dmc_worktree_provider - setup_nginx - setup_ssl - setup_service_permissions + if [ "$EXTERNAL_WORDPRESS" = true ]; then + log "External WordPress profile: site installation and mutation phases are skipped" + else + install_system_deps + setup_database + install_wordpress + setup_multisite + create_service_user + install_data_machine + create_dm_agent + sync_carried_plugins + install_extra_plugins + setup_homeboy_project + configure_homeboy_dmc_worktree_provider + setup_nginx + setup_ssl + setup_service_permissions + fi fi -source_policy_record_mode -owned_discovery_record_exclusions -source_policy_record_owned_sources -source_policy_record_writable_paths -source_policy_record_log_paths -guidance_sync_all -setup_ai_gateway +if [ "$EXTERNAL_WORDPRESS" != true ]; then + source_policy_record_mode + owned_discovery_record_exclusions + source_policy_record_owned_sources + source_policy_record_writable_paths + source_policy_record_log_paths + guidance_sync_all + setup_ai_gateway +fi runtime_install runtime_discover_dm_paths +external_wordpress_project_context discover_dm_workspace_dir runtime_generate_config -ai_gateway_configure_opencode +if [ "$EXTERNAL_WORDPRESS" != true ]; then ai_gateway_configure_opencode; fi runtime_install_hooks -configure_homeboy_wordpress_extension +if [ "$EXTERNAL_WORDPRESS" != true ]; then configure_homeboy_wordpress_extension; fi runtime_generate_instructions -opencode_project_subagents +if [ "$EXTERNAL_WORDPRESS" != true ]; then opencode_project_subagents; fi runtime_merge_mcp_servers install_skills -cli_transport_install -runtime_guard_sync +if [ "$EXTERNAL_WORDPRESS" != true ]; then cli_transport_install; runtime_guard_sync; fi # Install the reconciler, then run it once so a fresh install converges the same # way a live change will. -source_reconcile_sync -source_reconcile_run +if [ "$EXTERNAL_WORDPRESS" != true ]; then source_reconcile_sync; source_reconcile_run; fi install_chat_bridge -datamachine_worker_install +if [ "$EXTERNAL_WORDPRESS" != true ]; then datamachine_worker_install; fi print_summary diff --git a/tests/ci-coverage.sh b/tests/ci-coverage.sh index 9194904..895f1df 100755 --- a/tests/ci-coverage.sh +++ b/tests/ci-coverage.sh @@ -83,9 +83,9 @@ else echo " ok all $(ls tests/*.sh | wc -l | tr -d ' ') shell tests are referenced" fi -# Non-shell suites are invoked directly by their own jobs; assert the two that +# Non-shell suites are invoked directly by their own jobs; assert the ones that # exist stay wired, since they are the easiest to lose in a restructure. -for other in tests/dm-agent-sync.mjs tests/smoke-cli-transport.php; do +for other in tests/dm-agent-sync.mjs tests/setup-profile-compiler.mjs tests/smoke-cli-transport.php; do [ -e "$other" ] || continue case "$workflow_text" in *"$(basename "$other")"*) echo " ok $(basename "$other") is referenced" ;; diff --git a/tests/external-wordpress-runtime.sh b/tests/external-wordpress-runtime.sh new file mode 100644 index 0000000..2f71624 --- /dev/null +++ b/tests/external-wordpress-runtime.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# External profile: no local WordPress tree, argv-safe transport, local projection. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +RUNTIME_PROJECT_ROOT="$TMP/runtime root" +WORDPRESS_PATH="/remote/site root" +WORDPRESS_USER="agent user" +TRANSPORT="$TMP/control transport" +ARGS="$TMP/transport-args" +KIMAKI_ENV="$TMP/kimaki-env" + +mkdir -p "$RUNTIME_PROJECT_ROOT" +cat > "$TRANSPORT" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >> "$WP_TEST_ARGS" +case "$3:$4" in + core:is-installed) exit 0 ;; + datamachine:memory) + case "$5" in + injectable-files) printf '%s\n' '[{"filename":"SITE.md","layer":"shared","priority":10,"path":"/remote/wp-content/uploads/datamachine-files/shared/SITE.md"},{"filename":"SOUL.md","layer":"agent","priority":20,"path":"/remote/wp-content/uploads/datamachine-files/agents/remote/SOUL.md"}]' ;; + read) + case "$6" in + SITE.md) printf '%s\n' 'site context' ;; + SOUL.md) printf '%s\n' 'agent context' ;; + *) exit 7 ;; + esac + ;; + esac + ;; +esac +SH +chmod +x "$TRANSPORT" +mkdir -p "$TMP/bin" +cat > "$TMP/bin/kimaki" <<'SH' +#!/bin/bash +printf '%s\n%s\n%s\n' "$EXTERNAL_WORDPRESS" "$WORDPRESS_PATH" "$WORDPRESS_USER" > "$WP_TEST_KIMAKI_ENV" +SH +chmod +x "$TMP/bin/kimaki" + +export SCRIPT_DIR RUNTIME_PROJECT_ROOT WORDPRESS_PATH WORDPRESS_USER +export WP_TEST_ARGS="$ARGS" +export WP_TEST_KIMAKI_ENV="$KIMAKI_ENV" +export PATH="$TMP/bin:$PATH" +export WP_CONTROL_TRANSPORT_JSON="[\"$TRANSPORT\",\"--identity\",\"secret value with spaces\"]" +export EXTERNAL_WORDPRESS=true DRY_RUN=false LOCAL_MODE=true IS_STUDIO=false +export SITE_PATH="$RUNTIME_PROJECT_ROOT" CHAT_BRIDGE=kimaki KIMAKI_DATA_DIR="$RUNTIME_PROJECT_ROOT/.kimaki" +export OPENCODE_MODEL='' OPENCODE_SMALL_MODEL='' WITH_CLAUDE_CODE_AUTH=false RUNTIME=opencode +export SOURCE_MODE=workspace DM_WORKSPACE_DIR="$TMP/workspace" +UPDATED_ITEMS=() + +log() { :; } +warn() { printf '%s\n' "$*" >&2; } +error() { printf '%s\n' "$*" >&2; return 1; } + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/wordpress.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/external-wordpress.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/source-policy.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/skills.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/runtimes/opencode.sh" +error() { printf '%s\n' "$*" >&2; return 1; } + +external_wordpress_prepare_transport +external_wordpress_validate +external_wordpress_project_context +runtime_generate_config +runtime_generate_instructions +DETECTED_RUNTIMES=(opencode) +INSTALL_SKILLS=true +install_skills + +[ ! -e "$RUNTIME_PROJECT_ROOT/wp-config.php" ] || { echo "FAIL: test created a local WordPress tree"; exit 1; } +[ "$(cat "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/shared/SITE.md")" = "site context" ] || { echo "FAIL: site context not projected"; exit 1; } +[ "$(cat "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/agent/SOUL.md")" = "agent context" ] || { echo "FAIL: agent context not projected"; exit 1; } +[ -f "$RUNTIME_PROJECT_ROOT/.opencode/skills/upgrade-wp-coding-agents/SKILL.md" ] || { echo "FAIL: skills were not installed below runtime root"; exit 1; } +[ -f "$RUNTIME_PROJECT_ROOT/.kimaki/kimaki-config/plugins/dm-context-filter.ts" ] || { echo "FAIL: Kimaki config was not installed below runtime root"; exit 1; } +[ ! -e "$RUNTIME_PROJECT_ROOT/wp-content" ] || { echo "FAIL: WordPress-side files were written below runtime root"; exit 1; } +[ -L "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context" ] || { echo "FAIL: projected context is not atomically activated"; exit 1; } +grep -F -- 'WordPress control: `./.wp-coding-agents/bin/wp-control`' "$RUNTIME_PROJECT_ROOT/AGENTS.md" >/dev/null +if grep -F -- 'grep it as needed' "$RUNTIME_PROJECT_ROOT/AGENTS.md" >/dev/null; then + echo "FAIL: external guidance claims installed source is mounted" + exit 1 +fi + +python3 - "$RUNTIME_PROJECT_ROOT/opencode.json" "$RUNTIME_PROJECT_ROOT" <<'PY' +import json, sys +config, root = sys.argv[1:] +data = json.load(open(config)) +expected = ["./.wp-coding-agents/context/shared/SITE.md", "./.wp-coding-agents/context/agent/SOUL.md"] +if data.get("instructions") != expected: + raise SystemExit(f"projected instructions differ: {data.get('instructions')}") +for plugin in data.get("plugin", []): + if not plugin.startswith(root + "/"): + raise SystemExit(f"plugin escaped runtime root: {plugin}") +PY + +while IFS= read -r argument; do + case "$argument" in + "--path=$WORDPRESS_PATH"|"--user=$WORDPRESS_USER"|"secret value with spaces") ;; + esac +done < "$ARGS" +grep -F -- "--path=$WORDPRESS_PATH" "$ARGS" >/dev/null +grep -F -- "--user=$WORDPRESS_USER" "$ARGS" >/dev/null +grep -F -- "secret value with spaces" "$ARGS" >/dev/null + +before_wrapper_calls="$(wc -l < "$ARGS" | tr -d ' ')" +unset WORDPRESS_PATH WORDPRESS_USER +"$RUNTIME_PROJECT_ROOT/.wp-coding-agents/bin/wp-control" option get siteurl >/dev/null +after_wrapper_calls="$(wc -l < "$ARGS" | tr -d ' ')" +[ "$after_wrapper_calls" -gt "$before_wrapper_calls" ] || { echo "FAIL: runtime control wrapper did not execute transport"; exit 1; } +"$RUNTIME_PROJECT_ROOT/.wp-coding-agents/bin/kimaki" +[ "$(sed -n '1p' "$KIMAKI_ENV")" = true ] || { echo "FAIL: Kimaki launcher did not set external profile"; exit 1; } +[ "$(sed -n '2p' "$KIMAKI_ENV")" = "/remote/site root" ] || { echo "FAIL: Kimaki launcher lost WordPress path"; exit 1; } +[ "$(sed -n '3p' "$KIMAKI_ENV")" = "agent user" ] || { echo "FAIL: Kimaki launcher lost WordPress user"; exit 1; } +export WORDPRESS_PATH="/remote/site root" WORDPRESS_USER="agent user" + +if grep -R -F -- "secret value with spaces" "$RUNTIME_PROJECT_ROOT" >/dev/null 2>&1; then + echo "FAIL: transport credential persisted below runtime root" + exit 1 +fi + +mkdir -p "$TMP/outside" +printf 'outside-safe\n' > "$TMP/outside/SOUL.md" +printf 'stale\n' > "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/stale.md" +rm -rf "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/agent" +ln -s "$TMP/outside" "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/agent" +external_wordpress_project_context +[ ! -e "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/stale.md" ] || { echo "FAIL: stale projected context survived rerun"; exit 1; } +[ ! -L "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context/agent" ] || { echo "FAIL: projected context retained a redirecting symlink"; exit 1; } +[ "$(cat "$TMP/outside/SOUL.md")" = "outside-safe" ] || { echo "FAIL: projected context followed a symlink outside its root"; exit 1; } + +external_wordpress_project_context & +projection_one=$! +external_wordpress_project_context & +projection_two=$! +wait "$projection_one" +wait "$projection_two" +[ -d "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context" ] || { echo "FAIL: concurrent projection left a dangling context link"; exit 1; } +generation_count=$(python3 - "$RUNTIME_PROJECT_ROOT/.wp-coding-agents/context-generations" <<'PY' +import os, sys +print(sum(1 for entry in os.scandir(sys.argv[1]) if entry.is_dir(follow_symlinks=False))) +PY +) +[ "$generation_count" = 1 ] || { echo "FAIL: concurrent projection retained $generation_count generations"; exit 1; } + +saved_transport_json="$WP_CONTROL_TRANSPORT_JSON" +WP_CONTROL_TRANSPORT_JSON='["/bin/true","line\nbreak"]' +external_wordpress_prepare_transport +[ "${WP_CONTROL_TRANSPORT[1]}" = $'line\nbreak' ] || { echo "FAIL: transport argv newline was split"; exit 1; } +WP_CONTROL_TRANSPORT_JSON="$saved_transport_json" +external_wordpress_prepare_transport + +before="$(cksum "$RUNTIME_PROJECT_ROOT/opencode.json" "$RUNTIME_PROJECT_ROOT/AGENTS.md")" +external_wordpress_project_context +runtime_generate_config +runtime_generate_instructions +after="$(cksum "$RUNTIME_PROJECT_ROOT/opencode.json" "$RUNTIME_PROJECT_ROOT/AGENTS.md")" +[ "$before" = "$after" ] || { echo "FAIL: external profile is not idempotent"; exit 1; } + +WP_CONTROL_TRANSPORT_JSON='["/does/not/exist"]' +external_wordpress_prepare_transport +if external_wordpress_validate; then + echo "FAIL: unavailable transport validated" + exit 1 +fi + +echo "PASS: tests/external-wordpress-runtime.sh" diff --git a/tests/service-identity-defaults.sh b/tests/service-identity-defaults.sh index 8d02426..8c67f32 100755 --- a/tests/service-identity-defaults.sh +++ b/tests/service-identity-defaults.sh @@ -159,8 +159,8 @@ echo "wiring" # that branch on RUN_AS_ROOT, or it decides nothing. apply_line=$(grep -n '^detect_apply_source_mode_identity_default' setup.sh | head -1 | cut -d: -f1) mode_line=$(grep -n '^source_policy_resolve_mode' setup.sh | head -1 | cut -d: -f1) -user_line=$(grep -n '^ create_service_user' setup.sh | head -1 | cut -d: -f1) -perms_line=$(grep -n '^ setup_service_permissions' setup.sh | head -1 | cut -d: -f1) +user_line=$(grep -n '^[[:space:]]*create_service_user' setup.sh | head -1 | cut -d: -f1) +perms_line=$(grep -n '^[[:space:]]*setup_service_permissions' setup.sh | head -1 | cut -d: -f1) if [ -n "$apply_line" ] && [ -n "$mode_line" ] && [ -n "$user_line" ] && [ -n "$perms_line" ] && [ "$apply_line" -gt "$mode_line" ] && [ "$apply_line" -lt "$user_line" ] && [ "$apply_line" -lt "$perms_line" ]; then echo " ok setup.sh applies the default after the mode resolves and before it is used" diff --git a/tests/setup-profile-compiler.mjs b/tests/setup-profile-compiler.mjs index 40724f8..1026429 100644 --- a/tests/setup-profile-compiler.mjs +++ b/tests/setup-profile-compiler.mjs @@ -10,6 +10,46 @@ function compile(profile) { ) } +{ + const plan = compile({ + install_target: "external-runtime", + target: { + runtime_project_root: "/tmp/runtime root", + wordpress_path: "/remote/site root", + wordpress_user: "agent user", + control_transport_argv: ["/usr/local/bin/control transport", "--identity", "value with spaces"], + }, + runtime: { selection: "opencode" }, + chat_bridge: { selection: "kimaki" }, + overlays: {}, + agent: { slug: "remote" }, + }) + assert.match(plan.commands.apply, /RUNTIME_PROJECT_ROOT='\/tmp\/runtime root'/) + assert.match(plan.commands.apply, /WP_CONTROL_TRANSPORT_JSON='\["\/usr\/local\/bin\/control transport","--identity","value with spaces"\]'/) + assert.match(plan.commands.apply, /--external-wordpress --wordpress-path '\/remote\/site root' --wordpress-user 'agent user'/) + assert.match(plan.commands.start, /WP_CONTROL_TRANSPORT_JSON=.*\/tmp\/runtime root\/\.wp-coding-agents\/bin\/kimaki/) + assert.ok(plan.verification.overlays.includes("verify-external-wordpress-transport")) +} + +for (const selection of ["auto", "codex", "claude-code", "multiple"]) { + const result = spawnSync("node", ["scripts/compile-setup-profile.mjs"], { + input: JSON.stringify({ + install_target: "external-runtime", + target: { + runtime_project_root: "/tmp/runtime", + wordpress_path: "/remote/site", + control_transport_argv: ["/usr/local/bin/control"], + }, + runtime: { selection, runtimes: selection === "multiple" ? ["opencode", "codex"] : [] }, + chat_bridge: { selection: "none" }, + overlays: {}, + }), + encoding: "utf8", + }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /requires runtime\.selection=opencode/) +} + { const plan = compile({ install_target: "local", diff --git a/workspace/AGENTS.external.md b/workspace/AGENTS.external.md new file mode 100644 index 0000000..8385558 --- /dev/null +++ b/workspace/AGENTS.external.md @@ -0,0 +1,10 @@ +# AI Instructions + +WordPress control: `{{WP_CLI_CMD}}` + +### WordPress Site + +The WordPress filesystem is not mounted in this runtime. Use the control +command for live WordPress state and abilities. Make code changes in the +configured Data Machine Code workspace; do not invent local installed-source +paths.