Skip to content

feat: migrate an installed root agent onto a non-root service user - #330

Merged
chubes4 merged 5 commits into
mainfrom
feat/non-root-migration
Aug 5, 2026
Merged

feat: migrate an installed root agent onto a non-root service user#330
chubes4 merged 5 commits into
mainfrom
feat/non-root-migration

Conversation

@chubes4

@chubes4 chubes4 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Closes #93.

The gap

lib/detect.sh has always been able to install non-root — SERVICE_USER=opencode, create_service_user, User=opencode in 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-root looked like it did this and did not:

  • set SERVICE_USER_FORCED=true (suppressing identity adoption)
  • re-rendered every unit with User=opencode
  • never created the user
  • and because KIMAKI_DATA_DIR derives from the service home, silently repointed the agent at an empty /home/opencode/.kimaki while the live session database, runtime auth, and installed toolchains stayed in /root

The service came back amnesiac, or not at all. upgrade.sh calls no infrastructure functions at all — setup_service_permissions is setup.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. /root is mode 0700, so everything outside the inventory becomes unreachable the moment the identity changes.

Measured on the development box, /root held:

.ssh/           private keys to other machines in the fleet
.secrets/
.h44-secrets, .h44-target-dbpass      per-site database credentials
sweatpants-api-token.txt, ...
opencode-auth-backup.json

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:

runtime state dev toolchain + forge auth credentials
managed yes no never
engineering yes yes never

A managed agent has no git, no GitHub, and no build step in its world (#314), so migrating .cargo/.config/gh would 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 .ssh is 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-dbpass sailed straight past the narrower pattern. The test caught that, not review.

--migrate-extra covers 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 .ssh is 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 -n clean 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.

chubes4 added 5 commits August 4, 2026 22:14
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.
@chubes4
chubes4 merged commit 131fa03 into main Aug 5, 2026
20 checks passed
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