Compare commits

...
Author SHA1 Message Date
Bailey Dixon e3aae829e1 release(server): server-v1.6.4 2026-08-12 18:35:16 -04:00
Bailey Dixon 5207ed4193 Merge pull request #335 from Codename-11/fix/desktop-placement-device-identity
feat(desktop): support targeted multi-device control
2026-08-12 18:32:06 -04:00
Bailey Dixon b46bb00ea8 test(desktop): serialize cross-platform suite 2026-08-12 18:31:50 -04:00
Bailey Dixon b8dde409c5 merge: synchronize desktop management with dev
# Conflicts:
#	CHANGELOG.md
#	docs/decisions.md
2026-08-12 18:26:20 -04:00
Bailey Dixon ca7ded3939 test(server): prove concurrent desktop routing 2026-08-12 18:24:44 -04:00
Bailey Dixon db85a26c68 feat(desktop): add contextual approvals and UI pairing 2026-08-12 18:24:43 -04:00
Bailey Dixon aa2595629d feat(desktop): expand tray management controls 2026-08-12 17:55:56 -04:00
Bailey Dixon d79146dc90 feat(desktop): add ask every time access preset 2026-08-12 17:13:51 -04:00
Bailey Dixon eabc4dd328 refactor(desktop): clarify access navigation 2026-08-12 16:57:50 -04:00
Bailey Dixon e165bfeff3 feat(desktop): animate bidirectional relay traffic 2026-08-12 15:42:43 -04:00
Bailey Dixon 40bcd796d1 refactor(desktop): simplify host access presets 2026-08-12 13:29:29 -04:00
Bailey Dixon 57ae0c9456 feat(desktop): unify capabilities and activity drilldown 2026-08-12 13:06:34 -04:00
Bailey Dixon 9b31a16c89 fix(desktop): preserve Hermes shortcut icons 2026-08-12 11:44:17 -04:00
Bailey Dixon f97bbdd395 feat(desktop): add host-wide raw USB control 2026-08-12 11:38:09 -04:00
Bailey Dixon 722a294947 feat(desktop): adopt compact capability ledger 2026-08-12 11:13:50 -04:00
Bailey Dixon bbfb57b462 feat(desktop): harden targeted remote management 2026-08-12 10:37:12 -04:00
Bailey Dixon 6db12a0bec merge: complete upstream app and Relay workflows 2026-08-12 09:24:24 -04:00
Bailey Dixon f1de957848 fix(android): translate Manage workflows 2026-08-12 09:08:31 -04:00
Bailey Dixon cc01d9c8ad test(android): compile upstream workflow fixtures 2026-08-12 09:00:04 -04:00
Bailey Dixon d574182d84 fix(android): wire Manage workflow dialogs 2026-08-12 08:46:10 -04:00
Bailey Dixon a328763da3 fix(android): localize backup completion 2026-08-12 08:46:05 -04:00
Bailey Dixon f53db68e7d feat(android): complete upstream Manage workflows 2026-08-12 07:45:55 -04:00
Bailey Dixon eb9e570fc0 feat(android): show session repository and PR state 2026-08-12 07:41:35 -04:00
Bailey Dixon 260f119637 fix(voice): align upstream auth and transport 2026-08-12 07:39:42 -04:00
Bailey Dixon d0fa2ea39d fix(android): preserve clarify selection semantics 2026-08-12 07:37:28 -04:00
Bailey Dixon a1c74b1567 fix(plugin): enumerate phone home target 2026-08-12 07:34:19 -04:00
Bailey Dixon 7c45acd38d chore: merge server-v1.6.3 release history into dev 2026-08-11 22:03:36 -04:00
108 changed files with 7100 additions and 787 deletions
+35
View File
@@ -6,11 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Windows management separates each Relay host from this PC.** Host detail is now the per-host hub for identity, connection and pairing/session metadata, access, capabilities, authorized-client deauthorization, re-pairing, and guarded removal. Settings owns local daemon lifecycle, separate UI-at-sign-in and daemon-with-UI preferences, explicit UAC elevation and return to user mode, terminal/CLI launchers, daemon logs, diagnostics, updates, and a Help & About page with version details and documentation links. Existing installs keep automatic daemon startup off until enabled.
- **Desktop access uses four clear presets.** Restricted keeps every desktop capability off, Ask Every Time requests local approval for each available operation, Standard allows files while asking for screen/input/USB and withholding raw commands, and Full Access allows every available capability without task grants. New pairings default to Ask Every Time; existing hosts keep their stored policy. Individual changes snap to an exact preset when possible and otherwise become Custom.
- **The connected-host control is visibly interactive.** The compact Agent-to-PC route now gives the selected host a labeled server icon, host identity, Change affordance, stronger card treatment, and responsive width instead of presenting the host name as a cramped status pill.
- **The connection route shows real bidirectional traffic.** Staggered data packets now travel continuously from Agent to PC and PC to Agent, pass behind the selected host as the relay hop, and stop animating when the tunnel is offline or reduced motion is requested.
- **Nested management pages have a clear return control.** Host access, capabilities, activity, and host detail views now use a bordered Back button with a larger target, visible focus treatment, and explicit destination. Access copy also spells out that Standard allows files, asks for screen/input/USB, and keeps raw commands off.
- **Desktop activity supports evidence-first drilldown.** Overview shows the latest three events; the full activity view opens each event into bounded, locally stored request, stdout, stderr, structured result, exit, timing, and truncation details with sensitive request fields excluded.
- **Android sessions show their coding context when Hermes supplies it.** Session rows can display repository, Git branch, and the current state of the pull request created by that session while older hosts remain unchanged.
- Android Manage can now finish host-owned backup workflows, edit or remove learning nodes with explicit recovery guidance, configure and activate memory providers, and complete profile-scoped WhatsApp QR onboarding through the authenticated upstream Dashboard contracts.
### Changed
- **Relay voice custom transports follow upstream provider security options.** Relay-owned OpenAI/xAI realtime and TTS clients now honor explicit custom headers, custom CA bundles, the standard CA environment precedence, and an opt-in warned `ssl_verify=false` development mode without changing public-provider defaults or logging header values.
- **Voice Lab xAI sign-in uses device authorization.** The standalone xAI login now shows a verification URL and user code and polls for approval, matching upstream Hermes and removing the loopback callback/SSH-tunnel requirement while preserving existing Voice Lab token files and refresh behavior.
### Fixed
- **Ask-mode approval cards show the requested action.** A bounded command or action preview appears in the compact card, with full context in the expandable detail view and an option to review the live request in the main UI.
- **Mixed desktop capability policies are labeled Custom.** Overview no longer presents a misleading preset when individual capability controls differ.
- **Desktop pairing and connection security are explicit.** The management UI supports URL/code pairing directly and distinguishes encrypted `wss://` relay connections from unencrypted `ws://` routes.
- **Windows tray placement follows the notification-area monitor at its real DPI.** The management popup now derives responsive logical dimensions from the tray monitor's work area instead of guessing scale from the icon slot, keeping compact and high-DPI desktops consistently anchored.
- **PowerShell success output is complete and self-describing.** Scripts execute through a private UTF-8 temporary file, native exit status propagates, stdout and stderr are drained independently, and bounded output reports total, captured, and truncated bytes instead of returning unexplained empty success.
- **Phone is discoverable as a proactive delivery target.** The Relay phone adapter now publishes its configured home destination through Hermes' standard channel directory, so target listings can offer `phone` before any historical phone session exists.
- **Android clarify cards preserve upstream decision semantics.** Multi-select prompts keep independent selections and submit one exact list, while server expiry events—not an invented local deadline—retire unanswered cards.
- **Android keeps profile management and retained automation truthful.** Custom Endpoint list and mutation routes now follow the selected Hermes profile, while completed one-shot cron jobs show their retained outcome and expose only valid Runs/Delete actions.
- **Android and Relay recover more generated media reliably.** Android accepts upstream-valid wrapped, punctuated, adjacent, spaced, and Windows `MEDIA:` markers without consuming fenced examples, and Relay translates Docker-visible workspace, home, cache, and configured-mount paths before applying its existing credential, sandbox, and size checks.
## [1.6.4] - 2026-08-12
### Added
- **Desktop tools support explicit host targeting.** Every client-routed desktop tool accepts a stable device ID or unambiguous computer name, and `/desktop/health` enumerates connected targets and their advertised tools.
- **USB operations retain both routing scopes.** Raw USB and ADB tools use `device` to select the desktop PC, while ADB operations continue to use `serial` to select hardware attached to that PC.
### Fixed
- **Multiple desktop clients remain connected simultaneously.** The Relay no longer replaces the previous desktop when another heartbeat arrives; concurrent requests are bound to their selected WebSockets, responses from another PC are ignored, and an untargeted call fails closed when several desktops are online.
- **Pairing another desktop preserves existing credentials.** Legacy placeholder device identifiers are treated as absent instead of shared ownership, preventing an unrelated PC from revoking the first desktop's session.
## [1.6.3] - 2026-08-11
### Fixed
+11 -4
View File
@@ -1,8 +1,8 @@
# Hermes-Relay-Server v__VERSION__
**Release Date:** August 11, 2026
**Release Date:** August 12, 2026
This patch improves gateway recovery diagnostics and prevents clients from reconnecting in lockstep after a shared restart.
This patch adds safe simultaneous routing for multiple connected desktop PCs and makes host selection explicit across command, file, screen, USB, and ADB operations.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
@@ -10,8 +10,14 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
### Fixed
- **Bounded prior-exit diagnostics.** Relay Doctor and `/relay/info` distinguish a clean stop, an unclean exit, and unknown history, with an optional suspected out-of-memory hint. Raw logs are never returned through the API.
- **Desynchronized recovery.** Ordinary exponential reconnect delays use full jitter so multiple Relay clients do not retry in lockstep after the gateway restarts. Explicit reconnects and server-directed retry timing remain unchanged.
- **No more latest-client-wins routing.** Connecting a second desktop no longer evicts the first. Requests are bound to the selected desktop WebSocket, and a response from another PC cannot satisfy them.
- **Ambiguous calls fail closed.** With more than one desktop online, client-routed tools require a stable device ID or unambiguous computer name instead of silently choosing the latest heartbeat.
- **Pairing preserves existing PCs.** Placeholder legacy device identifiers no longer collide and revoke another desktop's session.
### Added
- **Target discovery.** `desktop_health` lists every connected desktop with its stable ID, name, and advertised tools.
- **Two-level USB targeting.** USB and ADB tools use `device` for the host PC; ADB operations retain `serial` for the attached Android device.
## Install / update
@@ -26,6 +32,7 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
## Verify
hermes relay doctor
# Agent/tool callers can use desktop_health to list desktop targets.
python scripts/check-plugin-version-sync.py --expect __VERSION__
---
+6 -16
View File
@@ -8,24 +8,14 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
## Structured desktop hardware capabilities
Desktop command, PowerShell, process, and job tools currently execute with the
desktop daemon's OS-user authority. Add typed hardware operations for reliable
schemas, audit detail, and task-scoped approval, but do not present hardware
toggles as isolation while an enabled general shell can reach the same device.
- Define per-host capabilities for commands, files, processes, clipboard,
screen, input, connected devices, microphone, and camera. Disabled must win,
hardware-sensitive capabilities must default off, and unavailable backends
must appear unavailable rather than as inert toggles.
- Add a **Structured only** access profile that withholds shell/process escape
hatches so individual capability toggles become enforceable boundaries.
- Implement connected-device support first with typed, serial-bound ADB
operations (`list`, `shell`, `push`, `pull`, `install`, and bounded logcat)
instead of a generic device-exec wrapper.
Structured access and per-host USB policy now ship with typed, serial-bound ADB
list, shell, push, pull, install, and bounded logcat operations. Remaining work:
- Add microphone and camera only with backend readiness detection, bounded local
grants, active-use indicators, audit events, and immediate cancellation.
- Reconcile legacy `desktop_screenshot` with the task-granted computer screenshot
path so screen capture follows one policy.
- Extend capability policy beyond hardware only where a typed broker provides a
meaningfully stronger boundary than Structured mode already provides.
---
@@ -932,7 +922,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
- Live gateway must discover the plugin (`~/.hermes/plugins/hermes-relay` → `plugin/`) and `plugins.enabled` must include `hermes-relay` for the `phone` platform to register. Confirm `phone` appears in `hermes gateway status` with `PHONE_ENABLED=1`.
- End-to-end: with the app paired + "Let Hermes message me" on, run `send_message target=phone text=...` (and a cron `deliver=phone`) and confirm a notification on the device. Verify 503 (no phone) and the off-by-default gates.
- **Phase 2c reply round-trip — ✅ DONE (verified on-device 2026-06-29).** Confirmed: agent → phone notification → inline reply → drained through the relay's loopback `GET /phone/replies` (different process) → `handle_message` (`role_authorized=True`, no `PHONE_ALLOW_ALL_USERS`) → agent answer back in the *same* thread. Both fixes required (see DEVLOG / the Phase 2c bullet above).
- **FIX: cron `deliver=phone` / standalone send is broken.** Live testing: `hermes send --to phone` returns `{"error": "Unknown platform: phone"}`. The standalone (non-gateway) send path doesn't run a `kind=standalone` plugin's programmatic `ctx.register_platform`, so it never learns `phone` — only the running gateway (which loads `register()` at startup) does. The agent path (`send_message target=phone` in the gateway) works and was verified end-to-end on-device; the standalone/cron path needs the platform discoverable there too (declare it so the standalone loader picks it up, or route cron through the gateway). Until then `cron deliver=phone` won't work.
- **Cron `deliver=phone` live certification pending.** The plugin now registers its standalone sender and enumerates the canonical phone home through the upstream adapter channel-directory hook. Re-run the device scenario above on the deployed plugin to certify scheduled delivery, including the offline queue and opt-in gates.
- **FIX SHIPPED (2026-07-07) — installer + doctor guard against stale duplicate plugin copies; live-host verify pending.** Root cause of the 2026-06-29 round-trip failure: the gateway loader dedups discovered plugins by manifest `name`, so a second directory declaring `name: hermes-relay` (an old-installer backup copy, or a stray native install) could win the dedup and make the gateway load stale code — silently ignoring every later deploy. `plugin/doctor.py` now emits a `plugin-name-unique` warning when more than one directory under `~/.hermes/plugins/` declares the same plugin name (distinct real targets only — two links to the same target are deduped), and `install.sh` sweeps any such duplicate so only the canonical `hermes-relay` symlink survives. (Current `install.sh` already `rm -rf`s the old link rather than backing it up inside the plugins dir, so the original "back up outside the plugins dir" half is moot.) **Verify on the live host:** `hermes relay doctor` reports the `plugin-name-unique` check, and a reinstall leaves exactly one `hermes-relay` entry under `~/.hermes/plugins/`.
## Phone platform — usability roadmap (post device-verification, 2026-06-29)
@@ -965,7 +955,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
The gateway-platform model is the *correct + sufficient architecture* (the phone is a registered platform peer, so anything that routes to a platform — `send_message`, cron `deliver=`, channel directory, background jobs — can reach the phone). These are the concrete gaps between "architecturally a peer" and "I never open Discord":
- **Guaranteed background delivery (the biggest gap; no push today).** Delivery is **live-WSS-only** + a 24 h relay buffer; there is **no FCM/UnifiedPush** wake-up. If the app process is dead AND not holding a socket, a message waits for the next reconnect, and the relay buffer is ephemeral (lost on relay restart). Discord/Telegram feel instant because they wake the device via push even when the app is dead. Decide a **push transport**: **UnifiedPush/ntfy** (recommended — self-hostable, no Google dependency, upstream *already* ships an `ntfy` platform, on-brand for self-hosted) vs **FCM** (simplest UX but adds Play Services + a push relay; clashes with self-hosted ethos — at most the `googlePlay` flavor) vs **persistent foreground keep-alive service** holding the relay WSS (zero new infra, like `GatewayKeepAliveService`, but battery cost + Doze-fragile). Likely: UnifiedPush primary + foreground-keepalive fallback.
- **Cron / background-job delivery is BROKEN** (already tracked above): `deliver=phone` standalone path → `Unknown platform: phone`. This is load-bearing for "receiver of crons/background jobs" — fix is required, not optional, for the replacement goal.
- **Cron / background-job delivery needs live certification.** The standalone sender and channel-directory enumeration are implemented; certify `deliver=phone` against a deployed Relay and paired device, including reconnect delivery from the bounded offline queue.
- **Agent-initiated multi-thread creation remains.** The app already renders N
`source=phone` sessions, user-created Threads vary `chat_id`, and replies route
by `chat_id` + `reply_to`. The missing parity is letting the agent open/name a
+1
View File
@@ -324,6 +324,7 @@ dependencies {
// QR Code scanning (ML Kit + CameraX)
implementation(libs.mlkit.barcode)
implementation(libs.zxing.core)
implementation(libs.camera.core)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
@@ -421,6 +421,14 @@ data class ChatSession(
/** Durable upstream session metadata, scoped by the owning connection/profile DB. */
val pinned: Boolean = false,
val archived: Boolean = false,
/** Optional newer-upstream workspace context; absent on legacy/API-only hosts. */
val workingDirectory: String? = null,
val gitBranch: String? = null,
val gitRepoRoot: String? = null,
val pullRequestNumber: Int? = null,
val pullRequestUrl: String? = null,
val pullRequestState: String? = null,
val pullRequestDraft: Boolean = false,
) {
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -129,6 +129,7 @@ data class ChatTurnAskCheckpoint(
val requestId: String? = null,
val text: String,
val choices: List<String>? = null,
val multiSelect: Boolean = false,
val smartDenied: Boolean = false,
val envVar: String? = null,
val timeoutSeconds: Int,
@@ -2,6 +2,8 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* A rich content card emitted inline in an assistant message via the
@@ -117,6 +119,8 @@ data class HermesCardInput(
val kind: String,
/** Quick-answer chips (clarify). Empty = no chip row. */
val choices: List<String> = emptyList(),
/** Choices toggle independently and require an explicit submit. */
val multiSelect: Boolean = false,
/** Render the inline free-text mini field under the chips. */
val allowFreeText: Boolean = false,
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
@@ -124,7 +128,7 @@ data class HermesCardInput(
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
val holdToConfirm: Boolean = false,
/**
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
* Wall-clock expiry for asks with an advertised deadline.
* The renderer shows a countdown footer (Amber under 30s) and
* self-collapses to "Expired — not granted" past it. Null = no timeout
* (approval is session-scoped).
@@ -155,6 +159,10 @@ data class HermesCardInput(
}
}
/** Exact JSON-array wire value expected by upstream multi-select clarify. */
internal fun encodeClarifyMultiSelectAnswer(values: List<String>): String =
Json.encodeToString(values.map(String::trim).filter(String::isNotEmpty).distinct())
/**
* A label/value row inside a card. [value] is rendered as markdown so the
* agent can embed emphasis, inline code, or links.
@@ -2036,6 +2036,13 @@ class ChatHandler {
hasModelConfig = item.hasModelConfig,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
)
}.sortedByDescending { it.activityTimestamp }
// Preserve the active session's optimistic row when the server list
@@ -7,9 +7,12 @@ import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.network.upstream.models.SessionListResponse
import com.hermesandroid.relay.network.upstream.models.SessionPullRequest
import com.hermesandroid.relay.network.upstream.models.SessionPullRequestScanResponse
import com.hermesandroid.relay.network.upstream.models.SessionPruneFilters
import com.hermesandroid.relay.network.upstream.models.SessionPrunePreview
import com.hermesandroid.relay.network.upstream.models.SessionPruneResult
import com.hermesandroid.relay.network.upstream.models.RepositoryPullRequestListResponse
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.auth.buildRawTokenStore
@@ -35,13 +38,18 @@ import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import okio.BufferedSink
// Status/session/provider snapshots are @Serializable so the Manage tab's
// disk cache (DashboardManageDiskCache) can persist Loaded entries verbatim.
@@ -173,6 +181,71 @@ data class DashboardCustomEndpointValidation(
val models: List<String>,
)
internal class BoundedStreamRequestBody(
private val declaredLength: Long?,
private val limitBytes: Long,
private val openStream: () -> InputStream,
) : RequestBody() {
init {
require(limitBytes > 0)
require(declaredLength == null || declaredLength >= 0)
require(declaredLength == null || declaredLength <= limitBytes) {
"Backup archive exceeds the ${limitBytes / (1024 * 1024)} MB upload limit."
}
}
override fun contentType() = "application/zip".toMediaType()
override fun contentLength(): Long = declaredLength ?: -1L
override fun writeTo(sink: BufferedSink) {
openStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var written = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
written += read
if (written > limitBytes) {
throw IOException("Backup archive exceeds the ${limitBytes / (1024 * 1024)} MB upload limit.")
}
sink.write(buffer, 0, read)
}
if (declaredLength != null && written != declaredLength) {
throw IOException("Backup archive changed while it was being read.")
}
}
}
}
internal fun copyBounded(
input: InputStream,
output: OutputStream,
declaredLength: Long?,
limitBytes: Long,
): Long {
require(limitBytes > 0)
require(declaredLength == null || declaredLength >= 0)
require(declaredLength == null || declaredLength <= limitBytes) {
"Backup archive exceeds the ${limitBytes / (1024 * 1024)} MB download limit."
}
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var written = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
written += read
if (written > limitBytes) {
throw IOException("Backup archive exceeds the ${limitBytes / (1024 * 1024)} MB download limit.")
}
output.write(buffer, 0, read)
}
if (declaredLength != null && written != declaredLength) {
throw IOException("Backup archive changed while it was being downloaded.")
}
return written
}
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
data class ElevenLabsVoice(
val voiceId: String,
@@ -206,8 +279,14 @@ class DashboardApiClient(
isLenient = true
coerceInputValues = true
},
private val nowMillis: () -> Long = System::currentTimeMillis,
) {
private val baseUrl: String = baseUrl.trim().trimEnd('/')
private val sessionPrScanLock = Any()
private val sessionPrScannedAt = mutableMapOf<String, Long>()
private val sessionPrScanWasTerminal = mutableMapOf<String, Boolean>()
private val sessionPullRequests = mutableMapOf<String, SessionPullRequest>()
private var sessionPrScanSupported: Boolean? = null
/**
* Resolve a request URL without ever throwing. okhttp's
@@ -518,6 +597,157 @@ class DashboardApiClient(
suspend fun createServerBackup(): Result<JsonObject> =
postJsonObject("/api/ops/backup")
/** Download only archives created inside upstream's guarded dashboard backup directory. */
suspend fun downloadServerBackup(
archive: String,
openOutput: () -> OutputStream,
): Result<String> = download(
path = "/api/ops/backup/download?archive=${queryValue(archive)}",
operation = "Hermes backup",
openOutput = openOutput,
)
/** Import a server-local archive path after the user confirms the destructive restore. */
suspend fun importServerBackup(archive: String): Result<JsonObject> =
postJsonObject(
path = "/api/ops/import",
payload = buildJsonObject { put("archive", archive) },
)
/** Upload an Android-selected zip to upstream's guarded staging directory and start import. */
suspend fun uploadServerBackup(
filename: String,
contentLength: Long?,
openStream: () -> InputStream,
force: Boolean = false,
): Result<JsonObject> = withContext(Dispatchers.IO) {
val path = "/api/ops/import-upload"
val httpUrl = resolveUrl(path) ?: return@withContext Result.failure(invalidUrlException())
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("force", force.toString())
.addFormDataPart(
"file",
filename.ifBlank { "hermes-backup.zip" },
runCatching {
BoundedStreamRequestBody(contentLength, MAX_BACKUP_TRANSFER_BYTES, openStream)
}.getOrElse { return@withContext Result.failure(it) },
)
.build()
executeJson(Request.Builder().url(httpUrl).post(body).build(), path)
}
suspend fun getLearningNode(id: String, profile: String? = null): Result<JsonObject> =
getJsonObject("/api/learning/node?id=${queryValue(id)}${profileQuerySuffix(profile)}")
suspend fun updateLearningNode(
id: String,
content: String,
profile: String? = null,
): Result<JsonObject> = putJsonObject(
path = "/api/learning/node",
payload = buildJsonObject {
put("id", id)
put("content", content)
profile?.trim()?.takeIf(String::isNotBlank)?.let { put("profile", it) }
},
)
suspend fun deleteLearningNode(id: String, profile: String? = null): Result<JsonObject> =
deleteJsonObjectWithBody(
path = "/api/learning/node",
payload = buildJsonObject {
put("id", id)
profile?.trim()?.takeIf(String::isNotBlank)?.let { put("profile", it) }
},
)
suspend fun selectMemoryProvider(provider: String): Result<JsonObject> =
putJsonObject(
path = "/api/memory/provider",
payload = buildJsonObject { put("provider", provider) },
)
/** Activate an already-configured provider inside the selected upstream profile. */
suspend fun activateMemoryProvider(provider: String, profile: String? = null): Result<JsonObject> =
updateMemoryProviderConfig(provider, JsonObject(emptyMap()), profile)
suspend fun getMemoryProviderConfig(
provider: String,
profile: String? = null,
): Result<JsonObject> = getJsonObject(
"/api/memory/providers/${pathSegment(provider)}/config${profileQuery(profile)}",
)
suspend fun updateMemoryProviderConfig(
provider: String,
values: JsonObject,
profile: String? = null,
): Result<JsonObject> = putJsonObject(
path = "/api/memory/providers/${pathSegment(provider)}/config${profileQuery(profile)}",
payload = buildJsonObject { put("values", values) },
)
suspend fun setupMemoryProvider(provider: String): Result<JsonObject> =
postJsonObject(
path = "/api/memory/providers/${pathSegment(provider)}/setup",
// Dependency installation is host-global upstream. Do not submit
// profile-owned values through this unscoped route.
payload = buildJsonObject { put("values", JsonObject(emptyMap())) },
)
suspend fun startWhatsAppOnboarding(
mode: String,
allowedUsers: String,
profile: String? = null,
): Result<JsonObject> = postJsonObject(
path = "/api/messaging/whatsapp/onboarding/start",
payload = buildJsonObject {
put("mode", mode)
put("allowed_users", allowedUsers)
profile?.trim()?.takeIf(String::isNotBlank)?.let { put("profile", it) }
},
)
suspend fun getWhatsAppOnboarding(pairingId: String): Result<JsonObject> =
getJsonObject("/api/messaging/whatsapp/onboarding/${pathSegment(pairingId)}")
suspend fun applyWhatsAppOnboarding(
pairingId: String,
mode: String,
allowedUsers: String,
profile: String? = null,
): Result<JsonObject> = postJsonObject(
path = "/api/messaging/whatsapp/onboarding/${pathSegment(pairingId)}/apply",
payload = buildJsonObject {
put("mode", mode)
put("allowed_users", allowedUsers)
profile?.trim()?.takeIf(String::isNotBlank)?.let { put("profile", it) }
},
)
suspend fun cancelWhatsAppOnboarding(pairingId: String): Result<JsonObject> =
deleteJsonObject("/api/messaging/whatsapp/onboarding/${pathSegment(pairingId)}")
suspend fun setMessagingPlatformEnabled(
platform: String,
enabled: Boolean,
profile: String? = null,
): Result<JsonObject> = putJsonObject(
path = "/api/messaging/platforms/${pathSegment(platform)}${profileQuery(profile)}",
payload = buildJsonObject {
put("enabled", enabled)
put("env", JsonObject(emptyMap()))
put("clear_env", JsonArray(emptyList()))
profile?.trim()?.takeIf(String::isNotBlank)?.let { put("profile", it) }
},
)
suspend fun testMessagingPlatform(platform: String, profile: String? = null): Result<JsonObject> =
postJsonObject(
"/api/messaging/platforms/${pathSegment(platform)}/test${profileQuery(profile)}",
)
suspend fun setProfileDescription(name: String, description: String): Result<JsonObject> =
putJsonObject(
path = "/api/profiles/${pathSegment(name)}/description",
@@ -764,7 +994,13 @@ class DashboardApiClient(
pageSessions.forEach { sessions.putIfAbsent(it.id, it) }
if (pageSessions.size < page.limit) break
}
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
Result.success(
enrichSessionWorkState(
sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)),
fixedProfile = profile?.trim()?.takeIf { it.isNotBlank() }
?: DEFAULT_SESSION_PROFILE_SCOPE,
),
)
}
/**
@@ -776,7 +1012,7 @@ class DashboardApiClient(
limit: Int = SESSION_LIST_WINDOW_LIMIT,
): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
val boundedLimit = limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)
getJson(
val result = getJson(
"/api/profiles/sessions?limit=$boundedLimit&offset=0&order=recent" +
"&min_messages=1&archived=include&profile=all",
).mapCatching { root ->
@@ -786,8 +1022,129 @@ class DashboardApiClient(
.distinctBy { "${it.profile}:${it.id}" }
.take(boundedLimit)
}
if (result.isFailure) return@withContext result
Result.success(enrichSessionWorkState(result.getOrThrow(), fixedProfile = null))
}
/**
* Attach the PR a coding session created using the current upstream
* transcript-backed endpoint. Repository and branch already arrive on the
* list row. Missing/older endpoints are deliberately ignored, leaving the
* original rows intact. Active misses retry on a bounded cadence; terminal
* rows get one final scan and resolved associations remain cached.
*/
private suspend fun enrichSessionWorkState(
sessions: List<SessionItem>,
fixedProfile: String?,
): List<SessionItem> {
val candidates = sessions.filter {
it.id.isNotBlank() &&
(!it.gitRepoRoot.isNullOrBlank() || !it.gitBranch.isNullOrBlank() || !it.cwd.isNullOrBlank())
}
val duplicateIds = if (fixedProfile == null) {
candidates.groupingBy { it.id }.eachCount().filterValues { it > 1 }.keys
} else {
emptySet()
}
val now = nowMillis()
val pending = synchronized(sessionPrScanLock) {
candidates.filter { session ->
if (session.id in duplicateIds) return@filter false
val key = sessionWorkKey(session, fixedProfile)
val scannedAt = sessionPrScannedAt[key]
val resolved = sessionPullRequests[key] != null
!resolved && when {
scannedAt == null -> true
session.endedAt != null -> sessionPrScanWasTerminal[key] != true
else -> now - scannedAt >= ACTIVE_SESSION_PR_MISS_TTL_MILLIS
}
}
}
val pendingIds = pending.map { it.id }.distinct()
if (pendingIds.isNotEmpty()) {
val payload = buildJsonObject {
put("ids", JsonArray(pendingIds.map { JsonPrimitive(it) }))
}
val scan = postJsonObject("/api/profiles/sessions/pull-requests", payload)
.mapCatching { root ->
json.decodeFromJsonElement(SessionPullRequestScanResponse.serializer(), root)
}
synchronized(sessionPrScanLock) {
// A legacy 404 is a compatibility outcome, not a session-list failure.
// Avoid hammering an unsupported host on every drawer refresh.
if (scan.isSuccess || sessionPrScanSupported == null) {
sessionPrScanSupported = scan.isSuccess
}
pending.forEach { session ->
val key = sessionWorkKey(session, fixedProfile)
sessionPrScannedAt[key] = now
sessionPrScanWasTerminal[key] = session.endedAt != null
scan.getOrNull()?.pullRequests?.get(session.id)?.takeIf {
it.number > 0 && it.url.isNotBlank()
}?.let { pullRequest ->
sessionPullRequests[key] = pullRequest
}
}
}
}
refreshPullRequestStates(candidates, fixedProfile)
val pullRequests = synchronized(sessionPrScanLock) { sessionPullRequests.toMap() }
return sessions.map { session ->
session.copy(pullRequest = pullRequests[sessionWorkKey(session, fixedProfile)])
}
}
/** Refresh current PR lifecycle state using upstream's repo-scoped GitHub view. */
private suspend fun refreshPullRequestStates(
sessions: List<SessionItem>,
fixedProfile: String?,
) {
if (synchronized(sessionPrScanLock) { sessionPrScanSupported } != true) return
val known = synchronized(sessionPrScanLock) { sessionPullRequests.toMap() }
sessions.groupBy { (it.gitRepoRoot ?: it.cwd).orEmpty().trim() }
.filterKeys { it.isNotBlank() }
.forEach { (path, repositorySessions) ->
val branches = repositorySessions.mapNotNull { it.gitBranch?.trim() }
.filter { it.isNotBlank() }
.distinct()
val numbers = repositorySessions.mapNotNull {
known[sessionWorkKey(it, fixedProfile)]?.number
}
.filter { it > 0 }
.distinct()
if (branches.isEmpty() && numbers.isEmpty()) return@forEach
val payload = buildJsonObject {
put("path", path)
put("branches", JsonArray(branches.map { JsonPrimitive(it) }))
put("numbers", JsonArray(numbers.map { JsonPrimitive(it) }))
}
val response = postJsonObject("/api/git/review/pr-list", payload)
.mapCatching { root ->
json.decodeFromJsonElement(RepositoryPullRequestListResponse.serializer(), root)
}
.getOrNull()
?: return@forEach
if (!response.ghReady) return@forEach
synchronized(sessionPrScanLock) {
repositorySessions.forEach { session ->
val key = sessionWorkKey(session, fixedProfile)
val recovered = sessionPullRequests[key]
val current = response.prs.firstOrNull { pr ->
recovered != null && pr.number == recovered.number
} ?: response.prs.firstOrNull { pr ->
!session.gitBranch.isNullOrBlank() && pr.branch == session.gitBranch
}
if (current != null && current.number > 0 && current.url.isNotBlank()) {
sessionPullRequests[key] = current
}
}
}
}
}
private fun sessionWorkKey(session: SessionItem, fixedProfile: String?): String =
"${fixedProfile ?: session.profile.orEmpty()}\u0000${session.id}"
/**
* A session's message history, scoped to its owning profile via the dashboard
* `GET /api/sessions/{id}/messages?profile=`. Required twin of [listSessions]:
@@ -1110,8 +1467,44 @@ class DashboardApiClient(
}
}
private suspend fun download(
path: String,
operation: String,
openOutput: () -> OutputStream,
): Result<String> =
withContext(Dispatchers.IO) {
val httpUrl = resolveUrl(path) ?: return@withContext Result.failure(invalidUrlException())
val request = Request.Builder().url(httpUrl).get().build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext Result.failure(apiFailure(response, operation))
val disposition = response.header("Content-Disposition").orEmpty()
val filename = Regex("filename=\\\"?([^\\\";]+)").find(disposition)?.groupValues?.get(1)
?: "hermes-backup.zip"
val body = response.body
val declaredLength = body.contentLength().takeIf { it >= 0 }
if (declaredLength != null && declaredLength > MAX_BACKUP_TRANSFER_BYTES) {
throw IOException("Backup archive exceeds the ${MAX_BACKUP_TRANSFER_BYTES / (1024 * 1024)} MB download limit.")
}
openOutput().use { output ->
body.byteStream().use { input ->
copyBounded(input, output, declaredLength, MAX_BACKUP_TRANSFER_BYTES)
}
}
Result.success(filename)
}
} catch (e: Exception) {
Result.failure(e)
}
}
companion object {
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
private const val DEFAULT_SESSION_PROFILE_SCOPE = "__dashboard_default__"
internal const val ACTIVE_SESSION_PR_MISS_TTL_MILLIS = 60_000L
// Mirrors current upstream `_MANAGED_FILE_MAX_BYTES`; enforcing it
// client-side avoids uploading a body the Dashboard will reject.
internal const val MAX_BACKUP_TRANSFER_BYTES = 100L * 1024L * 1024L
fun pathSegment(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
@@ -1169,6 +1562,11 @@ class DashboardApiClient(
return if (trimmed.isBlank()) "" else "?profile=${pathSegment(trimmed)}"
}
private fun profileQuerySuffix(profile: String?): String {
val trimmed = profile?.trim().orEmpty()
return if (trimmed.isBlank()) "" else "&profile=${queryValue(trimmed)}"
}
private fun profileLimitQuery(profile: String?, limit: Int): String {
val params = buildList {
val trimmed = profile?.trim().orEmpty()
@@ -444,15 +444,26 @@ class GatewayEventMapper(
}
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
)
"clarify.request" -> {
val choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
?.filter { it.isNotEmpty() }
?.distinct()
?.take(MAX_CLARIFY_CHOICES)
?.takeIf { it.isNotEmpty() }
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = choices,
multiSelect = payload.boolean("multi_select") == true && choices != null,
// Current upstream owns expiry through clarify.expire and
// does not advertise its configurable deadline. Never
// invent a local deadline; consume future additive
// metadata only when it is present and positive.
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
)
}
"approval.request" -> GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
@@ -561,9 +572,9 @@ class GatewayEventMapper(
}
}
// Upstream `_block()` timeouts per ask kind (server.py) — the blocked thread
// resolves to "" when these elapse. Approval has none (session-scoped).
private const val CLARIFY_TIMEOUT_SECONDS = 300
// Upstream clarify tool accepts at most four choices. Sudo/secret retain fixed
// `_block()` timeouts; clarify is configurable and expires authoritatively.
private const val MAX_CLARIFY_CHOICES = 4
private const val SUDO_TIMEOUT_SECONDS = 120
private const val SECRET_TIMEOUT_SECONDS = 300
@@ -218,13 +218,15 @@ data class GatewayAsk(
val text: String,
/** Server-advertised answers for clarify and approval requests. */
val choices: List<String>? = null,
/** Clarify-only: several advertised choices may be returned together. */
val multiSelect: Boolean = false,
/** Approval-only: the smart observer denied and the owner may override once. */
val smartDenied: Boolean = false,
/** Secret-only: the env var the value will be stored under. */
val envVar: String? = null,
/**
* Upstream blocking timeout (clarify/secret 300s, sudo 120s). 0 means no
* countdown — approvals are session-scoped and never expire on their own.
* Server-advertised blocking timeout. 0 means no client countdown; the
* authoritative `*.expire` event still retires the interaction.
*/
val timeoutSeconds: Int,
) {
@@ -170,11 +170,39 @@ data class SessionItem(
/** Durable flags returned by current Dashboard and API-server session resources. */
val pinned: Boolean = false,
val archived: Boolean = false,
/** Optional workspace metadata added by newer Dashboard session lists. */
val cwd: String? = null,
@SerialName("git_branch") val gitBranch: String? = null,
@SerialName("git_repo_root") val gitRepoRoot: String? = null,
/** Best-effort association from the Dashboard's read-only transcript scan. */
val pullRequest: SessionPullRequest? = null,
) {
val resolvedLastActivity: Double?
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
}
@Serializable
data class SessionPullRequest(
val number: Int,
val url: String,
val branch: String? = null,
val state: String? = null,
val draft: Boolean = false,
val title: String? = null,
)
@Serializable
data class SessionPullRequestScanResponse(
@SerialName("pull_requests") val pullRequests: Map<String, SessionPullRequest> = emptyMap(),
val scanned: List<String> = emptyList(),
)
@Serializable
data class RepositoryPullRequestListResponse(
val ghReady: Boolean = false,
val prs: List<SessionPullRequest> = emptyList(),
)
@Serializable
data class CreateSessionRequest(
val title: String? = null,
@@ -45,6 +45,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -81,6 +83,7 @@ import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.HermesCardField
import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.data.encodeClarifyMultiSelectAnswer
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import kotlinx.coroutines.coroutineScope
@@ -389,7 +392,8 @@ private fun ChoseRow(
/**
* The interactive answer surface for ask cards, composed from the
* [HermesCardInput] flags rather than the card type:
* - [HermesCardInput.choices] → AssistChip row, one tap dispatches.
* - [HermesCardInput.choices] → one-tap AssistChips, or independently
* selected FilterChips plus explicit submit for multi-select clarifies.
* - [HermesCardInput.allowFreeText] → [InlineAnswerField] mini pill +
* 18dp send affordance.
* - [HermesCardInput.masked] → password-style OutlinedTextField with a
@@ -411,6 +415,8 @@ private fun CardInputSlot(
// never be written into the saved-instance-state Bundle.
var answerText by remember { mutableStateOf("") }
var reveal by remember { mutableStateOf(false) }
var selectedChoices by remember(input.choices) { mutableStateOf(emptyList<String>()) }
val isMultiSelect = input.multiSelect && input.choices.isNotEmpty()
val showFreeText = !input.masked && (
input.allowFreeText ||
@@ -428,16 +434,45 @@ private fun CardInputSlot(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
input.choices.forEach { choice ->
AssistChip(
onClick = { onSubmit(choice) },
label = {
Text(choice, style = MaterialTheme.typography.labelMedium)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
if (isMultiSelect) {
val selected = choice in selectedChoices
FilterChip(
selected = selected,
onClick = {
selectedChoices = if (selected) {
selectedChoices - choice
} else {
selectedChoices + choice
}
},
label = { Text(choice, style = MaterialTheme.typography.labelMedium) },
leadingIcon = if (selected) {
{
Icon(
Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
} else {
null
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
),
)
} else {
AssistChip(
onClick = { onSubmit(choice) },
label = {
Text(choice, style = MaterialTheme.typography.labelMedium)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
}
}
@@ -482,21 +517,38 @@ private fun CardInputSlot(
onValueChange = { answerText = it },
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { onSubmit(answerText.trim()) },
enabled = answerText.isNotBlank(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.card_send_answer_a11y),
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
if (!isMultiSelect) {
IconButton(
onClick = { onSubmit(answerText.trim()) },
enabled = answerText.isNotBlank(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.card_send_answer_a11y),
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
}
if (isMultiSelect) {
val answers = selectedChoices +
listOfNotNull(answerText.trim().takeIf(String::isNotEmpty))
Spacer(Modifier.height(10.dp))
Button(
onClick = { onSubmit(encodeClarifyMultiSelectAnswer(answers)) },
enabled = answers.isNotEmpty(),
) {
Text(
stringResource(R.string.card_submit),
style = MaterialTheme.typography.labelMedium,
)
}
}
// Submit affordance for masked / hold-to-confirm inputs
when {
input.holdToConfirm -> {
@@ -104,6 +104,25 @@ data class ProfileSessionRow(
val session: ChatSession,
)
internal fun sessionWorkLabels(session: ChatSession): List<String> = buildList {
val repo = (session.gitRepoRoot ?: session.workingDirectory)
?.trimEnd('/', '\\')
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.takeIf { it.isNotBlank() }
repo?.let(::add)
session.gitBranch?.trim()?.takeIf { it.isNotBlank() }?.let(::add)
session.pullRequestNumber?.takeIf { it > 0 }?.let { number ->
val status = when {
session.pullRequestDraft -> "Draft"
!session.pullRequestState.isNullOrBlank() ->
session.pullRequestState.lowercase().replaceFirstChar { it.uppercaseChar() }
else -> null
}
add(listOfNotNull("PR #$number", status).joinToString(" · "))
}
}
internal fun sessionPinIcon(pinned: Boolean) =
if (pinned) Icons.Filled.Star else Icons.Outlined.StarBorder
@@ -206,7 +225,8 @@ fun SessionDrawerContent(
needle.isBlank() ||
session.sessionId.contains(needle, ignoreCase = true) ||
session.title.orEmpty().contains(needle, ignoreCase = true) ||
session.model.orEmpty().contains(needle, ignoreCase = true)
session.model.orEmpty().contains(needle, ignoreCase = true) ||
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
}
.sortedWith(
compareByDescending<ChatSession> { it.pinned }
@@ -648,7 +668,8 @@ fun SessionDrawerContent(
needle.isBlank() ||
row.profile.contains(needle, ignoreCase = true) ||
row.session.title.orEmpty().contains(needle, ignoreCase = true) ||
row.session.sessionId.contains(needle, ignoreCase = true)
row.session.sessionId.contains(needle, ignoreCase = true) ||
sessionWorkLabels(row.session).any { it.contains(needle, ignoreCase = true) }
}
AlertDialog(
onDismissRequest = { allProfilesOpen = false },
@@ -693,6 +714,15 @@ fun SessionDrawerContent(
style = relayMetadataStyle(),
color = RelayRefresh.Relay,
)
sessionWorkLabels(row.session).takeIf { it.isNotEmpty() }?.let { labels ->
Text(
labels.joinToString(" • "),
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -771,6 +801,28 @@ private fun SessionItem(
MaterialTheme.colorScheme.onSurface
}
)
val workLabels = sessionWorkLabels(session)
if (workLabels.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier
.padding(top = 4.dp)
.horizontalScroll(rememberScrollState()),
) {
workLabels.forEach { label ->
Text(
text = label,
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -2139,6 +2139,13 @@ fun ChatScreen(
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
@@ -54,6 +54,14 @@ internal enum class DashboardActionKind {
ValidateCustomEndpoint,
ActivateCustomEndpoint,
DeleteCustomEndpoint,
EditLearningNode,
DeleteLearningNode,
ConfigureMemoryProvider,
ActivateMemoryProvider,
SetupWhatsApp,
EnableChannel,
DisableChannel,
TestChannel,
// Input-backed kinds — intercepted before runAction and routed to a
// text-input or model-picker dialog instead of firing immediately.
@@ -3,11 +3,16 @@
package com.hermesandroid.relay.ui.screens
import android.content.Intent
import android.content.ContentResolver
import android.graphics.Bitmap
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -35,6 +40,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.Image
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -90,6 +96,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -125,16 +132,24 @@ import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.text.DateFormat
import java.util.Date
import java.io.IOException
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
/**
* Section of the Hermes dashboard Manage tab. The enum identity is used for
@@ -248,7 +263,10 @@ internal fun scopeDashboardManageItems(
if (
sectionPath == "/api/mcp/servers" ||
sectionPath == "/api/mcp/catalog" ||
sectionPath == "/api/providers/custom-endpoints"
sectionPath == "/api/providers/custom-endpoints" ||
sectionPath == "/api/learning/graph" ||
sectionPath == "/api/memory" ||
sectionPath == "/api/messaging/platforms"
) {
items.map { it.copy(profile = profile) }
} else {
@@ -375,6 +393,9 @@ private enum class DashboardSectionAction {
UpdateSkillsHub,
AddCustomEndpoint,
CreateServerBackup,
DownloadServerBackup,
ImportServerBackup,
SetupWhatsApp,
}
/** Editor session for a profile's SOUL.md — content is the FULL file from GET. */
@@ -384,6 +405,28 @@ private data class SoulEditorState(
val exists: Boolean,
)
private data class LearningEditorState(
val id: String,
val title: String,
val initialContent: String,
val profile: String?,
)
private data class MemoryProviderEditorState(
val name: String,
val schema: JsonObject,
val profile: String?,
)
private data class WhatsAppOnboardingState(
val pairingId: String,
val status: String,
val qrPayload: String?,
val mode: String,
val allowedUsers: String,
val error: String? = null,
)
/** Which config slot a model-picker selection writes to. */
private sealed interface ModelPickerTarget {
data object Main : ModelPickerTarget
@@ -424,6 +467,9 @@ fun DashboardManagementScreen(
val pendingMcpOAuth by oauthViewModel.pending.collectAsState()
val unsupportedOAuthRoutes by oauthViewModel.unsupportedRoutes.collectAsState()
val supportedOAuthRoutes by oauthViewModel.supportedRoutes.collectAsState()
val clientFactory = remember(dashboardUrl, connectionViewModel) {
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
}
var selectedTab by remember { mutableStateOf(0) }
var showingDetail by remember { mutableStateOf(false) }
var reloadNonce by remember { mutableStateOf(0) }
@@ -446,6 +492,66 @@ fun DashboardManagementScreen(
var oauthDialogHidden by remember(pendingMcpOAuth?.flowId) { mutableStateOf(false) }
var customEndpointEditor by remember { mutableStateOf<DashboardSummaryItem?>(null) }
var showCustomEndpointEditor by remember { mutableStateOf(false) }
var learningEditor by remember { mutableStateOf<LearningEditorState?>(null) }
var memoryProviderEditor by remember { mutableStateOf<MemoryProviderEditorState?>(null) }
var showWhatsAppSetup by remember { mutableStateOf(false) }
var whatsappOnboarding by remember { mutableStateOf<WhatsAppOnboardingState?>(null) }
var backupArchive by remember { mutableStateOf<String?>(null) }
var importArchiveInput by remember { mutableStateOf(false) }
var pendingBackupDownload by remember { mutableStateOf<String?>(null) }
val backupSaveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip"),
) { uri ->
val archive = pendingBackupDownload
pendingBackupDownload = null
if (uri != null && archive != null) scope.launch {
actionInFlight = true
withDashboardClient(clientFactory) {
it.downloadServerBackup(archive) {
context.contentResolver.openOutputStream(uri)
?: throw IOException("The selected destination could not be opened.")
}
}.fold(
onSuccess = { actionMessage = context.getString(R.string.dashboard_backup_saved, it) },
onFailure = {
withContext(Dispatchers.IO) { runCatching { context.contentResolver.delete(uri, null, null) } }
actionMessage = it.message ?: context.getString(R.string.dashboard_backup_download_failed)
},
)
actionInFlight = false
}
}
val backupImportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) scope.launch {
actionInFlight = true
val metadata = runCatching {
withContext(Dispatchers.IO) {
dashboardImportMetadata(context.contentResolver, uri)
}
}
metadata.fold(
onSuccess = { file ->
withDashboardClient(clientFactory) {
it.uploadServerBackup(
filename = file.name,
contentLength = file.length,
openStream = {
context.contentResolver.openInputStream(uri)
?: throw IOException("The selected archive could not be opened.")
},
)
}.fold(
onSuccess = { actionMessage = context.getString(R.string.dashboard_import_started) },
onFailure = { actionMessage = it.message ?: context.getString(R.string.dashboard_import_failed) },
)
},
onFailure = { actionMessage = it.message ?: context.getString(R.string.dashboard_import_failed) },
)
actionInFlight = false
}
}
val section = managementSections[selectedTab]
val connectionId = activeConnection?.id ?: "default"
@@ -490,10 +596,6 @@ fun DashboardManagementScreen(
)
}
}
val clientFactory = remember(dashboardUrl, connectionViewModel) {
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
}
suspend fun loadDashboardSection(
targetSection: DashboardManagementSection,
targetKey: String,
@@ -828,9 +930,10 @@ fun DashboardManagementScreen(
}
actionMessage = result.fold(
onSuccess = { root ->
backupArchive = root.stringField("archive")
root.stringField("message")
?: root.stringField("filename")?.let { "Server backup ready: $it" }
?: "Server backup created."
?: root.stringField("archive")?.let { "Server backup started: $it" }
?: "Server backup started."
},
onFailure = { error -> error.message ?: "Server backup failed." },
)
@@ -838,6 +941,41 @@ fun DashboardManagementScreen(
}
}
fun openLearningEditor(item: DashboardSummaryItem) {
if (actionInFlight) return
actionInFlight = true
scope.launch {
val result = withDashboardClient(clientFactory) { it.getLearningNode(item.id, effectiveProfileName) }
result.fold(
onSuccess = { root ->
learningEditor = LearningEditorState(
id = item.id,
title = item.title,
initialContent = root.stringField("content").orEmpty(),
profile = effectiveProfileName,
)
},
onFailure = { actionMessage = it.message ?: "Learning node could not be loaded." },
)
actionInFlight = false
}
}
fun openMemoryProvider(item: DashboardSummaryItem) {
if (actionInFlight) return
actionInFlight = true
scope.launch {
val result = withDashboardClient(clientFactory) {
it.getMemoryProviderConfig(item.id, effectiveProfileName)
}
result.fold(
onSuccess = { memoryProviderEditor = MemoryProviderEditorState(item.id, it, effectiveProfileName) },
onFailure = { actionMessage = it.message ?: "Memory provider configuration could not be loaded." },
)
actionInFlight = false
}
}
LaunchedEffect(dashboardUrl, selectedTab, reloadNonce, activeConnection?.id, effectiveProfileName) {
val forceCurrent = forceReloadKey == payloadKey
loadDashboardSection(
@@ -909,6 +1047,7 @@ fun DashboardManagementScreen(
pendingAction?.let { pending ->
val isActivateProfile = pending.action.kind == DashboardActionKind.ActivateProfile
val isDeleteLearning = pending.action.kind == DashboardActionKind.DeleteLearningNode
val actionLabel = dashboardActionLabel(pending.action)
AlertDialog(
onDismissRequest = { pendingAction = null },
@@ -922,6 +1061,8 @@ fun DashboardManagementScreen(
Text(
text = if (isActivateProfile) {
stringResource(R.string.dashboard_activate_profile_body, pending.item.title)
} else if (isDeleteLearning) {
stringResource(R.string.dashboard_learning_delete_warning)
} else {
stringResource(R.string.dashboard_generic_action_body, pending.item.title)
},
@@ -1354,6 +1495,9 @@ fun DashboardManagementScreen(
)
DashboardActionKind.EditProfileSoul ->
openSoulEditor(item)
DashboardActionKind.EditLearningNode -> openLearningEditor(item)
DashboardActionKind.ConfigureMemoryProvider -> openMemoryProvider(item)
DashboardActionKind.SetupWhatsApp -> showWhatsAppSetup = true
DashboardActionKind.AuthenticateMcp ->
if (mcpOAuthStartAllowed) {
oauthDialogHidden = false
@@ -1370,7 +1514,9 @@ fun DashboardManagementScreen(
// per-conversation switch in chat.
DashboardActionKind.ActivateProfile,
DashboardActionKind.ActivateCustomEndpoint,
DashboardActionKind.DeleteCustomEndpoint ->
DashboardActionKind.DeleteCustomEndpoint,
DashboardActionKind.ActivateMemoryProvider,
DashboardActionKind.DeleteLearningNode ->
pendingAction = PendingDashboardAction(item, action)
else -> if (action.destructive) {
pendingAction = PendingDashboardAction(item, action)
@@ -1395,6 +1541,20 @@ fun DashboardManagementScreen(
}
DashboardSectionAction.CreateServerBackup ->
runServerBackup()
DashboardSectionAction.DownloadServerBackup -> {
val archive = backupArchive
if (archive == null) {
actionMessage = context.getString(R.string.dashboard_backup_create_first)
} else {
pendingBackupDownload = archive
backupSaveLauncher.launch(
archive.substringAfterLast('/').substringAfterLast('\\')
.ifBlank { "hermes-backup.zip" },
)
}
}
DashboardSectionAction.ImportServerBackup -> importArchiveInput = true
DashboardSectionAction.SetupWhatsApp -> showWhatsAppSetup = true
}
},
mcpOAuthSupported = mcpOAuthStartAllowed,
@@ -1432,6 +1592,141 @@ fun DashboardManagementScreen(
}
}
}
learningEditor?.let { editor ->
TextDocumentEditorDialog(
title = context.getString(R.string.dashboard_learning_edit_title, editor.title),
initialContent = editor.initialContent,
warning = context.getString(R.string.dashboard_learning_edit_warning),
saving = actionInFlight,
onSave = { content ->
scope.launch {
actionInFlight = true
withDashboardClient(clientFactory) {
it.updateLearningNode(editor.id, content, editor.profile)
}.fold(
onSuccess = {
learningEditor = null
forceReloadKey = payloadKey
reloadNonce += 1
actionMessage = context.getString(R.string.dashboard_learning_saved)
},
onFailure = { actionMessage = it.message ?: context.getString(R.string.dashboard_learning_save_failed) },
)
actionInFlight = false
}
},
onDismiss = { learningEditor = null },
)
}
memoryProviderEditor?.let { editor ->
MemoryProviderDialog(
editor = editor,
saving = actionInFlight,
onSubmit = { rawValues, setup ->
val values = if (setup) JsonObject(emptyMap())
else runCatching { Json.parseToJsonElement(rawValues).jsonObject }.getOrNull()
if (!setup && values == null) {
actionMessage = context.getString(R.string.dashboard_memory_invalid_json)
} else scope.launch {
actionInFlight = true
val result = withDashboardClient(clientFactory) { client ->
if (setup) client.setupMemoryProvider(editor.name)
else client.updateMemoryProviderConfig(editor.name, checkNotNull(values), editor.profile)
}
result.fold(
onSuccess = {
memoryProviderEditor = null
forceReloadKey = payloadKey
reloadNonce += 1
actionMessage = if (setup) context.getString(R.string.dashboard_memory_setup_started)
else context.getString(R.string.dashboard_memory_saved)
},
onFailure = { actionMessage = it.message ?: context.getString(R.string.dashboard_memory_save_failed) },
)
actionInFlight = false
}
},
onDismiss = { memoryProviderEditor = null },
)
}
if (importArchiveInput) {
AlertDialog(
onDismissRequest = { importArchiveInput = false },
title = { Text(stringResource(R.string.dashboard_import_title)) },
text = { Text(stringResource(R.string.dashboard_import_warning)) },
confirmButton = {
Button(
onClick = {
importArchiveInput = false
backupImportLauncher.launch(arrayOf("application/zip", "application/octet-stream"))
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) { Text(stringResource(R.string.dashboard_import_confirm)) }
},
dismissButton = {
TextButton(onClick = { importArchiveInput = false }) { Text(stringResource(R.string.dashboard_cancel)) }
},
)
}
if (showWhatsAppSetup) {
WhatsAppSetupDialog(
onboarding = whatsappOnboarding,
busy = actionInFlight,
onStart = { mode, allowed ->
scope.launch {
actionInFlight = true
withDashboardClient(clientFactory) {
it.startWhatsAppOnboarding(mode, allowed, effectiveProfileName)
}.fold(
onSuccess = { root -> whatsappOnboarding = root.toWhatsAppOnboarding(mode, allowed) },
onFailure = { actionMessage = it.message ?: context.getString(R.string.dashboard_whatsapp_start_failed) },
)
actionInFlight = false
}
},
onApply = { state ->
scope.launch {
actionInFlight = true
withDashboardClient(clientFactory) {
it.applyWhatsAppOnboarding(state.pairingId, state.mode, state.allowedUsers, effectiveProfileName)
}.fold(
onSuccess = {
showWhatsAppSetup = false
whatsappOnboarding = null
forceReloadKey = payloadKey
reloadNonce += 1
actionMessage = context.getString(R.string.dashboard_whatsapp_saved)
},
onFailure = { actionMessage = it.message ?: context.getString(R.string.dashboard_whatsapp_apply_failed) },
)
actionInFlight = false
}
},
onDismiss = {
whatsappOnboarding?.pairingId?.let { pairingId ->
scope.launch { withDashboardClient(clientFactory) { it.cancelWhatsAppOnboarding(pairingId) } }
}
whatsappOnboarding = null
showWhatsAppSetup = false
},
)
}
LaunchedEffect(whatsappOnboarding?.pairingId, whatsappOnboarding?.status) {
val pairingId = whatsappOnboarding?.pairingId ?: return@LaunchedEffect
while (true) {
val current = whatsappOnboarding ?: break
if (current.pairingId != pairingId || current.status in setOf("connected", "error", "expired", "cancelled")) break
delay(1_500)
withDashboardClient(clientFactory) { it.getWhatsAppOnboarding(pairingId) }
.onSuccess { whatsappOnboarding = it.toWhatsAppOnboarding(current.mode, current.allowedUsers) }
.onFailure { whatsappOnboarding = current.copy(status = "error", error = it.message) }
}
}
}
private data class ManageTileSpec(
@@ -2296,6 +2591,9 @@ private fun LoadedBody(
val actionLabelUpdateInstalled = stringResource(R.string.dashboard_section_action_update_installed)
val actionLabelAddEndpoint = stringResource(R.string.dashboard_custom_endpoint_add)
val actionLabelServerBackup = stringResource(R.string.dashboard_section_action_server_backup)
val actionLabelDownloadBackup = stringResource(R.string.dashboard_section_action_download_backup)
val actionLabelImportBackup = stringResource(R.string.dashboard_section_action_import_backup)
val actionLabelSetupWhatsApp = stringResource(R.string.dashboard_action_setup_whatsapp)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
@@ -2325,6 +2623,11 @@ private fun LoadedBody(
)
DashboardManagementSection.Operations -> listOf(
DashboardSectionAction.CreateServerBackup to actionLabelServerBackup,
DashboardSectionAction.DownloadServerBackup to actionLabelDownloadBackup,
DashboardSectionAction.ImportServerBackup to actionLabelImportBackup,
)
DashboardManagementSection.Channels -> listOf(
DashboardSectionAction.SetupWhatsApp to actionLabelSetupWhatsApp,
)
else -> emptyList()
}
@@ -2420,6 +2723,14 @@ private fun dashboardActionLabel(kind: DashboardActionKind): String = when (kind
DashboardActionKind.ValidateCustomEndpoint -> stringResource(R.string.dashboard_action_validate)
DashboardActionKind.ActivateCustomEndpoint -> stringResource(R.string.dashboard_action_use)
DashboardActionKind.DeleteCustomEndpoint -> stringResource(R.string.dashboard_action_delete)
DashboardActionKind.EditLearningNode -> stringResource(R.string.dashboard_action_edit)
DashboardActionKind.DeleteLearningNode -> stringResource(R.string.dashboard_action_delete)
DashboardActionKind.ConfigureMemoryProvider -> stringResource(R.string.dashboard_action_configure)
DashboardActionKind.ActivateMemoryProvider -> stringResource(R.string.dashboard_action_use)
DashboardActionKind.SetupWhatsApp -> stringResource(R.string.dashboard_action_setup)
DashboardActionKind.EnableChannel -> stringResource(R.string.dashboard_action_enable)
DashboardActionKind.DisableChannel -> stringResource(R.string.dashboard_action_disable)
DashboardActionKind.TestChannel -> stringResource(R.string.dashboard_action_test)
}
/**
@@ -2454,6 +2765,14 @@ private fun dashboardActionLabel(context: android.content.Context, kind: Dashboa
DashboardActionKind.ValidateCustomEndpoint -> context.getString(R.string.dashboard_action_validate)
DashboardActionKind.ActivateCustomEndpoint -> context.getString(R.string.dashboard_action_use)
DashboardActionKind.DeleteCustomEndpoint -> context.getString(R.string.dashboard_action_delete)
DashboardActionKind.EditLearningNode -> context.getString(R.string.dashboard_action_edit)
DashboardActionKind.DeleteLearningNode -> context.getString(R.string.dashboard_action_delete)
DashboardActionKind.ConfigureMemoryProvider -> context.getString(R.string.dashboard_action_configure)
DashboardActionKind.ActivateMemoryProvider -> context.getString(R.string.dashboard_action_use)
DashboardActionKind.SetupWhatsApp -> context.getString(R.string.dashboard_action_setup)
DashboardActionKind.EnableChannel -> context.getString(R.string.dashboard_action_enable)
DashboardActionKind.DisableChannel -> context.getString(R.string.dashboard_action_disable)
DashboardActionKind.TestChannel -> context.getString(R.string.dashboard_action_test)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -3793,14 +4112,32 @@ private fun summarize(
?.map { (name, value) -> summarizeObjectItem(value, name) }
?: listOf(summarizeObjectItem(root, "Profile"))
}
DashboardManagementSection.Memory -> summarizeKeyValueOrList(root, "Memory")
DashboardManagementSection.Memory -> summarizeMemoryProviders(root)
DashboardManagementSection.Learning ->
root.arrayItems("nodes", "items")
?.mapIndexed { index, item -> summarizeObjectItem(item, "Node ${index + 1}") }
?.mapIndexed { index, item ->
val summary = summarizeObjectItem(item, "Node ${index + 1}")
summary.copy(
actions = listOf(
DashboardItemAction("Edit", DashboardActionKind.EditLearningNode),
DashboardItemAction("Delete", DashboardActionKind.DeleteLearningNode, destructive = true),
),
)
}
?: summarizeKeyValueOrList(root, "Learning")
DashboardManagementSection.Channels ->
root.arrayItems("platforms", "channels", "items")
?.mapIndexed { index, item -> summarizeObjectItem(item, "Channel ${index + 1}") }
?.mapIndexed { index, item ->
val summary = summarizeObjectItem(item, "Channel ${index + 1}")
val enabled = (item as? JsonObject)?.booleanField("enabled") == true
summary.copy(actions = buildList {
add(DashboardItemAction(if (enabled) "Disable" else "Enable", if (enabled) DashboardActionKind.DisableChannel else DashboardActionKind.EnableChannel))
add(DashboardItemAction("Test", DashboardActionKind.TestChannel))
if (summary.id.equals("whatsapp", ignoreCase = true)) {
add(DashboardItemAction("Setup", DashboardActionKind.SetupWhatsApp))
}
})
}
?: summarizeKeyValueOrList(root, "Channel")
DashboardManagementSection.Operations -> summarizeKeyValueOrList(root, "Status")
DashboardManagementSection.Models -> summarizeKeyValueOrList(root, "Model")
@@ -3809,6 +4146,32 @@ private fun summarize(
}
}
private fun summarizeMemoryProviders(root: JsonElement): List<DashboardSummaryItem> {
val obj = root as? JsonObject ?: return emptyList()
val active = obj.stringField("active").orEmpty()
return obj.arrayItems("providers").orEmpty().mapIndexed { index, provider ->
val summary = summarizeObjectItem(provider, "Provider ${index + 1}")
val providerObj = provider as? JsonObject
val available = providerObj?.booleanField("available") != false
val configured = providerObj?.booleanField("configured") == true ||
providerObj?.booleanField("ready") == true
summary.copy(
meta = listOfNotNull(
if (summary.id == active) "active" else null,
if (available) "available" else "setup required",
summary.meta,
).joinToString(" · "),
actions = buildList {
add(DashboardItemAction("Configure", DashboardActionKind.ConfigureMemoryProvider))
if (summary.id != active && configured) {
add(DashboardItemAction("Use", DashboardActionKind.ActivateMemoryProvider))
}
},
)
}
}
/**
* `GET /api/env` returns a map of var name → metadata. Upstream's SPA hides
* `channel_managed` vars because its Channels page owns them — we have no
@@ -4064,6 +4427,14 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
from = DashboardActionKind.DisableMcp,
to = DashboardItemAction("Enable", DashboardActionKind.EnableMcp),
)
DashboardActionKind.EnableChannel -> withEnabledMeta(true).withActionSwap(
from = DashboardActionKind.EnableChannel,
to = DashboardItemAction("Disable", DashboardActionKind.DisableChannel),
)
DashboardActionKind.DisableChannel -> withEnabledMeta(false).withActionSwap(
from = DashboardActionKind.DisableChannel,
to = DashboardItemAction("Enable", DashboardActionKind.EnableChannel),
)
DashboardActionKind.PauseCron -> withActionSwap(
from = DashboardActionKind.PauseCron,
to = DashboardItemAction("Resume", DashboardActionKind.ResumeCron),
@@ -4075,7 +4446,8 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
DashboardActionKind.DeleteCron,
DashboardActionKind.RemoveMcp,
DashboardActionKind.DeleteProfile,
DashboardActionKind.DeleteCustomEndpoint -> null
DashboardActionKind.DeleteCustomEndpoint,
DashboardActionKind.DeleteLearningNode -> null
DashboardActionKind.InstallMcpCatalog -> copy(
meta = appendMeta(meta, "installed"),
actions = emptyList(),
@@ -4094,7 +4466,12 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
DashboardActionKind.RevealEnvKey,
DashboardActionKind.EditProfileDescription,
DashboardActionKind.SetProfileModel,
DashboardActionKind.EditProfileSoul -> this
DashboardActionKind.EditProfileSoul,
DashboardActionKind.EditLearningNode,
DashboardActionKind.ConfigureMemoryProvider,
DashboardActionKind.ActivateMemoryProvider,
DashboardActionKind.SetupWhatsApp,
DashboardActionKind.TestChannel -> this
DashboardActionKind.EditCustomEndpoint,
DashboardActionKind.ValidateCustomEndpoint,
DashboardActionKind.ActivateCustomEndpoint -> this
@@ -4226,6 +4603,11 @@ private suspend fun DashboardApiClient.runDashboardAction(
deleteCustomEndpoint(id, profile = item.profile).map { JsonObject(emptyMap()) }
DashboardActionKind.RevealEnvKey -> revealEnvVar(id)
DashboardActionKind.ClearEnvKey -> deleteEnvVar(id)
DashboardActionKind.DeleteLearningNode -> deleteLearningNode(id, item.profile)
DashboardActionKind.ActivateMemoryProvider -> activateMemoryProvider(id, item.profile)
DashboardActionKind.EnableChannel -> setMessagingPlatformEnabled(id, true, item.profile)
DashboardActionKind.DisableChannel -> setMessagingPlatformEnabled(id, false, item.profile)
DashboardActionKind.TestChannel -> testMessagingPlatform(id, item.profile)
// Input-backed kinds are intercepted at the onAction layer and routed
// to dialogs; reaching here means a wiring bug, not a server problem.
DashboardActionKind.SetEnvKey,
@@ -4233,6 +4615,10 @@ private suspend fun DashboardApiClient.runDashboardAction(
DashboardActionKind.SetProfileModel,
DashboardActionKind.EditProfileSoul ->
Result.failure(IllegalStateException("${action.label} requires input"))
DashboardActionKind.EditLearningNode,
DashboardActionKind.ConfigureMemoryProvider,
DashboardActionKind.SetupWhatsApp ->
Result.failure(IllegalStateException("${action.label} requires input"))
DashboardActionKind.EditCustomEndpoint,
DashboardActionKind.ValidateCustomEndpoint ->
Result.failure(IllegalStateException("${action.label} requires input"))
@@ -4294,3 +4680,192 @@ private fun compactJsonLines(root: JsonObject): String =
root.entries.joinToString("\n") { (key, value) ->
"$key: ${value.shortDisplay()}"
}.ifBlank { "{ }" }
private fun JsonObject.toWhatsAppOnboarding(mode: String, allowedUsers: String): WhatsAppOnboardingState =
WhatsAppOnboardingState(
pairingId = stringField("pairing_id").orEmpty(),
status = stringField("status").orEmpty(),
qrPayload = stringField("qr_payload"),
mode = stringField("mode") ?: mode,
allowedUsers = stringField("allowed_users") ?: allowedUsers,
error = stringField("error"),
)
private fun memoryInitialValues(schema: JsonObject): String {
val direct = schema["values"] as? JsonObject
if (direct != null) return Json { prettyPrint = true }.encodeToString(JsonObject.serializer(), direct)
val fields = schema["fields"] as? JsonArray ?: return "{}"
val values = buildJsonObject {
fields.forEach { element ->
val field = element as? JsonObject ?: return@forEach
val key = field.stringField("key") ?: return@forEach
field["value"]?.let { put(key, it) }
}
}
return Json { prettyPrint = true }.encodeToString(JsonObject.serializer(), values)
}
@Composable
private fun TextDocumentEditorDialog(
title: String,
initialContent: String,
warning: String,
saving: Boolean,
onSave: (String) -> Unit,
onDismiss: () -> Unit,
) {
var content by remember(initialContent) { mutableStateOf(initialContent) }
Dialog(onDismissRequest = { if (!saving) onDismiss() }) {
Card(modifier = Modifier.fillMaxWidth().heightIn(min = 360.dp, max = 680.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(warning, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
value = content,
onValueChange = { content = it },
modifier = Modifier.fillMaxWidth().weight(1f),
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
enabled = !saving,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(R.string.dashboard_cancel)) }
Button(onClick = { onSave(content) }, enabled = !saving && content.isNotBlank()) {
Text(stringResource(R.string.dashboard_save))
}
}
}
}
}
}
@Composable
private fun MemoryProviderDialog(
editor: MemoryProviderEditorState,
saving: Boolean,
onSubmit: (String, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var values by remember(editor) { mutableStateOf(memoryInitialValues(editor.schema)) }
val fields = editor.schema["fields"] as? JsonArray
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(stringResource(R.string.dashboard_memory_config_title, editor.name)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.dashboard_memory_config_help), style = MaterialTheme.typography.bodySmall)
if (!fields.isNullOrEmpty()) {
Text(fields.joinToString("\n") { field ->
val obj = field as? JsonObject
val key = obj?.stringField("key").orEmpty()
val label = obj?.stringField("label") ?: key
val required = if (obj?.booleanField("required") == true) " *" else ""
"$label$required"
}, style = MaterialTheme.typography.labelSmall)
}
OutlinedTextField(
value = values,
onValueChange = { values = it },
modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp),
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
enabled = !saving,
label = { Text(stringResource(R.string.dashboard_memory_values_json)) },
)
}
},
confirmButton = {
Button(onClick = { onSubmit(values, false) }, enabled = !saving) { Text(stringResource(R.string.dashboard_save)) }
},
dismissButton = {
Row {
TextButton(onClick = { onSubmit(values, true) }, enabled = !saving) { Text(stringResource(R.string.dashboard_memory_run_setup)) }
TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(R.string.dashboard_cancel)) }
}
},
)
}
private fun qrBitmap(payload: String): Bitmap {
val matrix = QRCodeWriter().encode(payload, BarcodeFormat.QR_CODE, 640, 640)
val pixels = IntArray(matrix.width * matrix.height)
for (y in 0 until matrix.height) for (x in 0 until matrix.width) {
pixels[y * matrix.width + x] = if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
}
return Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
}
private data class DashboardImportMetadata(val name: String, val length: Long?)
private fun dashboardImportMetadata(resolver: ContentResolver, uri: Uri): DashboardImportMetadata {
var name: String? = null
var length: Long? = null
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex)
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) length = cursor.getLong(sizeIndex).takeIf { it >= 0 }
}
}
return DashboardImportMetadata(
name = name?.takeIf { it.isNotBlank() }
?: uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
?: "hermes-backup.zip",
length = length,
)
}
@Composable
private fun WhatsAppSetupDialog(
onboarding: WhatsAppOnboardingState?,
busy: Boolean,
onStart: (String, String) -> Unit,
onApply: (WhatsAppOnboardingState) -> Unit,
onDismiss: () -> Unit,
) {
var mode by remember { mutableStateOf("bot") }
var allowedUsers by remember { mutableStateOf("") }
val qr = remember(onboarding?.qrPayload) {
onboarding?.qrPayload?.let { runCatching { qrBitmap(it) }.getOrNull() }
}
AlertDialog(
onDismissRequest = { if (!busy) onDismiss() },
title = { Text(stringResource(R.string.dashboard_whatsapp_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (onboarding == null) {
Text(stringResource(R.string.dashboard_whatsapp_help), style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { mode = "bot" }) { Text(stringResource(R.string.dashboard_whatsapp_bot)) }
OutlinedButton(onClick = { mode = "self-chat" }) { Text(stringResource(R.string.dashboard_whatsapp_self_chat)) }
}
OutlinedTextField(
value = allowedUsers,
onValueChange = { allowedUsers = it },
label = { Text(stringResource(R.string.dashboard_whatsapp_allowed_users)) },
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(onboarding.status.replace('_', ' ').uppercase(), style = MaterialTheme.typography.labelMedium)
qr?.let { Image(it.asImageBitmap(), contentDescription = stringResource(R.string.dashboard_whatsapp_qr), modifier = Modifier.size(280.dp)) }
onboarding.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (onboarding.status !in setOf("connected", "error", "expired")) {
Text(stringResource(R.string.dashboard_whatsapp_scan), style = MaterialTheme.typography.bodySmall)
}
}
}
},
confirmButton = {
if (onboarding == null) {
Button(onClick = { onStart(mode, allowedUsers) }, enabled = !busy) { Text(stringResource(R.string.dashboard_whatsapp_start)) }
} else if (onboarding.status == "connected") {
Button(onClick = { onApply(onboarding) }, enabled = !busy) { Text(stringResource(R.string.dashboard_whatsapp_apply)) }
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.dashboard_cancel)) } },
)
}
@@ -4235,6 +4235,7 @@ class ChatViewModel : ViewModel() {
HermesCardInput.Kinds.CHOICE
},
choices = ask.choices.orEmpty(),
multiSelect = ask.multiSelect,
allowFreeText = true,
expiresAtMillis = expiresAt,
),
@@ -5041,6 +5042,7 @@ class ChatViewModel : ViewModel() {
requestId = ask.ask.requestId,
text = ask.ask.text,
choices = ask.ask.choices,
multiSelect = ask.ask.multiSelect,
smartDenied = ask.ask.smartDenied,
envVar = ask.ask.envVar,
timeoutSeconds = ask.ask.timeoutSeconds,
@@ -5225,6 +5227,7 @@ class ChatViewModel : ViewModel() {
requestId = saved.requestId,
text = saved.text,
choices = saved.choices,
multiSelect = saved.multiSelect,
smartDenied = saved.smartDenied,
envVar = saved.envVar,
timeoutSeconds = saved.timeoutSeconds,
@@ -3834,6 +3834,16 @@
<string name="dashboard_tab_channels_lower">canais</string>
<string name="dashboard_tab_operations_lower">operações</string>
<string name="dashboard_section_action_server_backup">Criar backup do servidor</string>
<string name="dashboard_section_action_download_backup">Salvar backup mais recente</string>
<string name="dashboard_section_action_import_backup">Importar backup</string>
<string name="dashboard_backup_create_first">Crie um backup nesta sessão antes de baixá-lo.</string>
<string name="dashboard_backup_download_failed">Não foi possível baixar o backup.</string>
<string name="dashboard_backup_saved">Backup salvo como %1$s.</string>
<string name="dashboard_import_title">Importar backup do servidor?</string>
<string name="dashboard_import_warning">Isso envia o ZIP selecionado ao Hermes e inicia a importação autenticada no servidor. A configuração e os dados podem ser substituídos. Primeiro, crie e salve um backup atual.</string>
<string name="dashboard_import_confirm">Importar</string>
<string name="dashboard_import_started">Importação do servidor iniciada. Acompanhe a conclusão em Operações antes de reiniciar o Hermes.</string>
<string name="dashboard_import_failed">Não foi possível iniciar a importação do servidor.</string>
<string name="dashboard_profile_mcp_servers_optional">Servidores MCP (opcional)</string>
<string name="dashboard_profile_mcp_servers_help">Nomes de servidor separados por vírgulas ou linhas. As credenciais permanecem no servidor.</string>
<string name="dashboard_tile_memory_title">Memória</string>
@@ -3844,6 +3854,34 @@
<string name="dashboard_tile_channels_sub">Status das plataformas de mensagens, incluindo WhatsApp</string>
<string name="dashboard_tile_operations_title">Operações do servidor</string>
<string name="dashboard_tile_operations_sub">Integridade do host e backup geral do servidor</string>
<string name="dashboard_action_configure">Configurar</string>
<string name="dashboard_action_setup">Configurar</string>
<string name="dashboard_action_setup_whatsapp">Configurar o WhatsApp</string>
<string name="dashboard_learning_edit_title">Editar %1$s</string>
<string name="dashboard_learning_edit_warning">As edições substituem todo o conteúdo do nó. As habilidades excluídas do grafo são arquivadas pelo Hermes e podem ser restauradas; a exclusão de nós de memória é permanente, então exporte um backup primeiro.</string>
<string name="dashboard_learning_saved">Nó de aprendizado salvo.</string>
<string name="dashboard_learning_save_failed">Não foi possível salvar o nó de aprendizado.</string>
<string name="dashboard_learning_delete_warning">O Hermes arquiva as habilidades aprendidas para que possam ser restauradas pelo servidor. A exclusão de nós de memória é permanente. Se este conteúdo puder ser necessário depois, salve primeiro um backup do servidor.</string>
<string name="dashboard_memory_config_title">Configurar %1$s</string>
<string name="dashboard_memory_config_help">Os valores permanecem no perfil do Hermes selecionado. Os campos secretos são aceitos pelo servidor e nunca retornados após o salvamento. Os campos obrigatórios são marcados com *.</string>
<string name="dashboard_memory_values_json">Valores do provedor (JSON)</string>
<string name="dashboard_memory_run_setup">Executar configuração</string>
<string name="dashboard_memory_invalid_json">Os valores do provedor devem formar um objeto JSON.</string>
<string name="dashboard_memory_setup_started">A instalação do provedor em todo o host foi iniciada. Atualize após a conclusão e salve os valores deste perfil.</string>
<string name="dashboard_memory_saved">Provedor de memória configurado e ativado.</string>
<string name="dashboard_memory_save_failed">Não foi possível salvar a configuração do provedor de memória.</string>
<string name="dashboard_whatsapp_title">Configurar o WhatsApp</string>
<string name="dashboard_whatsapp_help">O Hermes inicia uma sessão de pareamento de dez minutos limitada ao perfil. Escolha o modo bot para uma conta dedicada ou conversa própria para enviar mensagens à sua conta vinculada.</string>
<string name="dashboard_whatsapp_bot">Conta de bot</string>
<string name="dashboard_whatsapp_self_chat">Conversa própria</string>
<string name="dashboard_whatsapp_allowed_users">Números de telefone permitidos (opcional)</string>
<string name="dashboard_whatsapp_start">Iniciar pareamento</string>
<string name="dashboard_whatsapp_scan">Escaneie este código na tela Dispositivos conectados do WhatsApp. Mantenha esta caixa de diálogo aberta enquanto o Hermes confirma a conta.</string>
<string name="dashboard_whatsapp_qr">Código QR de dispositivo conectado do WhatsApp</string>
<string name="dashboard_whatsapp_apply">Ativar o WhatsApp</string>
<string name="dashboard_whatsapp_start_failed">Não foi possível iniciar o pareamento do WhatsApp.</string>
<string name="dashboard_whatsapp_apply_failed">Não foi possível salvar a configuração do WhatsApp.</string>
<string name="dashboard_whatsapp_saved">WhatsApp ativado; o Hermes iniciou a reinicialização do gateway.</string>
<string name="drawer_all_profiles">Todos os perfis</string>
<string name="drawer_no_profile_sessions">Nenhuma sessão de perfil correspondente.</string>
<string name="drawer_close">Fechar</string>
@@ -3932,6 +3932,44 @@
<string name="dashboard_tile_channels_sub">消息平台状态,包括 WhatsApp</string>
<string name="dashboard_tile_operations_title">服务器运维</string>
<string name="dashboard_tile_operations_sub">主机健康状况和服务器整体备份</string>
<string name="dashboard_section_action_download_backup">保存最新备份</string>
<string name="dashboard_section_action_import_backup">导入备份</string>
<string name="dashboard_backup_create_first">请先在本次会话中创建备份,再下载。</string>
<string name="dashboard_backup_download_failed">无法下载备份。</string>
<string name="dashboard_backup_saved">备份已保存为 %1$s。</string>
<string name="dashboard_import_title">导入服务器备份?</string>
<string name="dashboard_import_warning">这会将所选 ZIP 上传到 Hermes,并启动经过身份验证的服务器导入。导入可能替换配置和数据。请先创建并保存当前备份。</string>
<string name="dashboard_import_confirm">导入</string>
<string name="dashboard_import_started">服务器导入已启动。请在“运维”中等待完成,然后再重启 Hermes。</string>
<string name="dashboard_import_failed">无法启动服务器导入。</string>
<string name="dashboard_action_configure">配置</string>
<string name="dashboard_action_setup">设置</string>
<string name="dashboard_action_setup_whatsapp">设置 WhatsApp</string>
<string name="dashboard_learning_edit_title">编辑 %1$s</string>
<string name="dashboard_learning_edit_warning">编辑会替换节点的全部内容。Hermes 会归档从图谱中删除的技能,以便从归档中恢复;删除记忆节点则无法撤销,因此请先导出备份。</string>
<string name="dashboard_learning_saved">学习节点已保存。</string>
<string name="dashboard_learning_save_failed">无法保存学习节点。</string>
<string name="dashboard_learning_delete_warning">Hermes 会归档已学习技能,以便从服务器归档中恢复。删除记忆节点则无法撤销。如果以后可能需要这些内容,请先保存服务器备份。</string>
<string name="dashboard_memory_config_title">配置 %1$s</string>
<string name="dashboard_memory_config_help">值会保留在所选 Hermes 配置文件中。服务器接受机密字段,但保存后绝不会返回这些字段。必填字段以 * 标记。</string>
<string name="dashboard_memory_values_json">提供商值 (JSON)</string>
<string name="dashboard_memory_run_setup">运行设置</string>
<string name="dashboard_memory_invalid_json">提供商值必须是 JSON 对象。</string>
<string name="dashboard_memory_setup_started">全主机范围的提供商安装已启动。完成后请刷新,然后保存此配置文件的值。</string>
<string name="dashboard_memory_saved">记忆提供商已配置并启用。</string>
<string name="dashboard_memory_save_failed">无法保存记忆提供商配置。</string>
<string name="dashboard_whatsapp_title">设置 WhatsApp</string>
<string name="dashboard_whatsapp_help">Hermes 会启动一个时长十分钟、仅限当前配置文件的配对会话。专用账号请选择机器人模式;要向自己已关联的账号发消息,请选择自聊。</string>
<string name="dashboard_whatsapp_bot">机器人账号</string>
<string name="dashboard_whatsapp_self_chat">自聊</string>
<string name="dashboard_whatsapp_allowed_users">允许的电话号码(可选)</string>
<string name="dashboard_whatsapp_start">开始配对</string>
<string name="dashboard_whatsapp_scan">请在 WhatsApp 的“关联设备”页面扫描此代码。Hermes 确认账号期间,请保持此对话框打开。</string>
<string name="dashboard_whatsapp_qr">WhatsApp 关联设备二维码</string>
<string name="dashboard_whatsapp_apply">启用 WhatsApp</string>
<string name="dashboard_whatsapp_start_failed">无法开始 WhatsApp 配对。</string>
<string name="dashboard_whatsapp_apply_failed">无法保存 WhatsApp 配置。</string>
<string name="dashboard_whatsapp_saved">WhatsApp 已启用;Hermes 已开始重启网关。</string>
<string name="drawer_all_profiles">所有配置文件</string>
<string name="drawer_no_profile_sessions">没有匹配的配置文件会话。</string>
<string name="drawer_close">关闭</string>
+38
View File
@@ -4004,6 +4004,44 @@
<string name="dashboard_tile_channels_sub">Status der Nachrichtenplattformen einschließlich WhatsApp</string>
<string name="dashboard_tile_operations_title">Serverbetrieb</string>
<string name="dashboard_tile_operations_sub">Hostzustand und serverweite Sicherung</string>
<string name="dashboard_section_action_download_backup">Neueste Sicherung speichern</string>
<string name="dashboard_section_action_import_backup">Sicherung importieren</string>
<string name="dashboard_backup_create_first">Erstellen Sie in dieser Sitzung eine Sicherung, bevor Sie sie herunterladen.</string>
<string name="dashboard_backup_download_failed">Die Sicherung konnte nicht heruntergeladen werden.</string>
<string name="dashboard_backup_saved">Sicherung als %1$s gespeichert.</string>
<string name="dashboard_import_title">Server-Sicherung importieren?</string>
<string name="dashboard_import_warning">Dies lädt die ausgewählte ZIP-Datei zu Hermes hoch und startet den authentifizierten Serverimport. Dabei können Konfiguration und Daten ersetzt werden. Erstellen und speichern Sie zuerst eine aktuelle Sicherung.</string>
<string name="dashboard_import_confirm">Importieren</string>
<string name="dashboard_import_started">Serverimport gestartet. Warten Sie unter „Betrieb“ auf den Abschluss, bevor Sie Hermes neu starten.</string>
<string name="dashboard_import_failed">Der Serverimport konnte nicht gestartet werden.</string>
<string name="dashboard_action_configure">Konfigurieren</string>
<string name="dashboard_action_setup">Einrichten</string>
<string name="dashboard_action_setup_whatsapp">WhatsApp einrichten</string>
<string name="dashboard_learning_edit_title">%1$s bearbeiten</string>
<string name="dashboard_learning_edit_warning">Beim Bearbeiten wird der gesamte Inhalt des Knotens ersetzt. Aus dem Graphen gelöschte Skills werden von Hermes archiviert und können aus dem Archiv wiederhergestellt werden. Das Löschen von Speicherknoten ist endgültig; exportieren Sie daher zuerst eine Sicherung.</string>
<string name="dashboard_learning_saved">Lernknoten gespeichert.</string>
<string name="dashboard_learning_save_failed">Der Lernknoten konnte nicht gespeichert werden.</string>
<string name="dashboard_learning_delete_warning">Hermes archiviert erlernte Skills, sodass sie aus dem Serverarchiv wiederhergestellt werden können. Das Löschen von Speicherknoten ist endgültig. Speichern Sie zuerst eine Server-Sicherung, falls dieser Inhalt später noch benötigt wird.</string>
<string name="dashboard_memory_config_title">%1$s konfigurieren</string>
<string name="dashboard_memory_config_help">Die Werte verbleiben im ausgewählten Hermes-Profil. Vertrauliche Felder werden vom Server akzeptiert und nach dem Speichern nie zurückgegeben. Pflichtfelder sind mit * markiert.</string>
<string name="dashboard_memory_values_json">Anbieterwerte (JSON)</string>
<string name="dashboard_memory_run_setup">Einrichtung starten</string>
<string name="dashboard_memory_invalid_json">Die Anbieterwerte müssen ein JSON-Objekt sein.</string>
<string name="dashboard_memory_setup_started">Die hostweite Anbieterinstallation wurde gestartet. Aktualisieren Sie nach Abschluss die Ansicht und speichern Sie dann die Werte dieses Profils.</string>
<string name="dashboard_memory_saved">Speicheranbieter konfiguriert und aktiviert.</string>
<string name="dashboard_memory_save_failed">Die Konfiguration des Speicheranbieters konnte nicht gespeichert werden.</string>
<string name="dashboard_whatsapp_title">WhatsApp einrichten</string>
<string name="dashboard_whatsapp_help">Hermes startet eine zehnminütige, profilbezogene Kopplungssitzung. Wählen Sie den Bot-Modus für ein eigenes Konto oder den Selbstchat, um Nachrichten an Ihr eigenes verknüpftes Konto zu senden.</string>
<string name="dashboard_whatsapp_bot">Bot-Konto</string>
<string name="dashboard_whatsapp_self_chat">Selbstchat</string>
<string name="dashboard_whatsapp_allowed_users">Zulässige Telefonnummern (optional)</string>
<string name="dashboard_whatsapp_start">Kopplung starten</string>
<string name="dashboard_whatsapp_scan">Scannen Sie diesen Code unter „Verknüpfte Geräte“ in WhatsApp. Lassen Sie diesen Dialog geöffnet, während Hermes das Konto bestätigt.</string>
<string name="dashboard_whatsapp_qr">QR-Code für ein verknüpftes WhatsApp-Gerät</string>
<string name="dashboard_whatsapp_apply">WhatsApp aktivieren</string>
<string name="dashboard_whatsapp_start_failed">Die WhatsApp-Kopplung konnte nicht gestartet werden.</string>
<string name="dashboard_whatsapp_apply_failed">Die WhatsApp-Konfiguration konnte nicht gespeichert werden.</string>
<string name="dashboard_whatsapp_saved">WhatsApp aktiviert; Hermes hat einen Gateway-Neustart gestartet.</string>
<string name="drawer_all_profiles">Alle Profile</string>
<string name="drawer_no_profile_sessions">Keine passenden Profilsitzungen.</string>
<string name="drawer_close">Schließen</string>
+38
View File
@@ -3689,6 +3689,44 @@
<string name="dashboard_tile_channels_sub">Estado de plataformas de mensajería, incluido WhatsApp</string>
<string name="dashboard_tile_operations_title">Operaciones del servidor</string>
<string name="dashboard_tile_operations_sub">Estado del host y copia de seguridad del servidor</string>
<string name="dashboard_section_action_download_backup">Guardar la copia más reciente</string>
<string name="dashboard_section_action_import_backup">Importar copia de seguridad</string>
<string name="dashboard_backup_create_first">Crea una copia de seguridad en esta sesión antes de descargarla.</string>
<string name="dashboard_backup_download_failed">No se pudo descargar la copia de seguridad.</string>
<string name="dashboard_backup_saved">Copia de seguridad guardada como %1$s.</string>
<string name="dashboard_import_title">¿Importar copia de seguridad del servidor?</string>
<string name="dashboard_import_warning">Esto sube el ZIP seleccionado a Hermes e inicia la importación autenticada del servidor. Puede reemplazar la configuración y los datos. Crea y guarda primero una copia de seguridad actual.</string>
<string name="dashboard_import_confirm">Importar</string>
<string name="dashboard_import_started">Se inició la importación del servidor. Espera a que termine en Operaciones antes de reiniciar Hermes.</string>
<string name="dashboard_import_failed">No se pudo iniciar la importación del servidor.</string>
<string name="dashboard_action_configure">Configurar</string>
<string name="dashboard_action_setup">Configurar</string>
<string name="dashboard_action_setup_whatsapp">Configurar WhatsApp</string>
<string name="dashboard_learning_edit_title">Editar %1$s</string>
<string name="dashboard_learning_edit_warning">Al editar se reemplaza todo el contenido del nodo. Hermes archiva las habilidades eliminadas del grafo y se pueden restaurar desde el archivo; eliminar un nodo de memoria es permanente, así que exporta primero una copia de seguridad.</string>
<string name="dashboard_learning_saved">Nodo de aprendizaje guardado.</string>
<string name="dashboard_learning_save_failed">No se pudo guardar el nodo de aprendizaje.</string>
<string name="dashboard_learning_delete_warning">Hermes archiva las habilidades aprendidas para poder restaurarlas desde el archivo del servidor. Eliminar un nodo de memoria es permanente. Guarda primero una copia de seguridad del servidor si podrías necesitar este contenido más adelante.</string>
<string name="dashboard_memory_config_title">Configurar %1$s</string>
<string name="dashboard_memory_config_help">Los valores permanecen en el perfil de Hermes seleccionado. El servidor acepta los campos secretos y nunca los devuelve después de guardarlos. Los campos obligatorios están marcados con *.</string>
<string name="dashboard_memory_values_json">Valores del proveedor (JSON)</string>
<string name="dashboard_memory_run_setup">Ejecutar configuración</string>
<string name="dashboard_memory_invalid_json">Los valores del proveedor deben ser un objeto JSON.</string>
<string name="dashboard_memory_setup_started">Se inició la instalación del proveedor en todo el host. Actualiza la vista cuando termine y guarda los valores de este perfil.</string>
<string name="dashboard_memory_saved">Proveedor de memoria configurado y activado.</string>
<string name="dashboard_memory_save_failed">No se pudo guardar la configuración del proveedor de memoria.</string>
<string name="dashboard_whatsapp_title">Configurar WhatsApp</string>
<string name="dashboard_whatsapp_help">Hermes inicia una sesión de vinculación de diez minutos para este perfil. Elige el modo bot para una cuenta exclusiva o el chat contigo mismo para enviar mensajes a tu propia cuenta vinculada.</string>
<string name="dashboard_whatsapp_bot">Cuenta de bot</string>
<string name="dashboard_whatsapp_self_chat">Chat contigo mismo</string>
<string name="dashboard_whatsapp_allowed_users">Números de teléfono permitidos (opcional)</string>
<string name="dashboard_whatsapp_start">Iniciar vinculación</string>
<string name="dashboard_whatsapp_scan">Escanea este código desde la pantalla Dispositivos vinculados de WhatsApp. Mantén abierto este cuadro mientras Hermes confirma la cuenta.</string>
<string name="dashboard_whatsapp_qr">Código QR de dispositivo vinculado de WhatsApp</string>
<string name="dashboard_whatsapp_apply">Activar WhatsApp</string>
<string name="dashboard_whatsapp_start_failed">No se pudo iniciar la vinculación de WhatsApp.</string>
<string name="dashboard_whatsapp_apply_failed">No se pudo guardar la configuración de WhatsApp.</string>
<string name="dashboard_whatsapp_saved">WhatsApp activado; Hermes inició un reinicio del gateway.</string>
<string name="drawer_all_profiles">Todos los perfiles</string>
<string name="drawer_no_profile_sessions">No hay sesiones de perfil coincidentes.</string>
<string name="drawer_close">Cerrar</string>
+38
View File
@@ -3993,6 +3993,16 @@
<string name="dashboard_tab_channels_lower">チャンネル</string>
<string name="dashboard_tab_operations_lower">運用</string>
<string name="dashboard_section_action_server_backup">サーバーバックアップを作成</string>
<string name="dashboard_section_action_download_backup">最新のバックアップを保存</string>
<string name="dashboard_section_action_import_backup">バックアップをインポート</string>
<string name="dashboard_backup_create_first">ダウンロードする前に、このセッションでバックアップを作成してください。</string>
<string name="dashboard_backup_download_failed">バックアップをダウンロードできませんでした。</string>
<string name="dashboard_backup_saved">バックアップを %1$s として保存しました。</string>
<string name="dashboard_import_title">サーバーバックアップをインポートしますか?</string>
<string name="dashboard_import_warning">選択した ZIP を Hermes にアップロードし、認証済みのサーバーインポートを開始します。設定やデータが置き換わる可能性があります。先に現在のバックアップを作成して保存してください。</string>
<string name="dashboard_import_confirm">インポート</string>
<string name="dashboard_import_started">サーバーのインポートを開始しました。Hermes を再起動する前に、運用画面で完了を確認してください。</string>
<string name="dashboard_import_failed">サーバーのインポートを開始できませんでした。</string>
<string name="dashboard_profile_mcp_servers_optional">MCP サーバー(任意)</string>
<string name="dashboard_profile_mcp_servers_help">サーバー名をカンマまたは改行で区切ります。認証情報はサーバー側に保持されます。</string>
<string name="dashboard_tile_memory_title">メモリ</string>
@@ -4003,6 +4013,34 @@
<string name="dashboard_tile_channels_sub">WhatsApp を含むメッセージプラットフォームの状態</string>
<string name="dashboard_tile_operations_title">サーバー運用</string>
<string name="dashboard_tile_operations_sub">ホストの状態とサーバー全体のバックアップ</string>
<string name="dashboard_action_configure">設定</string>
<string name="dashboard_action_setup">セットアップ</string>
<string name="dashboard_action_setup_whatsapp">WhatsApp を設定</string>
<string name="dashboard_learning_edit_title">%1$sを編集</string>
<string name="dashboard_learning_edit_warning">編集するとノードの内容全体が置き換わります。グラフから削除したスキルは Hermes によってアーカイブされ、復元できます。メモリノードの削除は元に戻せないため、先にバックアップをエクスポートしてください。</string>
<string name="dashboard_learning_saved">学習ノードを保存しました。</string>
<string name="dashboard_learning_save_failed">学習ノードを保存できませんでした。</string>
<string name="dashboard_learning_delete_warning">Hermes は学習済みスキルをアーカイブするため、サーバーのアーカイブから復元できます。メモリノードの削除は元に戻せません。この内容が後で必要になる可能性がある場合は、先にサーバーバックアップを保存してください。</string>
<string name="dashboard_memory_config_title">%1$sを設定</string>
<string name="dashboard_memory_config_help">値は選択した Hermes プロファイルに保持されます。シークレット項目はサーバーに保存されますが、保存後に返されることはありません。必須項目には * が付いています。</string>
<string name="dashboard_memory_values_json">プロバイダーの値(JSON)</string>
<string name="dashboard_memory_run_setup">セットアップを実行</string>
<string name="dashboard_memory_invalid_json">プロバイダーの値は JSON オブジェクトである必要があります。</string>
<string name="dashboard_memory_setup_started">ホスト全体へのプロバイダーのインストールを開始しました。完了後に更新し、このプロファイルの値を保存してください。</string>
<string name="dashboard_memory_saved">メモリプロバイダーを設定して有効にしました。</string>
<string name="dashboard_memory_save_failed">メモリプロバイダーの設定を保存できませんでした。</string>
<string name="dashboard_whatsapp_title">WhatsApp を設定</string>
<string name="dashboard_whatsapp_help">Hermes はプロファイル単位のペアリングセッションを10分間開始します。専用アカウントにはボットモード、自分のリンク済みアカウントへのメッセージにはセルフチャットを選択してください。</string>
<string name="dashboard_whatsapp_bot">ボットアカウント</string>
<string name="dashboard_whatsapp_self_chat">セルフチャット</string>
<string name="dashboard_whatsapp_allowed_users">許可する電話番号(任意)</string>
<string name="dashboard_whatsapp_start">ペアリングを開始</string>
<string name="dashboard_whatsapp_scan">WhatsApp の「リンク済みデバイス」画面でこのコードをスキャンしてください。Hermes がアカウントを確認するまで、このダイアログを開いたままにしてください。</string>
<string name="dashboard_whatsapp_qr">WhatsApp リンク済みデバイスの QR コード</string>
<string name="dashboard_whatsapp_apply">WhatsApp を有効化</string>
<string name="dashboard_whatsapp_start_failed">WhatsApp のペアリングを開始できませんでした。</string>
<string name="dashboard_whatsapp_apply_failed">WhatsApp の設定を保存できませんでした。</string>
<string name="dashboard_whatsapp_saved">WhatsApp を有効にしました。Hermes がゲートウェイの再起動を開始しました。</string>
<string name="drawer_all_profiles">すべてのプロファイル</string>
<string name="drawer_no_profile_sessions">一致するプロファイルセッションはありません。</string>
<string name="drawer_close">閉じる</string>
+38
View File
@@ -3725,6 +3725,44 @@
<string name="dashboard_tile_channels_sub">Состояние платформ сообщений, включая WhatsApp</string>
<string name="dashboard_tile_operations_title">Операции сервера</string>
<string name="dashboard_tile_operations_sub">Состояние хоста и резервная копия всего сервера</string>
<string name="dashboard_section_action_download_backup">Сохранить последнюю резервную копию</string>
<string name="dashboard_section_action_import_backup">Импортировать резервную копию</string>
<string name="dashboard_backup_create_first">Перед скачиванием создайте резервную копию в этом сеансе.</string>
<string name="dashboard_backup_download_failed">Не удалось скачать резервную копию.</string>
<string name="dashboard_backup_saved">Резервная копия сохранена как %1$s.</string>
<string name="dashboard_import_title">Импортировать резервную копию сервера?</string>
<string name="dashboard_import_warning">Выбранный ZIP-файл будет загружен в Hermes, после чего начнётся аутентифицированный импорт на сервере. Он может заменить конфигурацию и данные. Сначала создайте и сохраните актуальную резервную копию.</string>
<string name="dashboard_import_confirm">Импортировать</string>
<string name="dashboard_import_started">Импорт на сервере запущен. Перед перезапуском Hermes дождитесь его завершения в разделе «Операции».</string>
<string name="dashboard_import_failed">Не удалось запустить импорт на сервере.</string>
<string name="dashboard_action_configure">Настроить</string>
<string name="dashboard_action_setup">Настройка</string>
<string name="dashboard_action_setup_whatsapp">Настроить WhatsApp</string>
<string name="dashboard_learning_edit_title">Изменить %1$s</string>
<string name="dashboard_learning_edit_warning">Изменения заменят всё содержимое узла. Hermes архивирует навыки, удалённые из графа, чтобы их можно было восстановить из архива; удаление узла памяти необратимо, поэтому сначала экспортируйте резервную копию.</string>
<string name="dashboard_learning_saved">Изученный узел сохранён.</string>
<string name="dashboard_learning_save_failed">Не удалось сохранить изученный узел.</string>
<string name="dashboard_learning_delete_warning">Hermes архивирует изученные навыки, чтобы их можно было восстановить из архива сервера. Удаление узла памяти необратимо. Если содержимое может понадобиться позже, сначала сохраните резервную копию сервера.</string>
<string name="dashboard_memory_config_title">Настроить %1$s</string>
<string name="dashboard_memory_config_help">Значения сохраняются в выбранном профиле Hermes. Секретные поля принимаются сервером и не возвращаются после сохранения. Обязательные поля отмечены символом *.</string>
<string name="dashboard_memory_values_json">Значения провайдера (JSON)</string>
<string name="dashboard_memory_run_setup">Запустить настройку</string>
<string name="dashboard_memory_invalid_json">Значения провайдера должны быть объектом JSON.</string>
<string name="dashboard_memory_setup_started">Установка провайдера на всём хосте запущена. После её завершения обновите данные, затем сохраните значения этого профиля.</string>
<string name="dashboard_memory_saved">Провайдер памяти настроен и активирован.</string>
<string name="dashboard_memory_save_failed">Не удалось сохранить конфигурацию провайдера памяти.</string>
<string name="dashboard_whatsapp_title">Настроить WhatsApp</string>
<string name="dashboard_whatsapp_help">Hermes запускает десятиминутный сеанс привязки для выбранного профиля. Выберите режим бота для отдельной учётной записи или чат с собой, чтобы писать в собственную привязанную учётную запись.</string>
<string name="dashboard_whatsapp_bot">Учётная запись бота</string>
<string name="dashboard_whatsapp_self_chat">Чат с собой</string>
<string name="dashboard_whatsapp_allowed_users">Разрешённые номера телефонов (необязательно)</string>
<string name="dashboard_whatsapp_start">Начать привязку</string>
<string name="dashboard_whatsapp_scan">Отсканируйте этот код на экране «Связанные устройства» в WhatsApp. Не закрывайте это окно, пока Hermes подтверждает учётную запись.</string>
<string name="dashboard_whatsapp_qr">QR-код связанного устройства WhatsApp</string>
<string name="dashboard_whatsapp_apply">Включить WhatsApp</string>
<string name="dashboard_whatsapp_start_failed">Не удалось начать привязку WhatsApp.</string>
<string name="dashboard_whatsapp_apply_failed">Не удалось сохранить конфигурацию WhatsApp.</string>
<string name="dashboard_whatsapp_saved">WhatsApp включён; Hermes начал перезапуск шлюза.</string>
<string name="drawer_all_profiles">Все профили</string>
<string name="drawer_no_profile_sessions">Нет подходящих сеансов профиля.</string>
<string name="drawer_close">Закрыть</string>
+38
View File
@@ -1977,6 +1977,16 @@
<string name="dashboard_section_action_browse_hub">Browse hub</string>
<string name="dashboard_section_action_update_installed">Update installed</string>
<string name="dashboard_section_action_server_backup">Create server backup</string>
<string name="dashboard_section_action_download_backup">Save latest backup</string>
<string name="dashboard_section_action_import_backup">Import backup</string>
<string name="dashboard_backup_create_first">Create a backup in this session before downloading it.</string>
<string name="dashboard_backup_download_failed">Backup could not be downloaded.</string>
<string name="dashboard_backup_saved">Backup saved as %1$s.</string>
<string name="dashboard_import_title">Import server backup?</string>
<string name="dashboard_import_warning">This uploads the selected zip to Hermes and starts its authenticated server import. It can replace configuration and data. Create and save a current backup first.</string>
<string name="dashboard_import_confirm">Import</string>
<string name="dashboard_import_started">Server import started. Watch Operations for completion before restarting Hermes.</string>
<string name="dashboard_import_failed">Server import could not be started.</string>
<string name="dashboard_profile_mcp_servers_optional">MCP servers (optional)</string>
<string name="dashboard_profile_mcp_servers_help">Comma or line-separated server names. Credentials remain server-owned.</string>
<string name="dashboard_tile_memory_title">Memory</string>
@@ -2010,6 +2020,34 @@
<string name="dashboard_action_use">Use</string>
<string name="dashboard_action_describe">Describe</string>
<string name="dashboard_action_model">Model</string>
<string name="dashboard_action_configure">Configure</string>
<string name="dashboard_action_setup">Setup</string>
<string name="dashboard_action_setup_whatsapp">Set up WhatsApp</string>
<string name="dashboard_learning_edit_title">Edit %1$s</string>
<string name="dashboard_learning_edit_warning">Edits replace the node’s complete content. Skills deleted from the graph are archived by Hermes and can be restored from the archive; memory-node deletion is permanent, so export a backup first.</string>
<string name="dashboard_learning_saved">Learning node saved.</string>
<string name="dashboard_learning_save_failed">Learning node could not be saved.</string>
<string name="dashboard_learning_delete_warning">Hermes archives learned skills so they can be restored from the server archive. Memory-node deletion is permanent. Save a server backup first if this content may be needed later.</string>
<string name="dashboard_memory_config_title">Configure %1$s</string>
<string name="dashboard_memory_config_help">Values stay on the selected Hermes profile. Secret fields are accepted by the server and are never returned after saving. Required fields are marked with *.</string>
<string name="dashboard_memory_values_json">Provider values (JSON)</string>
<string name="dashboard_memory_run_setup">Run setup</string>
<string name="dashboard_memory_invalid_json">Provider values must be a JSON object.</string>
<string name="dashboard_memory_setup_started">Host-wide provider installation started. Refresh after it completes, then save this profile’s values.</string>
<string name="dashboard_memory_saved">Memory provider configured and activated.</string>
<string name="dashboard_memory_save_failed">Memory provider configuration could not be saved.</string>
<string name="dashboard_whatsapp_title">Set up WhatsApp</string>
<string name="dashboard_whatsapp_help">Hermes starts a ten-minute, profile-scoped pairing session. Choose bot mode for a dedicated account or self-chat to message your own linked account.</string>
<string name="dashboard_whatsapp_bot">Bot account</string>
<string name="dashboard_whatsapp_self_chat">Self-chat</string>
<string name="dashboard_whatsapp_allowed_users">Allowed phone numbers (optional)</string>
<string name="dashboard_whatsapp_start">Start pairing</string>
<string name="dashboard_whatsapp_scan">Scan this code from WhatsApp’s Linked devices screen. Keep this dialog open while Hermes confirms the account.</string>
<string name="dashboard_whatsapp_qr">WhatsApp linked-device QR code</string>
<string name="dashboard_whatsapp_apply">Enable WhatsApp</string>
<string name="dashboard_whatsapp_start_failed">WhatsApp pairing could not be started.</string>
<string name="dashboard_whatsapp_apply_failed">WhatsApp configuration could not be saved.</string>
<string name="dashboard_whatsapp_saved">WhatsApp enabled; Hermes started a gateway restart.</string>
<string name="dashboard_mcp_oauth_title">Authenticate %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes will open the provider in your browser. Return here after approving access; credentials stay on the Hermes server.</string>
<string name="dashboard_mcp_oauth_approved">MCP authentication approved</string>
@@ -153,11 +153,14 @@ class ChatTurnCheckpointStoreTest {
priorUserMessageCount = 3,
baselineAssistantCount = 3,
pendingAsk = ChatTurnAskCheckpoint(
kind = "APPROVAL",
text = "Allow command?",
kind = "CLARIFY",
requestId = "clarify-1",
text = "Choose environments",
choices = listOf("dev", "prod"),
multiSelect = true,
timeoutSeconds = 0,
messageId = "ask-1",
cardKey = "approval-1",
cardKey = "clarify-1",
receivedAt = 1_004L,
),
queuedMessages = listOf(
@@ -0,0 +1,22 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Test
class HermesCardInputTest {
@Test
fun `multi select answer is an exact ordered deduplicated json array`() {
assertEquals(
"[\"prod\",\"dev\",\"custom, value\"]",
encodeClarifyMultiSelectAnswer(
listOf(" prod ", "dev", "prod", "", "custom, value"),
),
)
}
@Test
fun `zero multi select answers encode as an empty array`() {
assertEquals("[]", encodeClarifyMultiSelectAnswer(emptyList()))
}
}
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.network.upstream.models.SessionPullRequest
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
@@ -602,6 +603,35 @@ class ChatHandlerTest {
assertTrue(restartedHandler.sessions.value.first { it.sessionId == "archived" }.archived)
}
@Test
fun updateSessions_mapsOptionalWorkspaceAndPullRequestState() {
handler.updateSessions(
listOf(
SessionItem(
id = "coding",
cwd = "/work/repo",
gitBranch = "feature/mobile",
gitRepoRoot = "/work/repo",
pullRequest = SessionPullRequest(
number = 134,
url = "https://github.com/example/repo/pull/134",
state = "open",
draft = true,
),
),
),
)
val session = handler.sessions.value.single()
assertEquals("/work/repo", session.workingDirectory)
assertEquals("feature/mobile", session.gitBranch)
assertEquals("/work/repo", session.gitRepoRoot)
assertEquals(134, session.pullRequestNumber)
assertEquals("https://github.com/example/repo/pull/134", session.pullRequestUrl)
assertEquals("open", session.pullRequestState)
assertTrue(session.pullRequestDraft)
}
@Test
fun setSessionFlagsLocal_supportsOptimisticUpdateAndRollback() {
handler.updateSessions(listOf(SessionItem(id = "s1")))
@@ -14,8 +14,13 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import okio.Buffer
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
class DashboardApiClientTest {
@@ -864,6 +869,191 @@ class DashboardApiClientTest {
assertEquals("Review title fallbacks", sessions[1].preview)
}
@Test
fun listSessions_enrichesWorkspaceRowsWithTranscriptBackedPullRequest() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"sessions":[{"id":"coding-1","title":"Ship it","cwd":"/work/hermes-relay","git_branch":"feature/session-context","git_repo_root":"/work/hermes-relay"}]}""",
),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"pull_requests":{"coding-1":{"number":134,"url":"https://github.com/example/hermes-relay/pull/134"}},"scanned":["coding-1"]}""",
),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"ghReady":true,"prs":[{"branch":"feature/session-context","draft":false,"number":134,"state":"open","title":"Session context","url":"https://github.com/example/hermes-relay/pull/134"}]}""",
),
)
val session = DashboardApiClient(baseUrl = server.url("/").toString())
.listSessions()
.getOrThrow()
.single()
assertEquals("/work/hermes-relay", session.cwd)
assertEquals("feature/session-context", session.gitBranch)
assertEquals("/work/hermes-relay", session.gitRepoRoot)
assertEquals(134, session.pullRequest?.number)
assertEquals("https://github.com/example/hermes-relay/pull/134", session.pullRequest?.url)
assertEquals("open", session.pullRequest?.state)
assertEquals(false, session.pullRequest?.draft)
server.takeRequest()
val scanRequest = server.takeRequest()
assertEquals("POST", scanRequest.method)
assertEquals("/api/profiles/sessions/pull-requests", scanRequest.requestUrl!!.encodedPath)
assertEquals(
listOf("coding-1"),
Json.parseToJsonElement(scanRequest.body.readUtf8()).jsonObject["ids"]
?.let { it as JsonArray }
?.map { it.toString().trim('"') },
)
val stateRequest = server.takeRequest()
assertEquals("/api/git/review/pr-list", stateRequest.requestUrl!!.encodedPath)
val stateBody = Json.parseToJsonElement(stateRequest.body.readUtf8()).jsonObject
assertEquals("/work/hermes-relay", stateBody["path"]?.toString()?.trim('"'))
assertEquals(listOf("feature/session-context"), (stateBody["branches"] as JsonArray).map { it.toString().trim('"') })
assertEquals(listOf("134"), (stateBody["numbers"] as JsonArray).map { it.toString() })
}
@Test
fun listSessions_keepsWorkspaceMetadataWhenPullRequestEndpointIsUnavailable() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"sessions":[{"id":"legacy-1","git_branch":"dev","git_repo_root":"/work/legacy"}]}""",
),
)
server.enqueue(MockResponse().setResponseCode(404).setBody("not found"))
val session = DashboardApiClient(baseUrl = server.url("/").toString())
.listSessions()
.getOrThrow()
.single()
assertEquals("dev", session.gitBranch)
assertEquals("/work/legacy", session.gitRepoRoot)
assertEquals(null, session.pullRequest)
}
@Test
fun listSessions_retriesActiveSessionPullRequestMissAfterBoundedTtl() = runTest {
var now = 1_000L
val client = DashboardApiClient(
baseUrl = server.url("/").toString(),
nowMillis = { now },
)
val sessionList = """{"sessions":[{"id":"active-1","cwd":"/work/repo"}]}"""
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(sessionList))
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"pull_requests":{},"scanned":["active-1"]}"""),
)
assertEquals(null, client.listSessions().getOrThrow().single().pullRequest)
server.takeRequest()
server.takeRequest()
now += DashboardApiClient.ACTIVE_SESSION_PR_MISS_TTL_MILLIS - 1
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(sessionList))
assertEquals(null, client.listSessions().getOrThrow().single().pullRequest)
assertEquals("GET", server.takeRequest().method)
now += 1
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(sessionList))
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"pull_requests":{"active-1":{"number":12,"url":"https://github.com/example/repo/pull/12"}},"scanned":["active-1"]}""",
),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"ghReady":false,"prs":[]}"""),
)
assertEquals(12, client.listSessions().getOrThrow().single().pullRequest?.number)
assertEquals("GET", server.takeRequest().method)
assertEquals("/api/profiles/sessions/pull-requests", server.takeRequest().requestUrl!!.encodedPath)
assertEquals("/api/git/review/pr-list", server.takeRequest().requestUrl!!.encodedPath)
}
@Test
fun listSessions_performsOneFinalScanWhenAnActiveMissBecomesTerminal() = runTest {
val client = DashboardApiClient(baseUrl = server.url("/").toString())
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"sessions":[{"id":"finishing","cwd":"/work/repo"}]}"""),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"pull_requests":{},"scanned":["finishing"]}"""),
)
client.listSessions().getOrThrow()
repeat(2) { server.takeRequest() }
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"sessions":[{"id":"finishing","cwd":"/work/repo","ended_at":2000.0}]}"""),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"pull_requests":{"finishing":{"number":13,"url":"https://github.com/example/repo/pull/13"}},"scanned":["finishing"]}""",
),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"ghReady":false,"prs":[]}"""),
)
assertEquals(13, client.listSessions().getOrThrow().single().pullRequest?.number)
repeat(3) { server.takeRequest() }
}
@Test
fun listSessions_scopesPullRequestCacheByProfileAndSessionId() = runTest {
val client = DashboardApiClient(baseUrl = server.url("/").toString())
fun enqueueProfileRead(number: Int) {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"sessions":[{"id":"same","cwd":"/work/repo"}]}"""),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"pull_requests":{"same":{"number":$number,"url":"https://github.com/example/repo/pull/$number"}},"scanned":["same"]}""",
),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json")
.setBody("""{"ghReady":false,"prs":[]}"""),
)
}
enqueueProfileRead(11)
assertEquals(11, client.listSessions(profile = "alpha").getOrThrow().single().pullRequest?.number)
repeat(3) { server.takeRequest() }
enqueueProfileRead(22)
assertEquals(22, client.listSessions(profile = "beta").getOrThrow().single().pullRequest?.number)
repeat(3) { server.takeRequest() }
}
@Test
fun listAllProfileSessions_doesNotGuessAcrossDuplicateSessionIds() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"sessions":[{"id":"same","profile":"alpha","cwd":"/work/a"},{"id":"same","profile":"beta","cwd":"/work/b"}]}""",
),
)
val sessions = DashboardApiClient(baseUrl = server.url("/").toString())
.listAllProfileSessions()
.getOrThrow()
assertEquals(2, sessions.size)
assertTrue(sessions.all { it.pullRequest == null })
assertEquals("GET", server.takeRequest().method)
}
@Test
fun listAllProfileSessions_preservesOwnerAndCompositeIdentity() = runTest {
server.enqueue(
@@ -1372,6 +1562,155 @@ class DashboardApiClientTest {
assertEquals(true, settings.showReasoning)
assertEquals("off", settings.toolDisplay)
}
@Test
fun serverBackup_createDownloadAndImport_useUpstreamContracts() = runTest {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true,"archive":"/srv/backups/a.zip"}"""))
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/zip")
.setHeader("Content-Disposition", "attachment; filename=\"a.zip\"")
.setBody("archive-bytes"),
)
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true,"name":"import"}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true,"name":"import"}"""))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val created = client.createServerBackup().getOrThrow()
val downloadSink = ByteArrayOutputStream()
val downloadedFilename = client.downloadServerBackup(created["archive"]!!.toString().trim('"')) {
downloadSink
}.getOrThrow()
client.importServerBackup("/srv/backups/a.zip").getOrThrow()
val uploadBytes = "zip-data".encodeToByteArray()
client.uploadServerBackup(
filename = "phone.zip",
contentLength = uploadBytes.size.toLong(),
openStream = { ByteArrayInputStream(uploadBytes) },
).getOrThrow()
assertEquals("POST", server.takeRequest().method)
val downloadRequest = server.takeRequest()
assertEquals("/api/ops/backup/download", downloadRequest.requestUrl!!.encodedPath)
assertEquals("/srv/backups/a.zip", downloadRequest.requestUrl!!.queryParameter("archive"))
assertEquals("a.zip", downloadedFilename)
assertEquals("archive-bytes", downloadSink.toString(Charsets.UTF_8.name()))
val importRequest = server.takeRequest()
assertEquals("/api/ops/import", importRequest.requestUrl!!.encodedPath)
assertTrue(importRequest.body.readUtf8().contains(""""archive":"/srv/backups/a.zip""""))
val upload = server.takeRequest()
assertEquals("/api/ops/import-upload", upload.requestUrl!!.encodedPath)
val uploadBody = upload.body.readUtf8()
assertTrue(uploadBody.contains("filename=\"phone.zip\""))
assertTrue(uploadBody.contains("zip-data"))
}
@Test
fun boundedStreamRequestBody_streamsAndEnforcesDeclaredAndObservedLimits() {
val payload = "streamed-archive".encodeToByteArray()
val sink = Buffer()
BoundedStreamRequestBody(payload.size.toLong(), 32L) {
ByteArrayInputStream(payload)
}.writeTo(sink)
assertEquals("streamed-archive", sink.readUtf8())
assertThrows(IllegalArgumentException::class.java) {
BoundedStreamRequestBody(declaredLength = 33L, limitBytes = 32L) {
ByteArrayInputStream(byteArrayOf())
}
}
val oversizedUnknownLength = BoundedStreamRequestBody(null, 8L) {
ByteArrayInputStream("ninebytes".encodeToByteArray())
}
assertThrows(IOException::class.java) { oversizedUnknownLength.writeTo(Buffer()) }
}
@Test
fun copyBounded_streamsDownloadAndRejectsDeclaredAndObservedOverflow() {
val output = ByteArrayOutputStream()
val copied = copyBounded(
ByteArrayInputStream("download".encodeToByteArray()),
output,
declaredLength = 8L,
limitBytes = 16L,
)
assertEquals(8L, copied)
assertEquals("download", output.toString(Charsets.UTF_8.name()))
assertThrows(IllegalArgumentException::class.java) {
copyBounded(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream(), 17L, 16L)
}
assertThrows(IOException::class.java) {
copyBounded(
ByteArrayInputStream("seventeen-byte-doc".encodeToByteArray()),
ByteArrayOutputStream(),
declaredLength = null,
limitBytes = 16L,
)
}
}
@Test
fun downloadServerBackup_rejectsDeclaredOversizeBeforeOpeningDestination() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/zip")
.setHeader("Content-Length", DashboardApiClient.MAX_BACKUP_TRANSFER_BYTES + 1),
)
var destinationOpened = false
val result = DashboardApiClient(baseUrl = server.url("/").toString())
.downloadServerBackup("/srv/backups/oversize.zip") {
destinationOpened = true
ByteArrayOutputStream()
}
assertTrue(result.isFailure)
assertFalse(destinationOpened)
}
@Test
fun learningMutations_preserveNodeIdAndProfile() = runTest {
repeat(3) { server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true,"content":"body"}""")) }
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.getLearningNode("memory:MEMORY.md:0", "worker").getOrThrow()
client.updateLearningNode("memory:MEMORY.md:0", "replacement", "worker").getOrThrow()
client.deleteLearningNode("memory:MEMORY.md:0", "worker").getOrThrow()
val get = server.takeRequest()
assertEquals("memory:MEMORY.md:0", get.requestUrl!!.queryParameter("id"))
assertEquals("worker", get.requestUrl!!.queryParameter("profile"))
val put = server.takeRequest()
assertEquals("PUT", put.method)
assertTrue(put.body.readUtf8().contains(""""profile":"worker""""))
val delete = server.takeRequest()
assertEquals("DELETE", delete.method)
assertTrue(delete.body.readUtf8().contains(""""id":"memory:MEMORY.md:0""""))
}
@Test
fun memoryProviderAndWhatsApp_calls_areProfileScoped() = runTest {
repeat(5) { server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true,"pairing_id":"pair-1","status":"waiting"}""")) }
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val values = Json.parseToJsonElement("""{"url":"https://memory.example"}""").jsonObject
client.getMemoryProviderConfig("honcho", "worker").getOrThrow()
client.updateMemoryProviderConfig("honcho", values, "worker").getOrThrow()
client.selectMemoryProvider("honcho").getOrThrow()
client.startWhatsAppOnboarding("self-chat", "15551234567", "worker").getOrThrow()
client.applyWhatsAppOnboarding("pair-1", "self-chat", "15551234567", "worker").getOrThrow()
assertEquals("worker", server.takeRequest().requestUrl!!.queryParameter("profile"))
assertTrue(server.takeRequest().body.readUtf8().contains(""""values":{"url":"https://memory.example"}"""))
assertTrue(server.takeRequest().body.readUtf8().contains(""""provider":"honcho""""))
val start = server.takeRequest()
assertEquals("/api/messaging/whatsapp/onboarding/start", start.requestUrl!!.encodedPath)
assertTrue(start.body.readUtf8().contains(""""profile":"worker""""))
val apply = server.takeRequest()
assertEquals("/api/messaging/whatsapp/onboarding/pair-1/apply", apply.requestUrl!!.encodedPath)
assertTrue(apply.body.readUtf8().contains(""""profile":"worker""""))
}
}
private fun messagePageResponse(
@@ -715,8 +715,44 @@ class GatewayEventMapperTest {
assertEquals("r1", ask.requestId)
assertEquals("Which file?", ask.text)
assertEquals(listOf("a.txt", "b.txt"), ask.choices)
assertFalse(ask.multiSelect)
assertNull(ask.envVar)
assertEquals(300, ask.timeoutSeconds)
assertEquals(0, ask.timeoutSeconds)
}
@Test
fun `clarify request carries multi select only with bounded usable choices`() {
val r = Recorder()
mapperWith(r).onEvent(
"clarify.request",
obj(
"""{"request_id":"r1","question":"Where?","multi_select":true,"choices":""" +
"""[" dev ","prod","dev","","stage","canary","extra"]}""",
),
)
val ask = r.interactions.single()
assertTrue(ask.multiSelect)
assertEquals(listOf("dev", "prod", "stage", "canary"), ask.choices)
assertEquals(0, ask.timeoutSeconds)
}
@Test
fun `clarify ignores malformed multi select and consumes additive timeout metadata`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent(
"clarify.request",
obj("""{"request_id":"r1","multi_select":true,"choices":null,"timeout_seconds":42}"""),
)
mapper.onEvent(
"clarify.request",
obj("""{"request_id":"r2","multi_select":false,"choices":["a"]}"""),
)
assertFalse(r.interactions[0].multiSelect)
assertEquals(42, r.interactions[0].timeoutSeconds)
assertFalse(r.interactions[1].multiSelect)
}
@Test
@@ -94,6 +94,18 @@ class SessionModelsTest {
assertTrue(item.archived)
}
@Test
fun sessionItem_deserializesOptionalWorkspaceMetadata() {
val item = json.decodeFromString<SessionItem>(
"""{"id":"s1","cwd":"/work/repo","git_branch":"feature/mobile","git_repo_root":"/work/repo"}""",
)
assertEquals("/work/repo", item.cwd)
assertEquals("feature/mobile", item.gitBranch)
assertEquals("/work/repo", item.gitRepoRoot)
assertNull(item.pullRequest)
}
@Test
fun sessionItem_deserialization_acceptsIsoUpdatedAtFallback() {
val jsonStr = """
@@ -133,6 +145,10 @@ class SessionModelsTest {
assertNull(item.outputTokens)
assertEquals(false, item.pinned)
assertEquals(false, item.archived)
assertNull(item.cwd)
assertNull(item.gitBranch)
assertNull(item.gitRepoRoot)
assertNull(item.pullRequest)
}
// --- SessionListResponse ---
@@ -9,16 +9,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.HermesCardInput
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -112,6 +115,38 @@ class HermesApprovalCardInteractionTest {
compose.runOnIdle { assertEquals(listOf("once", "deny"), actions) }
}
@Test
fun `multi select clarify toggles choices and submits one ordered json array`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Choose environments",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf("dev", "stage", "prod"),
multiSelect = true,
allowFreeText = true,
),
),
cardKey = "clarify-multi",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
compose.onNodeWithText("Submit").assertIsNotEnabled()
compose.onNodeWithText("prod").performTouchInput { click() }
compose.onNodeWithText("dev").performTouchInput { click() }
compose.onNodeWithText("Submit").assertIsEnabled().performTouchInput { click() }
compose.runOnIdle { assertEquals(listOf("[\"prod\",\"dev\"]"), answers) }
}
private fun approvalCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_APPROVAL,
title = "Approval requested",
@@ -49,6 +49,34 @@ class SessionDrawerTest {
)
}
@Test
fun `session work labels use a safe repo name branch and pull request number`() {
val session = ChatSession(
sessionId = "coding-1",
title = "Ship it",
model = null,
gitRepoRoot = "C:\\worktrees\\hermes-relay\\",
gitBranch = "feature/android-session-context",
pullRequestNumber = 134,
pullRequestUrl = "https://github.com/example/hermes-relay/pull/134",
pullRequestState = "open",
)
assertEquals(
listOf("hermes-relay", "feature/android-session-context", "PR #134 · Open"),
sessionWorkLabels(session),
)
}
@Test
fun `session work labels stay empty on older hosts`() {
assertTrue(
sessionWorkLabels(
ChatSession(sessionId = "legacy", title = null, model = null),
).isEmpty(),
)
}
@Test
fun `reordered leading session remains visible after drawer refresh`() {
var sessions by mutableStateOf(
@@ -10,6 +10,7 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
@@ -26,6 +27,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
@@ -660,6 +662,78 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("once", cardMessage.cardDispatches.single().actionValue)
}
@Test
fun multiSelectClarifyPreservesCardSemanticsAndExactWireAnswer() {
viewModel.sendMessage("Ask which environments")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"clarify.request",
buildJsonObject {
put("request_id", "clarify-1")
put("question", "Which environments?")
put("multi_select", true)
put("choices", buildJsonArray {
add(JsonPrimitive("dev"))
add(JsonPrimitive("stage"))
add(JsonPrimitive("prod"))
})
},
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value != null }
val pending = requireNotNull(viewModel.pendingAsk.value)
val card = handler.messages.value.single { it.id == pending.messageId }.cards.single()
assertTrue(card.input?.multiSelect == true)
assertEquals(listOf("dev", "stage", "prod"), card.input?.choices)
assertEquals(null, card.input?.expiresAtMillis)
viewModel.answerAsk(pending.messageId, pending.cardKey, "[\"prod\",\"dev\"]")
val response = gatewayHarness.awaitRpc("clarify.respond")
assertEquals(JsonPrimitive("clarify-1"), response["request_id"])
assertEquals(JsonPrimitive("[\"prod\",\"dev\"]"), response["answer"])
awaitCondition { viewModel.pendingAsk.value == null }
}
@Test
fun authoritativeClarifyExpiryCollapsesCardAndRejectsLateAction() {
viewModel.sendMessage("Ask a question")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"clarify.request",
buildJsonObject {
put("request_id", "clarify-expired")
put("question", "Still there?")
put("choices", buildJsonArray { add(JsonPrimitive("yes")) })
},
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value != null }
val pending = requireNotNull(viewModel.pendingAsk.value)
serverWs.send(
gatewayHarness.eventFrame(
"clarify.expire",
buildJsonObject { put("request_id", "clarify-expired") },
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value == null }
val cardMessage = handler.messages.value.single { it.id == pending.messageId }
assertEquals(
HermesCardDispatch.EXPIRED_STAMP,
cardMessage.cardDispatches.single().actionValue,
)
viewModel.answerAsk(pending.messageId, pending.cardKey, "yes")
Thread.sleep(100)
assertTrue(gatewayHarness.rpcLog.none { it.first == "clarify.respond" })
}
@Test
fun explicitDenialActionEmitsResponseAndCollapsesCard() {
viewModel.sendMessage("Run the guarded command")
+72 -28
View File
@@ -32,41 +32,68 @@ binary and one set of state files; the tray does not bundle a private sidecar.
Clicking the tray icon toggles the management popup. Paired Hermes instances are
shown as **Hosts**; clients authenticated to the selected host appear separately
under Settings and can be deauthorized there. **Pair another host...** is the
in that host's detail page and can be deauthorized there. **Pair another host...** is the
last host-selector option, or the selector's only action when no hosts exist.
The tray cross-checks the daemon heartbeat and PID, labels the account as
**User** or **Administrator**, and disables lifecycle actions that do not apply
to the current state. **Start/Restart daemon as Administrator...** uses the
standard Windows UAC prompt; the tray itself stays unelevated. Settings can
check the Desktop release channel and self-update the CLI and management UI
together. The installer download is verified against the release
to the current state. **Restart as Administrator...** uses the standard Windows
UAC prompt; the tray itself stays unelevated. **Return to user mode** stops the
elevated daemon once and starts it again with normal user privileges. Because
every approved command and input action inherits the daemon's privilege,
Administrator mode is an explicit action rather than a persistent toggle.
Settings is reserved for this PC. **Start UI at sign-in** controls only the tray
startup entry; the separate **Start daemon with UI** preference decides whether
opening the tray also connects remote access. Automatic daemon startup is off
for existing installs until explicitly enabled. Settings also exposes **Open
terminal**, **Open Hermes CLI**, **View daemon log**, and **Run diagnostics**,
and manages Desktop release updates. **Help &
About** reports the UI, CLI, and connected Relay versions and links to the docs,
troubleshooting, release notes, logs, and diagnostics. The installer download is
verified against the release
`SHA256SUMS.txt`, preserves the startup preference, restores a previously
running daemon, and relaunches the tray after the silent replacement.
Access is selected per host. **Ask** keeps the connection available but attaches
no desktop tools. **Trusted** enables command and file tools while screen/input
still uses task grants. **Full Access** also allows screen, keyboard, and mouse
without task grants for that host; authentication, audit, deauthorization,
emergency stop, and UAC boundaries still apply.
Access is selected per host. **Restricted** keeps the connection available but
attaches no desktop tools. **Ask Every Time** advertises available command, file,
screen/input, and USB operations but requires local approval for each one. It is
the default for newly paired hosts; existing hosts retain their stored policy.
**Standard** enables typed operations while withholding
raw terminal, PowerShell, detached-process, and command-job launch. **Full Access**
allows every available capability without task grants for that host;
authentication, audit, deauthorization, emergency stop, and UAC boundaries
still apply. Commands, Files, Screen & Input, Raw USB, Microphone, and Camera
form one per-host capability ledger. Changing an individual gate selects an exact
preset when the resulting combination matches one and otherwise creates a
**Custom** policy. Existing `ask`, `structured`, and `trusted` CLI values remain
accepted as compatibility aliases; legacy `ask` still means Restricted, while
the new preset is `ask-every-time`. Raw USB gates direct native/vendor USB utility
execution plus secondary services such as ADB. Microphone and camera remain
unavailable until their controlled paths exist.
The tray's **Activity** section is a live, local view of recent remote actions
and management events such as daemon, host-access, grant, client, and update
changes.
It groups events into commands, files, screen, and input; highlights failures,
It groups events into commands, files, screen, input, and connected devices; highlights failures,
aborts, and non-zero process exits; and keeps request context collapsed until
explicitly expanded. Events record handler duration and request ID where
available. The compact Overview still shows only the three newest events.
Settings also keeps activity compact: it previews the three newest events and
opens a dedicated Activity detail page for wrapped filters and expandable
records. Handler failures and aborts are **Issues**; non-zero process exits are
opens a dedicated Activity page. Selecting an event opens bounded request,
stdout, stderr, result, exit, timing, and truncation evidence; sensitive request
inputs are excluded. Handler failures and aborts are **Issues**; non-zero process exits are
shown separately because probing commands may legitimately use them. **Clear**
removes both current and rotated local audit history after confirmation.
Clicking a card under **Hosts** opens that host's detail page; it does not change
the active connection. The detail page can set a local display name and has an
explicit connect action. Local names are stored in `desktop-control.json` and
do not rename the remote Hermes instance.
the active connection. The detail page is the per-host hub for its local display
name, connection state, Relay address and version, pairing/session details,
access, capabilities, and authorized clients. It also provides explicit connect,
re-pair, deauthorize-client, and guarded **Forget host** actions. Local names are
stored in `desktop-control.json` and do not rename the remote Hermes instance.
Forgetting removes the local session, alias, and access policy; deauthorization
is the separate action that removes a server-side client session.
## Install
@@ -241,6 +268,13 @@ relay one-shot code.
Now subsequent `hermes-relay ...` calls reuse the stored session token. Tokens live at `~/.hermes/remote-sessions.json` (mode 0600) — same file the Ink TUI uses, so pairing once from either surface works for both.
Each desktop installation also keeps a private stable identifier in
`~/.hermes/desktop-device-id`. This lets several PCs retain independent paired
sessions on one Relay. Re-pairing one PC replaces only that installation's old
credential. Every desktop RPC accepts a stable device ID or unambiguous computer
name. With several connected daemons the target is required, preventing an
agent command from silently following the most recent connection.
### Terminal plugins
The `hermes-relay plugins` command exposes optional terminal surfaces. The first
@@ -260,13 +294,18 @@ Herm uses `bun add -g herm-tui` when Bun is available and falls back to
### Host access and daemon bring-up
Starting the daemon no longer grants tools and no longer requires a tool grant.
With a paired host in **Ask**, it connects in locked mode with zero desktop tools.
With a paired host in **Restricted**, it connects in locked mode with zero desktop tools.
Select a host policy from the tray or use the CLI:
```sh
hermes-relay hosts list --json
hermes-relay hosts select ws://192.168.1.100:8767
hermes-relay hosts access trusted --remote ws://192.168.1.100:8767
hermes-relay hosts access ask-every-time --remote ws://192.168.1.100:8767
hermes-relay hosts access standard --remote ws://192.168.1.100:8767
hermes-relay hosts capability commands allow --remote ws://192.168.1.100:8767 --yes
hermes-relay hosts capability files ask --remote ws://192.168.1.100:8767
hermes-relay hosts capability screen-input ask --remote ws://192.168.1.100:8767
hermes-relay hosts capability usb ask --remote ws://192.168.1.100:8767
hermes-relay daemon start
```
@@ -385,7 +424,7 @@ overrides. The per-host policy is stored separately in
`~/.hermes/desktop-host-access.json`; Full Access enables this surface for its
host without an expiring task grant.
In Ask or Trusted mode, observe grants allow screenshots;
When Screen & Input is set to Ask, observe grants allow screenshots;
assist/control grants require explicit local approval before host input can run.
The daemon writes pending requests to `~/.hermes/grant-bridge`; review them with
`hermes-relay grants` or the tray's focused approval dialog. Grants
@@ -504,7 +543,7 @@ hermes-relay audit --json
```
```
Desktop-tool activity (4 most recent)
Desktop-tool activity (3 most recent)
WHEN TOOL STATUS DETAIL
12s ago desktop_read_file ● ok path=C:\src\app.ts
@@ -531,6 +570,8 @@ hermes-relay relay security # runtime auth toggles (run on the relay host)
hermes-relay daemon start # run in the background (no console window)
hermes-relay daemon status # state + uptime of the running daemon
hermes-relay daemon restart # restart with the caller's current privileges
hermes-relay daemon restart --administrator # Windows: request UAC and run elevated
hermes-relay daemon restart --user # Windows: return to normal user mode
hermes-relay daemon stop # stop it
hermes-relay daemon # run in the FOREGROUND (current console)
```
@@ -553,15 +594,18 @@ hermes-relay daemon
`status` reads the heartbeat file a running daemon maintains and cross-checks that the pid is alive — it exits non-zero (and says "not running") when the daemon is gone, so scripts can branch on it.
On Windows, keep the tray and normal daemon unelevated for routine operation.
Use **Start/Restart daemon as Administrator...** only when a desktop action
requires administrator access. Windows displays UAC consent, and the elevated
daemon records its privilege level in the same status file so later stop and
restart actions preserve the required elevation.
Use **Restart as Administrator...** only when a desktop action requires
administrator access. Windows displays UAC consent, and the elevated daemon
records its privilege level in the same status file so the UI can label it
clearly. Use **Return to user mode** to stop it once and start a normal daemon.
The equivalent CLI actions are `daemon restart --administrator` and `daemon
restart --user`; both are Windows-only and mutually exclusive.
> **Auto-start on boot/login:** the Windows menu can start the tray at user
> sign-in; it intentionally does not auto-elevate or silently start an
> Administrator daemon. Starting the daemon itself as a service still needs an
> OS service, systemd user unit, or launchd agent.
> **Auto-start on boot/login:** **Start UI at sign-in** registers the tray at
> user sign-in. **Start daemon with UI** is a separate opt-in and remains off
> for existing installs until enabled. Neither option auto-elevates or starts
> an Administrator daemon. Starting the daemon itself as a machine service
> still needs an OS service, systemd user unit, or launchd agent.
## Flags and environment
+1 -1
View File
@@ -49,7 +49,7 @@
"dev:install": "node scripts/dev-install.mjs",
"dev:install:tray": "npm run dev:install && node scripts/dev-install-tray.mjs",
"type-check": "tsc --noEmit -p tsconfig.json",
"test": "tsx --test tests/**/*.test.ts",
"test": "tsx --test --test-concurrency=1 tests/**/*.test.ts",
"clean": "rimraf dist"
},
"engines": {
+3
View File
@@ -67,6 +67,9 @@ const BOOLEAN_FLAGS = new Set([
'log-json',
'status',
'detach',
'administrator',
'user',
'elevation-child',
'allow-tools',
'allow-computer-use',
'experimental-computer-use',
+15 -3
View File
@@ -48,6 +48,9 @@ import {
shouldAdvertiseComputerUse
} from '../tools/handlerSet.js'
import { DesktopToolRouter } from '../tools/router.js'
import { effectiveHostAccessMode, effectiveHostCapabilityPolicies, getHostAccessMode, getHostCapabilityPolicies } from '../lib/hostAccessPolicy.js'
import { configureCapabilityPolicies } from '../tools/capabilityRuntime.js'
import { adbBackendAvailable } from '../tools/handlers/adb.js'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, RelayTransport } from '../transport/RelayTransport.js'
// (getSession is imported above with the other remoteSessions exports so we
@@ -129,6 +132,7 @@ async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
relay.onAuthSuccess((token, ver, meta) => {
void saveSession(url, token, ver, {
initializeAccessPolicy: true,
grants: meta.grants,
ttlExpiresAt: meta.ttlExpiresAt,
endpointRole
@@ -584,16 +588,24 @@ export async function chatCommand(args: ParsedArgs): Promise<number> {
const consent = await ensureToolsConsent(url)
if (consent.consented) {
const computerUseEnabled = shouldAdvertiseComputerUse(args.flags)
const storedAccessMode = await getHostAccessMode(url)
const accessMode = effectiveHostAccessMode(storedAccessMode, consent.consented)
const capabilities = effectiveHostCapabilityPolicies(storedAccessMode, consent.consented, await getHostCapabilityPolicies(url))
configureCapabilityPolicies(capabilities)
const usb = capabilities.usb !== 'disabled'
const adb = usb && adbBackendAvailable()
configureComputerUseRuntime({
url,
computerUseConsented: computerUseEnabled,
consentSource: consent.source ?? 'stored'
consentSource: consent.source ?? 'stored',
accessMode,
capabilities
})
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled })
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled, capabilities, usb, adb })
toolRouter = new DesktopToolRouter({
consentGranted: true,
hostUrl: url,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
handlers: desktopHandlers({ computerUse: computerUseEnabled, capabilities, usb, adb }),
advertisedTools: [...advertisedTools]
})
toolRouter.attach(relay)
+207 -11
View File
@@ -49,7 +49,7 @@ import {
type DaemonStatus
} from '../lib/daemonStatus.js'
import { rpcErrorMessage, asRpcResult } from '../lib/rpc.js'
import { effectiveHostAccessMode, getHostAccessMode } from '../lib/hostAccessPolicy.js'
import { effectiveHostAccessMode, effectiveHostCapabilityPolicies, getHostAccessMode, getHostCapabilityPolicies } from '../lib/hostAccessPolicy.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
@@ -67,6 +67,8 @@ import {
type ComputerGrant
} from '../tools/computerGrants.js'
import { DesktopToolRouter } from '../tools/router.js'
import { configureCapabilityPolicies } from '../tools/capabilityRuntime.js'
import { adbBackendAvailable } from '../tools/handlers/adb.js'
import { RelayTransport } from '../transport/RelayTransport.js'
import { setupGracefulExit } from '../lib/gracefulExit.js'
import { grantBridgeDir } from '../lib/grantBridge.js'
@@ -85,16 +87,24 @@ const DETACHED_START_POLL_MS = 100
const DAEMON_USAGE: UsageSpec = {
name: 'daemon',
summary: 'run headless — expose desktop tools to the agent even when no shell is open',
usage: ['daemon [run]', 'daemon start', 'daemon stop', 'daemon restart', 'daemon status'],
usage: [
'daemon [run]',
'daemon start [--administrator]',
'daemon stop [--administrator]',
'daemon restart [--administrator|--user]',
'daemon status'
],
subcommands: [
{ verb: 'run', desc: 'Run in the foreground (current console; default)' },
{ verb: 'start', desc: 'Start in the background — no console window; survives terminal close' },
{ verb: 'stop', desc: 'Stop the background daemon' },
{ verb: 'restart', desc: 'Restart the background daemon, preserving caller privileges' },
{ verb: 'restart', desc: 'Restart the background daemon, preserving caller privileges by default' },
{ verb: 'status', desc: 'Print state + uptime of the running daemon (alias: --status)' }
],
flags: [
{ flag: '--detach', desc: 'Alias for `daemon start` — run in the background' },
{ flag: '--administrator', desc: 'Windows: explicitly request UAC and run the daemon as Administrator' },
{ flag: '--user', desc: 'Windows: restart the daemon as the current unelevated user' },
{ flag: '--remote <url>', desc: 'Relay to connect to (default: stored/active session)' },
{ flag: '--token <token>', desc: 'Use an explicit session token (CI/provisioning)' },
{ flag: '--allow-tools', desc: 'Skip the stored-consent gate (only with --token; implies trust)' },
@@ -106,7 +116,8 @@ const DAEMON_USAGE: UsageSpec = {
examples: [
'hermes-relay daemon start',
'hermes-relay daemon status',
'hermes-relay daemon restart',
'hermes-relay daemon restart --administrator',
'hermes-relay daemon restart --user',
'hermes-relay daemon stop'
]
}
@@ -353,6 +364,116 @@ function buildDaemonChildArgs(args: ParsedArgs): string[] {
return out
}
type DaemonLifecycleSubcommand = 'start' | 'stop' | 'restart'
interface ElevationLaunchPlan {
program: string
args: string[]
env: NodeJS.ProcessEnv
targetProgram: string
targetArgs: string[]
}
/** Quote one argv item for CommandLineToArgvW. Start-Process accepts a single
* ArgumentList string, so preserve spaces, quotes, and trailing backslashes. */
function quoteWindowsArgument(value: string): string {
if (value.length > 0 && !/[\s"]/u.test(value)) return value
let out = '"'
let slashes = 0
for (const char of value) {
if (char === '\\') {
slashes += 1
continue
}
if (char === '"') {
out += '\\'.repeat(slashes * 2 + 1) + '"'
slashes = 0
continue
}
out += '\\'.repeat(slashes) + char
slashes = 0
}
return out + '\\'.repeat(slashes * 2) + '"'
}
function executableInvocationPrefix(): { program: string; args: string[] } {
const execIsNode = /node(\.exe)?$/i.test(path.basename(process.execPath))
return {
program: process.execPath,
args: execIsNode ? [process.argv[1] ?? ''] : []
}
}
/** Build the UAC launcher separately from execution so the exact privilege
* boundary and forwarded argv are regression-testable. */
function buildElevationLaunchPlan(
args: ParsedArgs,
subcommand: DaemonLifecycleSubcommand
): ElevationLaunchPlan {
const invocation = executableInvocationPrefix()
const targetArgs = [
...invocation.args,
'daemon',
subcommand,
...buildDaemonChildArgs(args).slice(1),
'--elevation-child'
]
const commandLine = targetArgs.map(quoteWindowsArgument).join(' ')
const script = [
"$ErrorActionPreference = 'Stop'",
'$argumentLine = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:HERMES_RELAY_ELEVATE_ARGS))',
'try {',
" $child = Start-Process -FilePath $env:HERMES_RELAY_ELEVATE_PROGRAM -ArgumentList $argumentLine -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
' exit $child.ExitCode',
'} catch {',
" [Console]::Error.WriteLine('Administrator request failed or was canceled: ' + $_.Exception.Message)",
' exit 1',
'}'
].join('\n')
return {
program: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
env: {
...process.env,
HERMES_RELAY_ELEVATE_PROGRAM: invocation.program,
HERMES_RELAY_ELEVATE_ARGS: Buffer.from(commandLine, 'utf8').toString('base64')
},
targetProgram: invocation.program,
targetArgs
}
}
async function runElevatedDaemonLifecycle(
args: ParsedArgs,
subcommand: DaemonLifecycleSubcommand
): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (process.platform !== 'win32') {
process.stderr.write(t.err('--administrator is supported only on Windows') + '\n')
return 1
}
const plan = buildElevationLaunchPlan(args, subcommand)
return new Promise(resolve => {
let settled = false
const child = spawn(plan.program, plan.args, {
env: plan.env,
stdio: 'inherit',
windowsHide: true
})
child.once('error', error => {
if (settled) return
settled = true
process.stderr.write(t.err(`failed to request Administrator access: ${error.message}`) + '\n')
resolve(1)
})
child.once('exit', code => {
if (settled) return
settled = true
resolve(code === 0 ? 0 : 1)
})
})
}
/** `daemon start` / `--detach` — spawn the foreground daemon as a detached
* background process (no console window on Windows), logging to a file. */
async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
@@ -503,12 +624,70 @@ async function restartDaemon(args: ParsedArgs): Promise<number> {
return startDetachedDaemon(args)
}
async function restartDaemonAsUser(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const identity = currentProcessIdentity()
if (identity.privilege === 'administrator') {
process.stderr.write(
t.err('cannot launch a user daemon from an Administrator process; run this command from the normal desktop UI or an unelevated terminal') + '\n'
)
return 1
}
const existing = await readDaemonStatus()
if (
process.platform === 'win32' &&
existing &&
isDaemonProcessAlive(existing) &&
existing.privilege === 'administrator'
) {
// An unelevated caller cannot terminate an elevated daemon. Elevate only
// the stop operation, wait for it to finish, then start the replacement
// from this original unelevated process.
const stopped = await runElevatedDaemonLifecycle(args, 'stop')
if (stopped !== 0) return stopped
return startDetachedDaemon(args)
}
return restartDaemon(args)
}
export async function daemonCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(DAEMON_USAGE, makeTheme({ noColor: !!args.flags['no-color'] }))
return 0
}
const sub = args.positional[0]
const administrator = args.flags.administrator === true
const user = args.flags.user === true
const elevationChild = args.flags['elevation-child'] === true
if ((administrator || user || elevationChild) && process.platform !== 'win32') {
process.stderr.write('daemon: --administrator and --user are supported only on Windows\n')
return 1
}
if (administrator && user) {
process.stderr.write('daemon: --administrator and --user are mutually exclusive\n')
return 1
}
if ((administrator || user || elevationChild) && (sub === 'status' || args.flags.status)) {
process.stderr.write('daemon: privilege flags apply only to start, stop, or restart\n')
return 1
}
if (user && sub !== 'start' && sub !== 'restart') {
process.stderr.write('daemon: --user applies only to start or restart\n')
return 1
}
if (elevationChild && currentProcessIdentity().privilege !== 'administrator') {
process.stderr.write('daemon: internal elevation helper did not receive an Administrator token\n')
return 1
}
if (administrator && !elevationChild && currentProcessIdentity().privilege !== 'administrator') {
if (sub !== 'start' && sub !== 'stop' && sub !== 'restart') {
process.stderr.write('daemon: --administrator requires start, stop, or restart\n')
return 1
}
return runElevatedDaemonLifecycle(args, sub)
}
if (args.flags.status || sub === 'status') {
return printDaemonStatus(args)
}
@@ -516,9 +695,16 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
return stopDaemon(args)
}
if (sub === 'restart') {
if (user) return restartDaemonAsUser(args)
return restartDaemon(args)
}
if (sub === 'start' || args.flags.detach) {
if (user && currentProcessIdentity().privilege === 'administrator') {
process.stderr.write(
'daemon: cannot launch a user daemon from an Administrator process; run this command from an unelevated terminal\n'
)
return 1
}
return startDetachedDaemon(args)
}
// Bare `daemon` (or `daemon run`) → foreground, the existing behavior below.
@@ -574,10 +760,8 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
// connectivity itself a privileged operation.
const consented = stored?.toolsConsented === true
const allowToolsFlag = !!args.flags['allow-tools']
const accessMode = effectiveHostAccessMode(
await getHostAccessMode(url),
stored?.toolsConsented === true
)
const storedAccessMode = await getHostAccessMode(url)
const accessMode = effectiveHostAccessMode(storedAccessMode, stored?.toolsConsented === true)
const toolsEnabled = consented || allowToolsFlag || accessMode !== 'ask'
log.info({
@@ -678,14 +862,23 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
// or redirected daemon still fails host input closed because no visible
// local grant approval prompt can run.
const interactive = !!process.stdin.isTTY && !!process.stderr.isTTY
const capabilities = effectiveHostCapabilityPolicies(
storedAccessMode,
stored?.toolsConsented === true,
await getHostCapabilityPolicies(url)
)
configureComputerUseRuntime({
url,
computerUseConsented: computerUseEnabled,
consentSource: consented ? 'stored' : toolsEnabled ? 'override' : 'none',
accessMode
accessMode,
capabilities
})
configureCapabilityPolicies(capabilities)
const usb = capabilities.usb !== 'disabled'
const adb = usb && adbBackendAvailable()
const advertisedTools = toolsEnabled
? advertisedDesktopTools({ computerUse: computerUseEnabled })
? advertisedDesktopTools({ computerUse: computerUseEnabled, capabilities, usb, adb })
: []
const toDaemonGrantStatus = (grant: ComputerGrant | null): DaemonComputerGrantStatus => ({
active: grant !== null,
@@ -722,7 +915,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
consentGranted: true,
interactive,
hostUrl: url,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
handlers: desktopHandlers({ computerUse: computerUseEnabled, capabilities, usb, adb }),
advertisedTools: [...advertisedTools]
})
: null
@@ -839,6 +1032,9 @@ export default daemonCommand
export type { LogFields }
export {
daemonStatusIsReady as __daemonStatusIsReadyForTests,
buildDaemonChildArgs as __buildDaemonChildArgsForTests,
buildElevationLaunchPlan as __buildElevationLaunchPlanForTests,
quoteWindowsArgument as __quoteWindowsArgumentForTests,
makeLogger as __makeLoggerForTests,
readDetachedStartupFailure as __readDetachedStartupFailureForTests,
rpcErrorMessage as __rpcErrorMessageForTests,
+133 -23
View File
@@ -2,14 +2,22 @@ import type { ParsedArgs } from '../cli.js'
import { getActiveDesktopRelayUrl, getDesktopHostAliases, setActiveDesktopRelayUrl, setDesktopHostAlias } from '../desktopConfig.js'
import {
effectiveHostAccessMode,
effectiveHostCapabilityPolicies,
getHostCapabilityPolicies,
getHostAccessMode,
isHostAccessMode,
removeHostAccessPolicy,
setHostAccessMode,
setHostCapabilityPolicy,
HOST_CAPABILITIES,
CAPABILITY_ACCESS_MODES,
type CapabilityPolicies,
type HostCapability,
type HostAccessMode
} from '../lib/hostAccessPolicy.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec, unknownSubcommand } from '../lib/usage.js'
import { getSession, listSessions, saveSession } from '../remoteSessions.js'
import { deleteSession, getSession, listSessions, saveSession } from '../remoteSessions.js'
const HOSTS_USAGE: UsageSpec = {
name: 'hosts',
@@ -18,23 +26,29 @@ const HOSTS_USAGE: UsageSpec = {
'hosts [list] [--json]',
'hosts select <relay-url>',
'hosts rename <relay-url> <name>',
'hosts access <ask|trusted|full-access> [--remote <url>] [--yes]'
'hosts forget <relay-url> --yes',
'hosts access <restricted|ask-every-time|standard|full-access> [--remote <url>] [--yes]',
'hosts capability <commands|files|screen-input|usb|microphone|camera> <disabled|ask|allow> [--remote <url>] [--yes]'
],
subcommands: [
{ verb: 'list', desc: 'List locally paired Hermes hosts (default)' },
{ verb: 'select <url>', desc: 'Choose the host used by the tray and daemon' },
{ verb: 'rename <url> <name>', desc: 'Set a local display name for a paired host' },
{ verb: 'access <mode>', desc: 'Set this PC access policy for one host' }
{ verb: 'forget <url>', desc: 'Remove the local pairing, alias, and access policy' },
{ verb: 'access <mode>', desc: 'Set a Restricted, Ask Every Time, Standard, or Full Access preset' },
{ verb: 'capability <name> <mode>', desc: 'Set one capability; exact presets are recognized automatically' }
],
flags: [
{ flag: '--remote <url>', desc: 'Host targeted by the access command' },
{ flag: '--json', desc: 'Emit machine-readable host state' },
{ flag: '--yes', desc: 'Confirm Full Access non-interactively' }
{ flag: '--yes', desc: 'Confirm Full Access, hardware Allow, or forgetting a host' }
],
examples: [
'hermes-relay hosts --json',
'hermes-relay hosts select wss://home.example:8767',
'hermes-relay hosts access full-access --remote wss://home.example:8767 --yes'
'hermes-relay hosts access ask-every-time --remote wss://home.example:8767',
'hermes-relay hosts access standard --remote wss://home.example:8767',
'hermes-relay hosts capability usb ask --remote wss://home.example:8767'
]
}
@@ -46,6 +60,7 @@ export interface LocalHostSummary {
paired_at: number
is_active: boolean
access_mode: HostAccessMode
capabilities: CapabilityPolicies
}
function hostLabel(url: string): string {
@@ -58,23 +73,36 @@ function hostLabel(url: string): string {
export function parseAccessMode(value: string | undefined): HostAccessMode | null {
const normalized = value?.trim().toLowerCase().replaceAll('-', '_')
return isHostAccessMode(normalized) ? normalized : null
if (normalized === 'restricted') return 'ask'
if (normalized === 'prompt' || normalized === 'ask_every_time') return 'ask_every_time'
if (normalized === 'standard') return 'structured'
return isHostAccessMode(normalized) && normalized !== 'custom' ? normalized : null
}
export function displayAccessMode(mode: HostAccessMode): string {
if (mode === 'ask') return 'restricted'
if (mode === 'ask_every_time') return 'ask-every-time'
if (mode === 'structured') return 'standard'
if (mode === 'trusted' || mode === 'custom') return 'custom'
return 'full-access'
}
async function localHosts(): Promise<LocalHostSummary[]> {
const [sessions, active, aliases] = await Promise.all([listSessions(), getActiveDesktopRelayUrl(), getDesktopHostAliases()])
return Promise.all(Object.entries(sessions).map(async ([url, session]) => ({
url,
host: aliases[url] ?? hostLabel(url),
server_version: session.serverVersion,
endpoint_role: session.endpointRole ?? null,
paired_at: session.pairedAt,
is_active: url === active,
access_mode: effectiveHostAccessMode(
await getHostAccessMode(url),
session.toolsConsented === true
)
}))).then(hosts => hosts.sort((a, b) =>
return Promise.all(Object.entries(sessions).map(async ([url, session]) => {
const storedMode = await getHostAccessMode(url)
const legacyConsented = session.toolsConsented === true
return {
url,
host: aliases[url] ?? hostLabel(url),
server_version: session.serverVersion,
endpoint_role: session.endpointRole ?? null,
paired_at: session.pairedAt,
is_active: url === active,
access_mode: effectiveHostAccessMode(storedMode, legacyConsented),
capabilities: effectiveHostCapabilityPolicies(storedMode, legacyConsented, await getHostCapabilityPolicies(url))
}
})).then(hosts => hosts.sort((a, b) =>
Number(b.is_active) - Number(a.is_active) || b.paired_at - a.paired_at || a.url.localeCompare(b.url)
))
}
@@ -110,8 +138,9 @@ async function listHosts(args: ParsedArgs): Promise<number> {
process.stdout.write(t.bold(`Paired Hermes hosts (${hosts.length})`) + '\n')
for (const host of hosts) {
process.stdout.write(
` ${host.is_active ? '*' : ' '} ${host.host} ${host.access_mode.replace('_', '-')}\n` +
t.muted(` ${host.url}${host.endpoint_role ? ` (${host.endpoint_role})` : ''}`) + '\n'
` ${host.is_active ? '*' : ' '} ${host.host} ${displayAccessMode(host.access_mode)}\n` +
t.muted(` ${host.url}${host.endpoint_role ? ` (${host.endpoint_role})` : ''}`) + '\n' +
t.muted(` Commands: ${host.capabilities.commands} · Files: ${host.capabilities.files} · Screen/input: ${host.capabilities.screen_input} · USB: ${host.capabilities.usb}`) + '\n'
)
}
return 0
@@ -136,7 +165,7 @@ async function selectHost(args: ParsedArgs): Promise<number> {
async function setAccess(args: ParsedArgs): Promise<number> {
const mode = parseAccessMode(args.positional[0])
if (!mode) {
process.stderr.write('error: access mode must be ask, trusted, or full-access\n')
process.stderr.write('error: access mode must be restricted, ask-every-time, standard, or full-access\n')
return 2
}
const requested = typeof args.flags.remote === 'string' ? args.flags.remote.trim() : ''
@@ -148,7 +177,7 @@ async function setAccess(args: ParsedArgs): Promise<number> {
}
if (mode === 'full_access' && args.flags.yes !== true) {
process.stderr.write(
'error: Full Access allows this host to use commands, files, screen, keyboard, and mouse without task grants. Pass --yes to confirm.\n'
'error: Full Access allows every available capability, including commands, files, screen, input, and Raw USB, without task grants. Pass --yes to confirm.\n'
)
return 2
}
@@ -167,12 +196,91 @@ async function setAccess(args: ParsedArgs): Promise<number> {
if (args.flags.json) process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
else {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
process.stdout.write(t.okLine(`${hostLabel(url)} access set to ${mode.replace('_', '-')}`) + '\n')
process.stdout.write(t.okLine(`${hostLabel(url)} access set to ${displayAccessMode(mode)}`) + '\n')
process.stdout.write(t.muted('Restart the daemon to apply this policy.') + '\n')
}
return 0
}
async function setCapability(args: ParsedArgs): Promise<number> {
const capability = args.positional[0]?.trim().toLowerCase()
const mode = args.positional[1]?.trim().toLowerCase()
const normalizedCapability = capability?.replaceAll('-', '_') as HostCapability | undefined
if (!normalizedCapability || !HOST_CAPABILITIES.includes(normalizedCapability)) {
process.stderr.write('error: capability must be commands, files, screen-input, usb, microphone, or camera\n')
return 2
}
if (!CAPABILITY_ACCESS_MODES.includes(mode as typeof CAPABILITY_ACCESS_MODES[number])) {
process.stderr.write('error: capability mode must be disabled, ask, or allow\n')
return 2
}
if ((normalizedCapability === 'microphone' || normalizedCapability === 'camera') && mode !== 'disabled') {
process.stderr.write(`error: ${normalizedCapability} brokering is not available yet; leave it disabled\n`)
return 2
}
if (mode === 'allow' && args.flags.yes !== true) {
process.stderr.write(`error: allowing ${normalizedCapability.replace('_', '/')} lets this host use that capability without per-operation approval. Pass --yes to confirm.\n`)
return 2
}
const requested = typeof args.flags.remote === 'string' ? args.flags.remote.trim() : ''
const url = requested || await getActiveDesktopRelayUrl() || ''
const session = url ? await getSession(url) : null
if (!url || !session) {
process.stderr.write('error: select or pass a locally paired Hermes host\n')
return 1
}
const policy = await setHostCapabilityPolicy(
url,
normalizedCapability,
mode as typeof CAPABILITY_ACCESS_MODES[number]
)
await saveSession(url, session.token, session.serverVersion, {
pairedAt: session.pairedAt,
toolsConsented: Object.values(policy.capabilities).some(value => value !== 'disabled')
})
const payload = { ok: true, url, access_mode: policy.access_mode, capability: normalizedCapability, mode, capabilities: policy.capabilities, restart_required: true }
if (args.flags.json) process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
else {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const label = normalizedCapability === 'usb' ? 'Raw USB' : normalizedCapability.replace('_', '/')
process.stdout.write(t.okLine(`${hostLabel(url)} ${label} access set to ${mode} (${displayAccessMode(policy.access_mode)})`) + '\n')
process.stdout.write(t.muted('Restart the daemon to apply this policy.') + '\n')
}
return 0
}
async function forgetHost(args: ParsedArgs): Promise<number> {
const url = args.positional[0]?.trim() ?? ''
if (!url || !(await getSession(url))) {
process.stderr.write('error: `hosts forget` requires a locally paired relay URL\n')
return 1
}
if (args.flags.yes !== true) {
process.stderr.write('error: forgetting a host removes its local session, display name, and access policy. Pass --yes to confirm.\n')
return 2
}
const active = await getActiveDesktopRelayUrl()
await Promise.all([
deleteSession(url),
removeHostAccessPolicy(url),
setDesktopHostAlias(url, null)
])
let nextActive = active
if (active === url) {
const remaining = Object.keys(await listSessions()).sort()
nextActive = remaining[0] ?? null
await setActiveDesktopRelayUrl(nextActive)
}
const payload = { ok: true, forgotten_url: url, active_url: nextActive, restart_required: active === url }
if (args.flags.json) process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
else {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
process.stdout.write(t.okLine(`forgot host ${hostLabel(url)}`) + '\n')
if (active === url) process.stdout.write(t.muted(nextActive ? `Selected ${hostLabel(nextActive)} as the active host.` : 'No active host remains.') + '\n')
}
return 0
}
export async function hostsCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(HOSTS_USAGE, makeTheme({ noColor: !!args.flags['no-color'] }))
@@ -182,7 +290,9 @@ export async function hostsCommand(args: ParsedArgs): Promise<number> {
if (subcommand === 'list') return listHosts(args)
if (subcommand === 'select') return selectHost(args)
if (subcommand === 'rename') return renameHost(args)
if (subcommand === 'forget') return forgetHost(args)
if (subcommand === 'access') return setAccess(args)
if (subcommand === 'capability') return setCapability(args)
return unknownSubcommand(HOSTS_USAGE, subcommand, makeTheme({ noColor: !!args.flags['no-color'] }))
}
+2 -4
View File
@@ -206,6 +206,7 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
grants: outcome.meta.grants,
ttlExpiresAt: outcome.meta.ttlExpiresAt,
endpointRole: target.endpointRole,
initializeAccessPolicy: true,
...(autoGrant ? { toolsConsented: true } : {})
})
process.stdout.write(t.okLine('Paired. Token stored in ~/.hermes/remote-sessions.json') + '\n')
@@ -228,11 +229,8 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
)
}
} else {
// Nudge the daemon-first workflow: most users who pair from a terminal
// want desktop tools, and discovering --grant-tools after the fact means
// an extra `shell` round-trip. Surface it once, here.
process.stdout.write(
t.muted(' tip: add --grant-tools to also enable desktop tools (needed for `daemon`).') + '\n'
t.muted(' desktop access: Ask Every Time (start `daemon`; each operation requires local approval).') + '\n'
)
}
+15 -3
View File
@@ -72,6 +72,9 @@ import {
shouldAdvertiseComputerUse
} from '../tools/handlerSet.js'
import { DesktopToolRouter } from '../tools/router.js'
import { effectiveHostAccessMode, effectiveHostCapabilityPolicies, getHostAccessMode, getHostCapabilityPolicies } from '../lib/hostAccessPolicy.js'
import { configureCapabilityPolicies } from '../tools/capabilityRuntime.js'
import { adbBackendAvailable } from '../tools/handlers/adb.js'
import { RelayTransport } from '../transport/RelayTransport.js'
const ATTACH_TIMEOUT_MS = 30_000
@@ -152,6 +155,7 @@ export async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
const relay = new RelayTransport(cfg)
relay.onAuthSuccess((token, ver, meta) => {
void saveSession(url, token, ver, {
initializeAccessPolicy: true,
grants: meta.grants,
ttlExpiresAt: meta.ttlExpiresAt,
endpointRole
@@ -410,16 +414,24 @@ export async function shellCommand(args: ParsedArgs): Promise<number> {
const consent = await ensureToolsConsent(url)
if (consent.consented) {
const computerUseEnabled = shouldAdvertiseComputerUse(args.flags)
const storedAccessMode = await getHostAccessMode(url)
const accessMode = effectiveHostAccessMode(storedAccessMode, consent.consented)
const capabilities = effectiveHostCapabilityPolicies(storedAccessMode, consent.consented, await getHostCapabilityPolicies(url))
configureCapabilityPolicies(capabilities)
const usb = capabilities.usb !== 'disabled'
const adb = usb && adbBackendAvailable()
configureComputerUseRuntime({
url,
computerUseConsented: computerUseEnabled,
consentSource: consent.source ?? 'stored'
consentSource: consent.source ?? 'stored',
accessMode,
capabilities
})
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled })
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled, capabilities, usb, adb })
toolRouter = new DesktopToolRouter({
consentGranted: true,
hostUrl: url,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
handlers: desktopHandlers({ computerUse: computerUseEnabled, capabilities, usb, adb }),
advertisedTools: [...advertisedTools]
})
toolRouter.attach(relay)
+1
View File
@@ -114,6 +114,7 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
const relay = new RelayTransport(relayCfg)
relay.onAuthSuccess((token, ver, meta) => {
void saveSession(url, token, ver, {
initializeAccessPolicy: true,
grants: meta.grants,
ttlExpiresAt: meta.ttlExpiresAt,
endpointRole
+1
View File
@@ -400,6 +400,7 @@ async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
relay.onAuthSuccess((token, ver, meta) => {
mintedToken = token
void saveSession(url, token, ver, {
initializeAccessPolicy: true,
grants: meta.grants,
ttlExpiresAt: meta.ttlExpiresAt,
endpointRole
+66 -3
View File
@@ -1,5 +1,66 @@
import { execFileSync } from 'node:child_process'
import { hostname, machine, release, type } from 'node:os'
import { randomUUID } from 'node:crypto'
import {
chmodSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { homedir, hostname, machine, release, type } from 'node:os'
import { dirname, join } from 'node:path'
const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const defaultDeviceIdPath = () => join(homedir(), '.hermes', 'desktop-device-id')
function readDeviceId(path: string): string | null {
try {
const value = readFileSync(path, 'utf8').trim()
return DEVICE_ID_PATTERN.test(value) ? value.toLowerCase() : null
} catch {
return null
}
}
/** Stable private identity for one desktop CLI installation.
*
* Hostname and model are display metadata; they are not ownership keys. A
* random per-install UUID lets several PCs pair to the same relay without one
* PC's explicit re-pair revoking another PC's session. */
export function desktopDeviceId(path = defaultDeviceIdPath()): string {
const existing = readDeviceId(path)
if (existing) return existing
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const generated = randomUUID().toLowerCase()
try {
// Exclusive creation makes simultaneous first launches converge on the
// same stored identity instead of returning two different UUIDs.
writeFileSync(path, `${generated}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
return generated
} catch {
const raced = readDeviceId(path)
if (raced) return raced
// Repair a malformed legacy/partial file atomically.
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`
try {
writeFileSync(temporary, `${generated}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
renameSync(temporary, path)
chmodSync(path, 0o600)
return generated
} finally {
try {
unlinkSync(temporary)
} catch {
// rename consumed it, or creation failed before it existed
}
}
}
}
function windowsDeviceModel(): string | null {
if (process.platform !== 'win32') return null
@@ -26,13 +87,14 @@ function windowsDeviceModel(): string | null {
}
/** Consistent identity metadata for every desktop relay connection surface. */
export function desktopRelayIdentity(): {
export function desktopRelayIdentity(deviceIdPath?: string): {
deviceName: string
deviceHostname: string
deviceModel: string
devicePlatform: string
clientSurface: string
deviceFormFactor: string
deviceId: string
} {
const host = hostname().trim() || 'Hermes-Relay desktop'
return {
@@ -41,6 +103,7 @@ export function desktopRelayIdentity(): {
deviceModel: windowsDeviceModel() || machine() || process.arch,
devicePlatform: `${type()} ${release()}`.trim(),
clientSurface: 'desktop',
deviceFormFactor: 'desktop'
deviceFormFactor: 'desktop',
deviceId: desktopDeviceId(deviceIdPath)
}
}
+72 -2
View File
@@ -31,15 +31,27 @@ export interface AuditEntry {
exit_code?: number
/** Truncated preview of the call args, for context. */
args_preview?: string
/** Bounded, redacted request detail for the local activity drilldown. */
request_detail?: string
/** Bounded command streams when returned by the handler. */
stdout?: string
stderr?: string
/** Bounded structured result after stdout/stderr are removed. */
result_detail?: string
request_truncated?: boolean
stdout_truncated?: boolean
stderr_truncated?: boolean
result_truncated?: boolean
/** Short success summary (path / exit code / first stdout line). */
summary?: string
error?: string
}
export type AuditCategory = 'command' | 'files' | 'screen' | 'input' | 'system' | 'other'
export type AuditCategory = 'command' | 'files' | 'screen' | 'input' | 'devices' | 'system' | 'other'
/** Rotate the log once it crosses ~1 MB, keeping a single `.1` backup. */
const MAX_BYTES = 1_000_000
const MAX_DETAIL_BYTES = 32_768
export function auditLogPath(): string {
return join(homedir(), '.hermes', 'desktop-audit.jsonl')
@@ -86,7 +98,7 @@ export async function readRecentAudit(limit = 50): Promise<AuditEntry[]> {
export function previewArgs(args: Record<string, unknown>): string | undefined {
try {
const parts: string[] = []
for (const key of ['path', 'command', 'cmd', 'pattern', 'cwd', 'pid', 'port', 'name']) {
for (const key of ['serial', 'source', 'destination', 'apk', 'path', 'command', 'script', 'executable', 'cmd', 'pattern', 'cwd', 'pid', 'port', 'name']) {
const v = (args as Record<string, unknown>)[key]
if (v !== undefined && v !== null && typeof v !== 'object') {
parts.push(`${key}=${String(v)}`)
@@ -102,6 +114,63 @@ export function previewArgs(args: Record<string, unknown>): string | undefined {
}
}
function boundedText(value: string): { text: string; truncated: boolean } {
const bytes = Buffer.from(value, 'utf8')
if (bytes.length <= MAX_DETAIL_BYTES) return { text: value, truncated: false }
return {
text: `${bytes.subarray(0, MAX_DETAIL_BYTES).toString('utf8')}\n[… truncated locally at ${MAX_DETAIL_BYTES} bytes]`,
truncated: true
}
}
/** Preserve useful local drilldown evidence without logging secrets, file
* bodies, environment values, or unbounded process output. */
export function auditDetails(
args: Record<string, unknown>,
result?: unknown
): Pick<AuditEntry, 'request_detail' | 'stdout' | 'stderr' | 'result_detail' | 'request_truncated' | 'stdout_truncated' | 'stderr_truncated' | 'result_truncated'> {
const safeRequest: Record<string, unknown> = {}
for (const key of [
'command', 'script', 'executable', 'arguments', 'cwd', 'path', 'source', 'destination',
'pattern', 'serial', 'apk', 'pid', 'port', 'job_id', 'offset', 'limit', 'timeout', 'reason'
]) {
if (args[key] !== undefined) safeRequest[key] = args[key]
}
const request = boundedText(JSON.stringify(safeRequest, null, 2))
const details: Pick<AuditEntry, 'request_detail' | 'stdout' | 'stderr' | 'result_detail' | 'request_truncated' | 'stdout_truncated' | 'stderr_truncated' | 'result_truncated'> = {
request_detail: request.text,
request_truncated: request.truncated || undefined
}
if (!result || typeof result !== 'object') {
if (result !== undefined) {
const value = boundedText(String(result))
details.result_detail = value.text
details.result_truncated = value.truncated || undefined
}
return details
}
const record = { ...(result as Record<string, unknown>) }
if (typeof record.stdout === 'string') {
const value = boundedText(record.stdout)
details.stdout = value.text
details.stdout_truncated = value.truncated || undefined
delete record.stdout
}
if (typeof record.stderr === 'string') {
const value = boundedText(record.stderr)
details.stderr = value.text
details.stderr_truncated = value.truncated || undefined
delete record.stderr
}
const serialized = JSON.stringify(record, null, 2)
if (serialized !== '{}') {
const value = boundedText(serialized)
details.result_detail = value.text
details.result_truncated = value.truncated || undefined
}
return details
}
/** Best-effort short success summary from a handler result. */
export function summarizeResult(result: unknown): string | undefined {
if (result === null || typeof result !== 'object') {
@@ -133,6 +202,7 @@ export function resultExitCode(result: unknown): number | undefined {
/** A small stable taxonomy shared by CLI audit consumers and the tray UI. */
export function categorizeTool(tool: string): AuditCategory {
const value = tool.toLowerCase()
if (value.includes('adb') || value.includes('usb')) return 'devices'
if (value.includes('computer_screenshot') || value.includes('screen')) return 'screen'
if (value.includes('computer_') || value.includes('mouse') || value.includes('keyboard')) return 'input'
if (value.includes('file') || value.includes('directory') || value.includes('patch')) return 'files'
+173 -8
View File
@@ -2,21 +2,66 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export const HOST_ACCESS_MODES = ['ask', 'trusted', 'full_access'] as const
export const HOST_ACCESS_MODES = ['ask', 'ask_every_time', 'structured', 'trusted', 'full_access', 'custom'] as const
export const DESKTOP_CAPABILITIES = ['commands', 'files', 'screen_input'] as const
export const HARDWARE_CAPABILITIES = ['usb', 'microphone', 'camera'] as const
export const HOST_CAPABILITIES = [...DESKTOP_CAPABILITIES, ...HARDWARE_CAPABILITIES] as const
export const CAPABILITY_ACCESS_MODES = ['disabled', 'ask', 'allow'] as const
export type HostAccessMode = (typeof HOST_ACCESS_MODES)[number]
export type DesktopCapability = (typeof DESKTOP_CAPABILITIES)[number]
export type HardwareCapability = (typeof HARDWARE_CAPABILITIES)[number]
export type HostCapability = (typeof HOST_CAPABILITIES)[number]
export type CapabilityAccessMode = (typeof CAPABILITY_ACCESS_MODES)[number]
export type CapabilityPolicies = Record<HostCapability, CapabilityAccessMode>
export interface HostAccessPolicy {
access_mode: HostAccessMode
capabilities: CapabilityPolicies
updated_at?: string
}
export interface HostAccessPolicyFile {
version: 1
version: 2
hosts: Record<string, HostAccessPolicy>
}
const STORE_VERSION = 1 as const
const STORE_VERSION = 2 as const
export const DEFAULT_CAPABILITY_POLICIES: CapabilityPolicies = Object.freeze({
commands: 'disabled',
files: 'disabled',
screen_input: 'disabled',
usb: 'disabled',
microphone: 'disabled',
camera: 'disabled'
})
type PresetAccessMode = Exclude<HostAccessMode, 'custom'>
export const PRESET_CAPABILITY_POLICIES: Readonly<Record<PresetAccessMode, CapabilityPolicies>> = Object.freeze({
ask: Object.freeze({ ...DEFAULT_CAPABILITY_POLICIES }),
ask_every_time: Object.freeze({
commands: 'ask', files: 'ask', screen_input: 'ask', usb: 'ask', microphone: 'disabled', camera: 'disabled'
}),
structured: Object.freeze({
commands: 'disabled', files: 'allow', screen_input: 'ask', usb: 'ask', microphone: 'disabled', camera: 'disabled'
}),
trusted: Object.freeze({
commands: 'allow', files: 'allow', screen_input: 'ask', usb: 'ask', microphone: 'disabled', camera: 'disabled'
}),
full_access: Object.freeze({
commands: 'allow', files: 'allow', screen_input: 'allow', usb: 'allow', microphone: 'allow', camera: 'allow'
})
})
export function presetCapabilityPolicies(mode: HostAccessMode): CapabilityPolicies {
return { ...(mode === 'custom' ? DEFAULT_CAPABILITY_POLICIES : PRESET_CAPABILITY_POLICIES[mode]) }
}
function sameCapabilities(left: CapabilityPolicies, right: CapabilityPolicies): boolean {
return HOST_CAPABILITIES.every(capability => left[capability] === right[capability])
}
export function hostAccessPolicyPath(): string {
return process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH ??
@@ -48,17 +93,39 @@ export function isHostAccessMode(value: unknown): value is HostAccessMode {
const emptyStore = (): HostAccessPolicyFile => ({ version: STORE_VERSION, hosts: {} })
const restrictiveness = (mode: HostAccessMode): number => HOST_ACCESS_MODES.indexOf(mode)
const capabilityRestrictiveness = (mode: CapabilityAccessMode): number => CAPABILITY_ACCESS_MODES.indexOf(mode)
function parsePolicy(value: unknown): HostAccessPolicy | null {
if (isHostAccessMode(value)) return { access_mode: value }
if (isHostAccessMode(value)) {
return { access_mode: value, capabilities: presetCapabilityPolicies(value) }
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const raw = value as Record<string, unknown>
const mode = raw.access_mode ?? raw.mode
if (!isHostAccessMode(mode)) return null
const rawCapabilities = raw.capabilities && typeof raw.capabilities === 'object' && !Array.isArray(raw.capabilities)
? raw.capabilities as Record<string, unknown>
: {}
const capabilities = presetCapabilityPolicies(mode)
let hasExplicitCapabilities = false
for (const capability of HOST_CAPABILITIES) {
const value = rawCapabilities[capability]
if (typeof value === 'string' && CAPABILITY_ACCESS_MODES.includes(value as CapabilityAccessMode)) {
capabilities[capability] = value as CapabilityAccessMode
hasExplicitCapabilities = true
}
}
// Full Access is intentionally a real override. Older stores could retain
// an independent USB Ask value, which was misleading because unrestricted
// commands could already reach that hardware.
if (mode === 'full_access') Object.assign(capabilities, PRESET_CAPABILITY_POLICIES.full_access)
const normalizedMode = mode !== 'custom' && hasExplicitCapabilities && !sameCapabilities(capabilities, PRESET_CAPABILITY_POLICIES[mode])
? 'custom'
: mode
return {
access_mode: mode,
access_mode: normalizedMode,
capabilities,
updated_at: typeof raw.updated_at === 'string' ? raw.updated_at : undefined
}
}
@@ -72,9 +139,28 @@ function mergePolicy(
if (!url) return
const current = hosts[url]
if (!current || restrictiveness(policy.access_mode) < restrictiveness(current.access_mode)) {
if (!current) {
hosts[url] = policy
return
}
const capabilities = Object.fromEntries(HOST_CAPABILITIES.map(capability => {
const currentMode = current.capabilities[capability]
const incomingMode = policy.capabilities[capability]
return [capability, capabilityRestrictiveness(incomingMode) < capabilityRestrictiveness(currentMode)
? incomingMode
: currentMode]
})) as CapabilityPolicies
hosts[url] = {
access_mode: inferAccessMode(capabilities),
capabilities
}
}
export function inferAccessMode(capabilities: CapabilityPolicies): HostAccessMode {
for (const mode of ['ask', 'ask_every_time', 'structured', 'trusted', 'full_access'] as const) {
if (sameCapabilities(capabilities, PRESET_CAPABILITY_POLICIES[mode])) return mode
}
return 'custom'
}
/**
@@ -103,7 +189,12 @@ export function parseHostAccessPolicyFile(value: unknown): HostAccessPolicyFile
const urls = raw[field]
if (!Array.isArray(urls)) continue
for (const url of urls) {
if (typeof url === 'string') mergePolicy(hosts, url, { access_mode: mode })
if (typeof url === 'string') {
mergePolicy(hosts, url, {
access_mode: mode,
capabilities: presetCapabilityPolicies(mode)
})
}
}
}
@@ -150,8 +241,12 @@ export async function setHostAccessMode(
if (!isHostAccessMode(accessMode)) throw new Error(`unsupported host access mode: ${String(accessMode)}`)
const store = await readHostAccessPolicies(filePath)
const current = store.hosts[canonical]
const policy: HostAccessPolicy = {
access_mode: accessMode,
capabilities: accessMode === 'custom'
? current?.capabilities ?? { ...DEFAULT_CAPABILITY_POLICIES }
: presetCapabilityPolicies(accessMode),
updated_at: new Date().toISOString()
}
store.hosts[canonical] = policy
@@ -159,6 +254,66 @@ export async function setHostAccessMode(
return policy
}
/** Initialize a newly paired host without changing an existing or migrated
* policy. Missing policy records otherwise keep the legacy fail-closed
* Restricted behavior. */
export async function initializePairedHostAccessPolicy(
relayUrl: string,
filePath = hostAccessPolicyPath()
): Promise<HostAccessPolicy> {
const canonical = canonicalRelayUrl(relayUrl)
if (!canonical) throw new Error('host access policy requires a valid ws:// or wss:// relay URL')
const store = await readHostAccessPolicies(filePath)
const existing = store.hosts[canonical]
if (existing) return existing
const policy: HostAccessPolicy = {
access_mode: 'ask_every_time',
capabilities: presetCapabilityPolicies('ask_every_time'),
updated_at: new Date().toISOString()
}
store.hosts[canonical] = policy
await writeJsonAtomic(filePath, store)
return policy
}
export async function getHostCapabilityPolicies(
relayUrl: string,
filePath = hostAccessPolicyPath()
): Promise<CapabilityPolicies> {
const canonical = canonicalRelayUrl(relayUrl)
if (!canonical) return { ...DEFAULT_CAPABILITY_POLICIES }
return (await readHostAccessPolicies(filePath)).hosts[canonical]?.capabilities ??
{ ...DEFAULT_CAPABILITY_POLICIES }
}
export async function setHostCapabilityPolicy(
relayUrl: string,
capability: HostCapability,
mode: CapabilityAccessMode,
filePath = hostAccessPolicyPath()
): Promise<HostAccessPolicy> {
const canonical = canonicalRelayUrl(relayUrl)
if (!canonical) throw new Error('host capability policy requires a valid relay URL')
if (!HOST_CAPABILITIES.includes(capability)) throw new Error(`unsupported capability: ${capability}`)
if (!CAPABILITY_ACCESS_MODES.includes(mode)) throw new Error(`unsupported capability mode: ${mode}`)
const store = await readHostAccessPolicies(filePath)
const current = store.hosts[canonical] ?? {
access_mode: 'ask' as HostAccessMode,
capabilities: { ...DEFAULT_CAPABILITY_POLICIES }
}
if (current.capabilities[capability] === mode) return current
const policy: HostAccessPolicy = {
...current,
capabilities: { ...current.capabilities, [capability]: mode },
access_mode: 'custom',
updated_at: new Date().toISOString()
}
policy.access_mode = inferAccessMode(policy.capabilities)
store.hosts[canonical] = policy
await writeJsonAtomic(filePath, store)
return policy
}
export async function removeHostAccessPolicy(
relayUrl: string,
filePath = hostAccessPolicyPath()
@@ -191,3 +346,13 @@ export function effectiveHostAccessMode(
): HostAccessMode {
return storedMode === 'ask' && legacyToolsConsented ? 'trusted' : storedMode
}
export function effectiveHostCapabilityPolicies(
storedMode: HostAccessMode,
legacyToolsConsented: boolean,
storedCapabilities: CapabilityPolicies
): CapabilityPolicies {
return storedMode === 'ask' && legacyToolsConsented
? presetCapabilityPolicies('trusted')
: { ...storedCapabilities }
}
+8
View File
@@ -7,6 +7,8 @@ import { promises as fs } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { initializePairedHostAccessPolicy } from './lib/hostAccessPolicy.js'
export interface RemoteSessionRecord {
token: string
serverVersion: string | null
@@ -149,6 +151,9 @@ export interface SaveSessionOptions {
endpointRole?: string | null
toolsConsented?: boolean
computerUseConsented?: boolean
/** Set only on a successful fresh pairing. Existing sessions and policy are
* never rewritten by routine token/session refreshes. */
initializeAccessPolicy?: boolean
}
export const saveSession = async (
@@ -185,6 +190,9 @@ export const saveSession = async (
: (prev?.computer_use_consented ?? false)
})
await writeFile(file)
if (!prev && options.initializeAccessPolicy) {
await initializePairedHostAccessPolicy(url)
}
} catch {
// Persistence failures are non-fatal — next run just re-pairs.
}
+16
View File
@@ -0,0 +1,16 @@
import type { CapabilityPolicies, HostCapability } from '../lib/hostAccessPolicy.js'
import { DEFAULT_CAPABILITY_POLICIES } from '../lib/hostAccessPolicy.js'
let policies: CapabilityPolicies = { ...DEFAULT_CAPABILITY_POLICIES }
export function configureCapabilityPolicies(next: CapabilityPolicies): void {
policies = { ...next }
}
export function capabilityPolicy(capability: HostCapability) {
return policies[capability]
}
export function capabilitySummary(): CapabilityPolicies {
return { ...policies }
}
+23 -11
View File
@@ -1,4 +1,9 @@
import type { HostAccessMode } from '../lib/hostAccessPolicy.js'
import {
DEFAULT_CAPABILITY_POLICIES,
presetCapabilityPolicies,
type CapabilityPolicies,
type HostAccessMode
} from '../lib/hostAccessPolicy.js'
export type ComputerGrantMode = 'observe' | 'assist' | 'control'
@@ -22,6 +27,7 @@ export interface ComputerUseRuntime {
computerUseConsented: boolean
consentSource: 'stored' | 'prompted' | 'override' | 'none'
accessMode: HostAccessMode
capabilities: CapabilityPolicies
}
let activeGrant: ComputerGrant | null = null
@@ -30,7 +36,8 @@ let runtime: ComputerUseRuntime = {
url: null,
computerUseConsented: false,
consentSource: 'none',
accessMode: 'ask'
accessMode: 'ask',
capabilities: { ...DEFAULT_CAPABILITY_POLICIES }
}
function nowMs(): number {
@@ -101,13 +108,15 @@ export function getActiveComputerGrant(): ComputerGrant | null {
}
export function getComputerGrantSummary(): Record<string, unknown> {
if (runtime.accessMode === 'full_access') {
if (runtime.capabilities.screen_input === 'allow') {
return {
active: true,
mode: 'full_access',
mode: runtime.accessMode === 'full_access' ? 'full_access' : 'capability_allow',
expires_at: null,
scope: null,
reason: 'This host has Full Access.'
reason: runtime.accessMode === 'full_access'
? 'This host has Full Access.'
: 'Screen and input are allowed by this host policy.'
}
}
const grant = getActiveComputerGrant()
@@ -130,9 +139,11 @@ export function getComputerGrantSummary(): Record<string, unknown> {
}
export function configureComputerUseRuntime(next: Partial<ComputerUseRuntime>): void {
const capabilities = next.capabilities ?? (next.accessMode ? presetCapabilityPolicies(next.accessMode) : runtime.capabilities)
runtime = {
...runtime,
...next
...next,
capabilities
}
}
@@ -142,7 +153,8 @@ export function getComputerUseRuntimeSummary(): Record<string, unknown> {
consented: runtime.computerUseConsented,
consent_source: runtime.consentSource,
access_mode: runtime.accessMode,
full_access: runtime.accessMode === 'full_access'
full_access: runtime.accessMode === 'full_access',
capabilities: { ...runtime.capabilities }
}
}
@@ -154,10 +166,10 @@ export interface RequestComputerGrantInput {
}
export function requestComputerGrant(input: RequestComputerGrantInput): Record<string, unknown> {
if (runtime.accessMode === 'full_access') {
if (runtime.capabilities.screen_input === 'allow') {
return {
ok: true,
full_access: true,
full_access: runtime.accessMode === 'full_access',
grant: getComputerGrantSummary(),
message: 'This host already has Full Access; no task grant is required.'
}
@@ -212,13 +224,13 @@ export function cancelComputerGrant(reason = 'cancelled'): Record<string, unknow
}
export function hasComputerInputGrant(): boolean {
if (runtime.accessMode === 'full_access') return true
if (runtime.capabilities.screen_input === 'allow') return true
const grant = getActiveComputerGrant()
return grant?.mode === 'assist' || grant?.mode === 'control'
}
export function hasComputerObserveGrant(): boolean {
return runtime.accessMode === 'full_access' || getActiveComputerGrant() !== null
return runtime.capabilities.screen_input === 'allow' || getActiveComputerGrant() !== null
}
export function hasFullHostAccess(): boolean {
+167 -9
View File
@@ -20,6 +20,15 @@ import {
jobStatusHandler
} from './handlers/jobs.js'
import { powershellHandler } from './handlers/powershell.js'
import {
adbDevicesHandler,
adbInstallHandler,
adbLogcatHandler,
adbPullHandler,
adbPushHandler,
adbShellHandler
} from './handlers/adb.js'
import { usbDevicesHandler, usbRunHandler } from './handlers/usb.js'
import {
findPidByPortHandler,
killProcessHandler,
@@ -44,6 +53,13 @@ import {
} from './handlers/computer.js'
import type { ToolHandler } from './router.js'
import { readDesktopUseSettingsSync } from '../lib/desktopUseSettings.js'
import { approveComputerGrant } from './computerActionApproval.js'
import {
DEFAULT_CAPABILITY_POLICIES,
type CapabilityAccessMode,
type CapabilityPolicies,
type HostCapability
} from '../lib/hostAccessPolicy.js'
/** Experimental computer-use tools are registered in the local handler map
* but heartbeat-advertised only when persistently enabled or explicitly
@@ -91,9 +107,44 @@ const BASE_DESKTOP_HANDLERS: Record<string, ToolHandler> = {
desktop_clipboard_read: clipboardReadHandler,
desktop_clipboard_write: clipboardWriteHandler,
desktop_screenshot: screenshotHandler,
desktop_open_in_editor: openInEditorHandler
desktop_open_in_editor: openInEditorHandler,
desktop_usb_devices: usbDevicesHandler,
desktop_usb_run: usbRunHandler,
desktop_adb_devices: adbDevicesHandler,
desktop_adb_shell: adbShellHandler,
desktop_adb_push: adbPushHandler,
desktop_adb_pull: adbPullHandler,
desktop_adb_install: adbInstallHandler,
desktop_adb_logcat: adbLogcatHandler
}
export const RAW_EXECUTION_TOOLS = Object.freeze([
'desktop_terminal',
'desktop_powershell',
'desktop_spawn_detached',
'desktop_job_start'
])
export const FILE_TOOLS = Object.freeze([
'desktop_read_file', 'desktop_write_file', 'desktop_patch', 'desktop_search_files',
'desktop_copy_directory', 'desktop_zip', 'desktop_unzip', 'desktop_checksum', 'desktop_open_in_editor'
])
export const SCREEN_INPUT_TOOLS = Object.freeze([
'desktop_clipboard_read', 'desktop_clipboard_write', 'desktop_screenshot', ...DESKTOP_COMPUTER_USE_TOOLS
])
export const RAW_USB_TOOLS = Object.freeze([
'desktop_usb_devices',
'desktop_usb_run'
])
export const ADB_TOOLS = Object.freeze([
'desktop_adb_devices',
'desktop_adb_shell',
'desktop_adb_push',
'desktop_adb_pull',
'desktop_adb_install',
'desktop_adb_logcat'
])
export const USB_TOOLS = Object.freeze([...RAW_USB_TOOLS, ...ADB_TOOLS])
const COMPUTER_USE_HANDLERS: Record<string, ToolHandler> = {
desktop_computer_status: computerStatusHandler,
desktop_computer_screenshot: computerScreenshotHandler,
@@ -111,6 +162,10 @@ export const DESKTOP_HANDLERS: Record<string, ToolHandler> = {
export interface DesktopAdvertiseOptions {
computerUse?: boolean
structuredOnly?: boolean
usb?: boolean
adb?: boolean
capabilities?: CapabilityPolicies
}
function envEnabled(value: string | undefined): boolean {
@@ -139,10 +194,117 @@ export function shouldAdvertiseComputerUse(
export function desktopHandlers(
opts: DesktopAdvertiseOptions = {}
): Record<string, ToolHandler> {
if (opts.computerUse !== true) {
return BASE_DESKTOP_HANDLERS
const policies: CapabilityPolicies = opts.capabilities ?? {
...DEFAULT_CAPABILITY_POLICIES,
commands: opts.structuredOnly === true ? 'disabled' : 'allow',
files: 'allow',
screen_input: opts.computerUse === true ? 'ask' : 'disabled',
usb: opts.usb === true ? 'allow' : 'disabled'
}
const handlers = opts.computerUse === true && policies.screen_input !== 'disabled'
? DESKTOP_HANDLERS
: BASE_DESKTOP_HANDLERS
const raw = new Set(RAW_EXECUTION_TOOLS)
const files = new Set(FILE_TOOLS)
const screenInput = new Set(SCREEN_INPUT_TOOLS)
const rawUsb = new Set(RAW_USB_TOOLS)
const adb = new Set(ADB_TOOLS)
const capabilityFor = (name: string): HostCapability | null =>
raw.has(name) ? 'commands'
: files.has(name) ? 'files'
: screenInput.has(name) ? 'screen_input'
: rawUsb.has(name) || adb.has(name) ? 'usb'
: null
const guarded = Object.entries(handlers).flatMap(([name, handler]) => {
const capability = capabilityFor(name)
const mode = capability ? policies[capability] : 'allow'
if (mode === 'disabled') return []
if (adb.has(name) && opts.adb !== true) return []
if (mode !== 'ask' || name.startsWith('desktop_computer_') || name === 'desktop_patch') {
return [[name, handler] as const]
}
return [[name, guardCapabilityHandler(name, capability!, mode, handler)] as const]
})
return Object.fromEntries(guarded)
}
function guardCapabilityHandler(
tool: string,
capability: HostCapability,
mode: CapabilityAccessMode,
handler: ToolHandler
): ToolHandler {
if (mode !== 'ask') return handler
return async (args, ctx) => {
const approval = await approveComputerGrant({
mode: `${capability}.${tool.replace(/^desktop_/, '')}`,
durationSeconds: 120,
reason: typeof args.reason === 'string' && args.reason.trim()
? args.reason.trim()
: `Run ${tool.replaceAll('_', ' ')}`,
scope: buildCapabilityGrantScope(tool, capability, args),
interactive: ctx.interactive
})
if (!approval.approved) throw new Error(approval.reason || `${capability} request rejected locally`)
return handler(args, ctx)
}
}
const GRANT_PREVIEW_LIMIT = 2_000
function previewText(value: unknown): string | null {
if (typeof value !== 'string') return null
const clean = value.replaceAll(/[^\S\r\n]+/g, ' ').replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim()
if (!clean) return null
return clean.length > GRANT_PREVIEW_LIMIT ? `${clean.slice(0, GRANT_PREVIEW_LIMIT)}\n… preview truncated` : clean
}
/** Build the local approval context shown before an Ask-mode operation runs.
* Keep command text exact enough to review while summarizing large file bodies
* and environment maps rather than copying them into the bridge request. */
export function buildCapabilityGrantScope(
tool: string,
capability: HostCapability,
args: Record<string, unknown>
): Record<string, unknown> {
let action = tool.replace(/^desktop_/, '').replaceAll('_', ' ')
let preview: string | null = null
if (tool === 'desktop_powershell') {
action = 'PowerShell script'
preview = previewText(args.script)
} else if (['desktop_terminal', 'desktop_job_start', 'desktop_spawn_detached'].includes(tool)) {
action = tool === 'desktop_job_start' ? 'Background command' : 'Terminal command'
preview = previewText(args.command)
} else if (tool === 'desktop_read_file') {
action = 'Read file'
preview = previewText(args.path)
} else if (tool === 'desktop_write_file') {
action = 'Write file'
const path = previewText(args.path)
const contentLength = typeof args.content === 'string' ? args.content.length : null
preview = path ? `${path}${contentLength === null ? '' : ` (${contentLength.toLocaleString()} characters)`}` : null
} else if (tool === 'desktop_search_files') {
action = 'Search files'
const pattern = previewText(args.pattern)
const path = previewText(args.path)
preview = [pattern ? `Pattern: ${pattern}` : null, path ? `Path: ${path}` : null].filter(Boolean).join('\n') || null
} else if (capability === 'usb') {
action = tool.replace(/^desktop_/, '').replaceAll('_', ' ')
const executable = previewText(args.executable)
const command = previewText(args.command)
const argumentList = Array.isArray(args.arguments)
? args.arguments.filter(value => typeof value === 'string').join(' ')
: null
preview = previewText([executable, argumentList, command].filter(Boolean).join(' '))
}
return {
capability,
tool,
action,
...(preview ? { preview } : {})
}
return DESKTOP_HANDLERS
}
/** Stable list of advertised tool names — what the heartbeat claims to
@@ -151,11 +313,7 @@ export function desktopHandlers(
export function advertisedDesktopTools(
opts: DesktopAdvertiseOptions = {}
): readonly string[] {
if (opts.computerUse !== true) {
const experimental = new Set(DESKTOP_COMPUTER_USE_TOOLS)
return Object.freeze(Object.keys(DESKTOP_HANDLERS).filter(name => !experimental.has(name)))
}
return Object.freeze(Object.keys(DESKTOP_HANDLERS))
return Object.freeze(Object.keys(desktopHandlers(opts)))
}
export const DESKTOP_ADVERTISED_TOOLS: readonly string[] = advertisedDesktopTools({
+152
View File
@@ -0,0 +1,152 @@
import { spawn, spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
import { approveComputerGrant } from '../computerActionApproval.js'
import { capabilityPolicy } from '../capabilityRuntime.js'
import type { ToolContext, ToolHandler } from '../router.js'
const MAX_OUTPUT_BYTES = 1024 * 1024
const DEFAULT_TIMEOUT_MS = 30_000
const MAX_TIMEOUT_MS = 120_000
const SERIAL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
export function adbBackendAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
const executable = env.HERMES_RELAY_ADB_PATH?.trim() || 'adb'
const result = spawnSync(executable, ['version'], { windowsHide: true, stdio: 'ignore', env })
return !result.error && result.status === 0
}
function requiredString(value: unknown, name: string): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(`missing or invalid "${name}" argument`)
return value.trim()
}
function serialArg(value: unknown): string {
const serial = requiredString(value, 'serial')
if (!SERIAL_PATTERN.test(serial)) throw new Error('invalid ADB serial')
return serial
}
function timeoutMs(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.min(Math.floor(value * 1000), MAX_TIMEOUT_MS)
: DEFAULT_TIMEOUT_MS
}
async function authorize(
operation: string,
scope: Record<string, unknown>,
reason: unknown,
ctx: ToolContext
): Promise<void> {
const policy = capabilityPolicy('usb')
if (policy === 'disabled') throw new Error('Raw USB access is disabled for this Hermes host')
if (policy === 'allow') return
const approval = await approveComputerGrant({
mode: `usb.${operation}`,
durationSeconds: 120,
reason: typeof reason === 'string' && reason.trim() ? reason.trim() : `Run brokered ADB ${operation}`,
scope,
interactive: ctx.interactive
})
if (!approval.approved) throw new Error(approval.reason || 'ADB request rejected locally')
}
async function runAdb(args: string[], ctx: ToolContext, timeout = DEFAULT_TIMEOUT_MS) {
const executable = process.env.HERMES_RELAY_ADB_PATH?.trim() || 'adb'
const child = spawn(executable, args, {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env
})
let stdout = Buffer.alloc(0)
let stderr = Buffer.alloc(0)
let stdoutBytes = 0
let stderrBytes = 0
let killedBy: 'timeout' | 'abort' | null = null
child.stdout.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length
if (stdout.length < MAX_OUTPUT_BYTES) stdout = Buffer.concat([stdout, chunk.subarray(0, MAX_OUTPUT_BYTES - stdout.length)])
})
child.stderr.on('data', (chunk: Buffer) => {
stderrBytes += chunk.length
if (stderr.length < MAX_OUTPUT_BYTES) stderr = Buffer.concat([stderr, chunk.subarray(0, MAX_OUTPUT_BYTES - stderr.length)])
})
const timer = setTimeout(() => { killedBy = 'timeout'; child.kill('SIGKILL') }, timeout)
timer.unref?.()
const abort = () => { killedBy = 'abort'; child.kill('SIGKILL') }
ctx.abortSignal.addEventListener('abort', abort, { once: true })
try {
const exitCode = await new Promise<number>((resolve, reject) => {
child.once('error', reject)
child.once('close', code => {
if (killedBy) reject(new Error(killedBy === 'timeout' ? `ADB timed out after ${timeout}ms` : 'ADB request aborted'))
else resolve(code ?? 1)
})
})
return {
stdout: stdout.toString('utf8'),
stderr: stderr.toString('utf8'),
exit_code: exitCode,
output: {
limit_bytes_per_stream: MAX_OUTPUT_BYTES,
stdout: { bytes: stdoutBytes, captured_bytes: stdout.length, truncated: stdoutBytes > stdout.length },
stderr: { bytes: stderrBytes, captured_bytes: stderr.length, truncated: stderrBytes > stderr.length }
}
}
} finally {
clearTimeout(timer)
ctx.abortSignal.removeEventListener('abort', abort)
}
}
export const adbDevicesHandler: ToolHandler = async (args, ctx) => {
await authorize('devices', {}, args.reason, ctx)
const result = await runAdb(['devices', '-l'], ctx)
const devices = result.stdout.split(/\r?\n/).slice(1).filter(Boolean).map(line => {
const [serial = '', state = '', ...details] = line.trim().split(/\s+/)
const metadata = Object.fromEntries(details.filter(item => item.includes(':')).map(item => item.split(/:(.*)/s).slice(0, 2)))
return { serial, state, ...metadata }
})
return { ...result, devices }
}
export const adbShellHandler: ToolHandler = async (args, ctx) => {
const serial = serialArg(args.serial)
const command = requiredString(args.command, 'command')
await authorize('shell', { serial, command }, args.reason, ctx)
return runAdb(['-s', serial, 'shell', command], ctx, timeoutMs(args.timeout))
}
export const adbPushHandler: ToolHandler = async (args, ctx) => {
const serial = serialArg(args.serial)
const source = resolve(ctx.cwd, requiredString(args.source, 'source'))
const destination = requiredString(args.destination, 'destination')
await authorize('push', { serial, source, destination }, args.reason, ctx)
return runAdb(['-s', serial, 'push', source, destination], ctx, timeoutMs(args.timeout))
}
export const adbPullHandler: ToolHandler = async (args, ctx) => {
const serial = serialArg(args.serial)
const source = requiredString(args.source, 'source')
const destination = resolve(ctx.cwd, requiredString(args.destination, 'destination'))
await authorize('pull', { serial, source, destination }, args.reason, ctx)
return runAdb(['-s', serial, 'pull', source, destination], ctx, timeoutMs(args.timeout))
}
export const adbInstallHandler: ToolHandler = async (args, ctx) => {
const serial = serialArg(args.serial)
const apk = resolve(ctx.cwd, requiredString(args.apk, 'apk'))
const replace = args.replace !== false
await authorize('install', { serial, apk, replace }, args.reason, ctx)
return runAdb(['-s', serial, 'install', ...(replace ? ['-r'] : []), apk], ctx, timeoutMs(args.timeout))
}
export const adbLogcatHandler: ToolHandler = async (args, ctx) => {
const serial = serialArg(args.serial)
const lines = typeof args.lines === 'number' && Number.isFinite(args.lines)
? Math.max(1, Math.min(Math.floor(args.lines), 5000))
: 500
await authorize('logcat', { serial, lines }, args.reason, ctx)
return runAdb(['-s', serial, 'logcat', '-d', '-t', String(lines)], ctx, timeoutMs(args.timeout))
}
+4 -2
View File
@@ -12,6 +12,7 @@ import { promises as fs } from 'node:fs'
import * as path from 'node:path'
import { approveOrReject } from '../patchApproval.js'
import { capabilityPolicy } from '../capabilityRuntime.js'
import { hasFullHostAccess } from '../computerGrants.js'
import type { ToolContext, ToolHandler } from '../router.js'
@@ -248,7 +249,8 @@ export const patchHandler: ToolHandler = async (args, ctx) => {
// Interactive gate. The approver returns a decision — accepted or
// rejected with a reason — and never throws. If the user edited the
// diff we re-parse the edited version (strict; still no fuzz).
const decision = hasFullHostAccess()
const filesAllowed = hasFullHostAccess() || capabilityPolicy('files') === 'allow'
const decision = filesAllowed
? { accepted: true }
: await approveOrReject(patchText, {
targetFile: abs,
@@ -258,7 +260,7 @@ export const patchHandler: ToolHandler = async (args, ctx) => {
const reason = decision.reason ?? 'user rejected patch'
throw new Error(`patch rejected: ${reason}`)
}
let approvalTag: 'auto' | 'user' | 'edited' = hasFullHostAccess() ? 'auto' : 'user'
let approvalTag: 'auto' | 'user' | 'edited' = filesAllowed ? 'auto' : 'user'
if (decision.editedPatch && decision.editedPatch !== patchText) {
try {
hunks = parseUnifiedDiff(decision.editedPatch)
+98 -60
View File
@@ -10,10 +10,9 @@
// are unusable. Real-world fallout: the user reported scripts echoing back
// to the prompt instead of executing.
//
// We avoid all of that by spawning PowerShell directly (no cmd wrapper) and
// piping the script through stdin with `-Command -`. Script text never
// touches argv, so PowerShell's own parser doesn't have to compete with
// a host shell's quoting rules first.
// We avoid all of that by spawning PowerShell directly (no cmd wrapper) with
// a private temporary UTF-8 script file. Script text never touches argv, so
// PowerShell's parser doesn't compete with a host shell's quoting rules first.
//
// Discovery order (override with `prefer`):
// - 'pwsh' PowerShell 7+ (cross-platform). Preferred when present.
@@ -24,6 +23,9 @@
// PowerShell is required for this tool by definition.
import { spawn } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ToolHandler } from '../router.js'
@@ -38,6 +40,37 @@ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
type ShellPick = 'pwsh' | 'powershell'
interface CapturedStream {
text: string
bytes: number
captured_bytes: number
truncated: boolean
}
class BoundedCapture {
private readonly chunks: Buffer[] = []
private totalBytes = 0
private capturedBytes = 0
append(chunk: Buffer): void {
this.totalBytes += chunk.length
const remaining = MAX_OUTPUT_BYTES - this.capturedBytes
if (remaining <= 0) return
const captured = chunk.subarray(0, remaining)
this.chunks.push(captured)
this.capturedBytes += captured.length
}
result(): CapturedStream {
return {
text: Buffer.concat(this.chunks, this.capturedBytes).toString('utf8'),
bytes: this.totalBytes,
captured_bytes: this.capturedBytes,
truncated: this.totalBytes > this.capturedBytes
}
}
}
function argString(v: unknown, name: string): string {
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`missing or invalid "${name}" argument`)
@@ -121,63 +154,56 @@ export const powershellHandler: ToolHandler = async (args, ctx) => {
const shell = await pickShell(prefer)
// Common args that suppress the user's $PROFILE (deterministic execution),
// refuse to read from a TTY (we're not interactive), and read the script
// from stdin (the `-` after -Command). We then close stdin so the script
// can't hang on Read-Host.
const shellArgs = ['-NoProfile', '-NonInteractive', '-Command', '-']
// A private temporary script avoids both cmd.exe quoting and PowerShell's
// line-oriented `-Command -` stdin parser. The latter can report exit 0 while
// failing to render some success-pipeline records consistently through a
// non-interactive host. Force UTF-8 and explicitly propagate the last native
// executable exit code when the script did not call `exit` itself.
const scriptDirectory = await mkdtemp(join(tmpdir(), 'hermes-relay-powershell-'))
const scriptPath = join(scriptDirectory, 'invoke.ps1')
const wrappedScript = [
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)',
'$OutputEncoding = [Console]::OutputEncoding',
script,
'if ($null -ne (Get-Variable LASTEXITCODE -ErrorAction SilentlyContinue)) { exit $LASTEXITCODE }'
].join('\n')
try {
await writeFile(scriptPath, wrappedScript, { encoding: 'utf8', mode: 0o600 })
} catch (error) {
await rm(scriptDirectory, { recursive: true, force: true })
throw error
}
const shellArgs = ['-NoProfile', '-NonInteractive']
if (process.platform === 'win32') {
shellArgs.push('-ExecutionPolicy', 'Bypass')
}
shellArgs.push('-File', scriptPath)
const start = Date.now()
const child = spawn(shell, shellArgs, {
cwd,
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
let child
try {
child = spawn(shell, shellArgs, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
await rm(scriptDirectory, { recursive: true, force: true })
throw error
}
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
let truncated = false
const stdoutCapture = new BoundedCapture()
const stderrCapture = new BoundedCapture()
let killedBy: 'timeout' | 'abort' | null = null
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length
if (stdoutBytes > MAX_OUTPUT_BYTES) {
truncated = true
try {
child.kill('SIGKILL')
} catch {
/* ignore */
}
return
}
stdout += chunk.toString('utf8')
stdoutCapture.append(chunk)
})
child.stderr?.on('data', (chunk: Buffer) => {
stderrBytes += chunk.length
if (stderrBytes > MAX_OUTPUT_BYTES) {
truncated = true
try {
child.kill('SIGKILL')
} catch {
/* ignore */
}
return
}
stderr += chunk.toString('utf8')
stderrCapture.append(chunk)
})
// Pipe script text through stdin verbatim — PowerShell parses it as if
// typed into an interactive session. Single quotes, here-strings, $vars,
// multiline blocks all work without re-quoting.
try {
child.stdin?.end(script + '\n')
} catch {
/* if write fails, the close handler will still resolve with whatever we got */
}
const timer = setTimeout(() => {
killedBy = 'timeout'
try {
@@ -204,12 +230,6 @@ export const powershellHandler: ToolHandler = async (args, ctx) => {
const exitCode = await new Promise<number>((resolve, reject) => {
child.on('error', e => reject(e))
child.on('close', (code, signal) => {
if (truncated) {
// Output cap fired SIGKILL — surface as a non-zero exit so the
// agent doesn't treat the truncated output as authoritative.
resolve(code ?? 137)
return
}
if (killedBy === 'timeout') {
reject(new Error(`timed out after ${timeoutMs}ms`))
return
@@ -226,16 +246,34 @@ export const powershellHandler: ToolHandler = async (args, ctx) => {
})
})
const stdout = stdoutCapture.result()
const stderr = stderrCapture.result()
const truncated = stdout.truncated || stderr.truncated
return {
stdout,
stderr,
stdout: stdout.text,
stderr: stderr.text,
exit_code: exitCode,
duration_ms: Date.now() - start,
shell,
truncated
truncated,
...(truncated ? { truncation_reason: 'output_limit' } : {}),
output: {
limit_bytes_per_stream: MAX_OUTPUT_BYTES,
stdout: {
bytes: stdout.bytes,
captured_bytes: stdout.captured_bytes,
truncated: stdout.truncated
},
stderr: {
bytes: stderr.bytes,
captured_bytes: stderr.captured_bytes,
truncated: stderr.truncated
}
}
}
} finally {
clearTimeout(timer)
ctx.abortSignal.removeEventListener('abort', onAbort)
await rm(scriptDirectory, { recursive: true, force: true })
}
}
+151
View File
@@ -0,0 +1,151 @@
import { spawn } from 'node:child_process'
import { platform } from 'node:os'
import { resolve } from 'node:path'
import { approveComputerGrant } from '../computerActionApproval.js'
import { capabilityPolicy } from '../capabilityRuntime.js'
import type { ToolContext, ToolHandler } from '../router.js'
const MAX_OUTPUT_BYTES = 1024 * 1024
const DEFAULT_TIMEOUT_MS = 30_000
const MAX_TIMEOUT_MS = 120_000
const MAX_ARGUMENTS = 128
const MAX_ARGUMENT_LENGTH = 8192
function requiredString(value: unknown, name: string): string {
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) {
throw new Error(`missing or invalid "${name}" argument`)
}
return value.trim()
}
function argumentsList(value: unknown): string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.length > MAX_ARGUMENTS) {
throw new Error(`"arguments" must be an array with at most ${MAX_ARGUMENTS} entries`)
}
return value.map((argument, index) => {
if (typeof argument !== 'string' || argument.includes('\0') || argument.length > MAX_ARGUMENT_LENGTH) {
throw new Error(`invalid USB utility argument at index ${index}`)
}
return argument
})
}
function timeoutMs(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.min(Math.floor(value * 1000), MAX_TIMEOUT_MS)
: DEFAULT_TIMEOUT_MS
}
async function authorize(
operation: string,
scope: Record<string, unknown>,
reason: unknown,
ctx: ToolContext
): Promise<void> {
const policy = capabilityPolicy('usb')
if (policy === 'disabled') throw new Error('Raw USB access is disabled for this Hermes host')
if (policy === 'allow') return
const approval = await approveComputerGrant({
mode: `usb.${operation}`,
durationSeconds: 120,
reason: typeof reason === 'string' && reason.trim() ? reason.trim() : `Use raw USB ${operation}`,
scope,
interactive: ctx.interactive
})
if (!approval.approved) throw new Error(approval.reason || 'Raw USB request rejected locally')
}
async function runProcess(
executable: string,
args: string[],
ctx: ToolContext,
cwd?: string,
timeout = DEFAULT_TIMEOUT_MS
) {
const child = spawn(executable, args, {
cwd,
windowsHide: true,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env
})
let stdout = Buffer.alloc(0)
let stderr = Buffer.alloc(0)
let stdoutBytes = 0
let stderrBytes = 0
let killedBy: 'timeout' | 'abort' | null = null
child.stdout.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length
if (stdout.length < MAX_OUTPUT_BYTES) stdout = Buffer.concat([stdout, chunk.subarray(0, MAX_OUTPUT_BYTES - stdout.length)])
})
child.stderr.on('data', (chunk: Buffer) => {
stderrBytes += chunk.length
if (stderr.length < MAX_OUTPUT_BYTES) stderr = Buffer.concat([stderr, chunk.subarray(0, MAX_OUTPUT_BYTES - stderr.length)])
})
const timer = setTimeout(() => { killedBy = 'timeout'; child.kill('SIGKILL') }, timeout)
timer.unref?.()
const abort = () => { killedBy = 'abort'; child.kill('SIGKILL') }
ctx.abortSignal.addEventListener('abort', abort, { once: true })
try {
const exitCode = await new Promise<number>((resolveExit, reject) => {
child.once('error', reject)
child.once('close', code => {
if (killedBy) reject(new Error(killedBy === 'timeout' ? `USB utility timed out after ${timeout}ms` : 'USB request aborted'))
else resolveExit(code ?? 1)
})
})
return {
stdout: stdout.toString('utf8'),
stderr: stderr.toString('utf8'),
exit_code: exitCode,
executable,
arguments: args,
output: {
limit_bytes_per_stream: MAX_OUTPUT_BYTES,
stdout: { bytes: stdoutBytes, captured_bytes: stdout.length, truncated: stdoutBytes > stdout.length },
stderr: { bytes: stderrBytes, captured_bytes: stderr.length, truncated: stderrBytes > stderr.length }
}
}
} finally {
clearTimeout(timer)
ctx.abortSignal.removeEventListener('abort', abort)
}
}
function enumerationCommand(): { executable: string; arguments: string[]; format: string } {
if (platform() === 'win32') {
return {
executable: 'powershell.exe',
arguments: [
'-NoLogo', '-NoProfile', '-NonInteractive', '-Command',
"$ErrorActionPreference='Stop'; @(Get-CimInstance Win32_PnPEntity | Where-Object { $_.PNPDeviceID -like 'USB\\*' } | Select-Object Name,PNPDeviceID,Status,Service,Manufacturer) | ConvertTo-Json -Compress"
],
format: 'windows-pnp-json'
}
}
if (platform() === 'darwin') {
return { executable: 'system_profiler', arguments: ['SPUSBDataType', '-json'], format: 'system-profiler-json' }
}
return { executable: 'lsusb', arguments: [], format: 'lsusb-text' }
}
export const usbDevicesHandler: ToolHandler = async (args, ctx) => {
await authorize('devices', {}, args.reason, ctx)
const command = enumerationCommand()
const result = await runProcess(command.executable, command.arguments, ctx)
let devices: unknown = result.stdout.split(/\r?\n/).filter(Boolean)
if (command.format.endsWith('-json') && result.stdout.trim()) {
try { devices = JSON.parse(result.stdout) } catch { /* retain bounded raw lines */ }
}
return { ...result, format: command.format, devices }
}
export const usbRunHandler: ToolHandler = async (args, ctx) => {
const executable = requiredString(args.executable, 'executable')
const utilityArgs = argumentsList(args.arguments)
const cwd = typeof args.cwd === 'string' && args.cwd.trim() ? resolve(ctx.cwd, args.cwd.trim()) : ctx.cwd
await authorize('run', { executable, arguments: utilityArgs, cwd }, args.reason, ctx)
return runProcess(executable, utilityArgs, ctx, cwd, timeoutMs(args.timeout))
}
+9 -3
View File
@@ -27,12 +27,14 @@ import type { RelayTransport } from '../transport/RelayTransport.js'
import {
appendAudit,
auditDetails,
categorizeTool,
previewArgs,
resultExitCode,
summarizeResult
} from '../lib/auditLog.js'
import { VERSION } from '../version.js'
import { desktopDeviceId } from '../deviceIdentity.js'
import { getComputerGrantSummary, getComputerUseRuntimeSummary } from './computerGrants.js'
/** The payload shape server → client for a single tool invocation. */
@@ -234,7 +236,9 @@ export class DesktopToolRouter {
pid: process.pid,
started_at_ms: this.startedAtMs,
uptime_ms: Date.now() - this.startedAtMs,
interactive: this.interactive
interactive: this.interactive,
device_id: desktopDeviceId(),
device_name: os.hostname()
}
if (this.advertisedTools.some(name => name.startsWith('desktop_computer_'))) {
const runtime = getComputerUseRuntimeSummary()
@@ -286,7 +290,7 @@ export class DesktopToolRouter {
}
const controller = new AbortController()
const timeoutMs = tool.startsWith('desktop_computer_')
const timeoutMs = tool.startsWith('desktop_computer_') || tool.startsWith('desktop_adb_') || tool.startsWith('desktop_usb_')
? COMPUTER_USE_HANDLER_TIMEOUT_MS
: HANDLER_TIMEOUT_MS
const timeoutTimer = setTimeout(() => {
@@ -321,7 +325,8 @@ export class DesktopToolRouter {
duration_ms: Date.now() - startedAt,
exit_code: resultExitCode(result),
args_preview: previewArgs(args),
summary: summarizeResult(result)
summary: summarizeResult(result),
...auditDetails(args, result)
})
} catch (e) {
clearTimeout(timeoutTimer)
@@ -350,6 +355,7 @@ export class DesktopToolRouter {
host_url: this.hostUrl,
duration_ms: Date.now() - startedAt,
args_preview: previewArgs(args),
...auditDetails(args),
error: message
})
}
+14 -1
View File
@@ -1,17 +1,30 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { categorizeTool, resultExitCode, summarizeResult } from '../src/lib/auditLog.js'
import { auditDetails, categorizeTool, resultExitCode, summarizeResult } from '../src/lib/auditLog.js'
test('audit events classify the activity surfaces used by the tray', () => {
assert.equal(categorizeTool('desktop_powershell'), 'command')
assert.equal(categorizeTool('desktop_read_file'), 'files')
assert.equal(categorizeTool('desktop_computer_screenshot'), 'screen')
assert.equal(categorizeTool('desktop_computer_input'), 'input')
assert.equal(categorizeTool('desktop_adb_shell'), 'devices')
assert.equal(categorizeTool('daemon.connect'), 'system')
assert.equal(categorizeTool('desktop_unknown'), 'other')
})
test('activity drilldown retains bounded command and stream evidence without sensitive inputs', () => {
const details = auditDetails(
{ script: 'Write-Output "hello"', cwd: 'C:\\work', env: { SECRET: 'hidden' }, content: 'private file body' },
{ exit_code: 0, stdout: 'hello\n', stderr: '', output: { stdout: { truncated: false } } }
)
assert.match(details.request_detail ?? '', /Write-Output/)
assert.doesNotMatch(details.request_detail ?? '', /SECRET|private file body/)
assert.equal(details.stdout, 'hello\n')
assert.equal(details.stderr, '')
assert.match(details.result_detail ?? '', /"exit_code": 0/)
})
test('audit events preserve process exit outcome separately from dispatch success', () => {
const result = { exit_code: 17, stdout: '', stderr: 'failed' }
assert.equal(resultExitCode(result), 17)
+62
View File
@@ -6,10 +6,14 @@ import test from 'node:test'
import type { DaemonStatus } from '../src/lib/daemonStatus.js'
import {
__buildDaemonChildArgsForTests as buildDaemonChildArgs,
__buildElevationLaunchPlanForTests as buildElevationLaunchPlan,
__daemonStatusIsReadyForTests as daemonStatusIsReady,
__quoteWindowsArgumentForTests as quoteWindowsArgument,
__readDetachedStartupFailureForTests as readDetachedStartupFailure,
__waitForDetachedStartupForTests as waitForDetachedStartup
} from '../src/commands/daemon.js'
import type { ParsedArgs } from '../src/cli.js'
function status(pid: number, state: DaemonStatus['state']): DaemonStatus {
return {
@@ -99,3 +103,61 @@ test('startup log diagnostics only inspect bytes appended for this attempt', asy
)
assert.equal(await readDetachedStartupFailure(logPath, (await fs.stat(logPath)).size), null)
})
test('daemon child argv forwards runtime options without lifecycle privilege flags', () => {
const args: ParsedArgs = {
command: 'daemon',
positional: ['restart'],
flags: {
remote: 'wss://relay.example.test/path with space',
'no-voice': true,
administrator: true,
user: true
}
}
assert.deepEqual(buildDaemonChildArgs(args), [
'daemon',
'--remote',
'wss://relay.example.test/path with space',
'--no-voice'
])
})
test('Windows argument quoting preserves spaces, quotes, and trailing slashes', () => {
assert.equal(quoteWindowsArgument('plain'), 'plain')
assert.equal(quoteWindowsArgument('two words'), '"two words"')
assert.equal(quoteWindowsArgument('say"hello'), '"say\\"hello"')
assert.equal(quoteWindowsArgument('C:\\path with space\\'), '"C:\\path with space\\\\"')
})
test('elevation launch plan targets one explicit lifecycle action and prevents recursion', () => {
const args: ParsedArgs = {
command: 'daemon',
positional: ['restart'],
flags: {
remote: 'wss://relay.example.test/path with space',
'log-json': true,
administrator: true
}
}
const plan = buildElevationLaunchPlan(args, 'restart')
assert.equal(plan.program, 'powershell.exe')
assert.match(plan.args.at(-1) ?? '', /Start-Process.+-Verb RunAs.+-Wait/s)
assert.deepEqual(plan.targetArgs.slice(-6), [
'daemon',
'restart',
'--remote',
'wss://relay.example.test/path with space',
'--log-json',
'--elevation-child'
])
assert.equal(plan.targetArgs.includes('--administrator'), false)
const encoded = plan.env.HERMES_RELAY_ELEVATE_ARGS
assert.equal(typeof encoded, 'string')
const commandLine = Buffer.from(encoded as string, 'base64').toString('utf8')
assert.match(commandLine, /daemon restart/)
assert.match(commandLine, /"wss:\/\/relay\.example\.test\/path with space"/)
})
+23 -4
View File
@@ -1,15 +1,34 @@
import assert from 'node:assert/strict'
import { hostname } from 'node:os'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { hostname, tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { desktopRelayIdentity } from '../src/deviceIdentity.js'
import { desktopDeviceId, desktopRelayIdentity } from '../src/deviceIdentity.js'
test('desktop relay identity uses hostname as the primary paired-device name', () => {
const identity = desktopRelayIdentity()
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
test('desktop relay identity uses hostname for display and a stable per-install ownership id', async () => {
const directory = await mkdtemp(join(tmpdir(), 'hermes-device-identity-test-'))
const path = join(directory, 'device-id')
const identity = desktopRelayIdentity(path)
assert.equal(identity.deviceName, hostname().trim() || 'Hermes-Relay desktop')
assert.equal(identity.deviceHostname, identity.deviceName)
assert.equal(identity.clientSurface, 'desktop')
assert.equal(identity.deviceFormFactor, 'desktop')
assert.ok(identity.deviceModel.length > 0)
assert.ok(identity.devicePlatform.length > 0)
assert.match(identity.deviceId, UUID_PATTERN)
assert.equal(desktopDeviceId(path), identity.deviceId)
assert.equal((await readFile(path, 'utf8')).trim(), identity.deviceId)
})
test('malformed desktop ownership ids are repaired without using hostname as identity', async () => {
const directory = await mkdtemp(join(tmpdir(), 'hermes-device-identity-repair-test-'))
const path = join(directory, 'device-id')
await writeFile(path, 'unknown\n')
const repaired = desktopDeviceId(path)
assert.match(repaired, UUID_PATTERN)
assert.notEqual(repaired, 'unknown')
})
+87 -3
View File
@@ -7,13 +7,17 @@ import test from 'node:test'
import {
canonicalRelayUrl,
effectiveHostAccessMode,
effectiveHostCapabilityPolicies,
getHostCapabilityPolicies,
getHostAccessMode,
hasFullAccess,
initializePairedHostAccessPolicy,
parseHostAccessPolicyFile,
readHostAccessPolicies,
removeHostAccessPolicy,
requiresTaskGrant,
setHostAccessMode
setHostAccessMode,
setHostCapabilityPolicy
} from '../src/lib/hostAccessPolicy.js'
test('canonical relay identity normalizes host casing, default ports, and trailing slashes', () => {
@@ -48,7 +52,7 @@ test('policy changes persist atomically under canonical host keys', async () =>
version: number
hosts: Record<string, { access_mode: string }>
}
assert.equal(onDisk.version, 1)
assert.equal(onDisk.version, 2)
assert.deepEqual(Object.keys(onDisk.hosts), ['wss://relay.example'])
assert.equal(onDisk.hosts['wss://relay.example']?.access_mode, 'full_access')
@@ -89,24 +93,104 @@ test('duplicate canonical entries choose the most restrictive valid policy', ()
const parsed = parseHostAccessPolicyFile({
hosts: {
'wss://relay.example': { access_mode: 'full_access' },
'WSS://RELAY.EXAMPLE:443/': { access_mode: 'ask' }
'WSS://RELAY.EXAMPLE:443/': { access_mode: 'ask', capabilities: { usb: 'allow' } },
'wss://relay.example/': { access_mode: 'trusted', capabilities: { usb: 'disabled' } }
}
})
assert.equal(parsed.hosts['wss://relay.example']?.access_mode, 'ask')
assert.equal(parsed.hosts['wss://relay.example']?.capabilities.usb, 'disabled')
})
test('routing helpers bypass task grants only for full access', () => {
assert.equal(requiresTaskGrant('ask'), true)
assert.equal(requiresTaskGrant('ask_every_time'), true)
assert.equal(requiresTaskGrant('structured'), true)
assert.equal(requiresTaskGrant('trusted'), true)
assert.equal(requiresTaskGrant('full_access'), false)
assert.equal(hasFullAccess('ask'), false)
assert.equal(hasFullAccess('full_access'), true)
})
test('capability changes recognize exact presets while other combinations remain Custom', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-host-access-'))
const filePath = join(dir, 'desktop-host-access.json')
try {
assert.deepEqual(await getHostCapabilityPolicies('wss://relay.example', filePath), {
commands: 'disabled', files: 'disabled', screen_input: 'disabled',
usb: 'disabled', microphone: 'disabled', camera: 'disabled'
})
await setHostCapabilityPolicy('wss://relay.example', 'usb', 'ask', filePath)
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'custom')
await setHostAccessMode('wss://relay.example', 'ask_every_time', filePath)
assert.deepEqual(await getHostCapabilityPolicies('wss://relay.example', filePath), {
commands: 'ask', files: 'ask', screen_input: 'ask',
usb: 'ask', microphone: 'disabled', camera: 'disabled'
})
await setHostCapabilityPolicy('wss://relay.example', 'commands', 'disabled', filePath)
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'custom')
await setHostCapabilityPolicy('wss://relay.example', 'files', 'allow', filePath)
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'structured')
await setHostAccessMode('wss://relay.example', 'structured', filePath)
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'structured')
assert.deepEqual(await getHostCapabilityPolicies('wss://relay.example', filePath), {
commands: 'disabled', files: 'allow', screen_input: 'ask',
usb: 'ask', microphone: 'disabled', camera: 'disabled'
})
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('new pairing initialization defaults to Ask Every Time without overwriting existing hosts', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-host-access-'))
const filePath = join(dir, 'desktop-host-access.json')
try {
const created = await initializePairedHostAccessPolicy('wss://relay.example', filePath)
assert.equal(created.access_mode, 'ask_every_time')
assert.deepEqual(created.capabilities, {
commands: 'ask', files: 'ask', screen_input: 'ask',
usb: 'ask', microphone: 'disabled', camera: 'disabled'
})
await setHostAccessMode('wss://relay.example', 'ask', filePath)
const preserved = await initializePairedHostAccessPolicy('WSS://RELAY.EXAMPLE:443/', filePath)
assert.equal(preserved.access_mode, 'ask')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('Full Access overrides legacy capability values and customization exits the preset', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-host-access-'))
const filePath = join(dir, 'desktop-host-access.json')
try {
await writeFile(filePath, JSON.stringify({
version: 1,
hosts: { 'wss://relay.example': { access_mode: 'full_access', capabilities: { usb: 'ask' } } }
}))
assert.deepEqual(await getHostCapabilityPolicies('wss://relay.example', filePath), {
commands: 'allow', files: 'allow', screen_input: 'allow',
usb: 'allow', microphone: 'allow', camera: 'allow'
})
const customized = await setHostCapabilityPolicy('wss://relay.example', 'usb', 'ask', filePath)
assert.equal(customized.access_mode, 'custom')
assert.equal(customized.capabilities.commands, 'allow')
assert.equal(customized.capabilities.usb, 'ask')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('legacy tool consent migrates visibly to trusted until Ask is explicit', () => {
assert.equal(effectiveHostAccessMode('ask', true), 'trusted')
assert.equal(effectiveHostAccessMode('ask', false), 'ask')
assert.equal(effectiveHostAccessMode('full_access', true), 'full_access')
assert.deepEqual(effectiveHostCapabilityPolicies('ask', true, {
commands: 'disabled', files: 'disabled', screen_input: 'disabled',
usb: 'disabled', microphone: 'disabled', camera: 'disabled'
}), {
commands: 'allow', files: 'allow', screen_input: 'ask',
usb: 'ask', microphone: 'disabled', camera: 'disabled'
})
})
test('read normalizes stored keys and ignores invalid records', async () => {
+59 -3
View File
@@ -5,9 +5,9 @@ import { join } from 'node:path'
import { afterEach, test } from 'node:test'
import type { ParsedArgs } from '../src/cli.js'
import { hostsCommand, parseAccessMode } from '../src/commands/hosts.js'
import { displayAccessMode, hostsCommand, parseAccessMode } from '../src/commands/hosts.js'
import { getActiveDesktopRelayUrl, getDesktopHostAliases, setDesktopConfigPath } from '../src/desktopConfig.js'
import { getHostAccessMode } from '../src/lib/hostAccessPolicy.js'
import { getHostAccessMode, getHostCapabilityPolicies } from '../src/lib/hostAccessPolicy.js'
import { getSession, saveSession, setStorePath } from '../src/remoteSessions.js'
const temporaryRoots: string[] = []
@@ -36,12 +36,50 @@ function args(positional: string[], flags: Record<string, string | true> = {}):
return { command: 'hosts', positional: [...positional], flags }
}
test('access mode parser accepts the user-facing full-access spelling', () => {
test('access mode parser accepts simplified names and compatibility aliases', () => {
assert.equal(parseAccessMode('restricted'), 'ask')
assert.equal(parseAccessMode('ask-every-time'), 'ask_every_time')
assert.equal(parseAccessMode('prompt'), 'ask_every_time')
assert.equal(parseAccessMode('standard'), 'structured')
assert.equal(parseAccessMode('ask'), 'ask')
assert.equal(parseAccessMode('trusted'), 'trusted')
assert.equal(parseAccessMode('structured'), 'structured')
assert.equal(parseAccessMode('full-access'), 'full_access')
assert.equal(parseAccessMode('full_access'), 'full_access')
assert.equal(parseAccessMode('custom'), null)
assert.equal(parseAccessMode('always'), null)
assert.equal(displayAccessMode('ask'), 'restricted')
assert.equal(displayAccessMode('ask_every_time'), 'ask-every-time')
assert.equal(displayAccessMode('structured'), 'standard')
assert.equal(displayAccessMode('trusted'), 'custom')
assert.equal(displayAccessMode('custom'), 'custom')
assert.equal(displayAccessMode('full_access'), 'full-access')
})
test('a fresh paired session gets Ask Every Time while an existing policy is preserved', async () => {
const root = await mkdtemp(join(tmpdir(), 'hermes-new-pair-policy-'))
temporaryRoots.push(root)
setStorePath(join(root, 'sessions.json'))
setDesktopConfigPath(join(root, 'desktop-control.json'))
process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH = join(root, 'host-access.json')
const url = 'wss://new.example.test:8767'
await saveSession(url, 'first-token', '1.2.3', { initializeAccessPolicy: true })
assert.equal(await getHostAccessMode(url), 'ask_every_time')
await hostsCommand(args(['access', 'restricted'], { remote: url, 'no-color': true }))
await saveSession(url, 'refreshed-token', '1.2.4', { initializeAccessPolicy: true })
assert.equal(await getHostAccessMode(url), 'ask')
})
test('USB capability policy requires confirmation for allow and rejects unavailable brokers', async () => {
const { url } = await setup()
assert.equal(await hostsCommand(args(['capability', 'usb', 'allow'], { remote: url })), 2)
assert.equal(await hostsCommand(args(['capability', 'usb', 'allow'], { remote: url, yes: true, 'no-color': true })), 0)
assert.equal((await getHostCapabilityPolicies(url)).usb, 'allow')
assert.equal(await getHostAccessMode(url), 'custom')
assert.equal(await hostsCommand(args(['capability', 'camera', 'allow'], { remote: url, yes: true })), 2)
assert.equal((await getHostCapabilityPolicies(url)).camera, 'disabled')
})
test('select and access commands update shared host state', async () => {
@@ -75,3 +113,21 @@ test('rename stores a local display name for a paired host', async () => {
assert.equal(await hostsCommand(args(['rename', url, 'Office', 'Hermes'], { 'no-color': true })), 0)
assert.deepEqual(await getDesktopHostAliases(), { [url]: 'Office Hermes' })
})
test('forget requires confirmation and removes only the targeted local host state', async () => {
const { url } = await setup()
const second = 'wss://second.example.test:8767'
await saveSession(second, 'second-token', '1.2.3')
await hostsCommand(args(['rename', url, 'Office'], { 'no-color': true }))
await hostsCommand(args(['access', 'standard'], { remote: url, 'no-color': true }))
await hostsCommand(args(['select', url], { 'no-color': true }))
assert.equal(await hostsCommand(args(['forget', url], { 'no-color': true })), 2)
assert.ok(await getSession(url))
assert.equal(await hostsCommand(args(['forget', url], { yes: true, 'no-color': true })), 0)
assert.equal(await getSession(url), null)
assert.equal(await getHostAccessMode(url), 'ask')
assert.deepEqual(await getDesktopHostAliases(), {})
assert.equal(await getActiveDesktopRelayUrl(), second)
assert.ok(await getSession(second))
})
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { powershellHandler } from '../src/tools/handlers/powershell.js'
const windowsOnly = { skip: process.platform !== 'win32' }
async function run(script: string) {
const controller = new AbortController()
return (await powershellHandler(
{ script, prefer: 'powershell', timeout: 30 },
{ cwd: process.cwd(), abortSignal: controller.signal, interactive: false }
)) as {
stdout: string
stderr: string
exit_code: number
truncated: boolean
truncation_reason?: string
output: {
limit_bytes_per_stream: number
stdout: { bytes: number; captured_bytes: number; truncated: boolean }
stderr: { bytes: number; captured_bytes: number; truncated: boolean }
}
}
}
test('PowerShell captures scalar, Write-Output, pipeline, foreach, and JSON output', windowsOnly, async () => {
const result = await run(`
'scalar'
Write-Output 'written'
foreach ($i in 1..3) { Write-Output "row-$i" }
[pscustomobject]@{ ok = $true; count = 3 } | ConvertTo-Json -Compress
`)
assert.equal(result.exit_code, 0)
assert.equal(result.stderr, '')
assert.match(result.stdout, /scalar/)
assert.match(result.stdout, /written/)
assert.match(result.stdout, /row-1\r?\nrow-2\r?\nrow-3/)
assert.match(result.stdout, /{"ok":true,"count":3}/)
assert.equal(result.output.stdout.bytes, Buffer.byteLength(result.stdout))
assert.equal(result.truncated, false)
})
test('PowerShell captures stdout and stderr while propagating native exit status', windowsOnly, async () => {
const result = await run(`
Write-Output 'before-native'
cmd.exe /d /c "echo native-out& echo native-error 1>&2& exit /b 7"
`)
assert.equal(result.exit_code, 7)
assert.match(result.stdout, /before-native/)
assert.match(result.stdout, /native-out/)
assert.match(result.stderr, /native-error/)
})
test('PowerShell distinguishes an intentional zero-output success', windowsOnly, async () => {
const result = await run('$value = 42')
assert.equal(result.exit_code, 0)
assert.equal(result.stdout, '')
assert.equal(result.stderr, '')
assert.equal(result.output.stdout.bytes, 0)
assert.equal(result.output.stdout.truncated, false)
})
test('PowerShell reports per-stream truncation metadata without disguising it as empty success', windowsOnly, async () => {
const result = await run("[Console]::Out.Write(('x' * 4300000))")
assert.equal(result.exit_code, 0)
assert.equal(result.truncated, true)
assert.equal(result.truncation_reason, 'output_limit')
assert.equal(result.output.stdout.truncated, true)
assert.equal(result.output.stdout.captured_bytes, result.output.limit_bytes_per_stream)
assert.ok(result.output.stdout.bytes > result.output.stdout.captured_bytes)
assert.equal(Buffer.byteLength(result.stdout), result.output.stdout.captured_bytes)
})
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { advertisedDesktopTools, buildCapabilityGrantScope, desktopHandlers, RAW_EXECUTION_TOOLS } from '../src/tools/handlerSet.js'
import { configureCapabilityPolicies } from '../src/tools/capabilityRuntime.js'
import { PRESET_CAPABILITY_POLICIES } from '../src/lib/hostAccessPolicy.js'
test('structured mode omits general raw execution but retains host-gated USB operations', () => {
const tools = advertisedDesktopTools({ structuredOnly: true, usb: true, adb: true })
for (const raw of RAW_EXECUTION_TOOLS) assert.equal(tools.includes(raw), false, raw)
for (const brokered of ['desktop_usb_devices', 'desktop_usb_run', 'desktop_adb_devices', 'desktop_adb_shell', 'desktop_adb_push', 'desktop_adb_pull', 'desktop_adb_install', 'desktop_adb_logcat']) {
assert.equal(tools.includes(brokered), true, brokered)
}
assert.equal(advertisedDesktopTools().includes('desktop_powershell'), true)
assert.equal(advertisedDesktopTools().includes('desktop_adb_devices'), false)
})
test('raw USB execution is a direct-spawn path governed by the USB policy', async () => {
const handler = desktopHandlers({ structuredOnly: true, usb: true }).desktop_usb_run!
configureCapabilityPolicies(PRESET_CAPABILITY_POLICIES.ask)
await assert.rejects(
handler({ executable: process.execPath, arguments: ['--version'] }, { cwd: process.cwd(), abortSignal: new AbortController().signal, interactive: false }),
/Raw USB access is disabled/
)
configureCapabilityPolicies(PRESET_CAPABILITY_POLICIES.full_access)
const result = await handler(
{ executable: process.execPath, arguments: ['--version'] },
{ cwd: process.cwd(), abortSignal: new AbortController().signal, interactive: false }
) as { exit_code: number; stdout: string; arguments: string[] }
assert.equal(result.exit_code, 0)
assert.match(result.stdout, /^v\d+/)
assert.deepEqual(result.arguments, ['--version'])
})
test('ADB handlers fail closed before process execution when USB is disabled', async () => {
configureCapabilityPolicies(PRESET_CAPABILITY_POLICIES.ask)
const handler = desktopHandlers({ structuredOnly: true, usb: true, adb: true }).desktop_adb_devices!
await assert.rejects(
handler({}, { cwd: process.cwd(), abortSignal: new AbortController().signal, interactive: false }),
/Raw USB access is disabled/
)
})
test('capability policies filter the matching tool groups and Full Access exposes all available tools', () => {
const structured = advertisedDesktopTools({
computerUse: true,
capabilities: PRESET_CAPABILITY_POLICIES.structured,
adb: true
})
assert.equal(structured.includes('desktop_powershell'), false)
assert.equal(structured.includes('desktop_read_file'), true)
assert.equal(structured.includes('desktop_computer_screenshot'), true)
assert.equal(structured.includes('desktop_usb_devices'), true)
const full = advertisedDesktopTools({
computerUse: true,
capabilities: PRESET_CAPABILITY_POLICIES.full_access,
adb: true
})
for (const tool of ['desktop_powershell', 'desktop_read_file', 'desktop_computer_action', 'desktop_usb_run', 'desktop_adb_shell']) {
assert.equal(full.includes(tool), true, tool)
}
})
test('Ask-mode grant context previews commands without copying file contents', () => {
assert.deepEqual(
buildCapabilityGrantScope('desktop_powershell', 'commands', { script: "Write-Output 'safe test'" }),
{ capability: 'commands', tool: 'desktop_powershell', action: 'PowerShell script', preview: "Write-Output 'safe test'" }
)
assert.deepEqual(
buildCapabilityGrantScope('desktop_write_file', 'files', { path: 'C:\\Temp\\note.txt', content: 'secret contents' }),
{ capability: 'files', tool: 'desktop_write_file', action: 'Write file', preview: 'C:\\Temp\\note.txt (15 characters)' }
)
})
+3 -1
View File
@@ -55,7 +55,9 @@ test('PowerShell launches an installer whose path contains spaces via environmen
test('NSIS bundle installs and removes the stable UI shim', async () => {
const script = await readFile(new URL('../tray/installer/hermes-relay.nsi', import.meta.url), 'utf8')
assert.match(script, /File \/oname=hermes-relay-ui\.cmd "\$\{UI_SHIM\}"/)
assert.match(script, /Hermes-Relay CLI UI\.lnk" "\$INSTDIR\\hermes-relay-ui\.cmd"/)
assert.match(script, /Hermes-Relay CLI UI\.lnk" "\$INSTDIR\\hermes-relay-ui\.cmd" "" "\$INSTDIR\\hermes-relay-tray\.exe" 0/)
assert.match(script, /Hermes-Relay CLI\.lnk" "\$INSTDIR\\hermes-relay\.exe" "" "\$INSTDIR\\hermes-relay-tray\.exe" 0/)
assert.match(script, /"DisplayIcon" '\"\$INSTDIR\\hermes-relay-tray\.exe\",0'/)
assert.match(script, /Delete "\$INSTDIR\\hermes-relay-ui\.cmd"/)
})
+3 -2
View File
@@ -63,6 +63,7 @@ tray_stop:
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "DisplayVersion" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "Publisher" "Axiom Labs"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "DisplayIcon" '"$INSTDIR\hermes-relay-tray.exe",0'
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "UninstallString" '"$INSTDIR\uninstall-hermes-relay.exe"'
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay" "NoRepair" 1
@@ -73,8 +74,8 @@ tray_stop:
Delete "$SMPROGRAMS\Hermes Relay\Uninstall Hermes Relay.lnk"
RMDir "$SMPROGRAMS\Hermes Relay"
CreateDirectory "$SMPROGRAMS\Hermes-Relay CLI"
CreateShortCut "$SMPROGRAMS\Hermes-Relay CLI\Hermes-Relay CLI.lnk" "$INSTDIR\hermes-relay.exe"
CreateShortCut "$SMPROGRAMS\Hermes-Relay CLI\Hermes-Relay CLI UI.lnk" "$INSTDIR\hermes-relay-ui.cmd"
CreateShortCut "$SMPROGRAMS\Hermes-Relay CLI\Hermes-Relay CLI.lnk" "$INSTDIR\hermes-relay.exe" "" "$INSTDIR\hermes-relay-tray.exe" 0
CreateShortCut "$SMPROGRAMS\Hermes-Relay CLI\Hermes-Relay CLI UI.lnk" "$INSTDIR\hermes-relay-ui.cmd" "" "$INSTDIR\hermes-relay-tray.exe" 0
CreateShortCut "$SMPROGRAMS\Hermes-Relay CLI\Uninstall Hermes-Relay CLI.lnk" "$INSTDIR\uninstall-hermes-relay.exe"
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "HermesRelayTray"
+519 -86
View File
@@ -8,12 +8,13 @@ mod app {
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::BTreeMap,
env, fs,
fs::OpenOptions,
io::Write,
os::windows::process::CommandExt,
path::{Path, PathBuf},
process::{Command, Output},
process::{Command, Output, Stdio},
sync::{mpsc, Arc, Mutex},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
@@ -22,16 +23,13 @@ mod app {
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, Position, RunEvent, Size,
State, WindowEvent,
AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, Position,
RunEvent, Size, State, WindowEvent,
};
use windows::{
core::{IUnknown, PCWSTR},
Win32::{
Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE, POINT},
Graphics::Gdi::{
GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST,
},
System::{
Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
Threading::{CreateMutexW, CREATE_NO_WINDOW},
@@ -52,10 +50,21 @@ mod app {
const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
const RUN_VALUE: &str = "HermesRelayTray";
const SETTINGS_KEY: &str = r"HKCU\Software\Hermes-Relay CLI UI";
const DAEMON_AUTOSTART_VALUE: &str = "DaemonAutostart";
const POPUP_GAP: f64 = 10.0;
const MONITOR_MARGIN: f64 = 8.0;
const TRAY_SLOT_LOGICAL_WIDTH: f64 = 32.0;
const TRAY_SLOT_LOGICAL_HEIGHT: f64 = 48.0;
const MAIN_LOGICAL_WIDTH: f64 = 380.0;
const MAIN_LOGICAL_HEIGHT: f64 = 620.0;
const MAIN_MIN_LOGICAL_WIDTH: f64 = 340.0;
const MAIN_MIN_LOGICAL_HEIGHT: f64 = 460.0;
const OFFICIAL_URLS: &[&str] = &[
"https://hermes-relay.dev/docs/",
"https://hermes-relay.dev/docs/desktop/",
"https://hermes-relay.dev/docs/desktop/troubleshooting/",
"https://github.com/Codename-11/hermes-relay",
"https://github.com/Codename-11/hermes-relay/releases",
];
#[derive(Clone, Copy, Debug)]
struct TrayAnchor {
@@ -232,6 +241,8 @@ mod app {
is_active: bool,
#[serde(default = "ask_mode")]
access_mode: String,
#[serde(default)]
capabilities: BTreeMap<String, String>,
}
fn ask_mode() -> String {
@@ -262,6 +273,22 @@ mod app {
#[serde(skip_serializing_if = "Option::is_none")]
args_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
request_detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stdout: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stderr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
result_detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
request_truncated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
stdout_truncated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
stderr_truncated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
result_truncated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
@@ -283,6 +310,19 @@ mod app {
activity: Vec<Activity>,
pending_grants: Vec<PendingGrantRequest>,
startup_enabled: bool,
daemon_autostart_enabled: bool,
hardware_availability: HardwareAvailability,
ui_version: &'static str,
cli_version: Option<String>,
cli_path: Option<String>,
}
#[derive(Debug, Serialize)]
struct HardwareAvailability {
usb: bool,
adb: bool,
microphone: bool,
camera: bool,
}
fn home_dir() -> Result<PathBuf, String> {
@@ -375,12 +415,54 @@ mod app {
Err(if stderr.is_empty() { stdout } else { stderr })
}
fn run_cli_checked_with_env(args: &[&str], key: &str, value: &str) -> Result<String, String> {
let output = Command::new(resolve_cli()?)
.args(args)
.env(key, value)
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.map_err(|e| format!("failed to run hermes-relay {}: {e}", args.join(" ")))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() {
return Ok(stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() { stdout } else { stderr })
}
fn run_json(args: &[&str]) -> Result<Value, String> {
let output = run_cli_checked(args)?;
serde_json::from_str(&output)
.map_err(|e| format!("invalid JSON from hermes-relay {}: {e}", args.join(" ")))
}
fn cli_details() -> (Option<String>, Option<String>) {
let Ok(path) = resolve_cli() else {
return (None, None);
};
let version = Command::new(&path)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| clean_cli_version(&String::from_utf8_lossy(&output.stdout)))
.filter(|value| !value.is_empty());
(version, Some(path.display().to_string()))
}
fn clean_cli_version(output: &str) -> String {
output
.trim()
.strip_prefix("hermes-relay ")
.unwrap_or_else(|| output.trim())
.to_string()
}
fn is_official_url(url: &str) -> bool {
OFFICIAL_URLS.contains(&url)
}
fn active_url() -> Option<String> {
let path = home_dir()
.ok()?
@@ -425,6 +507,7 @@ mod app {
} else {
"ask".to_string()
},
capabilities: BTreeMap::new(),
})
.collect())
}
@@ -502,6 +585,31 @@ mod app {
.is_ok_and(|s| s.success())
}
fn daemon_autostart_enabled() -> bool {
Command::new("reg.exe")
.args(["query", SETTINGS_KEY, "/v", DAEMON_AUTOSTART_VALUE])
.creation_flags(CREATE_NO_WINDOW.0)
.status()
.is_ok_and(|status| status.success())
}
fn hardware_availability() -> HardwareAvailability {
let adb = env::var_os("HERMES_RELAY_ADB_PATH")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "adb".into());
let adb = Command::new(adb)
.arg("version")
.creation_flags(CREATE_NO_WINDOW.0)
.status()
.is_ok_and(|status| status.success());
HardwareAvailability {
usb: true,
adb,
microphone: false,
camera: false,
}
}
#[tauri::command]
async fn get_snapshot() -> Result<Snapshot, String> {
let selected = active_url();
@@ -518,6 +626,8 @@ mod app {
for host in &mut hosts {
if host.access_mode == "full_access" {
host.access_mode = "full-access".to_string();
} else if host.access_mode == "ask_every_time" {
host.access_mode = "ask-every-time".to_string();
}
}
let daemon = run_json(&["daemon", "status", "--json"])
@@ -528,6 +638,7 @@ mod app {
.ok()
.and_then(|value| serde_json::from_value::<Vec<PendingGrantRequest>>(value).ok())
.unwrap_or_default();
let (cli_version, cli_path) = cli_details();
Ok(Snapshot {
hosts,
active_url: selected,
@@ -535,6 +646,11 @@ mod app {
activity: read_activity(),
pending_grants,
startup_enabled: startup_enabled(),
daemon_autostart_enabled: daemon_autostart_enabled(),
hardware_availability: hardware_availability(),
ui_version: env!("CARGO_PKG_VERSION"),
cli_version,
cli_path,
})
}
@@ -672,7 +788,10 @@ mod app {
#[tauri::command]
fn set_host_access(remote: String, mode: String) -> Result<(), String> {
if !matches!(mode.as_str(), "ask" | "trusted" | "full-access") {
if !matches!(
mode.as_str(),
"ask" | "ask-every-time" | "structured" | "trusted" | "full-access"
) {
return Err("invalid host access mode".to_string());
}
let mut args = vec![
@@ -697,6 +816,40 @@ mod app {
Ok(())
}
#[tauri::command]
fn set_host_capability(remote: String, capability: String, mode: String) -> Result<(), String> {
if !matches!(
capability.as_str(),
"commands" | "files" | "screen-input" | "usb" | "microphone" | "camera"
) {
return Err("invalid host capability".to_string());
}
if !matches!(mode.as_str(), "disabled" | "ask" | "allow") {
return Err("invalid capability access mode".to_string());
}
let mut args = vec![
"hosts",
"capability",
capability.as_str(),
mode.as_str(),
"--remote",
remote.as_str(),
];
if mode == "allow" {
args.push("--yes");
}
let was_running = daemon_is_running();
run_cli_checked(&args)?;
restart_daemon_if_running(was_running)?;
append_management_event(
"host.capability",
&format!("{capability} capability changed to {mode}"),
Some(&remote),
None,
);
Ok(())
}
#[tauri::command]
fn list_authorized_clients(remote: String) -> Result<Value, String> {
run_json(&["devices", "list", "--remote", &remote, "--json"])
@@ -739,28 +892,217 @@ mod app {
Ok(())
}
fn open_pair_terminal() -> Result<(), String> {
fn spawn_cli_terminal(cli_args: &[&str]) -> Result<(), String> {
let cli = resolve_cli()?;
let escaped = cli.display().to_string().replace('\'', "''");
Command::new("powershell.exe")
.args([
"-NoLogo",
"-NoExit",
"-Command",
&format!("& '{escaped}' pair"),
])
.spawn()
.map(|_| ())
.map_err(|e| format!("failed to open PowerShell: {e}"))
let args = serde_json::to_string(cli_args)
.map_err(|error| format!("cannot serialize CLI arguments: {error}"))?;
let powershell_args = [
"-NoLogo",
"-NoExit",
"-NoProfile",
"-Command",
"& $env:HERMES_RELAY_CLI @((ConvertFrom-Json $env:HERMES_RELAY_ARGS))",
];
let terminal = Command::new("wt.exe")
.arg("new-tab")
.arg("powershell.exe")
.args(powershell_args)
.env("HERMES_RELAY_CLI", &cli)
.env("HERMES_RELAY_ARGS", &args)
.spawn();
match terminal {
Ok(_) => Ok(()),
Err(_) => Command::new("powershell.exe")
.args(powershell_args)
.env("HERMES_RELAY_CLI", cli)
.env("HERMES_RELAY_ARGS", args)
.spawn()
.map(|_| ())
.map_err(|error| format!("failed to open a terminal: {error}")),
}
}
#[tauri::command]
fn pair_host() -> Result<(), String> {
open_pair_terminal()?;
append_management_event("host.pair", "Pairing opened", None, None);
async fn pair_host(remote: String, code: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let remote = remote.trim().to_string();
let code = code.trim().to_ascii_uppercase();
if remote.is_empty()
|| code.len() != 6
|| !code.chars().all(|c| c.is_ascii_alphanumeric())
{
return Err(
"Enter a ws:// or wss:// relay URL and a six-character pairing code"
.to_string(),
);
}
run_cli_checked_with_env(
&["pair", "--remote", &remote, "--non-interactive"],
"HERMES_RELAY_CODE",
&code,
)?;
append_management_event("host.pair", "Host paired", Some(&remote), None);
Ok(())
})
.await
.map_err(|error| format!("pair host task failed: {error}"))?
}
#[tauri::command]
fn open_management_from_grant(
app: AppHandle,
tray_position: State<'_, TrayPositionState>,
) -> Result<(), String> {
let anchor = tray_position.0.lock().ok().and_then(|value| *value);
reveal_main_window(&app, anchor);
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval("window.dispatchEvent(new CustomEvent('hermes-review-grant'))");
}
Ok(())
}
fn wait_for_daemon_privilege(expected: Option<&str>) -> Result<(), String> {
let deadline = std::time::Instant::now() + Duration::from_secs(8);
while std::time::Instant::now() < deadline {
let status = run_json(&["daemon", "status", "--json"])
.ok()
.and_then(|value| serde_json::from_value::<DaemonStatus>(value).ok());
let matches = match (expected, status) {
(None, None) => true,
(Some(privilege), Some(status)) => {
status.running && status.privilege.as_deref() == Some(privilege)
}
_ => false,
};
if matches {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
Err(match expected {
Some(privilege) => format!("daemon did not become ready as {privilege}"),
None => "daemon did not stop after the Administrator request".to_string(),
})
}
#[tauri::command]
async fn restart_daemon_as_administrator() -> Result<(), String> {
tauri::async_runtime::spawn_blocking(|| {
run_cli_checked(&["daemon", "restart", "--administrator"])?;
wait_for_daemon_privilege(Some("administrator"))?;
append_management_event(
"daemon.restart_admin",
"Relay daemon restarted as Administrator",
None,
None,
);
Ok(())
})
.await
.map_err(|error| format!("Administrator restart task failed: {error}"))?
}
#[tauri::command]
async fn restart_daemon_as_user() -> Result<(), String> {
tauri::async_runtime::spawn_blocking(|| {
run_cli_checked(&["daemon", "restart", "--user"])?;
wait_for_daemon_privilege(Some("user"))?;
append_management_event(
"daemon.restart_user",
"Relay daemon restarted as standard user",
None,
None,
);
Ok(())
})
.await
.map_err(|error| format!("standard-user restart task failed: {error}"))?
}
#[tauri::command]
fn open_terminal() -> Result<(), String> {
let home = home_dir()?;
let terminal = Command::new("wt.exe").args(["-d"]).arg(&home).spawn();
match terminal {
Ok(_) => Ok(()),
Err(_) => Command::new("powershell.exe")
.args(["-NoLogo", "-NoExit"])
.current_dir(home)
.spawn()
.map(|_| ())
.map_err(|error| format!("failed to open a terminal: {error}")),
}
}
#[tauri::command]
fn open_cli_terminal() -> Result<(), String> {
spawn_cli_terminal(&[])
}
#[tauri::command]
fn open_logs() -> Result<(), String> {
let path = home_dir()?.join(".hermes").join("daemon.log");
if !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create daemon log directory: {error}"))?;
}
fs::write(&path, b"")
.map_err(|error| format!("cannot create daemon log file: {error}"))?;
}
Command::new("notepad.exe")
.arg(path)
.spawn()
.map(|_| ())
.map_err(|error| format!("failed to open daemon logs: {error}"))
}
#[tauri::command]
fn run_diagnostics() -> Result<(), String> {
spawn_cli_terminal(&["doctor"])
}
#[tauri::command]
fn open_external_url(url: String) -> Result<(), String> {
if !is_official_url(&url) {
return Err("URL is not an approved Hermes-Relay destination".to_string());
}
Command::new("rundll32.exe")
.args(["url.dll,FileProtocolHandler", &url])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|error| format!("failed to open the link: {error}"))
}
#[tauri::command]
async fn forget_host(remote: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let selected_before = active_url();
let was_active = selected_before.as_deref() == Some(remote.as_str());
let was_running = daemon_is_running();
if was_active && was_running {
run_cli_checked(&["daemon", "stop"])?;
}
if let Err(error) = run_cli_checked(&["hosts", "forget", &remote, "--yes"]) {
if was_active && was_running {
let _ = run_cli_checked(&["daemon", "start"]);
}
return Err(error);
}
let selected_after = active_url();
if was_active && was_running && selected_after.is_some() {
run_cli_checked(&["daemon", "start"])?;
}
append_management_event("host.forget", "Host forgotten", Some(&remote), None);
Ok(())
})
.await
.map_err(|error| format!("forget host task failed: {error}"))?
}
#[tauri::command]
fn connect_daemon() -> Result<(), String> {
run_cli_checked(&["daemon", "start"])?;
@@ -814,6 +1156,53 @@ mod app {
}
}
#[tauri::command]
fn set_daemon_autostart(enabled: bool) -> Result<(), String> {
if !enabled && !daemon_autostart_enabled() {
return Ok(());
}
let mut command = Command::new("reg.exe");
if enabled {
command.args([
"add",
SETTINGS_KEY,
"/v",
DAEMON_AUTOSTART_VALUE,
"/t",
"REG_SZ",
"/d",
"1",
"/f",
]);
} else {
command.args(["delete", SETTINGS_KEY, "/v", DAEMON_AUTOSTART_VALUE, "/f"]);
}
let output = command
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.map_err(|error| format!("failed to update daemon autostart setting: {error}"))?;
if output.status.success() {
append_management_event(
"daemon.autostart",
if enabled {
"Automatic daemon start enabled"
} else {
"Automatic daemon start disabled"
},
None,
None,
);
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if error.is_empty() {
"failed to update daemon autostart setting".to_string()
} else {
error
})
}
}
fn clamped(value: f64, minimum: f64, maximum: f64) -> f64 {
if maximum < minimum {
minimum
@@ -884,21 +1273,26 @@ mod app {
)
}
fn physical_window_size(reported: PhysicalSize<u32>, anchor: TrayAnchor) -> PhysicalSize<u32> {
let width_scale = anchor.width / TRAY_SLOT_LOGICAL_WIDTH;
let height_scale = anchor.height / TRAY_SLOT_LOGICAL_HEIGHT;
let scale = if width_scale.is_finite()
&& height_scale.is_finite()
&& (width_scale - height_scale).abs() <= 0.25
{
((width_scale + height_scale) / 2.0).clamp(1.0, 4.0)
fn responsive_logical_window_size(
work_area: &PhysicalRect<i32, u32>,
scale: f64,
) -> LogicalSize<f64> {
let scale = if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
};
PhysicalSize::new(
(reported.width as f64 * scale).round() as u32,
(reported.height as f64 * scale).round() as u32,
)
let available_width = (work_area.size.width as f64 / scale - MONITOR_MARGIN * 2.0).max(1.0);
let available_height =
(work_area.size.height as f64 / scale - MONITOR_MARGIN * 2.0).max(1.0);
let width = MAIN_LOGICAL_WIDTH
.min(available_width)
.max(MAIN_MIN_LOGICAL_WIDTH.min(available_width));
let height = MAIN_LOGICAL_HEIGHT
.min(available_height)
.max(MAIN_MIN_LOGICAL_HEIGHT.min(available_height));
LogicalSize::new(width, height)
}
fn position_window(app: &AppHandle, anchor: TrayAnchor) {
@@ -907,40 +1301,33 @@ mod app {
};
let center_x = anchor.x + anchor.width / 2.0;
let center_y = anchor.y + anchor.height / 2.0;
let monitor = unsafe {
MonitorFromPoint(
POINT {
x: center_x.round() as i32,
y: center_y.round() as i32,
},
MONITOR_DEFAULTTONEAREST,
)
};
let mut monitor_info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
..Default::default()
};
if !unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }.as_bool() {
let Some(monitor) = window
.monitor_from_point(center_x, center_y)
.ok()
.flatten()
.or_else(|| window.current_monitor().ok().flatten())
.or_else(|| window.primary_monitor().ok().flatten())
else {
return;
}
let bounds = monitor_info.rcMonitor;
let monitor_position = PhysicalPosition::new(bounds.left, bounds.top);
let monitor_size = PhysicalSize::new(
(bounds.right - bounds.left) as u32,
(bounds.bottom - bounds.top) as u32,
);
// A hidden first-launch window may have been created on a monitor with a
// different scale factor. Move it onto the tray monitor before measuring,
// allowing WM_DPICHANGED to resize it before the final centered placement.
};
let work_area = monitor.work_area();
let scale = monitor.scale_factor();
let logical_size = responsive_logical_window_size(work_area, scale);
// Commit the tray monitor before applying its logical size. This avoids
// inheriting the hidden creation monitor's DPI on first launch.
let _ = window.set_position(PhysicalPosition::new(
center_x.round() as i32,
center_y.round() as i32,
));
let Ok(reported_size) = window.outer_size() else {
let _ = window.set_size(Size::Logical(logical_size));
// WebView frame metrics and monitor transitions can produce outer
// dimensions that differ from a logical-size times DPI calculation.
// Center from the authoritative post-resize bounds Windows reports.
let Ok(physical_size) = window.outer_size() else {
return;
};
let window_size = physical_window_size(reported_size, anchor);
let position = popup_position(anchor, window_size, monitor_position, monitor_size);
let position = popup_position(anchor, physical_size, work_area.position, work_area.size);
let _ = window.set_position(position);
}
@@ -972,7 +1359,7 @@ mod app {
}
fn grant_window_size(expanded: bool, scale: f64) -> PhysicalSize<u32> {
let logical_height = if expanded { 226.0 } else { 134.0 };
let logical_height = if expanded { 343.0 } else { 211.0 };
PhysicalSize::new(
(360.0_f64 * scale).round() as u32,
(logical_height * scale).round() as u32,
@@ -1127,14 +1514,25 @@ mod app {
select_host,
rename_host,
set_host_access,
set_host_capability,
list_authorized_clients,
revoke_authorized_client,
resolve_grant,
pair_host,
open_management_from_grant,
forget_host,
connect_daemon,
disconnect_daemon,
restart_daemon,
restart_daemon_as_administrator,
restart_daemon_as_user,
open_terminal,
open_cli_terminal,
open_logs,
run_diagnostics,
open_external_url,
set_startup,
set_daemon_autostart,
clear_activity,
present_grant_window
])
@@ -1201,16 +1599,25 @@ mod app {
.expect("failed to build Hermes-Relay CLI UI");
app.run(move |handle, event| match event {
RunEvent::Ready if show_on_launch => {
let anchor = current_tray_anchor(handle);
if let (Some(anchor), Some(state)) =
(anchor, handle.try_state::<TrayPositionState>())
{
if let Ok(mut stored) = state.0.lock() {
*stored = Some(anchor);
RunEvent::Ready => {
if daemon_autostart_enabled() {
thread::spawn(|| {
if !daemon_is_running() {
let _ = run_cli_checked(&["daemon", "start"]);
}
});
}
if show_on_launch {
let anchor = current_tray_anchor(handle);
if let (Some(anchor), Some(state)) =
(anchor, handle.try_state::<TrayPositionState>())
{
if let Ok(mut stored) = state.0.lock() {
*stored = Some(anchor);
}
}
reveal_main_window(handle, anchor);
}
reveal_main_window(handle, anchor);
}
RunEvent::ExitRequested {
api, code: None, ..
@@ -1252,6 +1659,26 @@ mod app {
);
}
#[test]
fn cli_version_is_clean_for_the_about_page() {
assert_eq!(
clean_cli_version("hermes-relay 0.4.0-alpha.7\r\n"),
"0.4.0-alpha.7"
);
}
#[test]
fn external_links_require_an_exact_official_destination() {
assert!(is_official_url("https://hermes-relay.dev/docs/desktop/"));
assert!(is_official_url(
"https://github.com/Codename-11/hermes-relay/releases"
));
assert!(!is_official_url("https://hermes-relay.dev.evil.test/docs/"));
assert!(!is_official_url(
"https://github.com/Codename-11/hermes-relay/issues/new"
));
}
#[test]
fn popup_centers_above_a_bottom_taskbar_icon() {
let position = popup_position(
@@ -1298,18 +1725,24 @@ mod app {
}
#[test]
fn tray_slot_scale_converts_virtualized_webview_size_to_physical_pixels() {
let size = physical_window_size(
PhysicalSize::new(434, 708),
TrayAnchor {
x: 1546.0,
y: 1020.0,
width: 40.0,
height: 60.0,
},
);
fn window_size_uses_monitor_dpi_not_taskbar_icon_geometry() {
let work_area = PhysicalRect {
position: PhysicalPosition::new(0, 0),
size: PhysicalSize::new(3440, 1390),
};
let logical = responsive_logical_window_size(&work_area, 1.25);
assert_eq!(logical, LogicalSize::new(380.0, 620.0));
}
assert_eq!(size, PhysicalSize::new(543, 885));
#[test]
fn window_height_compacts_to_a_small_scaled_work_area() {
let work_area = PhysicalRect {
position: PhysicalPosition::new(0, 0),
size: PhysicalSize::new(1366, 728),
};
let logical = responsive_logical_window_size(&work_area, 1.5);
assert_eq!(logical.width, 380.0);
assert!(logical.height < 480.0);
}
#[test]
@@ -1338,11 +1771,11 @@ mod app {
size: PhysicalSize::new(1920, 1040),
};
assert_eq!(collapsed, PhysicalSize::new(450, 168));
assert_eq!(expanded, PhysicalSize::new(450, 283));
assert_eq!(collapsed, PhysicalSize::new(450, 264));
assert_eq!(expanded, PhysicalSize::new(450, 429));
assert_eq!(
bottom_right_position(&work_area, collapsed, 1.25),
PhysicalPosition::new(1455, 857)
PhysicalPosition::new(1455, 761)
);
}
}
+4 -4
View File
@@ -14,10 +14,10 @@
{
"label": "main",
"title": "Hermes-Relay CLI UI",
"width": 420,
"height": 700,
"minWidth": 390,
"minHeight": 620,
"width": 380,
"height": 620,
"minWidth": 340,
"minHeight": 460,
"resizable": false,
"fullscreen": false,
"decorations": false,
+75 -8
View File
@@ -49,10 +49,50 @@ fn management_window_owns_the_expected_narrow_surfaces() {
"Clear activity",
"HostDetailPage",
"rename_host",
"Re-pair host",
"Forget host",
"forget_host",
"Remote access",
"duration_ms",
"exit_code",
"pending_grants",
"resolve_grant",
"Standard",
"Restricted",
"Ask Every Time",
"Capabilities",
"Raw USB access",
"Android Debug Bridge",
"Secondary USB service",
"hardware_availability",
"Secondary USB service",
"Devices",
"CLI & diagnostics",
"restart_daemon_as_administrator",
"restart_daemon_as_user",
"open_terminal",
"open_cli_terminal",
"open_logs",
"run_diagnostics",
"Help & About",
"HelpPage",
"open_external_url",
"Documentation",
"Troubleshooting",
"Release notes",
"cli_version",
"cli_path",
"ui_version",
"daemon_autostart_enabled",
"set_daemon_autostart",
"Start UI at sign-in",
"Start daemon with UI",
"PairHostPage",
"Pairing code",
"Encrypted relay connection",
"Unencrypted relay connection",
"open_management_from_grant",
"Open in UI",
] {
assert!(
source.contains(required),
@@ -93,13 +133,16 @@ fn grants_use_the_dedicated_card_and_host_changes_reconcile_daemon_truth() {
assert_eq!(grant_window["shadow"], false);
assert!(ui.contains("snapshot.daemon.url === host.url"));
assert!(ui.contains("formatGrantScope(grant.scope)"));
assert!(ui.contains("grantAction(grant.scope)"));
assert!(ui.contains("Requested action"));
assert!(ui.contains("hostAccessLabel(host)"));
assert!(native.contains("restart_daemon_if_running"));
assert!(native.contains("management.completed"));
assert!(native.contains("append_management_event"));
assert!(native.contains("fn clear_activity"));
assert!(native.contains("skip_serializing_if = \"Option::is_none\""));
assert!(native.contains("popup_position"));
assert!(native.contains("physical_window_size"));
assert!(native.contains("window.outer_size()"));
assert!(native.contains("run_cli_checked(&[\"daemon\", \"restart\"])"));
assert!(native.contains("start_tray_action_worker"));
assert!(native.contains("mpsc::channel::<TrayAction>()"));
@@ -107,6 +150,10 @@ fn grants_use_the_dedicated_card_and_host_changes_reconcile_daemon_truth() {
assert!(native.contains("async fn get_snapshot"));
assert!(native.contains("async fn check_desktop_update"));
assert!(native.contains("async fn install_desktop_update"));
assert!(native.contains("fn set_host_capability"));
assert!(native.contains("fn hardware_availability"));
assert!(native.contains("HERMES_RELAY_CODE"));
assert!(native.contains("--non-interactive"));
assert!(
native.contains("SHA256SUMS")
|| include_str!("../../src/updater.ts").contains("SHA256SUMS")
@@ -127,10 +174,10 @@ fn grants_use_the_dedicated_card_and_host_changes_reconcile_daemon_truth() {
assert!(native.contains("activation_request_path"));
assert!(native.contains("start_activation_watcher"));
assert!(native.contains("tray-show-request"));
assert!(native.contains("MonitorFromPoint"));
assert!(native.contains("GetMonitorInfoW"));
assert!(!native.contains("window.available_monitors()"));
assert!(!native.contains("let Ok(Some(monitor)) = window.monitor_from_point"));
assert!(native.contains("window.monitor_from_point(center_x, center_y)"));
assert!(native.contains("responsive_logical_window_size(work_area, scale)"));
assert!(!native.contains("GetMonitorInfoW"));
assert!(!native.contains("TRAY_SLOT_LOGICAL_WIDTH"));
}
#[test]
@@ -148,16 +195,36 @@ fn release_build_embeds_the_ui_instead_of_using_the_vite_server() {
fn management_window_keeps_the_reviewed_compact_geometry() {
let config = include_str!("../tauri.conf.json");
let ui = include_str!("../ui/App.tsx");
let styles = include_str!("../ui/styles.css");
let capability = include_str!("../capabilities/default.json");
assert!(config.contains("\"width\": 420"));
assert!(config.contains("\"height\": 700"));
assert!(config.contains("\"minWidth\": 390"));
assert!(config.contains("\"width\": 380"));
assert!(config.contains("\"height\": 620"));
assert!(config.contains("\"minWidth\": 340"));
assert!(config.contains("\"minHeight\": 460"));
assert!(config.contains("\"resizable\": false"));
assert_eq!(config.matches("\"alwaysOnTop\": true").count(), 2);
assert!(!ui.contains("toggleMaximize"));
assert!(!ui.contains("data-tauri-drag-region"));
assert!(!capability.contains("allow-start-dragging"));
assert!(ui.contains("policy-ledger"));
assert!(ui.contains("<AccessPage"));
assert!(ui.contains("<CapabilitiesPage"));
assert!(ui.contains("Back to Overview"));
assert!(styles.contains(".back-button:hover"));
assert!(ui.contains("snapshot.activity.slice(-3).reverse()"));
assert!(ui.contains("packet packet-outbound"));
assert!(ui.contains("packet packet-inbound"));
assert!(styles.contains("@keyframes packet-outbound"));
assert!(styles.contains("@keyframes packet-inbound"));
assert!(ui.contains("setPage('activity-detail')"));
assert!(ui.contains("page !== 'host-detail' || !detailUrl"));
assert!(ui.contains("list_authorized_clients', { remote: detailUrl }"));
assert!(ui.contains("setPolicyBack('host-detail')"));
assert!(ui.contains("['Request', entry.request_detail"));
assert!(ui.contains("Standard output"));
assert!(ui.contains("Standard error"));
assert!(styles.contains("position: fixed; inset: 0"));
assert!(ui.contains("useState(true)"));
assert!(ui.contains("hide().finally(() => setWindowVisible(true))"));
assert!(ui.contains("document.visibilityState === 'visible'"));
+334 -94
View File
@@ -1,28 +1,34 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import {
Activity as ActivityIcon, AlertTriangle, Check, ChevronDown, ChevronRight,
CircleHelp, Clock3, Download, Eye, FileText, Home, Laptop, Link2,
LoaderCircle, LogOut, Monitor, MousePointer2, Power, Radio, RefreshCw, Server,
Settings, ShieldCheck, TerminalSquare, Trash2, Unplug, UserRoundX, X
Activity as ActivityIcon, AlertTriangle, ArrowLeft, Bot, Check, ChevronDown, ChevronRight,
CircleHelp, Clock3, Download, ExternalLink, Eye, FileText, FolderOpen, Home, Info, Laptop, Link2,
Copy, LoaderCircle, LogOut, Monitor, MousePointer2, Power, Radio, RefreshCw, Server,
Settings, ShieldCheck, TerminalSquare, Trash2, Unplug, UserRoundX, X, Usb,
LockKeyhole, SlidersHorizontal, Mic, Video
} from 'lucide-react'
import logo from '../icons/icon-256.png'
import type { AccessMode, Activity, AuthorizedClient, Host, PendingGrantRequest, Snapshot, UpdateReport } from './types'
import type { AccessMode, Activity, AuthorizedClient, Capability, CapabilityMode, Host, PendingGrantRequest, Snapshot, UpdateReport } from './types'
type Page = 'overview' | 'hosts' | 'host-detail' | 'settings' | 'activity'
type PendingAction = { type: 'access'; mode: AccessMode } | { type: 'revoke'; client: AuthorizedClient } | { type: 'clear-activity' } | null
type Page = 'overview' | 'access' | 'capabilities' | 'hosts' | 'pair-host' | 'host-detail' | 'settings' | 'help' | 'activity' | 'activity-detail'
type PendingAction = { type: 'access'; mode: AccessMode } | { type: 'capability'; capability: Capability; mode: CapabilityMode } | { type: 'revoke'; client: AuthorizedClient; remote: string } | { type: 'repair' | 'forget'; host: Host } | { type: 'clear-activity' } | null
const isGrantWindow = '__TAURI_INTERNALS__' in window && getCurrentWindow().label === 'grant'
const demo: Snapshot = {
hosts: [{ url: 'wss://home-hermes.local:8767', name: 'Home Hermes', server_version: '0.9.0', endpoint_role: 'tailscale', paired_at: 1786458000, is_active: true, access_mode: 'trusted' }],
hosts: [{ url: 'wss://home-hermes.local:8767', name: 'Docker-Server', server_version: '1.6.3', endpoint_role: 'tailscale', paired_at: 1786458000, is_active: true, access_mode: 'full-access', capabilities: { commands: 'allow', files: 'allow', screen_input: 'allow', usb: 'allow', microphone: 'allow', camera: 'allow' } }],
active_url: 'wss://home-hermes.local:8767',
daemon: { state: 'connected', running: true, url: 'wss://home-hermes.local:8767', privilege: 'user', username: 'Local user' },
startup_enabled: true,
daemon_autostart_enabled: true,
ui_version: '0.4.0-alpha.7',
cli_version: '0.4.0-alpha.4',
cli_path: 'C:\\Program Files\\Hermes-Relay CLI\\hermes-relay.exe',
hardware_availability: { usb: true, adb: true, microphone: false, camera: false },
pending_grants: [],
activity: [
{ ts: Date.now() - 110_000, tool: 'desktop.shell', ok: true, summary: 'PowerShell command completed' },
{ ts: Date.now() - 110_000, tool: 'desktop_powershell', ok: true, summary: 'exit 0', request_detail: '{\n "script": "Get-Process | Select-Object -First 5"\n}', stdout: 'Handles NPM(K) PM(K) WS(K) CPU(s) Id ProcessName\n------- ------ ----- ----- ------ -- -----------\n 412 31 74248 98312 4.18 812 powershell' },
{ ts: Date.now() - 260_000, tool: 'desktop.connect', ok: true, summary: 'Home Hermes connected' },
{ ts: Date.now() - 480_000, tool: 'daemon.start', ok: true, summary: 'Relay daemon started' }
]
@@ -71,6 +77,7 @@ type ActivityFilter = 'all' | ActivityCategory | 'attention' | 'warning'
function activityCategory(entry: Activity): ActivityCategory {
if (entry.category) return entry.category
const tool = entry.tool.toLowerCase()
if (tool.includes('adb') || tool.includes('usb')) return 'devices'
if (tool.includes('screenshot') || tool.includes('screen')) return 'screen'
if (tool.includes('computer_') || tool.includes('mouse') || tool.includes('keyboard')) return 'input'
if (tool.includes('file') || tool.includes('directory') || tool.includes('patch')) return 'files'
@@ -121,11 +128,20 @@ function age(ts?: number): string {
function formatGrantScope(scope: unknown): string {
if (!scope || typeof scope !== 'object') return ''
return Object.entries(scope as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string' && value.trim())
.filter(([key, value]) => !['action', 'preview'].includes(key) && typeof value === 'string' && value.trim())
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ')
}
function grantAction(scope: unknown): { label: string; preview: string } | null {
if (!scope || typeof scope !== 'object') return null
const record = scope as Record<string, unknown>
const label = typeof record.action === 'string' ? record.action.trim() : ''
const preview = typeof record.preview === 'string' ? record.preview.trim() : ''
if (!label && !preview) return null
return { label: label || 'Requested action', preview: preview || label }
}
function friendlyUpdateError(error: unknown): string {
const message = String(error)
const jsonStart = message.indexOf('{')
@@ -139,9 +155,39 @@ function friendlyUpdateError(error: unknown): string {
}
const accessCopy: Record<AccessMode, string> = {
ask: 'The connection stays ready, but this host cannot use desktop tools until you allow access.',
trusted: 'This host may use command and file tools; screen and input still require a task grant.',
'full-access': 'This host may use commands, screen, mouse, and keyboard without asking.'
ask: 'All desktop capabilities are off. The relay connection stays ready.',
'ask-every-time': 'Each available command, file, screen, input, or USB operation asks first.',
structured: 'Files are allowed. Screen, input, and USB ask first. Raw commands stay off.',
trusted: 'Individual capabilities use the settings migrated from the former Trusted preset.',
'full-access': 'Every available capability is allowed without task grants.',
custom: 'Individual capabilities use the settings you choose.'
}
function formatPairedAt(ts?: number | null): string {
if (!ts) return 'Unknown'
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(ts * 1000))
}
const accessLabel: Record<AccessMode, string> = {
ask: 'Restricted', 'ask-every-time': 'Ask Every Time', structured: 'Standard', trusted: 'Custom', 'full-access': 'Full Access', custom: 'Custom'
}
const capabilityLabel: Record<CapabilityMode, string> = {
disabled: 'Off', ask: 'Ask', allow: 'Allow'
}
const presetCapabilities: Partial<Record<AccessMode, Host['capabilities']>> = {
ask: { commands: 'disabled', files: 'disabled', screen_input: 'disabled', usb: 'disabled', microphone: 'disabled', camera: 'disabled' },
'ask-every-time': { commands: 'ask', files: 'ask', screen_input: 'ask', usb: 'ask', microphone: 'disabled', camera: 'disabled' },
structured: { commands: 'disabled', files: 'allow', screen_input: 'ask', usb: 'ask', microphone: 'disabled', camera: 'disabled' },
trusted: { commands: 'allow', files: 'allow', screen_input: 'ask', usb: 'ask', microphone: 'disabled', camera: 'disabled' },
'full-access': { commands: 'allow', files: 'allow', screen_input: 'allow', usb: 'allow', microphone: 'allow', camera: 'allow' }
}
function hostAccessLabel(host: Host): string {
const expected = presetCapabilities[host.access_mode]
if (!expected || Object.entries(expected).some(([capability, mode]) => host.capabilities[capability as Capability] !== mode)) return 'Custom'
return accessLabel[host.access_mode]
}
export default function App() {
@@ -153,12 +199,18 @@ function ManagementApp() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [selectedUrl, setSelectedUrl] = useState<string | null>(null)
const [detailUrl, setDetailUrl] = useState<string | null>(null)
const [pairInitialUrl, setPairInitialUrl] = useState('')
const [activityBack, setActivityBack] = useState<Page>('settings')
const [policyBack, setPolicyBack] = useState<Page>('overview')
const [activityDetailBack, setActivityDetailBack] = useState<Page>('activity')
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null)
const [selectorOpen, setSelectorOpen] = useState(false)
const [clients, setClients] = useState<AuthorizedClient[]>([])
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState<PendingAction>(null)
const [windowVisible, setWindowVisible] = useState(true)
const [reviewGrantOpen, setReviewGrantOpen] = useState(false)
const selectorRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLElement>(null)
const hideTimer = useRef<number | null>(null)
@@ -166,7 +218,21 @@ function ManagementApp() {
const refresh = useCallback(async () => {
try {
const next = await call<Snapshot>('get_snapshot')
next.hosts = next.hosts.map(h => ({ ...h, name: h.name || displayHost(h.url) }))
next.hosts = next.hosts.map(h => {
const capabilities = h.capabilities as Partial<Host['capabilities']>
return {
...h,
name: h.name || displayHost(h.url),
capabilities: {
commands: capabilities.commands ?? (h.access_mode === 'full-access' ? 'allow' : h.access_mode === 'trusted' ? 'allow' : 'disabled'),
files: capabilities.files ?? (h.access_mode === 'ask' ? 'disabled' : h.access_mode === 'ask-every-time' ? 'ask' : 'allow'),
screen_input: capabilities.screen_input ?? (h.access_mode === 'full-access' ? 'allow' : h.access_mode === 'ask' ? 'disabled' : 'ask'),
usb: capabilities.usb ?? (h.access_mode === 'ask-every-time' ? 'ask' : 'disabled'),
microphone: capabilities.microphone ?? 'disabled',
camera: capabilities.camera ?? 'disabled'
}
}
})
setSnapshot(next)
setSelectedUrl(current => current && next.hosts.some(h => h.url === current) ? current : next.active_url ?? next.hosts[0]?.url ?? null)
setError(null)
@@ -191,9 +257,10 @@ function ManagementApp() {
const connected = Boolean(snapshot?.daemon.running && snapshot.daemon.state === 'connected' && host && snapshot.daemon.url === host.url)
useEffect(() => {
if (page !== 'settings' || !host) return
call<AuthorizedClient[]>('list_authorized_clients', { remote: host.url }).then(setClients).catch(e => setError(String(e)))
}, [page, host])
if (page !== 'host-detail' || !detailUrl) return
setClients([])
call<AuthorizedClient[]>('list_authorized_clients', { remote: detailUrl }).then(setClients).catch(e => setError(String(e)))
}, [page, detailUrl])
useEffect(() => {
contentRef.current?.scrollTo({ top: 0 })
@@ -201,11 +268,23 @@ function ManagementApp() {
async function action(name: string, args?: Record<string, unknown>) {
setBusy(name)
try { await call(name, args); await refresh(); setError(null) }
catch (e) { setError(String(e)) }
try { await call(name, args); await refresh(); setError(null); return true }
catch (e) { setError(String(e)); return false }
finally { setBusy(null) }
}
function openPair(url = '') {
setPairInitialUrl(url)
setSelectorOpen(false)
setPage('pair-host')
}
async function pairHost(remote: string, code: string) {
const paired = await action('pair_host', { remote, code })
if (paired) setPage('hosts')
return paired
}
async function selectHost(url: string) {
setSelectedUrl(url)
setSelectorOpen(false)
@@ -223,9 +302,17 @@ function ManagementApp() {
if (!work) return
if (work.type === 'access' && host) {
await action('set_host_access', { remote: host.url, mode: work.mode })
} else if (work.type === 'revoke' && host) {
await action('revoke_authorized_client', { remote: host.url, prefix: work.client.token_prefix })
setClients(await call<AuthorizedClient[]>('list_authorized_clients', { remote: host.url }))
} else if (work.type === 'capability' && host) {
await action('set_host_capability', { remote: host.url, capability: work.capability, mode: work.mode })
} else if (work.type === 'revoke') {
await action('revoke_authorized_client', { remote: work.remote, prefix: work.client.token_prefix })
setClients(await call<AuthorizedClient[]>('list_authorized_clients', { remote: work.remote }))
} else if (work.type === 'repair') {
openPair(work.host.url)
} else if (work.type === 'forget') {
await action('forget_host', { remote: work.host.url })
setDetailUrl(null)
setPage('hosts')
} else if (work.type === 'clear-activity') {
await action('clear_activity')
}
@@ -237,6 +324,12 @@ function ManagementApp() {
else action('set_host_access', { remote: host.url, mode })
}
const chooseCapability = (capability: Capability, mode: CapabilityMode) => {
if (!host || host.capabilities[capability] === mode || capability === 'microphone' || capability === 'camera') return
if (mode === 'allow') setPending({ type: 'capability', capability, mode })
else action('set_host_capability', { remote: host.url, capability, mode })
}
const hideWindow = useCallback(() => {
setWindowVisible(false)
if (hideTimer.current) window.clearTimeout(hideTimer.current)
@@ -261,77 +354,114 @@ function ManagementApp() {
}
}, [hideWindow])
useEffect(() => {
const review = () => { setReviewGrantOpen(true); void refresh() }
window.addEventListener('hermes-review-grant', review)
return () => window.removeEventListener('hermes-review-grant', review)
}, [refresh])
if (!snapshot) return <div className={`app-shell loading ${windowVisible ? 'window-visible' : ''}`}><LoaderCircle className="spin" /><span>Loading Hermes-Relay CLI UI…</span></div>
const reviewGrant = snapshot.pending_grants[0] ?? null
const reviewAction = grantAction(reviewGrant?.scope)
const reviewScope = formatGrantScope(reviewGrant?.scope)
const reviewHost = snapshot.hosts.find(item => item.url === snapshot.daemon.url)?.name ?? 'Connected host'
return <div className={`app-shell ${windowVisible ? 'window-visible' : ''}`}>
<header className="titlebar">
<div className="brand"><img src={logo} alt="" /><span>Hermes-Relay CLI UI</span></div>
<div className="window-controls">
<button aria-label="Help and About" title="Help and About" onClick={() => setPage('help')}><CircleHelp /></button>
<button aria-label="Hide window" onClick={hideWindow}><X /></button>
</div>
</header>
<main className="content" ref={contentRef}>
{page === 'overview' && <>
<section className="connection-hero">
<div className={`status-ring ${connected ? 'online' : 'offline'}`}>{connected ? <Check /> : <Unplug />}</div>
<div><h1>{connected ? 'Connected' : 'Disconnected'}</h1><p>{connected ? 'Remote control tunnel active' : 'The remote control tunnel is offline'}</p></div>
</section>
<section className="section host-section">
<label>Connected host</label>
<section className={`connection-route ${connected ? 'online' : 'offline'}`}>
{snapshot.hosts.length === 0 ?
<button className="empty-pair" onClick={() => action('pair_host')}><Link2 /><span><strong>Pair host</strong><small>Connect this PC to a Hermes instance</small></span><ChevronRight /></button> :
<div className="host-selector" ref={selectorRef}>
<button className="selector-button" aria-expanded={selectorOpen} onClick={() => setSelectorOpen(value => !value)}>
<span className="host-icon"><Monitor /></span><span className="host-main"><strong>{host?.name}</strong><small>{host?.endpoint_role ? `${host.endpoint_role.replace(/^./, x => x.toUpperCase())} relay` : 'Hermes relay'} <i>• {connected ? 'Online' : 'Offline'}</i></small></span><ChevronDown className={selectorOpen ? 'rotated' : ''} />
</button>
<button className="empty-pair" onClick={() => openPair()}><Link2 /><span><strong>Pair host</strong><small>Connect this PC to a Hermes instance</small></span><ChevronRight /></button> :
<div className="route-grid" ref={selectorRef}>
<span className="route-endpoint"><Bot /><small>Agent</small></span>
<i className="route-link left" />
<div className="route-host">
<button className="route-host-button" aria-expanded={selectorOpen} aria-label={`Connected host: ${host?.name}. Change host`} onClick={() => setSelectorOpen(value => !value)}>
<span className="route-host-icon"><Server /></span>
<span className="route-host-copy"><small>Connected host</small><strong>{host?.name}</strong></span>
<span className="route-host-action">Change</span><ChevronDown />
</button>
{selectorOpen && <div className="selector-menu">
{snapshot.hosts.map(item => <button key={item.url} className={item.url === host?.url ? 'selected' : ''} onClick={() => selectHost(item.url)}><Monitor /><span><strong>{item.name}</strong><small>{item.url}</small></span>{item.url === host?.url && <Check />}</button>)}
<button className="pair-option" onClick={() => { setSelectorOpen(false); action('pair_host') }}><Link2 /><span><strong>Pair host</strong><small>Connect another Hermes instance</small></span></button>
<button className="pair-option" onClick={() => openPair()}><Link2 /><span><strong>Pair host</strong><small>Connect another Hermes instance</small></span></button>
</div>}
</div>
<i className="route-link right" />
<span className="route-endpoint"><Monitor /><small>This PC</small></span>
{connected && <span className="route-traffic" aria-hidden="true">
<i className="packet packet-outbound" /><i className="packet packet-outbound packet-late" />
<i className="packet packet-inbound" /><i className="packet packet-inbound packet-late" />
</span>}
<div className={`route-status ${connected && host?.url.startsWith('ws://') ? 'insecure' : ''}`}><strong>{connected ? 'Connected' : 'Disconnected'}</strong><span>·</span><small>{connected ? (host?.url.startsWith('wss://') ? 'Encrypted relay connection' : 'Unencrypted relay connection') : 'Relay connection offline'}</small></div>
</div>}
</section>
{host && <section className="section access-section">
<div className="label-row"><label>Access for this host</label><span title="Access applies only to the selected Hermes host."><CircleHelp /></span></div>
<div className="access-control" role="radiogroup" aria-label="Host access">
<button role="radio" aria-checked={host.access_mode === 'ask'} className={host.access_mode === 'ask' ? 'active' : ''} onClick={() => chooseAccess('ask')}><CircleHelp /><span>Ask</span></button>
<button role="radio" aria-checked={host.access_mode === 'trusted'} className={host.access_mode === 'trusted' ? 'active' : ''} onClick={() => chooseAccess('trusted')}><ShieldCheck /><span>Trusted</span></button>
<button role="radio" aria-checked={host.access_mode === 'full-access'} className={host.access_mode === 'full-access' ? 'active' : ''} onClick={() => chooseAccess('full-access')}><Monitor /><span>Full Access</span></button>
</div>
<p className="access-copy">{accessCopy[host.access_mode]}</p>
{host && <section className="policy-ledger" aria-label="Host access and capabilities">
<button onClick={() => setPage('access')}>
<span className="policy-icon"><LockKeyhole /></span>
<span className="policy-name"><strong>Desktop access</strong><small>Preset for this host</small></span>
<span className="policy-value">{hostAccessLabel(host)}</span>
<ChevronRight />
</button>
<button onClick={() => setPage('capabilities')}>
<span className="policy-icon"><SlidersHorizontal /></span>
<span className="policy-name"><strong>Capabilities</strong><small>{host.access_mode === 'full-access' ? 'Included by Full Access' : 'Commands, files, screen and hardware'}</small></span>
<span className="capability-summary"><em>{host.access_mode === 'full-access' ? 'All Allow' : `USB ${capabilityLabel[host.capabilities.usb]}`}</em></span>
<ChevronRight />
</button>
</section>}
<button className="tunnel-button" disabled={busy !== null} onClick={() => action(connected ? 'disconnect_daemon' : 'connect_daemon')}><Power />{connected ? 'Disconnect Tunnel' : 'Connect Tunnel'}</button>
<section className="activity-section">
<div className="section-heading"><h2>Recent activity</h2><button onClick={() => setPage('settings')}>View all <ChevronRight /></button></div>
<ActivityList entries={snapshot.activity.slice(-3).reverse()} host={host} />
<div className="section-heading"><h2>Recent activity</h2><button onClick={() => { setActivityBack('overview'); setPage('activity') }}>View all <ChevronRight /></button></div>
<ActivityList entries={snapshot.activity.slice(-3).reverse()} host={host} onOpen={entry => { setSelectedActivity(entry); setActivityDetailBack('overview'); setPage('activity-detail') }} />
</section>
{snapshot.daemon.privilege === 'administrator' && <aside className="admin-warning"><AlertTriangle /><span>Hermes-Relay CLI is running as Administrator.</span><button onClick={() => setPage('settings')}>Learn more</button></aside>}
{snapshot.daemon.privilege === 'administrator' && <aside className="admin-warning"><AlertTriangle /><span>Hermes-Relay CLI is running as Administrator.</span><button onClick={() => { setSelectedUrl(snapshot.active_url ?? null); setPage('settings') }}>Learn more</button></aside>}
</>}
{page === 'hosts' && <HostsPage hosts={snapshot.hosts} selected={host} onOpen={url => { setDetailUrl(url); setPage('host-detail') }} onPair={() => action('pair_host')} />}
{page === 'host-detail' && <HostDetailPage host={snapshot.hosts.find(item => item.url === detailUrl) ?? null} busy={busy !== null} onBack={() => setPage('hosts')} onConnect={connectHost} onRename={(remote, name) => action('rename_host', { remote, name })} />}
{page === 'settings' && <SettingsPage host={host} daemon={snapshot.daemon} startup={snapshot.startup_enabled} clients={clients} activity={snapshot.activity} onRestart={() => action('restart_daemon')} onStartup={value => action('set_startup', { enabled: value })} onRevoke={client => setPending({ type: 'revoke', client })} onViewActivity={() => setPage('activity')} />}
{page === 'activity' && <ActivityPage entries={snapshot.activity} host={host} onBack={() => setPage('settings')} onClear={() => setPending({ type: 'clear-activity' })} />}
{page === 'access' && <AccessPage host={host} busy={busy !== null} onBack={() => setPage(policyBack)} onChoose={chooseAccess} />}
{page === 'capabilities' && <CapabilitiesPage host={host} availability={snapshot.hardware_availability} busy={busy !== null} onBack={() => setPage(policyBack)} onChoose={chooseCapability} />}
{page === 'hosts' && <HostsPage hosts={snapshot.hosts} selected={host} onOpen={url => { setDetailUrl(url); setSelectedUrl(url); setPage('host-detail') }} onPair={() => openPair()} />}
{page === 'pair-host' && <PairHostPage initialUrl={pairInitialUrl} busy={busy === 'pair_host'} onBack={() => setPage('hosts')} onPair={pairHost} />}
{page === 'host-detail' && <HostDetailPage host={snapshot.hosts.find(item => item.url === detailUrl) ?? null} clients={clients} busy={busy !== null} onBack={() => setPage('hosts')} onConnect={connectHost} onRename={(remote, name) => action('rename_host', { remote, name })} onAccess={() => { setPolicyBack('host-detail'); setPage('access') }} onCapabilities={() => { setPolicyBack('host-detail'); setPage('capabilities') }} onRevoke={(remote, client) => setPending({ type: 'revoke', client, remote })} onRepair={host => setPending({ type: 'repair', host })} onForget={host => setPending({ type: 'forget', host })} />}
{page === 'settings' && <SettingsPage daemon={snapshot.daemon} startup={snapshot.startup_enabled} daemonAutostart={snapshot.daemon_autostart_enabled ?? false} activity={snapshot.activity} onAction={action} onStartup={value => action('set_startup', { enabled: value })} onDaemonAutostart={value => action('set_daemon_autostart', { enabled: value })} onHelp={() => setPage('help')} onViewActivity={() => { setActivityBack('settings'); setPage('activity') }} onOpenActivity={entry => { setSelectedActivity(entry); setActivityDetailBack('settings'); setPage('activity-detail') }} />}
{page === 'help' && <HelpPage snapshot={snapshot} host={host} onBack={() => { setSelectedUrl(snapshot.active_url ?? null); setPage('settings') }} onAction={action} />}
{page === 'activity' && <ActivityPage entries={snapshot.activity} host={host} onBack={() => setPage(activityBack)} onClear={() => setPending({ type: 'clear-activity' })} onOpen={entry => { setSelectedActivity(entry); setActivityDetailBack('activity'); setPage('activity-detail') }} />}
{page === 'activity-detail' && <ActivityDetailPage entry={selectedActivity} host={host} onBack={() => setPage(activityDetailBack)} />}
</main>
{error && <div className="error-toast" role="alert"><AlertTriangle /><span>{error}</span><button onClick={() => setError(null)}><X /></button></div>}
{reviewGrantOpen && reviewGrant && <div className="modal-backdrop grant-review-backdrop" role="presentation"><div className="modal grant-review-modal" role="dialog" aria-modal="true" aria-labelledby="review-grant-title">
<div className="modal-icon"><ShieldCheck /></div><h2 id="review-grant-title">Review remote request</h2>
<p>{reviewHost} wants to perform an action on this PC.</p>
{reviewAction && <div className="grant-action-full"><dt>Requested action · {reviewAction.label}</dt><dd><pre>{reviewAction.preview}</pre></dd></div>}
<dl className="review-grant-facts"><div><dt>Reason</dt><dd>{reviewGrant.reason || 'No reason provided'}</dd></div>{reviewScope && <div><dt>Scope</dt><dd>{reviewScope}</dd></div>}</dl>
<div className="modal-actions"><button className="secondary" onClick={async () => { await action('resolve_grant', { id: reviewGrant.id, approved: false }); setReviewGrantOpen(false) }}>Reject</button><button className="primary" onClick={async () => { await action('resolve_grant', { id: reviewGrant.id, approved: true }); setReviewGrantOpen(false) }}>Approve</button></div>
</div></div>}
<nav className="bottom-nav">
<button className={page === 'overview' ? 'active' : ''} onClick={() => setPage('overview')}><Home /><span>Overview</span></button>
<button className={page === 'hosts' || page === 'host-detail' ? 'active' : ''} onClick={() => setPage('hosts')}><Monitor /><span>Hosts</span></button>
<button className={page === 'settings' || page === 'activity' ? 'active' : ''} onClick={() => setPage('settings')}><Settings /><span>Settings</span></button>
<button className={page === 'overview' || ((page === 'access' || page === 'capabilities') && policyBack === 'overview') ? 'active' : ''} onClick={() => { setSelectedUrl(snapshot.active_url ?? null); setPolicyBack('overview'); setPage('overview') }}><Home /><span>Overview</span></button>
<button className={page === 'hosts' || page === 'pair-host' || page === 'host-detail' || ((page === 'access' || page === 'capabilities') && policyBack === 'host-detail') ? 'active' : ''} onClick={() => setPage('hosts')}><Monitor /><span>Hosts</span></button>
<button className={page === 'settings' || page === 'help' || page === 'activity' || page === 'activity-detail' ? 'active' : ''} onClick={() => { setSelectedUrl(snapshot.active_url ?? null); setPage('settings') }}><Settings /><span>Settings</span></button>
</nav>
{pending && <div className="modal-backdrop" role="presentation"><div className="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div className="modal-icon">{pending.type === 'revoke' ? <UserRoundX /> : pending.type === 'clear-activity' ? <Trash2 /> : <AlertTriangle />}</div>
<h2 id="confirm-title">{pending.type === 'access' ? `Give ${host?.name} full access?` : pending.type === 'revoke' ? `Deauthorize ${pending.client.device_name ?? pending.client.token_prefix}?` : 'Clear local activity?'}</h2>
<p>{pending.type === 'access' ? 'This host will be able to run commands and control this PC without asking. Only use Full Access with a Hermes host you control.' : pending.type === 'revoke' ? (pending.client.is_current ? 'This is the current PC session. You will need to pair again.' : 'This client will immediately lose access to this Hermes host.') : 'This permanently removes the current and rotated desktop audit history from this PC. New activity will continue to be recorded.'}</p>
<div className="modal-actions"><button className="secondary" onClick={() => setPending(null)}>Cancel</button><button className="danger" onClick={confirmPending}>{pending.type === 'access' ? 'Enable Full Access' : pending.type === 'revoke' ? 'Deauthorize' : 'Clear activity'}</button></div>
<div className="modal-icon">{pending.type === 'revoke' ? <UserRoundX /> : pending.type === 'forget' || pending.type === 'clear-activity' ? <Trash2 /> : <AlertTriangle />}</div>
<h2 id="confirm-title">{pending.type === 'access' ? `Give ${host?.name} full access?` : pending.type === 'capability' ? `Always allow ${pending.capability.replace('_', ' & ')} for ${host?.name}?` : pending.type === 'revoke' ? `Deauthorize ${pending.client.device_name ?? pending.client.token_prefix}?` : pending.type === 'repair' ? `Re-pair ${pending.host.name}?` : pending.type === 'forget' ? `Forget ${pending.host.name}?` : 'Clear local activity?'}</h2>
<p>{pending.type === 'access' ? 'Every available capability will be allowed without task grants. Only use Full Access with a Hermes host you control.' : pending.type === 'capability' ? 'This allows the capability without per-operation approval. The matching preset is selected automatically; otherwise the policy becomes Custom.' : pending.type === 'revoke' ? (pending.client.is_current ? 'This is the current PC session. You will need to pair again.' : 'This client will immediately lose access to this Hermes host.') : pending.type === 'repair' ? 'A fresh pairing flow will replace this host session. Use this to recover an expired or invalid pairing.' : pending.type === 'forget' ? `This removes the local pairing, access policy, and display name. ${pending.host.is_active ? 'The active daemon will disconnect.' : 'The remote relay is not changed.'}` : 'This permanently removes the current and rotated desktop audit history from this PC. New activity will continue to be recorded.'}</p>
<div className="modal-actions"><button className="secondary" onClick={() => setPending(null)}>Cancel</button><button className="danger" onClick={confirmPending}>{pending.type === 'access' ? 'Enable Full Access' : pending.type === 'capability' ? 'Allow capability' : pending.type === 'revoke' ? 'Deauthorize' : pending.type === 'repair' ? 'Start re-pairing' : pending.type === 'forget' ? 'Forget host' : 'Clear activity'}</button></div>
</div></div>}
</div>
}
@@ -400,57 +530,58 @@ function GrantWindow() {
const hostName = snapshot.hosts.find(item => item.url === snapshot.daemon.url)?.name
?? (snapshot.daemon.url ? displayHost(snapshot.daemon.url) : 'Connected host')
const scope = formatGrantScope(grant.scope)
const action = grantAction(grant.scope)
const minutes = Math.max(1, Math.round(grant.duration_seconds / 60))
const grantCategory = grant.mode.split('.')[0]
const grantPresentation = grantCategory === 'usb'
? { eyebrow: 'USB access request', title: 'Raw USB access', summary: 'wants to run one operation on a connected USB device.' }
: grantCategory === 'commands'
? { eyebrow: 'Command execution request', title: 'Run command', summary: 'wants to run one command on this PC.' }
: grantCategory === 'files'
? { eyebrow: 'File access request', title: 'Access files', summary: 'wants to access files on this PC.' }
: grantCategory === 'screen_input'
? { eyebrow: 'Screen & input request', title: 'Control access', summary: `wants to view or control this PC for up to ${minutes} min.` }
: { eyebrow: 'Remote access request', title: `${grant.mode} access`, summary: `wants to use this PC for up to ${minutes} min.` }
return <div className={`grant-shell ${visible ? 'window-visible' : ''} ${expanded ? 'expanded' : ''}`}>
<section className="grant-card" role="dialog" aria-modal="true" aria-labelledby="grant-title">
<div className="grant-head">
<span className="grant-icon"><ShieldCheck /></span>
<span><small>Remote access request</small><strong id="grant-title">{grant.mode} access</strong></span>
<span><small>{grantPresentation.eyebrow}</small><strong id="grant-title">{grantPresentation.title}</strong></span>
<button className="grant-expand" aria-expanded={expanded} aria-label={expanded ? 'Hide request details' : 'Show request details'} onClick={toggleExpanded}><ChevronDown /></button>
</div>
<p className="grant-summary"><strong>{hostName}</strong> wants to control this PC for up to {minutes} min.</p>
<p className="grant-summary"><strong>{hostName}</strong> {grantPresentation.summary}</p>
{action && <div className="grant-action-preview"><small>Requested action · {action.label}</small><code>{action.preview}</code></div>}
<div className="grant-details" aria-hidden={!expanded}>
<dl><div><dt>Reason</dt><dd>{grant.reason || 'No reason provided'}</dd></div>{scope && <div><dt>Scope</dt><dd>{scope}</dd></div>}</dl>
<dl>{action && <div className="grant-action-full"><dt>Action preview</dt><dd><pre>{action.preview}</pre></dd></div>}<div><dt>Reason</dt><dd>{grant.reason || 'No reason provided'}</dd></div>{scope && <div><dt>Scope</dt><dd>{scope}</dd></div>}</dl>
</div>
<div className="grant-open-ui"><button disabled={busy} onClick={() => call('open_management_from_grant')}><ExternalLink /> Open in UI</button></div>
<div className="grant-actions"><button disabled={busy} onClick={() => resolve(false)}>Reject</button><button disabled={busy} onClick={() => resolve(true)}>Approve</button></div>
</section>
</div>
}
function ActivityList({ entries, host, detailed = false }: { entries: Activity[]; host: Host | null; detailed?: boolean }) {
const [expanded, setExpanded] = useState<string | null>(null)
function ActivityList({ entries, host, onOpen }: { entries: Activity[]; host: Host | null; onOpen?: (entry: Activity) => void }) {
if (!entries.length) return <div className="empty-state"><Clock3 /><span>No remote activity yet</span></div>
return <div className="activity-list">{entries.map((entry, i) => {
const category = activityCategory(entry)
const attention = needsAttention(entry)
const warning = isNonZeroExit(entry)
const key = entry.request_id ?? `${entry.ts}-${i}`
const Icon = category === 'command' ? TerminalSquare : category === 'files' ? FileText : category === 'screen' ? Eye : category === 'input' ? MousePointer2 : category === 'system' ? LogOut : ActivityIcon
const open = detailed && expanded === key
const Icon = category === 'command' ? TerminalSquare : category === 'files' ? FileText : category === 'screen' ? Eye : category === 'input' ? MousePointer2 : category === 'devices' ? Usb : category === 'system' ? LogOut : ActivityIcon
const detail = entry.error ?? entry.summary ?? (entry.aborted ? 'Request aborted' : 'Completed')
const eventHost = entry.host_url ? displayHost(entry.host_url) : host?.name ?? 'Local daemon'
return <article className={`activity-item ${open ? 'expanded' : ''}`} key={key}>
<button className="activity-row" aria-expanded={detailed ? open : undefined} onClick={() => detailed && setExpanded(open ? null : key)}>
return <article className="activity-item" key={key}>
<button className="activity-row" disabled={!onOpen} onClick={() => onOpen?.(entry)}>
<span className={`activity-icon ${attention || warning ? 'amber' : category === 'system' ? 'green' : 'violet'}`}><Icon /></span>
<span className="activity-copy"><strong>{activityName(entry.tool)}</strong><small className={attention ? 'attention' : warning ? 'warning' : ''}>{detail} · {eventHost}</small></span>
<span className="activity-tail"><time>{formatTime(entry.ts)}</time>{detailed && <ChevronDown />}</span>
<span className="activity-tail"><time>{formatTime(entry.ts)}</time>{onOpen && <ChevronRight />}</span>
</button>
{detailed && <div className="activity-details" aria-hidden={!open}>
<dl>
<div><dt>When</dt><dd>{formatDateTime(entry.ts)}</dd></div>
<div><dt>Duration</dt><dd>{formatDuration(entry.duration_ms)}</dd></div>
<div><dt>Status</dt><dd className={attention ? 'attention' : warning ? 'warning' : 'success'}>{entry.aborted ? 'Aborted' : !entry.ok ? 'Failed' : warning ? `Exit ${activityExitCode(entry)}` : 'Completed'}</dd></div>
<div><dt>Category</dt><dd>{category}</dd></div>
</dl>
{entry.args_preview && <div className="activity-context"><span>Request</span><code>{entry.args_preview}</code></div>}
{entry.request_id && <div className="activity-request-id">ID {entry.request_id}</div>}
</div>}
</article>
})}</div>
}
function ActivityPanel({ entries, host, onClear }: { entries: Activity[]; host: Host | null; onClear: () => void }) {
function ActivityPanel({ entries, host, onClear, onOpen }: { entries: Activity[]; host: Host | null; onClear: () => void; onOpen: (entry: Activity) => void }) {
const [filter, setFilter] = useState<ActivityFilter>('all')
const newest = entries.slice().reverse()
const attention = newest.filter(needsAttention).length
@@ -459,21 +590,111 @@ function ActivityPanel({ entries, host, onClear }: { entries: Activity[]; host:
const filters: Array<{ value: ActivityFilter; label: string }> = [
{ value: 'all', label: 'All' }, { value: 'command', label: 'Commands' },
{ value: 'files', label: 'Files' }, { value: 'screen', label: 'Screen' },
{ value: 'input', label: 'Input' }, { value: 'system', label: 'System' },
{ value: 'input', label: 'Input' }, { value: 'devices', label: 'Devices' }, { value: 'system', label: 'System' },
{ value: 'warning', label: 'Non-zero' }, { value: 'attention', label: 'Issues' }
]
return <div className="activity-panel">
<div className="activity-summary"><span><ActivityIcon /><strong>{entries.length}</strong><small>Recent events</small></span><span className={attention ? 'has-attention' : ''}><AlertTriangle /><strong>{attention}</strong><small>Issues</small></span><em><i /> Live</em></div>
<div className="activity-toolbar"><div className="activity-filters" aria-label="Filter activity">{filters.map(item => <button key={item.value} className={filter === item.value ? 'active' : ''} onClick={() => setFilter(item.value)}>{item.label}{item.value === 'attention' && attention > 0 ? ` ${attention}` : item.value === 'warning' && warnings > 0 ? ` ${warnings}` : ''}</button>)}</div><button className="clear-activity" disabled={!entries.length} onClick={onClear}><Trash2 /> Clear</button></div>
<div className="settings-card activity-card"><ActivityList entries={visible} host={host} detailed /></div>
<div className="settings-card activity-card"><ActivityList entries={visible} host={host} onOpen={onOpen} /></div>
</div>
}
function ActivityPage({ entries, host, onBack, onClear }: { entries: Activity[]; host: Host | null; onBack: () => void; onClear: () => void }) {
function ActivityPage({ entries, host, onBack, onClear, onOpen }: { entries: Activity[]; host: Host | null; onBack: () => void; onClear: () => void; onOpen: (entry: Activity) => void }) {
return <section className="page-panel activity-page">
<div className="page-title activity-page-title"><button className="back-button" onClick={onBack}><ChevronRight /> Settings</button><div><p>Local audit</p><h1>Activity</h1></div></div>
<div className="page-title activity-page-title"><button className="back-button" onClick={onBack}><ArrowLeft /> Back to Settings</button><div><p>Local audit</p><h1>Activity</h1></div></div>
<p className="page-intro">Remote actions and management changes recorded on this PC.</p>
<ActivityPanel entries={entries} host={host} onClear={onClear} />
<ActivityPanel entries={entries} host={host} onClear={onClear} onOpen={onOpen} />
</section>
}
function ActivityDetailPage({ entry, host, onBack }: { entry: Activity | null; host: Host | null; onBack: () => void }) {
if (!entry) return <section className="page-panel"><button className="back-button" onClick={onBack}><ArrowLeft /> Back to Activity</button><div className="large-empty"><ActivityIcon /><h2>Event unavailable</h2></div></section>
const attention = needsAttention(entry)
const warning = isNonZeroExit(entry)
const status = entry.aborted ? 'Aborted' : !entry.ok ? 'Failed' : warning ? `Exit ${activityExitCode(entry)}` : 'Completed'
const eventHost = entry.host_url ? displayHost(entry.host_url) : host?.name ?? 'Local daemon'
const blocks = [
['Request', entry.request_detail ?? entry.args_preview, entry.request_truncated],
['Standard output', entry.stdout, entry.stdout_truncated],
['Standard error', entry.stderr, entry.stderr_truncated],
['Result', entry.result_detail, entry.result_truncated],
['Error', entry.error, false]
] as const
return <section className="page-panel activity-detail-page">
<button className="back-button" onClick={onBack}><ArrowLeft /> Back to Activity</button>
<div className="activity-detail-title"><span className={`activity-icon ${attention || warning ? 'amber' : 'violet'}`}><TerminalSquare /></span><span><p>{activityCategory(entry)}</p><h1>{activityName(entry.tool)}</h1><small>{eventHost}</small></span></div>
<dl className="activity-detail-meta"><div><dt>Status</dt><dd className={attention ? 'attention' : warning ? 'warning' : 'success'}>{status}</dd></div><div><dt>When</dt><dd>{formatDateTime(entry.ts)}</dd></div><div><dt>Duration</dt><dd>{formatDuration(entry.duration_ms)}</dd></div></dl>
<div className="activity-output-list">{blocks.filter(([, value]) => value).map(([label, value, truncated]) => <section key={label}><header><strong>{label}</strong>{truncated && <em>Truncated</em>}</header><pre>{value}</pre></section>)}</div>
{entry.request_id && <div className="activity-request-id">Request ID {entry.request_id}</div>}
</section>
}
function AccessPage({ host, busy, onBack, onChoose }: { host: Host | null; busy: boolean; onBack: () => void; onChoose: (mode: AccessMode) => void }) {
if (!host) return <div className="page-panel large-empty"><LockKeyhole /><h2>No host selected</h2><button onClick={onBack}>Back to Overview</button></div>
const customPolicy = host.access_mode === 'custom' || host.access_mode === 'trusted'
const options: Array<{ mode: AccessMode; Icon: typeof CircleHelp }> = [
{ mode: 'ask', Icon: LockKeyhole },
{ mode: 'ask-every-time', Icon: CircleHelp },
{ mode: 'structured', Icon: ShieldCheck },
{ mode: 'full-access', Icon: Monitor }
]
return <section className="page-panel policy-detail-page">
<div className="page-title policy-page-title"><div><button className="back-button" onClick={onBack}><ArrowLeft /> Back to Overview</button><p>{host.name}</p><h1>Host access</h1></div></div>
<p className="page-intro">Choose the broadest kind of work this Hermes host may perform on this PC.</p>
{customPolicy && <div className="custom-policy-banner"><SlidersHorizontal /><span><strong>Custom policy</strong><small>Individual capabilities differ from the standard presets.</small></span></div>}
<div className="access-options" role="radiogroup" aria-label="Host access">
{options.map(({ mode, Icon }) => <button key={mode} role="radio" aria-checked={host.access_mode === mode} className={host.access_mode === mode ? 'active' : ''} disabled={busy} onClick={() => onChoose(mode)}>
<span className="option-icon"><Icon /></span><span><strong>{accessLabel[mode]}</strong><small>{accessCopy[mode]}</small></span>{host.access_mode === mode ? <span className="selected-check"><Check /></span> : <ChevronRight />}
</button>)}
</div>
{customPolicy && <p className="policy-footnote"><SlidersHorizontal />Custom reflects individual capability choices. Selecting a preset replaces them.</p>}
</section>
}
function CapabilitiesPage({ host, availability, busy, onBack, onChoose }: { host: Host | null; availability: Snapshot['hardware_availability']; busy: boolean; onBack: () => void; onChoose: (capability: Capability, mode: CapabilityMode) => void }) {
if (!host) return <div className="page-panel large-empty"><SlidersHorizontal /><h2>No host selected</h2><button onClick={onBack}>Back to Overview</button></div>
return <section className="page-panel policy-detail-page">
<div className="page-title policy-page-title"><div><button className="back-button" onClick={onBack}><ArrowLeft /> Back to Overview</button><p>{host.name}</p><h1>Capabilities</h1></div></div>
<p className="page-intro">Presets set these together. Capability changes select a matching preset automatically; other combinations become Custom.</p>
<div className="capability-list">
<CapabilityRow capability="commands" title="Command execution" copy="PowerShell, terminal and process launch" icon={<TerminalSquare />} modes={['disabled', 'ask', 'allow']} host={host} busy={busy} onChoose={onChoose} />
<CapabilityRow capability="files" title="Files" copy="Read, write, search and transfer" icon={<FileText />} modes={['disabled', 'ask', 'allow']} host={host} busy={busy} onChoose={onChoose} />
<CapabilityRow capability="screen_input" title="Screen & input" copy="Screenshots, clipboard, mouse and keyboard" icon={<MousePointer2 />} modes={['disabled', 'ask', 'allow']} host={host} busy={busy} onChoose={onChoose} />
<CapabilityRow capability="usb" title="Raw USB" copy="Native utilities and enabled USB services" icon={<Usb />} modes={['disabled', 'ask', 'allow']} host={host} busy={busy || !availability.usb} onChoose={onChoose} />
</div>
<div className="supported-broker"><span><i className={availability.adb ? '' : 'offline'} /><strong>Android Debug Bridge</strong></span><small>Secondary USB service</small><em>{availability.adb ? 'Available' : 'Unavailable'}</em></div>
<div className="unavailable-capabilities">
<div><span className="option-icon"><Mic /></span><span><strong>Microphone</strong><small>A bounded broker is not available yet</small></span><em>Unavailable</em></div>
<div><span className="option-icon"><Video /></span><span><strong>Camera</strong><small>A bounded broker is not available yet</small></span><em>Unavailable</em></div>
</div>
<p className="policy-footnote"><LockKeyhole />Full Access includes every available capability. Unavailable brokers still cannot be advertised.</p>
</section>
}
function CapabilityRow({ capability, title, copy, icon, modes, host, busy, onChoose }: { capability: Capability; title: string; copy: string; icon: ReactNode; modes: CapabilityMode[]; host: Host; busy: boolean; onChoose: (capability: Capability, mode: CapabilityMode) => void }) {
return <section className="capability-row"><div className="capability-row-head"><span className="option-icon">{icon}</span><span><strong>{title}</strong><small>{copy}</small></span>{host.access_mode === 'full-access' && <em>Included</em>}</div><div className="capability-modes" role="radiogroup" aria-label={`${title} access`}>{modes.map(mode => <button key={mode} disabled={busy} role="radio" aria-checked={host.capabilities[capability] === mode} className={host.capabilities[capability] === mode ? 'active' : ''} onClick={() => onChoose(capability, mode)}>{capabilityLabel[mode]}</button>)}</div></section>
}
function PairHostPage({ initialUrl, busy, onBack, onPair }: { initialUrl: string; busy: boolean; onBack: () => void; onPair: (remote: string, code: string) => Promise<boolean> }) {
const [remote, setRemote] = useState(initialUrl)
const [code, setCode] = useState('')
const normalizedCode = code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 6)
const validUrl = (() => { try { const url = new URL(remote.trim()); return ['ws:', 'wss:'].includes(url.protocol) && !url.username && !url.password } catch { return false } })()
const secure = remote.trim().toLowerCase().startsWith('wss://')
const canSubmit = validUrl && normalizedCode.length === 6 && !busy
return <section className="page-panel pair-host-page">
<button className="back-button" onClick={onBack}><ArrowLeft /> Back to Hosts</button>
<div className="page-title"><div><p>New connection</p><h1>{initialUrl ? 'Re-pair host' : 'Pair host'}</h1></div></div>
<p className="page-intro">Enter the relay address and the six-character code shown by Hermes.</p>
<form className="pair-form" onSubmit={async event => { event.preventDefault(); if (canSubmit) await onPair(remote.trim(), normalizedCode) }}>
<label><span>Relay URL</span><div className="pair-input-action"><input aria-label="Relay URL" autoCapitalize="none" autoCorrect="off" spellCheck={false} placeholder="wss://relay.example.com" value={remote} onChange={event => setRemote(event.target.value)} /><button type="button" title={remote ? 'Copy relay URL' : 'Paste relay URL'} aria-label={remote ? 'Copy relay URL' : 'Paste relay URL'} onClick={async () => { if (remote) await navigator.clipboard.writeText(remote.trim()); else setRemote(await navigator.clipboard.readText()) }}><Copy /></button></div></label>
{validUrl && <div className={`transport-notice ${secure ? 'secure' : 'insecure'}`}>{secure ? <ShieldCheck /> : <AlertTriangle />}<span><strong>{secure ? 'Encrypted connection' : 'Unencrypted connection'}</strong><small>{secure ? 'TLS protects relay traffic in transit.' : 'Use ws:// only on a trusted private network. Configure TLS and pair with wss:// for encryption.'}</small></span></div>}
<label><span>Pairing code</span><input className="pair-code" aria-label="Pairing code" autoComplete="one-time-code" inputMode="text" maxLength={6} placeholder="ABC123" value={normalizedCode} onChange={event => setCode(event.target.value)} /></label>
<p className="pair-privacy"><LockKeyhole />The code is passed directly to the local CLI and is not stored by the UI.</p>
<button className="primary-host-action" type="submit" disabled={!canSubmit}>{busy ? <LoaderCircle className="spin" /> : <Link2 />}{busy ? 'Pairing…' : initialUrl ? 'Re-pair host' : 'Pair host'}</button>
</form>
</section>
}
@@ -485,21 +706,27 @@ function HostsPage({ hosts, selected, onOpen, onPair }: { hosts: Host[]; selecte
</section>
}
function HostDetailPage({ host, busy, onBack, onConnect, onRename }: { host: Host | null; busy: boolean; onBack: () => void; onConnect: (url: string) => void; onRename: (url: string, name: string) => Promise<void> }) {
function HostDetailPage({ host, clients, busy, onBack, onConnect, onRename, onAccess, onCapabilities, onRevoke, onRepair, onForget }: { host: Host | null; clients: AuthorizedClient[]; busy: boolean; onBack: () => void; onConnect: (url: string) => void; onRename: (url: string, name: string) => Promise<unknown>; onAccess: () => void; onCapabilities: () => void; onRevoke: (remote: string, client: AuthorizedClient) => void; onRepair: (host: Host) => void; onForget: (host: Host) => void }) {
const [name, setName] = useState(host?.name ?? '')
useEffect(() => setName(host?.name ?? ''), [host?.url, host?.name])
if (!host) return <section className="page-panel"><button className="back-button" onClick={onBack}><ChevronRight /> Hosts</button><div className="large-empty"><Server /><h2>Host unavailable</h2><p>This pairing may have been removed.</p></div></section>
if (!host) return <section className="page-panel"><button className="back-button" onClick={onBack}><ArrowLeft /> Back to Hosts</button><div className="large-empty"><Server /><h2>Host unavailable</h2><p>This pairing may have been removed.</p></div></section>
const changed = name.trim() !== host.name && name.trim().length > 0
return <section className="page-panel host-detail-page">
<button className="back-button" onClick={onBack}><ChevronRight /> Hosts</button>
<div className="host-detail-hero"><span className="host-icon"><Server /></span><span><p>{host.is_active ? 'Active host' : 'Paired host'}</p><h1>{host.name}</h1><small>{host.url}</small></span></div>
<button className="back-button" onClick={onBack}><ArrowLeft /> Back to Hosts</button>
<div className="host-detail-hero"><span className="host-icon"><Server /></span><span><p>{host.is_active ? 'Active host' : 'Paired host'}</p><h1>{host.name}</h1><small>{host.url}</small></span><button className="copy-host-url" aria-label="Copy relay URL" title="Copy relay URL" onClick={() => navigator.clipboard.writeText(host.url)}><Copy /></button></div>
<div className="settings-group"><h2>Display name</h2><div className="rename-host"><input value={name} maxLength={64} aria-label="Host display name" onChange={event => setName(event.target.value)} /><button disabled={!changed || busy} onClick={() => onRename(host.url, name.trim())}>Save</button></div><p className="group-help host-name-help">Stored locally on this PC. It does not rename the Hermes server.</p></div>
<div className="settings-group"><h2>Connection details</h2><div className="settings-card host-detail"><dl><div><dt>Route</dt><dd>{host.endpoint_role ?? 'Custom'}</dd></div><div><dt>Version</dt><dd>{host.server_version ?? 'Unknown'}</dd></div><div><dt>Access</dt><dd>{host.access_mode === 'full-access' ? 'Full Access' : host.access_mode[0]!.toUpperCase() + host.access_mode.slice(1)}</dd></div></dl></div></div>
<div className="settings-group"><h2>Connection</h2><div className="settings-card host-detail"><dl><div><dt>Route</dt><dd>{host.endpoint_role ?? 'Custom'}</dd></div><div><dt>Relay</dt><dd>{host.server_version ?? 'Unknown'}</dd></div><div><dt>Paired</dt><dd>{formatPairedAt(host.paired_at)}</dd></div></dl></div></div>
<div className="settings-group"><h2>Remote access</h2><div className="settings-card management-links">
<button onClick={onAccess}><span className="setting-icon"><LockKeyhole /></span><span><strong>Desktop access</strong><small>Choose how this host requests control.</small></span><em>{hostAccessLabel(host)}</em><ChevronRight /></button>
<button onClick={onCapabilities}><span className="setting-icon"><SlidersHorizontal /></span><span><strong>Capabilities</strong><small>Commands, files, screen, input and hardware.</small></span><em>{host.access_mode === 'full-access' ? 'All allow' : 'Review'}</em><ChevronRight /></button>
</div></div>
<div className="settings-group"><h2>Authorized clients <span>{clients.length}</span></h2><p className="group-help">Sessions authenticated to this relay.</p><div className="settings-card client-list">{clients.length ? clients.map(client => { const identity = [client.device_model, client.device_platform, client.client_surface].filter(Boolean).join(' · ') || client.transport_hint || 'Client'; return <div className="client-row" key={client.token_prefix}><span className="client-icon"><Laptop /></span><span><strong>{client.device_name ?? 'Unnamed client'} {client.is_current && <em>This PC</em>}</strong><small>{identity} · {age(client.last_seen)}</small></span><button onClick={() => onRevoke(host.url, client)} aria-label={`Deauthorize ${client.device_name ?? client.token_prefix}`}><UserRoundX /></button></div> }) : <div className="empty-state"><Radio /><span>No authorized clients reported</span></div>}</div></div>
<button className="primary-host-action" disabled={host.is_active || busy} onClick={() => onConnect(host.url)}><Power />{host.is_active ? 'Currently connected host' : 'Connect to this host'}</button>
<div className="host-danger-actions"><button disabled={busy} onClick={() => onRepair(host)}><RefreshCw /> Re-pair host</button><button disabled={busy} onClick={() => onForget(host)}><Trash2 /> Forget host</button></div>
</section>
}
function SettingsPage({ host, daemon, startup, clients, activity, onRestart, onStartup, onRevoke, onViewActivity }: { host: Host | null; daemon: Snapshot['daemon']; startup: boolean; clients: AuthorizedClient[]; activity: Activity[]; onRestart: () => void; onStartup: (value: boolean) => void; onRevoke: (client: AuthorizedClient) => void; onViewActivity: () => void }) {
function SettingsPage({ daemon, startup, daemonAutostart, activity, onAction, onStartup, onDaemonAutostart, onHelp, onViewActivity, onOpenActivity }: { daemon: Snapshot['daemon']; startup: boolean; daemonAutostart: boolean; activity: Activity[]; onAction: (name: string, args?: Record<string, unknown>) => Promise<unknown>; onStartup: (value: boolean) => void; onDaemonAutostart: (value: boolean) => void; onHelp: () => void; onViewActivity: () => void; onOpenActivity: (entry: Activity) => void }) {
const [update, setUpdate] = useState<UpdateReport | null>(null)
const [updateBusy, setUpdateBusy] = useState<'check' | 'install' | null>(null)
const [updateError, setUpdateError] = useState<string | null>(null)
@@ -533,10 +760,23 @@ function SettingsPage({ host, daemon, startup, clients, activity, onRestart, onS
: 'Check the desktop release channel.'
return <section className="page-panel settings-page"><div className="page-title"><div><p>Local management</p><h1>Settings</h1></div></div>
<div className="settings-group"><h2>Relay daemon</h2><div className="settings-card"><div className="setting-row"><span><strong>Daemon status</strong><small>{daemon.running ? `${daemon.state} · ${daemon.privilege ?? 'user'}` : 'Stopped'}</small></span><button className="compact-button" onClick={onRestart}><RefreshCw /> Restart</button></div><label className="setting-row toggle-row"><span><strong>Start at sign-in</strong><small>Keep remote access ready after you sign in.</small></span><input type="checkbox" checked={startup} onChange={e => onStartup(e.target.checked)} /><i /></label></div></div>
<div className="settings-group"><h2>Relay daemon</h2><div className="settings-card"><div className="setting-row"><span><strong>Daemon status</strong><small>{daemon.running ? `${daemon.state} · ${daemon.privilege ?? 'user'}` : 'Stopped'}</small></span><button className="compact-button" onClick={() => onAction('restart_daemon')}><RefreshCw /> Restart</button></div><div className="setting-row"><span><strong>{daemon.privilege === 'administrator' ? 'Administrator mode' : 'User mode'}</strong><small>{daemon.privilege === 'administrator' ? 'Remote actions inherit elevated rights.' : 'Recommended for normal operation.'}</small></span><button className={`compact-button privilege-action ${daemon.privilege === 'administrator' ? '' : 'admin-action'}`} onClick={() => onAction(daemon.privilege === 'administrator' ? 'restart_daemon_as_user' : 'restart_daemon_as_administrator')}>{daemon.privilege === 'administrator' ? 'Return to user mode' : 'Restart as Administrator…'}</button></div><label className="setting-row toggle-row"><span><strong>Start UI at sign-in</strong><small>Launch the tray after you sign in.</small></span><input type="checkbox" checked={startup} onChange={e => onStartup(e.target.checked)} /><i /></label><label className="setting-row toggle-row"><span><strong>Start daemon with UI</strong><small>Connect remote access when the tray starts.</small></span><input type="checkbox" checked={daemonAutostart} onChange={e => onDaemonAutostart(e.target.checked)} /><i /></label></div></div>
<div className="settings-group"><h2>CLI & diagnostics</h2><div className="settings-card quick-action-grid"><button onClick={() => onAction('open_terminal')}><TerminalSquare /><span>Open terminal</span></button><button onClick={() => onAction('open_cli_terminal')}><Bot /><span>Open Hermes CLI</span></button><button onClick={() => onAction('open_logs')}><FolderOpen /><span>View daemon log</span></button><button onClick={() => onAction('run_diagnostics')}><ActivityIcon /><span>Run diagnostics</span></button></div></div>
<div className="settings-group"><h2>Updates</h2><div className={`settings-card update-card ${updateError ? 'error' : update?.ahead_of_latest ? 'ahead' : update?.up_to_date ? 'current' : ''}`}><div className="setting-row update-row"><span><strong>Hermes-Relay CLI UI</strong><small>{updateSummary}</small></span>{update && !update.up_to_date && !update.ahead_of_latest && !update.installed ? <button className="compact-button update-button" disabled={updateBusy !== null} onClick={installUpdate}>{updateBusy === 'install' ? <LoaderCircle className="spin" /> : <Download />} Install</button> : <button className="compact-button" disabled={updateBusy !== null} onClick={checkUpdate}>{updateBusy === 'check' ? <LoaderCircle className="spin" /> : <RefreshCw />} Check</button>}</div></div><p className="group-help update-help">Updates the management UI and CLI together, then restarts the tray automatically.</p></div>
{host && <div className="settings-group"><h2>Host details</h2><div className="settings-card host-detail"><div className="detail-head"><span className="host-icon"><Server /></span><span><strong>{host.name}</strong><small>{host.url}</small></span></div><dl><div><dt>Route</dt><dd>{host.endpoint_role ?? 'Custom'}</dd></div><div><dt>Version</dt><dd>{host.server_version ?? 'Unknown'}</dd></div><div><dt>Access</dt><dd>{host.access_mode === 'full-access' ? 'Full Access' : host.access_mode[0]!.toUpperCase() + host.access_mode.slice(1)}</dd></div></dl></div></div>}
{host && <div className="settings-group"><h2>Authorized clients <span>{clients.length}</span></h2><p className="group-help">Clients authenticated to {host.name}. Deauthorizing a client removes its relay session.</p><div className="settings-card client-list">{clients.length ? clients.map(client => { const identity = [client.device_model, client.device_platform, client.client_surface].filter(Boolean).join(' · ') || client.transport_hint || 'Client'; return <div className="client-row" key={client.token_prefix}><span className="client-icon"><Laptop /></span><span><strong>{client.device_name ?? 'Unnamed client'} {client.is_current && <em>This PC</em>}</strong><small>{identity} · {age(client.last_seen)}</small></span><button onClick={() => onRevoke(client)} aria-label={`Deauthorize ${client.device_name ?? client.token_prefix}`}><UserRoundX /></button></div> }) : <div className="empty-state"><Radio /><span>No authorized clients reported</span></div>}</div></div>}
<div className="settings-group"><div className="settings-group-heading"><h2>Activity</h2><button onClick={onViewActivity}>View all <ChevronRight /></button></div><p className="group-help">Recent remote actions recorded on this PC.</p><div className="settings-card padded"><ActivityList entries={activity.slice(-3).reverse()} host={host} /></div></div>
<button className="about-link" onClick={onHelp}><span className="setting-icon"><Info /></span><span><strong>Help & About</strong><small>Versions, documentation and troubleshooting.</small></span><ChevronRight /></button>
<div className="settings-group"><div className="settings-group-heading"><h2>Activity</h2><button onClick={onViewActivity}>View all <ChevronRight /></button></div><p className="group-help">Recent remote actions recorded on this PC.</p><div className="settings-card padded"><ActivityList entries={activity.slice(-3).reverse()} host={null} onOpen={onOpenActivity} /></div></div>
</section>
}
function HelpPage({ snapshot, host, onBack, onAction }: { snapshot: Snapshot; host: Host | null; onBack: () => void; onAction: (name: string, args?: Record<string, unknown>) => Promise<unknown> }) {
const links = [
['Documentation', 'https://hermes-relay.dev/docs/desktop/'],
['Troubleshooting', 'https://hermes-relay.dev/docs/desktop/troubleshooting/'],
['Release notes', 'https://github.com/Codename-11/hermes-relay/releases']
] as const
return <section className="page-panel help-page"><button className="back-button" onClick={onBack}><ArrowLeft /> Back to Settings</button><div className="about-hero"><img src={logo} alt="" /><span><p>Desktop companion</p><h1>Hermes-Relay CLI UI</h1><small>Compact control for this PC</small></span></div>
<div className="settings-group"><h2>About</h2><div className="settings-card about-facts"><dl><div><dt>UI version</dt><dd>{snapshot.ui_version ?? 'Unknown'}</dd></div><div><dt>CLI version</dt><dd>{snapshot.cli_version ?? 'Unknown'}</dd></div><div><dt>CLI path</dt><dd title={snapshot.cli_path ?? undefined}>{snapshot.cli_path ?? 'Not reported'}</dd></div><div><dt>Relay server</dt><dd>{host?.server_version ?? 'Not connected'}</dd></div></dl></div></div>
<div className="settings-group"><h2>Get help</h2><div className="settings-card management-links">{links.map(([label, url]) => <button key={url} onClick={() => onAction('open_external_url', { url })}><span><strong>{label}</strong></span><ExternalLink /></button>)}</div></div>
<button className="diagnostic-action" onClick={() => onAction('run_diagnostics')}><ActivityIcon /><span><strong>Run diagnostics</strong><small>Check the daemon, installation and active relay.</small></span><ChevronRight /></button>
</section>
}
+283 -29
View File
@@ -26,11 +26,11 @@ button { color: inherit; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }
.app-shell {
width: 100%; height: 100%; position: relative; overflow: hidden;
position: fixed; inset: 0; width: 100%; height: 100%; overflow: hidden;
background: radial-gradient(circle at 50% 15%, #15202a 0, var(--panel) 43%, #0b1218 100%);
border: 1px solid #35414d; border-radius: 9px;
box-shadow: 0 20px 65px #000b, inset 0 0 60px #080d124d;
display: grid; grid-template-rows: 50px minmax(0, 1fr) 52px;
display: grid; grid-template-rows: 46px minmax(0, 1fr) 48px;
opacity: 0; transform: translateY(8px) scale(.985); transform-origin: bottom center;
transition: opacity .14s ease-out, transform .18s cubic-bezier(.2, .8, .2, 1);
}
@@ -40,37 +40,83 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.titlebar { z-index: 4; border-bottom: 1px solid #202933; display: flex; align-items: center; justify-content: space-between; padding-left: 15px; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 15px; font-weight: 600; letter-spacing: -.2px; }
.brand img { width: 32px; height: 32px; object-fit: contain; }
.titlebar { z-index: 4; border-bottom: 1px solid #202933; display: flex; align-items: center; justify-content: space-between; padding-left: 13px; }
.brand { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; letter-spacing: -.2px; }
.brand img { width: 29px; height: 29px; object-fit: contain; }
.window-controls { align-self: stretch; display: flex; }
.window-controls button { width: 44px; border: 0; background: transparent; color: #b9bec5; font-size: 19px; cursor: pointer; }
.window-controls button { width: 42px; border: 0; background: transparent; color: #b9bec5; font-size: 19px; cursor: pointer; }
.window-controls button:hover { background: #ffffff0c; color: white; }
.window-controls button:last-child:hover { background: #c42b1c; }
.window-controls button:focus-visible { outline: 0; box-shadow: inset 0 0 0 1px var(--violet); }
.window-controls svg { width: 20px; display: block; margin: auto; }
.content { position: relative; z-index: 1; min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #4a5561 transparent; padding: 14px 25px 16px; }
.connection-hero { min-height: 78px; display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 6px; }
.status-ring { width: 56px; height: 56px; position: relative; border-radius: 50%; border: 3px solid currentColor; display: grid; place-items: center; box-shadow: 0 0 18px currentColor; }
.status-ring svg { width: 30px; height: 30px; stroke-width: 2.4; }
.content { position: relative; z-index: 1; min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #4a5561 transparent; padding: 11px 18px 13px; }
.connection-hero { min-height: 65px; display: flex; align-items: center; justify-content: center; gap: 14px; margin-bottom: 2px; }
.status-ring { width: 49px; height: 49px; position: relative; border-radius: 50%; border: 2.5px solid currentColor; display: grid; place-items: center; box-shadow: 0 0 17px currentColor; }
.status-ring svg { width: 26px; height: 26px; stroke-width: 2.4; }
.status-ring.online, .connection-hero h1 { color: var(--green); }
.status-ring.online::after { content: ''; position: absolute; inset: -4px; border: 2px solid currentColor; border-radius: 50%; pointer-events: none; animation: connected-pulse 2.4s cubic-bezier(.2, .7, .25, 1) infinite; }
@keyframes connected-pulse { 0%, 28% { opacity: .48; transform: scale(.92); } 78%, 100% { opacity: 0; transform: scale(1.42); } }
.status-ring.offline { color: #7d8791; box-shadow: none; }
.connection-hero h1 { margin: 0 0 4px; font-size: 23px; line-height: 1; font-weight: 650; }
.connection-hero p { margin: 0; color: #bdc1c7; font-size: 13.5px; }
.connection-hero h1 { margin: 0 0 3px; font-size: 21px; line-height: 1; font-weight: 650; }
.connection-hero p { margin: 0; color: #bdc1c7; font-size: 12.5px; }
.section { margin-top: 10px; }
.section > label, .label-row label { display: block; color: #bdc1c7; font-size: 14px; margin-bottom: 6px; }
.connection-route { min-height: 112px; padding: 9px 4px 8px; }
.route-grid { position: relative; display: grid; grid-template-columns: 43px minmax(16px, 1fr) minmax(145px, 260px) minmax(16px, 1fr) 43px; grid-template-rows: 60px 27px; align-items: center; gap: 0 6px; }
.route-endpoint { display: grid; place-items: center; gap: 2px; color: #fff; }
.route-endpoint svg { width: 21px; height: 21px; stroke-width: 2.4; }
.route-endpoint small { font-size: 10px; font-weight: 550; white-space: nowrap; }
.route-link { position: relative; z-index: 0; display: block; height: 1px; border-top: 1.5px dashed #4fdf72; opacity: .9; }
.connection-route.offline .route-link { border-color: #66717d; opacity: .55; }
.route-traffic { position: absolute; z-index: 1; pointer-events: none; left: 49px; right: 49px; top: 29px; height: 1px; overflow: hidden; }
.packet { position: absolute; top: -2px; left: 0; width: 9px; height: 5px; border-radius: 2px; background: #a3f9b2; box-shadow: 0 0 4px #72ed89, 0 0 10px #52df70; opacity: 0; }
.packet::after { content: ''; position: absolute; inset: 1px 2px; border-radius: 1px; background: #effff2; }
.packet-outbound { animation: packet-outbound 3.2s linear infinite; }
.packet-inbound { left: auto; right: 0; animation: packet-inbound 3.2s linear infinite; animation-delay: .8s; }
.packet-late { animation-delay: 1.6s; }
.packet-inbound.packet-late { animation-delay: 2.4s; }
@keyframes packet-outbound {
0% { left: 0; opacity: 0; transform: scale(.75); }
5%, 93% { opacity: 1; transform: scale(1); }
100% { left: calc(100% - 9px); opacity: 0; transform: scale(.75); }
}
@keyframes packet-inbound {
0% { right: 0; opacity: 0; transform: scale(.75); }
5%, 93% { opacity: 1; transform: scale(1); }
100% { right: calc(100% - 9px); opacity: 0; transform: scale(.75); }
}
.route-host { position: relative; z-index: 2; min-width: 0; }
.route-host > button { width: 100%; min-height: 50px; border: 1px solid #52606d; border-radius: 9px; background: #121c25; color: var(--text); padding: 5px 8px; display: flex; align-items: center; gap: 7px; text-align: left; cursor: pointer; white-space: nowrap; overflow: hidden; box-shadow: 0 5px 18px #0005; transition: border-color .14s ease, background .14s ease, box-shadow .14s ease; }
.route-host > button:hover { border-color: var(--green); background: #17232c; box-shadow: 0 6px 22px #0007, 0 0 13px #4bd7651c; }
.route-host > button:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }
.connection-route.offline .route-host > button { color: #a6afb8; border-color: #596570; }
.route-host-icon { width: 31px; height: 31px; flex: 0 0 31px; display: grid; place-items: center; border-radius: 7px; color: var(--green); background: #4bd76513; }
.route-host-icon svg { width: 17px; height: 17px; }
.route-host-copy { min-width: 0; flex: 1; display: grid; gap: 1px; }
.route-host-copy small { color: var(--muted); font-size: 9px; line-height: 1.1; letter-spacing: .55px; text-transform: uppercase; }
.route-host-copy strong { overflow: hidden; text-overflow: ellipsis; color: #f2f5f7; font-size: 11.5px; line-height: 1.25; font-weight: 650; letter-spacing: .2px; }
.route-host-action { color: var(--green); font-size: 9.5px; font-weight: 600; }
.route-host > button > svg { width: 14px; height: 14px; flex: 0 0 14px; color: var(--green); transition: transform .14s ease; }
.route-host > button[aria-expanded="true"] > svg { transform: rotate(180deg); }
.route-host .selector-menu { min-width: 290px; left: 50%; right: auto; transform: translateX(-50%); top: calc(100% + 5px); text-transform: none; letter-spacing: 0; animation-name: route-menu-in; }
@keyframes route-menu-in { from { opacity: 0; transform: translate(-50%, -4px); } }
.route-status { grid-column: 1 / -1; display: flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; }
.route-status strong { color: var(--green); font-size: 13px; font-weight: 600; }
.connection-route.offline .route-status strong { color: #8d98a3; }
.route-status span, .route-status small { color: var(--muted); font-size: 11px; }
.route-status.insecure strong, .route-status.insecure small { color: var(--amber); }
@media (max-width: 420px) { .route-host-action { display: none; } }
.section { margin-top: 8px; }
.section > label, .label-row label { display: block; color: #bdc1c7; font-size: 13px; margin-bottom: 5px; }
.host-selector { position: relative; }
.selector-button, .empty-pair { width: 100%; min-height: 54px; display: flex; align-items: center; gap: 11px; border: 1px solid var(--line-hi); border-radius: 7px; background: #111922b3; text-align: left; padding: 6px 10px; cursor: pointer; }
.selector-button, .empty-pair { width: 100%; min-height: 51px; display: flex; align-items: center; gap: 10px; border: 1px solid var(--line-hi); border-radius: 7px; background: #111922b3; text-align: left; padding: 5px 9px; cursor: pointer; }
.selector-button:hover, .empty-pair:hover { border-color: #697582; background: #17212b; }
.host-icon { width: 40px; height: 40px; flex: 0 0 40px; display: grid; place-items: center; border: 1px solid #4b5661; border-radius: 6px; background: #17212a; color: var(--violet); }
.host-icon svg { width: 25px; height: 25px; }
.host-icon { width: 37px; height: 37px; flex: 0 0 37px; display: grid; place-items: center; border: 1px solid #4b5661; border-radius: 6px; background: #17212a; color: var(--violet); }
.host-icon svg { width: 23px; height: 23px; }
.host-main, .empty-pair span { min-width: 0; flex: 1; display: grid; gap: 2px; }
.host-main strong, .empty-pair strong { font-size: 15.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.host-main small, .empty-pair small { color: var(--muted); font-size: 12px; }
.host-main strong, .empty-pair strong { font-size: 14.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.host-main small, .empty-pair small { color: var(--muted); font-size: 11.5px; }
.host-main i { font-style: normal; color: var(--green); }
.selector-button > svg { color: #aeb4bd; transition: transform .16s ease; }
.selector-button > svg.rotated { transform: rotate(180deg); }
@@ -85,34 +131,65 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.selector-menu small { color: var(--faint); overflow: hidden; text-overflow: ellipsis; }
.selector-menu .pair-option { border-top: 1px solid var(--line); border-radius: 0 0 5px 5px; margin-top: 4px; padding-top: 11px; }
.policy-ledger { margin-top: 10px; border: 1px solid #35414d; border-radius: 8px; background: #0f171fba; overflow: hidden; }
.policy-ledger > button { width: 100%; min-height: 55px; border: 0; border-bottom: 1px solid #2d3741; background: transparent; padding: 7px 9px; display: flex; align-items: center; gap: 9px; text-align: left; cursor: pointer; transition: background .14s ease, border-color .14s ease; }
.policy-ledger > button:last-child { border-bottom: 0; }
.policy-ledger > button:hover { background: #ffffff08; }
.policy-ledger > button:focus-visible { outline-offset: -2px; }
.policy-ledger > button > svg { width: 17px; flex: 0 0 17px; color: #8d97a2; }
.policy-icon { width: 31px; height: 31px; flex: 0 0 31px; border-radius: 7px; background: #191f2d; color: var(--violet); display: grid; place-items: center; }
.policy-icon svg { width: 18px; height: 18px; }
.policy-name { min-width: 0; flex: 1; display: grid; gap: 1px; }
.policy-name strong { font-size: 14px; font-weight: 570; }
.policy-name small { color: var(--faint); font-size: 10px; }
.policy-value { max-width: 115px; border: 1px solid #8d5fe3; border-radius: 14px; padding: 4px 9px; background: linear-gradient(120deg, #5630b6, #7548dc); color: white; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.capability-summary { max-width: 142px; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.capability-summary em { color: #c5cad0; font-size: 10.5px; font-style: normal; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.capability-summary em svg { width: 13px; height: 13px; color: var(--violet); }
.capability-summary i { color: var(--faint); font-size: 9.5px; font-style: normal; white-space: nowrap; }
.label-row { display: flex; align-items: center; justify-content: space-between; }
.label-row span { color: #aeb4bd; }
.label-row svg { width: 19px; height: 19px; }
.access-control { height: 50px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #56616c; border-radius: 7px; overflow: hidden; background: #101820; }
.access-control { min-height: 76px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid #56616c; border-radius: 7px; overflow: hidden; background: #101820; }
.access-control button { min-width: 0; border: 0; border-right: 1px solid #45505b; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 5px; white-space: nowrap; font-size: 13px; transition: background .15s ease, color .15s ease; }
.access-control button:last-child { border-right: 0; }
.access-control button:hover { background: #ffffff0b; }
.access-control button.active { background: linear-gradient(120deg, #5432bb, #7548dc); box-shadow: inset 0 1px #b880ff; }
.access-control button svg { width: 21px; height: 21px; }
.access-copy { margin: 10px auto 0; text-align: center; color: #b9bdc4; font-size: 12.5px; line-height: 1.4; max-width: 340px; }
.tunnel-button { width: 100%; height: 47px; margin: 12px 0; border: 1.5px solid var(--violet); border-radius: 7px; background: #111820b5; display: flex; align-items: center; justify-content: center; gap: 13px; font-size: 15.5px; cursor: pointer; }
.capability-panel { margin-top: 10px; border: 1px solid #35414d; border-radius: 8px; padding: 9px 10px; background: #0d151d; }
.capability-title { display: flex; align-items: center; gap: 8px; }
.capability-title svg { width: 19px; color: var(--violet); }
.capability-title span { display: flex; flex-direction: column; }
.capability-title small, .capability-unavailable small { color: var(--muted); }
.capability-modes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 8px; }
.capability-modes button { border: 1px solid #45515d; border-radius: 6px; background: #121c25; padding: 6px; font-size: 12px; }
.capability-modes button.active { border-color: var(--violet); background: #5d36c5; }
.capability-unavailable { display: grid; grid-template-columns: 1fr auto 1fr auto; gap: 6px; margin-top: 8px; font-size: 11px; opacity: .72; }
.capability-backend-note { display: block; margin-top: 8px; color: var(--amber); }
.capability-modes button:disabled { cursor: not-allowed; opacity: .45; }
.tunnel-button { width: 100%; height: 43px; margin: 10px 0; border: 1.5px solid var(--violet); border-radius: 7px; background: #111820b5; display: flex; align-items: center; justify-content: center; gap: 11px; font-size: 14.5px; cursor: pointer; }
.tunnel-button:hover { background: #6f3bd51e; box-shadow: 0 0 22px #974cff20; }
.tunnel-button svg { color: var(--violet); width: 24px; height: 24px; }
.tunnel-button svg { color: var(--violet); width: 21px; height: 21px; }
.activity-section { border-top: 1px solid var(--line); padding-top: 12px; }
.activity-section { border-top: 1px solid var(--line); padding-top: 10px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; }
.section-heading h2 { margin: 0; color: #b9bec5; font-size: 15px; font-weight: 500; }
.section-heading button { border: 0; background: transparent; color: var(--violet); display: flex; align-items: center; gap: 2px; cursor: pointer; font-size: 13px; }
.section-heading svg { width: 16px; }
.activity-item { border-bottom: 1px solid #2a333c; }
.activity-item:last-child { border-bottom: 0; }
.activity-row { width: 100%; min-height: 45px; border: 0; background: transparent; padding: 0; display: flex; align-items: center; gap: 9px; text-align: left; }
.activity-row { width: 100%; min-height: 43px; border: 0; background: transparent; padding: 0; display: flex; align-items: center; gap: 9px; text-align: left; }
.activity-row:not(:disabled) { cursor: pointer; }
.activity-row:not(:disabled):hover { background: #ffffff07; }
.activity-row:disabled { color: inherit; }
.activity-item .activity-row[aria-expanded] { cursor: pointer; padding: 4px 10px; }
.activity-item .activity-row[aria-expanded]:hover { background: #ffffff08; }
.activity-icon { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 50%; border: 1px solid #50336d; color: var(--violet); display: grid; place-items: center; }
.activity-icon { width: 31px; height: 31px; flex: 0 0 31px; border-radius: 8px; border: 1px solid #50336d; color: var(--violet); display: grid; place-items: center; }
.activity-icon.green { border-color: #285d35; color: var(--green); }
.activity-icon.amber { border-color: #6b5524; color: var(--amber); }
.activity-icon svg { width: 21px; height: 21px; }
.activity-icon svg { width: 18px; height: 18px; }
.activity-copy { flex: 1; min-width: 0; display: grid; }
.activity-copy strong { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-copy small { color: var(--muted); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -134,6 +211,25 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.activity-context { margin-top: 9px; display: grid; gap: 4px; }
.activity-context code { display: block; color: #cbd0d6; background: #090f15; border-radius: 5px; padding: 7px 8px; font: 10.5px/1.4 'Cascadia Mono', Consolas, monospace; overflow-wrap: anywhere; user-select: text; }
.activity-request-id { margin-top: 6px; color: #68737f; font: 9px 'Cascadia Mono', Consolas, monospace; overflow-wrap: anywhere; user-select: text; }
.activity-detail-page > .back-button { margin-bottom: 12px; }
.activity-detail-title { display: flex; align-items: center; gap: 11px; margin-bottom: 12px; }
.activity-detail-title > span:last-child { min-width: 0; display: grid; gap: 1px; }
.activity-detail-title p { margin: 0; color: var(--violet); font-size: 9px; text-transform: uppercase; letter-spacing: .8px; }
.activity-detail-title h1 { margin: 0; font-size: 19px; line-height: 1.15; font-weight: 600; }
.activity-detail-title small { color: var(--muted); font-size: 11px; }
.activity-detail-meta { margin: 0 0 11px; display: grid; grid-template-columns: .75fr 1.45fr .75fr; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.activity-detail-meta div { min-width: 0; padding: 7px 8px; border-right: 1px solid var(--line); display: grid; gap: 2px; }
.activity-detail-meta div:last-child { border: 0; }
.activity-detail-meta dt { color: var(--faint); font-size: 8.5px; text-transform: uppercase; letter-spacing: .55px; }
.activity-detail-meta dd { margin: 0; color: #c8cdd2; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-detail-meta dd.success { color: var(--green); }
.activity-detail-meta dd.warning, .activity-detail-meta dd.attention { color: var(--amber); }
.activity-output-list { display: grid; gap: 8px; }
.activity-output-list section { border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: #0a1117; }
.activity-output-list header { height: 28px; padding: 0 9px; display: flex; align-items: center; justify-content: space-between; background: #121b23; border-bottom: 1px solid var(--line); }
.activity-output-list header strong { font-size: 10px; font-weight: 550; }
.activity-output-list header em { color: var(--amber); font-size: 8px; font-style: normal; text-transform: uppercase; letter-spacing: .5px; }
.activity-output-list pre { max-height: 155px; margin: 0; padding: 9px; overflow: auto; color: #cdd2d7; font: 10px/1.45 'Cascadia Mono', Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
.admin-warning { min-height: 44px; border: 1px solid #5b4615; border-radius: 6px; background: #3c2d0d3d; margin-top: 11px; padding: 0 12px; display: flex; align-items: center; gap: 11px; font-size: 12px; }
.admin-warning svg { color: var(--amber); width: 22px; }
.admin-warning span { flex: 1; }
@@ -152,6 +248,64 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.page-title p { margin: 0 0 2px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--violet); font-size: 10px; }
.page-title h1 { margin: 0; font-size: 25px; font-weight: 600; }
.page-intro, .group-help { color: var(--muted); font-size: 13px; line-height: 1.45; }
.policy-page-title { margin-bottom: 8px; }
.policy-page-title p { margin-top: 8px; }
.policy-detail-page > .page-intro { margin: 0 0 12px; }
.access-options { display: grid; gap: 7px; }
.access-options > button { width: 100%; min-height: 67px; border: 1px solid #35414d; border-radius: 8px; background: #101820b8; padding: 9px 10px; display: flex; align-items: center; gap: 10px; text-align: left; cursor: pointer; transition: border-color .14s ease, background .14s ease, transform .14s ease; }
.access-options > button:hover:not(:disabled) { border-color: #6b4aa1; background: #171d29; transform: translateX(1px); }
.access-options > button.active { border-color: #8f60e7; background: linear-gradient(100deg, #6b3bd326, #111922 70%); box-shadow: inset 2px 0 var(--violet); }
.access-options > button:disabled { cursor: wait; opacity: .65; }
.access-options > button > span:nth-child(2) { min-width: 0; flex: 1; display: grid; gap: 3px; }
.access-options strong { font-size: 14px; font-weight: 600; }
.access-options small { color: var(--muted); font-size: 10.5px; line-height: 1.35; }
.access-options > button > svg { width: 17px; color: var(--faint); }
.option-icon { width: 35px; height: 35px; flex: 0 0 35px; border: 1px solid #414d59; border-radius: 8px; background: #18212a; color: var(--violet); display: grid; place-items: center; }
.option-icon svg { width: 19px; height: 19px; }
.selected-check { width: 24px; height: 24px; flex: 0 0 24px; border-radius: 50%; background: var(--violet-deep); display: grid; place-items: center; }
.selected-check svg { width: 14px; }
.policy-footnote { margin: 12px 2px 0; padding-top: 10px; border-top: 1px solid var(--line); color: var(--faint); font-size: 10.5px; line-height: 1.4; display: flex; align-items: flex-start; gap: 7px; }
.policy-footnote svg { width: 15px; height: 15px; flex: 0 0 15px; color: var(--violet); }
.custom-policy-banner { margin-bottom: 9px; padding: 8px 10px; border: 1px solid #624791; border-radius: 7px; background: #56359a18; display: flex; align-items: center; gap: 9px; }
.custom-policy-banner > svg { width: 20px; color: var(--violet); }
.custom-policy-banner span { display: grid; gap: 1px; }
.custom-policy-banner strong { font-size: 12px; }
.custom-policy-banner small { color: var(--muted); font-size: 10px; }
.capability-list { border: 1px solid #35414d; border-radius: 8px; background: #0f171fba; overflow: hidden; }
.capability-row { padding: 8px 9px; border-bottom: 1px solid var(--line); }
.capability-row:last-child { border-bottom: 0; }
.capability-row-head { display: flex; align-items: center; gap: 8px; }
.capability-row-head > span:nth-child(2) { min-width: 0; flex: 1; display: grid; gap: 1px; }
.capability-row-head strong { font-size: 12.5px; font-weight: 570; }
.capability-row-head small { color: var(--muted); font-size: 9.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.capability-row-head em { color: var(--green); font-size: 8px; font-style: normal; text-transform: uppercase; letter-spacing: .55px; }
.capability-row .option-icon { width: 29px; height: 29px; flex-basis: 29px; }
.capability-row .option-icon svg { width: 16px; height: 16px; }
.capability-row .capability-modes { margin: 7px 0 0 37px; }
.capability-row .capability-modes button { min-height: 26px; padding: 3px; font-size: 10px; cursor: pointer; }
.capability-row .capability-modes button:disabled { cursor: wait; }
.capability-detail-card { border: 1px solid #3a4651; border-radius: 9px; background: #101820b8; padding: 11px; }
.capability-detail-head { display: flex; align-items: center; gap: 10px; }
.capability-detail-head > span:nth-child(2) { min-width: 0; flex: 1; display: grid; gap: 2px; }
.capability-detail-head strong, .unavailable-capabilities strong { font-size: 14px; font-weight: 600; }
.capability-detail-head small, .unavailable-capabilities small { color: var(--muted); font-size: 10.5px; }
.capability-detail-head em { color: var(--faint); font-size: 9px; font-style: normal; text-transform: uppercase; letter-spacing: .6px; }
.capability-detail-head em.available { color: var(--green); }
.capability-detail-card > p { margin: 9px 1px 0; color: var(--muted); font-size: 10.5px; line-height: 1.4; }
.capability-detail-card .capability-modes { margin-top: 11px; }
.capability-detail-card .capability-modes button { min-height: 34px; cursor: pointer; }
.supported-broker { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; align-items: center; }
.supported-broker > span { min-width: 0; display: flex; align-items: center; gap: 6px; }
.supported-broker > span i { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 7px #5bd56c99; }
.supported-broker > span i.offline { background: var(--faint); box-shadow: none; }
.supported-broker strong { font-size: 11px; font-weight: 550; }
.supported-broker small { grid-column: 1; padding-left: 12px; color: var(--faint); font-size: 9.5px; }
.supported-broker em { grid-column: 2; grid-row: 1 / span 2; color: var(--faint); font-size: 8.5px; font-style: normal; text-transform: uppercase; letter-spacing: .5px; }
.unavailable-capabilities { margin-top: 10px; border: 1px solid #303b45; border-radius: 8px; background: #0e161db0; overflow: hidden; }
.unavailable-capabilities > div { min-height: 59px; padding: 8px 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); opacity: .72; }
.unavailable-capabilities > div:last-child { border-bottom: 0; }
.unavailable-capabilities > div > span:nth-child(2) { min-width: 0; flex: 1; display: grid; gap: 2px; }
.unavailable-capabilities em { color: var(--faint); font-size: 9px; font-style: normal; text-transform: uppercase; letter-spacing: .55px; }
.icon-button { width: 39px; height: 39px; border: 1px solid #4b5661; border-radius: 6px; background: #131c25; color: var(--violet); display: grid; place-items: center; cursor: pointer; }
.host-list { display: grid; gap: 9px; }
.host-card { width: 100%; border: 1px solid var(--line); border-radius: 8px; background: #111922b3; padding: 11px; display: flex; gap: 12px; align-items: center; text-align: left; cursor: pointer; }
@@ -168,6 +322,30 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.host-detail-hero p { margin: 0; color: var(--violet); font-size: 10px; text-transform: uppercase; letter-spacing: .8px; }
.host-detail-hero h1 { margin: 0; font-size: 22px; font-weight: 600; }
.host-detail-hero small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
.copy-host-url { width: 31px; height: 31px; margin-left: auto; flex: 0 0 31px; border: 1px solid #414d59; border-radius: 7px; background: #131c25; color: var(--muted); display: grid; place-items: center; cursor: pointer; }
.copy-host-url:hover { color: var(--ink); border-color: var(--violet); }
.copy-host-url svg { width: 15px; }
.pair-host-page .page-title { margin-top: 14px; }
.pair-form { display: grid; gap: 14px; }
.pair-form > label { display: grid; gap: 6px; color: #c9cdd2; font-size: 11px; font-weight: 550; }
.pair-form input { width: 100%; height: 42px; border: 1px solid #46515d; border-radius: 7px; background: #0b131a; color: var(--ink); padding: 0 11px; user-select: text; transition: border-color .14s ease, box-shadow .14s ease; }
.pair-form input:focus { outline: 0; border-color: var(--violet); box-shadow: 0 0 0 3px #a763ff1c; }
.pair-input-action { display: flex; gap: 7px; }
.pair-input-action input { min-width: 0; flex: 1; }
.pair-input-action button { width: 42px; flex: 0 0 42px; border: 1px solid #46515d; border-radius: 7px; background: #141e27; color: var(--muted); display: grid; place-items: center; cursor: pointer; }
.pair-input-action button:hover { color: var(--ink); border-color: var(--violet); }
.pair-input-action svg { width: 17px; }
.pair-code { font: 650 17px/1 "Cascadia Mono", Consolas, monospace; letter-spacing: 4px; text-transform: uppercase; }
.transport-notice { border: 1px solid #40505b; border-radius: 8px; padding: 9px 10px; display: flex; align-items: center; gap: 9px; animation: notice-in .18s ease-out; }
@keyframes notice-in { from { opacity: 0; transform: translateY(-3px); } }
.transport-notice svg { width: 19px; flex: 0 0 19px; }
.transport-notice span { min-width: 0; display: grid; gap: 2px; }
.transport-notice strong { font-size: 11px; }
.transport-notice small { color: var(--muted); font-size: 9.5px; line-height: 1.35; }
.transport-notice.secure { border-color: #326444; background: #163c251f; color: var(--green); }
.transport-notice.insecure { border-color: #735b2e; background: #5a431c22; color: var(--amber); }
.pair-privacy { margin: -3px 2px 0; color: var(--faint); font-size: 9.5px; line-height: 1.4; display: flex; align-items: flex-start; gap: 6px; }
.pair-privacy svg { width: 13px; flex: 0 0 13px; color: var(--violet); }
.rename-host { display: flex; gap: 7px; }
.rename-host input { min-width: 0; flex: 1; height: 38px; border: 1px solid #46515d; border-radius: 6px; background: #0c141b; color: var(--ink); padding: 0 10px; user-select: text; }
.rename-host button { border: 1px solid #7956c8; border-radius: 6px; background: #5934ad; padding: 0 13px; cursor: pointer; }
@@ -176,6 +354,22 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.primary-host-action { width: 100%; height: 43px; border: 1px solid var(--violet); border-radius: 7px; background: #6137bd; display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; }
.primary-host-action:disabled { border-color: #3e4954; background: #17212a; color: var(--muted); cursor: default; }
.primary-host-action svg { width: 19px; }
.management-links > button { width: 100%; min-height: 57px; border: 0; border-bottom: 1px solid var(--line); background: transparent; padding: 8px 10px; display: flex; align-items: center; gap: 9px; text-align: left; cursor: pointer; }
.management-links > button:last-child { border-bottom: 0; }
.management-links > button:hover { background: #ffffff08; }
.management-links > button > span:nth-child(2), .management-links > button > span:first-child:not(.setting-icon) { min-width: 0; flex: 1; display: grid; gap: 2px; }
.management-links strong { font-size: 13px; font-weight: 550; }
.management-links small { color: var(--muted); font-size: 10.5px; line-height: 1.25; }
.management-links em { color: var(--violet); font-size: 10px; font-style: normal; white-space: nowrap; }
.management-links > button > svg { width: 15px; height: 15px; flex: 0 0 15px; color: var(--faint); }
.setting-icon { width: 31px; height: 31px; flex: 0 0 31px; border-radius: 7px; background: #7041d527; color: var(--violet); display: grid; place-items: center; }
.setting-icon svg { width: 17px; height: 17px; }
.host-danger-actions { margin-top: 9px; display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.host-danger-actions button { min-height: 36px; border: 1px solid #46515d; border-radius: 6px; background: #121b24; color: #c5cbd1; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 11px; cursor: pointer; }
.host-danger-actions button:last-child { border-color: #624047; color: #e28b91; background: #25171b; }
.host-danger-actions button:hover:not(:disabled) { filter: brightness(1.12); }
.host-danger-actions button:disabled { opacity: .45; cursor: wait; }
.host-danger-actions svg { width: 14px; height: 14px; }
.large-empty { margin-top: 60px; text-align: center; color: var(--muted); }
.large-empty > svg { width: 45px; height: 45px; color: var(--violet); }
.large-empty h2 { color: var(--ink); font-size: 19px; }
@@ -214,8 +408,10 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.settings-group-heading button svg { width: 14px; height: 14px; }
.activity-page-title { display: block; }
.activity-page-title > div { margin-top: 7px; }
.back-button { border: 0; background: transparent; color: var(--violet); padding: 0; display: flex; align-items: center; gap: 3px; font-size: 11px; cursor: pointer; }
.back-button svg { width: 14px; height: 14px; transform: rotate(180deg); }
.back-button { min-height: 32px; border: 1px solid #3c4753; border-radius: 7px; background: #121b24; color: #d8dde3; padding: 5px 9px 5px 7px; display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 550; cursor: pointer; box-shadow: 0 3px 10px #0003; transition: border-color .14s ease, background .14s ease, color .14s ease, transform .14s ease; }
.back-button:hover { border-color: #8657d5; background: #191e2a; color: #fff; transform: translateX(-1px); }
.back-button:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }
.back-button svg { width: 15px; height: 15px; color: var(--violet); }
.activity-page > .page-intro { margin-top: -5px; }
.setting-row { min-height: 61px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.setting-row:last-child { border: 0; }
@@ -225,6 +421,35 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.compact-button { border: 1px solid #4c5762; border-radius: 5px; background: #17212a; padding: 6px 9px; display: flex; gap: 6px; align-items: center; cursor: pointer; font-size: 12px; }
.compact-button svg { width: 14px; }
.compact-button:disabled { opacity: .6; cursor: wait; }
.admin-action { border-color: #705528; color: #ffd883; background: #251e12; }
.privilege-action { max-width: 152px; justify-content: center; text-align: center; line-height: 1.2; }
.quick-action-grid { display: grid; grid-template-columns: 1fr 1fr; }
.quick-action-grid button { min-height: 57px; border: 0; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: transparent; color: #d9dde2; display: flex; align-items: center; gap: 8px; padding: 9px 11px; text-align: left; cursor: pointer; }
.quick-action-grid button:nth-child(2n) { border-right: 0; }
.quick-action-grid button:nth-last-child(-n+2) { border-bottom: 0; }
.quick-action-grid button:hover { background: #ffffff08; }
.quick-action-grid svg { width: 18px; height: 18px; color: var(--violet); }
.quick-action-grid span { font-size: 11.5px; }
.about-link, .diagnostic-action { width: 100%; min-height: 58px; margin: 0 0 19px; border: 1px solid var(--line); border-radius: 8px; background: #111922a8; padding: 8px 10px; display: flex; align-items: center; gap: 9px; text-align: left; cursor: pointer; }
.about-link:hover, .diagnostic-action:hover { border-color: #655078; background: #151d27; }
.about-link > span:nth-child(2), .diagnostic-action > span:nth-child(2) { min-width: 0; flex: 1; display: grid; gap: 2px; }
.about-link strong, .diagnostic-action strong { font-size: 13px; font-weight: 550; }
.about-link small, .diagnostic-action small { color: var(--muted); font-size: 10.5px; }
.about-link > svg, .diagnostic-action > svg { width: 16px; color: var(--faint); }
.help-page > .back-button { margin-bottom: 13px; }
.about-hero { display: flex; align-items: center; gap: 11px; margin-bottom: 18px; }
.about-hero img { width: 46px; height: 46px; }
.about-hero > span { min-width: 0; display: grid; gap: 1px; }
.about-hero p { margin: 0; color: var(--violet); font-size: 9px; text-transform: uppercase; letter-spacing: .8px; }
.about-hero h1 { margin: 0; font-size: 19px; font-weight: 600; }
.about-hero small { color: var(--muted); font-size: 11px; }
.about-facts dl { margin: 0; }
.about-facts dl div { min-height: 44px; padding: 7px 11px; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 83px minmax(0, 1fr); align-items: center; gap: 8px; }
.about-facts dl div:last-child { border-bottom: 0; }
.about-facts dt { color: var(--faint); font-size: 9.5px; text-transform: uppercase; letter-spacing: .55px; }
.about-facts dd { margin: 0; color: #d5d9de; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: text; }
.diagnostic-action { margin-bottom: 0; }
.diagnostic-action > svg:first-child { width: 19px; height: 19px; color: var(--violet); }
.update-row > span { min-width: 0; }
.update-row small { max-width: 220px; line-height: 1.35; }
.update-card { position: relative; }
@@ -301,12 +526,26 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.grant-shell.expanded .grant-expand svg { transform: rotate(180deg); }
.grant-summary { margin: 10px 0 12px; color: #c1c6cd; font-size: 12.5px; line-height: 1.4; }
.grant-summary strong { color: var(--ink); font-weight: 600; }
.grant-action-preview { margin: -2px 0 11px; padding: 8px 10px; border: 1px solid #34414d; border-radius: 8px; background: #0a1219; display: grid; gap: 3px; }
.grant-action-preview small { color: var(--faint); font-size: 8.5px; text-transform: uppercase; letter-spacing: .65px; }
.grant-action-preview code { color: #e3e7eb; font: 10.5px/1.35 "Cascadia Mono", Consolas, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.grant-details { max-height: 0; opacity: 0; overflow: hidden; transition: max-height .2s ease, opacity .16s ease, margin .2s ease; }
.grant-shell.expanded .grant-details { max-height: 108px; opacity: 1; margin-bottom: 11px; }
.grant-shell.expanded .grant-action-preview { display: none; }
.grant-shell.expanded .grant-details { max-height: 188px; opacity: 1; margin-bottom: 11px; overflow: auto; }
.grant-details dl { margin: 0; padding: 10px 11px; border-radius: 8px; background: #0a1118a8; }
.grant-details dl div + div { margin-top: 8px; }
.grant-details dt { color: var(--faint); font-size: 9px; text-transform: uppercase; letter-spacing: .8px; }
.grant-details dd { margin: 2px 0 0; color: #c4c9cf; font-size: 11px; line-height: 1.35; overflow-wrap: anywhere; }
.grant-action-full pre { margin: 4px 0 0; padding: 7px 8px; border: 1px solid #2d3944; border-radius: 6px; background: #080e14; color: #e5e9ed; font: 10px/1.4 "Cascadia Mono", Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.grant-open-ui { margin: -2px 0 8px; }
.grant-open-ui button { width: 100%; height: 29px; border: 0; border-radius: 6px; background: transparent; color: #b98aff; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 10px; cursor: pointer; }
.grant-open-ui button:hover { background: #a763ff10; color: #d0adff; }
.grant-open-ui svg { width: 13px; }
.grant-review-modal { max-height: calc(100% - 28px); overflow: auto; }
.review-grant-facts { margin: 10px 0 15px; padding: 10px; border-radius: 7px; background: #0a1118; }
.review-grant-facts div + div { margin-top: 8px; }
.review-grant-facts dt { color: var(--faint); font-size: 9px; text-transform: uppercase; letter-spacing: .65px; }
.review-grant-facts dd { margin: 2px 0 0; color: var(--muted); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.grant-actions { display: grid; grid-template-columns: .85fr 1.15fr; gap: 8px; }
.grant-actions button { height: 35px; border-radius: 7px; cursor: pointer; font-size: 12px; font-weight: 550; }
.grant-actions button:first-child { border: 1px solid #46515d; background: #17212a; color: #c5c9cf; }
@@ -330,6 +569,21 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--violet); ou
.activity-row { min-height: 44px; }
}
@media (max-height: 540px) {
.app-shell { grid-template-rows: 44px minmax(0, 1fr) 48px; }
.brand img { width: 28px; height: 28px; }
.content { padding: 8px 18px 12px; }
.connection-hero { min-height: 58px; gap: 13px; margin-bottom: 2px; }
.status-ring { width: 46px; height: 46px; }
.connection-hero h1 { font-size: 20px; }
.connection-hero p { font-size: 12.5px; }
.section { margin-top: 7px; }
.selector-button, .empty-pair { min-height: 49px; }
.access-control { min-height: 68px; }
.access-copy { font-size: 12px; }
.bottom-nav button { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
+18 -2
View File
@@ -1,4 +1,6 @@
export type AccessMode = 'ask' | 'trusted' | 'full-access'
export type AccessMode = 'ask' | 'ask-every-time' | 'structured' | 'trusted' | 'full-access' | 'custom'
export type CapabilityMode = 'disabled' | 'ask' | 'allow'
export type Capability = 'commands' | 'files' | 'screen_input' | 'usb' | 'microphone' | 'camera'
export interface Host {
url: string
@@ -8,6 +10,7 @@ export interface Host {
paired_at?: number | null
is_active: boolean
access_mode: AccessMode
capabilities: Record<Capability, CapabilityMode>
}
export interface DaemonStatus {
@@ -23,7 +26,7 @@ export interface Activity {
ts: number
kind?: 'tool.completed' | 'management.completed'
tool: string
category?: 'command' | 'files' | 'screen' | 'input' | 'system' | 'other'
category?: 'command' | 'files' | 'screen' | 'input' | 'devices' | 'system' | 'other'
ok: boolean
aborted?: boolean
request_id?: string
@@ -32,6 +35,14 @@ export interface Activity {
exit_code?: number
summary?: string
args_preview?: string
request_detail?: string
stdout?: string
stderr?: string
result_detail?: string
request_truncated?: boolean
stdout_truncated?: boolean
stderr_truncated?: boolean
result_truncated?: boolean
error?: string
}
@@ -42,6 +53,11 @@ export interface Snapshot {
activity: Activity[]
pending_grants: PendingGrantRequest[]
startup_enabled: boolean
daemon_autostart_enabled?: boolean
ui_version?: string | null
cli_version?: string | null
cli_path?: string | null
hardware_availability: { usb: boolean; adb: boolean; microphone: boolean; camera: boolean }
}
export interface PendingGrantRequest {
+145 -2
View File
@@ -266,7 +266,7 @@ not require an API endpoint or API bearer when dashboard chat is ready.
### 12. Android as a Hermes Platform/Channel — "Threads" (Shipped 2026-06-28; unified-session model 2026-06-29)
> **Status (2026-06-29):** SHIPPED as the `phone` platform plugin (`plugin/phone_platform.py`) — registered via `ctx.register_platform` with **no fork** (the ~16-file upstream change sketched below was avoided; the original research predates the open plugin-platform registry). Two-way reply is device-verified. Agent-facing entry is `send_message target=phone` (the stale `target=mobile:<device_id>` syntax below is superseded); cron `deliver=phone` is the one remaining broken path. See TODO.md "Phone platform — usability roadmap".
> **Status (2026-08-12):** SHIPPED as the `phone` platform plugin (`plugin/phone_platform.py`) — registered via `ctx.register_platform` with **no fork** (the ~16-file upstream change sketched below was avoided; the original research predates the open plugin-platform registry). Two-way reply is device-verified. Agent-facing entry is `send_message target=phone` (the stale `target=mobile:<device_id>` syntax below is superseded); standalone cron delivery uses the registered sender, and the adapter publishes its canonical home destination through upstream's channel directory. Live cron certification remains tracked in TODO.md.
>
> **Decision (2026-06-29) — unified-session "Threads", not a separate surface:** the proactive agent↔phone conversation is **not** a separate app lane/tab/segment. It is a **source-tagged session inside the one Chat surface** — a **Thread** (`source=phone`). The three things distinguishing a Thread from a normal gateway chat are *session properties*, not a separate UI: (a) the agent can initiate a turn, (b) it rides the relay `proactive` transport and is relay-gated, (c) it's a standing/named DM. **Scrollback = the gateway session store** (same read path Chat uses); **live receive = the relay `proactive` push** (→ notification); **send = `proactive.reply`**. The local `ProactiveInboxStore` is demoted to a live-push cache + outbox (no parallel history). The Thread capability is surfaced in the **connection best-path/capability UI** (a relay-tier capability, like terminal/bridge/voice) and as a clean **Threads** entry (thread-spool icon, NOT a phone glyph) pinned atop the session drawer when active — never a connection-wizard step. Degrades cleanly: no relay plugin → no `source=phone` sessions → Chat is unchanged (standard-path-safe). This **supersedes the earlier "separate Agent lane / 4th nav segment" sketch** and folds in the "show chat source/platform attribution in Chat" goal in one stroke.
>
@@ -1965,7 +1965,8 @@ after the turn settles and therefore cannot restore the in-between UI.
1. Persist active session-backed turns as a bounded set in the shared Android
DataStore, keyed by connection/profile context plus durable session id, with a
24-hour expiry. Store each visible assistant state and server-issued ask, but
24-hour expiry. Store each visible assistant state and server-issued ask,
including clarify multi-select semantics, but
never an entered password/secret or approval response.
2. On reopen, restore that UI immediately, then recover in this order: exact
`session.activate` using the saved live id; `session.resume` using the durable
@@ -2812,3 +2813,145 @@ empty transcript.
longer operate on a silent latest-500 subset. Recovery stays cheap for ordinary
sessions without allowing a bounded window to replace the complete visible
history. Older Hermes releases remain compatible.
---
## ADR 51 — Android coding-session context is optional upstream metadata
**Status:** Accepted (2026-08-12).
**Context.** Current upstream Hermes records a session's working directory, Git
branch, and repository root in its session database and returns those fields on
Dashboard session-list routes. It also exposes a read-only profile-wide endpoint
that recovers the pull request a session created from a narrowly validated tool
result. Android previously discarded all of that context, making coding chats
indistinguishable in the session drawer.
**Decision.** Android maps optional `cwd`, `git_branch`, and `git_repo_root`
fields from existing Dashboard list responses. For rows with workspace context,
it asks `POST /api/profiles/sessions/pull-requests` and accepts only a positive
PR number with a non-blank URL. Unresolved active sessions retry on a bounded
cadence and receive one final scan after ending; resolved associations and
terminal misses remain cached. Cache ownership is profile plus session id, and
ambiguous duplicate ids in an all-profile response are never assigned a
transcript result. Android then uses the repository-
scoped `POST /api/git/review/pr-list` view to refresh the matching branch or
known PR number's lifecycle state. The drawer displays only the repository
basename, exact branch, PR number, and lifecycle state; it does not expose host
paths. Both reads are best-effort. A missing route, unavailable GitHub CLI,
malformed response, or API-server-only connection leaves ordinary session
behavior intact and is not promoted to a session-list failure.
**Consequences.** Coding sessions become recognizable without introducing a
Relay dependency or a GitHub credential path. Repository and branch state can
still appear when PR recovery is unsupported, while legacy hosts show no new
badges and active sessions can surface a PR created after their first drawer
refresh without allowing cross-profile cache collisions.
---
## ADR 52 — Desktop RPC is explicitly device-targeted and capability-honest
**Status:** Accepted (2026-08-12).
**Context.** A Relay can retain several authorized desktop sessions, but the
desktop channel historically latched one latest WebSocket. Pairing two PCs could
therefore make an agent command change targets implicitly. The desktop surface
also mixes typed file/process/screen operations with unrestricted terminal and
PowerShell execution. Camera, microphone, and attached-device toggles cannot be
claimed as security boundaries while unrestricted code still runs as the same
Windows user.
**Decision.** Every desktop advertises a stable installation `device_id` and
display name. The Relay keeps all connected desktop WebSockets, accepts a
`device` selector on every client-routed tool, binds pending responses to that
WebSocket, and fails closed when more than one desktop is connected without an
explicit target. Health reports enumerate valid targets, and successful RPCs
identify the resolved target.
Typed tools are the preferred automation surface and declare a capability such
as `files.read`, `files.write`, `process.manage`, `screen.observe`, or
`input.control`. `desktop_terminal`, `desktop_powershell`, detached processes,
and background command jobs declare `system.execute`. This capability is an
escape hatch: at user privilege it can transitively reach files, processes,
USB devices, camera, or microphone through operating-system APIs. The UI must
not present independent hardware-deny toggles as enforceable while
`system.execute` remains enabled.
Existing local policy remains authoritative on each target PC: Ask does not
start the headless tool router without consent; Structured withholds
`desktop_terminal`, `desktop_powershell`, detached process launch, and command
job start; Trusted permits typed and execution tools but retains task grants for
screen/input; Full Access bypasses those task grants for that Relay host.
Hardware policy is separate and per host. USB defaults Disabled and exposes
only typed, serial-bound ADB list, shell, push, pull, install, and bounded
logcat operations. Ask raises the dedicated local approval card for every
operation; Allow requires explicit confirmation. Full Access does not override
the USB policy. Disabled or unavailable backends are omitted from advertised
tools. Microphone and camera remain visibly unavailable until bounded
brokers, active-use indication, and cancellation exist.
The management UI names the legacy no-tools Ask state **Restricted**. A separate
**Ask Every Time** preset advertises each available command, file, screen/input,
and USB operation behind a per-operation local approval; unavailable brokers
remain disabled. New pairings receive Ask Every Time explicitly, while missing
or existing legacy policy records retain their previous fail-closed meaning.
**Consequences.** Agents cannot accidentally execute against whichever PC most
recently sent a heartbeat, and a response from another PC cannot satisfy a
targeted request. Common operations remain typed and auditable while raw shell
power is labeled honestly. Structured mode makes the USB policy enforceable,
and device operations appear as their own local Activity category. Microphone
and camera controls are not shipped as cosmetic switches.
---
## ADR 53 — Desktop management separates host scope from local-PC scope
**Status:** Accepted (2026-08-12).
**Context.** The compact Windows tray had accumulated per-host connection and
authorization information alongside local daemon, update, and application
controls. That made Settings difficult to scan and left common operator tasks—
opening the CLI, viewing the daemon log, running diagnostics, or returning an
elevated daemon to normal user privileges—dependent on external instructions.
The tray must remain a small management utility rather than regrow into an
embedded terminal or general desktop client.
**Decision.** The three top-level destinations remain Overview, Hosts, and
Settings, with scope determining ownership:
- A Host detail page is the hub for one paired Relay instance. It owns the local
display name, connection state, Relay address/version, pairing and session
details, access preset, capabilities, authorized clients, explicit connect,
re-pair, client deauthorization, and guarded Forget host actions. Opening the
page never silently selects or connects the host.
- Settings owns this Windows PC and the installed application. It contains
daemon lifecycle and sign-in behavior, CLI launchers, logs and diagnostics,
bundle updates, and Help & About. **Start UI at sign-in** controls only the
per-user tray startup entry. **Start daemon with UI** is a separate opt-in,
defaults off for existing installs, and never implies elevation. Per-host
access and client lists do not appear there.
- **Open terminal** starts a normal terminal with `hermes-relay` available.
**Open Hermes CLI** starts the paired remote Hermes TUI in a real terminal;
neither action embeds a terminal emulator in the tray.
- **View daemon log** opens the local daemon log. **Run diagnostics** delegates
to the CLI diagnostic contract rather than creating a second health model.
- Help & About reports UI, CLI, and connected Relay versions and links to the
documentation, troubleshooting guide, and release notes through the default
browser. Log and diagnostic shortcuts remain available there as well.
The tray always remains unelevated. **Restart as Administrator...** is an
explicit UAC-mediated action that elevates only the daemon. **Return to user
mode** performs one elevated-daemon stop followed by a normal daemon start.
Elevation is not stored as a toggle or sign-in preference because every approved
command and input action inherits the daemon's privilege.
**Consequences.** Relay-specific actions are discoverable from the corresponding
host without making global Settings wider. Routine CLI and support workflows no
longer require users to find paths or commands manually. Administrator state is
visible and reversible, while the management UI and automatic startup retain
normal user privilege. The product stays a thin CLI/TUI plus compact Windows
management surface; chat, plugins, voice, and terminal rendering remain outside
the tray.
+19 -5
View File
@@ -73,10 +73,12 @@ As of 2026-05-18, current provider references are:
- xAI announcement for the advertised SuperGrok surfaces:
<https://x.ai/news/grok-hermes>
The lab implements its own browser-based OAuth 2.0 PKCE loopback flow and stores
tokens in `voice-lab-runs/auth/xai-oauth.json` by default. It does not require
the `hermes` command, does not read `~/.hermes/auth.json`, and does not scrape
Grok.com or X browser/session cookies.
The lab implements xAI's OAuth 2.0 device-code flow and stores tokens in
`voice-lab-runs/auth/xai-oauth.json` by default. It prints the verification URL
and user code, then polls while approval happens in any browser; headless hosts
do not need an SSH port forward. It does not require the `hermes` command, does
not read `~/.hermes/auth.json`, and does not scrape Grok.com or X browser/session
cookies.
## Output Architecture Decision
@@ -416,6 +418,16 @@ python -m plugin.voice_lab realtime-text `
--text "Read this in a calm, precise tone."
```
Relay-owned OpenAI/xAI realtime and TTS transports also accept the upstream-
compatible options `ssl_ca_cert`, `ssl_verify`, and `extra_headers`. The latter
is a JSON object when passed through the CLI, for example
`--provider-option extra_headers={"X-Gateway-Key":"..."}`. Provider-specific
headers override built-in defaults and may contain credentials, so they are
never logged. TLS CA resolution uses an explicit `ssl_ca_cert` first, then
`HERMES_CA_BUNDLE`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, and
`CURL_CA_BUNDLE`. `ssl_verify=false` is an unsafe local-development escape hatch
and emits a warning.
`websocket-client` is required for the OpenAI adapter. This environment already
has it installed; on a new environment, install it with:
@@ -456,7 +468,9 @@ Grok provider auth:
- Supported: lab-owned xAI OAuth credentials in
`VOICE_LAB_HOME/auth/xai-oauth.json`, created by
`python -m plugin.voice_lab auth --provider grok` or
`.\scripts\voice-lab.ps1 -Mode auth -Provider grok`.
`.\scripts\voice-lab.ps1 -Mode auth -Provider grok`. This uses xAI's device-
code flow, so `--no-browser` is suitable on a headless host without an SSH
callback tunnel.
- Supported: `XAI_API_KEY`, `VOICE_TOOLS_XAI_KEY`, or an ephemeral xAI realtime
client secret passed as `XAI_REALTIME_CLIENT_SECRET` / `XAI_EPHEMERAL_TOKEN`.
- Convenience alias: `GROK_API_KEY` is accepted locally, but `XAI_API_KEY` is
+17 -1
View File
@@ -389,12 +389,28 @@ Sources: `plugin/relay/channels/notifications.py`, `app/src/main/kotlin/.../noti
|------|-----------|---------|
| `desktop.command` | Server → Client | `{request_id, tool, args}` |
| `desktop.response` | Client → Server | `{request_id, ok: true, result}` or `{request_id, ok: false, error}` |
| `desktop.status` | Client → Server | `{advertised_tools, host, platform, version, computer_use?}` |
| `desktop.status` | Client → Server | `{advertised_tools, device_id, host, platform, version, computer_use?}` |
| `desktop.workspace` | Client → Server | Workspace context snapshot |
| `desktop.active_editor` | Client → Server | Active editor hint |
Experimental computer-use tools use the same channel but are advertised by the desktop client only when the experimental computer-use feature flag is enabled (`--experimental-computer-use` or `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`) after normal desktop-tool consent. The relay still treats `desktop_computer_*` names as strict-advertise tools so older or unflagged clients fail closed. Screenshots require an in-memory observe/assist/control grant. Host input currently uses a Windows-only CLI approval path: desktop-tool consent plus one visible local `yes` prompt for a task-scoped assist/control grant. Actions then run without per-action prompts until that grant expires or is canceled. Headless/non-interactive clients advertise blocked grant state and reject assist/control grant requests with structured failures.
Every desktop heartbeat includes its stable installation `device_id` and
display name. Tool HTTP bodies accept a relay-only `device` selector. With one
connected desktop the selector is optional; with several it is required and may
be an exact device ID or an unambiguous host/device name. The Relay binds each
pending request to the selected WebSocket, ignores responses from other PCs,
and includes resolved target metadata in the response. `/desktop/health` lists
the connected targets.
Per-host Structured access withholds `desktop_terminal`,
`desktop_powershell`, `desktop_spawn_detached`, and `desktop_job_start` from
`advertised_tools`. Brokered USB tools use the same request/response envelope:
`desktop_adb_devices`, `_shell`, `_push`, `_pull`, `_install`, and `_logcat`.
Every device operation other than enumeration requires an explicit ADB serial;
local USB policy independently disables, prompts for, or allows each call.
Disabled or unavailable ADB backends omit all six names from advertisement.
### 3.8 TUI *(new, being added — see `docs/plans/2026-04-22-desktop-tui-mvp.md`)*
**Purpose:** Remote desktop TUI — pipes JSON-RPC between the Node TUI client and a remote `tui_gateway` subprocess on the server.
+9 -1
View File
@@ -148,6 +148,13 @@ the local operator enables the runtime dev toggle with
| `done` | Server → App | `{ session_id, run_id, state: "final" }` |
Session management uses the REST API (`GET/POST /api/sessions`, `PATCH/DELETE /api/sessions/{id}`).
Newer Dashboard session rows may also supply `cwd`, `git_branch`, and
`git_repo_root`. Android reduces paths to a repository name for display and
uses the read-only `POST /api/profiles/sessions/pull-requests` transcript scan
to attach the PR a coding session created, then the repo-scoped read-only
`POST /api/git/review/pr-list` route for its current lifecycle state. All of
this metadata is optional; older Dashboard and API-server hosts retain the
ordinary session row.
#### Channel: `terminal`
PTY streaming — raw terminal I/O.
@@ -410,6 +417,7 @@ Bottom navigation bar with 4 tabs:
- **Agent Passport — profile inspection/configuration** — upstream Hermes profiles are selected from the Profile Shelf or the Passport's shared full switcher. Passport retains identity customization, model/personality/reasoning/safety configuration, inspection, and session analytics; it is not a second profile-picker implementation. See `docs/decisions.md` §21 and ADR 48.
- **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles.
- **Agent sheet — Approval controls** — gateway contract v3 exposes the profile-persisted `approvals.mode` policy (`manual` / `smart` / `off`) separately from YOLO. The launch/default profile gets the three-way control; multiplexed non-launch profiles reconcile `session.info.approval_mode` read-only until upstream config RPCs honor profile scope. The existing YOLO switch remains an explicit per-session override and never silently writes profile configuration. Older gateways keep chat and YOLO available while the profile control explains that an upstream update is required.
- **Interactive clarify cards** — ordinary upstream choices retain one-tap submission; `multi_select:true` choices toggle independently and require explicit submission as one JSON-array answer. Open text remains available for an Other answer. Android never invents a clarify deadline when upstream omits timeout metadata: the correlated `clarify.expire` event or an expired response retires the card authoritatively.
- **Streaming dots** — animated pulsing 3-dot indicator replaces static "streaming..." text
- Displays: streaming delta text; quiet thinking/reasoning disclosures that open while live and collapse when settled; consecutive routine tool activity summarized as one live ticker or settled disclosure; standalone lifecycle surfaces for approvals, failures, generated media, file edits, and delegated work; per-message token counts + cost
@@ -445,7 +453,7 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
- **Appearance customization** — each preset has an expandable editor directly below mode. Accent and Soft/Balanced/Sharp shape drafts update only the live preview until Apply; Reset restores the preset draft, applied values remain local in DataStore, and derived Material on-colors preserve readable contrast. Typography and density remain in their focused Font and Font size controls.
- **Appearance** — preset-first live preview, theme mode and typography controls, plus independent **Background visualization** (Off/Sphere with built-in or imported declarative JSON skins, or an imported validated pet-format animation) and **Floating pet** (None/installed pet) controls. A background pet-format asset reuses the safe renderer but never gains roaming, placement, or temperament behavior; its persisted selection is separate from the floating companion. Selected-pet controls include playback speed, 60–120% size, stabilization, temperament, opt-in **Walk around the interface**, and **Reset position**. Pets can be installed from a responsive, searchable Petdex thumbnail gallery, imported as a custom Relay `.zip`/single image, or prepared through a guided local-first creator that copies or prefills complete instructions into a fresh chat for user review before submission. Generated files are never auto-installed or shared. The gallery lazily requests upstream-cropped idle frames and animates only the selected installed pet; the global companion is hidden on that dense route so install/source controls remain clear. Petdex browsing prefers its v2 manifest with v1 fallback; full-atlas downloads to Android are user-initiated, exact-host and size constrained, attribution-preserving, validated, and installed atomically for offline use. Runtime decoding holds the previous complete clip during state swaps, caches a bounded set of clips/sheets, and rejects frame sequences or sheets beyond documented decoded-pixel ceilings. Settings cards and thinking controls remain registered obstacles beneath their walkable top rails, and the pet target yields pointer input during active scrolling. Existing combined selections migrate once: the previous main avatar becomes the background selection while any pet also remains available as the floating companion. Profile identity is unaffected.
- **Pet terrain diagnostics** — debug builds expose a default-off Developer Options toggle with a **Pet path inspector** anchored initially below the app header and its Android status-bar inset, leaving all header navigation and menu actions accessible. It starts as a narrow collapsed live-status bar, can be moved only from its grip, snaps to the nearest horizontal edge, retains normalized placement through navigation and rotation, and re-clamps when its size or viewport changes. **Reset position** returns it below the header. A recoverable **PASS** mode collapses the inspector and makes every region except its unlock button click-through, while the diagnostic Canvas remains visible. Expanding opens the default **Terrain** view; **Plan** reduces the canvas to the selected/active journey, while **Full** restores protected viewport, raw perch/rail labels, footprint, gate, and locomotion details. **Exit inspector** disables the persisted Developer Options overlay request. The inspector distinguishes measured perches, derived walk rails, narrow-bubble touchdown points, expanded collision regions, dashed collision-checked candidates, the selected out-and-back planner route with arrows and numbered stops, and the solid route active only while it is traversed. The planner maintains an event-driven lookahead while behavior pacing is idle, revalidates it when terrain or a supported waypoint changes, and keeps an in-flight transfer atomic instead of redirecting mid-jump. **Freeze** snapshots only the displayed diagnostics so a route can be inspected while the live planner continues unchanged. The selected route updates on each planner pass and is cleared when live terrain changes; its numbered loop describes the exact selected outbound legs and their reverse return. Active autonomous, recovery, and direct drag/drop paths are colored separately; recovery or direct manipulation never makes a path eligible for ambient travel. Candidate connectivity is diagnostic and never implies planner selection. The full-screen Canvas and non-control inspector regions remain pointer-transparent. Locking Developer Options clears it.
- **Data** — Backup, restore, reset with confirmation dialogs
- **Data** — Android-local backup, restore, and reset with confirmation dialogs. Manage → Operations also uses the authenticated upstream dashboard to create and download server backups and to upload an explicitly confirmed zip to the guarded import staging route. Manage → Learning edits full node content and confirms deletion (Hermes archives skill nodes; memory-node deletion is permanent). Manage → Memory uses upstream discovery/config/setup and provider activation, scoped to the selected profile. Manage → Channels includes upstream WhatsApp QR onboarding, status polling, apply/cancel, and the resulting gateway restart.
- **About** — logo on dark background, dynamic version from BuildConfig, Source + Docs link buttons, credits. What's New dialog.
---
+2
View File
@@ -25,6 +25,7 @@ markdown-renderer = "0.43.0"
coil = "3.5.0"
haze = "1.7.2"
mlkit-barcode = "17.3.0"
zxing-core = "3.5.4"
camera = "1.6.1"
play-publisher = "4.0.0"
media3 = "1.11.0"
@@ -84,6 +85,7 @@ haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", versi
# ML Kit Barcode Scanning
mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkit-barcode" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing-core" }
# CameraX
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camera" }
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Relay",
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
"icon": "Activity",
"version": "1.6.3",
"version": "1.6.4",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.6.3",
"version": "1.6.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.6.3",
"version": "1.6.4",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-relay-dashboard",
"version": "1.6.3",
"version": "1.6.4",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
+17
View File
@@ -542,6 +542,23 @@ class PhoneAdapter(BasePlatformAdapter): # type: ignore[misc,valid-type]
"""Return basic info about the phone "chat"."""
return {"name": self._home_channel_name or "Phone", "type": "dm"}
async def list_channels(self) -> List[Dict[str, str]]:
"""Enumerate the adapter's single canonical phone destination.
The upstream channel directory calls this optional adapter hook during
its normal startup and periodic rebuilds. Returning the configured home
channel here makes the phone discoverable to ``send_message`` listings
without creating a Relay-owned target registry or relying on historical
sessions to have been written first.
"""
return [
{
"id": _home_channel(),
"name": _home_channel_name(),
"type": "dm",
}
]
# ---------------------------------------------------------------------------
# Registry hooks (mirror ntfy)
+1 -1
View File
@@ -1,6 +1,6 @@
name: hermes-relay
manifest_version: 1
version: 1.6.3
version: 1.6.4
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
# All three are OPTIONAL — only needed if you use the relay's extra /
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.6.3"
__version__ = "1.6.4"
from .server import create_app, main # noqa: E402 — must come after __version__
+24 -2
View File
@@ -937,6 +937,12 @@ class SessionManager:
# Keep different devices independent and leave legacy clients with
# no device_id alone because an empty id cannot identify ownership.
normalized_device_id = device_id.strip()
# Legacy desktop clients omitted device_id, which the wire layer
# represented as the shared sentinel "unknown". Treat sentinels as
# absent ownership, otherwise pairing any second PC revokes every
# other legacy desktop session as if they were one installation.
if normalized_device_id.casefold() in {"unknown", "none", "null"}:
normalized_device_id = ""
if normalized_device_id:
replaced_sessions = [
token
@@ -1077,14 +1083,30 @@ class SessionManager:
if trusted.is_expired:
self._save_to_disk()
return None
if device_id and trusted.device_id != device_id:
normalized_device_id = device_id.strip()
legacy_desktop_identity = (
trusted.client_surface in {"desktop", "unknown"}
and trusted.device_id.strip().casefold() in {"unknown", "none", "null"}
and normalized_device_id.casefold() not in {"", "unknown", "none", "null"}
)
if (
normalized_device_id
and trusted.device_id != normalized_device_id
and not legacy_desktop_identity
):
logger.info(
"Refresh rejected for device_id mismatch: stored=%s attempted=%s",
trusted.device_id,
device_id,
normalized_device_id,
)
self._trusted_devices[refresh_hash] = trusted
return None
if legacy_desktop_identity:
# The refresh secret proves continuity for desktop clients created
# before they persisted an installation UUID. Upgrade that one
# trusted record in place without weakening normal ID mismatch
# rejection or conflating separate legacy PCs during pairing.
trusted.device_id = normalized_device_id
now = time.time()
new_refresh_token = _generate_refresh_token()
+231 -67
View File
@@ -6,11 +6,12 @@ Two jobs over the same WSS ``desktop`` envelope stream:
channel for the Node thin-client CLI / future TUI. Agent-side
Python tools in ``plugin/tools/desktop_tool.py`` POST to
``/desktop/*`` HTTP routes; the relay forwards them over the
``desktop.command`` envelope; the connected client services them
locally and returns ``desktop.response``, which bubbles back as the
HTTP response. Single-client MVP — the most recent client wins
``self.client_ws``. ``self.pending`` is asyncio-locked so concurrent
add/remove can't drop futures. Normal desktop tools keep the bridge-like
``desktop.command`` envelope; the explicitly selected client services
them locally and returns ``desktop.response``, which bubbles back as the
HTTP response. Multiple clients are keyed by stable device identity;
untargeted dispatch fails closed when more than one is connected.
``self.pending`` is asyncio-locked so concurrent add/remove can't drop
futures. Normal desktop tools keep the bridge-like
30 s timeout; computer-use calls get a longer timeout because they may
wait on a visible human approval prompt.
@@ -49,6 +50,7 @@ logger = logging.getLogger(__name__)
RESPONSE_TIMEOUT = 30.0 # seconds — matches bridge.RESPONSE_TIMEOUT
COMPUTER_USE_RESPONSE_TIMEOUT = 180.0 # seconds — allows human approval prompts
ADB_RESPONSE_TIMEOUT = 300.0 # seconds — approval plus a bounded 120 s operation
# Keys whose values must never appear in the ring buffer. Matched
# case-insensitively against the full key name.
@@ -98,10 +100,11 @@ class DesktopSession:
"""Per-WebSocket scratch space.
Holds BOTH:
* The single-client tool-routing latch fields (``advertised_tools``
/ ``client_status`` / ``last_seen_at``) — these are mirrored on
the outer :class:`DesktopHandler` for the latched client and
kept here per-WS for diagnostics + future multi-client support.
* The routing identity and capability fields (``device_id``,
``advertised_tools``, ``client_status``, and ``last_seen_at``).
These are authoritative for targeted multi-client dispatch; the
outer :class:`DesktopHandler` mirrors the most recent status only
for backward-compatible diagnostics.
* The workspace + active-editor snapshots advertised by the
desktop CLI (alpha.6).
@@ -112,6 +115,8 @@ class DesktopSession:
"""
def __init__(self) -> None:
self.device_id: str = ""
self.device_name: str = ""
# Latest ``desktop.workspace`` payload as-sent. Opaque dict — we
# don't parse fields here because the wire schema is owned by
# the client (versioned as ``version: 1``); future versions
@@ -124,10 +129,9 @@ class DesktopSession:
self.active_editor: dict[str, Any] | None = None
self.active_editor_received_at: float | None = None
# Per-WS view of the latest ``desktop.status`` envelope. The
# outer handler also keeps a flattened copy for the latched
# client; storing it here makes future multi-client diagnostics
# straightforward.
# Per-WS view of the latest ``desktop.status`` envelope. The outer
# handler also keeps a flattened compatibility snapshot, while all
# routing decisions use this per-client state.
self.advertised_tools: set[str] = set()
self.client_status: dict[str, Any] = {}
self.last_seen_at: float | None = None
@@ -154,13 +158,10 @@ class DesktopHandler:
"""
def __init__(self) -> None:
# The currently-connected desktop client's WebSocket. Assigned
# when a client sends its first desktop envelope and cleared via
# :meth:`detach_ws` when the client disconnects.
#
# TODO(multi-client): graduate to a dict keyed by session_id +
# per-tool routing. For the MVP the latest-wins model matches
# bridge.py exactly.
# Backward-compatible pointer to the most recently active desktop.
# It is never used to choose among multiple connected clients;
# targeted routing uses ``_sessions`` and fails closed if the caller
# omits a selector while multiple desktops are online.
self.client_ws: web.WebSocketResponse | None = None
# Tools advertised by the currently-attached client. Populated
@@ -177,6 +178,7 @@ class DesktopHandler:
# command is sent; resolved by handle_response; cancelled with
# ConnectionError on detach.
self.pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
self.pending_ws: dict[str, web.WebSocketResponse] = {}
self._lock = asyncio.Lock()
# Bounded ring buffer of recent commands.
@@ -184,10 +186,9 @@ class DesktopHandler:
maxlen=RECENT_COMMANDS_MAX
)
# ws → DesktopSession. Cleared per-ws in :meth:`detach_ws` so
# disconnected clients don't leak session structs. Lives
# alongside the single-client tool-routing fields above; the
# latched client always has an entry here too.
# ws → DesktopSession. This is the authoritative connected-target
# registry. Cleared per-ws in :meth:`detach_ws` so disconnected
# clients don't leak session structs.
self._sessions: dict[web.WebSocketResponse, DesktopSession] = {}
# ── Envelope dispatch ────────────────────────────────────────────────
@@ -196,6 +197,7 @@ class DesktopHandler:
self,
ws: web.WebSocketResponse,
envelope: dict[str, Any],
auth_session: Any | None = None,
) -> None:
"""Route an incoming desktop-channel envelope from the client.
@@ -238,10 +240,14 @@ class DesktopHandler:
if session is None:
session = DesktopSession()
self._sessions[ws] = session
if auth_session is not None:
auth_device_id = str(getattr(auth_session, "device_id", "") or "").strip()
if auth_device_id.casefold() not in {"", "unknown", "none", "null"}:
session.device_id = auth_device_id
session.device_name = str(getattr(auth_session, "device_name", "") or "").strip()
if msg_type == "response":
# Tool routing — client is replying to a pending command.
await self._latch_client_ws(ws)
await self.handle_response(ws, envelope)
return
@@ -286,31 +292,103 @@ class DesktopHandler:
self,
ws: web.WebSocketResponse,
envelope: dict[str, Any],
session: Any | None = None,
) -> None:
await self.handle_envelope(ws, envelope)
await self.handle_envelope(ws, envelope, auth_session=session)
async def _latch_client_ws(self, ws: web.WebSocketResponse) -> None:
"""Opportunistically latch ``ws`` as the active tool-routing client.
"""Refresh the legacy most-recent-client diagnostics pointer.
Mirrors alpha.1's behaviour: the first envelope wins, but a new
ws can take over and any pending futures bound to the old ws are
failed with a ``replaced by new client`` error.
Existing clients and their in-flight requests remain attached.
Actual dispatch is resolved from the complete per-WebSocket session
registry, never from this compatibility pointer when several targets
are connected.
"""
if self.client_ws is ws:
return
if self.client_ws is not None and not self.client_ws.closed:
logger.info(
"desktop: replacing previous client ws — new client took over"
)
await self._fail_pending("replaced by new client")
# Keep the compatibility/default pointer, but do not evict another
# connected PC or fail its in-flight requests. Explicit routing uses
# the per-WebSocket session registry below.
self.client_ws = ws
def _connected_targets(self) -> list[tuple[web.WebSocketResponse, DesktopSession]]:
return [(ws, session) for ws, session in self._sessions.items() if not ws.closed]
@staticmethod
def _normalized_selector(value: str) -> str:
return "".join(ch for ch in value.casefold() if ch.isalnum())
def _resolve_target(
self,
selector: str | None,
) -> tuple[web.WebSocketResponse, DesktopSession | None]:
targets = self._connected_targets()
if selector and selector.strip():
raw = selector.strip()
normalized = self._normalized_selector(raw)
matches: list[tuple[web.WebSocketResponse, DesktopSession]] = []
for ws, session in targets:
status = session.client_status
candidates = {
session.device_id,
session.device_name,
str(status.get("device_id", "")),
str(status.get("device_name", "")),
str(status.get("host", "")),
}
if any(
candidate
and (
candidate.casefold() == raw.casefold()
or self._normalized_selector(candidate) == normalized
)
for candidate in candidates
):
matches.append((ws, session))
if not matches:
available = [
session.device_name
or str(session.client_status.get("host", ""))
or session.device_id
for _, session in targets
]
raise DesktopError(
f"Unknown desktop device selector {raw!r}. Available: "
f"{', '.join(filter(None, available)) or 'none'}"
)
if len(matches) > 1:
raise DesktopError(f"Ambiguous desktop device selector {raw!r}; use its device_id")
return matches[0]
if len(targets) == 1:
return targets[0]
if len(targets) > 1:
available = [
(
f"{session.device_name or session.client_status.get('host') or 'desktop'} "
f"({session.device_id or session.client_status.get('device_id') or 'legacy'})"
)
for _, session in targets
]
raise DesktopError(
"Multiple desktop clients are connected; pass device or device_id explicitly. "
+ "Available: "
+ ", ".join(available)
)
ws = self.client_ws
if ws is not None and not ws.closed:
return ws, self._sessions.get(ws)
raise DesktopError(
"No desktop client connected. Start the Hermes desktop CLI and pair it."
)
# ── Outbound commands (called from HTTP handlers) ────────────────────
async def handle_command(
self,
method: str,
args: dict[str, Any] | None = None,
device: str | None = None,
) -> dict[str, Any]:
"""Dispatch a ``desktop_*`` tool call to the connected client.
@@ -319,20 +397,21 @@ class DesktopHandler:
no client is connected, the send fails, or the client doesn't
respond within the tool-specific response timeout.
"""
ws = self.client_ws
if ws is None or ws.closed:
raise DesktopError(
"No desktop client connected. Start the Hermes desktop CLI and pair it."
)
ws, target_session = self._resolve_target(device)
target_tools = (
target_session.advertised_tools
if target_session is not None
else self.advertised_tools
)
# Fail-closed on tools the client hasn't advertised. This is what
# lets the agent side's ``check_fn`` tell Hermes "this tool isn't
# available right now" without even attempting a round-trip.
if method.startswith("desktop_computer_") and not self.advertised_tools:
if method.startswith("desktop_computer_") and not target_tools:
raise DesktopError(
f"Desktop client has not advertised experimental computer-use tool {method!r}"
)
if self.advertised_tools and method not in self.advertised_tools:
if target_tools and method not in target_tools:
raise DesktopError(
f"Desktop client does not advertise tool {method!r}"
)
@@ -342,11 +421,17 @@ class DesktopHandler:
async with self._lock:
self.pending[request_id] = future
self.pending_ws[request_id] = ws
command_payload = {
"request_id": request_id,
"tool": method,
"args": args or {},
"target_device_id": (
target_session.device_id
if target_session is not None
else None
),
}
logger.info(
"desktop >>> %s args=%s",
@@ -368,21 +453,41 @@ class DesktopHandler:
except Exception as exc:
async with self._lock:
self.pending.pop(request_id, None)
self.pending_ws.pop(request_id, None)
record.decision = "error"
record.error = f"Failed to send command to client: {exc}"
logger.error("desktop: failed to send command: %s", exc)
raise DesktopError(f"Failed to send command to client: {exc}") from exc
timeout = (
COMPUTER_USE_RESPONSE_TIMEOUT
ADB_RESPONSE_TIMEOUT
if method.startswith("desktop_adb_")
else COMPUTER_USE_RESPONSE_TIMEOUT
if method.startswith("desktop_computer_")
else RESPONSE_TIMEOUT
)
try:
return await asyncio.wait_for(future, timeout=timeout)
response = await asyncio.wait_for(future, timeout=timeout)
response.setdefault(
"target",
{
"device_id": (
target_session.device_id
if target_session is not None
else None
),
"device_name": (
target_session.device_name
if target_session is not None
else None
),
},
)
return response
except asyncio.TimeoutError:
async with self._lock:
self.pending.pop(request_id, None)
self.pending_ws.pop(request_id, None)
record.decision = "timeout"
record.error = f"Desktop client did not respond within {timeout:.0f}s"
logger.warning(
@@ -393,6 +498,11 @@ class DesktopHandler:
raise DesktopError(
f"Desktop client did not respond within {timeout:.0f}s"
) from None
except asyncio.CancelledError:
async with self._lock:
self.pending.pop(request_id, None)
self.pending_ws.pop(request_id, None)
raise
# ── Inbound response routing ────────────────────────────────────────
@@ -409,7 +519,15 @@ class DesktopHandler:
return
async with self._lock:
expected_ws = self.pending_ws.get(request_id)
if expected_ws is not None and expected_ws is not ws:
logger.warning(
"desktop: ignored response from wrong target request_id=%s",
request_id,
)
return
future = self.pending.pop(request_id, None)
self.pending_ws.pop(request_id, None)
self._update_record_from_response(request_id, payload)
@@ -473,11 +591,15 @@ class DesktopHandler:
name for name in advertised if isinstance(name, str) and name
}
# Mirror onto the per-WS session so future multi-client
# diagnostics can see exactly what each client advertised.
# Mirror onto the authoritative per-WS session used for targeted
# routing and multi-client diagnostics.
if session is None:
session = self._sessions.get(ws)
if session is not None:
session.device_id = session.device_id or str(payload.get("device_id", "") or "").strip()
session.device_name = session.device_name or str(
payload.get("device_name", "") or payload.get("host", "")
).strip()
session.client_status = dict(self.client_status)
session.last_seen_at = self.last_seen_at
session.advertised_tools = set(self.advertised_tools)
@@ -491,10 +613,9 @@ class DesktopHandler:
# ── Public API for HTTP + tool handlers ─────────────────────────────
def is_client_connected(self) -> bool:
ws = self.client_ws
return ws is not None and not ws.closed
return bool(self._connected_targets())
def has_client_for(self, tool_name: str) -> bool:
def has_client_for(self, tool_name: str, device: str | None = None) -> bool:
"""True if a client is connected AND advertises ``tool_name``.
If the client never sent a ``desktop.status`` (empty advertised
@@ -502,14 +623,26 @@ class DesktopHandler:
clients that don't advertise still work. Clients that DO
advertise take the strict path.
"""
if not self.is_client_connected():
if device is None:
targets = self._connected_targets()
if not targets:
return False
return any(
bool(session.advertised_tools)
and tool_name in session.advertised_tools
for _, session in targets
) or (
not tool_name.startswith("desktop_computer_")
and any(not session.advertised_tools for _, session in targets)
)
try:
_, session = self._resolve_target(device)
except DesktopError:
return False
if tool_name.startswith("desktop_computer_") and not self.advertised_tools:
tools = session.advertised_tools if session is not None else self.advertised_tools
if tool_name.startswith("desktop_computer_") and not tools:
return False
if not self.advertised_tools:
# Client hasn't advertised yet — assume it can handle the call.
return True
return tool_name in self.advertised_tools
return not tools or tool_name in tools
def status_snapshot(self) -> dict[str, Any]:
"""Dict suitable for ``/desktop/_ping`` and diagnostics."""
@@ -519,6 +652,19 @@ class DesktopHandler:
"client_status": dict(self.client_status),
"last_seen_at": self.last_seen_at,
"pending_commands": len(self.pending),
"clients": [
{
"device_id": session.device_id or session.client_status.get("device_id"),
"device_name": (
session.device_name
or session.client_status.get("device_name")
or session.client_status.get("host")
),
"advertised_tools": sorted(session.advertised_tools),
"last_seen_at": session.last_seen_at,
}
for _, session in self._connected_targets()
],
}
# ── Workspace / editor accessors (alpha.6) ──────────────────────────
@@ -533,10 +679,7 @@ class DesktopHandler:
return self._sessions.get(ws)
def all_sessions(self) -> list[DesktopSession]:
"""Snapshot every known session. Useful for a future
``/desktop/sessions`` debug route that lists what every
connected client has advertised.
"""
"""Snapshot every known desktop session."""
return list(self._sessions.values())
# ── Activity feed ───────────────────────────────────────────────────
@@ -557,6 +700,7 @@ class DesktopHandler:
async with self._lock:
pending = dict(self.pending)
self.pending.clear()
self.pending_ws.clear()
if pending:
err = ConnectionError(
f"Desktop client disconnected ({reason})" if reason else
@@ -578,11 +722,9 @@ class DesktopHandler:
) -> None:
"""Drop per-ws state when a client disconnects.
Cleans up BOTH:
* Tool-routing state — if ``ws`` is the latched client, clear
the latch and fail any in-flight futures with a
"client disconnected" ConnectionError.
* Workspace state — pop the per-ws DesktopSession entry.
Cleans up BOTH the per-client routing/workspace state and only the
pending futures bound to this WebSocket. Other connected desktops and
their concurrent commands remain intact.
Called from the main ``_on_disconnect`` path in ``server.py``.
"""
@@ -597,11 +739,33 @@ class DesktopHandler:
# Tool-routing cleanup — only if this ws was the latched client.
if self.client_ws is ws:
self.client_ws = None
self.advertised_tools = set()
remaining = self._connected_targets()
self.client_ws = remaining[-1][0] if remaining else None
if remaining:
replacement = remaining[-1][1]
self.advertised_tools = set(replacement.advertised_tools)
self.client_status = dict(replacement.client_status)
self.last_seen_at = replacement.last_seen_at
else:
self.advertised_tools = set()
# keep client_status around for post-mortem diagnostics —
# it's small and the next connect overwrites it anyway.
await self._fail_pending(reason)
async with self._lock:
request_ids = [
request_id
for request_id, target in self.pending_ws.items()
if target is ws
]
futures = [
self.pending.pop(request_id)
for request_id in request_ids
if request_id in self.pending
]
for request_id in request_ids:
self.pending_ws.pop(request_id, None)
for future in futures:
if not future.done():
future.set_exception(ConnectionError(f"Desktop client disconnected ({reason})"))
async def close(self) -> None:
"""Server shutdown — cancel all pending commands, drop the
@@ -14,6 +14,10 @@ import aiohttp
from ....voice_lab.auth import load_voice_lab_env_file
from ....voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ....voice_lab.transport import (
merge_transport_headers,
resolve_voice_transport_options,
)
from ..models import (
ProviderEvent,
@@ -90,7 +94,7 @@ class OpenAIRealtimeAgentProvider:
capabilities = RealtimeAgentCapabilities(provider_id=provider_id)
def __init__(self, socket_factory: SocketFactory | None = None) -> None:
self._socket_factory = socket_factory or _create_aiohttp_websocket
self._socket_factory = socket_factory
async def connect(
self,
@@ -119,13 +123,26 @@ class OpenAIRealtimeAgentProvider:
or DEFAULT_URL
).rstrip("/")
url = _url_with_model(base_url, config.model or DEFAULT_MODEL)
transport = resolve_voice_transport_options(
config.provider_options,
base_url=base_url,
)
headers = {"Authorization": f"Bearer {auth.value}"}
safety_identifier = _option(config.provider_options, "safety_identifier")
if safety_identifier:
headers["OpenAI-Safety-Identifier"] = safety_identifier
headers = merge_transport_headers(headers, transport)
try:
socket = await self._socket_factory(url, headers, timeout)
if self._socket_factory is None:
socket = await _create_aiohttp_websocket(
url,
headers,
timeout,
ssl=transport.aiohttp_ssl,
)
else:
socket = await self._socket_factory(url, headers, timeout)
except aiohttp.WSServerHandshakeError as exc:
if exc.status in {401, 403}:
raise ProviderUnavailable(
@@ -303,6 +320,8 @@ async def _create_aiohttp_websocket(
url: str,
headers: dict[str, str],
timeout: float,
*,
ssl: Any = None,
) -> OpenAIProviderSocket:
# Liveness via WS heartbeat rather than an ambient total-timeout — realtime
# sessions legitimately live for many minutes; `timeout` bounds the connect.
@@ -310,7 +329,10 @@ async def _create_aiohttp_websocket(
timeout=aiohttp.ClientTimeout(total=None, connect=timeout, sock_connect=timeout)
)
try:
ws = await session.ws_connect(url, headers=headers, heartbeat=20.0)
kwargs: dict[str, Any] = {"headers": headers, "heartbeat": 20.0}
if ssl is not None:
kwargs["ssl"] = ssl
ws = await session.ws_connect(url, **kwargs)
except aiohttp.WSServerHandshakeError as exc:
await session.close()
if exc.status in {401, 403}:
+28 -7
View File
@@ -18,6 +18,10 @@ from ....voice_lab.auth import (
read_xai_oauth_token,
)
from ....voice_lab.providers.base import ProviderRunError, ProviderUnavailable
from ....voice_lab.transport import (
merge_transport_headers,
resolve_voice_transport_options,
)
from ..models import (
ProviderEvent,
@@ -88,7 +92,7 @@ class XAIRealtimeAgentProvider:
capabilities = RealtimeAgentCapabilities(provider_id=provider_id)
def __init__(self, socket_factory: SocketFactory | None = None) -> None:
self._socket_factory = socket_factory or _create_aiohttp_websocket
self._socket_factory = socket_factory
async def connect(
self,
@@ -114,12 +118,24 @@ class XAIRealtimeAgentProvider:
).rstrip("/")
model = urllib.parse.quote(config.model or DEFAULT_MODEL, safe="")
url = f"{base_url}?model={model}"
transport = resolve_voice_transport_options(
config.provider_options,
base_url=base_url,
)
headers = merge_transport_headers(
{"Authorization": f"Bearer {auth.value}"},
transport,
)
try:
socket = await self._socket_factory(
url,
{"Authorization": f"Bearer {auth.value}"},
timeout,
)
if self._socket_factory is None:
socket = await _create_aiohttp_websocket(
url,
headers,
timeout,
ssl=transport.aiohttp_ssl,
)
else:
socket = await self._socket_factory(url, headers, timeout)
except aiohttp.WSServerHandshakeError as exc:
if exc.status in {401, 403}:
raise ProviderUnavailable(
@@ -308,6 +324,8 @@ async def _create_aiohttp_websocket(
url: str,
headers: dict[str, str],
timeout: float,
*,
ssl: Any = None,
) -> XAIProviderSocket:
# Liveness is explicit: the WS heartbeat detects a dead peer instead of an
# ambient ClientTimeout(total=...) bounding the whole connection — a
@@ -317,7 +335,10 @@ async def _create_aiohttp_websocket(
timeout=aiohttp.ClientTimeout(total=None, connect=timeout, sock_connect=timeout)
)
try:
ws = await session.ws_connect(url, headers=headers, heartbeat=20.0)
kwargs: dict[str, Any] = {"headers": headers, "heartbeat": 20.0}
if ssl is not None:
kwargs["ssl"] = ssl
ws = await session.ws_connect(url, **kwargs)
except Exception:
await session.close()
raise
+10 -3
View File
@@ -966,12 +966,13 @@ async def handle_desktop_ping(request: web.Request) -> web.Response:
_require_loopback(request)
server: RelayServer = request.app["server"]
tool = request.query.get("tool", "").strip()
device = request.query.get("device", "").strip() or None
if not server.desktop.is_client_connected():
return web.json_response(
{"ok": False, "error": "no desktop client connected"},
status=503,
)
if tool and not server.desktop.has_client_for(tool):
if tool and not server.desktop.has_client_for(tool, device=device):
return web.json_response(
{"ok": False, "error": f"client connected but does not advertise tool {tool!r}"},
status=503,
@@ -1022,6 +1023,7 @@ async def handle_desktop_health(request: web.Request) -> web.Response:
"interactive": cs.get("interactive"),
"last_error": cs.get("last_error"),
"computer_use": cs.get("computer_use"),
"clients": snap.get("clients") or [],
"recent_commands": server.desktop.get_recent(limit=20),
}
return web.json_response(out)
@@ -1045,8 +1047,11 @@ async def handle_desktop_dispatch(request: web.Request) -> web.Response:
args = {}
except Exception: # noqa: BLE001
args = {}
device = args.pop("device", None) or args.pop("device_id", None)
if not isinstance(device, str):
device = None
try:
result = await server.desktop.handle_command(tool_name, args)
result = await server.desktop.handle_command(tool_name, args, device=device)
return web.json_response(result)
except Exception as exc: # DesktopError or asyncio.TimeoutError
msg = str(exc) or exc.__class__.__name__
@@ -4197,7 +4202,9 @@ async def _on_message(
# on the session; never replied to. Dispatching as a tracked task
# keeps it consistent with other channels so disconnect-cancel
# works uniformly.
task = asyncio.create_task(server.desktop.handle(ws, envelope))
token = server._clients.get(ws)
session = server.sessions.get_session(token) if token else None
task = asyncio.create_task(server.desktop.handle(ws, envelope, session=session))
_track_task(server, ws, task)
else:
logger.warning("Unknown channel: %s", channel)
+263
View File
@@ -0,0 +1,263 @@
"""Targeted multi-PC routing for the desktop RPC channel."""
from __future__ import annotations
import asyncio
import json
import unittest
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from plugin.relay.channels.desktop import DesktopError, DesktopHandler
from plugin.relay.server import handle_desktop_dispatch
from plugin.tools import desktop_tool
class _FakeWs:
def __init__(self) -> None:
self.sent: list[dict[str, Any]] = []
self.closed = False
async def send_str(self, payload: str) -> None:
self.sent.append(json.loads(payload))
@dataclass
class _FakeSession:
device_id: str
device_name: str
def _status(host: str) -> dict[str, Any]:
return {
"channel": "desktop",
"type": "desktop.status",
"payload": {
"host": host,
"advertised_tools": ["desktop_powershell", "desktop_read_file"],
},
}
async def _register_two() -> tuple[DesktopHandler, _FakeWs, _FakeWs]:
handler = DesktopHandler()
office = _FakeWs()
laptop = _FakeWs()
await handler.handle(
office, # type: ignore[arg-type]
_status("AXIOM-DESKTOP"),
session=_FakeSession("desktop-1", "AXIOM-DESKTOP"),
)
await handler.handle(
laptop, # type: ignore[arg-type]
_status("Axiom-Latitude"),
session=_FakeSession("desktop-2", "Axiom-Latitude"),
)
return handler, office, laptop
class DesktopMultiDeviceTests(unittest.IsolatedAsyncioTestCase):
async def test_tool_schema_exposes_script_and_device_selector(self) -> None:
parameters = desktop_tool._SCHEMAS["desktop_powershell"]["parameters"]
self.assertEqual(parameters["required"], ["script"])
self.assertIn("script", parameters["properties"])
self.assertIn("device", parameters["properties"])
self.assertEqual(
desktop_tool._SCHEMAS["desktop_powershell"]["x-hermes-capability"],
"system.execute",
)
async def test_adb_schemas_are_serial_bound_and_capability_labeled(self) -> None:
names = {
"desktop_adb_devices",
"desktop_adb_shell",
"desktop_adb_push",
"desktop_adb_pull",
"desktop_adb_install",
"desktop_adb_logcat",
}
for name in names:
schema = desktop_tool._SCHEMAS[name]
self.assertEqual(schema["x-hermes-capability"], "devices.usb")
self.assertIn("device", schema["parameters"]["properties"])
self.assertEqual(
desktop_tool._SCHEMAS["desktop_adb_shell"]["parameters"]["required"],
["serial", "command"],
)
async def test_raw_usb_schemas_are_host_gated_and_direct_spawn(self) -> None:
for name in {"desktop_usb_devices", "desktop_usb_run"}:
schema = desktop_tool._SCHEMAS[name]
self.assertEqual(schema["x-hermes-capability"], "devices.usb")
self.assertIn("device", schema["parameters"]["properties"])
run = desktop_tool._SCHEMAS["desktop_usb_run"]["parameters"]
self.assertEqual(run["required"], ["executable"])
self.assertEqual(run["properties"]["arguments"]["type"], "array")
response = Mock(status_code=200)
response.json.return_value = {"ok": True, "result": {"exit_code": 0}}
with patch.object(desktop_tool.requests, "post", return_value=response) as post:
desktop_tool.desktop_usb_run("fastboot", ["devices"], timeout=40)
self.assertEqual(post.call_args.kwargs["json"]["executable"], "fastboot")
self.assertEqual(post.call_args.kwargs["json"]["arguments"], ["devices"])
async def test_adb_http_timeout_covers_approval_and_operation(self) -> None:
response = Mock(status_code=200)
response.json.return_value = {"ok": True, "result": {"exit_code": 0}}
with patch.object(desktop_tool.requests, "post", return_value=response) as post:
desktop_tool.desktop_adb_install("serial-1", "app.apk", timeout=120)
self.assertGreaterEqual(post.call_args.kwargs["timeout"], 250)
async def test_tool_dispatch_forwards_device_as_relay_only_selector(self) -> None:
response = Mock(status_code=200)
response.json.return_value = {"ok": True, "result": {"stdout": "ok"}}
with patch.object(desktop_tool.requests, "post", return_value=response) as post:
desktop_tool._HANDLERS["desktop_powershell"](
{"script": "'ok'", "device": "desktop-1"}
)
self.assertEqual(post.call_args.kwargs["json"]["device"], "desktop-1")
self.assertEqual(post.call_args.kwargs["json"]["script"], "'ok'")
async def test_http_dispatch_strips_selector_before_client_forwarding(self) -> None:
desktop = Mock()
desktop.handle_command = AsyncMock(return_value={"ok": True, "result": {}})
request = Mock(
remote="127.0.0.1",
app={"server": Mock(desktop=desktop)},
match_info={"tool_name": "desktop_powershell"},
)
request.json = AsyncMock(
return_value={"script": "'ok'", "device": "desktop-1"}
)
response = await handle_desktop_dispatch(request)
self.assertEqual(response.status, 200)
desktop.handle_command.assert_awaited_once_with(
"desktop_powershell",
{"script": "'ok'"},
device="desktop-1",
)
async def test_untargeted_command_fails_closed_with_multiple_pcs(self) -> None:
handler, _office, _laptop = await _register_two()
with self.assertRaisesRegex(DesktopError, "pass device or device_id explicitly"):
await handler.handle_command("desktop_powershell", {"script": "pwd"})
async def test_name_target_routes_only_to_the_selected_pc(self) -> None:
handler, office, laptop = await _register_two()
task = asyncio.create_task(
handler.handle_command(
"desktop_powershell",
{"script": "pwd"},
device="axiom desktop",
)
)
await asyncio.sleep(0)
self.assertEqual(len(office.sent), 1)
self.assertEqual(laptop.sent, [])
request_id = office.sent[0]["payload"]["request_id"]
await handler.handle(
office, # type: ignore[arg-type]
{
"channel": "desktop",
"type": "desktop.response",
"payload": {
"request_id": request_id,
"ok": True,
"result": {"stdout": "office", "exit_code": 0},
},
},
)
result = await asyncio.wait_for(task, timeout=1)
self.assertEqual(result["result"]["stdout"], "office")
async def test_wrong_pc_cannot_satisfy_targeted_pending_request(self) -> None:
handler, office, laptop = await _register_two()
task = asyncio.create_task(
handler.handle_command(
"desktop_read_file",
{"path": "C:/marker.txt"},
device="desktop-2",
)
)
await asyncio.sleep(0)
request_id = laptop.sent[0]["payload"]["request_id"]
response = {
"channel": "desktop",
"type": "desktop.response",
"payload": {"request_id": request_id, "ok": True, "result": {"content": "x"}},
}
await handler.handle(office, response) # type: ignore[arg-type]
self.assertFalse(task.done())
await handler.handle(laptop, response) # type: ignore[arg-type]
result = await asyncio.wait_for(task, timeout=1)
self.assertEqual(result["result"]["content"], "x")
async def test_two_pcs_can_execute_concurrently_without_cross_talk(self) -> None:
handler, office, laptop = await _register_two()
office_task = asyncio.create_task(
handler.handle_command(
"desktop_powershell",
{"script": "'office'"},
device="desktop-1",
)
)
laptop_task = asyncio.create_task(
handler.handle_command(
"desktop_powershell",
{"script": "'laptop'"},
device="desktop-2",
)
)
await asyncio.sleep(0)
self.assertEqual(len(office.sent), 1)
self.assertEqual(len(laptop.sent), 1)
office_request_id = office.sent[0]["payload"]["request_id"]
laptop_request_id = laptop.sent[0]["payload"]["request_id"]
self.assertNotEqual(office_request_id, laptop_request_id)
# Complete them in reverse order to prove correlation is by request
# and target WebSocket, not by the latest status or response.
await handler.handle(
laptop, # type: ignore[arg-type]
{
"channel": "desktop",
"type": "desktop.response",
"payload": {
"request_id": laptop_request_id,
"ok": True,
"result": {"stdout": "laptop", "exit_code": 0},
},
},
)
self.assertFalse(office_task.done())
await handler.handle(
office, # type: ignore[arg-type]
{
"channel": "desktop",
"type": "desktop.response",
"payload": {
"request_id": office_request_id,
"ok": True,
"result": {"stdout": "office", "exit_code": 0},
},
},
)
office_result, laptop_result = await asyncio.gather(
office_task,
laptop_task,
)
self.assertEqual(office_result["result"]["stdout"], "office")
self.assertEqual(laptop_result["result"]["stdout"], "laptop")
self.assertEqual(office_result["target"]["device_id"], "desktop-1")
self.assertEqual(laptop_result["target"]["device_id"], "desktop-2")
async def test_health_snapshot_lists_connected_desktops(self) -> None:
handler, _office, _laptop = await _register_two()
clients = handler.status_snapshot()["clients"]
self.assertEqual({client["device_id"] for client in clients}, {"desktop-1", "desktop-2"})
+20
View File
@@ -462,5 +462,25 @@ class HomeChannelDefaultTests(_EnvIsolated):
self.assertNotIn("PHONE_HOME_CHANNEL", os.environ)
class ChannelDirectoryTests(_EnvIsolated):
"""The adapter enumerates its home through upstream's directory hook."""
def test_lists_default_phone_home(self) -> None:
adapter = pp.PhoneAdapter.__new__(pp.PhoneAdapter)
self.assertEqual(
_run(adapter.list_channels()),
[{"id": "phone", "name": "Phone", "type": "dm"}],
)
def test_lists_operator_configured_home(self) -> None:
os.environ["PHONE_HOME_CHANNEL"] = "family-phone"
os.environ["PHONE_HOME_CHANNEL_NAME"] = "Family phone"
adapter = pp.PhoneAdapter.__new__(pp.PhoneAdapter)
self.assertEqual(
_run(adapter.list_channels()),
[{"id": "family-phone", "name": "Family phone", "type": "dm"}],
)
if __name__ == "__main__":
unittest.main()
@@ -116,6 +116,7 @@ class OpenAIRealtimeAgentProviderTests(unittest.IsolatedAsyncioTestCase):
"api_key": "openai-test",
"safety_identifier": "phone-hash",
"transcription_language": "en",
"extra_headers": {"X-Voice-Gateway": "relay-test"},
},
)
)
@@ -127,6 +128,7 @@ class OpenAIRealtimeAgentProviderTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(captured["headers"]["Authorization"], "Bearer openai-test")
self.assertEqual(captured["headers"]["OpenAI-Safety-Identifier"], "phone-hash")
self.assertEqual(captured["headers"]["X-Voice-Gateway"], "relay-test")
session_update = fake_socket.sent[0]
self.assertEqual(session_update["type"], "session.update")
session = session_update["session"]
+42
View File
@@ -207,6 +207,48 @@ class SessionPersistenceRoundtripTests(unittest.TestCase):
)
)
def test_legacy_unknown_desktop_ids_do_not_replace_other_pcs(self) -> None:
mgr = SessionManager(persistence_path=self.path)
desktop_a = mgr.create_session(
"Office PC",
"unknown",
issue_refresh_token=True,
client_surface="desktop",
)
desktop_b = mgr.create_session(
"Laptop",
"unknown",
issue_refresh_token=True,
client_surface="desktop",
)
self.assertIsNotNone(mgr.get_session(desktop_a.token))
self.assertIsNotNone(mgr.get_session(desktop_b.token))
self.assertEqual(len(mgr.list_sessions()), 2)
def test_legacy_desktop_refresh_upgrades_to_stable_installation_id(self) -> None:
mgr = SessionManager(persistence_path=self.path)
legacy = mgr.create_session(
"Office PC",
"unknown",
issue_refresh_token=True,
client_surface="desktop",
)
assert legacy.refresh_token is not None
stable_id = "8e751a5a-b562-4c8b-8a81-88b1b5962fbb"
refreshed = mgr.refresh_session(
legacy.refresh_token,
device_name="Office PC",
device_id=stable_id,
client_surface="desktop",
)
self.assertIsNotNone(refreshed)
assert refreshed is not None
self.assertEqual(refreshed.device_id, stable_id)
self.assertTrue(mgr.has_trusted_device(stable_id))
def test_explicit_pair_keeps_other_devices(self) -> None:
mgr = SessionManager(persistence_path=self.path)
phone_a = mgr.create_session(
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from plugin.voice_lab.auth import (
VoiceLabAuthError,
_poll_xai_device_token,
_request_xai_device_code,
login_xai_oauth,
read_xai_oauth_token,
)
class VoiceLabDeviceCodeAuthTests(unittest.TestCase):
def test_device_code_request_requires_upstream_response_shape(self) -> None:
with patch(
"plugin.voice_lab.auth._post_form_response",
return_value=(200, {"device_code": "device-only"}),
), self.assertRaisesRegex(VoiceLabAuthError, "missing fields"):
_request_xai_device_code(scope="openid")
def test_poll_handles_pending_and_slow_down_before_success(self) -> None:
responses = [
(400, {"error": "authorization_pending"}),
(400, {"error": "slow_down"}),
(
200,
{
"access_token": "access-test",
"refresh_token": "refresh-test",
"expires_in": 3600,
"token_type": "Bearer",
},
),
]
with patch(
"plugin.voice_lab.auth._post_form_response",
side_effect=responses,
), patch("plugin.voice_lab.auth.time.sleep") as sleep:
token = _poll_xai_device_token(
token_endpoint="https://auth.x.ai/oauth2/token",
device_code="device-test",
expires_in=60,
poll_interval=2,
)
self.assertEqual(token["access_token"], "access-test")
self.assertEqual(sleep.call_args_list[0].args, (2,))
self.assertEqual(sleep.call_args_list[1].args, (3,))
def test_poll_reports_denied_authorization(self) -> None:
with patch(
"plugin.voice_lab.auth._post_form_response",
return_value=(400, {"error": "access_denied", "error_description": "Denied"}),
), self.assertRaisesRegex(VoiceLabAuthError, "Denied"):
_poll_xai_device_token(
token_endpoint="https://auth.x.ai/oauth2/token",
device_code="device-test",
expires_in=60,
poll_interval=1,
)
def test_poll_reports_expired_authorization(self) -> None:
with patch(
"plugin.voice_lab.auth._post_form_response",
return_value=(400, {"error": "expired_token"}),
), self.assertRaisesRegex(VoiceLabAuthError, "expired_token"):
_poll_xai_device_token(
token_endpoint="https://auth.x.ai/oauth2/token",
device_code="device-test",
expires_in=60,
poll_interval=1,
)
def test_poll_times_out_after_pending_authorization(self) -> None:
with patch(
"plugin.voice_lab.auth._post_form_response",
return_value=(400, {"error": "authorization_pending"}),
), patch(
"plugin.voice_lab.auth.time.monotonic",
side_effect=[0.0, 0.0, 2.0],
), patch(
"plugin.voice_lab.auth.time.sleep",
), self.assertRaisesRegex(VoiceLabAuthError, "Timed out"):
_poll_xai_device_token(
token_endpoint="https://auth.x.ai/oauth2/token",
device_code="device-test",
expires_in=1,
poll_interval=1,
)
def test_login_writes_device_code_store_compatible_with_existing_reader(self) -> None:
device = {
"device_code": "device-test",
"user_code": "ABCD-EFGH",
"verification_uri": "https://accounts.x.ai/device",
"verification_uri_complete": "https://accounts.x.ai/device?code=ABCD-EFGH",
"expires_in": 600,
"interval": 2,
}
tokens = {
"access_token": "access-test",
"refresh_token": "refresh-test",
"expires_in": 3600,
"expires_at_ms": 9999999999999,
"token_type": "Bearer",
}
with tempfile.TemporaryDirectory() as tmp:
auth_file = Path(tmp) / "xai-oauth.json"
with patch(
"plugin.voice_lab.auth._xai_oauth_discovery",
return_value={"token_endpoint": "https://auth.x.ai/oauth2/token"},
), patch(
"plugin.voice_lab.auth._request_xai_device_code",
return_value=device,
), patch(
"plugin.voice_lab.auth._poll_xai_device_token",
return_value=tokens,
) as poll, patch("builtins.print"), patch("webbrowser.open") as browser:
result = login_xai_oauth(
auth_file=auth_file,
no_browser=True,
timeout_seconds=180,
)
store = json.loads(auth_file.read_text(encoding="utf-8"))
resolved = read_xai_oauth_token(auth_file=auth_file, refresh=False)
self.assertEqual(store["auth_type"], "oauth_device_code")
self.assertEqual(resolved.access_token, "access-test")
self.assertEqual(result.token_type, "Bearer")
browser.assert_not_called()
self.assertEqual(poll.call_args.kwargs["device_code"], "device-test")
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import ssl
import tempfile
import types
import unittest
import urllib.request
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from plugin.relay.realtime_agent.providers.openai import _create_aiohttp_websocket
from plugin.voice_lab.providers.openai_realtime import _create_websocket
from plugin.voice_lab.providers.openai_tts import _urlopen_stream
from plugin.voice_lab.transport import (
header_lines,
merge_transport_headers,
resolve_voice_transport_options,
)
class VoiceTransportTests(unittest.TestCase):
def test_extra_headers_accept_json_and_override_protocol_defaults(self) -> None:
transport = resolve_voice_transport_options(
{"extra_headers": '{"Authorization":"Custom token","X-Gateway":"voice"}'},
base_url="wss://voice.example.test/realtime",
)
headers = merge_transport_headers(
{"Authorization": "Bearer default", "Accept": "application/json"},
transport,
)
self.assertEqual(headers["Authorization"], "Custom token")
self.assertEqual(headers["X-Gateway"], "voice")
self.assertIn("X-Gateway: voice", header_lines(headers))
def test_ssl_verify_false_maps_to_each_transport_without_env_ca(self) -> None:
with patch.dict("os.environ", {"HERMES_CA_BUNDLE": "ignored.pem"}, clear=True):
transport = resolve_voice_transport_options(
{"ssl_verify": "off"},
base_url="https://voice.example.test/v1",
)
self.assertFalse(transport.ssl_verify)
self.assertFalse(transport.aiohttp_ssl)
self.assertEqual(transport.websocket_sslopt["cert_reqs"], ssl.CERT_NONE)
self.assertEqual(transport.urllib_context.verify_mode, ssl.CERT_NONE)
def test_explicit_ca_precedes_environment_bundle(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
explicit = Path(tmp) / "explicit.pem"
environment = Path(tmp) / "environment.pem"
explicit.touch()
environment.touch()
context = ssl.create_default_context()
with patch.dict(
"os.environ",
{"HERMES_CA_BUNDLE": str(environment)},
clear=True,
), patch(
"plugin.voice_lab.transport.ssl.create_default_context",
return_value=context,
) as create_context:
transport = resolve_voice_transport_options(
{"ssl_ca_cert": str(explicit)},
base_url="https://voice.example.test/v1",
)
create_context.assert_called_once_with(cafile=str(explicit))
self.assertIs(transport.ssl_context, context)
def test_websocket_client_factory_forwards_ssl_options(self) -> None:
sslopt = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False}
create = MagicMock(return_value=object())
websocket = types.SimpleNamespace(create_connection=create)
with patch.dict("sys.modules", {"websocket": websocket}):
_create_websocket(
"wss://voice.example.test/realtime",
["X-Gateway: voice"],
5.0,
sslopt=sslopt,
)
create.assert_called_once_with(
"wss://voice.example.test/realtime",
header=["X-Gateway: voice"],
timeout=5.0,
sslopt=sslopt,
)
def test_urllib_factory_forwards_ssl_context(self) -> None:
context = ssl.create_default_context()
request = urllib.request.Request("https://voice.example.test/v1/audio/speech")
with patch("urllib.request.urlopen", return_value=object()) as urlopen:
_urlopen_stream(request, 5.0, context=context)
urlopen.assert_called_once_with(request, timeout=5.0, context=context)
class AiohttpVoiceTransportTests(unittest.IsolatedAsyncioTestCase):
async def test_aiohttp_factory_forwards_ssl_context(self) -> None:
context = ssl.create_default_context()
ws = AsyncMock()
session = MagicMock()
session.ws_connect = AsyncMock(return_value=ws)
session.close = AsyncMock()
with patch(
"plugin.relay.realtime_agent.providers.openai.aiohttp.ClientSession",
return_value=session,
):
socket = await _create_aiohttp_websocket(
"wss://voice.example.test/realtime",
{"X-Gateway": "voice"},
5.0,
ssl=context,
)
session.ws_connect.assert_awaited_once_with(
"wss://voice.example.test/realtime",
headers={"X-Gateway": "voice"},
heartbeat=20.0,
ssl=context,
)
await socket.close()
if __name__ == "__main__":
unittest.main()
+306 -10
View File
@@ -11,7 +11,7 @@ Tools registered (Phase B + remote-PC ergonomics, alpha.7):
Shell:
- desktop_terminal run a shell command (bash/cmd) — bounded 30s
- desktop_powershell run a PowerShell script via stdin (no quote-mangling)
- desktop_powershell run a private PowerShell script (no quote-mangling)
Process management:
- desktop_spawn_detached fire-and-forget process; returns pid + log path
@@ -35,6 +35,11 @@ Tools registered (Phase B + remote-PC ergonomics, alpha.7):
- desktop_health connected client name, uptime, advertised tools,
last error — answered by the relay (no client RTT)
USB:
- desktop_usb_devices enumerate host-visible USB devices
- desktop_usb_run direct-spawn a native/vendor USB utility
- desktop_adb_* serial-bound Android Debug Bridge conveniences
Experimental computer-use (observe-first):
- desktop_computer_status local display/grant/permission summary
- desktop_computer_screenshot screenshot wrapper with coordinate metadata
@@ -66,11 +71,15 @@ from __future__ import annotations
import json
import os
from contextvars import ContextVar
from typing import Any, Optional
import requests
_DESKTOP_TARGET: ContextVar[str | None] = ContextVar("desktop_target", default=None)
# ── Config ────────────────────────────────────────────────────────────────────
@@ -96,18 +105,22 @@ def _auth_headers() -> dict:
return {}
def _post(path: str, payload: dict) -> dict:
def _post(path: str, payload: dict, *, timeout: Optional[float] = None) -> dict:
"""POST ``payload`` to ``<relay>/desktop/<path>`` and return the JSON body.
Surface network / HTTP failures as structured ``{"error": ...}`` dicts
so the tool handlers can always return JSON to Hermes.
"""
payload = dict(payload)
target = _DESKTOP_TARGET.get()
if target:
payload["device"] = target
try:
r = requests.post(
f"{_relay_url()}{path}",
json=payload,
headers=_auth_headers(),
timeout=_timeout(),
timeout=_timeout() if timeout is None else timeout,
)
except requests.RequestException as exc:
return {"error": f"Relay unreachable: {exc}"}
@@ -253,6 +266,120 @@ def desktop_powershell(
return json.dumps(data)
# ── Host-gated raw USB + ADB services ───────────────────────────────────────
def desktop_usb_devices(reason: Optional[str] = None) -> str:
"""List USB devices visible to the targeted desktop host."""
payload: dict[str, Any] = {}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post("/desktop/desktop_usb_devices", payload, timeout=_adb_http_timeout())
)
def desktop_usb_run(
executable: str,
arguments: Optional[list[str]] = None,
cwd: Optional[str] = None,
timeout: int = 30,
reason: Optional[str] = None,
) -> str:
"""Direct-spawn a native or vendor USB utility without a shell."""
payload: dict[str, Any] = {
"executable": executable,
"arguments": list(arguments or []),
"timeout": int(timeout),
}
if cwd is not None:
payload["cwd"] = cwd
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post("/desktop/desktop_usb_run", payload, timeout=_adb_http_timeout(timeout))
)
def _adb_http_timeout(operation_timeout: int = 30) -> float:
"""Cover the local approval window plus the bounded ADB operation."""
return max(_timeout(), min(max(int(operation_timeout), 1), 120) + 130.0)
def desktop_adb_devices(reason: Optional[str] = None) -> str:
payload: dict[str, Any] = {}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post("/desktop/desktop_adb_devices", payload, timeout=_adb_http_timeout())
)
def desktop_adb_shell(serial: str, command: str, timeout: int = 30, reason: Optional[str] = None) -> str:
payload: dict[str, Any] = {"serial": serial, "command": command, "timeout": int(timeout)}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post(
"/desktop/desktop_adb_shell",
payload,
timeout=_adb_http_timeout(timeout),
)
)
def desktop_adb_push(serial: str, source: str, destination: str, timeout: int = 60, reason: Optional[str] = None) -> str:
payload: dict[str, Any] = {"serial": serial, "source": source, "destination": destination, "timeout": int(timeout)}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post(
"/desktop/desktop_adb_push",
payload,
timeout=_adb_http_timeout(timeout),
)
)
def desktop_adb_pull(serial: str, source: str, destination: str, timeout: int = 60, reason: Optional[str] = None) -> str:
payload: dict[str, Any] = {"serial": serial, "source": source, "destination": destination, "timeout": int(timeout)}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post(
"/desktop/desktop_adb_pull",
payload,
timeout=_adb_http_timeout(timeout),
)
)
def desktop_adb_install(serial: str, apk: str, replace: bool = True, timeout: int = 120, reason: Optional[str] = None) -> str:
payload: dict[str, Any] = {"serial": serial, "apk": apk, "replace": bool(replace), "timeout": int(timeout)}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post(
"/desktop/desktop_adb_install",
payload,
timeout=_adb_http_timeout(timeout),
)
)
def desktop_adb_logcat(serial: str, lines: int = 500, timeout: int = 30, reason: Optional[str] = None) -> str:
payload: dict[str, Any] = {"serial": serial, "lines": int(lines), "timeout": int(timeout)}
if reason is not None:
payload["reason"] = reason
return json.dumps(
_post(
"/desktop/desktop_adb_logcat",
payload,
timeout=_adb_http_timeout(timeout),
)
)
# ── Process management ────────────────────────────────────────────────────────
@@ -627,12 +754,13 @@ _SCHEMAS: dict[str, dict[str, Any]] = {
"desktop_powershell": {
"name": "desktop_powershell",
"description": (
"Run a PowerShell script on the desktop client, with the script text "
"fed to PowerShell via stdin (no cmd.exe quote-mangling). Use this "
"Run a PowerShell script on the targeted desktop client through a "
"private temporary UTF-8 file (no cmd.exe quote-mangling). Use this "
"instead of desktop_terminal('powershell -Command \"...\"') — the "
"latter loses quotes, here-strings, and dollar-vars to nested parsers. "
"Picks `pwsh` when present, falls back to Windows PowerShell on "
"Windows. Returns {stdout, stderr, exit_code, duration_ms, shell}."
"Windows. Returns stdout, stderr, exit_code, shell, and explicit "
"per-stream byte/truncation metadata. The required payload field is script."
),
"parameters": {
"type": "object",
@@ -650,6 +778,85 @@ _SCHEMAS: dict[str, dict[str, Any]] = {
"required": ["script"],
},
},
"desktop_usb_devices": {
"name": "desktop_usb_devices",
"description": (
"List USB devices visible to the targeted desktop. Governed by the "
"host's Raw USB Off/Ask/Allow policy."
),
"parameters": {"type": "object", "properties": {"reason": {"type": "string"}}},
},
"desktop_usb_run": {
"name": "desktop_usb_run",
"description": (
"Run a native or vendor USB utility by direct executable + argument "
"array, without shell parsing. This is host-wide Raw USB access and "
"is governed by the selected desktop's USB Off/Ask/Allow policy."
),
"parameters": {
"type": "object",
"properties": {
"executable": {"type": "string", "description": "Executable name or path."},
"arguments": {"type": "array", "items": {"type": "string"}, "maxItems": 128, "default": []},
"cwd": {"type": "string", "description": "Working directory for the utility."},
"timeout": {"type": "integer", "default": 30, "maximum": 120},
"reason": {"type": "string"},
},
"required": ["executable"],
},
},
"desktop_adb_devices": {
"name": "desktop_adb_devices",
"description": "List Android devices visible to the targeted desktop's brokered ADB backend.",
"parameters": {"type": "object", "properties": {"reason": {"type": "string"}}},
},
"desktop_adb_shell": {
"name": "desktop_adb_shell",
"description": "Run a bounded ADB shell command against one explicit device serial.",
"parameters": {"type": "object", "properties": {
"serial": {"type": "string"}, "command": {"type": "string"},
"timeout": {"type": "integer", "default": 30, "maximum": 120},
"reason": {"type": "string"},
}, "required": ["serial", "command"]},
},
"desktop_adb_push": {
"name": "desktop_adb_push",
"description": "Push one local file to one explicit Android device serial.",
"parameters": {"type": "object", "properties": {
"serial": {"type": "string"}, "source": {"type": "string"},
"destination": {"type": "string"}, "timeout": {"type": "integer", "default": 60, "maximum": 120},
"reason": {"type": "string"},
}, "required": ["serial", "source", "destination"]},
},
"desktop_adb_pull": {
"name": "desktop_adb_pull",
"description": "Pull one file from one explicit Android device serial.",
"parameters": {"type": "object", "properties": {
"serial": {"type": "string"}, "source": {"type": "string"},
"destination": {"type": "string"}, "timeout": {"type": "integer", "default": 60, "maximum": 120},
"reason": {"type": "string"},
}, "required": ["serial", "source", "destination"]},
},
"desktop_adb_install": {
"name": "desktop_adb_install",
"description": "Install one APK on one explicit Android device serial through the broker.",
"parameters": {"type": "object", "properties": {
"serial": {"type": "string"}, "apk": {"type": "string"},
"replace": {"type": "boolean", "default": True},
"timeout": {"type": "integer", "default": 120, "maximum": 120},
"reason": {"type": "string"},
}, "required": ["serial", "apk"]},
},
"desktop_adb_logcat": {
"name": "desktop_adb_logcat",
"description": "Return a bounded recent logcat snapshot from one explicit Android device serial.",
"parameters": {"type": "object", "properties": {
"serial": {"type": "string"},
"lines": {"type": "integer", "default": 500, "minimum": 1, "maximum": 5000},
"timeout": {"type": "integer", "default": 30, "maximum": 120},
"reason": {"type": "string"},
}, "required": ["serial"]},
},
# ── Process management ────────────────────────────────────────────────
"desktop_spawn_detached": {
"name": "desktop_spawn_detached",
@@ -882,12 +1089,15 @@ _SCHEMAS: dict[str, dict[str, Any]] = {
"desktop_health": {
"name": "desktop_health",
"description": (
"Report the connected desktop client's identity (host, platform, "
"version, pid), uptime, advertised tools, last error, and the most "
"List every connected desktop target's stable device_id, name, and "
"advertised tools, plus compatibility diagnostics for the most recent "
"client and the most "
"recent commands the relay has dispatched. Answered by the relay "
"directly — does NOT round-trip through the client — so it works "
"even when other tools are wedged. Use this to debug 'why isn't "
"desktop_terminal responding?' before giving up."
"even when other tools are wedged. When several desktops are listed, "
"choose one and pass its device_id in the device field of every "
"client-routed call. USB/ADB calls use device for the host PC and "
"serial for attached hardware where required."
),
"parameters": {"type": "object", "properties": {}},
},
@@ -1082,6 +1292,14 @@ _HANDLERS: dict[str, Any] = {
"desktop_patch": lambda args, **kw: desktop_patch(**args),
# Shell
"desktop_powershell": lambda args, **kw: desktop_powershell(**args),
"desktop_usb_devices": lambda args, **kw: desktop_usb_devices(**args),
"desktop_usb_run": lambda args, **kw: desktop_usb_run(**args),
"desktop_adb_devices": lambda args, **kw: desktop_adb_devices(**args),
"desktop_adb_shell": lambda args, **kw: desktop_adb_shell(**args),
"desktop_adb_push": lambda args, **kw: desktop_adb_push(**args),
"desktop_adb_pull": lambda args, **kw: desktop_adb_pull(**args),
"desktop_adb_install": lambda args, **kw: desktop_adb_install(**args),
"desktop_adb_logcat": lambda args, **kw: desktop_adb_logcat(**args),
# Process management
"desktop_spawn_detached": lambda args, **kw: desktop_spawn_detached(**args),
"desktop_list_processes": lambda args, **kw: desktop_list_processes(**args),
@@ -1109,6 +1327,84 @@ _HANDLERS: dict[str, Any] = {
}
def _targeted_handler(handler: Any) -> Any:
"""Consume the relay-only device selector before calling a tool function."""
def invoke(args: dict[str, Any], **kwargs: Any) -> Any:
call_args = dict(args)
target = call_args.pop("device", None) or call_args.pop("device_id", None)
token = _DESKTOP_TARGET.set(str(target).strip() if target else None)
try:
return handler(call_args, **kwargs)
finally:
_DESKTOP_TARGET.reset(token)
return invoke
for _tool_name, _handler in list(_HANDLERS.items()):
if _tool_name != "desktop_health":
_HANDLERS[_tool_name] = _targeted_handler(_handler)
_DEVICE_SELECTOR_SCHEMA = {
"type": "string",
"description": (
"Target desktop device_id or unambiguous device/host name. Required when "
"more than one desktop daemon is connected; use desktop_health to list clients."
),
}
_TOOL_CAPABILITIES = {
"desktop_read_file": "files.read",
"desktop_search_files": "files.read",
"desktop_checksum": "files.read",
"desktop_write_file": "files.write",
"desktop_patch": "files.write",
"desktop_copy_directory": "files.write",
"desktop_zip": "files.write",
"desktop_unzip": "files.write",
"desktop_terminal": "system.execute",
"desktop_powershell": "system.execute",
"desktop_usb_devices": "devices.usb",
"desktop_usb_run": "devices.usb",
"desktop_adb_devices": "devices.usb",
"desktop_adb_shell": "devices.usb",
"desktop_adb_push": "devices.usb",
"desktop_adb_pull": "devices.usb",
"desktop_adb_install": "devices.usb",
"desktop_adb_logcat": "devices.usb",
"desktop_spawn_detached": "system.execute",
"desktop_job_start": "system.execute",
"desktop_job_status": "process.manage",
"desktop_job_logs": "process.manage",
"desktop_job_cancel": "process.manage",
"desktop_job_list": "process.manage",
"desktop_list_processes": "process.manage",
"desktop_kill_process": "process.manage",
"desktop_find_pid_by_port": "process.manage",
"desktop_clipboard_read": "clipboard.read",
"desktop_clipboard_write": "clipboard.write",
"desktop_screenshot": "screen.observe",
"desktop_open_in_editor": "user_context.open",
"desktop_computer_status": "screen.observe",
"desktop_computer_screenshot": "screen.observe",
"desktop_computer_action": "input.control",
"desktop_computer_grant_request": "permissions.request",
"desktop_computer_cancel": "permissions.revoke",
}
for _tool_name, _schema in _SCHEMAS.items():
if _tool_name == "desktop_health":
continue
_parameters = _schema.get("parameters")
if isinstance(_parameters, dict):
_properties = _parameters.setdefault("properties", {})
if isinstance(_properties, dict):
_properties["device"] = dict(_DEVICE_SELECTOR_SCHEMA)
_capability = _TOOL_CAPABILITIES.get(_tool_name)
if _capability:
_schema["x-hermes-capability"] = _capability
_schema["x-hermes-raw-system-access"] = _capability == "system.execute"
# Tools whose check_fn should ping the relay rather than a specific client tool.
# desktop_health is callable even with no client connected — the whole point is
# to *report* whether one is connected — so it must not gate on `_check_tool`.
+105 -137
View File
@@ -2,20 +2,15 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread
from typing import Any
VOICE_LAB_HOME_ENV = "VOICE_LAB_HOME"
@@ -25,9 +20,7 @@ XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"
XAI_OAUTH_REDIRECT_HOST = "127.0.0.1"
XAI_OAUTH_REDIRECT_PORT = 56121
XAI_OAUTH_REDIRECT_PATH = "/callback"
XAI_OAUTH_DEVICE_CODE_URL = f"{XAI_OAUTH_ISSUER}/oauth2/device/code"
XAI_API_BASE_URL = "https://api.x.ai/v1"
TOKEN_REFRESH_SKEW_SECONDS = 120
@@ -134,50 +127,35 @@ def login_xai_oauth(
*,
auth_file: Path | None = None,
no_browser: bool = False,
timeout_seconds: float = 180.0,
timeout_seconds: float | None = None,
) -> XAIAuthResult:
path = auth_file or xai_oauth_path()
discovery = _xai_oauth_discovery()
authorization_endpoint = discovery["authorization_endpoint"]
token_endpoint = discovery["token_endpoint"]
redirect_uri = (
f"http://{XAI_OAUTH_REDIRECT_HOST}:{XAI_OAUTH_REDIRECT_PORT}"
f"{XAI_OAUTH_REDIRECT_PATH}"
device = _request_xai_device_code(
scope=os.getenv("VOICE_LAB_XAI_OAUTH_SCOPE", XAI_OAUTH_SCOPE),
)
code_verifier = _pkce_code_verifier()
code_challenge = _pkce_code_challenge(code_verifier)
state = secrets.token_urlsafe(24)
authorize_url = _xai_oauth_authorize_url(
authorization_endpoint=authorization_endpoint,
redirect_uri=redirect_uri,
code_challenge=code_challenge,
state=state,
verification_url = str(
device.get("verification_uri_complete") or device["verification_uri"]
)
user_code = str(device["user_code"])
print("Open this URL to authorize xAI for the standalone voice lab:")
print(verification_url)
print(f"If prompted, enter code: {user_code}")
if not no_browser:
try:
webbrowser.open(verification_url)
except Exception:
pass
server, thread, result = _start_callback_server(expected_state=state)
try:
print("Open this URL to authorize xAI for the standalone voice lab:")
print(authorize_url)
if not no_browser:
webbrowser.open(authorize_url)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline and "code" not in result and "error" not in result:
time.sleep(0.1)
if "error" in result:
raise VoiceLabAuthError(str(result["error"]))
code = str(result.get("code", "") or "").strip()
if not code:
raise VoiceLabAuthError("Timed out waiting for xAI OAuth browser callback")
finally:
server.shutdown()
thread.join(timeout=2)
tokens = _exchange_authorization_code(
expires_in = max(1, int(device["expires_in"]))
if timeout_seconds is not None:
expires_in = min(expires_in, max(1, int(timeout_seconds)))
tokens = _poll_xai_device_token(
token_endpoint=token_endpoint,
code=code,
code_verifier=code_verifier,
redirect_uri=redirect_uri,
device_code=str(device["device_code"]),
expires_in=expires_in,
poll_interval=max(1, int(device["interval"])),
)
tokens["base_url"] = XAI_API_BASE_URL
_write_token_store(path, tokens)
@@ -207,58 +185,77 @@ def _xai_oauth_discovery() -> dict[str, str]:
data = _get_json(XAI_OAUTH_DISCOVERY_URL)
except VoiceLabAuthError:
data = {
"authorization_endpoint": f"{XAI_OAUTH_ISSUER}/oauth2/authorize",
"token_endpoint": f"{XAI_OAUTH_ISSUER}/oauth2/token",
}
authorization_endpoint = str(data.get("authorization_endpoint", "") or "").strip()
token_endpoint = str(data.get("token_endpoint", "") or "").strip()
if not authorization_endpoint or not token_endpoint:
raise VoiceLabAuthError("xAI OAuth discovery did not include auth/token endpoints")
return {
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
}
if not token_endpoint:
raise VoiceLabAuthError("xAI OAuth discovery did not include a token endpoint")
return {"token_endpoint": token_endpoint}
def _xai_oauth_authorize_url(
*,
authorization_endpoint: str,
redirect_uri: str,
code_challenge: str,
state: str,
) -> str:
query = urllib.parse.urlencode(
{
"response_type": "code",
"client_id": _xai_oauth_client_id(),
"redirect_uri": redirect_uri,
"scope": os.getenv("VOICE_LAB_XAI_OAUTH_SCOPE", XAI_OAUTH_SCOPE),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
def _request_xai_device_code(*, scope: str) -> dict[str, Any]:
status, payload = _post_form_response(
XAI_OAUTH_DEVICE_CODE_URL,
{"client_id": _xai_oauth_client_id(), "scope": scope},
)
return f"{authorization_endpoint}?{query}"
if status != 200:
raise VoiceLabAuthError(
f"xAI device-code request failed (HTTP {status}): {_oauth_error(payload)}"
)
required = (
"device_code",
"user_code",
"verification_uri",
"verification_uri_complete",
"expires_in",
"interval",
)
missing = [name for name in required if name not in payload]
if missing:
raise VoiceLabAuthError(
f"xAI device-code response missing fields: {', '.join(missing)}"
)
return payload
def _exchange_authorization_code(
def _poll_xai_device_token(
*,
token_endpoint: str,
code: str,
code_verifier: str,
redirect_uri: str,
device_code: str,
expires_in: int,
poll_interval: int,
) -> dict[str, Any]:
payload = _post_form(
token_endpoint,
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": _xai_oauth_client_id(),
"code_verifier": code_verifier,
},
)
return _normalize_token_payload(payload)
deadline = time.monotonic() + max(1, expires_in)
interval = max(1, poll_interval)
while time.monotonic() < deadline:
status, payload = _post_form_response(
token_endpoint,
{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": _xai_oauth_client_id(),
"device_code": device_code,
},
)
if status == 200:
if not str(payload.get("access_token", "") or "").strip():
raise VoiceLabAuthError(
"xAI device-code token response did not include an access_token"
)
if not str(payload.get("refresh_token", "") or "").strip():
raise VoiceLabAuthError(
"xAI device-code token response did not include a refresh_token"
)
return _normalize_token_payload(payload)
error = str(payload.get("error", "") or "")
if error == "authorization_pending":
time.sleep(interval)
continue
if error == "slow_down":
interval = min(interval + 1, 30)
time.sleep(interval)
continue
raise VoiceLabAuthError(f"xAI device-code authorization failed: {_oauth_error(payload)}")
raise VoiceLabAuthError("Timed out waiting for xAI device authorization")
def _normalize_token_payload(payload: dict[str, Any]) -> dict[str, Any]:
@@ -269,55 +266,6 @@ def _normalize_token_payload(payload: dict[str, Any]) -> dict[str, Any]:
return data
def _start_callback_server(*, expected_state: str) -> tuple[HTTPServer, Thread, dict[str, Any]]:
result: dict[str, Any] = {}
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: Any) -> None:
return
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if parsed.path != XAI_OAUTH_REDIRECT_PATH:
self.send_response(404)
self.end_headers()
return
state = (params.get("state") or [""])[0]
if state != expected_state:
result["error"] = "xAI OAuth callback state did not match"
self._write_response("Authorization failed. You can close this tab.")
return
if "error" in params:
result["error"] = (params.get("error_description") or params["error"])[0]
self._write_response("Authorization failed. You can close this tab.")
return
result["code"] = (params.get("code") or [""])[0]
self._write_response("Authorization complete. You can close this tab.")
def _write_response(self, body: str) -> None:
encoded = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
server = HTTPServer((XAI_OAUTH_REDIRECT_HOST, XAI_OAUTH_REDIRECT_PORT), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread, result
def _pkce_code_verifier(length: int = 64) -> str:
return secrets.token_urlsafe(length)[:128]
def _pkce_code_challenge(code_verifier: str) -> str:
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _xai_oauth_client_id() -> str:
return os.getenv("VOICE_LAB_XAI_OAUTH_CLIENT_ID", XAI_OAUTH_CLIENT_ID).strip()
@@ -335,7 +283,7 @@ def _write_token_store(path: Path, tokens: dict[str, Any]) -> None:
store = {
"version": 1,
"provider": "xai",
"auth_type": "oauth_pkce",
"auth_type": "oauth_device_code",
"tokens": tokens,
}
tmp = path.with_suffix(path.suffix + ".tmp")
@@ -376,6 +324,13 @@ def _get_json(url: str) -> dict[str, Any]:
def _post_form(url: str, data: dict[str, str]) -> dict[str, Any]:
status, payload = _post_form_response(url, data)
if status < 200 or status >= 300:
raise VoiceLabAuthError(f"xAI OAuth token request failed: {_oauth_error(payload)}")
return payload
def _post_form_response(url: str, data: dict[str, str]) -> tuple[int, dict[str, Any]]:
encoded = urllib.parse.urlencode(data).encode("utf-8")
request = urllib.request.Request(
url,
@@ -388,15 +343,28 @@ def _post_form(url: str, data: dict[str, str]) -> dict[str, Any]:
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
status = int(getattr(response, "status", 200))
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise VoiceLabAuthError(f"xAI OAuth token request failed: {body}") from exc
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = {"error": body or f"HTTP {exc.code}"}
status = int(exc.code)
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
raise VoiceLabAuthError(f"xAI OAuth token request failed: {exc}") from exc
if not isinstance(payload, dict):
raise VoiceLabAuthError("xAI OAuth token request returned a non-object response")
return payload
return status, payload
def _oauth_error(payload: dict[str, Any]) -> str:
return str(
payload.get("error_description")
or payload.get("error")
or "unknown OAuth error"
)
def _strip_env_quotes(value: str) -> str:
+1 -2
View File
@@ -92,8 +92,7 @@ def build_parser() -> argparse.ArgumentParser:
auth.add_argument(
"--timeout",
type=float,
default=180.0,
help="Seconds to wait for the local browser callback",
help="Optional maximum seconds to wait for device-code approval",
)
auth.add_argument("--json", action="store_true", help="Print JSON")
auth.set_defaults(func=_cmd_auth)
+31 -8
View File
@@ -11,6 +11,11 @@ from typing import Any, Protocol
from ..auth import load_voice_lab_env_file
from ..metrics import MetricsRecorder
from ..transport import (
header_lines,
merge_transport_headers,
resolve_voice_transport_options,
)
from .base import (
ProviderInfo,
ProviderRunError,
@@ -86,7 +91,7 @@ class OpenAIRealtimeProvider(VoiceProvider):
)
def __init__(self, socket_factory: SocketFactory | None = None) -> None:
self._socket_factory = socket_factory or _create_websocket
self._socket_factory = socket_factory
def run_text(
self,
@@ -115,11 +120,12 @@ class OpenAIRealtimeProvider(VoiceProvider):
safety_identifier = _option(options, "safety_identifier")
request.output_path.parent.mkdir(parents=True, exist_ok=True)
headers = [
f"Authorization: Bearer {api_key}",
]
transport = resolve_voice_transport_options(options, base_url=url_base)
headers = {"Authorization": f"Bearer {api_key}"}
if safety_identifier:
headers.append(f"OpenAI-Safety-Identifier: {safety_identifier}")
headers["OpenAI-Safety-Identifier"] = safety_identifier
headers = merge_transport_headers(headers, transport)
header_values = header_lines(headers)
recorder.event(
"request_started",
@@ -135,7 +141,15 @@ class OpenAIRealtimeProvider(VoiceProvider):
audio_bytes = 0
events_seen: list[str] = []
try:
ws = self._socket_factory(url, headers, timeout)
if self._socket_factory is None:
ws = _create_websocket(
url,
header_values,
timeout,
sslopt=transport.websocket_sslopt,
)
else:
ws = self._socket_factory(url, header_values, timeout)
recorder.event("websocket_connected", provider=self.info.id, model=model)
_send_json(
ws,
@@ -236,7 +250,13 @@ class OpenAIRealtimeProvider(VoiceProvider):
)
def _create_websocket(url: str, headers: list[str], timeout: float) -> WebSocketLike:
def _create_websocket(
url: str,
headers: list[str],
timeout: float,
*,
sslopt: dict[str, Any] | None = None,
) -> WebSocketLike:
try:
import websocket # type: ignore
except ImportError as exc:
@@ -245,7 +265,10 @@ def _create_websocket(url: str, headers: list[str], timeout: float) -> WebSocket
"`pip install websocket-client`"
) from exc
return websocket.create_connection(url, header=headers, timeout=timeout)
kwargs: dict[str, Any] = {"header": headers, "timeout": timeout}
if sslopt is not None:
kwargs["sslopt"] = sslopt
return websocket.create_connection(url, **kwargs)
def _session_update(
+28 -8
View File
@@ -12,6 +12,7 @@ from typing import Any, Protocol
from ..auth import load_voice_lab_env_file
from ..metrics import MetricsRecorder
from ..transport import merge_transport_headers, resolve_voice_transport_options
from .base import (
ProviderInfo,
ProviderRunError,
@@ -77,7 +78,7 @@ class OpenAITTSProvider(VoiceProvider):
)
def __init__(self, stream_factory: HttpStreamFactory | None = None) -> None:
self._stream_factory = stream_factory or _urlopen_stream
self._stream_factory = stream_factory
def run_text(
self,
@@ -110,15 +111,20 @@ class OpenAITTSProvider(VoiceProvider):
body["instructions"] = instructions
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
http_request = urllib.request.Request(
endpoint,
data=payload,
method="POST",
headers={
transport = resolve_voice_transport_options(options, base_url=base_url)
headers = merge_transport_headers(
{
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/octet-stream",
},
transport,
)
http_request = urllib.request.Request(
endpoint,
data=payload,
method="POST",
headers=headers,
)
request.output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -134,7 +140,16 @@ class OpenAITTSProvider(VoiceProvider):
audio_bytes = 0
try:
with self._stream_factory(http_request, timeout) as response:
response_context = (
_urlopen_stream(
http_request,
timeout,
context=transport.urllib_context,
)
if self._stream_factory is None
else self._stream_factory(http_request, timeout)
)
with response_context as response:
with wave.open(str(request.output_path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
@@ -195,8 +210,13 @@ class OpenAITTSProvider(VoiceProvider):
def _urlopen_stream(
request: urllib.request.Request,
timeout: float,
*,
context: Any = None,
) -> HttpResponseLike:
return urllib.request.urlopen(request, timeout=timeout)
kwargs: dict[str, Any] = {"timeout": timeout}
if context is not None:
kwargs["context"] = context
return urllib.request.urlopen(request, **kwargs)
def _instructions_for_request(request: VoiceRequest) -> str | None:
+33 -5
View File
@@ -15,6 +15,11 @@ from ..auth import (
read_xai_oauth_token,
)
from ..metrics import MetricsRecorder
from ..transport import (
header_lines,
merge_transport_headers,
resolve_voice_transport_options,
)
from .base import (
ProviderInfo,
ProviderRunError,
@@ -87,7 +92,7 @@ class XAIRealtimeProvider(VoiceProvider):
)
def __init__(self, socket_factory: SocketFactory | None = None) -> None:
self._socket_factory = socket_factory or _create_websocket
self._socket_factory = socket_factory
def run_text(
self,
@@ -113,7 +118,13 @@ class XAIRealtimeProvider(VoiceProvider):
url = f"{url_base}?model={model}"
request.output_path.parent.mkdir(parents=True, exist_ok=True)
headers = [f"Authorization: Bearer {auth_token.value}"]
transport = resolve_voice_transport_options(options, base_url=url_base)
headers = header_lines(
merge_transport_headers(
{"Authorization": f"Bearer {auth_token.value}"},
transport,
)
)
recorder.event(
"request_started",
@@ -131,7 +142,15 @@ class XAIRealtimeProvider(VoiceProvider):
audio_bytes = 0
events_seen: list[str] = []
try:
ws = self._socket_factory(url, headers, timeout)
if self._socket_factory is None:
ws = _create_websocket(
url,
headers,
timeout,
sslopt=transport.websocket_sslopt,
)
else:
ws = self._socket_factory(url, headers, timeout)
recorder.event("websocket_connected", provider=self.info.id, model=model)
_send_json(ws, _session_update(request, voice=voice))
recorder.event("client_event_sent", client_event_type="session.update")
@@ -226,7 +245,13 @@ class XAIRealtimeProvider(VoiceProvider):
)
def _create_websocket(url: str, headers: list[str], timeout: float) -> WebSocketLike:
def _create_websocket(
url: str,
headers: list[str],
timeout: float,
*,
sslopt: dict[str, Any] | None = None,
) -> WebSocketLike:
try:
import websocket # type: ignore
except ImportError as exc:
@@ -235,7 +260,10 @@ def _create_websocket(url: str, headers: list[str], timeout: float) -> WebSocket
"`pip install websocket-client`"
) from exc
return websocket.create_connection(url, header=headers, timeout=timeout)
kwargs: dict[str, Any] = {"header": headers, "timeout": timeout}
if sslopt is not None:
kwargs["sslopt"] = sslopt
return websocket.create_connection(url, **kwargs)
def _session_update(request: VoiceRequest, *, voice: str) -> dict[str, Any]:
+28 -8
View File
@@ -13,6 +13,7 @@ from typing import Any, Protocol
from ..auth import load_voice_lab_env_file, read_xai_oauth_token
from ..metrics import MetricsRecorder
from ..transport import merge_transport_headers, resolve_voice_transport_options
from .base import (
ProviderInfo,
ProviderRunError,
@@ -64,7 +65,7 @@ class XAITTSProvider(VoiceProvider):
)
def __init__(self, stream_factory: HttpStreamFactory | None = None) -> None:
self._stream_factory = stream_factory or _urlopen_stream
self._stream_factory = stream_factory
def run_text(
self,
@@ -96,15 +97,20 @@ class XAITTSProvider(VoiceProvider):
options=options,
)
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
http_request = urllib.request.Request(
endpoint,
data=payload,
method="POST",
headers={
transport = resolve_voice_transport_options(options, base_url=base_url)
headers = merge_transport_headers(
{
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/octet-stream",
},
transport,
)
http_request = urllib.request.Request(
endpoint,
data=payload,
method="POST",
headers=headers,
)
request.output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -121,7 +127,16 @@ class XAITTSProvider(VoiceProvider):
audio_bytes = 0
try:
with self._stream_factory(http_request, timeout) as response:
response_context = (
_urlopen_stream(
http_request,
timeout,
context=transport.urllib_context,
)
if self._stream_factory is None
else self._stream_factory(http_request, timeout)
)
with response_context as response:
with wave.open(str(request.output_path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
@@ -183,8 +198,13 @@ class XAITTSProvider(VoiceProvider):
def _urlopen_stream(
request: urllib.request.Request,
timeout: float,
*,
context: Any = None,
) -> HttpResponseLike:
return urllib.request.urlopen(request, timeout=timeout)
kwargs: dict[str, Any] = {"timeout": timeout}
if context is not None:
kwargs["context"] = context
return urllib.request.urlopen(request, **kwargs)
def _tts_endpoint(base_url: str) -> str:

Some files were not shown because too many files have changed in this diff Show More