diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index bfe9bb093491..ec124958fcd6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -437,7 +437,6 @@ src/agents/openclaw-tools.media-factory-plan.test.ts src/agents/openclaw-tools.session-status.test.ts src/agents/openclaw-tools.sessions.test.ts src/agents/provider-attribution.test.ts -src/agents/provider-local-service.ts src/agents/provider-request-config.ts src/agents/provider-transport-fetch.test.ts src/agents/provider-transport-fetch.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 64f9a0c1353b..ae9cfe6a754e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -74,7 +74,7 @@ d117ebba8cc490501725778676a9d75855872b6e5fe2b2f64b1270d4808a2277 module/health f6e3c44e7d1090a97aca554a3c247219b8de78b3cb4399cac5efde8a0a6c1156 module/inbound-envelope 1d5ca69fcf2a8476a2309dcd830fc3bbae04b7029fa30eea1081b6590a789f10 module/inbound-event-delivery 36721c58f479fe9ca32737f67850f4607bf8753ef6b193ff129e7a9e5fdbc6bd module/inbound-reply-dispatch -c87e2ccdecbc69f2e2648441dc7a342828b06fe89d30c1523bcd8738b5158754 module/infra-runtime +fe7172bcab3d1adf00e7bcfce6beaea9f3b231df251d5e5ad8dc2d20abd90eca module/infra-runtime 2e717cccb3db127aed0287d4ea14c41a8e64e46d60c728638153e31e2fb0d296 module/ingress-effect-once 9389d91a090259f06e5ec2aae14c9857e45988d4edc4f91170cb8bc573b966a2 module/interactive-runtime 9dd66baf2def46386ad4706380e57f9068fa9bb3878be2d9ba961ec2f46d3d87 module/json-store diff --git a/extensions/browser/chrome-extension/modules/copilot-runtime.js b/extensions/browser/chrome-extension/modules/copilot-runtime.js index 0bb848756a49..0959c7ce09a1 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,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}; +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=()=>{if(settled)return;settled=!0,timer&&clearTimeout(timer),timer=null,cleanup();let error=new Error("aborted",{cause:abortSignal?.reason??new Error("aborted")});error.name="AbortError",reject(error)};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/retry/src/index.test.ts b/packages/retry/src/index.test.ts index 93464cea61ba..3a36ac9706ce 100644 --- a/packages/retry/src/index.test.ts +++ b/packages/retry/src/index.test.ts @@ -51,9 +51,14 @@ describe("RetrySupervisor", () => { }); const retry = supervisor.next(); const wait = sleepWithAbort(retry?.delayMs ?? 0, retry?.signal); - supervisor.cancel(new Error("stop")); + const reason = new Error("stop"); + supervisor.cancel(reason); - await expect(wait).rejects.toMatchObject({ message: "aborted" }); + await expect(wait).rejects.toMatchObject({ + name: "AbortError", + message: "aborted", + cause: reason, + }); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -69,7 +74,7 @@ describe("RetrySupervisor", () => { expect(timer?.hasRef()).toBe(false); controller.abort(); - await expect(sleeper).rejects.toMatchObject({ message: "aborted" }); + await expect(sleeper).rejects.toMatchObject({ name: "AbortError", message: "aborted" }); } finally { controller.abort(); setTimeoutSpy.mockRestore(); diff --git a/packages/retry/src/index.ts b/packages/retry/src/index.ts index 4aa7089e3577..c7b7211400cd 100644 --- a/packages/retry/src/index.ts +++ b/packages/retry/src/index.ts @@ -41,7 +41,12 @@ export async function sleepWithAbort( } timer = null; cleanup(); - reject(new Error("aborted", { cause: abortSignal?.reason ?? new Error("aborted") })); + // This leaf package cannot import the host abort helper; preserve its contract here. + const error = new Error("aborted", { + cause: abortSignal?.reason ?? new Error("aborted"), + }); + error.name = "AbortError"; + reject(error); }; abortSignal?.addEventListener("abort", onAbort, { once: true }); if (abortSignal?.aborted) { diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 2f0532310dd4..ddc36e2a4085 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -292,7 +292,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", // -1: text-runtime now names its global-singleton exports explicitly. - 81, + // -1: infra-runtime now names its error exports explicitly. + 80, env, ), }; diff --git a/src/agents/acp-spawn-runtime.ts b/src/agents/acp-spawn-runtime.ts index aaa7970837ef..c4a7dea60c99 100644 --- a/src/agents/acp-spawn-runtime.ts +++ b/src/agents/acp-spawn-runtime.ts @@ -19,6 +19,7 @@ import { resolveStorePath } from "../config/sessions/paths.js"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isMissingPathError } from "../infra/errors.js"; import { getSessionBindingService, type SessionBindingRecord, @@ -39,11 +40,6 @@ export function resolveAcpSessionMode(mode: "run" | "session"): AcpRuntimeSessio return mode === "session" ? "persistent" : "oneshot"; } -function isMissingPathError(error: unknown): boolean { - const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; - return code === "ENOENT" || code === "ENOTDIR"; -} - export async function resolveRuntimeCwdForAcpSpawn(params: { resolvedCwd?: string; explicitCwd?: string; diff --git a/src/agents/agent-tools.read.ts b/src/agents/agent-tools.read.ts index 06e8d3e326a1..5d574b39f556 100644 --- a/src/agents/agent-tools.read.ts +++ b/src/agents/agent-tools.read.ts @@ -8,7 +8,7 @@ import { URL } from "node:url"; import { detectMime } from "@openclaw/media-core/mime"; import { formatByteSize } from "@openclaw/normalization-core"; import { isWindowsDrivePath } from "../infra/archive-path.js"; -import { toErrorObject } from "../infra/errors.js"; +import { isMissingPathError, toErrorObject } from "../infra/errors.js"; import { canonicalPathFromExistingAncestor, root as fsRoot, @@ -268,9 +268,10 @@ function normalizeDailyMemoryReadPath(value: unknown): string | undefined { } function isNotFoundError(error: unknown): boolean { - if (typeof (error as NodeJS.ErrnoException | undefined)?.code === "string") { - return (error as NodeJS.ErrnoException).code === "ENOENT"; + if (isMissingPathError(error)) { + return true; } + // Injected tool implementations may expose only their legacy human-readable error. if (!(error instanceof Error)) { return false; } diff --git a/src/agents/apply-patch-file-ops.ts b/src/agents/apply-patch-file-ops.ts index fdde45ab36c1..8688df19816d 100644 --- a/src/agents/apply-patch-file-ops.ts +++ b/src/agents/apply-patch-file-ops.ts @@ -227,5 +227,16 @@ function assertBoundaryRead( return; } const reason = opened.reason === "validation" ? "unsafe path" : "path not found"; - throw new Error(`Failed boundary read for ${targetPath} (${reason})`); + const error = new Error(`Failed boundary read for ${targetPath} (${reason})`) as Error & { + code?: string; + }; + const sourceCode = + opened.error && typeof opened.error === "object" && "code" in opened.error + ? opened.error.code + : undefined; + if (sourceCode === "ENOENT" || sourceCode === "ENOTDIR") { + // Preserve the producer's classification so provenance observers do not parse messages. + error.code = sourceCode; + } + throw error; } diff --git a/src/agents/identity-avatar-file.ts b/src/agents/identity-avatar-file.ts index 74f926c93bf5..8e6cf5ed11ba 100644 --- a/src/agents/identity-avatar-file.ts +++ b/src/agents/identity-avatar-file.ts @@ -5,6 +5,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFileSync } from "../infra/boundary-file-read.js"; import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; import { isRenderableAvatarImageDataUrl } from "../shared/avatar-limits.js"; import { AVATAR_MAX_BYTES, @@ -43,14 +44,6 @@ type LocalAgentAvatarPath = { workspaceRoot: string; }; -function resolveExistingPath(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } -} - /** Resolve one local avatar source while retaining its canonical workspace root. */ export function resolveLocalAgentAvatarPath(params: { raw: string; @@ -58,12 +51,12 @@ export function resolveLocalAgentAvatarPath(params: { }): | { ok: true; value: LocalAgentAvatarPath } | { ok: false; reason: LocalAgentAvatarFailureReason } { - const workspaceRoot = resolveExistingPath(params.workspaceDir); + const workspaceRoot = resolveRealpathOrAbsolute(params.workspaceDir); const resolved = params.raw.startsWith("~") || path.isAbsolute(params.raw) ? resolveUserPath(params.raw) : path.resolve(workspaceRoot, params.raw); - const filePath = resolveExistingPath(resolved); + const filePath = resolveRealpathOrAbsolute(resolved); if (!isPathWithinRoot(workspaceRoot, filePath)) { return { ok: false, reason: "outside_workspace" }; } diff --git a/src/agents/memory-write-provenance.ts b/src/agents/memory-write-provenance.ts index 7b0b5a8eee2e..a9fc78408546 100644 --- a/src/agents/memory-write-provenance.ts +++ b/src/agents/memory-write-provenance.ts @@ -1,5 +1,6 @@ import { realpathSync } from "node:fs"; import path from "node:path"; +import { isMissingPathError } from "../infra/errors.js"; import { logWarn } from "../logger.js"; import type { MemoryFlushPlan } from "../plugins/memory-state.js"; @@ -20,11 +21,6 @@ type ProvenanceWriteOperations = { remove?: (absolutePath: string) => Promise; }; -function isMissingFileError(error: unknown): boolean { - const code = (error as { code?: unknown } | undefined)?.code; - return code === "ENOENT" || code === "not-found" || String(error).includes("(path not found)"); -} - export function withMemoryWriteProvenance( operations: T, observer: MemoryWriteProvenanceObserver | undefined, @@ -44,7 +40,7 @@ export function withMemoryWriteProvenance( .readFile(absolutePath) .then((value) => (Buffer.isBuffer(value) ? value.toString("utf8") : value)) .catch((error: unknown) => { - if (!isMissingFileError(error)) { + if (!isMissingPathError(error)) { throw error; } return ""; diff --git a/src/agents/provider-local-service.ts b/src/agents/provider-local-service.ts index 97a7cde78523..5e2dd6066fe7 100644 --- a/src/agents/provider-local-service.ts +++ b/src/agents/provider-local-service.ts @@ -10,6 +10,7 @@ import { clampPositiveTimerTimeoutMs, resolvePositiveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; +import { sleepWithAbort } from "@openclaw/retry"; import type { ModelProviderLocalServiceConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { toErrorObject } from "../infra/errors.js"; @@ -505,7 +506,7 @@ async function startAndWaitForLocalService(params: { if (Date.now() >= deadline) { throw new Error(`${provider} local service did not become ready at ${healthUrl}`); } - await sleep(PROBE_INTERVAL_MS, signal); + await sleepWithAbort(PROBE_INTERVAL_MS, signal, { ref: false }); } } @@ -703,25 +704,6 @@ function waitForAbort(promise: Promise, signal?: AbortSignal | null): Prom }); } -function sleep(ms: number, signal?: AbortSignal): Promise { - throwIfAborted(signal); - return new Promise((resolve, reject) => { - const cleanup = () => signal?.removeEventListener("abort", onAbort); - const onDone = () => { - cleanup(); - resolve(); - }; - const onAbort = () => { - clearTimeout(timeout); - cleanup(); - reject(toAbortError(signal)); - }; - const timeout: NodeJS.Timeout = setTimeout(onDone, ms); - timeout.unref?.(); - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - function waitForSpawnResult( child: ChildProcess, signal?: AbortSignal | null, @@ -790,4 +772,3 @@ export function hasLocalServiceProcessExited( ): boolean { return child.exitCode !== null || child.signalCode !== null; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/sessions/telemetry.test.ts b/src/agents/sessions/telemetry.test.ts new file mode 100644 index 000000000000..3c4d48dd58f5 --- /dev/null +++ b/src/agents/sessions/telemetry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SettingsManager } from "./settings-manager.js"; +import { isInstallTelemetryEnabled } from "./telemetry.js"; + +describe("isInstallTelemetryEnabled", () => { + const settings = (enabled: boolean) => + ({ getEnableInstallTelemetry: vi.fn(() => enabled) }) as unknown as SettingsManager; + + it("uses the canonical operator env truth table", () => { + expect(isInstallTelemetryEnabled(settings(false), " ON ")).toBe(true); + expect(isInstallTelemetryEnabled(settings(true), "off")).toBe(false); + }); + + it("falls back to persisted settings only when the env override is absent", () => { + expect(isInstallTelemetryEnabled(settings(true), undefined)).toBe(true); + expect(isInstallTelemetryEnabled(settings(true), "")).toBe(false); + }); +}); diff --git a/src/agents/sessions/telemetry.ts b/src/agents/sessions/telemetry.ts index 728df97bc63f..aa460f234e23 100644 --- a/src/agents/sessions/telemetry.ts +++ b/src/agents/sessions/telemetry.ts @@ -1,3 +1,4 @@ +import { isTruthyEnvValue } from "../../infra/env.js"; /** * Install telemetry switch. * @@ -5,19 +6,12 @@ */ import type { SettingsManager } from "./settings-manager.js"; -function isTruthyEnvFlag(value: string | undefined): boolean { - if (!value) { - return false; - } - return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; -} - /** Resolves whether install telemetry is enabled from env override or settings. */ export function isInstallTelemetryEnabled( settingsManager: SettingsManager, telemetryEnv: string | undefined = process.env.OPENCLAW_TELEMETRY, ): boolean { return telemetryEnv !== undefined - ? isTruthyEnvFlag(telemetryEnv) + ? isTruthyEnvValue(telemetryEnv) : settingsManager.getEnableInstallTelemetry(); } diff --git a/src/agents/sessions/tools/write.ts b/src/agents/sessions/tools/write.ts index 933348fc5742..9a5474e3fe24 100644 --- a/src/agents/sessions/tools/write.ts +++ b/src/agents/sessions/tools/write.ts @@ -13,6 +13,7 @@ import { dirname } from "node:path"; import { Container, Text } from "@earendil-works/pi-tui"; import { structuredPatch } from "diff"; import { Type } from "typebox"; +import { isMissingPathError } from "../../../infra/errors.js"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js"; import type { AgentTool } from "../../runtime/index.js"; @@ -94,12 +95,7 @@ const defaultWriteOperations: WriteOperations = { mtimeMs: stat.mtimeMs, } as const; } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - (error as { code?: unknown }).code === "ENOENT" - ) { + if (isMissingPathError(error)) { return null; } throw error; @@ -315,12 +311,10 @@ function formatWriteResult( } function isMissingFileError(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false; - } - if ("code" in error && (error as { code?: unknown }).code === "ENOENT") { + if (isMissingPathError(error)) { return true; } + // Injected write operations may preserve only their legacy human-readable error. return error instanceof Error && error.message.includes("No such file or directory"); } diff --git a/src/agents/utils/sleep.test.ts b/src/agents/utils/sleep.test.ts index 777d8c7827bb..c84551621b58 100644 --- a/src/agents/utils/sleep.test.ts +++ b/src/agents/utils/sleep.test.ts @@ -1,10 +1,44 @@ // Sleep utility tests cover timer-safe delay clamping and abort-listener cleanup // for long-running agent waits. import { describe, expect, it, vi } from "vitest"; +import { isAbortError } from "../../infra/abort-signal.js"; import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js"; import { sleep } from "./sleep.js"; describe("agents sleep", () => { + it("rejects a pre-aborted zero-duration wait with the canonical abort error", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled"); + controller.abort(reason); + + const error = await sleep(0, controller.signal).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ name: "AbortError", message: "aborted", cause: reason }); + expect(isAbortError(error)).toBe(true); + }); + + it("rejects a pre-aborted positive-duration wait", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled"); + controller.abort(reason); + + await expect(sleep(1, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + message: "aborted", + cause: reason, + }); + }); + + it("resolves a non-aborted zero-duration wait", async () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + try { + await expect(sleep(0, new AbortController().signal)).resolves.toBeUndefined(); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + it("clamps oversized delays before scheduling", async () => { vi.useFakeTimers(); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); @@ -37,4 +71,16 @@ describe("agents sleep", () => { vi.useRealTimers(); } }); + + it("rejects cancellation with the canonical abort classification and cause", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const sleeper = sleep(60_000, controller.signal); + + controller.abort(reason); + + const error = await sleeper.catch((caught: unknown) => caught); + expect(error).toMatchObject({ name: "AbortError", message: "aborted", cause: reason }); + expect(isAbortError(error)).toBe(true); + }); }); diff --git a/src/agents/utils/sleep.ts b/src/agents/utils/sleep.ts index 0a65606978b6..1cf49d997501 100644 --- a/src/agents/utils/sleep.ts +++ b/src/agents/utils/sleep.ts @@ -1,27 +1,17 @@ +import { sleepWithAbort } from "@openclaw/retry"; /** * Sleep helper that respects abort signal. */ +import { createAbortError } from "../../infra/abort-signal.js"; import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js"; export function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Aborted")); - return; - } - - const onAbort = () => { - clearTimeout(timeout); - reject(new Error("Aborted")); - }; - const timeout = setTimeout( - () => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, - resolveTimerTimeoutMs(ms, 0, 0), + // Cancellation wins even for zero-delay waits so aborted runs cannot + // advance into follow-on work such as computer-tool screenshot capture. + if (signal?.aborted) { + return Promise.reject( + createAbortError("aborted", { cause: signal.reason ?? new Error("aborted") }), ); - - signal?.addEventListener("abort", onAbort, { once: true }); - }); + } + return sleepWithAbort(resolveTimerTimeoutMs(ms, 0, 0), signal); } diff --git a/src/agents/utils/tools-manager.test.ts b/src/agents/utils/tools-manager.test.ts index 490905031daf..8677cb84171d 100644 --- a/src/agents/utils/tools-manager.test.ts +++ b/src/agents/utils/tools-manager.test.ts @@ -41,6 +41,7 @@ beforeEach(() => { afterEach(() => { vi.clearAllMocks(); vi.resetModules(); + vi.unstubAllEnvs(); if (originalAgentDir === undefined) { deleteTestEnvValue("OPENCLAW_AGENT_DIR"); } else { @@ -53,6 +54,14 @@ afterEach(() => { }); describe("ensureTool", () => { + it("treats trimmed on as the canonical offline opt-in", async () => { + vi.stubEnv("OPENCLAW_OFFLINE", " ON "); + const { ensureTool } = await import("./tools-manager.js"); + + await expect(ensureTool("fd", true)).resolves.toBeUndefined(); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + it("cancels release-check error bodies before releasing guarded fetches", async () => { const { ensureTool } = await import("./tools-manager.js"); const release = vi.fn(async () => {}); diff --git a/src/agents/utils/tools-manager.ts b/src/agents/utils/tools-manager.ts index 2ba94c2acc07..182877c5f608 100644 --- a/src/agents/utils/tools-manager.ts +++ b/src/agents/utils/tools-manager.ts @@ -21,6 +21,7 @@ import { pipeline } from "node:stream/promises"; import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import chalk from "chalk"; import { extractArchive } from "../../infra/archive.js"; +import { isTruthyEnvValue } from "../../infra/env.js"; import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; import { APP_NAME, getBinDir } from "../config.js"; import { readProviderJsonResponse } from "../provider-http-errors.js"; @@ -42,11 +43,7 @@ async function cancelUnreadResponseBody(response: Response): Promise { } function isOfflineModeEnabled(): boolean { - const value = process.env.OPENCLAW_OFFLINE; - if (!value) { - return false; - } - return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; + return isTruthyEnvValue(process.env.OPENCLAW_OFFLINE); } interface ToolConfig { diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index e989fa30cea9..46ea0f669f8a 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveStateDir } from "../../config/paths.js"; +import { isMissingPathError } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js"; @@ -237,7 +238,7 @@ async function shouldPreserveOrphanCandidate( try { listedKey = await canonicalPathKey(entry.path); } catch (error) { - if (isMissingFileError(error)) { + if (isMissingPathError(error)) { continue; } throw error; @@ -336,21 +337,17 @@ async function runSetupScript(repoRoot: string, worktreePath: string): Promise { let entries: Dirent[]; try { entries = await fs.readdir(root, { withFileTypes: true }); } catch (error) { - if (isMissingFileError(error)) { + if (isMissingPathError(error)) { return 0; } throw error; @@ -364,7 +361,7 @@ async function directorySizeBytes(root: string): Promise { try { total += (await fs.lstat(child)).size; } catch (error) { - if (!isMissingFileError(error)) { + if (!isMissingPathError(error)) { throw error; } } @@ -378,7 +375,7 @@ async function containsGitMarker(root: string, checkoutRoot = false): Promise { await fs.lstat(target); return true; } catch (error) { - if (isMissingFileError(error)) { + if (isMissingPathError(error)) { return false; } throw error; @@ -1283,7 +1280,7 @@ export class ManagedWorktreeService { try { managedPaths.add(await canonicalPathKey(record.path)); } catch (error) { - if (!isMissingFileError(error)) { + if (!isMissingPathError(error)) { throw error; } } diff --git a/src/channels/message/ingress-drain.ts b/src/channels/message/ingress-drain.ts index 2f0a18604c52..4cc9c0b250c5 100644 --- a/src/channels/message/ingress-drain.ts +++ b/src/channels/message/ingress-drain.ts @@ -4,6 +4,7 @@ * Owns claim recovery, per-lane serialization, adoption-time complete, retry / * dead-letter disposition, pre-adoption stall watchdog, and optional supersede. */ +import { sleepWithAbort } from "@openclaw/retry"; import { formatErrorMessage, toErrorObject } from "../../infra/errors.js"; import { createIngressDrainOwnerId, @@ -35,7 +36,6 @@ import { DEFAULT_INGRESS_RETRY_MAX_MS, resolveIngressFailureDisposition, resolveIngressRetryDelayMs, - sleepIngressRetryDelay, type IngressNonRetryableFailure, type IngressRetryPolicyConfig, } from "./ingress-retry-policy.js"; @@ -328,7 +328,7 @@ export function createChannelIngressDrain< log(`completion retry ${attempt} scheduled for event ${displayId}`); } // Abortable sleep: webhook stop aborts options.abortSignal mid-backoff. - await sleepIngressRetryDelay(delayMs, options.abortSignal); + await sleepWithAbort(delayMs, options.abortSignal, { ref: false }); } } }; diff --git a/src/channels/message/ingress-retry-policy.ts b/src/channels/message/ingress-retry-policy.ts index 459aa6107159..686a590690f9 100644 --- a/src/channels/message/ingress-retry-policy.ts +++ b/src/channels/message/ingress-retry-policy.ts @@ -119,24 +119,3 @@ export function resolveIngressFailureDisposition(params: { } return { kind: "release", attempt, message }; } - -/** Abortable delay used by drain retry/backoff loops. */ -export function sleepIngressRetryDelay(ms: number, abortSignal?: AbortSignal): Promise { - const abortError = () => - abortSignal?.reason instanceof Error ? abortSignal.reason : new Error("ingress-aborted"); - if (abortSignal?.aborted) { - return Promise.reject(abortError()); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - abortSignal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - timer.unref?.(); - const onAbort = () => { - clearTimeout(timer); - reject(abortError()); - }; - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); -} diff --git a/src/commands/doctor-retired-phone-control.ts b/src/commands/doctor-retired-phone-control.ts index 4f7ec60785dc..910ffe9ff354 100644 --- a/src/commands/doctor-retired-phone-control.ts +++ b/src/commands/doctor-retired-phone-control.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isMissingPathError } from "../infra/errors.js"; import { createPluginStateKeyedStore } from "../plugin-state/plugin-state-store.js"; import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.js"; @@ -55,13 +56,6 @@ type StatePathInspection = | { status: "missing" } | { status: "unsafe"; warning: string }; -function isMissingPathError(error: unknown): boolean { - if (!error || typeof error !== "object" || !("code" in error)) { - return false; - } - return error.code === "ENOENT" || error.code === "ENOTDIR"; -} - async function inspectStatePath(filePath: string, label: string): Promise { try { return (await fs.stat(filePath)).isFile() diff --git a/src/commands/doctor-skill-workshop-sqlite.ts b/src/commands/doctor-skill-workshop-sqlite.ts index 7252203779d3..9bd068d58f70 100644 --- a/src/commands/doctor-skill-workshop-sqlite.ts +++ b/src/commands/doctor-skill-workshop-sqlite.ts @@ -7,6 +7,7 @@ import { } from "../agents/agent-scope.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isMissingPathError } from "../infra/errors.js"; import { removePathWithinRoot } from "../infra/fs-safe-remove.js"; import { pathExists, root, type Root } from "../infra/fs-safe.js"; import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; @@ -36,11 +37,6 @@ type MigrationResult = { migrated: number; }; -function isNotFoundError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException).code; - return code === "not-found" || code === "ENOENT"; -} - async function readJson(rootDir: Root, relativePath: string, maxBytes: number): Promise { const read = await rootDir.read(relativePath, { hardlinks: "reject", @@ -107,7 +103,7 @@ async function readLegacyRollback( } return rollback.value; } catch (error) { - if (isNotFoundError(error)) { + if (isMissingPathError(error)) { return undefined; } throw error; @@ -240,7 +236,7 @@ export async function migrateLegacySkillWorkshopProposals(params: { }); migrated += 1; } catch (error) { - if (isNotFoundError(error)) { + if (isMissingPathError(error)) { if (await readSkillProposal(proposalId, { env }, {}, { reconcile: false })) { continue; } @@ -250,7 +246,7 @@ export async function migrateLegacySkillWorkshopProposals(params: { } await removePathWithinRoot({ rootDir: stateDir, relativePath: MANIFEST_PATH }).catch( (error: unknown) => { - if (!isNotFoundError(error)) { + if (!isMissingPathError(error)) { warnings.push(`Failed to remove legacy Skill Workshop proposal index: ${String(error)}`); } }, diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index da9048412259..46421882a6a3 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -38,6 +38,7 @@ import { import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; +import { safeRealpathSync } from "../infra/boundary-path.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { loadLegacySessionStore, @@ -354,13 +355,7 @@ function isPathUnderRoot(targetPath: string, rootPath: string): boolean { ); } -function tryResolveRealPath(targetPath: string): string | null { - try { - return fs.realpathSync(targetPath); - } catch { - return null; - } -} +const tryResolveRealPath = safeRealpathSync; function resolvePathThroughExistingAncestor( targetPath: string, diff --git a/src/config/includes.ts b/src/config/includes.ts index c43620c4cfb6..891e557cf4bb 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -17,6 +17,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { canUseRootFileOpen, openRootFileSync } from "../infra/boundary-file-read.js"; import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { mergeDeep as mergeDeepValues } from "../infra/deep-merge.js"; +import { isMissingPathError } from "../infra/errno.js"; import { isPathInside } from "../security/scan-paths.js"; import { isPlainObject } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; @@ -353,7 +354,7 @@ class IncludeProcessor { if (err instanceof ConfigIncludeError) { throw err; } - if (isNotFoundError(err)) { + if (isMissingPathError(err)) { // File doesn't exist yet - lexical containment check above is sufficient. return { resolvedPath: normalized, root: lexicalMatch }; } @@ -476,15 +477,6 @@ function createConfigIncludeBoundary( }; } -function isNotFoundError(error: unknown): boolean { - return Boolean( - error && - typeof error === "object" && - "code" in error && - (error as { code?: unknown }).code === "ENOENT", - ); -} - export function readConfigIncludeFileWithGuards(params: IncludeFileReadParams): string { const ioFs = params.ioFs ?? fs; const maxBytes = params.maxBytes ?? MAX_INCLUDE_FILE_BYTES; diff --git a/src/config/mutate.ts b/src/config/mutate.ts index 44ab453bc8ee..82d0197752c2 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -4,7 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { expectDefined } from "@openclaw/normalization-core"; -import { formatErrorMessage } from "../infra/errors.js"; +import { formatErrorMessage, isMissingPathError } from "../infra/errors.js"; import { withFileLock } from "../infra/file-lock.js"; import { root as createFsRoot, type Root as FsSafeRoot } from "../infra/fs-safe.js"; import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js"; @@ -415,11 +415,6 @@ type RootBoundIncludeFile = { root: FsSafeRoot; }; -function isMissingFileError(error: unknown): boolean { - const code = (error as { code?: unknown } | null)?.code; - return code === "ENOENT" || code === "not-found"; -} - function resolveRootBoundRelativePath(target: RootBoundIncludeFile, absolutePath: string): string { const relativePath = path.relative(target.root.rootReal, path.resolve(absolutePath)); const firstSegment = relativePath.split(path.sep)[0]; @@ -496,7 +491,7 @@ async function readRootBoundFileRawIfExists(target: RootBoundIncludeFile): Promi try { return await target.root.readText(target.relativePath); } catch (error) { - if (isMissingFileError(error)) { + if (isMissingPathError(error)) { return null; } throw error; @@ -508,7 +503,7 @@ async function assertRootConfigStillMatchesSnapshot(snapshot: ConfigFileSnapshot try { currentRaw = await fs.readFile(snapshot.path, "utf-8"); } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + if (!isMissingPathError(error)) { throw error; } } @@ -541,7 +536,7 @@ async function rollbackJsonFileWriteIfUnchanged(params: { try { await params.target.root.remove(params.target.relativePath); } catch (error) { - if (!isMissingFileError(error)) { + if (!isMissingPathError(error)) { throw error; } } diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 1b760a4e7eb3..e9abdda7571f 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { safeRealpathSync } from "../../infra/boundary-path.js"; import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveStateDir } from "../paths.js"; @@ -195,14 +196,6 @@ function resolveStructuralSessionFallbackPath( return path.normalize(path.resolve(candidateAbsPath)); } -function safeRealpathSync(filePath: string): string | undefined { - try { - return fs.realpathSync(filePath); - } catch { - return undefined; - } -} - function resolvePathWithinSessionsDir( sessionsDir: string, candidate: string, diff --git a/src/daemon/launchd-system.ts b/src/daemon/launchd-system.ts index 695440a5be8d..0dd83d8fa0d1 100644 --- a/src/daemon/launchd-system.ts +++ b/src/daemon/launchd-system.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; +import { isMissingPathError } from "../infra/errors.js"; import { execFileUtf8 } from "./exec-file.js"; import { execLaunchctl, @@ -32,10 +33,6 @@ function formatUnknownError(error: unknown): string { return truncateUtf16Safe(sanitizeForLog(raw), 500); } -function isMissingPathError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; -} - function quotePosixArgument(value: string): string { return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; } diff --git a/src/daemon/systemd-system.ts b/src/daemon/systemd-system.ts index 42bd921b7051..73ba88dfc632 100644 --- a/src/daemon/systemd-system.ts +++ b/src/daemon/systemd-system.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; +import { isMissingPathError } from "../infra/errors.js"; import { execFileUtf8 } from "./exec-file.js"; type SystemSystemdOwnership = @@ -23,11 +24,6 @@ function formatUnknownError(error: unknown): string { return truncateUtf16Safe(sanitizeForLog(raw), 500); } -function isMissingPathError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return code === "ENOENT" || code === "ENOTDIR"; -} - function quotePosixArgument(value: string): string { return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; } diff --git a/src/flows/doctor-health-contribution-runners.config.ts b/src/flows/doctor-health-contribution-runners.config.ts index 82d57a2b1d71..78bad9732459 100644 --- a/src/flows/doctor-health-contribution-runners.config.ts +++ b/src/flows/doctor-health-contribution-runners.config.ts @@ -10,18 +10,20 @@ import { } from "./doctor-health-contribution-utils.js"; import type { HealthCheckContext, HealthFinding } from "./health-checks.js"; -function isTruthyEnvValue(value: string | undefined): boolean { +function isExplicitOptOutEnvValue(value: string | undefined): boolean { if (!value) { return false; } + // Update handoff predates canonical opt-in flags: every non-false value means the + // parent opted in, so preserve its broad acceptance until that protocol is retired. const normalized = value.trim().toLowerCase(); return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no"; } function shouldSkipLegacyUpdateDoctorConfigWrite(env: NodeJS.ProcessEnv): boolean { return ( - isTruthyEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS) && - !isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]) + isExplicitOptOutEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS) && + !isExplicitOptOutEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]) ); } diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 33187ec5159a..ce8f986d7f67 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -3698,6 +3698,14 @@ describe("doctor health contributions", () => { }, shouldWrite: true, }, + { + name: "legacy protocol's broad parent opt-in", + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "enabled", + OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "supported", + }, + shouldWrite: true, + }, { name: "falsey update env values", env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" }, diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index ceb4e50d56b5..3a3da5b1dcf5 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -451,6 +451,8 @@ function escapesBase(baseDir: string, candidate: string): boolean { function safeRealpathSync(candidate: string): string | null { try { + // Hook containment prefers native canonicalization when Node exposes it. + // Keep the plain fallback only for runtimes without the native entrypoint. const nativeRealpath = fs.realpathSync.native as ((path: string) => string) | undefined; return nativeRealpath ? nativeRealpath(candidate) : fs.realpathSync(candidate); } catch { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index b1fc1d367acc..9a91f3d1f602 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -72,6 +72,7 @@ import { purgeAgentSessionStoreEntries } from "../../config/sessions.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isMissingPathError } from "../../infra/errors.js"; import { withAgentExecApprovalsRemoved } from "../../infra/exec-approvals.js"; import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js"; import { isPathInside } from "../../infra/path-guards.js"; @@ -387,13 +388,6 @@ async function statAgentCleanupPath(cleanupPath: AgentDeleteCleanupPath) { } } -function isMissingCleanupPathError(error: unknown): boolean { - return ( - (error instanceof FsSafeError && error.code === "not-found") || - (error as NodeJS.ErrnoException).code === "ENOENT" - ); -} - async function removeAgentPath( cleanupPath: AgentDeleteCleanupPath, ): Promise { @@ -405,7 +399,7 @@ async function removeAgentPath( if (error instanceof AgentCleanupIdentityMismatchError) { return { skipped: { path: pathname, reason: error.message } }; } - return isMissingCleanupPathError(error) + return isMissingPathError(error) ? { removed: { path: pathname, method: "missing" } } : cleanupFailure(pathname, error); } @@ -415,14 +409,14 @@ async function removeAgentPath( await movePathToTrash(trashPath); return { removed: { path: pathname, method: "trash" } }; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if (!isMissingPathError(error)) { return cleanupFailure(pathname, error); } try { await statAgentCleanupPath(cleanupPath); return cleanupFailure(pathname, error); } catch (statError) { - return isMissingCleanupPathError(statError) + return isMissingPathError(statError) ? { removed: { path: pathname, method: "missing" } } : cleanupFailure(pathname, statError); } @@ -450,14 +444,14 @@ async function resolveAgentDeleteCleanupTarget(pathname: string): Promise> | undefined; try { candidateStat = await fs.lstat(candidate); } catch (statError) { - if ((statError as NodeJS.ErrnoException).code !== "ENOENT") { + if (!isMissingPathError(statError)) { throw statError; } } @@ -538,7 +532,7 @@ async function prepareAgentDeleteCleanupPaths( try { sourceStat = await fs.lstat(pathname); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if (!isMissingPathError(error)) { preparationError ??= error; } } @@ -547,7 +541,7 @@ async function prepareAgentDeleteCleanupPaths( try { targetStat = await fs.lstat(resolvedPath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if (!isMissingPathError(error)) { preparationError ??= error; } targetStat = undefined; @@ -817,7 +811,7 @@ async function readWorkspaceFileContent( }); return safeRead.buffer.toString("utf-8"); } catch (err) { - if (err instanceof FsSafeError && err.code === "not-found") { + if (isMissingPathError(err)) { return undefined; } throw err; @@ -1337,7 +1331,7 @@ export const agentsHandlers: GatewayRequestHandlers = { try { await statAgentCleanupPath(cleanupPath); } catch (error) { - if (isMissingCleanupPathError(error)) { + if (isMissingPathError(error)) { replacementPresent = false; } else if (!(error instanceof AgentCleanupIdentityMismatchError)) { note = "completed cleanup path could not be verified; replacement preserved"; @@ -1524,7 +1518,7 @@ export const agentsHandlers: GatewayRequestHandlers = { nonBlockingRead: true, }); } catch (err) { - if (err instanceof FsSafeError && err.code === "not-found") { + if (isMissingPathError(err)) { respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath }); return; } diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index 0d02a3f5994c..27ebe7bc3303 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -10,6 +10,7 @@ import path from "node:path"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFile } from "../infra/boundary-file-read.js"; +import { safeRealpathSync } from "../infra/boundary-path.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; @@ -127,7 +128,7 @@ export async function loadInternalHooks( for (const entry of eligible) { try { - const hookBaseDir = resolveExistingRealpath(entry.hook.baseDir); + const hookBaseDir = safeRealpathSync(entry.hook.baseDir); if (!hookBaseDir) { log.error( `Hook '${safeLogValue(entry.hook.name)}' base directory is no longer readable: ${safeLogValue(entry.hook.baseDir)}`, @@ -225,14 +226,14 @@ export async function loadInternalHooks( } const baseDir = path.resolve(workspaceDir); const modulePath = path.resolve(baseDir, rawModule); - const baseDirReal = resolveExistingRealpath(baseDir); + const baseDirReal = safeRealpathSync(baseDir); if (!baseDirReal) { log.error( `Workspace directory is no longer readable while loading hooks: ${safeLogValue(baseDir)}`, ); continue; } - const modulePathSafe = resolveExistingRealpath(modulePath); + const modulePathSafe = safeRealpathSync(modulePath); if (!modulePathSafe) { log.error( `Handler module path could not be resolved with realpath: ${safeLogValue(rawModule)}`, @@ -303,11 +304,3 @@ export async function loadInternalHooks( return loadedCount; } - -function resolveExistingRealpath(value: string): string | null { - try { - return fs.realpathSync(value); - } catch { - return null; - } -} diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts index 68761dd6fddf..4168068e8eb7 100644 --- a/src/infra/boundary-path.ts +++ b/src/infra/boundary-path.ts @@ -1,5 +1,13 @@ // Exposes boundary path resolution helpers with fs-safe defaults. import "./fs-safe-defaults.js"; +import path from "node:path"; +import { safeRealpathSync } from "@openclaw/fs-safe/path"; +export { safeRealpathSync } from "@openclaw/fs-safe/path"; + +/** Returns a canonical path when resolvable, otherwise an absolute lexical path. */ +export function resolveRealpathOrAbsolute(value: string): string { + return safeRealpathSync(value) ?? path.resolve(value); +} // Boundary path resolution keeps alias expansion and realpath checks in one // shared contract before file IO happens. diff --git a/src/infra/env.ts b/src/infra/env.ts index 9daac97ba2db..b6a28340fbd6 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -1,8 +1,8 @@ // Normalizes env flag values and logs env warnings lazily. -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { SubsystemLogger } from "../logging/subsystem.js"; import { createLazyPromise } from "../shared/lazy-runtime.js"; +import { parseBooleanValue } from "../utils/boolean.js"; export { isFastTestRuntimeEnv, isVitestRuntimeEnv } from "./test-runtime-env.js"; let log: SubsystemLogger | null = null; @@ -95,18 +95,7 @@ export function resolveEnvNormalizationKeys(key: string): readonly string[] { /** Interprets common human/operator truthy env strings. */ export function isTruthyEnvValue(value?: string): boolean { - if (typeof value !== "string") { - return false; - } - switch (normalizeLowercaseStringOrEmpty(value)) { - case "1": - case "on": - case "true": - case "yes": - return true; - default: - return false; - } + return parseBooleanValue(value) === true; } /** Applies process-wide env normalization before runtime configuration is read. */ diff --git a/src/infra/errno.ts b/src/infra/errno.ts new file mode 100644 index 000000000000..c54937e93f6c --- /dev/null +++ b/src/infra/errno.ts @@ -0,0 +1,16 @@ +/** Type guard for NodeJS.ErrnoException (any object with a `code` property). */ +export function isErrno(err: unknown): err is NodeJS.ErrnoException { + return Boolean(err && typeof err === "object" && "code" in err); +} + +/** Checks whether an errno-shaped value has the exact code. */ +export function hasErrnoCode(err: unknown, code: string): boolean { + return isErrno(err) && err.code === code; +} + +/** Classifies missing filesystem paths across Node and fs-safe boundaries. */ +export function isMissingPathError(err: unknown): boolean { + return ( + hasErrnoCode(err, "ENOENT") || hasErrnoCode(err, "ENOTDIR") || hasErrnoCode(err, "not-found") + ); +} diff --git a/src/infra/errors.test.ts b/src/infra/errors.test.ts index 5421db64da43..28f24ccdccab 100644 --- a/src/infra/errors.test.ts +++ b/src/infra/errors.test.ts @@ -8,6 +8,7 @@ import { formatUncaughtError, hasErrnoCode, isErrno, + isMissingPathError, readErrorName, } from "./errors.js"; @@ -100,6 +101,18 @@ describe("error helpers", () => { expect(isErrno("busy")).toBe(false); }); + it.each(["ENOENT", "ENOTDIR", "not-found"])( + "classifies %s as a missing path without requiring Error identity", + (code) => { + expect(isMissingPathError({ code })).toBe(true); + }, + ); + + it("does not classify other fs-safe or errno failures as missing paths", () => { + expect(isMissingPathError({ code: "path-alias" })).toBe(false); + expect(isMissingPathError(new Error("ENOENT"))).toBe(false); + }); + it.each([ { value: 123n, expected: "123" }, { value: false, expected: "false" }, diff --git a/src/infra/errors.ts b/src/infra/errors.ts index eb731a3ce0fb..2eed244832be 100644 --- a/src/infra/errors.ts +++ b/src/infra/errors.ts @@ -1,6 +1,7 @@ // Normalizes error objects for codes, names, messages, and redacted logs. import { formatErrorMessage as formatSharedErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { redactSensitiveText } from "../logging/redact.js"; +export { hasErrnoCode, isErrno, isMissingPathError } from "./errno.js"; export function extractErrorCode(err: unknown): string | undefined { if (!err || typeof err !== "object") { @@ -53,20 +54,6 @@ export function collectErrorGraphCandidates( return candidates; } -/** - * Type guard for NodeJS.ErrnoException (any error with a `code` property). - */ -export function isErrno(err: unknown): err is NodeJS.ErrnoException { - return Boolean(err && typeof err === "object" && "code" in err); -} - -/** - * Check if an error has a specific errno code. - */ -export function hasErrnoCode(err: unknown, code: string): boolean { - return isErrno(err) && err.code === code; -} - export function formatErrorMessage(err: unknown): string { return formatSharedErrorMessage(err, { redact: redactSensitiveText }); } diff --git a/src/infra/exec-allowlist-pattern.ts b/src/infra/exec-allowlist-pattern.ts index dc83c0375a15..9e2e88aa755c 100644 --- a/src/infra/exec-allowlist-pattern.ts +++ b/src/infra/exec-allowlist-pattern.ts @@ -1,6 +1,6 @@ // Parses execution allowlist patterns for approval policy checks. -import fs from "node:fs"; import path from "node:path"; +import { safeRealpathSync } from "@openclaw/fs-safe/path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { escapeRegExp as escapeRegExpLiteral } from "../shared/regexp.js"; import { expandHomePrefix } from "./home-dir.js"; @@ -25,14 +25,6 @@ function normalizeMatchTarget(value: string): string { return normalized; } -function tryRealpath(value: string): string | null { - try { - return fs.realpathSync(value); - } catch { - return null; - } -} - function hasDotPathSegment(value: string): boolean { return value .replace(/\\/g, "/") @@ -97,8 +89,8 @@ export function matchesExecAllowlistPattern(pattern: string, target: string): bo let normalizedPattern = expanded; let normalizedTarget = target; if (process.platform === "win32" && !hasWildcard) { - normalizedPattern = tryRealpath(expanded) ?? expanded; - normalizedTarget = tryRealpath(target) ?? target; + normalizedPattern = safeRealpathSync(expanded) ?? expanded; + normalizedTarget = safeRealpathSync(target) ?? target; } normalizedPattern = normalizeMatchTarget(normalizedPattern); normalizedTarget = normalizeMatchTarget(normalizedTarget); diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index d398fc7f98af..b0c802cc410a 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -1,7 +1,7 @@ // Resolves command executables and wrapper policy paths for exec approvals. import crypto from "node:crypto"; -import fs from "node:fs"; import path from "node:path"; +import { safeRealpathSync } from "@openclaw/fs-safe/path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js"; import type { ExecAllowlistEntry } from "./exec-approvals.types.js"; @@ -51,14 +51,7 @@ function parseFirstToken(command: string): string | null { } function tryResolveRealpath(filePath: string | undefined): string | undefined { - if (!filePath) { - return undefined; - } - try { - return fs.realpathSync(filePath); - } catch { - return undefined; - } + return filePath ? (safeRealpathSync(filePath) ?? undefined) : undefined; } function buildExecutableResolution( diff --git a/src/infra/fs-safe-remove.ts b/src/infra/fs-safe-remove.ts index 25b93aad4ad5..7b57295c4fe5 100644 --- a/src/infra/fs-safe-remove.ts +++ b/src/infra/fs-safe-remove.ts @@ -3,6 +3,7 @@ import "./fs-safe-defaults.js"; import path from "node:path"; import { FsSafeError } from "@openclaw/fs-safe/errors"; import { root as fsSafeRoot, type Root } from "@openclaw/fs-safe/root"; +import { isMissingPathError } from "./errors.js"; async function listDirectoryEntries(root: Root, relativePath: string) { return await root.list(relativePath, { withFileTypes: true }); @@ -18,12 +19,7 @@ function compareDirectoryEntryNames(left: DirectoryEntry, right: DirectoryEntry) } function isNotFoundError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return ( - code === "not-found" || - code === "ENOENT" || - findPathAliasFilesystemCause(error)?.code === "ENOENT" - ); + return isMissingPathError(error) || isMissingPathError(findPathAliasFilesystemCause(error)); } function findPathAliasFilesystemCause(error: unknown): NodeJS.ErrnoException | undefined { diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index d7799f123fb4..10b7642a68cb 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { isMissingPathError } from "./errors.js"; import { readFileWindowFullySync } from "./file-read.js"; import { resolveGitHeadPath } from "./git-root.js"; import { pruneMapToMaxSize } from "./map-size.js"; @@ -33,14 +34,6 @@ type CommitMetadataReaders = { readPackageJsonCommit?: () => string | null; }; -function isMissingPathError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - const code = (error as NodeJS.ErrnoException).code; - return code === "ENOENT" || code === "ENOTDIR"; -} - const resolveCommitSearchDir = (options: { cwd?: string; moduleUrl?: string }) => { if (options.cwd) { return path.resolve(options.cwd); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 8da8d5bc4c6f..d076e7729f55 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -167,6 +167,8 @@ function getRedirectVisitKey(url: string, init: RequestInit | undefined): string } function isTruthyEnvValue(value: string | undefined): boolean { + // This flag relaxes an outbound-network security boundary. Keep exact lowercase + // tokens so whitespace or case variation cannot accidentally widen access. return value === "1" || value === "true" || value === "yes" || value === "on"; } diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index 11fb88d6d7fe..cf061612a3de 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -10,6 +10,7 @@ import type { DiagnosticMemoryPressureEvent, DiagnosticMemoryUsage, } from "../infra/diagnostic-events.js"; +import { isMissingPathError } from "../infra/errors.js"; import { registerFatalErrorHook } from "../infra/fatal-error-hooks.js"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; import { replaceFileAtomicSync } from "../infra/replace-file.js"; @@ -248,15 +249,6 @@ function isBundleFile(name: string): boolean { return name.startsWith(BUNDLE_PREFIX) && name.endsWith(BUNDLE_SUFFIX); } -function isMissingFileError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === "ENOENT" - ); -} - function readObject(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`Invalid stability bundle: ${label} must be an object`); @@ -1234,7 +1226,7 @@ function listDiagnosticStabilityBundleFilesSync( }) .toSorted((a, b) => b.mtimeMs - a.mtimeMs || b.path.localeCompare(a.path)); } catch (error) { - if (isMissingFileError(error)) { + if (isMissingPathError(error)) { return []; } throw error; diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index 40b53e219ddf..1e1356bacc90 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -7,6 +7,7 @@ import { parseConfigJson5 } from "../config/io.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { buildConfigSchema } from "../config/schema.js"; +import { isMissingPathError } from "../infra/errors.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { readRegularFileSync } from "../infra/regular-file.js"; import { VERSION } from "../version.js"; @@ -324,13 +325,6 @@ function configShapeReadFailure(params: { return shape; } -function isMissingPathError(error: unknown): boolean { - if (!error || typeof error !== "object" || !("code" in error)) { - return false; - } - return error.code === "ENOENT" || error.code === "ENOTDIR"; -} - function configReadErrorMessage(error: unknown, stat?: fs.Stats): string | undefined { if (!stat && isMissingPathError(error)) { return undefined; diff --git a/src/media/store.retry.test.ts b/src/media/store.retry.test.ts new file mode 100644 index 000000000000..cbc2b3bc118a --- /dev/null +++ b/src/media/store.retry.test.ts @@ -0,0 +1,80 @@ +// Media store retry tests cover the exact directory-recreation recovery boundary. +import fs from "node:fs/promises"; +import path from "node:path"; +import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { FsSafeError } from "../infra/fs-safe.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(() => { + vi.doUnmock("../infra/file-store.js"); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +function errnoError(code: string): Error { + return Object.assign(new Error(code), { code }); +} + +describe("media store directory recreation", () => { + it.each([ + { + name: "ENOTDIR", + error: () => errnoError("ENOTDIR"), + shouldRetry: false, + }, + { + name: "standalone fs-safe not-found", + error: () => new FsSafeError("not-found", "media target not found"), + shouldRetry: false, + }, + { + name: "fs-safe not-found wrapping ENOENT", + error: () => + new FsSafeError("not-found", "media target not found", { + cause: errnoError("ENOENT"), + }), + shouldRetry: true, + }, + ])("surfaces or retries $name according to its exact cause", async ({ error, shouldRetry }) => { + const stateDir = tempDirs.make("openclaw-media-retry-"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const segment = `retry-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const injectedError = error(); + let writeAttempts = 0; + vi.doMock("../infra/file-store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fileStore: (options: Parameters[0]) => { + const actualStore = actual.fileStore(options); + return { + ...actualStore, + write: async (...args: Parameters) => { + if (args[0].includes(`${segment}${path.sep}`) && writeAttempts++ === 0) { + throw injectedError; + } + return await actualStore.write(...args); + }, + }; + }, + }; + }); + + const store = await importFreshModule( + import.meta.url, + `./store.js?scope=retry-boundary-${segment}`, + ); + const result = store.saveMediaBuffer(Buffer.from("voice"), "audio/ogg", segment); + if (shouldRetry) { + const saved = await result; + await expect(fs.stat(saved.path)).resolves.toMatchObject({ size: 5 }); + expect(writeAttempts).toBe(2); + return; + } + await expect(result).rejects.toBe(injectedError); + expect(writeAttempts).toBe(1); + }); +}); diff --git a/src/media/store.ts b/src/media/store.ts index 4dc5920f7643..375499409002 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -171,7 +171,9 @@ function findErrorWithCode(err: unknown, code: string): NodeJS.ErrnoException | return findErrorWithCode(err.cause, code); } -function isMissingPathError(err: unknown): boolean { +function hasRecoverableMissingMediaDirCause(err: unknown): boolean { + // Recursive mkdir repairs only the ENOENT race where cleanup pruned the directory. + // Structural ENOTDIR and generic fs-safe absence remain terminal diagnostics. return findErrorWithCode(err, "ENOENT") !== undefined; } @@ -188,7 +190,7 @@ async function retryAfterRecreatingDir(dir: string, run: () => Promise): P attempts: 2, minDelayMs: 0, maxDelayMs: 0, - shouldRetry: isMissingPathError, + shouldRetry: hasRecoverableMissingMediaDirCause, onRetry: async () => { // Cleanup can prune the directory between mkdir and file open. Recreate // it once; further failures remain terminal instead of looping. diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 7432ffde264b..a5c9d9899113 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -619,7 +619,7 @@ describe("loadBundledEntryExportSync", () => { expect(result.stderr).toMatch(/sourceLoaderCallMs=0(?:\.0+)?(?:\s|$)/u); }); - it("can disable source-tree fallback for dist bundled entry checks", () => { + it("preserves presence-based source fallback disable semantics and cache modes", () => { stubPluginModuleLoaderJitiFactory( vi.fn(() => vi.fn(() => ({ sentinel: 42 }))) as unknown as PluginModuleLoaderFactory, ); @@ -639,20 +639,29 @@ describe("loadBundledEntryExportSync", () => { "utf8", ); - expect( + const loadSecretContract = () => loadBundledEntryExportSync(pathToFileURL(importerPath).href, { specifier: "./src/secret-contract.js", exportName: "sentinel", - }), - ).toBe(42); + }); - vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", "1"); + expect(loadSecretContract()).toBe(42); - expect(() => - loadBundledEntryExportSync(pathToFileURL(importerPath).href, { - specifier: "./src/secret-contract.js", - exportName: "sentinel", - }), - ).toThrow(`resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`); + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", "enabled"); + expect(loadSecretContract).toThrow( + `resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`, + ); + + for (const value of ["", " ", "off", "no", " ON ", "arbitrary"]) { + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", value); + expect(loadSecretContract).toThrow( + `resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`, + ); + } + + for (const value of ["0", " 0 ", "false", " FALSE "]) { + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", value); + expect(loadSecretContract()).toBe(42); + } }); }); diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 1dab4e100a90..dc9c1bdf6e68 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -134,7 +134,9 @@ const resolvedModulePaths = new Map(); const loadedModuleExports = new Map(); const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK"; -function isTruthyEnvFlag(value: string | undefined): boolean { +function isBundledEntrySourceFallbackDisabled(value: string | undefined): boolean { + // Presence-based disable is a shipped operator contract; canonical opt-in + // truthiness intentionally does not apply to this packaging flag. return value !== undefined && !/^(?:0|false)$/iu.test(value.trim()); } @@ -235,7 +237,7 @@ function resolveBundledEntryModuleCandidates( if (!importerPath.startsWith(distExtensionsRoot)) { return candidates; } - if (isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv])) { + if (isBundledEntrySourceFallbackDisabled(process.env[disableBundledEntrySourceFallbackEnv])) { return candidates; } @@ -297,7 +299,9 @@ function formatBundledEntryModuleOpenFailure(params: { } function createBundledEntryModulePathCacheKey(importMetaUrl: string, specifier: string): string { - const sourceFallbackDisabled = isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv]); + const sourceFallbackDisabled = isBundledEntrySourceFallbackDisabled( + process.env[disableBundledEntrySourceFallbackEnv], + ); return `${sourceFallbackDisabled ? "1" : "0"}\0${importMetaUrl}\0${specifier}`; } diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index cdf91e45f6e3..be5d14266cae 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -18,7 +18,17 @@ export { } from "../infra/diagnostic-events.js"; export * from "../infra/diagnostic-flags.js"; export * from "../infra/env.js"; -export * from "../infra/errors.js"; +export { + collectErrorGraphCandidates, + extractErrorCode, + formatErrorMessage, + formatUncaughtError, + hasErrnoCode, + isErrno, + readErrorName, + stringifyNonErrorCause, + toErrorObject, +} from "../infra/errors.js"; import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; /** @deprecated Shipped compat only (removed from core in #104546); no core caller. Removal with the next plugin-SDK major. */ diff --git a/src/plugin-sdk/memory-host-event-export.ts b/src/plugin-sdk/memory-host-event-export.ts index 0222e19ed7a7..142f4cc23900 100644 --- a/src/plugin-sdk/memory-host-event-export.ts +++ b/src/plugin-sdk/memory-host-event-export.ts @@ -1,6 +1,7 @@ import type { FileHandle } from "node:fs/promises"; import path from "node:path"; import { syncDirectoryIfSupported } from "../infra/directory-durability.js"; +import { isMissingPathError as isCanonicalMissingPathError } from "../infra/errors.js"; import { sameFileIdentity, type FileIdentityStat } from "../infra/fs-safe-advanced.js"; import { FsSafeError, root as createFsSafeRoot } from "../infra/fs-safe.js"; @@ -16,12 +17,7 @@ export type MemoryHostEventExportOwner = { type MemoryHostWorkspaceRoot = Awaited>; export function isMissingPathError(error: unknown): boolean { - const code = (error as { code?: unknown }).code; - return ( - code === "ENOENT" || - code === "ENOTDIR" || - (error instanceof FsSafeError && code === "not-found") - ); + return isCanonicalMissingPathError(error); } export function isRejectedWorkspaceArtifactPath(error: unknown): boolean { diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index a5fbc9172557..41d95f782cb2 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -70,6 +70,8 @@ function hasUsableBundledPluginTree(pluginsDir: string): boolean { function safeRealpathSync(targetPath: string): string | null { try { + // Trusted-root containment requires native platform canonicalization here. + // The shared plain-realpath helper must not replace this security boundary. return fs.realpathSync.native(targetPath); } catch { return null; diff --git a/src/plugins/loader-load-context.ts b/src/plugins/loader-load-context.ts index 52b80a35a1b8..fd54d7d922d3 100644 --- a/src/plugins/loader-load-context.ts +++ b/src/plugins/loader-load-context.ts @@ -5,6 +5,7 @@ import { resolveConfigEnvVars } from "../config/env-substitution.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; import { tryReadJsonSync } from "../infra/json-files.js"; import { resolveUserPath } from "../utils.js"; import { resolvePluginActivationSourceConfig } from "./activation-source-config.js"; @@ -31,14 +32,6 @@ import { import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js"; import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; -function safeRealpathOrResolve(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } -} - function resolveBundledPackageRootForCache(stockRoot?: string): string | undefined { if (!stockRoot) { return undefined; @@ -120,8 +113,8 @@ function resolveBundledPackageCacheIdentity( try { const stat = fs.statSync(packageJsonPath); identity = { - packageJson: safeRealpathOrResolve(packageJsonPath), - packageRoot: safeRealpathOrResolve(packageRoot), + packageJson: resolveRealpathOrAbsolute(packageJsonPath), + packageRoot: resolveRealpathOrAbsolute(packageRoot), packageVersion: readPackageVersionForCache(packageJsonPath), size: stat.size, mtimeMs: stat.mtimeMs, @@ -129,7 +122,7 @@ function resolveBundledPackageCacheIdentity( } catch { identity = { packageJson: path.resolve(packageJsonPath), - packageRoot: safeRealpathOrResolve(packageRoot), + packageRoot: resolveRealpathOrAbsolute(packageRoot), packageVersion: "missing", size: -1, mtimeMs: -1, diff --git a/src/plugins/loader-shared.ts b/src/plugins/loader-shared.ts index 5945d4a7912a..2ce142e21aac 100644 --- a/src/plugins/loader-shared.ts +++ b/src/plugins/loader-shared.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result"; import { normalizeLowercaseStringOrEmpty, @@ -7,6 +5,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { activateContextEngineRegistrations } from "../context-engine/registry.js"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_MEMORY_DREAMING_PLUGIN_ID, @@ -371,9 +370,5 @@ export function activatePluginRegistry( } export function safeRealpathOrResolve(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } + return resolveRealpathOrAbsolute(value); } diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts index d64204a2f833..087d18a0eb02 100644 --- a/src/plugins/manifest-metadata-scan.ts +++ b/src/plugins/manifest-metadata-scan.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { readRegularFileSync } from "../infra/regular-file.js"; @@ -155,18 +156,10 @@ function listSourceCheckoutPluginDirs(startOrder: number): CandidateDir[] { return dirs; } -function resolveComparablePath(filePath: string): string { - try { - return fs.realpathSync(filePath); - } catch { - return path.resolve(filePath); - } -} - function uniqueCandidateDirs(candidates: CandidateDir[]): CandidateDir[] { const byPath = new Map(); for (const candidate of candidates) { - const key = resolveComparablePath(candidate.pluginDir); + const key = resolveRealpathOrAbsolute(candidate.pluginDir); const existing = byPath.get(key); if (!existing || candidate.rank < existing.rank || candidate.order < existing.order) { byPath.set(key, candidate); diff --git a/src/plugins/plugin-runtime-artifact-resolution.ts b/src/plugins/plugin-runtime-artifact-resolution.ts index 664f105bcd8f..2c8ffd9283b5 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.ts @@ -1,6 +1,7 @@ /** Resolves the exact root and entry selected by the plugin runtime loader. */ import fs from "node:fs"; import path from "node:path"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; import type { OpenClawPackageManifest } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import type { PluginRegistry } from "./registry-types.js"; @@ -8,15 +9,6 @@ import { getActivePluginRegistry, requireActivePluginRegistry } from "./runtime. type PluginRuntimeArtifactEntryKind = "runtime" | "setup"; -// Pin one physical path per plugin id and logical entry within one installed registry. -function safeRealpathOrResolve(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } -} - export function clearPluginRuntimeArtifactResolutionMemo(): void { getActivePluginRegistry()?.pluginRuntimeArtifacts.clear(); } @@ -100,7 +92,7 @@ function resolvePackageLocalDistRuntimeArtifact(params: { )) { const artifactSource = path.join(artifactRoot, artifactRelativePath); if (fs.existsSync(artifactSource)) { - return safeRealpathOrResolve(artifactSource); + return resolveRealpathOrAbsolute(artifactSource); } } return null; @@ -113,8 +105,8 @@ function resolvePreferredBuiltRuntimeArtifact(params: { preferBuiltPluginArtifacts: boolean; packageManifest?: OpenClawPackageManifest; }): { source: string; rootDir: string } { - const rootDir = safeRealpathOrResolve(params.rootDir); - const source = safeRealpathOrResolve(params.source); + const rootDir = resolveRealpathOrAbsolute(params.rootDir); + const source = resolveRealpathOrAbsolute(params.source); if (!params.preferBuiltPluginArtifacts) { return { source, rootDir }; } @@ -155,8 +147,8 @@ function resolvePreferredBuiltRuntimeArtifact(params: { const artifactSource = path.join(artifactRoot, artifactRelativePath); if (fs.existsSync(artifactSource)) { return { - source: safeRealpathOrResolve(artifactSource), - rootDir: safeRealpathOrResolve(artifactRoot), + source: resolveRealpathOrAbsolute(artifactSource), + rootDir: resolveRealpathOrAbsolute(artifactRoot), }; } } @@ -174,8 +166,8 @@ export function resolvePluginRuntimeArtifact(params: { packageManifest?: OpenClawPackageManifest; registry?: PluginRegistry; }): { source: string; rootDir: string } { - const rootDir = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.rootDir)); - const source = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.source)); + const rootDir = resolveCanonicalDistRuntimeSource(resolveRealpathOrAbsolute(params.rootDir)); + const source = resolveCanonicalDistRuntimeSource(resolveRealpathOrAbsolute(params.source)); const memoKey = JSON.stringify([params.pluginId, rootDir, params.entryKind]); const targetRegistry = params.registry ?? requireActivePluginRegistry(); const cached = targetRegistry.pluginRuntimeArtifacts.get(memoKey); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 9c4f36f919dd..85c1e83588db 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import Module from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; import { PluginLruCache } from "./plugin-cache-primitives.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; import { @@ -152,13 +153,7 @@ function isNativeLoadableSdkTarget(targetPath: string): boolean { } } -function normalizePathForBoundary(candidate: string): string { - try { - return fs.realpathSync(candidate); - } catch { - return path.resolve(candidate); - } -} +const normalizePathForBoundary = resolveRealpathOrAbsolute; function findNearestPackageRoot(modulePath: string): string { let cursor = path.dirname(path.resolve(modulePath)); diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index ae19a9319562..e59cb18c39fe 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -15,6 +15,7 @@ import { type SecretInput, type SecretRef, } from "../config/types.secrets.js"; +import { safeRealpathSync } from "../infra/boundary-path.js"; import type { OAuthCredentials } from "../llm/oauth.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { isValidSecretRef } from "../secrets/ref-contract.js"; @@ -284,15 +285,6 @@ export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string): }; } -/** Resolve real path, returning null if the target doesn't exist. */ -function safeRealpathSync(dir: string): string | null { - try { - return fs.realpathSync(path.resolve(dir)); - } catch { - return null; - } -} - function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { const normalized = path.resolve(primaryAgentDir); const parentOfAgent = path.dirname(normalized); @@ -318,7 +310,7 @@ function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { const seen = new Set(); const result: string[] = []; for (const dir of [normalized, ...discovered]) { - const real = safeRealpathSync(dir); + const real = safeRealpathSync(path.resolve(dir)); if (real && !seen.has(real)) { seen.add(real); result.push(real); @@ -358,9 +350,9 @@ export async function writeOAuthCredentials( }); if (options?.syncSiblingAgents) { - const primaryReal = safeRealpathSync(resolvedAgentDir); + const primaryReal = safeRealpathSync(path.resolve(resolvedAgentDir)); for (const targetAgentDir of targetAgentDirs) { - const targetReal = safeRealpathSync(targetAgentDir); + const targetReal = safeRealpathSync(path.resolve(targetAgentDir)); if (targetReal && primaryReal && targetReal === primaryReal) { continue; } diff --git a/src/plugins/runtime-degraded-state.test.ts b/src/plugins/runtime-degraded-state.test.ts new file mode 100644 index 000000000000..53745f119ad0 --- /dev/null +++ b/src/plugins/runtime-degraded-state.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDirSync } from "../test-helpers/temp-dir.js"; +import { pluginInstallPathMatchesRoot } from "./runtime-degraded-state.js"; + +describe("pluginInstallPathMatchesRoot", () => { + it("matches an existing plugin root through a symlink alias", () => { + if (process.platform === "win32") { + return; + } + + withTempDirSync({ prefix: "openclaw-degraded-plugin-root-" }, (baseDir) => { + const pluginRoot = path.join(baseDir, "plugin"); + const pluginAlias = path.join(baseDir, "plugin-alias"); + fs.mkdirSync(pluginRoot); + fs.symlinkSync(pluginRoot, pluginAlias, "dir"); + + expect(pluginInstallPathMatchesRoot(pluginAlias, pluginRoot)).toBe(true); + }); + }); + + it("falls back to absolute lexical paths when plugin roots are missing", () => { + withTempDirSync({ prefix: "openclaw-degraded-plugin-root-" }, (baseDir) => { + const missingRoot = path.join(baseDir, "missing-plugin"); + const equivalentMissingRoot = path.join(baseDir, "nested", "..", "missing-plugin"); + + expect(pluginInstallPathMatchesRoot(equivalentMissingRoot, missingRoot)).toBe(true); + expect(pluginInstallPathMatchesRoot(path.join(baseDir, "other-missing"), missingRoot)).toBe( + false, + ); + }); + }); +}); diff --git a/src/plugins/runtime-degraded-state.ts b/src/plugins/runtime-degraded-state.ts index 48deafe67a59..f2c55848f5b5 100644 --- a/src/plugins/runtime-degraded-state.ts +++ b/src/plugins/runtime-degraded-state.ts @@ -1,5 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; +import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js"; /** Boot-stable quarantine state for configured plugins whose payload failed verification. */ @@ -101,14 +100,7 @@ export function pluginInstallPathMatchesRoot( if (!installPath) { return false; } - const canonicalize = (value: string) => { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } - }; - return canonicalize(installPath) === canonicalize(rootDir); + return resolveRealpathOrAbsolute(installPath) === resolveRealpathOrAbsolute(rootDir); } /** Matches install-record and discovered roots across symlink/path aliases. */ diff --git a/src/skills/loading/plugin-skills.ts b/src/skills/loading/plugin-skills.ts index 5482df83d2cf..73407cec42ae 100644 --- a/src/skills/loading/plugin-skills.ts +++ b/src/skills/loading/plugin-skills.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isMissingPathError } from "../../infra/errors.js"; import { walkDirectorySync } from "../../infra/fs-safe.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { @@ -252,7 +253,7 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str continue; } } catch (err) { - if (!isNotFoundError(err)) { + if (!isMissingPathError(err)) { log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`); continue; } @@ -300,7 +301,7 @@ function removeGeneratedPluginSkillEntry(linkPath: string): void { return; } } catch (err) { - if (isNotFoundError(err)) { + if (isMissingPathError(err)) { return; } } @@ -310,11 +311,3 @@ function removeGeneratedPluginSkillEntry(linkPath: string): void { // best-effort cleanup } } - -function isNotFoundError(err: unknown): boolean { - if (!err || typeof err !== "object") { - return false; - } - const code = (err as Record).code; - return code === "ENOENT" || code === "ENOTDIR"; -} diff --git a/src/skills/loading/symlink-targets.ts b/src/skills/loading/symlink-targets.ts index 77f4f742e134..3f430eadbe51 100644 --- a/src/skills/loading/symlink-targets.ts +++ b/src/skills/loading/symlink-targets.ts @@ -1,9 +1,9 @@ // Shared helpers for config-trusted skill symlink targets. -import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { safeRealpathSync } from "../../infra/boundary-path.js"; import { isPathInside } from "../../infra/path-guards.js"; import { resolveUserPath } from "../../utils.js"; @@ -12,7 +12,7 @@ export function resolveAllowedSkillSymlinkTargetRealPaths(config?: OpenClawConfi const targetPaths = rawTargets .map((dir) => normalizeOptionalString(dir) ?? "") .filter(Boolean) - .map((dir) => tryRealpath(resolveUserPath(dir))) + .map((dir) => safeRealpathSync(resolveUserPath(dir))) .filter((dir): dir is string => Boolean(dir)); return uniqueStrings(targetPaths); } @@ -31,10 +31,4 @@ export function findContainingAllowedSkillSymlinkTarget( return null; } -export function tryRealpath(filePath: string): string | null { - try { - return fs.realpathSync(filePath); - } catch { - return null; - } -} +export const tryRealpath = safeRealpathSync;