Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions components/website-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,62 @@ Guest services start inside the VM, not on the host.
- Guest: `192.168.83.2/24`, gateway `192.168.83.1`
- Host bridge: `agentbr0`, containing only the `agent-grace` tap.

## Guest access and first boot

From the homelab's `admin` account:

```sh
ssh grace-editor
```

The host alias uses `~/.ssh/grace-editor`; its public key is declared in
`components.website-editor.authorizedKeys`. The private key stays on the host.
Guest SSH accepts key authentication from the bridge gateway only; port 22 is
not forwarded from the LAN. From another device, SSH into patroclus first.
The guest has passwordless sudo. Its own SSH host key persists on the home
volume, so rebuilding/rebooting it does not change the server identity.

The private website repository requires a guest-specific GitHub deploy key.
Before the initial clone, create one **inside the guest**:

```sh
install -d -m 700 ~/.ssh
ssh-keygen -t ed25519 -N '' -C grace-editor -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub
```

Add that public key to `ajaxbits/gracebobber` → Settings → Deploy keys, enabling
write access for publishing. Keep the private half inside the guest. The
bootstrap uses SSH transport and a pinned GitHub host key; it never receives
the administrator's broad GitHub token. Bootstrap initially fails until this
deploy key is provisioned; guest SSH remains available independently.
Deploy keys authenticate Git operations only. Watching private Actions runs
with `gh` additionally requires a repository-scoped token with Actions read
access; Copilot authentication is not a substitute for that permission.

Then run **inside the guest**:

```sh
sudo systemctl restart grace-editor-bootstrap
sudo systemctl start opencode2-grace-editor grace-editor-preview
sudo systemctl status grace-editor-bootstrap opencode2-grace-editor grace-editor-preview
```

For bootstrap errors, use `sudo journalctl -u grace-editor-bootstrap -b` in
the guest. Its home filesystem is explicitly mounted before boot activation,
then tmpfiles sets the volume root's ownership to `agent:users`. Dependency
installation is stamped only after `npm ci` succeeds and repeated after a
lockfile/Node version change or incomplete install.

OpenCode's generated server password is available to the guest administrator
in `sudo journalctl -u opencode2-grace-editor -b`. Use it to connect the web UI,
then connect GitHub Copilot there. That OAuth connection and the repository
deploy key serve separate purposes. Both OpenCode state and the checkout are
on `/home/agent` and survive a VM restart.

`just dev` checks the preview over guest loopback, while reporting the LAN URL
to Grace. The agent is deliberately unable to connect to that host URL itself.

## Network ownership

