Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ It skips the complete pairing approval pass only when the qualification still ma
Any missing, unreadable, malformed, ambiguous, or changed observation runs the complete pairing approval pass.
A relevant allowlisted pending request also runs that complete path.

For a current Portable OpenClaw lifecycle receipt, NemoClaw also requires a finalized onboarding policy step and strictly settled local CLI operator pairing.
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.
An ambiguous approval result receives one final observation and no approval retry.
NemoClaw publishes no lease when the policy step is incomplete or the receipt, runtime identity, or pairing state is invalid or ambiguous.
The command exits nonzero with an incomplete-onboarding diagnostic and tells you to resume or rerun onboarding.

</AgentOnly>
Hermes and LangChain Deep Agents Code retain their existing session setup on the lease-accepted path.
When those checks pass, it can skip duplicate recovery, readiness polling, and inference-route repair.
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,19 @@ NemoClaw ignores ambient `XDG_CONFIG_HOME` during onboarding and restores its ex
Resume rejects a checkpoint that records another configuration root.
It also rejects stored authority or filesystem ownership drift without falling back to Docker.

<AgentOnly variant="openclaw">

Portable OpenClaw onboarding does not enter the `complete` state until NemoClaw proves that the local CLI operator pairing is settled.
The paired device must have exactly the `operator.pairing` and `operator.write` scopes.
Any pairing request considered during bounded repair must request exactly those scopes.
The active token and client authorization must have exactly the `operator.pairing`, `operator.read`, and `operator.write` scopes.
NemoClaw rejects every extra, missing, unknown, malformed, or ambiguous scope or identity shape.
If the policy preset step is incomplete, NemoClaw performs no pairing request or approval writes and publishes no launch-readiness evidence.
Selected Portable onboarding also stops when its lifecycle receipt is missing, invalid, legacy, or incompatible.
A failed check leaves onboarding incomplete and tells you to resume or rerun onboarding.

</AgentOnly>

<Warning title="Checkpoint Resume Compatibility">
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.
NemoClaw preserves the older session and exits before portable configuration, socket activation, or resource changes.
Expand Down Expand Up @@ -1287,6 +1300,17 @@ The next `launch` runs the complete preflight.
On Linux, the publication-failure diagnostic is redacted and does not print filesystem paths or environment values.
Run it for health checks and scripted readiness probes; users continue to run only `$$nemoclaw launch <name>`.

<AgentOnly variant="openclaw">

For a current Portable OpenClaw sandbox, `connect`, `connect --probe-only`, `recover`, and `launch` require the same strict local CLI operator pairing as onboarding.
If NemoClaw finds only the paired device and no pending request, it runs the canonical OpenClaw request producer once.
It then runs at most one canonical approval and observes the final pairing state.
An ambiguous approval result receives one final observation and no approval retry.
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.
Follow the diagnostic to resume or rerun onboarding.

</AgentOnly>

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.

### `$$nemoclaw <name> exec`
Expand Down
4 changes: 2 additions & 2 deletions docs/security/gateway-authentication-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ The auto-pair watcher automatically approves device pairing requests from recogn

| Aspect | Detail |
|---|---|
| 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. |
| 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. |
| What you can change | This is not a user-facing knob. The allowlist is defined by NemoClaw's OpenClaw device-approval helper. |
| Risk if relaxed | Approving all device types without validation lets rogue or unexpected clients pair with the gateway unchallenged. |
| 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. |
| 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. |

### Approve Administrative Scopes Manually

Expand Down
248 changes: 248 additions & 0 deletions src/lib/actions/sandbox/auto-pair-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import {
CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S,
CONNECT_AUTO_PAIR_TIMEOUT_MS,
} from "./connect-autopair-budget";
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";
import { buildTrustedProxyEnvSourceShell } from "./trusted-proxy-env";

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

const CONNECT_AUTO_PAIR_BUDGET = {
maxApprovals: CONNECT_AUTO_PAIR_MAX_APPROVALS,
Expand Down Expand Up @@ -1180,3 +1188,243 @@ export function runConnectAutoPairApprovalPass(
}
}
}

export type PortableOpenClawPairingApprovalReceipt =
| "approved"
| "ambiguous"
| "no-request"
| "rejected"
| "unavailable";

function fixedPortableApprovalReceipt(receipt: PortableOpenClawPairingApprovalReceipt): string {
return `print(${JSON.stringify(`${PORTABLE_PAIRING_APPROVAL_MARKER}${receipt}`)})`;
}

export function parsePortableOpenClawPairingApprovalReceipt(
output: string,
): PortableOpenClawPairingApprovalReceipt | null {
const lines = output.trimEnd().split(/\r?\n/u);
const markerLines = lines.filter((line) =>
line.startsWith(PORTABLE_PAIRING_APPROVAL_MARKER),
);
if (markerLines.length !== 1 || lines.at(-1) !== markerLines[0]) return null;
const receipt = markerLines[0]!.slice(PORTABLE_PAIRING_APPROVAL_MARKER.length);
return ["approved", "ambiguous", "no-request", "rejected", "unavailable"].includes(receipt)
? (receipt as PortableOpenClawPairingApprovalReceipt)
: null;
}

