diff --git a/docs/install/docker.md b/docs/install/docker.md index 2c8dcf1b233..0aea7350ce4 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -55,6 +55,10 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va - generate a gateway token and write it to `.env` - start the gateway via Docker Compose + During setup, pre-start onboarding and config writes run through + `openclaw-gateway` directly. `openclaw-cli` is for commands you run after + the gateway container already exists. + @@ -94,7 +98,15 @@ If you prefer to run each step yourself instead of using the setup script: ```bash docker build -t openclaw:local -f Dockerfile . -docker compose run --rm openclaw-cli onboard +docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ + dist/index.js onboard --mode local --no-install-daemon +docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ + dist/index.js config set gateway.mode local +docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ + dist/index.js config set gateway.bind lan +docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ + dist/index.js config set gateway.controlUi.allowedOrigins \ + '["http://localhost:18789","http://127.0.0.1:18789"]' --strict-json docker compose up -d openclaw-gateway ``` @@ -104,6 +116,13 @@ or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`; include it with `-f docker-compose.yml -f docker-compose.extra.yml`. + +Because `openclaw-cli` shares `openclaw-gateway`'s network namespace, it is a +post-start tool. Before `docker compose up -d openclaw-gateway`, run onboarding +and setup-time config writes through `openclaw-gateway` with +`--no-deps --entrypoint node`. + + ### Environment variables The setup script accepts these optional environment variables: diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index dfeefaedcfe..e2b5b03a72b 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -108,7 +108,7 @@ ensure_control_ui_allowed_origins() { local current_allowed_origins allowed_origin_json="$(printf '["http://localhost:%s","http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT" "$OPENCLAW_GATEWAY_PORT")" current_allowed_origins="$( - run_setup_cli config get gateway.controlUi.allowedOrigins 2>/dev/null || true + run_prestart_cli config get gateway.controlUi.allowedOrigins 2>/dev/null || true )" current_allowed_origins="${current_allowed_origins//$'\r'/}" @@ -117,26 +117,53 @@ ensure_control_ui_allowed_origins() { return 0 fi - run_setup_cli config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json \ + run_prestart_cli config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json \ >/dev/null echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind." } sync_gateway_mode_and_bind() { - run_setup_cli config set gateway.mode local >/dev/null - run_setup_cli config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null + run_prestart_cli config set gateway.mode local >/dev/null + run_prestart_cli config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup." } -run_setup_cli() { +run_prestart_gateway() { + docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps "$@" +} + +run_prestart_cli() { # During setup, avoid the shared-network openclaw-cli service because it # requires the gateway container's network namespace to already exist. That # creates a circular dependency for config writes that are needed before the # gateway can start cleanly. - docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps --entrypoint node openclaw-gateway \ + run_prestart_gateway --entrypoint node openclaw-gateway \ dist/index.js "$@" } +run_runtime_cli() { + local compose_scope="${1:-current}" + local deps_mode="${2:-with-deps}" + shift 2 + + local -a compose_args + local -a run_args=(run --rm) + + case "$compose_scope" in + current) compose_args=("${COMPOSE_ARGS[@]}") ;; + base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;; + *) fail "Unknown runtime CLI compose scope: $compose_scope" ;; + esac + + case "$deps_mode" in + with-deps) ;; + no-deps) run_args+=(--no-deps) ;; + *) fail "Unknown runtime CLI deps mode: $deps_mode" ;; + esac + + docker compose "${compose_args[@]}" "${run_args[@]}" openclaw-cli "$@" +} + contains_disallowed_chars() { local value="$1" [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] @@ -464,7 +491,7 @@ echo "==> Fixing data-directory permissions" # ownership of all user project files on Linux hosts. # After fixing the config dir, only the OpenClaw metadata subdirectory # (.openclaw/) inside the workspace gets chowned, not the user's project files. -docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps --user root --entrypoint sh openclaw-gateway -c \ +run_prestart_gateway --user root --entrypoint sh openclaw-gateway -c \ 'find /home/node/.openclaw -xdev -exec chown node:node {} +; \ [ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true' @@ -477,7 +504,7 @@ echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN" echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)." echo "Install Gateway daemon: No (managed by Docker Compose)" echo "" -run_setup_cli onboard --mode local --no-install-daemon +run_prestart_cli onboard --mode local --no-install-daemon echo "" echo "==> Docker gateway defaults" @@ -561,17 +588,17 @@ fi if [[ -n "$SANDBOX_ENABLED" ]]; then # Enable sandbox in OpenClaw config. sandbox_config_ok=true - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + if ! run_runtime_cli current no-deps \ config set agents.defaults.sandbox.mode "non-main" >/dev/null; then echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2 sandbox_config_ok=false fi - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + if ! run_runtime_cli current no-deps \ config set agents.defaults.sandbox.scope "agent" >/dev/null; then echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2 sandbox_config_ok=false fi - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + if ! run_runtime_cli current no-deps \ config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2 sandbox_config_ok=false @@ -585,7 +612,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then else echo "WARNING: Sandbox config was partially applied. Check errors above." >&2 echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2 - if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \ + if ! run_runtime_cli base no-deps \ config set agents.defaults.sandbox.mode "off" >/dev/null; then echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2 else @@ -601,7 +628,7 @@ else # Keep reruns deterministic: if sandbox is not active for this run, reset # persisted sandbox mode so future execs do not require docker.sock by stale # config alone. - if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + if ! run_runtime_cli current with-deps \ config set agents.defaults.sandbox.mode "off" >/dev/null; then echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2 fi diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index e1663ceaae4..f4c7f8d788e 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -114,6 +114,26 @@ function runDockerSetup( }); } +async function resetDockerLog(sandbox: DockerSetupSandbox) { + await writeFile(sandbox.logPath, ""); +} + +async function readDockerLog(sandbox: DockerSetupSandbox) { + return readFile(sandbox.logPath, "utf8"); +} + +async function readDockerLogLines(sandbox: DockerSetupSandbox) { + return (await readDockerLog(sandbox)).split("\n").filter(Boolean); +} + +function isGatewayStartLine(line: string) { + return line.includes("compose") && line.includes(" up -d") && line.includes("openclaw-gateway"); +} + +function findGatewayStartLineIndex(lines: string[]) { + return lines.findIndex((line) => isGatewayStartLine(line)); +} + async function runDockerSetupWithUnsetGatewayToken( sandbox: DockerSetupSandbox, suffix: string, @@ -204,7 +224,7 @@ describe("scripts/docker/setup.sh", () => { expect(extraCompose).toContain("openclaw-home:/home/node"); expect(extraCompose).toContain("volumes:"); expect(extraCompose).toContain("openclaw-home:"); - const log = await readFile(activeSandbox.logPath, "utf8"); + const log = await readDockerLog(activeSandbox); expect(log).toContain("--build-arg OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential"); expect(log).toContain( "run --rm --no-deps --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon", @@ -224,16 +244,12 @@ describe("scripts/docker/setup.sh", () => { it("avoids shared-network openclaw-cli before the gateway is started", async () => { const activeSandbox = requireSandbox(sandbox); - await writeFile(activeSandbox.logPath, ""); + await resetDockerLog(activeSandbox); const result = runDockerSetup(activeSandbox); expect(result.status).toBe(0); - const log = await readFile(activeSandbox.logPath, "utf8"); - const lines = log.split("\n").filter(Boolean); - const gatewayStartIdx = lines.findIndex( - (line) => - line.includes("compose") && line.includes(" up -d") && line.includes("openclaw-gateway"), - ); + const lines = await readDockerLogLines(activeSandbox); + const gatewayStartIdx = findGatewayStartLineIndex(lines); expect(gatewayStartIdx).toBeGreaterThanOrEqual(0); const prestartLines = lines.slice(0, gatewayStartIdx); @@ -286,7 +302,7 @@ describe("scripts/docker/setup.sh", () => { expect(sessionsDirStat.isDirectory()).toBe(true); // Verify that a root-user chown step runs before setup. - const log = await readFile(activeSandbox.logPath, "utf8"); + const log = await readDockerLog(activeSandbox); const chownIdx = log.indexOf("--user root"); const onboardIdx = log.indexOf("onboard"); expect(chownIdx).toBeGreaterThanOrEqual(0); @@ -350,7 +366,7 @@ describe("scripts/docker/setup.sh", () => { it("treats OPENCLAW_SANDBOX=0 as disabled", async () => { const activeSandbox = requireSandbox(sandbox); - await writeFile(activeSandbox.logPath, ""); + await resetDockerLog(activeSandbox); const result = runDockerSetup(activeSandbox, { OPENCLAW_SANDBOX: "0", @@ -360,7 +376,7 @@ describe("scripts/docker/setup.sh", () => { const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_SANDBOX="); - const log = await readFile(activeSandbox.logPath, "utf8"); + const log = await readDockerLog(activeSandbox); expect(log).toContain("--build-arg OPENCLAW_INSTALL_DOCKER_CLI="); expect(log).not.toContain("--build-arg OPENCLAW_INSTALL_DOCKER_CLI=1"); expect(log).toContain("config set agents.defaults.sandbox.mode off"); @@ -368,7 +384,7 @@ describe("scripts/docker/setup.sh", () => { it("resets stale sandbox mode and overlay when sandbox is not active", async () => { const activeSandbox = requireSandbox(sandbox); - await writeFile(activeSandbox.logPath, ""); + await resetDockerLog(activeSandbox); await writeFile( join(activeSandbox.rootDir, "docker-compose.sandbox.yml"), "services:\n openclaw-gateway:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n", @@ -381,14 +397,14 @@ describe("scripts/docker/setup.sh", () => { expect(result.status).toBe(0); expect(result.stderr).toContain("Sandbox requires Docker CLI"); - const log = await readFile(activeSandbox.logPath, "utf8"); + const log = await readDockerLog(activeSandbox); expect(log).toContain("config set agents.defaults.sandbox.mode off"); await expect(stat(join(activeSandbox.rootDir, "docker-compose.sandbox.yml"))).rejects.toThrow(); }); it("skips sandbox gateway restart when sandbox config writes fail", async () => { const activeSandbox = requireSandbox(sandbox); - await writeFile(activeSandbox.logPath, ""); + await resetDockerLog(activeSandbox); const socketPath = join(activeSandbox.rootDir, "sandbox.sock"); await withUnixSocket(socketPath, async () => { @@ -402,15 +418,10 @@ describe("scripts/docker/setup.sh", () => { expect(result.stderr).toContain("Failed to set agents.defaults.sandbox.scope"); expect(result.stderr).toContain("Skipping gateway restart to avoid exposing Docker socket"); - const log = await readFile(activeSandbox.logPath, "utf8"); - const gatewayStarts = log - .split("\n") - .filter( - (line) => - line.includes("compose") && - line.includes(" up -d") && - line.includes("openclaw-gateway"), - ); + const log = await readDockerLog(activeSandbox); + const gatewayStarts = (await readDockerLogLines(activeSandbox)).filter((line) => + isGatewayStartLine(line), + ); expect(gatewayStarts).toHaveLength(2); expect(log).toContain( "run --rm --no-deps openclaw-cli config set agents.defaults.sandbox.mode non-main",