This component does **not** enable the NixOS global nftables/NAT services or
Expand Down
5 changes: 5 additions & 0 deletions components/website-editor/options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ in
{
options.components.website-editor = {
enable = mkEnableOption "Grace Bobber's isolated website-editor MicroVM";
authorizedKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Public SSH keys for the guest agent account. SSH is reachable from the host only, not forwarded to the LAN.";
};
lan = {
interface = mkOption {
type = types.strMatching "[a-zA-Z0-9_.-]+";
Expand Down
120 changes: 101 additions & 19 deletions components/website-editor/vm.nix
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,51 @@ let
cfg = config.components.website-editor;
hostConfig = config;
hostName = "grace-editor";
repo = "https://github.com/ajaxbits/gracebobber.git";
repo = "git@github.com:ajaxbits/gracebobber.git";
tools = with pkgs; [
inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2
bash
curl
exiftool
fd
git
gh
imagemagick
jujutsu
jq
just
nodejs_22
openssh
poppler-utils
pkg-config
python3
ripgrep
vips
];
globalOpenCodeConfig = pkgs.writeText "grace-editor-opencode.json" (builtins.toJSON {
"$schema" = "https://opencode.ai/config.json";
update = "disable";
warming = false;
});
in
{
config = lib.mkIf cfg.enable {

programs.ssh.extraConfig = ''
Host grace-editor
HostName ${cfg.vm.ip}
User agent
IdentityFile ~/.ssh/grace-editor
IdentitiesOnly yes
'';

age.secrets."grace-editor/opencode.env" = {
file = ../../secrets/grace-editor/opencode.env.age;
path = "/run/grace-editor-secrets/opencode.env";
mode = "0400";
};


# Intentionally no autostart: run `systemctl start microvm@grace-editor`
# when the editing environment is wanted.
microvm.vms.${hostName} = {
Expand Down Expand Up @@ -49,24 +84,50 @@ in
home = "/home/agent";
createHome = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = cfg.authorizedKeys;
};
security.sudo.wheelNeedsPassword = false;

environment.systemPackages = with pkgs; [
inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2
bash
curl
exiftool
fd
git
imagemagick
jj
just
nodejs_22
poppler-utils
pkg-config
ripgrep
vips
environment.systemPackages = tools;
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# GitHub's published Ed25519 host key (https://api.github.com/meta).
# First-boot cloning must not block on an interactive trust prompt.
programs.ssh.knownHosts."github.com".publicKey =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";

services.openssh = {
enable = true;
openFirewall = false;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "no";
AllowUsers = [ "agent" ];
};
hostKeys = [ {
path = "/home/agent/.ssh-host-keys/ssh_host_ed25519_key";
type = "ed25519";
} ];
};
# Management is host-initiated; the host forwards only the web ports.
networking.firewall.extraCommands = ''
iptables -A nixos-fw -s ${cfg.vm.gateway}/32 -p tcp --dport 22 -j nixos-fw-accept
'';
networking.firewall.extraStopCommands = ''
iptables -D nixos-fw -s ${cfg.vm.gateway}/32 -p tcp --dport 22 -j nixos-fw-accept 2>/dev/null || true
'';

# A newly formatted volume has a root-owned filesystem root. User
# creation alone doesn't chown an existing home/mount point.
fileSystems."/home/agent".neededForBoot = true;
systemd.tmpfiles.rules = [
"d /home/agent 0700 agent users -"
"d /home/agent/.ssh-host-keys 0700 root root -"
"d /home/agent/.config 0700 agent users -"
"d /home/agent/.config/opencode 0700 agent users -"
# Update policy is global-only in V2. L (without +) preserves an
# existing guest configuration rather than overwriting user edits.
"L /home/agent/.config/opencode/opencode.json - - - - ${globalOpenCodeConfig}"
];

microvm = {
Expand Down Expand Up @@ -112,22 +173,39 @@ in
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
path = with pkgs; [ git jj nodejs_22 ];
path = tools;
environment.HOME = "/home/agent";
unitConfig.RequiresMountsFor = "/home/agent";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "agent";
WorkingDirectory = "/home/agent";
TimeoutStartSec = "15min";
};
script = ''
export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=15"
if [ ! -d gracebobber/.git ]; then
git clone ${repo} gracebobber
fi
cd gracebobber
if [ -z "$(jj config get user.name 2>/dev/null || true)" ]; then
jj config set --user user.name "Grace website editor"
fi
if [ -z "$(jj config get user.email 2>/dev/null || true)" ]; then
jj config set --user user.email "grace-editor@localhost"
fi
if [ ! -d .jj ]; then
jj git init --colocate
jj bookmark track main --remote=origin
fi
if [ ! -d node_modules ]; then
# A failed npm ci may leave node_modules behind. Only record the
# lockfile and Node version once an installation actually succeeds.
signature="$(sha256sum package-lock.json | cut -d ' ' -f1):$(node --version)"
if [ ! -x node_modules/.bin/astro ] ||
[ "$(cat node_modules/.grace-editor-deps 2>/dev/null || true)" != "$signature" ]; then
npm ci
printf '%s\n' "$signature" > node_modules/.grace-editor-deps
fi
'';
};
Expand All @@ -136,12 +214,14 @@ in
wantedBy = [ "multi-user.target" ];
after = [ "grace-editor-bootstrap.service" ];
requires = [ "grace-editor-bootstrap.service" ];
path = tools ++ [ "/run/wrappers" "/run/current-system/sw" ];
serviceConfig = {
User = "agent";
WorkingDirectory = "/home/agent/gracebobber";
Environment = [
"HOME=/home/agent"
"GRACE_EDITOR_PREVIEW_URL=http://172.22.0.10:${toString cfg.previewPort}"
"GRACE_EDITOR_PREVIEW_URL=http://${cfg.lan.hostIP}:${toString cfg.previewPort}"
"GRACE_EDITOR_PREVIEW_CHECK_URL=http://127.0.0.1:${toString cfg.previewPort}"
];
EnvironmentFile = hostConfig.age.secrets."grace-editor/opencode.env".path;
ExecStart = "${inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2}/bin/opencode2 serve --hostname 0.0.0.0 --port ${toString cfg.editorPort}";
Expand All @@ -154,6 +234,8 @@ in
wantedBy = [ "multi-user.target" ];
after = [ "grace-editor-bootstrap.service" ];
requires = [ "grace-editor-bootstrap.service" ];
path = tools;
environment.HOME = "/home/agent";
serviceConfig = {
User = "agent";
WorkingDirectory = "/home/agent/gracebobber";
Expand Down
8 changes: 7 additions & 1 deletion hosts/patroclus/configuration.nix
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ in
"github:ajaxbits/config#patroclus";
};
cloudflared.enable = true;
website-editor.enable = true;
website-editor = {
enable = true;
authorizedKeys = [
# Private key stays on patroclus in ~admin/.ssh/grace-editor.
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICDgXkJ7RFsYogbUgTKwxRcGx70e5pULOzDFl8e5i9yp patroclus grace-editor administration"
];
};
ebooks.enable = false;
mediacenter = {
enable = true;
Expand Down
Loading