mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
* 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:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user