jauntbox Get started

Documentation

How jauntbox works.

Commands, safety model and configuration. New here? Start with the quick start.

Box lifecycle

Up, work, down

A box is a server plus your persistent /home volume. You start it in the morning, work on it over Tailscale SSH, and it tears itself down in the evening. jauntbox is a single Go binary on your laptop; on the box the same code answers to jauntctl.

$ jauntbox up small               # returns at once; the box is ready a few minutes later
$ jauntbox status                 # box, tailnet presence, deadline and verdict
$ jauntbox                        # up + wait + attach (reconnects after network drops)
$ jauntbox ssh                    # a plain shell on the box
$ jauntbox night --until 07:00    # let agents keep the box overnight
$ jauntbox resize big             # swap to another profile; /home persists
$ jauntbox down                   # wrap up and delete the box (the volume stays)
  • up <profile> returns at once. Add --wait to block until the box is ready.
  • Bare jauntbox brings the box up if needed, waits, and attaches your persistent herdr session (one workspace per repo). It survives dropped Wi-Fi.
  • jauntbox ssh is a plain shell over Tailscale.
  • jauntbox down wraps up agents, revokes tokens, stops containers, syncs and deletes the box. It waits up to wrapup_grace + 14 minutes. The volume stays.
  • If the tailnet policy does not allow root ssh, down on an online box stops with an error and suggests jauntbox down --force, which deletes the box without the wrap-up. An offline box is deleted through Hetzner.

Profiles and resize

Profiles map a name to a Hetzner server type. The defaults are small, big and huge; jauntbox init picks server types for you and you can change them in the config (see configuration). jauntbox resize big swaps to another profile: more cores and memory in about two minutes, with /home intact.

Limits, self-delete and the safety net

The box goes down by itself, unless you are actively working or authorised a robot night session:

  • An on-box supervisor deletes the server at the evening cutoff (19:00), after the maximum uptime (10 hours), or when idle. It keeps the box while you type.
  • jauntbox night --until 07:00 lets agents keep the box overnight until a deadline. The night deadline itself starts a wrap-up.
  • A safety net deletes a box whose supervisor stops reporting.
  • The /home volume is never deleted by any automatic route; the server and its system disk are rebuilt from stock Ubuntu with the same toolchain each day.
  • Every limit is a config key (limits.*); times are evaluated in your timezone.

Your editor and your services

The box is a normal ssh host on your tailnet, so the tools you already use work with it as is.

  • VS Code Remote-SSH (and other remote editors that speak ssh): connect to <user>@jauntbox-<user> and open a folder under ~/src. It goes over Tailscale SSH, so no key or port setup. The editor’s server and extensions run on the box as you; agents still run in their containers, started with jauntctl agent from a terminal (the editor’s integrated terminal works).
  • Your services, from the laptop: anything you publish with docker compose or docker run -p on the box is reachable from your laptop at http://jauntbox-<user>:<port> over the tailnet: a database client, the app in your browser, an admin UI. Not from the internet: the box’s firewall only lets Tailscale in. IPv6 addresses go in brackets in a browser (http://[fd7a:…]:8081/).
  • Agents’ own servers are not published: reach them from the box by the container IP in jauntctl agent list, or from the laptop with ssh -N -L 8080:<run IP>:8080 <user>@jauntbox-<user>.

GitHub and secrets

No keys from home

The box never holds a long-lived credential on disk. A private GitHub App mints tokens for one repository at a time, valid for an hour and revoked at teardown.

Why a private GitHub App

The usual ways to let a machine push to GitHub are a personal access token, an SSH or deploy key, or gh auth login on the box. All of them are long-lived, sit on disk on the box, and usually reach far more than one repo (a token or a gh login can reach every repo you can). That is exactly what a prompt-injected agent would look for. A GitHub App changes each of those points.

Instead of

  • A token or key that never expires and sits on the box's disk.
  • A login that reaches every repo you can.
  • A credential you must remember to rotate or revoke.
  • A credential that may push to your default branch.

