Revert "fix(protocol): preserve gateway session attribution across node runs"

This reverts commit 735f176b01.
This commit is contained in:
joshavant
2026-08-07 13:57:45 -05:00
committed by Josh Avant
parent 75eeacd445
commit 026f4045b7
55 changed files with 622 additions and 3860 deletions
File diff suppressed because it is too large Load Diff
@@ -84,7 +84,6 @@ import ai.openclaw.app.node.LocationCaptureManager
import ai.openclaw.app.node.LocationHandler
import ai.openclaw.app.node.MobileUiHandler
import ai.openclaw.app.node.MotionHandler
import ai.openclaw.app.node.NodeInvokeSessionKeyEnvelope
import ai.openclaw.app.node.NodePresenceAliveBeacon
import ai.openclaw.app.node.NotificationsHandler
import ai.openclaw.app.node.PhotosHandler
@@ -95,7 +94,6 @@ import ai.openclaw.app.node.SystemHandler
import ai.openclaw.app.node.TalkHandler
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import ai.openclaw.app.node.currentNodeInvokeSessionKeyEnvelope
import ai.openclaw.app.node.invokeErrorFromThrowable
import ai.openclaw.app.node.parseHexColorArgb
import ai.openclaw.app.node.readAndroidPermissionSnapshot
@@ -111,7 +109,6 @@ import ai.openclaw.app.voice.TalkAudioPlayer
import ai.openclaw.app.voice.TalkModeManager
import ai.openclaw.app.voice.TalkPttOnceStart
import ai.openclaw.app.voice.TalkPttStopPayload
import ai.openclaw.app.voice.TalkSessionKeyEnvelope
import ai.openclaw.app.voice.VoiceConversationEntry
import ai.openclaw.app.voice.VoiceConversationRole
import ai.openclaw.app.voice.VoiceWakeManager
@@ -186,12 +183,6 @@ private const val CRON_JOBS_SNAPSHOT_MAX_ATTEMPTS = 3
private const val OperatorAdminScope = "operator.admin"
private const val OperatorPairingScope = "operator.pairing"
private fun NodeInvokeSessionKeyEnvelope.toTalkSessionKeyEnvelope(): TalkSessionKeyEnvelope =
when (this) {
NodeInvokeSessionKeyEnvelope.Legacy -> TalkSessionKeyEnvelope.Legacy
is NodeInvokeSessionKeyEnvelope.Authoritative -> TalkSessionKeyEnvelope.Authoritative(sessionKey)
}
private fun execApprovalOutcomeUnknownMessage(): String = nativeText("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.").source
private fun execApprovalStillPendingMessage(): String = nativeText("The Gateway still shows this approval as pending. Review it before trying again.").source
@@ -1058,13 +1049,13 @@ class NodeRuntime private constructor(
systemHandler = systemHandler,
talkHandler =
object : TalkHandler {
override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStart(currentNodeInvokeSessionKeyEnvelope().toTalkSessionKeyEnvelope())
override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStart()
override suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStop()
override suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttCancel()
override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttOnce(currentNodeInvokeSessionKeyEnvelope().toTalkSessionKeyEnvelope())
override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttOnce()
},
photosHandler = photosHandler,
contactsHandler = contactsHandler,
@@ -1762,7 +1753,7 @@ class NodeRuntime private constructor(
},
onEvent = ::handleNodeGatewayEvent,
onInvoke = { req ->
invokeDispatcher.handleInvoke(req)
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
},
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
@@ -3558,7 +3549,7 @@ class NodeRuntime private constructor(
setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off)
}
private suspend fun handleTalkPttStart(sessionKeyEnvelope: TalkSessionKeyEnvelope): GatewaySession.InvokeResult =
private suspend fun handleTalkPttStart(): GatewaySession.InvokeResult =
runTalkPttCommand {
talkMode.finishingPushToTalkCaptureId?.let {
return@runTalkPttCommand GatewaySession.InvokeResult.error(
@@ -3577,7 +3568,6 @@ class NodeRuntime private constructor(
val started =
talkMode.beginPushToTalk(
allowNewCapture = true,
sessionKeyEnvelope = sessionKeyEnvelope,
canStartCapture = {
_isForeground.value &&
voiceLifecycleEpoch.get() == lifecycleEpoch &&
@@ -3603,7 +3593,7 @@ class NodeRuntime private constructor(
GatewaySession.InvokeResult.ok(payload.toJson())
}
private suspend fun handleTalkPttOnce(sessionKeyEnvelope: TalkSessionKeyEnvelope): GatewaySession.InvokeResult =
private suspend fun handleTalkPttOnce(): GatewaySession.InvokeResult =
runTalkPttCommand {
currentTalkPttOnceBusy()?.let { busy ->
return@runTalkPttCommand GatewaySession.InvokeResult.ok(busy.payload.toJson())
@@ -3618,7 +3608,6 @@ class NodeRuntime private constructor(
) { ownershipEpoch ->
val started =
talkMode.beginPushToTalkOnce(
sessionKeyEnvelope = sessionKeyEnvelope,
canStartCapture = {
_isForeground.value &&
voiceLifecycleEpoch.get() == lifecycleEpoch &&
@@ -72,7 +72,6 @@ data class GatewayNodeInvokeRequest(
val paramsJson: String? = null,
val timeoutMs: Long? = null,
val idempotencyKey: String? = null,
val sessionKey: JsonElement? = null,
)
@Serializable
@@ -379,7 +378,6 @@ enum class GatewayMethod(
NodeDescribe("node.describe"),
NodePluginSurfaceRefresh("node.pluginSurface.refresh"),
NodePluginToolsUpdate("node.pluginTools.update"),
NodeProtocolFeaturesUpdate("node.protocolFeatures.update"),
NodeSkillsUpdate("node.skills.update"),
NodePendingDrain("node.pending.drain"),
NodePendingEnqueue("node.pending.enqueue"),
@@ -284,8 +284,6 @@ class GatewaySession(
private companion object {
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
private const val NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE =
"node-invoke-session-key-envelope-v1"
}
/**
@@ -297,8 +295,6 @@ class GatewaySession(
val command: String,
val paramsJson: String?,
val timeoutMs: Long?,
val sessionKey: String?,
val hasSessionKeyEnvelope: Boolean,
)
data class InvokeResult(
@@ -900,11 +896,6 @@ class GatewaySession(
val error: ErrorShape?,
)
private data class PendingRequest(
val deferred: CompletableDeferred<RpcResponse>,
val onResponse: ((RpcResponse) -> Unit)?,
)
private data class TicketedMediaRequest(
val url: String,
val headers: Map<String, String>,
@@ -927,11 +918,6 @@ class GatewaySession(
CLOSED,
}
private enum class NodeInvokeSessionEnvelopeMode {
AUTHORITATIVE,
LEGACY,
}
private inner class Connection(
val endpoint: GatewayEndpoint,
private val token: String?,
@@ -946,7 +932,6 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<ConnectedGateway>()
private val closedDeferred = CompletableDeferred<Unit>()
private val connectChallengeDeferred = CompletableDeferred<ConnectChallenge>()
private val nodeInvokeSessionEnvelopeMode = CompletableDeferred<NodeInvokeSessionEnvelopeMode>()
private val terminalCallbackClaimed = AtomicBoolean(false)
private val connectResponseAccepted = AtomicBoolean(false)
@@ -966,7 +951,7 @@ class GatewaySession(
private val incomingMessages = Channel<String>(Channel.UNLIMITED)
// RPC waiters belong to this socket generation. Closing it must not touch a replacement connection.
private val pending = ConcurrentHashMap<String, PendingRequest>()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
private val pendingLock = Any()
private val messagePumpJob =
@@ -1002,11 +987,10 @@ class GatewaySession(
method: String,
params: JsonElement?,
timeoutMs: Long,
onResponse: ((RpcResponse) -> Unit)? = null,
): RpcResponse {
val id = UUID.randomUUID().toString()
if (method == "connect") connectRequestId = id
val deferred = registerPending(id, onResponse).deferred
val deferred = registerPending(id)
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
return withTimeout(timeoutMs) { deferred.await() }
@@ -1130,7 +1114,7 @@ class GatewaySession(
onError: (ErrorShape) -> Unit,
) {
val id = UUID.randomUUID().toString()
val deferred = registerPending(id).deferred
val deferred = registerPending(id)
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
@@ -1165,20 +1149,16 @@ class GatewaySession(
}
}
private fun registerPending(
id: String,
onResponse: ((RpcResponse) -> Unit)? = null,
): PendingRequest {
private fun registerPending(id: String): CompletableDeferred<RpcResponse> {
val deferred = CompletableDeferred<RpcResponse>()
val request = PendingRequest(deferred = deferred, onResponse = onResponse)
// Registration and the close drain are one lifecycle decision; no waiter may slip between them.
synchronized(pendingLock) {
if (state.get() == ConnectionState.CLOSED) {
throw GatewayRequestNotEnqueued("Gateway closed")
}
pending[id] = request
pending[id] = deferred
}
return request
return deferred
}
suspend fun sendJson(obj: JsonObject) {
@@ -1384,67 +1364,8 @@ class GatewaySession(
}
val connected = parseConnectSuccess(res, identity.deviceId, selectedAuth.authSource)
connectDeferred.complete(connected)
startNodeInvokeSessionEnvelopeNegotiation()
}
private fun startNodeInvokeSessionEnvelopeNegotiation() {
if (options.role != "node" || onInvoke == null) {
nodeInvokeSessionEnvelopeMode.complete(NodeInvokeSessionEnvelopeMode.LEGACY)
return
}
connectionScope.launch {
val mode =
try {
val response =
request(
GatewayMethod.NodeProtocolFeaturesUpdate.rawValue,
buildJsonObject {
put(
"features",
JsonArray(
listOf(JsonPrimitive(NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE)),
),
)
},
timeoutMs = 15_000,
onResponse = { response ->
nodeInvokeSessionEnvelopeMode.complete(
resolveNodeInvokeSessionEnvelopeMode(response),
)
},
)
resolveNodeInvokeSessionEnvelopeMode(response)
} catch (err: TimeoutCancellationException) {
Log.w(loggerTag, "node protocol feature publish timed out")
NodeInvokeSessionEnvelopeMode.AUTHORITATIVE
} catch (err: CancellationException) {
nodeInvokeSessionEnvelopeMode.cancel(err)
throw err
} catch (err: Throwable) {
Log.w(
loggerTag,
"node protocol feature publish failed: ${err.message ?: err::class.java.simpleName}",
)
NodeInvokeSessionEnvelopeMode.AUTHORITATIVE
}
nodeInvokeSessionEnvelopeMode.complete(mode)
}
}
private fun isUnsupportedNodeProtocolFeaturesUpdate(response: RpcResponse): Boolean {
val error = response.error ?: return false
return !response.ok &&
error.code == "INVALID_REQUEST" &&
error.message == "unknown method: node.protocolFeatures.update"
}
private fun resolveNodeInvokeSessionEnvelopeMode(response: RpcResponse): NodeInvokeSessionEnvelopeMode =
if (isUnsupportedNodeProtocolFeaturesUpdate(response)) {
NodeInvokeSessionEnvelopeMode.LEGACY
} else {
NodeInvokeSessionEnvelopeMode.AUTHORITATIVE
}
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false
if (isLocalCleartextGatewayHost(endpoint.host)) return true
@@ -1703,9 +1624,10 @@ class GatewaySession(
private suspend fun handleMessage(text: String) {
val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return
val frameType = frame["type"].asStringOrNull()
// The transport closes its input only after accepting preceding frames. Drain all
// connection-owned responses so a completed RPC wins over the following close.
if (state.get() == ConnectionState.CLOSED && frameType != "res") {
if (
state.get() == ConnectionState.CLOSED &&
(frameType != "res" || frame["id"].asStringOrNull() != connectRequestId)
) {
return
}
when (frameType) {
@@ -1753,12 +1675,7 @@ class GatewaySession(
}
ErrorShape(wireError.code, wireError.message, details)
}
val rpcResponse = RpcResponse(id, response.ok, payloadJson, error)
pending.remove(id)?.let { request ->
// Response observers run in socket receive order before the next event is admitted.
request.onResponse?.invoke(rpcResponse)
request.deferred.complete(rpcResponse)
}
pending.remove(id)?.complete(RpcResponse(id, response.ok, payloadJson, error))
}
private fun handleEvent(frame: JsonObject) {
@@ -1808,31 +1725,18 @@ class GatewaySession(
}
private fun handleInvokeEvent(payloadJson: String) {
val receivedAtMs = SystemClock.elapsedRealtime()
val payloadObject =
runCatching { json.parseToJsonElement(payloadJson).asObjectOrNull() }.getOrNull() ?: return
val payload =
runCatching {
json.decodeFromJsonElement(GatewayNodeInvokeRequest.serializer(), payloadObject)
json.decodeFromString(GatewayNodeInvokeRequest.serializer(), payloadJson)
}.getOrNull() ?: return
// Older gateways sent structured `params`; keep accepting that shipped wire shape while
// generated models follow the canonical `paramsJSON` schema.
val paramsJson =
payload.paramsJson
?: payloadObject["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
val hasWireSessionKey = payloadObject.containsKey("sessionKey")
// An omitted envelope is only authoritative after this socket has finished
// publishing the feature. Do not reinterpret already-received legacy frames.
val envelopeNegotiationCompleteAtReceipt = nodeInvokeSessionEnvelopeMode.isCompleted
?: runCatching {
json.parseToJsonElement(payloadJson).asObjectOrNull()?.get("params")
}.getOrNull()?.let { value -> if (value is JsonNull) null else value.toString() }
connectionScope.launch {
val envelopeMode =
when {
hasWireSessionKey -> NodeInvokeSessionEnvelopeMode.AUTHORITATIVE
!envelopeNegotiationCompleteAtReceipt -> NodeInvokeSessionEnvelopeMode.LEGACY
else -> nodeInvokeSessionEnvelopeMode.await()
}
val hasSessionKeyEnvelope =
hasWireSessionKey || envelopeMode == NodeInvokeSessionEnvelopeMode.AUTHORITATIVE
val request =
InvokeRequest(
id = payload.id,
@@ -1840,38 +1744,24 @@ class GatewaySession(
command = payload.command,
paramsJson = paramsJson,
timeoutMs = payload.timeoutMs,
sessionKey =
payload.sessionKey
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
hasSessionKeyEnvelope = hasSessionKeyEnvelope,
)
val result = executeInvokeRequest(request, receivedAtMs)
val result = executeInvokeRequest(request)
sendInvokeResult(payload.id, payload.nodeId, result, payload.timeoutMs)
}
}
private suspend fun executeInvokeRequest(
request: InvokeRequest,
receivedAtMs: Long,
): InvokeResult {
private suspend fun executeInvokeRequest(request: InvokeRequest): InvokeResult {
val handler = onInvoke ?: return InvokeResult.error("UNAVAILABLE", "invoke handler missing")
return try {
val timeoutMs = resolveInvokeExecutionTimeoutMs(request.timeoutMs)
if (timeoutMs == null) {
handler(request)
} else {
val elapsedMs = (SystemClock.elapsedRealtime() - receivedAtMs).coerceAtLeast(0L)
if (elapsedMs >= timeoutMs) {
return InvokeResult.error("TIMEOUT", "node invoke timed out")
}
val remainingTimeoutMs = timeoutMs - elapsedMs
// Keep the deadline owner separate so a blocking handler cannot delay the timeout result.
// Cancellation still reaches cooperative handlers; late results are never sent.
val handlerTask = connectionScope.async { handler(request) }
try {
withTimeoutOrNull(remainingTimeoutMs) { handlerTask.await() }
withTimeoutOrNull(timeoutMs) { handlerTask.await() }
?: run {
handlerTask.cancel(CancellationException("node invoke timed out"))
InvokeResult.error("TIMEOUT", "node invoke timed out")
@@ -1935,9 +1825,7 @@ class GatewaySession(
pending.values.toList().also { pending.clear() }
}
for (waiter in waiters) {
waiter.deferred.completeExceptionally(
GatewayRequestOutcomeUnknown("Gateway disconnected before response"),
)
waiter.completeExceptionally(GatewayRequestOutcomeUnknown("Gateway disconnected before response"))
}
}
}
@@ -15,30 +15,8 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import ai.openclaw.app.protocol.OpenClawTalkCommand
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
internal sealed interface NodeInvokeSessionKeyEnvelope {
data object Legacy : NodeInvokeSessionKeyEnvelope
data class Authoritative(
val sessionKey: String?,
) : NodeInvokeSessionKeyEnvelope
}
private class NodeInvokeExecutionContext(
val sessionKeyEnvelope: NodeInvokeSessionKeyEnvelope,
) : AbstractCoroutineContextElement(NodeInvokeExecutionContext) {
companion object Key : CoroutineContext.Key<NodeInvokeExecutionContext>
}
internal suspend fun currentNodeInvokeSessionKeyEnvelope(): NodeInvokeSessionKeyEnvelope =
currentCoroutineContext()[NodeInvokeExecutionContext]?.sessionKeyEnvelope
?: NodeInvokeSessionKeyEnvelope.Legacy
/** Runtime state for SMS search, split so permission prompts are not reported as hard unavailability. */
internal enum class SmsSearchAvailabilityReason {
@@ -122,19 +100,6 @@ class InvokeDispatcher(
private val canvasCommandMutex = Mutex()
/** Dispatches one gateway node.invoke command after foreground and availability gates pass. */
suspend fun handleInvoke(request: GatewaySession.InvokeRequest): GatewaySession.InvokeResult {
val sessionKeyEnvelope =
if (request.hasSessionKeyEnvelope) {
NodeInvokeSessionKeyEnvelope.Authoritative(request.sessionKey)
} else {
NodeInvokeSessionKeyEnvelope.Legacy
}
return withContext(NodeInvokeExecutionContext(sessionKeyEnvelope)) {
handleInvoke(request.command, request.paramsJson)
}
}
/** Dispatches one command for direct native callers that have no gateway attribution envelope. */
suspend fun handleInvoke(
command: String,
paramsJson: String?,
@@ -112,26 +112,6 @@ internal sealed interface TalkPttOnceStart {
) : TalkPttOnceStart
}
internal sealed interface TalkSessionKeyEnvelope {
data object Legacy : TalkSessionKeyEnvelope
data class Authoritative(
val sessionKey: String?,
) : TalkSessionKeyEnvelope
}
internal fun resolveTalkChatSessionKey(
envelope: TalkSessionKeyEnvelope,
deviceSessionKey: String,
): String =
when (envelope) {
TalkSessionKeyEnvelope.Legacy -> deviceSessionKey.ifBlank { "main" }
is TalkSessionKeyEnvelope.Authoritative ->
// chat.send requires a non-empty routing key. An explicit null clears the
// device-selected session and uses the Gateway's canonical main route.
envelope.sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
}
internal suspend fun requestPhoneRealtimeSessionWithLanguageFallback(
language: String?,
request: suspend (language: String?) -> String,
@@ -240,7 +220,6 @@ class TalkModeManager internal constructor(
private val realtimeCaptureDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val realtimePlaybackDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val realtimeMarkAcknowledger: (suspend (sessionId: String, markName: String) -> Unit)? = null,
private val requestGatewayOverride: (suspend (method: String, paramsJson: String?, timeoutMs: Long) -> String)? = null,
) {
companion object {
private const val tag = "TalkMode"
@@ -334,7 +313,6 @@ class TalkModeManager internal constructor(
private var pttAutoStopEnabled = false
private var pttTimeoutJob: Job? = null
private var pttCompletion: CompletableDeferred<TalkPttStopPayload>? = null
private var pttSessionKeyEnvelope: TalkSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy
private var pttRecognitionRung: PushToTalkRecognitionRung? = null
private var pttReleaseCompletion: CompletableDeferred<Unit>? = null
private val pttFinalSegments = mutableListOf<String>()
@@ -494,7 +472,6 @@ class TalkModeManager internal constructor(
paramsJson: String?,
timeoutMs: Long = 15_000,
): String {
requestGatewayOverride?.let { return it(method, paramsJson, timeoutMs) }
val gatewayId = gatewayStableId()?.trim()?.takeIf { it.isNotEmpty() }
return if (gatewayId == null) {
session.request(method, paramsJson, timeoutMs)
@@ -527,21 +504,9 @@ class TalkModeManager internal constructor(
suspend fun beginPushToTalk(
allowNewCapture: Boolean,
canStartCapture: () -> Boolean = { true },
): TalkPttStartPayload =
beginPushToTalk(
allowNewCapture = allowNewCapture,
sessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy,
canStartCapture = canStartCapture,
)
internal suspend fun beginPushToTalk(
allowNewCapture: Boolean,
sessionKeyEnvelope: TalkSessionKeyEnvelope,
canStartCapture: () -> Boolean = { true },
): TalkPttStartPayload =
startPushToTalk(
allowNewCapture = allowNewCapture,
sessionKeyEnvelope = sessionKeyEnvelope,
canStartCapture = canStartCapture,
completion = null,
).payload
@@ -561,7 +526,6 @@ class TalkModeManager internal constructor(
private data class ClearedPushToTalkCapture(
val transcript: String,
val completion: CompletableDeferred<TalkPttStopPayload>?,
val sessionKeyEnvelope: TalkSessionKeyEnvelope,
)
private data class RealtimeCapturePause(
@@ -581,7 +545,6 @@ class TalkModeManager internal constructor(
private suspend fun startPushToTalk(
allowNewCapture: Boolean,
sessionKeyEnvelope: TalkSessionKeyEnvelope,
canStartCapture: () -> Boolean,
completion: CompletableDeferred<TalkPttStopPayload>?,
autoStopAfterMs: Long? = null,
@@ -659,9 +622,6 @@ class TalkModeManager internal constructor(
lastHeardAtMs = null
activePttCaptureId = captureId
pttCompletion = completion
// Bind routing to the capture owner. Later UI selection or retry invokes
// must not rebound this turn to another session.
pttSessionKeyEnvelope = sessionKeyEnvelope
try {
// PTT owns the microphone until its turn finishes. Waiting here prevents
// SpeechRecognizer from racing the realtime AudioRecord teardown.
@@ -691,7 +651,6 @@ class TalkModeManager internal constructor(
clearListenWatchdog()
activePttCaptureId = null
pttCompletion = null
pttSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy
completion?.cancel()
resumeRealtimeCaptureAfterPushToTalk(captureId)
setStatus(if (_isEnabled.value) nativeText("Listening") else nativeText("Ready"))
@@ -757,7 +716,7 @@ class TalkModeManager internal constructor(
// finally still resumes capture when the scope cancels this job.
gatewayWorkScope.launch(start = CoroutineStart.LAZY) {
try {
finalizeTranscript(transcript, cleared.sessionKeyEnvelope)
finalizeTranscript(transcript)
} finally {
withContext(NonCancellable + Dispatchers.Main) {
resumeRealtimeCaptureAfterPushToTalk(captureId)
@@ -822,7 +781,6 @@ class TalkModeManager internal constructor(
/** Starts a bounded one-shot PTT turn that auto-stops on silence or timeout. */
internal suspend fun beginPushToTalkOnce(
maxDurationMs: Long = 12_000L,
sessionKeyEnvelope: TalkSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy,
canStartCapture: () -> Boolean = { true },
): TalkPttOnceStart {
val busyCaptureId = activePttCaptureId ?: finishingPttCaptureId
@@ -841,7 +799,6 @@ class TalkModeManager internal constructor(
val start =
startPushToTalk(
allowNewCapture = true,
sessionKeyEnvelope = sessionKeyEnvelope,
canStartCapture = canStartCapture,
completion = completion,
autoStopAfterMs = maxDurationMs,
@@ -1072,7 +1029,6 @@ class TalkModeManager internal constructor(
finalizeInFlight = false
listeningMode = false
activePttCaptureId = null
pttSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy
synchronized(finishingPttLock) {
finishingPttJob?.cancel()
}
@@ -2382,17 +2338,14 @@ class TalkModeManager internal constructor(
finalizeInFlight = true
gatewayWorkScope.launch {
try {
finalizeTranscript(transcript, TalkSessionKeyEnvelope.Legacy)
finalizeTranscript(transcript)
} finally {
finalizeInFlight = false
}
}
}
private suspend fun finalizeTranscript(
transcript: String,
sessionKeyEnvelope: TalkSessionKeyEnvelope,
) {
private suspend fun finalizeTranscript(transcript: String) {
listeningMode = false
_isListening.value = false
setStatus(nativeText("Thinking…"), awaitingAgent = true)
@@ -2420,9 +2373,8 @@ class TalkModeManager internal constructor(
try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
val chatSessionKey = resolveTalkChatSessionKey(sessionKeyEnvelope, mainSessionKey)
Log.d(tag, "chat.send start sessionKey=$chatSessionKey chars=${prompt.length}")
val ack = sendChat(prompt, chatSessionKey)
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
if (ack.isTerminalFailure) {
@@ -2534,12 +2486,10 @@ class TalkModeManager internal constructor(
if (activePttCaptureId != captureId) return null
val transcript = PushToTalkTranscriptMerger.merge(pttFinalSegments, pttLivePartial)
val completion = pttCompletion
val sessionKeyEnvelope = pttSessionKeyEnvelope
pttTimeoutJob?.cancel()
pttTimeoutJob = null
pttAutoStopEnabled = false
pttCompletion = null
pttSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy
pttReleaseCompletion?.cancel()
pttReleaseCompletion = null
activePttCaptureId = null
@@ -2555,11 +2505,7 @@ class TalkModeManager internal constructor(
lastTranscript = ""
lastHeardAtMs = null
_inputLevel.value = 0f
return ClearedPushToTalkCapture(
transcript = transcript,
completion = completion,
sessionKeyEnvelope = sessionKeyEnvelope,
)
return ClearedPushToTalkCapture(transcript = transcript, completion = completion)
}
private fun finishPushToTalk(
@@ -2615,13 +2561,13 @@ class TalkModeManager internal constructor(
private suspend fun sendChat(
message: String,
sessionKey: String,
session: GatewaySession,
): ChatSendAck {
val runId = UUID.randomUUID().toString()
armPendingRun(runId)
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(sessionKey))
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("message", JsonPrimitive(message))
put("thinking", JsonPrimitive("low"))
put("timeoutMs", JsonPrimitive(30_000))
@@ -8,7 +8,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -91,12 +90,6 @@ private data class InvokeScenarioResult(
val resultParams: JsonObject,
)
private enum class ProtocolFeaturesResponse {
Supported,
Unsupported,
Failed,
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@@ -962,13 +955,11 @@ class GatewaySessionInvokeTest {
fun nodeInvokeRequest_roundTripsInvokeResult() =
runBlocking {
val handshakeOrigin = AtomicReference<String?>(null)
val protocolFeatures = AtomicReference<JsonObject?>(null)
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
onProtocolFeaturesUpdate = protocolFeatures::set,
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
@@ -977,18 +968,6 @@ class GatewaySessionInvokeTest {
assertEquals("node-1", result.request.nodeId)
assertEquals("debug.ping", result.request.command)
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
assertTrue(result.request.hasSessionKeyEnvelope)
assertNull(result.request.sessionKey)
assertEquals(
"node-invoke-session-key-envelope-v1",
protocolFeatures
.get()
?.get("features")
?.let { it as? JsonArray }
?.single()
?.jsonPrimitive
?.content,
)
assertNull(handshakeOrigin.get())
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
@@ -1010,266 +989,6 @@ class GatewaySessionInvokeTest {
)
}
@Test
fun nodeInvokeRequest_preservesSessionKeyEnvelopeValue() =
runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-session","nodeId":"node-1","command":"debug.ping","sessionKey":"agent:main:main"}}""",
) {
GatewaySession.InvokeResult.ok(null)
}
assertTrue(result.request.hasSessionKeyEnvelope)
assertEquals("agent:main:main", result.request.sessionKey)
}
@Test
fun nodeInvokeRequest_preservesExplicitSessionKeyClear() =
runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-clear","nodeId":"node-1","command":"debug.ping","sessionKey":null}}""",
) {
GatewaySession.InvokeResult.ok(null)
}
assertTrue(result.request.hasSessionKeyEnvelope)
assertNull(result.request.sessionKey)
}
@Test
fun nodeInvokeRequest_omitsSessionKeyEnvelopeOnlyForConfirmedLegacyGateway() =
runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-legacy","nodeId":"node-1","command":"debug.ping"}}""",
protocolFeaturesResponse = ProtocolFeaturesResponse.Unsupported,
) {
GatewaySession.InvokeResult.ok(null)
}
assertFalse(result.request.hasSessionKeyEnvelope)
assertNull(result.request.sessionKey)
}
@Test
fun nodeInvokeRequest_failsClosedWhenProtocolFeaturePublishFails() =
runBlocking {
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-failed","nodeId":"node-1","command":"debug.ping"}}""",
protocolFeaturesResponse = ProtocolFeaturesResponse.Failed,
) {
GatewaySession.InvokeResult.ok(null)
}
assertTrue(result.request.hasSessionKeyEnvelope)
assertNull(result.request.sessionKey)
}
@Test
fun nodeInvokeRequest_preservesLegacyEnvelopeForFrameReceivedBeforeNegotiationCompletes() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<JsonObject>()
val lastDisconnect = AtomicReference("")
val invokeSentAtNanos = AtomicReference<Long>()
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
invokeSentAtNanos.set(System.nanoTime())
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-pre-negotiation","nodeId":"node-1","command":"debug.ping","timeoutMs":500}}""",
)
}
"node.protocolFeatures.update" -> {
Thread {
Thread.sleep(2_000)
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
}.apply {
isDaemon = true
start()
}
}
"node.invoke.result" -> {
invokeResultParams.complete(frame["params"]?.jsonObject ?: JsonObject(emptyMap()))
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
}
}
}
val harness =
createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request ->
invokeRequest.complete(request)
GatewaySession.InvokeResult.ok(null)
}
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val request = withTimeout(1_000) { invokeRequest.await() }
val result = withTimeout(1_000) { invokeResultParams.await() }
assertFalse(request.hasSessionKeyEnvelope)
assertNull(request.sessionKey)
assertTrue(
TimeUnit.NANOSECONDS.toMillis(
System.nanoTime() - checkNotNull(invokeSentAtNanos.get()),
) < 1_000,
)
assertEquals(true, result["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun nodeInvokeRequest_usesAuthoritativeEnvelopeImmediatelyAfterNegotiationResponse() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, _ ->
when (method) {
"connect" -> webSocket.send(connectResponseFrame(id))
"node.protocolFeatures.update" -> {
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-post-negotiation","nodeId":"node-1","command":"debug.ping"}}""",
)
}
"node.invoke.result" ->
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
}
}
val harness =
createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request ->
invokeRequest.complete(request)
GatewaySession.InvokeResult.ok(null)
}
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
assertTrue(request.hasSessionKeyEnvelope)
assertNull(request.sessionKey)
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun nodeInvokeRequest_explicitEnvelopeBypassesProtocolNegotiation() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, _ ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-explicit","nodeId":"node-1","command":"debug.ping","timeoutMs":50,"sessionKey":"agent:main:main"}}""",
)
}
"node.protocolFeatures.update" -> {
Thread.sleep(150)
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
}
"node.invoke.result" ->
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
}
}
val harness =
createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request ->
invokeRequest.complete(request)
GatewaySession.InvokeResult.ok(null)
}
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
assertTrue(request.hasSessionKeyEnvelope)
assertEquals("agent:main:main", request.sessionKey)
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun nodeInvokeRequest_resetsLegacyNegotiationAfterReconnect() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val requests = Channel<GatewaySession.InvokeRequest>(capacity = 2)
val lastDisconnect = AtomicReference("")
fun startServer(protocolFeaturesResponse: ProtocolFeaturesResponse): MockWebServer =
startGatewayServer(json) { webSocket, id, method, _ ->
when (method) {
"connect" -> webSocket.send(connectResponseFrame(id))
"node.protocolFeatures.update" -> {
val response =
when (protocolFeaturesResponse) {
ProtocolFeaturesResponse.Supported ->
"""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}"""
ProtocolFeaturesResponse.Unsupported ->
"""{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unknown method: node.protocolFeatures.update"}}"""
ProtocolFeaturesResponse.Failed ->
"""{"type":"res","id":"$id","ok":false,"error":{"code":"UNAVAILABLE","message":"temporary failure"}}"""
}
webSocket.send(response)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-$protocolFeaturesResponse","nodeId":"node-1","command":"debug.ping"}}""",
)
}
"node.invoke.result" ->
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
}
}
val legacyServer = startServer(ProtocolFeaturesResponse.Unsupported)
val currentServer = startServer(ProtocolFeaturesResponse.Supported)
val harness =
createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request ->
requests.send(request)
GatewaySession.InvokeResult.ok(null)
}
try {
connectNodeSession(harness.session, legacyServer.port)
awaitConnectedOrThrow(connected, lastDisconnect, legacyServer)
val legacy = withTimeout(TEST_TIMEOUT_MS) { requests.receive() }
assertFalse(legacy.hasSessionKeyEnvelope)
harness.session.disconnectAndJoin()
connectNodeSession(harness.session, currentServer.port)
val current = withTimeout(TEST_TIMEOUT_MS) { requests.receive() }
assertTrue(current.hasSessionKeyEnvelope)
assertNull(current.sessionKey)
} finally {
harness.session.disconnect()
harness.sessionJob.cancelAndJoin()
legacyServer.shutdown()
currentServer.shutdown()
}
}
@Test
fun nodeInvokeRequest_usesParamsJsonWhenProvided() =
runBlocking {
@@ -1463,8 +1182,6 @@ class GatewaySessionInvokeTest {
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-cancelled","nodeId":"node-1","command":"camera.snap","timeoutMs":5000}}""",
)
}
"node.protocolFeatures.update" ->
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
"node.invoke.result" -> invokeResult.complete(Unit)
}
}
@@ -1771,8 +1488,6 @@ class GatewaySessionInvokeTest {
private suspend fun runInvokeScenario(
invokeEventFrame: String,
onHandshake: ((RecordedRequest) -> Unit)? = null,
protocolFeaturesResponse: ProtocolFeaturesResponse = ProtocolFeaturesResponse.Supported,
onProtocolFeaturesUpdate: ((JsonObject) -> Unit)? = null,
afterResult: suspend (InvokeScenarioResult) -> Unit = {},
onInvoke: suspend (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): InvokeScenarioResult {
@@ -1787,19 +1502,8 @@ class GatewaySessionInvokeTest {
onHandshake = onHandshake,
) { webSocket, id, method, frame ->
when (method) {
"connect" -> webSocket.send(connectResponseFrame(id))
"node.protocolFeatures.update" -> {
onProtocolFeaturesUpdate?.invoke(frame["params"]?.jsonObject ?: JsonObject(emptyMap()))
val response =
when (protocolFeaturesResponse) {
ProtocolFeaturesResponse.Supported ->
"""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}"""
ProtocolFeaturesResponse.Unsupported ->
"""{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unknown method: node.protocolFeatures.update"}}"""
ProtocolFeaturesResponse.Failed ->
"""{"type":"res","id":"$id","ok":false,"error":{"code":"UNAVAILABLE","message":"temporary failure"}}"""
}
webSocket.send(response)
"connect" -> {
webSocket.send(connectResponseFrame(id))
webSocket.send(invokeEventFrame)
}
"node.invoke.result" -> {
@@ -165,11 +165,10 @@ class GatewaySessionReconnectTest {
val unexpectedRequest = CompletableDeferred<Unit>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
when (method) {
"connect" -> webSocket.send(connectResponseFrame(id))
"node.protocolFeatures.update" ->
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
else -> unexpectedRequest.complete(Unit)
if (method == "connect") {
webSocket.send(connectResponseFrame(id))
} else {
unexpectedRequest.complete(Unit)
}
}
val harness =
@@ -272,56 +272,6 @@ class InvokeDispatcherTest {
)
}
@Test
fun handleInvoke_preservesGatewaySessionEnvelopeInsideHandlers() =
runTest {
val talk = InvokeDispatcherFakeTalkHandler()
val dispatcher = newDispatcher(talkHandler = talk)
dispatcher.handleInvoke(
GatewaySession.InvokeRequest(
id = "attributed",
nodeId = "node-1",
command = OpenClawTalkCommand.PttOnce.rawValue,
paramsJson = null,
timeoutMs = null,
sessionKey = "agent:main:main",
hasSessionKeyEnvelope = true,
),
)
dispatcher.handleInvoke(
GatewaySession.InvokeRequest(
id = "cleared",
nodeId = "node-1",
command = OpenClawTalkCommand.PttOnce.rawValue,
paramsJson = null,
timeoutMs = null,
sessionKey = null,
hasSessionKeyEnvelope = true,
),
)
dispatcher.handleInvoke(
GatewaySession.InvokeRequest(
id = "legacy",
nodeId = "node-1",
command = OpenClawTalkCommand.PttOnce.rawValue,
paramsJson = null,
timeoutMs = null,
sessionKey = null,
hasSessionKeyEnvelope = false,
),
)
assertEquals(
listOf(
NodeInvokeSessionKeyEnvelope.Authoritative("agent:main:main"),
NodeInvokeSessionKeyEnvelope.Authoritative(null),
NodeInvokeSessionKeyEnvelope.Legacy,
),
talk.sessionKeyEnvelopes,
)
}
@Test
fun handleInvoke_blocksTalkOnceButLeavesPttStartToRuntimeStateGateWhenBackgrounded() =
runTest {
@@ -507,30 +457,24 @@ private class InvokeDispatcherFakeSystemNotificationPoster : SystemNotificationP
private class InvokeDispatcherFakeTalkHandler : TalkHandler {
val calls = mutableListOf<String>()
val sessionKeyEnvelopes = mutableListOf<NodeInvokeSessionKeyEnvelope>()
private suspend fun record(call: String) {
calls.add(call)
sessionKeyEnvelopes.add(currentNodeInvokeSessionKeyEnvelope())
}
override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult {
record("start")
calls.add("start")
return GatewaySession.InvokeResult.ok("""{"captureId":"start"}""")
}
override suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult {
record("stop")
calls.add("stop")
return GatewaySession.InvokeResult.ok("""{"status":"stop"}""")
}
override suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult {
record("cancel")
calls.add("cancel")
return GatewaySession.InvokeResult.ok("""{"status":"cancel"}""")
}
override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult {
record("once")
calls.add("once")
return GatewaySession.InvokeResult.ok("""{"status":"once"}""")
}
}
@@ -37,9 +37,6 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -226,47 +223,6 @@ class TalkModeManagerTest {
assertFalse(completion.isCompleted)
}
@Test
fun pushToTalkChatSendUsesCaptureOwnedSessionEnvelope() =
runTest {
val cases =
listOf(
TalkSessionKeyEnvelope.Authoritative("agent:main:admitted") to "agent:main:admitted",
TalkSessionKeyEnvelope.Authoritative(null) to "main",
TalkSessionKeyEnvelope.Legacy to "device-selected",
)
for ((envelope, expectedSessionKey) in cases) {
val sentParams = CompletableDeferred<String>()
val manager =
createManager(
scope = this,
requestGateway = { method, paramsJson, _ ->
if (method == "chat.send") {
sentParams.complete(requireNotNull(paramsJson))
throw IllegalStateException("captured chat.send")
}
error("unexpected gateway request: $method")
},
)
manager.setMainSessionKey("device-selected")
setPrivateField(manager, "configLoaded", true)
setPrivateField(manager, "activePttCaptureId", "capture-$expectedSessionKey")
setPrivateField(manager, "pttSessionKeyEnvelope", envelope)
@Suppress("UNCHECKED_CAST")
(readPrivateField(manager, "pttFinalSegments") as MutableList<String>) += "send this"
withMain(dispatcher = Dispatchers.Unconfined, cleanup = manager::stopAllCapture) {
val result = manager.endPushToTalk("capture-$expectedSessionKey")
assertEquals("queued", result.status)
advanceUntilIdle()
}
val params = Json.parseToJsonElement(sentParams.await()).jsonObject
assertEquals(expectedSessionKey, params.getValue("sessionKey").jsonPrimitive.content)
}
}
@Test
fun cancelledOneShotWaitCleansItsCapture() =
runTest {
@@ -1121,7 +1077,6 @@ class TalkModeManagerTest {
realtimeCaptureDispatcher: CoroutineDispatcher = Dispatchers.IO,
realtimePlaybackDispatcher: CoroutineDispatcher = Dispatchers.IO,
realtimeMarkAcknowledger: (suspend (String, String) -> Unit)? = null,
requestGateway: (suspend (String, String?, Long) -> String)? = null,
): TalkModeManager {
val app = RuntimeEnvironment.getApplication()
val session =
@@ -1144,7 +1099,6 @@ class TalkModeManagerTest {
realtimeCaptureDispatcher = realtimeCaptureDispatcher,
realtimePlaybackDispatcher = realtimePlaybackDispatcher,
realtimeMarkAcknowledger = realtimeMarkAcknowledger,
requestGatewayOverride = requestGateway,
)
}
@@ -1,6 +1,6 @@
import Darwin
import Foundation
@_spi(AgentExecutionAttribution) import OpenClawKit
import OpenClawKit
import OSLog
extension Notification.Name {
@@ -118,8 +118,7 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
}
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let sessionKeyEnvelope = GatewayNodeInvokeContext.sessionKeyEnvelope
return await withCheckedContinuation { continuation in
await withCheckedContinuation { continuation in
self.queue.async {
guard self.process?.isRunning == true, self.manifest != nil else {
continuation.resume(returning: Self.unavailableResponse(
@@ -135,18 +134,12 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
}
self.invokeContinuations[request.id] = continuation
do {
var workerRequest: [String: Any] = [
let workerRequest: [String: Any] = [
"id": request.id,
"nodeId": request.nodeId ?? "",
"command": request.command,
"paramsJSON": request.paramsJSON ?? NSNull(),
]
switch sessionKeyEnvelope {
case .legacy:
break
case let .authoritative(sessionKey):
workerRequest["sessionKey"] = sessionKey ?? NSNull()
}
try self.enqueueWriteLocked([
"type": "invoke",
"request": workerRequest,
@@ -92,14 +92,12 @@ struct DashboardWindowSmokeTests {
@Test func `dashboard window controller shows and closes`() throws {
let url = try #require(URL(string: "http://127.0.0.1:18789/control/#token=device-token"))
let windowAutosaveName = "OpenClawDashboardWindow-Test-\(UUID().uuidString)"
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:18789/control/",
token: "device-token",
password: nil),
windowAutosaveName: windowAutosaveName)
password: nil))
controller.show()
#expect(controller.window?.styleMask.contains(.titled) == true)
#expect(controller.window?.styleMask.contains(.closable) == true)
@@ -117,7 +115,7 @@ struct DashboardWindowSmokeTests {
#expect(controller.window?.toolbar?.isVisible == true)
#expect((controller.window?.frame.width ?? 0) >= DashboardWindowLayout.windowMinSize.width)
#expect((controller.window?.frame.height ?? 0) >= DashboardWindowLayout.windowMinSize.height)
#expect(controller.window?.frameAutosaveName == windowAutosaveName)
#expect(controller.window?.frameAutosaveName == DashboardWindowLayout.windowFrameAutosaveName)
controller.closeDashboard()
}
@@ -1,5 +1,5 @@
import Foundation
@_spi(AgentExecutionAttribution) import OpenClawKit
import OpenClawKit
import Testing
@testable import OpenClaw
@@ -238,47 +238,6 @@ struct MacNodeHostWorkerTests {
await worker.stop()
}
@Test func `worker forwards only negotiated session envelopes`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let script = """
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
IFS= read -r attributed
printf '%s' "$attributed" | grep -q '"sessionKey":"agent:main:main"' || exit 40
printf '%s\\n' '{"type":"invoke-result","result":{"id":"attributed","ok":true}}'
IFS= read -r cleared
printf '%s' "$cleared" | grep -q '"sessionKey":null' || exit 41
printf '%s\\n' '{"type":"invoke-result","result":{"id":"cleared","ok":true}}'
IFS= read -r legacy
if printf '%s' "$legacy" | grep -q '"sessionKey"'; then exit 42; fi
printf '%s\\n' '{"type":"invoke-result","result":{"id":"legacy","ok":true}}'
while IFS= read -r line; do :; done
"""
_ = try await worker.start(command: ["/bin/sh", "-c", script])
let attributed = await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(
.authoritative("agent:main:main"))
{
await worker.invoke(BridgeInvokeRequest(
id: "attributed",
command: "system.run"))
}
let cleared = await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(.authoritative(nil)) {
await worker.invoke(BridgeInvokeRequest(
id: "cleared",
command: "system.run"))
}
let legacy = await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(.legacy) {
await worker.invoke(BridgeInvokeRequest(
id: "legacy",
command: "system.run"))
}
#expect(attributed.ok)
#expect(cleared.ok)
#expect(legacy.ok)
await worker.stop()
}
@Test func `worker forwards terminal input and cancellation frames`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let script = """
@@ -402,7 +402,6 @@ struct MacNodeModeCoordinatorTests {
"nodeId": "test-node",
"command": "computer.act",
"paramsJSON": "{}",
"sessionKey": NSNull(),
"timeoutMs": 0,
],
])
@@ -1,16 +1,5 @@
import Foundation
@_spi(AgentExecutionAttribution)
public enum GatewayNodeInvokeSessionKeyEnvelope: Equatable, Sendable {
case legacy
case authoritative(String?)
}
@_spi(AgentExecutionAttribution)
public enum GatewayNodeInvokeContext {
@TaskLocal public static var sessionKeyEnvelope: GatewayNodeInvokeSessionKeyEnvelope = .legacy
}
public struct BridgeInvokeRequest: Codable, Sendable {
public let type: String
public let id: String
@@ -6,6 +6,28 @@ import OSLog
/// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
private func gatewayErrorDetails(_ error: ErrorShape?) -> [String: ProtoAnyCodable] {
var details: [String: ProtoAnyCodable] = [:]
if let nested = error?.details?.value as? [String: ProtoAnyCodable] {
details.merge(nested) { _, nestedValue in nestedValue }
}
if let error {
if details["code"] == nil {
details["code"] = ProtoAnyCodable(error.code)
} else {
details["errorCode"] = ProtoAnyCodable(error.code)
}
details["message"] = ProtoAnyCodable(error.message)
if let retryable = error.retryable {
details["retryable"] = ProtoAnyCodable(retryable)
}
if let retryAfterMs = error.retryafterms {
details["retryAfterMs"] = ProtoAnyCodable(retryAfterMs)
}
}
return details
}
extension String {
fileprivate var nilIfEmpty: String? {
self.isEmpty ? nil : self
@@ -13,10 +35,22 @@ extension String {
}
public actor GatewayChannelActor {
nonisolated static func resolveRequestTimeoutMs(_ timeoutMs: Double?, defaultMs: Double) -> Double? {
timeoutMs == 0 ? nil : (timeoutMs ?? defaultMs)
}
nonisolated static func minimumProtocolVersion(role: String, clientMode: String) -> Int {
// Node RPC frames stayed compatible across v3/v4. Operator chat surfaces require v4.
if role == "node", clientMode == "node" {
return GATEWAY_MIN_NODE_PROTOCOL_VERSION
}
return GATEWAY_MIN_PROTOCOL_VERSION
}
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
private var activeConnectAttemptID: UUID?
var pending: [String: PendingRequest] = [:]
var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
private var connected = false
private var connectAttemptTask: Task<Void, Never>?
/// Socket ownership epoch. Every callback and send stays bound to the task
@@ -1089,10 +1123,8 @@ extension GatewayChannelActor {
switch frame {
case let .res(res):
let id = res.id
if let request = pending.removeValue(forKey: id) {
// Keep response observers ahead of the next socket frame.
await request.onResponse?(res)
request.continuation.resume(returning: .res(res))
if let waiter = pending.removeValue(forKey: id) {
waiter.resume(returning: .res(res))
}
case let .event(evt):
if evt.event == "connect.challenge" { return }
@@ -1351,8 +1383,7 @@ extension GatewayChannelActor {
params: params,
timeoutMs: timeoutMs,
task: task,
connectionGeneration: connectionGeneration,
onResponse: nil)
connectionGeneration: connectionGeneration)
}
/// Sends a request only on an already-connected physical socket. Unlike
@@ -1372,28 +1403,7 @@ extension GatewayChannelActor {
params: params,
timeoutMs: timeoutMs,
task: task,
connectionGeneration: expectedGeneration,
onResponse: nil)
}
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil,
ifCurrentConnectionGeneration expectedGeneration: UInt64,
onResponse: @escaping @Sendable (ResponseFrame) async -> Void) async throws -> Data
{
guard self.isConnected(connectionGeneration: expectedGeneration),
let task = self.task,
task.state == .running
else { throw CancellationError() }
return try await self.request(
method: method,
params: params,
timeoutMs: timeoutMs,
task: task,
connectionGeneration: expectedGeneration,
onResponse: onResponse)
connectionGeneration: expectedGeneration)
}
/// The generation is usable as a lease only while its socket is live.
@@ -1410,8 +1420,7 @@ extension GatewayChannelActor {
params: [String: AnyCodable]?,
timeoutMs: Double?,
task: WebSocketTaskBox,
connectionGeneration: UInt64,
onResponse: (@Sendable (ResponseFrame) async -> Void)?) async throws -> Data
connectionGeneration: UInt64) async throws -> Data
{
// Zero leaves terminal-operation deadlines to the Gateway owner.
let effectiveTimeout = Self.resolveRequestTimeoutMs(timeoutMs, defaultMs: self.defaultRequestTimeoutMs)
@@ -1426,9 +1435,7 @@ extension GatewayChannelActor {
cont.resume(throwing: CancellationError())
return
}
self.pending[payload.id] = PendingRequest(
continuation: cont,
onResponse: onResponse)
self.pending[payload.id] = cont
if let effectiveTimeout {
Task { [weak self] in
guard let self else { return }
@@ -1613,23 +1620,23 @@ extension GatewayChannelActor {
private func failPending(_ error: Error) async {
let waiters = self.pending
self.pending.removeAll()
for (_, request) in waiters {
request.continuation.resume(throwing: error)
for (_, waiter) in waiters {
waiter.resume(throwing: error)
}
}
private func timeoutRequest(id: String, timeoutMs: Double) async {
guard let request = self.pending.removeValue(forKey: id) else { return }
guard let waiter = self.pending.removeValue(forKey: id) else { return }
let err = NSError(
domain: "Gateway",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"])
request.continuation.resume(throwing: err)
waiter.resume(throwing: err)
}
private func cancelRequest(id: String) {
guard let request = self.pending.removeValue(forKey: id) else { return }
request.continuation.resume(throwing: CancellationError())
guard let waiter = self.pending.removeValue(forKey: id) else { return }
waiter.resume(throwing: CancellationError())
}
private func cancelConnectWaiter(id: UUID) {
@@ -1,6 +1,5 @@
import CryptoKit
import Foundation
import OpenClawProtocol
func gatewayIntValue(_ value: Any?) -> Int? {
if let value = value as? Int {
@@ -25,28 +24,6 @@ func gatewayIntValue(_ value: Any?) -> Int? {
return nil
}
func gatewayErrorDetails(_ error: ErrorShape?) -> [String: OpenClawProtocol.AnyCodable] {
var details: [String: OpenClawProtocol.AnyCodable] = [:]
if let nested = error?.details?.value as? [String: OpenClawProtocol.AnyCodable] {
details.merge(nested) { _, nestedValue in nestedValue }
}
if let error {
if details["code"] == nil {
details["code"] = OpenClawProtocol.AnyCodable(error.code)
} else {
details["errorCode"] = OpenClawProtocol.AnyCodable(error.code)
}
details["message"] = OpenClawProtocol.AnyCodable(error.message)
if let retryable = error.retryable {
details["retryable"] = OpenClawProtocol.AnyCodable(retryable)
}
if let retryAfterMs = error.retryafterms {
details["retryAfterMs"] = OpenClawProtocol.AnyCodable(retryAfterMs)
}
}
return details
}
/// Bridges task cancellation into the request continuation without racing send.
final class GatewayRequestCancellationGate: @unchecked Sendable {
private let lock = NSLock()
@@ -66,18 +43,6 @@ final class GatewayRequestCancellationGate: @unchecked Sendable {
}
extension GatewayChannelActor {
nonisolated static func resolveRequestTimeoutMs(_ timeoutMs: Double?, defaultMs: Double) -> Double? {
timeoutMs == 0 ? nil : (timeoutMs ?? defaultMs)
}
nonisolated static func minimumProtocolVersion(role: String, clientMode: String) -> Int {
// Node RPC frames stayed compatible across v3/v4. Operator chat surfaces require v4.
if role == "node", clientMode == "node" {
return GATEWAY_MIN_NODE_PROTOCOL_VERSION
}
return GATEWAY_MIN_PROTOCOL_VERSION
}
enum ConnectChallengeError: Error {
case invalid
case timeout
@@ -92,11 +57,6 @@ extension GatewayChannelActor {
"operator.pairing",
]
struct PendingRequest {
let continuation: CheckedContinuation<GatewayFrame, Error>
let onResponse: (@Sendable (ResponseFrame) async -> Void)?
}
struct SelectedConnectAuth {
let authToken: String?
let authBootstrapToken: String?
@@ -1,482 +0,0 @@
import CryptoKit
import Foundation
struct NodeInvokeRequestPayload: Codable {
var id: String
var nodeId: String
var command: String
var paramsJSON: String?
var timeoutMs: Int?
var idempotencyKey: String?
var sessionKey: String?
var hasSessionKeyEnvelope: Bool
private enum CodingKeys: String, CodingKey {
case id
case nodeId
case command
case paramsJSON
case timeoutMs
case idempotencyKey
case sessionKey
}
init(
id: String,
nodeId: String,
command: String,
paramsJSON: String?,
timeoutMs: Int?,
idempotencyKey: String?,
sessionKey: String? = nil,
hasSessionKeyEnvelope: Bool = false)
{
self.id = id
self.nodeId = nodeId
self.command = command
self.paramsJSON = paramsJSON
self.timeoutMs = timeoutMs
self.idempotencyKey = idempotencyKey
self.sessionKey = sessionKey
self.hasSessionKeyEnvelope = hasSessionKeyEnvelope
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.nodeId = try container.decode(String.self, forKey: .nodeId)
self.command = try container.decode(String.self, forKey: .command)
self.paramsJSON = try container.decodeIfPresent(String.self, forKey: .paramsJSON)
self.timeoutMs = try container.decodeIfPresent(Int.self, forKey: .timeoutMs)
self.idempotencyKey = try container.decodeIfPresent(String.self, forKey: .idempotencyKey)
self.hasSessionKeyEnvelope = container.contains(.sessionKey)
let sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.sessionKey = sessionKey?.isEmpty == false ? sessionKey : nil
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.nodeId, forKey: .nodeId)
try container.encode(self.command, forKey: .command)
try container.encodeIfPresent(self.paramsJSON, forKey: .paramsJSON)
try container.encodeIfPresent(self.timeoutMs, forKey: .timeoutMs)
try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey)
if self.hasSessionKeyEnvelope {
try container.encode(self.sessionKey, forKey: .sessionKey)
}
}
}
enum NodeInvokeSessionEnvelopeMode: Equatable, Sendable {
case authoritative
case legacy
}
struct NodeInvokeRequestContext: Sendable {
let envelopeMode: NodeInvokeSessionEnvelopeMode
let route: GatewayNodeSessionRoute
let receiptScope: String
let channel: GatewayChannelActor
let socketGeneration: UInt64
let receivedAt: ContinuousClock.Instant
}
enum ComputerInvokeReceiptState {
case inFlight(Task<BridgeInvokeResponse, Never>)
case completed(BridgeInvokeResponse)
var isCompleted: Bool {
if case .completed = self {
return true
}
return false
}
}
struct ComputerInvokeReceipt {
let id: UUID
let fingerprint: String
var state: ComputerInvokeReceiptState
var operationStarted: Bool
var operationSettled: Bool
}
struct ComputerInvokeReceiptKey: Hashable {
let receiptScopeBytes: [UInt8]
let idempotencyKeyBytes: [UInt8]
init(receiptScope: String, idempotencyKey: String) {
self.receiptScopeBytes = Array(receiptScope.utf8)
self.idempotencyKeyBytes = Array(idempotencyKey.utf8)
}
}
extension GatewayNodeSession {
static func staleRouteInvokeResponse(requestId: String) -> BridgeInvokeResponse {
BridgeInvokeResponse(
id: requestId,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: self.staleRouteInvokeMessage))
}
func invokeWithComputerReceipt(
requestPayload: NodeInvokeRequestPayload,
request: BridgeInvokeRequest,
timeoutMs: Int?,
receiptScope: String,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async
-> BridgeInvokeResponse
{
let timeout = timeoutMs.map { min(max(0, $0), Self.maxInvokeTimeoutMs) }
?? Self.defaultInvokeTimeoutMs
let deadline = timeout > 0
? ContinuousClock.now.advanced(by: .milliseconds(timeout))
: nil
return await self.invokeWithComputerReceipt(
requestPayload: requestPayload,
request: request,
deadline: deadline,
receiptScope: receiptScope,
onInvoke: onInvoke,
retryStaleJoinedReceipt: true)
}
private func invokeWithComputerReceipt(
requestPayload: NodeInvokeRequestPayload,
request: BridgeInvokeRequest,
deadline: ContinuousClock.Instant?,
receiptScope: String,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
retryStaleJoinedReceipt: Bool) async
-> BridgeInvokeResponse
{
let idempotencyKey = requestPayload.idempotencyKey?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard requestPayload.command == "computer.act", !idempotencyKey.isEmpty else {
return await Self.invokeWithComputerDeadline(
request: request,
deadline: deadline,
onInvoke: onInvoke)
}
let receiptKey = ComputerInvokeReceiptKey(
receiptScope: receiptScope,
idempotencyKey: idempotencyKey)
let fingerprint = Self.computerInvokeFingerprint(requestPayload)
if let receipt = computerInvokeReceipts[receiptKey] {
guard receipt.fingerprint == fingerprint else {
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "INVALID_REQUEST: computer.act idempotency key reused with different parameters"))
}
#if DEBUG
self.computerInvokeReceiptJoinCounts[receipt.id, default: 0] += 1
#endif
let response = switch receipt.state {
case let .inFlight(task):
// A duplicate joins the shared side effect but keeps its own deadline.
// Timing out this wait must not cancel the original receipt task.
await Self.invokeWithComputerDeadline(
request: request,
deadline: deadline,
onInvoke: { _ in await task.value })
case let .completed(response): response
}
self.discardRetryableComputerInvokeReceipt(
key: receiptKey,
receiptID: receipt.id,
fingerprint: fingerprint,
response: response)
if retryStaleJoinedReceipt, Self.isStaleRouteInvokeResponse(response) {
// A reconnect retry can join the old route's in-flight receipt.
// Once that receipt proves it never dispatched, retry exactly once
// with this request's route-bound invoke closure.
return await self.invokeWithComputerReceipt(
requestPayload: requestPayload,
request: request,
deadline: deadline,
receiptScope: receiptScope,
onInvoke: onInvoke,
retryStaleJoinedReceipt: false)
}
return Self.rebindInvokeResponse(response, requestId: request.id)
}
guard self.makeComputerInvokeReceiptCapacity() else {
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "UNAVAILABLE: computer.act receipt capacity exhausted"))
}
let receiptID = UUID()
let task = Task { [self] in
await Self.invokeWithComputerDeadline(
request: request,
deadline: deadline,
onInvoke: onInvoke,
onOperationStarted: { [weak self] in
await self?.markComputerInvokeOperationStarted(
key: receiptKey,
receiptID: receiptID,
fingerprint: fingerprint)
},
onOperationSettled: { [weak self] in
await self?.markComputerInvokeOperationSettled(
key: receiptKey,
receiptID: receiptID,
fingerprint: fingerprint)
})
}
self.computerInvokeReceipts[receiptKey] = ComputerInvokeReceipt(
id: receiptID,
fingerprint: fingerprint,
state: .inFlight(task),
operationStarted: false,
operationSettled: false)
self.computerInvokeReceiptOrder.append(receiptKey)
let response = await task.value
if Self.isStaleRouteInvokeResponse(response) {
self.discardRetryableComputerInvokeReceipt(
key: receiptKey,
receiptID: receiptID,
fingerprint: fingerprint,
response: response)
} else if let receipt = self.computerInvokeReceipts[receiptKey],
receipt.id == receiptID,
receipt.fingerprint == fingerprint
{
if receipt.operationStarted {
self.computerInvokeReceipts[receiptKey]?.state = .completed(response)
} else {
// No side effect could have started, so a retry must get a fresh
// receipt instead of inheriting this pre-dispatch timeout.
self.discardComputerInvokeReceipt(
key: receiptKey,
receiptID: receiptID,
fingerprint: fingerprint)
}
}
return Self.rebindInvokeResponse(response, requestId: request.id)
}
private static func invokeWithComputerDeadline(
request: BridgeInvokeRequest,
deadline: ContinuousClock.Instant?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
onOperationStarted: (@Sendable () async -> Void)? = nil,
onOperationSettled: (@Sendable () async -> Void)? = nil) async -> BridgeInvokeResponse
{
guard let deadline else {
return await invokeWithTimeout(
request: request,
timeoutMs: 0,
onInvoke: onInvoke,
onOperationStarted: onOperationStarted,
onOperationSettled: onOperationSettled)
}
let remaining = ContinuousClock.now.duration(to: deadline)
guard remaining > .zero else {
await onOperationSettled?()
return invokeTimeoutResponse(requestId: request.id)
}
let components = remaining.components
let remainingMs = max(
1,
Int(components.seconds) * 1000 +
Int(components.attoseconds / 1_000_000_000_000_000))
return await invokeWithTimeout(
request: request,
timeoutMs: remainingMs,
onInvoke: onInvoke,
onOperationStarted: onOperationStarted,
onOperationSettled: onOperationSettled)
}
#if DEBUG
// periphery:ignore - package tests exercise receipt dedupe around the private invoke path.
func invokeComputerWithReceiptForTesting(
requestId: String,
paramsJSON: String,
idempotencyKey: String,
receiptScope: String,
timeoutMs: Int = 0,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async
-> BridgeInvokeResponse
{
let payload = NodeInvokeRequestPayload(
id: requestId,
nodeId: "test-node",
command: "computer.act",
paramsJSON: paramsJSON,
timeoutMs: timeoutMs,
idempotencyKey: idempotencyKey)
return await self.invokeWithComputerReceipt(
requestPayload: payload,
request: BridgeInvokeRequest(
id: requestId,
command: "computer.act",
paramsJSON: paramsJSON,
nodeId: "test-node"),
timeoutMs: timeoutMs,
receiptScope: receiptScope,
onInvoke: onInvoke)
}
// periphery:ignore - package tests exercise retry after expiry before native dispatch.
func invokeComputerWithExpiredReceiptForTesting(
requestId: String,
paramsJSON: String,
idempotencyKey: String,
receiptScope: String,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async
-> BridgeInvokeResponse
{
let payload = NodeInvokeRequestPayload(
id: requestId,
nodeId: "test-node",
command: "computer.act",
paramsJSON: paramsJSON,
timeoutMs: 1,
idempotencyKey: idempotencyKey)
return await self.invokeWithComputerReceipt(
requestPayload: payload,
request: BridgeInvokeRequest(
id: requestId,
command: "computer.act",
paramsJSON: paramsJSON,
nodeId: "test-node"),
deadline: ContinuousClock.now.advanced(by: .milliseconds(-1)),
receiptScope: receiptScope,
onInvoke: onInvoke,
retryStaleJoinedReceipt: true)
}
// periphery:ignore - package tests assert receipt joining without exposing the receipt store.
func computerReceiptJoinCountForTesting(
idempotencyKey: String,
receiptScope: String) -> Int
{
let receiptKey = ComputerInvokeReceiptKey(
receiptScope: receiptScope,
idempotencyKey: idempotencyKey)
guard let receiptID = self.computerInvokeReceipts[receiptKey]?.id else { return 0 }
return self.computerInvokeReceiptJoinCounts[receiptID] ?? 0
}
#endif
private static func computerInvokeFingerprint(_ request: NodeInvokeRequestPayload) -> String {
// Negotiation may turn an omitted legacy envelope into an explicit null.
// Both mean unattributed; only a non-empty session key changes ownership.
let sessionEnvelope = request.sessionKey.flatMap {
$0.isEmpty ? nil : "attributed:" + $0
} ?? "unattributed"
let value = [
request.nodeId,
request.command,
request.paramsJSON ?? "",
sessionEnvelope,
].joined(separator: "\u{0}")
return SHA256.hash(data: Data(value.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
private static func rebindInvokeResponse(
_ response: BridgeInvokeResponse,
requestId: String) -> BridgeInvokeResponse
{
BridgeInvokeResponse(
type: response.type,
id: requestId,
ok: response.ok,
payload: response.payload,
payloadJSON: response.payloadJSON,
error: response.error)
}
private static func isStaleRouteInvokeResponse(_ response: BridgeInvokeResponse) -> Bool {
response.ok == false &&
response.error?.code == .unavailable &&
response.error?.message == self.staleRouteInvokeMessage
}
private func discardRetryableComputerInvokeReceipt(
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String,
response: BridgeInvokeResponse)
{
guard Self.isStaleRouteInvokeResponse(response),
self.computerInvokeReceipts[key]?.id == receiptID,
self.computerInvokeReceipts[key]?.fingerprint == fingerprint
else { return }
self.discardComputerInvokeReceipt(
key: key,
receiptID: receiptID,
fingerprint: fingerprint)
}
private func discardComputerInvokeReceipt(
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String)
{
guard self.computerInvokeReceipts[key]?.id == receiptID,
self.computerInvokeReceipts[key]?.fingerprint == fingerprint
else { return }
self.computerInvokeReceipts.removeValue(forKey: key)
self.computerInvokeReceiptOrder.removeAll { $0 == key }
#if DEBUG
self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID)
#endif
}
private func markComputerInvokeOperationStarted(
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String)
{
guard self.computerInvokeReceipts[key]?.id == receiptID,
self.computerInvokeReceipts[key]?.fingerprint == fingerprint
else { return }
self.computerInvokeReceipts[key]?.operationStarted = true
}
private func markComputerInvokeOperationSettled(
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String)
{
guard self.computerInvokeReceipts[key]?.id == receiptID,
self.computerInvokeReceipts[key]?.fingerprint == fingerprint
else { return }
self.computerInvokeReceipts[key]?.operationSettled = true
}
private func makeComputerInvokeReceiptCapacity() -> Bool {
while self.computerInvokeReceipts.count >= Self.computerInvokeReceiptLimit {
guard let completedIndex = computerInvokeReceiptOrder.firstIndex(where: { key in
guard let receipt = self.computerInvokeReceipts[key] else { return false }
return receipt.state.isCompleted && receipt.operationSettled
}) else { return false }
let evictedKey = self.computerInvokeReceiptOrder.remove(at: completedIndex)
let evictedReceipt = self.computerInvokeReceipts.removeValue(forKey: evictedKey)
#if DEBUG
if let receiptID = evictedReceipt?.id {
self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID)
}
#endif
}
return true
}
}
@@ -6,7 +6,6 @@ extension GatewayNodeSession {
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
onOperationStarted: (@Sendable () async -> Void)? = nil,
onOperationSettled: (@Sendable () async -> Void)? = nil) async -> BridgeInvokeResponse
{
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
@@ -16,7 +15,6 @@ extension GatewayNodeSession {
}
return Self.defaultInvokeTimeoutMs
}()
await onOperationStarted?()
guard timeout > 0 else {
let response = await onInvoke(request)
await onOperationSettled?()
@@ -73,20 +71,16 @@ extension GatewayNodeSession {
}
guard !Task.isCancelled else { return }
timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)")
latch.resume(Self.invokeTimeoutResponse(requestId: request.id))
latch.resume(BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "node invoke timed out")))
}
}
timeoutLogger
.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
return response
}
static func invokeTimeoutResponse(requestId: String) -> BridgeInvokeResponse {
BridgeInvokeResponse(
id: requestId,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "node invoke timed out"))
}
}
@@ -1,7 +1,17 @@
import CryptoKit
import Foundation
import OpenClawProtocol
import OSLog
private struct NodeInvokeRequestPayload: Codable {
var id: String
var nodeId: String
var command: String
var paramsJSON: String?
var timeoutMs: Int?
var idempotencyKey: String?
}
private struct NodeInvokeCancelPayload: Codable {
var invokeId: String
}
@@ -69,27 +79,47 @@ public struct GatewayNodeSessionCredentials: Sendable, Equatable {
public actor GatewayNodeSession {
@TaskLocal private static var executingLifecycleCallbackID: UUID?
private static let pluginSurfaceRefreshTimeoutMs = 8000.0
private static let nodeInvokeSessionKeyEnvelopeProtocolFeature =
"node-invoke-session-key-envelope-v1"
static let staleRouteInvokeMessage = "UNAVAILABLE: node route changed before dispatch"
private static let staleRouteInvokeMessage = "UNAVAILABLE: node route changed before dispatch"
private enum ComputerInvokeReceiptState {
case inFlight(Task<BridgeInvokeResponse, Never>)
case completed(BridgeInvokeResponse)
var isCompleted: Bool {
if case .completed = self {
return true
}
return false
}
}
private struct ComputerInvokeReceipt {
let id: UUID
let fingerprint: String
var state: ComputerInvokeReceiptState
var operationSettled: Bool
}
private struct ConnectOptionsKey: Equatable {
let normalizedInputs: String
let deviceAuthGatewayIDBytes: [UInt8]?
}
private struct ComputerInvokeReceiptKey: Hashable {
let receiptScopeBytes: [UInt8]
let idempotencyKeyBytes: [UInt8]
init(receiptScope: String, idempotencyKey: String) {
self.receiptScopeBytes = Array(receiptScope.utf8)
self.idempotencyKeyBytes = Array(idempotencyKey.utf8)
}
}
private struct ActiveInvoke {
let admissionGeneration: UInt64
let task: Task<BridgeInvokeResponse, Never>
}
private enum InvokeTimeoutBudget {
case disabled
case expired
case remaining(Int)
}
private struct LifecycleCallbackBarrier {
let id: UUID
let task: Task<Void, Never>
@@ -100,7 +130,7 @@ public actor GatewayNodeSession {
private let encoder = JSONEncoder()
static let defaultInvokeTimeoutMs = 30000
static let maxInvokeTimeoutMs = Int(Int32.max)
static let computerInvokeReceiptLimit = 256
private static let computerInvokeReceiptLimit = 256
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeCredentials: GatewayNodeSessionCredentials?
@@ -130,20 +160,16 @@ public actor GatewayNodeSession {
private var serverMethods: Set<String>?
private var serverCapabilities: Set<GatewayServerCapability>?
private var mainSessionKey: String?
private var nodeInvokeSessionEnvelopeMode: Task<NodeInvokeSessionEnvelopeMode, Never>?
private var nodeInvokeSessionEnvelopeModeResolved = false
private var nodeInvokeSessionEnvelopeNegotiationGeneration: UInt64 = 0
private var nodeInvokeControlDispatch: Task<Void, Never>?
private var snapshotWaiters: [UUID: CheckedContinuation<Bool, Never>] = [:]
private var snapshotReadyWaiters: [CheckedContinuation<Bool, Never>] = []
// `computer.act` is not safe to repeat after a response is lost. Keep recent
// in-flight/results on the long-lived node session so a channel reconnect can
// replay the receipt without posting input twice. App restart intentionally
// remains a wider durable-storage boundary.
var computerInvokeReceipts: [ComputerInvokeReceiptKey: ComputerInvokeReceipt] = [:]
var computerInvokeReceiptOrder: [ComputerInvokeReceiptKey] = []
private var computerInvokeReceipts: [ComputerInvokeReceiptKey: ComputerInvokeReceipt] = [:]
private var computerInvokeReceiptOrder: [ComputerInvokeReceiptKey] = []
#if DEBUG
var computerInvokeReceiptJoinCounts: [UUID: Int] = [:]
private var computerInvokeReceiptJoinCounts: [UUID: Int] = [:]
#endif
private struct ServerEventSubscriber {
@@ -881,10 +907,6 @@ extension GatewayNodeSession {
}
self.hasEverConnected = true
self.markSnapshotReceived()
self.startNodeInvokeSessionEnvelopeNegotiation(
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
socketGeneration: socketGeneration)
await self.notifyConnectedIfNeeded(
admissionGeneration: admissionGeneration)
case let .event(evt):
@@ -900,106 +922,7 @@ extension GatewayNodeSession {
}
}
private func startNodeInvokeSessionEnvelopeNegotiation(
channelGeneration: UInt64,
admissionGeneration: UInt64,
socketGeneration: UInt64)
{
self.nodeInvokeSessionEnvelopeMode?.cancel()
self.nodeInvokeSessionEnvelopeModeResolved = false
self.nodeInvokeSessionEnvelopeNegotiationGeneration &+= 1
let negotiationGeneration = self.nodeInvokeSessionEnvelopeNegotiationGeneration
guard self.connectOptions?.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "node",
let channel
else {
self.nodeInvokeSessionEnvelopeMode = Task { .legacy }
self.nodeInvokeSessionEnvelopeModeResolved = true
return
}
self.nodeInvokeSessionEnvelopeMode = Task { [weak self] in
guard let self else { return .authoritative }
let mode = await self.negotiateNodeInvokeSessionEnvelope(
channel: channel,
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
socketGeneration: socketGeneration,
negotiationGeneration: negotiationGeneration)
await self.markNodeInvokeSessionEnvelopeModeResolved(
channel: channel,
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
negotiationGeneration: negotiationGeneration)
return mode
}
}
private func negotiateNodeInvokeSessionEnvelope(
channel: GatewayChannelActor,
channelGeneration: UInt64,
admissionGeneration: UInt64,
socketGeneration: UInt64,
negotiationGeneration: UInt64) async -> NodeInvokeSessionEnvelopeMode
{
do {
_ = try await channel.request(
method: "node.protocolFeatures.update",
params: [
"features": AnyCodable([
Self.nodeInvokeSessionKeyEnvelopeProtocolFeature,
]),
],
timeoutMs: 15000,
ifCurrentConnectionGeneration: socketGeneration,
onResponse: { [weak self] _ in
guard let self else { return }
await self.markNodeInvokeSessionEnvelopeModeResolved(
channel: channel,
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
negotiationGeneration: negotiationGeneration)
})
guard self.channel === channel,
self.channelGeneration == channelGeneration,
self.admissionGeneration == admissionGeneration
else { return .authoritative }
return .authoritative
} catch let error as GatewayResponseError
where error.code == "INVALID_REQUEST" &&
error.message == "unknown method: node.protocolFeatures.update"
{
return .legacy
} catch is CancellationError {
return .authoritative
} catch {
self.logger.error(
"node protocol feature publish failed: \(error.localizedDescription, privacy: .public)")
// Only an exact unknown-method response enables the nested legacy field.
// Ambiguous failures stay fail-closed for the lifetime of this socket.
return .authoritative
}
}
private func markNodeInvokeSessionEnvelopeModeResolved(
channel: GatewayChannelActor,
channelGeneration: UInt64,
admissionGeneration: UInt64,
negotiationGeneration: UInt64)
{
guard !Task.isCancelled,
self.channel === channel,
self.channelGeneration == channelGeneration,
self.admissionGeneration == admissionGeneration,
self.nodeInvokeSessionEnvelopeNegotiationGeneration == negotiationGeneration
else { return }
self.nodeInvokeSessionEnvelopeModeResolved = true
}
private func resetConnectionState() {
self.nodeInvokeSessionEnvelopeMode?.cancel()
self.nodeInvokeControlDispatch?.cancel()
self.nodeInvokeSessionEnvelopeMode = nil
self.nodeInvokeSessionEnvelopeModeResolved = false
self.nodeInvokeControlDispatch = nil
self.hasNotifiedConnected = false
self.snapshotReceived = false
self.serverMethods = nil
@@ -1193,89 +1116,6 @@ extension GatewayNodeSession {
socketGeneration: UInt64) async
{
self.broadcastServerEvent(evt)
if evt.event == "node.invoke.request" ||
evt.event == "node.invoke.input" ||
evt.event == "node.invoke.cancel"
{
self.enqueueNodeInvokeEvent(
evt,
channel: channel,
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
socketGeneration: socketGeneration)
}
}
private func enqueueNodeInvokeEvent(
_ evt: EventFrame,
channel: GatewayChannelActor,
channelGeneration: UInt64,
admissionGeneration: UInt64,
socketGeneration: UInt64)
{
let receivedAt = ContinuousClock.now
if evt.event == "node.invoke.input" || evt.event == "node.invoke.cancel" {
// MacNodeModeCoordinator is the only production control consumer. Its worker
// buffers controls by invoke id until the invoke frame is registered.
let previous = self.nodeInvokeControlDispatch
let dispatch = Task { [weak self] in
await previous?.value
guard !Task.isCancelled, let self else { return }
await self.handleAdmittedNodeInvokeEvent(
evt,
mode: .authoritative,
channel: channel,
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
socketGeneration: socketGeneration,
receivedAt: receivedAt)
}
self.nodeInvokeControlDispatch = dispatch
return
}
let decodedRequest = evt.payload.flatMap { try? self.decodeInvokeRequest(from: $0) }
let hasWireSessionKey = decodedRequest?.hasSessionKeyEnvelope == true
// Omission becomes authoritative only after feature publication completes.
// Frames already received during negotiation retain legacy semantics.
let envelopeNegotiationResolvedAtReceipt = self.nodeInvokeSessionEnvelopeModeResolved
let envelopeModeTask =
hasWireSessionKey
? Task { .authoritative }
: self.nodeInvokeSessionEnvelopeMode ?? Task { .authoritative }
Task { [weak self] in
let mode: NodeInvokeSessionEnvelopeMode = if hasWireSessionKey {
.authoritative
} else if !envelopeNegotiationResolvedAtReceipt {
.legacy
} else {
await envelopeModeTask.value
}
guard !Task.isCancelled, let self else { return }
await self.handleAdmittedNodeInvokeEvent(
evt,
mode: mode,
channel: channel,
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
socketGeneration: socketGeneration,
receivedAt: receivedAt)
}
}
private func handleAdmittedNodeInvokeEvent(
_ evt: EventFrame,
mode: NodeInvokeSessionEnvelopeMode,
channel: GatewayChannelActor,
channelGeneration: UInt64,
admissionGeneration: UInt64,
socketGeneration: UInt64,
receivedAt: ContinuousClock.Instant) async
{
guard self.channelGeneration == channelGeneration,
self.admissionGeneration == admissionGeneration,
self.activeSocketGeneration == socketGeneration,
self.channel === channel
else { return }
if evt.event == "node.invoke.input" {
guard let payload = evt.payload, let onInvokeInput else { return }
do {
@@ -1313,20 +1153,17 @@ extension GatewayNodeSession {
channelGeneration: channelGeneration,
admissionGeneration: admissionGeneration,
socketGeneration: socketGeneration)
let context = NodeInvokeRequestContext(
envelopeMode: mode,
route: route,
receiptScope: self.computerInvokeReceiptScope(),
channel: channel,
socketGeneration: socketGeneration,
receivedAt: receivedAt)
let receiptScope = self.computerInvokeReceiptScope()
// GatewayChannel waits for push handling before it rearms receive. Run device work
// separately so a long invoke cannot starve heartbeats or later node requests.
Task.detached { [weak self] in
await self?.handleInvokeRequest(
request: request,
onInvoke: onInvoke,
context: context)
route: route,
receiptScope: receiptScope,
channel: channel,
socketGeneration: socketGeneration)
}
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -1336,15 +1173,13 @@ extension GatewayNodeSession {
private func handleInvokeRequest(
request: NodeInvokeRequestPayload,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
context: NodeInvokeRequestContext) async
route: GatewayNodeSessionRoute,
receiptScope: String,
channel: GatewayChannelActor,
socketGeneration: UInt64) async
{
var request = request
if context.envelopeMode == .authoritative, !request.hasSessionKeyEnvelope {
request.hasSessionKeyEnvelope = true
request.sessionKey = nil
}
guard self.isCurrentRoute(context.route),
self.channel === context.channel
guard self.isCurrentRoute(route),
self.channel === channel
else { return }
// Lifecycle cleanup gates owner readiness. Reject while it is suspended instead of
// holding the Gateway request until timeout; the replacement route stays fail-closed.
@@ -1358,8 +1193,8 @@ extension GatewayNodeSession {
error: OpenClawNodeError(
code: .unavailable,
message: "UNAVAILABLE: node lifecycle transition in progress")),
channel: context.channel,
socketGeneration: context.socketGeneration)
channel: channel,
socketGeneration: socketGeneration)
return
}
self.logger.info("node invoke executing id=\(request.id, privacy: .public)")
@@ -1368,52 +1203,33 @@ extension GatewayNodeSession {
command: request.command,
paramsJSON: request.paramsJSON,
nodeId: request.nodeId)
let sessionKeyEnvelope = Self.sessionKeyEnvelope(request: request)
let routeBoundInvoke: @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse = { [weak self] req in
// Timeout and receipt helpers may dispatch detached tasks. Rebind the immutable
// envelope at the final owner callback so those hops cannot erase attribution.
await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(sessionKeyEnvelope) {
guard let self else {
return Self.staleRouteInvokeResponse(requestId: req.id)
}
return await self.invokeIfCurrentRoute(
req,
expectedRoute: context.route,
onInvoke: onInvoke)
guard let self else {
return Self.staleRouteInvokeResponse(requestId: req.id)
}
return await self.invokeIfCurrentRoute(
req,
expectedRoute: route,
onInvoke: onInvoke)
}
let timeoutMs: Int
switch Self.invokeTimeoutBudget(timeoutMs: request.timeoutMs, receivedAt: context.receivedAt) {
case .disabled:
timeoutMs = 0
case .expired:
await self.sendInvokeResult(
request: request,
response: Self.invokeTimeoutResponse(requestId: request.id),
channel: context.channel,
socketGeneration: context.socketGeneration)
return
case let .remaining(remaining):
timeoutMs = remaining
}
let response = await self.invokeWithComputerReceipt(
let response = await invokeWithComputerReceipt(
requestPayload: request,
request: bridgeRequest,
timeoutMs: timeoutMs,
receiptScope: context.receiptScope,
timeoutMs: request.timeoutMs,
receiptScope: receiptScope,
onInvoke: routeBoundInvoke)
// Invoke output belongs to the requesting channel. A target switch while the device
// command is running must discard it instead of disclosing it to the replacement.
guard self.isCurrentRoute(context.route),
self.channel === context.channel
guard self.isCurrentRoute(route),
self.channel === channel
else { return }
self.logger.info(
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
await self.sendInvokeResult(
request: request,
response: response,
channel: context.channel,
socketGeneration: context.socketGeneration)
channel: channel,
socketGeneration: socketGeneration)
}
func invokeIfCurrentRoute(
@@ -1451,35 +1267,6 @@ extension GatewayNodeSession {
route.admissionGeneration == self.admissionGeneration
}
private static func sessionKeyEnvelope(
request: NodeInvokeRequestPayload) -> GatewayNodeInvokeSessionKeyEnvelope
{
if request.hasSessionKeyEnvelope {
return .authoritative(request.sessionKey)
}
return .legacy
}
private static func invokeTimeoutBudget(
timeoutMs: Int?,
receivedAt: ContinuousClock.Instant) -> InvokeTimeoutBudget
{
let timeout = timeoutMs.map { min(max(0, $0), Self.maxInvokeTimeoutMs) }
?? Self.defaultInvokeTimeoutMs
guard timeout > 0 else { return .disabled }
let duration = receivedAt.duration(to: ContinuousClock.now)
let components = duration.components
guard components.seconds >= 0 else { return .remaining(timeout) }
if components.seconds > Int64(timeout / 1000) {
return .expired
}
let elapsedMs =
Int(components.seconds) * 1000 +
Int(components.attoseconds / 1_000_000_000_000_000)
guard elapsedMs < timeout else { return .expired }
return .remaining(timeout - elapsedMs)
}
private func admitSocketGeneration(_ socketGeneration: UInt64) -> Bool {
if let lastRetiredSocketGeneration,
socketGeneration <= lastRetiredSocketGeneration
@@ -1593,6 +1380,159 @@ extension GatewayNodeSession {
}
}
private static func staleRouteInvokeResponse(requestId: String) -> BridgeInvokeResponse {
BridgeInvokeResponse(
id: requestId,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: self.staleRouteInvokeMessage))
}
private func invokeWithComputerReceipt(
requestPayload: NodeInvokeRequestPayload,
request: BridgeInvokeRequest,
timeoutMs: Int?,
receiptScope: String,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
retryStaleJoinedReceipt: Bool = true) async
-> BridgeInvokeResponse
{
let idempotencyKey = requestPayload.idempotencyKey?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard requestPayload.command == "computer.act", !idempotencyKey.isEmpty else {
return await Self.invokeWithTimeout(
request: request,
timeoutMs: timeoutMs,
onInvoke: onInvoke)
}
let receiptKey = ComputerInvokeReceiptKey(
receiptScope: receiptScope,
idempotencyKey: idempotencyKey)
let fingerprint = Self.computerInvokeFingerprint(requestPayload)
if let receipt = computerInvokeReceipts[receiptKey] {
guard receipt.fingerprint == fingerprint else {
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "INVALID_REQUEST: computer.act idempotency key reused with different parameters"))
}
#if DEBUG
self.computerInvokeReceiptJoinCounts[receipt.id, default: 0] += 1
#endif
let response = switch receipt.state {
case let .inFlight(task): await task.value
case let .completed(response): response
}
self.discardRetryableComputerInvokeReceipt(
key: receiptKey,
receiptID: receipt.id,
fingerprint: fingerprint,
response: response)
if retryStaleJoinedReceipt, Self.isStaleRouteInvokeResponse(response) {
// A reconnect retry can join the old route's in-flight receipt.
// Once that receipt proves it never dispatched, retry exactly once
// with this request's route-bound invoke closure.
return await self.invokeWithComputerReceipt(
requestPayload: requestPayload,
request: request,
timeoutMs: timeoutMs,
receiptScope: receiptScope,
onInvoke: onInvoke,
retryStaleJoinedReceipt: false)
}
return Self.rebindInvokeResponse(response, requestId: request.id)
}
guard self.makeComputerInvokeReceiptCapacity() else {
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "UNAVAILABLE: computer.act receipt capacity exhausted"))
}
let receiptID = UUID()
let task = Task { [self] in
await Self.invokeWithTimeout(
request: request,
timeoutMs: timeoutMs,
onInvoke: onInvoke,
onOperationSettled: { [weak self] in
await self?.markComputerInvokeOperationSettled(
key: receiptKey,
receiptID: receiptID,
fingerprint: fingerprint)
})
}
self.computerInvokeReceipts[receiptKey] = ComputerInvokeReceipt(
id: receiptID,
fingerprint: fingerprint,
state: .inFlight(task),
operationSettled: false)
self.computerInvokeReceiptOrder.append(receiptKey)
let response = await task.value
if Self.isStaleRouteInvokeResponse(response) {
self.discardRetryableComputerInvokeReceipt(
key: receiptKey,
receiptID: receiptID,
fingerprint: fingerprint,
response: response)
} else if self.computerInvokeReceipts[receiptKey]?.id == receiptID,
self.computerInvokeReceipts[receiptKey]?.fingerprint == fingerprint
{
self.computerInvokeReceipts[receiptKey]?.state = .completed(response)
}
return Self.rebindInvokeResponse(response, requestId: request.id)
}
#if DEBUG
// periphery:ignore - package tests exercise receipt dedupe around the private invoke path.
func invokeComputerWithReceiptForTesting(
requestId: String,
paramsJSON: String,
idempotencyKey: String,
receiptScope: String,
timeoutMs: Int = 0,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async
-> BridgeInvokeResponse
{
let payload = NodeInvokeRequestPayload(
id: requestId,
nodeId: "test-node",
command: "computer.act",
paramsJSON: paramsJSON,
timeoutMs: timeoutMs,
idempotencyKey: idempotencyKey)
return await self.invokeWithComputerReceipt(
requestPayload: payload,
request: BridgeInvokeRequest(
id: requestId,
command: "computer.act",
paramsJSON: paramsJSON,
nodeId: "test-node"),
timeoutMs: timeoutMs,
receiptScope: receiptScope,
onInvoke: onInvoke)
}
// periphery:ignore - package tests assert receipt joining without exposing the receipt store.
func computerReceiptJoinCountForTesting(
idempotencyKey: String,
receiptScope: String) -> Int
{
let receiptKey = ComputerInvokeReceiptKey(
receiptScope: receiptScope,
idempotencyKey: idempotencyKey)
guard let receiptID = self.computerInvokeReceipts[receiptKey]?.id else { return 0 }
return self.computerInvokeReceiptJoinCounts[receiptID] ?? 0
}
#endif
private func computerInvokeReceiptScope() -> String {
if let gatewayID = self.connectOptions?.deviceAuthGatewayID,
!gatewayID.isEmpty
@@ -1602,6 +1542,77 @@ extension GatewayNodeSession {
return "url:\(self.activeURL?.absoluteString ?? "unknown")"
}
private static func computerInvokeFingerprint(_ request: NodeInvokeRequestPayload) -> String {
let value = [request.nodeId, request.command, request.paramsJSON ?? ""].joined(separator: "\u{0}")
return SHA256.hash(data: Data(value.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
private static func rebindInvokeResponse(
_ response: BridgeInvokeResponse,
requestId: String) -> BridgeInvokeResponse
{
BridgeInvokeResponse(
type: response.type,
id: requestId,
ok: response.ok,
payload: response.payload,
payloadJSON: response.payloadJSON,
error: response.error)
}
private static func isStaleRouteInvokeResponse(_ response: BridgeInvokeResponse) -> Bool {
response.ok == false &&
response.error?.code == .unavailable &&
response.error?.message == self.staleRouteInvokeMessage
}
private func discardRetryableComputerInvokeReceipt(
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String,
response: BridgeInvokeResponse)
{
guard Self.isStaleRouteInvokeResponse(response),
self.computerInvokeReceipts[key]?.id == receiptID,
self.computerInvokeReceipts[key]?.fingerprint == fingerprint
else { return }
self.computerInvokeReceipts.removeValue(forKey: key)
self.computerInvokeReceiptOrder.removeAll { $0 == key }
#if DEBUG
self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID)
#endif
}
private func markComputerInvokeOperationSettled(
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String)
{
guard self.computerInvokeReceipts[key]?.id == receiptID,
self.computerInvokeReceipts[key]?.fingerprint == fingerprint
else { return }
self.computerInvokeReceipts[key]?.operationSettled = true
}
private func makeComputerInvokeReceiptCapacity() -> Bool {
while self.computerInvokeReceipts.count >= Self.computerInvokeReceiptLimit {
guard let completedIndex = computerInvokeReceiptOrder.firstIndex(where: { key in
guard let receipt = self.computerInvokeReceipts[key] else { return false }
return receipt.state.isCompleted && receipt.operationSettled
}) else { return false }
let evictedKey = self.computerInvokeReceiptOrder.remove(at: completedIndex)
let evictedReceipt = self.computerInvokeReceipts.removeValue(forKey: evictedKey)
#if DEBUG
if let receiptID = evictedReceipt?.id {
self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID)
}
#endif
}
return true
}
private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload {
try self.decodeEventPayload(from: payload)
}
@@ -3384,20 +3384,6 @@ public struct NodeSkillsUpdateParams: Codable, Sendable {
}
}
public struct NodeProtocolFeaturesUpdateParams: Codable, Sendable {
public let features: [String]
public init(
features: [String])
{
self.features = features
}
private enum CodingKeys: String, CodingKey {
case features
}
}
public struct NodePendingAckParams: Codable, Sendable {
public let ids: [String]
@@ -3569,7 +3555,6 @@ public struct NodeInvokeRequestEvent: Codable, Sendable {
public let paramsjson: String?
public let timeoutms: Int?
public let idempotencykey: String?
public let sessionkey: AnyCodable?
public init(
id: String,
@@ -3577,8 +3562,7 @@ public struct NodeInvokeRequestEvent: Codable, Sendable {
command: String,
paramsjson: String? = nil,
timeoutms: Int? = nil,
idempotencykey: String? = nil,
sessionkey: AnyCodable? = nil)
idempotencykey: String? = nil)
{
self.id = id
self.nodeid = nodeid
@@ -3586,7 +3570,6 @@ public struct NodeInvokeRequestEvent: Codable, Sendable {
self.paramsjson = paramsjson
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
@@ -3596,31 +3579,6 @@ public struct NodeInvokeRequestEvent: Codable, Sendable {
case paramsjson = "paramsJSON"
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
case sessionkey = "sessionKey"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.nodeid = try container.decode(String.self, forKey: .nodeid)
self.command = try container.decode(String.self, forKey: .command)
self.paramsjson = try container.decodeIfPresent(String.self, forKey: .paramsjson)
self.timeoutms = try container.decodeIfPresent(Int.self, forKey: .timeoutms)
self.idempotencykey = try container.decodeIfPresent(String.self, forKey: .idempotencykey)
self.sessionkey = container.contains(.sessionkey)
? try container.decode(AnyCodable.self, forKey: .sessionkey)
: nil
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(nodeid, forKey: .nodeid)
try container.encode(command, forKey: .command)
try container.encodeIfPresent(paramsjson, forKey: .paramsjson)
try container.encodeIfPresent(timeoutms, forKey: .timeoutms)
try container.encodeIfPresent(idempotencykey, forKey: .idempotencykey)
try container.encodeIfPresent(sessionkey, forKey: .sessionkey)
}
}
@@ -180,27 +180,4 @@ struct GatewayModelsCompatibilityTests {
#expect(decodedCleared.modelvalue?.value is NSNull)
#expect(reencodedCleared["model"] is NSNull)
}
@Test
func `node invoke session envelope preserves omitted null and value states`() throws {
let omitted = try JSONDecoder().decode(
NodeInvokeRequestEvent.self,
from: Data(#"{"id":"invoke-1","nodeId":"node-1","command":"debug.ping"}"#.utf8))
let cleared = try JSONDecoder().decode(
NodeInvokeRequestEvent.self,
from: Data(
#"{"id":"invoke-2","nodeId":"node-1","command":"debug.ping","sessionKey":null}"#.utf8))
let attributed = try JSONDecoder().decode(
NodeInvokeRequestEvent.self,
from: Data(
#"{"id":"invoke-3","nodeId":"node-1","command":"debug.ping","sessionKey":"agent:main:main"}"#.utf8))
let reencodedCleared = try #require(
JSONSerialization.jsonObject(with: JSONEncoder().encode(cleared))
as? [String: Any])
#expect(omitted.sessionkey == nil)
#expect(cleared.sessionkey?.value is NSNull)
#expect(attributed.sessionkey?.value as? String == "agent:main:main")
#expect(reencodedCleared["sessionKey"] is NSNull)
}
}
@@ -1,7 +1,7 @@
import Foundation
import OpenClawProtocol
import Testing
@_spi(AgentExecutionAttribution) @testable import OpenClawKit
@testable import OpenClawKit
extension NSLock {
fileprivate func withLock<T>(_ body: () -> T) -> T {
@@ -36,18 +36,6 @@ private actor StringCapture {
}
}
private actor SessionKeyEnvelopeCapture {
private var values: [GatewayNodeInvokeSessionKeyEnvelope] = []
func append(_ value: GatewayNodeInvokeSessionKeyEnvelope) {
self.values.append(value)
}
func all() -> [GatewayNodeInvokeSessionKeyEnvelope] {
self.values
}
}
/// Delivers a pong asynchronously, well before the deadline, so a cancelled deadline
/// task racing the gate would surface as a spurious timeout.
private final class DelayedPongWebSocketTask: WebSocketTasking, @unchecked Sendable {
@@ -204,10 +192,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private let helloSessionDefaults: [String: Any]?
private let helloDelayNanoseconds: UInt64
private let connectError: [String: Any]?
private let protocolFeaturesError: [String: Any]?
private let protocolFeaturesAutoResponse: Bool
private let protocolFeaturesResponseDelay: Duration
private let protocolFeaturesPostResponseInvoke: Bool
private let cancelGate: FirstCancelGate?
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
@@ -225,10 +209,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
helloSessionDefaults: [String: Any]? = nil,
helloDelayNanoseconds: UInt64 = 0,
connectError: [String: Any]? = nil,
protocolFeaturesError: [String: Any]? = nil,
protocolFeaturesAutoResponse: Bool = true,
protocolFeaturesResponseDelay: Duration = .zero,
protocolFeaturesPostResponseInvoke: Bool = false,
cancelGate: FirstCancelGate? = nil)
{
self.helloAuth = helloAuth
@@ -236,10 +216,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.helloSessionDefaults = helloSessionDefaults
self.helloDelayNanoseconds = helloDelayNanoseconds
self.connectError = connectError
self.protocolFeaturesError = protocolFeaturesError
self.protocolFeaturesAutoResponse = protocolFeaturesAutoResponse
self.protocolFeaturesResponseDelay = protocolFeaturesResponseDelay
self.protocolFeaturesPostResponseInvoke = protocolFeaturesPostResponseInvoke
self.cancelGate = cancelGate
}
@@ -281,25 +257,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.sentRequestMethods.append(method)
self.sentRequestPayloads.append(obj)
}
if method == "node.protocolFeatures.update",
self.protocolFeaturesAutoResponse,
let id = obj["id"] as? String
{
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(for: self.protocolFeaturesResponseDelay)
if let protocolFeaturesError = self.protocolFeaturesError {
self.emitError(id: id, error: protocolFeaturesError)
} else {
self.emitResponse(id: id, payload: ["ok": true])
}
if self.protocolFeaturesPostResponseInvoke {
self.emitInvokeRequest(
id: "post-negotiation",
command: "mcp.tools.call.v1")
}
}
}
guard method == "connect", let id = obj["id"] as? String else { return }
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
@@ -394,23 +351,25 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.emitInbound(.failure(URLError(.networkConnectionLost)))
}
func emitInvokeRequest(id: String, command: String, idempotencyKey: String? = nil) {
self.emitInvokeRequest(
id: id,
command: command,
paramsJSON: "{}",
idempotencyKey: idempotencyKey)
}
func emitInvokeRequest(
id: String,
command: String,
paramsJSON: String? = "{}",
idempotencyKey: String? = nil,
includeSessionKey: Bool = false,
sessionKey: String? = nil,
timeoutMs: Int? = nil)
paramsJSON: String?,
idempotencyKey: String? = nil)
{
self.emitInbound(.success(.data(Self.invokeRequestData(
id: id,
command: command,
paramsJSON: paramsJSON,
idempotencyKey: idempotencyKey,
includeSessionKey: includeSessionKey,
sessionKey: sessionKey,
timeoutMs: timeoutMs))))
idempotencyKey: idempotencyKey))))
}
func emitResponse(id: String, payload: [String: Any]) {
@@ -424,17 +383,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.emitInbound(.success(.data(data)))
}
func emitError(id: String, error: [String: Any]) {
let frame: [String: Any] = [
"type": "res",
"id": id,
"ok": false,
"error": error,
]
let data = (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
self.emitInbound(.success(.data(data)))
}
private func emitInbound(_ result: ReceiveResult) {
let handler = self.lock.withLock { () -> (@Sendable (ReceiveResult) -> Void)? in
guard let handler = self.pendingReceiveHandler else {
@@ -529,10 +477,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
id: String,
command: String,
paramsJSON: String?,
idempotencyKey: String?,
includeSessionKey: Bool,
sessionKey: String?,
timeoutMs: Int?) -> Data
idempotencyKey: String?) -> Data
{
var payload: [String: Any] = [
"id": id,
@@ -543,12 +488,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
if let idempotencyKey {
payload["idempotencyKey"] = idempotencyKey
}
if includeSessionKey {
payload["sessionKey"] = sessionKey ?? NSNull()
}
if let timeoutMs {
payload["timeoutMs"] = timeoutMs
}
let frame: [String: Any] = [
"type": "event",
"event": "node.invoke.request",
@@ -567,10 +506,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS
private let helloSessionDefaults: [String: Any]?
private let helloDelayNanoseconds: UInt64
private let connectError: [String: Any]?
private let protocolFeaturesError: [String: Any]?
private let protocolFeaturesAutoResponse: Bool
private let protocolFeaturesResponseDelay: Duration
private let protocolFeaturesPostResponseInvoke: Bool
private let cancelGate: FirstCancelGate?
let effectiveTLSFingerprintSHA256: String?
private var tasks: [FakeGatewayWebSocketTask] = []
@@ -583,10 +518,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS
helloSessionDefaults: [String: Any]? = nil,
helloDelayNanoseconds: UInt64 = 0,
connectError: [String: Any]? = nil,
protocolFeaturesError: [String: Any]? = nil,
protocolFeaturesAutoResponse: Bool = true,
protocolFeaturesResponseDelay: Duration = .zero,
protocolFeaturesPostResponseInvoke: Bool = false,
cancelGate: FirstCancelGate? = nil,
effectiveTLSFingerprintSHA256: String? = nil)
{
@@ -595,10 +526,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS
self.helloSessionDefaults = helloSessionDefaults
self.helloDelayNanoseconds = helloDelayNanoseconds
self.connectError = connectError
self.protocolFeaturesError = protocolFeaturesError
self.protocolFeaturesAutoResponse = protocolFeaturesAutoResponse
self.protocolFeaturesResponseDelay = protocolFeaturesResponseDelay
self.protocolFeaturesPostResponseInvoke = protocolFeaturesPostResponseInvoke
self.cancelGate = cancelGate
self.effectiveTLSFingerprintSHA256 = effectiveTLSFingerprintSHA256
}
@@ -629,10 +556,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS
helloSessionDefaults: self.helloSessionDefaults,
helloDelayNanoseconds: self.helloDelayNanoseconds,
connectError: self.connectError,
protocolFeaturesError: self.protocolFeaturesError,
protocolFeaturesAutoResponse: self.protocolFeaturesAutoResponse,
protocolFeaturesResponseDelay: self.protocolFeaturesResponseDelay,
protocolFeaturesPostResponseInvoke: self.protocolFeaturesPostResponseInvoke,
cancelGate: self.cancelGate)
self.tasks.append(task)
return WebSocketTaskBox(task: task)
@@ -859,29 +782,6 @@ private func nodeInvokePush(id: String, command: String) -> GatewayPush {
stateversion: nil))
}
private func nodeInvokeInputPush(id: String, seq: Int, payloadJSON: String) -> GatewayPush {
.event(EventFrame(
type: "event",
event: "node.invoke.input",
payload: AnyCodable([
"id": AnyCodable(id),
"nodeId": AnyCodable("test-node"),
"seq": AnyCodable(seq),
"payloadJSON": AnyCodable(payloadJSON),
]),
seq: nil,
stateversion: nil))
}
private func nodeInvokeCancelPush(id: String) -> GatewayPush {
.event(EventFrame(
type: "event",
event: "node.invoke.cancel",
payload: AnyCodable(["invokeId": AnyCodable(id)]),
seq: nil,
stateversion: nil))
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test func `operator canvas refresh uses the operator surface method`() async throws {
@@ -915,9 +815,7 @@ struct GatewayNodeSessionTests {
@Test func `canvas surface refresh is shared across callers with different timeouts`() async throws {
let expectedFingerprint = String(repeating: "ab", count: 32)
let session = FakeGatewayWebSocketSession(
protocolFeaturesAutoResponse: false,
effectiveTLSFingerprintSHA256: expectedFingerprint)
let session = FakeGatewayWebSocketSession(effectiveTLSFingerprintSHA256: expectedFingerprint)
let gateway = GatewayNodeSession()
let options = nodeConnectOptions(caps: ["canvas"], clientId: "openclaw-macos", clientDisplayName: "macOS Test")
@@ -926,20 +824,14 @@ struct GatewayNodeSessionTests {
async let first = gateway.refreshCanvasHostUrl(replacing: nil)
async let second = gateway.refreshCanvasHostUrl(timeoutSeconds: 1)
async let third = gateway.refreshPluginSurfaceUrl(surface: "canvas", timeoutSeconds: 2)
try await waitUntil("protocol feature and surface refresh sent") {
guard let task = session.latestTask() else { return false }
return task.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
task.sentRequestCount(method: "node.pluginSurface.refresh") == 1
try await waitUntil("single surface refresh sent") {
session.latestTask()?.sentRequestCount(method: "node.pluginSurface.refresh") == 1
}
let task = try #require(session.latestTask())
let featureRequest = try #require(task.sentRequests(method: "node.protocolFeatures.update").first)
let surfaceRequest = try #require(task.sentRequests(method: "node.pluginSurface.refresh").first)
try task.emitResponse(
id: #require(featureRequest["id"] as? String),
payload: ["ok": true])
try task.emitResponse(
id: #require(surfaceRequest["id"] as? String),
let request = try #require(task.sentRequests(method: "node.pluginSurface.refresh").first)
let requestID = try #require(request["id"] as? String)
task.emitResponse(
id: requestID,
payload: [
"surface": "canvas",
"pluginSurfaceUrls": [
@@ -1139,8 +1031,7 @@ struct GatewayNodeSessionTests {
}
@Test func `node invoke input and cancellation reach route callbacks`() async throws {
let session = FakeGatewayWebSocketSession(
protocolFeaturesResponseDelay: .seconds(1))
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let probe = NodeInvokeControlProbe()
let options = nodeConnectOptions(
@@ -1157,36 +1048,30 @@ struct GatewayNodeSessionTests {
onInvokeCancel: { invokeId in await probe.recordCancellation(invokeId) })
await gateway._test_handlePush(
nodeInvokePush(id: "blocked", command: "codex.terminal.resume.v1"),
.event(EventFrame(
type: "event",
event: "node.invoke.input",
payload: AnyCodable([
"id": AnyCodable("terminal-1"),
"nodeId": AnyCodable("test-node"),
"seq": AnyCodable(3),
"payloadJSON": AnyCodable(#"{"data":"hello"}"#),
]),
seq: nil,
stateversion: nil)),
socketGeneration: 1)
await gateway._test_handlePush(
nodeInvokeInputPush(
id: "terminal-1",
seq: 3,
payloadJSON: #"{"data":"hello"}"#),
socketGeneration: 1)
await gateway._test_handlePush(
nodeInvokeCancelPush(id: "terminal-1"),
socketGeneration: 1)
await gateway._test_handlePush(
nodeInvokeInputPush(
id: "blocked",
seq: 4,
payloadJSON: #"{"data":"queued"}"#),
socketGeneration: 1)
await gateway._test_handlePush(
nodeInvokeCancelPush(id: "blocked"),
.event(EventFrame(
type: "event",
event: "node.invoke.cancel",
payload: AnyCodable(["invokeId": AnyCodable("terminal-1")]),
seq: nil,
stateversion: nil)),
socketGeneration: 1)
try await waitUntil("invoke controls bypass protocol negotiation", timeoutSeconds: 0.2) {
let values = await probe.values()
return values.0.count == 2 && values.1.count == 2
}
let values = await probe.values()
#expect(values.0.contains(#"terminal-1:3:{"data":"hello"}"#))
#expect(values.0.contains(#"blocked:4:{"data":"queued"}"#))
#expect(values.1.contains("terminal-1"))
#expect(values.1.contains("blocked"))
#expect(values.0 == [#"terminal-1:3:{"data":"hello"}"#])
#expect(values.1 == ["terminal-1"])
await gateway.disconnect()
}
@@ -2386,396 +2271,6 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `node invoke negotiation carries authoritative session envelopes`() async throws {
let session = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false)
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
})
let task = try #require(session.latestTask())
try await waitUntil("protocol feature publication") {
task.sentRequestCount(method: "node.protocolFeatures.update") == 1
}
let publication = try #require(task.sentRequests(method: "node.protocolFeatures.update").first)
let publicationParams = try #require(publication["params"] as? [String: Any])
#expect(publicationParams["features"] as? [String] == [
"node-invoke-session-key-envelope-v1",
])
try task.emitResponse(
id: #require(publication["id"] as? String),
payload: ["ok": true])
task.emitInvokeRequest(
id: "attributed",
command: "mcp.tools.call.v1",
paramsJSON: "{}",
includeSessionKey: true,
sessionKey: "agent:main:main")
try await waitUntil("attributed envelope delivered") {
await capture.all().count == 1
}
try await waitUntil("receive loop ready for explicit clear") {
task.hasPendingReceiveHandler()
}
task.emitInvokeRequest(
id: "unattributed",
command: "mcp.tools.call.v1",
paramsJSON: "{}",
includeSessionKey: true,
sessionKey: nil)
try await waitUntil("explicit clear delivered") {
await capture.all().count == 2
}
#expect(await capture.all() == [
.authoritative("agent:main:main"),
.authoritative(nil),
])
await gateway.disconnect()
}
@Test
func `node invoke receipt timeout dispatch preserves authoritative session envelopes`() async throws {
let session = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false)
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = nodeConnectOptions(
caps: ["computer"],
commands: ["computer.act"],
clientId: "openclaw-macos",
clientDisplayName: "macOS Test")
try await gateway.connectForTest(
testURL("ws://example.invalid"),
options: options,
session: session,
onInvoke: { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
})
let task = try #require(session.latestTask())
try await waitUntil("protocol feature publication") {
task.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
task.hasPendingReceiveHandler()
}
let publication = try #require(task.sentRequests(method: "node.protocolFeatures.update").first)
try task.emitResponse(
id: #require(publication["id"] as? String),
payload: ["ok": true])
task.emitInvokeRequest(
id: "computer-attributed",
command: "computer.act",
paramsJSON: #"{"action":"type","text":"one"}"#,
idempotencyKey: "computer.act:v1:attributed",
includeSessionKey: true,
sessionKey: "agent:main:main",
timeoutMs: 1000)
try await waitUntil("attributed computer invoke completed") {
task.sentRequestCount(method: "node.invoke.result") == 1
}
try await waitUntil("receive loop ready for cleared computer invoke") {
task.hasPendingReceiveHandler()
}
task.emitInvokeRequest(
id: "computer-cleared",
command: "computer.act",
paramsJSON: #"{"action":"type","text":"two"}"#,
idempotencyKey: "computer.act:v1:cleared",
includeSessionKey: true,
sessionKey: nil,
timeoutMs: 1000)
try await waitUntil("cleared computer invoke completed") {
task.sentRequestCount(method: "node.invoke.result") == 2
}
#expect(await capture.all() == [
.authoritative("agent:main:main"),
.authoritative(nil),
])
await gateway.disconnect()
}
@Test
func `node invoke negotiation resets legacy fallback after reconnect`() async throws {
let legacySession = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false)
let currentSession = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false)
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
let onInvoke: @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse = { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
}
try await gateway.connect(
url: #require(URL(string: "ws://legacy.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: legacySession),
onConnected: {},
onDisconnected: { _ in },
onInvoke: onInvoke)
let legacyTask = try #require(legacySession.latestTask())
try await waitUntil("legacy negotiation completed") {
legacyTask.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
legacyTask.hasPendingReceiveHandler()
}
let legacyPublication = try #require(
legacyTask.sentRequests(method: "node.protocolFeatures.update").first)
try legacyTask.emitError(
id: #require(legacyPublication["id"] as? String),
error: [
"code": "INVALID_REQUEST",
"message": "unknown method: node.protocolFeatures.update",
])
legacyTask.emitInvokeRequest(id: "legacy", command: "mcp.tools.call.v1")
try await waitUntil("legacy envelope delivered") {
await capture.all().count == 1
}
try await gateway.connect(
url: #require(URL(string: "ws://current.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: currentSession),
onConnected: {},
onDisconnected: { _ in },
onInvoke: onInvoke)
let currentTask = try #require(currentSession.latestTask())
try await waitUntil("current negotiation completed") {
currentTask.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
currentTask.hasPendingReceiveHandler()
}
let currentPublication = try #require(
currentTask.sentRequests(method: "node.protocolFeatures.update").first)
try currentTask.emitResponse(
id: #require(currentPublication["id"] as? String),
payload: ["ok": true])
currentTask.emitInvokeRequest(id: "current", command: "mcp.tools.call.v1")
try await waitUntil("current envelope delivered") {
await capture.all().count == 2
}
#expect(await capture.all() == [
.legacy,
.authoritative(nil),
])
await gateway.disconnect()
}
@Test
func `node invoke negotiation fails closed on non compatibility errors`() async throws {
let session = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false)
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
})
let task = try #require(session.latestTask())
try await waitUntil("failed negotiation completed") {
task.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
task.hasPendingReceiveHandler()
}
let publication = try #require(task.sentRequests(method: "node.protocolFeatures.update").first)
try task.emitError(
id: #require(publication["id"] as? String),
error: [
"code": "UNAVAILABLE",
"message": "temporary failure",
])
task.emitInvokeRequest(id: "fail-closed", command: "mcp.tools.call.v1")
try await waitUntil("fail-closed envelope delivered") {
await capture.all().count == 1
}
#expect(await capture.all() == [.authoritative(nil)])
await gateway.disconnect()
}
@Test
func `node invoke received before negotiation preserves legacy envelope`() async throws {
let session = FakeGatewayWebSocketSession(
protocolFeaturesResponseDelay: .seconds(2))
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
})
let task = try #require(session.latestTask())
try await waitUntil("protocol feature publication") {
task.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
task.hasPendingReceiveHandler()
}
task.emitInvokeRequest(
id: "pre-negotiation",
command: "mcp.tools.call.v1",
paramsJSON: "{}",
timeoutMs: 500)
try await waitUntil("pre-negotiation invoke result", timeoutSeconds: 1) {
task.sentRequestCount(method: "node.invoke.result") == 1
}
#expect(await capture.all() == [.legacy])
let result = try #require(task.sentRequests(method: "node.invoke.result").first)
let params = try #require(result["params"] as? [String: Any])
#expect(params["ok"] as? Bool == true)
await gateway.disconnect()
}
@Test
func `node invoke immediately after negotiation response is authoritative`() async throws {
let session = FakeGatewayWebSocketSession(
protocolFeaturesPostResponseInvoke: true)
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
})
try await waitUntil("post-negotiation invoke delivered") {
await capture.all().count == 1
}
#expect(await capture.all() == [.authoritative(nil)])
await gateway.disconnect()
}
@Test
func `explicit node invoke envelope bypasses protocol negotiation`() async throws {
let session = FakeGatewayWebSocketSession(
protocolFeaturesResponseDelay: .seconds(1))
let gateway = GatewayNodeSession()
let capture = SessionKeyEnvelopeCapture()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: "macOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
credentials: .init(),
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope)
return BridgeInvokeResponse(id: request.id, ok: true)
})
let task = try #require(session.latestTask())
try await waitUntil("protocol feature publication") {
task.sentRequestCount(method: "node.protocolFeatures.update") == 1 &&
task.hasPendingReceiveHandler()
}
task.emitInvokeRequest(
id: "explicit",
command: "mcp.tools.call.v1",
paramsJSON: "{}",
includeSessionKey: true,
sessionKey: "agent:main:main",
timeoutMs: 100)
try await waitUntil("explicit invoke bypass", timeoutSeconds: 0.2) {
await capture.all().count == 1
}
#expect(await capture.all() == [.authoritative("agent:main:main")])
await gateway.disconnect()
}
@Test
func `node invoke result preserves structured worker payload`() async throws {
let session = FakeGatewayWebSocketSession()
@@ -2871,9 +2366,7 @@ struct GatewayNodeSessionTests {
id: "computer-completed-replay",
command: "computer.act",
paramsJSON: paramsJSON,
idempotencyKey: idempotencyKey,
includeSessionKey: true,
sessionKey: nil)
idempotencyKey: idempotencyKey)
try await waitUntil("completed computer receipt returned after reconnect") {
replayTask.sentRequestCount(method: "node.invoke.result") == 1
}
@@ -2900,73 +2393,6 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `duplicate computer receipt applies its timeout without cancelling the shared invoke`() async throws {
let gateway = GatewayNodeSession()
let probe = ComputerInvokeProbe()
let paramsJSON = #"{"action":"type","text":"hello"}"#
let key = "computer.act:v1:duplicate-timeout"
let scope = "gateway:duplicate-timeout"
let original = Task {
await gateway.invokeComputerWithReceiptForTesting(
requestId: "original",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
onInvoke: { request in await probe.execute(request) })
}
try await waitUntil("original computer invoke started") {
await probe.count() == 1
}
let duplicate = await gateway.invokeComputerWithReceiptForTesting(
requestId: "duplicate",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
timeoutMs: 10,
onInvoke: { request in await probe.execute(request) })
#expect(!duplicate.ok)
#expect(duplicate.id == "duplicate")
#expect(duplicate.error?.message == "node invoke timed out")
#expect(await probe.count() == 1)
await probe.release()
#expect(await original.value.ok)
#expect(await probe.count() == 1)
}
@Test
func `computer receipt timeout before dispatch remains retryable`() async {
let gateway = GatewayNodeSession()
let probe = ComputerInvokeProbe()
await probe.release()
let paramsJSON = #"{"action":"type","text":"hello"}"#
let key = "computer.act:v1:pre-dispatch-timeout"
let scope = "gateway:pre-dispatch-timeout"
let timedOut = await gateway.invokeComputerWithExpiredReceiptForTesting(
requestId: "expired",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
onInvoke: { request in await probe.execute(request) })
#expect(!timedOut.ok)
#expect(timedOut.error?.message == "node invoke timed out")
#expect(await probe.count() == 0)
let retry = await gateway.invokeComputerWithReceiptForTesting(
requestId: "retry",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
onInvoke: { request in await probe.execute(request) })
#expect(retry.ok)
#expect(await probe.count() == 1)
}
@Test
func `computer invoke receipts isolate canonically equivalent gateway owners`() async {
let gateway = GatewayNodeSession()
@@ -3055,99 +2481,6 @@ struct GatewayNodeSessionTests {
#expect(await freshProbe.count() == 1)
}
@Test
func `stale receipt retry dispatches exactly once without a deadline`() async throws {
let gateway = GatewayNodeSession()
let staleGate = AsyncGate()
let freshProbe = ComputerInvokeProbe()
let paramsJSON = #"{"action":"type","text":"hello"}"#
let key = "computer.act:v1:stale-no-deadline"
let scope = "gateway:stale-no-deadline"
let stale = Task {
await gateway.invokeComputerWithReceiptForTesting(
requestId: "stale",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
onInvoke: { request in
await staleGate.wait()
return GatewayNodeSession.staleRouteInvokeResponse(requestId: request.id)
})
}
try await waitUntil("stale receipt without deadline is in flight") {
await staleGate.hasStarted()
}
let replay = Task {
await gateway.invokeComputerWithReceiptForTesting(
requestId: "replay",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
onInvoke: { request in await freshProbe.execute(request) })
}
try await waitUntil("no-deadline replay joined the stale receipt") {
await gateway.computerReceiptJoinCountForTesting(
idempotencyKey: key,
receiptScope: scope) == 1
}
await staleGate.release()
try await waitUntil("no-deadline retry dispatched once") {
await freshProbe.count() == 1
}
await freshProbe.release()
#expect(await stale.value.ok == false)
#expect(await replay.value.ok)
#expect(await freshProbe.count() == 1)
}
@Test
func `stale receipt retry preserves the original invoke deadline`() async throws {
let gateway = GatewayNodeSession()
let staleGate = AsyncGate()
let freshProbe = ComputerInvokeProbe()
let paramsJSON = #"{"action":"type","text":"hello"}"#
let key = "computer.act:v1:stale-deadline"
let scope = "gateway:stale-deadline"
let stale = Task {
await gateway.invokeComputerWithReceiptForTesting(
requestId: "stale",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
onInvoke: { request in
await staleGate.wait()
return GatewayNodeSession.staleRouteInvokeResponse(requestId: request.id)
})
}
try await waitUntil("stale deadline receipt is in flight") {
await staleGate.hasStarted()
}
let replay = Task {
await gateway.invokeComputerWithReceiptForTesting(
requestId: "replay",
paramsJSON: paramsJSON,
idempotencyKey: key,
receiptScope: scope,
timeoutMs: 100,
onInvoke: { request in await freshProbe.execute(request) })
}
try await waitUntil("deadline replay joined the stale receipt") {
await gateway.computerReceiptJoinCountForTesting(
idempotencyKey: key,
receiptScope: scope) == 1
}
await staleGate.release()
let response = await replay.value
#expect(!response.ok)
#expect(response.error?.message == "node invoke timed out")
await freshProbe.release()
#expect(await stale.value.ok == false)
#expect(await freshProbe.count() <= 1)
}
@Test
func `timed out computer receipt stays non evictable until operation settles`() async throws {
let gateway = GatewayNodeSession()
@@ -13,7 +13,6 @@ import {
validateModelsListParams,
validateModelsProbeParams,
validateNodePluginToolsUpdateParams,
validateNodeProtocolFeaturesUpdateParams,
validateNodeSkillsUpdateParams,
validateNodePresenceActivityPayload,
validateSessionsListParams,
@@ -292,24 +291,6 @@ describe("lazy protocol validators", () => {
]);
});
it("validates bounded transient node protocol features", () => {
expect(
validateNodeProtocolFeaturesUpdateParams({
features: [protocol.NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE],
}),
).toBe(true);
expect(
validateNodeProtocolFeaturesUpdateParams({
features: ["duplicate", "duplicate"],
}),
).toBe(false);
expect(
validateNodeProtocolFeaturesUpdateParams({
features: ["x".repeat(129)],
}),
).toBe(false);
});
it("accepts selected-agent scope on chat send, history, and abort params", () => {
expectAccepted(validateChatHistoryParams, [
{
@@ -182,18 +182,14 @@ describe("native Gateway protocol levels", () => {
});
it("uses the min constant for native connect compatibility ranges", async () => {
const swiftSupportPath =
"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift";
const swiftSupport = await readRepoFile(swiftSupportPath);
const swiftChannelPath = "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift";
const swiftChannel = await readRepoFile(swiftChannelPath);
assertPattern(
swiftSupport,
swiftSupportPath,
swiftChannel,
swiftChannelPath,
/if role == "node", clientMode == "node" \{\s+return GATEWAY_MIN_NODE_PROTOCOL_VERSION\s+\}\s+return GATEWAY_MIN_PROTOCOL_VERSION/,
"node connections must use the node compatibility floor without changing operator clients.",
);
const swiftChannelPath = "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift";
const swiftChannel = await readRepoFile(swiftChannelPath);
assertPattern(
swiftChannel,
swiftChannelPath,
@@ -117,8 +117,6 @@ export {
NodePluginToolsUpdateParamsSchema,
NodeSkillDescriptorSchema,
NodeSkillsUpdateParamsSchema,
NodeProtocolFeaturesUpdateParamsSchema,
NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE,
NodePendingAckParamsSchema,
NodeInvokeParamsSchema,
NodeInvokeInputEventSchema,
@@ -1,26 +1,7 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { validateNodeInvokeProgressParams } from "../index.js";
import { NodeInvokeRequestEventSchema } from "./nodes.js";
describe("node protocol schemas", () => {
it("accepts gateway-owned session attribution on node invoke requests", () => {
const request = {
id: "invoke-1",
nodeId: "node-1",
command: "system.run",
paramsJSON: JSON.stringify({ command: ["echo", "ok"] }),
timeoutMs: 30_000,
idempotencyKey: "request-1",
sessionKey: "agent:main:main",
};
expect(Value.Check(NodeInvokeRequestEventSchema, request)).toBe(true);
expect(Value.Check(NodeInvokeRequestEventSchema, { ...request, sessionKey: null })).toBe(true);
expect(Value.Check(NodeInvokeRequestEventSchema, { ...request, sessionKey: "" })).toBe(false);
expect(Value.Check(NodeInvokeRequestEventSchema, { ...request, extra: true })).toBe(false);
});
it("accepts bounded progress chunks and rejects extra fields", () => {
expect(
validateNodeInvokeProgressParams({
@@ -129,19 +129,6 @@ export const NodeSkillsUpdateParamsSchema = closedObject({
export type NodeSkillDescriptor = Static<typeof NodeSkillDescriptorSchema>;
export type NodeSkillsUpdateParams = Static<typeof NodeSkillsUpdateParamsSchema>;
export const NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE =
"node-invoke-session-key-envelope-v1";
const NodeProtocolFeatureSchema = Type.String({ minLength: 1, maxLength: 128 });
/** Replaces transient protocol features supported by this node connection. */
export const NodeProtocolFeaturesUpdateParamsSchema = closedObject({
features: Type.Array(NodeProtocolFeatureSchema, { maxItems: 32, uniqueItems: true }),
});
export type NodeProtocolFeaturesUpdateParams = Static<
typeof NodeProtocolFeaturesUpdateParamsSchema
>;
/** Acknowledges queued node work that the node has consumed. */
export const NodePendingAckParamsSchema = closedObject({
ids: Type.Array(NonEmptyString, { minItems: 1 }),
@@ -245,8 +232,6 @@ export const NodeInvokeRequestEventSchema = closedObject({
paramsJSON: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
idempotencyKey: Type.Optional(NonEmptyString),
// Presence marks Gateway-owned attribution; null means intentionally unattributed.
sessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
});
/** Ordered input frame sent by the gateway to one long-lived node invoke. */
@@ -13,7 +13,6 @@ export const NodeProtocolSchemas = {
NodePluginToolsUpdateParams: nodes.NodePluginToolsUpdateParamsSchema,
NodeSkillDescriptor: nodes.NodeSkillDescriptorSchema,
NodeSkillsUpdateParams: nodes.NodeSkillsUpdateParamsSchema,
NodeProtocolFeaturesUpdateParams: nodes.NodeProtocolFeaturesUpdateParamsSchema,
NodePendingAckParams: nodes.NodePendingAckParamsSchema,
NodeDescribeParams: nodes.NodeDescribeParamsSchema,
...nodeInvoke.NodeInvokeProtocolSchemas,
@@ -140,9 +140,6 @@ export const validateNodeRenameParams = compile(S.NodeRenameParamsSchema);
export const validateNodeListParams = compile(S.NodeListParamsSchema);
export const validateNodePluginToolsUpdateParams = compile(S.NodePluginToolsUpdateParamsSchema);
export const validateNodeSkillsUpdateParams = compile(S.NodeSkillsUpdateParamsSchema);
export const validateNodeProtocolFeaturesUpdateParams = compile(
S.NodeProtocolFeaturesUpdateParamsSchema,
);
export const validateEnvironmentsCreateParams = compile(S.EnvironmentsCreateParamsSchema);
export const validateEnvironmentsDestroyParams = compile(S.EnvironmentsDestroyParamsSchema);
export const validateEnvironmentsListParams = compile(S.EnvironmentsListParamsSchema);
+3 -6
View File
@@ -452,17 +452,14 @@ function emitStructCustomCodable(
props: Record<string, JsonSchema>,
required: Set<string>,
): string {
const preservesExplicitNull = (key: string) =>
(name === "AgentsUpdateParams" && key === "model") ||
(name === "NodeInvokeRequestEvent" && key === "sessionKey");
if (!Object.keys(props).some(preservesExplicitNull)) {
if (name !== "AgentsUpdateParams" || !props.model) {
return "";
}
const decodedProperties = Object.entries(props).map(([key, propSchema]) => {
const propName = swiftStoredPropertyName(name, key);
if (preservesExplicitNull(key)) {
if (key === "model") {
// decodeIfPresent collapses an explicit JSON null into nil. Presence-aware decoding
// preserves Gateway distinctions between clearing and omitting nullable fields.
// preserves the Gateway patch distinction between clearing and omitting the model.
return ` self.${propName} = container.contains(.${propName})\n ? try container.decode(AnyCodable.self, forKey: .${propName})\n : nil`;
}
if (required.has(key)) {
+7 -21
View File
@@ -384,7 +384,6 @@ export function buildNodeSystemRunInvoke(params: {
// the node program timer. Without this the Gateway falls back to a fixed 30s
// pending-invoke timer and discards a later node result as `ignored`.
timeoutMs: params.target.invokeDeadlineMs,
sessionKey: params.sessionKey,
params: {
command: params.command,
rawCommand: params.rawCommand,
@@ -467,7 +466,6 @@ export async function prepareNodeSystemRun(params: {
{
nodeId: params.target.nodeId,
command: "system.run.prepare",
sessionKey: params.request.sessionKey,
params: {
command: params.target.argv,
rawCommand: params.request.command,
@@ -484,26 +482,14 @@ export async function prepareNodeSystemRun(params: {
if (!prepared) {
throw new Error("invalid system.run.prepare response");
}
const {
agentId: _preparedAgentId,
sessionKey: _preparedSessionKey,
...preparedPlan
} = prepared.plan;
// The node may normalize execution details, but it cannot redefine the
// Gateway-owned agent or session that requested preparation.
const plan: SystemRunApprovalPlan = {
...preparedPlan,
agentId: params.request.agentId ?? null,
sessionKey: params.request.sessionKey ?? null,
};
return {
plan,
argv: plan.argv,
rawCommand: plan.commandText,
transportRawCommand: plan.commandText,
cwd: plan.cwd ?? params.request.workdir,
agentId: params.request.agentId,
sessionKey: params.request.sessionKey,
plan: prepared.plan,
argv: prepared.plan.argv,
rawCommand: prepared.plan.commandText,
transportRawCommand: prepared.plan.commandText,
cwd: prepared.plan.cwd ?? params.request.workdir,
agentId: prepared.plan.agentId ?? params.request.agentId,
sessionKey: prepared.plan.sessionKey ?? params.request.sessionKey,
...(prepared.execPolicy ? { execPolicy: prepared.execPolicy } : {}),
allowAlwaysCoverage: prepared.allowAlwaysCoverage,
};
+6 -54
View File
@@ -95,11 +95,6 @@ const preparedPlan = vi.hoisted(() => ({
sha256: "abc123",
},
}));
const gatewayBoundPreparedPlan = {
...preparedPlan,
agentId: "requested-agent",
sessionKey: "requested-session",
};
const nodeCommandMarker = vi.hoisted(() => "=node-command:test");
const exactCommandMarker = (commandText: string): string =>
`=command:${crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16)}`;
@@ -361,7 +356,6 @@ function createNodeHostRequest(
type MockNodeInvokeParams = {
command?: string;
timeoutMs?: number;
sessionKey?: string;
params?: Record<string, unknown>;
};
@@ -1176,7 +1170,7 @@ describe("executeNodeHostCommand", () => {
expect(result.details?.status).toBe("approval-pending");
expect(requireRegisteredApprovalRequest()).toMatchObject({
systemRunPlan: gatewayBoundPreparedPlan,
systemRunPlan: preparedPlan,
toolCallId: "tool-node",
});
@@ -1188,15 +1182,11 @@ describe("executeNodeHostCommand", () => {
expect(call.options.timeoutMs).toBe(40_000);
expect(call.params?.timeoutMs).toBe(35_000);
expect(call.callOptions).toEqual({ scopes: ["operator.write", "operator.approvals"] });
expect(requireGatewayCommand("system.run.prepare").params?.sessionKey).toBe(
"requested-session",
);
expect(call.params?.sessionKey).toBe("requested-session");
const runParams = requireRunParams(call);
expect(runParams.approved).toBe(true);
expect(runParams.approvalDecision).toBe("allow-once");
expect(runParams.approvalSource).toBeUndefined();
expect(runParams.systemRunPlan).toEqual(gatewayBoundPreparedPlan);
expect(runParams.systemRunPlan).toEqual(preparedPlan);
expect(runParams.timeoutMs).toBe(30_000);
expect(runParams.turnSourceChannel).toBe("telegram");
expect(runParams.turnSourceTo).toBe("telegram:12345");
@@ -1205,38 +1195,6 @@ describe("executeNodeHostCommand", () => {
expect(resolveExecHostApprovalContextMock).toHaveBeenCalledTimes(2);
});
it("clears node-prepared attribution when the gateway request has none", async () => {
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "full",
hostAsk: "always",
askFallback: "deny",
});
const result = await executeNodeHostCommand(
createNodeHostRequest({
agentId: undefined,
sessionKey: undefined,
}),
);
expect(result.details?.status).toBe("approval-pending");
expect(requireRegisteredApprovalRequest()).toMatchObject({
systemRunPlan: {
agentId: null,
sessionKey: null,
},
});
await vi.waitFor(() => {
expect(callGatewayToolMock).toHaveBeenCalledTimes(3);
});
expect(requireRunParams(requireGatewayCall(2)).systemRunPlan).toMatchObject({
agentId: null,
sessionKey: null,
});
});
it("forwards cancellation without removing detached node approval scopes", async () => {
const abortController = new AbortController();
resolveExecHostApprovalContextMock.mockReturnValue({
@@ -2003,8 +1961,8 @@ describe("executeNodeHostCommand", () => {
command: "rm -rf /tmp/work",
argv: ["rm", "-rf", "/tmp/work"],
agent: {
id: "requested-agent",
sessionKey: "requested-session",
id: "prepared-agent",
sessionKey: "prepared-session",
},
}),
);
@@ -2097,7 +2055,7 @@ describe("executeNodeHostCommand", () => {
expect(autoReviewer).not.toHaveBeenCalled();
expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled();
expect(resolveExecApprovalsFromFileMock).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "requested-agent" }),
expect.objectContaining({ agentId: "prepared-agent" }),
);
expectSystemRunInvoke({ invokeDeadlineMs: 35_000, invokeWaitMs: 40_000, runTimeoutMs: 30_000 });
});
@@ -3708,12 +3666,7 @@ describe("executeNodeHostCommand", () => {
FOO: "bar",
});
expect(requireGatewayCommand("system.run.prepare").params?.params?.cwd).toBe("/tmp/work");
expect(requireGatewayCommand("system.run.prepare").params?.sessionKey).toBe(
"requested-session",
);
const runCall = requireGatewayCommand("system.run");
expect(runCall.params?.sessionKey).toBe("requested-session");
const runParams = requireRunParams(runCall);
const runParams = requireRunParams(requireGatewayCommand("system.run"));
expect(runParams.env).toEqual({ FOO: "bar" });
expect(runParams.cwd).toBe("/tmp/work");
const evalEnvs = evaluateShellAllowlistMock.mock.calls.map(
@@ -3734,7 +3687,6 @@ describe("executeNodeHostCommand", () => {
const call = requireGatewayCall(0);
expect(call.options.timeoutMs).toBe(40_000);
expect(call.params?.timeoutMs).toBe(35_000);
expect(call.params?.sessionKey).toBe("requested-session");
const runParams = requireRunParams(call);
expect(runParams.command).toEqual(["/bin/sh", "-lc", "bun ./script.ts"]);
expect(runParams.rawCommand).toBe("bun ./script.ts");
@@ -1681,10 +1681,7 @@ describe("exec approvals", () => {
expect(params.approved).toBeUndefined();
expect(params.approvalDecision).toBeUndefined();
expect(params.approvalSource).toBe("ask-fallback");
expect(params.systemRunPlan).toStrictEqual({
...preparedPlan,
agentId: "main",
});
expect(params.systemRunPlan).toStrictEqual(preparedPlan);
expect(params.runId).toBeTypeOf("string");
});
@@ -74,7 +74,6 @@ const CURRENT_TRAIN_METHODS = [
"cron.scratch.get",
"cron.scratch.set",
"memory.search",
"node.protocolFeatures.update",
"skills.proposals.evaluate",
"skills.proposals.events.list",
"hooks.status",
-1
View File
@@ -285,7 +285,6 @@ const CORE_GATEWAY_METHOD_SPECS = [
["node.describe", "nodes", "operator.read", "<=2026.7"],
["node.pluginSurface.refresh", "nodes", "node", "<=2026.7"],
["node.pluginTools.update", "nodes", "node", "<=2026.7"],
["node.protocolFeatures.update", "nodes", "node", "2026.7"],
["node.skills.update", "nodes", "node", "<=2026.7"],
["node.pending.drain", "nodes-pending", "node", "<=2026.7"],
["node.pending.enqueue", "nodes-pending", "operator.write", "<=2026.7"],
@@ -89,28 +89,4 @@ describe("invokeNodeClaudeCliRun", () => {
}),
);
});
it("forwards admitted session attribution to the envelope and legacy command params", async () => {
mocks.isNodeCommandAllowed.mockReturnValue({ ok: true });
mocks.invoke.mockResolvedValue({ ok: true });
await invokeNodeClaudeCliRun({
nodeId: "node-1",
argv: ["-p"],
stdin: "hello",
sessionKey: "agent:main:claude",
timeoutMs: 10_000,
idleTimeoutMs: 1_000,
onProgress: () => {},
});
expect(mocks.invoke).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:claude",
params: expect.objectContaining({
sessionKey: "agent:main:claude",
}),
}),
);
});
});
-1
View File
@@ -63,7 +63,6 @@ export async function invokeNodeClaudeCliRun(params: {
expectedConnId: node.connId,
...(node.pairingGeneration ? { expectedPairingGeneration: node.pairingGeneration } : {}),
command: NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
params: {
argv: params.argv,
stdin: params.stdin,
@@ -274,32 +274,6 @@ describe("applyPluginNodeInvokePolicy", () => {
});
});
it("keeps gateway session attribution authoritative over nested policy params", async () => {
setDangerousDemoCommandRegistry([
createDemoPolicy((ctx: OpenClawPluginNodeInvokePolicyContext) => ctx.invokeNode()),
]);
const { context, invoke } = createContext();
const sessionKey = "agent:main:main";
const forgedParams = { ...DEMO_PARAMS, sessionKey: "agent:attacker:forged" };
const result = await applyPluginNodeInvokePolicy({
context,
client: null,
nodeSession: createNodeSession(),
command: DEMO_COMMAND,
params: forgedParams,
sessionKey,
});
expect(result).toMatchObject({ ok: true });
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
params: forgedParams,
sessionKey,
}),
);
});
it.each([5_000, 0])(
"bounds plugin timeout override %i by the remaining invocation deadline",
async (overrideTimeoutMs) => {
-2
View File
@@ -182,7 +182,6 @@ export async function applyPluginNodeInvokePolicy(params: {
nodeSession: NodeSession;
command: string;
params: unknown;
sessionKey?: string;
turnSource?: {
channel?: unknown;
to?: unknown;
@@ -294,7 +293,6 @@ export async function applyPluginNodeInvokePolicy(params: {
timeoutMs,
...(params.signal ? { signal: params.signal } : {}),
idempotencyKey: override.idempotencyKey ?? params.idempotencyKey,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
onDispatchReady: () => {
// Only the registry knows that the transport send succeeded. Preserve
// pre-send failures as retry-safe while making later failures ambiguous.
-173
View File
@@ -9,7 +9,6 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_IDS } from "../../packages/gateway-protocol/src/client-info.js";
import { NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { getCurrentActiveNodeContext, setActiveNodeContext } from "../infra/active-node-context.js";
import { onDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
@@ -1294,178 +1293,6 @@ describe("gateway/node-registry", () => {
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("binds gateway-owned attribution into params for legacy nodes", async () => {
const registry = createNodeRegistry();
const frames = registerNode(registry);
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
params: {
value: "ok",
sessionKey: "agent:attacker:forged",
},
sessionKey: "agent:main:main",
timeoutMs: 0,
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; paramsJSON?: string | null; sessionKey?: string | null };
};
expect(request.payload?.sessionKey).toBe("agent:main:main");
expect(JSON.parse(request.payload?.paramsJSON ?? "{}")).toEqual({
value: "ok",
sessionKey: "agent:main:main",
});
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-1",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("keeps non-empty attribution for legacy commands without an object params carrier", async () => {
const registry = createNodeRegistry();
const frames = registerNode(registry);
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
params: "ping",
sessionKey: "agent:main:main",
timeoutMs: 0,
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; paramsJSON?: string | null; sessionKey?: string | null };
};
expect(request.payload?.sessionKey).toBe("agent:main:main");
expect(JSON.parse(request.payload?.paramsJSON ?? "null")).toBe("ping");
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-1",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("clears nested attribution for unattributed legacy node invokes", async () => {
const registry = createNodeRegistry();
const frames = registerNode(registry);
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
params: {
value: "ok",
sessionKey: "agent:attacker:forged",
},
timeoutMs: 0,
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; paramsJSON?: string | null; sessionKey?: string | null };
};
expect(request.payload).not.toHaveProperty("sessionKey");
expect(JSON.parse(request.payload?.paramsJSON ?? "{}")).toEqual({ value: "ok" });
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-1",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("omits an unattributed session envelope until the node negotiates explicit clears", async () => {
const registry = createNodeRegistry();
const frames = registerNode(registry);
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
timeoutMs: 0,
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; sessionKey?: string | null };
};
expect(request.payload).not.toHaveProperty("sessionKey");
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-1",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("emits explicit null for negotiated unattributed node invokes", async () => {
const registry = createNodeRegistry();
const frames = registerNode(registry);
registry.updateProtocolFeatures("node-1", "conn-1", [
NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE,
]);
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
timeoutMs: 0,
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; sessionKey?: string | null };
};
expect(request.payload?.sessionKey).toBeNull();
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-1",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("does not let a stale connection negotiate features for its replacement", async () => {
const registry = createNodeRegistry();
registerNodeSession(registry, makeClient("conn-old", "node-1"), {});
const frames: string[] = [];
registerNodeSession(registry, makeClient("conn-new", "node-1", frames), {});
expect(
registry.updateProtocolFeatures("node-1", "conn-old", [
NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE,
]),
).toBeNull();
const invoke = registry.invoke({
nodeId: "node-1",
command: "debug.ping",
timeoutMs: 0,
});
const request = JSON.parse(frames[0] ?? "{}") as {
payload?: { id?: string; sessionKey?: string | null };
};
expect(request.payload).not.toHaveProperty("sessionKey");
expect(
registry.handleInvokeResult({
id: request.payload?.id ?? "",
nodeId: "node-1",
connId: "conn-new",
ok: true,
}),
).toBe(true);
await expect(invoke).resolves.toMatchObject({ ok: true });
});
it("returns a structured result when a zero-timeout invoke disconnects", async () => {
const registry = createNodeRegistry();
registerNode(registry);
+5 -59
View File
@@ -15,7 +15,6 @@ import type {
NodePluginToolDescriptor,
NodeSkillDescriptor,
} from "../../packages/gateway-protocol/src/schema/nodes.js";
import { NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { setActiveNodeContext } from "../infra/active-node-context.js";
import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js";
import type { NodePairingBinding } from "../infra/node-pairing-state.js";
@@ -144,30 +143,6 @@ function normalizeSystemRunInvokeParams(params: { command: string; params?: unkn
return normalized;
}
/** Bind legacy nested attribution to the Gateway-owned session before dispatch. */
function bindNodeInvokeSessionKey(params: unknown, sessionKey: string | undefined): unknown {
if (!params || typeof params !== "object" || Array.isArray(params)) {
return params;
}
const bound = { ...(params as Record<string, unknown>) };
if (sessionKey) {
bound.sessionKey = sessionKey;
} else {
delete bound.sessionKey;
}
if (
bound.systemRunPlan &&
typeof bound.systemRunPlan === "object" &&
!Array.isArray(bound.systemRunPlan)
) {
bound.systemRunPlan = {
...(bound.systemRunPlan as Record<string, unknown>),
sessionKey: sessionKey ?? null,
};
}
return bound;
}
/** Result payload returned from node.invoke. */
export type NodeInvokeResult = {
ok: boolean;
@@ -277,7 +252,6 @@ export class NodeRegistry {
private nodesById = new Map<string, PairingBoundNodeSession>();
private nodesByConn = new Map<string, string>();
private eventTransportsByConn = new Map<string, NodeEventTransport>();
private protocolFeaturesByConn = new Map<string, ReadonlySet<string>>();
private pendingInvokes = new Map<string, PendingInvoke>();
private invokeStreams = new NodeInvokeStreamController({
pendingInvokes: this.pendingInvokes,
@@ -547,7 +521,6 @@ export class NodeRegistry {
const replacesPresence = previousSession?.lastActiveAtMs !== undefined;
this.nodesById.set(nodeId, session);
this.nodesByConn.set(client.connId, nodeId);
this.protocolFeaturesByConn.set(client.connId, new Set());
if (previousSession && previousSession.connId !== client.connId) {
// Install the replacement first so retiring its old invokes cannot
// remove the new session or publish a false offline transition.
@@ -591,7 +564,6 @@ export class NodeRegistry {
}
this.nodesByConn.delete(connId);
this.eventTransportsByConn.delete(connId);
this.protocolFeaturesByConn.delete(connId);
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
if (unregistersCurrentNode) {
const hadPresence = this.nodesById.get(nodeId)?.lastActiveAtMs !== undefined;
@@ -987,19 +959,6 @@ export class NodeRegistry {
});
return node;
}
updateProtocolFeatures(
nodeId: string,
connId: string | undefined,
features: readonly string[],
): NodeSession | null {
const node = this.nodesById.get(nodeId);
if (!node || node.connId !== connId) {
return null;
}
this.protocolFeaturesByConn.set(node.connId, new Set(features));
return node;
}
updateSurface(
nodeId: string,
surface: {
@@ -1180,19 +1139,12 @@ export class NodeRegistry {
}
}
const requestId = randomUUID();
const sessionKey = normalizeString(params.sessionKey) || undefined;
const invokeParams = bindNodeInvokeSessionKey(
normalizeSystemRunInvokeParams({
command: params.command,
params: params.params,
}),
sessionKey,
);
const invokeParams = normalizeSystemRunInvokeParams({
command: params.command,
params: params.params,
});
// Keep node and Gateway on the same timer-safe value; zero disables both deadlines.
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 30_000, 0);
const supportsSessionKeyEnvelope = this.protocolFeaturesByConn
.get(node.connId)
?.has(NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE);
const payload = {
id: requestId,
nodeId: params.nodeId,
@@ -1201,13 +1153,7 @@ export class NodeRegistry {
"params" in params && invokeParams !== undefined ? JSON.stringify(invokeParams) : null,
timeoutMs,
idempotencyKey: params.idempotencyKey,
// Object params carry a canonical nested binding for legacy nodes. Keep the
// additive non-empty envelope fallback for commands without an object carrier.
...(supportsSessionKeyEnvelope
? { sessionKey: sessionKey ?? null }
: sessionKey
? { sessionKey }
: {}),
sessionKey: normalizeString(params.sessionKey) || undefined,
};
const systemRunEvent = resolvePendingSystemRunEvent({
command: params.command,
@@ -57,11 +57,7 @@ afterEach(() => {
resetNodeWakeStateForTest();
});
function startNodeInvoke(options: {
invoke: ReturnType<typeof vi.fn>;
signal?: AbortSignal;
requestParams?: Record<string, unknown>;
}) {
function startNodeInvoke(options: { invoke: ReturnType<typeof vi.fn>; signal?: AbortSignal }) {
const respond = vi.fn();
const handler = nodeInvokeHandlers["node.invoke"];
if (!handler) {
@@ -75,7 +71,6 @@ function startNodeInvoke(options: {
params: { model: "node-local:small", prompt: "answer locally" },
timeoutMs: 10_000,
idempotencyKey: "paired-inference-idempotency-key",
...options.requestParams,
},
client: null,
isWebchatConnect: () => false,
@@ -95,28 +90,6 @@ function startNodeInvoke(options: {
}
describe("node.invoke caller cancellation", () => {
it("forwards the normalized gateway session key through plugin policy", async () => {
const invoke = vi.fn(async () => ({ ok: true, payload: { response: "node-only inference" } }));
const sessionKey = "agent:main:main";
const { invocation } = startNodeInvoke({
invoke,
requestParams: {
sessionKey,
params: {
model: "node-local:small",
prompt: "answer locally",
sessionKey: "agent:attacker:forged",
},
},
});
await invocation;
expect(mocks.applyPluginNodeInvokePolicy).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey }),
);
});
it("cancels paired-node work without breaking pairing lifecycle identity", async () => {
const controller = new AbortController();
const invoke = vi.fn(
@@ -465,7 +465,6 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = {
nodeSession,
command,
params: forwardedParams.params,
...(sessionKey ? { sessionKey } : {}),
turnSource: {
channel: p.turnSourceChannel,
to: p.turnSourceTo,
-28
View File
@@ -5,7 +5,6 @@ import {
validateNodeDescribeParams,
validateNodeListParams,
validateNodePluginToolsUpdateParams,
validateNodeProtocolFeaturesUpdateParams,
validateNodeSkillsUpdateParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -327,33 +326,6 @@ export const nodeReadHandlers: GatewayRequestHandlers = {
}
respond(true, { nodeId, tools: updated.nodePluginTools }, undefined);
},
"node.protocolFeatures.update": async ({ params, respond, client, context }) => {
if (!validateNodeProtocolFeaturesUpdateParams(params)) {
respondInvalidParams({
respond,
method: "node.protocolFeatures.update",
validator: validateNodeProtocolFeaturesUpdateParams,
});
return;
}
const nodeId = normalizeOptionalString(
client?.connect?.device?.id ?? client?.connect?.client?.id,
);
if (!nodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
const updated = context.nodeRegistry.updateProtocolFeatures(
nodeId,
client?.connId,
params.features,
);
if (!updated) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
respond(true, { nodeId }, undefined);
},
"node.skills.update": async ({ params, respond, client, context }) => {
if (!validateNodeSkillsUpdateParams(params)) {
respondInvalidParams({
-29
View File
@@ -126,7 +126,6 @@ function createContext() {
getActiveNode: vi.fn(),
updateSurface: vi.fn(),
updateNodeSkills: vi.fn(),
updateProtocolFeatures: vi.fn(),
},
};
}
@@ -197,34 +196,6 @@ describe("nodeHandlers node.skills.update", () => {
});
});
describe("nodeHandlers node.protocolFeatures.update", () => {
it("stores transient features on the calling node connection", async () => {
const features = ["node-invoke-session-key-envelope-v1"];
const { context, opts } = createOptions(
{ features },
{
client: {
connId: "conn-1",
connect: { device: { id: "node-1" }, client: { id: "node-client" } },
} as never,
},
);
context.nodeRegistry.updateProtocolFeatures.mockReturnValue({ nodeId: "node-1" });
await expectDefined(
nodeHandlers["node.protocolFeatures.update"],
'nodeHandlers["node.protocolFeatures.update"] test invariant',
)(opts);
expect(context.nodeRegistry.updateProtocolFeatures).toHaveBeenCalledWith(
"node-1",
"conn-1",
features,
);
expect(opts.respond).toHaveBeenCalledWith(true, { nodeId: "node-1" }, undefined);
});
});
async function pairAndroidNodeDevice(stateDir: string, nodeId: string): Promise<void> {
const pending = await requestDevicePairing(
{
@@ -122,16 +122,6 @@ export async function handleClaudeCliNodeInvoke(params: {
await params.deps.sendInvalidRequestResult(params.client, params.frame, error);
return;
}
if (Object.hasOwn(params.frame, "sessionKey")) {
const sessionKey = params.frame.sessionKey ?? null;
const requestWithoutSessionKey = { ...request };
delete requestWithoutSessionKey.sessionKey;
request = {
...requestWithoutSessionKey,
...(sessionKey ? { sessionKey } : {}),
...(request.systemRunPlan ? { systemRunPlan: { ...request.systemRunPlan, sessionKey } } : {}),
};
}
const approvalCommand = [claudePath, ...request.argv];
const preparedApproval = buildSystemRunApprovalPlan({
command: approvalCommand,
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NodeHostClient } from "./client.js";
import type { NodeHostInvokeRuntime } from "./invoke-agent-cli-claude-handler.js";
import { decodeClaudeCliNodeRunParams } from "./invoke-agent-cli-claude-params.js";
import { runClaudeCliNodeCommand } from "./invoke-agent-cli-claude.js";
import { handleInvoke, type NodeInvokeRequestPayload } from "./invoke.js";
@@ -227,46 +226,6 @@ describe("Claude CLI node command", () => {
});
});
it("clears nested Claude run attribution from an explicit Gateway envelope", async () => {
const executable = await executableScript("process.exit(0);");
const calls: Array<{ method: string; params: unknown }> = [];
const handleSystemRun = vi.fn(
async (_options: Parameters<NonNullable<NodeHostInvokeRuntime["handleSystemRun"]>>[0]) =>
undefined,
);
const invokeFrame = frame({
argv: ["-p"],
sessionKey: "agent:forged:request",
systemRunPlan: {
argv: [executable, "-p"],
cwd: null,
commandText: `${executable} -p`,
agentId: null,
sessionKey: "agent:forged:plan",
},
idleTimeoutMs: 1_000,
timeoutMs: 2_000,
});
invokeFrame.sessionKey = null;
await handleInvoke(invokeFrame, client(calls), { current: async () => [] }, undefined, {
claudePath: executable,
handleSystemRun: handleSystemRun as never,
});
expect(handleSystemRun).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
systemRunPlan: expect.objectContaining({ sessionKey: null }),
}),
}),
);
const runParams = handleSystemRun.mock.calls[0]?.[0]?.params as
| { sessionKey?: string }
| undefined;
expect(runParams).not.toHaveProperty("sessionKey");
});
it("converts forwarded OAuth into a child-only descriptor after approval", async () => {
const executable = await executableScript(`
const fs = require("node:fs");
+1 -44
View File
@@ -1,48 +1,5 @@
import { describe, expect, it } from "vitest";
import { coerceNodeInvokeInputPayload, coerceNodeInvokePayload } from "./invoke-payload.js";
describe("coerceNodeInvokePayload", () => {
it("preserves normalized gateway-owned session attribution", () => {
expect(
coerceNodeInvokePayload({
id: "invoke-1",
nodeId: "node-1",
command: "system.run",
sessionKey: " agent:main:main ",
}),
).toEqual({
id: "invoke-1",
nodeId: "node-1",
command: "system.run",
paramsJSON: null,
timeoutMs: null,
idempotencyKey: null,
sessionKey: "agent:main:main",
});
});
it("distinguishes a missing legacy envelope from an explicit clear", () => {
expect(
coerceNodeInvokePayload({ id: "i", nodeId: "n", command: "system.run" }),
).not.toHaveProperty("sessionKey");
expect(
coerceNodeInvokePayload({
id: "i",
nodeId: "n",
command: "system.run",
sessionKey: null,
}),
).toMatchObject({ sessionKey: null });
expect(
coerceNodeInvokePayload({
id: "i",
nodeId: "n",
command: "system.run",
sessionKey: " ",
}),
).toMatchObject({ sessionKey: null });
});
});
import { coerceNodeInvokeInputPayload } from "./invoke-payload.js";
describe("coerceNodeInvokeInputPayload", () => {
it("accepts a bounded well-formed input payload", () => {
-2
View File
@@ -21,7 +21,6 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl
: null;
const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null;
const idempotencyKey = typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null;
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() || null : null;
return {
id,
nodeId,
@@ -29,7 +28,6 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl
paramsJSON,
timeoutMs,
idempotencyKey,
...(Object.hasOwn(obj, "sessionKey") ? { sessionKey } : {}),
};
}
-53
View File
@@ -912,57 +912,4 @@ describe("node host invoke", () => {
allowlistRules: [],
});
});
it("clears nested prepare correlation when the Gateway envelope is unattributed", async () => {
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
await handleInvoke(
{
id: "invoke-unattributed-prepare",
nodeId: "node-1",
command: "system.run.prepare",
sessionKey: null,
paramsJSON: JSON.stringify({
command: ["echo", "ok"],
sessionKey: "agent:forged:prepare",
}),
},
{ request } as unknown as GatewayClient,
{ current: async () => [] },
);
const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as {
payloadJSON?: string;
};
const payload = JSON.parse(result.payloadJSON ?? "{}") as {
plan?: { sessionKey?: string | null };
};
expect(payload.plan?.sessionKey).toBeNull();
});
it("preserves nested prepare correlation from a legacy Gateway", async () => {
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
await handleInvoke(
{
id: "invoke-legacy-prepare",
nodeId: "node-1",
command: "system.run.prepare",
paramsJSON: JSON.stringify({
command: ["echo", "ok"],
sessionKey: "agent:legacy:prepare",
}),
},
{ request } as unknown as GatewayClient,
{ current: async () => [] },
);
const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as {
payloadJSON?: string;
};
const payload = JSON.parse(result.payloadJSON ?? "{}") as {
plan?: { sessionKey?: string | null };
};
expect(payload.plan?.sessionKey).toBe("agent:legacy:prepare");
});
});
+8 -35
View File
@@ -133,29 +133,6 @@ function resolveNodeSkillCwdParam<T extends { cwd?: unknown }>(params: T, nodeId
return resolved ? { ...params, cwd: resolved } : params;
}
function bindNodeInvokeSessionKey<
T extends {
sessionKey?: unknown;
systemRunPlan?: SystemRunParams["systemRunPlan"];
},
>(params: T, frame: NodeInvokeRequestPayload): T {
if (!Object.hasOwn(frame, "sessionKey")) {
return params;
}
const sessionKey = frame.sessionKey ?? null;
// The Gateway envelope owns run correlation. Nested command params are
// caller-controlled and must not mint or retain a different session binding.
const systemRunPlan =
params.systemRunPlan === undefined || params.systemRunPlan === null
? params.systemRunPlan
: { ...params.systemRunPlan, sessionKey: sessionKey ?? null };
return {
...params,
sessionKey,
...(systemRunPlan !== undefined ? { systemRunPlan } : {}),
};
}
function buildEnvOverrideRejectionMessage(params: {
rejectedOverrideBlockedKeys: string[];
rejectedOverrideInvalidKeys: string[];
@@ -769,12 +746,11 @@ async function dispatchInvoke(
}
try {
const { pluginCommandIo: io, pluginCommandContext: context } = runtime;
const hasSessionKeyEnvelope = Object.hasOwn(frame, "sessionKey");
const invokeContext =
context && (hasSessionKeyEnvelope || runtime.signal)
context && (frame.sessionKey || runtime.signal)
? {
...context,
...(hasSessionKeyEnvelope ? { sessionKey: frame.sessionKey ?? undefined } : {}),
...(frame.sessionKey ? { sessionKey: frame.sessionKey } : {}),
...(runtime.signal ? { signal: runtime.signal } : {}),
}
: context;
@@ -790,12 +766,9 @@ async function dispatchInvoke(
if (command === "system.run.prepare") {
try {
const params = bindNodeInvokeSessionKey(
resolveNodeSkillCwdParam(
decodeParams<SystemRunPrepareParams>(frame.paramsJSON),
frame.nodeId,
),
frame,
const params = resolveNodeSkillCwdParam(
decodeParams<SystemRunPrepareParams>(frame.paramsJSON),
frame.nodeId,
);
const prepared = buildSystemRunApprovalPlan(params);
if (!prepared.ok) {
@@ -852,9 +825,9 @@ async function dispatchInvoke(
let params: SystemRunParams;
try {
params = bindNodeInvokeSessionKey(
resolveNodeSkillCwdParam(decodeParams<SystemRunParams>(frame.paramsJSON), frame.nodeId),
frame,
params = resolveNodeSkillCwdParam(
decodeParams<SystemRunParams>(frame.paramsJSON),
frame.nodeId,
);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
@@ -1,490 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayClientRequestError, type GatewayClientOptions } from "../gateway/client.js";
import type { configureNodeHost } from "./config.js";
import type { NodeInvokeRequestPayload } from "./invoke-types.js";
import { runNodeHost } from "./runner.js";
const mocks = vi.hoisted(() => ({
capturedGatewayClientOptions: [] as GatewayClientOptions[],
capturedGatewayClients: [] as Array<{
request: ReturnType<typeof vi.fn<(method: string, params?: unknown) => Promise<unknown>>>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
activeRuntime: {
invoke: vi.fn(async (_payload: NodeInvokeRequestPayload) => {}),
handleInput: vi.fn(),
cancel: vi.fn(),
cancelAll: vi.fn(),
close: vi.fn(async () => {}),
},
configureNodeHost: vi.fn(async (params: Parameters<typeof configureNodeHost>[0]) => ({
version: 1 as const,
nodeId: params.nodeId?.trim() || "node-test",
displayName: params.displayName?.trim() || params.fallbackDisplayName,
gateway: params.gateway,
})),
getRuntimeConfig: vi.fn(() => ({
gateway: { handshakeTimeoutMs: 1_000 },
})),
startGatewayClientWhenEventLoopReady: vi.fn(async () => ({
ready: false,
aborted: false,
elapsedMs: 0,
})),
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: mocks.getRuntimeConfig,
}));
vi.mock("../gateway/client-start-readiness.js", () => ({
startGatewayClientWhenEventLoopReady: mocks.startGatewayClientWhenEventLoopReady,
}));
vi.mock("../gateway/client.js", () => ({
GatewayClientRequestError: class MockGatewayClientRequestError extends Error {
readonly gatewayCode: string;
constructor(params: { code: string; message: string }) {
super(params.message);
this.gatewayCode = params.code;
}
},
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
const client = {
request: vi.fn<(method: string, params?: unknown) => Promise<unknown>>(async () => ({})),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
mocks.capturedGatewayClientOptions.push(opts);
mocks.capturedGatewayClients.push(client);
return client;
},
}));
vi.mock("../gateway/credentials-secret-inputs.js", () => ({
resolveGatewayCredentialsWithSecretInputs: vi.fn(async () => ({})),
}));
vi.mock("../infra/device-identity.js", () => ({
loadOrCreateDeviceIdentity: vi.fn(() => ({
id: "device-test",
publicKey: "public-key-test",
privateKey: "private-key-test",
})),
}));
vi.mock("../infra/machine-name.js", () => ({
getMachineDisplayName: vi.fn(async () => "test-node"),
}));
vi.mock("../infra/executable-path.js", () => ({
resolveExecutableFromPathEnv: vi.fn(() => null),
}));
vi.mock("../infra/path-env.js", () => ({
ensureOpenClawCliOnPath: vi.fn(),
}));
vi.mock("./config.js", () => ({
configureNodeHost: mocks.configureNodeHost,
}));
vi.mock("./plugin-node-host.js", () => ({
ensureNodeHostPluginRegistry: vi.fn(async () => undefined),
listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({
commands: [],
caps: [],
nodePluginTools: [],
})),
watchRegisteredNodeHostCommandAvailability: vi.fn(() => () => undefined),
}));
vi.mock("./mcp.js", () => ({
startNodeHostMcpManager: vi.fn(async () => ({
configuredServerCount: 0,
descriptors: [],
callMcpTool: vi.fn(),
close: vi.fn(async () => undefined),
})),
}));
vi.mock("./skills.js", () => ({
scanNodeHostedSkills: vi.fn(() => []),
}));
vi.mock("./startup-state-migrations.js", () => ({
runStartupMigrations: vi.fn(async () => undefined),
}));
vi.mock("./runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime.js")>();
return {
...actual,
prepareNodeHostRuntime: async () => ({
manifest: { caps: [], commands: [], pathEnv: process.env.PATH ?? "" },
initialInventory: { skills: [], pluginTools: [] },
start: () => mocks.activeRuntime,
}),
};
});
function hello(options: GatewayClientOptions | undefined) {
options?.onHelloOk?.({
protocol: 1,
features: { methods: [], events: [] },
} as unknown as Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0]);
}
function deferNegotiation(
client: (typeof mocks.capturedGatewayClients)[number] | undefined,
): () => void {
let resolveNegotiation: (() => void) | undefined;
client?.request.mockImplementation((method: string) => {
if (method === "node.protocolFeatures.update") {
return new Promise((resolve) => {
resolveNegotiation = () => resolve({});
});
}
return Promise.resolve({});
});
return () => resolveNegotiation?.();
}
async function waitForProtocolFeaturesNegotiation(
client: (typeof mocks.capturedGatewayClients)[number] | undefined,
expectedCount = 1,
): Promise<void> {
await vi.waitFor(() => {
expect(
client?.request.mock.calls.filter(([method]) => method === "node.protocolFeatures.update"),
).toHaveLength(expectedCount);
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
async function startFakeNodeHost() {
await expect(runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 })).rejects.toThrow(
"event loop readiness timeout",
);
return {
options: mocks.capturedGatewayClientOptions[0],
client: mocks.capturedGatewayClients[0],
};
}
describe("node-host session envelope negotiation", () => {
beforeEach(() => {
mocks.capturedGatewayClientOptions.length = 0;
mocks.capturedGatewayClients.length = 0;
vi.clearAllMocks();
mocks.getRuntimeConfig.mockReturnValue({
gateway: { handshakeTimeoutMs: 1_000 },
});
});
it("preserves legacy semantics for invokes received before negotiation completes", async () => {
const { options, client } = await startFakeNodeHost();
const resolveNegotiation = deferNegotiation(client);
hello(options);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-negotiating",
nodeId: "node-1",
command: "system.run",
paramsJSON: '{"sessionKey":"nested-session"}',
},
});
options?.onEvent?.({
type: "event",
event: "node.invoke.input",
payload: {
id: "invoke-negotiating",
nodeId: "node-1",
seq: 1,
payloadJSON: '{"kind":"data"}',
},
});
await vi.waitFor(() => {
expect(mocks.activeRuntime.invoke).toHaveBeenCalledOnce();
expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith(
"invoke-negotiating",
1,
'{"kind":"data"}',
);
});
const invokePayload = mocks.activeRuntime.invoke.mock.calls[0]?.[0];
expect(invokePayload).toEqual(
expect.objectContaining({
id: "invoke-negotiating",
paramsJSON: '{"sessionKey":"nested-session"}',
}),
);
expect(Object.hasOwn(invokePayload ?? {}, "sessionKey")).toBe(false);
expect(mocks.activeRuntime.invoke.mock.invocationCallOrder[0]).toBeLessThan(
mocks.activeRuntime.handleInput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
resolveNegotiation();
await waitForProtocolFeaturesNegotiation(client);
});
it("does not let negotiation block explicit envelopes or unrelated controls", async () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
try {
const { options, client } = await startFakeNodeHost();
const resolveNegotiation = deferNegotiation(client);
hello(options);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-explicit-envelope",
nodeId: "node-1",
command: "system.run",
timeoutMs: 10,
sessionKey: "agent:main:explicit",
},
});
options?.onEvent?.({
type: "event",
event: "node.invoke.input",
payload: {
id: "invoke-explicit-envelope",
nodeId: "node-1",
seq: 1,
payloadJSON: '{"kind":"data"}',
},
});
options?.onEvent?.({
type: "event",
event: "node.invoke.cancel",
payload: {
invokeId: "invoke-already-running",
nodeId: "node-1",
},
});
await vi.waitFor(() => {
expect(mocks.activeRuntime.invoke).toHaveBeenCalledTimes(1);
expect(mocks.activeRuntime.invoke).toHaveBeenLastCalledWith(
expect.objectContaining({
id: "invoke-explicit-envelope",
sessionKey: "agent:main:explicit",
timeoutMs: 10,
}),
);
expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith(
"invoke-explicit-envelope",
1,
'{"kind":"data"}',
);
expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-already-running");
});
expect(mocks.activeRuntime.invoke.mock.invocationCallOrder[0]).toBeLessThan(
mocks.activeRuntime.handleInput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
resolveNegotiation();
await waitForProtocolFeaturesNegotiation(client);
} finally {
dateNowSpy.mockRestore();
}
});
it("charges queued dispatch against the invoke deadline", async () => {
const dateNowSpy = vi.spyOn(Date, "now");
let nowMs = 1_000;
dateNowSpy.mockImplementation(() => nowMs);
try {
const { options, client } = await startFakeNodeHost();
const resolveNegotiation = deferNegotiation(client);
hello(options);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-with-deadline",
nodeId: "node-1",
command: "system.run",
timeoutMs: 100,
},
});
nowMs += 40;
await vi.waitFor(() => {
expect(mocks.activeRuntime.invoke).toHaveBeenCalledWith(
expect.objectContaining({
id: "invoke-with-deadline",
timeoutMs: 60,
}),
);
});
resolveNegotiation();
await waitForProtocolFeaturesNegotiation(client);
} finally {
dateNowSpy.mockRestore();
}
});
it("does not dispatch invokes that expire before queued dispatch", async () => {
const dateNowSpy = vi.spyOn(Date, "now");
let nowMs = 1_000;
dateNowSpy.mockImplementation(() => nowMs);
try {
const { options, client } = await startFakeNodeHost();
const resolveNegotiation = deferNegotiation(client);
hello(options);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-expired",
nodeId: "node-1",
command: "system.run",
timeoutMs: 10,
},
});
options?.onEvent?.({
type: "event",
event: "node.invoke.input",
payload: {
id: "invoke-expired",
nodeId: "node-1",
seq: 0,
payloadJSON: '{"kind":"barrier"}',
},
});
nowMs += 10;
await vi.waitFor(() => {
expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith(
"invoke-expired",
0,
'{"kind":"barrier"}',
);
});
expect(mocks.activeRuntime.invoke).not.toHaveBeenCalled();
resolveNegotiation();
await waitForProtocolFeaturesNegotiation(client);
} finally {
dateNowSpy.mockRestore();
}
});
it("does not dispatch invokes cancelled before queued dispatch", async () => {
const { options, client } = await startFakeNodeHost();
const resolveNegotiation = deferNegotiation(client);
hello(options);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-cancelled",
nodeId: "node-1",
command: "system.run",
},
});
options?.onEvent?.({
type: "event",
event: "node.invoke.cancel",
payload: {
invokeId: "invoke-cancelled",
nodeId: "node-1",
},
});
await vi.waitFor(() => {
expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-cancelled");
});
expect(mocks.activeRuntime.invoke).not.toHaveBeenCalled();
resolveNegotiation();
await waitForProtocolFeaturesNegotiation(client);
});
it("preserves absent envelopes only after an old gateway is confirmed", async () => {
const { options, client } = await startFakeNodeHost();
client?.request.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unknown method: node.protocolFeatures.update",
}),
);
hello(options);
await waitForProtocolFeaturesNegotiation(client);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-legacy",
nodeId: "node-1",
command: "system.run",
paramsJSON: '{"sessionKey":"legacy-session"}',
},
});
await vi.waitFor(() => expect(mocks.activeRuntime.invoke).toHaveBeenCalledOnce());
const payload = mocks.activeRuntime.invoke.mock.calls[0]?.[0];
expect(payload && Object.hasOwn(payload, "sessionKey")).toBe(false);
});
it("renegotiates authoritative envelopes after reconnecting from an old gateway", async () => {
const { options, client } = await startFakeNodeHost();
client?.request.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unknown method: node.protocolFeatures.update",
}),
);
hello(options);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-legacy",
nodeId: "node-1",
command: "system.run",
},
});
await vi.waitFor(() => expect(mocks.activeRuntime.invoke).toHaveBeenCalledOnce());
expect(Object.hasOwn(mocks.activeRuntime.invoke.mock.calls[0]?.[0] ?? {}, "sessionKey")).toBe(
false,
);
options?.onClose?.(1000, "old gateway closed");
const resolveNegotiation = deferNegotiation(client);
hello(options);
resolveNegotiation();
await waitForProtocolFeaturesNegotiation(client, 2);
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-authoritative",
nodeId: "node-1",
command: "system.run",
},
});
await vi.waitFor(() => expect(mocks.activeRuntime.invoke).toHaveBeenCalledTimes(2));
expect(mocks.activeRuntime.invoke.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
id: "invoke-authoritative",
sessionKey: null,
}),
);
expect(
client?.request.mock.calls.filter(([method]) => method === "node.protocolFeatures.update"),
).toHaveLength(2);
});
});
+6 -44
View File
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import type { GatewayClientOptions } from "../gateway/client.js";
import type { configureNodeHost } from "./config.js";
import type { NodeInvokeRequestPayload } from "./invoke-types.js";
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import { runNodeHost } from "./runner.js";
@@ -11,7 +10,7 @@ const mocks = vi.hoisted(() => ({
capturedGatewayClientOptions: [] as GatewayClientOptions[],
capturedConfiguredGatewayConfigs: [] as Array<{ contextPath?: string }>,
capturedGatewayClients: [] as Array<{
request: ReturnType<typeof vi.fn<(method: string, params?: unknown) => Promise<unknown>>>;
request: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
@@ -50,7 +49,7 @@ const mocks = vi.hoisted(() => ({
})),
resolveGatewayCredentialsWithSecretInputs: vi.fn(async () => ({})),
activeRuntime: {
invoke: vi.fn(async (_payload: NodeInvokeRequestPayload) => {}),
invoke: vi.fn(async () => {}),
handleInput: vi.fn(),
cancel: vi.fn(),
cancelAll: vi.fn(),
@@ -67,17 +66,9 @@ vi.mock("../gateway/client-start-readiness.js", () => ({
}));
vi.mock("../gateway/client.js", () => ({
GatewayClientRequestError: class MockGatewayClientRequestError extends Error {
readonly gatewayCode: string;
constructor(params: { code: string; message: string }) {
super(params.message);
this.gatewayCode = params.code;
}
},
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
const client = {
request: vi.fn<(method: string, params?: unknown) => Promise<unknown>>(async () => ({})),
request: vi.fn(async () => ({})),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
@@ -254,27 +245,6 @@ describe("runNodeHost", () => {
);
const options = lastCapturedOptions();
options?.onEvent?.({
type: "event",
event: "node.invoke.request",
payload: {
id: "invoke-1",
nodeId: "node-1",
command: "system.run",
sessionKey: "agent:main:main",
},
});
await vi.waitFor(() =>
expect(mocks.activeRuntime.invoke).toHaveBeenCalledWith({
id: "invoke-1",
nodeId: "node-1",
command: "system.run",
paramsJSON: null,
timeoutMs: null,
idempotencyKey: null,
sessionKey: "agent:main:main",
}),
);
options?.onEvent?.({
type: "event",
event: "node.invoke.input",
@@ -285,15 +255,10 @@ describe("runNodeHost", () => {
event: "node.invoke.cancel",
payload: { invokeId: "invoke-1", nodeId: "node-1" },
});
await vi.waitFor(() => {
expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith(
"invoke-1",
3,
'{"kind":"data"}',
);
expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-1");
});
options?.onClose?.(1000, "connection closed");
expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith("invoke-1", 3, '{"kind":"data"}');
expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-1");
expect(mocks.activeRuntime.cancelAll).toHaveBeenCalledOnce();
});
@@ -532,9 +497,6 @@ describe("runNodeHost", () => {
features: { methods: [], events: [] },
} as unknown as Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0]);
expect(client?.request).toHaveBeenCalledWith("node.protocolFeatures.update", {
features: ["node-invoke-session-key-envelope-v1"],
});
expect(client?.request).toHaveBeenCalledWith("node.pluginTools.update", {
tools: [
{
+3 -122
View File
@@ -4,7 +4,6 @@ import {
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js";
import {
@@ -22,7 +21,6 @@ import {
coerceNodeInvokeInputPayload,
coerceNodeInvokePayload,
} from "./invoke-payload.js";
import type { NodeInvokeRequestPayload } from "./invoke-types.js";
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
import { runStartupMigrations } from "./startup-state-migrations.js";
@@ -126,35 +124,6 @@ function isUnsupportedNodeSkillsUpdateError(error: unknown): boolean {
);
}
function isUnsupportedNodeProtocolFeaturesUpdateError(error: unknown): boolean {
return (
error instanceof GatewayClientRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message.includes("unknown method: node.protocolFeatures.update")
);
}
type NodeInvokeSessionEnvelopeMode = "authoritative" | "legacy";
async function negotiateNodeInvokeSessionEnvelope(
client: GatewayClient,
): Promise<NodeInvokeSessionEnvelopeMode> {
try {
await client.request("node.protocolFeatures.update", {
features: [NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE],
});
return "authoritative";
} catch (error) {
if (isUnsupportedNodeProtocolFeaturesUpdateError(error)) {
return "legacy";
}
writeStderrLine(`node host protocol feature publish failed: ${String(error)}`);
// Only a confirmed unknown-method response enables the legacy nested field.
// Other failures keep omitted envelopes fail-closed while the connection lives.
return "authoritative";
}
}
async function publishNodePluginTools(client: GatewayClient, tools: unknown[]): Promise<void> {
try {
await client.request("node.pluginTools.update", { tools });
@@ -255,38 +224,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const url = `${scheme}://${urlHost}:${port}${contextPath}`;
let inventory: NodeHostInventory = preparedRuntime.initialInventory;
let gatewayHelloReceived = false;
let gatewayConnectionGeneration = 0;
let nodeInvokeSessionEnvelopeMode =
Promise.resolve<NodeInvokeSessionEnvelopeMode>("authoritative");
let nodeInvokeSessionEnvelopeNegotiationComplete = true;
const nodeInvokeEventDispatchByInvokeId = new Map<string, Promise<void>>();
// Cancellation can arrive before the queued request dispatches. Mark it immediately
// so the request cannot start before its queued cancel runs.
const queuedNodeInvokeCancellations = new Set<string>();
const queueNodeInvokeEvent = (
invokeId: string,
dispatch: (mode: NodeInvokeSessionEnvelopeMode) => void,
envelopeMode: Promise<NodeInvokeSessionEnvelopeMode> = Promise.resolve("authoritative"),
): void => {
const connectionGeneration = gatewayConnectionGeneration;
const previous = nodeInvokeEventDispatchByInvokeId.get(invokeId) ?? Promise.resolve();
const queued = previous
.then(async () => {
const mode = await envelopeMode;
if (connectionGeneration === gatewayConnectionGeneration) {
dispatch(mode);
}
})
.catch((error: unknown) => {
writeStderrLine(`node host invoke event dispatch failed: ${String(error)}`);
});
nodeInvokeEventDispatchByInvokeId.set(invokeId, queued);
void queued.then(() => {
if (nodeInvokeEventDispatchByInvokeId.get(invokeId) === queued) {
nodeInvokeEventDispatchByInvokeId.delete(invokeId);
}
});
};
const publishInventory = () => {
if (!gatewayHelloReceived) {
@@ -323,20 +260,14 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
if (evt.event === "node.invoke.cancel") {
const payload = coerceNodeInvokeCancelPayload(evt.payload);
if (payload) {
queuedNodeInvokeCancellations.add(payload.invokeId);
queueNodeInvokeEvent(payload.invokeId, () => {
activeRuntime.cancel(payload.invokeId);
queuedNodeInvokeCancellations.delete(payload.invokeId);
});
activeRuntime.cancel(payload.invokeId);
}
return;
}
if (evt.event === "node.invoke.input") {
const payload = coerceNodeInvokeInputPayload(evt.payload);
if (payload) {
queueNodeInvokeEvent(payload.invokeId, () => {
activeRuntime.handleInput(payload.invokeId, payload.seq, payload.payloadJSON);
});
activeRuntime.handleInput(payload.invokeId, payload.seq, payload.payloadJSON);
}
return;
}
@@ -347,56 +278,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
if (!payload) {
return;
}
const receivedAtMs = Date.now();
const hasSessionKeyEnvelope = Object.hasOwn(payload, "sessionKey");
// Omitted envelopes received before negotiation completes still use the legacy
// nested session field. Do not reinterpret them after the response arrives.
const envelopeModeAtReceipt = hasSessionKeyEnvelope
? Promise.resolve<NodeInvokeSessionEnvelopeMode>("authoritative")
: nodeInvokeSessionEnvelopeNegotiationComplete
? nodeInvokeSessionEnvelopeMode
: Promise.resolve<NodeInvokeSessionEnvelopeMode>("legacy");
queueNodeInvokeEvent(
payload.id,
(mode) => {
if (queuedNodeInvokeCancellations.delete(payload.id)) {
return;
}
// Older gateways may send non-empty attribution before negotiation.
// Preserve that envelope while still upgrading omitted negotiated requests to a clear.
let invokePayload: NodeInvokeRequestPayload =
mode === "authoritative" && !hasSessionKeyEnvelope
? { ...payload, sessionKey: null }
: payload;
if (typeof invokePayload.timeoutMs === "number" && invokePayload.timeoutMs > 0) {
// The Gateway sends its remaining deadline budget. Charge negotiation
// time here so delayed state-changing commands cannot run after expiry.
const elapsedMs = Math.max(0, Date.now() - receivedAtMs);
const remainingTimeoutMs = Math.max(0, invokePayload.timeoutMs - elapsedMs);
if (remainingTimeoutMs === 0) {
return;
}
invokePayload = { ...invokePayload, timeoutMs: remainingTimeoutMs };
}
void activeRuntime.invoke(invokePayload);
},
envelopeModeAtReceipt,
);
void activeRuntime.invoke(payload);
},
onHelloOk: () => {
writeStderrLine(`node host gateway connected: ${url}`);
gatewayConnectionGeneration += 1;
const connectionGeneration = gatewayConnectionGeneration;
nodeInvokeEventDispatchByInvokeId.clear();
queuedNodeInvokeCancellations.clear();
gatewayHelloReceived = true;
nodeInvokeSessionEnvelopeNegotiationComplete = false;
nodeInvokeSessionEnvelopeMode = negotiateNodeInvokeSessionEnvelope(client).then((mode) => {
if (connectionGeneration === gatewayConnectionGeneration) {
nodeInvokeSessionEnvelopeNegotiationComplete = true;
}
return mode;
});
publishInventory();
},
onConnectError: (err) => {
@@ -414,12 +300,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
});
},
onClose: (code, reason) => {
gatewayConnectionGeneration += 1;
nodeInvokeEventDispatchByInvokeId.clear();
queuedNodeInvokeCancellations.clear();
gatewayHelloReceived = false;
nodeInvokeSessionEnvelopeMode = Promise.resolve("authoritative");
nodeInvokeSessionEnvelopeNegotiationComplete = true;
activeRuntime.cancelAll();
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
},