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 c9a5c767c6 Merge pull request #437 from Codename-11/fix/android-session-activity-hotfix
fix(android): release authoritative session activity
2026-08-25 14:33:40 -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 524e319f95 release(android): android-v1.13.1 2026-08-25 13:04:28 -04:00
Bailey Dixon 647d1f9aea fix(android): make session activity authoritative 2026-08-25 12:56:22 -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
34 changed files with 948 additions and 300 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
+6
View File
@@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### 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
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
## [Android 1.13.0] - 2026-08-25
+8 -19
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.13.0
# Hermes-Relay Android v1.13.1
**Release Date:** August 25, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.13.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,28 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
## Added
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
## Changed
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
## Fixed
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
## Install / Verify
- App version: **1.13.0** (versionCode **49**).
- App version: **1.13.1** (versionCode **50**).
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
@@ -1 +1 @@
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
@@ -1 +1 @@
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
+14
View File
@@ -1,5 +1,19 @@
{
"versions": [
{
"version": "1.13.1",
"title": "Accurate session activity",
"date": "2026-08-25",
"sections": [
{
"header": "Follow live Hermes state",
"bullets": [
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
]
}
]
},
{
"version": "1.13.0",
"title": "Bots, usage, and reliable chat",
+4 -5
View File
@@ -1,6 +1,5 @@
v1.13.0 - Bots, usage, and reliable chat
v1.13.1 - Accurate session activity
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
* Include bounded visible text and an available screenshot in compatible Assistant turns.
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
* Distinguish Checking, Unavailable, and Background work in the session drawer.
@@ -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
@@ -855,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,
@@ -868,24 +874,28 @@ fun RelayApp() {
dashboardFactory = connectionViewModel::dashboardClientForActive,
sessionId = currentChatSessionId,
)
}
LaunchedEffect(gitOwnerKey) {
val dashboard = effectiveDashboardUrl
.takeIf { it.isNotBlank() }
?.let { connectionViewModel.dashboardClientForActive(it) }
gitStateViewModel.configure(dashboard)
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) {
val granted = (pluginsHubState as? PluginsHubState.Ready)
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(granted)
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
}
// What's New auto-show
@@ -1189,12 +1199,12 @@ fun RelayApp() {
parentAccessForCurrentRoute,
currentRoute,
) {
if (shouldRedirectSupervisedRoute(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = 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
@@ -1203,6 +1213,12 @@ fun RelayApp() {
}
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
}
}
@@ -2597,7 +2613,7 @@ fun RelayApp() {
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
supervisedPolicy = supervisedPolicy,
parentAccessUnlocked = parentAccessUnlocked,
parentAccessUnlocked = parentAccessForCurrentRoute,
onRequestParentAccess = { parentAccessUnlocked = true },
onUpdateSupervisedPolicy = { policy ->
activeConnectionId?.let { connectionId ->
@@ -2612,6 +2628,9 @@ fun RelayApp() {
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
@@ -2687,7 +2706,7 @@ fun RelayApp() {
)
}
composable(Screen.AdvancedSettings.route) {
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
AdvancedSettingsScreen(
@@ -2700,7 +2719,7 @@ fun RelayApp() {
}
}
composable(Screen.SupervisedAppearanceSettings.route) {
if (!supervisedPolicy.enabled && !parentAccessUnlocked) {
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedAppearanceSettingsScreen(
@@ -2718,7 +2737,7 @@ fun RelayApp() {
}
}
composable(Screen.SupervisedControls.route) {
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedControlsScreen(
@@ -3230,7 +3249,7 @@ fun RelayApp() {
AboutScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessUnlocked,
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
)
}
composable(
@@ -55,6 +55,7 @@ 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 →
@@ -72,7 +73,7 @@ fun GitStateScreen(
val detailState by viewModel.detail.collectAsState()
val contentState by viewModel.content.collectAsState()
val mutationState by viewModel.mutation.collectAsState()
val hasGrant = viewModel.hasWriteGrant()
val hasGrant by viewModel.writeGrant.collectAsState()
// Hoisted at screen level so confirmation/commit dialogs are modal.
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
@@ -150,18 +151,26 @@ fun GitStateScreen(
onStage = { path -> viewModel.stage(listOf(path)) },
onUnstage = { path -> viewModel.unstage(listOf(path)) },
onDiscard = { paths, deleteUntracked ->
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked)
viewModel.currentTarget()?.let { target ->
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked, target)
}
},
onCommitRequest = { showCommitDialog = true },
onFetch = { viewModel.fetch() },
onPull = { viewModel.pull() },
onPush = { pendingConfirm = ConfirmationRequest.Push },
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) {
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref)
viewModel.currentTarget()?.let { target ->
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, target)
}
} else {
viewModel.checkout(ref)
}
@@ -197,9 +206,10 @@ fun GitStateScreen(
onPushAfterCommitChange = { pushAfterCommit = it },
onCommit = { message ->
showCommitDialog = false
viewModel.commit(message)
if (pushAfterCommit) {
pendingConfirm = ConfirmationRequest.Push
viewModel.commit(message) { committedTarget ->
if (pushAfterCommit) {
pendingConfirm = ConfirmationRequest.Push(committedTarget)
}
}
},
)
@@ -219,6 +229,7 @@ fun GitStateScreen(
request.paths,
GitConfirmationStrings.DISCARD,
request.deleteUntracked,
request.target,
)
}) {
Text(stringResource(R.string.git_state_confirm_discard_confirm))
@@ -230,14 +241,17 @@ fun GitStateScreen(
}
},
)
ConfirmationRequest.Push -> AlertDialog(
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)
viewModel.push(
GitConfirmationStrings.PUSH,
expectedTarget = request.target,
)
}) {
Text(stringResource(R.string.git_state_confirm_push_confirm))
}
@@ -255,7 +269,11 @@ fun GitStateScreen(
confirmButton = {
TextButton(onClick = {
pendingConfirm = null
viewModel.checkout(request.ref, GitConfirmationStrings.DIRTY_CHECKOUT)
viewModel.checkout(
request.ref,
GitConfirmationStrings.DIRTY_CHECKOUT,
expectedTarget = request.target,
)
}) {
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
}
@@ -272,9 +290,14 @@ fun GitStateScreen(
/** A destructive action awaiting explicit user confirmation. */
private sealed interface ConfirmationRequest {
data class Discard(val paths: List<String>, val deleteUntracked: Boolean) : ConfirmationRequest
data object Push : ConfirmationRequest
data class DirtyCheckout(val ref: String) : 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
@@ -165,6 +165,7 @@ fun SettingsScreen(
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
@@ -455,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).
@@ -388,6 +388,16 @@ fun SupervisedControlsScreen(
)
}
if (policy.enabled) {
SupervisedNavigationRow(
icon = Icons.Filled.Lock,
title = "Return to supervised view",
subtitle = "Lock parent access and open the pinned agent chat",
onClick = onReturnToSupervisedView,
isDarkTheme = isDarkTheme,
)
}
Text(
"This mode restricts this Android client. The selected Hermes profile remains responsible for agent tools and content policy.",
style = MaterialTheme.typography.bodySmall,
@@ -746,15 +756,6 @@ fun SupervisedControlsScreen(
}
}
if (policy.enabled) {
TextButton(
onClick = onReturnToSupervisedView,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Lock, contentDescription = null)
Text("Return to supervised view", modifier = Modifier.padding(start = 8.dp))
}
}
Spacer(Modifier.height(16.dp))
}
}
@@ -62,14 +62,21 @@ object GitConfirmationStrings {
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`` receives the grant set and every mutation refuses (surfacing a
* readable message, never a POST) when the grant is absent. Destructive ops
* ``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.
*/
@@ -96,37 +103,73 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
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 loadJob: Job? = 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
private var writeGrant: Boolean = false
fun selectedRepoIdForDisplay(): String? = selectedRepoId
fun configure(dashboard: DashboardApiClient?) {
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(granted: Boolean) {
writeGrant = granted
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
if (ownerKey != scopeKey) return
_writeGrant.value = granted
}
fun hasWriteGrant(): Boolean = writeGrant
fun hasWriteGrant(): Boolean = _writeGrant.value
fun loadRepos() {
val client = api ?: run {
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
return
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
val expectedScope = scopeKey
reposJob?.cancel()
reposJob = viewModelScope.launch {
_repos.value = GitStateUiState.Loading
client.repos().fold(
onSuccess = { list -> _repos.value = GitStateUiState.Ready(list, null) },
onSuccess = { list ->
if (scopeKey == expectedScope) {
_repos.value = GitStateUiState.Ready(list, null)
}
},
onFailure = { error ->
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
if (scopeKey == expectedScope) {
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
}
},
)
}
@@ -134,14 +177,18 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
fun selectRepo(repoId: String) {
val client = api ?: return
targetGeneration += 1
selectedRepoId = repoId
val target = currentTarget() ?: return
_content.value = GitContentViewState.Idle
_mutation.value = GitMutationState.Idle
loadJob?.cancel()
loadJob = viewModelScope.launch {
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",
@@ -154,87 +201,114 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
}
}
/** Runs a mutation through the shared gate (grant + confirmation). */
private fun runMutation(label: String, block: suspend (GitStateApiClient, String) -> Result<GitMutationState>) {
/** 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 repoId = selectedRepoId ?: run {
val target = currentTarget() ?: run {
_mutation.value = GitMutationState.Error(label, "No repository selected")
return
}
if (!writeGrant) {
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
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
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, repoId).fold(
block(client, target.repoId).fold(
onSuccess = {
if (currentTarget() != target) return@fold
_mutation.value = it
_content.value = GitContentViewState.Idle
refreshDetail(repoId)
refreshDetail(client, target)
onSuccess(target)
},
onFailure = { error ->
_mutation.value = GitMutationState.Error(
label,
error.message ?: "Git action failed",
)
if (currentTarget() == target) {
_mutation.value = GitMutationState.Error(
label,
error.message ?: "Git action failed",
)
}
},
)
}
}
private fun refreshDetail(repoId: String) {
val client = api ?: return
viewModelScope.launch {
val statusResult = client.status(repoId)
val branchesResult = client.branches(repoId)
if (statusResult.isSuccess) {
_detail.value = GitRepoDetailState.Ready(
statusResult.getOrDefault(GitStatus()),
branchesResult.getOrDefault(emptyList()),
)
}
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 repoId = selectedRepoId ?: return
val target = currentTarget() ?: return
val client = api ?: return
loadJob?.cancel()
loadJob = viewModelScope.launch {
contentJob?.cancel()
contentJob = viewModelScope.launch {
_content.value = GitContentViewState.Loading
client.diff(repoId, path, kind).fold(
onSuccess = { diff -> _content.value = GitContentViewState.Diff(diff) },
client.diff(target.repoId, path, kind).fold(
onSuccess = { diff ->
if (currentTarget() == target) {
_content.value = GitContentViewState.Diff(diff)
}
},
onFailure = { error ->
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load diff",
)
if (currentTarget() == target) {
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load diff",
)
}
},
)
}
}
fun loadFile(path: String) {
val repoId = selectedRepoId ?: return
val target = currentTarget() ?: return
val client = api ?: return
loadJob?.cancel()
loadJob = viewModelScope.launch {
contentJob?.cancel()
contentJob = viewModelScope.launch {
_content.value = GitContentViewState.Loading
client.file(repoId, path).fold(
onSuccess = { file -> _content.value = GitContentViewState.File(file) },
client.file(target.repoId, path).fold(
onSuccess = { file ->
if (currentTarget() == target) {
_content.value = GitContentViewState.File(file)
}
},
onFailure = { error ->
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load file",
)
if (currentTarget() == target) {
_content.value = GitContentViewState.Error(
error.message ?: "Failed to load file",
)
}
},
)
}
@@ -250,17 +324,23 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
}
fun discard(paths: List<String>, confirmation: String, deleteUntracked: Boolean = false) =
runMutation("Discard") { c, r ->
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) = runMutation("Commit") { c, r ->
c.commit(r, message).map {
GitMutationState.Success("commit", 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 {
@@ -276,8 +356,13 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
}
fun push(confirmation: String, remote: String = "origin", branch: String = "") =
runMutation("Push") { c, r ->
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) }
}
@@ -286,7 +371,8 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
confirmation: String? = null,
newBranch: String = "",
track: Boolean = false,
) = runMutation("Checkout") { c, r ->
expectedTarget: GitTarget? = null,
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
c.checkout(r, ref, confirmation, newBranch, track)
.map { GitMutationState.Success("checkout", it.head) }
}
@@ -309,25 +395,26 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
return
}
val repoId = selectedRepoId ?: run {
val target = currentTarget() ?: run {
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
return
}
if (!writeGrant) {
if (!_writeGrant.value) {
_messageGeneration.value = GitMessageGenerationState.Ready(
"",
"Allow plugin changes (plugin.api.write) before using this action.",
)
return
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
messageJob?.cancel()
messageJob = viewModelScope.launch {
_messageGeneration.value = GitMessageGenerationState.Loading
val result = if (paths != null) {
client.commitMessageSelected(repoId, paths)
client.commitMessageSelected(target.repoId, paths)
} else {
client.commitMessage(repoId)
client.commitMessage(target.repoId)
}
if (currentTarget() != target) return@launch
result.fold(
onSuccess = { msg ->
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
@@ -351,11 +438,11 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
return
}
val repoId = selectedRepoId ?: run {
val target = currentTarget() ?: run {
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
return
}
if (!writeGrant) {
if (!_writeGrant.value) {
_mutation.value = GitMutationState.Error(
"Stash Checkout",
"Allow plugin changes (plugin.api.write) before using this action.",
@@ -363,24 +450,35 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
return
}
_stashNotice.value = null
loadJob?.cancel()
loadJob = viewModelScope.launch {
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(repoId, ref, newBranch, track).fold(
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(repoId)
refreshDetail(client, target)
},
onFailure = { error ->
_mutation.value = GitMutationState.Error(
"Stash Checkout",
error.message ?: "Git action failed",
)
if (currentTarget() == target) {
_mutation.value = GitMutationState.Error(
"Stash Checkout",
error.message ?: "Git action failed",
)
}
},
)
}
@@ -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),
)
@@ -27,6 +27,7 @@ import org.robolectric.annotation.Config
@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
@@ -46,8 +47,8 @@ class GitStateExtrasViewModelTest {
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()))
vm.setWriteGrant(grant)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
vm.setWriteGrant(ownerKey, grant)
return vm
}
@@ -29,6 +29,7 @@ import org.robolectric.annotation.Config
@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
@@ -48,7 +49,7 @@ class GitStateViewModelTest {
private fun viewModel(): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()))
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
return vm
}
@@ -16,6 +16,7 @@ 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
@@ -27,6 +28,7 @@ import org.robolectric.annotation.Config
@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
@@ -46,8 +48,8 @@ class GitStateWriteViewModelTest {
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()))
vm.setWriteGrant(grant)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
vm.setWriteGrant(ownerKey, grant)
return vm
}
@@ -139,6 +141,49 @@ class GitStateWriteViewModelTest {
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()
+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
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.13.0 - Bots, usage, and reliable chat
v1.13.1 - Accurate session activity
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
```
## Category
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.13.0"
appVersionCode = "49"
appVersionName = "1.13.1"
appVersionCode = "50"
agp = "9.3.2"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+7 -7
View File
File diff suppressed because one or more lines are too long
+17 -9
View File
@@ -228,11 +228,12 @@ async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
@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),
_ref(body, allow_empty=bool(new_branch)),
confirmation=body.get("confirmation"),
new_branch=_str_opt(body, "new_branch"),
new_branch=new_branch,
track=bool(body.get("track", False)),
)
except git_state.GitError as exc:
@@ -251,10 +252,11 @@ async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any
stash after a successful switch.
"""
try:
new_branch = _str_opt(body, "new_branch")
return git_state.stash_checkout(
_require_repo(body),
_ref(body),
new_branch=_str_opt(body, "new_branch"),
_ref(body, allow_empty=bool(new_branch)),
new_branch=new_branch,
track=bool(body.get("track", False)),
)
except git_state.GitError as exc:
@@ -307,16 +309,22 @@ def _message(body: dict[str, Any]) -> str:
def _remote(body: dict[str, Any]) -> str:
return body.get("remote") or "origin"
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:
return body.get("branch") or ""
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]) -> str:
ref = body.get("ref")
if not isinstance(ref, str) or not ref:
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
+3 -7
View File
@@ -167,9 +167,9 @@ export function getGitFile(repo, path) {
}
// ── Git State write operations ────────────────────────────────────────────
// Every write POST goes through the authenticated plugin namespace and is
// gated by the plugin.api.write grant (enforced client-side before any POST
// is sent). Destructive ops pass a per-use confirmation token in the body.
// 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, {
@@ -200,10 +200,6 @@ export function gitCommit(repo, message) {
return postGit("/git/commit", { repo, message });
}
export function gitCommitSelected(repo, message, paths) {
return postGit("/git/commit_selected", { repo, message, paths });
}
export function gitFetch(repo, remote = "origin") {
return postGit("/git/fetch", { repo, remote });
}
+8
View File
@@ -126,6 +126,14 @@ 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}.
+100 -50
View File
@@ -1,6 +1,6 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useEffect, useCallback } = SDK.hooks;
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
import {
getGitRepos,
@@ -27,6 +27,8 @@ import {
hasCommitSuggestion,
requiresConfirmation,
confirmationFor,
isCurrentRepoRequest,
shouldOfferPushAfterCommit,
} from "../lib/git-state.mjs";
import {
Alert,
@@ -122,10 +124,9 @@ function BranchesRow({ branches }) {
}
/**
* Write controls for the GitState tab. Every mutation is gated by the
* plugin.api.write grant (the tab is only reachable after the user grants it)
* and destructive ops (discard/push/dirty-checkout) are confirmed via the
* per-use confirmation-string mechanics before the POST is sent.
* 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,
@@ -143,6 +144,8 @@ function WriteControls({
pushAfterCommit,
onPushAfterCommitChange,
onStageAll,
onUnstageAll,
onDiscardAll,
onStage,
onUnstage,
onDiscard,
@@ -162,13 +165,13 @@ function WriteControls({
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={() => modified.forEach(onStage)}>
<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={() => staged.forEach(onUnstage)}>
<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={() => staged.forEach(onDiscard)}>
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onDiscardAll}>
Discard staged
</Button>
</div>
@@ -264,6 +267,9 @@ export default function GitState({ autoRefresh }) {
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);
@@ -272,19 +278,24 @@ export default function GitState({ autoRefresh }) {
const list = (data && data.repos) || [];
setRepos(list);
setNotice((data && data.notice) || null);
if (selected && !list.some((r) => r.id === selected)) {
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);
}
}, [selected]);
}, []);
useEffect(() => {
loadRepos();
@@ -297,47 +308,64 @@ export default function GitState({ autoRefresh }) {
}, [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) => {
if (!selected) return;
const repoId = selectedRef.current;
if (!repoId) return;
const generation = requestGenerationRef.current;
setFile(null);
setError(null);
try {
setDiff(await getGitDiff(selected, path, kind));
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));
}
}, [selected]);
}, []);
const showFile = useCallback(async (path) => {
if (!selected) return;
const repoId = selectedRef.current;
if (!repoId) return;
const generation = requestGenerationRef.current;
setDiff(null);
setError(null);
try {
setFile(await getGitFile(selected, path));
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));
}
}, [selected]);
}, []);
// ── Write controls (gated by plugin.api.write + confirmations) ───────────
// ── Authenticated Dashboard write controls + confirmations ──────────────
const [commitMessage, setCommitMessage] = useState("");
const [newBranch, setNewBranch] = useState("");
const [branchRef, setBranchRef] = useState("");
@@ -347,44 +375,55 @@ export default function GitState({ autoRefresh }) {
const [commitNotice, setCommitNotice] = useState(null);
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const refreshDetail = useCallback(async (repoId) => {
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) => {
if (!selected) return;
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(selected, paths);
else if (op === "unstage") await gitUnstage(selected, paths);
else if (op === "fetch") await gitFetch(selected, opts?.remote || "origin");
else if (op === "pull") await gitPull(selected, opts?.remote || "origin", opts?.branch || "");
else if (op === "commit") await gitCommit(selected, opts?.message);
else if (op === "commitSelected") await gitCommitSelected(selected, opts?.message, paths);
else if (op === "discard") await gitDiscard(selected, paths, opts?.confirmation, opts?.deleteUntracked);
else if (op === "push") await gitPush(selected, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
else if (op === "dirty-checkout") {
await gitCheckout(selected, opts.ref, {
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(selected);
await refreshDetail(repoId, generation);
return true;
} catch (err) {
setMutationError(err && err.message ? err.message : String(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);
}
},
[selected, refreshDetail],
[refreshDetail],
);
/**
@@ -392,14 +431,17 @@ export default function GitState({ autoRefresh }) {
* staged diff / model-unavailable degrade to a notice, never an error.
*/
const generateMessage = useCallback(async () => {
if (!selected) return;
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
? await gitCommitMessageSelected(selected, stagedPaths)
: await gitCommitMessage(selected);
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);
@@ -408,11 +450,14 @@ export default function GitState({ autoRefresh }) {
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 {
setGeneratingMessage(false);
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
setGeneratingMessage(false);
}
}
}, [selected, status]);
}, [status]);
/**
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
@@ -421,30 +466,37 @@ export default function GitState({ autoRefresh }) {
*/
const doStashCheckout = useCallback(async () => {
const ref = branchRef.trim();
if (!ref || !selected) return;
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(selected, ref, {
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(selected);
await refreshDetail(repoId, generation);
} catch (err) {
setMutationError(err && err.message ? err.message : String(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("");
}
}, [selected, branchRef, newBranch, refreshDetail]);
}, [branchRef, newBranch, refreshDetail]);
/**
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
@@ -476,17 +528,13 @@ export default function GitState({ autoRefresh }) {
setMutationError("Commit message must not be empty.");
return;
}
const stagedPaths = (status && status.staged || []).map((e) => e.path);
if (stagedPaths.length > 0) {
await applyMutation("commitSelected", stagedPaths, { message });
} else {
await applyMutation("commit", [], { message });
}
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 (pushAfterCommit) {
if (shouldOfferPushAfterCommit(succeeded, pushAfterCommit)) {
requestMutation("push", {});
}
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
@@ -601,6 +649,8 @@ export default function GitState({ autoRefresh }) {
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 })}
@@ -1,11 +1,14 @@
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", () => {
@@ -49,6 +52,33 @@ test("requiresConfirmation gates only destructive ops", () => {
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 {
+11
View File
@@ -88,6 +88,17 @@ class GitWriteApiTests(unittest.TestCase):
)
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")
@@ -92,6 +92,15 @@ class MobilePluginApiTests(unittest.TestCase):
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(
"/mobile/plugins/..%5Coutside/draft",
+228 -75
View File
@@ -11,8 +11,7 @@ Security contract
- ``repo`` params are opaque ids resolved against the scanned repo set; an
unknown id is rejected before any filesystem access.
- File paths are validated to reject traversal (``..``), absolute escapes, and
null bytes. Git itself treats paths as repo-relative, so this is defense in
depth.
null bytes. Working-tree reads additionally require canonical containment.
- Remote URLs are scrubbed of embedded userinfo before they reach any client.
"""
@@ -22,6 +21,7 @@ import logging
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
@@ -33,6 +33,9 @@ logger = logging.getLogger(__name__)
MAX_STATUS_ENTRIES = int(os.environ.get("GIT_STATE_MAX_STATUS_ENTRIES", "200"))
MAX_DIFF_BYTES = int(os.environ.get("GIT_STATE_MAX_DIFF_BYTES", "64_000"))
MAX_FILE_BYTES = int(os.environ.get("GIT_STATE_MAX_FILE_BYTES", "256_000"))
MAX_GIT_OUTPUT_BYTES = int(os.environ.get("GIT_STATE_MAX_OUTPUT_BYTES", "1_000_000"))
MAX_GIT_ERROR_BYTES = int(os.environ.get("GIT_STATE_MAX_ERROR_BYTES", "16_000"))
MAX_GIT_SCALAR_LENGTH = 512
GIT_TIMEOUT_SECONDS = float(os.environ.get("GIT_STATE_TIMEOUT_SECONDS", "10"))
# Default base path for repo discovery.
@@ -42,6 +45,8 @@ _BASE_PATH_ENV = "GIT_STATE_BASE_PATH"
# Matches a remote URL's userinfo (user[:password]@) so it can be scrubbed.
_USERINFO_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@]+)@")
_SSH_USERINFO_RE = re.compile(r"^([^/@:]+)@([^:]+):")
_ERROR_USERINFO_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^\s/@]+)@")
_ERROR_SSH_USERINFO_RE = re.compile(r"(?<![\w@])([^\s/@:]+)@([^\s:]+):")
class GitStateError(ValueError):
@@ -81,26 +86,68 @@ def base_path() -> Path:
return Path(raw).expanduser()
def _run_git_bounded(
repo: Path,
args: list[str],
*,
mutation: bool,
) -> tuple[int, str, str]:
"""Run Git without materializing unbounded stdout or stderr in memory."""
error_type = GitError if mutation else GitStateError
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
stdout=stdout_file,
stderr=stderr_file,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
if mutation:
raise GitError(f"git timed out for {repo.name}", code="network") from exc
raise GitStateError(f"git timed out for {repo.name}") from exc
except OSError as exc:
if mutation:
raise GitError(
f"could not run git for {repo.name}: {exc}",
code="non-repo",
) from exc
raise GitStateError(f"could not run git for {repo.name}: {exc}") from exc
stdout_file.seek(0)
stderr_file.seek(0)
stdout_bytes = stdout_file.read(MAX_GIT_OUTPUT_BYTES + 1)
stderr_bytes = stderr_file.read(MAX_GIT_ERROR_BYTES + 1)
if len(stdout_bytes) > MAX_GIT_OUTPUT_BYTES:
message = f"git {args[0] if args else 'command'} output exceeded the limit"
if mutation:
raise GitError(message, code="invalid-input")
raise error_type(message)
stdout = stdout_bytes.decode("utf-8", errors="replace")
stderr = stderr_bytes[:MAX_GIT_ERROR_BYTES].decode("utf-8", errors="replace")
if len(stderr_bytes) > MAX_GIT_ERROR_BYTES:
stderr += "\n[error output truncated]"
return result.returncode, stdout, stderr
def _safe_git_error(text: str) -> str:
"""Bound and scrub URL userinfo before returning Git diagnostics."""
scrubbed = _ERROR_USERINFO_RE.sub(r"\1", text)
scrubbed = _ERROR_SSH_USERINFO_RE.sub(r"\2:", scrubbed)
return scrubbed[:MAX_GIT_ERROR_BYTES].strip()
def _git(repo: Path, *args: str) -> str:
"""Run ``git -C <repo> <args>`` and return stdout. Raises on failure."""
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise GitStateError(f"git timed out for {repo.name}") from exc
except OSError as exc:
raise GitStateError(f"could not run git for {repo.name}: {exc}") from exc
if result.returncode != 0:
"""Run ``git -C <repo> <args>`` and return bounded stdout."""
returncode, stdout, stderr = _run_git_bounded(repo, list(args), mutation=False)
if returncode != 0:
raise GitStateError(
f"git {args[0] if args else 'command'} failed for {repo.name}: "
f"{result.stderr.strip() or result.stdout.strip()}"
f"{_safe_git_error(stderr or stdout)}"
)
return result.stdout
return stdout
def _is_git_repo(path: Path) -> bool:
@@ -108,9 +155,33 @@ def _is_git_repo(path: Path) -> bool:
return (path / ".git").exists()
def repo_id(repo: Path) -> str:
"""Opaque, stable id for a repo — its directory basename."""
return repo.name
def _is_link_or_junction(path: Path) -> bool:
is_junction = getattr(path, "is_junction", None)
return path.is_symlink() or bool(is_junction and is_junction())
def _is_within(root: Path, candidate: Path) -> bool:
return candidate == root or root in candidate.parents
def _has_link_component(base: Path, path: Path) -> bool:
current = base
try:
relative = path.relative_to(base)
except ValueError:
return True
for part in relative.parts:
current /= part
if _is_link_or_junction(current):
return True
return False
def repo_id(repo: Path, base: Path | None = None) -> str:
"""Stable collision-free id relative to the configured canonical base."""
if base is None:
return repo.name
return repo.relative_to(base).as_posix()
def scan_repos(base: Path) -> list[dict[str, Any]]:
@@ -123,19 +194,25 @@ def scan_repos(base: Path) -> list[dict[str, Any]]:
if not base.is_dir():
return []
canonical_base = base.resolve()
repos: list[dict[str, Any]] = []
for root in sorted(base.rglob("*")):
if not root.is_dir():
continue
if root.name == ".git":
continue
if not _is_git_repo(root):
if _has_link_component(base, root):
continue
repos.append(_describe_repo(root))
canonical_root = root.resolve()
if not _is_within(canonical_base, canonical_root):
continue
if not _is_git_repo(canonical_root):
continue
repos.append(_describe_repo(canonical_root, canonical_base))
return repos
def _describe_repo(repo: Path) -> dict[str, Any]:
def _describe_repo(repo: Path, base: Path) -> dict[str, Any]:
"""Build the scan entry for one repository."""
current_branch = ""
try:
@@ -151,7 +228,7 @@ def _describe_repo(repo: Path) -> dict[str, Any]:
pass
return {
"id": repo_id(repo),
"id": repo_id(repo, base),
"name": repo.name,
"root": str(repo),
"current_branch": current_branch,
@@ -329,9 +406,13 @@ def read_file(repo: Path, path: str) -> dict[str, Any]:
# modified-but-uncommitted file returns what is on disk. Read bytes first:
# binary content dies on the NUL check (before any decode), and non-UTF-8
# text raises a clear GitStateError instead of an unhandled 500.
disk_path = repo / safe_path
root = repo.resolve()
try:
raw = disk_path.read_bytes()
disk_path = (repo / safe_path).resolve(strict=True)
if not _is_within(root, disk_path):
raise GitStateError(f"path escapes repository: {safe_path}")
with disk_path.open("rb") as handle:
raw = handle.read(MAX_FILE_BYTES + 1)
except OSError as exc:
raise GitStateError(f"could not read file: {safe_path}") from exc
@@ -450,25 +531,14 @@ def _run_mutation(repo: Path, args: list[str]) -> str:
Arg lists only (never shell interpolation); bounded by a timeout.
"""
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise GitError(f"git timed out for {repo.name}", code="network") from exc
except OSError as exc:
raise GitError(f"could not run git for {repo.name}: {exc}", code="non-repo") from exc
if result.returncode != 0:
stderr = result.stderr.strip() or result.stdout.strip()
returncode, stdout, stderr_output = _run_git_bounded(repo, args, mutation=True)
if returncode != 0:
stderr = _safe_git_error(stderr_output or stdout)
raise GitError(
f"git {args[0] if args else 'command'} failed for {repo.name}: {stderr}",
code=_classify_git_failure(stderr),
)
return result.stdout
return stdout
def _mutate(repo: Path, args: list[str]) -> str:
@@ -491,6 +561,93 @@ def _validate_commit_message(message: str) -> str:
return message.strip()[:MAX_COMMIT_MESSAGE]
def _validate_git_scalar(value: str, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str):
raise GitError(f"{label} must be a string", code="invalid-input")
value = value.strip()
if not value:
if allow_empty:
return ""
raise GitError(f"{label} is required", code="invalid-input")
if len(value) > MAX_GIT_SCALAR_LENGTH:
raise GitError(f"{label} is too long", code="invalid-input")
if value.startswith("-"):
raise GitError(f"{label} must not be a git option", code="invalid-input")
if "\x00" in value or any(ord(char) < 32 for char in value):
raise GitError(f"{label} contains invalid characters", code="invalid-input")
return value
def _validate_remote(repo: Path, remote: str) -> str:
remote = _validate_git_scalar(remote, "remote")
configured = {line.strip() for line in _git(repo, "remote").splitlines() if line.strip()}
if remote not in configured:
raise GitError(f"unknown remote: {remote}", code="invalid-input")
return remote
def _validate_branch(repo: Path, branch: str, *, allow_empty: bool = False) -> str:
branch = _validate_git_scalar(branch, "branch", allow_empty=allow_empty)
if not branch:
return ""
try:
_git(repo, "check-ref-format", "--branch", branch)
except GitStateError as exc:
raise GitError(f"invalid branch: {branch}", code="invalid-input") from exc
return branch
def _validate_revision(repo: Path, ref: str, *, allow_empty: bool = False) -> str:
ref = _validate_git_scalar(ref, "ref", allow_empty=allow_empty)
if not ref:
return ""
try:
_git(repo, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}")
except GitStateError as exc:
raise GitError(f"unknown ref: {ref}", code="invalid-input") from exc
return ref
def _checkout_args(
repo: Path,
ref: str,
*,
new_branch: str,
track: bool,
) -> list[str]:
new_branch = _validate_branch(repo, new_branch, allow_empty=True)
ref = _validate_revision(repo, ref, allow_empty=bool(new_branch))
if track and not ref:
raise GitError("track requires a source ref", code="invalid-input")
if new_branch:
if not ref:
_validate_revision(repo, "HEAD")
returncode, _, error = _run_git_bounded(
repo,
["show-ref", "--verify", "--quiet", f"refs/heads/{new_branch}"],
mutation=False,
)
if returncode == 0:
raise GitError(f"branch already exists: {new_branch}", code="invalid-input")
if returncode != 1:
raise GitError(
f"could not validate branch {new_branch}: {_safe_git_error(error)}",
code="invalid-input",
)
args = ["checkout"]
if track:
args.append("--track")
args.extend(["-b", new_branch])
if ref:
args.append(ref)
return args
args = ["checkout"]
if track:
args.append("--track")
args.append(ref)
return args
def _fresh_mutation_result(
repo: Path,
extra: dict[str, Any] | None = None,
@@ -573,6 +730,7 @@ def commit_selected(repo: Path, message: str, paths: list[str]) -> dict[str, Any
def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
"""Fetch from ``remote`` (default origin) and return fresh status/branches."""
remote = _validate_remote(repo, remote)
_mutate(repo, ["fetch", "--prune", remote])
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
@@ -583,6 +741,8 @@ def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]
Pull never clobbers local work: a tree git refuses to fast-forward without
discarding local changes surfaces as a structured ``dirty`` GitError.
"""
remote = _validate_remote(repo, remote)
branch = _validate_branch(repo, branch, allow_empty=True)
args = ["pull", "--ff-only", remote]
if branch:
args.append(branch)
@@ -610,6 +770,8 @@ def push(
are returned so the UI can reflect ahead/behind after a successful push.
"""
_require_confirmation(confirmation, CONFIRM_PUSH)
remote = _validate_remote(repo, remote)
branch = _validate_branch(repo, branch, allow_empty=True)
args = ["push", remote]
if branch:
args.append(branch)
@@ -629,23 +791,10 @@ def checkout(
A dirty tree switch requires confirmation. Git still refuses to overwrite
conflicting local changes, so there is no data-loss path.
"""
if not ref:
raise GitStateError("ref is required")
if new_branch:
args = ["checkout", "-b", new_branch]
if track:
args.append("--track")
_mutate(repo, args)
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
args = _checkout_args(repo, ref, new_branch=new_branch, track=track)
if _is_dirty(repo):
if _is_dirty(repo) and not new_branch:
_require_confirmation(confirmation, CONFIRM_DIRTY_CHECKOUT)
args = ["checkout"]
if track:
# ``git checkout --track <remote>/<branch>`` creates a local tracking
# branch; only meaningful when the target is a remote-tracking ref.
args.append("--track")
args.append(ref)
_mutate(repo, args)
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
@@ -765,31 +914,35 @@ def stash_checkout(
so there is no data-loss path. ``new_branch``/``track`` mirror the plain
checkout surface.
"""
if not ref:
raise GitStateError("ref is required")
args = _checkout_args(repo, ref, new_branch=new_branch, track=track)
stashed = False
stash_message = ""
stash_oid = ""
if _is_dirty(repo):
stash_message = f"git-state: {ref}"
_mutate(repo, ["stash", "push", "-m", stash_message])
stash_message = f"git-state: {ref or new_branch}"
_mutate(repo, ["stash", "push", "--include-untracked", "-m", stash_message])
stash_oid = _git(repo, "rev-parse", "--verify", "refs/stash").strip()
stashed = True
if new_branch:
args = ["checkout", "-b", new_branch]
if track:
args.append("--track")
try:
_mutate(repo, args)
return {
**{"stashed": stashed, "stash_message": stash_message},
**_fresh_mutation_result(repo, {"branches": repo_branches(repo)}),
}
args = ["checkout"]
if track:
args.append("--track")
args.append(ref)
_mutate(repo, args)
except GitError as checkout_error:
if not stashed:
raise
try:
_mutate(repo, ["stash", "apply", "--index", stash_oid])
except GitError as restore_error:
raise GitError(
f"{checkout_error}; changes remain in stash {stash_oid}; "
f"automatic restore failed: {restore_error}",
code=checkout_error.code,
) from checkout_error
raise GitError(
f"{checkout_error}; working changes were restored and remain backed up "
f"in stash {stash_oid}",
code=checkout_error.code,
) from checkout_error
return {
**{"stashed": stashed, "stash_message": stash_message},
**_fresh_mutation_result(repo, {"branches": repo_branches(repo)}),
+7
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"})
@@ -200,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
@@ -331,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)
+73
View File
@@ -10,6 +10,7 @@ import os
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from plugin import git_state
@@ -45,6 +46,15 @@ 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())
@@ -93,6 +103,26 @@ class GitStateScanTests(unittest.TestCase):
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:
@@ -297,6 +327,35 @@ class GitStateFileTests(unittest.TestCase):
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:
@@ -343,12 +402,26 @@ class GitStateSecurityTests(unittest.TestCase):
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()
+44
View File
@@ -217,8 +217,52 @@ class StashCheckoutTests(_ExtrasBase):
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__":
+27
View File
@@ -200,6 +200,18 @@ class FetchPullPushTests(_MutationBase):
_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
@@ -290,6 +302,10 @@ class FetchPullPushTests(_MutationBase):
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")
@@ -305,6 +321,17 @@ class CheckoutTests(_MutationBase):
)
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")
+5
View File
@@ -84,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])