Compare commits

...
Author SHA1 Message Date
Bailey Dixon 94565e9d6d fix(android): stabilize supervised parent relock 2026-08-25 16:13:47 -04:00
Bailey Dixon 56c2e6fa07 Merge pull request #438 from Codename-11/chore/backmerge-android-1.13.1
chore: back-merge Android 1.13.1
2026-08-25 15:30:33 -04:00
Bailey Dixon 28629f3d93 chore: back-merge android 1.13.1 2026-08-25 15:18:32 -04:00
Bailey Dixon 0d1faf47a0 Merge pull request #435 from Codename-11/fix/git-state-audit
fix: harden Git state repository operations
2026-08-25 14:05:40 -04:00
Bailey Dixon 00288a2b3b test(git-state): cover links across platforms 2026-08-25 13:54:45 -04:00
Bailey Dixon 8f52feffba fix(git-state): harden repository operations 2026-08-25 13:51:10 -04:00
Bailey Dixon ee29e49361 Merge pull request #434 from Codename-11/fix/android-session-activity-truth
fix(android): make session activity authoritative
2026-08-25 12:37:31 -04:00
Bailey Dixon e2073b7692 chore: merge dev into session activity fix 2026-08-25 12:26:42 -04:00
Bailey Dixon 70b6d8ee5a Merge pull request #433 from mrvigneshvt/feat/git-state
feat: Git State — Hermex-parity repo management for host repos
2026-08-25 12:03:50 -04:00
Bailey Dixon 1f5e50ccd7 chore: merge dev into git-state 2026-08-25 11:51:44 -04:00
Bailey Dixon 5580c9d9bb Merge pull request #419 from Codename-11/feature/android-supervised-mode
feat(android): add supervised mode
2026-08-25 11:42:21 -04:00
Bailey Dixon 2ebdf55501 Merge branch 'dev' into feature/android-supervised-mode 2026-08-25 11:32:54 -04:00
mrvigneshvt ad107ea205 feat(git-state): add AI commit messages, stash-checkout, and push-after-commit
Commit-message generation reuses the upstream async LLM helper via the plugin's deferred-import pattern; empty staged diffs never call the model and failures degrade to an empty message plus notice. stash_checkout auto-stashes a dirty tree before switching (recoverable; stash surfaced as a notice). Push-after-commit toggle auto-starts the push confirmation flow without bypassing the confirmation token. Truncation caps consistent across all bounded endpoints. New UI strings localized across the 12-catalog parity gate.
2026-08-25 13:25:21 +00:00
mrvigneshvt f5aeb27e5a feat(git-state): add git write operations and Android write UI
POST endpoints for stage, unstage, discard, commit (selected paths supported), fetch, pull (--ff-only), push, and checkout with new-branch/track. Destructive ops enforce fixed per-use confirmation tokens (403 on missing or mismatched confirmation); the plugin.api.write grant gates writes client-side per the documented plugin contract; git failures classify into a structured taxonomy mapped to HTTP (409 dirty/conflict, 502 auth/network, 400 invalid). Android: commit dialog, branch picker with track, confirmation dialogs (standard AlertDialog pattern), grant-refusal notice, mutation progress/error rendering, 16 view-model tests. Dashboard: identically gated write controls. 56 endpoint/helper-level tests; deterministic fixtures.
2026-08-25 13:25:21 +00:00
mrvigneshvt fdaeb121d5 feat(git-state): add read-only git workspace browser (scan, status, branches, diff, file read)
Plugin endpoints under /api/plugins/hermes-relay/git/* backed by a scanned-repo allowlist with configurable base path; bounded responses with truncation flags; traversal-rejected file reads returning working-tree content with clear binary/non-UTF-8 errors; remote URLs scrubbed of userinfo; zero shell interpolation. Registers the Git mobile plugin page and a read-only dashboard tab; Android renders the surface via a dedicated Compose screen with view model and unit tests; all locale catalogs refreshed.
2026-08-25 13:25:21 +00:00
Bailey Dixon 06c0df6304 fix(android): make session activity authoritative 2026-08-25 08:51:12 -04:00
Bailey Dixon 71a2b3a7fb Merge pull request #431 from Codename-11/main
chore: reconcile production release into dev
2026-08-25 00:05:05 -04:00
Bailey Dixon 65e48084cb Merge remote-tracking branch 'origin/dev' into feature/android-supervised-mode 2026-08-24 21:52:07 -04:00
Bailey Dixon 1074ecc24f Merge remote-tracking branch 'origin/dev' into feature/android-supervised-mode
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt
#	docs/localization-status.json
2026-08-24 21:41:12 -04:00
Bailey Dixon f2a23e32aa fix(android): preserve supervised restart redirect 2026-08-24 21:33:01 -04:00
Bailey Dixon 630cc6d316 Merge remote-tracking branch 'origin/dev' into feature/android-supervised-mode
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ChatScreen.kt
#	docs/decisions.md
#	docs/localization-status.json
#	plugin/dashboard/dist/index.js
2026-08-24 21:08:50 -04:00
Bailey Dixon e16205d82a feat(android): polish supervised appearance 2026-08-24 20:50:11 -04:00
Bailey Dixon 676c37e5ca fix(android): stabilize supervised mode transitions 2026-08-24 20:26:44 -04:00
Bailey Dixon 9b6fed9bdd fix(android): bootstrap supervised navigation 2026-08-24 20:15:19 -04:00
Bailey Dixon 4d90eef3d8 Merge remote-tracking branch 'origin/dev' into feature/android-supervised-mode 2026-08-24 20:15:18 -04:00
Bailey Dixon 478323893a feat(android): refine supervised controls 2026-08-24 19:36:10 -04:00
Bailey Dixon fcddeeb810 docs: document Android supervised mode 2026-08-24 16:34:59 -04:00
Bailey Dixon 49002b7141 feat(plugin): report supervised Android clients 2026-08-24 16:34:59 -04:00
Bailey Dixon 326eb47df3 feat(android): add supervised mode 2026-08-24 16:34:59 -04:00
87 changed files with 11764 additions and 302 deletions
+9 -1
View File
@@ -38,6 +38,10 @@ jobs:
working-directory: plugin/dashboard
run: npm run build
- name: Test dashboard source
working-directory: plugin/dashboard
run: npm test
- name: Setup Python
uses: actions/setup-python@v7
with:
@@ -54,7 +58,11 @@ jobs:
run: pip install -r relay_server/requirements.txt fastapi httpx requests
- name: Run dashboard API tests
run: python -m unittest plugin.dashboard.test_plugin_api
run: >-
python -m unittest
plugin.dashboard.test_plugin_api
plugin.dashboard.test_git_api
plugin.dashboard.test_mobile_plugin_api
- name: Verify dashboard bundle outputs
run: |
+5 -1
View File
@@ -106,4 +106,8 @@ jobs:
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py \
plugin/tests/test_profile_discovery.py \
plugin/tests/test_profiles_updated_broadcast.py
plugin/tests/test_profiles_updated_broadcast.py \
plugin/tests/test_git_state.py \
plugin/tests/test_git_state_write.py \
plugin/tests/test_git_state_extras.py \
plugin/tests/test_mobile_plugin_store.py
+5
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
## [Android 1.13.1] - 2026-08-25
### Fixed
@@ -19,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
### Changed
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
import com.hermesandroid.relay.data.sameBrokerAuthority
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
@@ -18,6 +19,8 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -53,6 +56,39 @@ sealed class AuthState {
data class Failed(val reason: String) : AuthState()
}
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
if (!policy.isActive) return buildJsonObject { put("active", false) }
val capabilities = buildList {
add("text_chat")
if (policy.capabilities.newChat) add("new_chat")
if (policy.capabilities.cancelResponse) add("cancel")
if (policy.capabilities.steerResponse) add("steer")
if (policy.capabilities.attachments) add("attachments")
if (policy.capabilities.voice) add("voice")
if (policy.capabilities.generatedImages) add("generated_images")
if (policy.capabilities.shareGeneratedImages) add("share_images")
if (policy.capabilities.copyResponses) add("copy")
if (policy.capabilities.retryResponse) add("retry")
if (policy.capabilities.quoteReplies) add("quote_reply")
if (policy.visibility.resolved().showTimestamps) add("timestamps")
}.take(12)
return buildJsonObject {
put("active", true)
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
}
}
internal fun relaySupervisedModeUpdateEnvelope(
policy: SupervisedModePolicy,
): Envelope = Envelope(
channel = "system",
type = "supervised.update",
payload = buildJsonObject {
put("supervised_mode", relaySupervisedModePayload(policy))
},
)
@Serializable
data class ConnectionAuthSecrets(
val sessionToken: String? = null,
@@ -120,6 +156,60 @@ class AuthManager(
private val eagerHydrate: Boolean = true,
) : ChannelMultiplexer.ChannelHandler {
@Volatile
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
@Volatile
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
private var pendingSupervisedUpdateId: String? = null
private var supervisedUpdateFallbackJob: Job? = null
/**
* Update the public client-mode tag sent on Relay auth. This does not grant
* authority: Relay labels enforcement_owner=android_client and the Android
* policy remains the enforcing surface.
*/
fun updateSupervisedMode(policy: SupervisedModePolicy) {
if (supervisedMode == policy) return
supervisedMode = policy
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
}
/**
* Install the narrow compatibility path used when an older Relay ignores
* `system/supervised.update`. Reopening the authenticated socket causes
* the current policy to travel through the legacy `system/auth` payload.
*/
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
supervisedMetadataReconnectFallback = callback
}
private fun sendSupervisedModeUpdate() {
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
pendingSupervisedUpdateId = envelope.id
supervisedUpdateFallbackJob?.cancel()
multiplexer.send(envelope)
supervisedUpdateFallbackJob = scope.launch {
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
if (pendingSupervisedUpdateId == envelope.id) {
pendingSupervisedUpdateId = null
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
supervisedMetadataReconnectFallback?.invoke()
}
}
}
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
if (envelope.id != pendingSupervisedUpdateId) return
pendingSupervisedUpdateId = null
supervisedUpdateFallbackJob?.cancel()
supervisedUpdateFallbackJob = null
if (unsupported) {
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
supervisedMetadataReconnectFallback?.invoke()
}
}
companion object {
private const val TAG = "AuthManager"
private const val KEY_SESSION_TOKEN = "session_token"
@@ -134,6 +224,7 @@ class AuthManager(
// migration has run, so we never rebuild the legacy keyset to re-check.
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
private const val PAIRING_CODE_LENGTH = 6
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
/**
@@ -835,6 +926,10 @@ class AuthManager(
put("device_form_factor", "phone")
}
private fun JsonObjectBuilder.putSupervisedMode() {
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
}
private fun relayDeviceName(): String {
val configured = runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
@@ -890,6 +985,7 @@ class AuthManager(
put("device_id", deviceId)
putRelayDeviceIdentity()
putRelayClientSupports()
putSupervisedMode()
}
}
else -> {
@@ -906,6 +1002,7 @@ class AuthManager(
put("device_id", deviceId)
putRelayDeviceIdentity()
putRelayClientSupports()
putSupervisedMode()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
val obj = buildJsonObject {
@@ -985,6 +1082,8 @@ class AuthManager(
when (envelope.type) {
"auth.ok" -> handleAuthOk(envelope)
"auth.fail" -> handleAuthFail(envelope)
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
// `profiles.updated` push — sent by the v0.7.1+ relay on
// the "pairing" channel whenever its in-memory profile
// snapshot changes (file-watcher, SIGHUP, or a manual
@@ -1129,6 +1228,11 @@ class AuthManager(
get() = _authState.value is AuthState.Paired
private fun handleAuthOk(envelope: Envelope) {
// A successful auth always carries the latest client report, including
// after the compatibility reconnect used for older Relay versions.
pendingSupervisedUpdateId = null
supervisedUpdateFallbackJob?.cancel()
supervisedUpdateFallbackJob = null
scope.launch {
try {
val payload = envelope.payload
@@ -0,0 +1,163 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
// Read + write client for the Hermes-Relay Git State endpoints.
// All requests are confined to the ``hermes-relay`` plugin namespace and the
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
// encodes query values.
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
class GitStateApiClient(
dashboard: DashboardApiClient,
) {
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
private val json = Json { ignoreUnknownKeys = true }
suspend fun repos(): Result<List<GitRepo>> = scoped
.get("git/repos")
.mapCatching { element ->
json.decodeFromJsonElement<ReposResponse>(element).repos
}
suspend fun status(repo: String): Result<GitStatus> = scoped
.get("git/status", mapOf("repo" to repo))
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
.get("git/branches", mapOf("repo" to repo))
.mapCatching { element ->
json.decodeFromJsonElement<BranchesResponse>(element).branches
}
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
.get("git/file", mapOf("repo" to repo, "path" to path))
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
// ── Write operations ───────────────────────────────────────────────────
// Every write requires the plugin.api.write grant, which the app enforces
// (see GitStateViewModel: a POST is never sent without the grant). The
// server additionally enforces per-use confirmation strings for destructive
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
scoped.post("git/stage", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
scoped.post("git/unstage", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun discard(
repo: String,
paths: List<String>,
confirmation: String,
deleteUntracked: Boolean = false,
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
put("confirmation", confirmation)
put("delete_untracked", deleteUntracked)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
scoped.post("git/commit", buildJsonObject {
put("repo", repo)
put("message", message)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun commitSelected(
repo: String,
message: String,
paths: List<String>,
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
put("repo", repo)
put("message", message)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
scoped.post("git/fetch", buildJsonObject {
put("repo", repo)
put("remote", remote)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
scoped.post("git/pull", buildJsonObject {
put("repo", repo)
put("remote", remote)
put("branch", branch)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun push(
repo: String,
confirmation: String,
remote: String = "origin",
branch: String = "",
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
put("repo", repo)
put("remote", remote)
put("branch", branch)
put("confirmation", confirmation)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
suspend fun checkout(
repo: String,
ref: String,
confirmation: String? = null,
newBranch: String = "",
track: Boolean = false,
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
put("repo", repo)
put("ref", ref)
if (confirmation != null) put("confirmation", confirmation)
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
put("track", track)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
// ── Phase 3 extras ─────────────────────────────────────────────────────
/** Generate a commit-message suggestion from the staged diff. */
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
scoped.post("git/commit_message", buildJsonObject {
put("repo", repo)
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
/** Generate a commit-message suggestion from the given paths' staged diff. */
suspend fun commitMessageSelected(
repo: String,
paths: List<String>,
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
/** Checkout that auto-stashes a dirty tree first. */
suspend fun stashCheckout(
repo: String,
ref: String,
newBranch: String = "",
track: Boolean = false,
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
put("repo", repo)
put("ref", ref)
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
put("track", track)
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
}
@@ -0,0 +1,101 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A repository discovered by the plugin's /git/repos endpoint. */
@Serializable
data class GitRepo(
val id: String,
val name: String,
val root: String,
@SerialName("current_branch") val currentBranch: String? = null,
val dirty: Boolean = false,
)
/** Working-tree status from /git/status. */
@Serializable
data class GitStatus(
val counts: GitStatusCounts = GitStatusCounts(),
val staged: List<GitStatusEntry> = emptyList(),
val modified: List<GitStatusEntry> = emptyList(),
val untracked: List<GitStatusEntry> = emptyList(),
val truncated: Boolean = false,
)
@Serializable
data class GitStatusCounts(
val staged: Int = 0,
val modified: Int = 0,
val untracked: Int = 0,
)
@Serializable
data class GitStatusEntry(
val path: String,
)
/** A branch from /git/branches. */
@Serializable
data class GitBranch(
val name: String,
val upstream: String? = null,
val ahead: Int = 0,
val behind: Int = 0,
@SerialName("is_current") val isCurrent: Boolean = false,
)
/** A per-file diff from /git/diff. */
@Serializable
data class GitDiff(
val path: String,
val kind: String,
val diff: String,
val truncated: Boolean = false,
)
/** A tracked-file read from /git/file. */
@Serializable
data class GitFile(
val path: String,
val content: String,
val truncated: Boolean = false,
)
/** Wrapper for /git/repos response. */
@Serializable
internal data class ReposResponse(
val repos: List<GitRepo> = emptyList(),
val notice: String? = null,
)
/** Wrapper for /git/branches response. */
@Serializable
internal data class BranchesResponse(
val branches: List<GitBranch> = emptyList(),
)
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
@Serializable
data class GitMutationResult(
val head: String = "",
val status: GitStatus = GitStatus(),
val branches: List<GitBranch> = emptyList(),
)
/** A /git/commit_message suggestion: generated message + optional notice. */
@Serializable
data class GitCommitMessage(
val message: String = "",
val notice: String = "",
)
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
@Serializable
data class GitStashCheckoutResult(
val head: String = "",
val status: GitStatus = GitStatus(),
val branches: List<GitBranch> = emptyList(),
val stashed: Boolean = false,
@SerialName("stash_message") val stashMessage: String = "",
)
@@ -0,0 +1,234 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.hermesandroid.relay.ui.theme.AppThemes
/**
* Parent-configured restrictions for the official Android client.
*
* This policy deliberately describes a client presentation mode, not a server
* authorization boundary. The pinned profile is expected to have already been
* configured with the appropriate server-side tool and content restrictions.
*/
@Serializable
data class SupervisedModePolicy(
val enabled: Boolean = false,
val pinnedProfileName: String? = null,
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
val appearance: SupervisedAppearance = SupervisedAppearance(),
val visibility: SupervisedVisibility = SupervisedVisibility(),
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
) {
/** A saved policy is usable only when it names a concrete Hermes profile. */
val isConfigured: Boolean
get() = !pinnedProfileName.isNullOrBlank()
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
val isActive: Boolean
get() = enabled && isConfigured
internal fun normalized(): SupervisedModePolicy = copy(
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
capabilities = capabilities.normalized(),
appearance = appearance.normalized(),
parentAccess = parentAccess.normalized(),
)
}
/** Actions and content types the supervised chat surface may expose. */
@Serializable
data class SupervisedCapabilities(
val attachments: Boolean = false,
val voice: Boolean = false,
val generatedImages: Boolean = true,
val conversationHistory: Boolean = false,
val newChat: Boolean = true,
val cancelResponse: Boolean = true,
val steerResponse: Boolean = true,
val retryResponse: Boolean = true,
val copyResponses: Boolean = true,
val quoteReplies: Boolean = true,
val editAndResend: Boolean = false,
val shareGeneratedImages: Boolean = false,
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
SupervisedAttachmentCategory.Images,
),
) {
internal fun normalized(): SupervisedCapabilities = copy(
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
attachmentCategories = attachmentCategories.ifEmpty {
setOf(SupervisedAttachmentCategory.Images)
},
)
companion object {
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
const val MAX_ATTACHMENT_COUNT = 10
const val MAX_ATTACHMENT_FILE_MB = 100
}
}
/** Appearance applied only while the supervised root is locked. */
@Serializable
data class SupervisedAppearance(
val appThemeId: String = AppThemes.DEFAULT_ID,
val themePreference: String = "auto",
val showPet: Boolean = false,
val allowProfileIconChanges: Boolean = false,
val allowBackgroundChanges: Boolean = false,
) {
internal fun normalized(): SupervisedAppearance = copy(
appThemeId = AppThemes.byId(appThemeId).id,
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
)
private companion object {
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
}
}
/** Mutable operations available from a supervised conversation-history row. */
@Serializable
data class SupervisedSessionActions(
val pin: Boolean = false,
val rename: Boolean = false,
val archive: Boolean = false,
val delete: Boolean = false,
val shareTranscript: Boolean = false,
) {
val enabledCount: Int
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
val allEnabled: Boolean
get() = enabledCount == TOTAL
val noneEnabled: Boolean
get() = enabledCount == 0
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
pin = enabled,
rename = enabled,
archive = enabled,
delete = enabled,
shareTranscript = enabled,
)
companion object {
const val TOTAL = 5
}
}
enum class SupervisedSessionAction {
Pin,
Rename,
Archive,
Delete,
ShareTranscript,
}
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
if (!enabled) return true
if (!capabilities.conversationHistory) return false
return when (action) {
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
}
}
@Serializable
enum class SupervisedAttachmentCategory {
@SerialName("images")
Images,
@SerialName("documents")
Documents,
@SerialName("audio")
Audio,
@SerialName("video")
Video,
}
/**
* Controls which metadata and conversation affordances are rendered.
*
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
* technical users, while [Custom] tells the UI to honor every stored toggle.
*/
@Serializable
data class SupervisedVisibility(
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
val showAgentIdentity: Boolean = true,
val showModelName: Boolean = false,
val showProfileName: Boolean = false,
val showConnectionStatus: Boolean = true,
val showTechnicalRoute: Boolean = false,
val showTimestamps: Boolean = true,
val showToolNames: Boolean = false,
val showToolDetails: Boolean = false,
val showWorkingStatus: Boolean = true,
val showReasoning: Boolean = false,
val showUsage: Boolean = false,
) {
/** Resolve presets to the concrete flags consumed by chat presentation. */
fun resolved(): SupervisedVisibility = when (preset) {
SupervisedVisibilityPreset.Simple -> SIMPLE
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
SupervisedVisibilityPreset.Custom -> this
}
companion object {
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
val TRANSPARENT = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Transparent,
showModelName = true,
showProfileName = true,
showTechnicalRoute = true,
showToolNames = true,
showUsage = true,
)
}
}
@Serializable
enum class SupervisedVisibilityPreset {
@SerialName("simple")
Simple,
@SerialName("transparent")
Transparent,
@SerialName("custom")
Custom,
}
/** Device-authentication and automatic relock behavior for parent access. */
@Serializable
data class SupervisedParentAccess(
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
val requireDeviceAuthentication: Boolean = true,
val relockOnBackground: Boolean = true,
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
) {
internal fun normalized(): SupervisedParentAccess = copy(
requireDeviceAuthentication = true,
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
)
companion object {
const val DEFAULT_TIMEOUT_MINUTES = 5
const val MIN_TIMEOUT_MINUTES = 1
const val MAX_TIMEOUT_MINUTES = 60
}
}
@@ -0,0 +1,106 @@
package com.hermesandroid.relay.data
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
class SupervisedModeStore private constructor(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.relayDataStore)
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
dataStore.data.map { preferences ->
val decoded = decode(preferences[KEY_POLICIES])
if (decoded.corrupt) {
// A malformed persisted policy must never silently reopen the
// unrestricted app. Enabled + unconfigured renders the
// supervised recovery surface until an authenticated user
// repairs or clears the policy.
SupervisedModePolicy(enabled = true)
} else {
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
}
}
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
policies[connectionId] = policy.normalized()
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
suspend fun updatePolicy(
connectionId: String,
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
) {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
policies[connectionId] = transform(current).normalized()
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
updatePolicy(connectionId) { it.copy(enabled = enabled) }
}
suspend fun clear(connectionId: String) {
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
policies.remove(connectionId)
if (policies.isEmpty()) {
preferences.remove(KEY_POLICIES)
} else {
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
}
/** Clear supervised policies without disturbing unrelated app settings. */
suspend fun clearAll() {
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
}
private fun decode(raw: String?): DecodeResult {
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
return try {
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
} catch (error: Exception) {
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
DecodeResult(emptyMap(), corrupt = true)
}
}
private data class DecodeResult(
val policies: Map<String, SupervisedModePolicy>,
val corrupt: Boolean,
)
internal companion object {
private const val TAG = "SupervisedModeStore"
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
SupervisedModeStore(dataStore)
}
}
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
)
send(pong)
}
"auth.ok", "auth.fail" -> {
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
// Delegate to system handler if registered
handlers["system"]?.onMessage(envelope)
}
@@ -442,6 +442,35 @@ class ConnectionManager(
return true
}
/**
* Reopen the current authenticated Relay socket without discarding pair
* state. Used only as a compatibility fallback when an older Relay does
* not acknowledge a post-auth metadata update; the replacement socket's
* normal `system/auth` frame carries the latest metadata.
*/
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
if (isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
) {
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
return false
}
val previousSocket = webSocket
if (previousSocket == null) {
connect(targetUrl)
} else {
doConnect(
targetUrl,
previousSocketToClose = previousSocket,
replaceReason = "Relay metadata compatibility refresh",
)
}
return true
}
/**
* Same as [connect] but bypasses the resolver — used by the network-
* change callback when we've already picked a winner and just want to
@@ -48,6 +48,9 @@ interface VoiceAudioClient {
val effectiveRoute: VoiceAudioRoute
get() = route
/** Temporary client-policy override; the shared router honors it before user prefs. */
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
@@ -82,8 +85,15 @@ class AutoVoiceAudioClient(
private val standardReadyProvider: () -> Boolean,
private val relayReadyProvider: () -> Boolean,
) : VoiceAudioClient {
@Volatile
private var routeOverride: VoiceAudioRoute? = null
override fun setRouteOverride(route: VoiceAudioRoute?) {
routeOverride = route
}
override val route: VoiceAudioRoute
get() = routeProvider()
get() = routeOverride ?: routeProvider()
/**
* Resolve the configured preference to the backend a call would land on:
@@ -92,7 +102,7 @@ class AutoVoiceAudioClient(
* decide whether standard-only limitations (global TTS) currently apply.
*/
override val effectiveRoute: VoiceAudioRoute
get() = when (routeProvider()) {
get() = when (route) {
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
VoiceAudioRoute.Auto ->
@@ -114,7 +124,7 @@ class AutoVoiceAudioClient(
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
return when (routeProvider()) {
return when (route) {
VoiceAudioRoute.Standard -> {
if (!standardReadyProvider()) {
Result.failure(
@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -133,6 +134,8 @@ import com.hermesandroid.relay.data.CandidateBuild
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedModeStore
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel
@@ -144,6 +147,7 @@ import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.delay
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
import com.hermesandroid.relay.ui.screens.AboutScreen
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
@@ -170,9 +174,13 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
import com.hermesandroid.relay.ui.screens.PluginsScreen
import com.hermesandroid.relay.ui.screens.PluginPageScreen
import com.hermesandroid.relay.ui.screens.GitStateScreen
import com.hermesandroid.relay.viewmodel.GitStateViewModel
import com.hermesandroid.relay.ui.screens.TerminalScreen
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
@@ -192,7 +200,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
import com.hermesandroid.relay.viewmodel.PluginsViewModel
import com.hermesandroid.relay.viewmodel.PluginsHubState
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel
@@ -440,6 +450,7 @@ sealed class Screen(
}
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
data object PluginPage : Screen(
"plugins/{pluginId}/pages/{pageId}",
"Plugin",
@@ -531,6 +542,17 @@ sealed class Screen(
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
// above for the surviving route.)
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
data object SupervisedAppearanceSettings : Screen(
"settings/supervised/appearance",
"Appearance",
Icons.Filled.Settings,
)
data object SupervisedControls : Screen(
"settings/supervised",
"Supervised mode",
Icons.Filled.Settings,
)
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
@@ -589,6 +611,24 @@ sealed class Screen(
}
}
@Composable
private fun SupervisedStartupLoadingScreen() {
HermesRelayTheme(themePreference = "dark") {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Text(
text = "Loading protected settings…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun RelayApp() {
val applicationContext = LocalContext.current.applicationContext
@@ -597,13 +637,17 @@ fun RelayApp() {
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
val terminalViewModel: TerminalViewModel = viewModel()
val pluginsViewModel: PluginsViewModel = viewModel()
val gitStateViewModel: GitStateViewModel = viewModel()
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
LaunchedEffect(processRuntime) {
processRuntime.ensureInitialized()
}
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
SupervisedStartupLoadingScreen()
return
}
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
val voicePreferences = processRuntime.voicePreferences
@@ -700,6 +744,72 @@ fun RelayApp() {
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val connectionStoreHydrated by
connectionViewModel.connectionStore.isHydrated.collectAsState()
val supervisedModeStore = remember(applicationContext) {
SupervisedModeStore(applicationContext)
}
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
initialValue = null,
key1 = activeConnectionId,
key2 = supervisedModeStore,
) {
val connectionId = activeConnectionId
if (connectionId == null) {
value = null to SupervisedModePolicy()
} else {
supervisedModeStore.policyFlow(connectionId).collect { policy ->
value = connectionId to policy
}
}
}
val ownedSupervisedPolicyState = supervisedPolicyState.value
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
// Fail closed across process restoration. activeConnectionId starts as
// null while ConnectionStore reads DataStore, so null alone cannot prove
// this is a fresh install with no supervised policy to restore.
if (!isRelayNavigationHydrated(
connectionStoreHydrated = connectionStoreHydrated,
activeConnectionId = activeConnectionId,
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
)
) {
SupervisedStartupLoadingScreen()
return
}
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
}
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
profileSelectionSettled &&
supervisedPinnedProfile != null &&
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
)
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
supervisedPolicy.copy(pinnedProfileName = null)
} else supervisedPolicy
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
LaunchedEffect(
activeConnectionId,
supervisedPolicy,
agentProfiles,
selectedProfile,
profileSelectionSettled,
) {
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
if (!supervisedPolicy.enabled) {
parentAccessUnlocked = false
return@LaunchedEffect
}
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
connectionViewModel.selectProfile(pinned)
chatViewModel.activateGatewayProfile(pinned)
}
}
val connections by connectionViewModel.connections.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
@@ -746,6 +856,11 @@ fun RelayApp() {
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val gitOwnerKey = activeConnectionId?.takeIf { it.isNotBlank() }?.let { connectionId ->
effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { dashboardUrl ->
"$connectionId\u0000${effectiveSessionProfileName.orEmpty()}\u0000$dashboardUrl"
}
}
LaunchedEffect(
activeConnectionId,
effectiveDashboardUrl,
@@ -760,6 +875,28 @@ fun RelayApp() {
sessionId = currentChatSessionId,
)
}
LaunchedEffect(gitOwnerKey) {
val dashboard = effectiveDashboardUrl
.takeIf { it.isNotBlank() }
?.let { connectionViewModel.dashboardClientForActive(it) }
gitStateViewModel.configure(dashboard, gitOwnerKey)
}
// Mirror the plugin.api.write grant into the Git view model so write
// mutations are refused client-side until the user grants write access
// (matches the plug-in's grant gating in PluginsViewModel).
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
LaunchedEffect(pluginsHubState, gitOwnerKey) {
val ready = pluginsHubState as? PluginsHubState.Ready
val granted = ready
?.takeIf { it.ownerKey == gitOwnerKey }
?.plugins
?.firstOrNull { it.catalog.id == "hermes-relay" }
?.preferences
?.grants
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
}
// What's New auto-show
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
@@ -784,6 +921,22 @@ fun RelayApp() {
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val parentAccessForCurrentRoute = parentAccessUnlocked &&
!shouldRelockParentAccess(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessUnlocked,
route = currentRoute,
)
val resolvedTheme = resolveSupervisedTheme(
policy = supervisedPolicy,
parentAccessUnlocked = parentAccessForCurrentRoute,
globalAppThemeId = appThemeId,
globalThemePreference = themePreference,
)
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
// publish it + the full available set so every MorphingSphere picks it up
@@ -800,10 +953,10 @@ fun RelayApp() {
value = SphereRegistry.builtIns +
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
}
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
SphereRegistry.resolve(
selectedId = sphereSkinId,
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
available = availableSphereSkins,
)
}
@@ -938,37 +1091,19 @@ fun RelayApp() {
),
)
HermesRelayTheme(
appThemeId = appThemeId,
themePreference = themePreference,
appThemeId = resolvedTheme.appThemeId,
themePreference = resolvedTheme.themePreference,
fontScale = fontScale,
appFontId = appFontId,
accentHex = appearanceAccent,
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
shapeId = appearanceShape,
customTheme = activeCustomTheme,
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
) {
// Surface a crash report from a previous session, if any. Renders a
// platform Dialog (own window) so tree position is z-order-agnostic;
// it just needs to be inside the theme for Material colors.
CrashReportGate()
val navController = rememberNavController()
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the
// BridgeForegroundService notification's "Settings" action). The
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
// we forward each emission to the NavController. Single observer at
// the app root so every screen benefits.
LaunchedEffect(navController) {
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
navController.navigate(route) {
launchSingleTop = true
}
}
}
// === END PHASE3-safety-rails-followup ===
// Wire the proactive "session" surfacing once: a message with
// surfacing="session" is injected into the active chat conversation.
// ChatViewModel isn't available where ConnectionViewModel builds the
@@ -1039,8 +1174,74 @@ fun RelayApp() {
// restart cleanly lands back in setup.
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// The unlock remains useful while moving between parent-only settings,
// but never follows an enrolled device user back into supervised chat.
// Cross-layer requests (notifications, services, deep links) use the
// route-scoped unlock. As soon as Chat is current, the parent grant is
// ineffective even before the state-clearing effect runs.
LaunchedEffect(
navController,
supervisedPolicy.enabled,
parentAccessForCurrentRoute,
) {
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
if (
supervisedPolicy.enabled &&
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
) return@collect
navController.navigate(route) {
launchSingleTop = true
}
}
}
LaunchedEffect(
supervisedPolicy.enabled,
parentAccessForCurrentRoute,
currentRoute,
) {
val redirect = shouldRedirectSupervisedRoute(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = currentRoute,
)
if (redirect) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
launchSingleTop = true
}
}
}
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
// Route-scoped authority is already false on Chat. Let Navigation
// finish committing the new destination before clearing the raw
// parent grant, otherwise the same-frame root recomposition can
// leave a themed but contentless surface.
withFrameNanos { }
withFrameNanos { }
parentAccessUnlocked = false
}
}
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
if (parentAccessUnlocked) {
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
parentAccessUnlocked = false
}
}
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
val relockObserver = LifecycleEventObserver { _, event ->
if (
event == Lifecycle.Event.ON_PAUSE &&
supervisedPolicy.enabled &&
parentAccessUnlocked &&
supervisedPolicy.parentAccess.relockOnBackground
) {
parentAccessUnlocked = false
}
}
lifecycleOwner.lifecycle.addObserver(relockObserver)
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
}
val suppressGlobalChrome = shouldSuppressGlobalChrome(
onboardingCompleted = onboardingCompleted,
isDemoMode = isDemoMode,
@@ -1719,6 +1920,8 @@ fun RelayApp() {
!suppressGlobalChrome &&
!isKeyboardVisible &&
!showStartupSphere &&
(!supervisedPolicy.enabled ||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
) {
val footerRoute = resolveFooterRouteCandidate(
@@ -1793,12 +1996,16 @@ fun RelayApp() {
.fillMaxSize()
.padding(innerPadding),
) {
val routeContentAllowed = isSupervisedRouteContentAllowed(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = currentRoute,
)
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
modifier = Modifier.fillMaxSize(),
) {
composable(Screen.Onboarding.route) {
// The wizard inside OnboardingScreen now owns credential
@@ -1885,15 +2092,43 @@ fun RelayApp() {
// sheet.
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val requestedSessionId = backStackEntry.arguments
val rawRequestedSessionId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_SESSION_ID)
?.takeIf { it.isNotBlank() }
val requestedProfileRoute = backStackEntry.arguments
val rawRequestedProfileRoute = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROFILE)
?.takeIf { it.isNotBlank() }
val requestedProactiveChatId = backStackEntry.arguments
val rawRequestedProactiveChatId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
?.takeIf { it.isNotBlank() }
// Nav/deep-link arguments are not ownership evidence. The
// supervised drawer uses profile-scoped session rows
// directly; external args stay discarded until an
// owner-aware source can explicitly prove the binding.
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
policy = supervisedPolicy,
args = SupervisedChatRouteArgs(
sessionId = rawRequestedSessionId,
profile = rawRequestedProfileRoute,
proactiveChatId = rawRequestedProactiveChatId,
),
pinnedProfileOwnershipProven = false,
)
val requestedSessionId = sanitizedRouteArgs.sessionId
val requestedProfileRoute = sanitizedRouteArgs.profile
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
LaunchedEffect(
supervisedPolicy.enabled,
rawRequestedSessionId,
rawRequestedProfileRoute,
rawRequestedProactiveChatId,
) {
if (supervisedPolicy.enabled) {
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
}
}
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
LaunchedEffect(
@@ -2056,6 +2291,7 @@ fun RelayApp() {
launchSingleTop = true
}
},
supervisedPolicy = chatSupervisedPolicy,
onNavigateToBotMode = {
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
},
@@ -2376,6 +2612,25 @@ fun RelayApp() {
SettingsScreen(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
supervisedPolicy = supervisedPolicy,
parentAccessUnlocked = parentAccessForCurrentRoute,
onRequestParentAccess = { parentAccessUnlocked = true },
onUpdateSupervisedPolicy = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onNavigateToAdvancedSettings = {
navController.navigate(Screen.AdvancedSettings.route)
},
onNavigateToSupervisedAppearance = {
navController.navigate(Screen.SupervisedAppearanceSettings.route)
},
onNavigateToSupervisedControls = {
navController.navigate(Screen.SupervisedControls.route)
},
onBack = { navController.popBackStack() },
// (The `onNavigateToChatWithAgentSheet` callback that
// used to live here was removed 2026-04-21. Tapping
@@ -2450,6 +2705,62 @@ fun RelayApp() {
},
)
}
composable(Screen.AdvancedSettings.route) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
AdvancedSettingsScreen(
supervisedPolicy = supervisedPolicy,
onNavigateToSupervisedControls = {
navController.navigate(Screen.SupervisedControls.route)
},
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.SupervisedAppearanceSettings.route) {
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedAppearanceSettingsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
onPolicyChange = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.SupervisedControls.route) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedControlsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
profiles = agentProfiles.filterNot { it.isDefault },
onPolicyChange = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onBack = { navController.popBackStack() },
onReturnToSupervisedView = {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(Screen.Chat.route) { inclusive = false }
launchSingleTop = true
}
},
)
}
}
composable(Screen.ProviderUsage.route) {
UsageLimitsScreen(
connectionViewModel = connectionViewModel,
@@ -2462,10 +2773,20 @@ fun RelayApp() {
viewModel = pluginsViewModel,
onBack = { navController.popBackStack() },
onOpenPage = { pluginId, pageId ->
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
if (pluginId == "hermes-relay" && pageId == "git") {
navController.navigate(Screen.GitState.route)
} else {
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
}
},
)
}
composable(Screen.GitState.route) {
GitStateScreen(
viewModel = gitStateViewModel,
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.PluginPage.route,
arguments = listOf(
@@ -2927,7 +3248,8 @@ fun RelayApp() {
composable(Screen.About.route) {
AboutScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
)
}
composable(
@@ -3030,6 +3352,12 @@ fun RelayApp() {
)
}
}
if (!routeContentAllowed) {
// Keep the graph mounted so the redirect can complete, but
// cover restored parent-only content with an opaque fail-closed surface.
SupervisedStartupLoadingScreen()
}
}
} // end bridge-return wrapper column
} // end CompositionLocalProvider
}
@@ -3042,6 +3370,7 @@ fun RelayApp() {
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
val showFloatingPet = activeFloatingPet != null &&
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
floatingPetAllowedOnRoute(currentRoute) &&
!petActivity.hidden &&
!suppressGlobalChrome &&
@@ -3072,6 +3401,7 @@ fun RelayApp() {
),
animationEnabled = animationEnabled,
appForeground = appIsForeground,
interactive = !supervisedAppearanceLocked,
route = roamingRoute,
visitRequest = petCompanionCoordinator.pendingVisitRequest,
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
@@ -0,0 +1,34 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedModePolicy
internal data class ResolvedSupervisedTheme(
val appThemeId: String,
val themePreference: String,
val useGlobalCustomTheme: Boolean,
)
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
internal fun resolveSupervisedTheme(
policy: SupervisedModePolicy,
parentAccessUnlocked: Boolean,
globalAppThemeId: String,
globalThemePreference: String,
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
ResolvedSupervisedTheme(
appThemeId = policy.appearance.appThemeId,
themePreference = policy.appearance.themePreference,
useGlobalCustomTheme = false,
)
} else {
ResolvedSupervisedTheme(
appThemeId = globalAppThemeId,
themePreference = globalThemePreference,
useGlobalCustomTheme = true,
)
}
internal fun shouldShowPetInSupervisedMode(
policy: SupervisedModePolicy,
parentAccessUnlocked: Boolean,
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
@@ -0,0 +1,107 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.SupervisedModePolicy
/** Allowlist applied to external, deep-link, and programmatic navigation. */
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
if (parentAccessUnlocked) return true
val normalized = route?.substringBefore('?') ?: return false
return normalized == "chat" ||
normalized == Screen.Settings.route ||
normalized == Screen.SupervisedAppearanceSettings.route
}
/** Do not inspect or mutate a NavController until its first destination exists. */
internal fun shouldRedirectSupervisedRoute(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
currentRoute: String?,
): Boolean = currentRoute != null &&
supervisedEnabled &&
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
internal fun isSupervisedRouteContentAllowed(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
currentRoute: String?,
): Boolean = currentRoute == null ||
!supervisedEnabled ||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
/**
* Cold-start gate for the app navigation graph.
*
* A null active connection is also the seed value used while [ConnectionStore]
* is reading DataStore. Callers must therefore wait for the store's explicit
* hydration signal before treating null as "no connection" and composing the
* unrestricted onboarding/settings graph.
*/
internal fun isRelayNavigationHydrated(
connectionStoreHydrated: Boolean,
activeConnectionId: String?,
supervisedPolicyHydrated: Boolean,
): Boolean = connectionStoreHydrated &&
(activeConnectionId == null || supervisedPolicyHydrated)
/** A parent unlock never follows the user back into the supervised chat root. */
internal fun shouldRelockParentAccess(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
route: String?,
): Boolean = supervisedEnabled &&
parentAccessUnlocked &&
route?.substringBefore('?') == "chat"
/**
* External chat route arguments are untrusted. A session may be restored only
* after an owner-aware source has proved that it belongs to the pinned profile.
*/
internal fun mayRestoreSupervisedSessionRoute(
policy: SupervisedModePolicy,
requestedSessionId: String?,
requestedProfile: String?,
pinnedProfileOwnershipProven: Boolean,
): Boolean = policy.isActive &&
policy.capabilities.conversationHistory &&
pinnedProfileOwnershipProven &&
!requestedSessionId.isNullOrBlank() &&
!requestedProfile.isNullOrBlank() &&
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
internal data class SupervisedChatRouteArgs(
val sessionId: String? = null,
val profile: String? = null,
val proactiveChatId: String? = null,
)
/** Strip external chat targeting before any destination effect can dispatch it. */
internal fun sanitizeSupervisedChatRouteArgs(
policy: SupervisedModePolicy,
args: SupervisedChatRouteArgs,
pinnedProfileOwnershipProven: Boolean,
): SupervisedChatRouteArgs {
if (!policy.enabled) return args
val allowSession = mayRestoreSupervisedSessionRoute(
policy = policy,
requestedSessionId = args.sessionId,
requestedProfile = args.profile,
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
)
return if (allowSession) {
args.copy(proactiveChatId = null)
} else {
SupervisedChatRouteArgs()
}
}
/** A disabled policy may become active only after an enrolled credential succeeds. */
internal fun mayEnableSupervisedMode(
policy: SupervisedModePolicy,
deviceSecure: Boolean,
deviceCredentialConfirmed: Boolean,
): Boolean = !policy.enabled &&
policy.isConfigured &&
deviceSecure &&
deviceCredentialConfirmed
@@ -125,6 +125,7 @@ fun AttachmentGallery(
if (attachments.size < 2) return
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
val scope = rememberCoroutineScope()
val blurMode = LocalMediaBlurMode.current
val revealed = remember { mutableStateMapOf<String, Boolean>() }
@@ -189,7 +190,7 @@ fun AttachmentGallery(
)
}
if (!blurred) {
if (!blurred && exportAllowed) {
SaveOverlayButton(
onClick = {
scope.launch { saveAttachment(context, attachment) }
@@ -201,7 +202,7 @@ fun AttachmentGallery(
}
AttachmentActionsMenu(
expanded = menuExpanded,
expanded = menuExpanded && exportAllowed,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -312,6 +312,8 @@ fun AttachmentViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@@ -405,6 +407,7 @@ fun AttachmentViewer(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -448,6 +451,7 @@ internal fun AttachmentGalleryViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@@ -584,6 +588,7 @@ internal fun AttachmentGalleryViewer(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -613,6 +618,7 @@ private fun MediaViewerToolbar(
title: String,
busy: Boolean,
actionsEnabled: Boolean = true,
exportAllowed: Boolean = true,
onShare: () -> Unit,
onSave: () -> Unit,
onOpenExternal: () -> Unit,
@@ -653,11 +659,13 @@ private fun MediaViewerToolbar(
) {
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
}
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
if (exportAllowed) {
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
}
}
}
}
@@ -32,6 +32,7 @@ fun ChatFailurePanel(
onDetails: () -> Unit,
onRetry: () -> Unit,
onDismiss: () -> Unit,
showDetails: Boolean = true,
modifier: Modifier = Modifier,
) {
Surface(
@@ -72,8 +73,10 @@ fun ChatFailurePanel(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
if (showDetails) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
}
}
if (failure.recoverable) {
TextButton(onClick = onRetry) {
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -42,6 +43,9 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.MediaSaver
import kotlinx.coroutines.launch
/** Whether the current conversation policy permits copying image bytes out of the app. */
val LocalImageExportAllowed = staticCompositionLocalOf { true }
/**
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
*
@@ -104,6 +108,7 @@ fun ChatImageViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
@@ -156,60 +161,72 @@ fun ChatImageViewer(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
toast(context, errorMsg)
return@launch
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
toast(context, savedFmt.format(result.location))
toast(context, errorMsg)
return@launch
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
}
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
@@ -483,6 +483,7 @@ fun FloatingPetCompanion(
compact: Boolean,
animationEnabled: Boolean,
appForeground: Boolean,
interactive: Boolean = true,
route: String?,
visitRequest: PetVisitRequest?,
onVisitRequestConsumed: (String) -> Unit,
@@ -2318,13 +2319,14 @@ fun FloatingPetCompanion(
}
.pointerInput(
pet.id,
interactive,
safeBounds,
roamingRails,
settledHabitat,
positioned,
surfaceScrolling,
) {
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
return@pointerInput
}
detectDragGesturesAfterLongPress(
@@ -2399,16 +2401,16 @@ fun FloatingPetCompanion(
)
}
.clickable(
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
) {
tapReactionNonce += 1
setMenuExpanded(true)
}
.semantics(mergeDescendants = true) {
role = Role.Button
if (interactive) role = Role.Button
contentDescription = companionDescription
stateDescription = stateLabel
customActions = buildList {
customActions = if (interactive) buildList {
add(CustomAccessibilityAction(moveStartLabel) {
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
})
@@ -2438,7 +2440,7 @@ fun FloatingPetCompanion(
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
}
} else emptyList()
},
contentAlignment = Alignment.Center,
) {
@@ -2482,7 +2484,7 @@ fun FloatingPetCompanion(
}
DropdownMenu(
expanded = menuExpanded,
expanded = interactive && menuExpanded,
onDismissRequest = { setMenuExpanded(false) },
) {
DropdownMenuItem(
@@ -270,6 +270,7 @@ private fun ImageRender(
maxWidth: Dp
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
val scope = rememberCoroutineScope()
// Decode OFF the main thread — a large inbound image would otherwise block
// composition. Null while decoding (placeholder); decodeFailed → file card.
@@ -356,14 +357,14 @@ private fun ImageRender(
}
// One-tap save overlay — hidden while the blur cover is up so it
// doesn't sit over the "tap to reveal" prompt.
if (!blurred) {
if (!blurred && exportAllowed) {
SaveOverlayButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
)
}
AttachmentActionsMenu(
expanded = menuExpanded,
expanded = menuExpanded && exportAllowed,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -380,6 +381,8 @@ private fun FileCardRender(
maxWidth: Dp
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
val scope = rememberCoroutineScope()
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
var menuExpanded by remember { mutableStateOf(false) }
@@ -463,21 +466,23 @@ private fun FileCardRender(
}
}
// Visible one-tap save affordance (B2).
IconButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
if (exportAllowed) {
IconButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
AttachmentActionsMenu(
expanded = menuExpanded,
expanded = menuExpanded && exportAllowed,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -94,6 +94,14 @@ import java.util.Date
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
internal fun assistantImageContent(
content: String,
showImages: Boolean,
): Pair<String, List<ChatInlineImage>> {
val (body, images) = extractChatInlineImages(content)
return body to if (showImages) images else emptyList()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
@@ -101,6 +109,13 @@ fun MessageBubble(
modifier: Modifier = Modifier,
maxBubbleWidth: Dp = 300.dp,
showThinking: Boolean = true,
showAgentIdentity: Boolean = true,
showTimestamps: Boolean = true,
showWorkingStatus: Boolean = true,
showUsage: Boolean = true,
showTechnicalBadges: Boolean = true,
showAssistantImages: Boolean = true,
allowAssistantImageExport: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
onCopyMessage: (String) -> Unit = {},
@@ -238,18 +253,24 @@ fun MessageBubble(
// content so they render as real images (remote URLs via Coil) or a
// graceful inline notice — not the blank element the markdown renderer
// emits for an image link. User/system bubbles keep their raw content.
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
val (markdownBody, inlineImages) = remember(
visibleMessageContent,
isUser,
isSystem,
showAssistantImages,
) {
if (isUser || isSystem) {
visibleMessageContent to emptyList()
} else {
extractChatInlineImages(visibleMessageContent)
assistantImageContent(visibleMessageContent, showAssistantImages)
}
}
val showImageGeneration = shouldShowImageGenerationPlaceholder(
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
toolCalls = message.toolCalls,
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
val streamingStatusLabel = if (
!isUser &&
!isSystem &&
@@ -297,7 +318,10 @@ fun MessageBubble(
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
CompositionLocalProvider(
LocalMediaBlurMode provides blurMode,
LocalImageExportAllowed provides allowAssistantImageExport,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment,
@@ -305,7 +329,7 @@ fun MessageBubble(
// Keep sender identity in the first-message label rather than a
// persistent leading column. Long responses and every follow-up in the
// group therefore retain the full bubble-width allowance.
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -336,7 +360,7 @@ fun MessageBubble(
}
}
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
Row(
modifier = Modifier
.widthIn(max = maxBubbleWidth)
@@ -415,7 +439,7 @@ fun MessageBubble(
// is rendered directly in the conversation
// lane below, without an opaque bubble. Cards and attachments still own
// a normal bubble even when response prose has not arrived yet.
streamingStatusLabel?.let { streamingStatus ->
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
StandaloneStreamingStatus(
status = streamingStatus,
accessibilityDescription = a11yDescription,
@@ -508,7 +532,7 @@ fun MessageBubble(
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
showMessageActions = false
onCopyMessage(visibleMessageContent)
onCopyMessage(actionContent)
},
)
if (onQuoteMessage != null) {
@@ -516,7 +540,7 @@ fun MessageBubble(
text = { Text(stringResource(R.string.msg_bubble_quote)) },
onClick = {
showMessageActions = false
onQuoteMessage(message.copy(content = visibleMessageContent))
onQuoteMessage(message.copy(content = actionContent))
},
)
}
@@ -531,7 +555,7 @@ fun MessageBubble(
},
onClick = {
showMessageActions = false
onSpeakMessage?.invoke(visibleMessageContent)
onSpeakMessage?.invoke(actionContent)
},
)
}
@@ -601,7 +625,7 @@ fun MessageBubble(
) {
showMessageActions = true
} else {
onCopyMessage(visibleMessageContent)
onCopyMessage(actionContent)
}
}
)
@@ -798,7 +822,7 @@ fun MessageBubble(
}
}
val hasTokenUsage = !isUser &&
val hasTokenUsage = showUsage && !isUser &&
(message.inputTokens != null || message.outputTokens != null)
// Timestamp — only on the LAST bubble of a same-author run so a
@@ -808,13 +832,13 @@ fun MessageBubble(
// This row is reserved from the first streaming frame. Completion
// can reveal both timestamp and token usage without adding a new
// footer line or changing the bubble's measured height.
if (isLastInGroup) {
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (showTimestamps) Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
@@ -883,15 +907,15 @@ fun MessageBubble(
showEdit = showEditAction,
onCopy = {
showInlineActions = false
onCopyMessage(visibleMessageContent)
onCopyMessage(actionContent)
},
onQuote = {
showInlineActions = false
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
onQuoteMessage?.invoke(message.copy(content = actionContent))
},
onSpeak = {
showInlineActions = false
onSpeakMessage?.invoke(visibleMessageContent)
onSpeakMessage?.invoke(actionContent)
},
onStopSpeaking = {
showInlineActions = false
@@ -109,6 +109,7 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedSessionActions
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
@@ -208,6 +209,8 @@ fun SessionDrawerContent(
animationEnabled: Boolean = true,
autoTitlesSupported: Boolean = true,
archiveSupported: Boolean = true,
supervisedSessionActions: SupervisedSessionActions? = null,
newChatEnabled: Boolean = true,
onRefresh: (() -> Unit)? = null,
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
onOpenBotMode: (() -> Unit)? = null,
@@ -288,8 +291,10 @@ fun SessionDrawerContent(
allowBareSessionIds = !showAllProfiles,
)
val sourceSessions = sourceRows.map { it.session }
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
val showThreads = supervisedSessionActions == null &&
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
// External gateway sources present (discord/telegram/cron/…) for the source
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
val presentSources = sourceSessions
@@ -405,7 +410,7 @@ fun SessionDrawerContent(
)
// Source filter — show/hide gateway sources (default hides the
// noisy cron+webhook). Only when external sources are present.
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
Box {
IconButton(
onClick = { sourceFilterOpen = true },
@@ -466,7 +471,7 @@ fun SessionDrawerContent(
// Threads affordance — a clean thread-spool that toggles the Threads
// filter. Shown only when the Threads capability is active (or a Thread is
// already present), so an ordinary no-relay drawer is visually unchanged.
if (showThreads) {
if (supervisedSessionActions == null && showThreads) {
IconButton(
onClick = {
filter = if (filter == SessionDrawerFilter.Threads) {
@@ -537,7 +542,8 @@ fun SessionDrawerContent(
onNewChat()
}
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
enabled = newChatEnabled,
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
@@ -602,8 +608,10 @@ fun SessionDrawerContent(
}
SessionDrawerFilter.entries
.filter { item ->
(item != SessionDrawerFilter.Threads || showThreads) &&
(item != SessionDrawerFilter.Archive || archiveSupported)
(item != SessionDrawerFilter.Threads ||
(supervisedSessionActions == null && showThreads)) &&
(item != SessionDrawerFilter.Archive ||
effectiveArchiveSupported)
}
.forEach { item ->
FilterChip(
@@ -635,17 +643,19 @@ fun SessionDrawerContent(
)
}
}
TextButton(
onClick = { customizeOpen = true },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.drawer_customize_sessions))
if (supervisedSessionActions == null) {
TextButton(
onClick = { customizeOpen = true },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.drawer_customize_sessions))
}
}
// "+ New Thread" — Discord-style user-created thread, shown when the
// Threads filter is active. The first message opens the conversation.
@@ -779,13 +789,20 @@ fun SessionDrawerContent(
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
nowMillis = drawerNowMillis,
actionsEnabled = !provisional,
actionsEnabled = !provisional && (
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
supervisedSessionActions.rename ||
supervisedSessionActions.delete ||
(supervisedSessionActions.archive && archiveSupported)
),
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled && isOpen,
pinned = session.pinned,
archived = session.archived,
archiveSupported = archiveSupported,
supervisedSessionActions = supervisedSessionActions,
onClick = {
if (showAllProfiles) {
onSelectProfileSession?.invoke(row.profile, session.sessionId)
@@ -1347,6 +1364,7 @@ private fun SessionItem(
pinned: Boolean,
archived: Boolean,
archiveSupported: Boolean,
supervisedSessionActions: SupervisedSessionActions?,
onClick: () -> Unit,
onTogglePinned: () -> Unit,
onToggleArchived: () -> Unit,
@@ -1510,7 +1528,7 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
text = {
Text(
if (pinned) {
@@ -1536,7 +1554,7 @@ private fun SessionItem(
onTogglePinned()
},
)
DropdownMenuItem(
if (supervisedSessionActions == null) DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -1546,7 +1564,7 @@ private fun SessionItem(
onCopySessionId()
},
)
DropdownMenuItem(
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
@@ -1556,7 +1574,7 @@ private fun SessionItem(
onRename()
},
)
if (archiveSupported) {
if (archiveSupported && supervisedSessionActions?.archive != false) {
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
@@ -1576,7 +1594,7 @@ private fun SessionItem(
},
)
}
DropdownMenuItem(
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
text = {
Text(
text = stringResource(R.string.drawer_delete),
@@ -87,6 +87,8 @@ fun AboutScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onUnlockDeveloperOptions: () -> Unit = {},
/** Supervised clients may read About without gaining a settings mutation backdoor. */
allowDeveloperUnlock: Boolean = true,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -218,7 +220,7 @@ fun AboutScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
.clickable(enabled = allowDeveloperUnlock) {
if (devOptionsUnlocked) return@clickable
val now = System.currentTimeMillis()
if (now - lastTapTime > 2000) {
@@ -0,0 +1,96 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.ui.theme.LocalBrand
/** Optional and specialized features kept off the primary Settings surface. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdvancedSettingsScreen(
supervisedPolicy: SupervisedModePolicy,
onNavigateToSupervisedControls: () -> Unit,
onBack: () -> Unit,
) {
val isDarkTheme = LocalBrand.current.isDark
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
title = { Text(stringResource(R.string.settings_advanced)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.settings_advanced_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.settings_supervised_mode),
subtitle = when {
supervisedPolicy.isActive -> stringResource(
R.string.settings_supervised_on_profile,
supervisedPolicy.pinnedProfileName.orEmpty(),
)
supervisedPolicy.isConfigured -> stringResource(
R.string.settings_supervised_ready_profile,
supervisedPolicy.pinnedProfileName.orEmpty(),
)
else -> stringResource(R.string.settings_supervised_desc)
},
badge = supervisedPolicy.takeIf { it.isActive }?.let {
SettingsStatusPillModel(
label = stringResource(R.string.settings_supervised_on),
tone = SettingsStatusTone.Good,
)
},
onClick = onNavigateToSupervisedControls,
isDarkTheme = isDarkTheme,
petPerchKey = null,
)
}
}
}
@@ -1487,26 +1487,27 @@ private fun AppearanceSummaryRow(
/** Representative, theme-live chat sample so presets are judged in context. */
@Composable
private fun AppearanceLivePreview(
internal fun AppearanceLivePreview(
palette: BrandPalette,
shapeScale: AppearanceShapeScale,
restricted: Boolean = false,
) {
CompositionLocalProvider(
LocalBrand provides palette,
LocalAppearanceShapeScale provides shapeScale,
) {
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
AppearanceLivePreviewContent()
AppearanceLivePreviewContent(restricted = restricted)
}
}
}
@Composable
private fun AppearanceLivePreviewContent() {
private fun AppearanceLivePreviewContent(restricted: Boolean) {
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
val backgroundAvatar = LocalAgentAvatar.current
Card(
modifier = Modifier.fillMaxWidth().height(294.dp),
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
@@ -1648,14 +1649,16 @@ private fun AppearanceLivePreviewContent() {
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
Text(
text = stringResource(R.string.appearance_preview_tool_meta),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
modifier = Modifier.padding(start = 5.dp),
)
if (!restricted) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
Text(
text = stringResource(R.string.appearance_preview_tool_meta),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
modifier = Modifier.padding(start = 5.dp),
)
}
}
}
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
@@ -1678,10 +1681,12 @@ private fun AppearanceLivePreviewContent() {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
if (!restricted) {
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
}
Text(
stringResource(R.string.appearance_preview_message_placeholder),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
@@ -1691,7 +1696,7 @@ private fun AppearanceLivePreviewContent() {
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
}
}
Surface(
if (!restricted) Surface(
modifier = Modifier.align(Alignment.CenterHorizontally),
shape = appearanceRoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -1785,7 +1790,7 @@ private fun FontOptionRow(
* are added.
*/
@Composable
private fun ThemeSwatchChip(
internal fun ThemeSwatchChip(
appTheme: AppTheme,
selected: Boolean,
onClick: () -> Unit,
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
@@ -147,6 +148,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
@@ -179,6 +181,11 @@ import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
import com.hermesandroid.relay.data.ProfilePresentationPolicy
import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
import com.hermesandroid.relay.data.allowsSessionAction
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet
@@ -710,8 +717,40 @@ fun ChatScreen(
// existing test/preview call sites keep compiling.
onNavigateToVoiceSettings: () -> Unit = {},
onNavigateToProfileInspector: (String) -> Unit = {},
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
onNavigateToBotMode: () -> Unit = {},
) {
val supervised = supervisedPolicy.enabled
val supervisedVisibility = supervisedPolicy.visibility.resolved()
LaunchedEffect(supervisedPolicy) {
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
}
if (supervised && !supervisedPolicy.isActive) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Supervised chat unavailable") },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
},
)
},
) { padding ->
Box(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
"The supervised profile is unavailable. Parent access is required to update this connection.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
val voiceUiState by voiceViewModel.uiState.collectAsState()
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
@@ -734,7 +773,6 @@ fun ChatScreen(
LaunchedEffect(voiceUiState.voiceMode) {
if (!voiceUiState.voiceMode) voicePresentationOverride = null
}
// Route classified chat errors (media cache, streaming failures, …) to
// the app-wide snackbar. Same pattern every VM-bound screen uses.
val snackbarHost = LocalSnackbarHost.current
@@ -796,7 +834,22 @@ fun ChatScreen(
}
val messages by chatViewModel.messages.collectAsState()
val rawMessages by chatViewModel.messages.collectAsState()
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) rawMessages
else rawMessages.map { message ->
if (message.role == MessageRole.ASSISTANT) {
message.copy(
attachments = if (supervisedPolicy.capabilities.generatedImages) {
message.attachments.filter { it.isImage }
} else {
emptyList()
},
cards = emptyList(),
)
} else message
}
}
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
val newestReactableMessageKeys = remember(messages) {
setOfNotNull(
@@ -823,10 +876,17 @@ fun ChatScreen(
// Stable voice can use the standard Hermes dashboard audio routes or the
// optional Relay voice routes. Gate the mic on either route being usable;
// availability picks the actionable toast when neither is.
val voiceReady by connectionViewModel.voiceReady.collectAsState()
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val voiceReady = if (supervised) {
supervisedPolicy.capabilities.voice &&
standardVoiceAvailability ==
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
} else {
connectionVoiceReady
}
val chatSpeakResponseActionsEnabled =
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
@@ -990,8 +1050,15 @@ fun ChatScreen(
?: sessionModelState.pickerModel?.let { model ->
modelProviders.singleOrNull { model in it.models }?.slug
}
val showThinking by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
val showThinking = configuredShowThinking &&
(!supervised || supervisedVisibility.showReasoning)
val toolDisplay = if (!supervised) configuredToolDisplay else when {
supervisedVisibility.showToolDetails -> "detailed"
supervisedVisibility.showToolNames -> "compact"
else -> "off"
}
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
val keepComposerFocusedOnSend by
@@ -1010,7 +1077,10 @@ fun ChatScreen(
// marker so the user knows approvals are off without opening the agent drawer.
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
val maxAttachmentMb = if (supervised) {
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
} else configuredMaxAttachmentMb
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
// === Gateway desktop-parity state ===
@@ -1019,6 +1089,9 @@ fun ChatScreen(
val contextWindow by chatViewModel.contextWindow.collectAsState()
// Injected-context audit sheet (opened by tapping the context meter).
var showContextSheet by remember { mutableStateOf(false) }
LaunchedEffect(supervised) {
if (supervised) showContextSheet = false
}
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
val steerNotice by chatViewModel.steerNotice.collectAsState()
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
@@ -2020,9 +2093,9 @@ fun ChatScreen(
}
}
}
val showAutocomplete by remember(filteredCommands, inputText) {
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
derivedStateOf {
inputText.startsWith("/") && filteredCommands.isNotEmpty()
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
}
}
@@ -2295,7 +2368,7 @@ fun ChatScreen(
}
}
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
profiles = agentProfiles,
presentation = profilePresentation,
selectedKey = selectedProfileKey,
@@ -2322,7 +2395,7 @@ fun ChatScreen(
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = true,
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
drawerContent = {
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
val drawerTitle = if (drawerProfileName != null) {
@@ -2366,7 +2439,9 @@ fun ChatScreen(
}
SessionDrawerContent(
sessions = sessions,
sessions = if (
supervised && !supervisedPolicy.capabilities.conversationHistory
) emptyList() else sessions,
currentSessionId = currentSessionId,
scopeTitle = drawerTitle,
scopeSubtitle = drawerSubtitle,
@@ -2377,14 +2452,19 @@ fun ChatScreen(
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
archiveSupported = sessionArchivingSupported,
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
.takeIf { supervised },
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
onRefresh = { chatViewModel.refreshSessions() },
onOpenBotMode = {
scope.launch { drawerState.close() }
onNavigateToBotMode()
},
onNewChat = {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
if (!supervised || supervisedPolicy.capabilities.newChat) {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
}
},
onNewDefaultChat = {
if (isProfileLocked) return@SessionDrawerContent
@@ -2410,6 +2490,9 @@ fun ChatScreen(
scope.launch { drawerState.close() }
},
onDeleteSession = { sessionId ->
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
return@SessionDrawerContent
}
val connectionId = activeConnection?.id
val profileId = explicitBindingProfileName ?: selectedProfile?.name
chatViewModel.deleteSession(sessionId) {
@@ -2423,10 +2506,21 @@ fun ChatScreen(
}
},
onRenameSession = { sessionId, title ->
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
return@SessionDrawerContent
}
chatViewModel.renameSession(sessionId, title)
},
onSetSessionPinned = chatViewModel::setSessionPinned,
onSetSessionArchived = chatViewModel::setSessionArchived,
onSetSessionPinned = { sessionId, pinned ->
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
chatViewModel.setSessionPinned(sessionId, pinned)
}
},
onSetSessionArchived = { sessionId, archived ->
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
chatViewModel.setSessionArchived(sessionId, archived)
}
},
onCopySessionId = { sessionId ->
scope.launch {
clipboard.setClipEntry(
@@ -2456,7 +2550,7 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfilesSupported = !isProfileLocked &&
allProfilesSupported = !supervised && !isProfileLocked &&
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
@@ -2577,8 +2671,14 @@ fun ChatScreen(
// Top bar — messaging app style with avatar, name, model subtitle
TopAppBar(
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
}
} else if (supervisedPolicy.capabilities.newChat) {
IconButton(onClick = { chatViewModel.createNewChat() }) {
Icon(Icons.Filled.Edit, contentDescription = "New chat")
}
}
},
title = {
@@ -2610,8 +2710,10 @@ fun ChatScreen(
// style subtitle status.
var everConnected by remember { mutableStateOf(false) }
if (headerChatReady) everConnected = true
val showStreamingState = isStreaming &&
(!supervised || supervisedVisibility.showWorkingStatus)
val statusText = when {
headerChatReady -> if (isStreaming) {
headerChatReady -> if (showStreamingState) {
stringResource(R.string.chat_streaming)
} else {
stringResource(R.string.chat_connected_label)
@@ -2661,6 +2763,14 @@ fun ChatScreen(
// personality label.
val subtitleText = if (!headerChatReady) {
statusText
} else if (supervised) {
buildList {
if (supervisedVisibility.showProfileName) {
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
}
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
}.joinToString(" · ")
} else {
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
@@ -2679,7 +2789,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable {
.clickable(enabled = !supervised) {
if (profileShelfAvailable) {
showProfileShelf = !showProfileShelf
} else {
@@ -2703,7 +2813,7 @@ fun ChatScreen(
// Avatar — a plain 40dp circle whose letter swaps to the
// active agent (profile or personality). No overlay ring:
// the letter itself is the indicator.
Box(modifier = Modifier.size(40.dp)) {
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
@@ -2753,14 +2863,16 @@ fun ChatScreen(
}
}
}
ConnectionStatusBadge(
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd),
size = 10.dp
)
if (!supervised || supervisedVisibility.showConnectionStatus) {
ConnectionStatusBadge(
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd),
size = 10.dp,
)
}
}
// Name + single-line subtitle.
@@ -2801,7 +2913,13 @@ fun ChatScreen(
} else {
Column {
Text(
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
stringResource(R.string.screen_chat_label)
} else if (agentDisplayName.isNotBlank()) {
agentDisplayName
} else {
stringResource(R.string.chat_agent_default)
},
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
@@ -2838,7 +2956,7 @@ fun ChatScreen(
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (isStreaming && animationEnabled) {
if (showStreamingState && animationEnabled) {
StreamingDots(
color = subtitleColor,
modifier = Modifier.clearAndSetSemantics { },
@@ -2859,7 +2977,7 @@ fun ChatScreen(
// full explanation (global mode / --yolo / per-session)
// lives. Keeps the risk visible without eating subtitle
// width on every turn.
if (yoloEnabled == true) {
if (!supervised && yoloEnabled == true) {
RelayChromeIconButton(
icon = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.cd_approvals_off),
@@ -2877,12 +2995,14 @@ fun ChatScreen(
// tappable → Connections, so the affordance moved with the
// info. Dropping it here declutters the actions row and frees
// width for the title subtitle.)
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
if (!supervised) {
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
}
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = stringResource(R.string.cd_settings),
@@ -2895,7 +3015,11 @@ fun ChatScreen(
// Settings — which is what was squeezing the title subtitle.
// Session identity is useful before the first message; sharing only appears
// once the conversation has content.
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
if (
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
(supervised && messages.isNotEmpty() &&
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
) {
var showOverflowMenu by remember { mutableStateOf(false) }
Box {
RelayChromeIconButton(
@@ -2908,7 +3032,7 @@ fun ChatScreen(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false },
) {
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
DropdownMenuItem(
text = { Text(copySessionIdLabel) },
leadingIcon = {
@@ -2933,7 +3057,7 @@ fun ChatScreen(
},
)
}
if (messages.isNotEmpty()) {
if (!supervised && messages.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_search_conversation)) },
leadingIcon = {
@@ -2957,6 +3081,22 @@ fun ChatScreen(
shareConversation(context, messages)
},
)
} else if (
messages.isNotEmpty() &&
supervisedPolicy.allowsSessionAction(
SupervisedSessionAction.ShareTranscript,
)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_share_conversation)) },
leadingIcon = {
Icon(Icons.Filled.Share, contentDescription = null)
},
onClick = {
showOverflowMenu = false
shareConversation(context, messages)
},
)
}
}
}
@@ -2998,13 +3138,15 @@ fun ChatScreen(
// and the mode strip — slim bar + `NN% · used/max` token readout,
// color-graded by fullness. Composes to nothing until the server
// reports a context_max for the session.
ContextMeterBar(
usedFraction = contextUsage,
usedTokens = contextWindow?.usedTokens,
maxTokens = contextWindow?.maxTokens,
onClick = { showContextSheet = true },
)
if (showContextSheet) {
if (!supervised || supervisedVisibility.showUsage) {
ContextMeterBar(
usedFraction = contextUsage,
usedTokens = contextWindow?.usedTokens,
maxTokens = contextWindow?.maxTokens,
onClick = if (supervised) null else ({ showContextSheet = true }),
)
}
if (!supervised && showContextSheet) {
// Live audit of the exact extra context the agent will be
// injected with on the next turn (transparency / auditability).
InjectedContextSheet(
@@ -3065,7 +3207,31 @@ fun ChatScreen(
},
label = "chatEmptyStatePhaseTransition",
) { targetConnectState ->
if (targetConnectState == ChatConnectState.Connecting) {
if (supervised && targetConnectState != ChatConnectState.Ready) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (supervisedVisibility.showConnectionStatus) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (targetConnectState == ChatConnectState.Connecting) {
CircularProgressIndicator()
}
Text(
text = if (targetConnectState == ChatConnectState.Connecting) {
stringResource(R.string.chat_connecting_dots)
} else {
stringResource(R.string.chat_disconnected_label)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (targetConnectState == ChatConnectState.Connecting) {
ChatColdStartLoadingState(
animationEnabled = animationEnabled,
streamingIntensity = streamingIntensity,
@@ -3105,7 +3271,10 @@ fun ChatScreen(
Spacer(modifier = Modifier.weight(0.15f))
// ASCII sphere (constrained to square aspect)
if (LocalBackgroundVisualizationEnabled.current) {
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -3133,7 +3302,10 @@ fun ChatScreen(
// thread itself (not just the header) -
// the desktop's intro.
ChatConnectState.Ready ->
if (effectiveProfile != null) {
if (
effectiveProfile != null &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else {
stringResource(R.string.chat_start_conversation)
@@ -3151,7 +3323,11 @@ fun ChatScreen(
val profileBlurb = effectiveProfile?.description
?.trim()
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
if (
targetConnectState == ChatConnectState.Ready &&
profileBlurb != null &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = profileBlurb,
@@ -3268,6 +3444,7 @@ fun ChatScreen(
// Ambient avatar behind messages
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity) &&
animationBehindChat &&
!ambientMode
) {
@@ -3289,8 +3466,13 @@ fun ChatScreen(
// /media/by-path route when a relay session is paired,
// instead of degrading to the "image is on the server"
// notice. Null when no relay (standard no-plugin) → notice.
val relayServerImageResolver = remember(chatViewModel) {
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
val relayServerImageResolver = remember(
chatViewModel,
supervised,
supervisedPolicy.capabilities.generatedImages,
) {
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
}
val thinkingIndicatorConfig = remember(
thinkingIndicatorStyle,
@@ -3345,6 +3527,7 @@ fun ChatScreen(
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val processNotification = message.hermesProcessNotificationOrNull()
?.takeIf { !supervised || supervisedVisibility.showToolNames }
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
// no attachments). Attachments keep the bubble alive for inbound media;
@@ -3369,7 +3552,10 @@ fun ChatScreen(
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
// Date separator
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
if (
(!supervised || supervisedVisibility.showTimestamps) &&
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
) {
DateSeparator(timestamp = message.timestamp)
}
@@ -3381,7 +3567,9 @@ fun ChatScreen(
message.attachments.isNotEmpty() ||
message.cards.isNotEmpty()
message.backgroundTask?.let { task ->
message.backgroundTask
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
?.let { task ->
val taskModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
@@ -3439,6 +3627,14 @@ fun ChatScreen(
},
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
showUsage = !supervised || supervisedVisibility.showUsage,
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
allowAssistantImageExport = !supervised ||
supervisedPolicy.capabilities.shareGeneratedImages,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
recoveringAnswer = recoveringAnswer,
@@ -3451,9 +3647,9 @@ fun ChatScreen(
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = handleCardAction,
onCardInput = handleCardInput,
onSessionReference = { reference ->
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
onSessionReference = if (supervised) null else { reference ->
val target = agentProfiles.firstOrNull {
it.name.equals(reference.profile, ignoreCase = true)
}
@@ -3471,6 +3667,7 @@ fun ChatScreen(
}
},
onReact = if (
!supervised &&
isGatewayTransport &&
messageReactionsSupported &&
!message.isStreaming &&
@@ -3484,6 +3681,7 @@ fun ChatScreen(
null
},
onEditMessage = if (
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
@@ -3502,10 +3700,14 @@ fun ChatScreen(
null
},
animationEnabled = animationEnabled,
onQuoteMessage = { quoted ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
quotedMessage = quoted
},
onQuoteMessage = if (
!supervised || supervisedPolicy.capabilities.quoteReplies
) {
{ quoted ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
quotedMessage = quoted
}
} else null,
onNavigateToMessage = { messageId ->
val targetIndex = messages.indexOfFirst { it.id == messageId }
if (targetIndex >= 0) {
@@ -3516,7 +3718,10 @@ fun ChatScreen(
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
}
},
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
onSpeakMessage = if (
chatSpeakResponseActionsEnabled &&
(!supervised || supervisedPolicy.capabilities.voice)
) {
{ text -> voiceViewModel.speakResponse(text) }
} else {
null
@@ -3527,6 +3732,9 @@ fun ChatScreen(
null
},
onCopyMessage = { text ->
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
return@MessageBubble
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
@@ -3995,7 +4203,8 @@ fun ChatScreen(
// Gateway redirect is text-only. Attachment-bearing follow-ups must
// retain their files in the session-owned queue instead of showing
// a correction action that cannot carry them.
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
(!supervised || supervisedPolicy.capabilities.steerResponse)
val trailing = when {
!isStreaming && hasContent -> ChatInputTrailing.SEND
!isStreaming -> ChatInputTrailing.VOICE
@@ -4128,7 +4337,7 @@ fun ChatScreen(
}
}
}
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
ChatInputPickerControl(
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
contentDescription = stringResource(R.string.cd_select_model),
@@ -4179,6 +4388,7 @@ fun ChatScreen(
// is definitively unreachable (SSE-only) — the agent sheet carries the
// disabled-with-reason version there.
val effortControl = if (
!supervised &&
chatGatewayAvailability != GatewayAvailability.Unreachable &&
effortAvailability.supported != false &&
effortPickerOptions.isNotEmpty()
@@ -4195,7 +4405,13 @@ fun ChatScreen(
}
visibleChatFailure?.let { failure ->
val failureRouteLabel = when (failure.route) {
val displayFailure = if (!supervised) failure else failure.copy(
model = failure.model.takeIf { supervisedVisibility.showModelName },
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
)
val failureRouteLabel = if (
supervised && !supervisedVisibility.showTechnicalRoute
) "" else when (failure.route) {
ChatFailureRoute.GATEWAY ->
stringResource(R.string.chat_failure_route_gateway)
ChatFailureRoute.API_FALLBACK ->
@@ -4203,23 +4419,28 @@ fun ChatScreen(
null -> ""
}
ChatFailurePanel(
failure = failure,
failure = displayFailure,
routeLabel = failureRouteLabel,
onDetails = { showChatFailureDetails = true },
onRetry = { chatViewModel.retryLastMessage() },
onRetry = {
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
chatViewModel.retryLastMessage()
}
},
onDismiss = chatViewModel::dismissChatFailure,
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
)
if (showChatFailureDetails) {
ChatFailureDetailsDialog(
failure = failure,
failure = displayFailure,
routeLabel = failureRouteLabel,
onCopy = {
val details = buildString {
append(failureRouteLabel)
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
append("\n\n")
append(failure.rawError)
append(displayFailure.rawError)
}
scope.launch {
clipboard.setClipEntry(
@@ -4301,6 +4522,9 @@ fun ChatScreen(
)
},
onStop = {
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
return@ChatInputBar
}
chatViewModel.cancelStream()
// Firm haptic (LongPress — TextHandleMove was near-
// imperceptible) plus a "Stopped" badge stamped on the turn
@@ -4316,14 +4540,44 @@ fun ChatScreen(
}
},
onAttachPhotos = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
val allowed = !supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories &&
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
)
if (allowed) {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
},
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
onAttachCamera = requestCameraCapture,
onPasteImage = pasteImageFromClipboard,
onLongPressAttach = { showCommandPalette = true },
onAttachFiles = {
if (!supervised || supervisedPolicy.capabilities.attachments) {
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
val categories = supervisedPolicy.capabilities.attachmentCategories
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
if (SupervisedAttachmentCategory.Documents in categories) {
add("text/*")
add("application/pdf")
}
}.toTypedArray()
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
}
},
onAttachCamera = if (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories
)) requestCameraCapture else ({ }),
onPasteImage = if (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories
)) pasteImageFromClipboard else ({ }),
onLongPressAttach = { if (!supervised) showCommandPalette = true },
charLimit = charLimit,
caption = turnStatus ?: inputCaption,
voiceReady = voiceReady,
@@ -4335,8 +4589,13 @@ fun ChatScreen(
submitEnabled = pendingAttachments.none {
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
},
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
.takeIf { convertLargePastesToAttachments },
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
convertLargePastesToAttachments && (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Documents in
supervisedPolicy.capabilities.attachmentCategories
))
},
onLargePaste = { pastedText ->
val owner = activeComposerDraftKey ?: composerDraftKey
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
@@ -4654,7 +4913,7 @@ fun ChatScreen(
}
// Command palette bottom sheet
if (showCommandPalette) {
if (showCommandPalette && !supervised) {
CommandPalette(
commands = allCommands,
onSelect = { cmd ->
@@ -4682,7 +4941,7 @@ fun ChatScreen(
// personality, connection summary). Replaces the old AlertDialog and the
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
// the title Row in the TopAppBar above.
if (showAgentInfo) {
if (showAgentInfo && !supervised) {
AgentInfoSheet(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
@@ -0,0 +1,817 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.GitBranch
import com.hermesandroid.relay.data.GitDiff
import com.hermesandroid.relay.data.GitFile
import com.hermesandroid.relay.data.GitRepo
import com.hermesandroid.relay.data.GitStatus
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
import com.hermesandroid.relay.viewmodel.GitContentViewState
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
import com.hermesandroid.relay.viewmodel.GitMutationState
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
import com.hermesandroid.relay.viewmodel.GitStateUiState
import com.hermesandroid.relay.viewmodel.GitStateViewModel
import com.hermesandroid.relay.viewmodel.GitTarget
/**
* Git State screen (read + write): repo picker → working-tree status/branches →
* per-file diff or content. Writes require the ``plugin.api.write`` grant and
* destructive ops (discard/push/dirty-checkout) require an explicit per-use
* confirmation dialog; the fixed confirmation token is sent only on confirm.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GitStateScreen(
viewModel: GitStateViewModel,
onBack: () -> Unit,
) {
val reposState by viewModel.repos.collectAsState()
val detailState by viewModel.detail.collectAsState()
val contentState by viewModel.content.collectAsState()
val mutationState by viewModel.mutation.collectAsState()
val hasGrant by viewModel.writeGrant.collectAsState()
// Hoisted at screen level so confirmation/commit dialogs are modal.
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
var showCommitDialog by remember { mutableStateOf(false) }
var pushAfterCommit by rememberSaveable { mutableStateOf(false) }
// Staged paths for the AI magic-wand (commit_message_selected) + commit.
val stagedPaths = (detailState as? GitRepoDetailState.Ready)
?.status?.staged?.map { it.path } ?: emptyList()
val messageGenerationState by viewModel.messageGeneration.collectAsState()
val stashNotice by viewModel.stashNotice.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.git_state_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.git_state_back),
)
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val repos = reposState
when (repos) {
GitStateUiState.Loading -> CenteredSpinner()
is GitStateUiState.Error -> ErrorText(repos.message)
is GitStateUiState.Ready -> {
repos.notice?.let { ErrorText(it, warning = true) }
RepoPicker(
repos = repos.repos,
selectedId = viewModel.selectedRepoIdForDisplay(),
onSelect = viewModel::selectRepo,
)
when (val current = detailState) {
GitRepoDetailState.Idle -> Unit
GitRepoDetailState.Loading -> CenteredSpinner()
is GitRepoDetailState.Error -> ErrorText(current.message)
is GitRepoDetailState.Ready -> {
MutationBanner(
mutation = mutationState,
onClear = viewModel::clearMutationError,
)
stashNotice?.let { notice ->
Card(Modifier.fillMaxWidth()) {
Text(
notice,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (!hasGrant) {
WriteGrantNotice()
}
RepoDetail(
status = current.status,
branches = current.branches,
hasGrant = hasGrant,
onShowDiff = viewModel::loadDiff,
onShowFile = viewModel::loadFile,
onStage = { path -> viewModel.stage(listOf(path)) },
onUnstage = { path -> viewModel.unstage(listOf(path)) },
onDiscard = { paths, deleteUntracked ->
viewModel.currentTarget()?.let { target ->
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked, target)
}
},
onCommitRequest = { showCommitDialog = true },
onFetch = { viewModel.fetch() },
onPull = { viewModel.pull() },
onPush = {
viewModel.currentTarget()?.let { target ->
pendingConfirm = ConfirmationRequest.Push(target)
}
},
onSwitchBranch = { ref ->
val dirty = current.status.counts.staged > 0 ||
current.status.counts.modified > 0 ||
current.status.counts.untracked > 0
if (dirty) {
viewModel.currentTarget()?.let { target ->
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, target)
}
} else {
viewModel.checkout(ref)
}
},
onStashSwitchBranch = { ref ->
viewModel.stashCheckout(ref)
},
onCreateBranch = { name, track ->
viewModel.checkout("", newBranch = name, track = track)
},
)
}
}
ContentView(state = contentState)
}
}
}
}
if (showCommitDialog) {
CommitDialog(
onDismiss = { showCommitDialog = false },
hasStaged = stagedPaths.isNotEmpty(),
generatingMessage = messageGenerationState is GitMessageGenerationState.Loading,
onGenerate = {
viewModel.generateCommitMessage(
if (stagedPaths.isNotEmpty()) stagedPaths else null,
)
},
generatedMessage = (messageGenerationState as? GitMessageGenerationState.Ready)?.message ?: "",
generationNotice = (messageGenerationState as? GitMessageGenerationState.Ready)?.notice,
pushAfterCommit = pushAfterCommit,
onPushAfterCommitChange = { pushAfterCommit = it },
onCommit = { message ->
showCommitDialog = false
viewModel.commit(message) { committedTarget ->
if (pushAfterCommit) {
pendingConfirm = ConfirmationRequest.Push(committedTarget)
}
}
},
)
}
pendingConfirm?.let { request ->
val onDismiss = { pendingConfirm = null }
when (request) {
is ConfirmationRequest.Discard -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_confirm_discard_title)) },
text = { Text(stringResource(R.string.git_state_confirm_discard_text)) },
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.discard(
request.paths,
GitConfirmationStrings.DISCARD,
request.deleteUntracked,
request.target,
)
}) {
Text(stringResource(R.string.git_state_confirm_discard_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
is ConfirmationRequest.Push -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_confirm_push_title)) },
text = { Text(stringResource(R.string.git_state_confirm_push_text)) },
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.push(
GitConfirmationStrings.PUSH,
expectedTarget = request.target,
)
}) {
Text(stringResource(R.string.git_state_confirm_push_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
is ConfirmationRequest.DirtyCheckout -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_title)) },
text = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_text)) },
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.checkout(
request.ref,
GitConfirmationStrings.DIRTY_CHECKOUT,
expectedTarget = request.target,
)
}) {
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
}
}
}
/** A destructive action awaiting explicit user confirmation. */
private sealed interface ConfirmationRequest {
data class Discard(
val paths: List<String>,
val deleteUntracked: Boolean,
val target: GitTarget,
) : ConfirmationRequest
data class Push(val target: GitTarget) : ConfirmationRequest
data class DirtyCheckout(val ref: String, val target: GitTarget) : ConfirmationRequest
}
@Composable
private fun CommitDialog(
onDismiss: () -> Unit,
hasStaged: Boolean,
generatingMessage: Boolean,
onGenerate: () -> Unit,
generatedMessage: String,
generationNotice: String?,
pushAfterCommit: Boolean,
onPushAfterCommitChange: (Boolean) -> Unit,
onCommit: (String) -> Unit,
) {
var message by rememberSaveable { mutableStateOf("") }
// Pre-fill with the latest generated suggestion when it arrives.
if (generatedMessage.isNotEmpty() && message.isBlank()) {
message = generatedMessage
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.git_state_commit_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text(stringResource(R.string.git_state_commit_message_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
trailingIcon = {
IconButton(onClick = onGenerate, enabled = hasStaged && !generatingMessage) {
Icon(
Icons.Filled.AutoAwesome,
stringResource(R.string.git_state_generate_message),
)
}
},
)
if (generatingMessage) {
Text(
stringResource(R.string.git_state_generating_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
generationNotice?.let { notice ->
Text(
notice,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = pushAfterCommit, onCheckedChange = onPushAfterCommitChange)
Text(stringResource(R.string.git_state_push_after_commit))
}
}
},
confirmButton = {
TextButton(
onClick = { onCommit(message) },
enabled = message.isNotBlank(),
) {
Text(stringResource(R.string.git_state_commit_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.git_state_cancel))
}
},
)
}
@Composable
private fun RepoPicker(
repos: List<GitRepo>,
selectedId: String?,
onSelect: (String) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repos.forEach { repo ->
AssistChip(
onClick = { onSelect(repo.id) },
label = {
Text(
if (repo.dirty) "${repo.name} •" else repo.name,
fontWeight = if (repo.id == selectedId) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
@Composable
private fun MutationBanner(
mutation: GitMutationState,
onClear: () -> Unit,
) {
when (mutation) {
GitMutationState.Idle -> Unit
is GitMutationState.InProgress -> Card(Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(strokeWidth = 2.dp)
Text(
stringResource(R.string.git_state_mutation_in_progress, displayLabel(mutation.label)),
style = MaterialTheme.typography.bodyMedium,
)
}
}
is GitMutationState.Success -> Card(Modifier.fillMaxWidth()) {
Text(
stringResource(
R.string.git_state_mutation_success,
displayLabel(mutation.label),
mutation.head,
),
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
is GitMutationState.Error -> Card(Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(
R.string.git_state_mutation_failed,
displayLabel(mutation.label),
mutation.message,
),
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onClear) {
Text(stringResource(R.string.git_state_cancel))
}
}
}
}
}
private fun displayLabel(label: String): String =
label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
@Composable
private fun WriteGrantNotice() {
Card(Modifier.fillMaxWidth()) {
Text(
stringResource(R.string.git_state_write_grant_required),
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun RepoDetail(
status: GitStatus,
branches: List<GitBranch>,
hasGrant: Boolean,
onShowDiff: (String, String) -> Unit,
onShowFile: (String) -> Unit,
onStage: (String) -> Unit,
onUnstage: (String) -> Unit,
onDiscard: (List<String>, Boolean) -> Unit,
onCommitRequest: () -> Unit,
onFetch: () -> Unit,
onPull: () -> Unit,
onPush: () -> Unit,
onSwitchBranch: (String) -> Unit,
onStashSwitchBranch: (String) -> Unit,
onCreateBranch: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${stringResource(R.string.git_state_staged)} ${status.counts.staged} · " +
"${stringResource(R.string.git_state_modified)} ${status.counts.modified} · " +
"${stringResource(R.string.git_state_untracked)} ${status.counts.untracked}",
style = MaterialTheme.typography.labelLarge,
)
if (status.truncated) {
Text(
stringResource(R.string.git_state_truncated),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
status.staged.takeIf { it.isNotEmpty() }?.let { staged ->
GroupHeader(stringResource(R.string.git_state_staged))
staged.forEach { file ->
StatusRow(
path = file.path,
primaryLabel = stringResource(R.string.git_state_unstage),
onPrimary = { onUnstage(file.path) },
secondaryLabel = stringResource(R.string.git_state_discard),
onSecondary = { onDiscard(listOf(file.path), false) },
onOpen = { onShowDiff(file.path, "staged") },
enabled = hasGrant,
)
}
}
status.modified.takeIf { it.isNotEmpty() }?.let { modified ->
GroupHeader(stringResource(R.string.git_state_modified))
modified.forEach { file ->
StatusRow(
path = file.path,
primaryLabel = stringResource(R.string.git_state_stage),
onPrimary = { onStage(file.path) },
secondaryLabel = stringResource(R.string.git_state_discard),
onSecondary = { onDiscard(listOf(file.path), false) },
onOpen = { onShowDiff(file.path, "unstaged") },
enabled = hasGrant,
)
}
}
status.untracked.takeIf { it.isNotEmpty() }?.let { untracked ->
GroupHeader(stringResource(R.string.git_state_untracked))
untracked.forEach { file ->
StatusRow(
path = file.path,
primaryLabel = stringResource(R.string.git_state_stage),
onPrimary = { onStage(file.path) },
secondaryLabel = stringResource(R.string.git_state_discard),
onSecondary = { onDiscard(listOf(file.path), true) },
onOpen = { onShowFile(file.path) },
enabled = hasGrant,
)
}
}
// Commit + sync controls (writes; all gated by the grant).
Row(
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onFetch,
enabled = hasGrant && status.counts.untracked == 0,
) {
Text(stringResource(R.string.git_state_fetch))
}
OutlinedButton(onClick = onPull, enabled = hasGrant) {
Text(stringResource(R.string.git_state_pull))
}
OutlinedButton(
onClick = onPush,
enabled = hasGrant && status.counts.staged == 0,
) {
Text(stringResource(R.string.git_state_push))
}
}
Button(
onClick = onCommitRequest,
enabled = hasGrant && status.counts.staged > 0,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.git_state_commit))
}
}
}
if (branches.isNotEmpty()) {
BranchCard(
branches = branches,
hasGrant = hasGrant,
onSwitch = onSwitchBranch,
onStashSwitch = onStashSwitchBranch,
onCreate = onCreateBranch,
)
}
}
}
@Composable
private fun StatusRow(
path: String,
primaryLabel: String,
onPrimary: () -> Unit,
secondaryLabel: String,
onSecondary: () -> Unit,
onOpen: () -> Unit,
enabled: Boolean,
) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onOpen, modifier = Modifier.weight(1f)) {
Text(
path,
fontFamily = FontFamily.Monospace,
maxLines = 1,
)
}
TextButton(onClick = onPrimary, enabled = enabled) {
Text(primaryLabel)
}
TextButton(onClick = onSecondary, enabled = enabled) {
Text(secondaryLabel)
}
}
}
@Composable
private fun BranchCard(
branches: List<GitBranch>,
hasGrant: Boolean,
onSwitch: (String) -> Unit,
onStashSwitch: (String) -> Unit,
onCreate: (String, Boolean) -> Unit,
) {
var newBranchName by rememberSaveable { mutableStateOf("") }
var track by rememberSaveable { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(R.string.git_state_branches),
style = MaterialTheme.typography.titleSmall,
)
branches.forEach { branch ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
branchLabel(branch),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
if (branch.isCurrent) {
Text(
stringResource(R.string.git_state_current),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmall,
)
} else {
OutlinedButton(
onClick = { onSwitch(branch.name) },
enabled = hasGrant,
) {
Text(stringResource(R.string.git_state_switch))
}
OutlinedButton(
onClick = { onStashSwitch(branch.name) },
enabled = hasGrant,
) {
Text(stringResource(R.string.git_state_switch_stash))
}
}
}
}
// Create a new branch (optionally tracking the remote).
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = newBranchName,
onValueChange = { newBranchName = it },
label = { Text(stringResource(R.string.git_state_new_branch_hint)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
onClick = {
val name = newBranchName.trim()
if (name.isNotEmpty()) {
onCreate(name, track)
newBranchName = ""
track = false
}
},
enabled = hasGrant && newBranchName.isNotBlank(),
) {
Text(stringResource(R.string.git_state_create_branch))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = track, onCheckedChange = { track = it }, enabled = hasGrant)
Text(stringResource(R.string.git_state_track_remote))
}
}
}
}
private fun branchLabel(branch: GitBranch): String {
val base = branch.name
if (branch.upstream == null) return base
val track =
if (branch.ahead > 0 || branch.behind > 0) {
" (ahead ${branch.ahead}, behind ${branch.behind})"
} else {
""
}
return "$base → ${branch.upstream}$track"
}
@Composable
private fun GroupHeader(label: String) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
@Composable
private fun ContentView(state: GitContentViewState) {
when (state) {
GitContentViewState.Idle -> Unit
GitContentViewState.Loading -> CenteredSpinner()
is GitContentViewState.Error -> ErrorText(state.message)
is GitContentViewState.Diff -> MonospaceBlock(state.diff)
is GitContentViewState.File -> MonospaceBlock(state.file)
}
}
@Composable
private fun MonospaceBlock(diff: GitDiff) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${diff.path} (${diff.kind})",
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
)
if (diff.truncated) {
Text(
stringResource(R.string.git_state_truncated),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Text(
diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
)
}
}
}
@Composable
private fun MonospaceBlock(file: GitFile) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
file.path,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
)
if (file.truncated) {
Text(
stringResource(R.string.git_state_truncated),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Text(
file.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
)
}
}
}
@Composable
private fun CenteredSpinner() {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun ErrorText(message: String, warning: Boolean = false) {
Text(
message,
color = if (warning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -101,6 +101,7 @@ import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.GatewayAvailability
@@ -157,6 +158,14 @@ fun SettingsScreen(
connectionViewModel: ConnectionViewModel,
/** Header back affordance — Settings is a pushed destination, not a tab. */
onBack: (() -> Unit)? = null,
supervisedPolicy: SupervisedModePolicy? = null,
parentAccessUnlocked: Boolean = false,
/** Called only after the restricted surface completes device authentication. */
onRequestParentAccess: () -> Unit = {},
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
onNavigateToAdvancedSettings: () -> Unit = {},
onNavigateToSupervisedAppearance: () -> Unit = {},
onNavigateToSupervisedControls: () -> Unit = {},
// Needed by the Active Agent summary card at the top of the screen — it
// reads the current personality pick so the subtitle can render
// `connection · model · personality` without re-reading ChatViewModel
@@ -206,6 +215,21 @@ fun SettingsScreen(
// discoverable before a pair-and-pick happens.
onNavigateToProfileInspector: (profileName: String) -> Unit,
) {
// Keep the restricted root when an enabled policy becomes temporarily
// unusable (for example, its profile was renamed). Parent authentication,
// not a configuration error, is what unlocks the full settings surface.
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
SupervisedSettingsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
onPolicyChange = onUpdateSupervisedPolicy,
onBack = onBack,
onNavigateToAppearance = onNavigateToSupervisedAppearance,
onParentAccessGranted = onRequestParentAccess,
)
return
}
val context = LocalContext.current
val isDarkTheme = LocalBrand.current.isDark
@@ -432,6 +456,20 @@ fun SettingsScreen(
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (supervisedPolicy?.enabled == true && parentAccessUnlocked) {
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = "Supervised mode",
subtitle = "On · ${supervisedPolicy.pinnedProfileName.orEmpty()}",
badge = SettingsStatusPillModel(
label = "On",
tone = SettingsStatusTone.Good,
),
onClick = onNavigateToSupervisedControls,
isDarkTheme = isDarkTheme,
)
}
// ── Active Agent summary ───────────────────────────────────
// Mirrors the ChatScreen TopAppBar title block (avatar + name
// + one-line `connection · model · personality` subtitle).
@@ -497,6 +535,7 @@ fun SettingsScreen(
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
)
// ── Quick Controls ─────────────────────────────────────────
// The switches flipped most often, pinned to the top-level Settings
// landing instead of buried in a sub-screen. Persistent connection is
@@ -666,6 +705,24 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.settings_advanced),
subtitle = when {
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
else -> stringResource(R.string.settings_advanced_desc)
},
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
SettingsStatusPillModel(
label = "On",
tone = SettingsStatusTone.Good,
)
},
onClick = onNavigateToAdvancedSettings,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Analytics,
title = stringResource(R.string.settings_analytics),
@@ -1280,12 +1337,12 @@ private fun ProfileLockOptionRow(
}
}
private data class SettingsStatusPillModel(
internal data class SettingsStatusPillModel(
val label: String,
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
)
private enum class SettingsStatusTone {
internal enum class SettingsStatusTone {
Neutral,
Good,
Info,
@@ -1483,18 +1540,22 @@ private fun SettingsSectionHeader(
* mega-SettingsScreen.
*/
@Composable
private fun SettingsCategoryRow(
internal fun SettingsCategoryRow(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
isDarkTheme: Boolean,
badge: SettingsStatusPillModel? = null,
petPerchKey: String = title,
petPerchKey: String? = title,
) {
val surfaceModifier = if (petPerchKey != null) {
Modifier.settingsPetSurface("settings-category:$petPerchKey")
} else {
Modifier
}
Card(
modifier = Modifier
.settingsPetSurface("settings-category:$petPerchKey")
modifier = surfaceModifier
.fillMaxWidth()
.gradientBorder(
shape = appearanceRoundedCornerShape(12.dp),
@@ -49,6 +49,10 @@ import com.hermesandroid.relay.data.SessionActivityScope
import com.hermesandroid.relay.data.SessionActivityUpdate
import com.hermesandroid.relay.data.SessionLiveRuntime
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
import com.hermesandroid.relay.data.allowsSessionAction
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.data.HermesCard
@@ -336,6 +340,24 @@ internal fun voiceTurnTransportRejection(
}
class ChatViewModel : ViewModel() {
/**
* Active Android-only supervision policy. RelayApp replaces this snapshot
* whenever the active connection changes. Enforcement belongs here as well
* as in Compose so alternate UI entry points cannot bypass the restrictions.
*/
@Volatile
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
supervisedModePolicy = policy
if (policy.enabled) {
_pendingAttachments.update { attachments ->
attachments.filterIndexed { index, attachment ->
isAttachmentAllowedBySupervision(attachment, index)
}.take(policy.capabilities.attachmentMaxCount)
}
}
}
private var apiClient: HermesApiClient? = null
private var chatHandler: ChatHandler? = null
@@ -828,7 +850,10 @@ class ChatViewModel : ViewModel() {
}
fun addAttachment(attachment: Attachment) {
_pendingAttachments.update { it + attachment }
_pendingAttachments.update { current ->
if (!isAttachmentAllowedBySupervision(attachment, current.size)) current
else current + attachment
}
}
fun removeAttachment(index: Int) {
@@ -838,11 +863,20 @@ class ChatViewModel : ViewModel() {
}
fun replacePendingAttachments(attachments: List<Attachment>) {
_pendingAttachments.value = attachments.toList()
val policy = supervisedModePolicy
_pendingAttachments.value = if (!policy.enabled) {
attachments.toList()
} else {
attachments.filter { isAttachmentAllowedBySupervision(it, 0) }
.take(policy.capabilities.attachmentMaxCount)
}
}
fun replaceAttachment(composerId: String, attachment: Attachment) {
_pendingAttachments.update { attachments ->
if (!isAttachmentAllowedBySupervision(attachment, (attachments.size - 1).coerceAtLeast(0))) {
return@update attachments.filterNot { it.composerId == composerId }
}
var replaced = false
val updated = attachments.map { current ->
if (current.composerId == composerId) {
@@ -872,6 +906,23 @@ class ChatViewModel : ViewModel() {
_pendingAttachments.value = emptyList()
}
private fun isAttachmentAllowedBySupervision(attachment: Attachment, existingCount: Int): Boolean {
val policy = supervisedModePolicy
if (!policy.enabled) return true
val capabilities = policy.capabilities
if (!policy.isActive || !capabilities.attachments) return false
if (existingCount >= capabilities.attachmentMaxCount) return false
val maxBytes = capabilities.attachmentMaxFileMb.toLong() * 1024L * 1024L
if ((attachment.fileSize ?: 0L) > maxBytes) return false
val category = when {
attachment.contentType.startsWith("image/") -> SupervisedAttachmentCategory.Images
attachment.contentType.startsWith("audio/") -> SupervisedAttachmentCategory.Audio
attachment.contentType.startsWith("video/") -> SupervisedAttachmentCategory.Video
else -> SupervisedAttachmentCategory.Documents
}
return category in capabilities.attachmentCategories
}
// Server-side personality selection
private val _selectedPersonality = MutableStateFlow("default")
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
@@ -4624,6 +4675,7 @@ class ChatViewModel : ViewModel() {
onReady: ((String?) -> Unit)? = null,
onFailure: (() -> Unit)? = null,
) {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
val handler = chatHandler ?: return
recordPreResetEvidence(handler, "new_chat")
clearOpenedSessionOwner()
@@ -4956,6 +5008,7 @@ class ChatViewModel : ViewModel() {
}
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Delete)) return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
@@ -5020,6 +5073,7 @@ class ChatViewModel : ViewModel() {
}
fun renameSession(sessionId: String, newTitle: String) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Rename)) return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
@@ -5064,6 +5118,7 @@ class ChatViewModel : ViewModel() {
}
fun setSessionPinned(sessionId: String, pinned: Boolean) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Pin)) return
val expectedContextKey = activeProfileContextKey
val profileName = currentSessionProfileName()
mutateSessionFlag(
@@ -5082,6 +5137,7 @@ class ChatViewModel : ViewModel() {
}
fun setSessionArchived(sessionId: String, archived: Boolean) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Archive)) return
if (!_sessionArchivingSupported.value) {
emitError(
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
@@ -5150,6 +5206,22 @@ class ChatViewModel : ViewModel() {
fun sendMessage(text: String) {
if (text.isBlank()) return
supervisedMessageBlockReason(supervisedModePolicy, text)?.let { reason ->
chatHandler?.addSystemNotice(reason)
return
}
if (supervisedModePolicy.enabled) {
val attachments = _pendingAttachments.value
if (attachments.any { attachment ->
!isAttachmentAllowedBySupervision(attachment, attachments.indexOf(attachment))
}
) {
chatHandler?.addSystemNotice(
"One or more attachments are unavailable under the supervised policy.",
)
return
}
}
recordRecentPrompt(text)
// Demo / Explore mode: there is no server, but a silently dead Send
@@ -5418,6 +5490,10 @@ class ChatViewModel : ViewModel() {
action: com.hermesandroid.relay.data.HermesCardAction,
) {
val handler = chatHandler ?: return
if (supervisedModePolicy.enabled) {
handler.addSystemNotice("This action is unavailable in supervised mode.")
return
}
// Ask answers route straight to the gateway respond RPCs —
// answerAsk records its own (sanitized) dispatch stamp, so don't
// double-stamp here.
@@ -5456,6 +5532,10 @@ class ChatViewModel : ViewModel() {
ask: GatewayAsk,
restored: ChatTurnAskCheckpoint? = null,
) {
if (supervisedModePolicy.enabled) {
denySupervisedInteraction(handler, ask)
return
}
val sessionId = handler.currentSessionId.value
val contextKey = activeProfileContextKey
val existing = _pendingAsk.value
@@ -5584,6 +5664,33 @@ class ChatViewModel : ViewModel() {
sessionId?.let { maybeNotifyInteraction(it, ask) }
}
/**
* Supervised Chat never exposes approval, clarification, sudo, or secret
* inputs. Settle the upstream interaction immediately with its safest
* negative/empty response; if that cannot be confirmed, interrupt the turn
* so a hidden card cannot leave the session waiting indefinitely.
*/
private fun denySupervisedInteraction(handler: ChatHandler, ask: GatewayAsk) {
val gateway = gatewayClient
if (gateway == null) {
handler.addSystemNotice("An interactive request was blocked by supervised mode.")
cancelStream()
return
}
viewModelScope.launch {
val response: Result<GatewayAskResponse>? = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = "deny")
GatewayAsk.Kind.CLARIFY -> ask.requestId?.let {
gateway.respondClarify(it, "This supervised client cannot answer interactive requests.")
}
GatewayAsk.Kind.SUDO -> ask.requestId?.let { gateway.respondSudo(it, "") }
GatewayAsk.Kind.SECRET -> ask.requestId?.let { gateway.respondSecret(it, "") }
}
handler.addSystemNotice("An interactive request was denied by supervised mode.")
if (response == null || response.isFailure) cancelStream()
}
}
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
private fun approvalActions(ask: GatewayAsk): List<HermesCardAction> {
val advertised = ask.choices.orEmpty()
@@ -9404,6 +9511,9 @@ class ChatViewModel : ViewModel() {
* so we shouldn't see duplicate calls here.
*/
fun onMediaAttachmentRequested(messageId: String, token: String) {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return
val handler = chatHandler ?: return
val relay = relayHttpClient
val repo = mediaSettingsRepo
@@ -9461,6 +9571,9 @@ class ChatViewModel : ViewModel() {
* token and uses [RelayHttpClient.fetchMedia].
*/
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return
val handler = chatHandler ?: return
val relay = relayHttpClient ?: return
val repo = mediaSettingsRepo ?: return
@@ -9534,6 +9647,9 @@ class ChatViewModel : ViewModel() {
* it into the markdown-image renderer, which previously ignored the relay.
*/
suspend fun resolveServerImage(serverPath: String): ServerImageResult {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
val relay = relayHttpClient
?: return ServerImageResult.Failure("Relay not configured on this connection")
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
@@ -9576,6 +9692,11 @@ class ChatViewModel : ViewModel() {
expectedRole: MessageRole,
unavailableMessage: String,
) {
if (
expectedRole == MessageRole.ASSISTANT &&
supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return
val handler = chatHandler ?: return
val relay = relayHttpClient
val repo = mediaSettingsRepo
@@ -2749,6 +2749,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private fun installAuthManager(am: AuthManager) {
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
am.setSupervisedMetadataReconnectFallback {
connectionManager.reconnectForAuthenticatedMetadataUpdate()
}
authManager = am
// Push into the flow so the flatMapLatest chains on authState /
// pairingCode / currentPairedSession repoint to the new manager.
@@ -3591,6 +3594,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// ConnectionStore's EncryptedSharedPrefs.
profileController.profileSelectionStore.clear(connectionId)
profileController.profileLockStore.clear(connectionId)
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clear(connectionId)
profileController.profilePresentationStore.clear(connectionId)
profileController.profileSessionStore.clearConnection(connectionId)
profileController.profileDisplayAliasStore.clearConnection(connectionId)
@@ -3630,6 +3635,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
init {
authManager.setSupervisedMetadataReconnectFallback {
connectionManager.reconnectForAuthenticatedMetadataUpdate()
}
// Wire multiplexer to connection manager (for relay/bridge/terminal)
multiplexer.setSendCallback { envelope ->
connectionManager.send(envelope)
@@ -4096,6 +4104,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
connectionStore.removeConnection(duplicate.id)
profileController.profileSelectionStore.clear(duplicate.id)
profileController.profileLockStore.clear(duplicate.id)
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clear(duplicate.id)
profileController.profilePresentationStore.clear(duplicate.id)
profileController.profileSessionStore.clearConnection(duplicate.id)
}
@@ -7190,6 +7200,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
check(dataManager.resetAppData()) { "App data store reset failed" }
profileController.profileSelectionStore.clearAll()
profileController.profileLockStore.clearAll()
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clearAll()
profileController.profilePresentationStore.clearAll()
profileController.profileSessionStore.clearAll()
_apiServerUrl.value = ""
@@ -0,0 +1,510 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hermesandroid.relay.data.GitBranch
import com.hermesandroid.relay.data.GitDiff
import com.hermesandroid.relay.data.GitFile
import com.hermesandroid.relay.data.GitRepo
import com.hermesandroid.relay.data.GitStateApiClient
import com.hermesandroid.relay.data.GitStatus
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface GitStateUiState {
data object Loading : GitStateUiState
data class Error(val message: String) : GitStateUiState
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
}
sealed interface GitRepoDetailState {
data object Idle : GitRepoDetailState
data object Loading : GitRepoDetailState
data class Error(val message: String) : GitRepoDetailState
data class Ready(
val status: GitStatus,
val branches: List<GitBranch>,
) : GitRepoDetailState
}
sealed interface GitContentViewState {
data object Idle : GitContentViewState
data object Loading : GitContentViewState
data class Error(val message: String) : GitContentViewState
data class Diff(val diff: GitDiff) : GitContentViewState
data class File(val file: GitFile) : GitContentViewState
}
/** A single in-flight or completed write mutation on the selected repo. */
sealed interface GitMutationState {
data object Idle : GitMutationState
data class InProgress(val label: String) : GitMutationState
data class Error(val label: String, val message: String) : GitMutationState
data class Success(val label: String, val head: String) : GitMutationState
}
/** A commit-message generation attempt (AI magic-wand). */
sealed interface GitMessageGenerationState {
data object Idle : GitMessageGenerationState
data object Loading : GitMessageGenerationState
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
}
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
object GitConfirmationStrings {
const val DISCARD = "discard"
const val PUSH = "push"
const val DIRTY_CHECKOUT = "checkout-dirty"
}
data class GitTarget(
val scopeKey: String,
val repoId: String,
val generation: Long,
)
/**
* View model for the Git State Android surface (read + write).
*
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
* refuses (surfacing a readable message, never a POST) when that owner's grant
* is absent. Destructive ops
* (discard/push/dirty-checkout) additionally require a per-use confirmation
* string the caller echoes from GitConfirmationStrings.
*/
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
private val _messageGeneration =
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
private val _pushAfterCommit = MutableStateFlow(false)
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
private val _stashNotice = MutableStateFlow<String?>(null)
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
private val _writeGrant = MutableStateFlow(false)
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
private var api: GitStateApiClient? = null
private var reposJob: Job? = null
private var detailJob: Job? = null
private var contentJob: Job? = null
private var mutationJob: Job? = null
private var messageJob: Job? = null
private var scopeKey: String? = null
private var targetGeneration: Long = 0
private var selectedRepoId: String? = null
fun selectedRepoIdForDisplay(): String? = selectedRepoId
fun currentTarget(): GitTarget? {
val owner = scopeKey ?: return null
val repo = selectedRepoId ?: return null
return GitTarget(owner, repo, targetGeneration)
}
fun configure(dashboard: DashboardApiClient?, ownerKey: String?) {
reposJob?.cancel()
detailJob?.cancel()
contentJob?.cancel()
mutationJob?.cancel()
messageJob?.cancel()
targetGeneration += 1
scopeKey = ownerKey
selectedRepoId = null
_writeGrant.value = false
_detail.value = GitRepoDetailState.Idle
_content.value = GitContentViewState.Idle
_mutation.value = GitMutationState.Idle
_messageGeneration.value = GitMessageGenerationState.Idle
_stashNotice.value = null
api = dashboard?.let(::GitStateApiClient)
loadRepos()
}
/** Grants the plugin.api.write capability for this connection/profile. */
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
if (ownerKey != scopeKey) return
_writeGrant.value = granted
}
fun hasWriteGrant(): Boolean = _writeGrant.value
fun loadRepos() {
val client = api ?: run {
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
return
}
val expectedScope = scopeKey
reposJob?.cancel()
reposJob = viewModelScope.launch {
_repos.value = GitStateUiState.Loading
client.repos().fold(
onSuccess = { list ->
if (scopeKey == expectedScope) {
_repos.value = GitStateUiState.Ready(list, null)
}
},
onFailure = { error ->
if (scopeKey == expectedScope) {
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
}
},
)
}
}
fun selectRepo(repoId: String) {
val client = api ?: return
targetGeneration += 1
selectedRepoId = repoId
val target = currentTarget() ?: return
_content.value = GitContentViewState.Idle
_mutation.value = GitMutationState.Idle
detailJob?.cancel()
contentJob?.cancel()
detailJob = viewModelScope.launch {
_detail.value = GitRepoDetailState.Loading
val statusResult = client.status(repoId)
val branchesResult = client.branches(repoId)
if (currentTarget() != target) return@launch
if (statusResult.isFailure) {
_detail.value = GitRepoDetailState.Error(
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
)
return@launch
}
val status: GitStatus = statusResult.getOrThrow()
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
_detail.value = GitRepoDetailState.Ready(status, branches)
}
}
/** Runs one owner/repository-bound mutation without cancelling another mutation. */
private fun runMutation(
label: String,
expectedTarget: GitTarget? = null,
onSuccess: (GitTarget) -> Unit = {},
block: suspend (GitStateApiClient, String) -> Result<GitMutationState>,
) {
val client = api ?: run {
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
return
}
val target = currentTarget() ?: run {
_mutation.value = GitMutationState.Error(label, "No repository selected")
return
}
if (expectedTarget != null && expectedTarget != target) {
_mutation.value = GitMutationState.Error(label, "Repository context changed; review the action again.")
return
}
if (!_writeGrant.value) {
_mutation.value = GitMutationState.Error(
label,
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
if (mutationJob?.isActive == true) {
_mutation.value = GitMutationState.Error(label, "Another Git action is still in progress.")
return
}
detailJob?.cancel()
contentJob?.cancel()
mutationJob = viewModelScope.launch {
_mutation.value = GitMutationState.InProgress(label)
block(client, target.repoId).fold(
onSuccess = {
if (currentTarget() != target) return@fold
_mutation.value = it
_content.value = GitContentViewState.Idle
refreshDetail(client, target)
onSuccess(target)
},
onFailure = { error ->
if (currentTarget() == target) {
_mutation.value = GitMutationState.Error(
label,
error.message ?: "Git action failed",
)
}
},
)
}
}
private suspend fun refreshDetail(client: GitStateApiClient, target: GitTarget) {
val statusResult = client.status(target.repoId)
val branchesResult = client.branches(target.repoId)
if (currentTarget() == target && statusResult.isSuccess) {
_detail.value = GitRepoDetailState.Ready(
statusResult.getOrDefault(GitStatus()),
branchesResult.getOrDefault(emptyList()),
)
}
}
// ── Read operations ────────────────────────────────────────────────────
fun loadDiff(path: String, kind: String) {
val target = currentTarget() ?: return
val client = api ?: return
contentJob?.cancel()
contentJob = viewModelScope.launch {
_content.value = GitContentViewState.Loading
client.diff(target.repoId, path, kind).fold(
onSuccess = { diff ->
if (currentTarget() == target) {
_content.value = GitContentViewState.Diff(diff)
}
},
onFailure = { error ->
if (currentTarget() == target) {
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load diff",
)
}
},
)
}
}
fun loadFile(path: String) {
val target = currentTarget() ?: return
val client = api ?: return
contentJob?.cancel()
contentJob = viewModelScope.launch {
_content.value = GitContentViewState.Loading
client.file(target.repoId, path).fold(
onSuccess = { file ->
if (currentTarget() == target) {
_content.value = GitContentViewState.File(file)
}
},
onFailure = { error ->
if (currentTarget() == target) {
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load file",
)
}
},
)
}
}
// ── Write operations ───────────────────────────────────────────────────
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
}
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
}
fun discard(
paths: List<String>,
confirmation: String,
deleteUntracked: Boolean = false,
expectedTarget: GitTarget? = null,
) =
runMutation("Discard", expectedTarget = expectedTarget) { c, r ->
c.discard(r, paths, confirmation, deleteUntracked)
.map { GitMutationState.Success("discard", it.head) }
}
fun commit(message: String, onSuccess: (GitTarget) -> Unit = {}) =
runMutation("Commit", onSuccess = onSuccess) { c, r ->
c.commit(r, message).map {
GitMutationState.Success("commit", it.head)
}
}
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
c.commitSelected(r, message, paths).map {
GitMutationState.Success("commit", it.head)
}
}
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
}
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
}
fun push(
confirmation: String,
remote: String = "origin",
branch: String = "",
expectedTarget: GitTarget? = null,
) =
runMutation("Push", expectedTarget = expectedTarget) { c, r ->
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
}
fun checkout(
ref: String,
confirmation: String? = null,
newBranch: String = "",
track: Boolean = false,
expectedTarget: GitTarget? = null,
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
c.checkout(r, ref, confirmation, newBranch, track)
.map { GitMutationState.Success("checkout", it.head) }
}
// ── Phase 3 extras ─────────────────────────────────────────────────────
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
fun setPushAfterCommit(enabled: Boolean) {
_pushAfterCommit.value = enabled
}
/**
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
* Empty staged diff / model-unavailable degrade to a notice, never an error.
* Uses the shared write grant gate (a POST is never sent without the grant).
*/
fun generateCommitMessage(paths: List<String>? = null) {
val client = api ?: run {
_messageGeneration.value =
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
return
}
val target = currentTarget() ?: run {
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
return
}
if (!_writeGrant.value) {
_messageGeneration.value = GitMessageGenerationState.Ready(
"",
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
messageJob?.cancel()
messageJob = viewModelScope.launch {
_messageGeneration.value = GitMessageGenerationState.Loading
val result = if (paths != null) {
client.commitMessageSelected(target.repoId, paths)
} else {
client.commitMessage(target.repoId)
}
if (currentTarget() != target) return@launch
result.fold(
onSuccess = { msg ->
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
},
onFailure = { error ->
_messageGeneration.value = GitMessageGenerationState.Ready(
"",
error.message ?: "Could not generate a commit message.",
)
},
)
}
}
/**
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
*/
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
val client = api ?: run {
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
return
}
val target = currentTarget() ?: run {
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
return
}
if (!_writeGrant.value) {
_mutation.value = GitMutationState.Error(
"Stash Checkout",
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
_stashNotice.value = null
if (mutationJob?.isActive == true) {
_mutation.value = GitMutationState.Error(
"Stash Checkout",
"Another Git action is still in progress.",
)
return
}
detailJob?.cancel()
contentJob?.cancel()
mutationJob = viewModelScope.launch {
_mutation.value = GitMutationState.InProgress("Stash Checkout")
client.stashCheckout(target.repoId, ref, newBranch, track).fold(
onSuccess = { result ->
if (currentTarget() != target) return@fold
if (result.stashed) {
_stashNotice.value =
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
}
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
_content.value = GitContentViewState.Idle
refreshDetail(client, target)
},
onFailure = { error ->
if (currentTarget() == target) {
_mutation.value = GitMutationState.Error(
"Stash Checkout",
error.message ?: "Git action failed",
)
}
},
)
}
}
fun clearStashNotice() {
_stashNotice.value = null
}
/** True when the push-after-commit toggle is currently enabled. */
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
/** True when the named destructive op needs a confirmation echo. */
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
/** Fixed confirmation token for a destructive op (matches the server). */
fun confirmationFor(op: String): String? = when (op) {
"discard" -> GitConfirmationStrings.DISCARD
"push" -> GitConfirmationStrings.PUSH
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
else -> null
}
fun clearMutationError() {
if (_mutation.value is GitMutationState.Error) {
_mutation.value = GitMutationState.Idle
}
}
}
@@ -49,6 +49,7 @@ sealed interface PluginsHubState {
data object Disconnected : PluginsHubState
data object Loading : PluginsHubState
data class Ready(
val ownerKey: String,
val plugins: List<PluginHubItem>,
val preview: PluginCatalogPreview,
val refreshing: Boolean = false,
@@ -220,6 +221,7 @@ class PluginsViewModel(application: Application) : AndroidViewModel(application)
_hubState.value = result.fold(
onSuccess = { items ->
PluginsHubState.Ready(
ownerKey = expectedKey,
plugins = items,
preview = catalogPreview(items),
)
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedModePolicy
/**
* Fail-closed dispatch policy for Android Supervised Mode.
*
* This intentionally runs before demo handling, route selection, slash.exec,
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
* Unicode whitespace, preventing an indented slash command from bypassing the
* client restriction.
*/
internal fun supervisedMessageBlockReason(
policy: SupervisedModePolicy,
text: String,
): String? {
if (!policy.enabled) return null
if (!policy.isConfigured) {
return "Supervised mode is unavailable until the parent selects a profile."
}
if (text.trimStart().startsWith('/')) {
return "Slash commands are unavailable in supervised mode."
}
return null
}
@@ -21,6 +21,8 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.data.VoiceIntentTrace
@@ -279,6 +281,23 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
*/
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
internal fun isVoiceCommandAllowed(
action: VoiceCommandAction,
policy: SupervisedModePolicy,
): Boolean {
if (!policy.enabled) return true
val capabilities: SupervisedCapabilities = policy.capabilities
return when (action) {
VoiceCommandAction.StartNewChat -> capabilities.newChat
VoiceCommandAction.StopResponse,
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
VoiceCommandAction.EndVoiceChat,
VoiceCommandAction.PauseContinuousListening,
VoiceCommandAction.ResumeContinuousListening,
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
}
}
internal fun InteractionMode.storageValue(): String = when (this) {
InteractionMode.TapToTalk -> "tap"
InteractionMode.HoldToTalk -> "hold"
@@ -723,6 +742,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var voicePreferences: VoicePreferencesRepository? = null
private var voicePreferencesJob: Job? = null
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
private var finalAnswerOnly: Boolean = false
private var realtimeTraceDetails: Boolean = false
@@ -1380,6 +1400,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
/** Apply the active Android client policy at the voice coordinator boundary. */
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
supervisedModePolicy = policy
val supervised = policy.enabled
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
if (supervised) {
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
_voiceStats.update {
it.copy(
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
)
}
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
} else {
val prefs = voicePreferences ?: return
viewModelScope.launch {
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
}
}
}
private fun persistInteractionMode(mode: InteractionMode) {
val prefs = voicePreferences ?: return
viewModelScope.launch {
@@ -1485,7 +1527,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
) {
closeRealtimeSession()
}
voiceEngineMode = nextEngineMode
voiceEngineMode = if (supervisedModePolicy.enabled) {
VoiceEngineMode.HermesVoiceOutput
} else {
nextEngineMode
}
voiceStopPhrases = settings.stopPhrases
finalAnswerOnly = settings.finalAnswerOnly
realtimeTraceDetails = settings.realtimeTraceDetails
@@ -1506,7 +1552,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
vadThresholdMs = settings.silenceThresholdMs,
interactionMode = settings.interactionMode,
voiceEngineMode = settings.engineMode,
voiceEngineMode = voiceEngineMode.storageValue,
realtimeModel = settings.realtimeModel,
realtimeVoice = settings.realtimeVoice,
)
@@ -1540,6 +1586,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
activationId: String? = null,
expectScreenContext: Boolean = false,
) {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
val freshEntry = !_uiState.value.voiceMode
val orphanedRun = _uiState.value
.takeIf { freshEntry }
@@ -2430,6 +2477,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
return false
}
if (
supervisedModePolicy.enabled &&
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
) return false
val spoken = sanitizeForTts(text)
if (spoken.isBlank()) return false
@@ -3007,6 +3058,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return null
}
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
_uiState.update {
it.copy(
state = VoiceState.Idle,
outputAudioActive = false,
responseText = "That voice action is disabled by Parent controls.",
)
}
return action
}
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
@@ -3076,12 +3138,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
setError("Voice pipeline not initialized")
return
}
if (
supervisedModePolicy.enabled &&
audioClient.effectiveRoute != VoiceAudioRoute.Standard
) {
setError("Supervised voice requires the Standard Hermes voice route")
return
}
currentTurnPcm = inputPcm
currentTurnPcmSampleRate = inputSampleRate
resetBrokeredToolSpeechState()
resetRealtimeSpeechCoalescer()
resetTtsTurnStats()
val engineModeForTurn = voiceEngineMode
val engineModeForTurn = if (supervisedModePolicy.enabled) {
VoiceEngineMode.HermesVoiceOutput
} else {
voiceEngineMode
}
Log.i(
TAG,
"Processing voice input engine=${engineModeForTurn.storageValue} " +
@@ -3230,7 +3303,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
// time constant exists we should still short-circuit here for
// clarity, but today the factory already does the right thing.
val bridgeHandler = voiceBridgeIntentHandler
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
// === PHASE3-voice-cancel-midcountdown ===
// Voice-in-voice cancel: if a destructive action is currently
@@ -4906,7 +4979,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
private fun shouldPreferRealtimeVoice(): Boolean =
voiceOutputAvailable != false &&
!supervisedModePolicy.enabled &&
voiceOutputAvailable != false &&
realtimePcmPlayer != null &&
voiceClient != null &&
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
@@ -657,6 +657,14 @@
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
<string name="settings_diagnostics">Diagnóstico</string>
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
<string name="settings_advanced">Avançado</string>
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
<string name="settings_supervised_mode">Modo supervisionado</string>
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
<string name="settings_supervised_on">Ativado</string>
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
<string name="settings_developer_options">Opções do desenvolvedor</string>
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
<string name="settings_whats_new">Novidades</string>
@@ -3850,6 +3858,48 @@
<string name="plugins_keep">Manter</string>
<string name="plugins_remove">Remover</string>
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Voltar</string>
<string name="git_state_staged">Preparados</string>
<string name="git_state_modified">Modificados</string>
<string name="git_state_untracked">Não rastreados</string>
<string name="git_state_branches">Ramos</string>
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
<string name="git_state_no_changes">(sem alterações)</string>
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
<string name="git_state_stage">Preparar</string>
<string name="git_state_unstage">Despreparar</string>
<string name="git_state_discard">Descartar</string>
<string name="git_state_commit">Confirmar</string>
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
<string name="git_state_commit_message_hint">Mensagem do commit</string>
<string name="git_state_commit_confirm">Confirmar</string>
<string name="git_state_cancel">Cancelar</string>
<string name="git_state_fetch">Buscar</string>
<string name="git_state_pull">Puxar</string>
<string name="git_state_push">Enviar</string>
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
<string name="git_state_create_branch">Criar ramificação</string>
<string name="git_state_track_remote">Rastrear ramificação remota</string>
<string name="git_state_switch">Alternar</string>
<string name="git_state_current">Atual</string>
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
<string name="git_state_confirm_discard_confirm">Descartar</string>
<string name="git_state_confirm_push_title">Enviar alterações?</string>
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
<string name="git_state_confirm_push_confirm">Enviar</string>
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
<string name="git_state_confirm_checkout_confirm">Alternar</string>
<string name="git_state_generate_message">Gerar mensagem de commit</string>
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
<string name="git_state_push_after_commit">Enviar após o commit</string>
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
<string name="support_bundle_review">Revisar informações de suporte</string>
<string name="support_bundle_title">Informações de suporte</string>
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
@@ -698,6 +698,14 @@
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
<string name="settings_diagnostics">诊断</string>
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
<string name="settings_advanced">高级</string>
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
<string name="settings_supervised_mode">受监督模式</string>
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
<string name="settings_supervised_on">已开启</string>
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
<string name="settings_developer_options">开发者选项</string>
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
<string name="settings_whats_new">新功能</string>
@@ -3938,6 +3946,48 @@
<string name="plugins_keep">保留</string>
<string name="plugins_remove">移除</string>
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">返回</string>
<string name="git_state_staged">已暂存</string>
<string name="git_state_modified">已修改</string>
<string name="git_state_untracked">未跟踪</string>
<string name="git_state_branches">分支</string>
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
<string name="git_state_no_changes">(无更改)</string>
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
<string name="git_state_stage">暂存</string>
<string name="git_state_unstage">取消暂存</string>
<string name="git_state_discard">丢弃</string>
<string name="git_state_commit">提交</string>
<string name="git_state_commit_title">提交已暂存的更改</string>
<string name="git_state_commit_message_hint">提交消息</string>
<string name="git_state_commit_confirm">提交</string>
<string name="git_state_cancel">取消</string>
<string name="git_state_fetch">获取</string>
<string name="git_state_pull">拉取</string>
<string name="git_state_push">推送</string>
<string name="git_state_new_branch_hint">新分支名称</string>
<string name="git_state_create_branch">创建分支</string>
<string name="git_state_track_remote">跟踪远程分支</string>
<string name="git_state_switch">切换</string>
<string name="git_state_current">当前</string>
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
<string name="git_state_confirm_discard_title">丢弃更改?</string>
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
<string name="git_state_confirm_discard_confirm">丢弃</string>
<string name="git_state_confirm_push_title">推送更改?</string>
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
<string name="git_state_confirm_push_confirm">推送</string>
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
<string name="git_state_confirm_checkout_confirm">切换</string>
<string name="git_state_generate_message">生成提交信息</string>
<string name="git_state_generating_message">正在生成提交信息…</string>
<string name="git_state_push_after_commit">提交后推送</string>
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
<string name="support_bundle_review">查看支持信息</string>
<string name="support_bundle_title">支持信息</string>
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
+50
View File
@@ -698,6 +698,14 @@
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
<string name="settings_diagnostics">Diagnose</string>
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
<string name="settings_advanced">Erweitert</string>
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
<string name="settings_supervised_on">Ein</string>
<string name="settings_supervised_on_profile">Ein · %1$s</string>
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
<string name="settings_developer_options">Entwickleroptionen</string>
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
<string name="settings_whats_new">Neuigkeiten</string>
@@ -4010,6 +4018,48 @@
<string name="plugins_keep">Behalten</string>
<string name="plugins_remove">Entfernen</string>
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Zurück</string>
<string name="git_state_staged">Gestaged</string>
<string name="git_state_modified">Geändert</string>
<string name="git_state_untracked">Unverfolgt</string>
<string name="git_state_branches">Branches</string>
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
<string name="git_state_no_changes">(keine Änderungen)</string>
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
<string name="git_state_stage">Bereitstellen</string>
<string name="git_state_unstage">Zurücksetzen</string>
<string name="git_state_discard">Verwerfen</string>
<string name="git_state_commit">Committen</string>
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
<string name="git_state_commit_confirm">Committen</string>
<string name="git_state_cancel">Abbrechen</string>
<string name="git_state_fetch">Abrufen</string>
<string name="git_state_pull">Pullen</string>
<string name="git_state_push">Pushen</string>
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
<string name="git_state_create_branch">Branch erstellen</string>
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
<string name="git_state_switch">Wechseln</string>
<string name="git_state_current">Aktuell</string>
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
<string name="git_state_confirm_push_confirm">Pushen</string>
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
<string name="git_state_push_after_commit">Nach Commit pushen</string>
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
<string name="support_bundle_review">Supportinformationen prüfen</string>
<string name="support_bundle_title">Supportinformationen</string>
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
+50
View File
@@ -625,6 +625,14 @@
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
<string name="settings_diagnostics">Diagnóstico</string>
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
<string name="settings_advanced">Avanzado</string>
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
<string name="settings_supervised_mode">Modo supervisado</string>
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
<string name="settings_supervised_on">Activado</string>
<string name="settings_supervised_on_profile">Activado · %1$s</string>
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
<string name="settings_developer_options">Opciones de desarrollador</string>
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
<string name="settings_whats_new">Novedades</string>
@@ -3695,6 +3703,48 @@
<string name="plugins_keep">Conservar</string>
<string name="plugins_remove">Eliminar</string>
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Atrás</string>
<string name="git_state_staged">Preparados</string>
<string name="git_state_modified">Modificados</string>
<string name="git_state_untracked">Sin seguimiento</string>
<string name="git_state_branches">Ramas</string>
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
<string name="git_state_no_changes">(sin cambios)</string>
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
<string name="git_state_stage">Preparar</string>
<string name="git_state_unstage">Quitar de preparados</string>
<string name="git_state_discard">Descartar</string>
<string name="git_state_commit">Confirmar</string>
<string name="git_state_commit_title">Confirmar cambios preparados</string>
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
<string name="git_state_commit_confirm">Confirmar</string>
<string name="git_state_cancel">Cancelar</string>
<string name="git_state_fetch">Obtener</string>
<string name="git_state_pull">Traer</string>
<string name="git_state_push">Enviar</string>
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
<string name="git_state_create_branch">Crear rama</string>
<string name="git_state_track_remote">Seguir rama remota</string>
<string name="git_state_switch">Cambiar</string>
<string name="git_state_current">Actual</string>
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
<string name="git_state_confirm_discard_confirm">Descartar</string>
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
<string name="git_state_confirm_push_confirm">Enviar</string>
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
<string name="support_bundle_review">Revisar información de soporte</string>
<string name="support_bundle_title">Información de soporte</string>
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
+50
View File
@@ -698,6 +698,14 @@
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
<string name="settings_diagnostics">診断</string>
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
<string name="settings_advanced">詳細設定</string>
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
<string name="settings_supervised_mode">監督モード</string>
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
<string name="settings_supervised_on">オン</string>
<string name="settings_supervised_on_profile">オン · %1$s</string>
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
<string name="settings_developer_options">開発者向けオプション</string>
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
<string name="settings_whats_new">新着情報</string>
@@ -4009,6 +4017,48 @@
<string name="plugins_keep">保持</string>
<string name="plugins_remove">削除</string>
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">戻る</string>
<string name="git_state_staged">ステージ済み</string>
<string name="git_state_modified">変更</string>
<string name="git_state_untracked">未追跡</string>
<string name="git_state_branches">ブランチ</string>
<string name="git_state_truncated">結果は切り詰められています。最初の項目のみ表示されます。</string>
<string name="git_state_no_changes">(変更なし)</string>
<string name="git_state_write_grant_required">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(plugin.api.write)」を有効にしてください。</string>
<string name="git_state_stage">ステージ</string>
<string name="git_state_unstage">ステージ解除</string>
<string name="git_state_discard">破棄</string>
<string name="git_state_commit">コミット</string>
<string name="git_state_commit_title">ステージ済みの変更をコミット</string>
<string name="git_state_commit_message_hint">コミットメッセージ</string>
<string name="git_state_commit_confirm">コミット</string>
<string name="git_state_cancel">キャンセル</string>
<string name="git_state_fetch">フェッチ</string>
<string name="git_state_pull">プル</string>
<string name="git_state_push">プッシュ</string>
<string name="git_state_new_branch_hint">新しいブランチ名</string>
<string name="git_state_create_branch">ブランチを作成</string>
<string name="git_state_track_remote">リモートブランチを追跡</string>
<string name="git_state_switch">切り替え</string>
<string name="git_state_current">現在</string>
<string name="git_state_mutation_in_progress">%1$s を実行中…</string>
<string name="git_state_mutation_success">%1$s が完了しました。HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s に失敗しました: %2$s</string>
<string name="git_state_confirm_discard_title">変更を破棄しますか?</string>
<string name="git_state_confirm_discard_text">選択したファイルへのローカル変更は破棄され、元に戻せません。</string>
<string name="git_state_confirm_discard_confirm">破棄</string>
<string name="git_state_confirm_push_title">変更をプッシュしますか?</string>
<string name="git_state_confirm_push_text">現在のブランチをリモートにプッシュします。ローカルのコミットがリモートリポジトリに送信されます。</string>
<string name="git_state_confirm_push_confirm">プッシュ</string>
<string name="git_state_confirm_checkout_dirty_title">ローカル変更のあるブランチに切り替えますか?</string>
<string name="git_state_confirm_checkout_dirty_text">作業ツリーに未コミットの変更があります。切り替えると、それらが対象ブランチへ引き継がれる可能性があります。</string>
<string name="git_state_confirm_checkout_confirm">切り替え</string>
<string name="git_state_generate_message">コミットメッセージを生成</string>
<string name="git_state_generating_message">コミットメッセージを生成中…</string>
<string name="git_state_push_after_commit">コミット後にプッシュ</string>
<string name="git_state_switch_stash">切り替え(変更があればスタッシュ)</string>
<string name="support_bundle_review">サポート情報を確認</string>
<string name="support_bundle_title">サポート情報</string>
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
+50
View File
@@ -668,6 +668,14 @@
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
<string name="settings_diagnostics">Диагностика</string>
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
<string name="settings_advanced">Дополнительно</string>
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
<string name="settings_supervised_mode">Режим с контролем</string>
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
<string name="settings_supervised_on">Вкл.</string>
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
<string name="settings_developer_options">Настройки разработчика</string>
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
<string name="settings_whats_new">Что нового</string>
@@ -3731,6 +3739,48 @@
<string name="plugins_keep">Оставить</string>
<string name="plugins_remove">Удалить</string>
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Назад</string>
<string name="git_state_staged">В индексе</string>
<string name="git_state_modified">Изменённые</string>
<string name="git_state_untracked">Неотслеживаемые</string>
<string name="git_state_branches">Ветки</string>
<string name="git_state_truncated">Результаты усечены — показаны только первые записи.</string>
<string name="git_state_no_changes">(без изменений)</string>
<string name="git_state_write_grant_required">Для записи изменений требуется разрешение на изменение плагина. Включите «Разрешить изменения плагинов (plugin.api.write)» в настройках разрешений Plugins.</string>
<string name="git_state_stage">Добавить в индекс</string>
<string name="git_state_unstage">Убрать из индекса</string>
<string name="git_state_discard">Отменить</string>
<string name="git_state_commit">Коммит</string>
<string name="git_state_commit_title">Зафиксировать индексированные изменения</string>
<string name="git_state_commit_message_hint">Сообщение коммита</string>
<string name="git_state_commit_confirm">Коммит</string>
<string name="git_state_cancel">Отмена</string>
<string name="git_state_fetch">Обновить</string>
<string name="git_state_pull">Вытянуть</string>
<string name="git_state_push">Отправить</string>
<string name="git_state_new_branch_hint">Имя новой ветки</string>
<string name="git_state_create_branch">Создать ветку</string>
<string name="git_state_track_remote">Отслеживать удалённую ветку</string>
<string name="git_state_switch">Переключить</string>
<string name="git_state_current">Текущая</string>
<string name="git_state_mutation_in_progress">%1$s выполняется…</string>
<string name="git_state_mutation_success">%1$s завершено. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s не удалось: %2$s</string>
<string name="git_state_confirm_discard_title">Отменить изменения?</string>
<string name="git_state_confirm_discard_text">Локальные изменения выбранных файлов будут отменены и их нельзя будет восстановить.</string>
<string name="git_state_confirm_discard_confirm">Отменить</string>
<string name="git_state_confirm_push_title">Отправить изменения?</string>
<string name="git_state_confirm_push_text">Отправить текущую ветку в её удалённый репозиторий. Это передаст локальные коммиты в удалённый репозиторий.</string>
<string name="git_state_confirm_push_confirm">Отправить</string>
<string name="git_state_confirm_checkout_dirty_title">Переключить ветку с локальными изменениями?</string>
<string name="git_state_confirm_checkout_dirty_text">В рабочем дереве есть незакоммиченные изменения. Переключение может перенести их в целевую ветку.</string>
<string name="git_state_confirm_checkout_confirm">Переключить</string>
<string name="git_state_generate_message">Создать сообщение коммита</string>
<string name="git_state_generating_message">Создание сообщения коммита…</string>
<string name="git_state_push_after_commit">Отправить после коммита</string>
<string name="git_state_switch_stash">Переключить (сохранить изменения, если есть)</string>
<string name="support_bundle_review">Проверить сведения для поддержки</string>
<string name="support_bundle_title">Сведения для поддержки</string>
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
+53
View File
@@ -736,6 +736,14 @@
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
<string name="settings_advanced">Advanced</string>
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
<string name="settings_supervised_mode">Supervised mode</string>
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
<string name="settings_supervised_on">On</string>
<string name="settings_supervised_on_profile">On · %1$s</string>
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
<string name="settings_developer_options">Developer options</string>
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
<string name="settings_whats_new">What\'s New</string>
@@ -4206,6 +4214,51 @@
<string name="plugins_keep">Keep</string>
<string name="plugins_remove">Remove</string>
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Back</string>
<string name="git_state_staged">Staged</string>
<string name="git_state_modified">Modified</string>
<string name="git_state_untracked">Untracked</string>
<string name="git_state_branches">Branches</string>
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
<string name="git_state_no_changes">(no changes)</string>
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
<string name="git_state_stage">Stage</string>
<string name="git_state_unstage">Unstage</string>
<string name="git_state_discard">Discard</string>
<string name="git_state_commit">Commit</string>
<string name="git_state_commit_title">Commit staged changes</string>
<string name="git_state_commit_message_hint">Commit message</string>
<string name="git_state_commit_confirm">Commit</string>
<string name="git_state_cancel">Cancel</string>
<string name="git_state_fetch">Fetch</string>
<string name="git_state_pull">Pull</string>
<string name="git_state_push">Push</string>
<string name="git_state_new_branch_hint">New branch name</string>
<string name="git_state_create_branch">Create branch</string>
<string name="git_state_track_remote">Track remote branch</string>
<string name="git_state_switch">Switch</string>
<string name="git_state_current">Current</string>
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
<string name="git_state_confirm_discard_title">Discard changes?</string>
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
<string name="git_state_confirm_discard_confirm">Discard</string>
<string name="git_state_confirm_push_title">Push changes?</string>
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
<string name="git_state_confirm_push_confirm">Push</string>
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
<string name="git_state_confirm_checkout_confirm">Switch</string>
<string name="git_state_generate_message">Generate commit message</string>
<string name="git_state_generating_message">Generating commit message…</string>
<string name="git_state_push_after_commit">Push after commit</string>
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
<string name="support_bundle_review">Review support information</string>
<string name="support_bundle_title">Support information</string>
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
@@ -0,0 +1,55 @@
package com.hermesandroid.relay.auth
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedModeAuthPayloadTest {
@Test fun `active policy reports only public capability ids`() {
val payload = relaySupervisedModePayload(
SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(attachments = true, voice = true),
),
)
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
assertTrue("text_chat" in capabilities)
assertTrue("attachments" in capabilities)
assertTrue("voice" in capabilities)
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
}
@Test fun `inactive update explicitly clears Relay tag`() {
val payload = relaySupervisedModePayload(SupervisedModePolicy())
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
assertEquals(setOf("active"), payload.keys)
}
@Test fun `live update uses typed correlated system envelope`() {
val envelope = relaySupervisedModeUpdateEnvelope(
SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(voice = true),
),
)
assertEquals("system", envelope.channel)
assertEquals("supervised.update", envelope.type)
assertTrue(envelope.id.isNotBlank())
val mode = envelope.payload.getValue("supervised_mode")
.jsonObject
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
}
}
@@ -0,0 +1,221 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedModeStoreTest {
@Test
fun freshConnectionUsesRestrictiveDefaults() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
val policy = store.policyFlow("connection-a").first()
assertFalse(policy.enabled)
assertFalse(policy.isConfigured)
assertFalse(policy.isActive)
assertFalse(policy.capabilities.attachments)
assertFalse(policy.capabilities.voice)
assertFalse(policy.visibility.resolved().showModelName)
assertFalse(policy.visibility.resolved().showTechnicalRoute)
assertTrue(policy.parentAccess.requireDeviceAuthentication)
assertEquals(5, policy.parentAccess.timeoutMinutes)
}
@Test
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
val dataStore = InMemorySupervisedPreferencesDataStore()
val store = SupervisedModeStore.forTesting(dataStore)
val saved = SupervisedModePolicy(
enabled = true,
pinnedProfileName = " willow ",
capabilities = SupervisedCapabilities(
attachments = true,
voice = true,
attachmentMaxCount = 6,
attachmentMaxFileMb = 20,
attachmentCategories = setOf(
SupervisedAttachmentCategory.Images,
SupervisedAttachmentCategory.Documents,
),
sessionActions = SupervisedSessionActions(
pin = true,
rename = true,
shareTranscript = true,
),
),
appearance = SupervisedAppearance(
appThemeId = "rose",
themePreference = "dark",
showPet = true,
allowProfileIconChanges = true,
allowBackgroundChanges = true,
),
visibility = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Custom,
showAgentIdentity = true,
showModelName = true,
showToolNames = true,
),
)
store.setPolicy("connection-a", saved)
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
assertTrue(restored.isActive)
assertEquals("willow", restored.pinnedProfileName)
assertEquals(6, restored.capabilities.attachmentMaxCount)
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
assertEquals("rose", restored.appearance.appThemeId)
assertEquals("dark", restored.appearance.themePreference)
assertTrue(restored.appearance.showPet)
assertTrue(restored.appearance.allowProfileIconChanges)
assertTrue(restored.appearance.allowBackgroundChanges)
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
assertTrue(restored.visibility.showModelName)
assertTrue(restored.visibility.showToolNames)
}
@Test
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
store.clear("connection-a")
assertFalse(store.policyFlow("connection-a").first().enabled)
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
}
@Test
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy(
"connection-a",
SupervisedModePolicy(
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(voice = true),
),
)
store.setEnabled("connection-a", true)
store.updatePolicy("connection-a") {
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
}
val policy = store.policyFlow("connection-a").first()
assertTrue(policy.isActive)
assertTrue(policy.capabilities.voice)
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
}
@Test
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy(
"connection-a",
SupervisedModePolicy(
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
attachmentMaxCount = Int.MAX_VALUE,
attachmentMaxFileMb = -1,
attachmentCategories = emptySet(),
),
parentAccess = SupervisedParentAccess(
requireDeviceAuthentication = false,
timeoutMinutes = 0,
),
appearance = SupervisedAppearance(
appThemeId = "missing-theme",
themePreference = "sepia",
),
),
)
val policy = store.policyFlow("connection-a").first()
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
assertTrue(policy.parentAccess.requireDeviceAuthentication)
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
assertEquals("hermes-relay", policy.appearance.appThemeId)
assertEquals("auto", policy.appearance.themePreference)
}
@Test
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
val visibility = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Simple,
showModelName = true,
showTechnicalRoute = true,
showReasoning = true,
).resolved()
assertFalse(visibility.showModelName)
assertFalse(visibility.showTechnicalRoute)
assertFalse(visibility.showReasoning)
assertTrue(visibility.showAgentIdentity)
assertTrue(visibility.showConnectionStatus)
}
@Test
fun malformedPersistedPolicyFailsClosed() = runTest {
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
"supervised_mode_policies_v1",
)
val dataStore = InMemorySupervisedPreferencesDataStore(
mutablePreferencesOf(policyKey to "{not-valid-json"),
)
val policy = SupervisedModeStore.forTesting(dataStore)
.policyFlow("connection-a")
.first()
assertTrue(policy.enabled)
assertFalse(policy.isConfigured)
assertFalse(policy.isActive)
}
@Test
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
val dataStore = InMemorySupervisedPreferencesDataStore(
mutablePreferencesOf(unrelatedKey to "kept"),
)
val store = SupervisedModeStore.forTesting(dataStore)
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
store.clearAll()
assertFalse(store.policyFlow("connection-a").first().enabled)
assertEquals("kept", dataStore.data.first()[unrelatedKey])
}
}
private class InMemorySupervisedPreferencesDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
}
@@ -0,0 +1,41 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedSessionPolicyTest {
@Test fun `session action summary derives none mixed and all`() {
val none = SupervisedSessionActions()
val mixed = none.copy(rename = true, delete = true)
val all = none.withAll(true)
assertTrue(none.noneEnabled)
assertEquals(2, mixed.enabledCount)
assertFalse(mixed.noneEnabled)
assertFalse(mixed.allEnabled)
assertTrue(all.allEnabled)
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
}
@Test fun `supervised history and granular flag are both required`() {
val base = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
conversationHistory = true,
sessionActions = SupervisedSessionActions(rename = true),
),
)
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
assertFalse(
base.copy(
capabilities = base.capabilities.copy(conversationHistory = false),
).allowsSessionAction(SupervisedSessionAction.Rename),
)
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
}
}
@@ -0,0 +1,33 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.network.relay.models.Envelope
import org.junit.Assert.assertEquals
import org.junit.Test
class ChannelMultiplexerSupervisedUpdateTest {
@Test fun `supervised update acknowledgement reaches system auth handler`() {
val multiplexer = ChannelMultiplexer()
val received = mutableListOf<Envelope>()
multiplexer.registerHandler("system") { received += it }
val acknowledgement = Envelope(
channel = "system",
type = "supervised.updated",
id = "update-1",
)
multiplexer.route(acknowledgement)
assertEquals(listOf(acknowledgement), received)
}
@Test fun `correlated system error reaches system auth handler`() {
val multiplexer = ChannelMultiplexer()
val received = mutableListOf<Envelope>()
multiplexer.registerHandler("system") { received += it }
val error = Envelope(channel = "system", type = "error", id = "update-2")
multiplexer.route(error)
assertEquals(listOf(error), received)
}
}
@@ -0,0 +1,37 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class AutoVoiceAudioClientSupervisionTest {
@Test
fun `route override forces standard even when auto prefers ready relay`() = runTest {
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
val router = AutoVoiceAudioClient(
standardClient = standard,
relayClient = relay,
routeProvider = { VoiceAudioRoute.Auto },
standardReadyProvider = { true },
relayReadyProvider = { true },
)
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
router.setRouteOverride(VoiceAudioRoute.Standard)
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
router.setRouteOverride(null)
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
}
private class FakeVoiceClient(
override val route: VoiceAudioRoute,
private val transcript: String,
) : VoiceAudioClient {
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
}
}
@@ -0,0 +1,57 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedAppearance
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedAppearancePolicyTest {
private val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
appearance = SupervisedAppearance(
appThemeId = "rose",
themePreference = "dark",
showPet = false,
),
)
@Test fun `locked supervised root uses only its own theme`() {
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
assertEquals("rose", resolved.appThemeId)
assertEquals("dark", resolved.themePreference)
assertFalse(resolved.useGlobalCustomTheme)
}
@Test fun `parent access restores ordinary app theme`() {
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
assertEquals("midnight", resolved.appThemeId)
assertEquals("light", resolved.themePreference)
assertTrue(resolved.useGlobalCustomTheme)
}
@Test fun `pet visibility follows supervised policy only while locked`() {
assertFalse(shouldShowPetInSupervisedMode(policy, false))
assertTrue(shouldShowPetInSupervisedMode(policy, true))
assertTrue(
shouldShowPetInSupervisedMode(
policy.copy(appearance = policy.appearance.copy(showPet = true)),
false,
),
)
}
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
val recovery = SupervisedModePolicy(enabled = true)
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
assertEquals("hermes-relay", resolved.appThemeId)
assertEquals("auto", resolved.themePreference)
assertFalse(resolved.useGlobalCustomTheme)
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
}
}
@@ -0,0 +1,121 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedNavigationPolicyTest {
@Test fun `locked surface permits only approved destinations`() {
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
assertTrue(isSupervisedRouteAllowed("settings", false))
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
// The full Appearance destination includes profile/avatar/pet controls.
// The supervised Settings root owns its own allowlisted theme controls.
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
assertFalse(isSupervisedRouteAllowed("settings/about", false))
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
assertFalse(isSupervisedRouteAllowed("manage", false))
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
assertFalse(isSupervisedRouteAllowed(null, false))
}
@Test fun `parent unlock permits full navigation`() {
assertTrue(isSupervisedRouteAllowed("manage", true))
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
}
@Test fun `supervised redirect waits until the navigation graph has a route`() {
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
}
@Test fun `navigation waits for connection store before trusting null active id`() {
assertFalse(isRelayNavigationHydrated(false, null, false))
assertTrue(isRelayNavigationHydrated(true, null, false))
assertFalse(isRelayNavigationHydrated(true, "home", false))
assertTrue(isRelayNavigationHydrated(true, "home", true))
}
@Test fun `parent access relocks as soon as chat becomes current`() {
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
assertFalse(shouldRelockParentAccess(false, true, "chat"))
assertFalse(shouldRelockParentAccess(true, false, "chat"))
}
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(conversationHistory = true),
)
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
assertFalse(
mayRestoreSupervisedSessionRoute(
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
"session-1",
"willow",
true,
),
)
}
@Test fun `supervised external route discards session profile and proactive targets`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(conversationHistory = true),
)
val external = SupervisedChatRouteArgs(
sessionId = "parent-session",
profile = "willow",
proactiveChatId = "phone",
)
assertTrue(
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
SupervisedChatRouteArgs(),
)
assertTrue(
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
external.copy(proactiveChatId = null),
)
assertTrue(
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
)
}
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
assertFalse(
mayEnableSupervisedMode(
configured,
deviceSecure = false,
deviceCredentialConfirmed = true,
),
)
assertFalse(
mayEnableSupervisedMode(
configured,
deviceSecure = true,
deviceCredentialConfirmed = false,
),
)
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
assertTrue(mayEnableSupervisedMode(configured, true, true))
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
}
}
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedImagePresentationTest {
@Test
fun `disabled assistant images strip markdown without exposing a fetchable source`() {
val content = "Here it is ![result](https://example.com/private.png) and ![local](/tmp/result.png)"
val (body, images) = assistantImageContent(content, showImages = false)
assertEquals("Here it is and", body)
assertTrue(images.isEmpty())
}
@Test
fun `enabled assistant images preserve all supported sources`() {
val content = "![remote](https://example.com/a.png) ![local](/tmp/b.png)"
val (_, images) = assistantImageContent(content, showImages = true)
assertEquals(listOf("https://example.com/a.png", "/tmp/b.png"), images.map { it.src })
}
}
@@ -0,0 +1,174 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateExtrasViewModelTest {
private val ownerKey = "connection-a\u0000default\u0000dashboard"
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
vm.setWriteGrant(ownerKey, grant)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
)
}
private fun selectAlpha(vm: GitStateViewModel) {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
vm.selectRepo("alpha")
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
repeat(3) { server.takeRequest() }
}
// ── AI commit message (magic-wand) ─────────────────────────────────────
@Test
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson("""{"message":"feat: add feature","notice":""}""")
vm.generateCommitMessage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertEquals("feat: add feature", state.message)
assertEquals("", state.notice)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/commit_message_selected"))
assertTrue(req.body.readUtf8().contains("a.txt"))
}
@Test
fun `generate message without grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.generateCommitMessage(null)
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertTrue(state.notice.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
@Test
fun `empty staged diff surfaces notice without error`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson("""{"message":"","notice":"nothing staged"}""")
vm.generateCommitMessage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertEquals("", state.message)
assertEquals("nothing staged", state.notice)
}
// ── Push-after-commit toggle ───────────────────────────────────────────
@Test
fun `push after commit defaults off and toggles`() {
val vm = viewModel()
assertTrue(!vm.isPushAfterCommitEnabled())
vm.setPushAfterCommit(true)
assertTrue(vm.isPushAfterCommitEnabled())
vm.setPushAfterCommit(false)
assertTrue(!vm.isPushAfterCommitEnabled())
}
// ── Stash-checkout ─────────────────────────────────────────────────────
@Test
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson(
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
)
// refreshDetail fires two reads (status + branches).
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
vm.stashCheckout("feature")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
assertTrue(notice.contains("git-state: feature"))
assertTrue(notice.contains("git stash pop"))
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/stash_checkout"))
assertTrue(req.body.readUtf8().contains("feature"))
}
@Test
fun `stash checkout without grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.stashCheckout("feature")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
@Test
fun `clean stash checkout yields no stash notice`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson(
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
)
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
vm.stashCheckout("feature")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
// No stash notice for a clean checkout.
assertEquals(null, vm.stashNotice.value)
}
}
@@ -0,0 +1,167 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateViewModelTest {
private val ownerKey = "connection-a\u0000default\u0000dashboard"
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
@Test
fun `loadRepos maps repo list and notice`() = runBlocking {
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
)
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
assertEquals(1, state.repos.size)
assertEquals("alpha", state.repos.single().name)
assertNotNull(vm.repos.value)
}
@Test
fun `loadRepos surfaces server error`() = runBlocking {
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
}
assertTrue(state.message.isNotBlank())
}
@Test
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
)
// status
enqueueJson(
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
)
// branches
enqueueJson(
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
)
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
val ready = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
}
assertEquals(1, ready.status.counts.staged)
assertEquals(2, ready.status.counts.modified)
assertEquals(3, ready.status.counts.untracked)
assertTrue(ready.status.truncated)
assertEquals("main", ready.branches.single().name)
assertTrue(ready.branches.single().isCurrent)
}
@Test
fun `loadDiff surfaces truncated diff`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
vm.loadDiff("a.txt", "unstaged")
val content = withTimeout(5_000) {
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
}
assertEquals("a.txt", content.diff.path)
assertTrue(content.diff.truncated)
assertTrue(content.diff.diff.contains("change"))
}
@Test
fun `loadFile surfaces content and truncation`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
vm.loadFile("new.txt")
val content = withTimeout(5_000) {
vm.content.filterIsInstance<GitContentViewState.File>().first()
}
assertEquals("hello world", content.file.content)
assertFalse(content.file.truncated)
}
@Test
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
enqueueJson("""{"branches":[]}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("bogus")
val error = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
}
assertTrue(error.message.contains("unknown repository"))
}
}
@@ -0,0 +1,272 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateWriteViewModelTest {
private val ownerKey = "connection-a\u0000default\u0000dashboard"
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
vm.setWriteGrant(ownerKey, grant)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
private fun selectAlpha(vm: GitStateViewModel) {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
vm.selectRepo("alpha")
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
// Drain the three read requests (repos/status/branches) so the next
// takeRequest() returns the mutation POST we actually assert on.
repeat(3) { server.takeRequest() }
}
private fun enqueuePostSuccess(head: String) {
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
// refreshDetail fires two more requests (status + branches).
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
}
// ── Grant gating (security first) ──────────────────────────────────────
@Test
fun `stage without write grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.stage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
// No write POST was sent (only the 3 read requests for load/select).
assertEquals(3, server.requestCount)
}
@Test
fun `discard without write grant is refused`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
// ── Happy paths ────────────────────────────────────────────────────────
@Test
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc123")
vm.stage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
}
assertEquals("stage", state.label)
assertEquals("abc123", state.head)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/stage"))
assertTrue(req.body.readUtf8().contains("a.txt"))
}
@Test
fun `commit sends message and returns head`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("deadbeef")
vm.commit("add feature")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
}
assertEquals("commit", state.label)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/commit"))
assertTrue(req.body.readUtf8().contains("add feature"))
}
@Test
fun `commit success callback fires only after successful response`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
var committedTarget: GitTarget? = null
vm.commit("add feature") { committedTarget = it }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertEquals("alpha", committedTarget?.repoId)
}
@Test
fun `commit failure never invokes success callback`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"failed"}"""))
var callbackCalled = false
vm.commit("add feature") { callbackCalled = true }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Error>().first() }
assertFalse(callbackCalled)
}
@Test
fun `connection change revokes grant and rejects prior target`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
val priorTarget = vm.currentTarget()!!
enqueueJson("""{"repos":[]}""")
vm.configure(DashboardApiClient(server.url("/").toString()), "connection-b")
vm.setWriteGrant(ownerKey, true)
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.push(GitConfirmationStrings.PUSH, expectedTarget = priorTarget)
assertFalse(vm.hasWriteGrant())
assertEquals(null, vm.currentTarget())
assertTrue(vm.mutation.value is GitMutationState.Error)
}
@Test
fun `discard echoes the fixed confirmation token`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/discard"))
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
}
@Test
fun `push echoes the confirmation token`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.push(GitConfirmationStrings.PUSH)
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/push"))
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
}
@Test
fun `fetch and pull send their endpoints`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.fetch()
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
// Drain the two refreshDetail reads (status + branches) so the next
// takeRequest() sees only the pull POST.
repeat(2) { server.takeRequest() }
enqueuePostSuccess("xyz")
vm.pull("origin", "main")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
}
// ── Error branches ─────────────────────────────────────────────────────
@Test
fun `commit surfaces server error as readable message`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
)
vm.commit(" ")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("must not be empty"))
}
@Test
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
)
vm.discard(listOf("a.txt"), "wrong")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
}
// ── Confirmation gating helpers ────────────────────────────────────────
@Test
fun `requiresConfirmation and confirmationFor match destructive ops`() {
val vm = viewModel()
assertTrue(vm.requiresConfirmation("discard"))
assertTrue(vm.requiresConfirmation("push"))
assertTrue(vm.requiresConfirmation("dirty-checkout"))
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
assertEquals(null, vm.confirmationFor("commit"))
}
}
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SupervisedChatPolicyTest {
@Test
fun `normal mode preserves slash commands`() {
assertNull(supervisedMessageBlockReason(SupervisedModePolicy(), " /model"))
}
@Test
fun `enabled policy fails closed without pinned profile`() {
assertEquals(
"Supervised mode is unavailable until the parent selects a profile.",
supervisedMessageBlockReason(SupervisedModePolicy(enabled = true), "hello"),
)
}
@Test
fun `active policy blocks slash commands after unicode whitespace`() {
val policy = SupervisedModePolicy(enabled = true, pinnedProfileName = "willow")
assertEquals(
"Slash commands are unavailable in supervised mode.",
supervisedMessageBlockReason(policy, "\u2003\t /model hidden"),
)
assertNull(supervisedMessageBlockReason(policy, "please explain /model"))
}
}
@@ -0,0 +1,35 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.voice.VoiceCommandAction
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedVoiceCommandPolicyTest {
@Test
fun `normal mode preserves every voice command`() {
VoiceCommandAction.entries.forEach { action ->
assertTrue(isVoiceCommandAllowed(action, SupervisedModePolicy()))
}
}
@Test
fun `supervised mode gates new chat and cancellation independently`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
voice = true,
newChat = false,
cancelResponse = false,
),
)
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StartNewChat, policy))
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StopResponse, policy))
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.CancelBackgroundTask, policy))
assertTrue(isVoiceCommandAllowed(VoiceCommandAction.EndVoiceChat, policy))
}
}
+2
View File
@@ -91,6 +91,8 @@ the `relay_plugin_draft` tool to create or replace a generated declarative page.
tool accepts the same bounded schema as Android, stores JSON atomically below
`HERMES_HOME/mobile-plugins`, and rejects every `action.request`. Generated previews
therefore cannot reach Relay management APIs or acquire executable backend behavior.
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
generated drafts cannot shadow or duplicate that route.
The Relay mobile manifest exposes drafts as preview pages under the authenticated
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
+119 -2
View File
@@ -1,6 +1,6 @@
# Hermes-Relay — Decisions & Implementation Guide
> Updated: 2026-04-06
> Updated: 2026-08-24
>
> Read this before SPEC.md — it tells you what to build, what was deferred, and why.
@@ -3692,7 +3692,124 @@ be considered later without being silently introduced now.
---
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
## ADR 66 — Android Supervised Mode is a parent-controlled client policy
**Status:** Implemented in code; physical managed-device certification pending (2026-08-24).
**Context.** Some operators prepare a deliberately restricted Hermes profile
for use through a parent-supervised Android client. The profile remains the
authority for its model, prompt, tools, provider credentials, content behavior,
and server-side data. Hermes-Relay should help a parent present a smaller,
proctored phone interface without representing that interface as end-to-end
child security or as a server-enforced account type.
**Decision.** Android will treat Supervised Mode as an opt-in, locally enforced
policy pinned to one existing Connection and one existing Hermes profile. The
parent is responsible for preparing and reviewing that profile before enabling
the mode. Entering, changing, or leaving the parent policy requires Android
device authentication. That prompt authenticates an enrolled device user, not
a distinct server-side parent identity. While the policy is active, the app restores directly
into a restricted root and never renders the ordinary app behind an
authentication prompt. A missing Connection, missing profile, malformed policy,
failed authentication, process restart, or restored route that cannot prove its
owner fails closed to the restricted surface.
The ordinary Chat screen stays visually quiet. It does not carry a persistent
"supervised" banner. Its existing Settings action opens only approved
preferences; a separate **Parent access** row authenticates before showing the
policy editor or full application settings. Backgrounding, inactivity, process
recreation, and leaving parent settings relock parent access according to the
policy. Deep links, notification actions, restored navigation, shortcuts, and
programmatic routes pass the same gate.
The parent policy controls capabilities rather than imposing a special
attachment count. Initial capabilities are text chat, new chat, cancel, steer,
attachments, standard voice, generated-media viewing, save/share media, copy,
retry, quote/reply, and edit/resend. Attachments and voice are independently
enabled. When attachments are enabled, Android retains the normal supported
attachment flow and its existing size/type limits unless the parent selects a
stricter limit; disabling attachments removes every picker, paste-to-file,
camera/share-to-chat, and restored-draft entry point. Disabling voice removes
capture, voice intents, and voice settings from the restricted surface. Provider
credentials remain on the configured Hermes host under the existing standard
voice contract.
The restricted composer does not expose the command palette, slash
autocomplete, server command catalog, or command-generated action cards. A
leading slash is rejected locally rather than dispatched; approved outcomes
such as New chat and Cancel remain explicit typed UI actions. Approval,
clarification, secret, and elevated-access requests are denied or skipped
immediately with a bounded notice. The supervised user cannot authorize them;
a parent may retry from the authenticated full client.
Restricted Settings contains only parent-approved, non-authoritative choices,
such as a supervised-only theme, text size, language, haptics, accessibility,
message presentation, sensitive-media blur, and permitted voice playback
preferences. Connections, Manage, profiles, models, personalities, reasoning,
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
companion, diagnostics, logs, files, credentials, developer controls, Relay
management, and other sessions are absent rather than shown disabled.
The parent may allow the configured floating pet and may independently let the
supervised user change the phone-local profile icon or an already-installed chat
background. The parent retains those appearance controls when supervised-user
changes are disabled. Conversation history and its mutations are separate
permissions: pin, rename, archive/restore, transcript sharing, and delete are
individually allowlisted, while technical session identifiers and cross-profile
administration remain hidden. Delete retains its confirmation step.
The parent also chooses what Chat discloses. **Simple** is the default: agent
name/avatar plus generic Connected, Working, and Reconnecting states; it hides
model, profile, provider/route, context, token/usage, reasoning, and tool detail.
**Transparent** may add timestamps, bounded usage/context information, and
approved activity labels without exposing arguments, results, paths, or
credentials. **Custom** exposes the individual visibility switches. Model name
and profile name default off in every new policy. Required errors, safety
notices, parent-action states, and connection failures cannot be hidden by a
cosmetic visibility choice.
Session selection is limited to the pinned profile. New chat creates a new
conversation for that profile; history visibility, transcript retention, and
conversation actions follow the parent policy. Ending Supervised Mode may clear
local drafts, pending media, and restricted caches, but does not imply deletion
of server-owned session history. Server history remains available through the
parent's ordinary authenticated Hermes surfaces.
When the optional Relay plugin is paired, Android reports a bounded
client-declared `supervised` tag and a non-sensitive policy summary with its
ordinary device identity. Relay and its UI may display that tag and allow the
operator to revoke the paired Relay session through the existing revocation
model. The tag is informational: Relay does not interpret or enforce the Android
policy, pin a profile, filter Gateway traffic, or certify the client. Revoking
the Relay session removes Relay-backed capabilities but cannot revoke a direct
Dashboard/Gateway session or remotely disable an Android-only policy. Without
Relay pairing, Supervised Mode remains usable and locally enforced.
**Security and product boundary.** This mode restricts the official Android UI,
not the Hermes agent or server. It cannot secure another client, a modified APK,
direct server access, server-side tools, provider output, or a parent account
whose credentials are available elsewhere. It is not a substitute for profile
hardening, provider safety controls, parental review, operating-system controls,
or applicable legal obligations. Public language uses **Supervised Mode** or
**parent-controlled client**, not "child account," "safe for children," or
"server enforced."
**Verification gate.** Implementation requires policy, authentication,
navigation, process-death, deep-link, notification, capability, attachment,
voice, session-ownership, Relay-tag, and revocation tests. Physical testing must
cover the exact Android build on a managed/restricted device, including relock,
restart, offline recovery, and attempts to escape the restricted root. Until
that evidence exists, documentation and release notes must call the feature
planned or experimental and must not call it child-ready.
**Consequences.** The project gains a generalized, low-noise supervised client
without creating a new Hermes account type or making Relay a chat authorization
proxy. Parents receive clear local controls and optional paired-device
visibility, while server ownership and the limits of client-side enforcement
remain explicit.
---
## ADR 67 — Android Bot Mode is a separate upstream-owned messaging workspace
**Status:** Accepted (2026-08-24).
+11 -11
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -44,11 +44,11 @@
}
},
"es": {
"native_name": "Español",
"native_name": "Espa\u00f1ol",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -68,11 +68,11 @@
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"ja": {
"native_name": "日本語",
"native_name": "\u65e5\u672c\u8a9e",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -92,11 +92,11 @@
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"pt-BR": {
"native_name": "Português (Brasil)",
"native_name": "Portugu\u00eas (Brasil)",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -116,11 +116,11 @@
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"ru": {
"native_name": "Русский",
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -131,11 +131,11 @@
}
},
"zh-Hans": {
"native_name": "简体中文",
"native_name": "\u7b80\u4f53\u4e2d\u6587",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+5 -1
View File
@@ -7,7 +7,7 @@ Android's declarative plugin surface is specified in
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
**Updated:** 2026-08-22
**Updated:** 2026-08-24
---
@@ -46,6 +46,10 @@ token, terminal/bridge grants, and optional network candidates.
4. **Clean UX** — Material 3, minimal setup, and clear route identity for Vanilla Hermes vs Relay.
5. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
6. **Server-side state** — the app is a thin client. Sessions, history, memory, profiles, and dashboard state live on the Hermes server.
7. **Supervision is a client policy** — Android may offer a parent-controlled,
profile-pinned restricted interface, but it does not claim to make the
selected Hermes profile, server, or agent child-safe. See ADR 66 and the
[Supervised Mode guide](../user-docs/guide/supervised-mode.md).
---
+8 -8
View File
File diff suppressed because one or more lines are too long
+337
View File
@@ -0,0 +1,337 @@
"""Read and write Git state endpoints for the Hermes-Relay dashboard plugin.
Mounted by hermes-agent at ``/api/plugins/hermes-relay/git/*``. These routes
power both the dashboard tab and the Android mobile page.
Route map
---------
Read (GET, no grant):
- ``GET /git/repos`` → scanned repo list under the configured base path
- ``GET /git/status`` → grouped working-tree status for one repo
- ``GET /git/branches`` → branch list (name, upstream, ahead/behind, current)
- ``GET /git/diff`` → per-file diff (kind=staged|unstaged)
- ``GET /git/file`` → read a tracked file
Write (POST, require the plugin's ``plugin.api.write`` grant, which the app
enforces client-side before ever sending the request — the same gate used for
every mutating plugin action in PluginsViewModel.invokeAction):
- ``POST /git/stage`` — stage path list → fresh status
- ``POST /git/unstage`` — unstage path list → fresh status
- ``POST /git/discard`` — discard paths (confirmation) → fresh status
- ``POST /git/commit`` — commit staged index (message) → {head,status}
- ``POST /git/commit_selected``— commit selected paths (message+paths)
- ``POST /git/fetch`` — fetch a remote → {branches,status}
- ``POST /git/pull`` — pull remote/branch → fresh status
- ``POST /git/push`` — push (confirmation) → {branches,status}
- ``POST /git/checkout`` — switch branch (new_branch/track; dirty→confirmation)
Destructive writes (discard, push, dirty checkout) enforce a per-use
confirmation string server-side: missing/wrong → 403. Dirty/conflict trees →
409. The HTTP mapping keeps raw stack traces and JSON dumps out of the UI.
Security
--------
- ``repo`` is an opaque id validated against the scanned allowlist; unknown
ids → 400.
- File paths are validated to reject traversal and absolute escapes.
- Remote URLs are scrubbed of embedded userinfo.
- No ``shell=True`` anywhere: every git invocation uses argument lists.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, HTTPException, Query
from .. import git_state
router = APIRouter(prefix="/git")
def _bad_request(exc: Exception) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
# Structured error taxonomy → HTTP status. The UI renders ``detail`` (a
# human-readable message) plus ``code`` for styling; no raw traces/JSON dumps.
_GIT_ERROR_STATUS = {
"non-repo": 400,
"dirty": 409,
"conflict": 409,
"auth": 502,
"network": 502,
"invalid-input": 400,
"missing-confirmation": 403,
"wrong-confirmation": 403,
}
def _write_error(exc: git_state.GitError) -> HTTPException:
status = _GIT_ERROR_STATUS.get(exc.code, 400)
return HTTPException(status_code=status, detail=str(exc))
def _resolve(repo: str) -> Any:
return git_state.resolve_repo(git_state.base_path(), repo)
@router.get("/repos")
async def get_repos() -> dict[str, Any]:
"""Return the scanned repo list plus a notice when the base path is missing."""
base = git_state.base_path()
repos = git_state.scan_repos(base)
notice = None
if not base.is_dir():
notice = f"Git base path not found: {base}"
return {"repos": repos, "base_path": str(base), "notice": notice}
@router.get("/status")
async def get_status(repo: str = Query(...)) -> dict[str, Any]:
try:
return git_state.repo_status(_resolve(repo))
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.get("/branches")
async def get_branches(repo: str = Query(...)) -> dict[str, Any]:
try:
return {"branches": git_state.repo_branches(_resolve(repo))}
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.get("/diff")
async def get_diff(
repo: str = Query(...),
path: str = Query(...),
kind: str = Query("unstaged"),
) -> dict[str, Any]:
try:
return git_state.repo_diff(_resolve(repo), path, kind)
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.get("/file")
async def get_file(repo: str = Query(...), path: str = Query(...)) -> dict[str, Any]:
try:
return git_state.read_file(_resolve(repo), path)
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
# ── Write endpoints ────────────────────────────────────────────────────────
# Every write requires the plugin.api.write grant (enforced client-side). The
# router re-validates repo + paths against the allowlist/traversal rules and
# classifies git failures into a structured error the UI can render.
def _require_repo(body: dict[str, Any]) -> Any:
repo = body.get("repo")
if not isinstance(repo, str) or not repo:
raise git_state.GitStateError("repo is required")
return git_state.resolve_repo(git_state.base_path(), repo)
@router.post("/stage")
async def post_stage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.stage(_require_repo(body), _paths(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/unstage")
async def post_unstage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.unstage(_require_repo(body), _paths(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/discard")
async def post_discard(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.discard(
_require_repo(body),
_paths(body),
confirmation=body.get("confirmation"),
delete_untracked=bool(body.get("delete_untracked", False)),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit")
async def post_commit(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.commit(_require_repo(body), _message(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit_selected")
async def post_commit_selected(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.commit_selected(_require_repo(body), _message(body), _paths(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/fetch")
async def post_fetch(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.fetch(_require_repo(body), _remote(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/pull")
async def post_pull(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.pull(_require_repo(body), _remote(body), _branch(body))
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/push")
async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
return git_state.push(
_require_repo(body),
_remote(body),
_branch(body),
confirmation=body.get("confirmation"),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/checkout")
async def post_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
try:
new_branch = _str_opt(body, "new_branch")
return git_state.checkout(
_require_repo(body),
_ref(body, allow_empty=bool(new_branch)),
confirmation=body.get("confirmation"),
new_branch=new_branch,
track=bool(body.get("track", False)),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/stash_checkout")
async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Checkout that auto-stashes a dirty tree first.
Unlike a plain dirty checkout, no confirmation is required: a stash is
recoverable (``git stash pop``), so this is not a data-loss path. The
response carries ``stashed`` + ``stash_message`` so the UI can surface the
stash after a successful switch.
"""
try:
new_branch = _str_opt(body, "new_branch")
return git_state.stash_checkout(
_require_repo(body),
_ref(body, allow_empty=bool(new_branch)),
new_branch=new_branch,
track=bool(body.get("track", False)),
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit_message")
async def post_commit_message(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""Generate a conventional-style commit message from the staged diff.
Empty staged diff → ``{message:"", notice:"nothing staged"}`` without calling
the model. A missing/failed model degrades to an empty message + a
``model unavailable`` notice — never a 500. Only staged content is sent.
"""
try:
return await git_state.commit_message(_require_repo(body))
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
@router.post("/commit_message_selected")
async def post_commit_message_selected(
body: dict[str, Any] = Body(...),
) -> dict[str, Any]:
"""Generate a message from the staged diff of the given ``paths`` only."""
try:
return await git_state.commit_message_selected(
_require_repo(body), _paths(body)
)
except git_state.GitError as exc:
raise _write_error(exc) from exc
except git_state.GitStateError as exc:
raise _bad_request(exc) from exc
def _paths(body: dict[str, Any]) -> list[str]:
paths = body.get("paths")
if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths):
raise git_state.GitStateError("paths must be a non-empty list of strings")
return paths
def _message(body: dict[str, Any]) -> str:
message = body.get("message")
if not isinstance(message, str):
raise git_state.GitStateError("message is required")
return message
def _remote(body: dict[str, Any]) -> str:
remote = body.get("remote", "origin")
if not isinstance(remote, str):
raise git_state.GitStateError("remote must be a string")
return remote or "origin"
def _branch(body: dict[str, Any]) -> str:
branch = body.get("branch", "")
if not isinstance(branch, str):
raise git_state.GitStateError("branch must be a string")
return branch
def _ref(body: dict[str, Any], *, allow_empty: bool = False) -> str:
ref = body.get("ref", "" if allow_empty else None)
if not isinstance(ref, str) or (not ref and not allow_empty):
raise git_state.GitStateError("ref is required")
return ref
def _str_opt(body: dict[str, Any], key: str) -> str:
value = body.get(key)
return value if isinstance(value, str) else ""
__all__ = ["router"]
+9
View File
@@ -32,6 +32,15 @@ async def get_mobile_manifest() -> dict[str, Any]:
@router.get("/pages/{plugin_id}")
async def get_mobile_page(plugin_id: str = Path(...)) -> dict[str, Any]:
# The static read-only Git page is served directly from the git_state
# module (not the generated-page store). It carries no filesystem paths
# per the android-plugins.md document contract.
if plugin_id == "git":
from .. import git_state
document = git_state.build_git_document(git_state.base_path())
document["host_revision"] = 1
return document
try:
entry = _store().get(plugin_id)
document = dict(entry["document"])
+1
View File
@@ -142,6 +142,7 @@ def _validate_public_url(url: str) -> str:
router = APIRouter()
router.include_router(_plugin_module("dashboard.mobile_plugin_api").router)
router.include_router(_plugin_module("dashboard.git_api").router)
def _relay_unreachable(err: Exception) -> HTTPException:
+3
View File
@@ -6,6 +6,7 @@ import RelayManagement from "./tabs/RelayManagement.jsx";
import BridgeActivity from "./tabs/BridgeActivity.jsx";
import MediaInspector from "./tabs/MediaInspector.jsx";
import RemoteAccess from "./tabs/RemoteAccess.jsx";
import GitState from "./tabs/GitState.jsx";
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
import { Button, Switch } from "./lib/ui-shims.jsx";
@@ -19,6 +20,7 @@ const TABS = [
{ key: "activity", label: "Activity" },
{ key: "media", label: "Media" },
{ key: "remote", label: "Remote Access" },
{ key: "git", label: "Git" },
];
function readAutoRefresh() {
@@ -113,6 +115,7 @@ function RelayPluginRoot() {
{tab === "activity" && <BridgeActivity autoRefresh={autoRefresh} />}
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
</div>
<MobileConnectDialog
open={mobileConnectOpen}
+104
View File
@@ -139,3 +139,107 @@ export function mintPairingWithMode({ mode, publicUrl, prefer, ...rest } = {}) {
body: JSON.stringify(body),
});
}
// ── Git State tab ───────────────────────────────────────────────────────────
export function getGitRepos() {
return fetchJSON("/git/repos");
}
export function getGitStatus(repo) {
return fetchJSON(`/git/status?repo=${encodeURIComponent(repo)}`);
}
export function getGitBranches(repo) {
return fetchJSON(`/git/branches?repo=${encodeURIComponent(repo)}`);
}
export function getGitDiff(repo, path, kind = "unstaged") {
return fetchJSON(
`/git/diff?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&kind=${encodeURIComponent(kind)}`,
);
}
export function getGitFile(repo, path) {
return fetchJSON(
`/git/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}`,
);
}
// ── Git State write operations ────────────────────────────────────────────
// Every write POST goes through the authenticated Dashboard plugin namespace.
// Android separately enforces its local plugin.api.write preference before it
// calls this namespace. Destructive ops pass a per-use confirmation token.
function postGit(path, body) {
return fetchJSON(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export function gitStage(repo, paths) {
return postGit("/git/stage", { repo, paths });
}
export function gitUnstage(repo, paths) {
return postGit("/git/unstage", { repo, paths });
}
export function gitDiscard(repo, paths, confirmation, deleteUntracked = false) {
return postGit("/git/discard", {
repo,
paths,
confirmation,
delete_untracked: deleteUntracked,
});
}
export function gitCommit(repo, message) {
return postGit("/git/commit", { repo, message });
}
export function gitFetch(repo, remote = "origin") {
return postGit("/git/fetch", { repo, remote });
}
export function gitPull(repo, remote = "origin", branch = "") {
return postGit("/git/pull", { repo, remote, branch });
}
export function gitPush(repo, confirmation, remote = "origin", branch = "") {
return postGit("/git/push", { repo, remote, branch, confirmation });
}
export function gitCheckout(repo, ref, opts = {}) {
const body = {
repo,
ref,
confirmation: opts.confirmation,
new_branch: opts.newBranch || "",
track: !!opts.track,
};
return postGit("/git/checkout", body);
}
// ── Git State Phase 3 extras ───────────────────────────────────────────────
// AI commit-message suggestions + auto-stashing checkout.
export function gitCommitMessage(repo) {
return postGit("/git/commit_message", { repo });
}
export function gitCommitMessageSelected(repo, paths) {
return postGit("/git/commit_message_selected", { repo, paths });
}
export function gitStashCheckout(repo, ref, opts = {}) {
const body = {
repo,
ref,
new_branch: opts.newBranch || "",
track: !!opts.track,
};
return postGit("/git/stash_checkout", body);
}
+148
View File
@@ -0,0 +1,148 @@
// Pure data-mapping helpers for the Git State dashboard tab.
// Kept free of React/DOM so they are unit-testable with node:test.
/**
* Normalize a /git/repos response into a stable repo list.
* Accepts {repos:[...]} or a bare array.
*/
export function normalizeRepos(data) {
const list = Array.isArray(data) ? data : (data && data.repos) || [];
return list.filter((r) => r && typeof r.id === "string");
}
/**
* Normalize a /git/status response into {counts, staged, modified, untracked,
* truncated}. Missing groups become empty arrays.
*/
export function normalizeStatus(data) {
const src = data && typeof data === "object" ? data : {};
return {
counts: {
staged: Number(src.counts && src.counts.staged) || 0,
modified: Number(src.counts && src.counts.modified) || 0,
untracked: Number(src.counts && src.counts.untracked) || 0,
},
staged: Array.isArray(src.staged) ? src.staged : [],
modified: Array.isArray(src.modified) ? src.modified : [],
untracked: Array.isArray(src.untracked) ? src.untracked : [],
truncated: !!src.truncated,
};
}
/**
* Normalize a /git/branches response into a branch list.
* Accepts {branches:[...]} or a bare array.
*/
export function normalizeBranches(data) {
const list = Array.isArray(data) ? data : (data && data.branches) || [];
return list.filter((b) => b && typeof b.name === "string");
}
/**
* Build a human-readable branch label, e.g. "main → origin/main (ahead 1)".
*/
export function branchLabel(branch) {
if (!branch) return "";
const base = branch.name || "";
if (!branch.upstream) return base;
const track =
branch.ahead > 0 || branch.behind > 0
? ` (ahead ${branch.ahead}, behind ${branch.behind})`
: "";
return `${base} → ${branch.upstream}${track}`;
}
/**
* True when a status response is truncated (over the server cap).
*/
export function isTruncated(status) {
return !!(status && status.truncated);
}
/**
* Fixed per-use confirmation tokens for destructive git mutations. These
* mirror the plugin's server-side constants (plugin/git_state.py) and are
* sent in the POST body so the server can enforce the destructive gate.
* The dashboard tab shows a human-readable description before echoing these.
*/
export const CONFIRMATIONS = {
discard: "discard",
push: "push",
dirtyCheckout: "checkout-dirty",
};
/** Ops that require a per-use confirmation string before the POST is sent. */
const DESTRUCTIVE_OPS = new Set(["discard", "push", "dirty-checkout"]);
/**
* True when the named mutation requires a per-use confirmation string.
* The tab must not send the POST without it (matches the server gate).
*/
export function requiresConfirmation(op) {
return DESTRUCTIVE_OPS.has(op);
}
/**
* Return the fixed confirmation token for a destructive op, or null when the
* op is non-destructive (no confirmation needed).
*/
export function confirmationFor(op) {
if (!requiresConfirmation(op)) return null;
if (op === "discard") return CONFIRMATIONS.discard;
if (op === "push") return CONFIRMATIONS.push;
return CONFIRMATIONS.dirtyCheckout;
}
/**
* Normalize a mutation response ({head, status, branches}) into a stable
* shape, filling missing groups so the tab can render without defensive
* branching.
*/
export function normalizeMutationResult(data) {
const src = data && typeof data === "object" ? data : {};
return {
head: typeof src.head === "string" ? src.head : "",
status: normalizeStatus(src.status),
branches: normalizeBranches(src.branches),
};
}
/**
* Normalize a /git/commit_message response into {message, notice, stashed}.
* Missing fields degrade safely so the tab can render without branching.
*/
export function normalizeCommitMessage(data) {
const src = data && typeof data === "object" ? data : {};
return {
message: typeof src.message === "string" ? src.message : "",
notice: typeof src.notice === "string" ? src.notice : "",
};
}
/**
* True when a commit-message suggestion is usable (non-empty message).
*/
export function hasCommitSuggestion(result) {
return !!(result && result.message && result.message.trim());
}
export function isCurrentRepoRequest(currentRepo, currentGeneration, repo, generation) {
return currentRepo === repo && currentGeneration === generation;
}
export function shouldOfferPushAfterCommit(commitSucceeded, pushAfterCommit) {
return commitSucceeded === true && pushAfterCommit === true;
}
/**
* Normalize a /git/stash_checkout response: the standard mutation shape plus
* {stashed, stash_message}.
*/
export function normalizeStashCheckout(data) {
const src = data && typeof data === "object" ? data : {};
return {
...normalizeMutationResult(data),
stashed: !!src.stashed,
stashMessage: typeof src.stash_message === "string" ? src.stash_message : "",
};
}
@@ -0,0 +1,73 @@
const MAX_PROFILE_LABEL_LENGTH = 80;
const MAX_CAPABILITY_LENGTH = 32;
const MAX_CAPABILITIES = 12;
const MAX_VISIBLE_CAPABILITIES = 4;
const CAPABILITY_LABELS = {
text_chat: "Text chat",
attachments: "Attachments",
voice: "Voice",
generated_images: "Generated images",
new_chat: "New chat",
cancel: "Cancel",
steer: "Steer",
share_images: "Share images",
copy: "Copy",
retry: "Retry",
quote_reply: "Quote & reply",
timestamps: "Timestamps",
};
function boundedText(value, maxLength) {
if (typeof value !== "string") return null;
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
if (!normalized) return null;
return normalized.slice(0, maxLength);
}
function capabilityLabel(value) {
const normalized = boundedText(value, MAX_CAPABILITY_LENGTH);
if (!normalized) return null;
const key = normalized.toLowerCase();
return CAPABILITY_LABELS[key] || null;
}
/**
* Normalize optional, client-reported supervised-mode metadata for display.
* This deliberately requires active === true and never treats the report as a
* Relay authorization policy.
*/
export function supervisedSessionDisplay(session) {
const raw = session && session.supervised_mode;
if (
!raw ||
typeof raw !== "object" ||
Array.isArray(raw) ||
raw.active !== true ||
raw.enforcement_owner !== "android_client"
) {
return null;
}
const profileLabel = boundedText(raw.profile_label, MAX_PROFILE_LABEL_LENGTH);
const source = Array.isArray(raw.capabilities) ? raw.capabilities : [];
const capabilities = [];
const seen = new Set();
for (const entry of source.slice(0, MAX_CAPABILITIES)) {
const label = capabilityLabel(entry);
if (!label) continue;
const key = label.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
capabilities.push(label);
}
const visibleCapabilities = capabilities.slice(0, MAX_VISIBLE_CAPABILITIES);
const remainingCapabilityCount = Math.max(0, capabilities.length - visibleCapabilities.length);
return {
profileLabel,
visibleCapabilities,
remainingCapabilityCount,
};
}
+760
View File
@@ -0,0 +1,760 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
import {
getGitRepos,
getGitStatus,
getGitBranches,
getGitDiff,
getGitFile,
gitStage,
gitUnstage,
gitDiscard,
gitCommit,
gitCommitMessage,
gitCommitMessageSelected,
gitStashCheckout,
gitFetch,
gitPull,
gitPush,
gitCheckout,
} from "../lib/api.js";
import {
normalizeMutationResult,
normalizeCommitMessage,
normalizeStashCheckout,
hasCommitSuggestion,
requiresConfirmation,
confirmationFor,
isCurrentRepoRequest,
shouldOfferPushAfterCommit,
} from "../lib/git-state.mjs";
import {
Alert,
AlertTitle,
AlertDescription,
CardDescription,
Button,
Badge,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "../lib/ui-shims.jsx";
const {
Card,
CardHeader,
CardTitle,
CardContent,
Label,
} = SDK.components;
function TruncationNotice({ truncated }) {
if (!truncated) return null;
return (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
Results truncated — showing the first entries only.
</div>
);
}
function StatusRow({ status }) {
if (!status) return null;
const counts = status.counts || {};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-xs">
{counts.staged || 0} staged
</Badge>
<Badge variant="outline" className="text-xs">
{counts.modified || 0} modified
</Badge>
<Badge variant="outline" className="text-xs">
{counts.untracked || 0} untracked
</Badge>
</div>
<TruncationNotice truncated={status.truncated} />
{["staged", "modified", "untracked"].map((group) => {
const items = status[group] || [];
if (items.length === 0) return null;
return (
<div key={group}>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group}
</div>
<ul className="mt-1 space-y-0.5">
{items.map((item) => (
<li key={`${group}-${item.path}`} className="font-mono text-xs">
{item.path}
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
function BranchesRow({ branches }) {
if (!branches || branches.length === 0) return null;
return (
<div className="space-y-1">
{branches.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-xs">
<span className="font-mono">{b.name}</span>
{b.is_current ? <Badge className="text-xs">current</Badge> : null}
{b.upstream ? (
<span className="text-muted-foreground">
→ {b.upstream}
{b.ahead > 0 || b.behind > 0
? ` (ahead ${b.ahead}, behind ${b.behind})`
: ""}
</span>
) : null}
</div>
))}
</div>
);
}
/**
* Write controls for the authenticated Dashboard Git tab. Destructive ops
* (discard/push/dirty-checkout) are confirmed via the per-use confirmation
* mechanics before the POST is sent.
*/
function WriteControls({
status,
selected,
commitMessage,
onCommitMessageChange,
generatingMessage,
onGenerateMessage,
commitNotice,
newBranch,
onNewBranchChange,
branchRef,
onBranchRefChange,
mutating,
pushAfterCommit,
onPushAfterCommitChange,
onStageAll,
onUnstageAll,
onDiscardAll,
onStage,
onUnstage,
onDiscard,
onCommit,
onFetch,
onPull,
onPush,
onCheckout,
onStashCheckout,
}) {
const staged = (status && status.staged || []).map((e) => e.path);
const modified = (status && status.modified || []).map((e) => e.path);
return (
<div className="space-y-3 rounded-md border p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Write controls
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={onStageAll}>
Stage modified
</Button>
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onUnstageAll}>
Unstage staged
</Button>
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onDiscardAll}>
Discard staged
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="text"
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
placeholder="Commit message"
className="w-full rounded-md border px-2 py-1 text-xs"
/>
<Button
size="sm"
variant="outline"
disabled={mutating || generatingMessage || staged.length === 0}
onClick={onGenerateMessage}
title="Generate a commit message from the staged diff"
>
{generatingMessage ? "Generating…" : "Generate"}
</Button>
</div>
{commitNotice ? (
<div className="text-xs text-amber-600">{commitNotice}</div>
) : null}
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={pushAfterCommit}
onChange={(e) => onPushAfterCommitChange(e.target.checked)}
/>
Push after commit
</label>
<Button size="sm" disabled={mutating || !commitMessage.trim()} onClick={onCommit}>
Commit
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={branchRef}
onChange={(e) => onBranchRefChange(e.target.value)}
placeholder="Branch to switch to"
className="w-40 rounded-md border px-2 py-1 text-xs"
/>
<input
type="text"
value={newBranch}
onChange={(e) => onNewBranchChange(e.target.value)}
placeholder="New branch name"
className="w-40 rounded-md border px-2 py-1 text-xs"
/>
<Button size="sm" variant="outline" disabled={mutating || !branchRef.trim()} onClick={onCheckout}>
Checkout
</Button>
<Button
size="sm"
variant="outline"
disabled={mutating || !branchRef.trim()}
onClick={onStashCheckout}
title="Switch branches, auto-stashing a dirty tree first"
>
Stash-checkout
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={mutating} onClick={onFetch}>
Fetch
</Button>
<Button size="sm" variant="outline" disabled={mutating} onClick={onPull}>
Pull
</Button>
<Button size="sm" variant="destructive" disabled={mutating} onClick={onPush}>
Push
</Button>
</div>
</div>
);
}
export default function GitState({ autoRefresh }) {
const [repos, setRepos] = useState(null);
const [selected, setSelected] = useState(null);
const [status, setStatus] = useState(null);
const [branches, setBranches] = useState(null);
const [diff, setDiff] = useState(null);
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const selectedRef = useRef(null);
const requestGenerationRef = useRef(0);
const mutationActiveRef = useRef(false);
const loadRepos = useCallback(async () => {
setError(null);
try {
const data = await getGitRepos();
const list = (data && data.repos) || [];
setRepos(list);
setNotice((data && data.notice) || null);
const currentSelected = selectedRef.current;
if (currentSelected && !list.some((r) => r.id === currentSelected)) {
selectedRef.current = null;
requestGenerationRef.current += 1;
setSelected(null);
setStatus(null);
setBranches(null);
setDiff(null);
setFile(null);
setGeneratingMessage(false);
setCommitNotice(null);
}
} catch (err) {
setError(err && err.message ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadRepos();
}, [loadRepos]);
useEffect(() => {
if (!autoRefresh) return undefined;
const id = setInterval(loadRepos, 15000);
return () => clearInterval(id);
}, [autoRefresh, loadRepos]);
const selectRepo = useCallback(async (repoId) => {
const generation = requestGenerationRef.current + 1;
requestGenerationRef.current = generation;
selectedRef.current = repoId;
setSelected(repoId);
setStatus(null);
setBranches(null);
setDiff(null);
setFile(null);
setGeneratingMessage(false);
setCommitNotice(null);
setError(null);
try {
const [st, br] = await Promise.all([
getGitStatus(repoId),
getGitBranches(repoId),
]);
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setStatus(st);
setBranches(br && br.branches);
} catch (err) {
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setError(err && err.message ? err.message : String(err));
}
}, []);
const showDiff = useCallback(async (path, kind) => {
const repoId = selectedRef.current;
if (!repoId) return;
const generation = requestGenerationRef.current;
setFile(null);
setError(null);
try {
const nextDiff = await getGitDiff(repoId, path, kind);
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setDiff(nextDiff);
} catch (err) {
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setError(err && err.message ? err.message : String(err));
}
}, []);
const showFile = useCallback(async (path) => {
const repoId = selectedRef.current;
if (!repoId) return;
const generation = requestGenerationRef.current;
setDiff(null);
setError(null);
try {
const nextFile = await getGitFile(repoId, path);
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setFile(nextFile);
} catch (err) {
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setError(err && err.message ? err.message : String(err));
}
}, []);
// ── Authenticated Dashboard write controls + confirmations ──────────────
const [commitMessage, setCommitMessage] = useState("");
const [newBranch, setNewBranch] = useState("");
const [branchRef, setBranchRef] = useState("");
const [mutating, setMutating] = useState(false);
const [mutationError, setMutationError] = useState(null);
const [generatingMessage, setGeneratingMessage] = useState(false);
const [commitNotice, setCommitNotice] = useState(null);
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const refreshDetail = useCallback(async (repoId, generation) => {
const [st, br] = await Promise.all([
getGitStatus(repoId),
getGitBranches(repoId),
]);
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setStatus(st);
setBranches(br && br.branches);
}, []);
const applyMutation = useCallback(
async (op, paths, opts) => {
const repoId = selectedRef.current;
const generation = requestGenerationRef.current;
if (!repoId || mutationActiveRef.current) return false;
mutationActiveRef.current = true;
setMutationError(null);
setMutating(true);
try {
if (op === "stage") await gitStage(repoId, paths);
else if (op === "unstage") await gitUnstage(repoId, paths);
else if (op === "fetch") await gitFetch(repoId, opts?.remote || "origin");
else if (op === "pull") await gitPull(repoId, opts?.remote || "origin", opts?.branch || "");
else if (op === "commit") await gitCommit(repoId, opts?.message);
else if (op === "discard") await gitDiscard(repoId, paths, opts?.confirmation, opts?.deleteUntracked);
else if (op === "push") await gitPush(repoId, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
else if (op === "checkout" || op === "dirty-checkout") {
await gitCheckout(repoId, opts.ref, {
confirmation: opts.confirmation,
newBranch: opts.newBranch,
track: opts.track,
});
} else throw new Error(`Unknown Git operation: ${op}`);
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
return false;
}
await refreshDetail(repoId, generation);
return true;
} catch (err) {
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
setMutationError(err && err.message ? err.message : String(err));
}
return false;
} finally {
mutationActiveRef.current = false;
setMutating(false);
}
},
[refreshDetail],
);
/**
* Generate a commit-message suggestion from the staged diff (AI). Empty
* staged diff / model-unavailable degrade to a notice, never an error.
*/
const generateMessage = useCallback(async () => {
const repoId = selectedRef.current;
if (!repoId) return;
const generation = requestGenerationRef.current;
setGeneratingMessage(true);
setCommitNotice(null);
try {
const stagedPaths = (status && status.staged || []).map((e) => e.path);
const data = stagedPaths.length > 0 && !status?.truncated
? await gitCommitMessageSelected(repoId, stagedPaths)
: await gitCommitMessage(repoId);
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
const result = normalizeCommitMessage(data);
if (hasCommitSuggestion(result)) {
setCommitMessage(result.message);
setCommitNotice(result.notice || null);
} else {
setCommitNotice(result.notice || "Nothing staged to generate a message from.");
}
} catch (err) {
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
setCommitNotice(err && err.message ? err.message : String(err));
} finally {
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
setGeneratingMessage(false);
}
}
}, [status]);
/**
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
* confirmation is needed because a stash is recoverable (git stash pop).
* On success, surface the stash message so the user can pop it later.
*/
const doStashCheckout = useCallback(async () => {
const ref = branchRef.trim();
const repoId = selectedRef.current;
const generation = requestGenerationRef.current;
if (!ref || !repoId || mutationActiveRef.current) return;
mutationActiveRef.current = true;
setMutationError(null);
setCommitNotice(null);
setMutating(true);
try {
const data = await gitStashCheckout(repoId, ref, {
newBranch: newBranch.trim(),
track: false,
});
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
const result = normalizeStashCheckout(data);
if (result.stashed) {
setCommitNotice(
`Stashed changes on ${ref} as “${result.stashMessage}”. Use “git stash pop” to restore them.`,
);
}
await refreshDetail(repoId, generation);
} catch (err) {
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
setMutationError(err && err.message ? err.message : String(err));
}
} finally {
mutationActiveRef.current = false;
setMutating(false);
setBranchRef("");
setNewBranch("");
}
}, [branchRef, newBranch, refreshDetail]);
/**
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
* confirmation echoed back to the server. Matches the dashboard's existing
* confirm-before-destructive pattern (see RelayManagement onRevoke) and the
* plugin's confirmation-string mechanics.
*/
const requestMutation = useCallback((op, opts) => {
if (requiresConfirmation(op)) {
const description =
op === "discard"
? "Discard local changes? This cannot be undone."
: op === "push"
? "Push local commits to the remote repository?"
: "Working tree has uncommitted changes. Switch branches anyway?";
if (!window.confirm(description)) return;
const token = confirmationFor(op);
if (op === "discard") applyMutation("discard", opts?.paths, { confirmation: token, deleteUntracked: opts?.deleteUntracked });
else if (op === "push") applyMutation("push", [], { confirmation: token, remote: opts?.remote, branch: opts?.branch });
else if (op === "dirty-checkout") applyMutation("dirty-checkout", [], { ...opts, confirmation: token });
return;
}
applyMutation(op, opts?.paths, opts);
}, [applyMutation]);
const doCommit = useCallback(async () => {
const message = commitMessage.trim();
if (!message) {
setMutationError("Commit message must not be empty.");
return;
}
const succeeded = await applyMutation("commit", [], { message });
if (!succeeded) return;
setCommitMessage("");
// Push-after-commit: when the toggle is ON, immediately start the existing
// push confirmation flow. Confirmation is still required (never bypassed);
// the toggle only auto-starts it after a successful commit.
if (shouldOfferPushAfterCommit(succeeded, pushAfterCommit)) {
requestMutation("push", {});
}
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
const doCheckout = useCallback(async () => {
const ref = branchRef.trim();
if (!ref) return;
const dirty = status && (status.counts.modified + status.counts.staged + status.counts.untracked) > 0;
const opts = { ref, newBranch: newBranch.trim(), track: false };
if (dirty && !opts.newBranch) {
requestMutation("dirty-checkout", opts);
setBranchRef("");
setNewBranch("");
return;
}
await applyMutation("checkout", [], opts);
setBranchRef("");
setNewBranch("");
}, [branchRef, newBranch, status, applyMutation, requestMutation]);
if (loading && repos === null) {
return <div className="text-sm text-muted-foreground">Loading repositories…</div>;
}
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Git state unavailable</AlertTitle>
<AlertDescription>
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
<Button className="mt-2" size="sm" variant="outline" onClick={loadRepos}>
Retry
</Button>
</AlertDescription>
</Alert>
);
}
const list = repos || [];
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Repositories</CardTitle>
<CardDescription>
Repositories scanned from the configured Git base path.
</CardDescription>
</CardHeader>
<CardContent>
{notice ? (
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
{notice}
</div>
) : null}
{list.length === 0 ? (
<div className="text-sm text-muted-foreground">
No repositories found.
</div>
) : (
<div className="flex flex-wrap gap-2">
{list.map((repo) => (
<Button
key={repo.id}
size="sm"
variant={selected === repo.id ? "default" : "outline"}
onClick={() => selectRepo(repo.id)}
>
{repo.name}
{repo.dirty ? " •" : ""}
</Button>
))}
</div>
)}
</CardContent>
</Card>
{selected ? (
<Card>
<CardHeader>
<CardTitle>{selected}</CardTitle>
<CardDescription>
Working tree and branches. Tap a changed file to view its diff or
content.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{mutationError ? (
<Alert variant="destructive">
<AlertTitle>Git mutation failed</AlertTitle>
<AlertDescription>
<pre className="whitespace-pre-wrap text-xs">{mutationError}</pre>
</AlertDescription>
</Alert>
) : null}
<StatusRow status={status} />
<BranchesRow branches={branches} />
<WriteControls
status={status}
branches={branches}
selected={selected}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatingMessage={generatingMessage}
onGenerateMessage={generateMessage}
commitNotice={commitNotice}
newBranch={newBranch}
onNewBranchChange={setNewBranch}
branchRef={branchRef}
onBranchRefChange={setBranchRef}
mutating={mutating}
pushAfterCommit={pushAfterCommit}
onPushAfterCommitChange={setPushAfterCommit}
onStageAll={() => requestMutation("stage", { paths: (status && status.modified || []).map((e) => e.path) })}
onUnstageAll={() => requestMutation("unstage", { paths: (status && status.staged || []).map((e) => e.path) })}
onDiscardAll={() => requestMutation("discard", { paths: (status && status.staged || []).map((e) => e.path), deleteUntracked: false })}
onStage={(path) => requestMutation("stage", { paths: [path] })}
onUnstage={(path) => requestMutation("unstage", { paths: [path] })}
onDiscard={(path) => requestMutation("discard", { paths: [path], deleteUntracked: false })}
onCommit={doCommit}
onFetch={() => requestMutation("fetch", {})}
onPull={() => requestMutation("pull", {})}
onPush={() => requestMutation("push", {})}
onCheckout={doCheckout}
onStashCheckout={doStashCheckout}
/>
{status && (status.staged || []).length > 0 ? (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Staged diffs
</div>
<ul className="mt-1 space-y-0.5">
{(status.staged || []).map((item) => (
<li key={`sd-${item.path}`}>
<button
type="button"
className="font-mono text-xs text-primary underline"
onClick={() => showDiff(item.path, "staged")}
>
{item.path}
</button>
</li>
))}
</ul>
</div>
) : null}
{status && (status.modified || []).length > 0 ? (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Unstaged diffs
</div>
<ul className="mt-1 space-y-0.5">
{(status.modified || []).map((item) => (
<li key={`ud-${item.path}`}>
<button
type="button"
className="font-mono text-xs text-primary underline"
onClick={() => showDiff(item.path, "unstaged")}
>
{item.path}
</button>
</li>
))}
</ul>
</div>
) : null}
{status && (status.untracked || []).length > 0 ? (
<div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Untracked files
</div>
<ul className="mt-1 space-y-0.5">
{(status.untracked || []).map((item) => (
<li key={`uf-${item.path}`}>
<button
type="button"
className="font-mono text-xs text-primary underline"
onClick={() => showFile(item.path)}
>
{item.path}
</button>
</li>
))}
</ul>
</div>
) : null}
{diff ? (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diff — {diff.path} ({diff.kind})
</div>
<TruncationNotice truncated={diff.truncated} />
</div>
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
{diff.diff || "(no changes)"}
</pre>
</div>
) : null}
{file ? (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
File — {file.path}
</div>
<TruncationNotice truncated={file.truncated} />
</div>
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
{file.content}
</pre>
</div>
) : null}
</CardContent>
</Card>
) : null}
</div>
);
}
+34 -1
View File
@@ -13,6 +13,7 @@ import {
} from "../lib/api.js";
import { relativeTime, ttlCountdown, uptime, shortToken } from "../lib/formatters.js";
import { formatSessionExpiry } from "../lib/session-expiry.mjs";
import { supervisedSessionDisplay } from "../lib/supervised-session.mjs";
import PairDialog from "../components/PairDialog.jsx";
import {
Alert,
@@ -596,6 +597,7 @@ export default function RelayManagement({ autoRefresh }) {
const ov = overview || {};
const list = sessions || [];
const hasSupervisedSession = list.some((session) => supervisedSessionDisplay(session));
return (
<div className="space-y-4">
@@ -643,6 +645,13 @@ export default function RelayManagement({ autoRefresh }) {
</Button>
</CardHeader>
<CardContent>
{hasSupervisedSession ? (
<div className="mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Supervised mode is reported and enforced by the Android client, not by Relay. Relay
shows the client&apos;s reported settings here so a paired device can be identified and
revoked.
</div>
) : null}
{!autoRefresh ? (
<div className="mb-3">
<Button size="sm" variant="outline" onClick={load}>
@@ -685,13 +694,21 @@ export default function RelayManagement({ autoRefresh }) {
const grants = extractGrants(s);
const type = classifySession(s, grants);
const transport = sessionTransport(s);
const supervised = supervisedSessionDisplay(s);
const deviceDetail = [s.device_model, s.device_platform]
.filter((value) => value && value !== "unknown")
.join(" · ");
return (
<TableRow key={tokenPrefix || idx}>
<TableCell className="font-medium">
<div>{label}</div>
<div className="flex flex-wrap items-center gap-2">
<span>{label}</span>
{supervised ? (
<Badge variant="secondary" className="w-fit text-xs">
Supervised
</Badge>
) : null}
</div>
<div className="font-mono text-xs font-normal text-muted-foreground">
{tokenPrefix ? shortToken(tokenPrefix, 12) : "no token prefix"}
</div>
@@ -700,6 +717,22 @@ export default function RelayManagement({ autoRefresh }) {
{deviceDetail}
</div>
) : null}
{supervised && supervised.profileLabel ? (
<div className="text-xs font-normal text-muted-foreground">
Pinned profile: {supervised.profileLabel}
</div>
) : null}
{supervised && supervised.visibleCapabilities.length > 0 ? (
<div
className="max-w-xs text-xs font-normal text-muted-foreground"
title="Capabilities reported by the Android client"
>
Client allows: {supervised.visibleCapabilities.join(" · ")}
{supervised.remainingCapabilityCount > 0
? ` · +${supervised.remainingCapabilityCount} more`
: ""}
</div>
) : null}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
@@ -0,0 +1,126 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
normalizeMutationResult,
confirmationFor,
requiresConfirmation,
CONFIRMATIONS,
isCurrentRepoRequest,
shouldOfferPushAfterCommit,
} from "../src/lib/git-state.mjs";
test("normalizeMutationResult maps head/status/branches safely", () => {
const r = normalizeMutationResult({
head: "abc123",
status: { counts: { staged: 1 } },
branches: [{ name: "main" }],
});
assert.equal(r.head, "abc123");
assert.equal(r.status.counts.staged, 1);
assert.equal(r.branches.length, 1);
});
test("normalizeMutationResult tolerates missing fields", () => {
const r = normalizeMutationResult(null);
assert.equal(r.head, "");
assert.deepEqual(r.status.counts, { staged: 0, modified: 0, untracked: 0 });
assert.deepEqual(r.branches, []);
});
test("CONFIRMATIONS holds the fixed confirmation tokens", () => {
assert.equal(CONFIRMATIONS.discard, "discard");
assert.equal(CONFIRMATIONS.push, "push");
assert.equal(CONFIRMATIONS.dirtyCheckout, "checkout-dirty");
});
test("confirmationFor returns the token only for destructive ops", () => {
assert.equal(confirmationFor("discard"), CONFIRMATIONS.discard);
assert.equal(confirmationFor("push"), CONFIRMATIONS.push);
assert.equal(confirmationFor("dirty-checkout"), CONFIRMATIONS.dirtyCheckout);
assert.equal(confirmationFor("commit"), null);
assert.equal(confirmationFor("stage"), null);
});
test("requiresConfirmation gates only destructive ops", () => {
assert.equal(requiresConfirmation("discard"), true);
assert.equal(requiresConfirmation("push"), true);
assert.equal(requiresConfirmation("dirty-checkout"), true);
assert.equal(requiresConfirmation("stage"), false);
assert.equal(requiresConfirmation("commit"), false);
assert.equal(requiresConfirmation("fetch"), false);
});
test("repository request ownership rejects stale repo or generation", () => {
assert.equal(isCurrentRepoRequest("a", 2, "a", 2), true);
assert.equal(isCurrentRepoRequest("b", 2, "a", 2), false);
assert.equal(isCurrentRepoRequest("a", 3, "a", 2), false);
});
test("push-after-commit requires the exact commit to succeed", () => {
assert.equal(shouldOfferPushAfterCommit(true, true), true);
assert.equal(shouldOfferPushAfterCommit(false, true), false);
assert.equal(shouldOfferPushAfterCommit(true, false), false);
});
test("GitState commits the complete index and dispatches clean checkout", () => {
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
assert.match(source, /applyMutation\("commit", \[\], \{ message \}\)/);
assert.doesNotMatch(source, /applyMutation\("commitSelected"/);
assert.match(source, /op === "checkout" \|\| op === "dirty-checkout"/);
});
test("GitState bulk actions dispatch one bounded path array", () => {
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
assert.match(source, /onClick=\{onStageAll\}/);
assert.match(source, /onClick=\{onUnstageAll\}/);
assert.match(source, /onClick=\{onDiscardAll\}/);
assert.doesNotMatch(source, /forEach\(onStage\)|forEach\(onUnstage\)|forEach\(onDiscard\)/);
});
// ── Phase 3 extras ─────────────────────────────────────────────────────────
import {
normalizeCommitMessage,
hasCommitSuggestion,
normalizeStashCheckout,
} from "../src/lib/git-state.mjs";
test("normalizeCommitMessage maps message/notice safely", () => {
const r = normalizeCommitMessage({ message: "feat: add x", notice: "" });
assert.equal(r.message, "feat: add x");
assert.equal(r.notice, "");
});
test("normalizeCommitMessage tolerates missing/empty fields", () => {
assert.deepEqual(normalizeCommitMessage(null), { message: "", notice: "" });
assert.deepEqual(normalizeCommitMessage({}), { message: "", notice: "" });
});
test("hasCommitSuggestion is false for empty/whitespace messages", () => {
assert.equal(hasCommitSuggestion({ message: "feat: add x" }), true);
assert.equal(hasCommitSuggestion({ message: "" }), false);
assert.equal(hasCommitSuggestion({ message: " " }), false);
assert.equal(hasCommitSuggestion(null), false);
});
test("normalizeStashCheckout carries stashed + stash_message", () => {
const r = normalizeStashCheckout({
head: "abc",
stashed: true,
stash_message: "git-state: feature",
status: { counts: { staged: 0 } },
branches: [],
});
assert.equal(r.head, "abc");
assert.equal(r.stashed, true);
assert.equal(r.stashMessage, "git-state: feature");
assert.equal(r.status.counts.staged, 0);
});
test("normalizeStashCheckout defaults stashed false when absent", () => {
const r = normalizeStashCheckout({});
assert.equal(r.stashed, false);
assert.equal(r.stashMessage, "");
});
+60
View File
@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeRepos,
normalizeStatus,
normalizeBranches,
branchLabel,
isTruncated,
} from "../src/lib/git-state.mjs";
test("normalizeRepos accepts object and bare-array shapes", () => {
assert.deepEqual(
normalizeRepos({ repos: [{ id: "a", name: "A" }] }),
[{ id: "a", name: "A" }],
);
assert.deepEqual(normalizeRepos([{ id: "b", name: "B" }]), [
{ id: "b", name: "B" },
]);
assert.deepEqual(normalizeRepos(null), []);
assert.deepEqual(normalizeRepos({ repos: [{ id: 1 }, null] }), []);
});
test("normalizeStatus fills missing groups and preserves truncation", () => {
const status = normalizeStatus({
counts: { staged: 1, modified: 2, untracked: 3 },
staged: [{ path: "a" }],
truncated: true,
});
assert.equal(status.counts.staged, 1);
assert.equal(status.counts.modified, 2);
assert.equal(status.counts.untracked, 3);
assert.deepEqual(status.staged, [{ path: "a" }]);
assert.deepEqual(status.modified, []);
assert.deepEqual(status.untracked, []);
assert.equal(status.truncated, true);
assert.equal(isTruncated(status), true);
assert.equal(isTruncated(normalizeStatus({})), false);
});
test("normalizeBranches accepts object and bare-array shapes", () => {
assert.deepEqual(normalizeBranches({ branches: [{ name: "main" }] }), [
{ name: "main" },
]);
assert.deepEqual(normalizeBranches([{ name: "dev" }]), [{ name: "dev" }]);
assert.deepEqual(normalizeBranches(null), []);
});
test("branchLabel renders upstream and ahead/behind", () => {
assert.equal(branchLabel({ name: "main" }), "main");
assert.equal(
branchLabel({ name: "main", upstream: "origin/main" }),
"main → origin/main",
);
assert.equal(
branchLabel({ name: "feature", upstream: "origin/feature", ahead: 1, behind: 2 }),
"feature → origin/feature (ahead 1, behind 2)",
);
assert.equal(branchLabel(null), "");
});
@@ -0,0 +1,81 @@
import test from "node:test";
import assert from "node:assert/strict";
import { supervisedSessionDisplay } from "../src/lib/supervised-session.mjs";
test("leaves legacy and inactive sessions unchanged", () => {
assert.equal(supervisedSessionDisplay({ device_name: "Pixel" }), null);
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: false } }), null);
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: "true" } }), null);
assert.equal(
supervisedSessionDisplay({ supervised_mode: { active: true, enforcement_owner: "relay" } }),
null,
);
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: true } }), null);
assert.equal(supervisedSessionDisplay({ supervised_mode: [] }), null);
});
test("formats active client-reported metadata", () => {
assert.deepEqual(
supervisedSessionDisplay({
supervised_mode: {
active: true,
profile_label: " Willow ",
capabilities: ["attachments", "voice", "generated_images", "new_chat"],
enforcement_owner: "android_client",
},
}),
{
profileLabel: "Willow",
visibleCapabilities: ["Attachments", "Voice", "Generated images", "New chat"],
remainingCapabilityCount: 0,
},
);
});
test("bounds, sanitizes, and deduplicates untrusted display values", () => {
const result = supervisedSessionDisplay({
supervised_mode: {
active: true,
enforcement_owner: "android_client",
profile_label: `Willow\u0000 ${"x".repeat(100)}`,
capabilities: [
"voice",
"VOICE",
"unknown_capability",
"text_chat",
"generated_images",
"cancel",
"steer",
"attachments",
"new_chat",
"share_images",
"copy",
"retry",
"quote_reply",
"timestamps",
],
},
});
assert.equal(result.profileLabel.length, 80);
assert.deepEqual(
result.visibleCapabilities,
["Voice", "Text chat", "Generated images", "Cancel"],
);
assert.equal(result.remainingCapabilityCount, 6);
});
test("tolerates malformed optional members", () => {
assert.deepEqual(
supervisedSessionDisplay({
supervised_mode: {
active: true,
enforcement_owner: "android_client",
profile_label: 42,
capabilities: "voice",
},
}),
{ profileLabel: null, visibleCapabilities: [], remainingCapabilityCount: 0 },
);
});
+559
View File
@@ -0,0 +1,559 @@
"""Tests for the write (POST) Git State endpoints in plugin/dashboard/git_api.py.
Write endpoints are POST and rely on the plugin's ``plugin.api.write`` grant
which the app enforces client-side (see PluginsViewModel.invokeAction — the
precedent gate). Server-side, destructive operations are additionally enforced
by a required confirmation string; missing/wrong confirmation maps to 403 and
dirty/conflict trees map to 409, so the UI can render a readable message.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from plugin.dashboard import git_api
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, check=True
).stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
def _init_bare_remote(root: Path, name: str) -> Path:
remote = root / name
remote.mkdir(parents=True, exist_ok=True)
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
return remote
class GitWriteApiTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "alpha")
self.env = patch.dict(
os.environ,
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
)
self.env.start()
self.addCleanup(self.env.stop)
app = FastAPI()
app.include_router(git_api.router)
self.client = TestClient(app)
def _stage(self, path: str) -> None:
(self.repo / path).write_text("x", encoding="utf-8")
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
def test_stage_returns_fresh_status(self) -> None:
(self.repo / "new.txt").write_text("x", encoding="utf-8")
response = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": ["new.txt"]}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual(1, body["status"]["counts"]["staged"])
def test_unknown_repo_rejected(self) -> None:
response = self.client.post(
"/git/stage", json={"repo": "bogus", "paths": ["x"]}
)
self.assertEqual(400, response.status_code, response.text)
def test_remote_operations_reject_urls_options_and_wrong_types(self) -> None:
for path, payload in (
("/git/fetch", {"remote": "https://example.invalid/repo.git"}),
("/git/fetch", {"remote": "--all"}),
("/git/pull", {"remote": ["origin"], "branch": "main"}),
("/git/push", {"remote": "origin", "branch": "--mirror", "confirmation": "push"}),
):
with self.subTest(path=path, payload=payload):
response = self.client.post(path, json={"repo": "alpha", **payload})
self.assertEqual(400, response.status_code, response.text)
def test_commit_creates_commit(self) -> None:
self._stage("feature.txt")
before = _git(self.repo, "rev-parse", "HEAD")
response = self.client.post(
"/git/commit", json={"repo": "alpha", "message": "add feature"}
)
self.assertEqual(200, response.status_code, response.text)
after = _git(self.repo, "rev-parse", "HEAD")
self.assertNotEqual(before, after)
def test_commit_empty_message_rejected(self) -> None:
self._stage("feature.txt")
response = self.client.post(
"/git/commit", json={"repo": "alpha", "message": " "}
)
self.assertEqual(400, response.status_code, response.text)
def test_discard_without_confirmation_is_403(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
response = self.client.post(
"/git/discard", json={"repo": "alpha", "paths": ["tracked.txt"]}
)
self.assertEqual(403, response.status_code, response.text)
def test_discard_wrong_confirmation_is_403(self) -> None:
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
response = self.client.post(
"/git/discard",
json={
"repo": "alpha",
"paths": ["tracked.txt"],
"confirmation": "wrong",
},
)
self.assertEqual(403, response.status_code, response.text)
def test_discard_with_confirmation_succeeds(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
response = self.client.post(
"/git/discard",
json={
"repo": "alpha",
"paths": ["tracked.txt"],
"confirmation": git_state.CONFIRM_DISCARD,
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("v1", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
def test_push_requires_confirmation(self) -> None:
remote = _init_bare_remote(self.base, "origin-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
self._stage("feature.txt")
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
response = self.client.post(
"/git/push", json={"repo": "alpha", "remote": "origin", "branch": "main"}
)
self.assertEqual(403, response.status_code, response.text)
def test_push_with_confirmation_succeeds(self) -> None:
remote = _init_bare_remote(self.base, "remote-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
self._stage("feature.txt")
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
response = self.client.post(
"/git/push",
json={
"repo": "alpha",
"remote": "origin",
"branch": "main",
"confirmation": git_state.CONFIRM_PUSH,
},
)
self.assertEqual(200, response.status_code, response.text)
def test_checkout_dirty_requires_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
response = self.client.post(
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
)
# Missing confirmation on a dirty-tree switch is the destructive gate.
self.assertEqual(403, response.status_code, response.text)
# Confirming proceeds (git still refuses to overwrite conflicting work).
response = self.client.post(
"/git/checkout",
json={
"repo": "alpha",
"ref": "feature",
"confirmation": git_state.CONFIRM_DIRTY_CHECKOUT,
},
)
self.assertEqual(200, response.status_code, response.text)
def test_pull_dirty_returns_409_never_clobbers(self) -> None:
remote = _init_bare_remote(self.base, "remote-bare2")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
# Advance the remote from a descendant clone.
other = self.base / "other"
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
_git(other, "config", "user.email", "t@e.c")
_git(other, "config", "user.name", "T")
(other / "tracked.txt").write_text("remote", encoding="utf-8")
_git(other, "add", "tracked.txt")
_git(other, "commit", "-q", "-m", "remote")
_git(other, "push", "-q", "origin", "main")
(self.repo / "tracked.txt").write_text("local-uncommitted", encoding="utf-8")
response = self.client.post(
"/git/pull", json={"repo": "alpha", "remote": "origin", "branch": "main"}
)
self.assertEqual(409, response.status_code, response.text)
self.assertEqual("local-uncommitted", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
def test_fetch_returns_branches(self) -> None:
remote = _init_bare_remote(self.base, "remote-bare3")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
response = self.client.post(
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
def test_structured_error_taxonomy_is_readable(self) -> None:
# Unknown repo → 400 with a readable detail, never a stack trace.
response = self.client.post(
"/git/stage", json={"repo": "missing", "paths": ["x"]}
)
self.assertEqual(400, response.status_code, response.text)
self.assertNotIn("Traceback", response.text)
self.assertNotIn("subprocess", response.text.lower())
def test_push_wrong_confirmation_is_403(self) -> None:
remote = _init_bare_remote(self.base, "push-wrong-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
self._stage("feature.txt")
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
response = self.client.post(
"/git/push",
json={
"repo": "alpha",
"remote": "origin",
"branch": "main",
"confirmation": "nope",
},
)
self.assertEqual(403, response.status_code, response.text)
def test_push_with_confirmation_updates_remote_and_returns_branches(self) -> None:
remote = _init_bare_remote(self.base, "push-ok-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
self._stage("feature.txt")
r = self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
self.assertEqual(200, r.status_code, r.text)
head = r.json()["head"]
response = self.client.post(
"/git/push",
json={
"repo": "alpha",
"remote": "origin",
"branch": "main",
"confirmation": git_state.CONFIRM_PUSH,
},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
remote_head = _run(
["git", "ls-remote", str(remote), "refs/heads/main"], self.base
)
self.assertIn(head, remote_head)
def test_checkout_dirty_tree_wrong_confirmation_is_403(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
response = self.client.post(
"/git/checkout",
json={"repo": "alpha", "ref": "feature", "confirmation": "wrong"},
)
self.assertEqual(403, response.status_code, response.text)
def test_checkout_clean_tree_works_without_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
response = self.client.post(
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
self.assertEqual(
"feature", _git(self.repo, "symbolic-ref", "--short", "HEAD")
)
def test_checkout_new_branch_with_track_sets_upstream(self) -> None:
remote = _init_bare_remote(self.base, "newbranch-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
response = self.client.post(
"/git/checkout",
json={
"repo": "alpha",
"ref": "main",
"new_branch": "exp",
"track": True,
},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
# Upstream is set — rev-parse resolves to a commit, not an error.
self.assertTrue(_git(self.repo, "rev-parse", "exp@{upstream}"))
def test_fetch_updates_remote_tracking_ref(self) -> None:
remote = _init_bare_remote(self.base, "fetch-adv-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
other = self.base / "other"
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
_git(other, "config", "user.email", "t@e.c")
_git(other, "config", "user.name", "T")
(other / "remote.txt").write_text("rc fetch", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "rc fetch")
_git(other, "push", "-q", "origin", "main")
response = self.client.post(
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("branches", body)
self.assertIn("status", body)
remote_main = _git(self.repo, "rev-parse", "origin/main")
self.assertNotEqual(remote_main, _git(self.repo, "rev-parse", "HEAD"))
def test_pull_returns_200_with_remote_commit(self) -> None:
remote = _init_bare_remote(self.base, "pull-ok-bare")
_git(self.repo, "remote", "add", "origin", str(remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
other = self.base / "other"
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
_git(other, "config", "user.email", "t@e.c")
_git(other, "config", "user.name", "T")
(other / "remote.txt").write_text("rc pull", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "rc pull")
_git(other, "push", "-q", "origin", "main")
response = self.client.post(
"/git/pull",
json={"repo": "alpha", "remote": "origin", "branch": "main"},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("status", body)
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
self.assertIn("remote.txt", head_files)
def test_commit_selected_commits_only_given_paths(self) -> None:
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
_git(self.repo, "add", "kept.txt", "skip.txt")
response = self.client.post(
"/git/commit_selected",
json={"repo": "alpha", "message": "commit kept", "paths": ["kept.txt"]},
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("head", body)
self.assertIn("status", body)
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
self.assertIn("kept.txt", head_files)
self.assertNotIn("skip.txt", head_files)
def test_commit_response_includes_head_and_fresh_status(self) -> None:
self._stage("feature.txt")
response = self.client.post(
"/git/commit", json={"repo": "alpha", "message": "add feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("head", body)
self.assertEqual(body["head"], _git(self.repo, "rev-parse", "HEAD"))
self.assertIn("status", body)
self.assertEqual(0, body["status"]["counts"]["staged"])
self.assertEqual(0, body["status"]["counts"]["modified"])
def test_unstage_returns_fresh_status(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
r = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": ["tracked.txt"]}
)
self.assertEqual(200, r.status_code, r.text)
response = self.client.post(
"/git/unstage", json={"repo": "alpha", "paths": ["tracked.txt"]}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertIn("status", body)
status = body["status"]
self.assertNotIn(
"tracked.txt", [e["path"] for e in status["staged"]]
)
self.assertIn(
"tracked.txt", [e["path"] for e in status["modified"]]
)
def test_stage_missing_repo_in_body_is_400(self) -> None:
response = self.client.post("/git/stage", json={"paths": ["x"]})
self.assertEqual(400, response.status_code, response.text)
def test_stage_traversal_path_is_400(self) -> None:
response = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": ["../escape"]}
)
self.assertEqual(400, response.status_code, response.text)
def test_stage_too_many_paths_is_400(self) -> None:
paths = [f"f{i}.txt" for i in range(201)]
response = self.client.post(
"/git/stage", json={"repo": "alpha", "paths": paths}
)
self.assertEqual(400, response.status_code, response.text)
class GitExtrasApiTests(unittest.TestCase):
"""Endpoints for the Phase 3 extras surface (AI messages + stash-checkout)."""
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "alpha")
self.env = patch.dict(
os.environ,
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
)
self.env.start()
self.addCleanup(self.env.stop)
app = FastAPI()
app.include_router(git_api.router)
self.client = TestClient(app)
def _staged(self, path: str, content: str) -> None:
(self.repo / path).write_text(content, encoding="utf-8")
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
def test_commit_message_empty_staged_returns_notice_without_model(self) -> None:
# Clean tree → nothing staged → no model needed, no 500.
response = self.client.post("/git/commit_message", json={"repo": "alpha"})
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("", body["message"])
self.assertEqual("nothing staged", body["notice"])
def test_commit_message_with_staged_diff_generates_message(self) -> None:
self._staged("feature.txt", "new feature\n")
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="resp")), patch.object(
git_state, "_llm_extract", return_value="feat: add feature"
):
response = self.client.post(
"/git/commit_message", json={"repo": "alpha"}
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("feat: add feature", response.json()["message"])
def test_commit_message_model_failure_degrades_gracefully(self) -> None:
self._staged("a.txt", "x\n")
with patch.object(
git_state, "_llm_call", new=AsyncMock(side_effect=RuntimeError("no model"))
):
response = self.client.post(
"/git/commit_message", json={"repo": "alpha"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual("", body["message"])
self.assertIn("model", body["notice"].lower())
def test_commit_message_selected_honors_only_given_paths(self) -> None:
self._staged("kept.txt", "kept\n")
self._staged("skip.txt", "skip\n")
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="ok")), patch.object(
git_state, "_llm_extract", return_value="add kept"
):
response = self.client.post(
"/git/commit_message_selected",
json={"repo": "alpha", "paths": ["kept.txt"]},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("add kept", response.json()["message"])
def test_commit_message_unknown_repo_is_400(self) -> None:
response = self.client.post(
"/git/commit_message", json={"repo": "bogus"}
)
self.assertEqual(400, response.status_code, response.text)
def test_stash_checkout_dirty_tree_returns_stash_notice(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "README.md").write_text("dirty", encoding="utf-8")
response = self.client.post(
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertTrue(body["stashed"])
self.assertEqual("git-state: feature", body["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
def test_stash_checkout_clean_tree_plain_checkout(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
response = self.client.post(
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
)
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertFalse(body["stashed"])
self.assertEqual("", body["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_stash_checkout_bad_ref_is_400(self) -> None:
response = self.client.post(
"/git/stash_checkout", json={"repo": "alpha", "ref": "nope"}
)
self.assertEqual(400, response.status_code, response.text)
if __name__ == "__main__":
unittest.main()
+31 -2
View File
@@ -50,7 +50,8 @@ class MobilePluginApiTests(unittest.TestCase):
manifest = self.client.get("/mobile/manifest").json()
self.assertEqual("hermes-relay", manifest["id"])
self.assertEqual("draft", manifest["contributions"][0]["status"])
draft = next(c for c in manifest["contributions"] if c["id"] == "system-status")
self.assertEqual("draft", draft["status"])
loaded_document = self.client.get("/mobile/pages/system-status").json()
self.assertEqual(1, loaded_document.pop("host_revision"))
self.assertEqual(_document(), loaded_document)
@@ -70,7 +71,35 @@ class MobilePluginApiTests(unittest.TestCase):
json={"expected_digest": published_digest},
)
self.assertEqual({"ok": True, "id": "system-status"}, removed.json())
self.assertEqual([], self.client.get("/mobile/manifest").json()["contributions"])
remaining = self.client.get("/mobile/manifest").json()["contributions"]
self.assertEqual(["git"], [c["id"] for c in remaining])
def test_manifest_exposes_static_git_page(self) -> None:
manifest = self.client.get("/mobile/manifest").json()
git = next(c for c in manifest["contributions"] if c["id"] == "git")
self.assertEqual("page", git["surface"])
self.assertEqual("mobile/pages/git", git["document"]["path"])
# The Git page document is a GET-only read surface; it must not carry
# any action.request (which would require the write grant).
page = self.client.get("/mobile/pages/git").json()
self.assertNotIn("action", str(page))
def test_git_page_document_is_served(self) -> None:
response = self.client.get("/mobile/pages/git")
self.assertEqual(200, response.status_code, response.text)
body = response.json()
self.assertEqual(1, body["schemaVersion"])
self.assertEqual("git", body["pages"][0]["id"])
self.assertEqual(1, body["host_revision"])
def test_reserved_git_plugin_id_is_rejected(self) -> None:
response = self.client.put(
"/mobile/plugins/git/draft",
json={"title": "Shadow", "document": _document()},
)
self.assertEqual(400, response.status_code, response.text)
manifest = self.client.get("/mobile/manifest").json()
self.assertEqual(["git"], [item["id"] for item in manifest["contributions"]])
def test_traversal_and_bad_document_are_rejected(self) -> None:
traversal = self.client.put(
+1063
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -17,6 +17,7 @@ from typing import Any, Optional
PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
RESERVED_PLUGIN_IDS = frozenset({"git"})
MAX_DOCUMENT_BYTES = 512 * 1024
ALLOWED_LIFECYCLES = frozenset({"session", "persistent"})
ALLOWED_ELEMENT_TYPES = frozenset(
@@ -132,6 +133,8 @@ class MobilePluginStore:
for path in sorted(self.root.glob("*.json")):
if not PLUGIN_ID_RE.fullmatch(path.stem):
continue
if path.stem in RESERVED_PLUGIN_IDS:
continue
entry = self._read(path.stem, required=False)
if entry:
entries.append({k: v for k, v in entry.items() if k != "document"})
@@ -139,6 +142,26 @@ class MobilePluginStore:
def manifest(self) -> dict[str, Any]:
contributions = []
# Static read-only Git page contributed by the relay plugin itself.
# It carries no filesystem paths (per the android-plugins.md document
# contract); repo data flows through the /git/* API responses and is
# rendered as plain text by the host. No plugin.api.write grant is
# required for the read-only Git surface.
contributions.append(
{
"id": "git",
"surface": "page",
"title": "Git",
"description": "Browse repositories on this Hermes host",
"status": "published",
"lifecycle": "persistent",
"revision": 1,
"document": {
"method": "GET",
"path": "mobile/pages/git",
},
}
)
for summary in self.list():
is_draft = summary["status"] == "draft"
contributions.append(
@@ -180,6 +203,8 @@ class MobilePluginStore:
normalized = str(plugin_id).strip().lower()
if not PLUGIN_ID_RE.fullmatch(normalized):
raise MobilePluginStoreError("invalid plugin id")
if normalized in RESERVED_PLUGIN_IDS:
raise MobilePluginStoreError("plugin id is reserved")
return normalized
@staticmethod
@@ -311,6 +336,8 @@ class MobilePluginStore:
if required:
raise MobilePluginNotFoundError(plugin_id)
return {}
except MobilePluginStoreError:
raise
except (OSError, ValueError, json.JSONDecodeError):
if required:
raise MobilePluginNotFoundError(plugin_id)
+107 -1
View File
@@ -97,6 +97,29 @@ _PAIRING_CODE_TTL = 600.0
DEFAULT_REFRESH_TTL_SECONDS: float = 180 * 24 * 3600 # 180 days
_REFRESH_TOKEN_BYTES = 32
# Client-reported supervised-mode metadata is intentionally small and
# non-authoritative. Relay stores it only so paired-device surfaces can show
# the operator which Android client is presenting a restricted UI. The
# Android client remains the enforcement owner.
SUPERVISED_PROFILE_LABEL_MAX_LENGTH = 80
SUPERVISED_CAPABILITY_MAX_COUNT = 12
SUPERVISED_CAPABILITIES: frozenset[str] = frozenset(
{
"attachments",
"cancel",
"copy",
"generated_images",
"new_chat",
"quote_reply",
"retry",
"share_images",
"steer",
"text_chat",
"timestamps",
"voice",
}
)
# ── Data models ──────────────────────────────────────────────────────────────
@@ -114,6 +137,58 @@ def _refresh_token_hash(token: str) -> str:
return sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class SupervisedMode:
"""Bounded, client-reported metadata for paired-device display only."""
active: bool = False
profile_label: str = ""
capabilities: tuple[str, ...] = ()
def to_public_dict(self) -> dict[str, Any]:
"""Return the stable public wire shape for an active report."""
return {
"active": True,
"profile_label": self.profile_label,
"capabilities": list(self.capabilities),
"enforcement_owner": "android_client",
}
def parse_supervised_mode(value: Any) -> SupervisedMode:
"""Validate untrusted supervised-mode metadata.
Missing, inactive, malformed, oversized, or unknown values all normalize
to ordinary mode. Capability values are allowlisted so this public summary
cannot become a side channel for model, tool, path, or arbitrary client
data.
"""
ordinary = SupervisedMode()
if not isinstance(value, dict) or value.get("active") is not True:
return ordinary
raw_label = value.get("profile_label")
raw_capabilities = value.get("capabilities", [])
if not isinstance(raw_label, str) or not isinstance(raw_capabilities, list):
return ordinary
if not raw_label.isprintable():
return ordinary
label = raw_label.strip()
if not label or len(label) > SUPERVISED_PROFILE_LABEL_MAX_LENGTH:
return ordinary
if len(raw_capabilities) > SUPERVISED_CAPABILITY_MAX_COUNT:
return ordinary
capabilities: list[str] = []
for candidate in raw_capabilities:
if not isinstance(candidate, str) or candidate not in SUPERVISED_CAPABILITIES:
return ordinary
if candidate not in capabilities:
capabilities.append(candidate)
return SupervisedMode(True, label, tuple(capabilities))
def _default_grants(ttl_seconds: float, now: float) -> dict[str, float]:
"""Compute default per-channel grants given an overall session TTL.
@@ -229,6 +304,7 @@ class Session:
refresh_token: str | None = field(default=None, repr=False, compare=False)
device_model: str = "unknown"
device_platform: str = "unknown"
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
def __post_init__(self) -> None:
if self.expires_at == 0.0:
@@ -290,6 +366,7 @@ class TrustedDevice:
device_form_factor: str = "unknown"
device_model: str = "unknown"
device_platform: str = "unknown"
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
def __post_init__(self) -> None:
if self.expires_at == 0.0:
@@ -458,7 +535,7 @@ def _session_to_json(session: Session) -> dict[str, Any]:
return "never"
return v
return {
payload = {
"token": session.token,
"device_name": session.device_name,
"device_id": session.device_id,
@@ -473,6 +550,9 @@ def _session_to_json(session: Session) -> dict[str, Any]:
"device_platform": session.device_platform,
"first_seen": session.first_seen,
}
if session.supervised_mode.active:
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
return payload
def _session_from_json(payload: dict[str, Any]) -> Session | None:
@@ -508,6 +588,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
device_model = str(payload.get("device_model", "unknown"))
device_platform = str(payload.get("device_platform", "unknown"))
first_seen = float(payload.get("first_seen", created_at))
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
except (KeyError, TypeError, ValueError):
return None
@@ -525,6 +606,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
device_model=device_model,
device_platform=device_platform,
first_seen=first_seen,
supervised_mode=supervised_mode,
)
@@ -550,6 +632,8 @@ def _trusted_device_to_json(device: TrustedDevice) -> dict[str, Any]:
}
if device.grants is not None:
payload["grants"] = dict(device.grants)
if device.supervised_mode.active:
payload["supervised_mode"] = device.supervised_mode.to_public_dict()
return payload
@@ -586,6 +670,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
device_form_factor = str(payload.get("device_form_factor", "unknown"))
device_model = str(payload.get("device_model", "unknown"))
device_platform = str(payload.get("device_platform", "unknown"))
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
except (KeyError, TypeError, ValueError):
return None
@@ -606,6 +691,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
)
@@ -883,6 +969,7 @@ class SessionManager:
device_form_factor: str = "unknown",
device_model: str = "unknown",
device_platform: str = "unknown",
supervised_mode: SupervisedMode | None = None,
issue_refresh_token: bool = False,
) -> Session:
"""Create a new session for an authenticated device.
@@ -913,6 +1000,9 @@ class SessionManager:
device_platform:
Optional operating-system/platform metadata retained for device
details. Neither field participates in authorization.
supervised_mode:
Optional bounded report of Android's supervised client state.
Informational only; Relay does not enforce the reported policy.
issue_refresh_token:
When True, also create a persisted trusted-device credential and
attach the raw one-time refresh token to the returned
@@ -922,6 +1012,7 @@ class SessionManager:
"""
if ttl_seconds is None:
ttl_seconds = DEFAULT_TTL_SECONDS
supervised_mode = supervised_mode or SupervisedMode()
now = time.time()
if ttl_seconds == 0:
@@ -981,6 +1072,7 @@ class SessionManager:
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
)
token = str(uuid.uuid4())
@@ -997,6 +1089,7 @@ class SessionManager:
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
first_seen=now,
refresh_token=refresh_token,
)
@@ -1051,6 +1144,7 @@ class SessionManager:
device_form_factor=session.device_form_factor,
device_model=session.device_model,
device_platform=session.device_platform,
supervised_mode=session.supervised_mode,
)
session.refresh_token = refresh_token
self._save_to_disk()
@@ -1067,6 +1161,7 @@ class SessionManager:
device_form_factor: str = "unknown",
device_model: str = "unknown",
device_platform: str = "unknown",
supervised_mode: SupervisedMode | None = None,
) -> Session | None:
"""Mint a replacement session from a trusted-device refresh token.
@@ -1121,6 +1216,8 @@ class SessionManager:
trusted.device_model = device_model
if device_platform and device_platform != "unknown":
trusted.device_platform = device_platform
if supervised_mode is not None:
trusted.supervised_mode = supervised_mode
self._trusted_devices[new_refresh_hash] = trusted
session = self.create_session(
@@ -1133,6 +1230,7 @@ class SessionManager:
device_form_factor=trusted.device_form_factor,
device_model=trusted.device_model,
device_platform=trusted.device_platform,
supervised_mode=trusted.supervised_mode,
issue_refresh_token=False,
)
session.refresh_token = new_refresh_token
@@ -1153,6 +1251,7 @@ class SessionManager:
device_platform: str | None = None,
client_surface: str | None = None,
device_form_factor: str | None = None,
supervised_mode: SupervisedMode | None = None,
) -> None:
"""Adopt identity metadata from a valid reconnecting client.
@@ -1180,6 +1279,10 @@ class SessionManager:
setattr(session, field_name, value)
changed = True
if supervised_mode is not None and session.supervised_mode != supervised_mode:
session.supervised_mode = supervised_mode
changed = True
for trusted in self._trusted_devices.values():
if trusted.device_id != session.device_id:
continue
@@ -1192,6 +1295,9 @@ class SessionManager:
if getattr(trusted, field_name) != value:
setattr(trusted, field_name, value)
changed = True
if supervised_mode is not None and trusted.supervised_mode != supervised_mode:
trusted.supervised_mode = supervised_mode
changed = True
if changed:
self._save_to_disk()
+61 -1
View File
@@ -53,6 +53,7 @@ from .auth import (
RateLimiter,
Session,
SessionManager,
parse_supervised_mode,
)
from .channels.bridge import BridgeError, BridgeHandler
from .channels.chat import ChatHandler
@@ -887,7 +888,7 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
return None if math.isinf(ts) else ts
grants_out = {k: _norm(v) for k, v in session.grants.items()}
return {
payload = {
"token_prefix": session.token[:8],
"device_name": session.device_name,
"device_id": session.device_id,
@@ -903,6 +904,9 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
"device_platform": session.device_platform,
"is_current": current_token is not None and session.token == current_token,
}
if session.supervised_mode.active:
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
return payload
def _require_bearer_session(
@@ -1012,6 +1016,18 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
target = matches[0]
revoked_self = current_token is not None and target.token == current_token
server.sessions.revoke_session(target.token)
# Revocation ends already-connected Relay sockets as well as preventing
# future authentication. This does not enforce the Android supervised
# policy; it revokes the ordinary paired Relay session that reported it.
revoked_sockets = [
ws for ws, token in server._clients.items() if token == target.token
]
for ws in revoked_sockets:
if not ws.closed:
await ws.close(
code=aiohttp.WSCloseCode.POLICY_VIOLATION,
message=b"Relay session revoked",
)
route_credential_id = credential_id_for(target.token)
server.secure_link_route_credentials.pop(route_credential_id, None)
if server.secure_link_connector is not None:
@@ -4209,6 +4225,8 @@ def _build_auth_ok_payload(
}
if session.refresh_token:
payload["refresh_token"] = session.refresh_token
if session.supervised_mode.active:
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
if route_credential is not None:
payload["route_credential"] = route_credential
return payload
@@ -4310,6 +4328,11 @@ async def _authenticate(
device_platform = str(
payload.get("device_platform", "unknown") or "unknown"
).strip()
# Every authentication is a fresh client report. Missing or invalid
# metadata explicitly returns the paired session to ordinary mode rather
# than leaving a stale supervised badge behind after the client disables
# the mode or downgrades.
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
# Pairing policy is attached by a loopback-only operator flow. Clients
# may still send ttl_seconds / grants for wire compatibility, but those
@@ -4338,6 +4361,7 @@ async def _authenticate(
device_form_factor=(
device_form_factor if "device_form_factor" in payload else None
),
supervised_mode=supervised_mode,
)
if (
not refresh_token_attempt
@@ -4370,6 +4394,7 @@ async def _authenticate(
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
)
if session is not None:
server.rate_limiter.record_success(remote_ip)
@@ -4403,6 +4428,7 @@ async def _authenticate(
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
issue_refresh_token=True,
)
server.rate_limiter.record_success(remote_ip)
@@ -4584,6 +4610,40 @@ async def _handle_system(
elif msg_type == "pong":
# Client responding to our ping — nothing to do
pass
elif msg_type == "supervised.update":
# Informational update from an already-authenticated Android client.
# The socket's paired session is the only ownership input: payload
# fields cannot select or modify another session. Relay deliberately
# does not enforce the reported client policy.
token = server._clients.get(ws)
session = server.sessions.get_session(token) if token else None
if session is None:
await _send_system(
ws,
"error",
{"message": "Authenticated Relay session is no longer valid"},
msg_id,
)
return
if not isinstance(payload, dict):
payload = {}
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
server.sessions.update_session_device_metadata(
session,
supervised_mode=supervised_mode,
)
applied: dict[str, Any] = {
"active": False,
"enforcement_owner": "android_client",
}
if supervised_mode.active:
applied = supervised_mode.to_public_dict()
await _send_system(
ws,
"supervised.updated",
{"supervised_mode": applied},
msg_id,
)
else:
logger.debug("Unhandled system message type: %s", msg_type)
+427
View File
@@ -0,0 +1,427 @@
"""Tests for the read-only Git state surface (plugin/git_state.py).
Fixtures create REAL throwaway git repositories in tmp_path — init, config
user, commits, branches, and bare remotes. Git itself is never mocked.
"""
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
def _add_remote(repo: Path, remote_url: str, name: str = "origin") -> None:
_git(repo, "remote", "add", name, remote_url)
def _link_directory(link: Path, target: Path) -> None:
try:
link.symlink_to(target, target_is_directory=True)
except OSError:
if os.name != "nt":
raise
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
class GitStateScanTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(self.tempdir())
def tempdir(self) -> str:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
return self._td.name
def test_scan_finds_nested_repos_and_ignores_non_repos(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
_init_repo(base, "alpha")
_init_repo(base / "nested", "beta")
# A plain directory with no .git must be ignored.
(base / "plain").mkdir()
(base / "plain" / "file.txt").write_text("x", encoding="utf-8")
repos = git_state.scan_repos(base)
names = {r["name"] for r in repos}
self.assertEqual({"alpha", "beta"}, names)
for repo in repos:
self.assertEqual("main", repo["current_branch"])
self.assertFalse(repo["dirty"])
def test_scan_missing_base_path_returns_empty(self) -> None:
missing = self.tmp / "does-not-exist"
self.assertEqual([], git_state.scan_repos(missing))
def test_scan_excludes_git_internals(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
_init_repo(base, "alpha")
# A .git directory itself must never be reported as a repo.
repos = git_state.scan_repos(base)
self.assertTrue(all(".git" not in r["name"] for r in repos))
def test_scan_marks_dirty_repo(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
repo = _init_repo(base, "dirty")
(repo / "new.txt").write_text("untracked", encoding="utf-8")
repos = git_state.scan_repos(base)
dirty = next(r for r in repos if r["name"] == "dirty")
self.assertTrue(dirty["dirty"])
def test_nested_same_name_repos_have_distinct_round_trip_ids(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
first = _init_repo(base / "team-a", "service")
second = _init_repo(base / "team-b", "service")
repos = git_state.scan_repos(base)
self.assertEqual({"team-a/service", "team-b/service"}, {repo["id"] for repo in repos})
self.assertEqual(first.resolve(), git_state.resolve_repo(base, "team-a/service"))
self.assertEqual(second.resolve(), git_state.resolve_repo(base, "team-b/service"))
def test_scan_rejects_linked_repo_outside_base(self) -> None:
base = self.tmp / "projects"
base.mkdir(parents=True)
outside = _init_repo(self.tmp, "outside")
link = base / "linked"
_link_directory(link, outside)
self.assertEqual([], git_state.scan_repos(base))
class GitStateStatusTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "status-repo")
def test_status_groups_modified_untracked_and_staged(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
# staged change
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
# unstaged change
(self.repo / "README.md").write_text("# Changed\n", encoding="utf-8")
# untracked
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
status = git_state.repo_status(self.repo)
self.assertEqual(1, status["counts"]["staged"])
self.assertEqual(1, status["counts"]["modified"])
self.assertEqual(1, status["counts"]["untracked"])
self.assertEqual("tracked.txt", status["staged"][0]["path"])
self.assertEqual("README.md", status["modified"][0]["path"])
self.assertEqual("untracked.txt", status["untracked"][0]["path"])
self.assertFalse(status["truncated"])
def test_status_truncates_when_over_cap(self) -> None:
for i in range(git_state.MAX_STATUS_ENTRIES + 5):
(self.repo / f"file-{i}.txt").write_text("x", encoding="utf-8")
status = git_state.repo_status(self.repo)
self.assertTrue(status["truncated"])
self.assertLessEqual(
len(status["untracked"]),
git_state.MAX_STATUS_ENTRIES,
)
def test_status_lists_staged_and_modified_same_file(self) -> None:
# A file staged AND then modified again ("MM" in porcelain) must appear
# in BOTH the staged and modified groups (independent checks, not elif).
(self.repo / "mm.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "mm.txt")
_git(self.repo, "commit", "-q", "-m", "add mm")
(self.repo / "mm.txt").write_text("v2", encoding="utf-8")
_git(self.repo, "add", "mm.txt")
(self.repo / "mm.txt").write_text("v3", encoding="utf-8")
status = git_state.repo_status(self.repo)
staged_paths = {e["path"] for e in status["staged"]}
modified_paths = {e["path"] for e in status["modified"]}
self.assertIn("mm.txt", staged_paths)
self.assertIn("mm.txt", modified_paths)
def test_status_rename_emits_single_staged_entry(self) -> None:
# `git mv` produces two NUL-separated porcelain records ("R new\0old\0");
# the bare source-path record must be skipped, not misparsed as an XY
# record. Use a source name starting with "M" so a naive parser would
# misclassify the bare source record as staged with a truncated path.
(self.repo / "Moved.txt").write_text("content", encoding="utf-8")
_git(self.repo, "add", "Moved.txt")
_git(self.repo, "commit", "-q", "-m", "add Moved")
_git(self.repo, "mv", "Moved.txt", "new.txt")
status = git_state.repo_status(self.repo)
self.assertEqual(["new.txt"], [e["path"] for e in status["staged"]])
self.assertEqual([], status["modified"])
self.assertEqual([], status["untracked"])
def test_status_unstaged_rename_shows_delete_and_untracked(self) -> None:
# Rename on disk only (no `git mv`): delete + create → D + ??.
(self.repo / "old.txt").write_text("content", encoding="utf-8")
_git(self.repo, "add", "old.txt")
_git(self.repo, "commit", "-q", "-m", "add old")
(self.repo / "old.txt").unlink()
(self.repo / "new.txt").write_text("content", encoding="utf-8")
status = git_state.repo_status(self.repo)
self.assertEqual([], status["staged"])
self.assertEqual(["old.txt"], [e["path"] for e in status["modified"]])
self.assertEqual(["new.txt"], [e["path"] for e in status["untracked"]])
class GitStateBranchesTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "branch-repo")
def test_branches_reports_current_upstream_ahead_behind(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
_git(self.repo, "add", "feature.txt")
_git(self.repo, "commit", "-q", "-m", "feature work")
branches = git_state.repo_branches(self.repo)
by_name = {b["name"]: b for b in branches}
self.assertIn("main", by_name)
self.assertIn("feature", by_name)
self.assertTrue(by_name["feature"]["is_current"])
self.assertFalse(by_name["main"]["is_current"])
class GitStateDiffTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "diff-repo")
def test_diff_unstaged_and_staged_kinds(self) -> None:
(self.repo / "a.txt").write_text("one\n", encoding="utf-8")
_git(self.repo, "add", "a.txt")
_git(self.repo, "commit", "-q", "-m", "add a")
# staged change
(self.repo / "a.txt").write_text("two\n", encoding="utf-8")
_git(self.repo, "add", "a.txt")
# unstaged change
(self.repo / "a.txt").write_text("three\n", encoding="utf-8")
staged = git_state.repo_diff(self.repo, "a.txt", kind="staged")
self.assertIn("+two", staged["diff"])
unstaged = git_state.repo_diff(self.repo, "a.txt", kind="unstaged")
self.assertIn("+three", unstaged["diff"])
def test_diff_invalid_kind_raises(self) -> None:
with self.assertRaises(ValueError):
git_state.repo_diff(self.repo, "a.txt", kind="bogus")
def test_diff_truncates_large_output(self) -> None:
big = "x" * 200_000
(self.repo / "big.txt").write_text(big + "\n", encoding="utf-8")
_git(self.repo, "add", "big.txt")
_git(self.repo, "commit", "-q", "-m", "add big")
(self.repo / "big.txt").write_text(big + "y\n", encoding="utf-8")
result = git_state.repo_diff(self.repo, "big.txt", kind="unstaged")
self.assertTrue(result["truncated"])
self.assertLessEqual(len(result["diff"]), git_state.MAX_DIFF_BYTES)
class GitStateFileTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "file-repo")
def test_read_tracked_file(self) -> None:
content = git_state.read_file(self.repo, "README.md")
self.assertIn("Hello", content["content"])
def test_read_tracked_file_returns_working_tree_not_committed(self) -> None:
# A modified-but-uncommitted tracked file must return the on-disk
# (working-tree) content, not the last committed version.
(self.repo / "README.md").write_text("# Working Tree\n", encoding="utf-8")
content = git_state.read_file(self.repo, "README.md")
self.assertIn("Working Tree", content["content"])
self.assertNotIn("Hello", content["content"])
def test_read_untracked_file_raises(self) -> None:
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
with self.assertRaises(ValueError):
git_state.read_file(self.repo, "untracked.txt")
def test_read_missing_file_raises(self) -> None:
with self.assertRaises(ValueError):
git_state.read_file(self.repo, "nope.txt")
def test_read_tracked_binary_file_raises_gitstateerror(self) -> None:
# A TRACKED binary file (e.g. a committed PNG) must raise GitStateError
# ("binary file is not supported" / UTF-8), never an unhandled
# UnicodeDecodeError that escapes as a 500.
(self.repo / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary\xff\xfe")
_git(self.repo, "add", "image.png")
_git(self.repo, "commit", "-q", "-m", "add binary")
with self.assertRaises(git_state.GitStateError) as ctx:
git_state.read_file(self.repo, "image.png")
message = str(ctx.exception)
self.assertTrue("binary" in message or "UTF-8" in message)
def test_read_tracked_non_utf8_text_raises(self) -> None:
# A tracked, NUL-free but non-UTF-8 text file (Latin-1) must raise a
# clear GitStateError, not an unhandled UnicodeDecodeError.
(self.repo / "latin1.txt").write_bytes(b"caf\xe9 latin1")
_git(self.repo, "add", "latin1.txt")
_git(self.repo, "commit", "-q", "-m", "add latin1")
with self.assertRaises(git_state.GitStateError) as ctx:
git_state.read_file(self.repo, "latin1.txt")
self.assertIn("not valid UTF-8 text", str(ctx.exception))
def test_read_tracked_link_outside_repo_is_rejected(self) -> None:
if os.name == "nt":
outside = self.base / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("secret", encoding="utf-8")
link = self.repo / "leak"
_link_directory(link, outside)
tracked_path = "leak/secret.txt"
else:
outside = self.base / "outside.txt"
outside.write_text("secret", encoding="utf-8")
(self.repo / "leak.txt").symlink_to(outside)
tracked_path = "leak.txt"
_git(self.repo, "add", tracked_path)
_git(self.repo, "commit", "-q", "-m", "track link")
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
git_state.read_file(self.repo, tracked_path)
def test_read_tracked_file_is_bounded_during_read(self) -> None:
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
_git(self.repo, "add", "large.txt")
_git(self.repo, "commit", "-q", "-m", "large")
result = git_state.read_file(self.repo, "large.txt")
self.assertTrue(result["truncated"])
self.assertEqual(git_state.MAX_FILE_BYTES, len(result["content"]))
class GitStateDocumentTests(unittest.TestCase):
def test_document_missing_base_notice_leaks_no_path(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
missing = Path(self._td.name) / "does-not-exist"
doc = git_state.build_git_document(missing)
notice = doc["pages"][0]["content"]["children"][0]
self.assertEqual("notice", notice["id"])
value = notice["text"]["value"]
# The document contract forbids filesystem paths: no "/" and no
# path-like substring (e.g. the tmp dir name).
self.assertNotIn("/", value)
self.assertNotIn(self._td.name, value)
class GitStateSecurityTests(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "sec-repo")
def test_path_traversal_rejected(self) -> None:
for bad in ("../outside", "/etc/passwd", "a/../../b", "..%2Fescape"):
with self.subTest(path=bad):
with self.assertRaises(ValueError):
git_state.resolve_repo_path(self.repo, bad)
def test_remote_urls_scrubbed_of_userinfo(self) -> None:
_add_remote(self.repo, "https://user:secret@example.com/org/repo.git", "https")
_add_remote(self.repo, "ssh://git@example.com:2222/org/repo.git", "ssh")
_add_remote(self.repo, "git@example.com:org/repo.git", "scp")
remotes = git_state.repo_remotes(self.repo)
self.assertEqual(3, len(remotes))
for remote in remotes:
self.assertNotIn("secret", remote["url"])
self.assertNotIn("user:", remote["url"])
self.assertNotIn("git@", remote["url"])
def test_git_error_text_scrubs_embedded_remote_credentials(self) -> None:
message = git_state._safe_git_error(
"fatal: unable to access 'https://user:secret@example.com/repo.git'"
)
self.assertNotIn("user", message)
self.assertNotIn("secret", message)
def test_allowlist_accepts_only_scanned_repos(self) -> None:
scanned = git_state.scan_repos(self.base)
ids = {r["id"] for r in scanned}
self.assertIn(git_state.repo_id(self.repo), ids)
self.assertNotIn("bogus-id", ids)
def test_git_output_over_cap_fails_closed(self) -> None:
for index in range(20):
(self.repo / f"long-untracked-name-{index}.txt").write_text("x", encoding="utf-8")
with patch.object(git_state, "MAX_GIT_OUTPUT_BYTES", 32):
with self.assertRaisesRegex(git_state.GitStateError, "output exceeded"):
git_state.repo_status(self.repo)
if __name__ == "__main__":
unittest.main()
+269
View File
@@ -0,0 +1,269 @@
"""Tests for the Phase 3 extras surface of git_state.
Covers:
- ``commit_message`` / ``commit_message_selected``: staged-diff → LLM → a
conventional-style message suggestion. The model client is MONKEYPATCHED
(never a real API call); empty staged diff must skip the LLM entirely; a
missing/failing model degrades to an empty message + notice, never a 500.
- ``stash_checkout``: dirty tree → auto-stash + switch (stash list entry
present); clean tree → plain checkout; unknown ref → error.
Fixtures build REAL throwaway git repos in tmp_path. git itself is never
mocked.
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, check=True
).stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
class _ExtrasBase(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "extras-repo")
def _staged_change(self, path: str, content: str) -> None:
(self.repo / path).write_text(content, encoding="utf-8")
_git(self.repo, "add", path)
def _branch(self, name: str) -> None:
_git(self.repo, "checkout", "-q", "-b", name)
_git(self.repo, "checkout", "-q", "main")
def _patch_llm(self, *, text: str = "", exc: Exception | None = None):
"""Inject a fake agent.auxiliary_client chain into git_state.
``_llm_call`` is the async client; ``_llm_extract`` turns its return
into text. The failure path raises ``exc`` from the client.
"""
client = AsyncMock(side_effect=exc) if exc else AsyncMock(return_value="resp")
extractor = MagicMock(return_value=text)
return patch.object(git_state, "_llm_call", client), client, extractor, patch.object(
git_state, "_llm_extract", extractor
)
class CommitMessageTests(_ExtrasBase):
def test_commit_message_returns_suggestion_from_staged_diff(self) -> None:
self._staged_change("feature.txt", "new feature body\n")
llm_patch, client, extractor, extract_patch = self._patch_llm(
text="feat: add new feature\n\nBrings a new feature."
)
with llm_patch, extract_patch:
result = asyncio.run(git_state.commit_message(self.repo))
# The generated message is the LLM text (subject + optional body) so it
# can flow into the commit dialog and be edited.
self.assertEqual(
{"message": "feat: add new feature\n\nBrings a new feature.", "notice": ""},
result,
)
# The client was called with a messages list containing the staged diff.
_, kwargs = client.call_args
self.assertIn("messages", kwargs)
payload = " ".join(str(m) for m in kwargs["messages"])
self.assertIn("new feature", payload)
extractor.assert_called_once_with("resp")
def test_commit_message_empty_staged_diff_skips_llm(self) -> None:
# Clean tree: nothing staged → no LLM call.
p = patch.object(git_state, "_llm_call", AsyncMock())
with p as client:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
client.assert_not_called()
def test_commit_message_skips_llm_when_staged_diff_is_empty(self) -> None:
# A tracked file modified but NOT staged must never be sent.
(self.repo / "README.md").write_text("# Hello v2\n", encoding="utf-8")
p = patch.object(git_state, "_llm_call", AsyncMock())
with p as client:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
client.assert_not_called()
def test_commit_message_degrades_when_model_fails(self) -> None:
self._staged_change("feature.txt", "boom\n")
p, _, _, _ = self._patch_llm(exc=RuntimeError("no provider configured"))
with p:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual("", result["message"])
self.assertIn("model", result["notice"].lower())
def test_commit_message_degrades_when_model_unavailable(self) -> None:
self._staged_change("feature.txt", "x\n")
# Resolver itself fails (e.g. `agent` not installed).
with patch.object(git_state, "_resolve_llm", side_effect=ImportError("no agent")):
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual("", result["message"])
self.assertIn("model", result["notice"].lower())
def test_commit_message_bounds_staged_diff(self) -> None:
# A large staged diff is truncated to MAX_DIFF_BYTES and flagged.
big = "x" * (git_state.MAX_DIFF_BYTES + 10)
self._staged_change("big.txt", big)
p, client, extractor, ep = self._patch_llm(text="add big")
with p, ep:
result = asyncio.run(git_state.commit_message(self.repo))
self.assertEqual("add big", result["message"])
_, kwargs = client.call_args
payload = "".join(str(m) for m in kwargs["messages"])
self.assertLessEqual(len(payload), git_state.MAX_DIFF_BYTES + 2048)
self.assertNotIn("nothing staged", result.get("notice", ""))
class CommitMessageSelectedTests(_ExtrasBase):
def test_commit_message_selected_honors_only_given_paths(self) -> None:
self._staged_change("kept.txt", "kept content\n")
self._staged_change("skip.txt", "skip content\n")
p, client, extractor, extract_p = self._patch_llm(text="add kept")
with p, extract_p:
result = asyncio.run(
git_state.commit_message_selected(self.repo, ["kept.txt"])
)
self.assertEqual("add kept", result["message"])
_, kwargs = client.call_args
payload = "".join(str(m) for m in kwargs["messages"])
self.assertIn("kept content", payload)
self.assertNotIn("skip content", payload)
def test_commit_message_selected_skips_llm_when_none_selected_staged(self) -> None:
self._staged_change("kept.txt", "kept\n")
# A path with no staged content → nothing staged → no call.
(self.repo / "other.txt").write_text("other\n", encoding="utf-8")
_git(self.repo, "add", "other.txt")
# Stage only kept.txt, ask for other.txt's diff (not staged) → nothing.
p = patch.object(git_state, "_llm_call", AsyncMock())
with p as client:
# Ask for a path that has no STAGED diff: unstage other first.
_git(self.repo, "restore", "--staged", "other.txt")
result = asyncio.run(
git_state.commit_message_selected(self.repo, ["other.txt"])
)
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
client.assert_not_called()
def test_commit_message_selected_rejects_traversal_path(self) -> None:
self._staged_change("kept.txt", "kept\n")
with self.assertRaises(git_state.GitStateError):
asyncio.run(
git_state.commit_message_selected(self.repo, ["../escape"])
)
class StashCheckoutTests(_ExtrasBase):
def test_dirty_tree_stashes_then_switches(self) -> None:
self._branch("feature")
(self.repo / "README.md").write_text("dirty change\n", encoding="utf-8")
result = git_state.stash_checkout(self.repo, "feature")
self.assertTrue(result["stashed"])
self.assertEqual("git-state: feature", result["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
stash_list = _git(self.repo, "stash", "list")
self.assertIn("git-state: feature", stash_list)
def test_clean_tree_plain_checkout_no_stash(self) -> None:
self._branch("feature")
result = git_state.stash_checkout(self.repo, "feature")
self.assertFalse(result["stashed"])
self.assertEqual("", result["stash_message"])
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
self.assertEqual("", _git(self.repo, "stash", "list"))
def test_clean_tree_new_branch_switch(self) -> None:
result = git_state.stash_checkout(self.repo, "main", new_branch="exp")
self.assertFalse(result["stashed"])
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_stash_returns_fresh_status_and_branches(self) -> None:
self._branch("feature")
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
result = git_state.stash_checkout(self.repo, "feature")
self.assertIn("status", result)
self.assertIn("branches", result)
self.assertIn("head", result)
def test_bad_ref_raises(self) -> None:
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
with self.assertRaises(git_state.GitStateError):
git_state.stash_checkout(self.repo, "no-such-branch")
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
self.assertEqual("", _git(self.repo, "stash", "list"))
def test_existing_new_branch_is_rejected_before_stashing(self) -> None:
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
with self.assertRaisesRegex(git_state.GitError, "already exists"):
git_state.stash_checkout(self.repo, "main", new_branch="main")
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
self.assertEqual("", _git(self.repo, "stash", "list"))
def test_checkout_failure_restores_tracked_staged_and_untracked_changes(self) -> None:
self._branch("feature")
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
(self.repo / "staged.txt").write_text("staged\n", encoding="utf-8")
_git(self.repo, "add", "staged.txt")
(self.repo / "untracked.txt").write_text("untracked\n", encoding="utf-8")
original_mutate = git_state._mutate
def fail_checkout(repo: Path, args: list[str]) -> str:
if args[0] == "checkout":
raise git_state.GitError("forced checkout failure", code="conflict")
return original_mutate(repo, args)
with patch.object(git_state, "_mutate", side_effect=fail_checkout):
with self.assertRaisesRegex(git_state.GitError, "working changes were restored"):
git_state.stash_checkout(self.repo, "feature")
self.assertEqual("dirty\n", (self.repo / "README.md").read_text(encoding="utf-8"))
self.assertTrue((self.repo / "staged.txt").exists())
self.assertTrue((self.repo / "untracked.txt").exists())
self.assertIn("staged.txt", _git(self.repo, "diff", "--cached", "--name-only"))
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
def test_new_branch_uses_requested_start_point(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
_git(self.repo, "add", "feature-only.txt")
_git(self.repo, "commit", "-q", "-m", "feature")
_git(self.repo, "checkout", "-q", "main")
git_state.stash_checkout(self.repo, "feature", new_branch="from-feature")
self.assertTrue((self.repo / "feature-only.txt").exists())
if __name__ == "__main__":
unittest.main()
+406
View File
@@ -0,0 +1,406 @@
"""Tests for the write (mutation) surface of git_state.
Security denials are tested FIRST: destructive mutations (discard, push, dirty
checkout) reject when their confirmation string is missing or wrong. Then happy
paths, then error branches. Fixtures build REAL throwaway git repos and bare
remotes in tmp_path — git itself is never mocked.
"""
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from plugin import git_state
def _run(cmd: list[str], cwd: Path) -> str:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _git(repo: Path, *args: str) -> str:
return _run(["git", "-C", str(repo), *args], repo)
def _init_repo(root: Path, name: str) -> Path:
repo = root / name
repo.mkdir(parents=True)
_run(["git", "init", "-q", "-b", "main"], repo)
_run(["git", "config", "user.email", "test@example.com"], repo)
_run(["git", "config", "user.name", "Test User"], repo)
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-q", "-m", "initial commit")
return repo
def _init_bare_remote(root: Path, name: str) -> Path:
remote = root / name
remote.mkdir(parents=True, exist_ok=True)
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
return remote
class _MutationBase(unittest.TestCase):
def setUp(self) -> None:
import tempfile
self._td = tempfile.TemporaryDirectory()
self.addCleanup(self._td.cleanup)
self.base = Path(self._td.name) / "projects"
self.base.mkdir(parents=True)
self.repo = _init_repo(self.base, "write-repo")
def _make_change(self, path: str = "feature.txt", content: str = "hello") -> None:
(self.repo / path).write_text(content, encoding="utf-8")
_git(self.repo, "add", path)
def _head(self) -> str:
return _git(self.repo, "rev-parse", "HEAD")
class StageUnstageTests(_MutationBase):
def test_stage_moves_untracked_to_staged(self) -> None:
(self.repo / "new.txt").write_text("x", encoding="utf-8")
git_state.stage(self.repo, ["new.txt"])
status = git_state.repo_status(self.repo)
self.assertIn("new.txt", [e["path"] for e in status["staged"]])
self.assertNotIn("new.txt", [e["path"] for e in status["untracked"]])
def test_stage_accepts_multiple_paths(self) -> None:
(self.repo / "a.txt").write_text("a", encoding="utf-8")
(self.repo / "b.txt").write_text("b", encoding="utf-8")
git_state.stage(self.repo, ["a.txt", "b.txt"])
status = git_state.repo_status(self.repo)
staged = {e["path"] for e in status["staged"]}
self.assertTrue({"a.txt", "b.txt"} <= staged)
def test_unstage_returns_to_modified(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
git_state.unstage(self.repo, ["tracked.txt"])
status = git_state.repo_status(self.repo)
self.assertNotIn("tracked.txt", [e["path"] for e in status["staged"]])
self.assertIn("tracked.txt", [e["path"] for e in status["modified"]])
def test_stage_rejects_traversal_path(self) -> None:
with self.assertRaises(git_state.GitStateError):
git_state.stage(self.repo, ["../outside"])
class CommitTests(_MutationBase):
def test_commit_creates_a_real_commit(self) -> None:
before = self._head()
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "add feature")
after = self._head()
self.assertNotEqual(before, after)
message = _git(self.repo, "log", "-1", "--format=%s")
self.assertEqual("add feature", message)
def test_commit_rejects_empty_message(self) -> None:
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
with self.assertRaises(git_state.GitStateError):
git_state.commit(self.repo, " ")
def test_commit_returns_fresh_status(self) -> None:
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
result = git_state.commit(self.repo, "add feature")
self.assertEqual(result["head"], self._head())
self.assertEqual(result["status"]["counts"]["staged"], 0)
self.assertEqual(result["status"]["counts"]["modified"], 0)
def test_commit_selected_commits_only_given_paths(self) -> None:
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
_git(self.repo, "add", "kept.txt", "skip.txt")
git_state.commit_selected(self.repo, "commit kept only", ["kept.txt"])
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
self.assertIn("kept.txt", head_files)
self.assertNotIn("skip.txt", head_files)
def test_commit_selected_returns_fresh_status(self) -> None:
(self.repo / "a.txt").write_text("a", encoding="utf-8")
git_state.stage(self.repo, ["a.txt"])
result = git_state.commit_selected(self.repo, "commit a", ["a.txt"])
self.assertEqual(result["head"], self._head())
self.assertEqual(result["status"]["counts"]["staged"], 0)
def test_commit_selected_rejects_empty_message(self) -> None:
(self.repo / "a.txt").write_text("a", encoding="utf-8")
git_state.stage(self.repo, ["a.txt"])
with self.assertRaises(git_state.GitError):
git_state.commit_selected(self.repo, "", ["a.txt"])
class DiscardConfirmationTests(_MutationBase):
def test_discard_requires_confirmation_string(self) -> None:
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError) as ctx:
git_state.discard(self.repo, ["tracked.txt"], confirmation="")
self.assertIn("confirmation", str(ctx.exception).lower())
def test_discard_rejects_wrong_confirmation(self) -> None:
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError):
git_state.discard(self.repo, ["tracked.txt"], confirmation="wrong")
def test_discard_with_confirmation_reverts_tracked_changes(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
git_state.discard(self.repo, ["tracked.txt"], confirmation=git_state.CONFIRM_DISCARD)
content = (self.repo / "tracked.txt").read_text(encoding="utf-8")
self.assertEqual("v1", content)
def test_discard_delete_untracked_removes_untracked(self) -> None:
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
git_state.discard(
self.repo,
["untracked.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertFalse((self.repo / "untracked.txt").exists())
def test_discard_returns_fresh_status(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
result = git_state.discard(
self.repo,
["tracked.txt"],
confirmation=git_state.CONFIRM_DISCARD,
)
self.assertEqual(result["status"]["counts"]["modified"], 0)
class FetchPullPushTests(_MutationBase):
def setUp(self) -> None:
super().setUp()
self.remote = _init_bare_remote(self.base, "origin-bare")
_git(self.repo, "remote", "add", "origin", str(self.remote))
_git(self.repo, "push", "-q", "origin", "main")
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
def test_fetch_rejects_unknown_and_option_like_remote(self) -> None:
for remote in ("https://example.invalid/repo.git", "--all", "missing"):
with self.subTest(remote=remote):
with self.assertRaisesRegex(git_state.GitError, "remote"):
git_state.fetch(self.repo, remote)
def test_pull_and_push_reject_option_like_branch(self) -> None:
with self.assertRaisesRegex(git_state.GitError, "branch"):
git_state.pull(self.repo, "origin", "--all")
with self.assertRaisesRegex(git_state.GitError, "branch"):
git_state.push(self.repo, "origin", "--mirror", git_state.CONFIRM_PUSH)
def test_fetch_updates_remote_refs(self) -> None:
# Advance the remote from a descendant clone (not an independent repo:
# an independent root has its own "initial commit" SHA, and when it
# lands in a different second than the remote's initial commit the push
# is rejected as non-fast-forward, flaking the test).
other = self.base / "other"
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
_git(other, "config", "user.email", "test@example.com")
_git(other, "config", "user.name", "Test User")
(other / "remote.txt").write_text("remote change fetch", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "remote change fetch")
_git(other, "push", "-q", "origin", "main")
git_state.fetch(self.repo, "origin")
remote_main = _git(self.repo, "rev-parse", "origin/main")
self.assertNotEqual(remote_main, self._head())
def test_pull_brings_remote_commits(self) -> None:
other = self.base / "other"
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
_git(other, "config", "user.email", "test@example.com")
_git(other, "config", "user.name", "Test User")
(other / "remote.txt").write_text("remote change pull", encoding="utf-8")
_git(other, "add", "remote.txt")
_git(other, "commit", "-q", "-m", "remote change pull")
_git(other, "push", "-q", "origin", "main")
git_state.pull(self.repo, "origin", "main")
self.assertIn("remote.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
def test_push_requires_confirmation(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
with self.assertRaises(git_state.GitError) as ctx:
git_state.push(self.repo, "origin", "main", confirmation="")
self.assertIn("confirmation", str(ctx.exception).lower())
def test_push_rejects_wrong_confirmation(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
with self.assertRaises(git_state.GitError):
git_state.push(self.repo, "origin", "main", confirmation="nope")
def test_push_with_confirmation_updates_remote(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
remote_head = _run(["git", "ls-remote", str(self.remote), "refs/heads/main"], self.remote)
self.assertIn(self._head(), remote_head)
def test_pull_with_local_changes_yields_structured_dirty_error(self) -> None:
# Repo tracks a file, then the remote advances it. A local uncommitted
# edit to that file must never be clobbered by pull → structured dirty.
(self.repo / "tracked.txt").write_text("base", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked")
_git(self.repo, "push", "-q", "origin", "main")
# Advance the remote from a clean clone whose history descends from it.
other = self.base / "other"
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
_git(other, "config", "user.email", "test@example.com")
_git(other, "config", "user.name", "Test User")
(other / "tracked.txt").write_text("remote", encoding="utf-8")
_git(other, "add", "tracked.txt")
_git(other, "commit", "-q", "-m", "remote tracked")
_git(other, "push", "-q", "origin", "main")
# Local uncommitted change to tracked.txt → git refuses to clobber.
(self.repo / "tracked.txt").write_text("uncommitted", encoding="utf-8")
with self.assertRaises(git_state.GitError) as ctx:
git_state.pull(self.repo, "origin", "main")
self.assertEqual("dirty", ctx.exception.code)
def test_push_auth_failure_maps_to_auth_error(self) -> None:
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
git_state.stage(self.repo, ["feature.txt"])
git_state.commit(self.repo, "feature")
# Point origin at a host that will reject credentials.
_git(self.repo, "remote", "set-url", "origin", "https://invalid.invalid/x.git")
with self.assertRaises(git_state.GitError) as ctx:
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
self.assertIn(ctx.exception.code, ("auth", "network"))
class CheckoutTests(_MutationBase):
def test_checkout_rejects_option_like_ref(self) -> None:
with self.assertRaisesRegex(git_state.GitError, "git option"):
git_state.checkout(self.repo, "--detach")
def test_checkout_switches_branch(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
git_state.checkout(self.repo, "feature", confirmation="")
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_new_branch_creates_branch(self) -> None:
git_state.checkout(
self.repo,
"main",
new_branch="exp",
confirmation="",
)
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_new_branch_uses_requested_start_point(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
_git(self.repo, "add", "feature-only.txt")
_git(self.repo, "commit", "-q", "-m", "feature")
_git(self.repo, "checkout", "-q", "main")
git_state.checkout(self.repo, "feature", new_branch="from-feature")
self.assertTrue((self.repo / "feature-only.txt").exists())
def test_checkout_clean_tree_needs_no_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
git_state.checkout(self.repo, "feature", confirmation="")
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_dirty_tree_requires_confirmation(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError) as ctx:
git_state.checkout(self.repo, "feature", confirmation="")
self.assertIn("confirmation", str(ctx.exception).lower())
def test_checkout_dirty_tree_wrong_confirmation_rejected(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
with self.assertRaises(git_state.GitError):
git_state.checkout(self.repo, "feature", confirmation="wrong")
def test_checkout_dirty_tree_with_confirmation_switches(self) -> None:
_git(self.repo, "checkout", "-q", "-b", "feature")
_git(self.repo, "checkout", "-q", "main")
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
git_state.checkout(
self.repo,
"feature",
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
)
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
def test_checkout_track_sets_upstream(self) -> None:
self.remote = _init_bare_remote(self.base, "remote-bare.git")
_git(self.repo, "remote", "add", "origin", str(self.remote))
_git(self.repo, "push", "-q", "origin", "main")
# Create origin/feature remotely via a second clone.
other = _init_repo(self.base, "other")
_git(other, "remote", "add", "origin", str(self.remote))
_git(other, "checkout", "-q", "-b", "feature")
(other / "feature.txt").write_text("f", encoding="utf-8")
_git(other, "add", "feature.txt")
_git(other, "commit", "-q", "-m", "feature")
_git(other, "push", "-q", "origin", "feature")
# Bring the remote-tracking ref into this repo, then track it.
git_state.fetch(self.repo, "origin")
git_state.checkout(
self.repo,
"origin/feature",
confirmation="",
track=True,
)
current = _git(self.repo, "symbolic-ref", "--short", "HEAD")
self.assertEqual("feature", current)
upstream = _git(self.repo, "rev-parse", "--abbrev-ref", "feature@{upstream}")
self.assertEqual("origin/feature", upstream)
class AllowlistTests(_MutationBase):
def test_mutations_reject_unknown_repo(self) -> None:
with self.assertRaises(git_state.GitStateError):
git_state.stage(Path("/nonexistent"), ["x.txt"])
with self.assertRaises(git_state.GitStateError):
git_state.discard(
Path("/nonexistent"),
["x.txt"],
confirmation=git_state.CONFIRM_DISCARD,
)
if __name__ == "__main__":
unittest.main()
+10 -2
View File
@@ -46,14 +46,17 @@ class MobilePluginStoreTests(unittest.TestCase):
manifest = self.store.manifest()
self.assertEqual("hermes-relay", manifest["id"])
contribution = manifest["contributions"][0]
contribution = next(c for c in manifest["contributions"] if c["id"] == "daily-brief")
self.assertEqual("Draft: Daily Brief", contribution["title"])
self.assertEqual("mobile/pages/daily-brief", contribution["document"]["path"])
self.assertEqual(_document(), self.store.get("daily-brief")["document"])
published = self.store.publish("daily-brief")
self.assertEqual("published", published["status"])
self.assertEqual("Daily Brief", self.store.manifest()["contributions"][0]["title"])
published_contribution = next(
c for c in self.store.manifest()["contributions"] if c["id"] == "daily-brief"
)
self.assertEqual("Daily Brief", published_contribution["title"])
self.assertEqual({"ok": True, "id": "daily-brief"}, self.store.remove("daily-brief"))
self.assertEqual([], self.store.list())
@@ -81,6 +84,11 @@ class MobilePluginStoreTests(unittest.TestCase):
document={"schemaVersion": 1, "pages": []},
)
def test_rejects_reserved_git_id(self) -> None:
with self.assertRaisesRegex(MobilePluginStoreError, "reserved"):
self.store.draft("git", title="Shadow", description="", document=_document())
self.assertEqual(["git"], [item["id"] for item in self.store.manifest()["contributions"]])
def test_listing_omits_document_payload(self) -> None:
self.store.draft("compact", title="Compact", description="", document=_document())
self.assertNotIn("document", self.store.list()[0])
@@ -0,0 +1,367 @@
"""Client-reported supervised-mode metadata is bounded and informational."""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.auth import (
SUPERVISED_CAPABILITY_MAX_COUNT,
SUPERVISED_PROFILE_LABEL_MAX_LENGTH,
SessionManager,
SupervisedMode,
parse_supervised_mode,
)
from plugin.relay.config import RelayConfig
from plugin.relay.server import RelayServer, _build_auth_ok_payload, create_app
class SupervisedModeParsingTests(unittest.TestCase):
def test_valid_report_is_normalized_and_deduplicated(self) -> None:
parsed = parse_supervised_mode(
{
"active": True,
"profile_label": " Learning ",
"capabilities": ["text_chat", "voice", "voice"],
"enforcement_owner": "server", # client cannot override it
}
)
self.assertEqual(
parsed,
SupervisedMode(True, "Learning", ("text_chat", "voice")),
)
self.assertEqual(parsed.to_public_dict()["enforcement_owner"], "android_client")
def test_missing_inactive_and_malformed_reports_are_ordinary(self) -> None:
invalid = (
None,
[],
{"active": False, "profile_label": "Learning"},
{"active": "true", "profile_label": "Learning"},
{"active": True, "profile_label": 7},
{"active": True, "profile_label": "Learning", "capabilities": {}},
{"active": True, "profile_label": "Learning\n", "capabilities": []},
{
"active": True,
"profile_label": "Learning",
"capabilities": ["model:gpt-private"],
},
)
for value in invalid:
with self.subTest(value=value):
self.assertEqual(parse_supervised_mode(value), SupervisedMode())
def test_oversized_report_is_ordinary(self) -> None:
self.assertEqual(
parse_supervised_mode(
{
"active": True,
"profile_label": "x" * (SUPERVISED_PROFILE_LABEL_MAX_LENGTH + 1),
"capabilities": [],
}
),
SupervisedMode(),
)
self.assertEqual(
parse_supervised_mode(
{
"active": True,
"profile_label": "Learning",
"capabilities": ["voice"] * (SUPERVISED_CAPABILITY_MAX_COUNT + 1),
}
),
SupervisedMode(),
)
class SupervisedModePersistenceTests(unittest.TestCase):
def test_active_report_and_trusted_device_survive_restart_and_refresh(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sessions.json"
mode = SupervisedMode(
True,
"Learning",
("text_chat", "attachments", "voice"),
)
manager = SessionManager(persistence_path=path)
session = manager.create_session(
"Managed phone",
"managed-phone-id",
supervised_mode=mode,
issue_refresh_token=True,
)
refresh_token = session.refresh_token
assert refresh_token is not None
reloaded = SessionManager(persistence_path=path)
restored = reloaded.get_session(session.token)
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored.supervised_mode, mode)
reloaded._sessions.clear()
replacement = reloaded.refresh_session(
refresh_token,
device_name="Managed phone",
device_id="managed-phone-id",
)
self.assertIsNotNone(replacement)
assert replacement is not None
self.assertEqual(replacement.supervised_mode, mode)
def test_legacy_and_invalid_disk_rows_load_as_ordinary(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sessions.json"
manager = SessionManager(persistence_path=path)
session = manager.create_session("Legacy phone", "legacy-id")
payload = json.loads(path.read_text(encoding="utf-8"))
payload["sessions"][0]["supervised_mode"] = {
"active": True,
"profile_label": "Learning",
"capabilities": ["unknown_future_value"],
}
path.write_text(json.dumps(payload), encoding="utf-8")
reloaded = SessionManager(persistence_path=path)
restored = reloaded.get_session(session.token)
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored.supervised_mode, SupervisedMode())
payload["sessions"][0].pop("supervised_mode")
path.write_text(json.dumps(payload), encoding="utf-8")
legacy = SessionManager(persistence_path=path).get_session(session.token)
self.assertIsNotNone(legacy)
assert legacy is not None
self.assertEqual(legacy.supervised_mode, SupervisedMode())
def test_auth_ok_only_emits_active_client_report(self) -> None:
server = RelayServer(RelayConfig())
ordinary = server.sessions.create_session("Phone", "ordinary-id")
self.assertNotIn("supervised_mode", _build_auth_ok_payload(ordinary, server))
managed = server.sessions.create_session(
"Managed phone",
"managed-id",
supervised_mode=SupervisedMode(True, "Learning", ("voice",)),
)
report = _build_auth_ok_payload(managed, server)["supervised_mode"]
self.assertEqual(report["profile_label"], "Learning")
self.assertEqual(report["capabilities"], ["voice"])
self.assertEqual(report["enforcement_owner"], "android_client")
class SupervisedModeSessionRoutesTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
return create_app(RelayConfig())
async def test_list_exposes_active_report_and_omits_ordinary_report(self) -> None:
ordinary = self.app["server"].sessions.create_session("Phone", "ordinary-id")
self.app["server"].sessions.create_session(
"Managed phone",
"managed-id",
supervised_mode=SupervisedMode(
True, "Learning", ("text_chat", "attachments")
),
)
response = await self.client.get(
"/sessions", headers={"Authorization": f"Bearer {ordinary.token}"}
)
self.assertEqual(response.status, 200)
rows = {row["device_name"]: row for row in (await response.json())["sessions"]}
self.assertNotIn("supervised_mode", rows["Phone"])
self.assertEqual(
rows["Managed phone"]["supervised_mode"],
{
"active": True,
"profile_label": "Learning",
"capabilities": ["text_chat", "attachments"],
"enforcement_owner": "android_client",
},
)
async def test_pairing_auth_records_and_returns_client_report(self) -> None:
response = await self.client.post(
"/pairing/register", json={"code": "MODE01"}
)
self.assertEqual(response.status, 200, await response.text())
socket = await self.client.ws_connect("/ws")
await socket.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"pairing_code": "MODE01",
"device_name": "Managed phone",
"device_id": "managed-auth-id",
"client_surface": "android",
"supervised_mode": {
"active": True,
"profile_label": "Learning",
"capabilities": ["text_chat", "voice"],
},
},
}
)
envelope = await socket.receive_json()
await socket.close()
self.assertEqual(envelope["type"], "auth.ok")
report = envelope["payload"]["supervised_mode"]
self.assertEqual(report["profile_label"], "Learning")
self.assertEqual(report["capabilities"], ["text_chat", "voice"])
self.assertEqual(report["enforcement_owner"], "android_client")
stored = self.app["server"].sessions.get_session(
envelope["payload"]["session_token"]
)
self.assertIsNotNone(stored)
assert stored is not None
self.assertTrue(stored.supervised_mode.active)
reconnect = await self.client.ws_connect("/ws")
await reconnect.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"session_token": stored.token,
"device_id": "managed-auth-id",
},
}
)
ordinary_envelope = await reconnect.receive_json()
await reconnect.close()
self.assertEqual(ordinary_envelope["type"], "auth.ok")
self.assertNotIn("supervised_mode", ordinary_envelope["payload"])
refreshed = self.app["server"].sessions.get_session(stored.token)
self.assertIsNotNone(refreshed)
assert refreshed is not None
self.assertFalse(refreshed.supervised_mode.active)
async def test_authenticated_live_update_is_owned_acked_and_persisted(self) -> None:
manager = self.app["server"].sessions
original = SupervisedMode(True, "Learning", ("text_chat", "voice"))
session = manager.create_session(
"Managed phone",
"managed-live-id",
supervised_mode=original,
issue_refresh_token=True,
)
other = manager.create_session(
"Other phone",
"other-id",
supervised_mode=SupervisedMode(True, "Other", ("text_chat",)),
)
socket = await self.client.ws_connect("/ws")
await socket.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"session_token": session.token,
"device_id": session.device_id,
"supervised_mode": original.to_public_dict(),
},
}
)
self.assertEqual((await socket.receive_json())["type"], "auth.ok")
await socket.send_json(
{
"channel": "system",
"type": "supervised.update",
"id": "update-active",
"payload": {
# Must be ignored: ownership comes from the authenticated
# socket, not any selector supplied in the update body.
"session_token": other.token,
"supervised_mode": {
"active": True,
"profile_label": "School",
"capabilities": ["text_chat", "attachments"],
},
},
}
)
ack = await socket.receive_json()
self.assertEqual(ack["type"], "supervised.updated")
self.assertEqual(ack["id"], "update-active")
self.assertEqual(
ack["payload"]["supervised_mode"],
{
"active": True,
"profile_label": "School",
"capabilities": ["text_chat", "attachments"],
"enforcement_owner": "android_client",
},
)
self.assertEqual(
manager.get_session(session.token).supervised_mode,
SupervisedMode(True, "School", ("text_chat", "attachments")),
)
self.assertEqual(manager.get_session(other.token).supervised_mode.profile_label, "Other")
self.assertTrue(
any(
device.device_id == session.device_id
and device.supervised_mode.profile_label == "School"
for device in manager._trusted_devices.values()
)
)
await socket.send_json(
{
"channel": "system",
"type": "supervised.update",
"id": "update-inactive",
"payload": {"supervised_mode": {"active": False}},
}
)
cleared = await socket.receive_json()
await socket.close()
self.assertEqual(cleared["type"], "supervised.updated")
self.assertEqual(cleared["id"], "update-inactive")
self.assertEqual(
cleared["payload"]["supervised_mode"],
{"active": False, "enforcement_owner": "android_client"},
)
self.assertFalse(manager.get_session(session.token).supervised_mode.active)
self.assertTrue(
all(
not device.supervised_mode.active
for device in manager._trusted_devices.values()
if device.device_id == session.device_id
)
)
async def test_revoke_closes_connected_relay_socket(self) -> None:
caller = self.app["server"].sessions.create_session("Caller", "caller-id")
target = self.app["server"].sessions.create_session(
"Managed phone",
"managed-id",
supervised_mode=SupervisedMode(True, "Learning", ("text_chat",)),
)
socket = AsyncMock()
socket.closed = False
self.app["server"]._clients[socket] = target.token
response = await self.client.delete(
f"/sessions/{target.token[:8]}",
headers={"Authorization": f"Bearer {caller.token}"},
)
self.assertEqual(response.status, 200)
self.assertIsNone(self.app["server"].sessions.get_session(target.token))
socket.close.assert_awaited_once()
self.assertEqual(
socket.close.await_args.kwargs["message"], b"Relay session revoked"
)
+1
View File
@@ -220,6 +220,7 @@ export default defineConfig({
{ text: 'Remote access', link: '/guide/remote-access' },
{ text: 'Release tracks', link: '/guide/release-tracks' },
{ text: 'Chat', link: '/guide/chat' },
{ text: 'Supervised Mode', link: '/guide/supervised-mode' },
{ text: 'Sessions', link: '/guide/sessions' },
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
],
+6
View File
@@ -98,6 +98,12 @@ Google Play builds do not include AccessibilityService-backed screen reading or
| Message history | Loads from server on session switch |
| Persistence | Last session resumes on app restart |
## Supervised access
| Feature | Status | Description |
|---------|--------|-------------|
| Android Supervised Mode | Planned | Parent-controlled, profile-pinned restricted client interface; not a server-enforced child account. See the [Supervised Mode guide](/guide/supervised-mode). |
## Analytics
| Feature | Description |
+1
View File
@@ -53,6 +53,7 @@ voice routes. Sideload builds additionally expose Android Device Control routes.
- [Quick Start](/guide/quick-start) — Recommended Android + Relay setup
- [Installation & Setup](/guide/getting-started) — Builds, manual setup, and fallbacks
- [Chat Guide](/guide/chat) — Using the chat interface
- [Supervised Mode](/guide/supervised-mode) — Planned parent-controlled, profile-pinned Android interface
- [Sessions](/guide/sessions) — Managing conversations
- [Features](/features/) — All features at a glance
- [Architecture](/architecture/) — How it works under the hood
+226
View File
@@ -0,0 +1,226 @@
---
title: Supervised Mode
description: Configure a parent-controlled, profile-pinned Hermes-Relay Android experience
---
# Supervised Mode
::: warning Physical certification pending
Supervised Mode is implemented in the Android client, but it has not completed
physical managed-device certification. Treat it as experimental and do not call
an installation child-ready until the managed-device checks below pass.
:::
Supervised Mode is a parent-controlled, restricted view of Hermes-Relay for
Android. It is intended for a parent or guardian who has already created and
reviewed a suitably restricted Hermes profile and wants the phone app to expose
only an approved set of chat features.
It is a client-interface control, not a child account or a server security
boundary. The selected Hermes profile still controls the agent's prompt, model,
tools, provider credentials, content behavior, and server-side data.
## Before enabling it
Prepare the Hermes profile first. At minimum, review its:
- identity and system instructions;
- model and provider safety settings;
- enabled skills, tools, and external services;
- memory, files, schedules, and existing sessions;
- voice and image-generation providers;
- retention and parental-review expectations.
Supervised Mode cannot make an unrestricted profile safe by hiding controls on
the phone. Ordinary prose can still cause the configured agent to use whatever
server-side capabilities that profile has.
## Set it up
From full Android Settings, the parent:
1. Open **Settings → Advanced → Supervised Mode** for the active Hermes
Connection.
2. Choose one existing named profile. Android requires a secure device screen
lock before the mode can be enabled.
3. Select the allowed features and any stricter attachment or history limits.
4. Choose a visibility preset or customize what appears in Chat.
5. Review the summary, then enable the mode.
The app returns to the pinned profile's Chat screen. If the Connection or
profile is unavailable, the restricted client shows a recovery state
without falling back to another profile or exposing full Settings.
## The everyday experience
Chat should look like ordinary Hermes-Relay Chat. There is no persistent
Supervised Mode banner consuming conversation space. The agent name and avatar
remain the primary identity, with a small connection state when permitted.
The existing Settings button opens **Restricted Settings**, which contains only
approved preferences. A clearly labelled **Parent access** row starts device
authentication before any parent controls or full application settings appear.
Restricted Settings may include:
- a supervised-only theme, text size, language, and haptics;
- parent-approved pet display and, when allowed, a phone-local profile icon and
chat background;
- accessibility preferences;
- message presentation and sensitive-media blur;
- harmless playback or interaction preferences when voice is allowed;
- Help and About;
- the locked Parent access row.
Connections, profiles, Manage, model controls, personalities, reasoning,
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
companion, diagnostics, logs, files, credentials, developer options, Relay
management, and other sessions are not shown.
The command palette, slash autocomplete, server command catalog, and command
action cards are also absent. Messages whose first non-whitespace character is
`/` are rejected by the restricted client. Approved outcomes such as New chat
and Cancel remain normal, explicit buttons. If the agent requests approval, a
secret, clarification, or elevated access, the restricted client denies or
skips that request and shows a short notice. A parent can retry the task later
from the full client after authentication.
## Allowed features
The parent chooses capabilities independently. The proposed controls are:
| Capability | Suggested default | Effect when disabled |
|---|---:|---|
| Text chat | On | Required for the restricted chat experience |
| New chat | On | Removes the new-conversation action |
| Cancel reply | On | Removes Stop while a reply is running |
| Steer reply | On | Queues or disables mid-reply input instead |
| Attachments | Parent choice | Removes pickers, camera/share intake, paste-to-file, and restored attachment drafts |
| Standard voice | Parent choice | Removes recording, voice intents, and voice preferences |
| Generated media | On | Hides generated-image viewing and related actions |
| Save/share media | Off | Keeps permitted media view-only inside the app |
| Copy replies | On | Removes copy actions |
| Retry | On | Removes retry/regenerate actions |
| Quote/reply | On | Removes quote/reply actions |
| Edit and resend | Off | Prevents rewriting earlier prompts from the client |
| Session history | Parent choice | Limits the pinned profile to the current or approved conversations |
| Session actions | Off | Individually allows pin, rename, archive, share, and delete for visible history |
Attachments are a general capability, not a one-image rule. When enabled, the
normal supported attachment flow and app limits apply unless the parent chooses
a stricter maximum size or permitted-type policy. When disabled, every Android
entry point must be removed or rejected consistently, including share intents
and a draft restored after process death.
Standard voice uses the existing host-side voice configuration. Provider
credentials stay on the Hermes host and are not exposed in Restricted Settings.
The supervised theme is stored separately from the parent app theme and applies
only while the restricted root is locked. Pet display is parent-controlled.
Profile-icon and background changes can be enabled independently for the
supervised user; the authenticated parent retains those controls either way.
Session actions use a separate allowlist. The parent can allow all, allow none,
or choose individual actions. Copying technical session identifiers, browsing
other profiles, Relay Threads, and drawer customization remain unavailable.
Delete continues to require confirmation.
Generated media is limited by display policy, not by a claim that Android can
prove how the server created it. Parents may allow viewing while disabling save
and share. Ordinary remote links, files, and unsupported media retain the app's
normal safety behavior.
## What appears in Chat
Visibility controls affect presentation only. They never suppress an error,
safety notice, parent-action state, or connection failure that requires
attention.
### Simple (recommended)
- Shows the agent name and avatar.
- Shows generic **Connected**, **Working**, and **Reconnecting** states.
- Hides model, profile, provider, route, context, token usage, reasoning, and
tool details.
- Keeps the header and composer visually quiet.
### Transparent
Adds parent-approved timestamps, bounded usage or context information, and
safe activity labels. It still does not reveal tool arguments, tool results,
host paths, credentials, or administration surfaces.
### Custom
Lets the parent control individual surfaces, including:
- model name and profile name;
- connection state and route identity;
- timestamps, context, and token usage;
- generic work status, tool names, and tool detail;
- reasoning visibility;
- message and media actions.
Model and profile names default off for a new policy. The agent's friendly name
and avatar provide the normal identity in the Simple preset.
## Parent access and relocking
Enabling, changing, or ending Supervised Mode requires Android device
authentication. Parent access should relock when its authenticated task closes,
after the configured inactivity period, when the app backgrounds, or after
process recreation.
Android's device-credential prompt authenticates any user enrolled for that
device; it does not establish a separate parent identity. Use a device lock the
supervised user does not know, or keep the device under direct supervision.
The restricted root is restored before the first interactive screen. Deep
links, notification actions, shortcuts, saved back stacks, and share intents
must not provide a route around it. A missing or unreadable policy fails closed
to restricted recovery instead of opening full Settings.
Ending the mode may clear local drafts, pending attachments, and supervised
media caches according to the parent's choice. It does not automatically delete
Hermes sessions or history stored on the server. Parents review or delete that
history through their normal authenticated Hermes interface.
## Optional Relay visibility
If the Android client is paired with the optional Relay plugin, it may identify
itself with a client-reported **Supervised** tag and a short, non-sensitive
capability summary. The Relay UI can then make the device easy to recognize and
can revoke its paired Relay session through the normal paired-device controls.
The tag is informational. Relay does not enforce the Android policy, pin the
Hermes profile, filter direct Dashboard/Gateway chat, or certify that the client
is unmodified. Revoking the Relay session disables Relay-backed access for that
pairing; it does not remotely end an Android-only mode or revoke an independent
Dashboard sign-in. Supervised Mode does not require Relay.
## Limits of protection
Supervised Mode cannot control:
- another Hermes client or a modified Android build;
- someone with direct access to the Hermes server or parent credentials;
- tools, files, services, and provider behavior enabled in the selected profile;
- server-side session retention or provider data handling;
- the developmental suitability or factual accuracy of model output;
- Android behavior outside the Hermes-Relay app.
Use it alongside a restrictive Hermes profile, parental supervision, Android
parental or enterprise controls where appropriate, and regular review of the
profile and its conversations.
## Certification requirement
The feature should not be described as child-ready until the exact Android
build passes automated policy and navigation tests plus physical testing on a
managed/restricted Android device. Certification must cover authentication,
relocking, restart and offline recovery, process death, deep links,
notifications, share intents, attachments, voice, session ownership, Relay
tagging/revocation, and attempts to escape the restricted interface.
See [Profiles](/features/profiles) for the server-owned identity model and
[Chat](/guide/chat) for the full, unrestricted interface.