Compare commits
52
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 | ||
|
|
4605b87c10 | ||
|
|
6a91d6ee7e | ||
|
|
95a2813efe | ||
|
|
2ecf521c8c | ||
|
|
7752c5c404 | ||
|
|
e1d3764cd2 | ||
|
|
e34171b5ad | ||
|
|
8040cac39a | ||
|
|
aab70520ca | ||
|
|
5a0cd8123c | ||
|
|
4370d9a925 | ||
|
|
726308d2ef | ||
|
|
1acc3a4c80 | ||
|
|
11ccbd6e5c | ||
|
|
d13af35357 | ||
|
|
e3752d43f3 | ||
|
|
97293b62c3 | ||
|
|
454e770648 | ||
|
|
e18572e8e3 | ||
|
|
83f69725b9 | ||
|
|
72aa7c3046 | ||
|
|
3995c64493 | ||
|
|
ce538ced3d | ||
|
|
352bc5b439 | ||
|
|
9ec163b27b |
@@ -231,6 +231,20 @@ jobs:
|
||||
return (@($Value -split ';' | Where-Object { $_ }) -join ';')
|
||||
}
|
||||
|
||||
function Get-RawUserPath {
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
|
||||
if ($null -eq $environmentKey) { return '' }
|
||||
try {
|
||||
return [string]$environmentKey.GetValue(
|
||||
'Path',
|
||||
'',
|
||||
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
|
||||
)
|
||||
} finally {
|
||||
$environmentKey.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$setup = (Resolve-Path 'dist/tray/hermes-relay-windows-x64-setup.exe').Path
|
||||
$smokeRoot = Join-Path $env:RUNNER_TEMP 'hermes-installer-lifecycle-smoke'
|
||||
$smokeProfile = Join-Path $smokeRoot 'profile'
|
||||
@@ -244,7 +258,12 @@ jobs:
|
||||
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Hermes-Relay CLI'
|
||||
$oldUserProfile = $env:USERPROFILE
|
||||
$oldHomeEnv = $env:HOME
|
||||
$userPathBefore = Normalize-UserPath ([Environment]::GetEnvironmentVariable('Path', 'User'))
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
$hadUserPath = $environmentKey.GetValueNames() -contains 'Path'
|
||||
$originalUserPath = Get-RawUserPath
|
||||
$originalUserPathKind = if ($hadUserPath) { $environmentKey.GetValueKind('Path') } else { $null }
|
||||
$userPathBefore = 'C:\Windows\System32'
|
||||
$environmentKey.Dispose()
|
||||
$startupBefore = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
|
||||
|
||||
if (Test-Path $uninstallKey) { throw 'installer smoke requires a clean HermesRelay uninstall registry key' }
|
||||
@@ -256,8 +275,12 @@ jobs:
|
||||
$env:USERPROFILE = $smokeProfile
|
||||
$env:HOME = $smokeProfile
|
||||
try {
|
||||
& $setup /S "/D=$installDir"
|
||||
if ($LASTEXITCODE -ne 0) { throw "installer exited with code $LASTEXITCODE" }
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
$environmentKey.SetValue('Path', $userPathBefore, [Microsoft.Win32.RegistryValueKind]::String)
|
||||
$environmentKey.Dispose()
|
||||
|
||||
$installProcess = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru
|
||||
if ($installProcess.ExitCode -ne 0) { throw "installer exited with code $($installProcess.ExitCode)" }
|
||||
|
||||
$expectedFiles = @(
|
||||
'hermes-relay.exe',
|
||||
@@ -287,8 +310,8 @@ jobs:
|
||||
throw 'installer removed profile session data'
|
||||
}
|
||||
|
||||
& $uninstaller /S
|
||||
if ($LASTEXITCODE -ne 0) { throw "uninstaller exited with code $LASTEXITCODE" }
|
||||
$uninstallProcess = Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait -PassThru
|
||||
if ($uninstallProcess.ExitCode -ne 0) { throw "uninstaller exited with code $($uninstallProcess.ExitCode)" }
|
||||
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
while ((Test-Path -LiteralPath $uninstaller) -and [DateTime]::UtcNow -lt $deadline) {
|
||||
@@ -308,7 +331,9 @@ jobs:
|
||||
throw 'installer lifecycle modified preserved profile session data'
|
||||
}
|
||||
|
||||
$userPathAfter = Normalize-UserPath ([Environment]::GetEnvironmentVariable('Path', 'User'))
|
||||
# Compare the raw registry value so expandable entries such as
|
||||
# %USERPROFILE% are not resolved against the isolated smoke profile.
|
||||
$userPathAfter = Normalize-UserPath (Get-RawUserPath)
|
||||
if ($userPathAfter -ne $userPathBefore) {
|
||||
throw "uninstaller did not restore user PATH (before='$userPathBefore', after='$userPathAfter')"
|
||||
}
|
||||
@@ -322,10 +347,17 @@ jobs:
|
||||
Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue |
|
||||
Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $uninstaller) {
|
||||
& $uninstaller /S | Out-Null
|
||||
Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait | Out-Null
|
||||
}
|
||||
$env:USERPROFILE = $oldUserProfile
|
||||
$env:HOME = $oldHomeEnv
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
if ($hadUserPath) {
|
||||
$environmentKey.SetValue('Path', $originalUserPath, $originalUserPathKind)
|
||||
} else {
|
||||
$environmentKey.DeleteValue('Path', $false)
|
||||
}
|
||||
$environmentKey.Dispose()
|
||||
if (Test-Path -LiteralPath $smokeRoot) {
|
||||
Remove-Item -LiteralPath $smokeRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -6,6 +6,77 @@ 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
|
||||
|
||||
- **Relay diagnostics distinguish a prior clean stop from a crash.** Doctor and `/relay/info` expose only bounded clean, unclean, or unknown gateway-exit state with an optional suspected out-of-memory hint, without returning raw log evidence.
|
||||
- **Relay reconnects spread out after shared gateway restarts.** Ordinary exponential reconnect delays use full jitter while explicit reconnects and server-directed retry timing retain their exact behavior.
|
||||
|
||||
## [0.4.0-alpha.7] - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Installer lifecycle validation uses an isolated Windows PATH fixture.** Release smoke tests now verify add/remove cleanup against a fixed registry value and restore the runner's original value afterward, independently of the temporary profile used for session-preservation checks.
|
||||
|
||||
## [0.4.0-alpha.6] - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Installer cleanup validation compares the unexpanded Windows PATH.** Release smoke tests now read the raw user registry value, ensuring `%USERPROFILE%` entries are verified without temporary-profile expansion changing their apparent value.
|
||||
|
||||
## [0.4.0-alpha.5] - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Installer cleanup validation handles expandable Windows PATH entries.** Release smoke tests restore the original profile environment before comparing user PATH, avoiding false failures when unchanged `%USERPROFILE%` entries are expanded inside an isolated test profile.
|
||||
|
||||
## [0.4.0-alpha.4] - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows release validation waits for installer processes.** The packaged install/uninstall lifecycle smoke now captures GUI-subsystem process exit codes reliably before validating installed files, preserved sessions, registry state, and cleanup.
|
||||
|
||||
## [0.4.0-alpha.3] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
+11
-6
@@ -1,8 +1,8 @@
|
||||
# Hermes-Relay-Server v__VERSION__
|
||||
|
||||
**Release Date:** August 11, 2026
|
||||
**Release Date:** August 12, 2026
|
||||
|
||||
This patch makes paired sessions easier to identify and their expiry easier to understand across Relay clients and the Dashboard.
|
||||
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,10 +10,14 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Recognizable device names.** Relay uses the client hostname as the primary paired-session name when available while preserving compatibility with existing clients.
|
||||
- **Persisted device details.** Model and platform metadata survive refresh, reconnect, and Relay restart and appear in the authorized-session API and Dashboard detail view.
|
||||
- **Reconnect enrichment without re-pairing.** A valid paired client can fill missing identity metadata during reconnect without changing its authentication state.
|
||||
- **Human-readable expiry.** Long paired-session lifetimes use days, weeks, or a calendar date, with the exact local deadline retained for inspection.
|
||||
- **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
|
||||
|
||||
@@ -28,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)
|
||||
|
||||
@@ -366,6 +366,8 @@ data class ToolCall(
|
||||
* header can render without a separate lane registry.
|
||||
*/
|
||||
val taskLabel: String? = null,
|
||||
/** Live upstream child id used by subagent.steer while this lane runs. */
|
||||
val subagentId: String? = null,
|
||||
/** Deterministic non-low output risk reported by upstream for this call. */
|
||||
val outputRisk: String? = null,
|
||||
/** Human-readable deterministic findings; rendered as untrusted metadata. */
|
||||
@@ -419,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.
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.shared.EndpointSurface
|
||||
import com.hermesandroid.relay.network.shared.fullJitterDelayMs
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -139,6 +140,8 @@ class ConnectionManager(
|
||||
* non-null — the manager falls back to the single-URL path.
|
||||
*/
|
||||
private val deviceIdProvider: (suspend () -> String?)? = null,
|
||||
/** Random source for ordinary reconnect full-jitter; exact backoffs never use it. */
|
||||
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
|
||||
) {
|
||||
private val supervisorJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
|
||||
@@ -1213,8 +1216,9 @@ class ConnectionManager(
|
||||
SLOW_POLL_BACKOFF_MS
|
||||
}
|
||||
else -> {
|
||||
val ms = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
val capMs = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
val ms = fullJitterDelayMs(capMs, reconnectJitterUnit())
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/** Full-jitter retry delay in the inclusive range 0..[capMs]. */
|
||||
internal fun fullJitterDelayMs(
|
||||
capMs: Long,
|
||||
unit: Double = Random.nextDouble(),
|
||||
): Long {
|
||||
if (capMs <= 0L) return 0L
|
||||
val boundedUnit = unit.coerceIn(0.0, Math.nextDown(1.0))
|
||||
return (boundedUnit * (capMs + 1.0)).toLong().coerceAtMost(capMs)
|
||||
}
|
||||
@@ -90,12 +90,9 @@ class ChatHandler {
|
||||
// Fallback form (no relay): `MEDIA:/absolute/path` — relay wasn't
|
||||
// reachable when the tool fired, so we render an "unavailable"
|
||||
// placeholder instead of attempting a fetch.
|
||||
private val mediaRelayRegex = Regex("""MEDIA:hermes-relay://([A-Za-z0-9_-]+)""")
|
||||
// `/.+?` (not `/\S+`) so absolute paths containing spaces — e.g.
|
||||
// `MEDIA:/mnt/media/Coralee Adshade/undressher.jpg` — still match. The
|
||||
// trailing `\s*$` trims any trailing whitespace; non-greedy keeps the
|
||||
// capture to the path. OkHttp re-encodes the space for /media/by-path.
|
||||
private val mediaBarePathRegex = Regex("""^\s*MEDIA:(/.+?)\s*$""")
|
||||
private val mediaRelayPayloadRegex = Regex("""^hermes-relay://([A-Za-z0-9_-]+)$""")
|
||||
private val mediaMarkerPrefixRegex = Regex("MEDIA:")
|
||||
private val windowsAbsoluteMediaPathRegex = Regex("""^[A-Za-z]:[\\/].+""")
|
||||
// Rich card marker — single line, full JSON object payload.
|
||||
//
|
||||
// Agents emit:
|
||||
@@ -188,6 +185,7 @@ class ChatHandler {
|
||||
* post-stream finalize reconciliation pass.
|
||||
*/
|
||||
private var mediaLineBuffer = StringBuilder()
|
||||
private var mediaFenceDelimiter: String? = null
|
||||
private val dispatchedMediaMarkers = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
@@ -1176,12 +1174,14 @@ class ChatHandler {
|
||||
// Drop any pending line buffers / dedupe state so a fresh session
|
||||
// doesn't inherit leftovers from the previous one.
|
||||
mediaLineBuffer.clear()
|
||||
mediaFenceDelimiter = null
|
||||
dispatchedMediaMarkers.clear()
|
||||
annotationLineBuffer.clear()
|
||||
activeAnnotationTools.clear()
|
||||
cardLineBuffer.clear()
|
||||
dispatchedCardMarkers.clear()
|
||||
subagentLabels.clear()
|
||||
subagentIds.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1771,7 +1771,7 @@ class ChatHandler {
|
||||
for (line in text.lines()) {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||
if (parseMediaMarkerLine(t).isNotEmpty()) continue
|
||||
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
@@ -1788,6 +1788,51 @@ class ChatHandler {
|
||||
data class BarePath(val path: String) : MediaMarkerHit
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one marker-only line using upstream-compatible wrappers and
|
||||
* boundaries. Prose and malformed examples remain ordinary text; a whole
|
||||
* inline-code or emphasis wrapper is accepted, as are adjacent markers,
|
||||
* sentence-final punctuation, POSIX paths, and Windows absolute paths.
|
||||
*/
|
||||
private fun parseMediaMarkerLine(line: String): List<MediaMarkerHit> {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
||||
return emptyList()
|
||||
}
|
||||
val starts = mediaMarkerPrefixRegex.findAll(trimmed).map { it.range.first }.toList()
|
||||
if (starts.isEmpty()) return emptyList()
|
||||
val prefix = trimmed.substring(0, starts.first())
|
||||
if (prefix.any { !it.isWhitespace() && it !in "`*_~" }) return emptyList()
|
||||
|
||||
val hits = ArrayList<MediaMarkerHit>(starts.size)
|
||||
for ((index, start) in starts.withIndex()) {
|
||||
val payloadStart = start + "MEDIA:".length
|
||||
val payloadEnd = starts.getOrNull(index + 1) ?: trimmed.length
|
||||
val payload = trimmed.substring(payloadStart, payloadEnd)
|
||||
.trim()
|
||||
.trimEnd { it in "`*_~.,;:)}]" }
|
||||
.trim()
|
||||
if (payload.isEmpty()) return emptyList()
|
||||
val relay = mediaRelayPayloadRegex.matchEntire(payload)
|
||||
when {
|
||||
relay != null -> hits += MediaMarkerHit.RelayToken(relay.groupValues[1])
|
||||
payload.startsWith("/") || windowsAbsoluteMediaPathRegex.matches(payload) ->
|
||||
hits += MediaMarkerHit.BarePath(payload)
|
||||
else -> return emptyList()
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
private fun fenceDelimiter(line: String): String? {
|
||||
val trimmed = line.trimStart()
|
||||
return when {
|
||||
trimmed.startsWith("```") -> "```"
|
||||
trimmed.startsWith("~~~") -> "~~~"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan loaded (non-streaming) message content line-by-line for media
|
||||
* markers, append hits to [out], and return the content with matched
|
||||
@@ -1800,24 +1845,20 @@ class ChatHandler {
|
||||
out: MutableList<Pair<String, MediaMarkerHit>>,
|
||||
): String {
|
||||
var cleaned = content
|
||||
var openFence: String? = null
|
||||
for (rawLine in content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
|
||||
val relayMatch = mediaRelayRegex.find(trimmed)
|
||||
if (relayMatch != null) {
|
||||
out.add(messageId to MediaMarkerHit.RelayToken(relayMatch.groupValues[1]))
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
val delimiter = fenceDelimiter(rawLine)
|
||||
if (delimiter != null) {
|
||||
openFence = if (openFence == delimiter) null else if (openFence == null) delimiter else openFence
|
||||
continue
|
||||
}
|
||||
if (openFence != null) continue
|
||||
|
||||
val bareMatch = mediaBarePathRegex.find(trimmed)
|
||||
if (bareMatch != null) {
|
||||
out.add(messageId to MediaMarkerHit.BarePath(bareMatch.groupValues[1]))
|
||||
val hits = parseMediaMarkerLine(trimmed)
|
||||
if (hits.isNotEmpty()) {
|
||||
hits.forEach { out.add(messageId to it) }
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
@@ -1995,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
|
||||
@@ -2265,6 +2313,18 @@ class ChatHandler {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
|
||||
fenceDelimiter(line)?.let { delimiter ->
|
||||
mediaFenceDelimiter = if (mediaFenceDelimiter == delimiter) {
|
||||
null
|
||||
} else if (mediaFenceDelimiter == null) {
|
||||
delimiter
|
||||
} else {
|
||||
mediaFenceDelimiter
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (mediaFenceDelimiter != null) continue
|
||||
|
||||
if (tryDispatchMediaMarker(messageId, trimmed)) {
|
||||
stripLineFromContent(messageId, trimmed)
|
||||
}
|
||||
@@ -2394,29 +2454,26 @@ class ChatHandler {
|
||||
* Returns true when a marker was matched so the caller can strip the line.
|
||||
*/
|
||||
private fun tryDispatchMediaMarker(messageId: String, line: String): Boolean {
|
||||
val relayMatch = mediaRelayRegex.find(line)
|
||||
if (relayMatch != null) {
|
||||
val token = relayMatch.groupValues[1]
|
||||
val dedupeKey = "$messageId:relay:$token"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (relay): token=$token")
|
||||
onMediaAttachmentRequested(messageId, token)
|
||||
val hits = parseMediaMarkerLine(line)
|
||||
for (hit in hits) {
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (relay): token=${hit.token}")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
is MediaMarkerHit.BarePath -> {
|
||||
val dedupeKey = "$messageId:bare:${hit.path}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (bare-path): ${hit.path}")
|
||||
onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
val bareMatch = mediaBarePathRegex.find(line)
|
||||
if (bareMatch != null) {
|
||||
val path = bareMatch.groupValues[1]
|
||||
val dedupeKey = "$messageId:bare:$path"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (bare-path, unavailable): $path")
|
||||
onMediaBarePathRequested(messageId, path)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return hits.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2555,10 +2612,11 @@ class ChatHandler {
|
||||
if (mediaLineBuffer.isNotEmpty()) {
|
||||
val remaining = mediaLineBuffer.toString().trim()
|
||||
mediaLineBuffer.clear()
|
||||
if (remaining.isNotEmpty() && tryDispatchMediaMarker(messageId, remaining)) {
|
||||
if (mediaFenceDelimiter == null && remaining.isNotEmpty() && tryDispatchMediaMarker(messageId, remaining)) {
|
||||
stripLineFromContent(messageId, remaining)
|
||||
}
|
||||
}
|
||||
mediaFenceDelimiter = null
|
||||
|
||||
// Post-stream reconciliation: re-scan the final content for markers
|
||||
// that raced with stripLineFromContent during streaming.
|
||||
@@ -2567,12 +2625,16 @@ class ChatHandler {
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
var openFence: String? = null
|
||||
for (rawLine in msg.content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(trimmed) ||
|
||||
mediaBarePathRegex.containsMatchIn(trimmed)
|
||||
) {
|
||||
val delimiter = fenceDelimiter(rawLine)
|
||||
if (delimiter != null) {
|
||||
openFence = if (openFence == delimiter) null else if (openFence == null) delimiter else openFence
|
||||
continue
|
||||
}
|
||||
if (openFence == null && parseMediaMarkerLine(trimmed).isNotEmpty()) {
|
||||
tryDispatchMediaMarker(messageId, trimmed)
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
@@ -2903,6 +2965,7 @@ class ChatHandler {
|
||||
* [onStreamComplete] / [clearMessages].
|
||||
*/
|
||||
private val subagentLabels = mutableMapOf<Int, String>()
|
||||
private val subagentIds = mutableMapOf<Int, String>()
|
||||
|
||||
/**
|
||||
* Apply one gateway `subagent.*` lifecycle event to the streaming
|
||||
@@ -2918,6 +2981,9 @@ class ChatHandler {
|
||||
when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.START -> {
|
||||
if (label != null) subagentLabels[event.taskIndex] = label
|
||||
event.subagentId?.takeIf(String::isNotBlank)?.let {
|
||||
subagentIds[event.taskIndex] = it
|
||||
}
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.TOOL -> {
|
||||
@@ -2932,6 +2998,8 @@ class ChatHandler {
|
||||
isComplete = false,
|
||||
taskIndex = event.taskIndex,
|
||||
taskLabel = laneLabel,
|
||||
subagentId = event.subagentId?.takeIf(String::isNotBlank)
|
||||
?: subagentIds[event.taskIndex],
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
@@ -2971,6 +3039,7 @@ class ChatHandler {
|
||||
// "interrupted" lanes never finished — not a success either.
|
||||
val failed = event.status == "failed" || event.status == "interrupted"
|
||||
val laneLabel = subagentLabels.remove(event.taskIndex) ?: label
|
||||
val subagentId = subagentIds.remove(event.taskIndex) ?: event.subagentId
|
||||
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
@@ -3003,6 +3072,7 @@ class ChatHandler {
|
||||
completedAt = System.currentTimeMillis(),
|
||||
taskIndex = event.taskIndex,
|
||||
taskLabel = laneLabel,
|
||||
subagentId = subagentId,
|
||||
)
|
||||
}
|
||||
msg.copy(toolCalls = withSummary)
|
||||
@@ -3260,6 +3330,7 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
subagentLabels.clear()
|
||||
subagentIds.clear()
|
||||
}
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
@@ -3289,6 +3360,7 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
subagentLabels.clear()
|
||||
subagentIds.clear()
|
||||
}
|
||||
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
|
||||
+440
-6
@@ -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
|
||||
@@ -500,6 +579,7 @@ class DashboardApiClient(
|
||||
name: String,
|
||||
cloneFromDefault: Boolean = true,
|
||||
description: String? = null,
|
||||
mcpServers: List<String> = emptyList(),
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles",
|
||||
@@ -507,9 +587,167 @@ class DashboardApiClient(
|
||||
put("name", name)
|
||||
put("clone_from_default", cloneFromDefault)
|
||||
if (!description.isNullOrBlank()) put("description", description)
|
||||
if (mcpServers.isNotEmpty()) {
|
||||
put("mcp_servers", JsonArray(mcpServers.map(::JsonPrimitive)))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Create a host-owned Hermes backup, distinct from Android settings export. */
|
||||
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",
|
||||
@@ -605,15 +843,16 @@ class DashboardApiClient(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
|
||||
getJsonObject("/api/providers/custom-endpoints")
|
||||
suspend fun getCustomEndpoints(profile: String? = null): Result<DashboardCustomEndpoints> =
|
||||
getJsonObject("/api/providers/custom-endpoints${profileQuery(profile)}")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun saveCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
profile: String? = null,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints",
|
||||
"/api/providers/custom-endpoints${profileQuery(profile)}",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching(::parseCustomEndpoints)
|
||||
|
||||
@@ -634,13 +873,19 @@ class DashboardApiClient(
|
||||
|
||||
suspend fun activateCustomEndpoint(
|
||||
id: String,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints/${pathSegment(id)}/activate${profileQuery(profile)}",
|
||||
)
|
||||
|
||||
suspend fun deleteCustomEndpoint(
|
||||
id: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
|
||||
deleteJsonObject(
|
||||
"/api/providers/custom-endpoints/${pathSegment(id)}${profileQuery(profile)}",
|
||||
)
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
@@ -749,9 +994,157 @@ 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bounded, authoritative session window across every profile.
|
||||
* Every usable row must retain its owning profile; rows without one are
|
||||
* skipped rather than risking a cross-profile transcript or mutation.
|
||||
*/
|
||||
suspend fun listAllProfileSessions(
|
||||
limit: Int = SESSION_LIST_WINDOW_LIMIT,
|
||||
): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
val boundedLimit = limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)
|
||||
val result = getJson(
|
||||
"/api/profiles/sessions?limit=$boundedLimit&offset=0&order=recent" +
|
||||
"&min_messages=1&archived=include&profile=all",
|
||||
).mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
(parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList())
|
||||
.filter { it.id.isNotBlank() && !it.profile.isNullOrBlank() }
|
||||
.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]:
|
||||
@@ -1074,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")
|
||||
@@ -1133,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()
|
||||
|
||||
@@ -18,10 +18,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import com.hermesandroid.relay.network.shared.fullJitterDelayMs
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -86,6 +88,8 @@ class GatewayChatClient(
|
||||
private val promptSubmitTimeoutMs: Long = PROMPT_SUBMIT_REQUEST_TIMEOUT_MS,
|
||||
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
|
||||
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
|
||||
/** Random source for ordinary reconnect full-jitter. */
|
||||
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
|
||||
) {
|
||||
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
|
||||
private val sessionSource = "webui"
|
||||
@@ -1486,6 +1490,43 @@ class GatewayChatClient(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* React to the newest message for a role without guessing a transcript row
|
||||
* id. This follows the upstream gateway contract, which resolves the row
|
||||
* atomically inside the active session. A null emoji removes the reaction.
|
||||
*/
|
||||
suspend fun reactToNewest(role: String, emoji: String?): Result<JsonObject> {
|
||||
require(role == "user" || role == "assistant") { "unsupported reaction role" }
|
||||
val sid = liveSessionId
|
||||
?: return Result.failure(GatewayRpcException("no live session"))
|
||||
return rpc(
|
||||
"message.react",
|
||||
buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("newest_role", role)
|
||||
if (emoji == null) put("emoji", JsonNull) else put("emoji", emoji)
|
||||
put("author", "user")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Redirect one running child agent without interrupting the parent turn. */
|
||||
suspend fun steerSubagent(subagentId: String, text: String): Result<JsonObject> {
|
||||
val sessionId = liveSessionId
|
||||
?: return Result.failure(IllegalStateException("No live gateway session"))
|
||||
if (subagentId.isBlank() || text.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("Subagent and instruction are required"))
|
||||
}
|
||||
return rpc(
|
||||
"subagent.steer",
|
||||
buildJsonObject {
|
||||
put("session_id", sessionId)
|
||||
put("subagent_id", subagentId)
|
||||
put("text", text.trim())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Fetch the session/global reasoning effort and display mode. */
|
||||
suspend fun getReasoningSettings(): Result<GatewayReasoningSettings> {
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
@@ -2094,6 +2135,42 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// Upstream emits session.reclaimed process-wide, so it is identified
|
||||
// by payload rather than params.session_id. Retire only an exact live
|
||||
// runtime we own; preserve the durable id so the next send resumes it.
|
||||
if (type == "session.reclaimed") {
|
||||
val reclaimedLiveId = payload?.stringField("session_id")
|
||||
val reclaimedStoredId = payload?.stringField("stored_session_id")
|
||||
val reason = payload?.stringField("reason")
|
||||
val supportedReason = reason in setOf("idle_timeout", "lru_evict", "ws_orphan_reap")
|
||||
if (!reclaimedLiveId.isNullOrBlank() && supportedReason) {
|
||||
val background = backgroundTurns.remove(reclaimedLiveId)
|
||||
if (background != null) {
|
||||
callbackDispatcher {
|
||||
unmatchedTurnCompleteListener?.invoke(
|
||||
GatewayBackgroundTurnCompletion(
|
||||
storedSessionId = reclaimedStoredId?.takeIf(String::isNotBlank)
|
||||
?: background.storedSessionId,
|
||||
liveSessionId = reclaimedLiveId,
|
||||
profile = background.profile,
|
||||
expectedAssistantText = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (reclaimedLiveId == liveSessionId) {
|
||||
liveSessionId = null
|
||||
reclaimedStoredId?.takeIf(String::isNotBlank)?.let { storedSessionId = it }
|
||||
val turn = activeTurn
|
||||
if (turn != null && !turn.ended) {
|
||||
activeTurn = null
|
||||
turn.failFromTransport("Gateway reclaimed the inactive session")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// read_terminal is a renderer query, not a user decision. Android has
|
||||
// no xterm pane on the Gateway chat surface, so mirror upstream
|
||||
// desktop's no-live-pane behavior and answer with empty text instead
|
||||
@@ -2412,7 +2489,7 @@ class GatewayChatClient(
|
||||
Log.i(TAG, "Gateway socket rejoined for ${backgroundTurns.size} detached turn(s)")
|
||||
return
|
||||
}
|
||||
delay(backoffMs)
|
||||
delay(fullJitterDelayMs(backoffMs, reconnectJitterUnit()))
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(5_000L)
|
||||
}
|
||||
}
|
||||
@@ -2505,7 +2582,7 @@ class GatewayChatClient(
|
||||
)
|
||||
return
|
||||
}
|
||||
delay(backoffMs)
|
||||
delay(fullJitterDelayMs(backoffMs, reconnectJitterUnit()))
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(5_000L)
|
||||
}
|
||||
if (activeTurn === turn) activeTurn = null
|
||||
|
||||
+29
-13
@@ -207,7 +207,11 @@ class GatewayEventMapper(
|
||||
}
|
||||
else -> syntheticToolId(name)
|
||||
}
|
||||
val argsPreview = payload.string("args_text")
|
||||
val argsPreview = payload?.get("args")
|
||||
?.takeUnless { it is JsonPrimitive && it.contentOrNull.isNullOrBlank() }
|
||||
?.toString()
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
?: payload.string("args_text")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: payload.string("context")?.takeIf { it.isNotBlank() }
|
||||
callbacks.onToolCallStart(toolId, name, argsPreview)
|
||||
@@ -294,6 +298,7 @@ class GatewayEventMapper(
|
||||
// text; thinking/progress carry text only.
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
subagentId = payload.string("subagent_id"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -439,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,
|
||||
@@ -556,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,
|
||||
) {
|
||||
@@ -270,6 +272,7 @@ data class GatewaySubagentEvent(
|
||||
val toolName: String? = null,
|
||||
val preview: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
val subagentId: String? = null,
|
||||
) {
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ data class SessionItem(
|
||||
val preview: String? = null,
|
||||
val model: String? = null,
|
||||
val source: String? = null,
|
||||
/** Owning profile on the cross-profile `/api/profiles/sessions` endpoint. */
|
||||
val profile: String? = null,
|
||||
@SerialName("started_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val startedAt: Double? = null,
|
||||
@@ -168,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,
|
||||
|
||||
@@ -64,6 +64,10 @@ data class SlashCommand(
|
||||
* instead of plain text.
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Bounded server-recorded skill usage; zero for non-skill commands. */
|
||||
val usageRank: Int = 0,
|
||||
/** Sanitized server skill origin such as local or bundled. */
|
||||
val origin: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val SOURCE_SERVER = "server"
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -113,6 +114,10 @@ fun MessageBubble(
|
||||
onQuoteMessage: ((ChatMessage) -> Unit)? = null,
|
||||
/** Navigate a rendered quote chip to its original message id. */
|
||||
onNavigateToMessage: ((String) -> Unit)? = null,
|
||||
/** Open an upstream @session:<profile>/<id> reference in app. */
|
||||
onSessionReference: ((SessionReference) -> Unit)? = null,
|
||||
/** React to the newest message for this role; null removes the reaction. */
|
||||
onReact: ((String?) -> Unit)? = null,
|
||||
/**
|
||||
* Reads a completed assistant response through the active voice renderer.
|
||||
* Null hides the entry; the owning screen uses that to limit the action to
|
||||
@@ -170,6 +175,7 @@ fun MessageBubble(
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
val sessionReferences = remember(message.content) { parseSessionReferences(message.content) }
|
||||
|
||||
// Phone/voice-origin action bubble marker.
|
||||
//
|
||||
@@ -449,7 +455,7 @@ fun MessageBubble(
|
||||
val showEditAction = onEditMessage != null && isUser
|
||||
val showSpeakAction = shouldShowSpeakResponseAction(message, onSpeakMessage != null)
|
||||
val showStopSpeakingAction = shouldShowStopSpeakingAction(message, onStopSpeaking != null)
|
||||
if (onQuoteMessage != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
|
||||
if (onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
@@ -639,6 +645,35 @@ fun MessageBubble(
|
||||
SelectionContainer { messageTextContent() }
|
||||
}
|
||||
}
|
||||
if (onReact != null) {
|
||||
listOf("👍", "❤️", "😂").forEach { emoji ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("React $emoji") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onReact(emoji)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove reaction") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onReact(null)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (onSessionReference != null && sessionReferences.isNotEmpty()) {
|
||||
sessionReferences.forEach { reference ->
|
||||
TextButton(
|
||||
onClick = { onSessionReference(reference) },
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
) {
|
||||
Text("Open ${reference.label}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline generated images (assistant only) — rendered OUTSIDE
|
||||
// the SelectionContainer (they're not selectable text). Remote
|
||||
|
||||
@@ -99,6 +99,30 @@ internal enum class SessionDrawerFilter {
|
||||
internal const val SESSION_DRAWER_LIST_TAG = "session-drawer-list"
|
||||
internal const val UNPINNED_STAR_ALPHA = 0.45f
|
||||
|
||||
data class ProfileSessionRow(
|
||||
val profile: String,
|
||||
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
|
||||
|
||||
@@ -149,6 +173,10 @@ fun SessionDrawerContent(
|
||||
hiddenSources: Set<String> = emptySet(),
|
||||
/** Toggle a source's visibility (persisted). Null hides the source filter. */
|
||||
onToggleSourceHidden: ((String, Boolean) -> Unit)? = null,
|
||||
allProfileSessions: List<ProfileSessionRow> = emptyList(),
|
||||
allProfileSessionsLoading: Boolean = false,
|
||||
onRefreshAllProfiles: (() -> Unit)? = null,
|
||||
onSelectProfileSession: ((String, String) -> Unit)? = null,
|
||||
) {
|
||||
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var newThreadDialog by remember { mutableStateOf(false) }
|
||||
@@ -157,6 +185,7 @@ fun SessionDrawerContent(
|
||||
var query by remember { mutableStateOf("") }
|
||||
var searchExpanded by remember { mutableStateOf(false) }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
var allProfilesOpen by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
val trimmedQuery = query.trim()
|
||||
// Threads affordance shows when the capability is active OR there's already at least one
|
||||
@@ -196,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 }
|
||||
@@ -355,6 +385,16 @@ fun SessionDrawerContent(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (onRefreshAllProfiles != null && onSelectProfileSession != null) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
allProfilesOpen = true
|
||||
onRefreshAllProfiles()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.drawer_all_profiles))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -620,6 +660,82 @@ fun SessionDrawerContent(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (allProfilesOpen) {
|
||||
var allQuery by remember { mutableStateOf("") }
|
||||
val needle = allQuery.trim()
|
||||
val rows = allProfileSessions.filter { row ->
|
||||
needle.isBlank() ||
|
||||
row.profile.contains(needle, ignoreCase = true) ||
|
||||
row.session.title.orEmpty().contains(needle, ignoreCase = true) ||
|
||||
row.session.sessionId.contains(needle, ignoreCase = true) ||
|
||||
sessionWorkLabels(row.session).any { it.contains(needle, ignoreCase = true) }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { allProfilesOpen = false },
|
||||
title = { Text(stringResource(R.string.drawer_all_profiles)) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = allQuery,
|
||||
onValueChange = { allQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
when {
|
||||
allProfileSessionsLoading && allProfileSessions.isEmpty() ->
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
rows.isEmpty() -> Text(
|
||||
stringResource(R.string.drawer_no_profile_sessions),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(modifier = Modifier.height(420.dp)) {
|
||||
items(rows, key = { "${it.profile}:${it.session.sessionId}" }) { row ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
allProfilesOpen = false
|
||||
onSelectProfileSession?.invoke(row.profile, row.session.sessionId)
|
||||
}
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
row.session.title?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.drawer_untitled),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
row.profile,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { allProfilesOpen = false }) {
|
||||
Text(stringResource(R.string.drawer_close))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -685,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,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
data class SessionReference(val profile: String, val sessionId: String) {
|
||||
val label: String
|
||||
get() = "$profile · ${sessionId.takeLast(10)}"
|
||||
}
|
||||
|
||||
private val SESSION_REFERENCE = Regex("@session:([A-Za-z0-9_.-]{1,64})/([A-Za-z0-9_.:-]{1,160})")
|
||||
|
||||
/** Find session references outside fenced and inline code. */
|
||||
internal fun parseSessionReferences(markdown: String): List<SessionReference> {
|
||||
val visible = buildString(markdown.length) {
|
||||
var fenced = false
|
||||
markdown.lineSequence().forEach { line ->
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
fenced = !fenced
|
||||
appendLine()
|
||||
} else if (fenced) {
|
||||
appendLine()
|
||||
} else {
|
||||
var inline = false
|
||||
line.forEach { char ->
|
||||
if (char == '`') inline = !inline
|
||||
append(if (inline || char == '`') ' ' else char)
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
return SESSION_REFERENCE.findAll(visible)
|
||||
.map {
|
||||
SessionReference(
|
||||
it.groupValues[1],
|
||||
it.groupValues[2].trimEnd('.', ',', ';', '!', '?', ')', ']', '}'),
|
||||
)
|
||||
}
|
||||
.filter { it.sessionId.isNotBlank() }
|
||||
.distinct()
|
||||
.take(16)
|
||||
.toList()
|
||||
}
|
||||
@@ -25,8 +25,11 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -73,11 +76,13 @@ fun SubagentLane(
|
||||
taskIndex: Int,
|
||||
calls: List<ToolCall>,
|
||||
modifier: Modifier = Modifier,
|
||||
onSteer: ((subagentId: String, instruction: String) -> Unit)? = null,
|
||||
) {
|
||||
val anyRunning = calls.any { !it.isComplete }
|
||||
val allComplete = calls.isNotEmpty() && calls.all { it.isComplete }
|
||||
val anyFailed = calls.any { it.isComplete && it.success == false }
|
||||
val runningCount = calls.count { !it.isComplete }
|
||||
val steerableId = calls.firstNotNullOfOrNull { it.subagentId?.takeIf(String::isNotBlank) }
|
||||
|
||||
val laneLabel = calls.firstNotNullOfOrNull { call ->
|
||||
call.taskLabel?.takeIf { it.isNotBlank() }
|
||||
@@ -106,6 +111,8 @@ fun SubagentLane(
|
||||
val failureLabel = stringResource(R.string.cd_subagent_failed)
|
||||
|
||||
var expanded by remember { mutableStateOf(!allComplete) }
|
||||
var showSteerDialog by remember { mutableStateOf(false) }
|
||||
var steerText by remember { mutableStateOf("") }
|
||||
|
||||
// Auto-collapse when the last child completes — same pattern as
|
||||
// ToolProgressCard's LaunchedEffect(toolCall.isComplete).
|
||||
@@ -162,6 +169,12 @@ fun SubagentLane(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
if (anyRunning && steerableId != null && onSteer != null) {
|
||||
TextButton(onClick = { showSteerDialog = true }) {
|
||||
Text("Redirect")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = statusMeta,
|
||||
style = relayMetadataStyle(),
|
||||
@@ -202,4 +215,33 @@ fun SubagentLane(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSteerDialog && steerableId != null && onSteer != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSteerDialog = false },
|
||||
title = { Text("Redirect subagent") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = steerText,
|
||||
onValueChange = { steerText = it },
|
||||
label = { Text("New instruction") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val instruction = steerText.trim()
|
||||
showSteerDialog = false
|
||||
steerText = ""
|
||||
onSteer(steerableId, instruction)
|
||||
},
|
||||
enabled = steerText.isNotBlank(),
|
||||
) { Text("Redirect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showSteerDialog = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ import com.hermesandroid.relay.ui.components.ThinkingIndicatorStyle
|
||||
import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
|
||||
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
|
||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||
import com.hermesandroid.relay.ui.components.ProfileSessionRow
|
||||
import com.hermesandroid.relay.ui.components.ProfileDisplayManagerDialog
|
||||
import com.hermesandroid.relay.ui.components.ProfileShelf
|
||||
import com.hermesandroid.relay.ui.components.ProfileSwitcherSheet
|
||||
@@ -743,6 +744,13 @@ fun ChatScreen(
|
||||
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
|
||||
val newestReactableMessageKeys = remember(messages) {
|
||||
setOfNotNull(
|
||||
messages.lastOrNull { it.role == MessageRole.USER }?.uiKey,
|
||||
messages.lastOrNull { it.role == MessageRole.ASSISTANT }?.uiKey,
|
||||
)
|
||||
}
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
// Keep the screen on for the two "actively engaged, hands-off-keyboard"
|
||||
// cases: voice mode is a call-like continuous session (mirrors Assistant/
|
||||
@@ -1128,6 +1136,8 @@ fun ChatScreen(
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
|
||||
var allProfileSessionsLoading by remember { mutableStateOf(false) }
|
||||
PetInteractionLayer(
|
||||
owner = "chat-interaction-layer",
|
||||
active = shouldHideChatPet(
|
||||
@@ -2106,6 +2116,64 @@ fun ChatScreen(
|
||||
onToggleSourceHidden = { source, hidden ->
|
||||
connectionViewModel.setSourceHidden(source, hidden)
|
||||
},
|
||||
allProfileSessions = allProfileSessions,
|
||||
allProfileSessionsLoading = allProfileSessionsLoading,
|
||||
onRefreshAllProfiles = {
|
||||
if (!allProfileSessionsLoading) scope.launch {
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
}
|
||||
},
|
||||
onSelectProfileSession = { profileName, sessionId ->
|
||||
val target = agentProfiles.firstOrNull {
|
||||
it.name.equals(profileName, ignoreCase = true)
|
||||
}
|
||||
if (target != null || profileName.equals("default", ignoreCase = true)) {
|
||||
connectionViewModel.selectProfile(target)
|
||||
chatViewModel.activateGatewayProfile(target)
|
||||
chatViewModel.switchSession(sessionId)
|
||||
scope.launch { drawerState.close() }
|
||||
} else {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("Profile $profileName is not available.")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
@@ -3023,6 +3091,33 @@ fun ChatScreen(
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onSessionReference = { reference ->
|
||||
val target = agentProfiles.firstOrNull {
|
||||
it.name.equals(reference.profile, ignoreCase = true)
|
||||
}
|
||||
if (target != null) {
|
||||
connectionViewModel.selectProfile(target)
|
||||
chatViewModel.activateGatewayProfile(target)
|
||||
chatViewModel.switchSession(reference.sessionId)
|
||||
} else {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = "Profile ${reference.profile} is not available.",
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onReact = if (
|
||||
isGatewayTransport &&
|
||||
messageReactionsSupported &&
|
||||
!message.isStreaming &&
|
||||
message.uiKey in newestReactableMessageKeys
|
||||
) {
|
||||
{ emoji -> chatViewModel.reactToNewest(message.role, emoji) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
@@ -3156,6 +3251,7 @@ fun ChatScreen(
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = laneGroups.getValue(taskIndex),
|
||||
onSteer = chatViewModel::steerSubagent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+708
-19
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,7 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import okhttp3.sse.EventSource
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -1229,10 +1230,16 @@ class ChatViewModel : ViewModel() {
|
||||
// Pill update is the confirmation — no system bubble.
|
||||
// Surface only a server warning, ephemerally.
|
||||
warning?.let { _transientNotice.tryEmit(it) }
|
||||
gateway.modelOptions().onSuccess {
|
||||
_modelProviders.value = it.providers
|
||||
_gatewayCurrentModel.value = it.currentModel
|
||||
_gatewayCurrentProvider.value = it.currentProvider
|
||||
// Current upstream may defer the switch until the turn
|
||||
// boundary. Keep the optimistic requested identity; an
|
||||
// immediate model.options read still reports the old
|
||||
// model and would visibly snap the picker backward.
|
||||
if (!isDeferredConfigResult(result)) {
|
||||
gateway.modelOptions().onSuccess {
|
||||
_modelProviders.value = it.providers
|
||||
_gatewayCurrentModel.value = it.currentModel
|
||||
_gatewayCurrentProvider.value = it.currentProvider
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -1616,6 +1623,9 @@ class ChatViewModel : ViewModel() {
|
||||
val backgroundProcessesLoading: StateFlow<Boolean> = gatewayProcessController.loading
|
||||
val stoppingProcessIds: StateFlow<Set<String>> = gatewayProcessController.stoppingProcessIds
|
||||
|
||||
private val _messageReactionsSupported = MutableStateFlow(true)
|
||||
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
|
||||
|
||||
fun refreshBackgroundProcesses() {
|
||||
gatewayProcessController.refresh()
|
||||
}
|
||||
@@ -1642,6 +1652,7 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayClient = client
|
||||
if (changed) {
|
||||
resetApprovalModeState()
|
||||
_messageReactionsSupported.value = true
|
||||
gatewayProcessSource = client?.let(::GatewayChatProcessSource)
|
||||
gatewayProcessController.bind(
|
||||
newSource = gatewayProcessSource,
|
||||
@@ -2311,6 +2322,45 @@ class ChatViewModel : ViewModel() {
|
||||
private val _transientNotice = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
val transientNotice: SharedFlow<String> = _transientNotice.asSharedFlow()
|
||||
|
||||
fun reactToNewest(role: MessageRole, emoji: String?) {
|
||||
val gateway = gatewayClient ?: return
|
||||
if (role != MessageRole.USER && role != MessageRole.ASSISTANT) return
|
||||
viewModelScope.launch {
|
||||
gateway.reactToNewest(role.name.lowercase(), emoji).fold(
|
||||
onSuccess = {
|
||||
_transientNotice.tryEmit(if (emoji == null) "Reaction removed." else "Reaction added.")
|
||||
},
|
||||
onFailure = { error ->
|
||||
if ((error as? GatewayRpcException)?.code == -32601) {
|
||||
_messageReactionsSupported.value = false
|
||||
_transientNotice.tryEmit("Message reactions aren't supported by this gateway.")
|
||||
} else {
|
||||
_transientNotice.tryEmit("Couldn't update reaction: ${error.message ?: "unknown error"}")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun steerSubagent(subagentId: String, instruction: String) {
|
||||
val gateway = gatewayClient ?: return
|
||||
if (subagentId.isBlank() || instruction.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
gateway.steerSubagent(subagentId, instruction).fold(
|
||||
onSuccess = { result ->
|
||||
val status = result.stringValue("status") ?: "queued"
|
||||
_transientNotice.tryEmit(
|
||||
if (status == "rejected") "Subagent redirect was rejected."
|
||||
else "Subagent redirect queued.",
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_transientNotice.tryEmit("Couldn't redirect subagent: ${error.message ?: "unknown error"}")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer prefill requests from server command dispatch
|
||||
* (`{type:"prefill"}` — e.g. `/undo`). ChatScreen collects and sets the
|
||||
@@ -4185,6 +4235,7 @@ class ChatViewModel : ViewModel() {
|
||||
HermesCardInput.Kinds.CHOICE
|
||||
},
|
||||
choices = ask.choices.orEmpty(),
|
||||
multiSelect = ask.multiSelect,
|
||||
allowFreeText = true,
|
||||
expiresAtMillis = expiresAt,
|
||||
),
|
||||
@@ -4991,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,
|
||||
@@ -5175,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,
|
||||
@@ -8626,6 +8679,19 @@ internal fun parseCommandsCatalog(catalog: JsonObject): List<SlashCommand> {
|
||||
}
|
||||
val seen = mutableSetOf<String>()
|
||||
val out = mutableListOf<SlashCommand>()
|
||||
val skillMetadata = (catalog["skills"] as? JsonObject)
|
||||
?.entries
|
||||
?.take(MAX_CATALOG_SKILLS)
|
||||
?.associate { (rawName, element) ->
|
||||
val normalized = if (rawName.startsWith("/")) rawName.lowercase() else "/${rawName.lowercase()}"
|
||||
val metadata = element as? JsonObject
|
||||
val usage = (metadata?.get("usage") as? JsonPrimitive)?.intOrNull
|
||||
?.coerceIn(0, MAX_SKILL_USAGE) ?: 0
|
||||
val origin = metadata?.stringValue("origin")
|
||||
?.trim()?.take(MAX_SKILL_ORIGIN_CHARS)?.takeIf { it.isNotBlank() }
|
||||
normalized to (usage to origin)
|
||||
}
|
||||
.orEmpty()
|
||||
(catalog["pairs"] as? JsonArray)?.forEach { pairElement ->
|
||||
val pair = pairElement as? JsonArray ?: return@forEach
|
||||
val rawName = (pair.getOrNull(0) as? JsonPrimitive)?.contentOrNull?.trim()
|
||||
@@ -8634,16 +8700,35 @@ internal fun parseCommandsCatalog(catalog: JsonObject): List<SlashCommand> {
|
||||
if (isUnsupportedMobileCommand(name, pair)) return@forEach
|
||||
if (!seen.add(name.lowercase())) return@forEach
|
||||
val description = (pair.getOrNull(1) as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
|
||||
val skill = skillMetadata[name.lowercase()]
|
||||
out += SlashCommand(
|
||||
command = name,
|
||||
description = description.ifBlank { "Server command" },
|
||||
category = categoryByName[name.lowercase()] ?: SlashCommand.CATEGORY_SERVER,
|
||||
source = SlashCommand.SOURCE_SERVER,
|
||||
usageRank = skill?.first ?: 0,
|
||||
origin = skill?.second,
|
||||
)
|
||||
}
|
||||
return out
|
||||
// Preserve every non-skill command's exact catalog position. Rank only
|
||||
// entries occupying skill slots, so built-ins and quick commands do not
|
||||
// jump around when usage changes.
|
||||
val rankedSkills = out.filter { skillMetadata.containsKey(it.command.lowercase()) }
|
||||
.sortedWith(compareByDescending<SlashCommand> { it.usageRank }.thenBy { it.command })
|
||||
.iterator()
|
||||
return out.map { command ->
|
||||
if (skillMetadata.containsKey(command.command.lowercase())) rankedSkills.next() else command
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_CATALOG_SKILLS = 512
|
||||
private const val MAX_SKILL_USAGE = 1_000_000_000
|
||||
private const val MAX_SKILL_ORIGIN_CHARS = 32
|
||||
|
||||
internal fun isDeferredConfigResult(result: JsonObject): Boolean =
|
||||
(result["deferred"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.lowercase() in setOf("true", "1", "yes")
|
||||
|
||||
/** Dashboard `/api/config` response -> the same personality catalog as API mode. */
|
||||
internal fun parseDashboardPersonalityConfig(
|
||||
root: JsonObject,
|
||||
|
||||
@@ -1493,6 +1493,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
suspend fun listProfileScopedSessions(limit: Int = 200): Result<List<SessionItem>>? =
|
||||
profileController.listProfileScopedSessions(limit)
|
||||
|
||||
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? =
|
||||
profileController.listAllProfileSessions(limit)
|
||||
|
||||
suspend fun loadProfileScopedMessages(
|
||||
sessionId: String,
|
||||
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
|
||||
|
||||
@@ -344,6 +344,12 @@ class ProfileController(
|
||||
.listSessions(profile = profileName, limit = limit, archived = "include")
|
||||
}
|
||||
|
||||
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? {
|
||||
val connectionId = activeConnectionId.value ?: return null
|
||||
val dashboardUrl = activeDashboardUrlProvider() ?: return null
|
||||
return dashboardClientFactory(connectionId, dashboardUrl).listAllProfileSessions(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's transcript, scoped to the active profile via the dashboard
|
||||
* `/api/sessions/{id}/messages?profile=`. Returns `null` off the dashboard
|
||||
|
||||
@@ -3825,4 +3825,64 @@
|
||||
<string name="pet_creator_step_review">Revisar e importar</string>
|
||||
<string name="pet_creator_review_desc">Inspecione primeiro a imagem ou o ZIP retornado. A importação copia os arquivos validados para o armazenamento do app; ela não os publica.</string>
|
||||
<string name="pet_creator_import">Importar pet concluído</string>
|
||||
<string name="dashboard_tab_memory">Memória</string>
|
||||
<string name="dashboard_tab_learning">Aprendizado</string>
|
||||
<string name="dashboard_tab_channels">Canais</string>
|
||||
<string name="dashboard_tab_operations">Operações</string>
|
||||
<string name="dashboard_tab_memory_lower">memória</string>
|
||||
<string name="dashboard_tab_learning_lower">aprendizado</string>
|
||||
<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>
|
||||
<string name="dashboard_tile_memory_sub">Status do provedor e configuração de memória do servidor</string>
|
||||
<string name="dashboard_tile_learning_title">Grafo de aprendizado</string>
|
||||
<string name="dashboard_tile_learning_sub">Inspecione os nós aprendidos do perfil ativo</string>
|
||||
<string name="dashboard_tile_channels_title">Canais</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -3913,4 +3913,64 @@
|
||||
<string name="pet_creator_step_review">检查并导入</string>
|
||||
<string name="pet_creator_review_desc">请先检查返回的图片或 ZIP。导入会将经过验证的文件复制到应用专属存储空间,不会发布它们。</string>
|
||||
<string name="pet_creator_import">导入完成的宠物</string>
|
||||
<string name="dashboard_tab_memory">记忆</string>
|
||||
<string name="dashboard_tab_learning">学习</string>
|
||||
<string name="dashboard_tab_channels">频道</string>
|
||||
<string name="dashboard_tab_operations">运维</string>
|
||||
<string name="dashboard_tab_memory_lower">记忆</string>
|
||||
<string name="dashboard_tab_learning_lower">学习</string>
|
||||
<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_profile_mcp_servers_optional">MCP 服务器(可选)</string>
|
||||
<string name="dashboard_profile_mcp_servers_help">用逗号或换行分隔服务器名称。凭据仍由服务器保管。</string>
|
||||
<string name="dashboard_tile_memory_title">记忆</string>
|
||||
<string name="dashboard_tile_memory_sub">提供商状态和服务器端记忆配置</string>
|
||||
<string name="dashboard_tile_learning_title">学习图谱</string>
|
||||
<string name="dashboard_tile_learning_sub">查看当前配置文件的已学习节点</string>
|
||||
<string name="dashboard_tile_channels_title">频道</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -3985,4 +3985,64 @@
|
||||
<string name="pet_creator_step_review">Prüfen und importieren</string>
|
||||
<string name="pet_creator_review_desc">Prüfen Sie zuerst das zurückgegebene Bild oder ZIP. Beim Import werden die validierten Dateien in den App-Speicher kopiert; sie werden nicht veröffentlicht.</string>
|
||||
<string name="pet_creator_import">Fertiges Pet importieren</string>
|
||||
<string name="dashboard_tab_memory">Speicher</string>
|
||||
<string name="dashboard_tab_learning">Lernen</string>
|
||||
<string name="dashboard_tab_channels">Kanäle</string>
|
||||
<string name="dashboard_tab_operations">Betrieb</string>
|
||||
<string name="dashboard_tab_memory_lower">Speicher</string>
|
||||
<string name="dashboard_tab_learning_lower">Lernen</string>
|
||||
<string name="dashboard_tab_channels_lower">Kanäle</string>
|
||||
<string name="dashboard_tab_operations_lower">Betrieb</string>
|
||||
<string name="dashboard_section_action_server_backup">Server-Sicherung erstellen</string>
|
||||
<string name="dashboard_profile_mcp_servers_optional">MCP-Server (optional)</string>
|
||||
<string name="dashboard_profile_mcp_servers_help">Komma- oder zeilengetrennte Servernamen. Anmeldedaten verbleiben auf dem Server.</string>
|
||||
<string name="dashboard_tile_memory_title">Speicher</string>
|
||||
<string name="dashboard_tile_memory_sub">Anbieterstatus und servereigene Speicherkonfiguration</string>
|
||||
<string name="dashboard_tile_learning_title">Lerngraph</string>
|
||||
<string name="dashboard_tile_learning_sub">Gelernte Knoten des aktiven Profils prüfen</string>
|
||||
<string name="dashboard_tile_channels_title">Kanäle</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -3670,4 +3670,64 @@
|
||||
<string name="pet_creator_step_review">Revisar e importar</string>
|
||||
<string name="pet_creator_review_desc">Inspecciona primero la imagen o el ZIP recibido. Al importar se copian los archivos validados al almacenamiento de la aplicación; no se publican.</string>
|
||||
<string name="pet_creator_import">Importar mascota terminada</string>
|
||||
<string name="dashboard_tab_memory">Memoria</string>
|
||||
<string name="dashboard_tab_learning">Aprendizaje</string>
|
||||
<string name="dashboard_tab_channels">Canales</string>
|
||||
<string name="dashboard_tab_operations">Operaciones</string>
|
||||
<string name="dashboard_tab_memory_lower">memoria</string>
|
||||
<string name="dashboard_tab_learning_lower">aprendizaje</string>
|
||||
<string name="dashboard_tab_channels_lower">canales</string>
|
||||
<string name="dashboard_tab_operations_lower">operaciones</string>
|
||||
<string name="dashboard_section_action_server_backup">Crear copia del servidor</string>
|
||||
<string name="dashboard_profile_mcp_servers_optional">Servidores MCP (opcional)</string>
|
||||
<string name="dashboard_profile_mcp_servers_help">Nombres de servidor separados por comas o líneas. Las credenciales permanecen en el servidor.</string>
|
||||
<string name="dashboard_tile_memory_title">Memoria</string>
|
||||
<string name="dashboard_tile_memory_sub">Estado del proveedor y configuración de memoria del servidor</string>
|
||||
<string name="dashboard_tile_learning_title">Grafo de aprendizaje</string>
|
||||
<string name="dashboard_tile_learning_sub">Inspecciona los nodos aprendidos del perfil activo</string>
|
||||
<string name="dashboard_tile_channels_title">Canales</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -3984,4 +3984,64 @@
|
||||
<string name="pet_creator_step_review">確認してインポート</string>
|
||||
<string name="pet_creator_review_desc">返された画像またはZIPを最初に確認してください。インポートすると検証済みファイルがアプリ専用ストレージにコピーされますが、公開はされません。</string>
|
||||
<string name="pet_creator_import">完成したペットをインポート</string>
|
||||
<string name="dashboard_tab_memory">メモリ</string>
|
||||
<string name="dashboard_tab_learning">学習</string>
|
||||
<string name="dashboard_tab_channels">チャンネル</string>
|
||||
<string name="dashboard_tab_operations">運用</string>
|
||||
<string name="dashboard_tab_memory_lower">メモリ</string>
|
||||
<string name="dashboard_tab_learning_lower">学習</string>
|
||||
<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>
|
||||
<string name="dashboard_tile_memory_sub">プロバイダーの状態とサーバー側のメモリ設定</string>
|
||||
<string name="dashboard_tile_learning_title">学習グラフ</string>
|
||||
<string name="dashboard_tile_learning_sub">アクティブなプロファイルの学習済みノードを確認</string>
|
||||
<string name="dashboard_tile_channels_title">チャンネル</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -3706,4 +3706,64 @@
|
||||
<string name="pet_creator_step_review">Проверить и импортировать</string>
|
||||
<string name="pet_creator_review_desc">Сначала проверьте полученное изображение или ZIP. При импорте проверенные файлы копируются в хранилище приложения и не публикуются.</string>
|
||||
<string name="pet_creator_import">Импортировать готового питомца</string>
|
||||
<string name="dashboard_tab_memory">Память</string>
|
||||
<string name="dashboard_tab_learning">Обучение</string>
|
||||
<string name="dashboard_tab_channels">Каналы</string>
|
||||
<string name="dashboard_tab_operations">Операции</string>
|
||||
<string name="dashboard_tab_memory_lower">память</string>
|
||||
<string name="dashboard_tab_learning_lower">обучение</string>
|
||||
<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_profile_mcp_servers_optional">Серверы MCP (необязательно)</string>
|
||||
<string name="dashboard_profile_mcp_servers_help">Имена серверов через запятую или с новой строки. Учётные данные остаются на сервере.</string>
|
||||
<string name="dashboard_tile_memory_title">Память</string>
|
||||
<string name="dashboard_tile_memory_sub">Состояние провайдера и серверная настройка памяти</string>
|
||||
<string name="dashboard_tile_learning_title">Граф обучения</string>
|
||||
<string name="dashboard_tile_learning_sub">Просмотр изученных узлов активного профиля</string>
|
||||
<string name="dashboard_tile_channels_title">Каналы</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -1862,6 +1862,10 @@
|
||||
<string name="dashboard_tab_models">Models</string>
|
||||
<string name="dashboard_tab_keys">Keys</string>
|
||||
<string name="dashboard_tab_config">Config</string>
|
||||
<string name="dashboard_tab_memory">Memory</string>
|
||||
<string name="dashboard_tab_learning">Learning</string>
|
||||
<string name="dashboard_tab_channels">Channels</string>
|
||||
<string name="dashboard_tab_operations">Operations</string>
|
||||
<!-- Lowercase tab labels for compact UI surfaces (KPI strip) -->
|
||||
<string name="dashboard_tab_skills_lower">skills</string>
|
||||
<string name="dashboard_tab_cron_lower">cron</string>
|
||||
@@ -1872,6 +1876,10 @@
|
||||
<string name="dashboard_tab_models_lower">models</string>
|
||||
<string name="dashboard_tab_keys_lower">keys</string>
|
||||
<string name="dashboard_tab_config_lower">config</string>
|
||||
<string name="dashboard_tab_memory_lower">memory</string>
|
||||
<string name="dashboard_tab_learning_lower">learning</string>
|
||||
<string name="dashboard_tab_channels_lower">channels</string>
|
||||
<string name="dashboard_tab_operations_lower">operations</string>
|
||||
|
||||
<!-- Tile titles + subtitles -->
|
||||
<string name="dashboard_tile_profiles_title">Profiles</string>
|
||||
@@ -1968,6 +1976,27 @@
|
||||
<string name="dashboard_section_action_new_profile">New profile</string>
|
||||
<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>
|
||||
<string name="dashboard_tile_memory_sub">Provider status and host-owned memory configuration</string>
|
||||
<string name="dashboard_tile_learning_title">Learning graph</string>
|
||||
<string name="dashboard_tile_learning_sub">Inspect learned nodes for the active profile</string>
|
||||
<string name="dashboard_tile_channels_title">Channels</string>
|
||||
<string name="dashboard_tile_channels_sub">Messaging platform status, including WhatsApp</string>
|
||||
<string name="dashboard_tile_operations_title">Server operations</string>
|
||||
<string name="dashboard_tile_operations_sub">Host health and server-wide backup</string>
|
||||
|
||||
<!-- Action labels (rendered via kind → string) -->
|
||||
<string name="dashboard_action_set">Set</string>
|
||||
@@ -1991,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>
|
||||
@@ -3991,4 +4048,7 @@
|
||||
<string name="support_bundle_copied">Support information copied</string>
|
||||
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
|
||||
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
|
||||
<string name="drawer_all_profiles">All profiles</string>
|
||||
<string name="drawer_no_profile_sessions">No matching profile sessions.</string>
|
||||
<string name="drawer_close">Close</string>
|
||||
</resources>
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReconnectBackoffTest {
|
||||
@Test
|
||||
fun `full jitter stays within the cap`() {
|
||||
assertEquals(0L, fullJitterDelayMs(1_000L, 0.0))
|
||||
assertEquals(500L, fullJitterDelayMs(1_000L, 0.5))
|
||||
assertEquals(1_000L, fullJitterDelayMs(1_000L, 1.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-positive caps are immediate`() {
|
||||
assertEquals(0L, fullJitterDelayMs(0L, 0.5))
|
||||
assertEquals(0L, fullJitterDelayMs(-1L, 0.5))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -144,6 +145,43 @@ class ChatHandlerTest {
|
||||
assertEquals("ABC", messages[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaMarkers_acceptUpstreamWrappersPunctuationAdjacentAndWindowsPaths() {
|
||||
val paths = mutableListOf<String>()
|
||||
handler.onMediaBarePathRequested = { _, path -> paths += path }
|
||||
|
||||
handler.onTextDelta("assist-1", "`MEDIA:/tmp/report.csv.`\n")
|
||||
handler.onTextDelta(
|
||||
"assist-1",
|
||||
"**MEDIA:C:\\exports\\shot.png**, MEDIA:/tmp/other report.pdf\n",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"/tmp/report.csv",
|
||||
"C:\\exports\\shot.png",
|
||||
"/tmp/other report.pdf",
|
||||
),
|
||||
paths,
|
||||
)
|
||||
assertEquals("", handler.messages.value.single().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaMarkers_preserveFencedAndProseExamples() {
|
||||
val paths = mutableListOf<String>()
|
||||
handler.onMediaBarePathRequested = { _, path -> paths += path }
|
||||
|
||||
handler.onTextDelta(
|
||||
"assist-1",
|
||||
"```text\nMEDIA:/tmp/example.png\n```\nExample: `MEDIA:/tmp/example.pdf`\n",
|
||||
)
|
||||
|
||||
assertTrue(paths.isEmpty())
|
||||
assertTrue(handler.messages.value.single().content.contains("MEDIA:/tmp/example.png"))
|
||||
assertTrue(handler.messages.value.single().content.contains("MEDIA:/tmp/example.pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onTextDelta_setsStreamingFlag() {
|
||||
handler.onTextDelta("assist-1", "delta")
|
||||
@@ -565,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")))
|
||||
|
||||
+399
-8
@@ -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 {
|
||||
|
||||
@@ -690,24 +695,30 @@ class DashboardApiClientTest {
|
||||
apiKey = "never-persist-this",
|
||||
)
|
||||
|
||||
val listed = client.getCustomEndpoints().getOrThrow()
|
||||
client.saveCustomEndpoint(draft).getOrThrow()
|
||||
val listed = client.getCustomEndpoints("work profile").getOrThrow()
|
||||
client.saveCustomEndpoint(draft, "work profile").getOrThrow()
|
||||
val validation = client.validateCustomEndpoint(draft).getOrThrow()
|
||||
client.activateCustomEndpoint("local").getOrThrow()
|
||||
client.deleteCustomEndpoint("local").getOrThrow()
|
||||
client.activateCustomEndpoint("local", "work profile").getOrThrow()
|
||||
client.deleteCustomEndpoint("local", "work profile").getOrThrow()
|
||||
|
||||
assertEquals("local", listed.currentProvider)
|
||||
assertTrue(listed.endpoints.single().hasApiKey)
|
||||
assertEquals(listOf("qwen"), validation.models)
|
||||
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints?profile=work%20profile", server.takeRequest().path)
|
||||
val save = server.takeRequest()
|
||||
assertEquals("/api/providers/custom-endpoints", save.path)
|
||||
assertEquals("/api/providers/custom-endpoints?profile=work%20profile", save.path)
|
||||
val saveBody = save.body.readUtf8()
|
||||
assertTrue(saveBody.contains("never-persist-this"))
|
||||
assertTrue(saveBody.contains(""""models":["qwen","qwen-vl"]"""))
|
||||
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
||||
assertEquals(
|
||||
"/api/providers/custom-endpoints/local/activate?profile=work%20profile",
|
||||
server.takeRequest().path,
|
||||
)
|
||||
assertEquals(
|
||||
"/api/providers/custom-endpoints/local?profile=work%20profile",
|
||||
server.takeRequest().path,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -733,6 +744,30 @@ class DashboardApiClientTest {
|
||||
assertEquals("/api/profiles/old%20profile", delete.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileCreationCarriesMcpServersAndServerBackupIsDistinct() = runTest {
|
||||
repeat(2) {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true}"""),
|
||||
)
|
||||
}
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
|
||||
client.createProfile(
|
||||
name = "research",
|
||||
description = "Deep work",
|
||||
mcpServers = listOf("github", "memory"),
|
||||
).getOrThrow()
|
||||
client.createServerBackup().getOrThrow()
|
||||
|
||||
val create = server.takeRequest()
|
||||
assertEquals("/api/profiles", create.path)
|
||||
assertTrue(create.body.readUtf8().contains(""""mcp_servers":["github","memory"]"""))
|
||||
val backup = server.takeRequest()
|
||||
assertEquals("POST", backup.method)
|
||||
assertEquals("/api/ops/backup", backup.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getActiveProfileScope_distinguishesStickyDefaultFromDashboardProcess() = runTest {
|
||||
server.enqueue(
|
||||
@@ -834,6 +869,213 @@ 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(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"sessions":[{"id":"same","title":"A","profile":"default"},{"id":"same","title":"B","profile":"work"},{"id":"unsafe"}],"total":3}""",
|
||||
),
|
||||
)
|
||||
|
||||
val sessions = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
.listAllProfileSessions()
|
||||
.getOrThrow()
|
||||
|
||||
val url = server.takeRequest().requestUrl!!
|
||||
assertEquals("/api/profiles/sessions", url.encodedPath)
|
||||
assertEquals("all", url.queryParameter("profile"))
|
||||
assertEquals("include", url.queryParameter("archived"))
|
||||
assertEquals(listOf("default", "work"), sessions.map { it.profile })
|
||||
assertEquals(listOf("A", "B"), sessions.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_pagesAtUpstreamMaximumWhilePreservingTwoHundredRowWindow() = runTest {
|
||||
val firstPage = (0 until 100).joinToString(",") { "{\"id\":\"sess-$it\"}" }
|
||||
@@ -1320,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(
|
||||
|
||||
@@ -251,6 +251,8 @@ class GatewayClientHarness(
|
||||
put("status", steerStatus)
|
||||
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
|
||||
}
|
||||
"subagent.steer" -> buildJsonObject { put("status", steerStatus) }
|
||||
"message.react" -> buildJsonObject { put("row_id", 17) }
|
||||
"session.compress" -> compressPayload
|
||||
"slash.exec" -> buildJsonObject {
|
||||
put("output", "legacy compression started")
|
||||
@@ -677,6 +679,55 @@ class GatewayChatClientTest {
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global reclaim settles active turn once and next send resumes durable session`() {
|
||||
val first = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "first",
|
||||
newSessionTitle = "first",
|
||||
callbacks = first.callbacks,
|
||||
onPreflightFailure = { first.preflightFailures += it },
|
||||
)
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
val reclaim = harness.eventFrame(
|
||||
"session.reclaimed",
|
||||
buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
put("stored_session_id", "20260612_120000_abc123")
|
||||
put("reason", "idle_timeout")
|
||||
},
|
||||
null,
|
||||
)
|
||||
serverWs.send(reclaim)
|
||||
serverWs.send(reclaim)
|
||||
|
||||
assertTrue("reclaimed turn never settled", first.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
waitUntil { first.errors.size == 1 }
|
||||
|
||||
val second = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = "20260612_120000_abc123",
|
||||
text = "continue",
|
||||
newSessionTitle = null,
|
||||
callbacks = second.callbacks,
|
||||
onPreflightFailure = { second.preflightFailures += it },
|
||||
)
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpcCount("prompt.submit", 2)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "resumed") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue("resumed turn never completed", second.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(second.errors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited assistant turn for resumed session streams without sendTurn`() = runBlocking {
|
||||
val r = Recorder()
|
||||
@@ -1303,6 +1354,22 @@ class GatewayChatClientTest {
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subagent redirect carries exact parent and child identity`() = runBlocking {
|
||||
val recorder = Recorder()
|
||||
client.sendTurn(null, "delegate", null, recorder.callbacks) { recorder.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = client.steerSubagent("child-17", "focus on Android")
|
||||
|
||||
assertEquals("queued", (result.getOrThrow()["status"] as? JsonPrimitive)?.contentOrNull)
|
||||
val params = harness.awaitRpc("subagent.steer")
|
||||
assertEquals("live-1", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("child-17", (params["subagent_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress session uses dedicated rpc and parses authoritative messages`() {
|
||||
val r = Recorder()
|
||||
@@ -1753,6 +1820,23 @@ class GatewayChatClientTest {
|
||||
|
||||
// --- Pet thumbnails ---
|
||||
|
||||
@Test
|
||||
fun `message reaction targets newest role without guessing a row id`() {
|
||||
client.sendTurn("stored-1", "hi", null, Recorder().callbacks) {}
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.reactToNewest("assistant", "👍") }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val params = harness.awaitRpc("message.react")
|
||||
assertEquals("live-resumed", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("assistant", (params["newest_role"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("👍", (params["emoji"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("user", (params["author"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse("row_id" in params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet thumbnail connects on demand and sends upstream params`() {
|
||||
client.sessionProfileProvider = { "work" }
|
||||
|
||||
+56
-2
@@ -483,6 +483,23 @@ class GatewayEventMapperTest {
|
||||
assertEquals(listOf("t2" to "completed skill"), standard.toolDones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structured tool arguments win over legacy previews`() {
|
||||
val recorder = Recorder()
|
||||
val mapper = mapperWith(recorder)
|
||||
mapper.onEvent(
|
||||
"tool.start",
|
||||
obj(
|
||||
"""{"tool_id":"t3","name":"terminal","args":{"command":"echo live","timeout":30},"args_text":"stale","context":"older"}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("t3" to "{\"command\":\"echo live\",\"timeout\":30}"),
|
||||
recorder.toolStartArgs,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool complete with error routes to failed`() {
|
||||
val r = Recorder()
|
||||
@@ -574,7 +591,7 @@ class GatewayEventMapperTest {
|
||||
fun `subagent lifecycle maps phases and fields`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3}"""))
|
||||
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3,"subagent_id":"child-17"}"""))
|
||||
mapper.onEvent("subagent.thinking", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"hmm"}"""))
|
||||
mapper.onEvent(
|
||||
"subagent.tool",
|
||||
@@ -600,6 +617,7 @@ class GatewayEventMapperTest {
|
||||
assertEquals(1, start.taskIndex)
|
||||
assertEquals(3, start.taskCount)
|
||||
assertEquals("research topic", start.goal)
|
||||
assertEquals("child-17", start.subagentId)
|
||||
assertEquals("hmm", r.subagentEvents[1].preview)
|
||||
val tool = r.subagentEvents[2]
|
||||
assertEquals("web_search", tool.toolName)
|
||||
@@ -697,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(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SessionReferenceTest {
|
||||
@Test
|
||||
fun `references outside code are bounded and parsed`() {
|
||||
val text = """
|
||||
Open @session:research/20260811_abc123.
|
||||
`@session:hidden/inline`
|
||||
```text
|
||||
@session:hidden/fenced
|
||||
```
|
||||
Also @session:default/session-2
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SessionReference("research", "20260811_abc123"),
|
||||
SessionReference("default", "session-2"),
|
||||
),
|
||||
parseSessionReferences(text),
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -63,21 +63,48 @@ class DashboardManageParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpManagePlumbing_usesEffectiveProfileForListAndRowActions() {
|
||||
fun completedOneShotCron_showsOutcomeAndOnlyOffersRunsAndDelete() {
|
||||
val root = Json.parseToJsonElement(
|
||||
"""{"id":"once","name":"One shot","schedule":"@once","state":"completed","enabled":false,"last_status":"error","last_error":"model timeout","last_delivery_error":"phone offline"}""",
|
||||
).jsonObject
|
||||
|
||||
val row = summarizeObjectItem(root, "once")
|
||||
|
||||
assertTrue(row.meta.orEmpty().contains("last: error"))
|
||||
assertTrue(row.meta.orEmpty().contains("error: model timeout"))
|
||||
assertTrue(row.meta.orEmpty().contains("delivery: phone offline"))
|
||||
assertEquals(
|
||||
listOf(DashboardActionKind.ViewCronRuns, DashboardActionKind.DeleteCron),
|
||||
row.actions.map(DashboardItemAction::kind),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopedManagePlumbing_usesEffectiveProfileForListAndRowActions() {
|
||||
assertEquals(
|
||||
"/api/mcp/servers?profile=work%20profile",
|
||||
dashboardSectionRequestPath("/api/mcp/servers", "work profile"),
|
||||
)
|
||||
assertEquals(
|
||||
"/api/providers/custom-endpoints",
|
||||
"/api/providers/custom-endpoints?profile=work%20profile",
|
||||
dashboardSectionRequestPath("/api/providers/custom-endpoints", "work profile"),
|
||||
)
|
||||
assertEquals(
|
||||
"/api/learning/graph?profile=work%20profile",
|
||||
dashboardSectionRequestPath("/api/learning/graph", "work profile"),
|
||||
)
|
||||
val scoped = scopeDashboardManageItems(
|
||||
"/api/mcp/servers",
|
||||
"work profile",
|
||||
listOf(DashboardSummaryItem(id = "hosted", title = "Hosted")),
|
||||
)
|
||||
assertEquals("work profile", scoped.single().profile)
|
||||
val endpoint = scopeDashboardManageItems(
|
||||
"/api/providers/custom-endpoints",
|
||||
"work profile",
|
||||
listOf(DashboardSummaryItem(id = "local", title = "Local")),
|
||||
)
|
||||
assertEquals("work profile", endpoint.single().profile)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+42
@@ -58,6 +58,48 @@ class ChatViewModelCommandCatalogTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCommandsCatalog_ranksOnlySkillSlotsAndBoundsMetadata() {
|
||||
val catalog = buildJsonObject {
|
||||
put(
|
||||
"pairs",
|
||||
JsonArray(
|
||||
listOf(
|
||||
commandPair("status", "Show status"),
|
||||
commandPair("research", "Research"),
|
||||
commandPair("help", "Show help"),
|
||||
commandPair("work", "Worktree"),
|
||||
),
|
||||
),
|
||||
)
|
||||
put("skills", buildJsonObject {
|
||||
put("/research", buildJsonObject {
|
||||
put("usage", 60)
|
||||
put("origin", "bundled")
|
||||
})
|
||||
put("/work", buildJsonObject {
|
||||
put("usage", 172)
|
||||
put("origin", "local")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
val commands = parseCommandsCatalog(catalog)
|
||||
|
||||
assertEquals(listOf("/status", "/work", "/help", "/research"), commands.map { it.command })
|
||||
assertEquals(172, commands[1].usageRank)
|
||||
assertEquals("local", commands[1].origin)
|
||||
assertEquals(listOf("/status", "/help"), commands.filter { it.usageRank == 0 }.map { it.command })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deferredConfigResult_acceptsCurrentBooleanShapes() {
|
||||
assertTrue(isDeferredConfigResult(buildJsonObject { put("deferred", true) }))
|
||||
assertTrue(isDeferredConfigResult(buildJsonObject { put("deferred", "1") }))
|
||||
assertFalse(isDeferredConfigResult(buildJsonObject { put("deferred", false) }))
|
||||
assertFalse(isDeferredConfigResult(buildJsonObject { }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDashboardPersonalityConfig_readsWrappedAndDirectConfig() {
|
||||
val config = buildJsonObject {
|
||||
|
||||
+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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hermes-relay": "bin/hermes-relay.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -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,2 +1,2 @@
|
||||
// Regenerated from package.json by gen:version script. Do not edit by hand.
|
||||
export const VERSION = "0.4.0-alpha.3" as const
|
||||
export const VERSION = "0.4.0-alpha.7" as const
|
||||
|
||||
@@ -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"/)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import test from 'node:test'
|
||||
|
||||
import { updateCommand } from '../src/commands/update.js'
|
||||
import { checkForUpdate, downloadAndInstall } from '../src/updater.js'
|
||||
import { VERSION } from '../src/version.js'
|
||||
|
||||
async function captureStdout(run: () => Promise<number>): Promise<{ code: number; output: string }> {
|
||||
const originalWrite = process.stdout.write
|
||||
@@ -85,7 +86,7 @@ test('installer check reports a newer local preview without treating it as an er
|
||||
}))
|
||||
assert.equal(check.code, 0)
|
||||
const report = JSON.parse(check.output)
|
||||
assert.equal(report.current, '0.4.0-alpha.3')
|
||||
assert.equal(report.current, VERSION)
|
||||
assert.equal(report.latest_version, '0.4.0-alpha.2')
|
||||
assert.equal(report.up_to_date, true)
|
||||
assert.equal(report.ahead_of_latest, true)
|
||||
|
||||
Generated
+1
-1
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-alpha.3"
|
||||
version = "0.4.0-alpha.7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-alpha.3"
|
||||
version = "0.4.0-alpha.7"
|
||||
description = "Compact Windows management UI for Hermes-Relay CLI"
|
||||
authors = ["Axiom Labs"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"private": true,
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
|
||||
+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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes-Relay CLI UI",
|
||||
"version": "0.4.0-alpha.3",
|
||||
"version": "0.4.0-alpha.7",
|
||||
"identifier": "com.axiomlabs.hermes-relay-tray",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "6f0ae11279c01cb70b21a7908ffe71bc60a5bf37d000dc41b6c664a86612f311",
|
||||
"main": "54a759f3416a8ad19e1fc4d557c42d6aeca10e2091482a173777e016f7398057",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "6f0ae11279c01cb70b21a7908ffe71bc60a5bf37d000dc41b6c664a86612f311",
|
||||
"main": "54a759f3416a8ad19e1fc4d557c42d6aeca10e2091482a173777e016f7398057",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "6f0ae11279c01cb70b21a7908ffe71bc60a5bf37d000dc41b6c664a86612f311",
|
||||
"main": "54a759f3416a8ad19e1fc4d557c42d6aeca10e2091482a173777e016f7398057",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "6f0ae11279c01cb70b21a7908ffe71bc60a5bf37d000dc41b6c664a86612f311",
|
||||
"main": "54a759f3416a8ad19e1fc4d557c42d6aeca10e2091482a173777e016f7398057",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "6f0ae11279c01cb70b21a7908ffe71bc60a5bf37d000dc41b6c664a86612f311",
|
||||
"main": "54a759f3416a8ad19e1fc4d557c42d6aeca10e2091482a173777e016f7398057",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "6f0ae11279c01cb70b21a7908ffe71bc60a5bf37d000dc41b6c664a86612f311",
|
||||
"main": "54a759f3416a8ad19e1fc4d557c42d6aeca10e2091482a173777e016f7398057",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -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.2",
|
||||
"version": "1.6.4",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user