<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-GB">
	<title>Unbeholden</title>
	<subtitle>Digital sovereignty, in practice. Every entry in full, so you can read it without loading the site.</subtitle>
	<id>https://unbeholden.dev/</id>
	<link rel="alternate" type="text/html" href="https://unbeholden.dev/" />
	<link rel="self" type="application/atom+xml" href="https://unbeholden.dev/feed.xml" />
	<updated>2026-09-18T00:00:00.000Z</updated>
	<author><name>Unbeholden</name></author>
	<rights>Prose CC BY 4.0. Code CC0/MIT.</rights>
		<entry>
			<title>An agent that configures my machine without touching it</title>
			<id>https://unbeholden.dev/blog/an-agent-that-configures-my-machine</id>
			<link rel="alternate" type="text/html" href="https://unbeholden.dev/blog/an-agent-that-configures-my-machine" />
			<published>2026-09-18T00:00:00.000Z</published>
			<updated>2026-09-18T00:00:00.000Z</updated>
			<category term="Agents / Linux" />
			<summary>I set up the same Linux desktop by hand twice in a few months, refused to do it a third time, and then had an agent write the configuration from a container that has never seen the machine.</summary>
			<content type="html"><![CDATA[<p>I moved off macOS recently and put Fedora on a laptop, configured by hand, the way everyone
does it the first time. Then that laptop turned out to be broken. I sent it back, got the
money, and fell back to an old ThinkPad that had been sitting in a drawer.</p>
<p>The second install went faster, because I had dotfiles. My shell, my editor and my
configuration files landed in the right places within a few minutes of a fresh system.</p>
<p>Then I spent the rest of the day on everything else. Dotfiles stop at <code>$HOME</code>. Every change I
had made below that line, the packages, the services, the mounts, the things I had fixed once
and forgotten, was not written down anywhere except on a laptop I had already posted back.
So I did it again from memory, badly, over an afternoon.</p>
<p>Sitting there, I worked out roughly how long it would take me to do it a third time, and
decided I was not going to. Not because a third machine was likely, but because I had just
learned that the part of my computer I actually cared about was the part I had never bothered
to record.</p>
<p>That is why everything is declared now. What I did not expect is that I would end up writing
almost none of it.</p>
<h2 id="two-machines">Two machines</h2>
<p>The laptop is that ThinkPad, a T460s, now running NixOS with niri and noctalia.</p>
<p>The agent has never logged into it. It runs in a dev container on a different box, in a clone
of the repository the laptop is built from. The container is for editing, not the target.</p>
<p>There is nothing to enforce there. The container holds no credential for the laptop and has no
route to it. Handed a change to my window manager, the only thing it can do is commit it.</p>
<p>That is the arrangement the whole repository was written under. I made the decision to go
declarative and then, instead of learning Nix a module at a time over a month, I had the agent
write the configuration and I read what came back. The disk layout, the window manager, the
shell, the module that installs the command I use to apply any of it: all of it arrived as
diffs from a container that has never seen this laptop.</p>
<h2 id="one-repository-three-profiles">One repository, three profiles</h2>
<p>If you have not met these: a Nix flake is a definition of a system with its inputs pinned, so
the same definition produces the same result later. Home-manager owns the <code>$HOME</code> half, the
dotfiles and user packages, and it will run on its own on a distribution that is not NixOS.
The system half is NixOS itself, which is the part my Fedora dotfiles could never have carried.</p>
<p>Three home profiles, one system:</p>
<pre class="astro-code unbeholden" style="background-color:transparent;color:var(--fg); overflow-x: auto;" tabindex="0" data-language="nix"><code><span class="line"><span style="color:var(--fg)">homeConfigurations = {</span></span>
<span class="line"><span style="color:var(--accent)">  "pycan@t460s"</span><span style="color:var(--dim)">    =</span><span style="color:var(--dim)"> ...</span><span style="color:var(--fg)">;  </span><span style="color:var(--faint)"># the laptop</span></span>
<span class="line"><span style="color:var(--accent)">  "root@deb-agent"</span><span style="color:var(--dim)"> =</span><span style="color:var(--dim)"> ...</span><span style="color:var(--fg)">;  </span><span style="color:var(--faint)"># the LXC that runs the dev containers</span></span>
<span class="line"><span style="color:var(--fg)">  container        </span><span style="color:var(--dim)">=</span><span style="color:var(--dim)"> ...</span><span style="color:var(--fg)">;  </span><span style="color:var(--faint)"># any dev container: its hostname is a random id</span></span>
<span class="line"><span style="color:var(--fg)">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:var(--fg)">nixosConfigurations</span><span style="color:var(--dim)">.</span><span style="color:var(--fg)">t460s = </span><span style="color:var(--dim)">...</span><span style="color:var(--fg)">;</span></span></code></pre>
<p>Home-manager runs standalone on the second and third, so Debian stays Debian. There is one
container profile rather than one per container, because a container’s hostname is an id
nobody chose and there is nothing there worth keying on.</p>
<p>So the shell I get on the laptop is the shell I get over SSH on the LXC and the shell the
agent gets inside a container, because one file builds all three.</p>
<h2 id="why-an-agent-is-safe-to-point-at-this">Why an agent is safe to point at this</h2>
<p>Its output is a diff.</p>
<p>It edits Nix files in a clone, commits, and pushes a branch to my own Forgejo. Nothing it
wrote is running anywhere.</p>
<p>Which is why I run it with permissions off and no prompts at all. A permission prompt asks
whether to let the agent do something. Here there is nothing being done, only a file being
written in a container with nowhere to reach, so approving or refusing would be theatre. The
review happens once, on the diff, when I am ready to read it.</p>
<p>Applying is a separate act and it is mine. One command, <code>dots</code>, which is a three-line script
the repository installs into itself:</p>
<pre class="astro-code unbeholden" style="background-color:transparent;color:var(--fg); overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:var(--fg)">git </span><span style="color:var(--accent)">-C</span><span style="color:var(--accent)"> "$dotfiles"</span><span style="color:var(--accent)"> switch</span><span style="color:var(--accent)"> main</span></span>
<span class="line"><span style="color:var(--fg)">git </span><span style="color:var(--accent)">-C</span><span style="color:var(--accent)"> "$dotfiles"</span><span style="color:var(--accent)"> pull</span><span style="color:var(--accent)"> --rebase</span><span style="color:var(--accent)"> --autostash</span></span>
<span class="line"><span style="color:var(--fg)">sudo </span><span style="color:var(--accent)">nixos-rebuild</span><span style="color:var(--accent)"> switch</span><span style="color:var(--accent)"> --flake</span><span style="color:var(--accent)"> "$dotfiles#t460s"</span></span></code></pre>
<p>On the laptop that last line is a <code>nixos-rebuild</code>; on the LXC the same command is a
<code>home-manager switch</code>, because that host has no system half to rebuild. If the result is wrong
the previous generation is still in the boot menu, which I have not yet needed and am glad to
have.</p>
<h2 id="it-tests-its-own-environment">It tests its own environment</h2>
<p>An agent that can only guess whether its change works has written you a suggestion.</p>
<p>The container profile is the environment the agent is standing in, so it applies its change to
itself:</p>
<pre class="astro-code unbeholden" style="background-color:transparent;color:var(--fg); overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:var(--fg)">./install.sh </span><span style="color:var(--accent)">container</span><span style="color:var(--faint)">   # and again: it has to be idempotent</span></span>
<span class="line"><span style="color:var(--fg)">zsh </span><span style="color:var(--accent)">-ic</span><span style="color:var(--accent)"> exit</span><span style="color:var(--faint)">             # has to be silent</span></span></code></pre>
<p>Break the shell and it is the agent’s own shell that breaks, immediately, in a container that
costs nothing to throw away.</p>
<p>The system half is checked without a NixOS machine anywhere:</p>
<pre class="astro-code unbeholden" style="background-color:transparent;color:var(--fg); overflow-x: auto;" tabindex="0" data-language="bash"><code><span class="line"><span style="color:var(--fg)">nix </span><span style="color:var(--accent)">eval</span><span style="color:var(--accent)"> .#nixosConfigurations.t460s.config.system.build.toplevel.drvPath</span></span></code></pre>
<p>That evaluates the whole configuration of a machine the container has never seen. A malformed
module, an option that does not exist, a package renamed upstream: all of it fails there, on
Debian, in a container, some minutes before I would have found out by rebooting into it.</p>
<h2 id="what-it-cannot-test">What it cannot test</h2>
<p>The graphical half. There is no Wayland session in a dev container, so niri cannot run, and
everything downstream of niri is reasoning rather than evidence.</p>
<p>My best example is a dictation tool called handy, which recorded, transcribed, and then pasted
nothing at all. The chain: niri strips <code>DISPLAY</code> from what it spawns when no X server is
running, handy’s input library then fails to construct, and handy 0.9.1 reads that handle
before it dispatches, so every paste died on <code>Enigo state not initialized</code> and never reached
the <code>wtype</code> it would otherwise have used. The fix is an <code>xwayland-satellite</code> that exists for
handy alone and for no X11 app at all. Upstream moved that read in a later release, so this
stops being necessary whenever nixpkgs catches up.</p>
<p>An agent is good at that. Reading the issue tracker, finding the commit that moved the read,
writing the module: all of it is work it can do from a container. Watching the paste land is
not, so I do that bit.</p>
<h2 id="what-the-shape-buys">What the shape buys</h2>
<p>The dev containers are long running and they are not on my laptop.</p>
<p>I have several projects open on that box at once, a container each, and I keep them in herdr,
a multiplexer whose sidebar tracks which agent is in which pane and what state it is in.
Several agents working at the same time, on different repositories, none of them able to
reach another’s files.</p>
<p>I close the lid whenever. Nothing stops. I ssh back in, attach to herdr, and it is all where I
left it, including whatever ran while the laptop was shut. I have read the tail of a long run
on a phone, on a train, which is not a way to work but does beat the run not having happened.</p>
<p>The ThinkPad was supposed to be a stopgap until I bought something better. It compiles
nothing and stays quiet, and because the work happens on the other machine it has become the
laptop I use. A faster one would not make anything I do faster.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The box in the cupboard is a dependency of my working day now. My power and my uplink sit in
the path of everything, which they did not when the work happened in front of me.</p>
<p>A long-running container is a pet. It drifts from its definition quietly, and you find out
what you had been leaning on the day you rebuild it.</p>
<p>Nix is a large thing to ask of anyone: most of what is good here follows from the machine
being declarative rather than from Nix in particular, but Nix is what I use and the ramp is
not gentle.</p>
<p>Secrets want care instead of convenience. The one credential a container gets is a token for
my own Forgejo, so the agent can push a branch, and it arrives by my answering a prompt when
the container is created. One token per container, and never inside a remote URL, because that
writes it into <code>.git/config</code> where a push can carry it back out. My wifi key lives in a mode
600 file outside the repository, because NixOS renders profile definitions into the store and
the store is world readable.</p>
<p>And there are traps peculiar to this. A flake reads tracked files alone, so a new module wants
<code>git add</code> before a switch or it may as well not exist. Home-manager refuses to write over a
file already sitting at the path it wants, which is correct, and is the error I see most.
Anything an application rewrites for itself cannot live in the read-only store, so it gets a
symlink back out to the clone, and then that application’s ordinary use dirties the repository.</p>
<h2 id="whether-it-holds">Whether it holds</h2>
<p>The T460s is the first machine I put NixOS on, and I will replace it eventually. That install
is the test of all of this. A disk, a flake, one command, and then I find out whether I have
kept the repository honest since the last time I touched it.</p>
<p>What I did not plan was the agent part. It fell out of a decision I made because I could not
face another afternoon of remembering what I had done to a computer that no longer existed.</p>
]]></content>
		</entry>
</feed>