export function buildPortableOpenClawPairingApprovalScript(
approvalPolicyModuleB64: string,
expectedDeviceIdentitySha256: string,
): string {
if (
!approvalPolicyModuleB64 ||
Buffer.from(approvalPolicyModuleB64, "base64").toString("base64") !==
approvalPolicyModuleB64 ||
!PORTABLE_PAIRING_SHA256_RE.test(expectedDeviceIdentitySha256)
) {
throw new Error("Portable OpenClaw pairing approval inputs are invalid.");
}
return `
${buildTrustedProxyEnvSourceShell()}
command -v openclaw >/dev/null 2>&1 || { printf '%s\\n' '${PORTABLE_PAIRING_APPROVAL_MARKER}unavailable'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf '%s\\n' '${PORTABLE_PAIRING_APPROVAL_MARKER}unavailable'; exit 0; }
OPENCLAW_BIN="$(command -v openclaw)" \\
NEMOCLAW_APPROVAL_POLICY_B64=${shellQuote(approvalPolicyModuleB64)} \\
NEMOCLAW_EXPECTED_DEVICE_IDENTITY_SHA256=${shellQuote(expectedDeviceIdentitySha256)} \\
python3 - <<'PYAPPROVE'
import base64
import hashlib
import json
import os
import re
import subprocess
import time

OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw')
EXPECTED_IDENTITY = os.environ.get('NEMOCLAW_EXPECTED_DEVICE_IDENTITY_SHA256', '')
REQUEST_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$')
REQUEST_SCOPES = {'operator.pairing', 'operator.write'}

try:
policy_source = base64.b64decode(
os.environ.get('NEMOCLAW_APPROVAL_POLICY_B64', ''), validate=True,
).decode('utf-8')
policy_globals = {}
exec(compile(policy_source, 'openclaw_device_approval_policy.py', 'exec'), policy_globals)
approval_request_decision = policy_globals['approval_request_decision']
gateway_approval_env = policy_globals['gateway_approval_env']
except Exception:
${fixedPortableApprovalReceipt("unavailable")}
raise SystemExit(0)

pending = []
for pending_attempt in range(${PORTABLE_PAIRING_PENDING_ATTEMPTS}):
try:
listed = subprocess.run(
[OPENCLAW, 'devices', 'list', '--json'],
capture_output=True, text=True, timeout=${PORTABLE_PAIRING_LIST_TIMEOUT_S},
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
${fixedPortableApprovalReceipt("unavailable")}
raise SystemExit(0)
if listed.returncode != 0 or not listed.stdout.strip():
${fixedPortableApprovalReceipt("unavailable")}
raise SystemExit(0)
try:
data = json.loads(listed.stdout)
except ValueError:
${fixedPortableApprovalReceipt("unavailable")}
raise SystemExit(0)
if not isinstance(data, dict) or not isinstance(data.get('pending'), list):
${fixedPortableApprovalReceipt("unavailable")}
raise SystemExit(0)
pending = data['pending']
if pending:
break
if pending_attempt + 1 < ${PORTABLE_PAIRING_PENDING_ATTEMPTS}:
time.sleep(1)
if not pending:
${fixedPortableApprovalReceipt("no-request")}
raise SystemExit(0)
if len(pending) != 1 or not isinstance(pending[0], dict):
${fixedPortableApprovalReceipt("rejected")}
raise SystemExit(0)
request = pending[0]
request_id = request.get('requestId')
device_id = request.get('deviceId')
public_key = request.get('publicKey')
scopes = request.get('scopes')
identity = hashlib.sha256(json.dumps({
'deviceId': device_id,
'publicKey': public_key,
}, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()
decision = approval_request_decision(request)
if (
not isinstance(request_id, str)
or not REQUEST_ID_RE.fullmatch(request_id)
or not isinstance(device_id, str)
or not device_id
or not isinstance(public_key, str)
or not public_key
or 'publicKeyPem' in request
or identity != EXPECTED_IDENTITY
or request.get('clientId') != 'cli'
or request.get('clientMode') != 'cli'
or request.get('role') != 'operator'
or not isinstance(request.get('roles'), list)
or request.get('roles') != ['operator']
or not isinstance(scopes, list)
or len(scopes) != len(set(scopes))
or set(scopes) != REQUEST_SCOPES
or 'requestedScopes' in request
or type(request.get('isRepair')) is not bool
or not isinstance(decision, dict)
or decision.get('allowed') is not True
):
${fixedPortableApprovalReceipt("rejected")}
raise SystemExit(0)

approve_env = gateway_approval_env(os.environ)
approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None)
approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None)
try:
approved = subprocess.run(
[OPENCLAW, 'devices', 'approve', request_id, '--json'],
capture_output=True, text=True, timeout=${CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S},
env=approve_env,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
${fixedPortableApprovalReceipt("ambiguous")}
raise SystemExit(0)
${fixedPortableApprovalReceipt("approved")} if approved.returncode == 0 else ${fixedPortableApprovalReceipt("ambiguous")}
PYAPPROVE
exit 0
`;
}

