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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ WORKDIR /openclaw

# Pin to a known-good ref (tag/branch). Override in Railway template settings if needed.
# Using a released tag avoids build breakage when `main` temporarily references unpublished packages.
ARG OPENCLAW_GIT_REF=v2026.2.9
ARG OPENCLAW_GIT_REF=v2026.2.26
RUN git clone --depth 1 --branch "${OPENCLAW_GIT_REF}" https://github.com/openclaw/openclaw.git .

# Patch: relax version requirements for packages that may reference unpublished versions.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,27 @@ If `openclaw devices list` shows no pending request IDs:
- Ensure your state dir is the Railway volume (recommended): `OPENCLAW_STATE_DIR=/data/.openclaw`
- Check `/setup/api/debug` for the active state/workspace dirs + gateway readiness


### "origin not allowed" / Control UI WebSocket blocked

Recent openclaw releases check that the browser's `Origin` header matches the gateway host or an explicit allowlist.

This template automatically adds `https://<your-app>.up.railway.app` (from Railway's `RAILWAY_PUBLIC_DOMAIN` env var) to `gateway.controlUi.allowedOrigins` on startup, so the default Railway domain works out of the box.

If you use a **custom domain**, add it to the Railway Variables:

```
OPENCLAW_CONTROL_UI_ALLOWED_ORIGINS=https://my-custom-domain.example.com
```

Multiple origins are comma-separated:

```
OPENCLAW_CONTROL_UI_ALLOWED_ORIGINS=https://my-custom-domain.example.com,https://other.example.com
```

Then redeploy (or restart) so the wrapper patches the config before the gateway starts.

### “unauthorized: gateway token mismatch”

The Control UI connects using `gateway.remote.token` and the gateway validates `gateway.auth.token`.
Expand Down
6 changes: 6 additions & 0 deletions railway.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ requiredMountPath = "/data"
# can cause domain routing mismatches.
OPENCLAW_STATE_DIR = "/data/.openclaw"
OPENCLAW_WORKSPACE_DIR = "/data/workspace"

# Railway injects RAILWAY_PUBLIC_DOMAIN automatically and this template uses it
# to populate gateway.controlUi.allowedOrigins, which is required since openclaw
# added origin-checking to the Control UI WebSocket in recent releases.
# If you use a custom domain, add it here (comma-separated full origins):
# OPENCLAW_CONTROL_UI_ALLOWED_ORIGINS = "https://my-custom-domain.example.com"
64 changes: 64 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,77 @@ async function waitForGatewayReady(opts = {}) {
return false;
}

// Build the list of origins that should be allowed by gateway.controlUi.allowedOrigins.
// Sources:
// RAILWAY_PUBLIC_DOMAIN – injected automatically by Railway (e.g. "my-app.up.railway.app")
// OPENCLAW_CONTROL_UI_ALLOWED_ORIGINS – comma-separated extra origins set by the operator
function buildControlUiAllowedOrigins() {
const origins = new Set();

const railwayDomain = process.env.RAILWAY_PUBLIC_DOMAIN?.trim();
if (railwayDomain) {
origins.add(`https://${railwayDomain}`);
}

const extra = process.env.OPENCLAW_CONTROL_UI_ALLOWED_ORIGINS?.trim();
if (extra) {
for (const o of extra.split(",")) {
const t = o.trim();
if (t) origins.add(t);
}
}

return [...origins];
}

// Patch gateway.controlUi.allowedOrigins in the openclaw config file so the new
// origin-check introduced in recent openclaw releases doesn't block Railway deployments.
// Only runs when there is an existing config and at least one origin to add.
function patchGatewayAllowedOrigins() {
const origins = buildControlUiAllowedOrigins();
if (origins.length === 0) return;

const p = configPath();
let config = {};
try {
config = JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
// Config doesn't exist yet or is not valid JSON – skip patching, the user
// hasn't run the setup flow yet and the gateway won't start anyway.
return;
}

if (!config.gateway) config.gateway = {};
if (!config.gateway.controlUi) config.gateway.controlUi = {};

const existing = Array.isArray(config.gateway.controlUi.allowedOrigins)
? config.gateway.controlUi.allowedOrigins
: [];
const merged = [...new Set([...existing, ...origins])];

// Only write if the list actually changed to avoid spurious disk writes.
if (JSON.stringify(merged) === JSON.stringify(existing)) return;

config.gateway.controlUi.allowedOrigins = merged;
try {
fs.writeFileSync(p, JSON.stringify(config, null, 2) + "\n", "utf8");
console.log(`[gateway] controlUi.allowedOrigins patched → ${JSON.stringify(merged)}`);
} catch (err) {
console.warn(`[gateway] Failed to patch controlUi.allowedOrigins: ${err}`);
}
}

async function startGateway() {
if (gatewayProc) return;
if (!isConfigured()) throw new Error("Gateway cannot start: not configured");

fs.mkdirSync(STATE_DIR, { recursive: true });
fs.mkdirSync(WORKSPACE_DIR, { recursive: true });

// Ensure the Railway public domain (and any explicit overrides) are in the
// gateway.controlUi.allowedOrigins list before the process starts.
patchGatewayAllowedOrigins();

const args = [
"gateway",
"run",
Expand Down