From 1919c2c0b4a8e372030aa0a7c64a3a03cb1a4216 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 13:23:41 -0700 Subject: [PATCH 1/9] perf(cli): reuse qualified OpenClaw pairing state Signed-off-by: Senthil Ravichandran --- .../recover-rebuild-sandboxes.mdx | 12 + docs/reference/commands.mdx | 14 + .../gateway-authentication-controls.mdx | 4 +- .../auto-pair-approval-connect.test.ts | 19 +- src/lib/actions/sandbox/auto-pair-approval.ts | 20 +- .../connect-qualified-session-setup.test.ts | 54 ++ src/lib/actions/sandbox/connect.ts | 16 +- .../actions/sandbox/launch-readiness.test.ts | 133 ++++- src/lib/actions/sandbox/launch-readiness.ts | 52 +- .../openclaw-pairing-qualification.test.ts | 345 ++++++++++++ .../openclaw-pairing-qualification.ts | 505 ++++++++++++++++++ src/lib/actions/sandbox/launch.test.ts | 35 +- src/lib/actions/sandbox/launch.ts | 4 +- src/lib/state/launch-readiness-lease.test.ts | 65 +++ src/lib/state/launch-readiness-lease.ts | 69 ++- 15 files changed, 1319 insertions(+), 28 deletions(-) create mode 100644 src/lib/actions/sandbox/connect-qualified-session-setup.test.ts create mode 100644 src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts create mode 100644 src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 1de0a8609b6..6a6d6aa7284 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -154,6 +154,18 @@ Leaving the agent with `/exit` does not revoke it, and users do not refresh it m During the lease, `launch` still verifies the owning OpenShell gateway, exact live sandbox identity, registry and agent configuration, effective policy, inference route, required forwards, and semantic runtime health. Configured inference must return HTTP 2xx from the semantic `inference.local` probe, which is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. + + +For OpenClaw, `connect --probe-only` settles the existing allowlisted pairing flow before it publishes a credential-free pairing qualification with the lease. +The readiness evidence binds the OpenClaw version and trusted registry and agent manifest configuration. +Its pairing qualification binds the canonical CLI client, exact paired device identity, required operator role and scopes, owning OpenShell gateway, sandbox lifecycle identity, and fixed lease epoch. +Before lease acceptance, `launch` makes a bounded, read-only observation of the current OpenClaw-owned pairing state through the owning OpenShell gateway. +It skips the complete pairing approval pass only when the qualification still matches exactly and no relevant allowlisted request is pending. +Any missing, unreadable, malformed, ambiguous, or changed observation runs the complete pairing approval pass. +A relevant allowlisted pending request also runs that complete path. + + +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. The lease is not a health guarantee or repair authority. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index b4152665886..a5d9b047a32 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1028,6 +1028,20 @@ During that lease, another `launch` still verifies these conditions: - The recorded inference selection matches the live route, and `inference.local` returns HTTP 2xx from its semantic probe when inference is configured. This is stricter than the HTTP 200–499 reachability diagnostic used by ordinary `connect`. - The agent runtime and its required host-side forwards pass their semantic health checks. + + +For OpenClaw, `connect --probe-only` also settles the existing allowlisted pairing flow before it publishes the lease. +The readiness evidence binds the OpenClaw version and trusted registry and agent manifest configuration. +Its credential-free pairing qualification binds the canonical CLI client, exact paired device identity, required operator role and scopes, owning OpenShell gateway, sandbox lifecycle identity, and fixed lease epoch. +Before accepting that evidence, `launch` makes a bounded, read-only observation of the current OpenClaw-owned pairing state through the owning OpenShell gateway. +It skips the complete pairing approval pass only when the evidence still matches exactly and no relevant allowlisted request is pending. +Missing, unreadable, malformed, ambiguous, or changed pairing evidence runs the complete pairing approval pass. +A relevant allowlisted pending request also runs that complete path, so late scope requests remain eligible for approval. + + + +Hermes and LangChain Deep Agents Code retain their existing session setup on the lease-accepted path. + After these checks pass, `launch` can skip duplicate recovery, readiness polling, and inference-route repair. The lease does not replace a health check or authorize repair. For missing, expired, malformed, inaccessible, mismatched, or unhealthy evidence, NemoClaw fences any prior acceptable evidence before it runs the complete preflight. diff --git a/docs/security/gateway-authentication-controls.mdx b/docs/security/gateway-authentication-controls.mdx index 3cbfec1e190..0d6b8944bee 100644 --- a/docs/security/gateway-authentication-controls.mdx +++ b/docs/security/gateway-authentication-controls.mdx @@ -103,10 +103,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. 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. | | 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 and during `connect` 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. If you see `[auto-pair] rejected unknown client=...` in the logs, investigate the source of the unexpected connection. | ### Approve Administrative Scopes Manually diff --git a/src/lib/actions/sandbox/auto-pair-approval-connect.test.ts b/src/lib/actions/sandbox/auto-pair-approval-connect.test.ts index 6010f975d8c..b730489b1bf 100644 --- a/src/lib/actions/sandbox/auto-pair-approval-connect.test.ts +++ b/src/lib/actions/sandbox/auto-pair-approval-connect.test.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it, vi } from "vitest"; +import { performance } from "node:perf_hooks"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runConnectAutoPairApprovalPass, @@ -15,8 +16,14 @@ import { } from "./connect-autopair-budget"; describe("connect auto-pair approval pass", () => { + beforeEach(() => { + performance.clearMeasures("nemoclaw.openclaw-pairing.complete-fallback"); + }); + afterEach(() => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); + performance.clearMeasures("nemoclaw.openclaw-pairing.complete-fallback"); }); it("uses the shared connect approval budget", () => { @@ -35,6 +42,16 @@ describe("connect auto-pair approval pass", () => { }); }); + it("records the named complete pairing fallback stage without a timing threshold (#9023)", () => { + const runApprovalPass = vi.fn(); + + runConnectAutoPairApprovalPass("alpha", "nemoclaw", runApprovalPass); + + expect( + performance.getEntriesByName("nemoclaw.openclaw-pairing.complete-fallback"), + ).toHaveLength(1); + }); + it("pins sandbox exec to the owning OpenShell gateway despite ambient gateway drift (#8942)", () => { vi.stubEnv("OPENSHELL_GATEWAY", "ambient-sibling"); const spawn = vi.fn((_binary: string, _args: readonly string[]) => ({ diff --git a/src/lib/actions/sandbox/auto-pair-approval.ts b/src/lib/actions/sandbox/auto-pair-approval.ts index 973319a3a4f..766265612d5 100644 --- a/src/lib/actions/sandbox/auto-pair-approval.ts +++ b/src/lib/actions/sandbox/auto-pair-approval.ts @@ -1163,8 +1163,20 @@ export function runConnectAutoPairApprovalPass( return; } } - runApprovalPass(sandboxName, { - budget: CONNECT_AUTO_PAIR_BUDGET, - gatewayName: owningGatewayName, - }); + const startedAt = performance.now(); + try { + runApprovalPass(sandboxName, { + budget: CONNECT_AUTO_PAIR_BUDGET, + gatewayName: owningGatewayName, + }); + } finally { + try { + performance.measure("nemoclaw.openclaw-pairing.complete-fallback", { + start: startedAt, + end: performance.now(), + }); + } catch { + // Performance measurements never control the complete pairing pass. + } + } } diff --git a/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts b/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts new file mode 100644 index 00000000000..b9a69e190df --- /dev/null +++ b/src/lib/actions/sandbox/connect-qualified-session-setup.test.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { SandboxEntry } from "../../state/registry"; +import { + completeInteractiveSessionSetup, + completeReadinessQualifiedInteractiveSessionSetup, +} from "./connect"; + +function entry(agent: string): SandboxEntry { + return { + name: "alpha", + agent, + gatewayName: "nemoclaw-8080", + gatewayPort: 8080, + provider: null, + model: null, + gpuEnabled: false, + policies: [], + } as SandboxEntry; +} + +describe("readiness-qualified interactive session setup", () => { + it("delegates complete OpenClaw fallback to the existing pairing path once (#9023)", () => { + const runApprovalPass = vi.fn(); + + completeInteractiveSessionSetup("alpha", entry("openclaw"), runApprovalPass); + + expect(runApprovalPass).toHaveBeenCalledOnce(); + expect(runApprovalPass).toHaveBeenCalledWith("alpha", "nemoclaw"); + }); + + it("does not run the complete pairing path for qualified OpenClaw state (#9023)", () => { + const runApprovalPass = vi.fn(); + + completeReadinessQualifiedInteractiveSessionSetup("alpha", entry("openclaw"), runApprovalPass); + + expect(runApprovalPass).not.toHaveBeenCalled(); + }); + + it.each(["hermes", "langchain-deepagents-code", "unknown-agent"])( + "keeps the complete session path for %s (#9023)", + (agent) => { + const runApprovalPass = vi.fn(); + + completeReadinessQualifiedInteractiveSessionSetup("alpha", entry(agent), runApprovalPass); + + expect(runApprovalPass).toHaveBeenCalledOnce(); + expect(runApprovalPass).toHaveBeenCalledWith("alpha", "nemoclaw"); + }, + ); +}); diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index fd602829715..4909092860c 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -1217,10 +1217,24 @@ export function printInteractiveSessionHints(sandboxName: string): void { export function completeInteractiveSessionSetup( sandboxName: string, sb: SandboxEntry | null, + runApprovalPass = runConnectAutoPairApprovalPass, ): void { maybeEnsureHermesToolGatewayBroker(sb); const gatewayName = sb ? resolveSandboxGatewayName(sb) : getSandboxTargetGatewayName(sandboxName); - runConnectAutoPairApprovalPass(sandboxName, gatewayName); + runApprovalPass(sandboxName, gatewayName); +} + +/** Preserve non-OpenClaw setup after current OpenClaw pairing qualification. */ +export function completeReadinessQualifiedInteractiveSessionSetup( + sandboxName: string, + sb: SandboxEntry | null, + runApprovalPass = runConnectAutoPairApprovalPass, +): void { + maybeEnsureHermesToolGatewayBroker(sb); + const agentName = String(sb?.agent ?? "").trim(); + if (agentName === "openclaw") return; + const gatewayName = sb ? resolveSandboxGatewayName(sb) : getSandboxTargetGatewayName(sandboxName); + runApprovalPass(sandboxName, gatewayName); } /** diff --git a/src/lib/actions/sandbox/launch-readiness.test.ts b/src/lib/actions/sandbox/launch-readiness.test.ts index 164fb5821e3..82eeeb6aca1 100644 --- a/src/lib/actions/sandbox/launch-readiness.test.ts +++ b/src/lib/actions/sandbox/launch-readiness.test.ts @@ -9,6 +9,7 @@ import type { LaunchReadinessFence, LaunchReadinessIdentity, LaunchReadinessLease, + LaunchReadinessOpenClawSessionQualification, } from "../../state/launch-readiness-lease"; import { LaunchReadinessFenceError } from "../../state/launch-readiness-lease"; import type { SandboxEntry } from "../../state/registry"; @@ -105,7 +106,7 @@ function servingProfile(): NonNullable function fence(): LaunchReadinessFence { return { - schemaVersion: 1, + schemaVersion: 2, kind: "fence", epochId: EPOCH, sandboxName: SANDBOX, @@ -128,7 +129,7 @@ function fence(): LaunchReadinessFence { function lease(identity: LaunchReadinessIdentity): LaunchReadinessLease { return { - schemaVersion: 1, + schemaVersion: 2, kind: "lease", epochId: EPOCH, sandboxName: SANDBOX, @@ -186,6 +187,7 @@ describe("launch readiness validation", () => { let runtimeHealthy: boolean | null; let forwardsHealthy: boolean | null; let observedFingerprint: string; + let pairingStateSha256: string; let lockEvents: string[]; let externalEvents: string[]; let observationRequests: Array<{ @@ -207,6 +209,7 @@ describe("launch readiness validation", () => { runtimeHealthy = true; forwardsHealthy = true; observedFingerprint = FINGERPRINT; + pairingStateSha256 = "d".repeat(64); lockEvents = []; externalEvents = []; observationRequests = []; @@ -264,6 +267,30 @@ describe("launch readiness validation", () => { inferenceHealthRequests.push([sandboxName, gatewayName]); return { healthy: true, broken: false, httpStatus: 200, detail: "OK 200" }; }, + observeOpenClawPairingQualification: ( + sandboxName, + gatewayName, + openclawVersion, + stateDirectory, + ) => { + externalEvents.push("pairing-qualification"); + expect({ sandboxName, gatewayName, openclawVersion, stateDirectory }).toEqual({ + sandboxName: SANDBOX, + gatewayName: GATEWAY_NAME, + openclawVersion: "1.0.0", + stateDirectory: "/sandbox/.openclaw", + }); + return { + schemaVersion: 1, + kind: "openclaw-pairing", + openclawVersion, + deviceIdentitySha256: DIGEST, + pairingStateSha256, + policySha256: DIGEST, + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + }; + }, readLease: () => readKind === "valid" && publishedIdentity ? { kind: "valid", lease: lease(publishedIdentity) } @@ -312,7 +339,96 @@ describe("launch readiness validation", () => { "inference-get", "gateway-health", "forward-list", + "pairing-qualification", ]); + expect(publishedIdentity?.session).toMatchObject({ + kind: "openclaw-pairing", + openclawVersion: "1.0.0", + pairingStateSha256, + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + }); + }); + + it("fences a concurrent OpenClaw pairing change before launch acceptance (#9023)", async () => { + const currentDeps = await createAcceptedLease(); + pairingStateSha256 = "e".repeat(64); + + await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ + kind: "fallback", + category: "session", + fence: { epochId: EPOCH }, + recoveryBlocked: false, + }); + }); + + it("falls back when any exact OpenClaw pairing qualification value changes (#9023)", async () => { + const currentDeps = await createAcceptedLease(); + const stored = publishedIdentity?.session; + if (!stored || stored.kind !== "openclaw-pairing") + throw new Error("missing test qualification"); + const changedQualifications: LaunchReadinessOpenClawSessionQualification[] = [ + { ...stored, openclawVersion: "1.0.1" }, + { ...stored, deviceIdentitySha256: "2".repeat(64) }, + { ...stored, pairingStateSha256: "3".repeat(64) }, + { ...stored, policySha256: "4".repeat(64) }, + ]; + + for (const changed of changedQualifications) { + currentDeps.observeOpenClawPairingQualification = () => changed; + await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ + kind: "fallback", + category: "session", + fence: { epochId: EPOCH }, + recoveryBlocked: false, + }); + } + }); + + it("falls back when the OpenClaw gateway or lifecycle binding changes (#9023)", async () => { + const currentDeps = await createAcceptedLease(); + const stored = publishedIdentity?.session; + if (!stored || stored.kind !== "openclaw-pairing") + throw new Error("missing test qualification"); + currentDeps.observeOpenClawPairingQualification = () => stored; + currentDeps.fenceLease = () => ({ + ...fence(), + gatewayName: sandbox.gatewayName ?? GATEWAY_NAME, + gatewayPort: sandbox.gatewayPort ?? GATEWAY_PORT, + }); + currentDeps.readLease = (_sandboxName, gatewayName) => ({ + kind: gatewayName === GATEWAY_NAME ? "valid" : "identity", + lease: lease(publishedIdentity!), + }); + const original = sandbox; + + for (const changed of [ + { ...original, gatewayName: "nemoclaw-8081", gatewayPort: 8081 }, + { ...original, lifecycleGeneration: "generation-2" }, + { ...original, lifecycleLiveIdentityFingerprint: "5".repeat(64) }, + ]) { + sandbox = changed; + await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ + kind: "fallback", + fence: { epochId: EPOCH }, + recoveryBlocked: false, + }); + } + sandbox = original; + }); + + it("uses the complete fallback when current OpenClaw pairing observation fails (#9023)", async () => { + const currentDeps = await createAcceptedLease(); + currentDeps.observeOpenClawPairingQualification = () => { + throw new Error("observation failed"); + }; + + await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ + kind: "fallback", + category: "session", + fence: { epochId: EPOCH }, + recoveryBlocked: false, + }); }); it("uses a fenced CAS epoch when secure authority is available (#8942)", async () => { @@ -574,6 +690,7 @@ describe("launch readiness validation", () => { "gateway-health", "forward-list", "inference-health", + "pairing-qualification", ]); expect(observationRequests).toContainEqual({ sandboxName: SANDBOX, @@ -709,6 +826,7 @@ describe("launch readiness validation", () => { currentDeps.gatewayHealth = gatewayHealth; currentDeps.smoke = smoke; await createAcceptedLease(currentDeps); + externalEvents = []; expect(await inspectLaunchReadiness(SANDBOX, currentDeps)).toMatchObject({ kind: "accepted" }); expect(smoke).toHaveBeenCalledWith( SANDBOX, @@ -717,6 +835,8 @@ describe("launch readiness validation", () => { GATEWAY_NAME, ); expect(gatewayHealth).not.toHaveBeenCalled(); + expect(externalEvents).not.toContain("pairing-qualification"); + expect(publishedIdentity?.session).toBeNull(); }); it("uses the normalized trusted agent name for CUA semantic health (#8942)", async () => { @@ -814,6 +934,7 @@ describe("launch readiness validation", () => { expect(projection.version).toBe(2); const original = launchReadinessDigest(projection); const mutations: SandboxEntry[] = [ + { ...sandbox, agentVersion: "1.0.1" }, { ...sandbox, nemoclawVersion: "changed" }, { ...sandbox, @@ -1094,6 +1215,14 @@ describe("launch readiness validation", () => { kind: "evidence-failed", }); + const pairingObservationUnavailable = deps(); + pairingObservationUnavailable.observeOpenClawPairingQualification = () => { + throw new Error("pairing observation unavailable"); + }; + expect(await publishLaunchReadiness(publication, pairingObservationUnavailable)).toEqual({ + kind: "evidence-failed", + }); + const hashUnavailable = deps(); hashUnavailable.capture = (args) => ({ status: 0, diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index 1b4a2e38872..1f73104d975 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -47,6 +47,10 @@ import { resolveLaunchInteractiveCommand, resolveTrustedLaunchAgent, } from "./launch-readiness/health"; +import { + observeOpenClawPairingQualification, + OpenClawPairingQualificationError, +} from "./launch-readiness/openclaw-pairing-qualification"; const LIVE_POLICY_MAX_BYTES = 2 * 1_024 * 1_024; const ALLOWED_OPENSHELL_DRIVERS = new Set(["docker", "kubernetes", "vm"]); @@ -94,6 +98,7 @@ export interface LaunchReadinessDeps extends LaunchReadinessHealthDeps { readLease?: typeof readLaunchReadinessLease; fenceLease?: typeof fenceLaunchReadinessLease; publishLease?: typeof publishLaunchReadinessLease; + observeOpenClawPairingQualification?: typeof observeOpenClawPairingQualification; storeOptions?: LaunchReadinessStoreOptions; withSandboxLock?: typeof withSandboxMutationLock; withGatewayLock?: typeof withGatewayRouteMutationLock; @@ -692,6 +697,26 @@ async function captureLaunchIdentity( deps, ); + let session: LaunchReadinessIdentity["session"] = null; + if (agentName === "openclaw") { + const openclawVersion = normalizedString(entry.agentVersion); + const expectedVersion = normalizedString(agent.expected_version); + const stateDirectory = normalizedString(agent.config?.dir); + if (!openclawVersion || !expectedVersion || !stateDirectory) { + throw new OpenClawPairingQualificationError(); + } + try { + session = (deps.observeOpenClawPairingQualification ?? observeOpenClawPairingQualification)( + sandboxName, + gatewayName, + openclawVersion, + stateDirectory, + ); + } catch { + throw new OpenClawPairingQualificationError(); + } + } + return { identity: { registry: launchReadinessDigest(projection), @@ -706,22 +731,29 @@ async function captureLaunchIdentity( gatewayName, lifecycleGeneration, liveIdentityFingerprint: recordedFingerprint, + session, }, agent, sb: entry, }; } -function identityMatches(left: LaunchReadinessIdentity, right: LaunchReadinessIdentity): boolean { - return ( +function compareIdentity( + left: LaunchReadinessIdentity, + right: LaunchReadinessIdentity, +): "exact" | "config" | "session" { + const baseMatches = left.registry === right.registry && left.agent === right.agent && left.livePolicy === right.livePolicy && left.liveInference === right.liveInference && left.gatewayName === right.gatewayName && left.lifecycleGeneration === right.lifecycleGeneration && - left.liveIdentityFingerprint === right.liveIdentityFingerprint - ); + left.liveIdentityFingerprint === right.liveIdentityFingerprint; + if (!baseMatches) return "config"; + return launchReadinessDigest(left.session) === launchReadinessDigest(right.session) + ? "exact" + : "session"; } function debugDecision(category: LaunchReadinessDecisionCategory): void { @@ -803,7 +835,8 @@ export async function inspectLaunchReadiness( const validationStartedAt = performance.now(); try { const captured = await captureLaunchIdentity(sandboxName, gatewayName, gatewayPort, deps); - if (identityMatches(read.lease.identity, captured.identity)) { + const comparison = compareIdentity(read.lease.identity, captured.identity); + if (comparison === "exact") { debugDecision("accepted"); return { kind: "accepted", @@ -812,9 +845,14 @@ export async function inspectLaunchReadiness( sb: captured.sb, }; } - category = "config"; + category = comparison; } catch (error) { - category = error instanceof ObservationError ? error.category : "unsafe"; + category = + error instanceof ObservationError + ? error.category + : error instanceof OpenClawPairingQualificationError + ? "session" + : "unsafe"; } finally { recordPerformanceStage("live-validation", validationStartedAt); } diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts new file mode 100644 index 00000000000..3acb024d5e2 --- /dev/null +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -0,0 +1,345 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + buildOpenClawPairingObservationScript, + observeOpenClawPairingQualification, + OPENCLAW_PAIRING_REQUIRED_SCOPES, + parseOpenClawPairingObservation, +} from "./openclaw-pairing-qualification"; + +const TOKEN = "credential-value-must-not-leave-the-sandbox"; +const PRIVATE_KEY = "private-key-material-must-not-leave-the-sandbox"; +type PairedFixture = Record< + string, + { + deviceId: string; + publicKey: string; + approvedScopes: string[]; + tokens: { operator: { token: string } }; + [key: string]: unknown; + } +>; +type AuthFixture = { tokens: { operator: { token: string } } }; +const POLICY = ` +ALLOWED_CLIENTS = {'cli', 'openclaw-cli', 'openclaw-control-ui'} +ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'} +def approval_request_decision(device): + client_id = str(device.get('clientId', '')) + scopes = device.get('scopes', device.get('requestedScopes', [])) + if not isinstance(scopes, list): + return {'allowed': False, 'reason': 'malformed-scopes'} + return { + 'allowed': client_id in ALLOWED_CLIENTS and set(scopes).issubset(ALLOWED_SCOPES), + 'reason': 'allowlisted' if client_id in ALLOWED_CLIENTS else 'unknown-client', + } +`; + +function writeJson(filePath: string, value: unknown): void { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +function localScriptSpawn( + _binary: string, + _args: readonly string[], + options: Parameters[2], +) { + const result = spawnSync("sh", ["-s"], { + ...options, + stdio: ["pipe", "pipe", "pipe"], + }); + return result; +} + +describe("OpenClaw launch-readiness pairing qualification", () => { + let root: string; + let stateDirectory: string; + let deviceId: string; + let publicKey: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pairing-qualification-")); + stateDirectory = path.join(root, ".openclaw"); + fs.mkdirSync(path.join(stateDirectory, "devices"), { mode: 0o700, recursive: true }); + fs.mkdirSync(path.join(stateDirectory, "identity"), { mode: 0o700, recursive: true }); + stateDirectory = fs.realpathSync(stateDirectory); + fs.chmodSync(stateDirectory, 0o700); + fs.chmodSync(path.join(stateDirectory, "devices"), 0o700); + fs.chmodSync(path.join(stateDirectory, "identity"), 0o700); + const publicKeyBytes = Buffer.alloc(32, 7); + publicKey = publicKeyBytes.toString("base64url"); + deviceId = createHash("sha256").update(publicKeyBytes).digest("hex"); + writeJson(path.join(stateDirectory, "openclaw.json"), { + gateway: { mode: "local", auth: { token: TOKEN } }, + }); + writeJson(path.join(stateDirectory, "identity", "device.json"), { + deviceId, + publicKey, + privateKeyPem: PRIVATE_KEY, + }); + writeJson(path.join(stateDirectory, "identity", "device-auth.json"), { + version: 1, + deviceId, + tokens: { + operator: { + token: TOKEN, + role: "operator", + scopes: [...OPENCLAW_PAIRING_REQUIRED_SCOPES], + }, + }, + }); + writeJson(path.join(stateDirectory, "devices", "paired.json"), { + [deviceId]: { + deviceId, + publicKey, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: [...OPENCLAW_PAIRING_REQUIRED_SCOPES], + approvedScopes: [...OPENCLAW_PAIRING_REQUIRED_SCOPES], + tokens: { + operator: { + token: TOKEN, + role: "operator", + scopes: [...OPENCLAW_PAIRING_REQUIRED_SCOPES], + }, + }, + }, + }); + writeJson(path.join(stateDirectory, "devices", "pending.json"), {}); + performance.clearMeasures("nemoclaw.openclaw-pairing.qualification"); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function observe() { + return observeOpenClawPairingQualification( + "alpha", + "nemoclaw-8080", + "2026.7.1", + stateDirectory, + { + getOpenshellBinary: () => "openshell", + readApprovalPolicy: () => POLICY, + spawnSync: localScriptSpawn as typeof spawnSync, + }, + ); + } + + it("emits a credential-free qualification from stable OpenClaw-owned state (#9023)", () => { + const qualification = observe(); + const serialized = JSON.stringify(qualification); + + expect(qualification).toMatchObject({ + schemaVersion: 1, + kind: "openclaw-pairing", + openclawVersion: "2026.7.1", + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + pairingStateSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + policySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(serialized).not.toContain(TOKEN); + expect(serialized).not.toContain(PRIVATE_KEY); + expect(serialized).not.toContain(publicKey); + expect(serialized).not.toContain(deviceId); + expect(performance.getEntriesByName("nemoclaw.openclaw-pairing.qualification")).toHaveLength(1); + }); + + it("does not make paired credential values part of the receipt identity (#9023)", () => { + const first = observe(); + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const authPath = path.join(stateDirectory, "identity", "device-auth.json"); + const replacementToken = `${TOKEN}-rotated`; + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.tokens.operator.token = replacementToken; + writeJson(pairedPath, paired); + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; + auth.tokens.operator.token = replacementToken; + writeJson(authPath, auth); + + const second = observe(); + + expect(second).toEqual(first); + expect(JSON.stringify(second)).not.toContain(replacementToken); + }); + + it("does not derive pairing evidence from arbitrary OpenClaw configuration (#9023)", () => { + const first = observe(); + const credentialValue = `${TOKEN}-arbitrary-config`; + writeJson(path.join(stateDirectory, "openclaw.json"), { + unknown: { + privateKeyPem: credentialValue, + passwordValue: credentialValue, + credentialValue, + headers: { Authorization: `Bearer ${credentialValue}` }, + url: `https://user:${credentialValue}@example.invalid/path?token=${credentialValue}`, + args: ["run", credentialValue], + }, + }); + + const second = observe(); + const serialized = JSON.stringify(second); + + expect(second).toEqual(first); + expect(serialized).not.toContain(credentialValue); + }); + + it("rejects a new allowlisted pending request without calling the OpenClaw CLI (#9023)", () => { + writeJson(path.join(stateDirectory, "devices", "pending.json"), { + "request-1": { + requestId: "request-1", + clientId: "cli", + clientMode: "cli", + scopes: ["operator.write"], + }, + }); + + expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); + const script = buildOpenClawPairingObservationScript( + Buffer.from(POLICY, "utf8").toString("base64"), + stateDirectory, + ); + expect(script).not.toContain("openclaw devices list"); + expect(script).not.toContain("[OPENCLAW, 'devices', 'list'"); + }); + + it.each([ + [ + "malformed pending state", + () => writeJson(path.join(stateDirectory, "devices", "pending.json"), []), + ], + [ + "unsafe paired permissions", + () => fs.chmodSync(path.join(stateDirectory, "devices", "paired.json"), 0o666), + ], + [ + "mismatched client credential", + () => { + const authPath = path.join(stateDirectory, "identity", "device-auth.json"); + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; + auth.tokens.operator.token = "different-token"; + writeJson(authPath, auth); + }, + ], + [ + "incomplete required scopes", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.approvedScopes = ["operator.pairing", "operator.read"]; + writeJson(pairedPath, paired); + }, + ], + [ + "changed canonical client ID", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.clientId = "unknown-client"; + writeJson(pairedPath, paired); + }, + ], + [ + "changed canonical client mode", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.clientMode = "unknown-mode"; + writeJson(pairedPath, paired); + }, + ], + [ + "ambiguous local device state", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired.duplicate = { ...paired[deviceId]!, deviceId: "different-device" }; + writeJson(pairedPath, paired); + }, + ], + ])("rejects %s and requires the complete pairing path (#9023)", (_label, mutate) => { + mutate(); + expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); + }); + + it("pins observation to the named gateway and rejects non-terminal output (#9023)", () => { + const digest = "a".repeat(64); + const spawn = vi.fn( + (_binary: string, _args: readonly string[], _options: Parameters[2]) => ({ + status: 0, + signal: null, + stdout: `__NEMOCLAW_OPENCLAW_PAIRING_QUALIFICATION__=${JSON.stringify({ + deviceIdentitySha256: digest, + pairingStateSha256: digest, + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + })}\nuntrusted trailing output\n`, + stderr: "", + }), + ); + + expect(() => + observeOpenClawPairingQualification("alpha", "nemoclaw-8080", "2026.7.1", stateDirectory, { + getOpenshellBinary: () => "openshell", + readApprovalPolicy: () => POLICY, + spawnSync: spawn as never, + }), + ).toThrow("OpenClaw pairing qualification is unavailable"); + expect(spawn.mock.calls[0]?.[1]).toEqual([ + "sandbox", + "exec", + "--name", + "alpha", + "-g", + "nemoclaw-8080", + "--", + "sh", + "-s", + ]); + expect(spawn.mock.calls[0]?.[2]).toMatchObject({ + maxBuffer: 4 * 1_024, + stdio: ["pipe", "pipe", "ignore"], + timeout: 3_000, + }); + }); + + it("transports state paths without shell interpretation (#9023)", () => { + const rawStateDirectory = "/sandbox/state'$(touch should-not-run)"; + const script = buildOpenClawPairingObservationScript( + Buffer.from(POLICY, "utf8").toString("base64"), + rawStateDirectory, + ); + + expect(script).not.toContain(rawStateDirectory); + expect(script).toContain(Buffer.from(rawStateDirectory, "utf8").toString("base64")); + }); + + it("rejects extra receipt fields that could carry unrestricted state (#9023)", () => { + const digest = "a".repeat(64); + const output = `__NEMOCLAW_OPENCLAW_PAIRING_QUALIFICATION__=${JSON.stringify({ + deviceIdentitySha256: digest, + pairingStateSha256: digest, + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + token: TOKEN, + })}\n`; + + expect(parseOpenClawPairingObservation(output)).toBeNull(); + }); +}); diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts new file mode 100644 index 00000000000..1560f7337eb --- /dev/null +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -0,0 +1,505 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { resolveOpenshellBinary } from "../../../adapters/openshell/command-argv"; +import type { LaunchReadinessOpenClawSessionQualification } from "../../../state/launch-readiness-lease"; +import { ROOT } from "../../../state/paths"; +import { readAutoPairApprovalPolicyModule } from "../auto-pair-approval"; + +const QUALIFICATION_MARKER = "__NEMOCLAW_OPENCLAW_PAIRING_QUALIFICATION__="; +const SHA256_RE = /^[a-f0-9]{64}$/; +const OBSERVATION_TIMEOUT_MS = 3_000; +const OBSERVATION_MAX_OUTPUT_BYTES = 4 * 1_024; + +export const OPENCLAW_PAIRING_REQUIRED_ROLES = ["operator"] as const; +export const OPENCLAW_PAIRING_REQUIRED_SCOPES = [ + "operator.pairing", + "operator.read", + "operator.write", +] as const; + +export type OpenClawPairingQualification = LaunchReadinessOpenClawSessionQualification; + +interface ObservationProjection { + deviceIdentitySha256: string; + pairingStateSha256: string; + requiredRoles: ["operator"]; + requiredScopes: ["operator.pairing", "operator.read", "operator.write"]; +} + +interface OpenClawPairingQualificationDeps { + getOpenshellBinary: () => string; + readApprovalPolicy: () => string | null; + spawnSync: typeof spawnSync; +} + +export class OpenClawPairingQualificationError extends Error { + constructor() { + super("OpenClaw pairing qualification is unavailable."); + this.name = "OpenClawPairingQualificationError"; + } +} + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function isExactStringArray(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((entry, index) => entry === expected[index]) + ); +} + +export function parseOpenClawPairingObservation(output: string): ObservationProjection | null { + const lines = output.trimEnd().split(/\r?\n/); + const markerLines = lines.filter((line) => line.startsWith(QUALIFICATION_MARKER)); + if (markerLines.length !== 1 || lines.at(-1) !== markerLines[0]) return null; + let value: unknown; + try { + value = JSON.parse(markerLines[0]!.slice(QUALIFICATION_MARKER.length)) as unknown; + } catch { + return null; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + if ( + !hasExactKeys(record, [ + "deviceIdentitySha256", + "pairingStateSha256", + "requiredRoles", + "requiredScopes", + ]) || + typeof record.deviceIdentitySha256 !== "string" || + !SHA256_RE.test(record.deviceIdentitySha256) || + typeof record.pairingStateSha256 !== "string" || + !SHA256_RE.test(record.pairingStateSha256) || + !isExactStringArray(record.requiredRoles, OPENCLAW_PAIRING_REQUIRED_ROLES) || + !isExactStringArray(record.requiredScopes, OPENCLAW_PAIRING_REQUIRED_SCOPES) + ) { + return null; + } + return record as unknown as ObservationProjection; +} + +export function buildOpenClawPairingObservationScript( + approvalPolicyModuleB64: string, + stateDirectory: string, +): string { + if (!path.posix.isAbsolute(stateDirectory)) { + throw new OpenClawPairingQualificationError(); + } + if ( + !approvalPolicyModuleB64 || + Buffer.from(approvalPolicyModuleB64, "base64").toString("base64") !== approvalPolicyModuleB64 + ) { + throw new OpenClawPairingQualificationError(); + } + const stateDirectoryB64 = Buffer.from(stateDirectory, "utf8").toString("base64"); + // OpenClaw owns these in-sandbox state files. This observer + // only reads descriptor-pinned state from the sandbox and returns fixed + // allowlisted fields plus digests; the existing approval pass remains the + // only writer. Gateway pinning happens in observeOpenClawPairingQualification. + return ` +command -v python3 >/dev/null 2>&1 || exit 1 +NEMOCLAW_APPROVAL_POLICY_B64='${approvalPolicyModuleB64}' \ +NEMOCLAW_OPENCLAW_STATE_DIR_B64='${stateDirectoryB64}' \ +python3 - <<'PYQUALIFY' +import base64 +import binascii +import hashlib +import json +import os +import stat +import sys + +MARKER = ${JSON.stringify(QUALIFICATION_MARKER)} +MAX_ENTRY_BYTES = 512 * 1024 +REQUIRED_ROLES = ['operator'] +REQUIRED_SCOPES = ['operator.pairing', 'operator.read', 'operator.write'] + +def reject(): + sys.exit(1) + +try: + policy_source = base64.b64decode( + os.environ.get('NEMOCLAW_APPROVAL_POLICY_B64', ''), validate=True, + ).decode('utf-8') + STATE_DIR = base64.b64decode( + os.environ.get('NEMOCLAW_OPENCLAW_STATE_DIR_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'] +except Exception: + reject() + +if not os.path.isabs(STATE_DIR): + reject() +for required_flag in ('O_DIRECTORY', 'O_NOFOLLOW'): + if not hasattr(os, required_flag): + reject() + +directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) +path_flags = getattr(os, 'O_PATH', os.O_RDONLY) | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) +file_flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0) + +def directory_metadata(fd): + metadata = os.fstat(fd) + if not stat.S_ISDIR(metadata.st_mode) or metadata.st_mode & 0o022: + raise OSError('unsafe directory') + return (metadata.st_dev, metadata.st_ino, metadata.st_mode & 0o777) + +def file_metadata(fd): + metadata = os.fstat(fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_mode & 0o022 + or metadata.st_size < 1 + or metadata.st_size > MAX_ENTRY_BYTES + ): + raise OSError('unsafe file') + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_mode & 0o777, + ) + +def state_root_is_current(fd): + current = os.stat(STATE_DIR, follow_symlinks=False) + pinned = os.fstat(fd) + return ( + stat.S_ISDIR(current.st_mode) + and (current.st_dev, current.st_ino) == (pinned.st_dev, pinned.st_ino) + ) + +def open_state_root(): + root_fd = os.open(os.sep, path_flags) + try: + for component in (part for part in STATE_DIR.split(os.sep) if part): + if component in ('.', '..'): + raise OSError('unsafe state path') + next_fd = os.open(component, path_flags, dir_fd=root_fd) + os.close(root_fd) + root_fd = next_fd + directory_metadata(root_fd) + if not state_root_is_current(root_fd): + raise OSError('state root changed') + return root_fd + except Exception: + os.close(root_fd) + raise + +def directory_is_current(parent_fd, name, fd): + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + pinned = os.fstat(fd) + return ( + stat.S_ISDIR(current.st_mode) + and (current.st_dev, current.st_ino) == (pinned.st_dev, pinned.st_ino) + ) + +def open_directory(parent_fd, name): + if name not in ('devices', 'identity'): + raise OSError('unsupported directory') + fd = os.open(name, directory_flags, dir_fd=parent_fd) + directory_metadata(fd) + if not directory_is_current(parent_fd, name, fd): + os.close(fd) + raise OSError('directory changed') + return fd + +def read_entry(directory_fd, name): + if not name or name in ('.', '..') or os.sep in name: + raise OSError('unsafe entry') + fd = os.open(name, file_flags, dir_fd=directory_fd) + try: + before = file_metadata(fd) + chunks = [] + total = 0 + while True: + chunk = os.read(fd, min(64 * 1024, MAX_ENTRY_BYTES + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > MAX_ENTRY_BYTES: + raise OSError('entry too large') + after = file_metadata(fd) + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if before != after or (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns, current.st_mode & 0o777) != after: + raise OSError('entry changed') + return b''.join(chunks), after + finally: + os.close(fd) + +def read_snapshot(): + state_fd = open_state_root() + devices_fd = -1 + identity_fd = -1 + try: + devices_fd = open_directory(state_fd, 'devices') + identity_fd = open_directory(state_fd, 'identity') + identity_raw, identity_metadata = read_entry(identity_fd, 'device.json') + auth_raw, auth_metadata = read_entry(identity_fd, 'device-auth.json') + paired_raw, paired_metadata = read_entry(devices_fd, 'paired.json') + pending_raw, pending_metadata = read_entry(devices_fd, 'pending.json') + if ( + not state_root_is_current(state_fd) + or not directory_is_current(state_fd, 'devices', devices_fd) + or not directory_is_current(state_fd, 'identity', identity_fd) + ): + raise OSError('state root changed') + return { + 'directories': [directory_metadata(state_fd), directory_metadata(devices_fd), directory_metadata(identity_fd)], + 'identity': (identity_raw, identity_metadata), + 'auth': (auth_raw, auth_metadata), + 'paired': (paired_raw, paired_metadata), + 'pending': (pending_raw, pending_metadata), + } + finally: + if identity_fd >= 0: + os.close(identity_fd) + if devices_fd >= 0: + os.close(devices_fd) + os.close(state_fd) + +def parse_json(raw): + value = json.loads(raw.decode('utf-8')) + if not isinstance(value, dict): + raise ValueError('expected object') + return value + +def public_key(identity): + raw = str(identity.get('publicKey', '') or '').strip() + if raw: + return raw + pem = str(identity.get('publicKeyPem', '') or '') + body = ''.join(line.strip() for line in pem.splitlines() if '---' not in line) + if not body: + return '' + der = base64.b64decode(body, validate=True) + if len(der) < 32: + return '' + return base64.urlsafe_b64encode(der[-32:]).decode('ascii').rstrip('=') + +def exact_string_set(value, expected): + return ( + isinstance(value, list) + and all(isinstance(entry, str) and entry for entry in value) + and len(value) == len(set(value)) + and set(value) == set(expected) + ) + +def normalized_roles(device): + roles = set() + if device.get('role') is not None: + if not isinstance(device.get('role'), str) or not device.get('role'): + return None + roles.add(device.get('role')) + if device.get('roles') is not None: + raw_roles = device.get('roles') + if not isinstance(raw_roles, list) or not all(isinstance(role, str) and role for role in raw_roles): + return None + roles.update(raw_roles) + return roles + +try: + first = read_snapshot() + second = read_snapshot() + if first != second: + reject() + identity = parse_json(first['identity'][0]) + auth = parse_json(first['auth'][0]) + paired = parse_json(first['paired'][0]) + pending = parse_json(first['pending'][0]) + device_id = str(identity.get('deviceId', '') or '').strip() + device_public_key = public_key(identity) + public_key_raw = base64.urlsafe_b64decode(device_public_key + '=' * (-len(device_public_key) % 4)) + if not device_id or len(public_key_raw) != 32 or hashlib.sha256(public_key_raw).hexdigest() != device_id: + reject() + + local_paired = [ + (map_key, device) for map_key, device in paired.items() + if map_key == device_id or ( + isinstance(device, dict) and ( + str(device.get('deviceId', '') or '').strip() == device_id + or str(device.get('publicKey', '') or '').strip() == device_public_key + ) + ) + ] + if len(local_paired) != 1: + reject() + map_key, paired_device = local_paired[0] + if ( + map_key != device_id + or not isinstance(paired_device, dict) + or str(paired_device.get('deviceId', '') or '').strip() != device_id + or str(paired_device.get('publicKey', '') or '').strip() != device_public_key + or paired_device.get('clientId') != 'cli' + or paired_device.get('clientMode') != 'cli' + or normalized_roles(paired_device) != set(REQUIRED_ROLES) + or not exact_string_set(paired_device.get('scopes'), REQUIRED_SCOPES) + or not exact_string_set(paired_device.get('approvedScopes'), REQUIRED_SCOPES) + ): + reject() + paired_tokens = paired_device.get('tokens') + paired_operator = paired_tokens.get('operator') if isinstance(paired_tokens, dict) and set(paired_tokens) == {'operator'} else None + if ( + not isinstance(paired_operator, dict) + or str(paired_operator.get('role', '') or '').strip() != 'operator' + or paired_operator.get('revokedAtMs') is not None + or not str(paired_operator.get('token', '') or '').strip() + or not exact_string_set(paired_operator.get('scopes'), REQUIRED_SCOPES) + ): + reject() + auth_tokens = auth.get('tokens') + auth_operator = auth_tokens.get('operator') if isinstance(auth_tokens, dict) and set(auth_tokens) == {'operator'} else None + if ( + auth.get('version') != 1 + or str(auth.get('deviceId', '') or '').strip() != device_id + or not isinstance(auth_operator, dict) + or str(auth_operator.get('role', '') or '').strip() != 'operator' + or str(auth_operator.get('token', '') or '').strip() != str(paired_operator.get('token', '') or '').strip() + or not exact_string_set(auth_operator.get('scopes'), REQUIRED_SCOPES) + ): + reject() + + for request_id, request in pending.items(): + if ( + not isinstance(request_id, str) + or not request_id + or not isinstance(request, dict) + or str(request.get('requestId', '') or '').strip() != request_id + ): + reject() + decision = approval_request_decision(request) + if decision.get('reason') == 'malformed-scopes': + reject() + if decision.get('allowed'): + sys.exit(2) + + for map_key, device in paired.items(): + if not isinstance(map_key, str) or not map_key or not isinstance(device, dict): + reject() + if not isinstance(device.get('deviceId'), str) or not device.get('deviceId'): + reject() + if normalized_roles(device) is None: + reject() + + projection = { + 'deviceIdentitySha256': hashlib.sha256(json.dumps({ + 'deviceId': device_id, + 'publicKey': device_public_key, + }, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest(), + # Bind only the allowlisted security projection. Token values, unknown + # device state, and pending-request fields never contribute a reusable + # credential oracle outside the sandbox. + 'pairingStateSha256': hashlib.sha256(json.dumps({ + 'deviceId': device_id, + 'publicKey': device_public_key, + 'clientId': 'cli', + 'clientMode': 'cli', + 'roles': REQUIRED_ROLES, + 'scopes': REQUIRED_SCOPES, + 'approvedScopes': REQUIRED_SCOPES, + 'pairedToken': { + 'active': True, + 'role': 'operator', + 'scopes': REQUIRED_SCOPES, + }, + 'clientAuth': { + 'deviceId': device_id, + 'matchesPairedToken': True, + 'role': 'operator', + 'scopes': REQUIRED_SCOPES, + 'version': 1, + }, + 'relevantPending': False, + }, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest(), + 'requiredRoles': REQUIRED_ROLES, + 'requiredScopes': REQUIRED_SCOPES, + } + print(MARKER + json.dumps(projection, sort_keys=True, separators=(',', ':'))) +except (OSError, ValueError, TypeError, KeyError, binascii.Error, UnicodeError): + reject() +PYQUALIFY +`; +} + +function recordQualificationStage(startedAt: number): void { + try { + performance.measure("nemoclaw.openclaw-pairing.qualification", { + start: startedAt, + end: performance.now(), + }); + } catch { + // Performance measurements never control pairing qualification. + } +} + +export function observeOpenClawPairingQualification( + sandboxName: string, + gatewayName: string, + openclawVersion: string, + stateDirectory: string, + execDeps?: Partial, +): OpenClawPairingQualification { + const approvalPolicy = (execDeps?.readApprovalPolicy ?? readAutoPairApprovalPolicyModule)(); + const normalizedVersion = openclawVersion.trim(); + if (!approvalPolicy || !normalizedVersion || normalizedVersion.length > 128) { + throw new OpenClawPairingQualificationError(); + } + const approvalPolicyModuleB64 = Buffer.from(approvalPolicy, "utf8").toString("base64"); + const script = buildOpenClawPairingObservationScript(approvalPolicyModuleB64, stateDirectory); + const deps = { + getOpenshellBinary: execDeps?.getOpenshellBinary ?? resolveOpenshellBinary, + spawnSync: execDeps?.spawnSync ?? spawnSync, + }; + const startedAt = performance.now(); + 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: OBSERVATION_MAX_OUTPUT_BYTES, + stdio: ["pipe", "pipe", "ignore"], + timeout: OBSERVATION_TIMEOUT_MS, + }, + ); + if (result.error || result.signal || result.status !== 0) { + throw new OpenClawPairingQualificationError(); + } + const projection = parseOpenClawPairingObservation(String(result.stdout ?? "")); + if (!projection) throw new OpenClawPairingQualificationError(); + return { + schemaVersion: 1, + kind: "openclaw-pairing", + openclawVersion: normalizedVersion, + ...projection, + policySha256: sha256(approvalPolicy), + }; + } catch (error) { + if (error instanceof OpenClawPairingQualificationError) throw error; + throw new OpenClawPairingQualificationError(); + } finally { + recordQualificationStage(startedAt); + } +} diff --git a/src/lib/actions/sandbox/launch.test.ts b/src/lib/actions/sandbox/launch.test.ts index c6a3c60c4a4..4d8362d96a1 100644 --- a/src/lib/actions/sandbox/launch.test.ts +++ b/src/lib/actions/sandbox/launch.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ prepareInteractiveSession: vi.fn(), printInteractiveSessionHints: vi.fn(), completeInteractiveSessionSetup: vi.fn(), + completeReadinessQualifiedInteractiveSessionSetup: vi.fn(), execSandbox: vi.fn(), prepareHermesLightTerminalSkin: vi.fn(), inspectLaunchReadiness: vi.fn(), @@ -24,6 +25,8 @@ vi.mock("./connect", () => ({ prepareInteractiveSession: mocks.prepareInteractiveSession, printInteractiveSessionHints: mocks.printInteractiveSessionHints, completeInteractiveSessionSetup: mocks.completeInteractiveSessionSetup, + completeReadinessQualifiedInteractiveSessionSetup: + mocks.completeReadinessQualifiedInteractiveSessionSetup, })); vi.mock("./exec", () => ({ execSandbox: mocks.execSandbox, @@ -316,7 +319,7 @@ describe("launchSandbox", () => { expect(mocks.execSandbox).not.toHaveBeenCalled(); }); - it("uses the accepted lease path without running the complete preflight (#8942)", async () => { + it("skips the complete OpenClaw pairing pass after current lease qualification (#9023)", async () => { const openclaw = loadAgent("openclaw"); const sb = sandboxEntry("openclaw"); mocks.inspectLaunchReadiness.mockResolvedValue({ @@ -330,12 +333,16 @@ describe("launchSandbox", () => { expect(mocks.prepareInteractiveSession).not.toHaveBeenCalled(); expect(mocks.printInteractiveSessionHints).toHaveBeenCalledWith("alpha"); - expect(mocks.completeInteractiveSessionSetup).toHaveBeenCalledWith("alpha", sb); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith( + "alpha", + sb, + ); + expect(mocks.completeInteractiveSessionSetup).not.toHaveBeenCalled(); expect(mocks.publishLaunchReadiness).not.toHaveBeenCalled(); expect(mocks.printInteractiveSessionHints).toHaveBeenCalledBefore( - mocks.completeInteractiveSessionSetup, + mocks.completeReadinessQualifiedInteractiveSessionSetup, ); - expect(mocks.completeInteractiveSessionSetup).toHaveBeenCalledBefore( + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledBefore( mocks.prepareHermesLightTerminalSkin, ); expect(mocks.prepareHermesLightTerminalSkin).toHaveBeenCalledBefore(mocks.execSandbox); @@ -372,7 +379,10 @@ describe("launchSandbox", () => { expect.objectContaining({ epochId: "a".repeat(64) }), expect.any(Function), ); - expect(mocks.completeInteractiveSessionSetup).toHaveBeenCalledWith("alpha", sb); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledWith( + "alpha", + sb, + ); expect(mocks.execSandbox).toHaveBeenCalledOnce(); }); @@ -392,6 +402,21 @@ describe("launchSandbox", () => { expect(mocks.prepareInteractiveSession).not.toHaveBeenCalled(); expect(mocks.execSandbox).toHaveBeenCalledTimes(2); expect(mocks.publishLaunchReadiness).not.toHaveBeenCalled(); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).toHaveBeenCalledTimes(2); + expect(mocks.completeInteractiveSessionSetup).not.toHaveBeenCalled(); + }); + + it("runs the existing complete pairing path once after qualification fallback (#9023)", async () => { + mocks.prepareInteractiveSession.mockImplementationOnce(async () => { + mocks.completeInteractiveSessionSetup("alpha", sandboxEntry("openclaw")); + return { agent: loadAgent("openclaw"), sb: sandboxEntry("openclaw") }; + }); + + await launchSandbox("alpha"); + + expect(mocks.prepareInteractiveSession).toHaveBeenCalledOnce(); + expect(mocks.completeInteractiveSessionSetup).toHaveBeenCalledOnce(); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).not.toHaveBeenCalled(); }); it("publishes recaptured final state only after successful complete preflight (#8942)", async () => { diff --git a/src/lib/actions/sandbox/launch.ts b/src/lib/actions/sandbox/launch.ts index 2bdb77c61d9..ebc32171501 100644 --- a/src/lib/actions/sandbox/launch.ts +++ b/src/lib/actions/sandbox/launch.ts @@ -7,7 +7,7 @@ import { resolveSandboxGatewayName } from "../../gateway-runtime-action"; import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; import { withMcpLifecycleLock as withSandboxMutationLock } from "../../state/mcp-lifecycle-lock-acquisition"; import { - completeInteractiveSessionSetup, + completeReadinessQualifiedInteractiveSessionSetup, prepareInteractiveSession, printInteractiveSessionHints, } from "./connect"; @@ -82,7 +82,7 @@ export async function launchSandbox( while (true) { if (decision.kind === "accepted") { printInteractiveSessionHints(sandboxName); - completeInteractiveSessionSetup(sandboxName, decision.sb); + completeReadinessQualifiedInteractiveSessionSetup(sandboxName, decision.sb); session = { agent: decision.agent, sb: decision.sb }; break; } diff --git a/src/lib/state/launch-readiness-lease.test.ts b/src/lib/state/launch-readiness-lease.test.ts index a8ec06b8a0e..35a43a2179b 100644 --- a/src/lib/state/launch-readiness-lease.test.ts +++ b/src/lib/state/launch-readiness-lease.test.ts @@ -40,6 +40,23 @@ function identity(gatewayName = GATEWAY_NAME): LaunchReadinessIdentity { gatewayName, lifecycleGeneration: "generation-1", liveIdentityFingerprint: DIGEST, + session: null, + }; +} + +function openClawIdentity(): LaunchReadinessIdentity { + return { + ...identity(), + session: { + schemaVersion: 1, + kind: "openclaw-pairing", + openclawVersion: "2026.7.1", + deviceIdentitySha256: DIGEST, + pairingStateSha256: DIGEST, + policySha256: DIGEST, + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + }, }; } @@ -250,6 +267,28 @@ describe("launch readiness lease storage", () => { expect(second.leaseExpiresWallMs).toBe(first.leaseExpiresWallMs); }); + it("fences a schema-1 receipt through the normal fallback without invalidating authority (#9023)", () => { + const first = publish(); + const receiptPath = launchReadinessReceiptPath(SANDBOX, GATEWAY_PORT, home); + const oldReceipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")) as { + schemaVersion: number; + identity: Record; + }; + oldReceipt.schemaVersion = 1; + delete oldReceipt.identity.session; + fs.writeFileSync(receiptPath, JSON.stringify(oldReceipt), { mode: 0o600 }); + + expect(readLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()).kind).toBe("malformed"); + const next = fenceLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()); + + expect(next).toMatchObject({ schemaVersion: 2, epochId: EPOCH_B }); + expect(next.preservedLeaseStartedWallMs).toBe(first.leaseStartedWallMs); + expect(next.preservedLeaseExpiresWallMs).toBe(first.leaseExpiresWallMs); + expect( + JSON.parse(fs.readFileSync(launchReadinessAuthorityPath(SANDBOX, runtimeRoot), "utf8")), + ).toMatchObject({ schemaVersion: 1, epochId: EPOCH_B }); + }); + it("starts a new envelope only after the prior lease expires", () => { const first = publish(); wallMs = first.leaseExpiresWallMs; @@ -497,6 +536,32 @@ describe("launch readiness lease storage", () => { expect(readLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()).kind).toBe("unsafe"); }); + it("stores only the exact credential-free OpenClaw session qualification (#9023)", () => { + const fence = fenceLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()); + publishLaunchReadinessLease( + SANDBOX, + GATEWAY_PORT, + fence.epochId, + openClawIdentity(), + options(), + ); + const receiptPath = launchReadinessReceiptPath(SANDBOX, GATEWAY_PORT, home); + const raw = fs.readFileSync(receiptPath, "utf8"); + expect(raw).not.toContain("token"); + expect(raw).not.toContain("privateKey"); + expect(readLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options())).toMatchObject({ + kind: "valid", + lease: { identity: { session: openClawIdentity().session } }, + }); + + const value = JSON.parse(raw) as { + identity: { session: { requiredScopes: string[] } }; + }; + value.identity.session.requiredScopes = ["operator.pairing", "operator.write"]; + fs.writeFileSync(receiptPath, JSON.stringify(value), { mode: 0o600 }); + expect(readLaunchReadinessLease(SANDBOX, GATEWAY_PORT, options()).kind).toBe("malformed"); + }); + it("requires an exact bounded private runtime-authority record", () => { publish(); const authorityPath = launchReadinessAuthorityPath(SANDBOX, runtimeRoot); diff --git a/src/lib/state/launch-readiness-lease.ts b/src/lib/state/launch-readiness-lease.ts index 6ce06422036..bad624d0180 100644 --- a/src/lib/state/launch-readiness-lease.ts +++ b/src/lib/state/launch-readiness-lease.ts @@ -10,7 +10,7 @@ import path from "node:path"; import { nemoclawStateRoot } from "./state-root"; export const LAUNCH_READINESS_LEASE_MS = 24 * 60 * 60 * 1_000; -export const LAUNCH_READINESS_SCHEMA_VERSION = 1; +export const LAUNCH_READINESS_SCHEMA_VERSION = 2; export const LAUNCH_READINESS_MAX_BYTES = 16 * 1_024; const RECEIPT_DIRECTORY = "launch-readiness"; @@ -27,10 +27,24 @@ export interface LaunchReadinessIdentity { gatewayName: string; lifecycleGeneration: string; liveIdentityFingerprint: string; + session: LaunchReadinessSessionQualification | null; } -export interface LaunchReadinessLease { +export interface LaunchReadinessOpenClawSessionQualification { schemaVersion: 1; + kind: "openclaw-pairing"; + openclawVersion: string; + deviceIdentitySha256: string; + pairingStateSha256: string; + policySha256: string; + requiredRoles: ["operator"]; + requiredScopes: ["operator.pairing", "operator.read", "operator.write"]; +} + +export type LaunchReadinessSessionQualification = LaunchReadinessOpenClawSessionQualification; + +export interface LaunchReadinessLease { + schemaVersion: 2; kind: "lease"; epochId: string; sandboxName: string; @@ -51,7 +65,7 @@ export interface LaunchReadinessLease { } export interface LaunchReadinessFence { - schemaVersion: 1; + schemaVersion: 2; kind: "fence"; epochId: string; sandboxName: string; @@ -189,6 +203,51 @@ function isEpoch(value: unknown): value is string { return typeof value === "string" && SHA256_RE.test(value); } +function isExactStringArray(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((entry, index) => entry === expected[index]) + ); +} + +function isSessionQualification(value: unknown): value is LaunchReadinessSessionQualification { + if (!isPlainRecord(value)) return false; + if ( + !hasExactKeys(value, [ + "schemaVersion", + "kind", + "openclawVersion", + "deviceIdentitySha256", + "pairingStateSha256", + "policySha256", + "requiredRoles", + "requiredScopes", + ]) + ) { + return false; + } + return ( + value.schemaVersion === 1 && + value.kind === "openclaw-pairing" && + typeof value.openclawVersion === "string" && + value.openclawVersion.length > 0 && + value.openclawVersion.length <= 128 && + typeof value.deviceIdentitySha256 === "string" && + SHA256_RE.test(value.deviceIdentitySha256) && + typeof value.pairingStateSha256 === "string" && + SHA256_RE.test(value.pairingStateSha256) && + typeof value.policySha256 === "string" && + SHA256_RE.test(value.policySha256) && + isExactStringArray(value.requiredRoles, ["operator"]) && + isExactStringArray(value.requiredScopes, [ + "operator.pairing", + "operator.read", + "operator.write", + ]) + ); +} + function isIdentity(value: unknown): value is LaunchReadinessIdentity { if (!isPlainRecord(value)) return false; if ( @@ -200,6 +259,7 @@ function isIdentity(value: unknown): value is LaunchReadinessIdentity { "gatewayName", "lifecycleGeneration", "liveIdentityFingerprint", + "session", ]) ) { return false; @@ -220,7 +280,8 @@ function isIdentity(value: unknown): value is LaunchReadinessIdentity { typeof value.livePolicy === "string" && SHA256_RE.test(value.livePolicy) && typeof value.liveInference === "string" && - SHA256_RE.test(value.liveInference) + SHA256_RE.test(value.liveInference) && + (value.session === null || isSessionQualification(value.session)) ); } From 0564ce684db9a2a6a2caa76c69a80014e266f4e3 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 13 Aug 2026 13:56:08 -0700 Subject: [PATCH 2/9] fix(cli): support shared OpenClaw pairing state Signed-off-by: Carlos Villela --- .../actions/sandbox/launch-readiness.test.ts | 18 +++++------ .../openclaw-pairing-qualification.test.ts | 20 +++++++----- .../openclaw-pairing-qualification.ts | 31 ++++++++++++++++--- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/lib/actions/sandbox/launch-readiness.test.ts b/src/lib/actions/sandbox/launch-readiness.test.ts index 82eeeb6aca1..29c17128d4b 100644 --- a/src/lib/actions/sandbox/launch-readiness.test.ts +++ b/src/lib/actions/sandbox/launch-readiness.test.ts @@ -365,13 +365,13 @@ describe("launch readiness validation", () => { it("falls back when any exact OpenClaw pairing qualification value changes (#9023)", async () => { const currentDeps = await createAcceptedLease(); const stored = publishedIdentity?.session; - if (!stored || stored.kind !== "openclaw-pairing") - throw new Error("missing test qualification"); + expect(stored).toMatchObject({ kind: "openclaw-pairing" }); + const qualification = stored as LaunchReadinessOpenClawSessionQualification; const changedQualifications: LaunchReadinessOpenClawSessionQualification[] = [ - { ...stored, openclawVersion: "1.0.1" }, - { ...stored, deviceIdentitySha256: "2".repeat(64) }, - { ...stored, pairingStateSha256: "3".repeat(64) }, - { ...stored, policySha256: "4".repeat(64) }, + { ...qualification, openclawVersion: "1.0.1" }, + { ...qualification, deviceIdentitySha256: "2".repeat(64) }, + { ...qualification, pairingStateSha256: "3".repeat(64) }, + { ...qualification, policySha256: "4".repeat(64) }, ]; for (const changed of changedQualifications) { @@ -388,9 +388,9 @@ describe("launch readiness validation", () => { it("falls back when the OpenClaw gateway or lifecycle binding changes (#9023)", async () => { const currentDeps = await createAcceptedLease(); const stored = publishedIdentity?.session; - if (!stored || stored.kind !== "openclaw-pairing") - throw new Error("missing test qualification"); - currentDeps.observeOpenClawPairingQualification = () => stored; + expect(stored).toMatchObject({ kind: "openclaw-pairing" }); + const qualification = stored as LaunchReadinessOpenClawSessionQualification; + currentDeps.observeOpenClawPairingQualification = () => qualification; currentDeps.fenceLease = () => ({ ...fence(), gatewayName: sandbox.gatewayName ?? GATEWAY_NAME, diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts index 3acb024d5e2..f2c00564739 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -45,8 +45,8 @@ def approval_request_decision(device): `; function writeJson(filePath: string, value: unknown): void { - fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); - fs.chmodSync(filePath, 0o600); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o660 }); + fs.chmodSync(filePath, 0o660); } function localScriptSpawn( @@ -70,12 +70,12 @@ describe("OpenClaw launch-readiness pairing qualification", () => { beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pairing-qualification-")); stateDirectory = path.join(root, ".openclaw"); - fs.mkdirSync(path.join(stateDirectory, "devices"), { mode: 0o700, recursive: true }); - fs.mkdirSync(path.join(stateDirectory, "identity"), { mode: 0o700, recursive: true }); + fs.mkdirSync(path.join(stateDirectory, "devices"), { mode: 0o2770, recursive: true }); + fs.mkdirSync(path.join(stateDirectory, "identity"), { mode: 0o2770, recursive: true }); stateDirectory = fs.realpathSync(stateDirectory); - fs.chmodSync(stateDirectory, 0o700); - fs.chmodSync(path.join(stateDirectory, "devices"), 0o700); - fs.chmodSync(path.join(stateDirectory, "identity"), 0o700); + fs.chmodSync(stateDirectory, 0o2770); + fs.chmodSync(path.join(stateDirectory, "devices"), 0o2770); + fs.chmodSync(path.join(stateDirectory, "identity"), 0o2770); const publicKeyBytes = Buffer.alloc(32, 7); publicKey = publicKeyBytes.toString("base64url"); deviceId = createHash("sha256").update(publicKeyBytes).digest("hex"); @@ -140,7 +140,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { ); } - it("emits a credential-free qualification from stable OpenClaw-owned state (#9023)", () => { + it("emits a credential-free qualification from supported shared OpenClaw state (#9023)", () => { const qualification = observe(); const serialized = JSON.stringify(qualification); @@ -228,6 +228,10 @@ describe("OpenClaw launch-readiness pairing qualification", () => { "unsafe paired permissions", () => fs.chmodSync(path.join(stateDirectory, "devices", "paired.json"), 0o666), ], + [ + "world-readable device credentials", + () => fs.chmodSync(path.join(stateDirectory, "identity", "device-auth.json"), 0o604), + ], [ "mismatched client credential", () => { diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts index 1560f7337eb..312d8fb5d33 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -157,16 +157,27 @@ file_flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) | getattr def directory_metadata(fd): metadata = os.fstat(fd) - if not stat.S_ISDIR(metadata.st_mode) or metadata.st_mode & 0o022: + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_gid != os.getegid() + or metadata.st_mode & 0o002 + ): raise OSError('unsafe directory') - return (metadata.st_dev, metadata.st_ino, metadata.st_mode & 0o777) + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_uid, + metadata.st_gid, + metadata.st_mode & 0o7777, + ) def file_metadata(fd): metadata = os.fstat(fd) if ( not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 - or metadata.st_mode & 0o022 + or metadata.st_gid != os.getegid() + or metadata.st_mode & 0o007 or metadata.st_size < 1 or metadata.st_size > MAX_ENTRY_BYTES ): @@ -174,9 +185,11 @@ def file_metadata(fd): return ( metadata.st_dev, metadata.st_ino, + metadata.st_uid, + metadata.st_gid, metadata.st_size, metadata.st_mtime_ns, - metadata.st_mode & 0o777, + metadata.st_mode & 0o7777, ) def state_root_is_current(fd): @@ -240,7 +253,15 @@ def read_entry(directory_fd, name): raise OSError('entry too large') after = file_metadata(fd) current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - if before != after or (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns, current.st_mode & 0o777) != after: + if before != after or ( + current.st_dev, + current.st_ino, + current.st_uid, + current.st_gid, + current.st_size, + current.st_mtime_ns, + current.st_mode & 0o7777, + ) != after: raise OSError('entry changed') return b''.join(chunks), after finally: From 643693fb8d6c5a67729f74c9c4d0c88c7ac3edea Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 13 Aug 2026 14:00:01 -0700 Subject: [PATCH 3/9] test(cli): address pairing review feedback Signed-off-by: Carlos Villela --- .../actions/sandbox/launch-readiness.test.ts | 15 +- src/lib/actions/sandbox/launch-readiness.ts | 6 +- .../openclaw-pairing-qualification.test.ts | 262 +++++++++--------- src/lib/state/launch-readiness-lease.ts | 2 + 4 files changed, 151 insertions(+), 134 deletions(-) diff --git a/src/lib/actions/sandbox/launch-readiness.test.ts b/src/lib/actions/sandbox/launch-readiness.test.ts index 29c17128d4b..88dbb06ccaf 100644 --- a/src/lib/actions/sandbox/launch-readiness.test.ts +++ b/src/lib/actions/sandbox/launch-readiness.test.ts @@ -402,14 +402,21 @@ describe("launch readiness validation", () => { }); const original = sandbox; - for (const changed of [ - { ...original, gatewayName: "nemoclaw-8081", gatewayPort: 8081 }, - { ...original, lifecycleGeneration: "generation-2" }, - { ...original, lifecycleLiveIdentityFingerprint: "5".repeat(64) }, + for (const { changed, category } of [ + { + changed: { ...original, gatewayName: "nemoclaw-8081", gatewayPort: 8081 }, + category: "identity", + }, + { changed: { ...original, lifecycleGeneration: "generation-2" }, category: "config" }, + { + changed: { ...original, lifecycleLiveIdentityFingerprint: "5".repeat(64) }, + category: "identity", + }, ]) { sandbox = changed; await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ kind: "fallback", + category, fence: { epochId: EPOCH }, recoveryBlocked: false, }); diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index 1f73104d975..7793c85cf7c 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -700,9 +700,11 @@ async function captureLaunchIdentity( let session: LaunchReadinessIdentity["session"] = null; if (agentName === "openclaw") { const openclawVersion = normalizedString(entry.agentVersion); - const expectedVersion = normalizedString(agent.expected_version); const stateDirectory = normalizedString(agent.config?.dir); - if (!openclawVersion || !expectedVersion || !stateDirectory) { + // Pairing qualification requires a versioned trusted definition. The + // receipt binds the sandbox's recorded version, including supported stale + // versions that the normal launch warning permits. + if (!openclawVersion || !normalizedString(agent.expected_version) || !stateDirectory) { throw new OpenClawPairingQualificationError(); } try { diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts index f2c00564739..3017d5e7f0d 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -19,6 +19,8 @@ import { const TOKEN = "credential-value-must-not-leave-the-sandbox"; const PRIVATE_KEY = "private-key-material-must-not-leave-the-sandbox"; +const PYTHON3_AVAILABLE = + spawnSync("sh", ["-c", "command -v python3"], { stdio: "ignore" }).status === 0; type PairedFixture = Record< string, { @@ -140,146 +142,150 @@ describe("OpenClaw launch-readiness pairing qualification", () => { ); } - it("emits a credential-free qualification from supported shared OpenClaw state (#9023)", () => { - const qualification = observe(); - const serialized = JSON.stringify(qualification); + describe.skipIf(!PYTHON3_AVAILABLE)("state observation", () => { + it("emits a credential-free qualification from supported shared OpenClaw state (#9023)", () => { + const qualification = observe(); + const serialized = JSON.stringify(qualification); - expect(qualification).toMatchObject({ - schemaVersion: 1, - kind: "openclaw-pairing", - openclawVersion: "2026.7.1", - requiredRoles: ["operator"], - requiredScopes: ["operator.pairing", "operator.read", "operator.write"], - deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), - pairingStateSha256: expect.stringMatching(/^[a-f0-9]{64}$/), - policySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + expect(qualification).toMatchObject({ + schemaVersion: 1, + kind: "openclaw-pairing", + openclawVersion: "2026.7.1", + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + pairingStateSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + policySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(serialized).not.toContain(TOKEN); + expect(serialized).not.toContain(PRIVATE_KEY); + expect(serialized).not.toContain(publicKey); + expect(serialized).not.toContain(deviceId); + expect(performance.getEntriesByName("nemoclaw.openclaw-pairing.qualification")).toHaveLength( + 1, + ); }); - expect(serialized).not.toContain(TOKEN); - expect(serialized).not.toContain(PRIVATE_KEY); - expect(serialized).not.toContain(publicKey); - expect(serialized).not.toContain(deviceId); - expect(performance.getEntriesByName("nemoclaw.openclaw-pairing.qualification")).toHaveLength(1); - }); - - it("does not make paired credential values part of the receipt identity (#9023)", () => { - const first = observe(); - const pairedPath = path.join(stateDirectory, "devices", "paired.json"); - const authPath = path.join(stateDirectory, "identity", "device-auth.json"); - const replacementToken = `${TOKEN}-rotated`; - const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; - paired[deviceId]!.tokens.operator.token = replacementToken; - writeJson(pairedPath, paired); - const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; - auth.tokens.operator.token = replacementToken; - writeJson(authPath, auth); - const second = observe(); + it("does not make paired credential values part of the receipt identity (#9023)", () => { + const first = observe(); + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const authPath = path.join(stateDirectory, "identity", "device-auth.json"); + const replacementToken = `${TOKEN}-rotated`; + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.tokens.operator.token = replacementToken; + writeJson(pairedPath, paired); + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; + auth.tokens.operator.token = replacementToken; + writeJson(authPath, auth); - expect(second).toEqual(first); - expect(JSON.stringify(second)).not.toContain(replacementToken); - }); + const second = observe(); - it("does not derive pairing evidence from arbitrary OpenClaw configuration (#9023)", () => { - const first = observe(); - const credentialValue = `${TOKEN}-arbitrary-config`; - writeJson(path.join(stateDirectory, "openclaw.json"), { - unknown: { - privateKeyPem: credentialValue, - passwordValue: credentialValue, - credentialValue, - headers: { Authorization: `Bearer ${credentialValue}` }, - url: `https://user:${credentialValue}@example.invalid/path?token=${credentialValue}`, - args: ["run", credentialValue], - }, + expect(second).toEqual(first); + expect(JSON.stringify(second)).not.toContain(replacementToken); }); - const second = observe(); - const serialized = JSON.stringify(second); + it("does not derive pairing evidence from arbitrary OpenClaw configuration (#9023)", () => { + const first = observe(); + const credentialValue = `${TOKEN}-arbitrary-config`; + writeJson(path.join(stateDirectory, "openclaw.json"), { + unknown: { + privateKeyPem: credentialValue, + passwordValue: credentialValue, + credentialValue, + headers: { Authorization: `Bearer ${credentialValue}` }, + url: `https://user:${credentialValue}@example.invalid/path?token=${credentialValue}`, + args: ["run", credentialValue], + }, + }); - expect(second).toEqual(first); - expect(serialized).not.toContain(credentialValue); - }); + const second = observe(); + const serialized = JSON.stringify(second); - it("rejects a new allowlisted pending request without calling the OpenClaw CLI (#9023)", () => { - writeJson(path.join(stateDirectory, "devices", "pending.json"), { - "request-1": { - requestId: "request-1", - clientId: "cli", - clientMode: "cli", - scopes: ["operator.write"], - }, + expect(second).toEqual(first); + expect(serialized).not.toContain(credentialValue); }); - expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); - const script = buildOpenClawPairingObservationScript( - Buffer.from(POLICY, "utf8").toString("base64"), - stateDirectory, - ); - expect(script).not.toContain("openclaw devices list"); - expect(script).not.toContain("[OPENCLAW, 'devices', 'list'"); - }); + it("rejects a new allowlisted pending request without calling the OpenClaw CLI (#9023)", () => { + writeJson(path.join(stateDirectory, "devices", "pending.json"), { + "request-1": { + requestId: "request-1", + clientId: "cli", + clientMode: "cli", + scopes: ["operator.write"], + }, + }); - it.each([ - [ - "malformed pending state", - () => writeJson(path.join(stateDirectory, "devices", "pending.json"), []), - ], - [ - "unsafe paired permissions", - () => fs.chmodSync(path.join(stateDirectory, "devices", "paired.json"), 0o666), - ], - [ - "world-readable device credentials", - () => fs.chmodSync(path.join(stateDirectory, "identity", "device-auth.json"), 0o604), - ], - [ - "mismatched client credential", - () => { - const authPath = path.join(stateDirectory, "identity", "device-auth.json"); - const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; - auth.tokens.operator.token = "different-token"; - writeJson(authPath, auth); - }, - ], - [ - "incomplete required scopes", - () => { - const pairedPath = path.join(stateDirectory, "devices", "paired.json"); - const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; - paired[deviceId]!.approvedScopes = ["operator.pairing", "operator.read"]; - writeJson(pairedPath, paired); - }, - ], - [ - "changed canonical client ID", - () => { - const pairedPath = path.join(stateDirectory, "devices", "paired.json"); - const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; - paired[deviceId]!.clientId = "unknown-client"; - writeJson(pairedPath, paired); - }, - ], - [ - "changed canonical client mode", - () => { - const pairedPath = path.join(stateDirectory, "devices", "paired.json"); - const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; - paired[deviceId]!.clientMode = "unknown-mode"; - writeJson(pairedPath, paired); - }, - ], - [ - "ambiguous local device state", - () => { - const pairedPath = path.join(stateDirectory, "devices", "paired.json"); - const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; - paired.duplicate = { ...paired[deviceId]!, deviceId: "different-device" }; - writeJson(pairedPath, paired); - }, - ], - ])("rejects %s and requires the complete pairing path (#9023)", (_label, mutate) => { - mutate(); - expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); + expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); + const script = buildOpenClawPairingObservationScript( + Buffer.from(POLICY, "utf8").toString("base64"), + stateDirectory, + ); + expect(script).not.toContain("openclaw devices list"); + expect(script).not.toContain("[OPENCLAW, 'devices', 'list'"); + }); + + it.each([ + [ + "malformed pending state", + () => writeJson(path.join(stateDirectory, "devices", "pending.json"), []), + ], + [ + "unsafe paired permissions", + () => fs.chmodSync(path.join(stateDirectory, "devices", "paired.json"), 0o666), + ], + [ + "world-readable device credentials", + () => fs.chmodSync(path.join(stateDirectory, "identity", "device-auth.json"), 0o604), + ], + [ + "mismatched client credential", + () => { + const authPath = path.join(stateDirectory, "identity", "device-auth.json"); + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; + auth.tokens.operator.token = "different-token"; + writeJson(authPath, auth); + }, + ], + [ + "incomplete required scopes", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.approvedScopes = ["operator.pairing", "operator.read"]; + writeJson(pairedPath, paired); + }, + ], + [ + "changed canonical client ID", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.clientId = "unknown-client"; + writeJson(pairedPath, paired); + }, + ], + [ + "changed canonical client mode", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.clientMode = "unknown-mode"; + writeJson(pairedPath, paired); + }, + ], + [ + "ambiguous local device state", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired.duplicate = { ...paired[deviceId]!, deviceId: "different-device" }; + writeJson(pairedPath, paired); + }, + ], + ])("rejects %s and requires the complete pairing path (#9023)", (_label, mutate) => { + mutate(); + expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); + }); }); it("pins observation to the named gateway and rejects non-terminal output (#9023)", () => { diff --git a/src/lib/state/launch-readiness-lease.ts b/src/lib/state/launch-readiness-lease.ts index bad624d0180..48cf67671cf 100644 --- a/src/lib/state/launch-readiness-lease.ts +++ b/src/lib/state/launch-readiness-lease.ts @@ -10,6 +10,8 @@ import path from "node:path"; import { nemoclawStateRoot } from "./state-root"; export const LAUNCH_READINESS_LEASE_MS = 24 * 60 * 60 * 1_000; +// This version covers lease and fence records. Nested session qualifications +// and the separate runtime authority keep their independent schema versions. export const LAUNCH_READINESS_SCHEMA_VERSION = 2; export const LAUNCH_READINESS_MAX_BYTES = 16 * 1_024; From f5d7c92f714e061c4dfc21e25697ca3efcbcf5bb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 13 Aug 2026 14:13:29 -0700 Subject: [PATCH 4/9] test(cli): cover pairing fallback boundaries Signed-off-by: Carlos Villela --- src/lib/actions/sandbox/launch-readiness.test.ts | 13 +++++++++++++ .../openclaw-pairing-qualification.test.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/src/lib/actions/sandbox/launch-readiness.test.ts b/src/lib/actions/sandbox/launch-readiness.test.ts index 88dbb06ccaf..b709e288439 100644 --- a/src/lib/actions/sandbox/launch-readiness.test.ts +++ b/src/lib/actions/sandbox/launch-readiness.test.ts @@ -362,6 +362,19 @@ describe("launch readiness validation", () => { }); }); + it("falls back when the stored OpenClaw identity lacks pairing qualification (#9023)", async () => { + const currentDeps = await createAcceptedLease(); + expect(publishedIdentity).not.toBeNull(); + publishedIdentity = { ...publishedIdentity!, session: null }; + + await expect(inspectLaunchReadiness(SANDBOX, currentDeps)).resolves.toMatchObject({ + kind: "fallback", + category: "session", + fence: { epochId: EPOCH }, + recoveryBlocked: false, + }); + }); + it("falls back when any exact OpenClaw pairing qualification value changes (#9023)", async () => { const currentDeps = await createAcceptedLease(); const stored = publishedIdentity?.session; diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts index 3017d5e7f0d..fbb33a20d15 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -283,6 +283,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { }, ], ])("rejects %s and requires the complete pairing path (#9023)", (_label, mutate) => { + expect(() => observe()).not.toThrow(); mutate(); expect(() => observe()).toThrow("OpenClaw pairing qualification is unavailable"); }); From 7d8a46a5e700baf7deb3f44d61bd1e72e547d7f9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 13 Aug 2026 14:34:13 -0700 Subject: [PATCH 5/9] test(cli): model pairing qualification in fixtures Signed-off-by: Carlos Villela --- test/cli/connect-recovery.test.ts | 5 +++++ test/helpers/launch-readiness-fixture.ts | 10 ++++++++++ test/sandbox-connect-inference/helpers.ts | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/test/cli/connect-recovery.test.ts b/test/cli/connect-recovery.test.ts index 3a85fafdabb..82377e52aa6 100644 --- a/test/cli/connect-recovery.test.ts +++ b/test/cli/connect-recovery.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { LAUNCH_READINESS_FIXTURE_POLICY, + LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT, launchReadinessRegistryFixture, } from "../helpers/launch-readiness-fixture"; import { nonWslPlatformNodeOptions } from "../helpers/platform-override-node-options"; @@ -29,6 +30,10 @@ type GatewayControlDockerStubOptions = { }; const launchReadinessObservationStubLines = [ + 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [[ "$*" == *"-- sh -s"* ]]; then', + ` printf '%s\\n' ${JSON.stringify(LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT)}`, + " exit 0", + "fi", 'if [ "$1" = "policy" ] && [ "$2" = "get" ]; then', ` printf '%b' ${JSON.stringify(LAUNCH_READINESS_FIXTURE_POLICY)}`, " exit 0", diff --git a/test/helpers/launch-readiness-fixture.ts b/test/helpers/launch-readiness-fixture.ts index c8077c41b2b..b82320c8cb8 100644 --- a/test/helpers/launch-readiness-fixture.ts +++ b/test/helpers/launch-readiness-fixture.ts @@ -16,8 +16,18 @@ network_policies: - path: /usr/bin/curl `; +export const LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT = + `__NEMOCLAW_OPENCLAW_PAIRING_QUALIFICATION__=${JSON.stringify({ + deviceIdentitySha256: "a".repeat(64), + pairingStateSha256: "b".repeat(64), + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + })}`; + export function launchReadinessRegistryFixture(sandboxId = "abc") { return { + agent: "openclaw", + agentVersion: "2026.7.1", openshellDriver: "docker", openshellVersion: "0.0.16", gatewayName: resolveGatewayName(DEFAULT_GATEWAY_PORT), diff --git a/test/sandbox-connect-inference/helpers.ts b/test/sandbox-connect-inference/helpers.ts index 054dd5d784c..9193f44d3e1 100644 --- a/test/sandbox-connect-inference/helpers.ts +++ b/test/sandbox-connect-inference/helpers.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { afterEach, expect } from "vitest"; import { LAUNCH_READINESS_FIXTURE_POLICY, + LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT, launchReadinessRegistryFixture, } from "../helpers/launch-readiness-fixture"; import { nonWslPlatformNodeOptions } from "../helpers/platform-override-node-options"; @@ -245,6 +246,10 @@ if (args[0] === "sandbox" && args[1] === "exec") { const command = [args.join(" "), input].filter(Boolean).join("\\n"); if (!command.includes("inference.local/v1/models")) { fs.writeFileSync(stateFile, JSON.stringify(state)); + if (input.includes("NEMOCLAW_OPENCLAW_STATE_DIR_B64=")) { + process.stdout.write(${JSON.stringify(`${LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT}\n`)}); + process.exit(0); + } // Test hook (#4263 / CodeRabbit): when the connect-time auto-pair // approval pass is specifically targeted, simulate the failure // path the production code must tolerate. The approval program is carried From 7972b185bead83796a7525395a6ecbe13a643860 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 14:38:03 -0700 Subject: [PATCH 6/9] fix(cli): qualify canonical OpenClaw scope views Signed-off-by: Senthil Ravichandran --- .../openclaw-pairing-qualification.test.ts | 160 +++++++++++++++++- .../openclaw-pairing-qualification.ts | 24 +-- 2 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts index fbb33a20d15..7bf16724772 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -10,9 +10,15 @@ import { performance } from "node:perf_hooks"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildAutoPairApprovalScript, + parseAutoPairApprovalReceipt, + readAutoPairApprovalPolicyModule, +} from "../auto-pair-approval"; import { buildOpenClawPairingObservationScript, observeOpenClawPairingQualification, + OPENCLAW_PAIRING_REQUEST_SCOPES, OPENCLAW_PAIRING_REQUIRED_SCOPES, parseOpenClawPairingObservation, } from "./openclaw-pairing-qualification"; @@ -26,12 +32,13 @@ type PairedFixture = Record< { deviceId: string; publicKey: string; + scopes: string[]; approvedScopes: string[]; - tokens: { operator: { token: string } }; + tokens: { operator: { token: string; scopes: string[] } }; [key: string]: unknown; } >; -type AuthFixture = { tokens: { operator: { token: string } } }; +type AuthFixture = { tokens: { operator: { token: string; scopes: string[] } } }; const POLICY = ` ALLOWED_CLIENTS = {'cli', 'openclaw-cli', 'openclaw-control-ui'} ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'} @@ -108,8 +115,8 @@ describe("OpenClaw launch-readiness pairing qualification", () => { clientMode: "cli", role: "operator", roles: ["operator"], - scopes: [...OPENCLAW_PAIRING_REQUIRED_SCOPES], - approvedScopes: [...OPENCLAW_PAIRING_REQUIRED_SCOPES], + scopes: [...OPENCLAW_PAIRING_REQUEST_SCOPES], + approvedScopes: [...OPENCLAW_PAIRING_REQUEST_SCOPES], tokens: { operator: { token: TOKEN, @@ -128,7 +135,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { vi.restoreAllMocks(); }); - function observe() { + function observe(approvalPolicy = POLICY) { return observeOpenClawPairingQualification( "alpha", "nemoclaw-8080", @@ -136,14 +143,14 @@ describe("OpenClaw launch-readiness pairing qualification", () => { stateDirectory, { getOpenshellBinary: () => "openshell", - readApprovalPolicy: () => POLICY, + readApprovalPolicy: () => approvalPolicy, spawnSync: localScriptSpawn as typeof spawnSync, }, ); } describe.skipIf(!PYTHON3_AVAILABLE)("state observation", () => { - it("emits a credential-free qualification from supported shared OpenClaw state (#9023)", () => { + it("emits credential-free qualification from canonical settled OpenClaw state (#9023)", () => { const qualification = observe(); const serialized = JSON.stringify(qualification); @@ -166,6 +173,116 @@ describe("OpenClaw launch-readiness pairing qualification", () => { ); }); + it("qualifies the persisted result of the complete canonical approval transition (#9023)", () => { + const approvalPolicy = readAutoPairApprovalPolicyModule(); + expect(approvalPolicy).toBeTruthy(); + const requestId = "canonical-cli-write"; + writeJson(path.join(stateDirectory, "identity", "device-auth.json"), { + version: 1, + deviceId, + tokens: { + operator: { + token: TOKEN, + role: "operator", + scopes: ["operator.pairing"], + }, + }, + }); + writeJson(path.join(stateDirectory, "devices", "paired.json"), { + [deviceId]: { + deviceId, + publicKey, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing"], + approvedScopes: ["operator.pairing"], + tokens: { + operator: { + token: TOKEN, + role: "operator", + scopes: ["operator.pairing"], + }, + }, + }, + }); + writeJson(path.join(stateDirectory, "devices", "pending.json"), { + [requestId]: { + requestId, + deviceId, + publicKey, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: [...OPENCLAW_PAIRING_REQUEST_SCOPES], + isRepair: true, + }, + }); + const openclawPath = path.join(root, "openclaw"); + fs.writeFileSync( + openclawPath, + `#!${process.execPath} +const fs = require("fs"); +const path = require("path"); +const args = process.argv.slice(2); +if (args[0] !== "devices" || args[1] !== "approve") process.exit(2); +const stateDir = process.env.NEMOCLAW_TEST_CLONE_STATE_DIR; +const pendingPath = path.join(stateDir, "devices", "pending.json"); +const pairedPath = path.join(stateDir, "devices", "paired.json"); +const pending = JSON.parse(fs.readFileSync(pendingPath, "utf8")); +const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")); +const request = pending[args[2]]; +delete pending[args[2]]; +paired[request.deviceId] = { + ...paired[request.deviceId], + scopes: request.scopes, + approvedScopes: request.scopes, + tokens: { + operator: { + token: "rotated-canonical-token", + role: "operator", + scopes: ["operator.pairing", "operator.read", "operator.write"], + }, + }, +}; +fs.writeFileSync(pendingPath, JSON.stringify(pending)); +fs.writeFileSync(pairedPath, JSON.stringify(paired)); +process.stdout.write("{}\\n"); +`, + { mode: 0o755 }, + ); + const approval = spawnSync("sh", { + encoding: "utf-8", + env: { + ...process.env, + PATH: `${root}:/usr/bin:/bin`, + NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING: "1", + NEMOCLAW_TEST_CLONE_STATE_DIR: stateDirectory, + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_GATEWAY_TOKEN: "gateway-token", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + OPENCLAW_STATE_DIR: stateDirectory, + }, + input: buildAutoPairApprovalScript( + Buffer.from(approvalPolicy as string, "utf8").toString("base64"), + { + emitReceipt: true, + localDeviceOnly: true, + budget: { maxApprovals: 1 }, + }, + ), + }); + + expect(approval.status).toBe(0); + expect(parseAutoPairApprovalReceipt(approval.stdout)).toBe("approved-one"); + expect(observe(approvalPolicy as string)).toMatchObject({ + requiredRoles: ["operator"], + requiredScopes: ["operator.pairing", "operator.read", "operator.write"], + }); + }); + it("does not make paired credential values part of the receipt identity (#9023)", () => { const first = observe(); const pairedPath = path.join(stateDirectory, "devices", "paired.json"); @@ -247,7 +364,16 @@ describe("OpenClaw launch-readiness pairing qualification", () => { }, ], [ - "incomplete required scopes", + "changed paired request scopes", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.scopes = ["operator.pairing", "operator.read", "operator.write"]; + writeJson(pairedPath, paired); + }, + ], + [ + "changed approved request scopes", () => { const pairedPath = path.join(stateDirectory, "devices", "paired.json"); const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; @@ -255,6 +381,24 @@ describe("OpenClaw launch-readiness pairing qualification", () => { writeJson(pairedPath, paired); }, ], + [ + "changed paired token scopes", + () => { + const pairedPath = path.join(stateDirectory, "devices", "paired.json"); + const paired = JSON.parse(fs.readFileSync(pairedPath, "utf8")) as PairedFixture; + paired[deviceId]!.tokens.operator.scopes = ["operator.pairing", "operator.write"]; + writeJson(pairedPath, paired); + }, + ], + [ + "changed client-auth token scopes", + () => { + const authPath = path.join(stateDirectory, "identity", "device-auth.json"); + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFixture; + auth.tokens.operator.scopes = ["operator.pairing", "operator.write"]; + writeJson(authPath, auth); + }, + ], [ "changed canonical client ID", () => { diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts index 312d8fb5d33..f33af3ec7df 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -16,6 +16,9 @@ const OBSERVATION_TIMEOUT_MS = 3_000; const OBSERVATION_MAX_OUTPUT_BYTES = 4 * 1_024; export const OPENCLAW_PAIRING_REQUIRED_ROLES = ["operator"] as const; +// Canonical approval preserves the requested pairing/write view on the paired +// record and adds the implied read scope only to credential-bearing token views. +export const OPENCLAW_PAIRING_REQUEST_SCOPES = ["operator.pairing", "operator.write"] as const; export const OPENCLAW_PAIRING_REQUIRED_SCOPES = [ "operator.pairing", "operator.read", @@ -127,7 +130,8 @@ import sys MARKER = ${JSON.stringify(QUALIFICATION_MARKER)} MAX_ENTRY_BYTES = 512 * 1024 REQUIRED_ROLES = ['operator'] -REQUIRED_SCOPES = ['operator.pairing', 'operator.read', 'operator.write'] +REQUEST_SCOPES = ['operator.pairing', 'operator.write'] +TOKEN_SCOPES = ['operator.pairing', 'operator.read', 'operator.write'] def reject(): sys.exit(1) @@ -373,8 +377,8 @@ try: or paired_device.get('clientId') != 'cli' or paired_device.get('clientMode') != 'cli' or normalized_roles(paired_device) != set(REQUIRED_ROLES) - or not exact_string_set(paired_device.get('scopes'), REQUIRED_SCOPES) - or not exact_string_set(paired_device.get('approvedScopes'), REQUIRED_SCOPES) + or not exact_string_set(paired_device.get('scopes'), REQUEST_SCOPES) + or not exact_string_set(paired_device.get('approvedScopes'), REQUEST_SCOPES) ): reject() paired_tokens = paired_device.get('tokens') @@ -384,7 +388,7 @@ try: or str(paired_operator.get('role', '') or '').strip() != 'operator' or paired_operator.get('revokedAtMs') is not None or not str(paired_operator.get('token', '') or '').strip() - or not exact_string_set(paired_operator.get('scopes'), REQUIRED_SCOPES) + or not exact_string_set(paired_operator.get('scopes'), TOKEN_SCOPES) ): reject() auth_tokens = auth.get('tokens') @@ -395,7 +399,7 @@ try: or not isinstance(auth_operator, dict) or str(auth_operator.get('role', '') or '').strip() != 'operator' or str(auth_operator.get('token', '') or '').strip() != str(paired_operator.get('token', '') or '').strip() - or not exact_string_set(auth_operator.get('scopes'), REQUIRED_SCOPES) + or not exact_string_set(auth_operator.get('scopes'), TOKEN_SCOPES) ): reject() @@ -435,24 +439,24 @@ try: 'clientId': 'cli', 'clientMode': 'cli', 'roles': REQUIRED_ROLES, - 'scopes': REQUIRED_SCOPES, - 'approvedScopes': REQUIRED_SCOPES, + 'pairedRequestScopes': REQUEST_SCOPES, + 'approvedRequestScopes': REQUEST_SCOPES, 'pairedToken': { 'active': True, 'role': 'operator', - 'scopes': REQUIRED_SCOPES, + 'scopes': TOKEN_SCOPES, }, 'clientAuth': { 'deviceId': device_id, 'matchesPairedToken': True, 'role': 'operator', - 'scopes': REQUIRED_SCOPES, + 'scopes': TOKEN_SCOPES, 'version': 1, }, 'relevantPending': False, }, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest(), 'requiredRoles': REQUIRED_ROLES, - 'requiredScopes': REQUIRED_SCOPES, + 'requiredScopes': TOKEN_SCOPES, } print(MARKER + json.dumps(projection, sort_keys=True, separators=(',', ':'))) except (OSError, ValueError, TypeError, KeyError, binascii.Error, UnicodeError): From 830e662cd7f63904ec7bcf18ef307364a16c688e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 13 Aug 2026 14:49:56 -0700 Subject: [PATCH 7/9] test(cli): scope pairing observer stub Signed-off-by: Carlos Villela --- test/cli/connect-recovery.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/cli/connect-recovery.test.ts b/test/cli/connect-recovery.test.ts index 82377e52aa6..1e207aef1cd 100644 --- a/test/cli/connect-recovery.test.ts +++ b/test/cli/connect-recovery.test.ts @@ -31,8 +31,11 @@ type GatewayControlDockerStubOptions = { const launchReadinessObservationStubLines = [ 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [[ "$*" == *"-- sh -s"* ]]; then', - ` printf '%s\\n' ${JSON.stringify(LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT)}`, - " exit 0", + " qualification_script=$(cat)", + ' if [[ "$qualification_script" == *"NEMOCLAW_OPENCLAW_STATE_DIR_B64="* ]]; then', + ` printf '%s\\n' ${JSON.stringify(LAUNCH_READINESS_PAIRING_QUALIFICATION_OUTPUT)}`, + " exit 0", + " fi", "fi", 'if [ "$1" = "policy" ] && [ "$2" = "get" ]; then', ` printf '%b' ${JSON.stringify(LAUNCH_READINESS_FIXTURE_POLICY)}`, From 0d3a5322d09a3ce4ad0f1f7a7d3e446fc0dee403 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 15:08:40 -0700 Subject: [PATCH 8/9] fix(e2e): normalize OSC replies in launch harness Signed-off-by: Senthil Ravichandran --- test/e2e/live/launch-agent-turn.ts | 1 + test/e2e/support/launch-agent-turn.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/test/e2e/live/launch-agent-turn.ts b/test/e2e/live/launch-agent-turn.ts index 6d7df608401..72b0a03a133 100644 --- a/test/e2e/live/launch-agent-turn.ts +++ b/test/e2e/live/launch-agent-turn.ts @@ -91,6 +91,7 @@ printf '%s\r' "$NEMOCLAW_LAUNCH_PROMPT" >&3 reply_seen=0 has_exact_reply() { tail -c "+$((response_start + 1))" "$capture" \ + | sed -E $'s/\x1B][^\x07\x1B]*(\x07|\x1B\\\\)//g' \ | sed -E $'s/\x1B\\[[0-?]*[ -\\/]*[@-~]//g' \ | tr '\r' '\n' \ | LC_ALL=C tr -d '\000-\010\013\014\016-\037\177' \ diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index f36a9b2427a..d0e827eb346 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -183,12 +183,30 @@ it.runIf(process.platform !== "win32")( }, ); +it.runIf(process.platform !== "win32")( + "accepts an exact reply wrapped in a terminated OSC-8 hyperlink (#9023)", + () => { + for (const terminator of ["\u0007", "\u001b\\"]) { + const reply = + `\u001b]8;;https://example.invalid/reply${terminator}` + + `PONG\u001b]8;;${terminator}`; + const result = runLaunchTurnFixture(0, reply); + + expect(result.signal, result.stderr).toBeNull(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("NEMOCLAW_LAUNCH_TURN_OK"); + } + }, +); + it.runIf(process.platform !== "win32")( "rejects a reply token embedded in extra prose (#8942)", () => { for (const reply of [ "The answer is PONG, with extra prose.", "The answer is \u001b[31mPONG\u001b[0m, with extra prose.", + "\u001b]8;;https://example.invalid/reply\u0007PONG with extra prose\u001b]8;;\u0007", + "\u001b]8;;https://example.invalid/replyPONG", ]) { const result = runLaunchTurnFixture(0, reply, true); From ca36f4afff9a7d7e72b61b6dd174519f3ce62a7e Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Thu, 13 Aug 2026 15:18:16 -0700 Subject: [PATCH 9/9] fix(e2e): await idle before launch exit Signed-off-by: Senthil Ravichandran --- test/e2e/live/launch-agent-turn.ts | 40 ++++++++++++++++++++-- test/e2e/support/launch-agent-turn.test.ts | 7 +++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/launch-agent-turn.ts b/test/e2e/live/launch-agent-turn.ts index 72b0a03a133..071d5e40710 100644 --- a/test/e2e/live/launch-agent-turn.ts +++ b/test/e2e/live/launch-agent-turn.ts @@ -89,13 +89,15 @@ response_start="$(wc -c <"$capture")" printf '%s\r' "$NEMOCLAW_LAUNCH_PROMPT" >&3 reply_seen=0 -has_exact_reply() { +normalized_response() { tail -c "+$((response_start + 1))" "$capture" \ | sed -E $'s/\x1B][^\x07\x1B]*(\x07|\x1B\\\\)//g' \ | sed -E $'s/\x1B\\[[0-?]*[ -\\/]*[@-~]//g' \ | tr '\r' '\n' \ - | LC_ALL=C tr -d '\000-\010\013\014\016-\037\177' \ - | awk -v expected="$NEMOCLAW_LAUNCH_EXPECTED_REPLY" ' + | LC_ALL=C tr -d '\000-\010\013\014\016-\037\177' +} +has_exact_reply() { + normalized_response | awk -v expected="$NEMOCLAW_LAUNCH_EXPECTED_REPLY" ' { line = $0 sub(/^[[:space:]]+/, "", line) @@ -105,6 +107,20 @@ has_exact_reply() { END { exit found ? 0 : 1 } ' } +has_post_reply_ready() { + normalized_response | awk \ + -v expected="$NEMOCLAW_LAUNCH_EXPECTED_REPLY" \ + -v ready="$NEMOCLAW_LAUNCH_READY_TEXT" ' + { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == expected) reply = 1 + if (reply && index(line, ready) != 0) found = 1 + } + END { exit found ? 0 : 1 } + ' +} for _ in {1..180}; do if has_exact_reply; then reply_seen=1 @@ -116,6 +132,24 @@ for _ in {1..180}; do sleep 1 done +if [[ "$reply_seen" = 1 && -n "$NEMOCLAW_LAUNCH_READY_TEXT" ]]; then + post_reply_ready_seen=0 + for _ in {1..60}; do + if has_post_reply_ready; then + post_reply_ready_seen=1 + break + fi + if ! kill -0 "$session_pid" 2>/dev/null; then + break + fi + sleep 1 + done + if [[ "$post_reply_ready_seen" != 1 ]]; then + echo "launch did not return to the expected TUI state after the reply" >&2 + exit 1 + fi +fi + if [[ -n "$NEMOCLAW_LAUNCH_EXIT_COMMAND" ]]; then printf '%s\r' "$NEMOCLAW_LAUNCH_EXIT_COMMAND" >&3 else diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index d0e827eb346..7f09b02b82d 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -112,7 +112,7 @@ it.runIf(process.platform === "linux")( ); it.runIf(process.platform !== "win32")( - "waits for OpenClaw gateway readiness before sending the launch prompt (#7230)", + "waits for OpenClaw idle before the prompt and again before the exit command (#9023)", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-launch-turn-ready-")); const scriptStub = join(fixtureRoot, "script"); @@ -136,6 +136,11 @@ fi printf 'gateway connected | idle\n' | tee -a "$capture" IFS= read -r -d $'\r' _ printf 'PONG\n' | tee -a "$capture" +if IFS= read -r -t 1 -d $'\r' _; then + echo "exit arrived before post-reply readiness" >&2 + exit 1 +fi +printf 'gateway connected | idle\n' | tee -a "$capture" IFS= read -r -d $'\r' exit_command [[ "$exit_command" == "/exit" ]] exit 0