fix(desktop): harden workspace identity transitions

Co-authored-by: Wrench <911929dfb7f90834d50a0bdf1a24a05989d1078b93cc94edf9ad0db2fba683eb@buzz.block.builderlab.xyz>
Signed-off-by: Wrench <911929dfb7f90834d50a0bdf1a24a05989d1078b93cc94edf9ad0db2fba683eb@buzz.block.builderlab.xyz>
This commit is contained in:
Wrench
2026-08-18 15:10:54 -04:00
parent 61e951af71
commit 634776bb3d
10 changed files with 222 additions and 24 deletions

View File

@@ -2,14 +2,13 @@ use std::{
collections::HashMap,
io::Write,
sync::{
atomic::{AtomicBool, AtomicU16, AtomicU8},
atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8},
Arc, Mutex,
},
};
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, Manager};
#[cfg(feature = "mesh-llm")]
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
@@ -35,6 +34,11 @@ pub struct AppState {
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
/// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex<Option<String>>,
/// Serializes workspace mutation transactions. Each caller also takes a
/// generation ticket before queueing so an older queued apply fails closed
/// rather than running after a newer workspace has won.
pub workspace_apply_lock: AsyncMutex<()>,
pub workspace_apply_generation: AtomicU64,
/// Set during backend setup when managed agents are eligible for launch
/// restore. `apply_workspace` consumes it after installing the workspace
/// relay and identity, so agents never start against the fallback relay.
@@ -206,6 +210,8 @@ pub fn build_app_state() -> AppState {
header across origins (redirect-hop SSRF)",
),
relay_url_override: Mutex::new(None),
workspace_apply_lock: AsyncMutex::new(()),
workspace_apply_generation: AtomicU64::new(0),
managed_agent_restore_pending: AtomicBool::new(false),
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
shutdown_started: AtomicBool::new(false),

View File

@@ -206,9 +206,9 @@ pub(super) async fn start_local_agent_with_preflight(
app: &AppHandle,
state: &AppState,
pubkey: &str,
owner_hex: &str,
allow_fresh_create_start: bool,
expected_relay_url: Option<&str>,
expected_signer_pubkey: Option<&str>,
) -> Result<ManagedAgentSummary, String> {
let record_snapshot = {
let _store_guard = state
@@ -256,6 +256,11 @@ pub(super) async fn start_local_agent_with_preflight(
expected_relay_url,
crate::relay::relay_ws_url_with_override(state),
)?;
// Bind the active owner after the same final await as the relay. A
// same-relay identity replacement during mesh preflight must not release
// the stale preflight owner to spawn.
let workspace_owner =
crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?;
let _store_guard = state
.managed_agents_store_lock
@@ -295,7 +300,7 @@ pub(super) async fn start_local_agent_with_preflight(
app,
record,
&mut runtimes,
Some(owner_hex),
Some(workspace_owner.as_str()),
&workspace_relay_url,
)?;
save_managed_agents(app, &records)?;
@@ -411,10 +416,6 @@ pub async fn create_managed_agent(
);
}
// Snapshot the workspace owner pubkey for the legacy-record auth_tag
// fallback. Computed outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
// ── Phase 1: generate keys (sync lock) ────────────────────────────────────
let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = {
let _store_guard = state
@@ -747,8 +748,7 @@ pub async fn create_managed_agent(
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
let mut spawn_error = None;
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true, None).await
{
match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await {
Ok(agent) => agent,
Err(error) => {
let _store_guard = state
@@ -957,9 +957,9 @@ pub async fn start_managed_agent(
&app,
&state,
&pubkey,
&owner_hex,
false,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)
.await
}

View File

@@ -10,6 +10,23 @@ use crate::managed_agents::{
};
use crate::relay;
const WORKSPACE_APPLY_SUPERSEDED: &str = "workspace apply superseded by a newer request";
fn next_apply_generation(generation: &std::sync::atomic::AtomicU64) -> u64 {
generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
}
fn assert_current_apply_generation(
generation: &std::sync::atomic::AtomicU64,
ticket: u64,
) -> Result<(), String> {
if generation.load(Ordering::Acquire) == ticket {
Ok(())
} else {
Err(WORKSPACE_APPLY_SUPERSEDED.to_string())
}
}
/// Adopt the pre-scoping global retention database's pending rows into `scope`.
///
/// Best-effort: a failure is logged and the boot proceeds. The migration's own
@@ -131,11 +148,18 @@ pub async fn apply_workspace(
agent_managed_profiles: Option<bool>,
app: AppHandle,
) -> Result<(), String> {
let state = app.state::<AppState>();
let apply_generation = next_apply_generation(&state.workspace_apply_generation);
let _apply_guard = state.workspace_apply_lock.lock().await;
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
let restore_app = app.clone();
let apply_app = app.clone();
// Capture the caller's relay before the blocking apply. Reading shared
// state afterward could pick up a newer concurrent community switch.
let profile_reconcile_relay = relay_url.clone();
tokio::task::spawn_blocking(move || {
let app = apply_app;
let state = app.state::<AppState>();
// ── Validate before mutating ──────────────────────────────────────────
@@ -166,6 +190,11 @@ pub async fn apply_workspace(
None => None,
};
// A newer caller may have taken a generation ticket while this apply
// was validating paths off-thread. Fail before the first mutation so
// the stale apply cannot transiently overwrite the newer workspace.
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
// ── Apply all state changes (nothing below can fail) ──────────────────
{
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
@@ -214,6 +243,8 @@ pub async fn apply_workspace(
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
let state = restore_app.state::<AppState>();
super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?;
// The Bumble→Pollen migration may have renamed stopped agents. Reconcile
@@ -316,5 +347,34 @@ pub async fn apply_workspace(
});
}
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicU64;
use super::{assert_current_apply_generation, next_apply_generation};
#[test]
fn newer_workspace_apply_supersedes_older_ticket_before_mutation() {
let generation = AtomicU64::new(0);
let delayed_a = next_apply_generation(&generation);
let fast_b = next_apply_generation(&generation);
let error = assert_current_apply_generation(&generation, delayed_a).unwrap_err();
assert!(error.contains("superseded"), "{error}");
assert_current_apply_generation(&generation, fast_b).unwrap();
}
#[test]
fn sequential_workspace_applies_each_hold_current_ticket() {
let generation = AtomicU64::new(0);
let first = next_apply_generation(&generation);
assert_current_apply_generation(&generation, first).unwrap();
let second = next_apply_generation(&generation);
assert_current_apply_generation(&generation, second).unwrap();
}
}

View File

@@ -87,7 +87,7 @@ pub fn relay_http_base_url(relay_url: &str) -> String {
mod scope;
pub use scope::{
assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope,
ScopedWorkspaceRelay,
bind_expected_signer, ScopedWorkspaceRelay,
};
pub fn relay_api_base_url() -> String {

View File

@@ -88,9 +88,36 @@ pub fn assert_expected_signer(
Ok(())
}
/// A workspace-signer read that has passed the caller-captured identity check.
///
/// The only constructor is [`bind_expected_signer`], so side effects consume
/// the exact owner read that was validated rather than a stale pre-await value.
#[derive(Debug)]
pub struct ScopedWorkspaceSigner(String);
impl ScopedWorkspaceSigner {
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Validate a caller-captured signer against one active-owner read and bind
/// that exact read for the side effect to consume. `None` preserves unscoped
/// callers while still making the owner input single-read.
pub fn bind_expected_signer(
expected_signer_pubkey: Option<&str>,
actual_signer_hex: String,
) -> Result<ScopedWorkspaceSigner, String> {
assert_expected_signer(expected_signer_pubkey, &actual_signer_hex)?;
Ok(ScopedWorkspaceSigner(actual_signer_hex))
}
#[cfg(test)]
mod tests {
use super::{assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope};
use super::{
assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope,
bind_expected_signer,
};
#[test]
fn matching_scope_passes_across_ws_http_normalization() {
@@ -183,6 +210,25 @@ mod tests {
assert!(error.contains("active identity changed"), "{error}");
}
#[test]
fn signer_bind_fails_closed_after_same_relay_identity_switch() {
let captured = nostr::Keys::generate().public_key().to_hex();
let switched = nostr::Keys::generate().public_key().to_hex();
let error = bind_expected_signer(Some(&captured), switched).unwrap_err();
assert!(error.contains("active identity changed"), "{error}");
}
#[test]
fn signer_bind_returns_exact_read_for_scoped_and_unscoped_callers() {
let actual = nostr::Keys::generate().public_key().to_hex();
let scoped = bind_expected_signer(Some(&actual), actual.clone()).unwrap();
assert_eq!(scoped.as_str(), actual);
let unscoped_actual = nostr::Keys::generate().public_key().to_hex();
let unscoped = bind_expected_signer(None, unscoped_actual.clone()).unwrap();
assert_eq!(unscoped.as_str(), unscoped_actual);
}
#[test]
fn absent_signer_preserves_unscoped_sends() {
let hex = nostr::Keys::generate().public_key().to_hex();

View File

@@ -10,6 +10,7 @@ import {
} from "./projectAgentConversation.ts";
import {
clearStoredProjectsAgentConversation,
projectsConversationScope,
readStoredProjectsAgentConversation,
writeStoredProjectsAgentConversation,
} from "./projectAgentConversationStorage.ts";
@@ -51,6 +52,50 @@ globalThis.localStorage = {
beforeEach(() => store.clear());
test("conversation scopes isolate same-relay identities and restore on return", () => {
const relay = "wss://relay.example.com";
const resource = "30617:owner:buzz";
const identityA = "a".repeat(64);
const identityB = "b".repeat(64);
const scopeA = projectsConversationScope(
"detail",
relay,
identityA,
resource,
);
const scopeB = projectsConversationScope(
"detail",
relay,
identityB,
resource,
);
assert.notEqual(scopeA, scopeB);
const pointer = {
agentPubkey: AGENT_PUBKEY,
channelId: EXISTING_DM.id,
opener: OPENER,
};
writeStoredProjectsAgentConversation(scopeA, pointer);
assert.equal(readStoredProjectsAgentConversation(scopeB), null);
assert.deepEqual(readStoredProjectsAgentConversation(scopeA), pointer);
});
test("conversation scopes fail closed without relay, signer, or resource", () => {
assert.equal(
projectsConversationScope("detail", null, SELF_PUBKEY, "repo"),
null,
);
assert.equal(
projectsConversationScope("detail", WORKSPACE_ID, null, "repo"),
null,
);
assert.equal(
projectsConversationScope("detail", WORKSPACE_ID, SELF_PUBKEY, ""),
null,
);
});
test("an existing agent DM is never auto-restored without a stored pointer", () => {
const restored = restoreProjectsAgentConversation({
stored: null,

View File

@@ -1,5 +1,16 @@
const CONVERSATION_STORAGE_PREFIX = "buzz.projects.agentConversation";
/** Builds the identity boundary for Projects conversation pointers and drafts. */
export function projectsConversationScope(
surface: string,
relayUrl: string | null,
signerPubkey: string | null,
resource: string,
): string | null {
if (!relayUrl || !signerPubkey || !resource) return null;
return `${surface}:${relayUrl}:${signerPubkey.toLowerCase()}:${resource}`;
}
/**
* The exact opening prompt of an inline Projects conversation, identified by
* the signed event the relay accepted. `createdAt` alone (epoch seconds)

View File

@@ -16,6 +16,7 @@ import {
import {
clearStoredProjectsAgentConversation,
type ProjectsConversationOpener,
projectsConversationScope,
readStoredProjectsAgentConversation,
type StoredProjectsAgentConversation,
writeStoredProjectsAgentConversation,
@@ -57,6 +58,7 @@ export function ProjectAgentChatPanel({
widthPx: number;
}) {
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
// Repository coordinates (`kind:owner:dtag`) are not globally unique — the
// same address can exist on two relays. Scope persistence, drafts, and
// restore to the community's relay identity so a community switch can
@@ -66,9 +68,15 @@ export function ProjectAgentChatPanel({
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
: null;
const storageScope = relayScope
? `detail:${relayScope}:${context.repoAddress}`
const signerScope = identityQuery.data?.pubkey
? normalizePubkey(identityQuery.data.pubkey)
: null;
const storageScope = projectsConversationScope(
"detail",
relayScope,
signerScope,
context.repoAddress,
);
const [isSending, setIsSending] = React.useState(false);
const [storedConversation, setStoredConversation] =
React.useState<StoredProjectsAgentConversation | null>(() =>
@@ -78,7 +86,6 @@ export function ProjectAgentChatPanel({
React.useState<ProjectAgentConversation | null>(null);
const candidates = useAgentCandidates();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();

View File

@@ -2,6 +2,8 @@ import type * as React from "react";
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import { ProjectAgentChatPanel } from "./ProjectAgentChatPanel";
import { ProjectRepositoryActionsPanel } from "./ProjectRepositoryActionsPanel";
@@ -24,6 +26,7 @@ export function ProjectDetailRightPanel({
sharedHeaderBackdrop?: boolean;
}) {
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
if (mode === "chat") {
// Remount on community identity as well as repository address: the same
// repo coordinate can exist in two communities, and retained panel state
@@ -31,11 +34,14 @@ export function ProjectDetailRightPanel({
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
: "";
const signerScope = identityQuery.data?.pubkey
? normalizePubkey(identityQuery.data.pubkey)
: "";
return (
<ProjectAgentChatPanel
canResetWidth={repositoryProps.canResetWidth}
context={context}
key={`${relayScope}:${context.repoAddress}`}
key={`${relayScope}:${signerScope}:${context.repoAddress}`}
onResetWidth={repositoryProps.onResetWidth}
onResizeStart={repositoryProps.onResizeStart}
sharedHeaderBackdrop={sharedHeaderBackdrop}

View File

@@ -55,6 +55,7 @@ import {
import {
clearStoredProjectsAgentConversation,
type ProjectsConversationOpener,
projectsConversationScope,
readStoredProjectsAgentConversation,
type StoredProjectsAgentConversation,
writeStoredProjectsAgentConversation,
@@ -363,9 +364,7 @@ export function ProjectsAgentPromptPage({
}) {
const [prompt, setPrompt] = React.useState("");
const [storedConversation, setStoredConversation] =
React.useState<StoredProjectsAgentConversation | null>(() =>
readStoredProjectsAgentConversation(workspaceId),
);
React.useState<StoredProjectsAgentConversation | null>(null);
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
() => storedConversation?.agentPubkey ?? null,
);
@@ -396,6 +395,15 @@ export function ProjectsAgentPromptPage({
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
: null;
const signerScope = identityQuery.data?.pubkey
? normalizePubkey(identityQuery.data.pubkey)
: null;
const scopedWorkspaceId = projectsConversationScope(
"workspace",
relayScope,
signerScope,
workspaceId ?? "",
);
const candidatePubkeys = React.useMemo(
() => candidates.map((candidate) => candidate.pubkey),
@@ -454,6 +462,15 @@ export function ProjectsAgentPromptPage({
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
onLinkShortcutRef.current = linkEditor.openFromShortcut;
React.useEffect(() => {
const stored = readStoredProjectsAgentConversation(scopedWorkspaceId);
setStoredConversation(stored);
setConversation(null);
setSelectedPubkey(stored?.agentPubkey ?? null);
setPrompt("");
richText.clearContent();
}, [richText.clearContent, scopedWorkspaceId]);
React.useEffect(() => {
if (!richText.editor) return;
const frame = window.requestAnimationFrame(richText.focusPreserve);
@@ -532,7 +549,7 @@ export function ProjectsAgentPromptPage({
};
setConversation(nextConversation);
setStoredConversation(stored);
writeStoredProjectsAgentConversation(workspaceId, stored);
writeStoredProjectsAgentConversation(scopedWorkspaceId, stored);
}
setPrompt("");
richText.clearContent();
@@ -554,20 +571,20 @@ export function ProjectsAgentPromptPage({
richText.getMarkdown,
selectedAgent,
startAgentMutation,
workspaceId,
scopedWorkspaceId,
]);
submitPromptRef.current = () => {
void handleSubmit();
};
const handleClearConversation = React.useCallback(() => {
clearStoredProjectsAgentConversation(workspaceId);
clearStoredProjectsAgentConversation(scopedWorkspaceId);
setStoredConversation(null);
setConversation(null);
setSelectedPubkey(null);
setPrompt("");
richText.clearContent();
}, [richText.clearContent, workspaceId]);
}, [richText.clearContent, scopedWorkspaceId]);
const promptBox = (
<>