Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3aae829e1 | ||
|
|
5207ed4193 | ||
|
|
b46bb00ea8 | ||
|
|
b8dde409c5 | ||
|
|
ca7ded3939 | ||
|
|
db85a26c68 | ||
|
|
aa2595629d | ||
|
|
d79146dc90 | ||
|
|
eabc4dd328 | ||
|
|
e165bfeff3 | ||
|
|
40bcd796d1 | ||
|
|
57ae0c9456 | ||
|
|
9b31a16c89 | ||
|
|
f97bbdd395 | ||
|
|
722a294947 | ||
|
|
bbfb57b462 | ||
|
|
6db12a0bec | ||
|
|
f1de957848 | ||
|
|
cc01d9c8ad | ||
|
|
d574182d84 | ||
|
|
a328763da3 | ||
|
|
f53db68e7d | ||
|
|
eb9e570fc0 | ||
|
|
260f119637 | ||
|
|
d0fa2ea39d | ||
|
|
a1c74b1567 | ||
|
|
7c45acd38d |
@@ -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
@@ -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__
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+400
-2
@@ -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()
|
||||
|
||||
+23
-12
@@ -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.
|
||||
|
||||
+588
-13
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")))
|
||||
|
||||
+339
@@ -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(
|
||||
|
||||
+37
-1
@@ -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
|
||||
|
||||
+16
@@ -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 ---
|
||||
|
||||
+36
-1
@@ -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(
|
||||
|
||||
+74
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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'] }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"/)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)' }
|
||||
)
|
||||
})
|
||||
@@ -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"/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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
@@ -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 /
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user