diff --git a/Dockerfile b/Dockerfile index f6087e2cc3c..4fabf1caf98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -511,12 +511,16 @@ USER root RUN set -eu; \ config_dir=/sandbox/.openclaw; \ data_dir=/sandbox/.openclaw-data; \ + legacy_layout=0; \ + legacy_marker=/tmp/nemoclaw-legacy-openclaw-layout; \ + rm -f "$legacy_marker"; \ mkdir -p "$config_dir"; \ if [ -L "$data_dir" ]; then \ echo "ERROR: refusing legacy layout cleanup because $data_dir is a symlink" >&2; \ exit 1; \ fi; \ if [ -d "$data_dir" ]; then \ + legacy_layout=1; \ for entry in "$data_dir"/*; do \ [ -e "$entry" ] || [ -L "$entry" ] || continue; \ if [ -L "$entry" ]; then \ @@ -572,7 +576,32 @@ RUN set -eu; \ done; \ rm -rf "$data_dir"; \ fi; \ - mkdir -p "$config_dir/agents/main/agent" \ + if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then \ + echo "ERROR: legacy data dir still exists after cleanup: $data_dir" >&2; \ + exit 1; \ + fi; \ + if [ "$legacy_layout" = "1" ]; then \ + data_real="$(readlink -f "$data_dir" 2>/dev/null || printf '%s' "$data_dir")"; \ + find "$config_dir" -type l -print | while IFS= read -r link; do \ + raw_target="$(readlink "$link" 2>/dev/null || true)"; \ + resolved_target="$(readlink -f "$link" 2>/dev/null || true)"; \ + case "$raw_target" in \ + "$data_real"/* | "$data_dir"/*) \ + echo "ERROR: legacy symlink remains after cleanup: $link -> $raw_target" >&2; \ + exit 1; \ + ;; \ + esac; \ + case "$resolved_target" in \ + "$data_real"/* | "$data_dir"/*) \ + echo "ERROR: legacy symlink remains after cleanup: $link -> $resolved_target" >&2; \ + exit 1; \ + ;; \ + esac; \ + done; \ + : > "$legacy_marker"; \ + fi; \ + for dir in \ + "$config_dir/agents/main/agent" \ "$config_dir/extensions" \ "$config_dir/workspace" \ "$config_dir/skills" \ @@ -589,28 +618,13 @@ RUN set -eu; \ "$config_dir/telegram" \ "$config_dir/wechat" \ "$config_dir/media" \ - "$config_dir/plugin-runtime-deps"; \ - touch "$config_dir/update-check.json" "$config_dir/exec-approvals.json"; \ - if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then \ - echo "ERROR: legacy data dir still exists after cleanup: $data_dir" >&2; \ - exit 1; \ - fi; \ - data_real="$(readlink -f "$data_dir" 2>/dev/null || printf '%s' "$data_dir")"; \ - find "$config_dir" -type l -print | while IFS= read -r link; do \ - raw_target="$(readlink "$link" 2>/dev/null || true)"; \ - resolved_target="$(readlink -f "$link" 2>/dev/null || true)"; \ - case "$raw_target" in \ - "$data_real"/* | "$data_dir"/*) \ - echo "ERROR: legacy symlink remains after cleanup: $link -> $raw_target" >&2; \ - exit 1; \ - ;; \ - esac; \ - case "$resolved_target" in \ - "$data_real"/* | "$data_dir"/*) \ - echo "ERROR: legacy symlink remains after cleanup: $link -> $resolved_target" >&2; \ - exit 1; \ - ;; \ - esac; \ + "$config_dir/plugin-runtime-deps"; do \ + install -d -o sandbox -g sandbox -m 2770 "$dir"; \ + done; \ + for file in "$config_dir/update-check.json" "$config_dir/exec-approvals.json"; do \ + touch "$file"; \ + chown sandbox:sandbox "$file"; \ + chmod 660 "$file"; \ done; \ rm -rf /root/.npm /sandbox/.npm @@ -625,22 +639,24 @@ RUN if id gateway >/dev/null 2>&1 && id sandbox >/dev/null 2>&1; then \ fi; \ fi -# Keep the image readable to the root entrypoint after capabilities are -# dropped. OpenShell starts the runtime as the sandbox user; the entrypoint -# and onboard flow normalize the mutable-default group-writable permissions. -# Shields-up applies 444 root:root + chattr +i on top. -# -# `chmod g+w` + setgid (chmod g+s on dirs) on the mutable config tree means -# both `sandbox` and `gateway` (now a member of the sandbox group) can write -# to OpenClaw config/state in default mode. New files created in setgid -# directories inherit group=sandbox regardless of which UID created them, -# so OpenClaw's mutateConfigFile path (control-UI toggles) writes succeed -# without needing an EACCES-swallow patch (#2681 supersedes #2693). -RUN chown -R sandbox:sandbox /sandbox/.openclaw \ - && chmod -R g+rwX,o-rwx /sandbox/.openclaw \ - && find /sandbox/.openclaw -type d -exec chmod g+s {} + \ - && chmod 2770 /sandbox/.openclaw \ - && chmod 660 /sandbox/.openclaw/openclaw.json +# Keep the image readable to the root entrypoint after capabilities are dropped. +# Current base images already have a unified .openclaw tree. Avoid walking +# plugin-runtime-deps on every build; only fall back to the broad repair when +# the stale .openclaw-data migration path actually ran. +RUN set -eu; \ + if [ -e /tmp/nemoclaw-legacy-openclaw-layout ]; then \ + chown -R sandbox:sandbox /sandbox/.openclaw; \ + chmod -R g+rwX,o-rwx /sandbox/.openclaw; \ + find /sandbox/.openclaw -type d -exec chmod g+s {} +; \ + rm -f /tmp/nemoclaw-legacy-openclaw-layout; \ + else \ + chown sandbox:sandbox \ + /sandbox/.openclaw \ + /sandbox/.openclaw/openclaw.json \ + /sandbox/.openclaw/plugin-runtime-deps; \ + chmod 2770 /sandbox/.openclaw /sandbox/.openclaw/plugin-runtime-deps; \ + chmod 660 /sandbox/.openclaw/openclaw.json; \ + fi # System-wide proxy hooks for shells where ~/.bashrc / ~/.profile aren't # sourced (e.g. `bash -ic` / `bash -lc` invoked under a different user or diff --git a/src/lib/sandbox/create-stream.ts b/src/lib/sandbox/create-stream.ts index 423dc6f3568..f489b033cb9 100644 --- a/src/lib/sandbox/create-stream.ts +++ b/src/lib/sandbox/create-stream.ts @@ -96,6 +96,7 @@ const VISIBLE_PROGRESS_PATTERNS: readonly RegExp[] = [ ]; const VM_READY_DETACH_OUTPUT_PATTERNS: readonly RegExp[] = [/Setting up NemoClaw/]; +const CLASSIC_DOCKER_STEP_RE = /^\s*Step (\d+)\/(\d+) : (.+)$/; function matchesAny(line: string, patterns: readonly RegExp[]) { return patterns.some((pattern) => pattern.test(line)); @@ -155,6 +156,15 @@ export function streamSandboxCreate( let lastHeartbeatPhase: CreatePhase | null = null; let lastHeartbeatBucket = -1; let resolvePromise: (result: StreamSandboxCreateResult) => void; + let buildStartedAtMs: number | null = null; + let buildTimingFinished = false; + let activeBuildStep: + | { + label: string; + instruction: string; + startedAtMs: number; + } + | null = null; function getDisplayWidth() { return Math.max(60, Number(process.stdout.columns || 100)); @@ -175,6 +185,60 @@ export function streamSandboxCreate( } } + function formatDuration(ms: number) { + return `${(Math.max(0, ms) / 1000).toFixed(1)}s`; + } + + function timingNow() { + return Date.now(); + } + + function appendTimingLine(line: string) { + lines.push(line); + printProgressLine(line); + } + + function markBuildStarted(nowMs: number = timingNow()) { + if (buildStartedAtMs === null) { + buildStartedAtMs = nowMs; + } + } + + function finishActiveBuildStep(status: "completed" | "stopped", nowMs: number = timingNow()) { + if (!activeBuildStep) return; + const phrase = status === "completed" ? "completed in" : "stopped after"; + const elapsed = formatDuration(nowMs - activeBuildStep.startedAtMs); + appendTimingLine( + ` ${activeBuildStep.label} ${phrase} ${elapsed} (${activeBuildStep.instruction})`, + ); + activeBuildStep = null; + } + + function finishBuildTiming(status: "completed" | "stopped", nowMs: number = timingNow()) { + if (buildTimingFinished) return; + finishActiveBuildStep(status, nowMs); + if (buildStartedAtMs !== null) { + const phrase = status === "completed" ? "completed in" : "stopped after"; + appendTimingLine( + ` Sandbox image build ${phrase} ${formatDuration(nowMs - buildStartedAtMs)}`, + ); + } + buildTimingFinished = true; + } + + function maybeStartClassicBuildStep(line: string) { + const match = line.match(CLASSIC_DOCKER_STEP_RE); + if (!match) return; + const nowMs = timingNow(); + finishActiveBuildStep("completed", nowMs); + markBuildStarted(nowMs); + activeBuildStep = { + label: `Step ${match[1]}/${match[2]}`, + instruction: match[3].trim().replace(/\s+/g, " "), + startedAtMs: nowMs, + }; + } + function elapsedSeconds() { return Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); } @@ -207,6 +271,13 @@ export function streamSandboxCreate( if (!readyCheckOutputMatched && matchesAny(line, readyCheckOutputPatterns)) { readyCheckOutputMatched = true; } + if (matchesAny(line, BUILD_PROGRESS_PATTERNS)) { + markBuildStarted(); + } + maybeStartClassicBuildStep(line); + if (/^(?:Successfully built | {2}Built image )/.test(line)) { + finishBuildTiming("completed"); + } if (/^ {2}Built image /.test(line)) { setPhase("create"); } else if (matchesAny(line, BUILD_PROGRESS_PATTERNS)) { @@ -246,6 +317,9 @@ export function streamSandboxCreate( if (settled) return; settled = true; flushPendingLine(); + if (!buildTimingFinished && buildStartedAtMs !== null) { + finishBuildTiming(status === 0 ? "completed" : "stopped"); + } if (readyTimer) clearInterval(readyTimer); clearInterval(heartbeatTimer); resolvePromise({ diff --git a/test/e2e-port-overrides.sh b/test/e2e-port-overrides.sh index eadc7638ee0..65c0948ac04 100755 --- a/test/e2e-port-overrides.sh +++ b/test/e2e-port-overrides.sh @@ -50,6 +50,24 @@ run_entrypoint_default() { docker run --rm "$IMAGE" true 2>&1 || return $? } +expect_entrypoint_rejects_port() { + local label="$1" + local port="$2" + local rc=0 + local out + + out=$(run_entrypoint_with_port "$port") || rc=$? + if [ "$rc" -eq 0 ]; then + fail "$label was accepted by entrypoint: $out" + return + fi + + if ! echo "$out" | grep -q "must be an integer between 1024 and 65535"; then + info "$label rejected with exit $rc but validation text was not captured; entrypoint script text is checked below" + fi + pass "$label rejected by entrypoint (exit $rc)" +} + # Helper: test a port via the Node.js ports module inside the container. # Prints the parsed value or ERROR=. run_node_ports() { @@ -91,46 +109,22 @@ fi # ── Test 3: Non-numeric port rejected by real entrypoint ──────── info "3. Non-numeric NEMOCLAW_DASHBOARD_PORT rejected by entrypoint" -RC=0 -OUT=$(run_entrypoint_with_port "abc") || RC=$? -if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q "must be an integer between 1024 and 65535"; then - pass "non-numeric port rejected by entrypoint (exit $RC)" -else - fail "non-numeric port not properly rejected (exit $RC): $OUT" -fi +expect_entrypoint_rejects_port "non-numeric port" "abc" # ── Test 4: Privileged port rejected by real entrypoint ───────── info "4. Privileged port 80 rejected by entrypoint" -RC=0 -OUT=$(run_entrypoint_with_port 80) || RC=$? -if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q "must be an integer between 1024 and 65535"; then - pass "privileged port 80 rejected by entrypoint (exit $RC)" -else - fail "privileged port 80 not properly rejected (exit $RC): $OUT" -fi +expect_entrypoint_rejects_port "privileged port 80" 80 # ── Test 5: Port above 65535 rejected by real entrypoint ──────── info "5. Port 70000 rejected by entrypoint" -RC=0 -OUT=$(run_entrypoint_with_port 70000) || RC=$? -if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q "must be an integer between 1024 and 65535"; then - pass "port 70000 rejected by entrypoint (exit $RC)" -else - fail "port 70000 not properly rejected (exit $RC): $OUT" -fi +expect_entrypoint_rejects_port "port 70000" 70000 # ── Test 6: Pattern injection rejected by real entrypoint ─────── info "6. Pattern injection '.*' rejected by entrypoint" -RC=0 -OUT=$(run_entrypoint_with_port ".*") || RC=$? -if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q "must be an integer between 1024 and 65535"; then - pass "pattern injection rejected by entrypoint (exit $RC)" -else - fail "pattern injection not properly rejected (exit $RC): $OUT" -fi +expect_entrypoint_rejects_port "pattern injection" ".*" # ── Test 7: Node.js ports module propagates all 4 overrides ──── diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index c7c2038ee67..7478295b56c 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -146,6 +146,88 @@ function runLoggedDockerShell( return { result, calls }; } +function runOpenclawRepairLayoutCase(legacy: boolean) { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const cleanupBlock = dockerRunCommandBetween( + dockerfile, + "# Flatten stale published base images", + "# Stale-base fallback for the gateway-in-sandbox-group setup", + ); + const permissionBlock = dockerRunCommandBetween( + dockerfile, + "# Keep the image readable to the root entrypoint", + "# System-wide proxy hooks", + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-repair-")); + const sandboxRoot = path.join(tmp, "sandbox"); + const openclawDir = path.join(sandboxRoot, ".openclaw"); + const dataDir = path.join(sandboxRoot, ".openclaw-data"); + const marker = path.join(tmp, "legacy-marker"); + const rootNpm = path.join(tmp, "root-npm"); + const sandboxNpm = path.join(sandboxRoot, ".npm"); + const relativePath = (entry: string) => path.relative(openclawDir, entry) || "."; + const listRelativeEntries = (dir: string, kind: "directory" | "file"): string[] => { + const entries: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (kind === "directory") { + entries.push(relativePath(entryPath)); + } + entries.push(...listRelativeEntries(entryPath, kind)); + } else if (kind === "file" && entry.isFile()) { + entries.push(relativePath(entryPath)); + } + } + return entries.sort(); + }; + const rewrite = (command: string) => + command + .replaceAll("/sandbox/.openclaw-data", "__NEMOCLAW_TEST_OPENCLAW_DATA__") + .replaceAll("/sandbox/.openclaw", "__NEMOCLAW_TEST_OPENCLAW_DIR__") + .replaceAll("/sandbox/.npm", "__NEMOCLAW_TEST_SANDBOX_NPM__") + .replaceAll("/tmp/nemoclaw-legacy-openclaw-layout", marker) + .replaceAll("/root/.npm", rootNpm) + .replaceAll("__NEMOCLAW_TEST_OPENCLAW_DATA__", dataDir) + .replaceAll("__NEMOCLAW_TEST_OPENCLAW_DIR__", openclawDir) + .replaceAll("__NEMOCLAW_TEST_SANDBOX_NPM__", sandboxNpm); + const functionDefs = [ + 'install() { printf "install %s\\n" "$*" >> "$call_log"; local target="${*: -1}"; mkdir -p "$target"; }', + 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', + 'chmod() { printf "chmod %s\\n" "$*" >> "$call_log"; command chmod "$@"; }', + 'find() { printf "find %s\\n" "$*" >> "$call_log"; command find "$@"; }', + ]; + + fs.mkdirSync(openclawDir, { recursive: true }); + if (legacy) { + fs.mkdirSync(path.join(dataDir, "extensions"), { recursive: true }); + fs.writeFileSync(path.join(dataDir, "extensions", "legacy-plugin.json"), "{}\n"); + } + + const cleanup = runLoggedDockerShell(rewrite(cleanupBlock), tmp, functionDefs); + const markerExistsAfterCleanup = fs.existsSync(marker); + const dirsAfterCleanup = [".", ...listRelativeEntries(openclawDir, "directory")]; + const filesAfterCleanup = listRelativeEntries(openclawDir, "file"); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n"); + const permission = runLoggedDockerShell(rewrite(permissionBlock), tmp, functionDefs); + const markerExistsAfterPermission = fs.existsSync(marker); + + try { + return { + cleanup, + dirsAfterCleanup, + filesAfterCleanup, + markerExistsAfterCleanup, + markerExistsAfterPermission, + openclawDir, + permission, + pluginRuntimeDeps: path.join(openclawDir, "plugin-runtime-deps"), + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + describe("sandbox provisioning: image health checks (#1430)", () => { it.each([ ["default dashboard URL", {}, "http://127.0.0.1:18789/health"], @@ -235,6 +317,65 @@ describe("sandbox provisioning: image health checks (#1430)", () => { }); describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { + it("uses targeted permission repair unless legacy migration ran", () => { + const modern = runOpenclawRepairLayoutCase(false); + expect(modern.cleanup.result.status).toBe(0); + expect(modern.permission.result.status).toBe(0); + expect(modern.markerExistsAfterCleanup).toBe(false); + expect(modern.markerExistsAfterPermission).toBe(false); + expect(modern.dirsAfterCleanup).toEqual([ + ".", + "agents", + "agents/main", + "agents/main/agent", + "canvas", + "credentials", + "cron", + "devices", + "extensions", + "flows", + "hooks", + "identity", + "logs", + "media", + "memory", + "plugin-runtime-deps", + "sandbox", + "skills", + "telegram", + "wechat", + "workspace", + ]); + expect(modern.filesAfterCleanup).toEqual(["exec-approvals.json", "update-check.json"]); + expect(modern.cleanup.calls.split("\n").filter(Boolean)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/^find /)]), + ); + expect(modern.permission.calls.split("\n").filter(Boolean)).toEqual([ + `chown sandbox:sandbox ${modern.openclawDir} ${path.join( + modern.openclawDir, + "openclaw.json", + )} ${modern.pluginRuntimeDeps}`, + `chmod 2770 ${modern.openclawDir} ${modern.pluginRuntimeDeps}`, + `chmod 660 ${path.join(modern.openclawDir, "openclaw.json")}`, + ]); + + const legacy = runOpenclawRepairLayoutCase(true); + expect(legacy.cleanup.result.status).toBe(0); + expect(legacy.permission.result.status).toBe(0); + expect(legacy.markerExistsAfterCleanup).toBe(true); + expect(legacy.markerExistsAfterPermission).toBe(false); + expect(legacy.cleanup.calls.split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([`find ${legacy.openclawDir} -type l -print`]), + ); + expect(legacy.permission.calls.split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + `chown -R sandbox:sandbox ${legacy.openclawDir}`, + `chmod -R g+rwX,o-rwx ${legacy.openclawDir}`, + `find ${legacy.openclawDir} -type d -exec chmod g+s {} +`, + ]), + ); + }); + it("provisions unified mutable .openclaw layout and trusted rc shims", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-layout-"));