From 2b3009f718d60861a6796dd066e14fbd33b1fdfb Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Sat, 1 Aug 2026 14:22:51 -0700 Subject: [PATCH] fix(gateway): preserve node invoke dispatch provenance (#117139) * fix(gateway): preserve node invoke dispatch provenance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6381c461-11aa-4700-8648-4da1393e1c59 * fix(gateway): preserve timeout precedence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6381c461-11aa-4700-8648-4da1393e1c59 * fix(gateway): mark wake-path disconnect as not dispatched Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6381c461-11aa-4700-8648-4da1393e1c59 * fix(gateway): mark plugin dispatch after send Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6381c461-11aa-4700-8648-4da1393e1c59 * build(browser): regenerate Copilot runtime Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6381c461-11aa-4700-8648-4da1393e1c59 --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6381c461-11aa-4700-8648-4da1393e1c59 --- .../modules/copilot-runtime.js | 2 +- .../src/client.request-timeout.test.ts | 36 +++++ packages/gateway-client/src/client.ts | 19 ++- .../gateway-client/src/protocol-client.ts | 13 +- src/gateway/node-invoke-plugin-policy.test.ts | 65 ++++++-- src/gateway/node-invoke-plugin-policy.ts | 10 +- src/gateway/node-registry.invoke-stream.ts | 67 +++++--- src/gateway/node-registry.test.ts | 148 ++++++++++++++++-- src/gateway/node-registry.ts | 8 +- .../server-methods/exec-approvals.test.ts | 5 + src/gateway/server-methods/exec-approvals.ts | 12 +- .../server-methods/nodes.helpers.test.ts | 59 +++++++ src/gateway/server-methods/nodes.helpers.ts | 16 +- .../server-methods/nodes.invoke-wake.test.ts | 5 + src/gateway/server-methods/nodes.invoke.ts | 50 +++--- src/gateway/watch-node-http.test.ts | 9 +- 16 files changed, 443 insertions(+), 81 deletions(-) create mode 100644 packages/gateway-client/src/client.request-timeout.test.ts create mode 100644 src/gateway/server-methods/nodes.helpers.test.ts diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.js b/extensions/browser/chrome-extension/modules/copilot-runtime.js index 847f54416cdd..0bb848756a49 100644 --- a/extensions/browser/chrome-extension/modules/copilot-runtime.js +++ b/extensions/browser/chrome-extension/modules/copilot-runtime.js @@ -1 +1 @@ -function normalizeDeviceMetadataForAuth(value){if(typeof value!="string")return"";let trimmed=value.trim();return trimmed?trimmed.replace(/[A-Z]/g,char=>String.fromCharCode(char.charCodeAt(0)+32)):""}function buildDeviceAuthPayloadV3(params){let scopes=params.scopes.join(","),token=params.token??"",platform=normalizeDeviceMetadataForAuth(params.platform),deviceFamily=normalizeDeviceMetadataForAuth(params.deviceFamily);return["v3",params.deviceId,params.clientId,params.clientMode,params.role,scopes,String(params.signedAtMs),token,params.nonce,platform,deviceFamily].join("|")}function normalized(value){return typeof value=="string"&&value.trim()||void 0}function selectGatewayConnectAuth(params){let authToken=normalized(params.token),bootstrapToken=normalized(params.bootstrapToken),explicitDeviceToken=normalized(params.deviceToken),authPassword=normalized(params.password),storedToken=normalized(params.storedToken),stored={storedToken,storedScopes:params.storedScopes};if(params.preferBootstrapToken&&bootstrapToken)return{authBootstrapToken:bootstrapToken,authPassword,...stored};let useRetryToken=params.pendingDeviceTokenRetry===!0&&!explicitDeviceToken&&!!(authToken&&storedToken&¶ms.trustedDeviceTokenRetry),resolvedDeviceToken=explicitDeviceToken??(useRetryToken||!(authToken||authPassword)&&(!bootstrapToken||storedToken)?storedToken:void 0),usingStoredDeviceToken=!!(resolvedDeviceToken&&!explicitDeviceToken&&storedToken)&&resolvedDeviceToken===storedToken,selectedToken=authToken??resolvedDeviceToken,authBootstrapToken=!authToken&&!resolvedDeviceToken&&!authPassword?bootstrapToken:void 0;return{authToken:selectedToken,authBootstrapToken,authDeviceToken:useRetryToken?storedToken:void 0,authPassword,authApprovalRuntimeToken:normalized(params.approvalRuntimeToken),authAgentRuntimeIdentityToken:normalized(params.agentRuntimeIdentityToken),signatureToken:selectedToken??authBootstrapToken,resolvedDeviceToken,usingStoredDeviceToken,...stored}}function buildGatewayConnectAuth(selected){let auth={token:selected.authToken,bootstrapToken:selected.authBootstrapToken,deviceToken:selected.authDeviceToken??selected.resolvedDeviceToken,password:selected.authPassword,approvalRuntimeToken:selected.authApprovalRuntimeToken,agentRuntimeIdentityToken:selected.authAgentRuntimeIdentityToken};return Object.values(auth).some(Boolean)?auth:void 0}function resolveGatewayConnectScopes(params){return params.requestedScopes??(params.usingStoredDeviceToken&¶ms.storedScopes?.length?params.storedScopes:[...params.defaultScopes])}var GatewayBrowserDeviceAuthLifecycle=class{constructor(deps){this.deps=deps}async buildPlan(params){let identity=await this.deps.loadIdentity(),stored=identity?await this.deps.tokenStore.load({clientId:params.client.id,deviceId:identity.deviceId,role:params.role}):null,storedValue=stored?.token,selectedAuth=selectGatewayConnectAuth({token:params.token,bootstrapToken:params.bootstrapToken,password:params.password,storedToken:storedValue,storedScopes:stored?.scopes,pendingDeviceTokenRetry:params.pendingDeviceTokenRetry,trustedDeviceTokenRetry:params.trustedDeviceTokenRetry,preferBootstrapToken:params.preferBootstrapToken}),{usingStoredDeviceToken}=selectedAuth,scopes=resolveGatewayConnectScopes({requestedScopes:selectedAuth.authBootstrapToken&¶ms.bootstrapScopes?[...params.bootstrapScopes]:void 0,usingStoredDeviceToken,storedScopes:selectedAuth.storedScopes,defaultScopes:params.defaultScopes});if(!identity)return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth)};let signedAtMs=params.challengeTs===void 0?this.deps.nowMs?.()??Date.now():params.challengeTs;if(typeof signedAtMs!="number"||!Number.isSafeInteger(signedAtMs)||signedAtMs<0)throw new Error("gateway connect challenge timestamp invalid");let nonce=params.nonce??"",{authBootstrapToken:primary,signatureToken:signed}=selectedAuth,token=null;primary?token=primary:signed&&(token=signed);let payload=buildDeviceAuthPayloadV3({deviceId:identity.deviceId,clientId:params.client.id,clientMode:params.client.mode,role:params.role,scopes,signedAtMs,token,nonce,platform:params.client.platform,deviceFamily:params.client.deviceFamily});return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth),device:{id:identity.deviceId,publicKey:identity.publicKey,signature:await identity.sign(payload),signedAt:signedAtMs,nonce}}}async acceptHello(hello,plan){let token=hello.auth?.deviceToken?.trim();!token||!plan.identity||await this.deps.tokenStore.store({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:hello.auth?.role??plan.role,token,scopes:hello.auth?.scopes??[]})}async clearStoredToken(plan){plan.identity&&await this.deps.tokenStore.clear({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:plan.role})}};function isRecord(value){return!!value&&typeof value=="object"&&!Array.isArray(value)}function isNonEmptyString(value){return typeof value=="string"&&value.length>0}function isNonNegativeInteger(value){return typeof value=="number"&&Number.isInteger(value)&&value>=0}function isGatewayErrorShape(value){return!isRecord(value)||!isNonEmptyString(value.code)||!isNonEmptyString(value.message)||value.retryable!==void 0&&typeof value.retryable!="boolean"?!1:value.retryAfterMs===void 0||isNonNegativeInteger(value.retryAfterMs)}function isGatewayEventFrame(value){return!isRecord(value)||value.type!=="event"||!isNonEmptyString(value.event)?!1:value.seq===void 0||isNonNegativeInteger(value.seq)}function isGatewayResponseFrame(value){return!isRecord(value)||value.type!=="res"||!isNonEmptyString(value.id)||typeof value.ok!="boolean"?!1:value.error===void 0||isGatewayErrorShape(value.error)}function computeBackoff(policy,attempt){let base=Math.min(policy.maxMs,policy.initialMs*policy.factor**Math.max(attempt-1,0)),jitter=base*policy.jitter*Math.random();return Math.min(policy.maxMs,Math.round(base+jitter))}async function sleepWithAbort(ms,abortSignal,options={}){if(!Number.isFinite(ms)||ms<=0)return;let delayMs=Math.min(Math.max(Math.floor(ms),1),2147e6);await new Promise((resolve,reject)=>{let settled=!1,timer=null,cleanup=()=>abortSignal?.removeEventListener("abort",onAbort),onAbort=()=>{settled||(settled=!0,timer&&clearTimeout(timer),timer=null,cleanup(),reject(new Error("aborted",{cause:abortSignal?.reason??new Error("aborted")})))};if(abortSignal?.addEventListener("abort",onAbort,{once:!0}),abortSignal?.aborted){onAbort();return}timer=setTimeout(()=>{settled=!0,cleanup(),timer=null,resolve()},delayMs),options.ref===!1&&timer.unref?.(),abortSignal?.aborted&&onAbort()})}var RetrySupervisor=class{constructor(policy,maxAttempts=Number.POSITIVE_INFINITY){this.policy=policy;this.maxAttempts=maxAttempts;this.attempts=0;this.initialMs=policy.initialMs}reset(initialMs=this.policy.initialMs){this.cancel(),this.attempts=0,this.initialMs=initialMs,this.nextDelayOverrideMs=void 0}cancel(reason=new Error("retry cancelled")){this.pendingAbort?.abort(reason),this.pendingAbort=void 0}next(abortSignal){let override=this.nextDelayOverrideMs;if(this.nextDelayOverrideMs=void 0,override===void 0&&++this.attempts>Math.ceil(this.maxAttempts))return;let attempt=Math.max(this.attempts,1),delayMs=override??computeBackoff({...this.policy,initialMs:this.initialMs},attempt);this.cancel();let pendingAbort=new AbortController;return this.pendingAbort=pendingAbort,{attempt,delayMs,signal:abortSignal?AbortSignal.any([pendingAbort.signal,abortSignal]):pendingAbort.signal}}},DEFAULT_RETRY_CONFIG={attempts:3,minDelayMs:300,maxDelayMs:3e4,jitter:0},defaultSleep=ms=>new Promise(resolve=>{setTimeout(resolve,ms)});function asFiniteNumber(value){return typeof value=="number"&&Number.isFinite(value)?value:void 0}function clampNumber(value,fallback,min,max){let next=asFiniteNumber(value);return next===void 0?fallback:Math.min(Math.max(next,min??Number.NEGATIVE_INFINITY),max??Number.POSITIVE_INFINITY)}function resolveAttemptCount(value,fallback){return Math.max(1,Math.round(asFiniteNumber(value)??fallback))}function resolveRetryDelayMs(value){let finite=value===Number.POSITIVE_INFINITY?2147e6:asFiniteNumber(value)??0;return Math.min(Math.max(Math.round(finite),0),2147e6)}function resolveJitterConfig(value,fallback){if(value==="full")return"full";let fraction=asFiniteNumber(value);return fraction===void 0?fallback:Math.min(Math.max(fraction,0),1)}function resolveRetryConfig(defaults=DEFAULT_RETRY_CONFIG,overrides){let attempts=resolveAttemptCount(overrides?.attempts,defaults.attempts),minDelayMs=resolveRetryDelayMs(clampNumber(overrides?.minDelayMs,defaults.minDelayMs,0)),maxDelayMs=Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(overrides?.maxDelayMs,defaults.maxDelayMs,0)));return{attempts,minDelayMs,maxDelayMs,jitter:resolveJitterConfig(overrides?.jitter,defaults.jitter)}}function applyJitter(delayMs,jitter,mode,random){if(jitter==="full")return mode==="symmetric"?Math.max(0,Math.round(delayMs*(.5+random()*.5))):Math.max(0,Math.ceil(delayMs*(1+random())));if(jitter<=0)return mode==="positive"?Math.ceil(delayMs):delayMs;let fraction=random(),offset=mode==="positive"?fraction*jitter:(fraction*2-1)*jitter,raw=delayMs*(1+offset);return Math.max(0,mode==="positive"?Math.ceil(raw):Math.round(raw))}function toRetryError(value,fallbackMessage="Non-Error thrown"){if(value instanceof Error)return value;if(typeof value=="string")return new Error(value);let error=new Error(fallbackMessage,{cause:value});return(typeof value=="object"&&value!==null||typeof value=="function")&&Object.assign(error,value),error}function createRetryRunner(runtime={}){let runtimeSleep=runtime.sleep??defaultSleep,runtimeRandom=runtime.random??Math.random,createFailure=runtime.createFailure??(errors=>toRetryError(errors.at(-1)??new Error("Retry failed")));return async function(fn,attemptsOrOptions=3,initialDelayMs=300){let attemptErrors=[];if(typeof attemptsOrOptions=="number"){let attempts=resolveAttemptCount(attemptsOrOptions,DEFAULT_RETRY_CONFIG.attempts);for(let index=0;index0?resolved.maxDelayMs:Number.POSITIVE_INFINITY,retryAfterMaxDelayMs=options.retryAfterMaxDelayMs===void 0?maxDelayMs:Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(options.retryAfterMaxDelayMs,maxDelayMs,0))),random=options.random??runtimeRandom,sleep=options.sleep??runtimeSleep,shouldRetry=options.shouldRetry??(()=>!0);for(let attempt=1;attempt<=maxAttempts;attempt+=1)try{return await fn()}catch(err2){if(attemptErrors.push(err2),attempt>=maxAttempts||!shouldRetry(err2,attempt))break;let context={attempt,maxAttempts,err:err2,label:options.label},retryAfterMs=options.retryAfterMs?.(err2),hasRetryAfter=typeof retryAfterMs=="number"&&Number.isFinite(retryAfterMs),configuredDelay=typeof options.delayMs=="function"?options.delayMs(context):options.delayMs,resolvedConfiguredDelay=configuredDelay===void 0?void 0:resolveRetryDelayMs(configuredDelay),baseDelay=hasRetryAfter?Math.max(retryAfterMs,minDelayMs):resolvedConfiguredDelay===void 0?minDelayMs*2**(attempt-1):Math.max(resolvedConfiguredDelay,minDelayMs),delayCap=hasRetryAfter?retryAfterMaxDelayMs:maxDelayMs,delay=Math.min(baseDelay,delayCap),canHonorRetryAfter=hasRetryAfter&&(retryAfterMs??0)<=delayCap,wantsPositiveDraw=resolved.jitter==="full"&&!hasRetryAfter||canHonorRetryAfter;delay=applyJitter(delay,resolved.jitter,wantsPositiveDraw?"positive":"symmetric",random),delay=Math.min(Math.max(delay,minDelayMs),delayCap),await options.onRetry?.({...context,delayMs:delay}),delay>0&&await sleep(delay)}throw createFailure(attemptErrors)}}var retryAsync=createRetryRunner();var GatewayEventListeners=class{constructor(){this.listeners=new Map}add(listener){let subscription=this.listeners.get(listener)??{};return this.listeners.set(listener,subscription),()=>{this.listeners.get(listener)===subscription&&this.listeners.delete(listener)}}snapshot(){return[...this.listeners]}isCurrent(listener,subscription){return this.listeners.get(listener)===subscription}};var GatewayProtocolRequestError=class extends Error{constructor(error){super(error.message??"request failed"),this.name="GatewayProtocolRequestError",this.code=error.code??"UNAVAILABLE",this.gatewayCode=this.code,this.details=error.details,this.retryable=error.retryable===!0,this.retryAfterMs=error.retryAfterMs}};var DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS=15e3;function startGatewayConnectTimeout(onTimeout){let timer=setTimeout(onTimeout,DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);return timer.unref?.(),timer}function clearGatewayConnectTimeout(timer){return timer!==null&&clearTimeout(timer),null}var GatewayProtocolClient=class{constructor(opts){this.opts=opts;this.socket=null;this.pending=new Map;this.listeners=new GatewayEventListeners;this.stopped=!0;this.generation=0;this.lastSeq=null;this.connectNonce=null;this.connectSent=!1;this.connectRequestSent=!1;this.handshakeTimer=null;this.reconnectSignal=null;this.socketOpened=!1;this.helloReceived=!1;this.connectTiming=null;this.reconnectSupervisor=new RetrySupervisor({initialMs:opts.reconnect.initialMs,maxMs:opts.reconnect.maxMs,factor:opts.reconnect.multiplier,jitter:0})}get connected(){return this.socket?.isOpen()??!1}get hasPendingRequests(){return this.pending.size>0}get connecting(){return this.connectSent&&!this.helloReceived}get hasUnboundedPendingRequests(){return[...this.pending.values()].some(pending=>pending.unbounded)}start(){this.socket||this.reconnectSignal||(this.stopped=!1,this.reconnectSupervisor.cancel(),this.connect())}stop(){this.stopped=!0,this.clearHandshakeTimer(),this.reconnectSignal=null,this.reconnectSupervisor.reset();let socket=this.socket;socket&&this.opts.notifyStoppedClose&&(this.stoppedSocket={socket,context:this.closeContext()}),this.socket=null,this.connectFailure=void 0,this.connectTiming=null,this.flushRequests(new Error("gateway client stopped")),socket?.close()}request(method,params,options){let socket=this.socket;if(!socket?.isOpen())return Promise.reject(new Error("gateway not connected"));if(typeof method!="string"||method.length===0)return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));let id=this.opts.createRequestId(),timeoutMs=options?.timeoutMs===null?void 0:options?.timeoutMs??this.opts.requestTimeoutMs;return new Promise((resolve,reject)=>{let timeout,pending={resolve:value=>resolve(value),reject,expectFinal:options?.expectFinal===!0,acceptedNotified:!1,onAccepted:options?.onAccepted,unbounded:timeoutMs===void 0,method,startedAtMs:this.nowMs()},onAbort=()=>{this.pending.delete(id),timeout&&clearTimeout(timeout),this.finishRequestTiming(id,pending,!1,"CLIENT_ABORTED"),reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`))},cleanup=()=>{timeout&&clearTimeout(timeout),options?.signal?.removeEventListener("abort",onAbort)};if(options?.signal?.aborted){reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`));return}pending.cleanup=cleanup,timeoutMs!==void 0&&timeoutMs>=0&&(timeout=setTimeout(()=>{this.pending.delete(id),options?.signal?.removeEventListener("abort",onAbort),this.finishRequestTiming(id,pending,!1,"CLIENT_TIMEOUT"),reject(this.opts.createRequestTimeoutError?.(method,timeoutMs)??new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`))},timeoutMs),timeout.unref?.()),options?.signal?.addEventListener("abort",onAbort,{once:!0}),this.pending.set(id,pending);try{socket.send(JSON.stringify({type:"req",id,method,params})),this.invoke("sent",()=>options?.onSent?.())}catch(error){this.pending.delete(id),cleanup(),this.finishRequestTiming(id,pending,!1,"CLIENT_SEND_ERROR"),reject(error instanceof Error?error:new Error(String(error)))}})}addEventListener(listener){return this.listeners.add(listener)}closeSocket(code,reason){this.socket?.close(code,reason)}resetReconnectBackoff(initialMs){this.reconnectSignal=null,this.reconnectSupervisor.reset(initialMs)}recordTiming(phase,generation,plan,detail){let now=this.nowMs(),state=this.connectTiming;!state||state.generation!==generation||(state.hasChallenge||=phase==="challenge",state.usedFallback||=phase==="fallback",this.invoke("connect timing",()=>this.opts.onTiming?.({phase,generation,durationMs:Math.max(0,now-state.startedAtMs),phaseDurationMs:Math.max(0,now-state.lastAtMs),hasChallenge:state.hasChallenge,usedFallback:state.usedFallback,plan,detail})),state.lastAtMs=now,(phase==="hello"||phase==="failed")&&(this.connectTiming=null))}connect(){if(this.stopped)return;let generation=this.generation+1;this.lastSeq=null,this.connectNonce=null,this.connectChallengeTs=void 0,this.connectSent=this.connectRequestSent=!1,this.socketOpened=!1,this.helloReceived=!1,this.connectFailure=void 0;let socket;try{socket=this.opts.createSocket({open:()=>this.handleOpen(socket,generation),message:data=>this.handleMessage(socket,generation,data),close:(code,reason)=>this.handleClose(socket,generation,code,reason),error:error=>this.handleSocketError(socket,generation,error)})}catch(error){let normalized2=error instanceof Error?error:new Error(String(error));if(this.opts.onSocketFactoryError?.(normalized2),this.opts.onConnectError?.(normalized2),this.opts.rethrowSocketFactoryError?.(normalized2))throw normalized2;this.opts.shouldRetrySocketFactoryError?.(normalized2)&&!this.stopped&&!this.socket&&!this.reconnectSignal&&this.scheduleReconnect();return}this.generation=generation,this.socket=socket;let now=this.nowMs();this.connectTiming={generation,startedAtMs:now,lastAtMs:now,hasChallenge:!1,usedFallback:!1}}handleOpen(socket,generation){if(this.isActive(socket,generation)){if(this.socketOpened=!0,this.recordTiming("socket-open",generation),this.connectNonce){this.sendConnect(socket,generation);return}this.armHandshakeTimer(socket,generation)}}armHandshakeTimer(socket,generation){this.clearHandshakeTimer();let armedAt=Date.now();this.handshakeTimer=setTimeout(()=>{if(this.handshakeTimer=null,!this.isActive(socket,generation)||this.connectSent||!socket.isOpen())return;if(this.opts.handshake.mode==="fallback"){this.recordTiming("fallback",generation),this.sendConnect(socket,generation);return}let elapsedMs=Date.now()-armedAt,error=new Error(this.opts.handshake.timeoutMessage?.(elapsedMs)??`gateway connect challenge timeout after ${elapsedMs}ms`);this.opts.onConnectError?.(error),socket.close(1008,"connect challenge timeout")},this.opts.handshake.timeoutMs),this.handshakeTimer.unref?.()}sendConnect(socket,generation){if(!this.isActive(socket,generation)||!socket.isOpen()||this.connectSent)return;this.connectSent=!0,this.clearHandshakeTimer(),this.handshakeTimer=startGatewayConnectTimeout(()=>{this.isActive(socket,generation)&&!this.helloReceived&&socket.close(4e3,"connect timeout")});let planOrPromise;try{planOrPromise=this.opts.buildConnectPlan({nonce:this.connectNonce,challengeTs:this.connectChallengeTs,generation})}catch(error){this.handleConnectPlanError(socket,generation,error);return}if(planOrPromise instanceof Promise){planOrPromise.then(plan=>this.sendConnectPlan(socket,generation,plan)).catch(error=>this.handleConnectPlanError(socket,generation,error));return}this.sendConnectPlan(socket,generation,planOrPromise)}handleConnectPlanError(socket,generation,error){if(!this.isActive(socket,generation))return;let normalized2=error instanceof Error?error:new Error(String(error)),outcome=this.opts.onConnectPlanError?.(normalized2)??{closeCode:1008,closeReason:"connect failed"};this.opts.onConnectError?.(outcome.error??normalized2),outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)}sendConnectPlan(socket,generation,plan){if(!this.isActive(socket,generation)||!socket.isOpen())return;let context={generation,nonce:this.connectNonce,challengeTs:this.connectChallengeTs,plan};this.recordTiming("connect-plan-ready",generation,plan),this.recordTiming("request-sent",generation,plan),this.connectRequestSent=!0,this.request("connect",this.opts.buildConnectParams(plan)).then(hello=>{this.isActive(socket,generation)&&(this.helloReceived=!0,this.clearHandshakeTimer(),this.connectFailure=void 0,this.reconnectSupervisor.reset(),this.recordTiming("hello",generation,plan),this.opts.onConnectHello?.(hello,context),this.invoke("hello",()=>this.opts.onHello?.(hello)))}).catch(error=>{if(!this.isActive(socket,generation))return;let requestError=error instanceof GatewayProtocolRequestError?error:new GatewayProtocolRequestError({message:String(error)}),outcome=this.opts.onConnectFailure?.(requestError,context)??{closeCode:1008,closeReason:"connect failed"};this.connectFailure={error:requestError,reconnectDelayMs:outcome.reconnectDelayMs},outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)})}handleMessage(socket,generation,raw){if(!this.isActive(socket,generation))return;let parsed;try{parsed=JSON.parse(raw)}catch(error){this.opts.onParseError?.(error);return}if(isGatewayEventFrame(parsed)){if(this.opts.onActivity?.(),parsed.event==="connect.challenge"){let payload=parsed.payload,nonce=typeof payload?.nonce=="string"?payload.nonce.trim():"";if(!nonce){if(this.opts.handshake.mode==="require-challenge"){let error=new Error("gateway connect challenge missing nonce");this.opts.onConnectError?.(error),socket.close(1008,"connect challenge missing nonce")}return}this.connectNonce=nonce;let challengeTs=payload?.ts;this.connectChallengeTs=typeof challengeTs=="number"&&Number.isSafeInteger(challengeTs)&&challengeTs>=0?challengeTs:null,this.recordTiming("challenge",generation),this.sendConnect(socket,generation);return}let seq=typeof parsed.seq=="number"?parsed.seq:null;if(seq!==null){if(this.lastSeq!==null&&seq>this.lastSeq+1){let expected=this.lastSeq+1;if(this.invoke("gap",()=>this.opts.onGap?.({expected,received:seq})),!this.isActive(socket,generation))return}this.lastSeq=seq}let listeners=this.listeners.snapshot();this.invoke("event",()=>this.opts.onEvent?.(parsed));for(let[listener,subscription]of listeners){if(!this.isActive(socket,generation))return;this.listeners.isCurrent(listener,subscription)&&this.invoke("event listener",()=>listener(parsed))}return}isGatewayResponseFrame(parsed)&&(this.opts.onActivity?.(),this.handleResponse(parsed))}handleResponse(frame){let pending=this.pending.get(frame.id);if(!pending)return;let status=frame.payload?.status;if(pending.expectFinal&&status==="accepted"){pending.acceptedNotified||(pending.acceptedNotified=!0,this.invoke("accepted",()=>pending.onAccepted?.(frame.payload)));return}if(this.pending.delete(frame.id),pending.cleanup?.(),frame.ok){this.finishRequestTiming(frame.id,pending,!0),pending.resolve(frame.payload);return}this.finishRequestTiming(frame.id,pending,!1,frame.error?.code),pending.reject(this.opts.createRequestError?.(frame.error??{})??new GatewayProtocolRequestError(frame.error??{}))}handleClose(socket,generation,code,reason){if(this.socket!==socket){if(this.stoppedSocket?.socket===socket){let context2={...this.stoppedSocket.context,code,reason};this.stoppedSocket=void 0,this.invoke("close",()=>this.opts.onClose?.(context2,{retry:!1,notify:!0}))}return}this.socket=null,this.clearHandshakeTimer();let context={...this.closeContext(),code,reason,generation};this.connectFailure=void 0;let decision=this.opts.resolveClose(context);this.flushRequests(decision.pendingError??context.connectFailure?.error??new Error(`gateway closed (${code}): ${reason}`)),this.invoke("close",()=>this.opts.onClose?.(context,decision)),decision.retry&&!this.stopped&&this.scheduleReconnect(decision.reconnectDelayMs??context.connectFailure?.reconnectDelayMs)}handleSocketError(socket,generation,error){!this.isActive(socket,generation)||this.connectSent||this.opts.onConnectError?.(error)}flushRequests(error){for(let[id,pending]of this.pending)this.finishRequestTiming(id,pending,!1,"CLIENT_CLOSED"),pending.cleanup?.(),pending.reject(error);this.pending.clear()}finishRequestTiming(id,pending,ok,errorCode){let endedAtMs=this.nowMs();this.invoke("request timing",()=>this.opts.onRequestTiming?.({id,method:pending.method,ok,durationMs:Math.max(0,endedAtMs-pending.startedAtMs),startedAtMs:pending.startedAtMs,endedAtMs,errorCode}))}scheduleReconnect(overrideMs){overrideMs!==void 0&&(this.reconnectSupervisor.nextDelayOverrideMs=overrideMs);let retry=this.reconnectSupervisor.next();retry&&(this.reconnectSignal=retry.signal,sleepWithAbort(retry.delayMs,retry.signal).then(()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null,this.connect())},()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null)}))}closeContext(){return{generation:this.generation,socketOpened:this.socketOpened,helloReceived:this.helloReceived,connectRequestSent:this.connectRequestSent,connectFailure:this.connectFailure}}isActive(socket,generation){return!this.stopped&&this.socket===socket&&this.generation===generation}nowMs(){return this.opts.nowMs?.()??Date.now()}clearHandshakeTimer(){this.handshakeTimer=clearGatewayConnectTimeout(this.handshakeTimer)}invoke(label,callback){try{callback()}catch(error){this.opts.onCallbackError?.(label,error)}}};var GATEWAY_CLIENT_IDS={WEBCHAT_UI:"webchat-ui",CONTROL_UI:"openclaw-control-ui",BROWSER_COPILOT:"openclaw-browser-copilot",TUI:"openclaw-tui",WEBCHAT:"webchat",CLI:"cli",GATEWAY_CLIENT:"gateway-client",MACOS_APP:"openclaw-macos",LINUX_APP:"openclaw-linux",IOS_APP:"openclaw-ios",WATCHOS_APP:"openclaw-watchos",ANDROID_APP:"openclaw-android",NODE_HOST:"node-host",WORKER:"openclaw-worker",TEST:"test",FINGERPRINT:"fingerprint",PROBE:"openclaw-probe"};var GATEWAY_CLIENT_MODES={WEBCHAT:"webchat",CLI:"cli",UI:"ui",BACKEND:"backend",NODE:"node",WORKER:"worker",PROBE:"probe",TEST:"test"},GATEWAY_CLIENT_CAPS={AGENT_KIND:"agent-kind",APPROVALS:"approvals",EXEC_APPROVALS:"exec-approvals",INLINE_WIDGETS:"inline-widgets",RUN_TOOL_BINDINGS:"run-tool-bindings",SESSION_SCOPED_EVENTS:"session-scoped-events",PLUGIN_APPROVALS:"plugin-approvals",TASK_SUGGESTIONS:"task-suggestions",TERMINAL_OFFSET_SEQ:"terminal-offset-seq",TOOL_EVENTS:"tool-events",UI_COMMANDS:"ui-commands"},GATEWAY_CLIENT_ID_SET=new Set(Object.values(GATEWAY_CLIENT_IDS)),GATEWAY_CLIENT_MODE_SET=new Set(Object.values(GATEWAY_CLIENT_MODES));var PROTOCOL_VERSION=4,MIN_CLIENT_PROTOCOL_VERSION=4;/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */var ed25519_CURVE=Object.freeze({p:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,n:0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,h:8n,a:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,d:0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,Gx:0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,Gy:0x6666666666666666666666666666666666666666666666666666666666666658n}),{p:P,n:N,Gx,Gy,a:_a,d:_d,h}=ed25519_CURVE,L=32,captureTrace=(...args)=>{"captureStackTrace"in Error&&typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(...args)},err=(message="")=>{let e=new Error(message);throw captureTrace(e,err),e},isBig=n=>typeof n=="bigint",isStr=s=>typeof s=="string",isBytes=a=>a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"&&"BYTES_PER_ELEMENT"in a&&a.BYTES_PER_ELEMENT===1,abytes=(value,length,title="")=>{let bytes=isBytes(value),len=value?.length,needsLen=length!==void 0;if(!bytes||needsLen&&len!==length){let prefix=title&&`"${title}" `,ofLen=needsLen?` of length ${length}`:"",got=bytes?`length=${len}`:`type=${typeof value}`,msg=prefix+"expected Uint8Array"+ofLen+", got "+got;throw bytes?new RangeError(msg):new TypeError(msg)}return value},u8n=len=>new Uint8Array(len),u8fr=buf=>Uint8Array.from(buf),padh=(n,pad)=>n.toString(16).padStart(pad,"0"),bytesToHex=b=>Array.from(abytes(b)).map(e=>padh(e,2)).join(""),C={_0:48,_9:57,A:65,F:70,a:97,f:102},_ch=ch=>{if(ch>=C._0&&ch<=C._9)return ch-C._0;if(ch>=C.A&&ch<=C.F)return ch-(C.A-10);if(ch>=C.a&&ch<=C.f)return ch-(C.a-10)},hexToBytes=hex=>{let e="hex invalid";if(!isStr(hex))return err(e);let hl=hex.length,al=hl/2;if(hl%2)return err(e);let array=u8n(al);for(let ai=0,hi=0;aiglobalThis?.crypto,subtle=()=>cr()?.subtle??err("crypto.subtle must be defined, consider polyfill"),concatBytes=(...arrs)=>{let len=0;for(let a of arrs)len+=abytes(a).length;let r=u8n(len),pad=0;return arrs.forEach(a=>{r.set(a,pad),pad+=a.length}),r},randomBytes=(len=L)=>cr().getRandomValues(u8n(len)),big=BigInt,assertRange=(n,min,max,msg="bad number: out of range")=>{if(!isBig(n))throw new TypeError(msg);if(min<=n&&n{let r=a%b;return r>=0n?r:b+r},P_MASK=(1n<<255n)-1n,modP=num=>{num<0n&&err("negative coordinate");let r=(num>>255n)*19n+(num&P_MASK);return r=(r>>255n)*19n+(r&P_MASK),r%P},modN=a=>M(a,N),invert=(num,md)=>{(num===0n||md<=0n)&&err("no inverse n="+num+" mod="+md);let a=M(num,md),b=md,x=0n,y=1n,u=1n,v=0n;for(;a!==0n;){let q=b/a,r=b%a,m=x-u*q,n=y-v*q;b=a,a=r,x=u,y=v,u=m,v=n}return b===1n?M(x,md):err("no inverse")},callHash=name=>{let fn=hashes[name];return typeof fn!="function"&&err("hashes."+name+" not set"),fn},checkDigest=value=>abytes(value,64,"digest");var apoint=p=>p instanceof Point?p:err("Point expected"),B256=2n**256n,Point=class _Point{static BASE;static ZERO;X;Y;Z;T;constructor(X,Y,Z,T){let max=B256;this.X=assertRange(X,0n,max),this.Y=assertRange(Y,0n,max),this.Z=assertRange(Z,1n,max),this.T=assertRange(T,0n,max),Object.freeze(this)}static CURVE(){return ed25519_CURVE}static fromAffine(p){return new _Point(p.x,p.y,1n,modP(p.x*p.y))}static fromBytes(hex,zip215=!1){let d=_d,normed=u8fr(abytes(hex,L)),lastByte=hex[31];normed[31]=lastByte&-129;let y=bytesToNumberLE(normed);assertRange(y,0n,zip215?B256:P);let y2=modP(y*y),u=M(y2-1n),v=modP(d*y2+1n),{isValid,value:x}=uvRatio(u,v);isValid||err("bad point: y not sqrt");let isXOdd=(x&1n)===1n,isLastByteOdd=(lastByte&128)!==0;return!zip215&&x===0n&&isLastByteOdd&&err("bad point: x==0, isLastByteOdd"),isLastByteOdd!==isXOdd&&(x=M(-x)),new _Point(x,y,1n,modP(x*y))}static fromHex(hex,zip215){return _Point.fromBytes(hexToBytes(hex),zip215)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}assertValidity(){let a=_a,d=_d,p=this;if(p.is0())return err("bad point: ZERO");let{X,Y,Z,T}=p,X2=modP(X*X),Y2=modP(Y*Y),Z2=modP(Z*Z),Z4=modP(Z2*Z2),aX2=modP(X2*a),left=modP(Z2*(aX2+Y2)),right=M(Z4+modP(d*modP(X2*Y2)));if(left!==right)return err("bad point: equation left != right (1)");let XY=modP(X*Y),ZT=modP(Z*T);return XY!==ZT?err("bad point: equation left != right (2)"):this}equals(other){let{X:X1,Y:Y1,Z:Z1}=this,{X:X2,Y:Y2,Z:Z2}=apoint(other),X1Z2=modP(X1*Z2),X2Z1=modP(X2*Z1),Y1Z2=modP(Y1*Z2),Y2Z1=modP(Y2*Z1);return X1Z2===X2Z1&&Y1Z2===Y2Z1}is0(){return this.equals(I)}negate(){return new _Point(M(-this.X),this.Y,this.Z,M(-this.T))}double(){let{X:X1,Y:Y1,Z:Z1}=this,a=_a,A=modP(X1*X1),B=modP(Y1*Y1),C2=modP(2n*Z1*Z1),D=modP(a*A),x1y1=M(X1+Y1),E=M(modP(x1y1*x1y1)-A-B),G2=M(D+B),F=M(G2-C2),H=M(D-B),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}add(other){let{X:X1,Y:Y1,Z:Z1,T:T1}=this,{X:X2,Y:Y2,Z:Z2,T:T2}=apoint(other),a=_a,d=_d,A=modP(X1*X2),B=modP(Y1*Y2),C2=modP(modP(T1*d)*T2),D=modP(Z1*Z2),E=M(modP(M(X1+Y1)*M(X2+Y2))-A-B),F=M(D-C2),G2=M(D+C2),H=M(B-modP(a*A)),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}subtract(other){return this.add(apoint(other).negate())}multiply(n,safe=!0){if(!safe&&n===0n||(assertRange(n,1n,N),!safe&&this.is0()))return I;if(n===1n)return this;if(this.equals(G))return wNAF(n).p;let p=I,f=G;for(let d=this;n>0n;d=d.double(),n>>=1n)n&1n?p=p.add(d):safe&&(f=f.add(d));return p}multiplyUnsafe(scalar){return this.multiply(scalar,!1)}toAffine(){let{X,Y,Z}=this;if(this.equals(I))return{x:0n,y:1n};let iz=invert(Z,P);modP(Z*iz)!==1n&&err("invalid inverse");let x=modP(X*iz),y=modP(Y*iz);return{x,y}}toBytes(){let{x,y}=this.toAffine(),b=numTo32bLE(y);return b[31]|=x&1n?128:0,b}toHex(){return bytesToHex(this.toBytes())}clearCofactor(){return this.multiply(big(h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let p=this.multiply(N/2n,!1).double();return N%2n&&(p=p.add(this)),p.is0()}},G=new Point(Gx,Gy,1n,M(Gx*Gy)),I=new Point(0n,1n,1n,0n);Point.BASE=G;Point.ZERO=I;var numTo32bLE=num=>hexToBytes(padh(assertRange(num,0n,B256),64)).reverse(),bytesToNumberLE=b=>big("0x"+bytesToHex(u8fr(abytes(b)).reverse())),pow2=(x,power)=>{let r=x;for(;power-- >0n;)r=modP(r*r);return r},pow_2_252_3=x=>{let x2=modP(x*x),b2=modP(x2*x),b4=modP(pow2(b2,2n)*b2),b5=modP(pow2(b4,1n)*x),b10=modP(pow2(b5,5n)*b5),b20=modP(pow2(b10,10n)*b10),b40=modP(pow2(b20,20n)*b20),b80=modP(pow2(b40,40n)*b40),b160=modP(pow2(b80,80n)*b80),b240=modP(pow2(b160,80n)*b80),b250=modP(pow2(b240,10n)*b10);return{pow_p_5_8:modP(pow2(b250,2n)*x),b2}},RM1=0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n,uvRatio=(u,v)=>{let v3=modP(v*modP(v*v)),v7=modP(modP(v3*v3)*v),pow=pow_2_252_3(modP(u*v7)).pow_p_5_8,x=modP(u*modP(v3*pow)),vx2=modP(v*modP(x*x)),root1=x,root2=modP(x*RM1),useRoot1=vx2===u,useRoot2=vx2===M(-u),noRoot=vx2===M(-u*RM1);return useRoot1&&(x=root1),(useRoot2||noRoot)&&(x=root2),(M(x)&1n)===1n&&(x=M(-x)),{isValid:useRoot1||useRoot2,value:x}},modL_LE=hash=>modN(bytesToNumberLE(hash)),sha512a=(...m)=>Promise.resolve(callHash("sha512Async")(concatBytes(...m))).then(checkDigest),sha512s=(...m)=>checkDigest(callHash("sha512")(concatBytes(...m))),hash2extK=hashed=>{let copy=u8fr(hashed),head=copy.slice(0,32);head[0]&=248,head[31]&=127,head[31]|=64;let prefix=copy.slice(32,64),scalar=modL_LE(head),point=G.multiply(scalar),pointBytes=point.toBytes();return{head,prefix,scalar,point,pointBytes}},getExtendedPublicKeyAsync=secretKey=>sha512a(abytes(secretKey,L)).then(hash2extK),getExtendedPublicKey=secretKey=>hash2extK(sha512s(abytes(secretKey,L))),getPublicKeyAsync=secretKey=>getExtendedPublicKeyAsync(secretKey).then(p=>p.pointBytes);var hashFinishA=res=>sha512a(res.hashable).then(res.finish);var _sign=(e,rBytes,msg)=>{let{pointBytes:P2,scalar:s}=e,r=modL_LE(rBytes),R=G.multiply(r).toBytes();return{hashable:concatBytes(R,P2,msg),finish:hashed=>{let S=modN(r+modL_LE(hashed)*s);return abytes(concatBytes(R,numTo32bLE(S)),64)}}},signAsync=async(message,secretKey)=>{let m=abytes(message),e=await getExtendedPublicKeyAsync(secretKey),rBytes=await sha512a(e.prefix,m);return hashFinishA(_sign(e,rBytes,m))};var hashes={sha512Async:async message=>{let s=subtle(),m=concatBytes(message);return u8n(await s.digest("SHA-512",m.buffer))},sha512:void 0},randomSecretKey=seed=>(seed=seed===void 0?randomBytes(L):seed,abytes(seed,L));var utils=Object.freeze({getExtendedPublicKeyAsync,getExtendedPublicKey,randomSecretKey}),W=8,scalarBits=256,pwindows=Math.ceil(scalarBits/W)+1,pwindowSize=2**(W-1),precompute=()=>{let points=[],p=G,b=p;for(let w=0;w{let n=p.negate();return cnd?n:p},wNAF=n=>{let comp=Gpows||(Gpows=precompute()),p=I,f=G,pow_2_w=2**W,maxNum=pow_2_w,mask=big(pow_2_w-1),shiftBy=big(W);for(let w=0;w>=shiftBy,wbits>pwindowSize&&(wbits-=maxNum,n+=1n);let off=w*pwindowSize,offF=off,offP=off+Math.abs(wbits)-1,isEven=w%2!==0,isNeg=wbits<0;wbits===0?f=f.add(ctneg(isEven,comp[offF])):p=p.add(ctneg(isNeg,comp[offP]))}return n!==0n&&err("invalid wnaf"),{p,f}};export{GATEWAY_CLIENT_CAPS,GATEWAY_CLIENT_IDS,GATEWAY_CLIENT_MODES,GatewayBrowserDeviceAuthLifecycle,GatewayProtocolClient,GatewayProtocolRequestError,MIN_CLIENT_PROTOCOL_VERSION,PROTOCOL_VERSION,utils as ed25519Utils,getPublicKeyAsync,signAsync}; +function normalizeDeviceMetadataForAuth(value){if(typeof value!="string")return"";let trimmed=value.trim();return trimmed?trimmed.replace(/[A-Z]/g,char=>String.fromCharCode(char.charCodeAt(0)+32)):""}function buildDeviceAuthPayloadV3(params){let scopes=params.scopes.join(","),token=params.token??"",platform=normalizeDeviceMetadataForAuth(params.platform),deviceFamily=normalizeDeviceMetadataForAuth(params.deviceFamily);return["v3",params.deviceId,params.clientId,params.clientMode,params.role,scopes,String(params.signedAtMs),token,params.nonce,platform,deviceFamily].join("|")}function normalized(value){return typeof value=="string"&&value.trim()||void 0}function selectGatewayConnectAuth(params){let authToken=normalized(params.token),bootstrapToken=normalized(params.bootstrapToken),explicitDeviceToken=normalized(params.deviceToken),authPassword=normalized(params.password),storedToken=normalized(params.storedToken),stored={storedToken,storedScopes:params.storedScopes};if(params.preferBootstrapToken&&bootstrapToken)return{authBootstrapToken:bootstrapToken,authPassword,...stored};let useRetryToken=params.pendingDeviceTokenRetry===!0&&!explicitDeviceToken&&!!(authToken&&storedToken&¶ms.trustedDeviceTokenRetry),resolvedDeviceToken=explicitDeviceToken??(useRetryToken||!(authToken||authPassword)&&(!bootstrapToken||storedToken)?storedToken:void 0),usingStoredDeviceToken=!!(resolvedDeviceToken&&!explicitDeviceToken&&storedToken)&&resolvedDeviceToken===storedToken,selectedToken=authToken??resolvedDeviceToken,authBootstrapToken=!authToken&&!resolvedDeviceToken&&!authPassword?bootstrapToken:void 0;return{authToken:selectedToken,authBootstrapToken,authDeviceToken:useRetryToken?storedToken:void 0,authPassword,authApprovalRuntimeToken:normalized(params.approvalRuntimeToken),authAgentRuntimeIdentityToken:normalized(params.agentRuntimeIdentityToken),signatureToken:selectedToken??authBootstrapToken,resolvedDeviceToken,usingStoredDeviceToken,...stored}}function buildGatewayConnectAuth(selected){let auth={token:selected.authToken,bootstrapToken:selected.authBootstrapToken,deviceToken:selected.authDeviceToken??selected.resolvedDeviceToken,password:selected.authPassword,approvalRuntimeToken:selected.authApprovalRuntimeToken,agentRuntimeIdentityToken:selected.authAgentRuntimeIdentityToken};return Object.values(auth).some(Boolean)?auth:void 0}function resolveGatewayConnectScopes(params){return params.requestedScopes??(params.usingStoredDeviceToken&¶ms.storedScopes?.length?params.storedScopes:[...params.defaultScopes])}var GatewayBrowserDeviceAuthLifecycle=class{constructor(deps){this.deps=deps}async buildPlan(params){let identity=await this.deps.loadIdentity(),stored=identity?await this.deps.tokenStore.load({clientId:params.client.id,deviceId:identity.deviceId,role:params.role}):null,storedValue=stored?.token,selectedAuth=selectGatewayConnectAuth({token:params.token,bootstrapToken:params.bootstrapToken,password:params.password,storedToken:storedValue,storedScopes:stored?.scopes,pendingDeviceTokenRetry:params.pendingDeviceTokenRetry,trustedDeviceTokenRetry:params.trustedDeviceTokenRetry,preferBootstrapToken:params.preferBootstrapToken}),{usingStoredDeviceToken}=selectedAuth,scopes=resolveGatewayConnectScopes({requestedScopes:selectedAuth.authBootstrapToken&¶ms.bootstrapScopes?[...params.bootstrapScopes]:void 0,usingStoredDeviceToken,storedScopes:selectedAuth.storedScopes,defaultScopes:params.defaultScopes});if(!identity)return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth)};let signedAtMs=params.challengeTs===void 0?this.deps.nowMs?.()??Date.now():params.challengeTs;if(typeof signedAtMs!="number"||!Number.isSafeInteger(signedAtMs)||signedAtMs<0)throw new Error("gateway connect challenge timestamp invalid");let nonce=params.nonce??"",{authBootstrapToken:primary,signatureToken:signed}=selectedAuth,token=null;primary?token=primary:signed&&(token=signed);let payload=buildDeviceAuthPayloadV3({deviceId:identity.deviceId,clientId:params.client.id,clientMode:params.client.mode,role:params.role,scopes,signedAtMs,token,nonce,platform:params.client.platform,deviceFamily:params.client.deviceFamily});return{clientId:params.client.id,role:params.role,identity,selectedAuth,scopes,auth:buildGatewayConnectAuth(selectedAuth),device:{id:identity.deviceId,publicKey:identity.publicKey,signature:await identity.sign(payload),signedAt:signedAtMs,nonce}}}async acceptHello(hello,plan){let token=hello.auth?.deviceToken?.trim();!token||!plan.identity||await this.deps.tokenStore.store({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:hello.auth?.role??plan.role,token,scopes:hello.auth?.scopes??[]})}async clearStoredToken(plan){plan.identity&&await this.deps.tokenStore.clear({clientId:plan.clientId,deviceId:plan.identity.deviceId,role:plan.role})}};function isRecord(value){return!!value&&typeof value=="object"&&!Array.isArray(value)}function isNonEmptyString(value){return typeof value=="string"&&value.length>0}function isNonNegativeInteger(value){return typeof value=="number"&&Number.isInteger(value)&&value>=0}function isGatewayErrorShape(value){return!isRecord(value)||!isNonEmptyString(value.code)||!isNonEmptyString(value.message)||value.retryable!==void 0&&typeof value.retryable!="boolean"?!1:value.retryAfterMs===void 0||isNonNegativeInteger(value.retryAfterMs)}function isGatewayEventFrame(value){return!isRecord(value)||value.type!=="event"||!isNonEmptyString(value.event)?!1:value.seq===void 0||isNonNegativeInteger(value.seq)}function isGatewayResponseFrame(value){return!isRecord(value)||value.type!=="res"||!isNonEmptyString(value.id)||typeof value.ok!="boolean"?!1:value.error===void 0||isGatewayErrorShape(value.error)}function computeBackoff(policy,attempt){let base=Math.min(policy.maxMs,policy.initialMs*policy.factor**Math.max(attempt-1,0)),jitter=base*policy.jitter*Math.random();return Math.min(policy.maxMs,Math.round(base+jitter))}async function sleepWithAbort(ms,abortSignal,options={}){if(!Number.isFinite(ms)||ms<=0)return;let delayMs=Math.min(Math.max(Math.floor(ms),1),2147e6);await new Promise((resolve,reject)=>{let settled=!1,timer=null,cleanup=()=>abortSignal?.removeEventListener("abort",onAbort),onAbort=()=>{settled||(settled=!0,timer&&clearTimeout(timer),timer=null,cleanup(),reject(new Error("aborted",{cause:abortSignal?.reason??new Error("aborted")})))};if(abortSignal?.addEventListener("abort",onAbort,{once:!0}),abortSignal?.aborted){onAbort();return}timer=setTimeout(()=>{settled=!0,cleanup(),timer=null,resolve()},delayMs),options.ref===!1&&timer.unref?.(),abortSignal?.aborted&&onAbort()})}var RetrySupervisor=class{constructor(policy,maxAttempts=Number.POSITIVE_INFINITY){this.policy=policy;this.maxAttempts=maxAttempts;this.attempts=0;this.initialMs=policy.initialMs}reset(initialMs=this.policy.initialMs){this.cancel(),this.attempts=0,this.initialMs=initialMs,this.nextDelayOverrideMs=void 0}cancel(reason=new Error("retry cancelled")){this.pendingAbort?.abort(reason),this.pendingAbort=void 0}next(abortSignal){let override=this.nextDelayOverrideMs;if(this.nextDelayOverrideMs=void 0,override===void 0&&++this.attempts>Math.ceil(this.maxAttempts))return;let attempt=Math.max(this.attempts,1),delayMs=override??computeBackoff({...this.policy,initialMs:this.initialMs},attempt);this.cancel();let pendingAbort=new AbortController;return this.pendingAbort=pendingAbort,{attempt,delayMs,signal:abortSignal?AbortSignal.any([pendingAbort.signal,abortSignal]):pendingAbort.signal}}},DEFAULT_RETRY_CONFIG={attempts:3,minDelayMs:300,maxDelayMs:3e4,jitter:0},defaultSleep=ms=>new Promise(resolve=>{setTimeout(resolve,ms)});function asFiniteNumber(value){return typeof value=="number"&&Number.isFinite(value)?value:void 0}function clampNumber(value,fallback,min,max){let next=asFiniteNumber(value);return next===void 0?fallback:Math.min(Math.max(next,min??Number.NEGATIVE_INFINITY),max??Number.POSITIVE_INFINITY)}function resolveAttemptCount(value,fallback){return Math.max(1,Math.round(asFiniteNumber(value)??fallback))}function resolveRetryDelayMs(value){let finite=value===Number.POSITIVE_INFINITY?2147e6:asFiniteNumber(value)??0;return Math.min(Math.max(Math.round(finite),0),2147e6)}function resolveJitterConfig(value,fallback){if(value==="full")return"full";let fraction=asFiniteNumber(value);return fraction===void 0?fallback:Math.min(Math.max(fraction,0),1)}function resolveRetryConfig(defaults=DEFAULT_RETRY_CONFIG,overrides){let attempts=resolveAttemptCount(overrides?.attempts,defaults.attempts),minDelayMs=resolveRetryDelayMs(clampNumber(overrides?.minDelayMs,defaults.minDelayMs,0)),maxDelayMs=Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(overrides?.maxDelayMs,defaults.maxDelayMs,0)));return{attempts,minDelayMs,maxDelayMs,jitter:resolveJitterConfig(overrides?.jitter,defaults.jitter)}}function applyJitter(delayMs,jitter,mode,random){if(jitter==="full")return mode==="symmetric"?Math.max(0,Math.round(delayMs*(.5+random()*.5))):Math.max(0,Math.ceil(delayMs*(1+random())));if(jitter<=0)return mode==="positive"?Math.ceil(delayMs):delayMs;let fraction=random(),offset=mode==="positive"?fraction*jitter:(fraction*2-1)*jitter,raw=delayMs*(1+offset);return Math.max(0,mode==="positive"?Math.ceil(raw):Math.round(raw))}function toRetryError(value,fallbackMessage="Non-Error thrown"){if(value instanceof Error)return value;if(typeof value=="string")return new Error(value);let error=new Error(fallbackMessage,{cause:value});return(typeof value=="object"&&value!==null||typeof value=="function")&&Object.assign(error,value),error}function createRetryRunner(runtime={}){let runtimeSleep=runtime.sleep??defaultSleep,runtimeRandom=runtime.random??Math.random,createFailure=runtime.createFailure??(errors=>toRetryError(errors.at(-1)??new Error("Retry failed")));return async function(fn,attemptsOrOptions=3,initialDelayMs=300){let attemptErrors=[];if(typeof attemptsOrOptions=="number"){let attempts=resolveAttemptCount(attemptsOrOptions,DEFAULT_RETRY_CONFIG.attempts);for(let index=0;index0?resolved.maxDelayMs:Number.POSITIVE_INFINITY,retryAfterMaxDelayMs=options.retryAfterMaxDelayMs===void 0?maxDelayMs:Math.max(minDelayMs,resolveRetryDelayMs(clampNumber(options.retryAfterMaxDelayMs,maxDelayMs,0))),random=options.random??runtimeRandom,sleep=options.sleep??runtimeSleep,shouldRetry=options.shouldRetry??(()=>!0);for(let attempt=1;attempt<=maxAttempts;attempt+=1)try{return await fn()}catch(err2){if(attemptErrors.push(err2),attempt>=maxAttempts||!shouldRetry(err2,attempt))break;let context={attempt,maxAttempts,err:err2,label:options.label},retryAfterMs=options.retryAfterMs?.(err2),hasRetryAfter=typeof retryAfterMs=="number"&&Number.isFinite(retryAfterMs),configuredDelay=typeof options.delayMs=="function"?options.delayMs(context):options.delayMs,resolvedConfiguredDelay=configuredDelay===void 0?void 0:resolveRetryDelayMs(configuredDelay),baseDelay=hasRetryAfter?Math.max(retryAfterMs,minDelayMs):resolvedConfiguredDelay===void 0?minDelayMs*2**(attempt-1):Math.max(resolvedConfiguredDelay,minDelayMs),delayCap=hasRetryAfter?retryAfterMaxDelayMs:maxDelayMs,delay=Math.min(baseDelay,delayCap),canHonorRetryAfter=hasRetryAfter&&(retryAfterMs??0)<=delayCap,wantsPositiveDraw=resolved.jitter==="full"&&!hasRetryAfter||canHonorRetryAfter;delay=applyJitter(delay,resolved.jitter,wantsPositiveDraw?"positive":"symmetric",random),delay=Math.min(Math.max(delay,minDelayMs),delayCap),await options.onRetry?.({...context,delayMs:delay}),delay>0&&await sleep(delay)}throw createFailure(attemptErrors)}}var retryAsync=createRetryRunner();var GatewayEventListeners=class{constructor(){this.listeners=new Map}add(listener){let subscription=this.listeners.get(listener)??{};return this.listeners.set(listener,subscription),()=>{this.listeners.get(listener)===subscription&&this.listeners.delete(listener)}}snapshot(){return[...this.listeners]}isCurrent(listener,subscription){return this.listeners.get(listener)===subscription}};var GatewayProtocolRequestError=class extends Error{constructor(error){super(error.message??"request failed"),this.name="GatewayProtocolRequestError",this.code=error.code??"UNAVAILABLE",this.gatewayCode=this.code,this.details=error.details,this.retryable=error.retryable===!0,this.retryAfterMs=error.retryAfterMs}};var DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS=15e3;function startGatewayConnectTimeout(onTimeout){let timer=setTimeout(onTimeout,DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS);return timer.unref?.(),timer}function clearGatewayConnectTimeout(timer){return timer!==null&&clearTimeout(timer),null}var GatewayProtocolClient=class{constructor(opts){this.opts=opts;this.socket=null;this.pending=new Map;this.listeners=new GatewayEventListeners;this.stopped=!0;this.generation=0;this.lastSeq=null;this.connectNonce=null;this.connectSent=!1;this.connectRequestSent=!1;this.handshakeTimer=null;this.reconnectSignal=null;this.socketOpened=!1;this.helloReceived=!1;this.connectTiming=null;this.reconnectSupervisor=new RetrySupervisor({initialMs:opts.reconnect.initialMs,maxMs:opts.reconnect.maxMs,factor:opts.reconnect.multiplier,jitter:0})}get connected(){return this.socket?.isOpen()??!1}get hasPendingRequests(){return this.pending.size>0}get connecting(){return this.connectSent&&!this.helloReceived}get hasUnboundedPendingRequests(){return[...this.pending.values()].some(pending=>pending.unbounded)}start(){this.socket||this.reconnectSignal||(this.stopped=!1,this.reconnectSupervisor.cancel(),this.connect())}stop(){this.stopped=!0,this.clearHandshakeTimer(),this.reconnectSignal=null,this.reconnectSupervisor.reset();let socket=this.socket;socket&&this.opts.notifyStoppedClose&&(this.stoppedSocket={socket,context:this.closeContext()}),this.socket=null,this.connectFailure=void 0,this.connectTiming=null,this.flushRequests(new Error("gateway client stopped")),socket?.close()}request(method,params,options){let socket=this.socket;if(!socket?.isOpen())return Promise.reject(new Error("gateway not connected"));if(typeof method!="string"||method.length===0)return Promise.reject(new Error("invalid request frame: method must be a non-empty string"));let id=this.opts.createRequestId(),timeoutMs=options?.timeoutMs===null?void 0:options?.timeoutMs??this.opts.requestTimeoutMs;return new Promise((resolve,reject)=>{let timeout,requestSent=!1,pending={resolve:value=>resolve(value),reject,expectFinal:options?.expectFinal===!0,acceptedNotified:!1,onAccepted:options?.onAccepted,unbounded:timeoutMs===void 0,method,startedAtMs:this.nowMs()},onAbort=()=>{this.pending.delete(id),pending.cleanup?.(),this.finishRequestTiming(id,pending,!1,"CLIENT_ABORTED"),reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`))},cleanup=()=>{timeout&&clearTimeout(timeout),options?.signal?.removeEventListener("abort",onAbort)};if(options?.signal?.aborted){reject(this.opts.createRequestAbortError?.(method)??new Error(`gateway request aborted for ${method}`));return}pending.cleanup=cleanup,timeoutMs!==void 0&&timeoutMs>=0&&(timeout=setTimeout(()=>{this.pending.get(id)===pending&&(this.pending.delete(id),options?.signal?.removeEventListener("abort",onAbort),this.finishRequestTiming(id,pending,!1,"CLIENT_TIMEOUT"),reject(this.opts.createRequestTimeoutError?.(method,timeoutMs,requestSent)??new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`)))},timeoutMs),timeout.unref?.()),options?.signal?.addEventListener("abort",onAbort,{once:!0}),this.pending.set(id,pending);try{socket.send(JSON.stringify({type:"req",id,method,params})),requestSent=!0,this.invoke("sent",()=>options?.onSent?.())}catch(error){this.pending.delete(id),cleanup(),this.finishRequestTiming(id,pending,!1,"CLIENT_SEND_ERROR"),reject(error instanceof Error?error:new Error(String(error)))}})}addEventListener(listener){return this.listeners.add(listener)}closeSocket(code,reason){this.socket?.close(code,reason)}resetReconnectBackoff(initialMs){this.reconnectSignal=null,this.reconnectSupervisor.reset(initialMs)}recordTiming(phase,generation,plan,detail){let now=this.nowMs(),state=this.connectTiming;!state||state.generation!==generation||(state.hasChallenge||=phase==="challenge",state.usedFallback||=phase==="fallback",this.invoke("connect timing",()=>this.opts.onTiming?.({phase,generation,durationMs:Math.max(0,now-state.startedAtMs),phaseDurationMs:Math.max(0,now-state.lastAtMs),hasChallenge:state.hasChallenge,usedFallback:state.usedFallback,plan,detail})),state.lastAtMs=now,(phase==="hello"||phase==="failed")&&(this.connectTiming=null))}connect(){if(this.stopped)return;let generation=this.generation+1;this.lastSeq=null,this.connectNonce=null,this.connectChallengeTs=void 0,this.connectSent=this.connectRequestSent=!1,this.socketOpened=!1,this.helloReceived=!1,this.connectFailure=void 0;let socket;try{socket=this.opts.createSocket({open:()=>this.handleOpen(socket,generation),message:data=>this.handleMessage(socket,generation,data),close:(code,reason)=>this.handleClose(socket,generation,code,reason),error:error=>this.handleSocketError(socket,generation,error)})}catch(error){let normalized2=error instanceof Error?error:new Error(String(error));if(this.opts.onSocketFactoryError?.(normalized2),this.opts.onConnectError?.(normalized2),this.opts.rethrowSocketFactoryError?.(normalized2))throw normalized2;this.opts.shouldRetrySocketFactoryError?.(normalized2)&&!this.stopped&&!this.socket&&!this.reconnectSignal&&this.scheduleReconnect();return}this.generation=generation,this.socket=socket;let now=this.nowMs();this.connectTiming={generation,startedAtMs:now,lastAtMs:now,hasChallenge:!1,usedFallback:!1}}handleOpen(socket,generation){if(this.isActive(socket,generation)){if(this.socketOpened=!0,this.recordTiming("socket-open",generation),this.connectNonce){this.sendConnect(socket,generation);return}this.armHandshakeTimer(socket,generation)}}armHandshakeTimer(socket,generation){this.clearHandshakeTimer();let armedAt=Date.now();this.handshakeTimer=setTimeout(()=>{if(this.handshakeTimer=null,!this.isActive(socket,generation)||this.connectSent||!socket.isOpen())return;if(this.opts.handshake.mode==="fallback"){this.recordTiming("fallback",generation),this.sendConnect(socket,generation);return}let elapsedMs=Date.now()-armedAt,error=new Error(this.opts.handshake.timeoutMessage?.(elapsedMs)??`gateway connect challenge timeout after ${elapsedMs}ms`);this.opts.onConnectError?.(error),socket.close(1008,"connect challenge timeout")},this.opts.handshake.timeoutMs),this.handshakeTimer.unref?.()}sendConnect(socket,generation){if(!this.isActive(socket,generation)||!socket.isOpen()||this.connectSent)return;this.connectSent=!0,this.clearHandshakeTimer(),this.handshakeTimer=startGatewayConnectTimeout(()=>{this.isActive(socket,generation)&&!this.helloReceived&&socket.close(4e3,"connect timeout")});let planOrPromise;try{planOrPromise=this.opts.buildConnectPlan({nonce:this.connectNonce,challengeTs:this.connectChallengeTs,generation})}catch(error){this.handleConnectPlanError(socket,generation,error);return}if(planOrPromise instanceof Promise){planOrPromise.then(plan=>this.sendConnectPlan(socket,generation,plan)).catch(error=>this.handleConnectPlanError(socket,generation,error));return}this.sendConnectPlan(socket,generation,planOrPromise)}handleConnectPlanError(socket,generation,error){if(!this.isActive(socket,generation))return;let normalized2=error instanceof Error?error:new Error(String(error)),outcome=this.opts.onConnectPlanError?.(normalized2)??{closeCode:1008,closeReason:"connect failed"};this.opts.onConnectError?.(outcome.error??normalized2),outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)}sendConnectPlan(socket,generation,plan){if(!this.isActive(socket,generation)||!socket.isOpen())return;let context={generation,nonce:this.connectNonce,challengeTs:this.connectChallengeTs,plan};this.recordTiming("connect-plan-ready",generation,plan),this.recordTiming("request-sent",generation,plan),this.connectRequestSent=!0,this.request("connect",this.opts.buildConnectParams(plan)).then(hello=>{this.isActive(socket,generation)&&(this.helloReceived=!0,this.clearHandshakeTimer(),this.connectFailure=void 0,this.reconnectSupervisor.reset(),this.recordTiming("hello",generation,plan),this.opts.onConnectHello?.(hello,context),this.invoke("hello",()=>this.opts.onHello?.(hello)))}).catch(error=>{if(!this.isActive(socket,generation))return;let requestError=error instanceof GatewayProtocolRequestError?error:new GatewayProtocolRequestError({message:String(error)}),outcome=this.opts.onConnectFailure?.(requestError,context)??{closeCode:1008,closeReason:"connect failed"};this.connectFailure={error:requestError,reconnectDelayMs:outcome.reconnectDelayMs},outcome.stop&&(this.stopped=!0),socket.close(outcome.closeCode,outcome.closeReason)})}handleMessage(socket,generation,raw){if(!this.isActive(socket,generation))return;let parsed;try{parsed=JSON.parse(raw)}catch(error){this.opts.onParseError?.(error);return}if(isGatewayEventFrame(parsed)){if(this.opts.onActivity?.(),parsed.event==="connect.challenge"){let payload=parsed.payload,nonce=typeof payload?.nonce=="string"?payload.nonce.trim():"";if(!nonce){if(this.opts.handshake.mode==="require-challenge"){let error=new Error("gateway connect challenge missing nonce");this.opts.onConnectError?.(error),socket.close(1008,"connect challenge missing nonce")}return}this.connectNonce=nonce;let challengeTs=payload?.ts;this.connectChallengeTs=typeof challengeTs=="number"&&Number.isSafeInteger(challengeTs)&&challengeTs>=0?challengeTs:null,this.recordTiming("challenge",generation),this.sendConnect(socket,generation);return}let seq=typeof parsed.seq=="number"?parsed.seq:null;if(seq!==null){if(this.lastSeq!==null&&seq>this.lastSeq+1){let expected=this.lastSeq+1;if(this.invoke("gap",()=>this.opts.onGap?.({expected,received:seq})),!this.isActive(socket,generation))return}this.lastSeq=seq}let listeners=this.listeners.snapshot();this.invoke("event",()=>this.opts.onEvent?.(parsed));for(let[listener,subscription]of listeners){if(!this.isActive(socket,generation))return;this.listeners.isCurrent(listener,subscription)&&this.invoke("event listener",()=>listener(parsed))}return}isGatewayResponseFrame(parsed)&&(this.opts.onActivity?.(),this.handleResponse(parsed))}handleResponse(frame){let pending=this.pending.get(frame.id);if(!pending)return;let status=frame.payload?.status;if(pending.expectFinal&&status==="accepted"){pending.acceptedNotified||(pending.acceptedNotified=!0,this.invoke("accepted",()=>pending.onAccepted?.(frame.payload)));return}if(this.pending.delete(frame.id),pending.cleanup?.(),frame.ok){this.finishRequestTiming(frame.id,pending,!0),pending.resolve(frame.payload);return}this.finishRequestTiming(frame.id,pending,!1,frame.error?.code),pending.reject(this.opts.createRequestError?.(frame.error??{})??new GatewayProtocolRequestError(frame.error??{}))}handleClose(socket,generation,code,reason){if(this.socket!==socket){if(this.stoppedSocket?.socket===socket){let context2={...this.stoppedSocket.context,code,reason};this.stoppedSocket=void 0,this.invoke("close",()=>this.opts.onClose?.(context2,{retry:!1,notify:!0}))}return}this.socket=null,this.clearHandshakeTimer();let context={...this.closeContext(),code,reason,generation};this.connectFailure=void 0;let decision=this.opts.resolveClose(context);this.flushRequests(decision.pendingError??context.connectFailure?.error??new Error(`gateway closed (${code}): ${reason}`)),this.invoke("close",()=>this.opts.onClose?.(context,decision)),decision.retry&&!this.stopped&&this.scheduleReconnect(decision.reconnectDelayMs??context.connectFailure?.reconnectDelayMs)}handleSocketError(socket,generation,error){!this.isActive(socket,generation)||this.connectSent||this.opts.onConnectError?.(error)}flushRequests(error){for(let[id,pending]of this.pending)this.finishRequestTiming(id,pending,!1,"CLIENT_CLOSED"),pending.cleanup?.(),pending.reject(error);this.pending.clear()}finishRequestTiming(id,pending,ok,errorCode){let endedAtMs=this.nowMs();this.invoke("request timing",()=>this.opts.onRequestTiming?.({id,method:pending.method,ok,durationMs:Math.max(0,endedAtMs-pending.startedAtMs),startedAtMs:pending.startedAtMs,endedAtMs,errorCode}))}scheduleReconnect(overrideMs){overrideMs!==void 0&&(this.reconnectSupervisor.nextDelayOverrideMs=overrideMs);let retry=this.reconnectSupervisor.next();retry&&(this.reconnectSignal=retry.signal,sleepWithAbort(retry.delayMs,retry.signal).then(()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null,this.connect())},()=>{this.reconnectSignal===retry.signal&&(this.reconnectSignal=null)}))}closeContext(){return{generation:this.generation,socketOpened:this.socketOpened,helloReceived:this.helloReceived,connectRequestSent:this.connectRequestSent,connectFailure:this.connectFailure}}isActive(socket,generation){return!this.stopped&&this.socket===socket&&this.generation===generation}nowMs(){return this.opts.nowMs?.()??Date.now()}clearHandshakeTimer(){this.handshakeTimer=clearGatewayConnectTimeout(this.handshakeTimer)}invoke(label,callback){try{callback()}catch(error){this.opts.onCallbackError?.(label,error)}}};var GATEWAY_CLIENT_IDS={WEBCHAT_UI:"webchat-ui",CONTROL_UI:"openclaw-control-ui",BROWSER_COPILOT:"openclaw-browser-copilot",TUI:"openclaw-tui",WEBCHAT:"webchat",CLI:"cli",GATEWAY_CLIENT:"gateway-client",MACOS_APP:"openclaw-macos",LINUX_APP:"openclaw-linux",IOS_APP:"openclaw-ios",WATCHOS_APP:"openclaw-watchos",ANDROID_APP:"openclaw-android",NODE_HOST:"node-host",WORKER:"openclaw-worker",TEST:"test",FINGERPRINT:"fingerprint",PROBE:"openclaw-probe"};var GATEWAY_CLIENT_MODES={WEBCHAT:"webchat",CLI:"cli",UI:"ui",BACKEND:"backend",NODE:"node",WORKER:"worker",PROBE:"probe",TEST:"test"},GATEWAY_CLIENT_CAPS={AGENT_KIND:"agent-kind",APPROVALS:"approvals",EXEC_APPROVALS:"exec-approvals",INLINE_WIDGETS:"inline-widgets",RUN_TOOL_BINDINGS:"run-tool-bindings",SESSION_SCOPED_EVENTS:"session-scoped-events",PLUGIN_APPROVALS:"plugin-approvals",TASK_SUGGESTIONS:"task-suggestions",TERMINAL_OFFSET_SEQ:"terminal-offset-seq",TOOL_EVENTS:"tool-events",UI_COMMANDS:"ui-commands"},GATEWAY_CLIENT_ID_SET=new Set(Object.values(GATEWAY_CLIENT_IDS)),GATEWAY_CLIENT_MODE_SET=new Set(Object.values(GATEWAY_CLIENT_MODES));var PROTOCOL_VERSION=4,MIN_CLIENT_PROTOCOL_VERSION=4;/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */var ed25519_CURVE=Object.freeze({p:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,n:0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,h:8n,a:0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,d:0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,Gx:0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,Gy:0x6666666666666666666666666666666666666666666666666666666666666658n}),{p:P,n:N,Gx,Gy,a:_a,d:_d,h}=ed25519_CURVE,L=32,captureTrace=(...args)=>{"captureStackTrace"in Error&&typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(...args)},err=(message="")=>{let e=new Error(message);throw captureTrace(e,err),e},isBig=n=>typeof n=="bigint",isStr=s=>typeof s=="string",isBytes=a=>a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"&&"BYTES_PER_ELEMENT"in a&&a.BYTES_PER_ELEMENT===1,abytes=(value,length,title="")=>{let bytes=isBytes(value),len=value?.length,needsLen=length!==void 0;if(!bytes||needsLen&&len!==length){let prefix=title&&`"${title}" `,ofLen=needsLen?` of length ${length}`:"",got=bytes?`length=${len}`:`type=${typeof value}`,msg=prefix+"expected Uint8Array"+ofLen+", got "+got;throw bytes?new RangeError(msg):new TypeError(msg)}return value},u8n=len=>new Uint8Array(len),u8fr=buf=>Uint8Array.from(buf),padh=(n,pad)=>n.toString(16).padStart(pad,"0"),bytesToHex=b=>Array.from(abytes(b)).map(e=>padh(e,2)).join(""),C={_0:48,_9:57,A:65,F:70,a:97,f:102},_ch=ch=>{if(ch>=C._0&&ch<=C._9)return ch-C._0;if(ch>=C.A&&ch<=C.F)return ch-(C.A-10);if(ch>=C.a&&ch<=C.f)return ch-(C.a-10)},hexToBytes=hex=>{let e="hex invalid";if(!isStr(hex))return err(e);let hl=hex.length,al=hl/2;if(hl%2)return err(e);let array=u8n(al);for(let ai=0,hi=0;aiglobalThis?.crypto,subtle=()=>cr()?.subtle??err("crypto.subtle must be defined, consider polyfill"),concatBytes=(...arrs)=>{let len=0;for(let a of arrs)len+=abytes(a).length;let r=u8n(len),pad=0;return arrs.forEach(a=>{r.set(a,pad),pad+=a.length}),r},randomBytes=(len=L)=>cr().getRandomValues(u8n(len)),big=BigInt,assertRange=(n,min,max,msg="bad number: out of range")=>{if(!isBig(n))throw new TypeError(msg);if(min<=n&&n{let r=a%b;return r>=0n?r:b+r},P_MASK=(1n<<255n)-1n,modP=num=>{num<0n&&err("negative coordinate");let r=(num>>255n)*19n+(num&P_MASK);return r=(r>>255n)*19n+(r&P_MASK),r%P},modN=a=>M(a,N),invert=(num,md)=>{(num===0n||md<=0n)&&err("no inverse n="+num+" mod="+md);let a=M(num,md),b=md,x=0n,y=1n,u=1n,v=0n;for(;a!==0n;){let q=b/a,r=b%a,m=x-u*q,n=y-v*q;b=a,a=r,x=u,y=v,u=m,v=n}return b===1n?M(x,md):err("no inverse")},callHash=name=>{let fn=hashes[name];return typeof fn!="function"&&err("hashes."+name+" not set"),fn},checkDigest=value=>abytes(value,64,"digest");var apoint=p=>p instanceof Point?p:err("Point expected"),B256=2n**256n,Point=class _Point{static BASE;static ZERO;X;Y;Z;T;constructor(X,Y,Z,T){let max=B256;this.X=assertRange(X,0n,max),this.Y=assertRange(Y,0n,max),this.Z=assertRange(Z,1n,max),this.T=assertRange(T,0n,max),Object.freeze(this)}static CURVE(){return ed25519_CURVE}static fromAffine(p){return new _Point(p.x,p.y,1n,modP(p.x*p.y))}static fromBytes(hex,zip215=!1){let d=_d,normed=u8fr(abytes(hex,L)),lastByte=hex[31];normed[31]=lastByte&-129;let y=bytesToNumberLE(normed);assertRange(y,0n,zip215?B256:P);let y2=modP(y*y),u=M(y2-1n),v=modP(d*y2+1n),{isValid,value:x}=uvRatio(u,v);isValid||err("bad point: y not sqrt");let isXOdd=(x&1n)===1n,isLastByteOdd=(lastByte&128)!==0;return!zip215&&x===0n&&isLastByteOdd&&err("bad point: x==0, isLastByteOdd"),isLastByteOdd!==isXOdd&&(x=M(-x)),new _Point(x,y,1n,modP(x*y))}static fromHex(hex,zip215){return _Point.fromBytes(hexToBytes(hex),zip215)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}assertValidity(){let a=_a,d=_d,p=this;if(p.is0())return err("bad point: ZERO");let{X,Y,Z,T}=p,X2=modP(X*X),Y2=modP(Y*Y),Z2=modP(Z*Z),Z4=modP(Z2*Z2),aX2=modP(X2*a),left=modP(Z2*(aX2+Y2)),right=M(Z4+modP(d*modP(X2*Y2)));if(left!==right)return err("bad point: equation left != right (1)");let XY=modP(X*Y),ZT=modP(Z*T);return XY!==ZT?err("bad point: equation left != right (2)"):this}equals(other){let{X:X1,Y:Y1,Z:Z1}=this,{X:X2,Y:Y2,Z:Z2}=apoint(other),X1Z2=modP(X1*Z2),X2Z1=modP(X2*Z1),Y1Z2=modP(Y1*Z2),Y2Z1=modP(Y2*Z1);return X1Z2===X2Z1&&Y1Z2===Y2Z1}is0(){return this.equals(I)}negate(){return new _Point(M(-this.X),this.Y,this.Z,M(-this.T))}double(){let{X:X1,Y:Y1,Z:Z1}=this,a=_a,A=modP(X1*X1),B=modP(Y1*Y1),C2=modP(2n*Z1*Z1),D=modP(a*A),x1y1=M(X1+Y1),E=M(modP(x1y1*x1y1)-A-B),G2=M(D+B),F=M(G2-C2),H=M(D-B),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}add(other){let{X:X1,Y:Y1,Z:Z1,T:T1}=this,{X:X2,Y:Y2,Z:Z2,T:T2}=apoint(other),a=_a,d=_d,A=modP(X1*X2),B=modP(Y1*Y2),C2=modP(modP(T1*d)*T2),D=modP(Z1*Z2),E=M(modP(M(X1+Y1)*M(X2+Y2))-A-B),F=M(D-C2),G2=M(D+C2),H=M(B-modP(a*A)),X3=modP(E*F),Y3=modP(G2*H),T3=modP(E*H),Z3=modP(F*G2);return new _Point(X3,Y3,Z3,T3)}subtract(other){return this.add(apoint(other).negate())}multiply(n,safe=!0){if(!safe&&n===0n||(assertRange(n,1n,N),!safe&&this.is0()))return I;if(n===1n)return this;if(this.equals(G))return wNAF(n).p;let p=I,f=G;for(let d=this;n>0n;d=d.double(),n>>=1n)n&1n?p=p.add(d):safe&&(f=f.add(d));return p}multiplyUnsafe(scalar){return this.multiply(scalar,!1)}toAffine(){let{X,Y,Z}=this;if(this.equals(I))return{x:0n,y:1n};let iz=invert(Z,P);modP(Z*iz)!==1n&&err("invalid inverse");let x=modP(X*iz),y=modP(Y*iz);return{x,y}}toBytes(){let{x,y}=this.toAffine(),b=numTo32bLE(y);return b[31]|=x&1n?128:0,b}toHex(){return bytesToHex(this.toBytes())}clearCofactor(){return this.multiply(big(h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let p=this.multiply(N/2n,!1).double();return N%2n&&(p=p.add(this)),p.is0()}},G=new Point(Gx,Gy,1n,M(Gx*Gy)),I=new Point(0n,1n,1n,0n);Point.BASE=G;Point.ZERO=I;var numTo32bLE=num=>hexToBytes(padh(assertRange(num,0n,B256),64)).reverse(),bytesToNumberLE=b=>big("0x"+bytesToHex(u8fr(abytes(b)).reverse())),pow2=(x,power)=>{let r=x;for(;power-- >0n;)r=modP(r*r);return r},pow_2_252_3=x=>{let x2=modP(x*x),b2=modP(x2*x),b4=modP(pow2(b2,2n)*b2),b5=modP(pow2(b4,1n)*x),b10=modP(pow2(b5,5n)*b5),b20=modP(pow2(b10,10n)*b10),b40=modP(pow2(b20,20n)*b20),b80=modP(pow2(b40,40n)*b40),b160=modP(pow2(b80,80n)*b80),b240=modP(pow2(b160,80n)*b80),b250=modP(pow2(b240,10n)*b10);return{pow_p_5_8:modP(pow2(b250,2n)*x),b2}},RM1=0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n,uvRatio=(u,v)=>{let v3=modP(v*modP(v*v)),v7=modP(modP(v3*v3)*v),pow=pow_2_252_3(modP(u*v7)).pow_p_5_8,x=modP(u*modP(v3*pow)),vx2=modP(v*modP(x*x)),root1=x,root2=modP(x*RM1),useRoot1=vx2===u,useRoot2=vx2===M(-u),noRoot=vx2===M(-u*RM1);return useRoot1&&(x=root1),(useRoot2||noRoot)&&(x=root2),(M(x)&1n)===1n&&(x=M(-x)),{isValid:useRoot1||useRoot2,value:x}},modL_LE=hash=>modN(bytesToNumberLE(hash)),sha512a=(...m)=>Promise.resolve(callHash("sha512Async")(concatBytes(...m))).then(checkDigest),sha512s=(...m)=>checkDigest(callHash("sha512")(concatBytes(...m))),hash2extK=hashed=>{let copy=u8fr(hashed),head=copy.slice(0,32);head[0]&=248,head[31]&=127,head[31]|=64;let prefix=copy.slice(32,64),scalar=modL_LE(head),point=G.multiply(scalar),pointBytes=point.toBytes();return{head,prefix,scalar,point,pointBytes}},getExtendedPublicKeyAsync=secretKey=>sha512a(abytes(secretKey,L)).then(hash2extK),getExtendedPublicKey=secretKey=>hash2extK(sha512s(abytes(secretKey,L))),getPublicKeyAsync=secretKey=>getExtendedPublicKeyAsync(secretKey).then(p=>p.pointBytes);var hashFinishA=res=>sha512a(res.hashable).then(res.finish);var _sign=(e,rBytes,msg)=>{let{pointBytes:P2,scalar:s}=e,r=modL_LE(rBytes),R=G.multiply(r).toBytes();return{hashable:concatBytes(R,P2,msg),finish:hashed=>{let S=modN(r+modL_LE(hashed)*s);return abytes(concatBytes(R,numTo32bLE(S)),64)}}},signAsync=async(message,secretKey)=>{let m=abytes(message),e=await getExtendedPublicKeyAsync(secretKey),rBytes=await sha512a(e.prefix,m);return hashFinishA(_sign(e,rBytes,m))};var hashes={sha512Async:async message=>{let s=subtle(),m=concatBytes(message);return u8n(await s.digest("SHA-512",m.buffer))},sha512:void 0},randomSecretKey=seed=>(seed=seed===void 0?randomBytes(L):seed,abytes(seed,L));var utils=Object.freeze({getExtendedPublicKeyAsync,getExtendedPublicKey,randomSecretKey}),W=8,scalarBits=256,pwindows=Math.ceil(scalarBits/W)+1,pwindowSize=2**(W-1),precompute=()=>{let points=[],p=G,b=p;for(let w=0;w{let n=p.negate();return cnd?n:p},wNAF=n=>{let comp=Gpows||(Gpows=precompute()),p=I,f=G,pow_2_w=2**W,maxNum=pow_2_w,mask=big(pow_2_w-1),shiftBy=big(W);for(let w=0;w>=shiftBy,wbits>pwindowSize&&(wbits-=maxNum,n+=1n);let off=w*pwindowSize,offF=off,offP=off+Math.abs(wbits)-1,isEven=w%2!==0,isNeg=wbits<0;wbits===0?f=f.add(ctneg(isEven,comp[offF])):p=p.add(ctneg(isNeg,comp[offP]))}return n!==0n&&err("invalid wnaf"),{p,f}};export{GATEWAY_CLIENT_CAPS,GATEWAY_CLIENT_IDS,GATEWAY_CLIENT_MODES,GatewayBrowserDeviceAuthLifecycle,GatewayProtocolClient,GatewayProtocolRequestError,MIN_CLIENT_PROTOCOL_VERSION,PROTOCOL_VERSION,utils as ed25519Utils,getPublicKeyAsync,signAsync}; diff --git a/packages/gateway-client/src/client.request-timeout.test.ts b/packages/gateway-client/src/client.request-timeout.test.ts new file mode 100644 index 000000000000..e9d4dbcfd0b3 --- /dev/null +++ b/packages/gateway-client/src/client.request-timeout.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { GatewayClient, GatewayClientRequestTimeoutError } from "./client.js"; +import type { GatewayProtocolSocket } from "./protocol-client.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +test("reports that a timed-out request crossed the transport send boundary", async () => { + vi.useFakeTimers(); + const client = new GatewayClient({ requestTimeoutMs: 100 }); + const send = vi.fn(); + const socket: GatewayProtocolSocket = { + isOpen: () => true, + send, + close: vi.fn(), + }; + Object.assign( + (client as unknown as { protocol: { socket: GatewayProtocolSocket | null } }).protocol, + { socket }, + ); + + const request = client.request("node.invoke", { nodeId: "node-1" }); + const outcome = request.catch((value: unknown) => value); + expect(send).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(100); + + const error = await outcome; + expect(error).toBeInstanceOf(GatewayClientRequestTimeoutError); + expect(error).toMatchObject({ + method: "node.invoke", + timeoutMs: 100, + requestSent: true, + }); +}); diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 20e0367827ea..a00669bc9c1a 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -37,12 +37,12 @@ import { import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; import { GatewayProtocolClient, - GatewayProtocolRequestError, type GatewayProtocolCloseContext, type GatewayProtocolRequestOptions, type GatewayProtocolSocket, type GatewayProtocolSocketHandlers, } from "./protocol-client.js"; +import { GatewayProtocolRequestError } from "./protocol-request.js"; import { shouldPauseGatewayReconnect } from "./reconnect-policy.js"; import { DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, @@ -248,6 +248,20 @@ export class GatewayClientRequestError extends GatewayProtocolRequestError { } } +export class GatewayClientRequestTimeoutError extends Error { + readonly method: string; + readonly timeoutMs: number; + readonly requestSent: boolean; + + constructor(params: { method: string; timeoutMs: number; requestSent: boolean }) { + super(`gateway request timeout for ${params.method}`); + this.name = "GatewayClientRequestTimeoutError"; + this.method = params.method; + this.timeoutMs = params.timeoutMs; + this.requestSent = params.requestSent; + } +} + class GatewayClientTransientPreHelloCloseError extends Error { constructor() { super("gateway transient pre-hello clean close"); @@ -402,7 +416,8 @@ export class GatewayClient { createSocket: (handlers) => this.createSocket(handlers), createRequestId: randomUUID, createRequestError: (error) => new GatewayClientRequestError(error), - createRequestTimeoutError: (method) => new Error(`gateway request timeout for ${method}`), + createRequestTimeoutError: (method, timeoutMs, requestSent) => + new GatewayClientRequestTimeoutError({ method, timeoutMs, requestSent }), createRequestAbortError: createGatewayRequestAbortError, buildConnectPlan: ({ nonce, challengeTs }) => { if (!nonce) { diff --git a/packages/gateway-client/src/protocol-client.ts b/packages/gateway-client/src/protocol-client.ts index 6cfe0b56cb63..6cdc9e6ee88b 100644 --- a/packages/gateway-client/src/protocol-client.ts +++ b/packages/gateway-client/src/protocol-client.ts @@ -84,7 +84,7 @@ type GatewayProtocolClientOptions = { createSocket: (handlers: GatewayProtocolSocketHandlers) => GatewayProtocolSocket; createRequestId: () => string; createRequestError?: (error: Partial) => GatewayProtocolRequestError; - createRequestTimeoutError?: (method: string, timeoutMs: number) => Error; + createRequestTimeoutError?: (method: string, timeoutMs: number, requestSent: boolean) => Error; createRequestAbortError?: (method: string) => Error; buildConnectPlan: (params: { nonce: string | null; @@ -226,6 +226,7 @@ export class GatewayProtocolClient { options?.timeoutMs === null ? undefined : (options?.timeoutMs ?? this.opts.requestTimeoutMs); return new Promise((resolve, reject) => { let timeout: ReturnType | undefined; + let requestSent = false; const pending: GatewayPendingRequest = { resolve: (value) => resolve(value as T), reject, @@ -238,9 +239,7 @@ export class GatewayProtocolClient { }; const onAbort = () => { this.pending.delete(id); - if (timeout) { - clearTimeout(timeout); - } + pending.cleanup?.(); this.finishRequestTiming(id, pending, false, "CLIENT_ABORTED"); reject( this.opts.createRequestAbortError?.(method) ?? @@ -263,11 +262,14 @@ export class GatewayProtocolClient { pending.cleanup = cleanup; if (timeoutMs !== undefined && timeoutMs >= 0) { timeout = setTimeout(() => { + if (this.pending.get(id) !== pending) { + return; + } this.pending.delete(id); options?.signal?.removeEventListener("abort", onAbort); this.finishRequestTiming(id, pending, false, "CLIENT_TIMEOUT"); reject( - this.opts.createRequestTimeoutError?.(method, timeoutMs) ?? + this.opts.createRequestTimeoutError?.(method, timeoutMs, requestSent) ?? new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`), ); }, timeoutMs); @@ -277,6 +279,7 @@ export class GatewayProtocolClient { this.pending.set(id, pending); try { socket.send(JSON.stringify({ type: "req", id, method, params })); + requestSent = true; this.invoke("sent", () => options?.onSent?.()); } catch (error) { this.pending.delete(id); diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index 066f19b3b053..9fb97e46ac8d 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -22,7 +22,7 @@ import type { OpenClawPluginNodeInvokePolicyContext } from "../plugins/types.js" import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; import { applyPluginNodeInvokePolicy } from "./node-invoke-plugin-policy.js"; -import type { NodeSession } from "./node-registry.js"; +import type { NodeInvokeResult, NodeSession } from "./node-registry.js"; import { listPendingOperatorApprovals } from "./operator-approval-store.js"; import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; @@ -67,12 +67,19 @@ function createContext(opts?: { pluginApprovalIosPushDelivery?: GatewayRequestContext["pluginApprovalIosPushDelivery"]; }) { const nodeSession = opts?.nodeSession ?? createNodeSession(); - const invoke = vi.fn(async () => ({ - ok: true, - payload: { ok: true, value: 1 }, - payloadJSON: null, - error: null, - })); + const invoke = vi.fn( + async (params?: { + onDispatchReady?: (invokeId: string) => void; + }): Promise => { + params?.onDispatchReady?.("invoke-1"); + return { + ok: true, + payload: { ok: true, value: 1 }, + payloadJSON: null, + error: null, + }; + }, + ); return { context: { getRuntimeConfig: @@ -272,6 +279,7 @@ describe("applyPluginNodeInvokePolicy", () => { params: DEMO_PARAMS, timeoutMs: undefined, idempotencyKey: undefined, + onDispatchReady: expect.any(Function), }); }); @@ -304,14 +312,15 @@ describe("applyPluginNodeInvokePolicy", () => { }, ); - it("marks plugin-owned work dispatched before calling the node transport", async () => { + it("marks plugin-owned work dispatched only after the node transport accepts it", async () => { setDangerousDemoCommandRegistry([ createDemoPolicy((ctx: OpenClawPluginNodeInvokePolicyContext) => ctx.invokeNode()), ]); const { context, invoke } = createContext(); const dispatchOrder: string[] = []; - invoke.mockImplementationOnce(async () => { + invoke.mockImplementationOnce(async (params) => { dispatchOrder.push("node transport"); + params?.onDispatchReady?.("invoke-1"); return { ok: true, payload: { ok: true, value: 1 }, @@ -330,7 +339,43 @@ describe("applyPluginNodeInvokePolicy", () => { }); expect(result).toMatchObject({ ok: true }); - expect(dispatchOrder).toStrictEqual(["dispatched", "node transport"]); + expect(dispatchOrder).toStrictEqual(["node transport", "dispatched"]); + }); + + it("keeps plugin-owned work pre-dispatch when the node transport rejects the send", async () => { + setDangerousDemoCommandRegistry([ + createDemoPolicy((ctx: OpenClawPluginNodeInvokePolicyContext) => ctx.invokeNode()), + ]); + const { context, invoke } = createContext(); + const onNodeCommandDispatched = vi.fn(); + invoke.mockResolvedValueOnce({ + ok: false, + payload: null, + payloadJSON: null, + error: { code: "UNAVAILABLE", message: "failed to send invoke to node" }, + }); + + const result = await applyPluginNodeInvokePolicy({ + context, + client: null, + nodeSession: createNodeSession(), + command: DEMO_COMMAND, + params: DEMO_PARAMS, + onNodeCommandDispatched, + }); + + expect(result).toMatchObject({ + ok: false, + code: "UNAVAILABLE", + details: { + nodeError: { code: "UNAVAILABLE", message: "failed to send invoke to node" }, + nodeCommandDispatched: false, + }, + }); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ onDispatchReady: expect.any(Function) }), + ); + expect(onNodeCommandDispatched).not.toHaveBeenCalled(); }); it("rejects expired plugin-owned work without dispatching it", async () => { diff --git a/src/gateway/node-invoke-plugin-policy.ts b/src/gateway/node-invoke-plugin-policy.ts index 1ba6e53e2a5b..260bf787c25e 100644 --- a/src/gateway/node-invoke-plugin-policy.ts +++ b/src/gateway/node-invoke-plugin-policy.ts @@ -282,10 +282,6 @@ export async function applyPluginNodeInvokePolicy(params: { ? Math.min(requestedTimeoutMs, remainingTimeoutMs) : remainingTimeoutMs : requestedTimeoutMs; - // Once the registry owns the request, any failure is ambiguous to callers: - // the node may have acted before the response was lost or rejected. - nodeCommandDispatched = true; - params.onNodeCommandDispatched?.(); const res = await params.context.nodeRegistry.invoke({ nodeId: params.nodeSession.nodeId, expectedConnId: params.nodeSession.connId, @@ -297,6 +293,12 @@ export async function applyPluginNodeInvokePolicy(params: { timeoutMs, ...(params.signal ? { signal: params.signal } : {}), idempotencyKey: override.idempotencyKey ?? params.idempotencyKey, + onDispatchReady: () => { + // Only the registry knows that the transport send succeeded. Preserve + // pre-send failures as retry-safe while making later failures ambiguous. + nodeCommandDispatched = true; + params.onNodeCommandDispatched?.(); + }, }); if (!res.ok) { return { diff --git a/src/gateway/node-registry.invoke-stream.ts b/src/gateway/node-registry.invoke-stream.ts index 28827a3b6ffa..8bf0a302a6bf 100644 --- a/src/gateway/node-registry.invoke-stream.ts +++ b/src/gateway/node-registry.invoke-stream.ts @@ -16,6 +16,7 @@ export type PendingInvoke = { error?: { code?: string; message?: string } | null; }) => void; reject: (err: Error) => void; + deadlineAtMs?: number; hardTimer?: ReturnType; idleTimer?: ReturnType; idleTimeoutMs?: number; @@ -93,9 +94,14 @@ export class NodeInvokeStreamController { if (pending.connId !== connId) { continue; } - this.clearTimers(pending); + if (pending.deadlineAtMs !== undefined && Date.now() >= pending.deadlineAtMs) { + this.settleTimeout(id, pending); + continue; + } + if (!this.takePending(id, pending)) { + continue; + } this.options.disconnectPending(pending); - this.options.pendingInvokes.delete(id); } } @@ -104,8 +110,13 @@ export class NodeInvokeStreamController { if (!pending || pending.nodeId !== params.nodeId || pending.connId !== params.connId) { return false; } - this.clearTimers(pending); - this.options.pendingInvokes.delete(params.id); + if (pending.deadlineAtMs !== undefined && Date.now() >= pending.deadlineAtMs) { + this.settleTimeout(params.id, pending); + return false; + } + if (!this.takePending(params.id, pending)) { + return false; + } if (!params.ok) { this.options.onFailedResult(pending); } @@ -125,29 +136,31 @@ export class NodeInvokeStreamController { idleTimeoutMs: number; signal?: AbortSignal; }): void { + if (params.timeoutMs > 0) { + params.pending.deadlineAtMs = Date.now() + params.timeoutMs; + } + this.options.pendingInvokes.set(params.requestId, params.pending); if (params.timeoutMs > 0) { params.pending.hardTimer = setTimeout(() => { - this.sendInvokeCancel(params.requestId, params.pending); - this.clearTimers(params.pending); - this.options.pendingInvokes.delete(params.requestId); - params.pending.resolve({ - ok: false, - error: { code: "TIMEOUT", message: "node invoke timed out" }, - }); + this.settleTimeout(params.requestId, params.pending); }, params.timeoutMs); } if (params.pending.onProgress && params.idleTimeoutMs > 0) { params.pending.idleTimeoutMs = params.idleTimeoutMs; } - this.options.pendingInvokes.set(params.requestId, params.pending); if (params.signal) { const onAbort = () => { - if (this.options.pendingInvokes.get(params.requestId) !== params.pending) { + if ( + params.pending.deadlineAtMs !== undefined && + Date.now() >= params.pending.deadlineAtMs + ) { + this.settleTimeout(params.requestId, params.pending); + return; + } + if (!this.takePending(params.requestId, params.pending)) { return; } this.sendInvokeCancel(params.requestId, params.pending); - this.clearTimers(params.pending); - this.options.pendingInvokes.delete(params.requestId); params.pending.resolve({ ok: false, error: { code: "ABORTED", message: "node invoke cancelled" }, @@ -225,12 +238,10 @@ export class NodeInvokeStreamController { private createIdleTimer(requestId: string, pending: PendingInvoke) { return setTimeout(() => { - if (this.options.pendingInvokes.get(requestId) !== pending) { + if (!this.takePending(requestId, pending)) { return; } this.sendInvokeCancel(requestId, pending); - this.clearTimers(pending); - this.options.pendingInvokes.delete(requestId); pending.resolve({ ok: false, error: { code: "IDLE_TIMEOUT", message: "node invoke produced no progress" }, @@ -251,4 +262,24 @@ export class NodeInvokeStreamController { private sendInvokeCancel(requestId: string, pending: PendingInvoke): void { this.options.sendCancel(requestId, pending); } + + private settleTimeout(requestId: string, pending: PendingInvoke): void { + if (!this.takePending(requestId, pending)) { + return; + } + this.sendInvokeCancel(requestId, pending); + pending.resolve({ + ok: false, + error: { code: "TIMEOUT", message: "node invoke timed out" }, + }); + } + + private takePending(requestId: string, pending: PendingInvoke): boolean { + if (this.options.pendingInvokes.get(requestId) !== pending) { + return false; + } + this.options.pendingInvokes.delete(requestId); + this.clearTimers(pending); + return true; + } } diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 093dca444dc8..75e290df6944 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -987,7 +987,6 @@ describe("gateway/node-registry", () => { command: "debug.ping", timeoutMs: 0, }); - const disconnected = invoke.catch((error: unknown) => error); const request = JSON.parse(previousFrames[0] ?? "{}") as { payload?: { id?: string }; }; @@ -1009,7 +1008,13 @@ describe("gateway/node-registry", () => { message: "node connection changed during connectivity probe", }, }); - await expect(disconnected).resolves.toEqual(new Error("node disconnected (debug.ping)")); + await expect(invoke).resolves.toEqual({ + ok: false, + error: { + code: "DISCONNECTED", + message: "node disconnected (debug.ping)", + }, + }); expect( registry.handleInvokeResult({ id: request.payload?.id ?? "", @@ -1051,7 +1056,6 @@ describe("gateway/node-registry", () => { command: "system.run", timeoutMs: 0, }); - const oldDisconnected = oldInvoke.catch((err: unknown) => err); const oldRequest = JSON.parse(oldFrames[0] ?? "{}") as { payload?: { id?: string } }; const newSession = registerNodeSession(registry, newClient, {}); @@ -1063,7 +1067,13 @@ describe("gateway/node-registry", () => { ok: true, }), ).toBe(false); - await expect(oldDisconnected).resolves.toEqual(new Error("node disconnected (system.run)")); + await expect(oldInvoke).resolves.toEqual({ + ok: false, + error: { + code: "DISCONNECTED", + message: "node disconnected (system.run)", + }, + }); expect(registry.get("node-1")).toBe(newSession); expect(registry.unregister("conn-old")).toBeNull(); expect(registry.get("node-1")).toBe(newSession); @@ -1095,6 +1105,7 @@ describe("gateway/node-registry", () => { it("rejects invoke when the node connection changed before dispatch", async () => { const registry = createNodeRegistry(); const replacementFrames: string[] = []; + const onDispatchReady = vi.fn(); registerNodeSession(registry, makeClient("conn-old", "node-1"), {}); registerNodeSession(registry, makeClient("conn-new", "node-1", replacementFrames), {}); @@ -1103,12 +1114,14 @@ describe("gateway/node-registry", () => { nodeId: "node-1", expectedConnId: "conn-old", command: "system.run", + onDispatchReady, }), ).resolves.toEqual({ ok: false, error: { code: "ROUTE_CHANGED", message: "node connection changed before dispatch" }, }); expect(replacementFrames).toEqual([]); + expect(onDispatchReady).not.toHaveBeenCalled(); }); it("matches pending system.run events to the issuing connection", async () => { @@ -1280,7 +1293,7 @@ describe("gateway/node-registry", () => { await expect(invoke).resolves.toMatchObject({ ok: true }); }); - it("rejects zero-timeout invokes when the node disconnects", async () => { + it("returns a structured result when a zero-timeout invoke disconnects", async () => { const registry = createNodeRegistry(); registerNode(registry); const invoke = registry.invoke({ @@ -1288,12 +1301,120 @@ describe("gateway/node-registry", () => { command: "debug.ping", timeoutMs: 0, }); - const disconnected = invoke.catch((error: unknown) => error); expect(registry.unregister("conn-1")).toBe("node-1"); - const error = await disconnected; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe("node disconnected (debug.ping)"); + await expect(invoke).resolves.toEqual({ + ok: false, + error: { + code: "DISCONNECTED", + message: "node disconnected (debug.ping)", + }, + }); + }); + + it("accepts results before the hard deadline and times out results at the deadline", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const registry = createNodeRegistry(); + const frames = registerNode(registry); + const beforeDispatch = vi.fn(); + + const beforeDeadline = registry.invoke({ + nodeId: "node-1", + command: "debug.ping", + timeoutMs: 100, + onDispatchReady: beforeDispatch, + }); + const beforeRequest = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } }; + vi.setSystemTime(1_099); + expect( + registry.handleInvokeResult({ + id: beforeRequest.payload?.id ?? "", + nodeId: "node-1", + connId: "conn-1", + ok: true, + }), + ).toBe(true); + expect(beforeDispatch).toHaveBeenCalledOnce(); + await expect(beforeDeadline).resolves.toMatchObject({ ok: true }); + + vi.setSystemTime(2_000); + const atDeadline = registry.invoke({ + nodeId: "node-1", + command: "debug.ping", + timeoutMs: 100, + }); + const atRequest = JSON.parse(frames[1] ?? "{}") as { payload?: { id?: string } }; + vi.setSystemTime(2_100); + const terminalResult = { + id: atRequest.payload?.id ?? "", + nodeId: "node-1", + connId: "conn-1", + ok: true, + }; + + expect(registry.handleInvokeResult(terminalResult)).toBe(false); + expect(registry.handleInvokeResult(terminalResult)).toBe(false); + await expect(atDeadline).resolves.toEqual({ + ok: false, + error: { code: "TIMEOUT", message: "node invoke timed out" }, + }); + }); + + it("prefers an elapsed hard deadline when disconnect beats the timer callback", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const registry = createNodeRegistry(); + registerNode(registry); + const invoke = registry.invoke({ + nodeId: "node-1", + command: "debug.ping", + timeoutMs: 100, + }); + + vi.setSystemTime(1_100); + expect(registry.unregister("conn-1")).toBe("node-1"); + + await expect(invoke).resolves.toEqual({ + ok: false, + error: { code: "TIMEOUT", message: "node invoke timed out" }, + }); + }); + + it("prefers an elapsed hard deadline when abort beats the timer callback", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const registry = createNodeRegistry(); + registerNode(registry); + + const beforeDeadlineController = new AbortController(); + const beforeDeadline = registry.invoke({ + nodeId: "node-1", + command: "debug.ping", + timeoutMs: 100, + signal: beforeDeadlineController.signal, + }); + vi.setSystemTime(1_099); + beforeDeadlineController.abort(); + await expect(beforeDeadline).resolves.toEqual({ + ok: false, + error: { code: "ABORTED", message: "node invoke cancelled" }, + }); + + vi.setSystemTime(2_000); + const atDeadlineController = new AbortController(); + const atDeadline = registry.invoke({ + nodeId: "node-1", + command: "debug.ping", + timeoutMs: 100, + signal: atDeadlineController.signal, + }); + vi.setSystemTime(2_100); + atDeadlineController.abort(); + await expect(atDeadline).resolves.toEqual({ + ok: false, + error: { code: "TIMEOUT", message: "node invoke timed out" }, + }); }); it("orders streamed invoke progress and drops state after the final result", async () => { @@ -1561,12 +1682,17 @@ describe("gateway/node-registry", () => { idleTimeoutMs: 100, onProgress: () => {}, }); - const disconnected = invoke.catch((error: unknown) => error); const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } }; const invokeId = request.payload?.id ?? ""; expect(registry.unregister("conn-1")).toBe("node-1"); - await expect(disconnected).resolves.toBeInstanceOf(Error); + await expect(invoke).resolves.toEqual({ + ok: false, + error: { + code: "DISCONNECTED", + message: "node disconnected (agent.cli.claude.run.v1)", + }, + }); expect( registry.handleInvokeProgress({ invokeId, diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index fda6e489b4fd..417614ccd132 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -302,7 +302,13 @@ export class NodeRegistry { }, }); } else { - pending.reject(new Error(`node disconnected (${pending.command})`)); + pending.resolve({ + ok: false, + error: { + code: "DISCONNECTED", + message: `node disconnected (${pending.command})`, + }, + }); } }, }); diff --git a/src/gateway/server-methods/exec-approvals.test.ts b/src/gateway/server-methods/exec-approvals.test.ts index ad24cba652f5..1575ffbc4015 100644 --- a/src/gateway/server-methods/exec-approvals.test.ts +++ b/src/gateway/server-methods/exec-approvals.test.ts @@ -329,6 +329,7 @@ describe("exec approvals gateway methods", () => { expectedPairingGeneration: "generation-1", command, params: { includeResolvedDefaults: true }, + onDispatchReady: expect.any(Function), }); expect(respond).toHaveBeenCalledWith(true, payload, undefined); }); @@ -419,6 +420,7 @@ describe("exec approvals gateway methods", () => { expectedPairingGeneration: "generation-1", command, params: {}, + onDispatchReady: expect.any(Function), }); expect(respond).toHaveBeenCalledWith(true, payload, undefined); }); @@ -480,6 +482,7 @@ describe("exec approvals gateway methods", () => { rules: [{ pattern: "hostname", action: "allow" }], baseHash: "sha256:current", }, + onDispatchReady: expect.any(Function), }); expect(respond).toHaveBeenCalledWith(true, { updated: true, hash: "sha256:next" }, undefined); }); @@ -560,6 +563,7 @@ describe("exec approvals gateway methods", () => { nodeId: "missing-node", command: "system.execApprovals.get", params: {}, + onDispatchReady: expect.any(Function), }); expect(respond).toHaveBeenCalledWith( false, @@ -568,6 +572,7 @@ describe("exec approvals gateway methods", () => { code: "UNAVAILABLE", details: { nodeError: { code: "NOT_CONNECTED", message: "node not connected" }, + nodeCommandDispatched: false, }, }), ); diff --git a/src/gateway/server-methods/exec-approvals.ts b/src/gateway/server-methods/exec-approvals.ts index 306c9cc4c5aa..dcfbdf45a033 100644 --- a/src/gateway/server-methods/exec-approvals.ts +++ b/src/gateway/server-methods/exec-approvals.ts @@ -26,7 +26,7 @@ import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-comma import type { NodeSession } from "../node-registry.js"; import { resolveBaseHashParam } from "./base-hash.js"; import { - respondUnavailableOnNodeInvokeError, + respondUnavailableOnNodeInvokeErrorWithProvenance, respondUnavailableOnThrow, safeParseJson, } from "./nodes.helpers.js"; @@ -164,6 +164,7 @@ async function respondWithExecApprovalsNodePayload { + let nodeCommandDispatched = false; const res = await params.context.nodeRegistry.invoke({ nodeId, ...(nodeSession @@ -176,8 +177,15 @@ async function respondWithExecApprovalsNodePayload { + nodeCommandDispatched = true; + }, }); - if (!respondUnavailableOnNodeInvokeError(params.respond, res)) { + if ( + !respondUnavailableOnNodeInvokeErrorWithProvenance(params.respond, res, { + nodeCommandDispatched, + }) + ) { return; } const payload = params.readPayload(res); diff --git a/src/gateway/server-methods/nodes.helpers.test.ts b/src/gateway/server-methods/nodes.helpers.test.ts new file mode 100644 index 000000000000..231887def0a0 --- /dev/null +++ b/src/gateway/server-methods/nodes.helpers.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { respondUnavailableOnNodeInvokeErrorWithProvenance } from "./nodes.helpers.js"; +import type { RespondFn } from "./types.js"; + +function createRespond(): ReturnType> { + return vi.fn(); +} + +describe("respondUnavailableOnNodeInvokeErrorWithProvenance", () => { + it("propagates proven pre-dispatch provenance", () => { + const respond = createRespond(); + + expect( + respondUnavailableOnNodeInvokeErrorWithProvenance( + respond, + { + ok: false, + error: { code: "NOT_CONNECTED", message: "node not connected" }, + }, + { nodeCommandDispatched: false }, + ), + ).toBe(false); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + details: { + nodeError: { code: "NOT_CONNECTED", message: "node not connected" }, + nodeCommandDispatched: false, + }, + }), + ); + }); + + it.each(["TIMEOUT", "DISCONNECTED"])("propagates post-dispatch provenance for %s", (code) => { + const respond = createRespond(); + + respondUnavailableOnNodeInvokeErrorWithProvenance( + respond, + { + ok: false, + error: { code, message: "terminal node outcome" }, + }, + { nodeCommandDispatched: true }, + ); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + details: { + nodeError: { code, message: "terminal node outcome" }, + nodeCommandDispatched: true, + }, + }), + ); + }); +}); diff --git a/src/gateway/server-methods/nodes.helpers.ts b/src/gateway/server-methods/nodes.helpers.ts index 115d5c9fe0bc..2095bd5135e6 100644 --- a/src/gateway/server-methods/nodes.helpers.ts +++ b/src/gateway/server-methods/nodes.helpers.ts @@ -49,6 +49,16 @@ export async function respondUnavailableOnThrow(respond: RespondFn, fn: () => Pr export function respondUnavailableOnNodeInvokeError( respond: RespondFn, res: T, +): res is T & { ok: true } { + return respondUnavailableOnNodeInvokeErrorWithProvenance(respond, res); +} + +export function respondUnavailableOnNodeInvokeErrorWithProvenance< + T extends { ok: boolean; error?: unknown }, +>( + respond: RespondFn, + res: T, + provenance?: { nodeCommandDispatched: boolean }, ): res is T & { ok: true } { if (res.ok) { return true; @@ -60,11 +70,15 @@ export function respondUnavailableOnNodeInvokeError { expect(call[0]).toBe(false); expect(call[2]?.code).toBe(ErrorCodes.UNAVAILABLE); expect(call[2]?.message).toBe("node not connected"); + expect(call[2]?.details).toEqual({ + code: "NOT_CONNECTED", + nodeError: { code: "NOT_CONNECTED", message: "node not connected" }, + nodeCommandDispatched: false, + }); expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled(); expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-methods/nodes.invoke.ts b/src/gateway/server-methods/nodes.invoke.ts index 3dd02dd599f7..8048c453876c 100644 --- a/src/gateway/server-methods/nodes.invoke.ts +++ b/src/gateway/server-methods/nodes.invoke.ts @@ -29,7 +29,7 @@ import { handleNodeInvokeProgress } from "./nodes.handlers.invoke-progress.js"; import { handleNodeInvokeResult } from "./nodes.handlers.invoke-result.js"; import { respondInvalidParams, - respondUnavailableOnNodeInvokeError, + respondUnavailableOnNodeInvokeErrorWithProvenance, respondUnavailableOnThrow, safeParseJson, } from "./nodes.helpers.js"; @@ -196,30 +196,21 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { } const invokeDeadlineAtMs = typeof p.timeoutMs === "number" && p.timeoutMs > 0 ? Date.now() + p.timeoutMs : undefined; - let pluginNodeCommandDispatched = false; + let nodeCommandDispatched = false; const resolveRemainingInvokeTimeoutMs = () => invokeDeadlineAtMs === undefined ? p.timeoutMs : Math.max(0, invokeDeadlineAtMs - Date.now()); - const respondIfInvokeExpired = (includeDispatchState = false) => { + const respondIfInvokeExpired = () => { if (invokeDeadlineAtMs === undefined || resolveRemainingInvokeTimeoutMs() !== 0) { return false; } - if (pluginNodeCommandDispatched || includeDispatchState) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "TIMEOUT: node invoke timed out", { - details: { - nodeError: { code: "TIMEOUT", message: "node invoke timed out" }, - nodeCommandDispatched: pluginNodeCommandDispatched, - }, - }), - ); - return true; - } - respondUnavailableOnNodeInvokeError(respond, { - ok: false, - error: { code: "TIMEOUT", message: "node invoke timed out" }, - }); + respondUnavailableOnNodeInvokeErrorWithProvenance( + respond, + { + ok: false, + error: { code: "TIMEOUT", message: "node invoke timed out" }, + }, + { nodeCommandDispatched }, + ); return true; }; await respondUnavailableOnThrow(respond, async () => { @@ -406,7 +397,11 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { - details: { code: "NOT_CONNECTED" }, + details: { + code: "NOT_CONNECTED", + nodeError: { code: "NOT_CONNECTED", message: "node not connected" }, + nodeCommandDispatched: false, + }, }), ); return; @@ -487,7 +482,7 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { onNodeCommandDispatched: () => { // Deadline races must retain transport ownership so a command // already handed to the node is never advertised as retry-safe. - pluginNodeCommandDispatched = true; + nodeCommandDispatched = true; }, idempotencyKey: p.idempotencyKey, isInvocationCurrent: () => @@ -496,7 +491,7 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { invokeDeadlineAtMs, ); if (policyResult === NODE_INVOKE_DEADLINE_EXPIRED) { - respondIfInvokeExpired(true); + respondIfInvokeExpired(); return; } if (!(await continuePairingWork())) { @@ -599,6 +594,9 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { signal: invocationLifecycle, idempotencyKey: p.idempotencyKey, ...(sessionKey ? { sessionKey } : {}), + onDispatchReady: () => { + nodeCommandDispatched = true; + }, }); if (!(await continuePairingWork())) { return; @@ -670,7 +668,11 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { ); return; } - if (!respondUnavailableOnNodeInvokeError(respond, res)) { + if ( + !respondUnavailableOnNodeInvokeErrorWithProvenance(respond, res, { + nodeCommandDispatched, + }) + ) { return; } return; diff --git a/src/gateway/watch-node-http.test.ts b/src/gateway/watch-node-http.test.ts index 9fe009247ac4..12732f0384c7 100644 --- a/src/gateway/watch-node-http.test.ts +++ b/src/gateway/watch-node-http.test.ts @@ -476,7 +476,6 @@ describe("watch node HTTP transport", () => { command: "device.info", timeoutMs: 2_000, }); - const invokeAfterDisconnect = invoke.catch((error: unknown) => error); const pollResponse = await fetch(`${baseUrl}/poll`, { method: "POST", headers: { authorization: `Bearer ${String(connected.sessionToken)}` }, @@ -517,7 +516,13 @@ describe("watch node HTTP transport", () => { nodeId: identity.deviceId, reason: "node pairing changed", }); - await expect(invokeAfterDisconnect).resolves.toBeInstanceOf(Error); + await expect(invoke).resolves.toEqual({ + ok: false, + error: { + code: "DISCONNECTED", + message: "node disconnected (device.info)", + }, + }); runtime.close(); });