Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3a1729d
spec-simplify: merge Phase 3 (docs) into Phases 1 and 2
jyaunches Apr 24, 2026
f22aa69
Add test specification for brev-launchable-pairing-fix
jyaunches Apr 24, 2026
9447a99
Add validation plan for brev-launchable-pairing-fix
jyaunches Apr 24, 2026
420f6b6
validation: add Docker build+inspect scenario for Brev Launchable
jyaunches Apr 24, 2026
6a03d36
Approve validation plan for brev-launchable-pairing-fix
jyaunches Apr 24, 2026
56bef9c
spec-review-design: no changes needed
jyaunches Apr 24, 2026
b0d8efe
spec-review-implementation: fix config path gateway.auth → gateway.co…
jyaunches Apr 24, 2026
cbc3c2d
test: add failing tests for Phase 1 — generate-openclaw-config.py
jyaunches Apr 24, 2026
ca46f47
feat: extract inline Python config to scripts/generate-openclaw-confi…
jyaunches Apr 24, 2026
563b57d
Mark Phase 1 as completed [ca46f470]
jyaunches Apr 24, 2026
e84a548
test: add failing tests for Phase 2 — non-loopback auto-disable devic…
jyaunches Apr 24, 2026
054f1d7
feat: auto-disable device auth for non-loopback URLs (#2341)
jyaunches Apr 24, 2026
1dbfc56
Mark Phase 2 as completed [054f1d73]
jyaunches Apr 24, 2026
8ec43d8
validation: 9/10 scenarios validated, 1 skipped (Docker not running)
jyaunches Apr 24, 2026
581dd8b
chore: remove specs/ directory from tracked files
jyaunches Apr 24, 2026
bdd663e
Merge remote-tracking branch 'origin/main' into issue-2341-brev-launc…
jyaunches Apr 24, 2026
2d928c3
fix: update nemoclaw-start.test.ts for extracted config script
jyaunches Apr 24, 2026
68aed03
Merge remote-tracking branch 'origin/main' into issue-2341-brev-launc…
jyaunches Apr 25, 2026
50d29bf
fix: address CodeRabbit review — defaults and schemeless URL parsing
jyaunches Apr 25, 2026
4dc3faf
fix: make --clear-token idempotent when config absent
jyaunches Apr 26, 2026
9f5d517
fix: address remaining CodeRabbit review feedback
ericksoa Apr 27, 2026
5c88191
Merge branch 'main' into issue-2341-brev-launchable-pairing-required
ericksoa Apr 27, 2026
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
80 changes: 9 additions & 71 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \
# Copy startup script and shared sandbox initialisation library
COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh
COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh

# Build args for config that varies per deployment.
Expand Down Expand Up @@ -228,8 +229,10 @@ ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=
# (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}).
# Used to enable guild-channel responses for native Discord. Default: empty map.
ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=
# Set to "1" to disable device-pairing auth (development/headless only).
# Default: "0" (device auth enabled — secure by default).
# Set to "1" to force-disable device-pairing auth. Also auto-disabled when
# CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments)
# since terminal-based pairing is impossible in those contexts.
# Default: "0" (device auth enabled for local deployments — secure by default).
ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0
# Unique per build — busts the Docker cache for the token-injection layer
# so each image gets a fresh gateway auth token.
Expand Down Expand Up @@ -288,75 +291,10 @@ USER sandbox
# the OpenShell proxy. Mirror of the Telegram treatment immediately below.
# Remove once OpenClaw lands an env-var-honouring fix for the Discord
# gateway equivalent to openclaw/openclaw#62878 (Slack Socket Mode).
RUN python3 -c "\
import base64, json, os; \
from urllib.parse import urlparse; \
proxy_url = f\"http://{os.environ['NEMOCLAW_PROXY_HOST']}:{os.environ['NEMOCLAW_PROXY_PORT']}\"; \
model = os.environ['NEMOCLAW_MODEL']; \
chat_ui_url = os.environ['CHAT_UI_URL']; \
provider_key = os.environ['NEMOCLAW_PROVIDER_KEY']; \
primary_model_ref = os.environ['NEMOCLAW_PRIMARY_MODEL_REF']; \
inference_base_url = os.environ['NEMOCLAW_INFERENCE_BASE_URL']; \
inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \
context_window = int(os.environ.get('NEMOCLAW_CONTEXT_WINDOW', '131072')); \
max_tokens = int(os.environ.get('NEMOCLAW_MAX_TOKENS', '4096')); \
reasoning = os.environ.get('NEMOCLAW_REASONING', 'false') == 'true'; \
inference_inputs = [v.strip() for v in os.environ.get('NEMOCLAW_INFERENCE_INPUTS', 'text').split(',') if v.strip()] or ['text']; \
_raw_agent_timeout = os.environ.get('NEMOCLAW_AGENT_TIMEOUT', '600'); \
agent_timeout = int(_raw_agent_timeout) if _raw_agent_timeout.isdigit() and int(_raw_agent_timeout) > 0 else (_ for _ in ()).throw(ValueError('NEMOCLAW_AGENT_TIMEOUT must be a positive integer')); \
inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \
msg_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_CHANNELS_B64', 'W10=') or 'W10=').decode('utf-8')); \
_allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_ALLOWED_IDS_B64', 'e30=') or 'e30=').decode('utf-8')); \
_discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_B64', 'e30=') or 'e30=').decode('utf-8')); \
_token_keys = {'discord': 'token', 'telegram': 'botToken', 'slack': 'botToken'}; \
_env_keys = {'discord': 'DISCORD_BOT_TOKEN', 'telegram': 'TELEGRAM_BOT_TOKEN', 'slack': 'SLACK_BOT_TOKEN'}; \
_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, 'healthMonitor': {'enabled': False}, **({'appToken': 'openshell:resolve:env:SLACK_APP_TOKEN'} if ch == 'slack' else {}), **({'proxy': proxy_url} if ch in ('telegram', 'discord') else {}), **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \
_ch_cfg['discord'].update({'groupPolicy': 'allowlist', 'guilds': _discord_guilds}) if 'discord' in _ch_cfg and _discord_guilds else None; \
parsed = urlparse(chat_ui_url); \
chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \
origins = ['http://127.0.0.1:18789']; \
origins = list(dict.fromkeys(origins + [chat_origin])); \
disable_device_auth = os.environ.get('NEMOCLAW_DISABLE_DEVICE_AUTH', '') == '1'; \
allow_insecure = parsed.scheme == 'http'; \
providers = { \
provider_key: { \
'baseUrl': inference_base_url, \
'apiKey': 'unused', \
'api': inference_api, \
'models': [{**({'compat': inference_compat} if inference_compat else {}), 'id': model, 'name': primary_model_ref, 'reasoning': reasoning, 'input': inference_inputs, 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': context_window, 'maxTokens': max_tokens}] \
} \
}; \
config = { \
'agents': {'defaults': {'model': {'primary': primary_model_ref}, 'timeoutSeconds': agent_timeout}}, \
'models': {'mode': 'merge', 'providers': providers}, \
'channels': {'defaults': {}, **_ch_cfg}, \
'update': {'checkOnStart': False}, \
'gateway': { \
'mode': 'local', \
'controlUi': { \
'allowInsecureAuth': allow_insecure, \
'dangerouslyDisableDeviceAuth': disable_device_auth, \
'allowedOrigins': origins, \
}, \
'trustedProxies': ['127.0.0.1', '::1'], \
'auth': {'token': ''} \
} \
}; \
config.update({ \
'tools': { \
'web': { \
'search': { \
'enabled': True, \
'provider': 'brave', \
'apiKey': 'openshell:resolve:env:BRAVE_API_KEY' \
}, \
'fetch': {'enabled': True} \
} \
} \
}) if os.environ.get('NEMOCLAW_WEB_SEARCH_ENABLED', '') == '1' else None; \
path = os.path.expanduser('~/.openclaw/openclaw.json'); \
json.dump(config, open(path, 'w'), indent=2); \
os.chmod(path, 0o600)"
# Generate openclaw.json from environment variables. Config generation logic
# lives in scripts/generate-openclaw-config.py — see that file for the full
# list of env vars and derivation rules.
RUN python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py

# Install NemoClaw plugin into OpenClaw
RUN openclaw doctor --fix > /dev/null 2>&1 || true \
Expand Down
244 changes: 244 additions & 0 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Generate openclaw.json from environment variables.

Called at Docker image build time (RUN layer) after ARG→ENV promotion.
Reads all configuration from os.environ — never from string interpolation
in Dockerfile source. See: C-2 security model.

Usage:
python3 scripts/generate-openclaw-config.py # Generate config

Environment variables:
CHAT_UI_URL Dashboard URL (default: http://127.0.0.1:18789)
NEMOCLAW_MODEL Model identifier
NEMOCLAW_PROVIDER_KEY Provider key for model config
NEMOCLAW_PRIMARY_MODEL_REF Primary model reference
NEMOCLAW_INFERENCE_BASE_URL Inference endpoint
NEMOCLAW_INFERENCE_API Inference API type
NEMOCLAW_INFERENCE_INPUTS Comma-separated model inputs (default: text)
NEMOCLAW_CONTEXT_WINDOW Context window size (default: 131072)
NEMOCLAW_MAX_TOKENS Max tokens (default: 4096)
NEMOCLAW_REASONING Enable reasoning (default: false)
NEMOCLAW_AGENT_TIMEOUT Per-request timeout seconds (default: 600)
NEMOCLAW_INFERENCE_COMPAT_B64 Base64-encoded inference compat JSON
NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded channel list
NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map
NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config
NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth
NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1)
NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128)
NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools
"""

from __future__ import annotations

import base64
import json
import os
import re
from urllib.parse import urlparse


def is_loopback(hostname: str) -> bool:
"""Check if a hostname is a loopback address.

