@@ -6,12 +6,113 @@ import type { ConfigObject } from "../security/credential-filter";
66import type { ShieldsAuditEntry } from "../shields/audit" ;
77import { type InferenceApi , readOpenClawPrimaryRouteApi } from "./inference-route-api" ;
88import { InferenceSetError } from "./inference-set-error" ;
9+ import {
10+ runPortableOpenClawPairingApproval ,
11+ runPortableOpenClawPairingRequestProducer ,
12+ type PortableOpenClawPairingApprovalReceipt ,
13+ } from "./sandbox/auto-pair-approval" ;
914import type { GatewayRestartResult } from "./sandbox/gateway-restart" ;
15+ import {
16+ observeOpenClawPairingSettlement ,
17+ type OpenClawPairingSettlementObservation ,
18+ } from "./sandbox/launch-readiness/openclaw-pairing-qualification" ;
19+
20+ export type InferenceSetOpenClawPairingTarget = {
21+ readonly sandboxName : string ;
22+ readonly gatewayName : string ;
23+ readonly openclawVersion : string ;
24+ readonly stateDirectory : string ;
25+ } ;
26+
27+ export type InferenceSetOpenClawPairingFailureLayer =
28+ | "initial-state-unavailable"
29+ | "final-state-unavailable"
30+ | "final-state-unsettled"
31+ | "pairing-operation-failed"
32+ | "pairing-target-unavailable"
33+ | `approval-${Exclude < PortableOpenClawPairingApprovalReceipt , "approved" > } `;
34+
35+ export type InferenceSetOpenClawPairingResult =
36+ | { readonly ok : true }
37+ | { readonly ok : false ; readonly failureLayer : InferenceSetOpenClawPairingFailureLayer } ;
38+
39+ export type InferenceSetOpenClawPairingDeps = {
40+ observePairing : (
41+ target : InferenceSetOpenClawPairingTarget ,
42+ ) => OpenClawPairingSettlementObservation ;
43+ publishScopeRequest : ( target : InferenceSetOpenClawPairingTarget ) => void ;
44+ approveScopeRequest : (
45+ target : InferenceSetOpenClawPairingTarget ,
46+ deviceIdentitySha256 : string ,
47+ ) => PortableOpenClawPairingApprovalReceipt ;
48+ } ;
49+
50+ const defaultOpenClawPairingDeps : InferenceSetOpenClawPairingDeps = {
51+ observePairing : ( target ) =>
52+ observeOpenClawPairingSettlement (
53+ target . sandboxName ,
54+ target . gatewayName ,
55+ target . openclawVersion ,
56+ target . stateDirectory ,
57+ ) ,
58+ publishScopeRequest : ( target ) =>
59+ runPortableOpenClawPairingRequestProducer ( target . sandboxName , target . gatewayName ) ,
60+ approveScopeRequest : ( target , deviceIdentitySha256 ) =>
61+ runPortableOpenClawPairingApproval (
62+ target . sandboxName ,
63+ target . gatewayName ,
64+ deviceIdentitySha256 ,
65+ ) ,
66+ } ;
67+
68+ /**
69+ * Reconcile the local OpenClaw CLI device after an inference route change.
70+ *
71+ * The state observer accepts only the exact operator pairing/read/write projection.
72+ * The request producer runs only for a pairing-only device. The approval helper then
73+ * binds one allowlisted request to the observed device identity. A final state read,
74+ * not command output, decides whether the inference switch can report success.
75+ */
76+ export function settleInferenceSetOpenClawPairing (
77+ target : InferenceSetOpenClawPairingTarget ,
78+ deps : InferenceSetOpenClawPairingDeps = defaultOpenClawPairingDeps ,
79+ ) : InferenceSetOpenClawPairingResult {
80+ let initial : OpenClawPairingSettlementObservation ;
81+ try {
82+ initial = deps . observePairing ( target ) ;
83+ } catch {
84+ return { ok : false , failureLayer : "initial-state-unavailable" } ;
85+ }
86+ if ( initial . state === "settled" ) return { ok : true } ;
87+
88+ let approval : PortableOpenClawPairingApprovalReceipt ;
89+ try {
90+ deps . publishScopeRequest ( target ) ;
91+ approval = deps . approveScopeRequest ( target , initial . deviceIdentitySha256 ) ;
92+ } catch {
93+ return { ok : false , failureLayer : "pairing-operation-failed" } ;
94+ }
95+
96+ let final : OpenClawPairingSettlementObservation ;
97+ try {
98+ final = deps . observePairing ( target ) ;
99+ } catch {
100+ return { ok : false , failureLayer : "final-state-unavailable" } ;
101+ }
102+ if ( final . state === "settled" ) return { ok : true } ;
103+ return {
104+ ok : false ,
105+ failureLayer : approval === "approved" ? "final-state-unsettled" : `approval-${ approval } ` ,
106+ } ;
107+ }
10108
11109export interface InferenceGatewayRestartDeps {
12110 appendAuditEntry : ( entry : ShieldsAuditEntry ) => void ;
13111 log : ( message : string ) => void ;
14112 restartSandboxGateway : ( sandboxName : string ) => GatewayRestartResult ;
113+ settleOpenClawPairing : (
114+ target : InferenceSetOpenClawPairingTarget ,
115+ ) => InferenceSetOpenClawPairingResult ;
15116}
16117
17118interface InferenceResultForGateway {
@@ -33,17 +134,25 @@ interface InferenceResultForGateway {
33134export interface InferenceMutation < T extends InferenceResultForGateway > {
34135 result : T ;
35136 openClawGatewayRestartRequired : boolean ;
137+ openClawPairing :
138+ | { readonly state : "not-required" }
139+ | { readonly state : "required" ; readonly target : InferenceSetOpenClawPairingTarget }
140+ | { readonly state : "target-unavailable" } ;
36141}
37142
38- // SOURCE_OF_TRUTH_REVIEW (cross-family OpenClaw restart ; gateway regression
39- // #4504, OpenClaw 2026.6.10 adopted in #5595): that version hot-reloads model
143+ // SOURCE_OF_TRUTH_REVIEW (OpenClaw post-switch convergence ; gateway regressions
144+ // #4504 and #9527): OpenClaw 2026.6.10 adopted in #5595 hot-reloads model
40145// identity but retains request shaping when the API family changes. NemoClaw
41- // therefore restarts only after the route, config, and integrity hash commit,
42- // and outside the config transition lock. Unit coverage proves restart,
43- // no-restart, redaction, audit-failure, and post-commit recovery behavior;
44- // openclaw-inference-switch live coverage proves gateway health and forwarding.
45- // Remove this coordination when the minimum supported OpenClaw hot-reloads
46- // request shaping across API-family changes, keeping the tests until then.
146+ // restarts only after the route, config, and integrity hash commit. Every
147+ // changed OpenClaw route then requires exact local device-scope convergence
148+ // before the command reports success. Both operations run outside the config
149+ // transition lock and inside the sandbox lifecycle lock. Unit coverage proves
150+ // restart, no-restart, scope convergence, redaction, audit-failure, and
151+ // post-commit recovery behavior. The openclaw-inference-switch live target
152+ // proves gateway health and forwarding. Remove the restart when the minimum
153+ // supported OpenClaw hot-reloads request shaping across API-family changes.
154+ // Remove pairing settlement when OpenClaw no longer requires a separate
155+ // allowlisted device-scope upgrade after a route change.
47156
48157export function defaultInferenceGatewayRestart ( sandboxName : string ) : GatewayRestartResult {
49158 const recovery : typeof import ( "./sandbox/process-recovery" ) = require ( "./sandbox/process-recovery" ) ;
@@ -78,18 +187,21 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
78187 agentName : string ;
79188 configChanged : boolean ;
80189 nextApi : string ;
190+ openClawPairingTarget ?: InferenceSetOpenClawPairingTarget ;
81191 previousApi : InferenceApi | null ;
82192 result : T ;
83193 } ,
84194 deps : Pick < InferenceGatewayRestartDeps , "appendAuditEntry" | "log" > ,
85195) : InferenceMutation < T > {
86- const { agentName, configChanged, nextApi, previousApi, result } = options ;
196+ const { agentName, configChanged, nextApi, openClawPairingTarget , previousApi, result } = options ;
87197 const openClawGatewayRestartRequired =
88198 agentName === "openclaw" &&
89199 configChanged &&
90200 result . inSandboxConfigSynced &&
91201 previousApi !== null &&
92202 previousApi !== nextApi ;
203+ const openClawPairingConvergenceRequired =
204+ agentName === "openclaw" && configChanged && result . inSandboxConfigSynced ;
93205
94206 const auditEntry : ShieldsAuditEntry = {
95207 action : "inference_set" ,
@@ -99,11 +211,13 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
99211 ! result . inSandboxConfigSynced
100212 ? " (in-sandbox sync incomplete)"
101213 : openClawGatewayRestartRequired
102- ? " (gateway restart pending)"
103- : ""
214+ ? " (gateway restart and pairing convergence pending)"
215+ : openClawPairingConvergenceRequired
216+ ? " (pairing convergence pending)"
217+ : ""
104218 } `,
105219 } ;
106- if ( openClawGatewayRestartRequired ) {
220+ if ( openClawGatewayRestartRequired || openClawPairingConvergenceRequired ) {
107221 appendPostCommitInferenceAudit ( deps , auditEntry ) ;
108222 } else {
109223 deps . appendAuditEntry ( auditEntry ) ;
@@ -112,51 +226,92 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
112226 // A Hermes switch whose Web Dashboard profile did not converge is not fully
113227 // applied, so withhold the success line (the caller already warned) (#6893).
114228 const hermesDashboardStale = agentName === "hermes" && result . dashboardConverged === false ;
115- if ( result . inSandboxConfigSynced && ! openClawGatewayRestartRequired && ! hermesDashboardStale ) {
229+ if (
230+ result . inSandboxConfigSynced &&
231+ ! openClawGatewayRestartRequired &&
232+ ! openClawPairingConvergenceRequired &&
233+ ! hermesDashboardStale
234+ ) {
116235 deps . log (
117236 agentName === "hermes"
118237 ? ` Inference route synced for '${ result . sandboxName } ': ${ result . model } `
119238 : ` Inference route synced for '${ result . sandboxName } ': ${ result . primaryModelRef } ` ,
120239 ) ;
121240 }
122241
123- return { result, openClawGatewayRestartRequired } ;
242+ return {
243+ result,
244+ openClawGatewayRestartRequired,
245+ openClawPairing : ! openClawPairingConvergenceRequired
246+ ? { state : "not-required" }
247+ : openClawPairingTarget
248+ ? { state : "required" , target : openClawPairingTarget }
249+ : { state : "target-unavailable" } ,
250+ } ;
124251}
125252
126- export function completeInferenceGatewayRestart < T extends InferenceResultForGateway > (
253+ export function completeInferencePostCommit < T extends InferenceResultForGateway > (
127254 mutation : InferenceMutation < T > ,
128255 deps : InferenceGatewayRestartDeps ,
129256) : void {
130- if ( ! mutation . openClawGatewayRestartRequired ) return ;
131-
132257 const { result } = mutation ;
133- deps . log (
134- ` Restarting the OpenClaw gateway in '${ result . sandboxName } ' to apply the new inference API family...` ,
135- ) ;
136- let restartFailure : string | null = null ;
137- try {
138- const restart = deps . restartSandboxGateway ( result . sandboxName ) ;
139- if ( ! restart . ok ) restartFailure = restart . failureLayer ;
140- } catch {
141- restartFailure = "restart exception" ;
258+ if ( mutation . openClawGatewayRestartRequired ) {
259+ deps . log (
260+ ` Restarting the OpenClaw gateway in '${ result . sandboxName } ' to apply the new inference API family...` ,
261+ ) ;
262+ let restartFailure : string | null = null ;
263+ try {
264+ const restart = deps . restartSandboxGateway ( result . sandboxName ) ;
265+ if ( ! restart . ok ) restartFailure = restart . failureLayer ;
266+ } catch {
267+ restartFailure = "restart exception" ;
268+ }
269+ if ( restartFailure ) {
270+ appendPostCommitInferenceAudit ( deps , {
271+ action : "inference_set" ,
272+ sandbox : result . sandboxName ,
273+ timestamp : new Date ( ) . toISOString ( ) ,
274+ reason : `inference set openclaw:${ result . provider } :${ result . model } (config committed; gateway restart failed: ${ restartFailure } )` ,
275+ } ) ;
276+ throw new InferenceSetError (
277+ `Inference route and config were updated for '${ result . sandboxName } ', but the managed OpenClaw gateway restart/recovery did not complete successfully. ` +
278+ `The committed route was not rolled back. Retry with '${ CLI_NAME } ${ result . sandboxName } gateway restart'.` ,
279+ ) ;
280+ }
281+ }
282+ const pairingMutation = mutation . openClawPairing ;
283+ if ( pairingMutation . state === "not-required" ) return ;
284+ let pairing : InferenceSetOpenClawPairingResult ;
285+ if ( pairingMutation . state === "target-unavailable" ) {
286+ pairing = { ok : false , failureLayer : "pairing-target-unavailable" } ;
287+ } else {
288+ try {
289+ pairing = deps . settleOpenClawPairing ( pairingMutation . target ) ;
290+ } catch {
291+ pairing = { ok : false , failureLayer : "pairing-operation-failed" } ;
292+ }
142293 }
143- if ( restartFailure ) {
294+ if ( ! pairing . ok ) {
144295 appendPostCommitInferenceAudit ( deps , {
145296 action : "inference_set" ,
146297 sandbox : result . sandboxName ,
147298 timestamp : new Date ( ) . toISOString ( ) ,
148- reason : `inference set openclaw:${ result . provider } :${ result . model } (config committed; gateway restart failed: ${ restartFailure } )` ,
299+ reason : `inference set openclaw:${ result . provider } :${ result . model } (config committed; ${
300+ mutation . openClawGatewayRestartRequired ? "gateway restart completed; " : ""
301+ } pairing convergence failed: ${ pairing . failureLayer } )`,
149302 } ) ;
150303 throw new InferenceSetError (
151- `Inference route and config were updated for '${ result . sandboxName } ', but the managed OpenClaw gateway restart/recovery did not complete successfully . ` +
152- `The committed route was not rolled back. Retry with '${ CLI_NAME } ${ result . sandboxName } gateway restart' .` ,
304+ `Inference route and config were updated for '${ result . sandboxName } ', but OpenClaw gateway pairing did not converge ( ${ pairing . failureLayer } ) . ` +
305+ `The committed route was not rolled back. Run '${ CLI_NAME } ${ result . sandboxName } doctor --fix', then retry the agent turn .` ,
153306 ) ;
154307 }
155308 appendPostCommitInferenceAudit ( deps , {
156309 action : "inference_set" ,
157310 sandbox : result . sandboxName ,
158311 timestamp : new Date ( ) . toISOString ( ) ,
159- reason : `inference set openclaw:${ result . provider } :${ result . model } (gateway restart completed)` ,
312+ reason : `inference set openclaw:${ result . provider } :${ result . model } (${
313+ mutation . openClawGatewayRestartRequired ? "gateway restart and " : ""
314+ } pairing convergence completed)`,
160315 } ) ;
161316 deps . log ( ` Inference route synced for '${ result . sandboxName } ': ${ result . primaryModelRef } ` ) ;
162317}
0 commit comments