Skip to content

Commit 255f4a7

Browse files
lroolleclaude
andcommitted
feat(tmux): deva-tmux — tmux as a built-in module, both transports
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>
1 parent b7ba729 commit 255f4a7

9 files changed

Lines changed: 889 additions & 166 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `deva-tmux` (#412): tmux interaction is now a built-in module instead of
12+
loose scripts. One in-container CLI, two transports — `ssh` (default:
13+
authenticated, reboot-durable, runs the host's tmux client so there is
14+
no client/server protocol coupling) and `socat` (fallback: the old
15+
unauthenticated TCP daemon, kept for hosts without sshd, loud warning
16+
every start). Both land the host server on `/tmp/host-tmux.sock`, so
17+
`tmux-bridge` (Layer 2) and `htmux` muscle memory work unchanged.
18+
Container->host stays opt-in: `deva.sh --host-tmux` mounts `~/.ssh`
19+
read-only and passes `DEVA_HOST_USER`; without it the baked CLI is
20+
inert. Host-side lifecycle moved where it belongs: `deva.sh tmux setup`
21+
authorizes your key by writing your own `authorized_keys` (killing the
22+
in-container docker-mount write from the #406 draft), `deva.sh tmux
23+
host-daemon start|stop|status` manages the socat fallback, `deva.sh
24+
tmux doctor` diagnoses the host side. `deva-bridge-tmux` is now a compat
25+
shim. Verified live against a real host: both transports, bridge
26+
lifecycle, native client + Layer 2 through the forwarded socket.
27+
1028
## [0.14.0] - 2026-07-10
1129

1230
### Added

DEV-LOGS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
- Minimal markdown markers, no unnecessary formatting, minimal emojis.
1414
- Reference issue numbers in the format `#<issue-number>` for easy linking.
1515

16+
# [2026-07-10] Dev Log: deva-tmux — tmux as a built-in module, both transports #412
17+
- Why: tmux interaction was four loose scripts with zero deva.sh integration; #406 built the right transport (ssh) but deleted the socat fallback and stayed a loose script. Direction split matters: host->container (deva.sh shell) and agent<->agent (tmux-bridge) keep the sandbox; container->host breaks it and must be opt-in.
18+
- What:
19+
- scripts/deva-tmux (new, baked into both images): ls/attach/tmux/bridge/setup/doctor with transport backends — ssh default (authenticated, launchd-durable, host-side tmux client so zero protocol coupling), socat fallback (unauthenticated TCP, warns loudly). DEVA_TMUX_TRANSPORT=auto|ssh|socat; auto probes ssh then socat. Both transports land on /tmp/host-tmux.sock -> tmux-bridge Layer 2 unchanged
20+
- inert-without-provisioning is the security property: baked binary + no key + no daemon = no host reach. deva.sh --host-tmux is the opt-in: mounts ~/.ssh ro (dedup-safe, user -v wins), passes DEVA_HOST_USER, launcher's deva-tmux copy overrides the image copy in repo mode (skew insurance)
21+
- deva.sh tmux command group (host side): setup writes your OWN authorized_keys — the #406 draft did this from inside the container via a docker host-mount, which is exactly the host-reach pattern the tool exists to gate; moving it host-side dissolved the scariest code. host-daemon start|stop|status wraps the socat daemon (inline fallback when scripts/ absent). doctor for the host view
22+
- deva-bridge-tmux -> compat shim (old flags -> env -> deva-tmux bridge start --transport socat --foreground); deva-bridge-tmux-host unchanged; tmux-bridge stays vendored byte-for-byte (read-guard TTL idea goes upstream, not a fork)
23+
- found+fixed in live test: backgrounded ssh -N -L inherited the caller's stdout, so `deva-tmux bridge start | tail` hung forever waiting for EOF — the #406 draft had the same latent bug. Bridge children now detach stdio to a state-dir log
24+
- Result: verified live against a real macOS host from inside the dev container — doctor sees both transports, ssh bridge + socat bridge both land the host server on the socket, native tmux client and tmux-bridge list work through it, lifecycle idempotent, socket cleaned on stop; mount-shape suite asserts off/on/dedup provisioning shapes; shellcheck severity=error clean
25+
1626
# [2026-07-10] Dev Log: repo surface burnish #409
1727
- Why: product ships four agents but half the storefront said three; agents visiting the repo had no llms.txt; nightly 2026-07-09 failed on a transient ubuntu mirror sync and partially published (base pushed, rust did not — nightly/nightly-rust tags diverged a day)
1828
- What:

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,17 @@ RUN --mount=type=cache,target=/home/deva/.npm,uid=${DEVA_UID},gid=${DEVA_GID},sh
234234
USER root
235235

236236
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
237+
# deva-tmux: container->host tmux CLI (ssh + socat transports). Baked but
238+
# inert without host-side provisioning (deva.sh --host-tmux / tmux setup).
239+
COPY scripts/deva-tmux /usr/local/bin/deva-tmux
240+
# deva-bridge-tmux: compat shim -> deva-tmux bridge --transport socat
237241
COPY scripts/deva-bridge-tmux /usr/local/bin/deva-bridge-tmux
238242
# tmux-bridge: vendored from smux (layer-2 agent comms CLI over tmux panes)
239243
# See scripts/tmux-bridge.VENDORED for upstream commit and SHA256 pin.
240244
COPY scripts/tmux-bridge /usr/local/bin/tmux-bridge
241245

242246
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh && \
247+
chmod 755 /usr/local/bin/deva-tmux && \
243248
chmod 755 /usr/local/bin/deva-bridge-tmux && \
244249
chmod 755 /usr/local/bin/tmux-bridge && \
245250
chmod -R 755 /usr/local/bin/scripts || true

Dockerfile.rust

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,14 @@ RUN --mount=type=cache,target=/home/deva/.npm,uid=${DEVA_UID},gid=${DEVA_GID},sh
151151
USER root
152152

153153
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
154+
# deva-tmux: container->host tmux CLI (ssh + socat transports). Baked but
155+
# inert without host-side provisioning (deva.sh --host-tmux / tmux setup).
156+
COPY scripts/deva-tmux /usr/local/bin/deva-tmux
157+
# deva-bridge-tmux: compat shim -> deva-tmux bridge --transport socat
154158
COPY scripts/deva-bridge-tmux /usr/local/bin/deva-bridge-tmux
155159

156160
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh && \
161+
chmod 755 /usr/local/bin/deva-tmux && \
157162
chmod 755 /usr/local/bin/deva-bridge-tmux && \
158163
chmod -R 755 /usr/local/bin/scripts || true
159164

deva.sh

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ GLOBAL_MODE=false
7979
DEBUG_MODE=false
8080
DRY_RUN=false
8181
CODEX_BROWSER_MCP=false
82+
HOST_TMUX=false
8283

8384
if [ -t 0 ] && [ -t 1 ]; then
8485
DOCKER_TERMINAL_ARGS=(-it)
@@ -120,6 +121,8 @@ Container management commands (docker/tmux-style):
120121
deva.sh insight [args] Generate data report (ccx insight pass-through)
121122
deva.sh ccx [-g] [cmd] [args]
122123
Run any ccx command inside a container
124+
deva.sh tmux <cmd> Host-side tmux lifecycle: setup (ssh key),
125+
host-daemon start|stop|status (socat fallback), doctor
123126
124127
Advanced:
125128
deva.sh --show-config Show resolved configuration (debug)
@@ -136,6 +139,12 @@ Deva flags:
136139
-Q, --quick Bare mode: no host config mounts, no .deva loading, no autolink,
137140
implies --rm. Like emacs -Q. Mutually exclusive with -c.
138141
--host-net Use host networking for the agent container
142+
--host-tmux Opt in to container->host tmux: mounts ~/.ssh (ro)
143+
and passes DEVA_HOST_USER so the in-container
144+
deva-tmux CLI can reach the host tmux server.
145+
Off by default - it lets the container run
146+
commands on the host. Inside: deva-tmux doctor.
147+
Docs: docs/tmux-bridge-agent-comms.md
139148
--browser-mcp Codex only: wire Playwright MCP through Codex config overrides.
140149
Uses the rust profile because browser runtime deps live there.
141150
Alias: --with-browser
@@ -749,6 +758,30 @@ prepare_claude_chrome_bridge() {
749758
USER_ENVS+=("DEVA_CHROME_HOST_BRIDGE_DIR=$bridge_mount")
750759
}
751760

761+
# Container->host tmux is opt-in (--host-tmux). The in-container CLI
762+
# (deva-tmux) is baked into the image but inert without this provisioning:
763+
# the ssh transport becomes usable only when the user's ~/.ssh (read-only)
764+
# and host username ride in. In a repo checkout the launcher's copy of
765+
# deva-tmux is mounted over the image copy so flag semantics never skew
766+
# against an older image; installed launchers rely on the image copy.
767+
prepare_host_tmux() {
768+
[ "$HOST_TMUX" = true ] || return 0
769+
770+
local host_user
771+
host_user="$(configured_env_value DEVA_HOST_USER || true)"
772+
if [ -z "$host_user" ]; then
773+
host_user="$(id -un)"
774+
fi
775+
776+
if [ -d "$HOME/.ssh" ] && ! user_volume_mounts_target "/home/deva/.ssh"; then
777+
USER_VOLUMES+=("$HOME/.ssh:/home/deva/.ssh:ro")
778+
fi
779+
if [ -f "$SCRIPT_DIR/scripts/deva-tmux" ] && ! user_volume_mounts_target "/usr/local/bin/deva-tmux"; then
780+
USER_VOLUMES+=("$SCRIPT_DIR/scripts/deva-tmux:/usr/local/bin/deva-tmux:ro")
781+
fi
782+
USER_ENVS+=("DEVA_HOST_USER=$host_user")
783+
}
784+
752785
prepare_browser_integration() {
753786
[ "$CODEX_BROWSER_MCP" = true ] || return 0
754787

@@ -1271,6 +1304,181 @@ shorten_path() {
12711304
fi
12721305
}
12731306
1307+
# --- deva.sh tmux: host-side lifecycle for container->host tmux ------------
1308+
# The container side is deva-tmux (baked into the image, inert without
1309+
# provisioning). The host side owns exactly what must not be done from
1310+
# inside the sandbox: authorizing the ssh key and running the socat daemon.
1311+
1312+
tmux_host_setup() {
1313+
local marker="deva-host-tmux"
1314+
local pub=""
1315+
for k in "$HOME/.ssh/id_ed25519.pub" "$HOME/.ssh/id_rsa.pub"; do
1316+
[ -f "$k" ] && { pub="$k"; break; }
1317+
done
1318+
if [ -z "$pub" ]; then
1319+
echo "error: no ssh keypair found in ~/.ssh" >&2
1320+
echo "generate one first: ssh-keygen -t ed25519" >&2
1321+
echo "then re-run: deva.sh tmux setup" >&2
1322+
return 1
1323+
fi
1324+
1325+
mkdir -p "$HOME/.ssh"
1326+
touch "$HOME/.ssh/authorized_keys"
1327+
chmod 600 "$HOME/.ssh/authorized_keys"
1328+
local pubkey
1329+
pubkey="$(cat "$pub")"
1330+
if grep -qF "$pubkey" "$HOME/.ssh/authorized_keys"; then
1331+
echo "ok: key already authorized ($pub)"
1332+
else
1333+
printf '%s %s\n' "$pubkey" "$marker" >>"$HOME/.ssh/authorized_keys"
1334+
echo "ok: key authorized ($pub)"
1335+
echo "undo: remove the '$marker' line from ~/.ssh/authorized_keys"
1336+
fi
1337+
1338+
if timeout 3 bash -c 'exec 3<>/dev/tcp/127.0.0.1/22' 2>/dev/null; then
1339+
echo "ok: sshd is listening on port 22"
1340+
else
1341+
echo "warning: sshd is not reachable on 127.0.0.1:22" >&2
1342+
echo "macOS: System Settings > General > Sharing > Remote Login" >&2
1343+
echo " or: sudo systemsetup -setremotelogin on" >&2
1344+
return 1
1345+
fi
1346+
echo "container side: deva.sh --host-tmux <agent>, then: deva-tmux doctor"
1347+
}
1348+
1349+
# The socat fallback daemon exposes the host tmux socket over TCP with NO
1350+
# authentication — any process that can reach the port drives your tmux.
1351+
# Prefer the ssh transport; this exists for hosts without Remote Login.
1352+
tmux_host_daemon() {
1353+
local action="${1:-status}"
1354+
local state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/deva/tmux"
1355+
local pid_file="$state_dir/host-daemon.pid"
1356+
local daemon="$SCRIPT_DIR/scripts/deva-bridge-tmux-host"
1357+
local bind="${DEVA_BRIDGE_BIND:-127.0.0.1}"
1358+
local port="${DEVA_BRIDGE_PORT:-41555}"
1359+
1360+
case "$action" in
1361+
status)
1362+
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
1363+
echo "host-daemon up (pid $(cat "$pid_file"), ${bind}:${port}) — UNAUTHENTICATED, prefer ssh"
1364+
else
1365+
echo "host-daemon down"
1366+
return 1
1367+
fi
1368+
;;
1369+
stop)
1370+
if [ -f "$pid_file" ]; then
1371+
kill "$(cat "$pid_file")" 2>/dev/null || true
1372+
rm -f "$pid_file"
1373+
echo "host-daemon stopped"
1374+
else
1375+
echo "host-daemon not running"
1376+
fi
1377+
;;
1378+
start)
1379+
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
1380+
echo "host-daemon already up (pid $(cat "$pid_file"))"
1381+
return 0
1382+
fi
1383+
command -v socat >/dev/null 2>&1 || {
1384+
echo "error: socat not installed on host" >&2
1385+
return 1
1386+
}
1387+
local socket
1388+
socket="$(tmux display-message -p '#{socket_path}' 2>/dev/null || true)"
1389+
[ -n "$socket" ] || socket="/private/tmp/tmux-$(id -u)/default"
1390+
if [ ! -S "$socket" ]; then
1391+
echo "error: no host tmux server socket at $socket (start tmux first)" >&2
1392+
return 1
1393+
fi
1394+
echo "WARNING: exposing host tmux over UNAUTHENTICATED ${bind}:${port}" >&2
1395+
echo " any local process gains tmux control; prefer: deva.sh tmux setup" >&2
1396+
mkdir -p "$state_dir"
1397+
if [ -x "$daemon" ]; then
1398+
nohup "$daemon" -q -b "$bind" -p "$port" -s "$socket" >>"$state_dir/host-daemon.log" 2>&1 &
1399+
else
1400+
# Installed launcher without the repo's scripts/: same mechanics inline.
1401+
nohup socat "TCP-LISTEN:${port},bind=${bind},reuseaddr,fork" "UNIX-CONNECT:${socket}" >>"$state_dir/host-daemon.log" 2>&1 &
1402+
fi
1403+
echo $! >"$pid_file"
1404+
sleep 0.3
1405+
if kill -0 "$(cat "$pid_file")" 2>/dev/null; then
1406+
echo "host-daemon up (pid $(cat "$pid_file"), ${bind}:${port} -> ${socket})"
1407+
echo "container side: deva-tmux bridge start --transport socat"
1408+
else
1409+
rm -f "$pid_file"
1410+
echo "error: host-daemon failed to start (see $state_dir/host-daemon.log)" >&2
1411+
return 1
1412+
fi
1413+
;;
1414+
*)
1415+
echo "error: unknown host-daemon action: $action (start|stop|status)" >&2
1416+
return 1
1417+
;;
1418+
esac
1419+
}
1420+
1421+
tmux_host_doctor() {
1422+
echo "deva tmux doctor (host side)"
1423+
echo "---"
1424+
local pub=""
1425+
for k in "$HOME/.ssh/id_ed25519.pub" "$HOME/.ssh/id_rsa.pub"; do
1426+
[ -f "$k" ] && { pub="$k"; break; }
1427+
done
1428+
if [ -n "$pub" ]; then
1429+
echo "ssh keypair: $pub"
1430+
if [ -f "$HOME/.ssh/authorized_keys" ] && grep -qF "$(cat "$pub")" "$HOME/.ssh/authorized_keys"; then
1431+
echo "authorized: yes"
1432+
else
1433+
echo "authorized: NO (run: deva.sh tmux setup)"
1434+
fi
1435+
else
1436+
echo "ssh keypair: NONE (ssh-keygen -t ed25519)"
1437+
fi
1438+
if timeout 3 bash -c 'exec 3<>/dev/tcp/127.0.0.1/22' 2>/dev/null; then
1439+
echo "sshd port 22: open"
1440+
else
1441+
echo "sshd port 22: CLOSED (macOS: Sharing > Remote Login)"
1442+
fi
1443+
local sessions
1444+
sessions="$(tmux ls 2>/dev/null | wc -l | tr -d ' ')"
1445+
echo "tmux sessions: $sessions"
1446+
tmux_host_daemon status || true
1447+
echo "---"
1448+
echo "container side: deva.sh --host-tmux <agent>, then deva-tmux doctor"
1449+
}
1450+
1451+
cmd_tmux() {
1452+
local sub="${1:-help}"
1453+
[ $# -gt 0 ] && shift
1454+
case "$sub" in
1455+
setup) tmux_host_setup "$@" ;;
1456+
host-daemon) tmux_host_daemon "${1:-status}" ;;
1457+
doctor) tmux_host_doctor ;;
1458+
help | -h | --help)
1459+
cat <<'EOF'
1460+
deva.sh tmux - host-side lifecycle for container->host tmux
1461+
1462+
Usage:
1463+
deva.sh tmux setup Authorize your ssh key (ssh transport, default)
1464+
deva.sh tmux host-daemon start|stop|status socat fallback daemon (UNAUTHENTICATED)
1465+
deva.sh tmux doctor Host-side diagnosis
1466+
1467+
Container side:
1468+
deva.sh --host-tmux <agent> Opt-in provisioning at launch
1469+
deva-tmux ls|attach|bridge|doctor In-container CLI (both transports)
1470+
tmux-bridge list|read|type|... Agent<->agent pane comms (Layer 2)
1471+
1472+
Docs: docs/tmux-bridge-agent-comms.md
1473+
EOF
1474+
;;
1475+
*)
1476+
echo "error: unknown tmux subcommand: $sub (setup|host-daemon|doctor|help)" >&2
1477+
exit 1
1478+
;;
1479+
esac
1480+
}
1481+
12741482
cmd_status() {
12751483
local show_all=false verbose=false
12761484
@@ -2856,6 +3064,11 @@ parse_wrapper_args() {
28563064
i=$((i + 1))
28573065
continue
28583066
;;
3067+
--host-tmux)
3068+
HOST_TMUX=true
3069+
i=$((i + 1))
3070+
continue
3071+
;;
28593072
--rm)
28603073
EPHEMERAL_MODE=true
28613074
i=$((i + 1))
@@ -2976,7 +3189,14 @@ if [ ${#PRE_ARGS[@]} -gt 0 ]; then
29763189
done
29773190
fi
29783191
2979-
if [ ${#PRE_ARGS[@]} -gt 0 ]; then
3192+
# `deva.sh tmux <sub> [args]` is a command group: only the FIRST token
3193+
# selects it, so subcommand args (status, stop, ...) can't collide with the
3194+
# management-mode tokens scanned below.
3195+
if [ "${PRE_ARGS[0]:-}" = "tmux" ]; then
3196+
MANAGEMENT_MODE="tmux"
3197+
fi
3198+
3199+
if [ ${#PRE_ARGS[@]} -gt 0 ] && [ "$MANAGEMENT_MODE" != "tmux" ]; then
29803200
for tok in "${PRE_ARGS[@]}"; do
29813201
case "$tok" in
29823202
help | --help | -h)
@@ -3023,6 +3243,11 @@ if [ ${#PRE_ARGS[@]} -gt 0 ]; then
30233243
fi
30243244
30253245
if [ "$MANAGEMENT_MODE" != "launch" ]; then
3246+
if [ "$MANAGEMENT_MODE" = "tmux" ]; then
3247+
cmd_tmux "${PRE_ARGS[@]:1}"
3248+
exit $?
3249+
fi
3250+
30263251
if [ "$MANAGEMENT_MODE" = "ps" ]; then
30273252
list_containers_pretty
30283253
exit 0
@@ -3340,6 +3565,7 @@ autolink_legacy_into_deva_root() {
33403565
check_agent "$ACTIVE_AGENT"
33413566
prepare_browser_integration
33423567
prepare_claude_chrome_bridge
3568+
prepare_host_tmux
33433569
append_shared_agents_mount
33443570
33453571
if [ -n "$CONFIG_HOME" ] && [ "$DRY_RUN" != true ]; then

0 commit comments

Comments
 (0)