feat(security): Harden Docker browser container chromium flags (#23889) (#31504)

* Gateway: honor OPENCLAW_GATEWAY_URL override for remote/local calls

* Agents: fix sandbox sessionKey usage for PI embedded subagent calls

* Sandbox: tighten browser container Chromium runtime flags

* fix: add sandbox browser defaults for container hardening

* docs: expand sandbox browser default flags list

* fix: make sandbox browser flags optional and preserve gateway env auth overrides

* docs: scope PR 31504 changelog entry

* style: format gateway call override handling

* fix: dedupe sandbox browser chrome args

* fix: preserve remote tls fingerprint for env gateway override

* fix: enforce auth for env gateway URL override

* chore: document gateway override auth security expectations
This commit is contained in:
Vincent Koc
2026-03-02 11:28:27 -08:00
committed by GitHub
parent ea1fe77c83
commit a19a7f5e6e
11 changed files with 350 additions and 20 deletions

View File

@@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.

View File

@@ -1177,6 +1177,35 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity.
- `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`).
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
- Launch defaults are defined in `scripts/sandbox-browser-entrypoint.sh` and tuned for container hosts:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-3d-apis`
- `--disable-gpu`
- `--disable-software-rasterizer`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-features=TranslateUI`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--renderer-process-limit=2`
- `--no-zygote`
- `--metrics-recording-only`
- `--disable-extensions` (default enabled)
- `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu` are
enabled by default and can be disabled with
`OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if WebGL/3D usage requires it.
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` re-enables extensions if your workflow
depends on them.
- `--renderer-process-limit=2` can be changed with
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`; set `0` to use Chromium's
default process limit.
- plus `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
- Defaults are the container image baseline; use a custom browser image with a custom
entrypoint to change container defaults.
</Accordion>
@@ -2251,6 +2280,7 @@ See [Plugins](/tools/plugin).
color: "#FF4500",
// headless: false,
// noSandbox: false,
// extraArgs: [],
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false,
},
@@ -2265,6 +2295,8 @@ See [Plugins](/tools/plugin).
- Remote profiles are attach-only (start/stop/reset disabled).
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `extraArgs` appends extra launch flags to local Chromium startup (for example
`--disable-gpu`, window sizing, or debug flags).
---

View File

@@ -148,6 +148,40 @@ scripts/sandbox-browser-setup.sh
By default, sandbox containers run with **no network**.
Override with `agents.defaults.sandbox.docker.network`.
The bundled sandbox browser image also applies conservative Chromium startup defaults
for containerized workloads. Current container defaults include:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-3d-apis`
- `--disable-gpu`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-extensions`
- `--disable-features=TranslateUI`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--disable-software-rasterizer`
- `--no-zygote`
- `--metrics-recording-only`
- `--renderer-process-limit=2`
- `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
- The three graphics hardening flags (`--disable-3d-apis`,
`--disable-software-rasterizer`, `--disable-gpu`) are optional and are useful
when containers lack GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0`
if your workload requires WebGL or other 3D/browser features.
- `--disable-extensions` is enabled by default and can be disabled with
`OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows.
- `--renderer-process-limit=2` is controlled by
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`, where `0` keeps Chromium's default.
If you need a different runtime profile, use a custom browser image and provide
your own entrypoint. For local (non-container) Chromium profiles, use
`browser.extraArgs` to append additional startup flags.
Security defaults:
- `network: "host"` is blocked.

View File

