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
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ FROM ${BASE_IMAGE}
ARG OPENCLAW_VERSION=2026.5.27
ARG OPENCLAW_2026_5_27_INTEGRITY=sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw==

# OpenClaw 2026.5.27 loads some generated source through jiti. Disable its
# filesystem transform cache so source fragments that mention provider marker
# names do not persist under /tmp/jiti inside the sandbox.
ENV JITI_FS_CACHE=false

# Harden: remove unnecessary build tools and network probes from base image (#830)
# Protect runtime tools before autoremove — the GHCR base may predate the
# procps/e2fsprogs/tmux additions, leaving ps/chattr/tmux absent or auto-marked.
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ RUN printf '%s\n' \
ARG OPENCLAW_VERSION=2026.5.27
ARG OPENCLAW_2026_5_27_INTEGRITY=sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw==

# Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker
# names do not persist in runtime snapshots or leak-scan inputs.
ENV JITI_FS_CACHE=false

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# Install OpenClaw CLI + PyYAML.
Expand Down
110 changes: 108 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,20 @@ HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing
# timeout reduction, and token cleanup for a more comprehensive fix.
ALLOWED_CLIENTS = {'openclaw-control-ui'}
ALLOWED_MODES = {'webchat', 'cli'}
ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'}


def requested_scopes(device):
if 'scopes' in device:
scopes = device.get('scopes')
elif 'requestedScopes' in device:
scopes = device.get('requestedScopes')
else:
return set()
if not isinstance(scopes, list):
return None
return {str(scope).strip() for scope in scopes if str(scope or '').strip()}

Comment thread
coderabbitai[bot] marked this conversation as resolved.

RUN_TIMEOUT_SECS = _env_seconds('NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS', 10)

Expand Down Expand Up @@ -1836,6 +1850,15 @@ while time.time() < DEADLINE:
HANDLED.add(request_id)
print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}')
continue
scopes = requested_scopes(device)
if scopes is None:
HANDLED.add(request_id)
print(f'[auto-pair] rejected malformed scopes client={client_id} mode={client_mode}')
continue
if scopes and not scopes.issubset(ALLOWED_SCOPES):
HANDLED.add(request_id)
print(f'[auto-pair] rejected disallowed scopes={sorted(scopes)} client={client_id} mode={client_mode}')
continue
arc, aout, aerr = run(
OPENCLAW, 'devices', 'approve', request_id, '--json', strip_gateway_env=True,
)
Expand Down Expand Up @@ -2081,6 +2104,7 @@ export NO_PROXY="$_NO_PROXY_VAL"
export http_proxy="$_PROXY_URL"
export https_proxy="$_PROXY_URL"
export no_proxy="$_NO_PROXY_VAL"
export JITI_FS_CACHE="false"
PROXYEOF
local _openclaw_env_name _openclaw_env_value _escaped_openclaw_env_value
for _openclaw_env_name in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_OAUTH_DIR OPENCLAW_WORKSPACE_DIR; do
Expand Down Expand Up @@ -2110,8 +2134,90 @@ openclaw() {
# the approval command itself. Approval calls temporarily drop the gateway
# URL/port/token; other commands keep the full gateway environment.
if [ "${1:-}" = "devices" ] && [ "${2:-}" = "approve" ]; then
( unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT OPENCLAW_GATEWAY_TOKEN; command openclaw "$@" )
return $?
_nemoclaw_approve_request_id="${3:-}"
_nemoclaw_approve_state_dir="${OPENCLAW_STATE_DIR:-/sandbox/.openclaw}"
_nemoclaw_approve_before=""
if [ -n "$_nemoclaw_approve_request_id" ] && command -v python3 >/dev/null 2>&1; then
_nemoclaw_approve_before="$(NEMOCLAW_APPROVE_REQUEST_ID="$_nemoclaw_approve_request_id" NEMOCLAW_APPROVE_STATE_DIR="$_nemoclaw_approve_state_dir" python3 - <<'PYAPPROVEBEFORE' 2>/dev/null || true
import json
import os
from pathlib import Path

root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices"
request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or ""
try:
pending = json.loads((root / "pending.json").read_text(encoding="utf-8"))
except Exception:
pending = {}
if not isinstance(pending, dict):
pending = {}
request = next((item for item in pending.values() if isinstance(item, dict) and item.get("requestId") == request_id), None)
if request:
print(json.dumps({
"requestId": request_id,
"deviceId": request.get("deviceId"),
"scopes": request.get("scopes") or request.get("requestedScopes") or [],
}, sort_keys=True))
PYAPPROVEBEFORE
)"
fi
_nemoclaw_approve_errexit=0
case $- in *e*) _nemoclaw_approve_errexit=1 ;; esac
set +e
_nemoclaw_approve_output="$(unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT OPENCLAW_GATEWAY_TOKEN; command openclaw "$@" 2>&1)"
_nemoclaw_approve_rc=$?
if [ "$_nemoclaw_approve_errexit" = "1" ]; then set -e; else set +e; fi
if [ "$_nemoclaw_approve_rc" -eq 0 ]; then
printf '%s\n' "$_nemoclaw_approve_output"
return 0
fi
if [ -n "$_nemoclaw_approve_request_id" ] && [ -n "$_nemoclaw_approve_before" ] && command -v python3 >/dev/null 2>&1; then
if NEMOCLAW_APPROVE_REQUEST_ID="$_nemoclaw_approve_request_id" NEMOCLAW_APPROVE_STATE_DIR="$_nemoclaw_approve_state_dir" NEMOCLAW_APPROVE_BEFORE="$_nemoclaw_approve_before" python3 - <<'PYAPPROVEAFTER'; then
import json
import os
from pathlib import Path