You get

  • Only the repos you install the App on, with just contents: write, pull_requests: write and metadata: read: no workflows, no administration, no secrets.
  • A token for one repository at a time, valid for 1 hour; an agent run gets a token for its own repo only.
  • Tokens revoked before the box deletes itself. Nothing long-lived is written to the box's disk: the App's private key is pushed into the box's RAM vault by jauntbox unlock and never enters agent containers.
  • Rulesets keep the default branch for humans. The App never bypasses them, so agents push branches and open PRs and you merge (see rulesets; on GitHub Free, setup github names the repos it can't protect).

“Private” means only its owner (you, or your organisation) can install it. It isn't listed publicly and nobody else can add it to their repos. setup github creates it for you in the browser in a minute (the manifest flow), named jauntbox-<user>, one App per person.

$ jauntbox setup github           # once: create and install the GitHub App
$ jauntbox setup github --check   # later: report drift from the expected permissions
  • setup github runs the GitHub App manifest flow in your browser and creates jauntbox-<user>. It asks which account owns the App: your personal account or one of your organisations (default github.org; --owner <login> skips the question). The App is private, so only its owner can install it. Install it on the repos you want agents to touch.
  • It needs an admin credential once: gh auth token, or GITHUB_ADMIN_TOKEN when gh is absent. That credential is used on the laptop only; it is never stored and never sent to the box.
  • If your browser is on another device than the one running jauntbox, add --listen tailnet (the callback server binds to this machine's Tailscale IP) or --listen <ip>[:port]. Wildcard addresses such as 0.0.0.0 are refused.
  • The App has contents: write, pull_requests: write and metadata: read. Nothing else: no workflows, no administration, no secrets.
  • On the box, git uses a credential helper that mints a one-repo token, cached until 10 minutes before it expires. jaunt-repos lists the repos the App is installed on and clones them; it warns if it finds an old gh auth login, which you should remove.
  • Tokens are revoked before the box deletes itself; token.revoked events are written to /home/.jauntbox/events.jsonl on the volume.

Rulesets and the GitHub Free caveat

On each repo the default branch (plus any github.protected_patterns entries in your config) can only be updated or merged by repo admins, which is you. The App never bypasses the ruleset, so agents push branches and open PRs; you merge.

GitHub only runs rulesets on private repos for paid plans (Team for an organisation, Pro for a personal account). On GitHub Free, setup github protects what it can, names the private repos it could not protect, and stops. --allow-unprotected (one run) or github.allow_unprotected: true accepts that agents can push to those repos' default branch; setup github --check keeps flagging them with a warning.

Adding repos later

The App works for any repo you install it on, including ones you create after setup. Three steps:

  1. Install the App on the repo (on GitHub). In the settings of the account or organisation that owns the App, open GitHub Apps, click Configure next to jauntbox-<user>, and add the repo under Repository access. Choosing All repositories includes every repo you create later automatically.
  2. Protect it: run jauntbox setup github again. It skips what already exists (the App, its key, its permissions) and adds the default-branch ruleset to every repo the App is installed on. Skip this and agents can push to the new repo’s default branch. jauntbox setup github --check lists any installed repo whose ruleset is missing or has drifted. On GitHub Free a private repo can’t get a ruleset: setup names it and stops unless you accept that with allow_unprotected (see above).
  3. Use it on the box. Nothing to restart or unlock: the repo list is read live and tokens are minted per repo on demand. jaunt-repos shows and clones the new repo, and jauntctl agent works in its checkout. Starting an agent in a repo the App isn’t installed on is refused with the install link.

Secrets, the RAM vault and unlock

Your secrets stay on the laptop in ~/.config/jauntbox/secrets/ (files mode 0600) and are pushed into the box's RAM (/run/jauntbox/secrets) when the box doesn't hold them, such as a new or rebooted box.

$ jauntbox secrets set CLAUDE_CODE_OAUTH_TOKEN   # an agent credential (prompted, never echoed)
$ jauntbox secrets set BOX_SUDO_PASSWORD         # the sudo password on the box
$ jauntbox secrets list                          # names only, never values
$ jauntbox unlock                                # push secrets to the box
  • After jauntbox secrets set, run jauntbox unlock to push the new value.
  • Locked state. After a reboot (or before the first unlock) the box has no secrets in memory, and git push prints the locked message. Laptop commands that reach the box (jauntbox, ssh, status) unlock it automatically; jauntbox status shows the locked message if that failed or this laptop has no secrets stored.
  • jauntbox unlock refuses to push an empty set, so a secret-less client never wipes a working box.
  • Box-side commands answer to jauntctl (supervise, stop, night-set, box-status, secrets-load, mint, repos, git-credential). After upgrading jauntbox, run jauntbox or jauntbox up once against an existing box before jauntbox down, so the box gets jauntctl.

Locked and unlocked

Your secrets live on your laptop (~/.config/jauntbox/secrets/, files 0600). The box only ever holds them in RAM, in a root-only tmpfs (/run/jauntbox/secrets), never on its disk. So a new or rebooted box starts locked: no GitHub App key and no agent credentials in memory. That is deliberate: a rebooted box, a disk snapshot or someone booting the server into a rescue system finds no credentials.

  • While locked, git push and token minting print the locked message, jauntctl agent refuses to start, and jauntbox status shows the box as locked.
  • Unlocking pushes the secrets from the laptop into the box's RAM over ssh.
  • Laptop commands that reach the box (jauntbox, jauntbox ssh, jauntbox status) unlock it automatically.
  • jauntbox unlock does it explicitly. You need it after jauntbox secrets set, to push a changed value.
  • It refuses to push an empty set, so a laptop with no secrets stored never wipes a working box.
  • A reboot also ends running agent containers' tokens (they live in RAM too). After unlocking, start agents again.

Root and sudo on the box

You are not root by default

  • Root comes from the laptop. jauntbox's own privileged steps (install, units, secrets load, the unlock marker, stop, night, box status) run over Tailscale SSH as root. The tailnet policy allows that only for you. If it is not allowed, the command fails with root ssh to <host> failed; your tailnet policy must allow ssh as root to tag:jauntbox-<user>. Run jauntbox acl-snippet (it includes root), paste it into your policy, and retry. The laptop never falls back to your sudo.
  • sudo asks for a password. Set it with jauntbox secrets set BOX_SUDO_PASSWORD. Every unlock applies it with chpasswd; it is never stored on the box. Without it, unlock and status print a note and you have no sudo password. An empty password is refused.
  • Passwordless sudo covers four jauntctl commands only: mint, repos, box-status and agent, in the forms the sudoers file lists. Not install, systemctl, secrets-load, stop, night-set or supervise. Under sudo, jauntctl refuses --config, scrubs its environment, and mint takes only owner/name.

Rootless Docker and Compose

There is no docker group. docker and docker compose work without sudo against your own rootless daemon (context rootless, socket /run/user/<uid>/docker.sock, data in ~/.local/share/docker on /home, so images survive rebuilds). Published ports must be 1024 and up. sudo docker ... reaches the root daemon and asks for your password. Rootless setup is best-effort: if it fails, the provisioning log says WARNING: rootless docker setup failed: <step>.

Metadata endpoint

The unit jauntbox-metadata-guard.service drops traffic to 169.254.169.254 from any non-root process, so the Hetzner user data (which holds boot-time secrets) isn't readable by you or your containers. If iptables fails the unit fails open and shows as failed.

The accepted weakness

You know the sudo password, so you can become root on your own box. A process running as you can't use sudo by itself, but it can plant a fake sudo (an alias or a PATH shim in your shell config) and capture the password the next time you type it. Type the password only in a shell you trust. The split narrows what a process running as you can do on its own; it does not protect against you or against a shell that has already been tampered with.

Upgrading

Update the tailnet policy first. Boxes created before the privilege split keep the old passwordless sudo for everything until they are recreated, which the daily teardown does. Such a box also keeps its old jauntctl, so BOX_SUDO_PASSWORD sits in that box's RAM vault until it is rebuilt.

Agents

Claude in a container, one repo at a time

Agents run in containers on the box, never in your shell. You start them from the box itself with jauntctl agent, in the folder of the repo they should work on.

$ jauntbox                # attach the box's herdr session
$ cd ~/src/<repo>         # in the repo's herdr pane
$ jauntctl agent          # Claude opens right there, in a container
$ jauntctl agent [--branch b] [--max 2h] [--skip-permissions]   # start an interactive run in this checkout
$ jauntctl agent --unattended "one-line prompt"                 # start a run that works on its own
$ jauntctl agent attach [<run>] [--shell]   # reopen Claude (it continues) or open bash in the container
$ jauntctl agent list                       # runs with state, limit and container IP
$ jauntctl agent stop [<run>] [--now]       # ask for a wrap-up; --now also stops the container
$ jauntctl agent logs [<run>] [--follow]    # a run's Claude log

First start, step by step

Steps 1 and 2 are once per laptop and once per box; step 3 is what you do every time.

  1. Give agents a Claude credential (on your laptop, once). It is stored with your other secrets and pushed into the box’s RAM.
    $ claude setup-token   # prints a long-lived token: copy it
    $ jauntbox secrets set CLAUDE_CODE_OAUTH_TOKEN   # paste it; never echoed
    $ jauntbox unlock   # push it to the box
  2. Prepare the box (on the box, once). Agents commit as you, so set your git identity, then clone the repos the App is installed on into ~/src.
    $ git config --global user.name "Your Name"
    $ git config --global user.email you@example.com
    $ jaunt-repos   # lists the installed repos and clones them
  3. Start an agent (every time). Open the box’s herdr session from your laptop, open a pane in the repo, and start the run there.
    $ jauntbox   # on the laptop: attach the box's herdr session
    $ cd ~/src/<repo>   # in a pane
    $ jauntctl agent   # Claude opens in this pane, inside a container

    The very first start builds the agent image: it says so and takes a few minutes. Later starts take seconds.

  4. Come back to it. Closing the pane doesn’t end the run.
    $ jauntctl agent list   # your runs: state, limit, container IP
    $ jauntctl agent attach   # reopen Claude in this checkout (it continues)
    $ jauntctl agent stop   # ask it to wrap up into CONTINUATION.md

If it refuses: a locked box (run jauntbox unlock, see Locked and unlocked), a missing CLAUDE_CODE_OAUTH_TOKEN or git identity, a repo the App isn’t installed on (the message has the install link, see Adding repos later), or a checkout that already has a run (it names the run to attach to). Each message says how to fix it.

How long it runs: --max 2h sets this run’s limit (above 0, at most 24h); otherwise the box’s agent.max_duration applies (default 8h). At the limit the run wraps up.

Where it works, and branches

The container mounts the checkout you are in, with your uid, so you see its changes live and its files stay yours. The agent stays on your current branch. On the repo's default branch (or a detached HEAD) the run switches the checkout to agent/<repo>-<YYYYMMDD> and says so; --branch b switches to b (created from origin/b when only the remote has it, else from your current commit). Uncommitted changes go along; a switch git refuses fails the start. Agents push branches and open PRs, never merge.

Several repos at once

Each repo under ~/src can have its own run, container and token, one run per checkout. A second jauntctl agent in a checkout that already has a run refuses and names the run to attach to.

Closing the pane

Closing the pane doesn't end the run. Interactive containers stay up; reopen with jauntctl agent attach (in the same folder, or give a run id). attach and stop act only on your own running containers. --skip-permissions lets interactive Claude skip permission prompts; unattended runs always do, because the container is the sandbox. herdr does not see Claude's working or idle status through docker exec.

Unattended runs and logs

jauntctl agent --unattended "…" starts a run that works on its own. The prompt must be a single line for now (multi-line prompts are refused). jauntctl agent logs --follow watches the run. logs reads any of your own runs, finished or not; without an id it shows the newest run of the current checkout.

Isolation

What a container holds, and what it can't reach

What a container holds

Only the checkout (/work), a read-only per-run state directory (its token and wrap-up flag), the repo's agent home on the volume (/home/agent: Claude's config and transcripts, the mise cache) and the run's log directory (/var/log/jauntbox). No Docker socket, no host networking, not privileged, all capabilities dropped, no-new-privileges.

It gets a token for that one repo (re-read from a file the supervisor refreshes) and only the agent credentials the adapter declares. It never holds the App private key, the Hetzner token or other repos' tokens. Toolchains come from mise inside the container (the repo's mise config is trusted there), cached in the agent home on the volume.

