feat: migrate an installed root agent onto a non-root service user - #330
Merged
Conversation
lib/detect.sh has always been able to INSTALL non-root. What has never existed is a way to move an install that is already running as root onto that path, which is what #93 asks for and why every box in the fleet is still root. `./upgrade.sh --non-root` looked like it did this and did not. It set SERVICE_USER_FORCED=true, re-rendered every unit with User=opencode, and stopped there: it never created the user, and because KIMAKI_DATA_DIR is derived from the service home it silently repointed the agent at an empty /home/opencode/.kimaki while the live session database, runtime auth, and installed toolchains stayed behind in /root. The service came back amnesiac, or not at all. That path now fails closed and names the flag that does the job. The substance is lib/service-migration.sh, and its important property is not the chown. The new service home starts empty and is filled only from an explicit allowlist, so /root staying 0700 and root-owned is what puts everything outside the inventory beyond the agent's reach. On the box this was developed against that is SSH private keys to other machines in the fleet, a secret store, per-site database passwords, and API tokens — all reachable by the agent today, none of them anything it needs. The inventory is the capability boundary; the migration is what draws it. So the list is enumerated rather than globbed (#318), and it is posture-aware, making it the file-axis counterpart to the capability table in #327: managed carries runtime state only, engineering also carries the dev toolchain and forge credentials, because that posture's whole job is workspace/git/GitHub work. Neither carries credentials, and there is no flag to opt in. Exclusion is enforced in two layers, since a list that merely forgets .ssh is one careless addition away from shipping it: named paths plus shape-based patterns for the operator-specific names shipped code cannot responsibly guess (#320). The patterns are deliberately broad — a false positive costs one manual move, a false negative hands over a credential and still reports success. `*pass*` rather than `*password*` because the development box had a database password in `.h44-target-dbpass`, which the narrower pattern sailed past and the test caught. --migrate-extra covers install-specific state the shipped inventory cannot know about, and is filtered through the same exclusions so the escape hatch does not become the route keys travel. Unit rendering is deliberately left alone. Once the migration has set SERVICE_USER / SERVICE_HOME / KIMAKI_DATA_DIR, the existing phases render from those exactly as before; a second unit renderer here would fork the source of truth #204 already established. Validated against the real install: 8.6 GiB migrates, every credential present on the box is excluded.
CI runs unprivileged, the development box runs as root, and preflight checked EUID before it checked its own arguments. So `--migrate-user root` told an unprivileged operator to re-run under sudo, and only then told them the flag was wrong. Two assertions passed locally and failed in CI for exactly that reason. Argument validation now comes first, which is both the better error and the order-independent one. The privilege gate reads through service_migration_effective_uid() so the checks after it are reachable in a test without being root — the same seam SYSTEMD_UNIT_DIR already provides for unit discovery — and the gate itself now has its own assertion rather than being implied by the others failing. Verified as root and as nobody; both pass.
The generated file encodes the euid of the process that generated it. data-machine's datamachine_agents_md_wp_cli_cmd() and data-machine-code's resolve_wp_cli_cmd() both append `--allow-root` to every WP-CLI example when posix_geteuid() === 0, and upgrade.sh runs under sudo. So migrating an install to a non-root service user and then recomposing in the same run would write an AGENTS.md instructing a non-root agent to run `wp --allow-root` — a file that misdescribes the agent's own environment. Nothing breaks, which is why this would have gone unnoticed: --allow-root is a no-op for a non-root caller (verified). It is wrong in the way #322 was wrong. Prose that misdescribes the runtime misinforms the agent, and #93 names this exact cascade — agents dutifully copying --allow-root out of generated guidance is half of what kept the root-write loop going. wp_run_as_service_user() drops to SERVICE_USER for the compose, mirroring homeboy_run() including its test seam, so the existing euid detection becomes correct by construction rather than gaining a second signal that can disagree with the first. It passes no WP_ROOT_FLAG on that branch, since the point of it is that the invocation is not root. Both call sites move: the main compose phase in upgrade.sh and the post-Homeboy recompose, which would otherwise re-bake --allow-root over the file the first one just got right. A test pins that every executing call site goes through the helper. Dry-run output now names the identity it would compose as. The pre-existing comment about normalizing permissions after compose stays: that handled the ownership consequence of the caller's identity, and this handles the content consequence.
Refuse to migrate from inside the unit being stopped. An agent driving its own upgrade runs inside the chat-bridge unit — /proc/self/cgroup here reads 0::/system.slice/kimaki.service — and the migration's first act is to stop that unit. It would have killed the migration mid-move: 8 GiB of state partly relocated, no unit rendered, nothing left running to finish or report, recovery by hand on a box whose agent is now gone. This is a refusal and not a warning because the process that would read the warning is the one that disappears. The error names a detached way to re-run it. Chown every level created on the way down, not just the immediate parent. `mkdir -p ~/.local/share` as root creates BOTH levels root-owned, and chowning only `.local/share` left `~/.local` unwritable by the service user — surfacing later as a permission error nowhere near this code, on the next thing wanting `~/.local/bin`. Verified: `.local` came out root-owned before this, service-owned after. Read the account's primary group instead of assuming it matches the name. `chown user:user` holds for a useradd-created `opencode` and fails outright for an existing account named via --migrate-user — `nobody` is in `nogroup` on Debian, and chown errors rather than degrading. Say plainly that services are left stopped. upgrade.sh does not restart anything, it prints a restart hint, which is fine for an ordinary upgrade where nothing was stopped and misleading after one that stopped everything. Also folds the duplicated unit-discovery in stop_units into service_migration_units, now that the self-migration check needs the same list.
bridges/kimaki/post-upgrade.sh runs as ExecStartPre, as the SERVICE user, under `set -euo pipefail`, with no `-` prefix on the unit directive. It removes bundled skills from the npm package directory, which is root-owned (/usr/lib/node_modules/kimaki, 0755) and which a non-root service user cannot unlink from. The rm was unguarded, so it aborts the script, which fails ExecStartPre, which means the service never starts. This predates the migration — it breaks any `--non-root` install, a shape setup.sh already supports and offers as the recommended default. The migration is what makes it reachable in practice, which is why it belongs in this PR. The failure mode is the bad kind: late and disconnected from its cause. There is nothing to remove until `npm update -g kimaki` recreates a bundled skill, so a migrated box boots fine for days and then permanently stops booting, with the migration long since declared successful. These removals are hygiene against npm restoring files we do not want on the skill surface — not correctness. Being unable to perform them is worth a warning and never a failed start, so they now go through try_remove_package_path, which warns and continues. The obsolete-plugin rm gets the same treatment: on a VPS PLUGINS_DIR is /opt/kimaki-config/plugins, also root-owned. Test proves the behaviour rather than the text: as an unprivileged user the old form never reaches the end of the script, the new one warns, continues, and leaves the path in place.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #93.
The gap
lib/detect.shhas always been able to install non-root —SERVICE_USER=opencode,create_service_user,User=opencodein every unit. What has never existed is a way to move an install that is already running as root onto that path. That is why every box in the fleet is still root../upgrade.sh --non-rootlooked like it did this and did not:SERVICE_USER_FORCED=true(suppressing identity adoption)User=opencodeKIMAKI_DATA_DIRderives from the service home, silently repointed the agent at an empty/home/opencode/.kimakiwhile the live session database, runtime auth, and installed toolchains stayed in/rootThe service came back amnesiac, or not at all.
upgrade.shcalls no infrastructure functions at all —setup_service_permissionsissetup.sh-only — so nothing chowned anything either. That path now fails closed and names the flag that works.The inventory is the capability boundary
The important property is not the chown. The new service home starts empty and is filled only from an explicit allowlist.
/rootis mode0700, so everything outside the inventory becomes unreachable the moment the identity changes.Measured on the development box,
/rootheld:All reachable by the agent today. None of it anything the agent needs. Migrating is what takes them away — that is the point of the change, not a side effect.
Posture-aware
This makes the migration the file-axis counterpart to the capability table in #327:
A managed agent has no git, no GitHub, and no build step in its world (#314), so migrating
.cargo/.config/ghwould widen its reach for a capability it is never asked to exercise. There is no flag to opt into credentials.Exclusion has two layers
A list that merely forgets
.sshis one careless addition away from shipping it. So: named paths, plus shape-based patterns for the operator-specific names shipped code cannot responsibly guess at (#320).The patterns are deliberately broad, because the costs are asymmetric — a false positive means the operator moves one directory by hand; a false negative hands over a credential and still reports success.
*pass*rather than*password*because.h44-target-dbpasssailed straight past the narrower pattern. The test caught that, not review.--migrate-extracovers install-specific state the shipped inventory can't know about (homeboy-modules,go-sdk), and runs through the same exclusions so the escape hatch doesn't become the route keys travel. Supplying--migrate-extra .sshis refused with an explanation rather than silently dropped.Deliberately not included
Unit rendering. Once the migration sets
SERVICE_USER/SERVICE_HOME/KIMAKI_DATA_DIR, the existing phases render from those exactly as before. A second renderer here would fork the source of truth #204 established.Validation
Dry-run against the real install: 8.6 GiB migrates (same-filesystem, so renames), every credential on the box excluded.
New CI job
service-migration. Siblings re-run green locally:posture,service-identity-adoption,runtime-guard,bridge-render,repair-opencode-json;bash -nclean across the repo.Not yet applied anywhere
This ships the mechanism. Applying it is a separate, deliberate operator step — h44 first (managed, least state), chubes.net last, since it is the box that holds the fleet keys and the one running the agent that wrote this.