request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or ""
root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices"
try:
before = json.loads(os.environ.get("NEMOCLAW_APPROVE_BEFORE") or "{}")
except Exception:
before = {}

def load(name):
try:
value = json.loads((root / name).read_text(encoding="utf-8"))
except Exception:
return {}
return value if isinstance(value, dict) else {}

def norm(value):
return str(value or "").strip()

def scopes(entry):
return {norm(scope) for scope in (entry.get("approvedScopes") or entry.get("scopes") or []) if norm(scope)}

requested = {norm(scope) for scope in (before.get("scopes") or []) if norm(scope)}
device_id = norm(before.get("deviceId"))
pending = load("pending.json")
paired = load("paired.json")
still_pending = any(isinstance(item, dict) and item.get("requestId") == request_id for item in pending.values())
paired_entry = paired.get(device_id) if device_id else None
if request_id and requested and not still_pending and isinstance(paired_entry, dict) and requested.issubset(scopes(paired_entry)):
print(json.dumps({
"requestId": request_id,
"deviceId": device_id,
"approvedScopes": sorted(requested),
"compatibility": "openclaw-approve-applied-after-nonzero",
}, sort_keys=True))
raise SystemExit(0)
raise SystemExit(1)
PYAPPROVEAFTER
return 0
fi
fi
printf '%s\n' "$_nemoclaw_approve_output"
return "$_nemoclaw_approve_rc"
fi
case "$1" in
configure)
Expand Down
18 changes: 18 additions & 0 deletions src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,8 +697,21 @@ import sys
OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw')
ALLOWED_CLIENTS = {'openclaw-control-ui'}
ALLOWED_MODES = {'webchat', 'cli'}
ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'}
MAX_APPROVALS = ${CONNECT_AUTO_PAIR_MAX_APPROVALS}


