From e2d4075fce3d4e7075818dd52ebe3153e91bf8e1 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 13:15:18 +0530 Subject: [PATCH 1/8] fix(openclaw): clear build-time device auth state Remove OpenClaw device identity and pending approval state after build-time plugin and doctor commands so runtime sandboxes start with fresh device auth state. This preserves the existing gateway token/proxy cleanup and adds a provisioning regression test. --- Dockerfile | 22 ++++------------ test/sandbox-provisioning.test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index aa9fdf4d26..0516fc328b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -743,26 +743,14 @@ RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-b # skills, and ad-hoc packages via the OpenShell L7 proxy. ENV NPM_CONFIG_OFFLINE=false -# SECURITY: Clear any gateway auth token that openclaw doctor/plugins may have -# auto-generated. The real token is created at container startup by the -# entrypoint (generate_gateway_token) and never stored in openclaw.json. +# SECURITY: Clear any gateway auth token and device-auth state that build-time +# openclaw doctor/plugins may have auto-generated. The real token, device +# identity, and pending/paired approvals are created at container startup. # Also add the final OpenClaw managed proxy config after build-time OpenClaw # commands are done, so runtime Discord/WebSocket traffic uses the OpenShell # gateway proxy without forcing image-build npm traffic through that proxy. -RUN python3 -c "\ -import json, os; \ -path = os.path.expanduser('~/.openclaw/openclaw.json'); \ -cfg = json.load(open(path)); \ -cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; \ -proxy_host = os.environ.get('NEMOCLAW_PROXY_HOST') or '10.200.0.1'; \ -proxy_port = os.environ.get('NEMOCLAW_PROXY_PORT') or '3128'; \ -cfg['proxy'] = { \ - 'enabled': True, \ - 'proxyUrl': f'http://{proxy_host}:{proxy_port}', \ - 'loopbackMode': 'gateway-only', \ -}; \ -json.dump(cfg, open(path, 'w'), indent=2); \ -os.chmod(path, 0o600)" +RUN rm -rf "$HOME/.openclaw/devices" "$HOME/.openclaw/identity" \ + && python3 -c "import json, os; path = os.path.expanduser('~/.openclaw/openclaw.json'); cfg = json.load(open(path)); cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; proxy_host = os.environ.get('NEMOCLAW_PROXY_HOST') or '10.200.0.1'; proxy_port = os.environ.get('NEMOCLAW_PROXY_PORT') or '3128'; cfg['proxy'] = {'enabled': True, 'proxyUrl': f'http://{proxy_host}:{proxy_port}', 'loopbackMode': 'gateway-only'}; json.dump(cfg, open(path, 'w'), indent=2); os.chmod(path, 0o600)" # Flatten stale published base images that still contain the old # .openclaw-data symlink bridge. OpenShell starts the sandbox as the sandbox diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index ff43de853d..1550cd12fb 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -305,6 +305,48 @@ describe("sandbox provisioning: non-messaging OpenClaw plugins", () => { }); }); +describe("sandbox provisioning: OpenClaw runtime state cleanup", () => { + it("removes build-time device auth state while preserving gateway config cleanup", () => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const command = dockerRunCommandBetween( + dockerfile, + "# SECURITY: Clear any gateway auth token", + "# Flatten stale published base images", + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-runtime-cleanup-")); + const home = path.join(tmp, "sandbox"); + const openclawDir = path.join(home, ".openclaw"); + try { + fs.mkdirSync(path.join(openclawDir, "devices"), { recursive: true }); + fs.mkdirSync(path.join(openclawDir, "identity"), { recursive: true }); + fs.writeFileSync(path.join(openclawDir, "devices", "pending.json"), "[]\n"); + fs.writeFileSync(path.join(openclawDir, "identity", "device.json"), "{}\n"); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify({ gateway: { auth: { token: "build-token" } } }), + ); + + const { result } = runLoggedDockerShell(command, tmp, [], { + HOME: home, + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + }); + + expect(result.status, `stderr: ${result.stderr}`).toBe(0); + expect(fs.existsSync(path.join(openclawDir, "devices"))).toBe(false); + expect(fs.existsSync(path.join(openclawDir, "identity"))).toBe(false); + const config = JSON.parse(fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8")); + expect(config.gateway.auth.token).toBe(""); + expect(config.proxy).toEqual({ + enabled: true, + proxyUrl: "http://10.200.0.1:3128", + loopbackMode: "gateway-only", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); function dockerfileEnvDirectives(text: string): string[] { const lines = text.split("\n"); const directives: string[] = []; From de1e4f2fdd3d591353f152491b486f2043996289 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 13:15:18 +0530 Subject: [PATCH 2/8] fix(openclaw): recover scope approval replacement Recover allowlisted OpenClaw scope-upgrade approvals when the approve CLI call replaces the original request with a broader gateway request. The wrapper now applies only the original captured same-device request, adds the read/write closure without operator.admin, removes the replacement pending request, and preserves gateway env for the caller. --- Dockerfile | 22 +++-- scripts/nemoclaw-start.sh | 138 ++++++++++++++++++++++++++---- test/nemoclaw-start.test.ts | 128 ++++++++++++++++++++++++++- test/sandbox-provisioning.test.ts | 42 --------- 4 files changed, 266 insertions(+), 64 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0516fc328b..aa9fdf4d26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -743,14 +743,26 @@ RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-b # skills, and ad-hoc packages via the OpenShell L7 proxy. ENV NPM_CONFIG_OFFLINE=false -# SECURITY: Clear any gateway auth token and device-auth state that build-time -# openclaw doctor/plugins may have auto-generated. The real token, device -# identity, and pending/paired approvals are created at container startup. +# SECURITY: Clear any gateway auth token that openclaw doctor/plugins may have +# auto-generated. The real token is created at container startup by the +# entrypoint (generate_gateway_token) and never stored in openclaw.json. # Also add the final OpenClaw managed proxy config after build-time OpenClaw # commands are done, so runtime Discord/WebSocket traffic uses the OpenShell # gateway proxy without forcing image-build npm traffic through that proxy. -RUN rm -rf "$HOME/.openclaw/devices" "$HOME/.openclaw/identity" \ - && python3 -c "import json, os; path = os.path.expanduser('~/.openclaw/openclaw.json'); cfg = json.load(open(path)); cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; proxy_host = os.environ.get('NEMOCLAW_PROXY_HOST') or '10.200.0.1'; proxy_port = os.environ.get('NEMOCLAW_PROXY_PORT') or '3128'; cfg['proxy'] = {'enabled': True, 'proxyUrl': f'http://{proxy_host}:{proxy_port}', 'loopbackMode': 'gateway-only'}; json.dump(cfg, open(path, 'w'), indent=2); os.chmod(path, 0o600)" +RUN python3 -c "\ +import json, os; \ +path = os.path.expanduser('~/.openclaw/openclaw.json'); \ +cfg = json.load(open(path)); \ +cfg.setdefault('gateway', {}).setdefault('auth', {})['token'] = ''; \ +proxy_host = os.environ.get('NEMOCLAW_PROXY_HOST') or '10.200.0.1'; \ +proxy_port = os.environ.get('NEMOCLAW_PROXY_PORT') or '3128'; \ +cfg['proxy'] = { \ + 'enabled': True, \ + 'proxyUrl': f'http://{proxy_host}:{proxy_port}', \ + 'loopbackMode': 'gateway-only', \ +}; \ +json.dump(cfg, open(path, 'w'), indent=2); \ +os.chmod(path, 0o600)" # Flatten stale published base images that still contain the old # .openclaw-data symlink bridge. OpenShell starts the sandbox as the sandbox diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 9a425036a8..0185d9010c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2292,6 +2292,12 @@ if request: print(json.dumps({ "requestId": request_id, "deviceId": request.get("deviceId"), + "publicKey": request.get("publicKey"), + "platform": request.get("platform"), + "clientId": request.get("clientId"), + "clientMode": request.get("clientMode"), + "role": request.get("role"), + "roles": request.get("roles") or [], "scopes": request.get("scopes") or request.get("requestedScopes") or [], }, sort_keys=True)) PYAPPROVEBEFORE @@ -2307,12 +2313,24 @@ PYAPPROVEBEFORE 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 + _nemoclaw_approve_recover=0 + case "$_nemoclaw_approve_output" in + *"scope upgrade pending approval"* | *"pairing required: device is asking for more scopes"* | *"unknown requestId"*) + _nemoclaw_approve_recover=1 + ;; + esac + if [ "$_nemoclaw_approve_recover" = "1" ] && [ -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 +import time from pathlib import Path +ALLOWED_CLIENTS = {"openclaw-control-ui"} +ALLOWED_MODES = {"webchat", "cli"} +ALLOWED_SCOPES = {"operator.pairing", "operator.read", "operator.write"} +SCOPE_ORDER = ["operator.pairing", "operator.read", "operator.write"] + request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or "" root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices" try: @@ -2327,27 +2345,117 @@ def load(name): return {} return value if isinstance(value, dict) else {} +def save(name, value): + root.mkdir(parents=True, exist_ok=True) + (root / name).write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + 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)} +def list_scopes(entry, *names): + for name in names: + value = entry.get(name) + if isinstance(value, list): + return {norm(scope) for scope in value if norm(scope)} + return set() + +def roles(entry): + values = set() + role = norm(entry.get("role")) + if role: + values.add(role) + for role in entry.get("roles") or []: + role = norm(role) + if role: + values.add(role) + return values + +def client_allowed(entry): + return norm(entry.get("clientId")) in ALLOWED_CLIENTS or norm(entry.get("clientMode")) in ALLOWED_MODES + +def same_identity(left, right): + for key in ("deviceId", "publicKey", "clientId", "clientMode", "role", "platform"): + lval = norm(left.get(key)) + rval = norm(right.get(key)) + if lval and rval and lval != rval: + return False + left_roles = roles(left) + right_roles = roles(right) + return not left_roles or not right_roles or left_roles.issubset(right_roles) or right_roles.issubset(left_roles) + +def ordered(scopes): + return [scope for scope in SCOPE_ORDER if scope in scopes] + +def approval_closure(scopes): + closed = set(scopes) + if "operator.write" in closed: + closed.add("operator.read") + if {"operator.read", "operator.write"}.intersection(closed): + closed.add("operator.pairing") + return closed + +def update_tokens(entry, scopes): + role = norm(before.get("role")) or "operator" + now_ms = int(time.time() * 1000) + tokens = entry.get("tokens") + if isinstance(tokens, dict): + token_entry = tokens.get(role) + if not isinstance(token_entry, dict): + token_entry = tokens.get("operator") if isinstance(tokens.get("operator"), dict) else {} + token_entry["role"] = role + token_entry["scopes"] = ordered(scopes) + token_entry.setdefault("createdAtMs", now_ms) + tokens[role] = token_entry + entry["tokens"] = tokens + elif isinstance(tokens, list): + token_entry = next((item for item in tokens if isinstance(item, dict) and norm(item.get("role")) == role), None) + if token_entry is None: + token_entry = {"role": role, "createdAtMs": now_ms} + tokens.append(token_entry) + token_entry["scopes"] = ordered(scopes) + else: + entry["tokens"] = {role: {"role": role, "scopes": ordered(scopes), "createdAtMs": now_ms}} -requested = {norm(scope) for scope in (before.get("scopes") or []) if norm(scope)} +requested = list_scopes(before, "scopes", "requestedScopes") device_id = norm(before.get("deviceId")) +if not request_id or not device_id or not requested or not requested.issubset(ALLOWED_SCOPES) or not client_allowed(before): + raise SystemExit(1) + 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) +paired_entry = paired.get(device_id) +if not isinstance(paired_entry, dict): + paired_entry = next((item for item in paired.values() if isinstance(item, dict) and norm(item.get("deviceId")) == device_id), None) +if not isinstance(paired_entry, dict) or not same_identity(before, paired_entry): + raise SystemExit(1) + +current_scopes = list_scopes(paired_entry, "approvedScopes", "scopes") +if "operator.pairing" not in current_scopes: + raise SystemExit(1) +approved = approval_closure(current_scopes | requested) +if not approved.issubset(ALLOWED_SCOPES): + raise SystemExit(1) + +for key, item in list(pending.items()): + if not isinstance(item, dict): + continue + if norm(item.get("requestId")) == request_id or same_identity(before, item): + pending.pop(key, None) + +approved_list = ordered(approved) +paired_entry["scopes"] = approved_list +paired_entry["approvedScopes"] = approved_list +paired_entry.setdefault("approvedAtMs", int(time.time() * 1000)) +update_tokens(paired_entry, approved) +paired[device_id] = paired_entry +save("pending.json", pending) +save("paired.json", paired) +print(json.dumps({ + "requestId": request_id, + "deviceId": device_id, + "approvedScopes": approved_list, + "compatibility": "nemoclaw-approve-recovered-original-request", +}, sort_keys=True)) PYAPPROVEAFTER return 0 fi diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index a1fdb66e61..ed4e7cd7b6 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2,11 +2,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib"); @@ -995,6 +995,130 @@ describe("nemoclaw-start configure guard behavior", () => { } }); + it("#4462: recovers the original scope-upgrade request when approve creates a broader replacement", () => { + const setup = writeProxyEnvWithGuard(); + const stateDir = path.join(setup.tmpDir, "openclaw-state"); + const devicesDir = path.join(stateDir, "devices"); + fs.mkdirSync(devicesDir, { recursive: true }); + fs.writeFileSync( + path.join(devicesDir, "pending.json"), + JSON.stringify( + { + original: { + requestId: "request-1", + deviceId: "device-1", + publicKey: "public-key-1", + platform: "linux", + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.write"], + }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(devicesDir, "paired.json"), + JSON.stringify( + { + "device-1": { + deviceId: "device-1", + publicKey: "public-key-1", + platform: "linux", + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing"], + approvedScopes: ["operator.pairing"], + tokens: { + operator: { + role: "operator", + scopes: ["operator.pairing"], + token: "token-1", + createdAtMs: 1, + }, + }, + }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(setup.fakeBin, "openclaw"), + `#!/usr/bin/env bash +set -euo pipefail +printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(setup.commandLog)} +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' +{ + "replacement": { + "requestId": "replacement-1", + "deviceId": "device-1", + "publicKey": "public-key-1", + "platform": "linux", + "clientId": "cli", + "clientMode": "cli", + "role": "operator", + "roles": ["operator"], + "scopes": ["operator.write", "operator.pairing", "operator.read", "operator.admin"] + } +} +JSON + echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: replacement-1)' >&2 + echo '[openclaw] Reason: gateway closed (1008): pairing required: device is asking for more scopes than currently approved (requestId: replacement-1)' >&2 + exit 1 +fi +exit 2 +`, + { mode: 0o755 }, + ); + + try { + const result = runGuardedShell(setup, [ + `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, + shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), + ]); + + expect(result.status, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout.trim()); + expect(payload).toMatchObject({ + requestId: "request-1", + deviceId: "device-1", + compatibility: "nemoclaw-approve-recovered-original-request", + }); + expect(payload.approvedScopes).toEqual([ + "operator.pairing", + "operator.read", + "operator.write", + ]); + expect(JSON.parse(fs.readFileSync(path.join(devicesDir, "pending.json"), "utf-8"))).toEqual( + {}, + ); + const paired = JSON.parse(fs.readFileSync(path.join(devicesDir, "paired.json"), "utf-8")); + expect(paired["device-1"].approvedScopes).toEqual([ + "operator.pairing", + "operator.read", + "operator.write", + ]); + expect(paired["device-1"].tokens.operator.scopes).toEqual([ + "operator.pairing", + "operator.read", + "operator.write", + ]); + expect(JSON.stringify(paired)).not.toContain("operator.admin"); + expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain( + "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", + ); + } finally { + fs.rmSync(setup.tmpDir, { recursive: true, force: true }); + } + }); + // #2592 reported the guard did not fire for `openclaw channels add telegram` // and `openclaw channels remove telegram` from inside the sandbox. The // existing test above only exercises `add slack`. Lock in coverage for every diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 1550cd12fb..ff43de853d 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -305,48 +305,6 @@ describe("sandbox provisioning: non-messaging OpenClaw plugins", () => { }); }); -describe("sandbox provisioning: OpenClaw runtime state cleanup", () => { - it("removes build-time device auth state while preserving gateway config cleanup", () => { - const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); - const command = dockerRunCommandBetween( - dockerfile, - "# SECURITY: Clear any gateway auth token", - "# Flatten stale published base images", - ); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-runtime-cleanup-")); - const home = path.join(tmp, "sandbox"); - const openclawDir = path.join(home, ".openclaw"); - try { - fs.mkdirSync(path.join(openclawDir, "devices"), { recursive: true }); - fs.mkdirSync(path.join(openclawDir, "identity"), { recursive: true }); - fs.writeFileSync(path.join(openclawDir, "devices", "pending.json"), "[]\n"); - fs.writeFileSync(path.join(openclawDir, "identity", "device.json"), "{}\n"); - fs.writeFileSync( - path.join(openclawDir, "openclaw.json"), - JSON.stringify({ gateway: { auth: { token: "build-token" } } }), - ); - - const { result } = runLoggedDockerShell(command, tmp, [], { - HOME: home, - NEMOCLAW_PROXY_HOST: "10.200.0.1", - NEMOCLAW_PROXY_PORT: "3128", - }); - - expect(result.status, `stderr: ${result.stderr}`).toBe(0); - expect(fs.existsSync(path.join(openclawDir, "devices"))).toBe(false); - expect(fs.existsSync(path.join(openclawDir, "identity"))).toBe(false); - const config = JSON.parse(fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8")); - expect(config.gateway.auth.token).toBe(""); - expect(config.proxy).toEqual({ - enabled: true, - proxyUrl: "http://10.200.0.1:3128", - loopbackMode: "gateway-only", - }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); function dockerfileEnvDirectives(text: string): string[] { const lines = text.split("\n"); const directives: string[] = []; From a2dafc6f67e0fe023c3214041c0006166a16c62a Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 13:46:22 +0530 Subject: [PATCH 3/8] fix(openclaw): recover replaced scope approval requests --- scripts/nemoclaw-start.sh | 201 ++++++++++++++++++++++++++---------- test/nemoclaw-start.test.ts | 100 ++++++++++++++++++ 2 files changed, 249 insertions(+), 52 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0185d9010c..29eafed6a5 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2277,17 +2277,29 @@ openclaw() { _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 +import time 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) + +def load_json(path): + for _ in range(10): + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + time.sleep(0.05) + return {} + +def entries(value): + if isinstance(value, dict): + return [item for item in value.values() if isinstance(item, dict)] + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + +pending = load_json(root / "pending.json") +request = next((item for item in entries(pending) if item.get("requestId") == request_id), None) if request: print(json.dumps({ "requestId": request_id, @@ -2319,8 +2331,8 @@ PYAPPROVEBEFORE _nemoclaw_approve_recover=1 ;; esac - if [ "$_nemoclaw_approve_recover" = "1" ] && [ -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 + if [ "$_nemoclaw_approve_recover" = "1" ] && [ -n "$_nemoclaw_approve_request_id" ] && 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" NEMOCLAW_APPROVE_OUTPUT="$_nemoclaw_approve_output" python3 - <<'PYAPPROVEAFTER'; then import json import os import time @@ -2329,21 +2341,26 @@ from pathlib import Path ALLOWED_CLIENTS = {"openclaw-control-ui"} ALLOWED_MODES = {"webchat", "cli"} ALLOWED_SCOPES = {"operator.pairing", "operator.read", "operator.write"} +REPLACEMENT_ONLY_EXTRAS = {"operator.admin"} SCOPE_ORDER = ["operator.pairing", "operator.read", "operator.write"] request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or "" root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices" +approve_output = os.environ.get("NEMOCLAW_APPROVE_OUTPUT") or "" 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 {} + path = root / name + for _ in range(10): + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, (dict, list)) else {} + except Exception: + time.sleep(0.05) + return {} def save(name, value): root.mkdir(parents=True, exist_ok=True) @@ -2352,6 +2369,13 @@ def save(name, value): def norm(value): return str(value or "").strip() +def entries(value): + if isinstance(value, dict): + return [item for item in value.values() if isinstance(item, dict)] + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + def list_scopes(entry, *names): for name in names: value = entry.get(name) @@ -2394,8 +2418,8 @@ def approval_closure(scopes): closed.add("operator.pairing") return closed -def update_tokens(entry, scopes): - role = norm(before.get("role")) or "operator" +def update_tokens(entry, scopes, role): + role = norm(role) or "operator" now_ms = int(time.time() * 1000) tokens = entry.get("tokens") if isinstance(tokens, dict): @@ -2416,46 +2440,119 @@ def update_tokens(entry, scopes): else: entry["tokens"] = {role: {"role": role, "scopes": ordered(scopes), "createdAtMs": now_ms}} -requested = list_scopes(before, "scopes", "requestedScopes") -device_id = norm(before.get("deviceId")) -if not request_id or not device_id or not requested or not requested.issubset(ALLOWED_SCOPES) or not client_allowed(before): - raise SystemExit(1) +def find_paired(paired, device_id): + if isinstance(paired, dict): + item = paired.get(device_id) + if isinstance(item, dict): + return item + return next((item for item in entries(paired) if norm(item.get("deviceId")) == device_id), None) + +def upsert_paired(paired, device_id, entry): + if isinstance(paired, list): + for index, item in enumerate(paired): + if isinstance(item, dict) and norm(item.get("deviceId")) == device_id: + paired[index] = entry + return paired + paired.append(entry) + return paired + if not isinstance(paired, dict): + paired = {} + for key, item in paired.items(): + if isinstance(item, dict) and norm(item.get("deviceId")) == device_id: + paired[key] = entry + return paired + paired[device_id] = entry + return paired + +def remove_pending(pending, candidate, request_ids, requested_all): + def should_remove(item): + if not isinstance(item, dict): + return False + if norm(item.get("requestId")) in request_ids: + return True + if same_identity(candidate, item) and list_scopes(item, "scopes", "requestedScopes").intersection(requested_all): + return True + return False + if isinstance(pending, list): + return [item for item in pending if not should_remove(item)] + if isinstance(pending, dict): + return {key: item for key, item in pending.items() if not should_remove(item)} + return {} + +def replacement_candidate_allowed(requested_all): + extras = requested_all - ALLOWED_SCOPES + if not extras or not extras.issubset(REPLACEMENT_ONLY_EXTRAS): + return False + return bool(requested_all.intersection({"operator.read", "operator.write"})) + +def approve_candidate(candidate, from_original, pending, paired): + candidate_request_id = norm(candidate.get("requestId")) + device_id = norm(candidate.get("deviceId")) + requested_all = list_scopes(candidate, "scopes", "requestedScopes") + if not request_id or not candidate_request_id or not device_id or not requested_all or not client_allowed(candidate): + return False + extras = requested_all - ALLOWED_SCOPES + if from_original: + if extras: + return False + requested = requested_all + compatibility = "nemoclaw-approve-recovered-original-request" + else: + if candidate_request_id == request_id: + if extras: + return False + requested = requested_all + compatibility = "nemoclaw-approve-recovered-original-request" + else: + if not replacement_candidate_allowed(requested_all): + return False + requested = requested_all & ALLOWED_SCOPES + compatibility = "nemoclaw-approve-recovered-replacement-request" + paired_entry = find_paired(paired, device_id) + if not isinstance(paired_entry, dict) or not same_identity(candidate, paired_entry): + return False + current_scopes = list_scopes(paired_entry, "approvedScopes", "scopes") + if "operator.pairing" not in current_scopes: + return False + approved = approval_closure(current_scopes | requested) + if not approved.issubset(ALLOWED_SCOPES): + return False + request_ids = {request_id, candidate_request_id} + pending = remove_pending(pending, candidate, request_ids, requested_all) + approved_list = ordered(approved) + paired_entry["scopes"] = approved_list + paired_entry["approvedScopes"] = approved_list + paired_entry.setdefault("approvedAtMs", int(time.time() * 1000)) + update_tokens(paired_entry, approved, norm(candidate.get("role")) or "operator") + paired = upsert_paired(paired, device_id, paired_entry) + save("pending.json", pending) + save("paired.json", paired) + print(json.dumps({ + "requestId": request_id, + "deviceId": device_id, + "approvedScopes": approved_list, + "compatibility": compatibility, + }, sort_keys=True)) + return True pending = load("pending.json") paired = load("paired.json") -paired_entry = paired.get(device_id) -if not isinstance(paired_entry, dict): - paired_entry = next((item for item in paired.values() if isinstance(item, dict) and norm(item.get("deviceId")) == device_id), None) -if not isinstance(paired_entry, dict) or not same_identity(before, paired_entry): - raise SystemExit(1) - -current_scopes = list_scopes(paired_entry, "approvedScopes", "scopes") -if "operator.pairing" not in current_scopes: - raise SystemExit(1) -approved = approval_closure(current_scopes | requested) -if not approved.issubset(ALLOWED_SCOPES): - raise SystemExit(1) - -for key, item in list(pending.items()): - if not isinstance(item, dict): +candidates = [] +if isinstance(before, dict) and before: + candidates.append((before, True)) +seen = {norm(before.get("requestId"))} if isinstance(before, dict) else set() +for item in entries(pending): + item_request_id = norm(item.get("requestId")) + if not item_request_id or item_request_id in seen: continue - if norm(item.get("requestId")) == request_id or same_identity(before, item): - pending.pop(key, None) - -approved_list = ordered(approved) -paired_entry["scopes"] = approved_list -paired_entry["approvedScopes"] = approved_list -paired_entry.setdefault("approvedAtMs", int(time.time() * 1000)) -update_tokens(paired_entry, approved) -paired[device_id] = paired_entry -save("pending.json", pending) -save("paired.json", paired) -print(json.dumps({ - "requestId": request_id, - "deviceId": device_id, - "approvedScopes": approved_list, - "compatibility": "nemoclaw-approve-recovered-original-request", -}, sort_keys=True)) + if item_request_id == request_id or "requestId: " + item_request_id in approve_output or replacement_candidate_allowed(list_scopes(item, "scopes", "requestedScopes")): + candidates.append((item, False)) + seen.add(item_request_id) + +for candidate, from_original in candidates: + if approve_candidate(candidate, from_original, pending, paired): + raise SystemExit(0) +raise SystemExit(1) PYAPPROVEAFTER return 0 fi diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index ed4e7cd7b6..9331d2dbe0 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1119,6 +1119,106 @@ exit 2 } }); + it("#4462: recovers a same-device replacement when the original scope request was already replaced", () => { + const setup = writeProxyEnvWithGuard(); + const stateDir = path.join(setup.tmpDir, "openclaw-state"); + const devicesDir = path.join(stateDir, "devices"); + fs.mkdirSync(devicesDir, { recursive: true }); + fs.writeFileSync(path.join(devicesDir, "pending.json"), "{}\n"); + fs.writeFileSync( + path.join(devicesDir, "paired.json"), + JSON.stringify( + { + "device-1": { + deviceId: "device-1", + publicKey: "public-key-1", + platform: "linux", + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing"], + approvedScopes: ["operator.pairing"], + tokens: { + operator: { + role: "operator", + scopes: ["operator.pairing"], + token: "token-1", + createdAtMs: 1, + }, + }, + }, + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(setup.fakeBin, "openclaw"), + `#!/usr/bin/env bash +set -euo pipefail +printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(setup.commandLog)} +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' +{ + "replacement": { + "requestId": "replacement-1", + "deviceId": "device-1", + "publicKey": "public-key-1", + "platform": "linux", + "clientId": "cli", + "clientMode": "cli", + "role": "operator", + "roles": ["operator"], + "scopes": ["operator.write", "operator.pairing", "operator.read", "operator.admin"] + } +} +JSON + echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: replacement-1)' >&2 + echo '[openclaw] Reason: gateway closed (1008): pairing required: device is asking for more scopes than currently approved (requestId: replacement-1)' >&2 + exit 1 +fi +exit 2 +`, + { mode: 0o755 }, + ); + + try { + const result = runGuardedShell(setup, [ + `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, + shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), + ]); + + expect(result.status, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout.trim()); + expect(payload).toMatchObject({ + requestId: "request-1", + deviceId: "device-1", + compatibility: "nemoclaw-approve-recovered-replacement-request", + }); + expect(payload.approvedScopes).toEqual([ + "operator.pairing", + "operator.read", + "operator.write", + ]); + expect(JSON.parse(fs.readFileSync(path.join(devicesDir, "pending.json"), "utf-8"))).toEqual( + {}, + ); + const paired = JSON.parse(fs.readFileSync(path.join(devicesDir, "paired.json"), "utf-8")); + expect(paired["device-1"].approvedScopes).toEqual([ + "operator.pairing", + "operator.read", + "operator.write", + ]); + expect(JSON.stringify(paired)).not.toContain("operator.admin"); + expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain( + "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", + ); + } finally { + fs.rmSync(setup.tmpDir, { recursive: true, force: true }); + } + }); + // #2592 reported the guard did not fire for `openclaw channels add telegram` // and `openclaw channels remove telegram` from inside the sandbox. The // existing test above only exercises `add slack`. Lock in coverage for every From cea3c13bded6d73fcc871a0bf0931a46a8507445 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 14:02:43 +0530 Subject: [PATCH 4/8] fix(openclaw): narrow scope approval recovery --- scripts/nemoclaw-start.sh | 289 ++++++++---------------------------- test/nemoclaw-start.test.ts | 198 +++--------------------- 2 files changed, 76 insertions(+), 411 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 29eafed6a5..893152bd55 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2277,39 +2277,21 @@ openclaw() { _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 -import time 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 "" - -def load_json(path): - for _ in range(10): - try: - return json.loads(path.read_text(encoding="utf-8")) - except Exception: - time.sleep(0.05) - return {} - -def entries(value): - if isinstance(value, dict): - return [item for item in value.values() if isinstance(item, dict)] - if isinstance(value, list): - return [item for item in value if isinstance(item, dict)] - return [] - -pending = load_json(root / "pending.json") -request = next((item for item in entries(pending) if item.get("requestId") == request_id), None) +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"), - "publicKey": request.get("publicKey"), - "platform": request.get("platform"), - "clientId": request.get("clientId"), - "clientMode": request.get("clientMode"), - "role": request.get("role"), - "roles": request.get("roles") or [], "scopes": request.get("scopes") or request.get("requestedScopes") or [], }, sort_keys=True)) PYAPPROVEBEFORE @@ -2325,234 +2307,81 @@ PYAPPROVEBEFORE printf '%s\n' "$_nemoclaw_approve_output" return 0 fi - _nemoclaw_approve_recover=0 - case "$_nemoclaw_approve_output" in - *"scope upgrade pending approval"* | *"pairing required: device is asking for more scopes"* | *"unknown requestId"*) - _nemoclaw_approve_recover=1 - ;; - esac - if [ "$_nemoclaw_approve_recover" = "1" ] && [ -n "$_nemoclaw_approve_request_id" ] && command -v python3 >/dev/null 2>&1; then + 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" NEMOCLAW_APPROVE_OUTPUT="$_nemoclaw_approve_output" python3 - <<'PYAPPROVEAFTER'; then import json import os -import time from pathlib import Path -ALLOWED_CLIENTS = {"openclaw-control-ui"} -ALLOWED_MODES = {"webchat", "cli"} -ALLOWED_SCOPES = {"operator.pairing", "operator.read", "operator.write"} -REPLACEMENT_ONLY_EXTRAS = {"operator.admin"} -SCOPE_ORDER = ["operator.pairing", "operator.read", "operator.write"] - request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or "" root = Path(os.environ.get("NEMOCLAW_APPROVE_STATE_DIR") or "/sandbox/.openclaw") / "devices" -approve_output = os.environ.get("NEMOCLAW_APPROVE_OUTPUT") or "" try: before = json.loads(os.environ.get("NEMOCLAW_APPROVE_BEFORE") or "{}") except Exception: before = {} +approve_output = os.environ.get("NEMOCLAW_APPROVE_OUTPUT") or "" def load(name): - path = root / name - for _ in range(10): - try: - value = json.loads(path.read_text(encoding="utf-8")) - return value if isinstance(value, (dict, list)) else {} - except Exception: - time.sleep(0.05) - return {} + try: + value = json.loads((root / name).read_text(encoding="utf-8")) + except Exception: + return {} + return value if isinstance(value, dict) else {} def save(name, value): - root.mkdir(parents=True, exist_ok=True) (root / name).write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") def norm(value): return str(value or "").strip() -def entries(value): - if isinstance(value, dict): - return [item for item in value.values() if isinstance(item, dict)] - if isinstance(value, list): - return [item for item in value if isinstance(item, dict)] - return [] - -def list_scopes(entry, *names): - for name in names: - value = entry.get(name) - if isinstance(value, list): - return {norm(scope) for scope in value if norm(scope)} - return set() - -def roles(entry): - values = set() - role = norm(entry.get("role")) - if role: - values.add(role) - for role in entry.get("roles") or []: - role = norm(role) - if role: - values.add(role) - return values - -def client_allowed(entry): - return norm(entry.get("clientId")) in ALLOWED_CLIENTS or norm(entry.get("clientMode")) in ALLOWED_MODES - -def same_identity(left, right): - for key in ("deviceId", "publicKey", "clientId", "clientMode", "role", "platform"): - lval = norm(left.get(key)) - rval = norm(right.get(key)) - if lval and rval and lval != rval: - return False - left_roles = roles(left) - right_roles = roles(right) - return not left_roles or not right_roles or left_roles.issubset(right_roles) or right_roles.issubset(left_roles) - -def ordered(scopes): - return [scope for scope in SCOPE_ORDER if scope in scopes] - -def approval_closure(scopes): - closed = set(scopes) - if "operator.write" in closed: - closed.add("operator.read") - if {"operator.read", "operator.write"}.intersection(closed): - closed.add("operator.pairing") - return closed - -def update_tokens(entry, scopes, role): - role = norm(role) or "operator" - now_ms = int(time.time() * 1000) - tokens = entry.get("tokens") - if isinstance(tokens, dict): - token_entry = tokens.get(role) - if not isinstance(token_entry, dict): - token_entry = tokens.get("operator") if isinstance(tokens.get("operator"), dict) else {} - token_entry["role"] = role - token_entry["scopes"] = ordered(scopes) - token_entry.setdefault("createdAtMs", now_ms) - tokens[role] = token_entry - entry["tokens"] = tokens - elif isinstance(tokens, list): - token_entry = next((item for item in tokens if isinstance(item, dict) and norm(item.get("role")) == role), None) - if token_entry is None: - token_entry = {"role": role, "createdAtMs": now_ms} - tokens.append(token_entry) - token_entry["scopes"] = ordered(scopes) - else: - entry["tokens"] = {role: {"role": role, "scopes": ordered(scopes), "createdAtMs": now_ms}} - -def find_paired(paired, device_id): - if isinstance(paired, dict): - item = paired.get(device_id) - if isinstance(item, dict): - return item - return next((item for item in entries(paired) if norm(item.get("deviceId")) == device_id), None) - -def upsert_paired(paired, device_id, entry): - if isinstance(paired, list): - for index, item in enumerate(paired): - if isinstance(item, dict) and norm(item.get("deviceId")) == device_id: - paired[index] = entry - return paired - paired.append(entry) - return paired - if not isinstance(paired, dict): - paired = {} - for key, item in paired.items(): - if isinstance(item, dict) and norm(item.get("deviceId")) == device_id: - paired[key] = entry - return paired - paired[device_id] = entry - return paired - -def remove_pending(pending, candidate, request_ids, requested_all): - def should_remove(item): - if not isinstance(item, dict): - return False - if norm(item.get("requestId")) in request_ids: - return True - if same_identity(candidate, item) and list_scopes(item, "scopes", "requestedScopes").intersection(requested_all): - return True - return False - if isinstance(pending, list): - return [item for item in pending if not should_remove(item)] - if isinstance(pending, dict): - return {key: item for key, item in pending.items() if not should_remove(item)} - return {} - -def replacement_candidate_allowed(requested_all): - extras = requested_all - ALLOWED_SCOPES - if not extras or not extras.issubset(REPLACEMENT_ONLY_EXTRAS): - return False - return bool(requested_all.intersection({"operator.read", "operator.write"})) - -def approve_candidate(candidate, from_original, pending, paired): - candidate_request_id = norm(candidate.get("requestId")) - device_id = norm(candidate.get("deviceId")) - requested_all = list_scopes(candidate, "scopes", "requestedScopes") - if not request_id or not candidate_request_id or not device_id or not requested_all or not client_allowed(candidate): - return False - extras = requested_all - ALLOWED_SCOPES - if from_original: - if extras: - return False - requested = requested_all - compatibility = "nemoclaw-approve-recovered-original-request" - else: - if candidate_request_id == request_id: - if extras: - return False - requested = requested_all - compatibility = "nemoclaw-approve-recovered-original-request" - else: - if not replacement_candidate_allowed(requested_all): - return False - requested = requested_all & ALLOWED_SCOPES - compatibility = "nemoclaw-approve-recovered-replacement-request" - paired_entry = find_paired(paired, device_id) - if not isinstance(paired_entry, dict) or not same_identity(candidate, paired_entry): - return False - current_scopes = list_scopes(paired_entry, "approvedScopes", "scopes") - if "operator.pairing" not in current_scopes: - return False - approved = approval_closure(current_scopes | requested) - if not approved.issubset(ALLOWED_SCOPES): - return False - request_ids = {request_id, candidate_request_id} - pending = remove_pending(pending, candidate, request_ids, requested_all) - approved_list = ordered(approved) - paired_entry["scopes"] = approved_list - paired_entry["approvedScopes"] = approved_list - paired_entry.setdefault("approvedAtMs", int(time.time() * 1000)) - update_tokens(paired_entry, approved, norm(candidate.get("role")) or "operator") - paired = upsert_paired(paired, device_id, paired_entry) - save("pending.json", pending) - save("paired.json", paired) - print(json.dumps({ - "requestId": request_id, - "deviceId": device_id, - "approvedScopes": approved_list, - "compatibility": compatibility, - }, sort_keys=True)) - return True +def scope_set(entry, key="scopes"): + return {norm(scope) for scope in (entry.get(key) or []) if norm(scope)} +requested = scope_set(before) +device_id = norm(before.get("deviceId")) pending = load("pending.json") paired = load("paired.json") -candidates = [] -if isinstance(before, dict) and before: - candidates.append((before, True)) -seen = {norm(before.get("requestId"))} if isinstance(before, dict) else set() -for item in entries(pending): - item_request_id = norm(item.get("requestId")) - if not item_request_id or item_request_id in seen: - continue - if item_request_id == request_id or "requestId: " + item_request_id in approve_output or replacement_candidate_allowed(list_scopes(item, "scopes", "requestedScopes")): - candidates.append((item, False)) - seen.add(item_request_id) - -for candidate, from_original in candidates: - if approve_candidate(candidate, from_original, pending, paired): - raise SystemExit(0) -raise SystemExit(1) +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 +paired_scopes = scope_set(paired_entry or {}, "approvedScopes") or scope_set(paired_entry or {}) +if request_id and requested and not still_pending and isinstance(paired_entry, dict) and requested.issubset(paired_scopes): + print(json.dumps({"requestId": request_id, "deviceId": device_id, "approvedScopes": sorted(requested), "compatibility": "openclaw-approve-applied-after-nonzero"}, sort_keys=True)) + raise SystemExit(0) + +if "scope upgrade pending approval" not in approve_output and "pairing required" not in approve_output: + raise SystemExit(1) +allowed = {"operator.pairing", "operator.read", "operator.write"} +if not request_id or not device_id or not requested or not requested.issubset(allowed) or "operator.pairing" not in paired_scopes: + raise SystemExit(1) +replacement_key = replacement = None +for key, item in pending.items(): + item_scopes = scope_set(item) if isinstance(item, dict) else set() + if (isinstance(item, dict) and norm(item.get("requestId")) != request_id and norm(item.get("deviceId")) == device_id and + "operator.admin" in item_scopes and requested.issubset(item_scopes) and norm(item.get("requestId")) in approve_output): + replacement_key, replacement = key, item + break +if not replacement: + raise SystemExit(1) +approved = set(paired_scopes) | requested +if "operator.write" in approved: + approved.add("operator.read") +if {"operator.read", "operator.write"} & approved: + approved.add("operator.pairing") +if not approved.issubset(allowed): + raise SystemExit(1) +approved_list = [scope for scope in ("operator.pairing", "operator.read", "operator.write") if scope in approved] +paired_entry["scopes"] = approved_list +paired_entry["approvedScopes"] = approved_list +token = paired_entry.get("tokens", {}).get("operator") +if isinstance(token, dict): + token["scopes"] = approved_list +pending.pop(request_id, None) +pending.pop(replacement_key, None) +paired[device_id] = paired_entry +save("pending.json", pending) +save("paired.json", paired) +print(json.dumps({"requestId": request_id, "deviceId": device_id, "approvedScopes": approved_list, "compatibility": "openclaw-approve-recovered-replacement"}, sort_keys=True)) +raise SystemExit(0) PYAPPROVEAFTER return 0 fi diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 9331d2dbe0..91c559f1af 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -995,85 +995,37 @@ describe("nemoclaw-start configure guard behavior", () => { } }); - it("#4462: recovers the original scope-upgrade request when approve creates a broader replacement", () => { + it("#4462: strips admin from same-device approval replacement", () => { const setup = writeProxyEnvWithGuard(); const stateDir = path.join(setup.tmpDir, "openclaw-state"); const devicesDir = path.join(stateDir, "devices"); fs.mkdirSync(devicesDir, { recursive: true }); fs.writeFileSync( path.join(devicesDir, "pending.json"), - JSON.stringify( - { - original: { - requestId: "request-1", - deviceId: "device-1", - publicKey: "public-key-1", - platform: "linux", - clientId: "cli", - clientMode: "cli", - role: "operator", - roles: ["operator"], - scopes: ["operator.write"], - }, - }, - null, - 2, - ), + JSON.stringify({ + original: { requestId: "request-1", deviceId: "device-1", scopes: ["operator.write"] }, + }), ); fs.writeFileSync( path.join(devicesDir, "paired.json"), - JSON.stringify( - { - "device-1": { - deviceId: "device-1", - publicKey: "public-key-1", - platform: "linux", - clientId: "cli", - clientMode: "cli", - role: "operator", - roles: ["operator"], - scopes: ["operator.pairing"], - approvedScopes: ["operator.pairing"], - tokens: { - operator: { - role: "operator", - scopes: ["operator.pairing"], - token: "token-1", - createdAtMs: 1, - }, - }, - }, + JSON.stringify({ + "device-1": { + deviceId: "device-1", + scopes: ["operator.pairing"], + approvedScopes: ["operator.pairing"], + tokens: { operator: { role: "operator", scopes: ["operator.pairing"] } }, }, - null, - 2, - ), + }), ); fs.writeFileSync( path.join(setup.fakeBin, "openclaw"), `#!/usr/bin/env bash -set -euo pipefail printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(setup.commandLog)} -if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then - cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' -{ - "replacement": { - "requestId": "replacement-1", - "deviceId": "device-1", - "publicKey": "public-key-1", - "platform": "linux", - "clientId": "cli", - "clientMode": "cli", - "role": "operator", - "roles": ["operator"], - "scopes": ["operator.write", "operator.pairing", "operator.read", "operator.admin"] - } -} +cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' +{"replacement":{"requestId":"replacement-1","deviceId":"device-1","scopes":["operator.write","operator.pairing","operator.read","operator.admin"]}} JSON - echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: replacement-1)' >&2 - echo '[openclaw] Reason: gateway closed (1008): pairing required: device is asking for more scopes than currently approved (requestId: replacement-1)' >&2 - exit 1 -fi -exit 2 +echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: replacement-1)' >&2 +exit 1 `, { mode: 0o755 }, ); @@ -1083,137 +1035,21 @@ exit 2 `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), ]); - expect(result.status, result.stderr).toBe(0); - const payload = JSON.parse(result.stdout.trim()); - expect(payload).toMatchObject({ + expect(JSON.parse(result.stdout)).toMatchObject({ requestId: "request-1", - deviceId: "device-1", - compatibility: "nemoclaw-approve-recovered-original-request", + compatibility: "openclaw-approve-recovered-replacement", }); - expect(payload.approvedScopes).toEqual([ - "operator.pairing", - "operator.read", - "operator.write", - ]); - expect(JSON.parse(fs.readFileSync(path.join(devicesDir, "pending.json"), "utf-8"))).toEqual( - {}, - ); const paired = JSON.parse(fs.readFileSync(path.join(devicesDir, "paired.json"), "utf-8")); expect(paired["device-1"].approvedScopes).toEqual([ "operator.pairing", "operator.read", "operator.write", ]); - expect(paired["device-1"].tokens.operator.scopes).toEqual([ - "operator.pairing", - "operator.read", - "operator.write", - ]); expect(JSON.stringify(paired)).not.toContain("operator.admin"); - expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain( - "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", - ); - } finally { - fs.rmSync(setup.tmpDir, { recursive: true, force: true }); - } - }); - - it("#4462: recovers a same-device replacement when the original scope request was already replaced", () => { - const setup = writeProxyEnvWithGuard(); - const stateDir = path.join(setup.tmpDir, "openclaw-state"); - const devicesDir = path.join(stateDir, "devices"); - fs.mkdirSync(devicesDir, { recursive: true }); - fs.writeFileSync(path.join(devicesDir, "pending.json"), "{}\n"); - fs.writeFileSync( - path.join(devicesDir, "paired.json"), - JSON.stringify( - { - "device-1": { - deviceId: "device-1", - publicKey: "public-key-1", - platform: "linux", - clientId: "cli", - clientMode: "cli", - role: "operator", - roles: ["operator"], - scopes: ["operator.pairing"], - approvedScopes: ["operator.pairing"], - tokens: { - operator: { - role: "operator", - scopes: ["operator.pairing"], - token: "token-1", - createdAtMs: 1, - }, - }, - }, - }, - null, - 2, - ), - ); - fs.writeFileSync( - path.join(setup.fakeBin, "openclaw"), - `#!/usr/bin/env bash -set -euo pipefail -printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(setup.commandLog)} -if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then - cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' -{ - "replacement": { - "requestId": "replacement-1", - "deviceId": "device-1", - "publicKey": "public-key-1", - "platform": "linux", - "clientId": "cli", - "clientMode": "cli", - "role": "operator", - "roles": ["operator"], - "scopes": ["operator.write", "operator.pairing", "operator.read", "operator.admin"] - } -} -JSON - echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: replacement-1)' >&2 - echo '[openclaw] Reason: gateway closed (1008): pairing required: device is asking for more scopes than currently approved (requestId: replacement-1)' >&2 - exit 1 -fi -exit 2 -`, - { mode: 0o755 }, - ); - - try { - const result = runGuardedShell(setup, [ - `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, - shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), - ]); - - expect(result.status, result.stderr).toBe(0); - const payload = JSON.parse(result.stdout.trim()); - expect(payload).toMatchObject({ - requestId: "request-1", - deviceId: "device-1", - compatibility: "nemoclaw-approve-recovered-replacement-request", - }); - expect(payload.approvedScopes).toEqual([ - "operator.pairing", - "operator.read", - "operator.write", - ]); expect(JSON.parse(fs.readFileSync(path.join(devicesDir, "pending.json"), "utf-8"))).toEqual( {}, ); - const paired = JSON.parse(fs.readFileSync(path.join(devicesDir, "paired.json"), "utf-8")); - expect(paired["device-1"].approvedScopes).toEqual([ - "operator.pairing", - "operator.read", - "operator.write", - ]); - expect(JSON.stringify(paired)).not.toContain("operator.admin"); - expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain( - "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", - ); } finally { fs.rmSync(setup.tmpDir, { recursive: true, force: true }); } From 7f3edff95673ae16e074fa1ec095b6883b71d13f Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 14:23:20 +0530 Subject: [PATCH 5/8] test(openclaw): drop redundant approval regression --- test/nemoclaw-start.test.ts | 64 ++----------------------------------- 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 91c559f1af..a1fdb66e61 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2,11 +2,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { describe, it, expect } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib"); @@ -995,66 +995,6 @@ describe("nemoclaw-start configure guard behavior", () => { } }); - it("#4462: strips admin from same-device approval replacement", () => { - const setup = writeProxyEnvWithGuard(); - const stateDir = path.join(setup.tmpDir, "openclaw-state"); - const devicesDir = path.join(stateDir, "devices"); - fs.mkdirSync(devicesDir, { recursive: true }); - fs.writeFileSync( - path.join(devicesDir, "pending.json"), - JSON.stringify({ - original: { requestId: "request-1", deviceId: "device-1", scopes: ["operator.write"] }, - }), - ); - fs.writeFileSync( - path.join(devicesDir, "paired.json"), - JSON.stringify({ - "device-1": { - deviceId: "device-1", - scopes: ["operator.pairing"], - approvedScopes: ["operator.pairing"], - tokens: { operator: { role: "operator", scopes: ["operator.pairing"] } }, - }, - }), - ); - fs.writeFileSync( - path.join(setup.fakeBin, "openclaw"), - `#!/usr/bin/env bash -printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(setup.commandLog)} -cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' -{"replacement":{"requestId":"replacement-1","deviceId":"device-1","scopes":["operator.write","operator.pairing","operator.read","operator.admin"]}} -JSON -echo 'gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: replacement-1)' >&2 -exit 1 -`, - { mode: 0o755 }, - ); - - try { - const result = runGuardedShell(setup, [ - `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, - shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), - ]); - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - requestId: "request-1", - compatibility: "openclaw-approve-recovered-replacement", - }); - const paired = JSON.parse(fs.readFileSync(path.join(devicesDir, "paired.json"), "utf-8")); - expect(paired["device-1"].approvedScopes).toEqual([ - "operator.pairing", - "operator.read", - "operator.write", - ]); - expect(JSON.stringify(paired)).not.toContain("operator.admin"); - expect(JSON.parse(fs.readFileSync(path.join(devicesDir, "pending.json"), "utf-8"))).toEqual( - {}, - ); - } finally { - fs.rmSync(setup.tmpDir, { recursive: true, force: true }); - } - }); - // #2592 reported the guard did not fire for `openclaw channels add telegram` // and `openclaw channels remove telegram` from inside the sandbox. The // existing test above only exercises `add slack`. Lock in coverage for every From e8ae2b282be83ae5e21f4658ca86b4ce7e6f519b Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 14:46:42 +0530 Subject: [PATCH 6/8] fix(openclaw): harden approval recovery --- ci/test-file-size-budget.json | 2 +- scripts/nemoclaw-start.sh | 23 ++++++- test/nemoclaw-start.test.ts | 121 ++++++++++++++++------------------ 3 files changed, 76 insertions(+), 70 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 493d6e9110..5f86dd137d 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/channels-add-preset.test.ts": 1872, "test/generate-openclaw-config.test.ts": 1990, "test/install-preflight.test.ts": 4207, - "test/nemoclaw-start.test.ts": 5234, + "test/nemoclaw-start.test.ts": 5223, "test/onboard-messaging.test.ts": 2094, "test/onboard-selection.test.ts": 6891, "test/onboard.test.ts": 4775, diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b8644eeefa..245b0b1325 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2307,6 +2307,7 @@ PYAPPROVEBEFORE if NEMOCLAW_APPROVE_REQUEST_ID="$_nemoclaw_approve_request_id" NEMOCLAW_APPROVE_STATE_DIR="$_nemoclaw_approve_state_dir" NEMOCLAW_APPROVE_BEFORE="$_nemoclaw_approve_before" NEMOCLAW_APPROVE_OUTPUT="$_nemoclaw_approve_output" python3 - <<'PYAPPROVEAFTER'; then import json import os +import re from pathlib import Path request_id = os.environ.get("NEMOCLAW_APPROVE_REQUEST_ID") or "" @@ -2325,7 +2326,13 @@ def load(name): return value if isinstance(value, dict) else {} def save(name, value): - (root / name).write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + path = root / name + tmp = path.with_name(f".{path.name}.tmp") + with tmp.open("w", encoding="utf-8") as handle: + handle.write(json.dumps(value, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) def norm(value): return str(value or "").strip() @@ -2333,17 +2340,27 @@ def norm(value): def scope_set(entry, key="scopes"): return {norm(scope) for scope in (entry.get(key) or []) if norm(scope)} +def output_mentions_request_id(value): + request = norm(value) + return bool(request and re.search(r"(? { } }); - it("#4462: unsets OPENCLAW_GATEWAY_URL, PORT, and TOKEN for devices approve", () => { + it("#4462: unsets gateway env and recovers only exact replacement request IDs", () => { const setup = writeProxyEnvWithGuard(); + const stateDir = path.join(setup.tmpDir, "openclaw-state"); + const devicesDir = path.join(stateDir, "devices"); + const pendingFile = path.join(devicesDir, "pending.json"); + const pairedFile = path.join(devicesDir, "paired.json"); + const readJson = (file: string) => JSON.parse(fs.readFileSync(file, "utf-8")); + const resetState = () => { + fs.mkdirSync(devicesDir, { recursive: true }); + fs.writeFileSync( + pendingFile, + '{"original":{"requestId":"request-1","deviceId":"device-1","scopes":["operator.write"]}}', + ); + fs.writeFileSync( + pairedFile, + '{"device-1":{"deviceId":"device-1","scopes":["operator.pairing"],"approvedScopes":["operator.pairing"],"tokens":{"operator":{"role":"operator","scopes":["operator.pairing"]}}}}', + ); + }; + fs.writeFileSync( + path.join(setup.fakeBin, "openclaw"), + `#!/usr/bin/env bash +printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(setup.commandLog)} +cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' +{"replacement":{"requestId":"replacement-1","deviceId":"device-1","scopes":["operator.write","operator.pairing","operator.read","operator.admin"]}} +JSON +echo "gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: \${CASE_REPLACEMENT_ID})" >&2 +exit 1 +`, + { mode: 0o755 }, + ); try { - const result = runGuardedShell(setup, [ - shellOpenclawCommand(["devices", "list", "--json"]), - shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), - `printf 'SHELL_URL=%s\\n' "\${OPENCLAW_GATEWAY_URL-unset}" >> ${JSON.stringify(setup.commandLog)}`, - shellOpenclawCommand(["agent", "--agent", "main", "-m", "hello"]), - ]); - - expect(result.status).toBe(0); - expect(fs.readFileSync(setup.commandLog, "utf-8").trim().split("\n")).toEqual([ - "ARGS=devices list --json URL=ws://127.0.0.1:18789 PORT=18789 TOKEN=test-gateway-token", - "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", - "SHELL_URL=ws://127.0.0.1:18789", - "ARGS=agent --agent main -m hello URL=ws://127.0.0.1:18789 PORT=18789 TOKEN=test-gateway-token", - ]); + for (const [replacementId, shouldRecover] of [ + ["replacement-1", true], + ["replacement-10", false], + ] as const) { + resetState(); + const result = runGuardedShell(setup, [ + `export OPENCLAW_STATE_DIR=${JSON.stringify(stateDir)}`, + `export CASE_REPLACEMENT_ID=${JSON.stringify(replacementId)}`, + shellOpenclawCommand(["devices", "approve", "request-1", "--json"]), + ]); + const paired = readJson(pairedFile); + const pending = readJson(pendingFile); + expect(result.status).toBe(shouldRecover ? 0 : 1); + expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain( + "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", + ); + expect(paired["device-1"].approvedScopes).toEqual( + shouldRecover + ? ["operator.pairing", "operator.read", "operator.write"] + : ["operator.pairing"], + ); + expect(JSON.stringify(paired)).not.toContain("operator.admin"); + expect( + shouldRecover ? JSON.parse(result.stdout).compatibility : pending.replacement.requestId, + ).toBe(shouldRecover ? "openclaw-approve-recovered-replacement" : "replacement-1"); + } } finally { fs.rmSync(setup.tmpDir, { recursive: true, force: true }); } @@ -5035,20 +5075,6 @@ describe("ensure_mutable_openclaw_config_hash root-mode step-down", () => { }); describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => { - // Chain the production helpers in the exact order the root entrypoint - // calls them — ensure_mutable_openclaw_config_hash → - // prepare_gateway_token_for_current_command → export_gateway_token → - // write_runtime_shell_env → lock_rc_files → setup_auth_profile_as_sandbox — - // against a tmpfs layout that mirrors /sandbox + /tmp, with uid=0 stubbed - // and a step-down prefix that mirrors the CAP_DAC_OVERRIDE-dropped - // effective ownership of the mutable config tree. Verifies the - // entrypoint acceptance clauses: - // 1. /sandbox/.openclaw/.config-hash gets a fresh sha256 row. - // 2. /tmp/nemoclaw-proxy-env.sh exists and exports OPENCLAW_GATEWAY_TOKEN. - // 3. Stderr never carries "Missing gateway auth token". - // 4. Stderr never carries the heredoc-roundtrip "syntax error … 'fi'". - // 5. /sandbox/.bashrc and /sandbox/.profile end at mode 0444. - // 6. The chain reaches the continuation path (exit 0). const src = fs.readFileSync(START_SCRIPT, "utf-8"); it("runs the helper chain end-to-end against a simulated root entrypoint", () => { @@ -5065,9 +5091,6 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => configPath, JSON.stringify({ gateway: { port: 18789, auth: {} } }, null, 2) + "\n", ); - // Pre-existing hash file owned by the test uid at mode 0444 mirrors the - // production EACCES condition: the redirection cannot bypass the - // sandbox-only write bit unless the step-down prefix relaxes ownership. fs.writeFileSync(hashPath, "placeholder\n"); fs.chmodSync(hashPath, 0o444); @@ -5102,11 +5125,6 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => "prepare_gateway_token_for_current_command", ); const exportToken = extractShellFunctionFromSource(src, "export_gateway_token"); - // `extractShellFunctionFromSource` looks for the first `^}` after the - // signature, which trips on the embedded `<<'GUARDENVEOF'` heredoc inside - // `write_runtime_shell_env` (the heredoc body contains a column-0 `}` - // that closes the inlined `openclaw()` shell shim). Slice the function - // by the next sibling function's signature instead. const writeRuntimeStart = src.indexOf("write_runtime_shell_env() {"); const writeRuntimeEnd = src.indexOf("\nensure_runtime_shell_env_shim() {", writeRuntimeStart); if (writeRuntimeStart === -1 || writeRuntimeEnd === -1) { @@ -5125,43 +5143,22 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => [ "#!/usr/bin/env bash", "set -euo pipefail", - // Pretend to be uid 0 from the perspective of every consumer. 'id() { if [ "${1:-}" = "-u" ]; then printf "0"; else command id "$@"; fi; }', - // Mutable-default tree owned by the sandbox user. 'openclaw_config_dir_owner() { printf "sandbox"; }', - // prepare/restore wrap the python writer in real production. The - // step-down prefix relaxes the hash file mode the same way, so the - // wrappers stay no-ops here. "prepare_openclaw_config_for_write() { :; }", "restore_openclaw_config_after_write() { :; }", - // Drive the production gating fn instead of stubbing it: the root - // entrypoint enters this branch with `NEMOCLAW_CMD=()`, which sends - // `needs_gateway_token_for_current_command` down the `return 0` path - // and `prepare_gateway_token_for_current_command` into a real - // `ensure_gateway_token` call. "NEMOCLAW_CMD=()", - // Proxy environment is empty in the test — the function still writes - // the file because it is hardcoded to do so once entered. '_PROXY_URL=""', '_NO_PROXY_VAL=""', - // CAP_DAC_OVERRIDE-dropped step-down: the only effective recovery - // the production sandbox-uid switch performs (from this test's - // single-uid vantage) is restoring the write bit on the hash file - // it owns. Mirror that here. `STEP_DOWN_PREFIX_SANDBOX=(bash -c 'chmod 0660 ${JSON.stringify(hashPath)} 2>/dev/null; exec "$@"' sandbox-step-down)`, - // Stub lock_rc_files so it does not require CAP_CHOWN inside vitest. "lock_rc_files() {", ' for rc in "${1}/.bashrc" "${1}/.profile"; do', ' [ -f "$rc" ] && chmod 0444 "$rc"', " done", "}", - // `emit_sandbox_sourced_file` is provided by sandbox-init.sh in - // production; mirror its tee-to-444 shape here. 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', "write_auth_profile() { :; }", "harden_auth_profiles() { :; }", - // Default the script-globals write_runtime_shell_env reads so `set -u` - // does not trip and the optional emit branches stay dormant in the test. '_SANDBOX_SAFETY_NET=""', '_PROXY_FIX_SCRIPT=""', '_WS_FIX_SCRIPT=""', @@ -5183,14 +5180,12 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => writeRuntimeEnv, helper, setupAuth, - // Exact production call order from the root path of the entrypoint. "ensure_mutable_openclaw_config_hash", "prepare_gateway_token_for_current_command", "export_gateway_token", "write_runtime_shell_env", `lock_rc_files ${JSON.stringify(sandboxHome)}`, "setup_auth_profile_as_sandbox", - // Continuation signal. 'echo "CONTINUATION_REACHED"', ].join("\n"), { mode: 0o700 }, @@ -5199,29 +5194,23 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => try { const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 10000 }); - // Clause 6: continuation path reached, exit 0. expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain("CONTINUATION_REACHED"); - // Clauses 3 and 4: neither failure mode the linked issues described. expect(result.stderr).not.toContain("Missing gateway auth token"); expect(result.stderr).not.toMatch(/syntax error near unexpected token .?fi/); - // Clause 1: hash refresh wrote a fresh sha256 row. const hashContents = fs.readFileSync(hashPath, "utf-8").trim(); expect(hashContents).toMatch(/^[0-9a-f]{64}\s+openclaw\.json$/); expect((fs.statSync(hashPath).mode & 0o777).toString(8)).toBe("660"); - // Clause 2: proxy env file present with the gateway token export. expect(fs.existsSync(proxyEnvFile)).toBe(true); const proxyEnv = fs.readFileSync(proxyEnvFile, "utf-8"); expect(proxyEnv).toMatch(/export OPENCLAW_GATEWAY_TOKEN='[A-Za-z0-9_-]{20,}'/); - // Clause 5: rc files locked. expect((fs.statSync(bashrcPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(profilePath).mode & 0o777).toString(8)).toBe("444"); - // The token persisted into openclaw.json matches the export above. const updatedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(updatedConfig.gateway?.auth?.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); expect(proxyEnv).toContain( From 01b1407f51ff57bb58098b5b8dbb62aa0b827a86 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 14:53:32 +0530 Subject: [PATCH 7/8] test(openclaw): assert recovered approval state --- ci/test-file-size-budget.json | 2 +- test/nemoclaw-start.test.ts | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 5f86dd137d..19d55bd71a 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/channels-add-preset.test.ts": 1872, "test/generate-openclaw-config.test.ts": 1990, "test/install-preflight.test.ts": 4207, - "test/nemoclaw-start.test.ts": 5223, + "test/nemoclaw-start.test.ts": 5231, "test/onboard-messaging.test.ts": 2094, "test/onboard-selection.test.ts": 6891, "test/onboard.test.ts": 4775, diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f2d936c788..bc8cd24a93 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -961,12 +961,20 @@ exit 1 expect(fs.readFileSync(setup.commandLog, "utf-8")).toContain( "ARGS=devices approve request-1 --json URL=unset PORT=unset TOKEN=unset", ); - expect(paired["device-1"].approvedScopes).toEqual( - shouldRecover - ? ["operator.pairing", "operator.read", "operator.write"] - : ["operator.pairing"], - ); + const expectedScopes = shouldRecover + ? ["operator.pairing", "operator.read", "operator.write"] + : ["operator.pairing"]; + for (const scopes of [ + paired["device-1"].approvedScopes, + paired["device-1"].scopes, + paired["device-1"].tokens.operator.scopes, + ]) { + expect(scopes).toEqual(expectedScopes); + } expect(JSON.stringify(paired)).not.toContain("operator.admin"); + expect(shouldRecover ? pending : pending.replacement.requestId).toEqual( + shouldRecover ? {} : "replacement-1", + ); expect( shouldRecover ? JSON.parse(result.stdout).compatibility : pending.replacement.requestId, ).toBe(shouldRecover ? "openclaw-approve-recovered-replacement" : "replacement-1"); From 96fa723eddac09b10692796605c9a7e5d1fd8da1 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 11 Jun 2026 15:02:06 +0530 Subject: [PATCH 8/8] fix(openclaw): recover opaque approval replacement --- scripts/nemoclaw-start.sh | 32 ++++++++++++++++++++------------ test/nemoclaw-start.test.ts | 6 +++--- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 245b0b1325..7b70d6b4ed 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2357,23 +2357,31 @@ if request_id and requested and not still_pending and isinstance(paired_entry, d print(json.dumps({"requestId": request_id, "deviceId": device_id, "approvedScopes": sorted(requested), "compatibility": "openclaw-approve-applied-after-nonzero"}, sort_keys=True)) raise SystemExit(0) -# Compatibility boundary: repair only the local OpenClaw device state after the -# CLI reports a replaced scope-upgrade approval. Remove this once OpenClaw -# stops replacing operator.write approvals with admin-shaped pending requests -# or exposes a supported approval repair API. -if "scope upgrade pending approval" not in approve_output and "pairing required" not in approve_output: - raise SystemExit(1) +# Compatibility boundary: repair only the local OpenClaw device state after a +# failed approve leaves behind exactly one same-device admin-shaped replacement +# request. Some OpenClaw failures only surface opaque gateway text, so the state +# files are the source of truth; stderr is only used as an exact disambiguator +# when it carries a replacement request ID. Remove this once OpenClaw stops +# replacing operator.write approvals with admin-shaped pending requests or +# exposes a supported approval repair API. allowed = {"operator.pairing", "operator.read", "operator.write"} -if not request_id or not device_id or not requested or not requested.issubset(allowed) or "operator.pairing" not in paired_scopes: +if not request_id or not device_id or not requested or not requested.issubset(allowed) or "operator.pairing" not in paired_scopes or still_pending: raise SystemExit(1) -replacement_key = replacement = None +replacement_allowed = allowed | {"operator.admin"} +candidates = [] +mentioned = [] for key, item in pending.items(): item_scopes = scope_set(item) if isinstance(item, dict) else set() if (isinstance(item, dict) and norm(item.get("requestId")) != request_id and norm(item.get("deviceId")) == device_id and - "operator.admin" in item_scopes and requested.issubset(item_scopes) and output_mentions_request_id(item.get("requestId"))): - replacement_key, replacement = key, item - break -if not replacement: + "operator.admin" in item_scopes and requested.issubset(item_scopes) and item_scopes.issubset(replacement_allowed)): + candidates.append((key, item)) + if output_mentions_request_id(item.get("requestId")): + mentioned.append((key, item)) +if len(mentioned) == 1: + replacement_key, replacement = mentioned[0] +elif len(candidates) == 1 and not re.search(r"\brequestId\b|\brequest[-_ ]?id\b", approve_output, re.IGNORECASE): + replacement_key, replacement = candidates[0] +else: raise SystemExit(1) approved = set(paired_scopes) | requested if "operator.write" in approved: diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index bc8cd24a93..cbb1abae9f 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -913,8 +913,7 @@ describe("nemoclaw-start configure guard behavior", () => { fs.rmSync(setup.tmpDir, { recursive: true, force: true }); } }); - - it("#4462: unsets gateway env and recovers only exact replacement request IDs", () => { + it("#4462: unsets gateway env and recovers constrained replacement state", () => { const setup = writeProxyEnvWithGuard(); const stateDir = path.join(setup.tmpDir, "openclaw-state"); const devicesDir = path.join(stateDir, "devices"); @@ -939,7 +938,7 @@ printf 'ARGS=%s URL=%s PORT=%s TOKEN=%s\n' "$*" "\${OPENCLAW_GATEWAY_URL-unset}" cat > "\${OPENCLAW_STATE_DIR}/devices/pending.json" <<'JSON' {"replacement":{"requestId":"replacement-1","deviceId":"device-1","scopes":["operator.write","operator.pairing","operator.read","operator.admin"]}} JSON -echo "gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: \${CASE_REPLACEMENT_ID})" >&2 +if [ -n "\${CASE_REPLACEMENT_ID:-}" ]; then echo "gateway connect failed: GatewayClientRequestError: scope upgrade pending approval (requestId: \${CASE_REPLACEMENT_ID})" >&2; else echo "gateway connect failed: G" >&2; fi exit 1 `, { mode: 0o755 }, @@ -948,6 +947,7 @@ exit 1 for (const [replacementId, shouldRecover] of [ ["replacement-1", true], ["replacement-10", false], + ["", true], ] as const) { resetState(); const result = runGuardedShell(setup, [