/** Run the canonical request producer once; all command output remains in the sandbox. */
export function runPortableOpenClawPairingRequestProducer(
sandboxName: string,
gatewayName: string,
execDeps?: AutoPairApprovalExecDeps,
): void {
const script = `
${buildTrustedProxyEnvSourceShell()}
command -v openclaw >/dev/null 2>&1 || exit 0
NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\
openclaw agent --agent main -m "ping" \\
--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true
exit 0
`;
const deps =
execDeps ??
(() => {
const { getOpenshellBinary } =
require("../../adapters/openshell/runtime") as typeof import("../../adapters/openshell/runtime");
return { getOpenshellBinary, spawnSync };
})();
try {
deps.spawnSync(
deps.getOpenshellBinary(),
["sandbox", "exec", "--name", sandboxName, "-g", gatewayName, "--", "sh", "-s"],
{
cwd: ROOT,
env: process.env,
input: script,
encoding: "utf8",
stdio: ["pipe", "ignore", "ignore"],
timeout: PORTABLE_PAIRING_PRODUCER_TIMEOUT_MS,
},
);
} catch {
// The strict approval and final observation classify producer failure.
}
}

/** Invoke at most one canonical `openclaw devices approve` command. */
export function runPortableOpenClawPairingApproval(
sandboxName: string,
gatewayName: string,
expectedDeviceIdentitySha256: string,
execDeps?: AutoPairApprovalExecDeps,
): PortableOpenClawPairingApprovalReceipt {
const approvalPolicy = readAutoPairApprovalPolicyModule();
if (!approvalPolicy) return "unavailable";
let script: string;
try {
script = buildPortableOpenClawPairingApprovalScript(
Buffer.from(approvalPolicy, "utf8").toString("base64"),
expectedDeviceIdentitySha256,
);
} catch {
return "unavailable";
}
const deps =
execDeps ??
(() => {
const { getOpenshellBinary } =
require("../../adapters/openshell/runtime") as typeof import("../../adapters/openshell/runtime");
return { getOpenshellBinary, spawnSync };
})();
try {
const result = deps.spawnSync(
deps.getOpenshellBinary(),
["sandbox", "exec", "--name", sandboxName, "-g", gatewayName, "--", "sh", "-s"],
{
cwd: ROOT,
env: process.env,
input: script,
encoding: "utf8",
maxBuffer: PORTABLE_PAIRING_APPROVAL_MAX_OUTPUT_BYTES,
stdio: ["pipe", "pipe", "ignore"],
timeout: CONNECT_AUTO_PAIR_TIMEOUT_MS,
},
);
if (result.error || result.signal || result.status !== 0) return "ambiguous";
return parsePortableOpenClawPairingApprovalReceipt(String(result.stdout ?? "")) ?? "ambiguous";
} catch {
return "ambiguous";
}
}
32 changes: 32 additions & 0 deletions src/lib/actions/sandbox/connect-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,38 @@ describe("connectSandbox flow", () => {
expect(harness.runAutoPairSpy).toHaveBeenCalledWith("alpha", "nemoclaw-8091");
});

it("uses strict settlement and skips ordinary approval for a completed Portable sandbox (#9207)", async () => {
const harness = createConnectHarness({
portablePairingSettlementResult: { kind: "settled" },
});

await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)");

expect(harness.settlePortablePairingSpy).toHaveBeenCalledWith("alpha");
expect(harness.runAutoPairSpy).not.toHaveBeenCalled();
});

it("stops connect with an incomplete-onboarding diagnosis when Portable settlement fails (#9207)", async () => {
const harness = createConnectHarness({
portablePairingSettlementResult: {
kind: "incomplete",
reason: "portable-policy-incomplete",
},
});

await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)");

const output = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("Portable onboarding for 'alpha' is incomplete");
expect(output).toContain("Resume or rerun onboarding");
expect(harness.runAutoPairSpy).not.toHaveBeenCalled();
expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith(
"openshell",
["sandbox", "connect", "alpha"],
expect.anything(),
);
});

it("restores the terminal and prints reconnect guidance when SSH disconnects", async () => {
const setRawModeSpy = vi.fn();
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Expand Down
14 changes: 14 additions & 0 deletions src/lib/actions/sandbox/connect-probe-observe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ describe("connectSandbox probe-only observe mode", () => {
);
});

it("settles completed Portable pairing before publishing probe readiness (#9207)", async () => {
const harness = createConnectHarness({
portablePairingSettlementResult: { kind: "settled" },
});

await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined();

expect(harness.settlePortablePairingSpy).toHaveBeenCalledWith("alpha");
expect(harness.runAutoPairSpy).not.toHaveBeenCalled();
expect(harness.settlePortablePairingSpy.mock.invocationCallOrder[0]).toBeLessThan(
harness.publishLaunchReadinessSpy.mock.invocationCallOrder[0]!,
);
});

it("uses gatewayRecovery=recover on the full connect path", async () => {
const harness = createConnectHarness();

Expand Down
Loading
Loading