@@ -64,6 +64,13 @@ Optional env vars:
- `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`)
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
- `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` — disable container browser hardening flags
`--disable-3d-apis`, `--disable-software-rasterizer`, `--disable-gpu` when you need
WebGL/3D compatibility.
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` — keep extensions enabled when browser
flows require them (default keeps extensions disabled in sandbox browser).
- `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>` — set Chromium renderer process
limit; set to `0` to skip the flag and use Chromium default behavior.
After it finishes:
@@ -672,6 +679,38 @@ Notes:
- Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`.
- Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`).
- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query).
- Browser container startup defaults are conservative for shared/container workloads, including:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-3d-apis`
- `--disable-software-rasterizer`
- `--disable-gpu`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-features=TranslateUI`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--metrics-recording-only`
- `--renderer-process-limit=2`
- `--no-zygote`
- `--disable-extensions`
- If `agents.defaults.sandbox.browser.noSandbox` is set, `--no-sandbox` and
`--disable-setuid-sandbox` are also appended.
- The three graphics hardening flags above are optional. If your workload needs
WebGL/3D, set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` to run without
`--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu`.
- Extension behavior is controlled by `--disable-extensions` and can be disabled
(enables extensions) via `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for
extension-dependent pages or extensions-heavy workflows.
- `--renderer-process-limit=2` is also configurable with
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT`; set `0` to let Chromium choose its
default process limit when browser concurrency needs tuning.
Defaults are applied by default in the bundled image. If you need different
Chromium flags, use a custom browser image and provide your own entrypoint.
Use config:

View File

@@ -1,6 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
dedupe_chrome_args() {
local -A seen_args=()
local -a unique_args=()
for arg in "${CHROME_ARGS[@]}"; do
if [[ -n "${seen_args["$arg"]:+x}" ]]; then
continue
fi
seen_args["$arg"]=1
unique_args+=("$arg")
done
CHROME_ARGS=("${unique_args[@]}")
}
export DISPLAY=:1
export HOME=/tmp/openclaw-home
export XDG_CONFIG_HOME="${HOME}/.config"
@@ -14,6 +29,9 @@ ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}"
ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-${CLAWDBOT_BROWSER_NO_SANDBOX:-0}}"
NOVNC_PASSWORD="${OPENCLAW_BROWSER_NOVNC_PASSWORD:-${CLAWDBOT_BROWSER_NOVNC_PASSWORD:-}}"
DISABLE_GRAPHICS_FLAGS="${OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS:-1}"
DISABLE_EXTENSIONS="${OPENCLAW_BROWSER_DISABLE_EXTENSIONS:-1}"
RENDERER_PROCESS_LIMIT="${OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT:-2}"
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
@@ -22,7 +40,6 @@ Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
if [[ "${HEADLESS}" == "1" ]]; then
CHROME_ARGS=(
"--headless=new"
"--disable-gpu"
)
else
CHROME_ARGS=()
@@ -45,9 +62,30 @@ CHROME_ARGS+=(
"--disable-features=TranslateUI"
"--disable-breakpad"
"--disable-crash-reporter"
"--no-zygote"
"--metrics-recording-only"
)
DISABLE_GRAPHICS_FLAGS_LOWER="${DISABLE_GRAPHICS_FLAGS,,}"
if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "1" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "true" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "yes" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "on" ]]; then
CHROME_ARGS+=(
"--disable-3d-apis"
"--disable-gpu"
"--disable-software-rasterizer"
)
fi
DISABLE_EXTENSIONS_LOWER="${DISABLE_EXTENSIONS,,}"
if [[ "${DISABLE_EXTENSIONS_LOWER}" == "1" || "${DISABLE_EXTENSIONS_LOWER}" == "true" || "${DISABLE_EXTENSIONS_LOWER}" == "yes" || "${DISABLE_EXTENSIONS_LOWER}" == "on" ]]; then
CHROME_ARGS+=(
"--disable-extensions"
)
fi
if [[ "${RENDERER_PROCESS_LIMIT}" =~ ^[0-9]+$ && "${RENDERER_PROCESS_LIMIT}" -gt 0 ]]; then
CHROME_ARGS+=("--renderer-process-limit=${RENDERER_PROCESS_LIMIT}")
fi
if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
CHROME_ARGS+=(
"--no-sandbox"
@@ -55,6 +93,7 @@ if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
)
fi
dedupe_chrome_args
chromium "${CHROME_ARGS[@]}" about:blank &
for _ in $(seq 1 50); do

View File

@@ -369,7 +369,7 @@ export async function compactEmbeddedPiSessionDirect(
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,

View File

@@ -584,7 +584,7 @@ export async function runEmbeddedAttempt(
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
@@ -751,7 +751,7 @@ export async function runEmbeddedAttempt(
sandbox: (() => {
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
});
return { mode: runtime.mode, sandboxed: runtime.sandboxed };
})(),
@@ -1185,7 +1185,7 @@ export async function runEmbeddedAttempt(
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
config: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
sessionKey: sandboxSessionKey,
});
const {

View File

@@ -11,6 +11,7 @@ let lastClientOptions: {
url?: string;
token?: string;
password?: string;
tlsFingerprint?: string;
scopes?: string[];
onHelloOk?: () => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
@@ -90,7 +91,12 @@ function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword =
}
describe("callGateway url resolution", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
const envSnapshot = captureEnv([
"OPENCLAW_ALLOW_INSECURE_PRIVATE_WS",
"OPENCLAW_GATEWAY_URL",
"OPENCLAW_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_TOKEN",
]);
beforeEach(() => {
envSnapshot.restore();
@@ -184,6 +190,68 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.token).toBe("explicit-token");
});
it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => {
loadConfig.mockReturnValue({
gateway: { mode: "remote", bind: "loopback", remote: {} },
});
resolveGatewayPort.mockReturnValue(18789);
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws";
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
await callGateway({
method: "health",
});
expect(lastClientOptions?.url).toBe("wss://gateway-in-container.internal:9443/ws");
expect(lastClientOptions?.token).toBe("env-token");
expect(lastClientOptions?.password).toBeUndefined();
});
it("uses remote tlsFingerprint with env URL override", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "remote",
remote: {
url: "wss://remote.example:9443/ws",
tlsFingerprint: "remote-fingerprint",
},
},
});
setGatewayNetworkDefaults(18789);
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws";
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
await callGateway({
method: "health",
});
expect(lastClientOptions?.tlsFingerprint).toBe("remote-fingerprint");
});
it("does not apply remote tlsFingerprint for CLI url override", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "remote",
remote: {
url: "wss://remote.example:9443/ws",
tlsFingerprint: "remote-fingerprint",
},
},
});
setGatewayNetworkDefaults(18789);
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
await callGateway({
method: "health",
url: "wss://override.example:9443/ws",
token: "explicit-token",
});
expect(lastClientOptions?.tlsFingerprint).toBeUndefined();
});
it.each([
{
label: "uses least-privilege scopes by default for non-CLI callers",
@@ -300,6 +368,28 @@ describe("buildGatewayConnectionDetails", () => {
expect(details.remoteFallbackNote).toBeUndefined();
});
it("uses env OPENCLAW_GATEWAY_URL when set", () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } });
resolveGatewayPort.mockReturnValue(18800);
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
const prevUrl = process.env.OPENCLAW_GATEWAY_URL;
try {
process.env.OPENCLAW_GATEWAY_URL = "wss://browser-gateway.local:9443/ws";
const details = buildGatewayConnectionDetails();
expect(details.url).toBe("wss://browser-gateway.local:9443/ws");
expect(details.urlSource).toBe("env OPENCLAW_GATEWAY_URL");
expect(details.bindDetail).toBeUndefined();
} finally {
if (prevUrl === undefined) {
delete process.env.OPENCLAW_GATEWAY_URL;
} else {
process.env.OPENCLAW_GATEWAY_URL = prevUrl;
}
}
});
it("throws for insecure ws:// remote URLs (CWE-319)", () => {
loadConfig.mockReturnValue({
gateway: {
@@ -434,7 +524,12 @@ describe("callGateway url override auth requirements", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]);
envSnapshot = captureEnv([
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_GATEWAY_URL",
"CLAWDBOT_GATEWAY_URL",
]);
resetGatewayCallMocks();
setGatewayNetworkDefaults(18789);
});
@@ -457,6 +552,18 @@ describe("callGateway url override auth requirements", () => {
callGateway({ method: "health", url: "wss://override.example/ws" }),
).rejects.toThrow("explicit credentials");
});
it("throws when env URL override is set without env credentials", async () => {
process.env.OPENCLAW_GATEWAY_URL = "wss://override.example/ws";
loadConfig.mockReturnValue({
gateway: {
mode: "local",
auth: { token: "local-token", password: "local-password" },
},
});
await expect(callGateway({ method: "health" })).rejects.toThrow("explicit credentials");
});
});
describe("callGateway password resolution", () => {

View File

@@ -86,14 +86,30 @@ export function resolveExplicitGatewayAuth(opts?: ExplicitGatewayAuth): Explicit
export function ensureExplicitGatewayAuth(params: {
urlOverride?: string;
auth: ExplicitGatewayAuth;
urlOverrideSource?: "cli" | "env";
explicitAuth?: ExplicitGatewayAuth;
resolvedAuth?: ExplicitGatewayAuth;
errorHint: string;
configPath?: string;
}): void {
if (!params.urlOverride) {
return;
}
if (params.auth.token || params.auth.password) {
// URL overrides are untrusted redirects and can move WebSocket traffic off the intended host.
// Never allow an override to silently reuse implicit credentials or device token fallback.
const explicitToken = params.explicitAuth?.token;
const explicitPassword = params.explicitAuth?.password;
if (params.urlOverrideSource === "cli" && (explicitToken || explicitPassword)) {
return;
}
const hasResolvedAuth =
params.resolvedAuth?.token ||
params.resolvedAuth?.password ||
explicitToken ||
explicitPassword;
// Env overrides are supported for deployment ergonomics, but only when explicit auth is available.
// This avoids implicit device-token fallback against attacker-controlled WSS endpoints.
if (params.urlOverrideSource === "env" && hasResolvedAuth) {
return;
}
const message = [
@@ -107,7 +123,12 @@ export function ensureExplicitGatewayAuth(params: {
}
export function buildGatewayConnectionDetails(
options: { config?: OpenClawConfig; url?: string; configPath?: string } = {},
options: {
config?: OpenClawConfig;
url?: string;
configPath?: string;
urlSource?: "cli" | "env";
} = {},
): GatewayConnectionDetails {
const config = options.config ?? loadConfig();
const configPath =
@@ -120,25 +141,34 @@ export function buildGatewayConnectionDetails(
const scheme = tlsEnabled ? "wss" : "ws";
// Self-connections should always target loopback; bind mode only controls listener exposure.
const localUrl = `${scheme}://127.0.0.1:${localPort}`;
const urlOverride =
const cliUrlOverride =
typeof options.url === "string" && options.url.trim().length > 0
? options.url.trim()
: undefined;
const envUrlOverride = cliUrlOverride
? undefined
: (trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ??
trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL));
const urlOverride = cliUrlOverride ?? envUrlOverride;
const remoteUrl =
typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined;
const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl;
const urlSourceHint =
options.urlSource ?? (cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined);
const url = urlOverride || remoteUrl || localUrl;
const urlSource = urlOverride
? "cli --url"
? urlSourceHint === "env"
? "env OPENCLAW_GATEWAY_URL"
: "cli --url"
: remoteUrl
? "config gateway.remote.url"
: remoteMisconfigured
? "missing gateway.remote.url (fallback local)"
: "local loopback";
const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
const remoteFallbackNote = remoteMisconfigured
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
: undefined;
const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
// Security check: block ALL insecure ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
@@ -196,6 +226,7 @@ type ResolvedGatewayCallContext = {
isRemoteMode: boolean;
remote?: GatewayRemoteSettings;
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
remoteUrl?: string;
explicitAuth: ExplicitGatewayAuth;
};
@@ -226,10 +257,25 @@ function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewa
const remote = isRemoteMode
? (config.gateway?.remote as GatewayRemoteSettings | undefined)
: undefined;
const urlOverride = trimToUndefined(opts.url);
const cliUrlOverride = trimToUndefined(opts.url);
const envUrlOverride = cliUrlOverride
? undefined
: (trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ??
trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL));
const urlOverride = cliUrlOverride ?? envUrlOverride;
const urlOverrideSource = cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined;
const remoteUrl = trimToUndefined(remote?.url);
const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password });
return { config, configPath, isRemoteMode, remote, urlOverride, remoteUrl, explicitAuth };
return {
config,
configPath,
isRemoteMode,
remote,
urlOverride,
urlOverrideSource,
remoteUrl,
explicitAuth,
};
}
function ensureRemoteModeUrlConfigured(context: ResolvedGatewayCallContext): void {
@@ -254,6 +300,7 @@ function resolveGatewayCredentials(context: ResolvedGatewayCallContext): {
env: process.env,
explicitAuth: context.explicitAuth,
urlOverride: context.urlOverride,
urlOverrideSource: context.urlOverrideSource,
remotePasswordPrecedence: "env-first",
});
}
@@ -266,7 +313,7 @@ async function resolveGatewayTlsFingerprint(params: {
const { opts, context, url } = params;
const useLocalTls =
context.config.gateway?.tls?.enabled === true &&
!context.urlOverride &&
!context.urlOverrideSource &&
!context.remoteUrl &&
url.startsWith("wss://");
const tlsRuntime = useLocalTls
@@ -274,7 +321,10 @@ async function resolveGatewayTlsFingerprint(params: {
: undefined;
const overrideTlsFingerprint = trimToUndefined(opts.tlsFingerprint);
const remoteTlsFingerprint =
context.isRemoteMode && !context.urlOverride && context.remoteUrl
// Env overrides may still inherit configured remote TLS pinning for private cert deployments.
// CLI overrides remain explicit-only and intentionally skip config remote TLS to avoid
// accidentally pinning against caller-supplied target URLs.
context.isRemoteMode && context.urlOverrideSource !== "cli"
? trimToUndefined(context.remote?.tlsFingerprint)
: undefined;
return (
@@ -388,9 +438,12 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
): Promise<T> {
const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(opts.timeoutMs);
const context = resolveGatewayCallContext(opts);
const resolvedCredentials = resolveGatewayCredentials(context);
ensureExplicitGatewayAuth({
urlOverride: context.urlOverride,
auth: context.explicitAuth,
urlOverrideSource: context.urlOverrideSource,
explicitAuth: context.explicitAuth,
resolvedAuth: resolvedCredentials,
errorHint: "Fix: pass --token or --password (or gatewayToken in tools).",
configPath: context.configPath,
});
@@ -398,11 +451,12 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
const connectionDetails = buildGatewayConnectionDetails({
config: context.config,
url: context.urlOverride,
urlSource: context.urlOverrideSource,
...(opts.configPath ? { configPath: opts.configPath } : {}),
});
const url = connectionDetails.url;
const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url });
const { token, password } = resolveGatewayCredentials(context);
const { token, password } = resolvedCredentials;
return await executeGatewayRequestWithScopes<T>({
opts,
scopes,

View File

@@ -78,6 +78,19 @@ describe("resolveGatewayCredentialsFromConfig", () => {
expect(resolved).toEqual({});
});
it("uses env credentials for env-sourced url overrides", () => {
const resolved = resolveGatewayCredentialsFor(
{
auth: DEFAULT_GATEWAY_AUTH,
},
{
urlOverride: "wss://example.com",
urlOverrideSource: "env",
},
);
expectEnvGatewayCredentials(resolved);
});
it("uses local-mode environment values before local config", () => {
const resolved = resolveGatewayCredentialsFor({
mode: "local",

View File

@@ -94,6 +94,7 @@ export function resolveGatewayCredentialsFromConfig(params: {
env?: NodeJS.ProcessEnv;
explicitAuth?: ExplicitGatewayAuth;
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
modeOverride?: GatewayCredentialMode;
includeLegacyEnv?: boolean;
localTokenPrecedence?: GatewayCredentialPrecedence;
@@ -110,9 +111,19 @@ export function resolveGatewayCredentialsFromConfig(params: {
if (explicitToken || explicitPassword) {
return { token: explicitToken, password: explicitPassword };
}
if (trimToUndefined(params.urlOverride)) {
if (trimToUndefined(params.urlOverride) && params.urlOverrideSource !== "env") {
return {};
}
if (trimToUndefined(params.urlOverride) && params.urlOverrideSource === "env") {
return resolveGatewayCredentialsFromValues({
configToken: undefined,
configPassword: undefined,
env,
includeLegacyEnv,
tokenPrecedence: "env-first",
passwordPrecedence: "env-first",
});
}
const mode: GatewayCredentialMode =
params.modeOverride ?? (params.cfg.gateway?.mode === "remote" ? "remote" : "local");