diff --git a/Dockerfile b/Dockerfile index b56a1a68634..77ded4c1ed8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,20 +33,24 @@ ARG OPENCLAW_2026_5_22_INTEGRITY=sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72 # Harden: remove unnecessary build tools and network probes from base image (#830) # Protect runtime tools before autoremove — the GHCR base may predate the -# procps/e2fsprogs additions, leaving ps/chattr absent or auto-marked. The -# conditional install keeps stale bases usable while fresh bases skip apt. -# Refs: #2343, shields-up chattr hardening +# procps/e2fsprogs/tmux additions, leaving ps/chattr/tmux absent or auto-marked. +# The conditional install keeps stale bases usable while fresh bases skip apt. +# tmux is required by OpenClaw's bundled tmux-session flow (#4513); a stale base +# without it makes that flow fail with `tmux: command not found`. +# Refs: #2343, #4513, shields-up chattr hardening # hadolint ignore=DL3001 RUN set -eu; \ - apt-mark manual procps e2fsprogs 2>/dev/null || true; \ + apt-mark manual procps e2fsprogs tmux 2>/dev/null || true; \ (apt-get remove --purge -y gcc gcc-12 g++ g++-12 cpp cpp-12 make \ netcat-openbsd netcat-traditional ncat 2>/dev/null || true); \ apt-get autoremove --purge -y; \ needs_ps=0; \ needs_chattr=0; \ + needs_tmux=0; \ if ! command -v ps >/dev/null 2>&1; then needs_ps=1; fi; \ if ! command -v chattr >/dev/null 2>&1; then needs_chattr=1; fi; \ - if [ "$needs_ps" = "1" ] || [ "$needs_chattr" = "1" ]; then \ + if ! command -v tmux >/dev/null 2>&1; then needs_tmux=1; fi; \ + if [ "$needs_ps" = "1" ] || [ "$needs_chattr" = "1" ] || [ "$needs_tmux" = "1" ]; then \ apt-get update; \ if [ "$needs_ps" = "1" ]; then \ apt-get install -y --no-install-recommends procps=2:4.0.4-9; \ @@ -54,10 +58,14 @@ RUN set -eu; \ if [ "$needs_chattr" = "1" ]; then \ apt-get install -y --no-install-recommends e2fsprogs=1.47.2-3+b11; \ fi; \ + if [ "$needs_tmux" = "1" ]; then \ + apt-get install -y --no-install-recommends tmux=3.5a-3; \ + fi; \ fi; \ rm -rf /var/lib/apt/lists/*; \ ps --version; \ - command -v chattr >/dev/null + command -v chattr >/dev/null; \ + command -v tmux >/dev/null # Copy built plugin and blueprint into the sandbox diff --git a/Dockerfile.base b/Dockerfile.base index fdd34790c7b..3d38d98607c 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -68,6 +68,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jq=1.7.1-6+deb13u2 \ vim-tiny=2:9.1.1230-2 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ + tmux=3.5a-3 \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/bin/python3 /usr/local/bin/python diff --git a/test/e2e/test-sandbox-operations.sh b/test/e2e/test-sandbox-operations.sh index 0e9f3d3ec75..cc1549cda9a 100755 --- a/test/e2e/test-sandbox-operations.sh +++ b/test/e2e/test-sandbox-operations.sh @@ -452,6 +452,42 @@ test_sbx_04_log_streaming() { fi } +# ── TC-SBX-09: Tmux Session Flow ──────────────────────────────────────────── +# OpenClaw's bundled tmux-session flow shells out to `tmux` inside the sandbox. +# The sandbox image must ship tmux (issue #4513) or that flow fails with +# `tmux: command not found`. Assert the binary is present and can drive a full +# detached session lifecycle (new-session → list → kill), which is the exact +# shape the bundled flow exercises. +test_sbx_09_tmux_session_flow() { + log "=== TC-SBX-09: Tmux Session Flow ===" + require_sandbox "$SANDBOX_A" "TC-SBX-09" || return + + local which_out + which_out=$(sandbox_exec "command -v tmux || echo TMUX_MISSING" 2>&1) || true + if echo "$which_out" | grep -q "TMUX_MISSING"; then + fail "TC-SBX-09: Tmux Session Flow" "tmux not found inside sandbox (issue #4513)" + return + fi + pass "TC-SBX-09: tmux is installed in the sandbox ($(echo "$which_out" | head -1))" + + # Drive a detached session lifecycle the way the bundled flow does. tmux needs + # a writable socket dir; /tmp is on the sandbox write set. + local sess="nemoclaw-e2e-tmux-$$" + local flow_out + flow_out=$(sandbox_exec "TMUX_TMPDIR=/tmp tmux new-session -d -s '${sess}' 'sleep 30' \ + && TMUX_TMPDIR=/tmp tmux list-sessions \ + && TMUX_TMPDIR=/tmp tmux kill-session -t '${sess}' \ + && echo TMUX_FLOW_OK" 2>&1) || true + + if echo "$flow_out" | grep -q "TMUX_FLOW_OK" && echo "$flow_out" | grep -q "${sess}"; then + pass "TC-SBX-09: tmux new/list/kill session lifecycle works" + else + # Best-effort cleanup in case kill-session never ran. + sandbox_exec "TMUX_TMPDIR=/tmp tmux kill-session -t '${sess}' 2>/dev/null || true" >/dev/null 2>&1 || true + fail "TC-SBX-09: Tmux Session Flow" "Session lifecycle failed: $(echo "$flow_out" | head -5)" + fi +} + # ============================================================================= # Phase 2: Non-destructive recovery (sandbox A stays alive) # ============================================================================= @@ -792,6 +828,7 @@ main() { test_sbx_02_connect_chat test_sbx_03_status_fields test_sbx_04_log_streaming + test_sbx_09_tmux_session_flow # Phase 2: Non-destructive recovery (sandbox A stays alive) test_sbx_07_registry_rebuild diff --git a/test/runner.test.ts b/test/runner.test.ts index ba22cebcb6e..3e2f16f9ed2 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -859,4 +859,48 @@ describe("regression guards", () => { expect(src).not.toContain("SKIP_VLLM=1"); }); }); + + describe("sandbox ships tmux for the bundled tmux-session flow (#4513)", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + + it("base image installs a pinned tmux in the apt package list", () => { + const src = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf-8"); + // Pinned (DL3008) tmux must be part of the single base apt-get install + // layer so fresh builds ship it without a runtime apt round-trip. + expect(src).toMatch(/tmux=[0-9]/); + }); + + it("runtime image repairs tmux on stale bases and asserts it at build time", () => { + const src = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8"); + // Stale GHCR bases predating the tmux addition must still converge: the + // hardening layer detects a missing tmux, installs a pinned version, and + // fails the build if tmux is still absent afterwards. + expect(src).toContain("needs_tmux=1"); + expect(src).toMatch(/apt-get install -y --no-install-recommends tmux=[0-9]/); + expect(src).toContain("command -v tmux >/dev/null"); + }); + + it("base and runtime images pin tmux to the same version", () => { + const baseSrc = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf-8"); + const runtimeSrc = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8"); + const baseVersion = baseSrc.match(/tmux=([0-9][^\s\\]*)/)?.[1]; + const runtimeVersion = runtimeSrc.match( + /apt-get install -y --no-install-recommends tmux=([0-9][^\s\\;]*)/, + )?.[1]; + expect(baseVersion).toBeDefined(); + expect(runtimeVersion).toBeDefined(); + expect(runtimeVersion).toBe(baseVersion); + }); + + it("the e2e sandbox suite exercises the tmux-session flow", () => { + const src = fs.readFileSync( + path.join(repoRoot, "test", "e2e", "test-sandbox-operations.sh"), + "utf-8", + ); + expect(src).toContain("test_sbx_09_tmux_session_flow"); + expect(src).toContain("command -v tmux"); + // The smoke must be wired into the run, not just defined. + expect(src).toMatch(/^\s*test_sbx_09_tmux_session_flow\s*$/m); + }); + }); });