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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 110 additions & 13 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,111 @@ verify_config_integrity() {
fi
}

_read_gateway_token() {
python3 - <<'PYTOKEN'
import json
try:
with open('/sandbox/.openclaw/openclaw.json') as f:
cfg = json.load(f)
print(cfg.get('gateway', {}).get('auth', {}).get('token', ''))
except Exception:
print('')
PYTOKEN
}

export_gateway_token() {
local token
token="$(_read_gateway_token)"
local marker_begin="# nemoclaw-gateway-token begin"
local marker_end="# nemoclaw-gateway-token end"

if [ -z "$token" ]; then
# Remove any stale marker blocks from rc files so revoked/old tokens
# are not re-exported in later interactive sessions.
unset OPENCLAW_GATEWAY_TOKEN
for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do
if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then
local tmp
tmp="$(mktemp)"
awk -v b="$marker_begin" -v e="$marker_end" \
'$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp"
cat "$tmp" >"$rc_file"
rm -f "$tmp"
fi
done
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export OPENCLAW_GATEWAY_TOKEN="$token"

# Persist to .bashrc/.profile so interactive sessions (openshell sandbox
# connect) also see the token — same pattern as the proxy config above.
# Shell-escape the token so quotes/dollars/backticks cannot break the
# sourced snippet or allow code injection.
local escaped_token
escaped_token="$(printf '%s' "$token" | sed "s/'/'\\\\''/g")"
local snippet
snippet="${marker_begin}
export OPENCLAW_GATEWAY_TOKEN='${escaped_token}'
${marker_end}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do
if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then
local tmp
tmp="$(mktemp)"
awk -v b="$marker_begin" -v e="$marker_end" \
'$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp"
printf '%s\n' "$snippet" >>"$tmp"
cat "$tmp" >"$rc_file"
rm -f "$tmp"
elif [ -w "$rc_file" ] || [ -w "$(dirname "$rc_file")" ]; then
printf '\n%s\n' "$snippet" >>"$rc_file"
fi
done
}

install_configure_guard() {
# Installs a shell function that intercepts `openclaw configure` inside the
# sandbox. The config is Landlock read-only — atomic writes to
# /sandbox/.openclaw/ fail with EACCES. Instead of a cryptic error, guide
# the user to the correct host-side workflow.
local marker_begin="# nemoclaw-configure-guard begin"
local marker_end="# nemoclaw-configure-guard end"
local snippet
read -r -d '' snippet <<'GUARD' || true
# nemoclaw-configure-guard begin
openclaw() {
case "$1" in
configure)
echo "Error: 'openclaw configure' cannot modify config inside the sandbox." >&2
echo "The sandbox config is read-only (Landlock enforced) for security." >&2
echo "" >&2
echo "To change your configuration, exit the sandbox and run:" >&2
echo " nemoclaw onboard --resume" >&2
echo "" >&2
echo "This rebuilds the sandbox with your updated settings." >&2
return 1
;;
esac
command openclaw "$@"
}
# nemoclaw-configure-guard end
GUARD

for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do
if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then
local tmp
tmp="$(mktemp)"
awk -v b="$marker_begin" -v e="$marker_end" \
'$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp"
printf '%s\n' "$snippet" >>"$tmp"
cat "$tmp" >"$rc_file"
rm -f "$tmp"
elif [ -w "$rc_file" ] || [ -w "$(dirname "$rc_file")" ]; then
printf '\n%s\n' "$snippet" >>"$rc_file"
fi
done
}