What it cannot reach

  • The cloud metadata service (169.254.169.254) and the tailnet (100.64.0.0/10, fd7a:115c:a1e0::/48): traffic a container sends through the box is dropped on the root daemon (DOCKER-USER, IPv4 and IPv6).
  • The box itself: anything arriving from a Docker bridge (docker0, br-*) at the host (INPUT) is dropped, so a container cannot reach the bridge gateway, the box's tailnet address, sshd or anything you run on the box (dev servers, databases, your rootless Docker's published ports), on any of the box's addresses.
  • Replies to connections the box opens are let through, so you still reach a container by its IP (previews). The internet, GitHub and package registries stay reachable.
  • The unit is enabled by the install step (best effort) and every jauntctl agent start re-applies the rules and refuses to start if it cannot. Containers you start yourself on the root daemon (sudo docker run) are covered once the guard has been applied (by an agent start or after a reboot).

The checkout guard, and what it does not cover

Inside the container .git/config, .git/hooks, .git/commondir and (if present) .git/modules are read-only, so the agent cannot plant hooks or config your own git would run. .git itself is mounted separately (writable, so the agent can commit), which makes it a mount point the agent cannot move away or remove to plant a fresh one. Your global git settings also stop your git descending into submodules.

It does not cover code you run yourself after the agent changed it: tests, make, mise and direnv config. Run agent-changed tests inside the container (jauntctl agent attach --shell) and don't trust agent-changed mise or direnv files. Host git works normally in the checkout (status, log, commit, push); only running the agent's code on the host is what to avoid.

