mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Revert "fix(protocol): preserve gateway session attribution across node runs"
This reverts commit 735f176b01.
This commit is contained in:
+141
-141
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))
|
||||
|
||||
+2
-298
@@ -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" -> {
|
||||
|
||||
+4
-5
@@ -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?
|
||||
|
||||
-482
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user