Skip to content

feat: v0 — the agent, and only the agent - #8

Merged
nicodes merged 8 commits into
mainfrom
feat/box-report
Aug 3, 2026
Merged

feat: v0 — the agent, and only the agent#8
nicodes merged 8 commits into
mainfrom
feat/box-report

Conversation

@nicodes

@nicodes nicodes commented Aug 2, 2026

Copy link
Copy Markdown
Owner

appify.md §11's v0, built as the only way komizo reads a server. No compatibility layer, no second path — v0, line drawn.

What's here

  • internal/box — the report schema and the probes. Shared by the CLI and the agent so the schema is a Go type the compiler checks, not a wire format two codebases agree about in prose. Version states the rule (add fields, never repurpose one), which the shell never needed because it shipped fresh from the laptop on every poll.
  • cmd/komizo-boxrootd (root, timer, writes report.json), plus report/poll/monitor/metrics/history.
  • komizo report — one reading of a box in one screen, --json for anything else.
  • internal/agent — the compiled agents, embedded into the CLI. make agents builds them; the release action runs it first.

init installs the agent. komizo list, the TUI poll and the monitor all read it. ~530 lines of telemetry shell and its six record parsers are gone, along with ~1,300 lines of test that asserted on a wire format that no longer exists.

Diagnose is new as a place rather than as logic — alias clashes, detached apps, a stopped proxy were computed by each reader, and there are about to be four readers.

Five docker calls, flat

The first cut of the port asked per app: a compose ps to find an app's containers, an inspect per container for its service and pid, another for its mounts. Roughly eighty invocations per report on a six-app box, on a five-second poll — worse than the shell it replaced, which had been rewritten once for exactly this reason.

Membership now comes from compose's own project.working_dir label, and aliases, mounts, pids and timestamps all come out of one inspect over every id at once. Five calls per report at one app and at twenty, with a test that fails if that changes.

Bugs found on the way

  • A regression from the port: parseInventory scrubbed control characters at the boundary; inventoryFromReport did not. An escape sequence in a container name or docker's status prose would have reached the terminal — JSON carries one through perfectly well.
  • A second one: disk folding lived in the deleted parseSystem, so / and /var/lib/docker could chart as two bars for one filesystem. The agent already folds by device; the reader now trusts it, and the rule has a test where it lives.
  • Reading a seeked file dropped the first line whether or not a seek had happened — silently losing the oldest reading of every history and access log small enough to be read whole.
  • rootd created /var/lib/komizo even when --report pointed elsewhere, making it unrunnable as anyone but root and untestable.
  • Two display tests had drifted far enough to assert an upstream of blog-web when every box has always written <app>-gate.

Properties kept, with tests that fail if they're lost

  • No secret and no request detail can reach the report. The access-log struct has no field for a path, a client IP or a user agent, so leaking one is a decision rather than an accident.
  • report.json is 0644, which is what lets komizo_monitor have no privileges at all (appify.md §3). That account is created and does nothing yet — there's no agent process to run as it, because there's nothing to report to.
  • stopped travels in the report, so a deliberate stop doesn't page anyone.

Not verified

The agent has never run on a real Alpine box. Everything is tested against a fake /proc, a fake docker and a temp filesystem; the install script is syntax-checked and rendered but not executed. First run on a real box is the remaining risk — most likely the OpenRC service or busybox adduser's flags. There is no fallback path now, which is the point, and worth knowing before the first init.

Size

Both agents are 2.6MB, so the CLI grows ~5MB carrying amd64 and arm64.

🤖 Generated with Claude Code

nicodes and others added 4 commits August 2, 2026 16:08
v0 of design/appify.md §11: komizo-rootd probes the machine and writes
report.json, and everything that used to be shell doing it is now a
typed document.

internal/box is shared by two programs that are not upgraded together --
the binary on the server writes a report, the CLI reads one -- so the
schema is a Go type the compiler checks rather than a wire format two
codebases agree about in prose. Version states the rule: add fields,
never repurpose one. The shell had no such constraint because it was
shipped fresh from the laptop on every poll.

