Skip to content

Commit 4a35f44

Browse files
committed
fix(cli): settle portable OpenClaw pairing (#9207)
Signed-off-by: Senthil Ravichandran <senthilr@nvidia.com>
1 parent 54cb2a4 commit 4a35f44

22 files changed

Lines changed: 2062 additions & 74 deletions

docs/manage-sandboxes/recover-rebuild-sandboxes.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ It skips the complete pairing approval pass only when the qualification still ma
164164
Any missing, unreadable, malformed, ambiguous, or changed observation runs the complete pairing approval pass.
165165
A relevant allowlisted pending request also runs that complete path.
166166

167+
For a current Portable OpenClaw lifecycle receipt, NemoClaw also requires a finalized onboarding policy step and strictly settled local CLI operator pairing.
168+
If only the paired device exists and no request is pending, recovery runs the canonical OpenClaw request producer once and makes at most one approval attempt.
169+
An ambiguous approval result receives one final observation and no approval retry.
170+
NemoClaw publishes no lease when the policy step is incomplete or the receipt, runtime identity, or pairing state is invalid or ambiguous.
171+
The command exits nonzero with an incomplete-onboarding diagnostic and tells you to resume or rerun onboarding.
172+
167173
</AgentOnly>
168174
Hermes and LangChain Deep Agents Code retain their existing session setup on the lease-accepted path.
169175
When those checks pass, it can skip duplicate recovery, readiness polling, and inference-route repair.

docs/reference/commands.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,19 @@ NemoClaw ignores ambient `XDG_CONFIG_HOME` during onboarding and restores its ex
378378
Resume rejects a checkpoint that records another configuration root.
379379
It also rejects stored authority or filesystem ownership drift without falling back to Docker.
380380

381+
<AgentOnly variant="openclaw">
382+
383+
Portable OpenClaw onboarding does not enter the `complete` state until NemoClaw proves that the local CLI operator pairing is settled.
384+
The paired device must have exactly the `operator.pairing` and `operator.write` scopes.
385+
Any pairing request considered during bounded repair must request exactly those scopes.
386+
The active token and client authorization must have exactly the `operator.pairing`, `operator.read`, and `operator.write` scopes.
387+
NemoClaw rejects every extra, missing, unknown, malformed, or ambiguous scope or identity shape.
388+
If the policy preset step is incomplete, NemoClaw performs no pairing request or approval writes and publishes no launch-readiness evidence.
389+
Selected Portable onboarding also stops when its lifecycle receipt is missing, invalid, legacy, or incompatible.
390+
A failed check leaves onboarding incomplete and tells you to resume or rerun onboarding.
391+
392+
</AgentOnly>
393+
381394
<Warning title="Checkpoint Resume Compatibility">
382395
An active onboarding session with checkpoint schema 1, 2, or 3 cannot resume because those schemas did not record the default or portable profile authority.
383396
NemoClaw preserves the older session and exits before portable configuration, socket activation, or resource changes.
@@ -1287,6 +1300,17 @@ The next `launch` runs the complete preflight.
12871300
On Linux, the publication-failure diagnostic is redacted and does not print filesystem paths or environment values.
12881301
Run it for health checks and scripted readiness probes; users continue to run only `$$nemoclaw launch <name>`.
12891302

1303+
<AgentOnly variant="openclaw">
1304+
1305+
For a current Portable OpenClaw sandbox, `connect`, `connect --probe-only`, `recover`, and `launch` require the same strict local CLI operator pairing as onboarding.
1306+
If NemoClaw finds only the paired device and no pending request, it runs the canonical OpenClaw request producer once.
1307+
It then runs at most one canonical approval and observes the final pairing state.
1308+
An ambiguous approval result receives one final observation and no approval retry.
1309+
Pairing with missing, extra, unknown, malformed, or ambiguous scope or identity data exits nonzero with an incomplete-onboarding diagnostic instead of opening a session or publishing launch-readiness evidence.
1310+
Follow the diagnostic to resume or rerun onboarding.
1311+
1312+
</AgentOnly>
1313+
12901314
Use [`$$nemoclaw launch <name>`](#$$nemoclaw-launch-name) when you want launch-readiness validation, an automatic fallback that runs the complete preflight, and then the agent instead of a sandbox shell.
12911315

12921316
### `$$nemoclaw <name> exec`

docs/security/gateway-authentication-controls.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,10 @@ The auto-pair watcher automatically approves device pairing requests from recogn
118118

119119
| Aspect | Detail |
120120
|---|---|
121-
| Default | Startup auto-pairing and `connect`-time approval share one policy. A lease-qualified `launch` checks current pairing state and runs the complete approval path when the stored qualification no longer matches or a relevant allowlisted request is pending. NemoClaw approves devices only when `clientId` is `cli`, `openclaw-cli`, or `openclaw-control-ui`, and only for `operator.pairing`, `operator.read`, and `operator.write` scopes. An allowlisted `clientMode` alone is never sufficient; all other clients or scopes are rejected and logged. |
121+
| Default | Startup auto-pairing and `connect`-time approval share one policy. A lease-qualified `launch` checks current pairing state and runs the complete approval path when the stored qualification no longer matches or a relevant allowlisted request is pending. NemoClaw approves devices only when `clientId` is `cli`, `openclaw-cli`, or `openclaw-control-ui`, and only for `operator.pairing`, `operator.read`, and `operator.write` scopes. An allowlisted `clientMode` alone is never sufficient; all other clients or scopes are rejected and logged. Portable OpenClaw finalization and recovery require the local `cli` client in `cli` mode with the `operator` role. Its paired device must have exactly `operator.pairing` and `operator.write`; any pairing request considered during bounded repair must request exactly those scopes. Its active token and client authorization must have exactly `operator.pairing`, `operator.read`, and `operator.write`. Every extra, missing, unknown, malformed, or ambiguous scope or identity shape is rejected. |
122122
| What you can change | This is not a user-facing knob. The allowlist is defined by NemoClaw's OpenClaw device-approval helper. |
123123
| Risk if relaxed | Approving all device types without validation lets rogue or unexpected clients pair with the gateway unchallenged. |
124-
| Recommendation | No action needed. NemoClaw handles this automatically at startup, during `connect`, and through the complete `launch` fallback for late scope upgrades. If you see `[auto-pair] rejected unknown client=...` in the logs, investigate the source of the unexpected connection. |
124+
| Recommendation | No action needed. NemoClaw handles this automatically at startup, during `connect`, and through the complete `launch` fallback for late scope upgrades. Portable repair invokes the canonical request producer once and makes at most one approval attempt. It observes an ambiguous result once and never repeats the approval. If you see `[auto-pair] rejected unknown client=...` in the logs, investigate the source of the unexpected connection. |
125125

126126
### Approve Administrative Scopes Manually
127127

src/lib/actions/sandbox/auto-pair-approval.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {
5454
CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S,
5555
CONNECT_AUTO_PAIR_TIMEOUT_MS,
5656
} from "./connect-autopair-budget";
57+
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";
58+
import { buildTrustedProxyEnvSourceShell } from "./trusted-proxy-env";
5759

5860
// Bound the in-sandbox work: 2s list + 1s × MAX_APPROVALS attempts plus
5961
// shell/python startup slack fits inside the outer spawnSync cap, so a wedged
@@ -65,6 +67,12 @@ export const AUTO_PAIR_APPROVAL_TIMEOUT_MS = 12_000;
6567
const AUTO_PAIR_LIST_TIMEOUT_S = 2;
6668
const AUTO_PAIR_APPROVE_TIMEOUT_S = 1;
6769
const AUTO_PAIR_POST_TIMEOUT_POLL_S = 0.1;
70+
const PORTABLE_PAIRING_APPROVAL_MARKER = "__NEMOCLAW_PORTABLE_PAIRING_APPROVAL__=";
71+
const PORTABLE_PAIRING_SHA256_RE = /^[a-f0-9]{64}$/u;
72+
const PORTABLE_PAIRING_APPROVAL_MAX_OUTPUT_BYTES = 4 * 1_024;
73+
const PORTABLE_PAIRING_PRODUCER_TIMEOUT_MS = 30_000;
74+
const PORTABLE_PAIRING_PENDING_ATTEMPTS = 5;
75+
const PORTABLE_PAIRING_LIST_TIMEOUT_S = 2;
6876

6977
const CONNECT_AUTO_PAIR_BUDGET = {
7078
maxApprovals: CONNECT_AUTO_PAIR_MAX_APPROVALS,
@@ -1180,3 +1188,243 @@ export function runConnectAutoPairApprovalPass(
11801188
}
11811189
}
11821190
}
1191+
1192+
export type PortableOpenClawPairingApprovalReceipt =
1193+
| "approved"
1194+
| "ambiguous"
1195+
| "no-request"
1196+
| "rejected"
1197+
| "unavailable";
1198+
1199+
function fixedPortableApprovalReceipt(receipt: PortableOpenClawPairingApprovalReceipt): string {
1200+
return `print(${JSON.stringify(`${PORTABLE_PAIRING_APPROVAL_MARKER}${receipt}`)})`;
1201+
}
1202+
1203+
export function parsePortableOpenClawPairingApprovalReceipt(
1204+
output: string,
1205+
): PortableOpenClawPairingApprovalReceipt | null {
1206+
const lines = output.trimEnd().split(/\r?\n/u);
1207+
const markerLines = lines.filter((line) =>
1208+
line.startsWith(PORTABLE_PAIRING_APPROVAL_MARKER),
1209+
);
1210+
if (markerLines.length !== 1 || lines.at(-1) !== markerLines[0]) return null;
1211+
const receipt = markerLines[0]!.slice(PORTABLE_PAIRING_APPROVAL_MARKER.length);
1212+
return ["approved", "ambiguous", "no-request", "rejected", "unavailable"].includes(receipt)
1213+
? (receipt as PortableOpenClawPairingApprovalReceipt)
1214+
: null;
1215+
}
1216+
1217+
export function buildPortableOpenClawPairingApprovalScript(
1218+
approvalPolicyModuleB64: string,
1219+
expectedDeviceIdentitySha256: string,
1220+
): string {
1221+
if (
1222+
!approvalPolicyModuleB64 ||
1223+
Buffer.from(approvalPolicyModuleB64, "base64").toString("base64") !==
1224+
approvalPolicyModuleB64 ||
1225+
!PORTABLE_PAIRING_SHA256_RE.test(expectedDeviceIdentitySha256)
1226+
) {
1227+
throw new Error("Portable OpenClaw pairing approval inputs are invalid.");
1228+
}
1229+
return `
1230+
${buildTrustedProxyEnvSourceShell()}
1231+
command -v openclaw >/dev/null 2>&1 || { printf '%s\\n' '${PORTABLE_PAIRING_APPROVAL_MARKER}unavailable'; exit 0; }
1232+
command -v python3 >/dev/null 2>&1 || { printf '%s\\n' '${PORTABLE_PAIRING_APPROVAL_MARKER}unavailable'; exit 0; }
1233+
OPENCLAW_BIN="$(command -v openclaw)" \\
1234+
NEMOCLAW_APPROVAL_POLICY_B64=${shellQuote(approvalPolicyModuleB64)} \\
1235+
NEMOCLAW_EXPECTED_DEVICE_IDENTITY_SHA256=${shellQuote(expectedDeviceIdentitySha256)} \\
1236+
python3 - <<'PYAPPROVE'
1237+
import base64
1238+
import hashlib
1239+
import json
1240+
import os
1241+
import re
1242+
import subprocess
1243+
import time
1244+
1245+
OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw')
1246+
EXPECTED_IDENTITY = os.environ.get('NEMOCLAW_EXPECTED_DEVICE_IDENTITY_SHA256', '')
1247+
REQUEST_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$')
1248+
REQUEST_SCOPES = {'operator.pairing', 'operator.write'}
1249+
1250+
try:
1251+
policy_source = base64.b64decode(
1252+
os.environ.get('NEMOCLAW_APPROVAL_POLICY_B64', ''), validate=True,
1253+
).decode('utf-8')
1254+
policy_globals = {}
1255+
exec(compile(policy_source, 'openclaw_device_approval_policy.py', 'exec'), policy_globals)
1256+
approval_request_decision = policy_globals['approval_request_decision']
1257+
gateway_approval_env = policy_globals['gateway_approval_env']
1258+
except Exception:
1259+
${fixedPortableApprovalReceipt("unavailable")}
1260+
raise SystemExit(0)
1261+
1262+
pending = []
1263+
for pending_attempt in range(${PORTABLE_PAIRING_PENDING_ATTEMPTS}):
1264+
try:
1265+
listed = subprocess.run(
1266+
[OPENCLAW, 'devices', 'list', '--json'],
1267+
capture_output=True, text=True, timeout=${PORTABLE_PAIRING_LIST_TIMEOUT_S},
1268+
)
1269+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1270+
${fixedPortableApprovalReceipt("unavailable")}
1271+
raise SystemExit(0)
1272+
if listed.returncode != 0 or not listed.stdout.strip():
1273+
${fixedPortableApprovalReceipt("unavailable")}
1274+
raise SystemExit(0)
1275+
try:
1276+
data = json.loads(listed.stdout)
1277+
except ValueError:
1278+
${fixedPortableApprovalReceipt("unavailable")}
1279+
raise SystemExit(0)
1280+
if not isinstance(data, dict) or not isinstance(data.get('pending'), list):
1281+
${fixedPortableApprovalReceipt("unavailable")}
1282+
raise SystemExit(0)
1283+
pending = data['pending']
1284+
if pending:
1285+
break
1286+
if pending_attempt + 1 < ${PORTABLE_PAIRING_PENDING_ATTEMPTS}:
1287+
time.sleep(1)
1288+
if not pending:
1289+
${fixedPortableApprovalReceipt("no-request")}
1290+
raise SystemExit(0)
1291+
if len(pending) != 1 or not isinstance(pending[0], dict):
1292+
${fixedPortableApprovalReceipt("rejected")}
1293+
raise SystemExit(0)
1294+
request = pending[0]
1295+
request_id = request.get('requestId')
1296+
device_id = request.get('deviceId')
1297+
public_key = request.get('publicKey')
1298+
scopes = request.get('scopes')
1299+
identity = hashlib.sha256(json.dumps({
1300+
'deviceId': device_id,
1301+
'publicKey': public_key,
1302+
}, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()
1303+
decision = approval_request_decision(request)
1304+
if (
1305+
not isinstance(request_id, str)
1306+
or not REQUEST_ID_RE.fullmatch(request_id)
1307+
or not isinstance(device_id, str)
1308+
or not device_id
1309+
or not isinstance(public_key, str)
1310+
or not public_key
1311+
or 'publicKeyPem' in request
1312+
or identity != EXPECTED_IDENTITY
1313+
or request.get('clientId') != 'cli'
1314+
or request.get('clientMode') != 'cli'
1315+
or request.get('role') != 'operator'
1316+
or not isinstance(request.get('roles'), list)
1317+
or request.get('roles') != ['operator']
1318+
or not isinstance(scopes, list)
1319+
or len(scopes) != len(set(scopes))
1320+
or set(scopes) != REQUEST_SCOPES
1321+
or 'requestedScopes' in request
1322+
or type(request.get('isRepair')) is not bool
1323+
or not isinstance(decision, dict)
1324+
or decision.get('allowed') is not True
1325+
):
1326+
${fixedPortableApprovalReceipt("rejected")}
1327+
raise SystemExit(0)
1328+
1329+
approve_env = gateway_approval_env(os.environ)
1330+
approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None)
1331+
approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None)
1332+
try:
1333+
approved = subprocess.run(
1334+
[OPENCLAW, 'devices', 'approve', request_id, '--json'],
1335+
capture_output=True, text=True, timeout=${CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S},
1336+
env=approve_env,
1337+
)
1338+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1339+
${fixedPortableApprovalReceipt("ambiguous")}
1340+
raise SystemExit(0)
1341+
${fixedPortableApprovalReceipt("approved")} if approved.returncode == 0 else ${fixedPortableApprovalReceipt("ambiguous")}
1342+
PYAPPROVE
1343+
exit 0
1344+
`;
1345+
}
1346+
1347+
/** Run the canonical request producer once; all command output remains in the sandbox. */
1348+
export function runPortableOpenClawPairingRequestProducer(
1349+
sandboxName: string,
1350+
gatewayName: string,
1351+
execDeps?: AutoPairApprovalExecDeps,
1352+
): void {
1353+
const script = `
1354+
${buildTrustedProxyEnvSourceShell()}
1355+
command -v openclaw >/dev/null 2>&1 || exit 0
1356+
NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\
1357+
openclaw agent --agent main -m "ping" \\
1358+
--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true
1359+
exit 0
1360+
`;
1361+
const deps =
1362+
execDeps ??
1363+
(() => {
1364+
const { getOpenshellBinary } =
1365+
require("../../adapters/openshell/runtime") as typeof import("../../adapters/openshell/runtime");
1366+
return { getOpenshellBinary, spawnSync };
1367+
})();
1368+
try {
1369+
deps.spawnSync(
1370+
deps.getOpenshellBinary(),
1371+
["sandbox", "exec", "--name", sandboxName, "-g", gatewayName, "--", "sh", "-s"],
1372+
{
1373+
cwd: ROOT,
1374+
env: process.env,
1375+
input: script,
1376+
encoding: "utf8",
1377+
stdio: ["pipe", "ignore", "ignore"],
1378+
timeout: PORTABLE_PAIRING_PRODUCER_TIMEOUT_MS,
1379+
},
1380+
);
1381+
} catch {
1382+
// The strict approval and final observation classify producer failure.
1383+
}
1384+
}
1385+
1386+
/** Invoke at most one canonical `openclaw devices approve` command. */
1387+
export function runPortableOpenClawPairingApproval(
1388+
sandboxName: string,
1389+
gatewayName: string,
1390+
expectedDeviceIdentitySha256: string,
1391+
execDeps?: AutoPairApprovalExecDeps,
1392+
): PortableOpenClawPairingApprovalReceipt {
1393+
const approvalPolicy = readAutoPairApprovalPolicyModule();
1394+
if (!approvalPolicy) return "unavailable";
1395+
let script: string;
1396+
try {
1397+
script = buildPortableOpenClawPairingApprovalScript(
1398+
Buffer.from(approvalPolicy, "utf8").toString("base64"),
1399+
expectedDeviceIdentitySha256,
1400+
);
1401+
} catch {
1402+
return "unavailable";
1403+
}
1404+
const deps =
1405+
execDeps ??
1406+
(() => {
1407+
const { getOpenshellBinary } =
1408+
require("../../adapters/openshell/runtime") as typeof import("../../adapters/openshell/runtime");
1409+
return { getOpenshellBinary, spawnSync };
1410+
})();
1411+
try {
1412+
const result = deps.spawnSync(
1413+
deps.getOpenshellBinary(),
1414+
["sandbox", "exec", "--name", sandboxName, "-g", gatewayName, "--", "sh", "-s"],
1415+
{
1416+
cwd: ROOT,
1417+
env: process.env,
1418+
input: script,
1419+
encoding: "utf8",
1420+
maxBuffer: PORTABLE_PAIRING_APPROVAL_MAX_OUTPUT_BYTES,
1421+
stdio: ["pipe", "pipe", "ignore"],
1422+
timeout: CONNECT_AUTO_PAIR_TIMEOUT_MS,
1423+
},
1424+
);
1425+
if (result.error || result.signal || result.status !== 0) return "ambiguous";
1426+
return parsePortableOpenClawPairingApprovalReceipt(String(result.stdout ?? "")) ?? "ambiguous";
1427+
} catch {
1428+
return "ambiguous";
1429+
}
1430+
}

src/lib/actions/sandbox/connect-flow.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,38 @@ describe("connectSandbox flow", () => {
8383
expect(harness.runAutoPairSpy).toHaveBeenCalledWith("alpha", "nemoclaw-8091");
8484
});
8585

86+
it("uses strict settlement and skips ordinary approval for a completed Portable sandbox (#9207)", async () => {
87+
const harness = createConnectHarness({
88+
portablePairingSettlementResult: { kind: "settled" },
89+
});
90+
91+
await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)");
92+
93+
expect(harness.settlePortablePairingSpy).toHaveBeenCalledWith("alpha");
94+
expect(harness.runAutoPairSpy).not.toHaveBeenCalled();
95+
});
96+
97+
it("stops connect with an incomplete-onboarding diagnosis when Portable settlement fails (#9207)", async () => {
98+
const harness = createConnectHarness({
99+
portablePairingSettlementResult: {
100+
kind: "incomplete",
101+
reason: "portable-policy-incomplete",
102+
},
103+
});
104+
105+
await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)");
106+
107+
const output = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
108+
expect(output).toContain("Portable onboarding for 'alpha' is incomplete");
109+
expect(output).toContain("Resume or rerun onboarding");
110+
expect(harness.runAutoPairSpy).not.toHaveBeenCalled();
111+
expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith(
112+
"openshell",
113+
["sandbox", "connect", "alpha"],
114+
expect.anything(),
115+
);
116+
});
117+
86118
it("restores the terminal and prints reconnect guidance when SSH disconnects", async () => {
87119
const setRawModeSpy = vi.fn();
88120
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });

src/lib/actions/sandbox/connect-probe-observe.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ describe("connectSandbox probe-only observe mode", () => {
5555
);
5656
});
5757

58+
it("settles completed Portable pairing before publishing probe readiness (#9207)", async () => {
59+
const harness = createConnectHarness({
60+
portablePairingSettlementResult: { kind: "settled" },
61+
});
62+
63+
await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined();
64+
65+
expect(harness.settlePortablePairingSpy).toHaveBeenCalledWith("alpha");
66+
expect(harness.runAutoPairSpy).not.toHaveBeenCalled();
67+
expect(harness.settlePortablePairingSpy.mock.invocationCallOrder[0]).toBeLessThan(
68+
harness.publishLaunchReadinessSpy.mock.invocationCallOrder[0]!,
69+
);
70+
});
71+
5872
it("uses gatewayRecovery=recover on the full connect path", async () => {
5973
const harness = createConnectHarness();
6074

0 commit comments

Comments
 (0)