Mirrors isLoopbackHostname() from src/lib/url-utils.ts.
Returns True for localhost, ::1, and 127.x.x.x addresses.
"""
normalized = (hostname or "").strip().lower().strip("[]")
if normalized == "localhost" or normalized == "::1":
return True
return bool(re.match(r"^127(?:\.\d{1,3}){3}$", normalized))


def build_config(env: dict | None = None) -> dict:
"""Build the complete openclaw config dict from environment variables.

Args:
env: Dict of environment variables. Defaults to os.environ.

Returns:
Complete config dict ready to be written as JSON.
"""
if env is None:
env = dict(os.environ)

# Treat empty-string env vars as unset so the documented defaults still
# apply when callers pass an explicit "" (e.g. `docker build --build-arg
# CHAT_UI_URL=`).
proxy_host = env.get("NEMOCLAW_PROXY_HOST") or "10.200.0.1"
proxy_port = env.get("NEMOCLAW_PROXY_PORT") or "3128"
proxy_url = f"http://{proxy_host}:{proxy_port}"
model = env["NEMOCLAW_MODEL"]
chat_ui_url = env.get("CHAT_UI_URL") or "http://127.0.0.1:18789"
provider_key = env["NEMOCLAW_PROVIDER_KEY"]
primary_model_ref = env["NEMOCLAW_PRIMARY_MODEL_REF"]
inference_base_url = env["NEMOCLAW_INFERENCE_BASE_URL"]
inference_api = env["NEMOCLAW_INFERENCE_API"]
context_window = int(env.get("NEMOCLAW_CONTEXT_WINDOW", "131072"))
max_tokens = int(env.get("NEMOCLAW_MAX_TOKENS", "4096"))
reasoning = env.get("NEMOCLAW_REASONING", "false") == "true"
inference_inputs = [
v.strip()
for v in env.get("NEMOCLAW_INFERENCE_INPUTS", "text").split(",")
if v.strip()
] or ["text"]

_raw_agent_timeout = env.get("NEMOCLAW_AGENT_TIMEOUT", "600")
if not _raw_agent_timeout.isdigit() or int(_raw_agent_timeout) <= 0:
raise ValueError("NEMOCLAW_AGENT_TIMEOUT must be a positive integer")
agent_timeout = int(_raw_agent_timeout)

inference_compat = json.loads(
base64.b64decode(env["NEMOCLAW_INFERENCE_COMPAT_B64"]).decode("utf-8")
)

msg_channels = json.loads(
base64.b64decode(
env.get("NEMOCLAW_MESSAGING_CHANNELS_B64", "W10=") or "W10="
).decode("utf-8")
)
_allowed_ids = json.loads(
base64.b64decode(
env.get("NEMOCLAW_MESSAGING_ALLOWED_IDS_B64", "e30=") or "e30="
).decode("utf-8")
)
_discord_guilds = json.loads(
base64.b64decode(
env.get("NEMOCLAW_DISCORD_GUILDS_B64", "e30=") or "e30="
).decode("utf-8")
)

_token_keys = {"discord": "token", "telegram": "botToken", "slack": "botToken"}
_env_keys = {
"discord": "DISCORD_BOT_TOKEN",
"telegram": "TELEGRAM_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
}

_ch_cfg = {}
for ch in msg_channels:
if ch not in _token_keys:
continue
account = {
_token_keys[ch]: f"openshell:resolve:env:{_env_keys[ch]}",
"enabled": True,
"healthMonitor": {"enabled": False},
}
if ch == "slack":
account["appToken"] = "openshell:resolve:env:SLACK_APP_TOKEN"
if ch in ("telegram", "discord"):
account["proxy"] = proxy_url
if ch == "telegram":
account["groupPolicy"] = "open"
if ch in _allowed_ids and _allowed_ids[ch]:
account["dmPolicy"] = "allowlist"
account["allowFrom"] = _allowed_ids[ch]
_ch_cfg[ch] = {"accounts": {"default": account}}

if "discord" in _ch_cfg and _discord_guilds:
_ch_cfg["discord"].update(
{"groupPolicy": "allowlist", "guilds": _discord_guilds}
)

# Normalize schemeless URLs before parsing — urlparse("remote-host:18789")
# misclassifies hostname as scheme. Mirrors ensureScheme() in dashboard-contract.ts.
_normalized_url = chat_ui_url
if chat_ui_url and not re.match(r"^[a-z][a-z0-9+.-]*://", chat_ui_url, re.IGNORECASE):
_normalized_url = f"http://{chat_ui_url}"

parsed = urlparse(_normalized_url)
chat_origin = (
f"{parsed.scheme}://{parsed.netloc}"
if parsed.scheme and parsed.netloc
else "http://127.0.0.1:18789"
)
origins = list(dict.fromkeys(["http://127.0.0.1:18789", chat_origin]))

# Auto-disable device auth when CHAT_UI_URL is non-loopback — terminal-based
# pairing is impossible when the user only has web access (Brev Launchable,
# remote deployments). The explicit env var override still works but cannot
# re-enable device auth for non-loopback URLs (security default).
_is_remote = not is_loopback(parsed.hostname or "")
disable_device_auth = (
env.get("NEMOCLAW_DISABLE_DEVICE_AUTH", "") == "1"
or _is_remote
)
allow_insecure = parsed.scheme == "http"

providers = {
provider_key: {
"baseUrl": inference_base_url,
"apiKey": "unused",
"api": inference_api,
"models": [
{
**({"compat": inference_compat} if inference_compat else {}),
"id": model,
"name": primary_model_ref,
"reasoning": reasoning,
"input": inference_inputs,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
},
"contextWindow": context_window,
"maxTokens": max_tokens,
}
],
}
}

config = {
"agents": {
"defaults": {
"model": {"primary": primary_model_ref},
"timeoutSeconds": agent_timeout,
}
},
"models": {"mode": "merge", "providers": providers},
"channels": {"defaults": {}, **_ch_cfg},
"update": {"checkOnStart": False},
"gateway": {
"mode": "local",
"controlUi": {
"allowInsecureAuth": allow_insecure,
"dangerouslyDisableDeviceAuth": disable_device_auth,
"allowedOrigins": origins,
},
"trustedProxies": ["127.0.0.1", "::1"],
"auth": {"token": ""},
},
}

if env.get("NEMOCLAW_WEB_SEARCH_ENABLED", "") == "1":
config["tools"] = {
"web": {
"search": {
"enabled": True,
"provider": "brave",
"apiKey": "openshell:resolve:env:BRAVE_API_KEY",
},
"fetch": {"enabled": True},
}
}

return config


def main() -> None:
"""Generate openclaw.json from environment variables."""
config = build_config()
path = os.path.expanduser("~/.openclaw/openclaw.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump(config, f, indent=2)
os.chmod(path, 0o600)


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
# Optional env:
# NVIDIA_API_KEY API key for NVIDIA-hosted inference
# CHAT_UI_URL Browser origin that will access the forwarded dashboard
# NEMOCLAW_DISABLE_DEVICE_AUTH Build-time only. Set to "1" to skip device-pairing auth
# NEMOCLAW_DISABLE_DEVICE_AUTH Build-time only. Set to "1" to skip device-pairing auth.
# Also auto-disabled when CHAT_UI_URL is non-loopback.
# (development/headless). Has no runtime effect — openclaw.json
# is baked at image build and verified by hash at startup.
# NEMOCLAW_MODEL_OVERRIDE Override the primary model at startup without rebuilding
Expand Down
12 changes: 12 additions & 0 deletions src/lib/dashboard-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe("buildChain", () => {
healthEndpoint: "/health", port: 18789, bindAddress: "127.0.0.1",
});
expect(c.corsOrigins).toEqual(["http://127.0.0.1:18789"]);
expect(c.shouldDisableDeviceAuth).toBe(false);
});

it("preserves custom port from loopback URL", () => {
Expand All @@ -26,13 +27,15 @@ describe("buildChain", () => {
expect(c.bindAddress).toBe("0.0.0.0");
expect(c.corsOrigins[0]).toBe("http://127.0.0.1:18789");
expect(c.corsOrigins).toContain("https://my-brev-host.example.com:18789");
expect(c.shouldDisableDeviceAuth).toBe(true);
});

it("uses WSL host address and binds to 0.0.0.0", () => {
const c = buildChain({ isWsl: true, wslHostAddress: "172.24.240.1" });
expect(c.forwardTarget).toBe("0.0.0.0:18789");
expect(c.accessUrl).toBe("http://172.24.240.1:18789");
expect(c.corsOrigins).toContain("http://172.24.240.1:18789");
expect(c.shouldDisableDeviceAuth).toBe(true);
});

it("respects explicit port override", () => {
Expand All @@ -53,6 +56,15 @@ describe("buildChain", () => {
const c = buildChain({ chatUiUrl: "remote-host:18789" });
expect(c.accessUrl).toBe("http://remote-host:18789");
expect(c.forwardTarget).toBe("0.0.0.0:18789");
expect(c.shouldDisableDeviceAuth).toBe(true);
});

it("shouldDisableDeviceAuth is false for localhost", () => {
expect(buildChain({ chatUiUrl: "http://localhost:18789" }).shouldDisableDeviceAuth).toBe(false);
});

it("shouldDisableDeviceAuth is false for IPv6 loopback", () => {
expect(buildChain({ chatUiUrl: "http://[::1]:18789" }).shouldDisableDeviceAuth).toBe(false);
});
});

Expand Down
Loading
Loading