Toolbox

Bridge

A per-user host daemon that forwards in-container URL opens (xdg-open, $BROWSER, OAuth redirects) to the host's real browser, editor opens (code/codium) to the host's VS Code / VSCodium, proximo up|down|status|errors|skill (arguments included) to the host proximo binary, and herdr's agent-state chimes to the host's audio output. Opt-in: nothing runs on the host until toolbox bridge install.

Quick start

toolbox bridge install     # generate token, write LaunchAgent/systemd unit, start daemon
toolbox bridge status      # show install state, port, daemon liveness
toolbox bridge uninstall   # stop daemon, remove unit + token

Run them as yourself — never under sudo. Both supervisors are per-user (a LaunchAgent in the calling user's GUI domain, a systemd unit on their user bus) and root has neither, so a sudo install used to write the plist/unit and a root-owned token into whatever HOME sudo handed it and only then die inside launchctl (Bootstrap failed: 125: Domain does not support specified action) or systemctl (Failed to connect to bus), leaving state every later non-sudo run tripped over. install and uninstall now refuse up front when the effective uid is 0 (bridge.EnsureUserContext), naming $SUDO_USER as the account to re-run as.

The bridge: config key (default on) controls whether the container gets the bridge mounts at all — see mount gating.

Architecture

The container has no display server. CLIs inside toolbox shell that invoke xdg-open <url>, set $BROWSER, or expect an OAuth redirect to land somewhere clickable have no fallback by default. The bridge plumbs URL opens out to the host's real browser; the same channel carries editor opens (code ., codium <file>) to the host's VS Code / VSCodium, and herdr's agent chimes to the host's audio output — the container has no player for those either:

container                                          host
─────────                                          ────
xdg-open <url>                                     toolbox bridge daemon
code/codium <path>                                   │ listens on 127.0.0.1:<port>
paplay <file>  (herdr probes for it)                 │ + (Linux) unix run/bridge.sock
  └─ wrappers at tail of Dockerfile                  │ (port + token read from
       │  read /home/toolbox/.toolbox/bridge/        │  ~/.toolbox/toolbox/bridge/)
       │  {port,token} (RO bind-mount)               │
       ├─ POST /open   { "url": … }                  ├─ open / xdg-open <url>
       ├─ POST /edit   { "editor": …, "path": … }    ├─ code / codium <path>
       └─ POST /sound  { "name": …, "data": … }      └─ afplay / probed player
            (all three: Authorization: Bearer <token>)

Transport

bridge_post in bridge-lib.sh tries the daemon's unix socket first (/home/toolbox/.toolbox/bridge/run/bridge.sock, present when the host daemon is a Linux build) and falls back to TCP http://host.docker.internal:<port> when the socket is absent or the connect dies (curl status 000). The fallback fires only on 000: a real HTTP status (even 4xx/5xx) is never retried, or a flaky socket would double-exec /proximo on the host.

Why two transports: the TCP listener binds 127.0.0.1 only. On macOS that works — Docker Desktop's vpnkit proxies host.docker.internal connections from the host loopback. On native Linux (docker-ce) host.docker.internal:host-gateway resolves to the docker0 gateway IP (e.g. 172.17.0.1), where nothing listens — connection refused, bridge unreachable. The unix socket (bound by bindUnixListener in internal/bridge/socket_linux.go, GOOS-gated like the agents) sidesteps routing entirely: the container reaches it through the bridge-run RW bind mount. Docker Desktop cannot share host unix sockets with containers (docker.sock is special-cased), so macOS has no unix listener and Docker Desktop on Linux falls through 000 to TCP, which works there via vpnkit.

Version-skew matrix:

Host CLI Image Runtime Result
new new docker-ce Linux unix socket (the fix)
new pre-socket docker-ce Linux TCP-only shim → still unreachable (as before the fix; pull the image)
pre-socket new any no run/ mount → -S false → TCP, identical to before
new new Docker Desktop (macOS or Linux) socket absent/untraversable → 000 → TCP via vpnkit → works

The container-side wrappers are installed at the tail of internal/build/assets/Dockerfile (after the COPY init.d/ step): xdg-open shadows /usr/local/bin/xdg-open and its synonyms (sensible-browser, gnome-open, x-www-browser, www-browser, open); code ships with codium as a symlink (editor inferred from basename $0). ENV BROWSER=xdg-open lets tools that honour $BROWSER route through the same wrapper.

State directory

State lives in ~/.toolbox/toolbox/bridge/ (HostDir in internal/bridge/paths.go), mounted read-only into the container at /home/toolbox/.toolbox/bridge (ContainerDir) — except the run/ subdir, which carries the unix socket and gets its own RW nested mount (bridge-run; connect() on a socket inside a RO mount fails with EROFS). Four files plus the socket dir: token (bearer secret, generated at install), port (chosen at daemon start, written even when the socket transport is active — the TCP fallback and bridge_ready need it), pid, log, run/bridge.sock. The dir is mode 0700; the bind keeps the container from rotating either secret. ~/.toolbox/toolbox/ is the toolbox-own namespace (state lives next to state/, home of the pull cache): the ~/.toolbox root is reserved for per-app config/credential dirs, so a future app can never collide with toolbox-own state. The bridge dir is deliberately a sibling of state/, not inside it — state/ is rw-mounted wholesale into the container and the token must stay read-only. toolbox shell migrates a pre-namespace ~/.toolbox/state onto ~/.toolbox/toolbox/state once, best-effort (mountplan.MigrateLegacyToolboxState).

Install topology

Per-host supervisor differs by platform — implementation in internal/bridge/agent_darwin.go and agent_linux.go:

Host Unit Registered via
macOS ~/Library/LaunchAgents/com.filippolmt.toolbox.bridge.plist launchctl bootstrap gui/<uid>
Linux ~/.config/systemd/user/toolbox-bridge.service systemctl --user daemon-reload && enable --now

Both run the same hidden subcommand toolbox bridge daemon in the foreground; the supervisor handles restart-on-crash and login-time start. toolbox bridge install writes the unit + token then triggers the bootstrap; uninstall reverses both steps. Install/uninstall also retire the pre-rename browser-bridge era artifacts: the com.filippolmt.toolbox.browser LaunchAgent / toolbox-browser.service unit is stopped and removed, and install renames a ~/.toolbox/browser state dir onto the new location (token preserved, so running containers keep authenticating). Stale leftovers go too: the old macOS log file (toolbox-browser.log) and any legacy dir recreated by an old binary's CreateIfMissing mount are deleted on the next install / shell start. The browser-bridge CLI spelling survives as a deprecated cobra alias — installed units keep invoking it until the user reruns toolbox bridge install; drop it only in a major release. Anything else (status reads, log inspection) is read-only on the state dir.

Security boundary

The daemon refuses anything that isn't:

  1. Bound to 127.0.0.1 (no LAN exposure — checked at listen time); the Linux unix socket is 0600 inside a 0700 dir owned by the same UID the container runs as (--user), so it adds no principal beyond the user themselves. The bearer token is required on both transports.
  2. Authenticated with the exact bearer token from ~/.toolbox/toolbox/bridge/token (constant-time compare).
  3. /open: scheme http or https only — file://, javascript:, data: etc. are rejected with 400. /edit: editor in the fixed allowlist (code, codium — a client-supplied name never reaches exec) and an absolute, existing host path after filepath.Clean, otherwise 400. /credential: op in the fixed allowlist (get, store, erasegit credential fill|approve|reject), otherwise 400. /proximo: subcommand in the fixed allowlist (up, down, status, errors, skill), otherwise 400 — its arguments are then forwarded verbatim, with one exception: --out/-o is rejected, because through the bridge it would write to the host filesystem (redirect the shim's stdout in the container instead), and errors dom is refused outright because it writes a host file with or without the flag. /sound: a non-empty base64 payload, otherwise 400 — the request carries the MP3 bytes, never a path, and the daemon picks both the temp file and the player itself (afplay on macOS, a fixed probe chain on Linux), so nothing a caller sends reaches exec.
  4. Below the route's body cap — the URL length cap on /open//edit, 512 KiB on /sound (413 past it), sized for a developer's own chime rather than for herdr's built-in ones.
  5. Within the rate limit. /open, /edit and /proximo share one bucket (10/s, burst 5); /credential has its own, more generous bucket (30/s, burst 15) so a git clone with many HTTPS submodules — a rapid burst of credential lookups — is not throttled into failure. /sound has its own bucket too — the same 10/s, burst 5, so the point is separation and not headroom: a run of chimes cannot spend the budget an OAuth redirect needs, and vice versa.

The container side can read token because the mount is RO; an attacker who lands shell-equivalent privileges inside the container can therefore open URLs on the host browser and open existing host paths in an allowlisted editor (non-executing: the editor renders file contents), but cannot exfiltrate the token to a different network namespace (the daemon only accepts 127.0.0.1, and the container's 127.0.0.1 is a different namespace) and cannot make the daemon exec an arbitrary binary (fixed allowlist, direct exec, no shell). /proximo widens that last sentence in one direction only: arbitrary arguments reach one known binary, on a gated verb. Argv is passed as a slice to exec.CommandContext, so an argument never reaches a word-splitting or globbing context, and the argument-shaped risk that remains — writing to a host path — is what the --out/-o rule closes. See ADR-0004. /sound moves the residual risk to the content axis instead of the path axis: any process in the container can make the host play arbitrary audio up to the body cap. That is annoyance, not escalation, and it stays under the ceiling ADR-0004 already declares — the token is readable in the container by design, and the daemon cannot tell which workspace a request came from. See ADR-0009.

Editor shims

/usr/local/bin/code (+ codium symlink) is a bridge shim, not a real editor — the smoke test asserts the bridge-lib marker in both so a dropped COPY or a shadowing real binary fails the image build. All bridge shims (xdg-open, code/codium, proximo, git-credential-toolbox, paplay) source /usr/local/lib/toolbox/bridge-lib.sh for the shared transport (state-dir location, readiness checks, curl POST — TestShimPathsMatchGoConstants pins the state dir there against bridge.ContainerDir); each shim keeps only its own validation, messages, and exit policy. Behaviour:

Git credentials

git clone https://… for a self-hosted host (Forgejo, Gitea, any HTTPS remote glab/gh don't cover) prompts on every clone inside the container: the host ~/.gitconfig is mounted RW so its credential.helper line is visible, but the helper binary (osxkeychain, manager, …) either doesn't exist in the Debian image or can't reach the host's OS keychain — it yields nothing and git falls back to a terminal prompt.

/credential closes that gap by forwarding the git credential helper protocol to the host, where git credential fill|approve|reject delegates to whatever the host is configured with — the macOS Keychain, or libsecret / gnome-keyring on a Linux host. Nothing is stored on the container disk, and it works for any HTTPS host.

Agent sounds

herdr runs inside the container, and its agent-state chimes never played: the Linux build carries no audio backend and spawns an external player instead (paplay, pw-play, ffplay, mpg123, mpv, in that order), while the image has none of the five, no /dev/snd and no sound-server socket. /sound moves the playback to the host and leaves herdr as the source of the signal — which sound, for which agent, and when all stay its decision.

The herdr side

Two keys in ~/.config/herdr/config.toml complete the channel, and the image writes neither — that path is one host-global RW bind shared by every toolbox container, so an init.d script writing it would edit the developer's own file on behalf of every project. Set them once, by hand:

[ui.sound]
enabled = true          # upstream default; explicit so a default flip can't mute the channel

[ui.toast]
delivery = "terminal"   # optional banner: OSC 9 on the client's stdout, which is the docker exec TTY

Mount gating

bridge: true in config (default — internal/config/plan.go seeds it) causes mountplan.Defaults (internal/mountplan/defaults.go) to append the three bridge binds (RO state ×2 targets + RW bridge-run). Setting bridge: false drops all three; the in-container wrapper has nothing to talk to and falls back to the one-line tip emitted by cmd/shell.go ("install the host daemon with toolbox bridge install"). The toggle lives outside tools: because it's host-side — it does not invalidate the image hash.

Uninstall surface

toolbox bridge uninstall   # supervisor unit + state dir

uninstall removes only what install wrote: the supervisor unit (LaunchAgent plist / systemd user unit) and the state dir ~/.toolbox/toolbox/bridge (token + port + pid + log), the pre-rename ~/.toolbox/browser included. No system-level files, no sudo (refused outright — see quick start), no Homebrew formula touch.

Wiping the state dir is best-effort on macOS. Every open toolbox shell that has the bridge enabled (the default — bridge: false drops the mounts) bind-mounts ~/.toolbox/toolbox/bridge/run read-write, and Docker Desktop shares that path into the VM over virtiofs, which refuses the unlink with permission denied — a mount held open, not a broken permission. (On a native-Linux host the bind lives only in the container's mount namespace, so the directory is removed cleanly and you never see this.) The daemon is already stopped and unregistered at that point, so the command warns and exits 0 rather than reporting a rollback that did not happen:

$ toolbox bridge uninstall
warning: bridge state dir /Users/you/.toolbox/toolbox/bridge not removed: unlinkat …/run: permission denied
close any open toolbox shells (they bind-mount the state dir) and re-run
bridge: daemon removed; state dir left behind

Close the shells and re-run, or rm -rf ~/.toolbox/toolbox/bridge once they are gone. A clean run prints bridge: uninstalled and nothing on stderr. One case still fails outright: if the token file survived the removal — or its absence cannot be established at all — the command exits non-zero, because a later install would otherwise pick the old token back up instead of generating a fresh one.

Troubleshooting

See also: proximo for the /proximo endpoint that drives the host proximo stack from inside the shell, and toolbox shell port publishing for the inverse direction (host → container connections).