Ported: the inventory, the per-container cgroup stats (v1 and v2), disk,
listening ports read from /proc/<pid>/net/tcp, volume sizes, and the
access-log request counts. Diagnose is new as a place rather than as
logic -- alias clashes and the rest were computed by each reader, and
there are about to be four readers.

Two properties carried across deliberately, with tests that fail if
either is lost: no secret and no request detail can reach the report,
and report.json is 0644 so the reporting account needs no privileges.

Found on the way: reading a seeked file dropped the first line whether or
not a seek had happened, which silently lost the oldest reading of every
history and access log small enough to be read whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switches every read path -- `komizo list`, the TUI poll, the monitor --
off piping shell over SSH and onto komizo-box. `komizo report` is new:
one reading of a box, in one screen, with --json for anything else.

init installs the agent instead of the sampler. The binary is embedded
(internal/agent) rather than downloaded, for the same reason the
provisioning shell is: init has to work on a box with nothing on it, and
adding a release URL to that would add a network dependency and a
supply-chain step to the one operation meant to work when little else
does. Two connections -- the binary on its own, then the installer --
because base64 in a heredoc turns a failed transfer into a syntax error
in the middle of a script running as root.

The agents must exist before the CLI compiles, since //go:embed cannot
invoke a compiler. `make agents` builds them and the release action runs
it first; a CLI built without gets one clear message at the moment
somebody installs an agent, not a broken build.

Both binaries are 2.6MB, so the CLI grows by ~5MB for amd64 and arm64.

komizo_monitor is created with no shell, no doas and no docker group --
appify.md §3's account, which nothing uses yet because there is no agent
process to run as it. The per-minute crontab entry goes; the sampler's
log stays, being the only record of what happened before the update.

Fixed on the way: rootd created /var/lib/komizo even when --report
pointed elsewhere, which made it unrunnable as anyone but root and
untestable. It now creates only what it writes, and laying out the box
is the installer's job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README described a server komizo installs almost nothing on and
reads by piping shell. Half of that is still true -- provisioning is
still shell -- and the reading half is not.

Says the trade out loud rather than only the win: the shell arrived fresh
on every poll, so a newer komizo read new things off an untouched box,
and an agent has to be updated to learn anything new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draws the line at v0. Nothing here preserves anything the old path did;
what the agent reports is what komizo knows.

Deleted: ~530 lines of telemetry shell (inventory, metrics, system-log,
storage, sampler, sampler-install and the three probe libs), their Go
builders, and the six record parsers in internal/app. About 1,300 lines
of test went with them -- not because the behaviour stopped mattering,
but because they asserted on a wire format that no longer exists.

The tests that DID matter were fixture-bound rather than format-bound, so
they now build box.Reports and go through inventoryFromReport. That is
strictly better than before: the display tests exercise the one path a
real box's facts actually take, instead of a hand-written tab record no
box ever emitted. Two of them had drifted far enough to assert an
upstream of "blog-web" when every box has always written "<app>-gate".

Compat removed: the one-line version file, the history file that had to
coexist with system.log, the crontab line that displaced the sampler.

Fixed, a regression from the port: parseInventory scrubbed control
characters at the boundary and inventoryFromReport did not, so an escape
sequence in a container name or docker's status prose would have reached
the terminal. Scrubbing is back, in the one place values cross.

Fixed, a second one: disk folding lived in the deleted parseSystem. The
agent already folds by device in Probe.disks, so the reader now trusts
what it is sent -- and the rule finally has a test where it lives.

And the optimisation the port had regressed: containers were found with
a `compose ps` per app, then an `inspect` per container for its service
and pid, then another for its mounts. Roughly eighty docker calls per
report on a six-app box, on a five-second poll. Membership now comes
from compose's own project.working_dir label, and aliases, mounts, pids
and timestamps all come out of one `inspect` over every id at once.

