diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f203c069c..36d2d5b337c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Changes - MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97. +- Podman: simplify the container setup around the current rootless user, install the launch helper under `~/.local/bin`, and document the host-CLI `openclaw --container ...` workflow instead of a dedicated `openclaw` service user. - Slack/tool actions: add an explicit `upload-file` Slack action that routes file uploads through the existing Slack upload transport, with optional filename/title/comment overrides for channels and DMs. - Message actions/files: start unifying file-first sends on the canonical `upload-file` action by adding explicit support for Microsoft Teams and Google Chat, and by exposing BlueBubbles file sends through `upload-file` while keeping the legacy `sendAttachment` alias. - Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index aa75b9cf2b5..e8395d7b542 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -169,7 +169,7 @@ Look for: Common signatures: -- `Gateway start blocked: set gateway.mode=local` → local gateway mode is not enabled. Fix: set `gateway.mode="local"` in your config (or run `openclaw configure`). If you are running OpenClaw via Podman using the dedicated `openclaw` user, the config lives at `~openclaw/.openclaw/openclaw.json`. +- `Gateway start blocked: set gateway.mode=local` → local gateway mode is not enabled. Fix: set `gateway.mode="local"` in your config (or run `openclaw configure`). If you are running OpenClaw via Podman, the default config path is `~/.openclaw/openclaw.json`. - `refusing to bind gateway ... without auth` → non-loopback bind without token/password. - `another gateway instance is already listening` / `EADDRINUSE` → port conflict. diff --git a/docs/install/podman.md b/docs/install/podman.md index c21980b5c08..f99ad257fb1 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -7,127 +7,261 @@ title: "Podman" # Podman -Run the OpenClaw Gateway in a **rootless** Podman container. Uses the same image as Docker (built from the repo [Dockerfile](https://github.com/openclaw/openclaw/blob/main/Dockerfile)). +Run the OpenClaw Gateway in a rootless Podman container, managed by your current non-root user. + +The intended model is: + +- Podman runs the gateway container. +- Your host `openclaw` CLI is the control plane. +- Persistent state lives on the host under `~/.openclaw` by default. +- Day-to-day management uses `openclaw --container ...` instead of `sudo -u openclaw`, `podman exec`, or a separate service user. ## Prerequisites -- **Podman** (rootless mode) -- **sudo** access for one-time setup (creating the dedicated user and building the image) +- **Podman** in rootless mode +- **OpenClaw CLI** installed on the host +- **Optional:** `systemd --user` if you want Quadlet-managed auto-start +- **Optional:** `sudo` only if you want `loginctl enable-linger "$(whoami)"` for boot persistence on a headless host ## Quick start - From the repo root, run the setup script. It creates a dedicated `openclaw` user, builds the container image, and installs the launch script: - - ```bash - ./scripts/podman/setup.sh - ``` - - This also creates a minimal config at `~openclaw/.openclaw/openclaw.json` (sets `gateway.mode` to `"local"`) so the Gateway can start without running the wizard. - - By default the container is **not** installed as a systemd service -- you start it manually in the next step. For a production-style setup with auto-start and restarts, pass `--quadlet` instead: - - ```bash - ./scripts/podman/setup.sh --quadlet - ``` - - (Or set `OPENCLAW_PODMAN_QUADLET=1`. Use `--container` to install only the container and launch script.) - - **Optional build-time env vars** (set before running `scripts/podman/setup.sh`): - - - `OPENCLAW_DOCKER_APT_PACKAGES` -- install extra apt packages during image build. - - `OPENCLAW_EXTENSIONS` -- pre-install extension dependencies (space-separated names, e.g. `diagnostics-otel matrix`). - + From the repo root, run `./scripts/podman/setup.sh`. - - For a quick manual launch: - - ```bash - ./scripts/run-openclaw-podman.sh launch - ``` - + + Start the container with `./scripts/run-openclaw-podman.sh launch`. - - To add channels or providers interactively: - - ```bash - ./scripts/run-openclaw-podman.sh launch setup - ``` - - Then open `http://127.0.0.1:18789/` and use the token from `~openclaw/.openclaw/.env` (or the value printed by setup). + + Run `./scripts/run-openclaw-podman.sh launch setup`, then open `http://127.0.0.1:18789/`. + + + Set `OPENCLAW_CONTAINER=openclaw`, then use normal `openclaw` commands from the host. +Setup details: + +- `./scripts/podman/setup.sh` builds `openclaw:local` in your rootless Podman store by default, or uses `OPENCLAW_IMAGE` / `OPENCLAW_PODMAN_IMAGE` if you set one. +- It creates `~/.openclaw/openclaw.json` with `gateway.mode: "local"` if missing. +- It creates `~/.openclaw/.env` with `OPENCLAW_GATEWAY_TOKEN` if missing. +- For manual launches, the helper reads only a small allowlist of Podman-related keys from `~/.openclaw/.env` and passes explicit runtime env vars to the container; it does not hand the full env file to Podman. + +Quadlet-managed setup: + +```bash +./scripts/podman/setup.sh --quadlet +``` + +Quadlet is a Linux-only option because it depends on systemd user services. + +You can also set `OPENCLAW_PODMAN_QUADLET=1`. + +Optional build/setup env vars: + +- `OPENCLAW_IMAGE` or `OPENCLAW_PODMAN_IMAGE` -- use an existing/pulled image instead of building `openclaw:local` +- `OPENCLAW_DOCKER_APT_PACKAGES` -- install extra apt packages during image build +- `OPENCLAW_EXTENSIONS` -- pre-install extension dependencies at build time + +Container start: + +```bash +./scripts/run-openclaw-podman.sh launch +``` + +The script starts the container as your current uid/gid with `--userns=keep-id` and bind-mounts your OpenClaw state into the container. + +Onboarding: + +```bash +./scripts/run-openclaw-podman.sh launch setup +``` + +Then open `http://127.0.0.1:18789/` and use the token from `~/.openclaw/.env`. + +Host CLI default: + +```bash +export OPENCLAW_CONTAINER=openclaw +``` + +Then commands such as these will run inside that container automatically: + +```bash +openclaw dashboard --no-open +openclaw gateway status --deep +openclaw doctor +openclaw channels login +``` + +On macOS, Podman machine may make the browser appear non-local to the gateway. +If the Control UI reports device-auth errors after launch, prefer the SSH +tunnel flow in [macOS Podman SSH tunnel](#macos-podman-ssh-tunnel). For +remote HTTPS access, use the Tailscale guidance in +[Podman + Tailscale](#podman--tailscale). + +## macOS Podman SSH tunnel + +On macOS, Podman machine can make the browser appear non-local to the gateway even when the published port is only on `127.0.0.1`. + +For local browser access, use an SSH tunnel into the Podman VM and open the tunneled localhost port instead. + +Recommended local tunnel port: + +- `28889` on the Mac host +- forwarded to `127.0.0.1:18789` inside the Podman VM + +Start the tunnel in a separate terminal: + +```bash +ssh -N \ + -i ~/.local/share/containers/podman/machine/machine \ + -p \ + -L 28889:127.0.0.1:18789 \ + core@127.0.0.1 +``` + +In that command, `` is the Podman VM's SSH port on the Mac host. Check your current value with: + +```bash +podman system connection list +``` + +Allow the tunneled browser origin once. This is required the first time you use the tunnel because the launcher can auto-seed the Podman-published port, but it cannot infer your chosen browser tunnel port: + +```bash +OPENCLAW_CONTAINER=openclaw openclaw config set gateway.controlUi.allowedOrigins \ + '["http://127.0.0.1:18789","http://localhost:18789","http://127.0.0.1:28889","http://localhost:28889"]' \ + --strict-json +podman restart openclaw +``` + +That is a one-time step for the default `28889` tunnel. + +Then open: + +```text +http://127.0.0.1:28889/ +``` + +Notes: + +- `18789` is usually already occupied on the Mac host by the Podman-published gateway port, so the tunnel uses `28889` as the local browser port. +- If the UI asks for pairing approval, prefer explicit container-targeted or explicit-URL commands so the host CLI does not fall back to local pairing files: + +```bash +openclaw --container openclaw devices list +openclaw --container openclaw devices approve --latest +``` + +- Equivalent explicit-URL form: + +```bash +openclaw devices list \ + --url ws://127.0.0.1:28889 \ + --token "$(sed -n 's/^OPENCLAW_GATEWAY_TOKEN=//p' ~/.openclaw/.env | head -n1)" +``` + +## Podman + Tailscale + +For HTTPS or remote browser access, follow the main Tailscale docs. + +Podman-specific note: + +- Keep the Podman publish host at `127.0.0.1`. +- Prefer host-managed `tailscale serve` over `openclaw gateway --tailscale serve`. +- For local macOS browser access without HTTPS, prefer the SSH tunnel section above. + +See: + +- [Tailscale](/gateway/tailscale) +- [Control UI](/web/control-ui) + ## Systemd (Quadlet, optional) -If you ran `./scripts/podman/setup.sh --quadlet` (or `OPENCLAW_PODMAN_QUADLET=1`), a [Podman Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) unit is installed so the gateway runs as a systemd user service for the openclaw user. The service is enabled and started at the end of setup. +If you ran `./scripts/podman/setup.sh --quadlet`, setup installs a Quadlet file at: -- **Start:** `sudo systemctl --machine openclaw@ --user start openclaw.service` -- **Stop:** `sudo systemctl --machine openclaw@ --user stop openclaw.service` -- **Status:** `sudo systemctl --machine openclaw@ --user status openclaw.service` -- **Logs:** `sudo journalctl --machine openclaw@ --user -u openclaw.service -f` +```bash +~/.config/containers/systemd/openclaw.container +``` -The quadlet file lives at `~openclaw/.config/containers/systemd/openclaw.container`. To change ports or env, edit that file (or the `.env` it sources), then `sudo systemctl --machine openclaw@ --user daemon-reload` and restart the service. On boot, the service starts automatically if lingering is enabled for openclaw (setup does this when loginctl is available). +Useful commands: -To add quadlet **after** an initial setup that did not use it, re-run: `./scripts/podman/setup.sh --quadlet`. +- **Start:** `systemctl --user start openclaw.service` +- **Stop:** `systemctl --user stop openclaw.service` +- **Status:** `systemctl --user status openclaw.service` +- **Logs:** `journalctl --user -u openclaw.service -f` -## The openclaw user (non-login) +After editing the Quadlet file: -`scripts/podman/setup.sh` creates a dedicated system user `openclaw`: +```bash +systemctl --user daemon-reload +systemctl --user restart openclaw.service +``` -- **Shell:** `nologin` — no interactive login; reduces attack surface. -- **Home:** e.g. `/home/openclaw` — holds `~/.openclaw` (config, workspace) and the launch script `run-openclaw-podman.sh`. -- **Rootless Podman:** The user must have a **subuid** and **subgid** range. Many distros assign these automatically when the user is created. If setup prints a warning, add lines to `/etc/subuid` and `/etc/subgid`: +For boot persistence on SSH/headless hosts, enable lingering for your current user: - ```text - openclaw:100000:65536 - ``` +```bash +sudo loginctl enable-linger "$(whoami)" +``` - Then start the gateway as that user (e.g. from cron or systemd): +## Config, env, and storage - ```bash - sudo -u openclaw /home/openclaw/run-openclaw-podman.sh - sudo -u openclaw /home/openclaw/run-openclaw-podman.sh setup - ``` +- **Config dir:** `~/.openclaw` +- **Workspace dir:** `~/.openclaw/workspace` +- **Token file:** `~/.openclaw/.env` +- **Launch helper:** `./scripts/run-openclaw-podman.sh` -- **Config:** Only `openclaw` and root can access `/home/openclaw/.openclaw`. To edit config: use the Control UI once the gateway is running, or `sudo -u openclaw $EDITOR /home/openclaw/.openclaw/openclaw.json`. +The launch script and Quadlet bind-mount host state into the container: -## Environment and config +- `OPENCLAW_CONFIG_DIR` -> `/home/node/.openclaw` +- `OPENCLAW_WORKSPACE_DIR` -> `/home/node/.openclaw/workspace` -- **Token:** Stored in `~openclaw/.openclaw/.env` as `OPENCLAW_GATEWAY_TOKEN`. `scripts/podman/setup.sh` and `run-openclaw-podman.sh` generate it if missing (uses `openssl`, `python3`, or `od`). -- **Optional:** In that `.env` you can set provider keys (e.g. `GROQ_API_KEY`, `OLLAMA_API_KEY`) and other OpenClaw env vars. -- **Host ports:** By default the script maps `18789` (gateway) and `18790` (bridge). Override the **host** port mapping with `OPENCLAW_PODMAN_GATEWAY_HOST_PORT` and `OPENCLAW_PODMAN_BRIDGE_HOST_PORT` when launching. -- **Gateway bind:** By default, `run-openclaw-podman.sh` starts the gateway with `--bind loopback` for safe local access. To expose on LAN, set `OPENCLAW_GATEWAY_BIND=lan` and configure `gateway.controlUi.allowedOrigins` (or explicitly enable host-header fallback) in `openclaw.json`. -- **Paths:** Host config and workspace default to `~openclaw/.openclaw` and `~openclaw/.openclaw/workspace`. Override the host paths used by the launch script with `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR`. +By default those are host directories, not anonymous container state, so config and workspace survive container replacement. +The Podman setup also seeds `gateway.controlUi.allowedOrigins` for `127.0.0.1` and `localhost` on the published gateway port so the local dashboard works with the container's non-loopback bind. -## Storage model +Useful env vars for the manual launcher: -- **Persistent host data:** `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` are bind-mounted into the container and retain state on the host. -- **Ephemeral sandbox tmpfs:** if you enable `agents.defaults.sandbox`, the tool sandbox containers mount `tmpfs` at `/tmp`, `/var/tmp`, and `/run`. Those paths are memory-backed and disappear with the sandbox container; the top-level Podman container setup does not add its own tmpfs mounts. -- **Disk growth hotspots:** the main paths to watch are `media/`, `agents//sessions/sessions.json`, transcript JSONL files, `cron/runs/*.jsonl`, and rolling file logs under `/tmp/openclaw/` (or your configured `logging.file`). +- `OPENCLAW_PODMAN_CONTAINER` -- container name (`openclaw` by default) +- `OPENCLAW_PODMAN_IMAGE` / `OPENCLAW_IMAGE` -- image to run +- `OPENCLAW_PODMAN_GATEWAY_HOST_PORT` -- host port mapped to container `18789` +- `OPENCLAW_PODMAN_BRIDGE_HOST_PORT` -- host port mapped to container `18790` +- `OPENCLAW_PODMAN_PUBLISH_HOST` -- host interface for published ports; default is `127.0.0.1` +- `OPENCLAW_GATEWAY_BIND` -- gateway bind mode inside the container; default is `lan` +- `OPENCLAW_PODMAN_USERNS` -- `keep-id` (default), `auto`, or `host` -`scripts/podman/setup.sh` now stages the image tar in a private temp directory and prints the chosen base dir during setup. For non-root runs it accepts `TMPDIR` only when that base is safe to use; otherwise it falls back to `/var/tmp`, then `/tmp`. The saved tar stays owner-only and is streamed into the target user’s `podman load`, so private caller temp dirs do not block setup. +The manual launcher reads `~/.openclaw/.env` before finalizing container/image defaults, so you can persist these there. + +If you use a non-default `OPENCLAW_CONFIG_DIR` or `OPENCLAW_WORKSPACE_DIR`, set the same variables for both `./scripts/podman/setup.sh` and later `./scripts/run-openclaw-podman.sh launch` commands. The repo-local launcher does not persist custom path overrides across shells. + +Quadlet note: + +- The generated Quadlet service intentionally keeps a fixed, hardened default shape: `127.0.0.1` published ports, `--bind lan` inside the container, and `keep-id` user namespace. +- It still reads `~/.openclaw/.env` for gateway runtime env such as `OPENCLAW_GATEWAY_TOKEN`, but it does not consume the manual launcher's Podman-specific override allowlist. +- If you need custom publish ports, publish host, or other container-run flags, use the manual launcher or edit `~/.config/containers/systemd/openclaw.container` directly, then reload and restart the service. ## Useful commands -- **Logs:** With quadlet: `sudo journalctl --machine openclaw@ --user -u openclaw.service -f`. With script: `sudo -u openclaw podman logs -f openclaw` -- **Stop:** With quadlet: `sudo systemctl --machine openclaw@ --user stop openclaw.service`. With script: `sudo -u openclaw podman stop openclaw` -- **Start again:** With quadlet: `sudo systemctl --machine openclaw@ --user start openclaw.service`. With script: re-run the launch script or `podman start openclaw` -- **Remove container:** `sudo -u openclaw podman rm -f openclaw` — config and workspace on the host are kept +- **Container logs:** `podman logs -f openclaw` +- **Stop container:** `podman stop openclaw` +- **Remove container:** `podman rm -f openclaw` +- **Open dashboard URL from host CLI:** `openclaw dashboard --no-open` +- **Health/status via host CLI:** `openclaw gateway status --deep` ## Troubleshooting -- **Permission denied (EACCES) on config or auth-profiles:** The container defaults to `--userns=keep-id` and runs as the same uid/gid as the host user running the script. Ensure your host `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` are owned by that user. -- **Gateway start blocked (missing `gateway.mode=local`):** Ensure `~openclaw/.openclaw/openclaw.json` exists and sets `gateway.mode="local"`. `scripts/podman/setup.sh` creates this file if missing. -- **Rootless Podman fails for user openclaw:** Check `/etc/subuid` and `/etc/subgid` contain a line for `openclaw` (e.g. `openclaw:100000:65536`). Add it if missing and restart. -- **Container name in use:** The launch script uses `podman run --replace`, so the existing container is replaced when you start again. To clean up manually: `podman rm -f openclaw`. -- **Script not found when running as openclaw:** Ensure `scripts/podman/setup.sh` was run so that `run-openclaw-podman.sh` is copied to openclaw’s home (e.g. `/home/openclaw/run-openclaw-podman.sh`). -- **Quadlet service not found or fails to start:** Run `sudo systemctl --machine openclaw@ --user daemon-reload` after editing the `.container` file. Quadlet requires cgroups v2: `podman info --format '{{.Host.CgroupsVersion}}'` should show `2`. +- **Permission denied (EACCES) on config or workspace:** The container runs with `--userns=keep-id` and `--user :` by default. Ensure the host config/workspace paths are owned by your current user. +- **Gateway start blocked (missing `gateway.mode=local`):** Ensure `~/.openclaw/openclaw.json` exists and sets `gateway.mode="local"`. `scripts/podman/setup.sh` creates this if missing. +- **Container CLI commands hit the wrong target:** Use `openclaw --container ...` explicitly, or export `OPENCLAW_CONTAINER=` in your shell. +- **`openclaw update` fails with `--container`:** Expected. Rebuild/pull the image, then restart the container or the Quadlet service. +- **Quadlet service does not start:** Run `systemctl --user daemon-reload`, then `systemctl --user start openclaw.service`. On headless systems you may also need `sudo loginctl enable-linger "$(whoami)"`. +- **SELinux blocks bind mounts:** Leave the default mount behavior alone; the launcher auto-adds `:Z` on Linux when SELinux is enforcing or permissive. -## Optional: run as your own user +## Related -To run the gateway as your normal user (no dedicated openclaw user): build the image, create `~/.openclaw/.env` with `OPENCLAW_GATEWAY_TOKEN`, and run the container with `--userns=keep-id` and mounts to your `~/.openclaw`. The launch script is designed for the openclaw-user flow; for a single-user setup you can instead run the `podman run` command from the script manually, pointing config and workspace to your home. Recommended for most users: use `scripts/podman/setup.sh` and run as the openclaw user so config and process are isolated. +- [Docker](/install/docker) +- [Gateway background process](/gateway/background-process) +- [Gateway troubleshooting](/gateway/troubleshooting) diff --git a/scripts/podman/openclaw.container.in b/scripts/podman/openclaw.container.in index 1618774b841..e447e6069bb 100644 --- a/scripts/podman/openclaw.container.in +++ b/scripts/podman/openclaw.container.in @@ -1,22 +1,26 @@ # OpenClaw gateway — Podman Quadlet (rootless) -# Installed by scripts/podman/setup.sh into openclaw's ~/.config/containers/systemd/ -# {{OPENCLAW_HOME}} is replaced at install time. +# Installed by scripts/podman/setup.sh into the current user's ~/.config/containers/systemd/ +# {{OPENCLAW_HOME}}, {{OPENCLAW_CONFIG_DIR}}, {{OPENCLAW_WORKSPACE_DIR}}, +# {{IMAGE_NAME}}, and {{CONTAINER_NAME}} are replaced at install time. [Unit] Description=OpenClaw gateway (rootless Podman) [Container] -Image=openclaw:local -ContainerName=openclaw +Image={{IMAGE_NAME}} +ContainerName={{CONTAINER_NAME}} UserNS=keep-id # Keep container UID/GID aligned with the invoking user so mounted config is readable. User=%U:%G -Volume={{OPENCLAW_HOME}}/.openclaw:/home/node/.openclaw:Z -EnvironmentFile={{OPENCLAW_HOME}}/.openclaw/.env +Volume={{OPENCLAW_CONFIG_DIR}}:/home/node/.openclaw:Z +Volume={{OPENCLAW_WORKSPACE_DIR}}:/home/node/.openclaw/workspace:Z +EnvironmentFile={{OPENCLAW_CONFIG_DIR}}/.env Environment=HOME=/home/node Environment=TERM=xterm-256color -PublishPort=18789:18789 -PublishPort=18790:18790 +Environment=NPM_CONFIG_CACHE=/home/node/.openclaw/.npm +Environment=OPENCLAW_NO_RESPAWN=1 +PublishPort=127.0.0.1:18789:18789 +PublishPort=127.0.0.1:18790:18790 Pull=never Exec=node dist/index.js gateway --bind lan --port 18789 diff --git a/scripts/podman/setup.sh b/scripts/podman/setup.sh index 1851271bee4..6f4209d8cc8 100755 --- a/scripts/podman/setup.sh +++ b/scripts/podman/setup.sh @@ -1,24 +1,33 @@ #!/usr/bin/env bash -# One-time host setup for rootless OpenClaw in Podman: creates the openclaw -# user, builds the image, loads it into that user's Podman store, and installs -# the launch script. Run from repo root with sudo capability. +# One-time host setup for rootless OpenClaw in Podman. Uses the current +# non-root user throughout, builds or pulls the image into that user's Podman +# store, writes config under ~/.openclaw by default, and uses the repo-local +# launch script at ./scripts/run-openclaw-podman.sh. # # Usage: ./scripts/podman/setup.sh [--quadlet|--container] -# --quadlet Install systemd Quadlet so the container runs as a user service -# --container Only install user + image + launch script; you start the container manually (default) +# --quadlet Install a Podman Quadlet as the current user's systemd service +# --container Only install image + config; you start the container manually (default) # Or set OPENCLAW_PODMAN_QUADLET=1 (or 0) to choose without a flag. # # After this, start the gateway manually: # ./scripts/run-openclaw-podman.sh launch -# ./scripts/run-openclaw-podman.sh launch setup # onboarding wizard -# Or as the openclaw user: sudo -u openclaw /home/openclaw/run-openclaw-podman.sh -# If you used --quadlet, you can also: sudo systemctl --machine openclaw@ --user start openclaw.service +# ./scripts/run-openclaw-podman.sh launch setup +# Or, if you used --quadlet: +# systemctl --user start openclaw.service set -euo pipefail -OPENCLAW_USER="${OPENCLAW_PODMAN_USER:-openclaw}" REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh" QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in" +OPENCLAW_USER="$(id -un)" +OPENCLAW_HOME="${HOME:-}" +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-}" +OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-}" +OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-${OPENCLAW_IMAGE:-openclaw:local}}" +OPENCLAW_CONTAINER_NAME="${OPENCLAW_PODMAN_CONTAINER:-openclaw}" +PLATFORM_NAME="$(uname -s 2>/dev/null || echo unknown)" +HOST_GATEWAY_PORT="${OPENCLAW_PODMAN_GATEWAY_HOST_PORT:-${OPENCLAW_GATEWAY_PORT:-18789}}" +QUADLET_GATEWAY_PORT="18789" require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then @@ -27,144 +36,134 @@ require_cmd() { fi } -is_writable_dir() { - local dir="$1" - [[ -n "$dir" && -d "$dir" && ! -L "$dir" && -w "$dir" && -x "$dir" ]] -} - -is_safe_tmp_base() { - local dir="$1" - local mode="" - local owner="" - is_writable_dir "$dir" || return 1 - mode="$(stat -Lc '%a' "$dir" 2>/dev/null || true)" - if [[ -n "$mode" ]]; then - local perm=$((8#$mode)) - if (( (perm & 0022) != 0 && (perm & 01000) == 0 )); then - return 1 - fi - fi - if is_root; then - owner="$(stat -Lc '%u' "$dir" 2>/dev/null || true)" - if [[ -n "$owner" && "$owner" != "0" ]]; then - return 1 - fi - fi - return 0 -} - -resolve_image_tmp_dir() { - if ! is_root && is_safe_tmp_base "${TMPDIR:-}"; then - printf '%s' "$TMPDIR" - return 0 - fi - if is_safe_tmp_base "/var/tmp"; then - printf '%s' "/var/tmp" - return 0 - fi - if is_safe_tmp_base "/tmp"; then - printf '%s' "/tmp" - return 0 - fi - printf '%s' "/tmp" -} - is_root() { [[ "$(id -u)" -eq 0 ]]; } -run_root() { - if is_root; then - "$@" - else - sudo "$@" +fail() { + echo "$*" >&2 + exit 1 +} + +validate_single_line_value() { + local label="$1" + local value="$2" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "Invalid $label: control characters are not allowed." fi } -run_as_user() { - # When switching users, the caller's cwd may be inaccessible to the target - # user (e.g. a private home dir). Wrap in a subshell that cd's to a - # world-traversable directory so sudo/runuser don't fail with "cannot chdir". - # TODO: replace with fully rootless podman build to eliminate the need for - # user-switching entirely. - local user="$1" - shift - if command -v sudo >/dev/null 2>&1; then - ( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" ) - elif is_root && command -v runuser >/dev/null 2>&1; then - ( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" ) +validate_absolute_path() { + local label="$1" + local value="$2" + validate_single_line_value "$label" "$value" + [[ "$value" == /* ]] || fail "Invalid $label: expected an absolute path." + [[ "$value" != *"//"* ]] || fail "Invalid $label: repeated slashes are not allowed." + [[ "$value" != *"/./"* && "$value" != */. && "$value" != *"/../"* && "$value" != */.. ]] || + fail "Invalid $label: dot path segments are not allowed." +} + +validate_mount_source_path() { + local label="$1" + local value="$2" + validate_absolute_path "$label" "$value" + [[ "$value" != *:* ]] || fail "Invalid $label: ':' is not allowed in Podman bind-mount source paths." +} + +validate_container_name() { + local value="$1" + validate_single_line_value "container name" "$value" + [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || + fail "Invalid container name: $value" +} + +validate_image_name() { + local value="$1" + validate_single_line_value "image name" "$value" + case "$value" in + oci-archive:*|docker-archive:*|dir:*|oci:*|containers-storage:*|docker-daemon:*|archive:* ) + fail "Invalid image name: transport prefixes are not allowed: $value" + ;; + esac + [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]] || + fail "Invalid image name: $value" +} + +ensure_safe_existing_dir() { + local label="$1" + local dir="$2" + validate_absolute_path "$label" "$dir" + [[ -d "$dir" ]] || fail "Missing $label: $dir" + [[ ! -L "$dir" ]] || fail "Unsafe $label: symlinks are not allowed ($dir)" +} + +stat_uid() { + local path="$1" + if stat -f '%u' "$path" >/dev/null 2>&1; then + stat -f '%u' "$path" else - echo "Need sudo (or root+runuser) to run commands as $user." >&2 - exit 1 + stat -Lc '%u' "$path" fi } -run_as_openclaw() { - # Avoid root writes into $OPENCLAW_HOME (symlink/hardlink/TOCTOU footguns). - # Anything under the target user's home should be created/modified as that user. - run_as_user "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" "$@" +stat_mode() { + local path="$1" + if stat -f '%Lp' "$path" >/dev/null 2>&1; then + stat -f '%Lp' "$path" + else + stat -Lc '%a' "$path" + fi +} + +ensure_private_existing_dir_owned_by_user() { + local label="$1" + local dir="$2" + local uid="" + local mode="" + ensure_safe_existing_dir "$label" "$dir" + uid="$(stat_uid "$dir")" + [[ "$uid" == "$(id -u)" ]] || fail "Unsafe $label: not owned by current user ($dir)" + mode="$(stat_mode "$dir")" + (( (8#$mode & 0022) == 0 )) || fail "Unsafe $label: group/other writable ($dir)" +} + +ensure_safe_write_file_path() { + local label="$1" + local file="$2" + local dir + validate_absolute_path "$label" "$file" + if [[ -e "$file" ]]; then + [[ ! -L "$file" ]] || fail "Unsafe $label: symlinks are not allowed ($file)" + [[ -f "$file" ]] || fail "Unsafe $label: expected a regular file ($file)" + fi + dir="$(dirname "$file")" + ensure_safe_existing_dir "${label} parent directory" "$dir" +} + +write_file_atomically() { + local file="$1" + local mode="$2" + local dir="" + local tmp="" + ensure_safe_write_file_path "output file" "$file" + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.tmp.XXXXXX")" + cat >"$tmp" + chmod "$mode" "$tmp" + mv -f "$tmp" "$file" +} + +validate_port() { + local label="$1" + local value="$2" + local numeric="" + [[ "$value" =~ ^[0-9]{1,5}$ ]] || fail "Invalid $label: must be numeric." + numeric=$((10#$value)) + (( numeric >= 1 && numeric <= 65535 )) || fail "Invalid $label: out of range." } escape_sed_replacement_pipe_delim() { - # Escape replacement metacharacters for sed "s|...|...|g" replacement text. printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' } -# Quadlet: opt-in via --quadlet or OPENCLAW_PODMAN_QUADLET=1 -INSTALL_QUADLET=false -for arg in "$@"; do - case "$arg" in - --quadlet) INSTALL_QUADLET=true ;; - --container) INSTALL_QUADLET=false ;; - esac -done -if [[ -n "${OPENCLAW_PODMAN_QUADLET:-}" ]]; then - case "${OPENCLAW_PODMAN_QUADLET,,}" in - 1|yes|true) INSTALL_QUADLET=true ;; - 0|no|false) INSTALL_QUADLET=false ;; - esac -fi - -require_cmd podman -if ! is_root; then - require_cmd sudo -fi -if [[ ! -f "$REPO_PATH/Dockerfile" ]]; then - echo "Dockerfile not found at $REPO_PATH. Set OPENCLAW_REPO_PATH to the repo root." >&2 - exit 1 -fi -if [[ ! -f "$RUN_SCRIPT_SRC" ]]; then - echo "Launch script not found at $RUN_SCRIPT_SRC." >&2 - exit 1 -fi - -generate_token_hex_32() { - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex 32 - return 0 - fi - if command -v python3 >/dev/null 2>&1; then - python3 - <<'PY' -import secrets -print(secrets.token_hex(32)) -PY - return 0 - fi - if command -v od >/dev/null 2>&1; then - # 32 random bytes -> 64 lowercase hex chars - od -An -N32 -tx1 /dev/urandom | tr -d " \n" - return 0 - fi - echo "Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2 - exit 1 -} - -user_exists() { - local user="$1" - if command -v getent >/dev/null 2>&1; then - getent passwd "$user" >/dev/null 2>&1 && return 0 - fi - id -u "$user" >/dev/null 2>&1 -} - resolve_user_home() { local user="$1" local home="" @@ -180,81 +179,184 @@ resolve_user_home() { printf '%s' "$home" } -resolve_nologin_shell() { - for cand in /usr/sbin/nologin /sbin/nologin /usr/bin/nologin /bin/false; do - if [[ -x "$cand" ]]; then - printf '%s' "$cand" - return 0 - fi - done - printf '%s' "/usr/sbin/nologin" +generate_token_hex_32() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY + return 0 + fi + if command -v od >/dev/null 2>&1; then + od -An -N32 -tx1 /dev/urandom | tr -d " \n" + return 0 + fi + echo "Missing dependency: need openssl or python3 (or od) to generate OPENCLAW_GATEWAY_TOKEN." >&2 + exit 1 } -# Create openclaw user (non-login, with home) if missing -if ! user_exists "$OPENCLAW_USER"; then - NOLOGIN_SHELL="$(resolve_nologin_shell)" - echo "Creating user $OPENCLAW_USER ($NOLOGIN_SHELL, with home)..." - if command -v useradd >/dev/null 2>&1; then - run_root useradd -m -s "$NOLOGIN_SHELL" "$OPENCLAW_USER" - elif command -v adduser >/dev/null 2>&1; then - # Debian/Ubuntu: adduser supports --disabled-password/--gecos. Busybox adduser differs. - run_root adduser --disabled-password --gecos "" --shell "$NOLOGIN_SHELL" "$OPENCLAW_USER" - else - echo "Neither useradd nor adduser found, cannot create user $OPENCLAW_USER." >&2 - exit 1 - fi -else - echo "User $OPENCLAW_USER already exists." -fi - -OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" -OPENCLAW_UID="$(id -u "$OPENCLAW_USER" 2>/dev/null || true)" -OPENCLAW_CONFIG="$OPENCLAW_HOME/.openclaw" -LAUNCH_SCRIPT_DST="$OPENCLAW_HOME/run-openclaw-podman.sh" - -# Prefer systemd user services (Quadlet) for production. Enable lingering early so rootless Podman can run -# without an interactive login. -if command -v loginctl &>/dev/null; then - run_root loginctl enable-linger "$OPENCLAW_USER" 2>/dev/null || true -fi -if [[ -n "${OPENCLAW_UID:-}" && -d /run/user ]] && command -v systemctl &>/dev/null; then - if [[ ! -d "/run/user/$OPENCLAW_UID" ]]; then - run_root install -d -m 700 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "/run/user/$OPENCLAW_UID" || true - fi - run_root mkdir -p "/run/user/$OPENCLAW_UID/containers" || true - run_root chown "$OPENCLAW_UID:$OPENCLAW_UID" "/run/user/$OPENCLAW_UID/containers" || true - run_root chmod 700 "/run/user/$OPENCLAW_UID/containers" || true -fi - -mkdir_user_dirs_as_openclaw() { - run_root install -d -m 700 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "$OPENCLAW_HOME" "$OPENCLAW_CONFIG" - run_root install -d -m 700 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "$OPENCLAW_CONFIG/workspace" -} - -ensure_subid_entry() { +seed_local_control_ui_origins() { local file="$1" - if [[ ! -f "$file" ]]; then - return 1 + local port="$2" + local dir="" + local tmp="" + ensure_safe_write_file_path "config file" "$file" + if ! command -v python3 >/dev/null 2>&1; then + echo "Warning: python3 not found; unable to seed gateway.controlUi.allowedOrigins in $file." >&2 + return 0 fi - grep -q "^${OPENCLAW_USER}:" "$file" 2>/dev/null + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.config.tmp.XXXXXX")" + if ! python3 - "$file" "$port" "$tmp" <<'PY' +import json +import sys + +path = sys.argv[1] +port = sys.argv[2] +tmp = sys.argv[3] +try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) +except json.JSONDecodeError as exc: + print( + f"Warning: unable to seed gateway.controlUi.allowedOrigins in {path}: existing config is not strict JSON ({exc}). Leaving file unchanged.", + file=sys.stderr, + ) + raise SystemExit(1) +if not isinstance(data, dict): + raise SystemExit(f"{path}: expected top-level object") +gateway = data.setdefault("gateway", {}) +if not isinstance(gateway, dict): + raise SystemExit(f"{path}: expected gateway object") +gateway.setdefault("mode", "local") +control_ui = gateway.setdefault("controlUi", {}) +if not isinstance(control_ui, dict): + raise SystemExit(f"{path}: expected gateway.controlUi object") +allowed = control_ui.get("allowedOrigins") +managed_localhosts = {"127.0.0.1", "localhost"} +desired = [ + f"http://127.0.0.1:{port}", + f"http://localhost:{port}", +] +if not isinstance(allowed, list): + allowed = [] +cleaned = [] +for origin in allowed: + if not isinstance(origin, str): + continue + normalized = origin.strip() + if not normalized: + continue + if normalized.startswith("http://"): + host_port = normalized[len("http://") :] + host = host_port.split(":", 1)[0] + if host in managed_localhosts: + continue + cleaned.append(normalized) +control_ui["allowedOrigins"] = cleaned + desired +with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") +PY + then + rm -f "$tmp" + return 0 + fi + [[ -s "$tmp" ]] || { + rm -f "$tmp" + return 0 + } + chmod 600 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$file" } -if ! ensure_subid_entry /etc/subuid || ! ensure_subid_entry /etc/subgid; then - echo "WARNING: ${OPENCLAW_USER} may not have subuid/subgid ranges configured." >&2 - echo "If rootless Podman fails, add 'openclaw:100000:65536' to both /etc/subuid and /etc/subgid." >&2 +upsert_env_var() { + local file="$1" + local key="$2" + local value="$3" + local tmp + local dir + ensure_safe_write_file_path "env file" "$file" + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.env.tmp.XXXXXX")" + if [[ -f "$file" ]]; then + awk -v k="$key" -v v="$value" ' + BEGIN { found = 0 } + $0 ~ ("^" k "=") { print k "=" v; found = 1; next } + { print } + END { if (!found) print k "=" v } + ' "$file" >"$tmp" + else + printf '%s=%s\n' "$key" "$value" >"$tmp" + fi + mv "$tmp" "$file" + chmod 600 "$file" 2>/dev/null || true +} + +INSTALL_QUADLET=false +for arg in "$@"; do + case "$arg" in + --quadlet) INSTALL_QUADLET=true ;; + --container) INSTALL_QUADLET=false ;; + esac +done +if [[ -n "${OPENCLAW_PODMAN_QUADLET:-}" ]]; then + case "${OPENCLAW_PODMAN_QUADLET,,}" in + 1|yes|true) INSTALL_QUADLET=true ;; + 0|no|false) INSTALL_QUADLET=false ;; + esac +fi +if [[ "$INSTALL_QUADLET" == true && "$PLATFORM_NAME" != "Linux" ]]; then + fail "--quadlet is only supported on Linux with systemd user services." fi -mkdir_user_dirs_as_openclaw +SEED_GATEWAY_PORT="$HOST_GATEWAY_PORT" +if [[ "$INSTALL_QUADLET" == true ]]; then + SEED_GATEWAY_PORT="$QUADLET_GATEWAY_PORT" +fi -IMAGE_TMP_BASE="$(resolve_image_tmp_dir)" -echo "Using temp base for image export: $IMAGE_TMP_BASE" -IMAGE_TAR_DIR="$(mktemp -d "${IMAGE_TMP_BASE%/}/openclaw-podman-image.XXXXXX")" -chmod 700 "$IMAGE_TAR_DIR" -IMAGE_TAR="$IMAGE_TAR_DIR/openclaw-image.tar" -cleanup_image_tar() { - rm -rf "$IMAGE_TAR_DIR" -} -trap cleanup_image_tar EXIT +require_cmd podman +if is_root; then + echo "Run scripts/podman/setup.sh as your normal user so Podman stays rootless." >&2 + exit 1 +fi +if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]] && [[ ! -f "$REPO_PATH/Dockerfile" ]]; then + echo "Dockerfile not found at $REPO_PATH. Set OPENCLAW_REPO_PATH to the repo root." >&2 + exit 1 +fi +if [[ ! -f "$RUN_SCRIPT_SRC" ]]; then + echo "Launch script not found at $RUN_SCRIPT_SRC." >&2 + exit 1 +fi + +if [[ -z "$OPENCLAW_HOME" ]]; then + OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" +fi +if [[ -z "$OPENCLAW_HOME" ]]; then + echo "Unable to resolve HOME for user $OPENCLAW_USER." >&2 + exit 1 +fi +if [[ -z "$OPENCLAW_CONFIG_DIR" ]]; then + OPENCLAW_CONFIG_DIR="$OPENCLAW_HOME/.openclaw" +fi +if [[ -z "$OPENCLAW_WORKSPACE_DIR" ]]; then + OPENCLAW_WORKSPACE_DIR="$OPENCLAW_CONFIG_DIR/workspace" +fi +validate_absolute_path "home directory" "$OPENCLAW_HOME" +validate_mount_source_path "config directory" "$OPENCLAW_CONFIG_DIR" +validate_mount_source_path "workspace directory" "$OPENCLAW_WORKSPACE_DIR" +validate_container_name "$OPENCLAW_CONTAINER_NAME" +validate_image_name "$OPENCLAW_IMAGE" +validate_port "gateway host port" "$HOST_GATEWAY_PORT" +validate_port "seed gateway port" "$SEED_GATEWAY_PORT" + +install -d -m 700 "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_WORKSPACE_DIR" +ensure_private_existing_dir_owned_by_user "config directory" "$OPENCLAW_CONFIG_DIR" +ensure_private_existing_dir_owned_by_user "workspace directory" "$OPENCLAW_WORKSPACE_DIR" BUILD_ARGS=() if [[ -n "${OPENCLAW_DOCKER_APT_PACKAGES:-}" ]]; then @@ -264,49 +366,93 @@ if [[ -n "${OPENCLAW_EXTENSIONS:-}" ]]; then BUILD_ARGS+=(--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}") fi -echo "Building image openclaw:local..." -podman build -t openclaw:local -f "$REPO_PATH/Dockerfile" "${BUILD_ARGS[@]}" "$REPO_PATH" -echo "Saving image to $IMAGE_TAR ..." -podman save -o "$IMAGE_TAR" openclaw:local +if [[ "$OPENCLAW_IMAGE" == "openclaw:local" ]]; then + echo "Building image $OPENCLAW_IMAGE ..." + podman build -t "$OPENCLAW_IMAGE" -f "$REPO_PATH/Dockerfile" "${BUILD_ARGS[@]+"${BUILD_ARGS[@]}"}" "$REPO_PATH" +else + if podman image exists "$OPENCLAW_IMAGE" >/dev/null 2>&1; then + echo "Using existing image $OPENCLAW_IMAGE" + else + echo "Pulling image $OPENCLAW_IMAGE ..." + podman pull "$OPENCLAW_IMAGE" + fi +fi -echo "Loading image into $OPENCLAW_USER Podman store..." -run_as_openclaw podman load -i "$IMAGE_TAR" - -echo "Installing launch script to $LAUNCH_SCRIPT_DST ..." -run_root install -m 0755 -o "$OPENCLAW_UID" -g "$OPENCLAW_UID" "$RUN_SCRIPT_SRC" "$LAUNCH_SCRIPT_DST" - -if [[ ! -f "$OPENCLAW_CONFIG/.env" ]]; then +ENV_FILE="$OPENCLAW_CONFIG_DIR/.env" +if [[ ! -f "$ENV_FILE" ]]; then TOKEN="$(generate_token_hex_32)" - run_as_openclaw sh -lc "umask 077 && printf '%s\n' 'OPENCLAW_GATEWAY_TOKEN=$TOKEN' > '$OPENCLAW_CONFIG/.env'" - echo "Generated OPENCLAW_GATEWAY_TOKEN and wrote it to $OPENCLAW_CONFIG/.env" + ( + umask 077 + write_file_atomically "$ENV_FILE" 600 < '$OPENCLAW_CONFIG/openclaw.json' <<'JSON' -{ \"gateway\": { \"mode\": \"local\" } } -JSON" - echo "Wrote minimal config to $OPENCLAW_CONFIG/openclaw.json" +CONFIG_JSON="$OPENCLAW_CONFIG_DIR/openclaw.json" +if [[ ! -f "$CONFIG_JSON" ]]; then + ( + umask 077 + write_file_atomically "$CONFIG_JSON" 600 < '$QUADLET_DST'" - run_as_openclaw chmod 0644 "$QUADLET_DST" + OPENCLAW_CONFIG_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_CONFIG_DIR")" + OPENCLAW_WORKSPACE_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_WORKSPACE_DIR")" + OPENCLAW_IMAGE_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_IMAGE")" + OPENCLAW_CONTAINER_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_CONTAINER_NAME")" + sed \ + -e "s|{{OPENCLAW_HOME}}|$OPENCLAW_HOME_ESCAPED|g" \ + -e "s|{{OPENCLAW_CONFIG_DIR}}|$OPENCLAW_CONFIG_ESCAPED|g" \ + -e "s|{{OPENCLAW_WORKSPACE_DIR}}|$OPENCLAW_WORKSPACE_ESCAPED|g" \ + -e "s|{{IMAGE_NAME}}|$OPENCLAW_IMAGE_ESCAPED|g" \ + -e "s|{{CONTAINER_NAME}}|$OPENCLAW_CONTAINER_ESCAPED|g" \ + "$QUADLET_TEMPLATE" | write_file_atomically "$QUADLET_DST" 644 - echo "Reloading and enabling user service..." - run_root systemctl --machine "${OPENCLAW_USER}@" --user daemon-reload - run_root systemctl --machine "${OPENCLAW_USER}@" --user enable --now openclaw.service - echo "Quadlet installed and service started." + if command -v systemctl >/dev/null 2>&1; then + echo "Reloading and starting user service..." + if systemctl --user daemon-reload && systemctl --user start openclaw.service; then + echo "Quadlet installed and service started." + else + echo "Quadlet installed, but automatic start failed." >&2 + echo "Try: systemctl --user daemon-reload && systemctl --user start openclaw.service" >&2 + if command -v loginctl >/dev/null 2>&1; then + echo "For boot persistence on headless hosts, you may also need: sudo loginctl enable-linger $(whoami)" >&2 + fi + fi + else + echo "systemctl not found; Quadlet installed but not started." >&2 + fi else - echo "Container + launch script installed." + echo "Container setup complete." fi echo echo "Next:" echo " ./scripts/run-openclaw-podman.sh launch" echo " ./scripts/run-openclaw-podman.sh launch setup" +echo " openclaw --container $OPENCLAW_CONTAINER_NAME dashboard --no-open" diff --git a/scripts/run-openclaw-podman.sh b/scripts/run-openclaw-podman.sh index aa19d3350bf..7ff6ad99e5d 100755 --- a/scripts/run-openclaw-podman.sh +++ b/scripts/run-openclaw-podman.sh @@ -3,18 +3,18 @@ # # One-time setup (from repo root): ./scripts/podman/setup.sh # Then: -# ./scripts/run-openclaw-podman.sh launch # Start gateway -# ./scripts/run-openclaw-podman.sh launch setup # Onboarding wizard +# ./scripts/run-openclaw-podman.sh launch # Start gateway +# ./scripts/run-openclaw-podman.sh launch setup # Onboarding wizard # -# As the openclaw user (no repo needed): -# sudo -u openclaw /home/openclaw/run-openclaw-podman.sh -# sudo -u openclaw /home/openclaw/run-openclaw-podman.sh setup +# Manage the running container from the host CLI: +# openclaw --container openclaw dashboard --no-open +# openclaw --container openclaw channels login # # Legacy: "setup-host" delegates to the Podman setup script set -euo pipefail -OPENCLAW_USER="${OPENCLAW_PODMAN_USER:-openclaw}" +PLATFORM_NAME="$(uname -s 2>/dev/null || echo unknown)" resolve_user_home() { local user="$1" @@ -31,11 +31,176 @@ resolve_user_home() { printf '%s' "$home" } -OPENCLAW_HOME="$(resolve_user_home "$OPENCLAW_USER")" -OPENCLAW_UID="$(id -u "$OPENCLAW_USER" 2>/dev/null || true)" -LAUNCH_SCRIPT="$OPENCLAW_HOME/run-openclaw-podman.sh" +fail() { + echo "$*" >&2 + exit 1 +} -# Legacy: setup-host → run the Podman setup script +validate_single_line_value() { + local label="$1" + local value="$2" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "Invalid $label: control characters are not allowed." + fi +} + +validate_absolute_path() { + local label="$1" + local value="$2" + validate_single_line_value "$label" "$value" + [[ "$value" == /* ]] || fail "Invalid $label: expected an absolute path." + [[ "$value" != *"//"* ]] || fail "Invalid $label: repeated slashes are not allowed." + [[ "$value" != *"/./"* && "$value" != */. && "$value" != *"/../"* && "$value" != */.. ]] || + fail "Invalid $label: dot path segments are not allowed." +} + +validate_mount_source_path() { + local label="$1" + local value="$2" + validate_absolute_path "$label" "$value" + [[ "$value" != *:* ]] || fail "Invalid $label: ':' is not allowed in Podman bind-mount source paths." +} + +ensure_safe_existing_regular_file() { + local label="$1" + local file="$2" + validate_absolute_path "$label" "$file" + [[ -e "$file" ]] || fail "Missing $label: $file" + [[ ! -L "$file" ]] || fail "Unsafe $label: symlinks are not allowed ($file)" + [[ -f "$file" ]] || fail "Unsafe $label: expected a regular file ($file)" +} + +ensure_safe_existing_dir() { + local label="$1" + local dir="$2" + validate_absolute_path "$label" "$dir" + [[ -d "$dir" ]] || fail "Missing $label: $dir" + [[ ! -L "$dir" ]] || fail "Unsafe $label: symlinks are not allowed ($dir)" +} + +stat_uid() { + local path="$1" + if stat -f '%u' "$path" >/dev/null 2>&1; then + stat -f '%u' "$path" + else + stat -Lc '%u' "$path" + fi +} + +stat_mode() { + local path="$1" + if stat -f '%Lp' "$path" >/dev/null 2>&1; then + stat -f '%Lp' "$path" + else + stat -Lc '%a' "$path" + fi +} + +ensure_private_existing_dir_owned_by_user() { + local label="$1" + local dir="$2" + local uid="" + local mode="" + ensure_safe_existing_dir "$label" "$dir" + uid="$(stat_uid "$dir")" + [[ "$uid" == "$(id -u)" ]] || fail "Unsafe $label: not owned by current user ($dir)" + mode="$(stat_mode "$dir")" + (( (8#$mode & 0022) == 0 )) || fail "Unsafe $label: group/other writable ($dir)" +} + +ensure_private_existing_regular_file_owned_by_user() { + local label="$1" + local file="$2" + local uid="" + local mode="" + ensure_safe_existing_regular_file "$label" "$file" + uid="$(stat_uid "$file")" + [[ "$uid" == "$(id -u)" ]] || fail "Unsafe $label: not owned by current user ($file)" + mode="$(stat_mode "$file")" + (( (8#$mode & 0077) == 0 )) || fail "Unsafe $label: expected owner-only permissions ($file)" +} + +ensure_safe_write_file_path() { + local label="$1" + local file="$2" + local dir + validate_absolute_path "$label" "$file" + if [[ -e "$file" ]]; then + [[ ! -L "$file" ]] || fail "Unsafe $label: symlinks are not allowed ($file)" + [[ -f "$file" ]] || fail "Unsafe $label: expected a regular file ($file)" + fi + dir="$(dirname "$file")" + ensure_safe_existing_dir "${label} parent directory" "$dir" +} + +write_file_atomically() { + local file="$1" + local mode="$2" + local dir="" + local tmp="" + ensure_safe_write_file_path "output file" "$file" + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.tmp.XXXXXX")" + cat >"$tmp" + chmod "$mode" "$tmp" + mv -f "$tmp" "$file" +} + +load_podman_env_file() { + local file="$1" + local line="" + local key="" + local value="" + local trimmed="" + local dir="" + ensure_private_existing_regular_file_owned_by_user "Podman env file" "$file" + dir="$(dirname "$file")" + ensure_private_existing_dir_owned_by_user "Podman env directory" "$dir" + exec 9<"$file" || fail "Unable to open Podman env file: $file" + while IFS= read -r line <&9 || [[ -n "$line" ]]; do + trimmed="${line#"${line%%[![:space:]]*}"}" + [[ -z "$trimmed" || "${trimmed:0:1}" == "#" ]] && continue + [[ "$line" == *"="* ]] || continue + key="${line%%=*}" + value="${line#*=}" + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + case "$key" in + OPENCLAW_GATEWAY_TOKEN|OPENCLAW_PODMAN_CONTAINER|OPENCLAW_PODMAN_IMAGE|OPENCLAW_IMAGE|OPENCLAW_PODMAN_PULL|OPENCLAW_PODMAN_GATEWAY_HOST_PORT|OPENCLAW_GATEWAY_PORT|OPENCLAW_PODMAN_BRIDGE_HOST_PORT|OPENCLAW_BRIDGE_PORT|OPENCLAW_GATEWAY_BIND|OPENCLAW_PODMAN_USERNS|OPENCLAW_BIND_MOUNT_OPTIONS|OPENCLAW_PODMAN_PUBLISH_HOST) + ;; + *) + continue + ;; + esac + if [[ "$value" =~ ^\".*\"$ || "$value" =~ ^\'.*\'$ ]]; then + value="${value:1:${#value}-2}" + fi + printf -v "$key" '%s' "$value" + export "$key" + done + exec 9<&- +} + +validate_port() { + local label="$1" + local value="$2" + local numeric="" + [[ "$value" =~ ^[0-9]{1,5}$ ]] || fail "Invalid $label: must be numeric." + numeric=$((10#$value)) + (( numeric >= 1 && numeric <= 65535 )) || fail "Invalid $label: out of range." +} + +EFFECTIVE_USER="$(id -un)" +EFFECTIVE_HOME="${HOME:-}" +if [[ -z "$EFFECTIVE_HOME" ]]; then + EFFECTIVE_HOME="$(resolve_user_home "$EFFECTIVE_USER")" +fi +if [[ "$(id -u)" -eq 0 ]]; then + fail "Run run-openclaw-podman.sh as your normal user so Podman stays rootless." +fi + +# Legacy: setup-host -> run the Podman setup script if [[ "${1:-}" == "setup-host" ]]; then shift REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -51,36 +216,42 @@ if [[ "${1:-}" == "setup-host" ]]; then exit 1 fi -# --- Step 2: launch (from repo: re-exec as openclaw in safe cwd; from openclaw home: run container) --- if [[ "${1:-}" == "launch" ]]; then shift - if [[ -n "${OPENCLAW_UID:-}" && "$(id -u)" -ne "$OPENCLAW_UID" ]]; then - # Exec as openclaw with cwd=/tmp so a nologin user never inherits an invalid cwd. - exec sudo -u "$OPENCLAW_USER" env HOME="$OPENCLAW_HOME" PATH="$PATH" TERM="${TERM:-}" \ - bash -c 'cd /tmp && exec '"$LAUNCH_SCRIPT"' "$@"' _ "$@" - fi - # Already openclaw; fall through to container run (with remaining args, e.g. "setup") fi -# --- Container run (script in openclaw home, run as openclaw) --- -EFFECTIVE_HOME="${HOME:-}" -if [[ -n "${OPENCLAW_UID:-}" && "$(id -u)" -eq "$OPENCLAW_UID" ]]; then - EFFECTIVE_HOME="$OPENCLAW_HOME" - export HOME="$OPENCLAW_HOME" -fi if [[ -z "${EFFECTIVE_HOME:-}" ]]; then - EFFECTIVE_HOME="${OPENCLAW_HOME:-/tmp}" + EFFECTIVE_HOME="/tmp" fi +validate_absolute_path "effective home" "$EFFECTIVE_HOME" + +CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$EFFECTIVE_HOME/.openclaw}" +ENV_FILE="${OPENCLAW_PODMAN_ENV:-$CONFIG_DIR/.env}" +# Bootstrap `.env` may set runtime/container options, but it must not +# relocate the config/workspace/env paths mid-run. Those path overrides are +# only honored from the parent process environment before bootstrap. +if [[ -f "$ENV_FILE" ]]; then + load_podman_env_file "$ENV_FILE" +fi + CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$EFFECTIVE_HOME/.openclaw}" ENV_FILE="${OPENCLAW_PODMAN_ENV:-$CONFIG_DIR/.env}" WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$CONFIG_DIR/workspace}" CONTAINER_NAME="${OPENCLAW_PODMAN_CONTAINER:-openclaw}" -OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-openclaw:local}" +OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-${OPENCLAW_IMAGE:-openclaw:local}}" PODMAN_PULL="${OPENCLAW_PODMAN_PULL:-never}" HOST_GATEWAY_PORT="${OPENCLAW_PODMAN_GATEWAY_HOST_PORT:-${OPENCLAW_GATEWAY_PORT:-18789}}" HOST_BRIDGE_PORT="${OPENCLAW_PODMAN_BRIDGE_HOST_PORT:-${OPENCLAW_BRIDGE_PORT:-18790}}" +PUBLISH_HOST="${OPENCLAW_PODMAN_PUBLISH_HOST:-127.0.0.1}" +validate_mount_source_path "config directory" "$CONFIG_DIR" +validate_mount_source_path "workspace directory" "$WORKSPACE_DIR" +validate_absolute_path "env file path" "$ENV_FILE" +validate_single_line_value "container name" "$CONTAINER_NAME" +validate_single_line_value "image name" "$OPENCLAW_IMAGE" +validate_single_line_value "publish host" "$PUBLISH_HOST" +validate_port "gateway host port" "$HOST_GATEWAY_PORT" +validate_port "bridge host port" "$HOST_BRIDGE_PORT" -# Safe cwd for podman (openclaw is nologin; avoid inherited cwd from sudo) cd "$EFFECTIVE_HOME" 2>/dev/null || cd /tmp 2>/dev/null || true RUN_SETUP=false @@ -90,28 +261,24 @@ if [[ "${1:-}" == "setup" || "${1:-}" == "onboard" ]]; then fi mkdir -p "$CONFIG_DIR" "$WORKSPACE_DIR" -# Subdirs the app may create at runtime (canvas, cron); create here so ownership is correct mkdir -p "$CONFIG_DIR/canvas" "$CONFIG_DIR/cron" -chmod 700 "$CONFIG_DIR" "$WORKSPACE_DIR" 2>/dev/null || true +chmod 700 "$CONFIG_DIR" "$WORKSPACE_DIR" +ensure_private_existing_dir_owned_by_user "config directory" "$CONFIG_DIR" +ensure_private_existing_dir_owned_by_user "workspace directory" "$WORKSPACE_DIR" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck source=/dev/null - source "$ENV_FILE" 2>/dev/null || true - set +a -fi - -# Keep Podman default local-only unless explicitly overridden. -# Non-loopback binds require gateway.controlUi.allowedOrigins (security hardening). -# NOTE: must be evaluated after sourcing ENV_FILE so OPENCLAW_GATEWAY_BIND set in .env takes effect. -GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-loopback}" +# For published container ports, the gateway must listen on the container +# interface. Keep host access local-only by default via 127.0.0.1 publish. +GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" upsert_env_var() { local file="$1" local key="$2" local value="$3" local tmp - tmp="$(mktemp)" + local dir + ensure_safe_write_file_path "env file" "$file" + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.env.tmp.XXXXXX")" if [[ -f "$file" ]]; then awk -v k="$key" -v v="$value" ' BEGIN { found = 0 } @@ -146,21 +313,180 @@ PY exit 1 } +create_token_env_file() { + local file="$1" + local token="$2" + local dir="" + local tmp="" + dir="$(dirname "$file")" + ensure_private_existing_dir_owned_by_user "token env directory" "$dir" + tmp="$(mktemp "$dir/.token.env.XXXXXX")" + chmod 600 "$tmp" + printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "$token" >"$tmp" + printf '%s' "$tmp" +} + +sync_local_control_ui_origins_via_cli() { + local file="$1" + local port="$2" + local config_dir="" + local allowed_json="" + local merged_json="" + config_dir="$(dirname "$file")" + if ! command -v openclaw >/dev/null 2>&1; then + echo "Warning: openclaw not found; unable to sync gateway.controlUi.allowedOrigins in $file." >&2 + return 0 + fi + if ! command -v python3 >/dev/null 2>&1; then + OPENCLAW_CONTAINER="" OPENCLAW_CONFIG_DIR="$config_dir" \ + openclaw config set gateway.controlUi.allowedOrigins \ + "[\"http://127.0.0.1:${port}\",\"http://localhost:${port}\"]" \ + --strict-json >/dev/null + return 0 + fi + allowed_json="$( + OPENCLAW_CONTAINER="" OPENCLAW_CONFIG_DIR="$config_dir" \ + openclaw config get gateway.controlUi.allowedOrigins --json 2>/dev/null || true + )" + merged_json="$(python3 - "$port" "$allowed_json" <<'PY' +import json +import sys + +port = sys.argv[1] +raw = sys.argv[2] if len(sys.argv) > 2 else "" +desired = [ + f"http://127.0.0.1:{port}", + f"http://localhost:{port}", +] +allowed = [] +if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + allowed = parsed + except json.JSONDecodeError: + allowed = [] +cleaned = [] +seen = set() +for origin in allowed + desired: + if not isinstance(origin, str): + continue + normalized = origin.strip() + if not normalized or normalized in seen: + continue + cleaned.append(normalized) + seen.add(normalized) +print(json.dumps(cleaned)) +PY + )" + OPENCLAW_CONTAINER="" OPENCLAW_CONFIG_DIR="$config_dir" \ + openclaw config set gateway.controlUi.allowedOrigins "$merged_json" --strict-json >/dev/null +} + +sync_local_control_ui_origins() { + local file="$1" + local port="$2" + local dir="" + local tmp="" + ensure_safe_write_file_path "config file" "$file" + if ! command -v python3 >/dev/null 2>&1; then + echo "Warning: python3 not found; unable to sync gateway.controlUi.allowedOrigins in $file." >&2 + return 0 + fi + dir="$(dirname "$file")" + ensure_private_existing_dir_owned_by_user "config file directory" "$dir" + tmp="$(mktemp "$dir/.config.tmp.XXXXXX")" + if ! python3 - "$file" "$port" "$tmp" <<'PY' +import json +import sys + +path = sys.argv[1] +port = sys.argv[2] +tmp = sys.argv[3] +try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) +except json.JSONDecodeError as exc: + print( + f"Warning: unable to sync gateway.controlUi.allowedOrigins in {path}: existing config is not strict JSON ({exc}). Leaving file unchanged.", + file=sys.stderr, + ) + raise SystemExit(1) +if not isinstance(data, dict): + raise SystemExit(f"{path}: expected top-level object") +gateway = data.setdefault("gateway", {}) +if not isinstance(gateway, dict): + raise SystemExit(f"{path}: expected gateway object") +gateway.setdefault("mode", "local") +control_ui = gateway.setdefault("controlUi", {}) +if not isinstance(control_ui, dict): + raise SystemExit(f"{path}: expected gateway.controlUi object") +allowed = control_ui.get("allowedOrigins") +desired = [ + f"http://127.0.0.1:{port}", + f"http://localhost:{port}", +] +if not isinstance(allowed, list): + allowed = [] +cleaned = [] +seen = set() +for origin in allowed: + if not isinstance(origin, str): + continue + normalized = origin.strip() + if not normalized or normalized in seen: + continue + cleaned.append(normalized) + seen.add(normalized) +for origin in desired: + if origin not in seen: + cleaned.append(origin) + seen.add(origin) +control_ui["allowedOrigins"] = cleaned +with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") +PY + then + rm -f "$tmp" + sync_local_control_ui_origins_via_cli "$file" "$port" + return 0 + fi + [[ -s "$tmp" ]] || { + rm -f "$tmp" + return 0 + } + chmod 600 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$file" +} + +TOKEN_ENV_FILE="" +cleanup_token_env_file() { + if [[ -n "$TOKEN_ENV_FILE" && -f "$TOKEN_ENV_FILE" ]]; then + rm -f "$TOKEN_ENV_FILE" + fi +} +trap cleanup_token_env_file EXIT + if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then export OPENCLAW_GATEWAY_TOKEN="$(generate_token_hex_32)" mkdir -p "$(dirname "$ENV_FILE")" + ensure_safe_existing_dir "env file directory" "$(dirname "$ENV_FILE")" upsert_env_var "$ENV_FILE" "OPENCLAW_GATEWAY_TOKEN" "$OPENCLAW_GATEWAY_TOKEN" echo "Generated OPENCLAW_GATEWAY_TOKEN and wrote it to $ENV_FILE." >&2 fi -# The gateway refuses to start unless gateway.mode=local is set in config. -# Keep this minimal; users can run the wizard later to configure channels/providers. CONFIG_JSON="$CONFIG_DIR/openclaw.json" if [[ ! -f "$CONFIG_JSON" ]]; then - echo '{ gateway: { mode: "local" } }' >"$CONFIG_JSON" - chmod 600 "$CONFIG_JSON" 2>/dev/null || true + ( + umask 077 + write_file_atomically "$CONFIG_JSON" 600 <<'JSON' +{ "gateway": { "mode": "local" } } +JSON + ) echo "Created $CONFIG_JSON (minimal gateway.mode=local)." >&2 fi +sync_local_control_ui_origins "$CONFIG_JSON" "$HOST_GATEWAY_PORT" PODMAN_USERNS="${OPENCLAW_PODMAN_USERNS:-keep-id}" USERNS_ARGS=() @@ -184,11 +510,6 @@ else echo "Starting container without --user (OPENCLAW_PODMAN_USERNS=$PODMAN_USERNS), mounts may require ownership fixes." >&2 fi -ENV_FILE_ARGS=() -[[ -f "$ENV_FILE" ]] && ENV_FILE_ARGS+=(--env-file "$ENV_FILE") - -# On Linux with SELinux enforcing/permissive, add ,Z so Podman relabels the -# bind-mounted directories and the container can access them. SELINUX_MOUNT_OPTS="" if [[ -z "${OPENCLAW_BIND_MOUNT_OPTIONS:-}" ]]; then if [[ "$(uname -s 2>/dev/null)" == "Linux" ]] && command -v getenforce >/dev/null 2>&1; then @@ -198,38 +519,56 @@ if [[ -z "${OPENCLAW_BIND_MOUNT_OPTIONS:-}" ]]; then fi fi else - # Honour explicit override (e.g. OPENCLAW_BIND_MOUNT_OPTIONS=":Z" → strip leading colon for inline use). SELINUX_MOUNT_OPTS="${OPENCLAW_BIND_MOUNT_OPTIONS#:}" [[ -n "$SELINUX_MOUNT_OPTS" ]] && SELINUX_MOUNT_OPTS=",$SELINUX_MOUNT_OPTS" fi if [[ "$RUN_SETUP" == true ]]; then - exec podman run --pull="$PODMAN_PULL" --rm -it \ + TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")" + podman run --pull="$PODMAN_PULL" --rm -it \ --init \ "${USERNS_ARGS[@]}" "${RUN_USER_ARGS[@]}" \ -e HOME=/home/node -e TERM=xterm-256color -e BROWSER=echo \ - -e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ + -e NPM_CONFIG_CACHE=/home/node/.openclaw/.npm \ + -e OPENCLAW_NO_RESPAWN=1 \ + --env-file "$TOKEN_ENV_FILE" \ -v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \ -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \ - "${ENV_FILE_ARGS[@]}" \ "$OPENCLAW_IMAGE" \ node dist/index.js onboard "$@" + exit 0 fi +TOKEN_ENV_FILE="$(create_token_env_file "$ENV_FILE" "$OPENCLAW_GATEWAY_TOKEN")" podman run --pull="$PODMAN_PULL" -d --replace \ --name "$CONTAINER_NAME" \ --init \ "${USERNS_ARGS[@]}" "${RUN_USER_ARGS[@]}" \ -e HOME=/home/node -e TERM=xterm-256color \ - -e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ - "${ENV_FILE_ARGS[@]}" \ + -e NPM_CONFIG_CACHE=/home/node/.openclaw/.npm \ + -e OPENCLAW_NO_RESPAWN=1 \ + --env-file "$TOKEN_ENV_FILE" \ -v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \ -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \ - -p "${HOST_GATEWAY_PORT}:18789" \ - -p "${HOST_BRIDGE_PORT}:18790" \ + -p "${PUBLISH_HOST}:${HOST_GATEWAY_PORT}:18789" \ + -p "${PUBLISH_HOST}:${HOST_BRIDGE_PORT}:18790" \ "$OPENCLAW_IMAGE" \ node dist/index.js gateway --bind "$GATEWAY_BIND" --port 18789 echo "Container $CONTAINER_NAME started. Dashboard: http://127.0.0.1:${HOST_GATEWAY_PORT}/" +echo "Host CLI: openclaw --container $CONTAINER_NAME dashboard --no-open" echo "Logs: podman logs -f $CONTAINER_NAME" -echo "For auto-start/restarts, use: ./scripts/podman/setup.sh --quadlet (Quadlet + systemd user service)." +if [[ "$PLATFORM_NAME" == "Darwin" ]]; then + echo "macOS Podman note: if Control UI login hits device-auth errors, prefer the SSH-tunnel or Tailscale paths in docs/install/podman.md." + echo "Local-safe workaround:" + echo " OPENCLAW_CONTAINER=$CONTAINER_NAME openclaw dashboard --no-open" + echo " One-time setup:" + echo " OPENCLAW_CONTAINER=$CONTAINER_NAME openclaw config set gateway.controlUi.allowedOrigins '[\"http://127.0.0.1:18789\",\"http://localhost:18789\",\"http://127.0.0.1:28889\",\"http://localhost:28889\"]' --strict-json" + echo " podman restart $CONTAINER_NAME" + echo " ssh -N -i ~/.local/share/containers/podman/machine/machine -p -L 28889:127.0.0.1:18789 core@127.0.0.1" + echo " Then open http://127.0.0.1:28889/" + echo " Note: find with: podman system connection list" +fi +if [[ "$PLATFORM_NAME" == "Linux" ]]; then + echo "For auto-start/restarts, use: ./scripts/podman/setup.sh --quadlet (Quadlet + systemd user service)." +fi