def requested_scopes(device):
if 'scopes' in device:
scopes = device.get('scopes')
elif 'requestedScopes' in device:
scopes = device.get('requestedScopes')
else:
return set()
if not isinstance(scopes, list):
return None
return {str(scope).strip() for scope in scopes if str(scope or '').strip()}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
proc = subprocess.run(
[OPENCLAW, 'devices', 'list', '--json'],
Expand Down Expand Up @@ -732,9 +745,14 @@ for device in pending:
client_mode = device.get('clientMode', '')
if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES:
continue
scopes = requested_scopes(device)
if scopes is None or (scopes and not scopes.issubset(ALLOWED_SCOPES)):
continue
seen_request_ids.add(request_id)
approve_env = os.environ.copy()
approve_env.pop('OPENCLAW_GATEWAY_URL', None)
approve_env.pop('OPENCLAW_GATEWAY_PORT', None)
approve_env.pop('OPENCLAW_GATEWAY_TOKEN', None)
attempted_count += 1
try:
approve_proc = subprocess.run(
Expand Down
39 changes: 36 additions & 3 deletions test/e2e/test-issue-4462-scope-upgrade-approval.sh
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ for req in sorted(pending, key=lambda item: item.get("ts") or 0, reverse=True):
print(req["requestId"])
raise SystemExit(0)
if kind == "scope-upgrade" and paired_entry and roles(req).issubset(roles(paired_entry) or roles(req)):
if requested and not requested.issubset(approved):
# OpenClaw 2026.5.27 may create a follow-on operator.admin request after
# the write/read request has already been applied. Keep this selector
# focused on the NemoClaw gateway-mode upgrade contract.
if {"operator.write", "operator.read"}.intersection(requested) and not requested.issubset(approved):
print(req["requestId"])
raise SystemExit(0)
raise SystemExit(1)
Expand Down Expand Up @@ -363,7 +366,32 @@ for device in sorted(paired, key=lambda item: item.get("approvedAtMs") or 0, rev
if not is_cli(device):
continue
approved = scopes(device)
if "operator.admin" in approved or {"operator.write", "operator.read"}.issubset(approved):
if {"operator.write", "operator.read"}.issubset(approved):
print(norm(device.get("deviceId")) or "cli-device")
raise SystemExit(0)
raise SystemExit(1)
'
}

select_cli_paired_with_admin() {
python3 -c '
import json
import sys

doc = json.load(sys.stdin)
paired = [p for p in doc.get("paired") or [] if isinstance(p, dict)]

def norm(value):
return str(value or "").strip()

def is_cli(entry):
return norm(entry.get("clientMode")).lower() == "cli" or "cli" in norm(entry.get("clientId")).lower()

def scopes(entry):
return {norm(scope) for scope in (entry.get("approvedScopes") or entry.get("scopes") or []) if norm(scope)}

for device in sorted(paired, key=lambda item: item.get("approvedAtMs") or 0, reverse=True):
if is_cli(device) and "operator.admin" in scopes(device):
print(norm(device.get("deviceId")) or "cli-device")
raise SystemExit(0)
raise SystemExit(1)
Expand Down Expand Up @@ -957,6 +985,7 @@ state="$(device_state_json 2>&1)" || {
printf '=== state after scope-upgrade approval ===\n%s\n' "$state" >>"$STATE_LOG"
pending_after_approval=$(printf '%s' "$state" | select_cli_request scope-upgrade 2>/dev/null) || pending_after_approval=""
paired_with_agent_scopes=$(printf '%s' "$state" | select_cli_paired_with_agent_scopes 2>/dev/null) || paired_with_agent_scopes=""
paired_with_admin=$(printf '%s' "$state" | select_cli_paired_with_admin 2>/dev/null) || paired_with_admin=""
if [ -n "$pending_after_approval" ]; then
fail "Scope-upgrade request is still pending after approval (${pending_after_approval})"
exit 1
Expand All @@ -965,7 +994,11 @@ if [ -z "$paired_with_agent_scopes" ]; then
fail "No CLI paired device has operator.write and operator.read after approval: $(printf '%s' "$state" | summarize_device_state)"
exit 1
fi
pass "scope-upgrade approval grants the CLI device operator.write and operator.read"
if [ -n "$paired_with_admin" ]; then
fail "Unexpected operator.admin approval for CLI device (${paired_with_admin})"
exit 1
fi
pass "scope-upgrade approval grants the CLI device operator.write and operator.read without approving operator.admin"

section "Phase 5: Verify agent stays on gateway path"

Expand Down
116 changes: 116 additions & 0 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,122 @@ exit 2
}
}, 40_000);

it("rejects malformed CLI scope request payloads", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-malformed-"));
const fakeOpenclaw = path.join(tmpDir, "openclaw");
const approveLog = path.join(tmpDir, "approvals.log");
const malformedPending = JSON.stringify({
pending: [
{
requestId: "malformed-cli",
clientId: "openclaw-cli",
clientMode: "cli",
scopes: "operator.write",
},
],
paired: [],
});

fs.writeFileSync(
fakeOpenclaw,
`#!/usr/bin/env bash
set -euo pipefail
if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then
printf '%s\n' ${JSON.stringify(malformedPending)}
exit 0
fi
if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then
echo "$3" >> ${JSON.stringify(approveLog)}
printf '{}\n'
exit 0
fi
echo "unexpected: $*" >&2
exit 2
`,
{ mode: 0o755 },
);

try {
const run = spawnSync("python3", ["-c", buildAutoPairScript()], {
encoding: "utf-8",
env: {
...process.env,
OPENCLAW_BIN: fakeOpenclaw,
NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001",
NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2",
NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1",
},
timeout: 20_000,
});
expect(run.status).toBe(0);
expect(run.stdout).toContain(
"[auto-pair] rejected malformed scopes client=openclaw-cli mode=cli",
);
expect(run.stdout).toContain("watcher deadline reached approvals=0");
expect(fs.existsSync(approveLog)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);

it("rejects disallowed CLI admin scope requests", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-admin-"));
const fakeOpenclaw = path.join(tmpDir, "openclaw");
const approveLog = path.join(tmpDir, "approvals.log");
const adminPending = JSON.stringify({
pending: [
{
requestId: "admin-cli",
clientId: "openclaw-cli",
clientMode: "cli",
scopes: ["operator.admin"],
},
],
paired: [],
});

fs.writeFileSync(
fakeOpenclaw,
`#!/usr/bin/env bash
set -euo pipefail
if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then
printf '%s\n' ${JSON.stringify(adminPending)}
exit 0
fi
if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then
echo "$3" >> ${JSON.stringify(approveLog)}
printf '{}\n'
exit 0
fi
echo "unexpected: $*" >&2
exit 2
`,
{ mode: 0o755 },
);

try {
const run = spawnSync("python3", ["-c", buildAutoPairScript()], {
encoding: "utf-8",
env: {
...process.env,
OPENCLAW_BIN: fakeOpenclaw,
NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001",
NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2",
NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1",
},
timeout: 20_000,
});
expect(run.status).toBe(0);
expect(run.stdout).toContain(
"[auto-pair] rejected disallowed scopes=['operator.admin'] client=openclaw-cli mode=cli",
);
expect(run.stdout).toContain("watcher deadline reached approvals=0");
expect(fs.existsSync(approveLog)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);

it("falls back to fast-deadline transition when no convergence signal arrives", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-slow-fastdl-"));
const fakeOpenclaw = path.join(tmpDir, "openclaw");
Expand Down
Loading
Loading