feat(#1218): support backend-owned connector setup operations (#1229)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 20:20:44 +03:00
1 parent bf53905a93
commit f34292aa83
21 files changed
+1872 -7

No files matched your search

@@ -0,0 +1,75 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.util.ConnectorUrlValidator
/** Backend-owned connector setup operation snapshot. Secrets are intentionally absent. */
data class ConnectionOperationSnapshot(
val sessionId: String?,
val opId: String,
val seq: Long,
val deadlineAt: Double,
val timeoutSeconds: Double?,
val toolCallId: String?,
val settled: Boolean,
val settledBy: String?,
val targets: List<ConnectionOperationTarget>,
)
data class ConnectionOperationTarget(
val name: String,
val kind: ConnectionTargetKind,
val action: ConnectionTargetAction,
val state: ConnectionTargetState,
val detail: String?,
val instructions: String?,
val discoveryError: String?,
val connectUrl: String?,
val connectionId: String?,
val attempt: String?,
val requiredEnv: List<ConnectionEnvField>,
val tools: List<String>,
val hint: String?,
) {
/** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */
val safeConnectUrl: String?
get() = connectUrl?.takeIf(ConnectorUrlValidator::isValidHttpsUrl)
override fun toString(): String =
"ConnectionOperationTarget(" +
"name=$name, kind=$kind, action=$action, state=$state, detail=$detail, " +
"instructions=$instructions, discoveryError=$discoveryError, " +
"connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " +
"connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " +
"tools=$tools, hint=$hint)"
}
data class ConnectionEnvField(
val name: String,
val required: Boolean,
val secret: Boolean,
val defaultValue: String?,
val prompt: String?,
)
enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN }
enum class ConnectionTargetAction {
AUTHORIZE,
CONNECT,
ENABLE,
INSTALL,
RECONNECT,
UNKNOWN,
}
enum class ConnectionTargetState {
PENDING,
INITIATED,
CONNECTED,
SKIPPED,
FAILED,
EXPIRED,
UNAVAILABLE,
NOT_CONNECTED,
UNKNOWN,
}
@@ -0,0 +1,114 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectionEnvField
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
/** Tolerant parser for backend-owned connector operation snapshots. */
object ConnectionOperationParser {
fun parse(
payload: Map<String, Any?>,
sessionId: String? = null,
): ConnectionOperationSnapshot? {
val opId = (payload["op_id"] as? String)?.trim() ?: return null
val seq = payload["seq"].toLongOrNull() ?: return null
val deadlineAt = payload["deadline_at"].toDoubleOrNull() ?: return null
val targets =
(payload["targets"] as? List<*>)
?.mapNotNull { parseTarget(it as? Map<*, *>) }
?: return null
if (opId.isBlank() || seq < 0L || !deadlineAt.isFinite() || deadlineAt <= 0.0 || targets.isEmpty()) {
return null
}
return ConnectionOperationSnapshot(
sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() },
opId = opId,
seq = seq,
deadlineAt = deadlineAt,
timeoutSeconds =
payload["timeout_seconds"]
.toDoubleOrNull()
?.takeIf { it.isFinite() && it > 0.0 },
toolCallId = payload["tool_call_id"] as? String,
settled = payload["settled"] as? Boolean ?: false,
settledBy = payload["settled_by"] as? String,
targets = targets,
)
}
private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? {
val map = raw ?: return null
val name = (map["name"] as? String)?.trim() ?: return null
if (name.isBlank()) return null
return ConnectionOperationTarget(
name = name,
kind =
enumValue(
map["kind"] as? String,
ConnectionTargetKind.values(),
ConnectionTargetKind.UNKNOWN,
),
action =
enumValue(
map["action"] as? String,
ConnectionTargetAction.values(),
ConnectionTargetAction.UNKNOWN,
),
state =
enumValue(
map["state"] as? String,
ConnectionTargetState.values(),
ConnectionTargetState.UNKNOWN,
),
detail = map["detail"] as? String,
instructions = map["instructions"] as? String,
discoveryError = map["discovery_error"] as? String,
connectUrl = map["connect_url"] as? String,
connectionId = map["connection_id"] as? String,
attempt = map["attempt"] as? String,
requiredEnv =
(map["required_env"] as? List<*>)
?.mapNotNull { parseEnv(it as? Map<*, *>) }
?: emptyList(),
tools = (map["tools"] as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
hint = map["hint"] as? String,
)
}
private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? {
val map = raw ?: return null
val name = (map["name"] as? String)?.trim() ?: return null
if (name.isBlank()) return null
val secret = map["secret"] as? Boolean ?: false
return ConnectionEnvField(
name = name,
required = map["required"] as? Boolean ?: false,
secret = secret,
defaultValue = if (secret) null else map["default"] as? String,
prompt = map["prompt"] as? String,
)
}
private inline fun <reified T : Enum<T>> enumValue(
raw: String?,
values: Array<T>,
fallback: T,
): T = values.firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: fallback
private fun Any?.toLongOrNull(): Long? =
when (this) {
is Number -> this.toLong().takeIf { this.toDouble() == it.toDouble() }
is String -> this.toLongOrNull()
else -> null
}
private fun Any?.toDoubleOrNull(): Double? =
when (this) {
is Number -> this.toDouble()
is String -> this.toDoubleOrNull()
else -> null
}
}
@@ -75,6 +75,19 @@ object EventParser {
val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String
return when (eventType) {
"connection.request", "connection.update" -> {
val snapshot = payload?.let { ConnectionOperationParser.parse(it, sessionId) }
// Operation payloads may carry credential defaults and OAuth URLs.
// Never retain the raw frame when a malformed operation degrades to Unknown.
if (snapshot == null) {
WsEvent.Unknown("")
} else if (eventType == "connection.request") {
WsEvent.ConnectionRequest(snapshot)
} else {
WsEvent.ConnectionUpdate(snapshot)
}
}
"gateway.ready" -> {
WsEvent.GatewayReady(payload)
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.ui.chat.TodoItem
/** Parsed WebSocket events emitted by [HermesWsClient]. */
@@ -191,6 +192,14 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
data class ConnectionRequest(
val snapshot: ConnectionOperationSnapshot,
) : WsEvent()
data class ConnectionUpdate(
val snapshot: ConnectionOperationSnapshot,
) : WsEvent()
// ── Status ───────────────────────────────────────────────────────────
data class StatusUpdate(
@@ -101,6 +101,9 @@ object WsMethods {
// ── Connectors (issue #1091) ───────────────────────────────────────────
const val CONNECTION_RESPOND = "connection.respond"
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
/** List available third-party connectors and authorization state for a session. */
const val CONNECTORS_LIST = "connectors.list"