This commit is contained in:
Davit
2026-04-27 11:51:20 +04:00
parent 7ce858f81b
commit b4fa5feb6e
12 changed files with 249 additions and 63 deletions
+21 -10
View File
@@ -110,22 +110,33 @@ openclaw_client restart # production (installed via `npm start`)
npm run dev # development
```
`api/.env`, the Vite dev/preview server, the built `serve.mjs`, `ALLOWED_DOMAIN`, `API_PUBLIC_URL`, and the bundled client's `VITE_API_BASE_URL` are all derived from this file, so both dev and production stay consistent.
`api/.env`, the Vite dev/preview server, and the built `serve.mjs` all read this file so both dev and production stay consistent. The API derives `API_PUBLIC_URL` from each request's `Host` header (so workspace URLs match the hostname the user is actually browsing) and ships a permissive CORS default; see the **CORS / remote access** note below for how to lock it down.
### Environment Variables
Generated automatically on first run in `api/.env` (see `api/.env.example` for reference):
| Variable | Default | Description |
| ---------------- | ------------------------ | ----------------------------------------------------- |
| `NODE_ENV` | `development` | Environment mode |
| `JWT_SECRET` | _(random)_ | Secret for JWT signing |
| `DB_PATH` | `./data/openclaw.sqlite` | Path to SQLite database file |
| `PORT` | _(API_PORT)_ | API listen port (driven by `~/.openclaw_client/.env`) |
| `ALLOWED_DOMAIN` | _(CLIENT origin)_ | CORS allowed origin(s), comma-separated |
| `API_PUBLIC_URL` | _(API origin)_ | Public base URL used for generated workspace URLs |
| Variable | Default | Description |
| ---------------------- | -------------------------- | ----------------------------------------------------------------- |
| `NODE_ENV` | `development` | Environment mode |
| `JWT_SECRET` | _(random)_ | Secret for JWT signing |
| `DB_PATH` | `./data/openclaw.sqlite` | Path to SQLite database file |
| `PORT` | _(API_PORT)_ | API listen port (driven by `~/.openclaw_client/.env`) |
| `ALLOWED_DOMAIN` | _(unset — allow all)_ | CORS allowlist, comma-separated; only enforced when `OPENCLAW_STRICT_CORS=1` |
| `OPENCLAW_STRICT_CORS` | _(off)_ | Set to `1` to reject any origin not in `ALLOWED_DOMAIN` |
| `API_PUBLIC_URL` | _(derived from request)_ | Optional fallback origin for workspace URLs when no `Host` header |
The client reads `VITE_API_BASE_URL` at build time; it is set automatically to match `API_PORT`. Override by setting it in `client/.env` only if you deploy behind a custom host.
> **CORS / remote access.** OpenClaw Client is a single-user local app. By
> default the API allows every origin and the client derives the API URL
> from `window.location`, so the same install works on `localhost`, on a
> LAN IP, and over Tailscale without any extra configuration. To lock it
> down to a fixed allowlist, set `ALLOWED_DOMAIN` and `OPENCLAW_STRICT_CORS=1`
> in `api/.env` (or `~/.openclaw_client/api/.env` for production installs).
The client picks its API origin at runtime: `__OPENCLAW_CONFIG__.apiBaseUrl`
(injected by the production static server from the request host) ▸
`VITE_API_BASE_URL` (build-time override) ▸ derived from
`window.location` + `VITE_API_PORT`.
To regenerate secrets, delete `api/.env` and run `npm run dev` or `npm run setup` again.
+16 -4
View File
@@ -1,8 +1,20 @@
NODE_ENV=development
JWT_SECRET=your-secret-here
DB_PATH=./data/openclaw.sqlite
# The following three are normally driven by ~/.openclaw_client/.env
# (API_PORT / CLIENT_PORT). Listed here for reference.
# Normally driven by ~/.openclaw_client/.env (API_PORT / CLIENT_PORT). Listed
# here for reference.
PORT=18802
ALLOWED_DOMAIN=http://localhost:18800
API_PUBLIC_URL=http://localhost:18802
# --- CORS ---
# OpenClaw Client is a single-user local app that you typically reach from
# multiple devices on your LAN / Tailscale, so the default policy is
# "allow every origin". Uncomment the two lines below to lock it down to
# a strict allowlist.
# ALLOWED_DOMAIN=http://localhost:18800,http://my-host.tail-net.ts.net:18800
# OPENCLAW_STRICT_CORS=1
# --- API public URL ---
# Optional fallback used when the API can't read `Host` from the
# request (server-to-server callers). Normal browser traffic always
# uses the request host so workspace URLs match wherever the client is.
# API_PUBLIC_URL=http://localhost:18802
+2 -1
View File
@@ -5,7 +5,8 @@ declare global {
interface ProcessEnv {
NODE_ENV: 'test' | 'development' | 'production';
JWT_SECRET: string;
ALLOWED_DOMAIN: string;
ALLOWED_DOMAIN?: string;
OPENCLAW_STRICT_CORS?: string;
}
}
}
+48 -11
View File
@@ -1,21 +1,58 @@
import cors from 'cors';
/**
* CORS policy for the OpenClaw Client API.
*
* This is a single-user local desktop app (not a public multi-tenant
* service). Auth is JWT-bearer in the `Authorization` header — no
* cookies, so CSRF surface is essentially zero — and the whole point of
* exposing it on `0.0.0.0` is so the user can reach it from their other
* devices on the same LAN or over Tailscale. A strict origin allowlist
* is therefore the wrong default: it breaks every legitimate access
* pattern beyond "browser open on the install host" while protecting
* nothing of value.
*
* Policy:
* - In `development`, allow every origin (was already the case).
* - In production, allow every origin **by default** so installs
* accessed via Tailscale/LAN/IP just work.
* - If `ALLOWED_DOMAIN` is set (comma-separated), it acts as an
* allowlist *augmenting* the permissive default — and if
* `OPENCLAW_STRICT_CORS=1` is also set, the allowlist becomes the
* final word and everything else is rejected.
*
* Operators who want the old strict behaviour:
* `ALLOWED_DOMAIN=https://openclaw.example.com OPENCLAW_STRICT_CORS=1`
*/
const STRICT = ['1', 'true', 'yes'].includes(
String(process.env.OPENCLAW_STRICT_CORS || '').toLowerCase()
);
const ALLOWLIST = (process.env.ALLOWED_DOMAIN || '')
.split(',')
.map((d) => d.trim())
.filter(Boolean);
export default cors({
exposedHeaders: 'access-token',
origin: (origin, next) => {
if (!origin || process.env.NODE_ENV === 'development') return next(null, true);
// Same-origin / non-browser callers (curl, server-to-server) don't
// send Origin and shouldn't be blocked.
if (!origin) return next(null, true);
const allowed = process.env.ALLOWED_DOMAIN
? process.env.ALLOWED_DOMAIN.split(',')
.map((d) => d.trim())
.filter(Boolean)
: [];
if (process.env.NODE_ENV === 'development') return next(null, true);
if (allowed.includes(origin)) return next(null, true);
if (ALLOWLIST.includes(origin)) return next(null, true);
return next(
new Error('The CORS policy for this site does not allow access from the specified Origin.'),
false
);
if (STRICT) {
return next(
new Error(
`CORS policy: origin ${origin} is not in ALLOWED_DOMAIN and OPENCLAW_STRICT_CORS is on.`
),
false
);
}
return next(null, true);
},
});
+35 -3
View File
@@ -14,7 +14,37 @@ import {
} from '../../@types/message';
import * as ocService from '../../services/openclaw';
const API_PUBLIC_URL = process.env.API_PUBLIC_URL || 'http://localhost:18802';
/**
* Resolve the public origin to use when minting URLs back to the client.
*
* OpenClaw Client is deployed in two patterns:
* 1. Local-only: browser on the install host. `Host` header reads
* `localhost:<port>` and the API_PUBLIC_URL env (default
* `http://localhost:18802`) was historically hardcoded — fine.
* 2. LAN/Tailscale/IP: browser on a different device. `Host` reads
* `<remote-host>:<port>`. A hardcoded localhost URL would point
* the remote browser at *its own machine*, breaking workspace
* file previews and downloads silently.
*
* So we prefer `req.headers.host` (already validated by Express + the
* cors middleware) and only fall back to the env override / default
* for non-HTTP callers. `x-forwarded-host` is honoured for users
* running behind a reverse proxy.
*/
const apiPublicUrl = (req: {
headers: Record<string, string | string[] | undefined>;
protocol?: string;
}): string => {
const envOverride = process.env.API_PUBLIC_URL;
const xfHost = req.headers['x-forwarded-host'];
const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost) || req.headers.host;
if (host) {
const xfProto = req.headers['x-forwarded-proto'];
const proto = (Array.isArray(xfProto) ? xfProto[0] : xfProto) || req.protocol || 'http';
return `${proto}://${host}`;
}
return envOverride || 'http://localhost:18802';
};
function stripWrapperTags(text: string): string {
return text
@@ -105,9 +135,11 @@ const chat: Chat = async (req, res, next) => {
const agent = await agentRepo.findOneBy({ _id: conv.agentId });
const agentIdForFiles = agent?.openclawAgentId || 'main';
const publicUrl = apiPublicUrl(req);
const msgCount = await msgRepo.count({ where: { conversationId: conv._id } });
if (msgCount === 0) {
ocService.appendBootstrapImageRule(agentIdForFiles, conv.agentId, API_PUBLIC_URL);
ocService.appendBootstrapImageRule(agentIdForFiles, conv.agentId, publicUrl);
}
const filePaths = uploadedFiles.map((uf) =>
@@ -121,7 +153,7 @@ const chat: Chat = async (req, res, next) => {
originalName: f.originalname,
mimetype: f.mimetype,
size: f.size,
url: `${API_PUBLIC_URL}/api/agent/${conv.agentId}/workspace/uploads/${encodeURIComponent(savedName)}`,
url: `${publicUrl}/api/agent/${conv.agentId}/workspace/uploads/${encodeURIComponent(savedName)}`,
};
});
+39 -1
View File
@@ -3,6 +3,44 @@ import { Button, TextField, Card, Typography, Box, CircularProgress, Alert } fro
import { useFormik, FormikProvider, Form } from 'formik';
import { useNavigate } from 'react-router';
import { useLoginMutation } from '../../features/auth';
import { API_BASE_URL } from '../../shared/api/baseApi';
/**
* Translate an RTK Query error into a message that actually helps the
* user. Previously we collapsed every error to "Login failed. Please
* check your credentials." which made network/CORS failures look like
* bad passwords — sent us on a goose chase the first time the app was
* accessed over Tailscale.
*/
function describeLoginError(error: unknown): string {
if (!error || typeof error !== 'object') return 'Login failed. Please try again.';
const e = error as { status?: number | string; data?: unknown; error?: string };
if (e.status === 401) return 'Login failed. Please check your credentials.';
if (e.status === 'FETCH_ERROR') {
return (
`Could not reach the API at ${API_BASE_URL}. ` +
'If you opened this page from another device, make sure the API ' +
'is reachable on the same hostname (and that any firewall / ' +
'reverse proxy forwards both the client and API ports).'
);
}
if (e.status === 'PARSING_ERROR') {
return 'API responded with something that is not JSON. The server may have crashed mid-request — check the API logs.';
}
if (typeof e.status === 'number' && e.status >= 500) {
return `Server error (${e.status}). Check the API logs.`;
}
if (
e.data &&
typeof e.data === 'object' &&
'message' in e.data &&
typeof (e.data as { message: unknown }).message === 'string'
) {
return (e.data as { message: string }).message;
}
if (typeof e.error === 'string') return e.error;
return 'Login failed. Please try again.';
}
export default function LoginPage() {
const navigate = useNavigate();
@@ -53,7 +91,7 @@ export default function LoginPage() {
<Form>
{error && (
<Alert severity="error" sx={{ marginBottom: 2 }}>
Login failed. Please check your credentials.
{describeLoginError(error)}
</Alert>
)}
<TextField
+26 -4
View File
@@ -12,11 +12,33 @@ declare global {
}
}
const runtimeApiBase =
typeof window !== 'undefined' ? window.__OPENCLAW_CONFIG__?.apiBaseUrl : undefined;
/**
* Pick the API origin that lets the bundle work on whichever host the
* page was loaded from (localhost on the install machine, a Tailscale
* MagicDNS name, a LAN IP, etc.).
*
* Resolution order:
* 1. `window.__OPENCLAW_CONFIG__.apiBaseUrl` — the production static
* server (`client/serve.mjs`) injects this from `req.headers.host`
* so the URL always matches the page's hostname.
* 2. `VITE_API_BASE_URL` — explicit build-time override; respected if
* the operator wants to pin a fixed URL.
* 3. Derived from `window.location` — same protocol + hostname as the
* page, with the API port (`__OPENCLAW_CONFIG__.apiPort` ▸
* `VITE_API_PORT` ▸ `18802`). This is the fallback dev mode lands
* on when accessed over Tailscale or a LAN IP without any custom
* env wiring.
*/
function resolveApiBaseUrl(): string {
if (typeof window === 'undefined') return 'http://localhost:18802/api';
const cfg = window.__OPENCLAW_CONFIG__;
if (cfg?.apiBaseUrl) return cfg.apiBaseUrl;
if (import.meta.env.VITE_API_BASE_URL) return import.meta.env.VITE_API_BASE_URL as string;
const apiPort = cfg?.apiPort || Number(import.meta.env.VITE_API_PORT) || 18802;
return `${window.location.protocol}//${window.location.hostname}:${apiPort}/api`;
}
export const API_BASE_URL =
runtimeApiBase || import.meta.env.VITE_API_BASE_URL || 'http://localhost:18802/api';
export const API_BASE_URL = resolveApiBaseUrl();
const rawBaseQuery = fetchBaseQuery({
baseUrl: API_BASE_URL,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-client",
"version": "2.4.6",
"version": "2.4.7",
"description": "Web-based chat interface for OpenClaw AI agents",
"private": true,
"type": "module",
+26 -18
View File
@@ -107,7 +107,9 @@ export function deploy() {
process.stdout.write('🔨 Building...\n');
run(NPM_BIN, ['run', 'build'], API_SRC);
// VITE_API_BASE_URL is embedded into the bundle at build time
// VITE_API_PORT is embedded into the bundle as a fallback; the
// runtime resolves the actual API origin from the page's hostname so
// the same build works on localhost, LAN, and Tailscale.
try {
execFileSync(NPM_BIN, ['run', 'build'], { cwd: CLIENT_SRC, stdio: 'pipe', env: buildEnv });
} catch (err) {
@@ -141,29 +143,35 @@ export function deploy() {
const canonicalDbPath = path.join(dataDir, 'openclaw.sqlite');
const envDist = path.join(apiDist, '.env');
const allowedDomain = `http://localhost:${clientPort}`;
const apiPublicUrl = `http://localhost:${apiPort}`;
// We seed only what the runtime can't figure out on its own.
// - DB_PATH and PORT must match where the install actually lives.
// - JWT_SECRET must persist across reinstalls or every login token
// gets invalidated, so we generate it once.
// - ALLOWED_DOMAIN / API_PUBLIC_URL are deliberately omitted: the
// API has a permissive CORS default and derives public URLs from
// the request host. Users who want strict CORS set
// `ALLOWED_DOMAIN=...` and `OPENCLAW_STRICT_CORS=1` themselves.
const seedDefaults = {
NODE_ENV: 'production',
JWT_SECRET: crypto.randomBytes(32).toString('hex'),
DB_PATH: canonicalDbPath,
PORT: String(apiPort),
};
const overrides = {
DB_PATH: canonicalDbPath,
PORT: String(apiPort),
};
if (!existsSync(envDist)) {
writeFileSync(
envDist,
[
'NODE_ENV=production',
`JWT_SECRET=${crypto.randomBytes(32).toString('hex')}`,
`DB_PATH=${canonicalDbPath}`,
`PORT=${apiPort}`,
`ALLOWED_DOMAIN=${allowedDomain}`,
`API_PUBLIC_URL=${apiPublicUrl}`,
'',
].join('\n')
Object.entries(seedDefaults)
.map(([k, v]) => `${k}=${v}`)
.concat('')
.join('\n')
);
} else {
const overrides = {
DB_PATH: canonicalDbPath,
PORT: String(apiPort),
ALLOWED_DOMAIN: allowedDomain,
API_PUBLIC_URL: apiPublicUrl,
};
const seen = new Set();
const lines = readFileSync(envDist, 'utf-8').split('\n');
const updated = lines.map((line) => {
+25 -6
View File
@@ -64,9 +64,30 @@ export function readPorts() {
}
/**
* Derived env vars expected by API and Client code.
* Use these when spawning child processes so a single user-level .env is
* the source of truth.
* Derived env vars for spawning child processes.
*
* Note we deliberately do NOT export `ALLOWED_DOMAIN`, `API_PUBLIC_URL`,
* or `VITE_API_BASE_URL` here, even though the API and client both read
* those vars. Pinning them to `http://localhost:${port}` would block
* legitimate access from other devices on the user's LAN / Tailscale —
* the very deployment pattern this app is designed for. Instead:
*
* - `ALLOWED_DOMAIN` is unset by default; the API ships a permissive
* CORS policy and an `OPENCLAW_STRICT_CORS=1` opt-in for the strict
* allowlist behaviour.
* - `API_PUBLIC_URL` is derived per-request from the `Host` header
* (with `x-forwarded-*` honoured) — see `routes/message/controller`.
* - `VITE_API_BASE_URL` is left empty so the bundle isn't built with
* a baked-in `http://localhost:...` URL; the client derives the
* API origin at runtime from `__OPENCLAW_CONFIG__` or
* `window.location` — see `client/src/shared/api/baseApi`.
*
* Users who want strict mode can still set any of those keys in
* `~/.openclaw_client/.env` or `api/.env`; nothing here overwrites them.
*
* `VITE_API_PORT` is exported so the build can embed a sensible default
* for the runtime URL derivation when the user installs across a
* non-standard port.
*/
export function portEnv() {
const { apiPort, clientPort } = readPorts();
@@ -74,8 +95,6 @@ export function portEnv() {
API_PORT: String(apiPort),
CLIENT_PORT: String(clientPort),
PORT: String(apiPort),
ALLOWED_DOMAIN: `http://localhost:${clientPort}`,
API_PUBLIC_URL: `http://localhost:${apiPort}`,
VITE_API_BASE_URL: `http://localhost:${apiPort}/api`,
VITE_API_PORT: String(apiPort),
};
}
+5 -2
View File
@@ -35,14 +35,17 @@ function parseEnvFile(file) {
const userEnv = parseEnvFile(USER_ENV);
const apiPort = Number(userEnv.API_PORT) || 18802;
const clientPort = Number(userEnv.CLIENT_PORT) || 18800;
// We deliberately do not export ALLOWED_DOMAIN / API_PUBLIC_URL here:
// the API has a permissive CORS default and derives public URLs from
// the request host, so the same install works on localhost, LAN, and
// Tailscale. Users who want strict CORS set ALLOWED_DOMAIN and
// OPENCLAW_STRICT_CORS=1 in ~/.openclaw_client/api/.env themselves.
const childEnv = {
...process.env,
NODE_ENV: 'production',
API_PORT: String(apiPort),
CLIENT_PORT: String(clientPort),
PORT: String(apiPort),
ALLOWED_DOMAIN: `http://localhost:${clientPort}`,
API_PUBLIC_URL: `http://localhost:${apiPort}`,
};
const children = [];
+5 -2
View File
@@ -17,6 +17,11 @@ if (fs.existsSync(API_ENV)) {
const JWT_SECRET = crypto.randomBytes(32).toString('hex');
// ALLOWED_DOMAIN / API_PUBLIC_URL are deliberately omitted: the API has
// a permissive CORS default and derives public URLs from the request
// host so the same install works on localhost, LAN, and Tailscale.
// Users who want strict CORS can set `ALLOWED_DOMAIN=...` and
// `OPENCLAW_STRICT_CORS=1` themselves.
fs.writeFileSync(
API_ENV,
[
@@ -24,8 +29,6 @@ fs.writeFileSync(
`JWT_SECRET=${JWT_SECRET}`,
`DB_PATH=./data/openclaw.sqlite`,
`PORT=${apiPort}`,
`ALLOWED_DOMAIN=http://localhost:${clientPort}`,
`API_PUBLIC_URL=http://localhost:${apiPort}`,
'',
].join('\n'),
);