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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions src/lib/actions/sandbox/launch-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ import {
} from "./launch-readiness/health";
import {
observeOpenClawPairingQualification,
observeOpenClawPairingRepairSettlement,
observeOpenClawPairingSettlement,
OpenClawPairingQualificationError,
type OpenClawPairingRepairObservation,
type OpenClawPairingSettlementObservation,
} from "./launch-readiness/openclaw-pairing-qualification";

Expand Down Expand Up @@ -113,6 +115,7 @@ export interface LaunchReadinessDeps extends LaunchReadinessHealthDeps {
fenceLease?: typeof fenceLaunchReadinessLease;
publishLease?: typeof publishLaunchReadinessLease;
observeOpenClawPairingQualification?: typeof observeOpenClawPairingQualification;
observeOpenClawPairingRepairSettlement?: typeof observeOpenClawPairingRepairSettlement;
observeOpenClawPairingSettlement?: typeof observeOpenClawPairingSettlement;
runPortablePairingProducer?: typeof runPortableOpenClawPairingRequestProducer;
runPortablePairingApproval?: typeof runPortableOpenClawPairingApproval;
Expand Down Expand Up @@ -966,8 +969,9 @@ export function resolveOrdinaryOpenClawPairingTarget(

/**
* Settle current Portable OpenClaw pairing under the lifecycle then owning
* gateway-route locks. An ambiguous approval receives one strict final
* observation and never another write.
* gateway-route locks. The repair observation admits only one canonical local
* write request; a final strict observation requires that request to be gone.
* An ambiguous approval receives one final observation and never another write.
*/
export async function settlePortableOpenClawPairing(
sandboxName: string,
Expand All @@ -981,7 +985,10 @@ export async function settlePortableOpenClawPairing(
const updateSandbox = deps.updateSandbox ?? registry.updateSandbox;
const withSandboxLock = deps.withSandboxLock ?? withSandboxMutationLock;
const withGatewayLock = deps.withGatewayLock ?? withGatewayRouteMutationLock;
const observePairing = deps.observeOpenClawPairingSettlement ?? observeOpenClawPairingSettlement;
const observeRepairPairing =
deps.observeOpenClawPairingRepairSettlement ?? observeOpenClawPairingRepairSettlement;
const observeSettledPairing =
deps.observeOpenClawPairingSettlement ?? observeOpenClawPairingSettlement;
const runProducer = deps.runPortablePairingProducer ?? runPortableOpenClawPairingRequestProducer;
const runApproval = deps.runPortablePairingApproval ?? runPortableOpenClawPairingApproval;

Expand Down Expand Up @@ -1060,9 +1067,9 @@ export async function settlePortableOpenClawPairing(
return incompletePortablePairing("portable-runtime-identity-invalid");
}

let first: OpenClawPairingSettlementObservation;
let first: OpenClawPairingRepairObservation;
try {
first = observePairing(
first = observeRepairPairing(
sandboxName,
target.gatewayName,
target.version,
Expand All @@ -1073,17 +1080,21 @@ export async function settlePortableOpenClawPairing(
}
if (first.state === "settled") return { kind: "settled" };

runProducer(sandboxName, target.gatewayName);
// A canonical pending transition is the producer's completed output.
if (first.state === "pairing-only") {
runProducer(sandboxName, target.gatewayName);
}
runApproval(sandboxName, target.gatewayName, first.deviceIdentitySha256);

try {
const final = observePairing(
const final: OpenClawPairingSettlementObservation = observeSettledPairing(
sandboxName,
target.gatewayName,
target.version,
target.stateDirectory,
);
return final.state === "settled"
return final.state === "settled" &&
final.deviceIdentitySha256 === first.deviceIdentitySha256
? { kind: "settled" }
: incompletePortablePairing("portable-pairing-incomplete");
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ import {
import {
buildOpenClawPairingObservationScript,
observeOpenClawPairingQualification,
observeOpenClawPairingRepairSettlement,
observeOpenClawPairingSettlement,
observeOrdinaryOpenClawPairingSettlement,
OPENCLAW_PAIRING_REQUEST_SCOPES,
OPENCLAW_PAIRING_REQUIRED_SCOPES,
parseOpenClawPairingObservation,
parseOpenClawPairingRepairObservation,
parseOpenClawPairingSettlementObservation,
} from "./openclaw-pairing-qualification";

Expand Down Expand Up @@ -179,6 +181,20 @@ describe("OpenClaw launch-readiness pairing qualification", () => {
);
}

function observeRepairSettlement(approvalPolicy = POLICY) {
return observeOpenClawPairingRepairSettlement(
"alpha",
"nemoclaw-8080",
"2026.7.1",
stateDirectory,
{
getOpenshellBinary: () => "openshell",
readApprovalPolicy: () => approvalPolicy,
spawnSync: localScriptSpawn as typeof spawnSync,
},
);
}

function writePairingOnlyState(): void {
const pairedPath = path.join(stateDirectory, "devices", "paired.json");
const authPath = path.join(stateDirectory, "identity", "device-auth.json");
Expand Down Expand Up @@ -305,9 +321,10 @@ describe("OpenClaw launch-readiness pairing qualification", () => {
expect(() => observeOrdinarySettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
expect(() => observeSettlement()).toThrow(
expect(() => observeRepairSettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
expect(() => observeSettlement()).toThrow("OpenClaw pairing qualification is unavailable");

writeJson(path.join(stateDirectory, "devices", "pending.json"), {
related: {
Expand All @@ -321,6 +338,9 @@ describe("OpenClaw launch-readiness pairing qualification", () => {
expect(() => observeOrdinarySettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
expect(() => observeRepairSettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
});

it("observes pairing-only state while one canonical scope upgrade awaits approval (#9817)", () => {
Expand All @@ -343,9 +363,11 @@ describe("OpenClaw launch-readiness pairing qualification", () => {
state: "pairing-only",
deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(() => observeSettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
expect(observeRepairSettlement()).toEqual({
state: "pairing-pending",
deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(() => observeSettlement()).toThrow("OpenClaw pairing qualification is unavailable");

writeJson(path.join(stateDirectory, "devices", "pending.json"), {
first: {
Expand All @@ -370,6 +392,9 @@ describe("OpenClaw launch-readiness pairing qualification", () => {
expect(() => observeOrdinarySettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
expect(() => observeRepairSettlement()).toThrow(
"OpenClaw pairing qualification is unavailable",
);
});

it("qualifies the persisted result of the complete canonical approval transition (#9023)", () => {
Expand Down Expand Up @@ -845,4 +870,18 @@ process.stdout.write("{}\\n");
),
).toBeNull();
});

it("keeps canonical pending state outside strict Portable settlement (#9817)", () => {
const digest = "a".repeat(64);
const output = `__NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT__=${JSON.stringify({
state: "pairing-pending",
deviceIdentitySha256: digest,
})}\n`;

expect(parseOpenClawPairingRepairObservation(output)).toEqual({
state: "pairing-pending",
deviceIdentitySha256: digest,
});
expect(parseOpenClawPairingSettlementObservation(output)).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export type OpenClawPairingSettlementObservation = {
readonly deviceIdentitySha256: string;
};

export type OpenClawPairingRepairObservation = {
readonly state: "pairing-only" | "pairing-pending" | "settled";
readonly deviceIdentitySha256: string;
};

interface ObservationProjection {
deviceIdentitySha256: string;
pairingStateSha256: string;
Expand Down Expand Up @@ -102,9 +107,7 @@ export function parseOpenClawPairingObservation(output: string): ObservationProj
return record as unknown as ObservationProjection;
}

export function parseOpenClawPairingSettlementObservation(
output: string,
): OpenClawPairingSettlementObservation | null {
function parseOpenClawPairingSettlementRecord(output: string): Record<string, unknown> | null {
const lines = output.trimEnd().split(/\r?\n/);
const markerLines = lines.filter((line) => line.startsWith(SETTLEMENT_MARKER));
if (markerLines.length !== 1 || lines.at(-1) !== markerLines[0]) return null;
Expand All @@ -119,18 +122,44 @@ export function parseOpenClawPairingSettlementObservation(
if (
!hasExactKeys(record, ["deviceIdentitySha256", "state"]) ||
typeof record.deviceIdentitySha256 !== "string" ||
!SHA256_RE.test(record.deviceIdentitySha256) ||
(record.state !== "pairing-only" && record.state !== "settled")
!SHA256_RE.test(record.deviceIdentitySha256)
) {
return null;
}
return record;
}

export function parseOpenClawPairingSettlementObservation(
output: string,
): OpenClawPairingSettlementObservation | null {
const record = parseOpenClawPairingSettlementRecord(output);
if (!record || (record.state !== "pairing-only" && record.state !== "settled")) return null;
return record as OpenClawPairingSettlementObservation;
}

export function parseOpenClawPairingRepairObservation(
output: string,
): OpenClawPairingRepairObservation | null {
const record = parseOpenClawPairingSettlementRecord(output);
if (
!record ||
(record.state !== "pairing-only" &&
record.state !== "pairing-pending" &&
record.state !== "settled")
) {
return null;
}
return record as OpenClawPairingRepairObservation;
}

export function buildOpenClawPairingObservationScript(
approvalPolicyModuleB64: string,
stateDirectory: string,
mode: "ordinary-settlement" | "qualification" | "settlement" = "qualification",
mode:
| "ordinary-settlement"
| "qualification"
| "repair-settlement"
| "settlement" = "qualification",
): string {
if (!path.posix.isAbsolute(stateDirectory)) {
throw new OpenClawPairingQualificationError();
Expand Down Expand Up @@ -167,7 +196,8 @@ REQUIRED_ROLES = ['operator']
PAIRING_ONLY_SCOPES = ['operator.pairing']
REQUEST_SCOPES = ['operator.pairing', 'operator.write']
TOKEN_SCOPES = ['operator.pairing', 'operator.read', 'operator.write']
ORDINARY_SETTLEMENT = ${mode === "ordinary-settlement" ? "True" : "False"}
ALLOW_CANONICAL_PENDING = ${mode === "ordinary-settlement" || mode === "repair-settlement" ? "True" : "False"}
REPORT_CANONICAL_PENDING = ${mode === "repair-settlement" ? "True" : "False"}
STRICT_SETTLEMENT = ${mode === "settlement" ? "True" : "False"}
ED25519_SPKI_PREFIX = bytes.fromhex('302a300506032b6570032100')
RAW_PUBLIC_KEY_RE = re.compile(r'^[A-Za-z0-9_-]{43}$')
Expand Down Expand Up @@ -486,7 +516,7 @@ try:
or request.get('requestId') != request_id
):
reject()
if ORDINARY_SETTLEMENT:
if ALLOW_CANONICAL_PENDING:
# The startup watcher can publish the canonical write transition
# before finalization observes the pairing-only device. Admit only
# that exact intermediate state so the owning controller can reach
Expand Down Expand Up @@ -540,7 +570,7 @@ try:
and exact_string_set(paired_operator.get('scopes'), PAIRING_ONLY_SCOPES)
and exact_string_set(auth_operator.get('scopes'), PAIRING_ONLY_SCOPES)
)
if ORDINARY_SETTLEMENT and pending and (len(pending) != 1 or not pairing_only):
if ALLOW_CANONICAL_PENDING and pending and (len(pending) != 1 or not pairing_only):
reject()
if not settled and not pairing_only:
reject()
Expand All @@ -551,7 +581,7 @@ try:
}, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()
${
mode !== "qualification"
? "print(MARKER + json.dumps({\n 'deviceIdentitySha256': device_identity_sha256,\n 'state': 'settled' if settled else 'pairing-only',\n }, sort_keys=True, separators=(',', ':')))\n sys.exit(0)"
? "print(MARKER + json.dumps({\n 'deviceIdentitySha256': device_identity_sha256,\n 'state': 'settled' if settled else ('pairing-pending' if REPORT_CANONICAL_PENDING and pending else 'pairing-only'),\n }, sort_keys=True, separators=(',', ':')))\n sys.exit(0)"
: "if not settled:\n reject()"
}

Expand Down Expand Up @@ -608,7 +638,7 @@ function runOpenClawPairingObservation(
gatewayName: string,
openclawVersion: string,
stateDirectory: string,
mode: "ordinary-settlement" | "qualification" | "settlement",
mode: "ordinary-settlement" | "qualification" | "repair-settlement" | "settlement",
execDeps?: Partial<OpenClawPairingQualificationDeps>,
): { readonly output: string; readonly policy: string } {
const approvalPolicy = (execDeps?.readApprovalPolicy ?? readAutoPairApprovalPolicyModule)();
Expand Down Expand Up @@ -674,6 +704,31 @@ export function observeOpenClawPairingSettlement(
}
}

export function observeOpenClawPairingRepairSettlement(
sandboxName: string,
gatewayName: string,
openclawVersion: string,
stateDirectory: string,
execDeps?: Partial<OpenClawPairingQualificationDeps>,
): OpenClawPairingRepairObservation {
try {
const executed = runOpenClawPairingObservation(
sandboxName,
gatewayName,
openclawVersion,
stateDirectory,
"repair-settlement",
execDeps,
);
const observation = parseOpenClawPairingRepairObservation(executed.output);
if (!observation) throw new OpenClawPairingQualificationError();
return observation;
} catch (error) {
if (error instanceof OpenClawPairingQualificationError) throw error;
throw new OpenClawPairingQualificationError();
}
}

export function observeOrdinaryOpenClawPairingSettlement(
sandboxName: string,
gatewayName: string,
Expand Down
Loading
Loading