Inside the container, because .git/config is read-only, git push -u reports a config write error but the push lands; branches are created without tracking.

Trust

The box user is trusted: they have the sudo password and can read everything an agent can. Agents never run on the host. This doesn't make an agent safe; it limits what a bad day can cost.

Wrap-up

CONTINUATION.md, and picking up again

On any forced stop (jauntctl agent stop, the run's --max duration, the box's night deadline or jauntbox down) the agent is told to write CONTINUATION.md (what is done, what is next), commit it and push. Claude's hooks (managed settings the repo and the agent cannot change) inject the instruction and block stopping until it is pushed.

  • If nothing appears within limits.wrapup_grace (default 5 min), the box writes a mechanical CONTINUATION.md (git status, diff summary, last log lines) and pushes it itself, with --no-verify so your repo's hooks don't block it.
  • It never pushes to the default branch: if the checkout is on it (or on a detached HEAD) by then, the work goes to a new agent/<repo>-wrapup-<UTC stamp> branch. If it cannot write the note it pushes nothing.
  • Teardown order: wrap-up agents, revoke tokens, stop containers, sync, delete the box.
  • Wrap-ups the box starts carry its stop reason prefixed box-: box-idle, box-evening-cutoff, box-max-uptime, box-night-deadline, box-agents-done or box-stop-requested. When the box goes back to "keep" (for example you type in a pane during an idle wrap-up), those are cleared, but not wrap-ups from jauntctl agent stop or run-max. jauntbox night itself starts no wrap-up; the night deadline does.
  • The supervisor's agent work is bounded: 2 minutes per normal pass, up to 5 minutes to finish agents before the box deletes itself.

