Skip to content

fix: pin writable HOME for CLI dispatch so scheduled kimaki sends survive WP-cron (#228)#239

Merged
chubes4 merged 1 commit into
mainfrom
fix-dispatch-home
Jun 18, 2026
Merged

fix: pin writable HOME for CLI dispatch so scheduled kimaki sends survive WP-cron (#228)#239
chubes4 merged 1 commit into
mainfrom
fix-dispatch-home

Conversation

@chubes4

@chubes4 chubes4 commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

The scheduled EC Agent Progress Ping Data Machine flow (flow 4) failed on dispatch with:

exited with code 64 ... DB Failed to create data directory /var/www/.kimaki: EACCES: permission denied, mkdir '/var/www/.kimaki'

…while the same flow run manually as the opencode user succeeded. The pipeline is fine — the only difference is the HOME of the process that shells out to kimaki.

Root cause — the unit-vs-dispatch asymmetry

There are two paths that spawn kimaki, and only one of them set HOME:

Path HOME handling Result
systemd unit (kimaki.service) ✅ sets Environment=HOME=$SERVICE_HOME + Environment=KIMAKI_DATA_DIR=... (fixed for the unit in #233/commit aaace07) works
CLI dispatch config (agents/dispatch-message → CLI transport) ❌ the installer-written bridge:kimaki channel block emitted command/args/detach/timeout but no env, so the generic transport's build_env_map() returned null and proc_open inherited the caller's env broken under cron

Under the WP-cron heartbeat (wp cron event run as www-data, whose passwd HOME is /var/www — root-owned, 0755, unwritable), kimaki can't create $HOME/.kimaki and exits 64. The interactive opencode shell has a writable HOME, which is why manual runs worked. This is the same HOME-reset / config-dir-not-writable class as #93/#198.

The fix — both halves (durable across upgrades)

1. Installer (config) — the primary, upgrade-surviving fix

  • lib/cli-channel.sh: cli_channel_register / _cli_channel_render_block gain an optional env_json arg that renders an 'env' => [ ... ] line into the channel block. A new _cli_channel_json_object_to_php_array helper (python3 + naive fallback, mirroring the existing array converter) converts the JSON object. Channels with no env render byte-identically to the historical four-key block — no churn for cc-connect/telegram.
  • bridges/kimaki.sh_kimaki_register_cli_channel: stamps HOME + KIMAKI_DATA_DIR into the channel env, derived from the already-resolved adopted service identity (SERVICE_HOME / KIMAKI_DATA_DIR) — never a hardcoded /home/opencode, so a RUN_AS_ROOT install pins /root and a non-root install pins the service user's home. Mirrors exactly what _kimaki_install_systemd does for the unit.
  • Because every upgrade rewrites the marker block, the env now survives upgrades — this is why the installer half matters, not a one-off edit.

Generated block now looks like:

$channels['kimaki'] = [
    'command' => '/usr/bin/kimaki',
    'args'    => [ 'send', '--channel', '{recipient}', '--prompt', '{message}' ],
    'detach'  => true,
    'timeout' => 600,
    'env'     => [ 'HOME' => '/home/opencode', 'KIMAKI_DATA_DIR' => '/home/opencode/.kimaki' ],
];

The transport already overlays $config['env'] on top of the inherited env (build_env_map), so this configured HOME wins → proc_open no longer inherits /var/www.

2. Transport (defense-in-depth, layer-pure)

  • templates/wp-coding-agents-cli-transport.phpbuild_env_map() no longer blindly passes a poisoned HOME through. If HOME is not channel-pinned and the effective value is empty or points at a non-writable directory, HOME is dropped so proc_open falls back to the system/account default instead of a guaranteed-unwritable path.
  • Kept strictly generic: no vendor names, no hardcoded paths in the transport. The concrete writable HOME value comes from the channel config (installer-written), preserving layer purity.

Verification

  • php tests/smoke-cli-transport.php — 27/27 pass, including 3 new regression assertions:
    • unwritable inherited HOME is not leaked to the child,
    • guarded channel still inherits other parent env,
    • a channel-pinned writable HOME wins over a poisoned inherited HOME.
  • tests/cli-channel-perms.sh, tests/cli-channel-binary-path.sh, tests/cli-transport-install.sh — all pass (binary-path exercises _kimaki_register_cli_channel with no SERVICE_HOME → emits no env → backward-compatible).
  • php -l clean on the transport + smoke test; bash -n clean on lib/cli-channel.sh + bridges/kimaki.sh.
  • Functional render check confirms the generated mu-plugin file is valid PHP and the no-env path is byte-identical to the prior four-key form. Verified the python3-absent fallback produces identical output.

Closes #228

cc <@1493317298151489577>

…vive WP-cron

The scheduled "EC Agent Progress Ping" dispatch failed with
`EACCES mkdir /var/www/.kimaki` because the CLI dispatch transport inherited
the caller's HOME. Under the WP-cron heartbeat (PHP-FPM as www-data,
HOME=/var/www, root-owned and unwritable) kimaki could not create its data
dir, so every scheduled send died with exit 64 — while a manual run as the
opencode user (writable HOME) succeeded.

This is the dispatch-config twin of the systemd-unit HOME fix (#233): the
unit path already sets Environment=HOME=$SERVICE_HOME + KIMAKI_DATA_DIR, but
the installer-written channel block omitted env entirely, so the generic
transport fell back to inheriting the parent env.

Fixed in both layers for durability (the installer half is what survives
upgrades, since each upgrade rewrites the marker block):

1. Installer (config): lib/cli-channel.sh gains an optional env_json arg that
   renders an 'env' => [ ... ] line into the channel block; bridges/kimaki.sh
   stamps HOME + KIMAKI_DATA_DIR derived from the already-resolved adopted
   service identity (SERVICE_HOME / KIMAKI_DATA_DIR), never a hardcoded path.
   Channels without env render byte-identically to the historical four-key
   form. Vendor-specific paths stay in installer config, not the transport.

2. Transport (defense-in-depth, layer-pure): build_env_map() no longer lets a
   poisoned HOME pass through. If HOME is not channel-pinned and the effective
   value is empty or points at a non-writable directory, it is dropped so
   proc_open uses a system default instead of a guaranteed-unwritable path. No
   vendor names or hardcoded paths in the generic transport.

Adds regression coverage: unwritable inherited HOME is dropped, other parent
env still inherited, and a channel-pinned writable HOME wins over a poisoned
inherited one.

Closes #228
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant