Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -221,6 +223,7 @@ jobs:
- codex-runtime
- datamachine-worker
- detect-site-domain
- external-wordpress-runtime
- dm-workspace-discovery
- homeboy-codebox-canary
- homeboy-components
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion bridges/kimaki.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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='<argv-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:"
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions bridges/kimaki/plugins/dm-agent-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion lib/detect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions lib/external-wordpress.sh
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 5 additions & 1 deletion lib/repair-opencode-json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion lib/skills.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/source-policy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/wordpress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion operator-entrypoints/wp-coding-agents-setup/interview.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ 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:
- Local: WordPress root path and whether it is a WordPress Studio site.
- 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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
},
Expand Down
Loading
Loading