feat: opt-in host-tmux (container->host tmux), sandbox-safe by default#406
feat: opt-in host-tmux (container->host tmux), sandbox-safe by default#406lroolle wants to merge 3 commits into
Conversation
- host-tmux: attach/ls/tmux run the host tmux client over ssh;
bridge forwards the host socket to /tmp/host-tmux.sock (ssh -L,
0600) keeping tmux-bridge layer 2 unchanged; setup installs the
user's own pubkey once via docker host mount and prints the undo;
doctor walks target -> dns -> port 22 -> auth -> tmux
- drop deva-bridge-tmux{,-host}: unauthenticated TCP 41555 gave any
container or local process run-shell on the host, the host daemon
died on every reboot, and native attach coupled client/server
tmux protocol versions
- sshd is launchd-managed: container -> host tmux now survives host
reboots with no host-side ritual
Closes #405
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR replaces deva’s Layer-1 tmux transport from an unauthenticated socat TCP bridge to an SSH-based host transport (host-tmux), while keeping the Layer-2 tmux-bridge workflow and /tmp/host-tmux.sock socket path intact.
Changes:
- Added
scripts/host-tmuxprovidingsetup,attach/ls/tmux,bridge, anddoctorcommands over SSH. - Removed the socat-based Layer-1 scripts
scripts/deva-bridge-tmuxandscripts/deva-bridge-tmux-host. - Updated docs + images to install and document
host-tmuxas the default Layer-1 transport.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/host-tmux | New SSH-based Layer-1 transport and optional socket-forwarding bridge. |
| scripts/deva-bridge-tmux-host | Removes the host-side socat TCP listener. |
| scripts/deva-bridge-tmux | Removes the container-side socat unix-socket proxy. |
| docs/tmux-bridge-agent-comms.md | Rewrites Layer-1 documentation to host-tmux + SSH forwarding. |
| Dockerfile | Installs host-tmux into the main image instead of deva-bridge-tmux. |
| Dockerfile.rust | Installs host-tmux into the rust image instead of deva-bridge-tmux. |
| DEV-LOGS.md | Adds dev-log entry describing the rationale and verification notes. |
| CHANGELOG.md | Documents the addition of host-tmux and removal of the socat bridge. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| echo "installing pubkey into $home/.ssh/authorized_keys via docker host mount..." | ||
| "${dockercmd[@]}" run --rm -e PUB="$pub" -e MARKER="$KEY_MARKER" -v "$home/.ssh:/hostssh" alpine sh -c ' | ||
| touch /hostssh/authorized_keys | ||
| grep -qxF "$PUB $MARKER" /hostssh/authorized_keys || grep -qxF "$PUB" /hostssh/authorized_keys \ | ||
| || printf "%s %s\n" "$PUB" "$MARKER" >> /hostssh/authorized_keys | ||
| chmod 600 /hostssh/authorized_keys' \ | ||
| || die "docker key install failed; $manual" |
| stop) | ||
| if [[ -f "$BRIDGE_PID" ]]; then | ||
| kill "$(cat "$BRIDGE_PID")" 2>/dev/null || true | ||
| rm -f "$BRIDGE_PID" "$BRIDGE_SOCK" | ||
| echo "bridge stopped" | ||
| else | ||
| echo "bridge not running" | ||
| fi | ||
| ;; |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 501c8d5a8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| local user="${DEVA_HOST_USER:-${DEVA_CHROME_HOST_USER:-}}" | ||
| [[ -n "$user" ]] || die "cannot determine host user; set DEVA_HOST_USER=<host username>" |
There was a problem hiding this comment.
Set the host user for normal containers
In containers started without Claude Chrome, this fallback is empty because prepare_claude_chrome_bridge is the only launcher path that exports DEVA_CHROME_HOST_USER, and it returns unless the active agent is Claude with --chrome. As a result, host_target aborts before setup, ls, bridge, or doctor can do anything in the default deva/codex workflows, so the documented host-tmux setup quick start fails unless every user manually supplies DEVA_HOST_USER; pass the host user from the launcher or derive it independently here.
Useful? React with 👍 / 👎.
| ;; | ||
| stop) | ||
| if [[ -f "$BRIDGE_PID" ]]; then | ||
| kill "$(cat "$BRIDGE_PID")" 2>/dev/null || true |
There was a problem hiding this comment.
Verify the bridge PID before killing it
If bridge.pid is stale, for example after the ssh forwarder exits or a container with a persisted home is restarted while /tmp/host-tmux.sock is gone, this kill can signal an unrelated process that reused the recorded PID because stop only checks that the pid file exists. Use bridge_alive or verify the recorded process is the ssh forwarder before sending SIGTERM.
Useful? React with 👍 / 👎.
- doctor's closed-port message now includes the one-liner (sudo systemsetup -setremotelogin on) next to the Settings path - docs: no-sshd fallbacks — container-side tmux via docker exec (zero host config, covers agent comms), or the retired socat bridge from git history with its caveats; enabling Remote Login from inside a container is impossible by design (file access, never process execution) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| cmd_ls() { | ||
| local bin | ||
| bin=$(host_tmux_bin) | ||
| hssh "$(host_target)" "$bin ls" | ||
| } | ||
|
|
||
| cmd_attach() { | ||
| [[ -t 0 ]] || die "attach needs a tty; use 'host-tmux tmux ...' instead" | ||
| local bin cmd="attach" | ||
| bin=$(host_tmux_bin) | ||
| [[ -n "${1:-}" ]] && cmd="new-session -A -s $(printf '%q' "$1")" | ||
| mkdir -p "$STATE_DIR" | ||
| exec ssh -t "${SSH_OPTS[@]}" "$(host_target)" "$bin $cmd" | ||
| } | ||
|
|
||
| cmd_tmux() { | ||
| [[ $# -ge 1 ]] || die "'tmux' needs arguments" | ||
| local bin | ||
| bin=$(host_tmux_bin) | ||
| hssh "$(host_target)" "$bin $(printf '%q ' "$@")" | ||
| } |
| echo "installing pubkey into $home/.ssh/authorized_keys via docker host mount..." | ||
| "${dockercmd[@]}" run --rm -e PUB="$pub" -e MARKER="$KEY_MARKER" -v "$home/.ssh:/hostssh" alpine sh -c ' | ||
| touch /hostssh/authorized_keys | ||
| grep -qxF "$PUB $MARKER" /hostssh/authorized_keys || grep -qxF "$PUB" /hostssh/authorized_keys \ | ||
| || printf "%s %s\n" "$PUB" "$MARKER" >> /hostssh/authorized_keys | ||
| chmod 600 /hostssh/authorized_keys' \ | ||
| || die "docker key install failed; $manual" |
The container is the sandbox. host-tmux reaches OUT to run commands on the host, so baking it into every image handed every agent a host-exec path (deva mounts host ~/.ssh and auto-mounts docker.sock — either is a turnkey escape once the script is present). - do not install host-tmux in the image (drop Dockerfile COPY) - add `deva.sh --host-tmux`: provisions the script + host ~/.ssh (ro) per run; the ssh mount dedups against a user .deva that already mounts it (added after user volumes, guarded by new docker_args_has_mount_target) - docs reframed around the sandbox: safe default is host->container (deva.sh shell / docker exec — host reaches in, container gains nothing); host-tmux is the explicit opt-in escape hatch; the hardened --no-docker config stays sealed unless --host-tmux is set - tests: 3 --host-tmux cases in scripts/test-mount-shape.sh Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| local target | ||
| if ! target=$(host_target); then | ||
| echo "target: FAILED (set DEVA_HOST_USER=<host username>)" | ||
| return 1 | ||
| fi |
| # --host-tmux needs the host ssh key inside the container to authenticate. | ||
| # Add it here, after all user volumes, so we skip it when the user's .deva | ||
| # already mounts ~/.ssh (else the dedup guard below would reject it). | ||
| if [ "${HOST_TMUX:-false}" = true ] && [ -d "$HOME/.ssh" ] \ | ||
| && ! docker_args_has_mount_target "/home/deva/.ssh"; then | ||
| DOCKER_ARGS+=("-v" "$HOME/.ssh:/home/deva/.ssh:ro") | ||
| fi |
|
Closing as superseded rather than rejected on substance — the ssh transport analysis here is right (unauthenticated TCP 41555 was a hole, reboot durability matters, protocol decoupling matters) and the direction-split framing is keeping. What changes: tmux interaction becomes a built-in deva module supporting BOTH modes — the in-container agent<->agent bridge and the opt-in container->host transport — instead of loose scripts riding alongside. New issue + design PR incoming; this branch's work carries forward into it. |
tmux interaction was four loose scripts with zero deva.sh integration; the #406 draft built the right transport (ssh) but deleted the socat fallback and stayed a loose script. This makes it a module: - scripts/deva-tmux (baked into both images): ls/attach/tmux/bridge/ setup/doctor over two transport backends. ssh default: authenticated, reboot-durable, runs the HOST's tmux client so there is no client/server protocol coupling. socat fallback: the old unauthenticated TCP daemon, kept for hosts without sshd, loud warning on every start. Both land on /tmp/host-tmux.sock — tmux-bridge (Layer 2) and htmux muscle memory work unchanged. - Inert without provisioning: baked binary + no key + no daemon = no host reach. deva.sh --host-tmux is the opt-in — mounts ~/.ssh read-only (dedup-safe, user -v wins), passes DEVA_HOST_USER, and in repo mode mounts the launcher's deva-tmux over the image copy. - deva.sh tmux command group (host side): setup writes your OWN authorized_keys — the #406 draft did that from inside the container via a docker host-mount, exactly the host-reach pattern this tool exists to gate; moving it host-side deletes the scariest code. host-daemon start|stop|status wraps the socat daemon (inline fallback for installed launchers). doctor for the host view. - deva-bridge-tmux becomes a compat shim (old flags -> env -> deva-tmux bridge start --transport socat --foreground); deva-bridge-tmux-host unchanged; tmux-bridge stays vendored byte-for-byte. - Fixed in live testing: a backgrounded ssh -N -L inherited the caller's stdout, so piping 'bridge start' hung forever waiting for EOF (the #406 draft shared this bug). Bridge children now detach stdio to a state-dir log. Verified live against a real macOS host from inside a container: both transports bridge the host server onto the socket, native tmux client and tmux-bridge Layer 2 drive real panes through it, lifecycle idempotent, socket cleaned on stop. Mount-shape suite asserts the off/on/dedup provisioning shapes. Close #412 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #405
The shape
deva's model: the container IS the sandbox. So this splits by direction and
keeps host reach opt-in.
What
scripts/host-tmux— ssh to host tmux:attach/ls/tmux(host's own tmuxclient, zero protocol coupling),
bridge(ssh -Lforwards the hostsocket to /tmp/host-tmux.sock, 0600, keeps tmux-bridge Layer 2),
setup(installs your pubkey once via docker host mount; probes the real home
read-only first; prints undo),
doctor(target -> dns -> port 22 -> auth-> tmux).
deva.sh --host-tmuxprovisions it (script + host ~/.ssh ro) per run,dedup-safe against a user
.devathat already mounts ~/.ssh.deva-bridge-tmux{,-host}.--no-dockerhardened config stayssealed unless
--host-tmuxis passed.Why not the socat bridge
Unauthenticated TCP 41555 gave every container and local process run-shell
on the host; the host daemon died on reboot; native attach coupled
client/server tmux protocol versions. ssh is authenticated, reboot-durable
(launchd), and version-decoupled.
Test plan
scripts/host-tmuxlive against OrbStack/macOS: doctor, setup idempotence,spaced-arg quoting, tty guard, bridge lifecycle, tmux-bridge Layer 2 over
the forwarded socket.
deva.sh --host-tmux:scripts/test-mount-shape.sh(all pass) covers off/ on-without-ssh / on-with-user-ssh-dedup; dry-run confirms the docker
argv.
--host-tmuxcontainer run end-to-end (needs animage build/run outside the dev container). Dry-run + mount-shape suite
cover the argv assembly only.
🤖 Generated with Claude Code