validate_openclaw_symlinks() {
local entry name target expected
for entry in /sandbox/.openclaw/*; do
Expand Down Expand Up @@ -205,19 +310,7 @@ configure_messaging_channels() {
print_dashboard_urls() {
local token chat_ui_base local_url remote_url

token="$(
python3 - <<'PYTOKEN'
import json
import os
path = '/sandbox/.openclaw/openclaw.json'
try:
cfg = json.load(open(path))
except Exception:
print('')
else:
print(cfg.get('gateway', {}).get('auth', {}).get('token', ''))
PYTOKEN
)"
token="$(_read_gateway_token)"

chat_ui_base="${CHAT_UI_URL%/}"
local_url="http://127.0.0.1:${PUBLIC_PORT}/"
Expand Down Expand Up @@ -410,6 +503,8 @@ if [ "$(id -u)" -ne 0 ]; then
echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2
exit 1
fi
export_gateway_token
install_configure_guard
configure_messaging_channels
validate_openclaw_symlinks
write_auth_profile
Expand Down Expand Up @@ -441,6 +536,8 @@ fi

# Verify config integrity before starting anything
verify_config_integrity
export_gateway_token
install_configure_guard

# Inject messaging channel config if provider tokens are present.
# Must run AFTER integrity check (to detect build-time tampering) and
Expand Down
94 changes: 94 additions & 0 deletions test/nemoclaw-start.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,100 @@ describe("nemoclaw-start non-root fallback", () => {
});
});

describe("nemoclaw-start gateway token export (#1114)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

it("defines _read_gateway_token helper used by both export and dashboard", () => {
expect(src).toMatch(/_read_gateway_token\(\) \{/);
// export_gateway_token calls the helper
expect(src).toMatch(/token="\$\(_read_gateway_token\)"/);
// print_dashboard_urls also calls the helper
const dashboardFn = src.match(/print_dashboard_urls\(\) \{([\s\S]*?)^\}/m);
expect(dashboardFn).toBeTruthy();
expect(dashboardFn[1]).toContain("_read_gateway_token");
});

it("uses with-open context manager in the Python snippet", () => {
const helperFn = src.match(/_read_gateway_token\(\) \{([\s\S]*?)^\}/m);
expect(helperFn).toBeTruthy();
expect(helperFn[1]).toContain("with open(");
});

it("unsets stale OPENCLAW_GATEWAY_TOKEN when token is empty", () => {
const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m);
expect(exportFn).toBeTruthy();
const body = exportFn[1];
// Must unset before returning on empty token
const unsetPos = body.indexOf("unset OPENCLAW_GATEWAY_TOKEN");
const returnPos = body.indexOf("return");
expect(unsetPos).toBeGreaterThan(-1);
expect(returnPos).toBeGreaterThan(-1);
expect(unsetPos).toBeLessThan(returnPos);
});

it("shell-escapes the token before embedding in rc snippet", () => {
const exportFn = src.match(/export_gateway_token\(\) \{([\s\S]*?)^\}/m);
expect(exportFn).toBeTruthy();
const body = exportFn[1];
// Must use single quotes around the escaped token value
expect(body).toContain("escaped_token");
expect(body).toMatch(/export OPENCLAW_GATEWAY_TOKEN='\$\{escaped_token\}'/);
});

it("calls export_gateway_token in both root and non-root paths", () => {
const calls = src.match(/export_gateway_token/g) || [];
// definition + 2 call sites
expect(calls.length).toBeGreaterThanOrEqual(3);
});
});

describe("nemoclaw-start configure guard (#1114)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

it("defines install_configure_guard function", () => {
expect(src).toMatch(/install_configure_guard\(\) \{/);
});

it("intercepts openclaw configure with an actionable error", () => {
// The guard installs a heredoc containing a shell function — extract the
// full block between the function definition and the next top-level function.
const guardBlock = src.match(
/install_configure_guard\(\) \{([\s\S]*?)^validate_openclaw_symlinks/m,
);
expect(guardBlock).toBeTruthy();
const body = guardBlock[1];
expect(body).toContain("configure)");
expect(body).toContain("nemoclaw onboard --resume");
expect(body).toContain("return 1");
});

it("passes non-configure subcommands through to the real binary", () => {
const guardBlock = src.match(
/install_configure_guard\(\) \{([\s\S]*?)^validate_openclaw_symlinks/m,
);
expect(guardBlock).toBeTruthy();
expect(guardBlock[1]).toContain('command openclaw "$@"');
});

it("uses idempotent marker blocks", () => {
const guardBlock = src.match(
/install_configure_guard\(\) \{([\s\S]*?)^validate_openclaw_symlinks/m,
);
expect(guardBlock).toBeTruthy();
const body = guardBlock[1];
expect(body).toContain("nemoclaw-configure-guard begin");
expect(body).toContain("nemoclaw-configure-guard end");
// Uses awk to strip existing block before re-inserting
expect(body).toContain("awk");
});

it("calls install_configure_guard in both root and non-root paths", () => {
const calls = src.match(/install_configure_guard/g) || [];
// definition + 2 call sites
expect(calls.length).toBeGreaterThanOrEqual(3);
});
});

describe("nemoclaw-start auto-pair client whitelisting (#117)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

Expand Down
Loading