Five calls per report, flat, at one app and at twenty. There is a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicodes nicodes changed the title feat: v0 — the report, in Go feat: v0 — the agent, and only the agent Aug 2, 2026
nicodes and others added 4 commits August 2, 2026 17:18
…on gate

errNoAgent matched "not found" against the captured stdout and the Go
error string. Neither can hold it: the message is written to the far
end's stderr, which runCapture passes through to the terminal, and the
error this side is "exit status 127". So a box with no agent produced a
raw exit-status error, and the one instruction that would have fixed it
was unreachable.

Now it matches the exit CODE. 127 is what a shell reports for a command
it could not find, and ssh passes the remote status through unchanged.

Stderr is captured instead of passed through, which also stops a failing
agent from writing into the middle of the TUI's frame.

The poll and the monitor decoded with a bare json.Unmarshal, so only the
report was ever checked against the schema version -- and the two that
skipped it are the ones that would have rendered a plausible screen of
nothing. Every document now states its version and goes through one
generic box.Decode. A document with no version at all is refused rather
than read as v0: some other JSON would otherwise decode into a report
full of zeroes and draw as a broken server.

Report.Stale had no caller. It does now: `komizo report --cached` says
when a reading is old, which is the one case where it can be, and a
stale report of a healthy box is exactly what a box that died five
minutes ago looks like.

Also removed: `komizo-box metrics` and `history`, which `monitor`
subsumes and nothing called; the awk-execution tests and helpers left
behind by the deleted metrics.sh; three copies of the agent-detection
logic, now one askBox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Volumes and dirBytes had no test at all, and the rules in them are the
subtle kind: a volume shared by two containers is walked once and
attributed twice, hard links count once, a sparse gigabyte is measured
by what it occupies rather than what it claims, and a volume whose host
directory is gone reports nothing rather than zero.

internal/agent had none either. It now asserts that every architecture
komizo claims to ship is actually embedded and is actually an ELF binary
-- a release missing one would otherwise be found by whoever ran init on
an arm box.

lastLine was wrong for the one-line case: it searched the trimmed string
and sliced the original, so a single line ending in a newline fell
through untouched and would have carried the newline into a status bar.

Also: a test helper reimplementing strings.Contains, and the last two
comments describing code that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First run on a real Alpine box, which is what this was waiting for.

report.json was 0644 inside /var/lib/komizo, which is 0750 root:root --
correctly, because apps/<app>.env names every app's deploy account and
directory. Nothing unprivileged can traverse into it, so the mode on the
file meant nothing: `su komizo_monitor -c "cat report.json"` is
Permission denied.

That is the property design/appify.md §3 is built on, and it did not
hold. The test that was supposed to catch it asserted the file's mode,
which is a proxy for the property rather than the property, and passed
the whole time.

The report moves to /run/komizo/report.json, in a 0755 directory. /run
because that is what /run is for, and because a report is a claim about
NOW -- tmpfs clears it on reboot and rootd rewrites it seconds later,
which is right rather than a cost. The history stays under /var/lib: it
has to survive a reboot, and nothing unprivileged needs it, because the
agent posts each report as it is written.

The installer now PROVES the boundary on the box rather than asserting
it -- it reads the report as komizo_monitor and fails the install if it
cannot. The Go test walks the real directory chain, and checks that the
check can fail by pointing it at the old layout.

Also from the same run: komizo_monitor's home field named an /home path
nothing created, now /nonexistent; history.jsonl claimed 0644 inside a
private directory, now 0640, because a mode that says something the
directory contradicts is how this happened in the first place.

Verified on alpine:3.20 with openrc: static musl binary runs, adduser
flags are right, the service file is written, rc-update takes it, the
account can read the report and cannot read the state directory, and a
second run of the installer is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
internal/box was right when two programs in one module shared it. The
service is a third reader and a different module, and a schema only one
module can express is one the others have to restate -- which is two
definitions of a wire format waiting to disagree, and the exact thing
the version rule exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicodes
nicodes merged commit fb1abcf into main Aug 3, 2026
1 check passed
@nicodes
nicodes deleted the feat/box-report branch August 3, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant