Skip to content

Repository files navigation

sidebutton/agent-runners

Install scripts for the SideButton agent VM: a thin bootstrapper, a shared base/ set of step scripts, and a catalog of optional components.

Production agents are provisioned by piping https://sidebutton.com/install.sh to bash — that thin bootstrapper resolves AGENT_RUNNER + AGENT_COMPONENTS + RUNNERS_REF, downloads this repo at the requested ref, and hands off to base/run.sh.

Model: one base + optional components

There is a single base runner, ubuntu-claude-code (named after its deps: Ubuntu desktop + Claude Code). The base installs a complete, dispatch-free agent — RDP in and run claude manually, clone the assigned workspace repos (git credential helpers are pre-wired). Everything else is an optional component, selected per-agent via the AGENT_COMPONENTS env list.

See docs/COMPONENTS.md for the full design.

Base (always installed)

XFCE desktop + xrdp/x11vnc/Xvfb, Node 22, the Claude Code CLI, ~/.agent-env + git credential helpers + ~/workspace, per-agent secrets, portal registration, and a recurring heartbeat (keeps the agent online even without the SideButton server). A base-only agent — one booted with an empty AGENT_COMPONENTS — has no capabilities and is not dispatchable: a manual / RDP agent. The portal no longer produces that set (see globally required below); it remains reachable for hand-rolled cloud-init and for agents provisioned before SCRUM-2036.

Components (components.json)

slug kind requires notes
claude-code runtime — Claude Code agentic CLI — the default agent runtime (base always installs it; no chip — the portal hardcodes a lead "Claude Code" chip)
claude-code-router runtime claude-code CCR proxy on 127.0.0.1:3456 routing Claude Code to a configured provider via ANTHROPIC_BASE_URL in ~/.agent-env
chrome runtime — Chrome browser
sidebutton-server runtime — MCP server on :9876 — unlocks dispatch + capabilities
sidebutton-extension runtime chrome, sidebutton-server Chrome managed-policy force-install + handshake wait
knowledge-packs packs sidebutton-server required: true — universal agents ops pack + account registry
dotnet9 toolchain — .NET 9 SDK
elixir toolchain — Erlang/OTP + Elixir via mise, one pair pre-baked at provision (Hex + rebar3 bootstrapped, shims on /usr/local/bin so dispatched jobs see them); repos override via .tool-versions (see docs/ELIXIR.md)
android-sdk toolchain — OpenJDK 17 + Android SDK (cmdline-tools, platform 36, build-tools; licenses pre-accepted; ANDROID_HOME set) — headless Gradle build/lint/unit-test, no emulator
android-emulator toolchain android-sdk Android Emulator + headless AVD sb-default (API 36 x86_64) with on-demand sb-avd-start/sb-avd-stop — needs KVM (/dev/kvm), on-device UI discovery/testing
docker toolchain — Docker engine (+ agent in docker group)
postgres-client toolchain — psql
openvpn toolchain — OpenVPN client + sb-vpn-connect helper; auto-consumes .ovpn at /etc/sidebutton/config/openvpn/ (see docs/OPENVPN.md)
wireguard toolchain — WireGuard client + sb-wg-connect helper (split-tunnel); auto-consumes .conf at /etc/sidebutton/config/wireguard/ (see docs/WIREGUARD.md)
rdp-client toolchain — FreeRDP (xfreerdp) + sb-rdp-connect helper; renders an outbound RDP session as a fixed-geometry window on :10 for computer-use; auto-consumes /etc/sidebutton/rdp.env (see docs/RDP.md)

base/components.sh resolves AGENT_COMPONENTS (comma- or space-separated) into the has_component helper + the SKIP_* / INSTALL_* gates the step scripts read, and enforces requires defensively. Component install logic lives under base/components/<slug>/ (install.sh for runtime/toolchain installs; pre-services.sh / post-services.sh for lifecycle phases — e.g. the extension's managed-policy + handshake).

A component may declare required: true — globally required: the portal unions it into every agent's component set regardless of profile or wizard selection, so it cannot be unchecked. knowledge-packs is the only one today; because its own requires pulls sidebutton-server in, unioning the required set and closing over requires makes every portal-provisioned agent dispatchable — retiring the RDP-only path by design.

Enforced since SCRUM-2036 (SCRUM-2035 shipped the data; SCRUM-2036 shipped the union in the portal's resolveProfile, which also honours each profile's locked list). The enforcement is at resolve time in the portal, not in these scripts: base/components.sh still takes AGENT_COMPONENTS at face value and forces the server only when packs are already selected — it never adds packs itself. One consequence worth knowing when hand-rolling cloud-init: the portal's floor is non-empty and does not name claude-code, so an agent provisioned from components: [] skips the default-on Claude Code install (that branch fires only for a genuinely empty set) — name claude-code explicitly if you want the CLI.

Note this DATA field is distinct from the JSON-Schema required keyword that lists a component object's mandatory keys — they sit side by side in the same $defs.

components.json is validated against components.schema.json, enforced by base/tests/test-components-schema.sh (see Testing / CI below).

A component may also declare config_files[] — runtime config files it consumes from a declared target_path (e.g. WireGuard .conf → /etc/sidebutton/config/wireguard/). Base step 19f-component-config.sh (refresh-safe; detects each config-declaring component by its installed helper) installs a per-slug systemd path-unit watcher that applies files landing at that path — at boot and on change — and tears down what is removed, so manual SSH placement and (future) portal push converge on identical behaviour. The one privileged write hop is sb-config-place (narrow NOPASSWD sudoers, same pattern as sb-self-update), which confines every write to the installed component's declared target_path (root:600). See docs/WIREGUARD.md / OPENVPN.md / RDP.md.

Plugins (plugins.json) — separate, role-driven

MCP plugins are Claude-Code-skill-like tools the SideButton server loads (not components). Each entry carries install detail (repo/ref/submodules/system_deps) + default_roles; the wizard pre-checks them by role in Step 2 (only when sidebutton-server is selected), and the final selection is sent as SIDEBUTTON_PLUGINS and installed by base/19b-plugins.sh.

slug default_roles requires
screen-record ["*"] (all roles) sidebutton-server
writing-quality ["smm"] sidebutton-server

plugins.json is validated against plugins.schema.json.

Profiles (profiles.json) — wizard presets

A profile is a named preset of components (+ default roles) the Create-Agent wizard pre-checks; the user may uncheck or add any component — except the ones the profile locks.

Profile Components Locked Roles
SideButton SWE Full Stack (default) claude-code, chrome, sidebutton-server, sidebutton-extension, knowledge-packs sidebutton-extension se, qa, sd, pm
SideButton SWE .NET Full Stack + dotnet9 sidebutton-extension se, qa, sd, pm
SideButton SWE Android Full Stack + android-sdk + android-emulator — se, qa, sd, pm
SideButton App Agent claude-code, chrome, sidebutton-server, knowledge-packs — se, qa, pm

locked[] pins components the wizard must not let the user untick — a SideButton SWE Full Stack without the Chrome extension is no longer the thing its name promises. Every locked slug must also appear in that profile's own components[]. Globally-required components (required in components.json) are the separate, catalog-wide mechanism.

profiles.json is validated against profiles.schema.json, enforced by base/tests/test-profiles-schema.sh — which also pins the cross-file semantics (order/default coherence, components[] and locked[] resolution, and that every profile's closure over requires unlocks dispatch).

Plugins are selected separately, by role (see Plugins above) — not baked into profile presets.

Dropped: swe-native (SCRUM-2035) — unused, and its component set is identical to app-builder's. aliases is declared in the schema but consumed nowhere, so the slug does NOT fall back: a stored profile: 'swe-native' is rejected on re-provision, while existing agents keep running (dispatchability derives from components, not the profile).

Portal display metadata (single source of truth)

The portal vendors these files instead of hardcoding; refresh with pnpm --filter website sync:runners, and a CI git diff --exit-code fails on drift:

  • components.json — component catalog: kind, requires, dep-chip.
  • profiles.json — wizard presets (component sets + roles).
  • variants.json — the single base runner (display fallback + legacy kind).
  • plugins.json — the role-driven plugin catalog: install detail (slug → git repo) + per-plugin default_roles, consumed by base/19b-plugins.sh via SIDEBUTTON_PLUGINS.

Layout

agent-runners/
├── install.sh                    # direct entry: resolves variant dir → base/run.sh
├── components.json               # component catalog (vendored by portal)
├── profiles.json                 # wizard presets (vendored by portal)
├── variants.json                 # single base runner manifest (vendored by portal)
├── plugins.json                  # role-driven plugin catalogue (slug → repo + default_roles)
├── docs/COMPONENTS.md            # component model + implementation plan
├── base/
│   ├── lib.sh                    # log/die/step + run_variant_hook
│   ├── lib-refresh.sh            # shared change-gated base-artifact refresh (sb-self-update + agent-redeploy.sh)
│   ├── refresh-manifest.txt      # base steps safe to re-run on a live agent (drives the refresh)
│   ├── components.sh             # resolve AGENT_COMPONENTS → has_component + gates
│   ├── 01-preflight.sh … 20-mark-installed.sh   # shared steps (sourced in order)
│   ├── 06-chrome.sh              # gated on INSTALL_CHROME
│   ├── 08-sidebutton.sh          # SB server (gated) + installs the sb-self-update wrapper (all agents)
│   ├── 16-services-prep.sh       # chrome/sidebutton units written conditionally
│   ├── 18b-heartbeat-timer.sh    # recurring online beat when serverless
│   ├── components/               # per-component install + lifecycle scripts
│   │   ├── dotnet9/install.sh
│   │   ├── elixir/install.sh
│   │   ├── docker/install.sh
│   │   ├── postgres-client/install.sh
│   │   ├── openvpn/{install.sh,sb-vpn-connect}
│   │   ├── wireguard/{install.sh,sb-wg-connect}
│   │   ├── rdp-client/{install.sh,sb-rdp-connect}
│   │   └── sidebutton-extension/{pre,post}-services.sh
│   ├── assets/                   # wallpaper.png, report-health-snapshot.sh, sb-registry-sync.sh, sb-self-update.sh
│   ├── tests/                    # pure bash+jq regression guards + run-all.sh runner
│   └── run.sh                    # orchestrator
└── variants/
    └── ubuntu-claude-code/       # the single base variant (manifest + README; no hooks)

How base/run.sh works

  1. 01-preflight.sh, then components.sh resolves the component set + gates.
  2. Install phase: base steps run; gated steps (06-chrome, 08-sidebutton, 13-knowledge-packs, 15-claude-mcp, 19c-health-report) honor the gates; toolchain components install after the agent user exists (so docker can add it to the docker group).
  3. pre-services: the sidebutton-extension component writes the Chrome managed policy (must precede chrome.service first start), then run_variant_hook "pre-services" (no-op for the base variant).
  4. 17-services-start.sh starts the desktop + selected services.
  5. post-services: the extension component waits for browser_connected.
  6. Register + heartbeat (+ recurring timer), secrets, plugins, health reporter (desktop screenshot + a per-session Claude terminal-window crop via xdotool), account registry, component config watchers, mark-installed.

The variant hook mechanism (run_variant_hook) is retained but the single base variant ships no hooks — component behaviour is driven from run.sh.

Gates derived from the component set

Gate Set when
SKIP_SIDEBUTTON_SERVER=1 sidebutton-server not selected → skips 08, the sidebutton.service unit, 15, 19c; reports sidebutton: not-installed
SKIP_KNOWLEDGE_PACKS=1 knowledge-packs not selected → skips 13 + 19d
INSTALL_CHROME=0 chrome not selected → skips 06 + the chrome.service unit/start
INSTALL_EXTENSION=1 sidebutton-extension selected → runs the extension component's pre/post-services

Env vars consumed

Var Required Default Notes
AGENT_TOKEN yes — Bootstrap token from /portal/agents
AGENT_NAME yes — Unique fleet identifier
AGENT_ROLE no se One of se, qa, sd
AGENT_COMPONENTS no — Comma/space list of component slugs. Unset ⇒ manual base agent (no optional components)
AGENT_RUNNER no ubuntu-claude-code The single base variant
RUNNERS_REF no main Git ref the bootstrapper downloads this repo at
PORTAL_URL no https://sidebutton.com Portal base URL
AGENT_PASSWORD no random Initial RDP password (overwritten by portal secret)
AGENT_TIMEZONE no Europe/Berlin IANA zone for every clock on the VM, set by base/09b-clock.sh (which also pins Claude Code's timeFormat to 24-hour). Unset, only a box still on UTC takes the default; a live agent reads it from ~/.agent-env when sb-self-update next re-runs the base steps
SIDEBUTTON_DEFAULT_REGISTRY no — Per-account knowledge-pack registry (git URL); additive on top of the agents pack
SIDEBUTTON_DEFAULT_REGISTRY_TOKEN no — Auth token for a private registry; delivered via the secrets fetch
SIDEBUTTON_PLUGINS no — Comma plugin slugs (plugins.json); selected by the portal per role. Requires sidebutton-server
CLAUDE_PLUGINS no — Comma name@marketplace refs for Claude Code's own plugin store — a different system from SIDEBUTTON_PLUGINS. Installed by base/19i-claude-plugins.sh; needs the claude-code component
CLAUDE_PLUGIN_MARKETPLACES no — Comma alias=owner/repo — where to fetch the marketplaces CLAUDE_PLUGINS names, for any the box does not already know

No legacy

Old agents are deleted and re-provisioned, so the portal always sends an explicit AGENT_COMPONENTS (+ SIDEBUTTON_PLUGINS). base/components.sh reads AGENT_COMPONENTS only — unset ⇒ a manual base agent. The old AGENT_RUNNER→component-set mapping and profile aliases have been removed.

Self-update (the fleet path)

Existing agents keep themselves current via sb-self-update — a tiny root-owned wrapper installed by base/08 and run fleet-wide by the Self Update ops job (agent_self_update) through a narrow NOPASSWD sudoers rule scoped to only that wrapper (the agent's single privileged action). It does four idempotent, change-gated things:

  1. Upgrade the global sidebutton CLI/server npm package, restarting the service only when the version changed (gated on the sidebutton.service unit, so a no-op on serverless variants).
  2. Refresh base artifacts — re-download agent-runners@<ref> and re-run the refresh-safe base steps (base/refresh-manifest.txt) + re-merge the Claude hooks block over ~/.claude/settings.json, so step-script / hook changes reach the fleet without an operator SSH. A fingerprint over the deployed artifacts is compared against /etc/sidebutton/updated; a routine tick with nothing new upstream is a true no-op (no rewrite, no restart). The shared apply logic lives in base/lib-refresh.sh, which the operator break-glass agent-redeploy.sh (in the-assistant) also sources from the downloaded tree, so the two paths can't drift.
  3. Reconcile the agents ops pack from the public catalog, so default workflows published after an agent was provisioned become dispatchable (refresh-only).
  4. Bring Claude Code to its target version — the one in base/components/claude-code/version: latest by default, or an exact version that holds or rolls back the whole fleet on its next run when a release goes bad. Claude Code is installed once at provisioning with its autoupdater off, so without this an agent stays on the release of its provisioning day, and a model that needs a newer CLI fails every job. The step acts only on an existing npm-global install, downloads before it swaps, verifies the new binary and rolls back if it does not run, re-runs the 15b onboarding seed on a change, and never restarts sidebutton.service (it runs inside a job) — so the running server keeps reporting the version it started with until its next restart.

Steps 3 and 4 run on every call, ahead of the step-2 fingerprint gate, which is also what lets them reach agents whose installed wrapper predates them.

The manifest is the source of truth for which steps are safe to re-run on a live box — token-rotating / re-registering / OS-install steps are deliberately excluded. Add new refresh-safe steps there so they reach existing agents.

It takes no arguments. sb-self-update --help / -h prints usage and exits 0 without fetching, writing or creating a temp dir, so the wrapper is safe to probe on a live agent (with or without sudo); any other argument is rejected with usage on stderr and exit 2, and a run that is not root refuses with exit 1 rather than half-applying an update it cannot finish (SCRUM-2029). Before that gate the wrapper ignored $@ entirely, so even a --help probe ran a real, unprivileged, partially-applied update.

Drift visibility: the heartbeat (base/18, and the serverless 18b timer) reports the effective runners_ref + a base_artifacts_sha from the markers (/etc/sidebutton/updated, else the provision-time /etc/sidebutton/installed), so the portal can show what each agent is actually running.

Idempotency

/etc/sidebutton/installed is written by 20-mark-installed.sh on success (now including a base_artifacts_sha fingerprint + runners_repo). The bootstrapper short-circuits with a service-health summary when present; remove the marker to force a reinstall. /etc/sidebutton/updated records the effective ref + fingerprint after each sb-self-update base-artifact refresh.

Testing / CI

base/tests/ holds pure bash + jq regression guards (no bats, no network) — each runnable directly (bash base/tests/<name>.sh) and together via the runner:

bash base/tests/run-all.sh          # discover + run the suite (CI mode); SUITE GREEN on success
bash base/tests/run-all.sh --all    # also run host-dependent guards (only green on a clean box)

run-all.sh auto-discovers every test-*.sh (a new guard needs no wiring), prints a per-file PASS/FAIL + tally, and exits non-zero on any failure. It skips the guards listed in base/tests/ci-exclude.txt — currently only test-sb-self-update.sh, which false-fails on a host that already has a global sidebutton CLI (every provisioned agent VM); each exclusion carries its reason.

Component-model coverage (the catalog ↔ schema ↔ on-disk ↔ run.sh wiring contract):

Guard Asserts
test-components-schema.sh components.json is valid against components.schema.json (structure, enums, slug pattern, additionalProperties:false, required is a boolean and at least one component carries it) + unique slugs + every requires[] resolves
test-profiles-schema.sh profiles.json is valid against profiles.schema.json (required/allowed keys, slug pattern, additionalProperties:false) + default and order cohere with profiles[], every components[] slug resolves in components.json, every locked[] slug is one the profile itself installs, and every profile's component closure (incl. globally-required components, over requires) unlocks dispatch
test-component-resolution.sh every base/components/<dir> is a catalog slug and wired into run.sh; every non-base-installed slug has a dir; the base-installed allowlist (chrome/sidebutton-server/knowledge-packs) is justified by its 06/08/13 step; all *.sh parse
test-default-install-parity.sh default / empty / back-compat + every profile resolves to a byte-identical gate vector vs a committed snapshot (re-bless: BLESS=1 bash …)
test-claude-code-* / test-claude-code-router-component.sh the claude-code + CCR components' catalog shape, install dir, and run.sh wiring
test-elixir-component.sh the elixir component's catalog shape + run.sh wiring, and its install contract: versions pinned (no floating latest) with the Elixir -otp-NN suffix matching the pinned OTP major; mise, mix and the shims always runuser-wrapped as $AGENT_USER (a root-run shim resolves /root's mise data dir); ~/.config/~/.local/~/.cache made agent-owned before the first runuser (they are root-owned until 13/15, which run after the toolchain loop); shims symlinked onto /usr/local/bin under their own basename (dispatched-job PATH; mise dispatches on argv[0]) with no dangling rebar3/mix.ps1; Hex + rebar bootstrapped with --force on the invocation; the 5 apt packages; agent-owned Dialyzer PLT dir; no exit/die (sourced into run.sh); the description's docker-pairing + disk guidance. Assertions run against a comment-stripped copy of install.sh, so prose cannot satisfy a guard
test-component-config.sh config_files[] schema + the 3 declarations; 19f refresh-safety (helper-signal detection, run.sh/manifest/fingerprint wiring); sb-config-place traversal/confinement rejection; sb-config-reconcile apply/sha-gate/teardown + service dispatch
test-19e-session-tidy.sh session-tidy (SCRUM-1769): the Stop-only sentinel writer lands before the job-session gate and can never abort the step-complete POST; the sweep spares the active job / fresh marks / unverified pids (comm + starttime) and honours SB_SESSION_CLOSE_TTL_SEC=0; run.sh/manifest wiring; the retired reaper names stay unused

CI (.github/workflows/tests.yml) runs run-all.sh on every push to main + PR (ubuntu-latest + jq).

License

MIT

About

Install scripts for the SideButton agent VM: thin bootstrapper + shared base/ modules + per-variant overlays.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages