Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,31 +33,39 @@ 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; \
fi; \
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
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions test/e2e/test-sandbox-operations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# =============================================================================
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading