update podman setup and docs (#55388)

* update podman setup and docs

Signed-off-by: sallyom <somalley@redhat.com>

* podman: persist runtime env defaults

Co-authored-by: albertxos <kickban3000@gmail.com>
Signed-off-by: sallyom <somalley@redhat.com>

* podman: harden env and path handling, other setup updates

Signed-off-by: sallyom <somalley@redhat.com>

* podman: allow symlinked home path components

Signed-off-by: sallyom <somalley@redhat.com>

* update podman docs

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: albertxos <kickban3000@gmail.com>
This commit is contained in:
Sally O'Malley 2026-03-27 11:47:35 -04:00 committed by GitHub
parent 5e8db468ff
commit df5b9ef0c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 999 additions and 375 deletions

View File

@ -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 <name> ...` 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.

View File

@ -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.

View File

@ -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 <name> ...` 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
<Steps>
<Step title="One-time setup">
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`.
</Step>
<Step title="Start the Gateway">
For a quick manual launch:
```bash
./scripts/run-openclaw-podman.sh launch
```
<Step title="Start the Gateway container">
Start the container with `./scripts/run-openclaw-podman.sh launch`.
</Step>
<Step title="Run the onboarding wizard">
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).
<Step title="Run onboarding inside the container">
Run `./scripts/run-openclaw-podman.sh launch setup`, then open `http://127.0.0.1:18789/`.
</Step>
<Step title="Manage the running container from the host CLI">
Set `OPENCLAW_CONTAINER=openclaw`, then use normal `openclaw` commands from the host.
</Step>
</Steps>
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 <podman-vm-ssh-port> \
-L 28889:127.0.0.1:18789 \
core@127.0.0.1
```
In that command, `<podman-vm-ssh-port>` 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/<agentId>/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 users `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 openclaws 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 <your uid>:<your gid>` 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 <name> ...` explicitly, or export `OPENCLAW_CONTAINER=<name>` 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)

View File

@ -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

View File

@ -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 <<EOF
OPENCLAW_GATEWAY_TOKEN=$TOKEN
EOF
)
echo "Generated OPENCLAW_GATEWAY_TOKEN and wrote it to $ENV_FILE"
fi
upsert_env_var "$ENV_FILE" "OPENCLAW_PODMAN_CONTAINER" "$OPENCLAW_CONTAINER_NAME"
upsert_env_var "$ENV_FILE" "OPENCLAW_PODMAN_IMAGE" "$OPENCLAW_IMAGE"
if [[ ! -f "$OPENCLAW_CONFIG/openclaw.json" ]]; then
run_as_openclaw sh -lc "umask 077 && cat > '$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 <<JSON
{
"gateway": {
"mode": "local",
"controlUi": {
"allowedOrigins": [
"http://127.0.0.1:${SEED_GATEWAY_PORT}",
"http://localhost:${SEED_GATEWAY_PORT}"
]
}
}
}
JSON
)
echo "Wrote minimal config to $CONFIG_JSON"
fi
seed_local_control_ui_origins "$CONFIG_JSON" "$SEED_GATEWAY_PORT"
if [[ "$INSTALL_QUADLET" == true ]]; then
QUADLET_DIR="$OPENCLAW_HOME/.config/containers/systemd"
QUADLET_DST="$QUADLET_DIR/openclaw.container"
echo "Installing Quadlet to $QUADLET_DST ..."
run_as_openclaw mkdir -p "$QUADLET_DIR"
mkdir -p "$QUADLET_DIR"
ensure_safe_existing_dir "quadlet directory" "$QUADLET_DIR"
OPENCLAW_HOME_ESCAPED="$(escape_sed_replacement_pipe_delim "$OPENCLAW_HOME")"
sed "s|{{OPENCLAW_HOME}}|$OPENCLAW_HOME_ESCAPED|g" "$QUADLET_TEMPLATE" | \
run_as_openclaw sh -lc "cat > '$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"

View File

@ -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 <podman-vm-ssh-port> -L 28889:127.0.0.1:18789 core@127.0.0.1"
echo " Then open http://127.0.0.1:28889/"
echo " Note: find <podman-vm-ssh-port> 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