Resume

jauntctl agent --branch <branch> on a branch that already has CONTINUATION.md opens Claude with "Read CONTINUATION.md at the repository root and continue from it."

Configuration

The config file

Default location: ~/.config/jauntbox/config.yaml (or $XDG_CONFIG_HOME/jauntbox/config.yaml). jauntbox init writes it; the commented example is [examples/jauntbox.yaml](https://github.com/WA-Systems-EU/jaunt/blob/trunk/examples/jauntbox.yaml). user, ssh_public_key and timezone have no default and are required. API tokens are never written to it: they come from HCLOUD_TOKEN and TAILSCALE_API_KEY (or ~/.config/jauntbox/env, mode 0600, which jauntbox reads when the variables are unset).

KeyDefaultMeaning
userrequiredLowercase letters, digits, dashes; names every resource.
ssh_public_keyrequiredYour public key, installed on the box.
timezonerequiredIANA name, e.g. Europe/Paris; all limit times are evaluated here.
locationfsn1Hetzner location.
imageubuntu-26.04Base image.
volume_size_gb100Size of the persistent /home volume.
box_binary""Path to the on-box supervisor binary.
default_profilesmallProfile used when none is given.
profilessmall cpx42, big ccx33, huge ccx43 (amd64)Name to server_type and arch. The example marks the server types as placeholders until verified; init picks types for you.
limits.max_uptime10hLongest a box stays up.
limits.evening_cutoff"19:00"Evening deletion time, in your timezone.
limits.idle_day2hIdle limit by day.
limits.idle_night1hIdle limit during night hours.
limits.night_hours"22:00-07:00"The night window.
limits.human_active_window30mHow recent your activity counts as actively working.
limits.wrapup_grace5mTime the agent gets to write and push CONTINUATION.md.
agent.max_duration8hPer-run cap on an agent container; above 0, at most 24h.
hetzner_token_envHCLOUD_TOKENName of the environment variable holding the Hetzner token.
tailscale_api_key_envTAILSCALE_API_KEYName of the environment variable holding the Tailscale API key.
github.orgnoneDefault owner offered by setup github.
github.protected_patternsnoneExtra branch patterns the ruleset protects, besides the default branch.
github.allow_unprotectedfalseAccept agents pushing to default branches that rulesets cannot protect (GitHub Free).

The github.* keys are described in the README prose; they are not in the example file.