From 4d40b6e5bb032f2c0755127172c50dee213f65a3 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 18:48:41 -0600 Subject: [PATCH 1/6] fix(desktop): restore release agent mentions Keep OSS relay discovery governed by shared channel policy while restricting owner-only builds to cryptographically verified same-owner relay agents. Remove the replay-amplifying remote policy subscription and retain focused polling plus send-time revalidation. Co-authored-by: Carl Signed-off-by: Wes --- .../agent_discovery/relay_directory.rs | 48 +++++- desktop/src/features/agents/AGENTS.md | 12 ++ .../lib/agentAutocompleteEligibility.test.mjs | 18 ++ .../lib/agentAutocompleteEligibility.ts | 18 +- .../agents/lib/useAgentsDataRefresh.test.mjs | 106 ++---------- .../agents/lib/useAgentsDataRefresh.ts | 161 ++---------------- desktop/src/testing/e2eBridge.ts | 3 + desktop/tests/e2e/mentions.spec.ts | 30 ++++ 8 files changed, 151 insertions(+), 245 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index b97cefc0ee8..a4da9c475d9 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -77,10 +77,22 @@ async fn query_all_relay_pages( } } +fn owner_only_relay_directory() -> bool { + crate::managed_agents::owner_only_access_build() +} + +fn retain_verified_owner( + verified_owners: &mut std::collections::HashMap, + required_owner: &str, +) { + verified_owners.retain(|_, owner| owner.eq_ignore_ascii_case(required_owner)); +} + pub(crate) async fn list_relay_agents_for_state( state: &AppState, ) -> Result, String> { let viewer_pubkey = current_user_pubkey(state)?; + let owner_only = owner_only_relay_directory(); let relay_pubkey = identity_archive::fetch_relay_self(state) .await? .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; @@ -128,7 +140,14 @@ pub(crate) async fn list_relay_agents_for_state( // query. Each exact `(owner, d=agent)` filter returns at most one current // replaceable event, so forged 30177 coordinates cannot amplify or crowd // the authentic policy out of a bounded result page. - let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); + let mut verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); + // The internal capability narrows the remote directory to cryptographically + // verified agents owned by the active user. Same-owner siblings remain + // mentionable because they are inside the harness's owner-only boundary; + // all cross-owner coordinates are discarded before policy lookup. + if owner_only { + retain_verified_owner(&mut verified_owners, &viewer_pubkey); + } let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); let mut managed_agent_events = Vec::new(); for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { @@ -144,6 +163,14 @@ pub(crate) async fn list_relay_agents_for_state( &managed_agent_events, &profile_events, ); + if owner_only { + agents.retain(|agent| { + agent + .owner_pubkey + .as_deref() + .is_some_and(|owner| owner.eq_ignore_ascii_case(&viewer_pubkey)) + }); + } agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); for agent in &mut agents { agent.channel_ids = member_agent_channel_ids @@ -163,6 +190,25 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result { + assert.equal( + relayAgentIsSharedWithUser( + { + ownerPubkey: CURRENT_PUBKEY.toUpperCase(), + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }, + new Set(["general"]), + CURRENT_PUBKEY, + ), + true, + ); +}); + test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => { const sharedChannelIds = new Set(["general"]); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index a4b235fa04c..516520e2ca3 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -10,7 +10,10 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { } export function relayAgentIsSharedWithUser( - agent: Pick, + agent: Pick< + RelayAgent, + "channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist" + >, sharedChannelIds: ReadonlySet, currentPubkey?: string | null, ) { @@ -18,6 +21,14 @@ export function relayAgentIsSharedWithUser( ? normalizePubkey(currentPubkey) : null; + if ( + agent.respondTo === "owner-only" && + normalizedCurrentPubkey && + agent.ownerPubkey + ) { + return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey; + } + if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { return agent.respondToAllowlist .map((pubkey) => normalizePubkey(pubkey)) @@ -31,7 +42,10 @@ export function relayAgentIsSharedWithUser( } export function relayAgentCanRespondInChannel( - agent: Pick, + agent: Pick< + RelayAgent, + "channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist" + >, channelId: string, currentPubkey?: string | null, ) { diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs index 7a3643a3088..a836f9c7dfc 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs @@ -1,101 +1,17 @@ import assert from "node:assert/strict"; -import test, { mock } from "node:test"; +import test from "node:test"; -import { relayClient } from "@/shared/api/relayClient"; -import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; -import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts"; +import { relayAgentsQueryKey } from "@/features/agents/hooks"; +import { LOCAL_AGENT_DATA_QUERY_KEYS } from "./useAgentsDataRefresh.ts"; -const coordinates = [ - { ownerPubkey: "owner-a", agentPubkey: "agent-a" }, - { ownerPubkey: "owner-b", agentPubkey: "agent-b" }, -]; +const serializedLocalKeys = LOCAL_AGENT_DATA_QUERY_KEYS.map((key) => + JSON.stringify(key), +); -function event(pubkey, dTag) { - return { - id: "id", - pubkey, - created_at: 1, - kind: KIND_MANAGED_AGENT, - tags: dTag ? [["d", dTag]] : [], - content: "{}", - sig: "sig", - }; -} - -test("remote managed policy refresh accepts only exact authenticated coordinates", async () => { - let onEvent; - let filter; - let unsubscribeCalls = 0; - mock.method(relayClient, "subscribeLive", (nextFilter, listener) => { - filter = nextFilter; - onEvent = listener; - return Promise.resolve(() => { - unsubscribeCalls += 1; - return Promise.resolve(); - }); - }); - - let refreshes = 0; - const stop = startRelayAgentPolicyRefresh(coordinates, () => { - refreshes += 1; - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.deepEqual(filter, { - kinds: [KIND_MANAGED_AGENT], - authors: ["owner-a", "owner-b"], - "#d": ["agent-a", "agent-b"], - limit: 0, - }); - onEvent(event("owner-a", "agent-a")); - assert.equal(refreshes, 1); - - for (const irrelevant of [ - event("owner-x", "agent-a"), - event("owner-a", "agent-x"), - event("owner-a", "agent-b"), // authors×d cross-product - event("owner-a", null), - ]) { - onEvent(irrelevant); - } - assert.equal(refreshes, 1, "irrelevant coordinates must not refresh"); - - stop(); - assert.equal(unsubscribeCalls, 1); - mock.reset(); -}); - -test("stopping before subscription readiness still closes the live query", async () => { - let resolveSubscription; - let unsubscribeCalls = 0; - mock.method( - relayClient, - "subscribeLive", - () => - new Promise((resolve) => { - resolveSubscription = resolve; - }), +test("local agent refresh never invalidates the relay directory", () => { + assert.equal( + serializedLocalKeys.includes(JSON.stringify(relayAgentsQueryKey)), + false, + "local reconciliation must not trigger a relay-wide directory rebuild", ); - - const stop = startRelayAgentPolicyRefresh(coordinates, () => {}); - stop(); - resolveSubscription(() => { - unsubscribeCalls += 1; - return Promise.resolve(); - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(unsubscribeCalls, 1); - mock.reset(); -}); - -test("no authenticated coordinates creates no global subscription", () => { - let subscriptions = 0; - mock.method(relayClient, "subscribeLive", () => { - subscriptions += 1; - return Promise.resolve(() => Promise.resolve()); - }); - startRelayAgentPolicyRefresh([], () => {})(); - assert.equal(subscriptions, 0); - mock.reset(); }); diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 0349618d114..b086f12a9c4 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -2,94 +2,25 @@ import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { relayClient } from "@/shared/api/relayClient"; -import type { RelayAgent, RelayEvent } from "@/shared/api/types"; -import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; import { managedAgentsQueryKey, personasQueryKey, - relayAgentsQueryKey, teamsQueryKey, } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; -const COALESCE_MS = 200; -export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000; - -export type RelayAgentPolicyCoordinate = { - agentPubkey: string; - ownerPubkey: string; -}; - -function eventDTag(event: RelayEvent): string | null { - return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; -} - -/** - * Subscribe only to authenticated managed-agent coordinates already returned by - * the relay directory. The callback repeats the exact owner+d check because a - * combined Nostr filter admits the authors×d cross-product. - */ -export function startRelayAgentPolicyRefresh( - coordinates: RelayAgentPolicyCoordinate[], - onChange: () => void, - onError: (error: unknown) => void = (error) => { - console.warn("Couldn’t subscribe to managed agent policy updates", error); - }, -): () => void { - if (coordinates.length === 0) return () => {}; - - const allowed = new Set( - coordinates.map( - ({ ownerPubkey, agentPubkey }) => - `${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`, - ), - ); - const authors = [ - ...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)), - ]; - const agentPubkeys = [ - ...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)), - ]; - let disposed = false; - let unsubscribe: (() => Promise) | null = null; - void relayClient - .subscribeLive( - { - kinds: [KIND_MANAGED_AGENT], - authors, - "#d": agentPubkeys, - limit: 0, - }, - (event) => { - const dTag = eventDTag(event); - if ( - dTag && - allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) - ) { - onChange(); - } - }, - ) - .then((nextUnsubscribe) => { - if (disposed) void nextUnsubscribe(); - else unsubscribe = nextUnsubscribe; - }) - .catch(onError); - - return () => { - disposed = true; - void unsubscribe?.(); - }; -} +export const LOCAL_AGENT_DATA_QUERY_KEYS = [ + personasQueryKey, + teamsQueryKey, + managedAgentsQueryKey, +] as const; -function relayPolicyCoordinates(agents: RelayAgent[] | undefined) { - return (agents ?? []).flatMap((agent) => - agent.ownerPubkey - ? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }] - : [], - ); -} +// Trailing-coalesce local agent-store bursts into one cache refresh. The relay +// directory is deliberately excluded: local persona/team/agent reconciliation +// cannot change remote directory records, and rebuilding that directory is a +// relay-wide operation. Remote data keeps its focused poll and is revalidated +// directly before an agent mention is sent. +const COALESCE_MS = 200; export function useAgentsDataRefresh(): void { const queryClient = useQueryClient(); @@ -107,80 +38,16 @@ export function useAgentsDataRefresh(): void { const unlisten = listen("agents-data-changed", () => { if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { - void queryClient.invalidateQueries({ queryKey: personasQueryKey }); - void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); - void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); - void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + for (const queryKey of LOCAL_AGENT_DATA_QUERY_KEYS) { + void queryClient.invalidateQueries({ queryKey }); + } }, COALESCE_MS); }); - let policyStop = () => {}; - let policyTimer: ReturnType | undefined; - let policyDirty = false; - let policyRefreshInFlight = false; - let policyDisposed = false; - let coordinateKey = ""; - - const refreshPolicyDirectory = () => { - if (policyRefreshInFlight || policyTimer !== undefined) { - policyDirty = true; - return; - } - policyRefreshInFlight = true; - void queryClient - .invalidateQueries({ queryKey: relayAgentsQueryKey }) - .finally(() => { - policyRefreshInFlight = false; - if (policyDisposed) return; - policyTimer = setTimeout(() => { - policyTimer = undefined; - if (policyDirty) { - policyDirty = false; - refreshPolicyDirectory(); - } - }, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS); - }); - }; - - const resubscribePolicy = () => { - const coordinates = relayPolicyCoordinates( - queryClient.getQueryData(relayAgentsQueryKey), - ); - const nextKey = coordinates - .map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`) - .sort() - .join("|"); - if (nextKey === coordinateKey) return; - coordinateKey = nextKey; - policyStop(); - policyStop = startRelayAgentPolicyRefresh( - coordinates, - refreshPolicyDirectory, - ); - }; - resubscribePolicy(); - const unsubscribeQueryCache = queryClient - .getQueryCache() - .subscribe((event) => { - if ( - event.query.queryKey.length === relayAgentsQueryKey.length && - event.query.queryKey.every( - (value: unknown, index: number) => - value === relayAgentsQueryKey[index], - ) - ) { - resubscribePolicy(); - } - }); - return () => { - policyDisposed = true; if (timer !== undefined) clearTimeout(timer); - if (policyTimer !== undefined) clearTimeout(policyTimer); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); - unsubscribeQueryCache(); - policyStop(); }; }, [queryClient]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4215d183ac2..1239f9522a3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -113,6 +113,7 @@ type MockManagedAgentRuntimeSeed = { type MockRelayAgentSeed = { pubkey: string; + ownerPubkey?: string | null; name: string; agentType?: string; capabilities?: string[]; @@ -851,6 +852,7 @@ type RawSendChannelMessageResponse = { type RawRelayAgent = { pubkey: string; + owner_pubkey?: string | null; name: string; agent_type: string; channels: string[]; @@ -2324,6 +2326,7 @@ function resetMockRelayAgents(config?: E2eConfig) { }); mockRelayAgents.push({ pubkey: seed.pubkey, + owner_pubkey: seed.ownerPubkey ?? null, name: seed.name, agent_type: seed.agentType ?? "goose", channels: channels.map((channel) => channel.name), diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 4efd6dd7a3f..5562920a349 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1454,6 +1454,36 @@ test("owner-only builds hide other-owned relay agents", async ({ page }) => { await expect(autocomplete(page)).toHaveCount(0); }); +test("owner-only builds show verified same-owner relay agents", async ({ + page, +}) => { + await installMockBridge(page, { + ownerOnlyAccessBuild: true, + searchProfiles: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + displayName: "quinn", + ownerPubkey: MOCK_VIEWER_PUBKEY, + isAgent: true, + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + ownerPubkey: MOCK_VIEWER_PUBKEY, + name: "quinn", + respondTo: "owner-only", + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill("@quinn"); + + await expect(autocomplete(page).getByText("quinn")).toBeVisible(); +}); + test("relay-only allowlisted agents stay hidden outside their channel", async ({ page, }) => { From 8183a5995ad80f309d146f791c9907a2131c10ab Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 19:00:57 -0600 Subject: [PATCH 2/6] fix(desktop): coalesce startup agent policy history Collapse managed-agent startup backfill events to the final NIP-33 head per owner and agent coordinate before reconciliation. This prevents historical policy revisions from stopping and restarting the same runtime repeatedly while preserving serialized handling for live policy changes. Co-authored-by: Carl Signed-off-by: Wes --- .../agents/lib/usePersonaSync.test.mjs | 52 ++++++++++++++++++- .../src/features/agents/lib/usePersonaSync.ts | 45 +++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a67b68cc7fd..cfc0d901c3a 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -8,7 +8,10 @@ import { KIND_PERSONA, KIND_TEAM, } from "@/shared/constants/kinds"; -import { startPersonaSync } from "./usePersonaSync.ts"; +import { + coalesceManagedAgentBackfill, + startPersonaSync, +} from "./usePersonaSync.ts"; const EXPECTED_KINDS = [ KIND_PERSONA, @@ -17,6 +20,53 @@ const EXPECTED_KINDS = [ KIND_DELETION, ]; +function event({ + id, + kind = KIND_MANAGED_AGENT, + createdAt, + pubkey = "owner-pubkey", + dTag = "agent-pubkey", +}) { + return { + id, + pubkey, + created_at: createdAt, + kind, + tags: dTag ? [["d", dTag]] : [], + content: "{}", + sig: "sig", + }; +} + +test("startup backfill keeps only the newest managed-agent head per coordinate", () => { + const persona = event({ + id: "persona", + kind: KIND_PERSONA, + createdAt: 1, + dTag: "persona-id", + }); + const otherAgent = event({ + id: "other-agent", + createdAt: 2, + dTag: "other-agent", + }); + const oldest = event({ id: "oldest", createdAt: 1 }); + const sameSecondLoser = event({ id: "f", createdAt: 3 }); + const newest = event({ id: "a", createdAt: 3 }); + + assert.deepEqual( + coalesceManagedAgentBackfill([ + oldest, + persona, + newest, + otherAgent, + sameSecondLoser, + ]).map(({ id }) => id), + ["persona", "a", "other-agent"], + "NIP-33 uses newest created_at and lowest id on a tie", + ); +}); + // Regression guard for the fresh-start backfill gap (F3): a device that comes // online AFTER another published gets zero history from a live-only `limit: 0` // subscription, because reconnect-replay's since-cursor is undefined until the diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index 66ed679ad95..57d33089a9b 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -20,6 +20,48 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; +function eventDTag(event: RelayEvent): string | null { + return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; +} + +function eventIsNewer(candidate: RelayEvent, current: RelayEvent): boolean { + return ( + candidate.created_at > current.created_at || + (candidate.created_at === current.created_at && candidate.id < current.id) + ); +} + +/** + * Keep only the NIP-33 head for each managed-agent coordinate in a startup + * backfill. Applying historical policy revisions one by one can stop and start + * the same runtime for every revision; the retained store only needs the final + * head. Other event kinds stay in relay order because persona/team projections + * do not trigger runtime policy transitions and deletion ordering is separate. + */ +export function coalesceManagedAgentBackfill( + events: readonly RelayEvent[], +): RelayEvent[] { + const heads = new Map(); + + for (const event of events) { + if (event.kind !== KIND_MANAGED_AGENT) continue; + const dTag = eventDTag(event); + if (!dTag) continue; + const coordinate = `${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`; + const current = heads.get(coordinate); + if (!current || eventIsNewer(event, current)) heads.set(coordinate, event); + } + + return events.filter((event) => { + if (event.kind !== KIND_MANAGED_AGENT) return true; + const dTag = eventDTag(event); + if (!dTag) return true; + return ( + heads.get(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) === event + ); + }); +} + // Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: // one-shot backfill of existing heads + tombstones, then a live subscription. // Returns a disposer that closes the live subscription. Extracted from the hook @@ -56,7 +98,8 @@ export function startPersonaSync( .fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 }) .then((events) => { if (onCancelled()) return; - for (const event of events) reconcile(event); + for (const event of coalesceManagedAgentBackfill(events)) + reconcile(event); }) .catch((error) => { console.warn("[usePersonaSync] backfill failed:", error); From 9e6a320b678e2d60088c9110394485b08f1514c3 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 19:07:11 -0600 Subject: [PATCH 3/6] fix(desktop): compare effective inbound agent access Keep raw managed-agent policy portable across build modes, but compare the effective projected gate before deciding to restart a runtime. Owner-only builds project both old and inbound policy to owner-only, so byte-level policy drift must not churn the agent fleet during startup. Co-authored-by: Carl Signed-off-by: Wes --- .../src/commands/agent_models_tests.rs | 17 +++++++++++++++++ .../src/commands/agent_models_update.rs | 9 +++++++++ .../src-tauri/src/commands/personas/inbound.rs | 1 + 3 files changed, 27 insertions(+) diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index c00cb5cd2d9..a9e3b677753 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -12,18 +12,35 @@ fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() { &[], RespondTo::OwnerOnly, &[], + false, )); assert!(managed_agent_access_policy_changed( RespondTo::Allowlist, &allowlist_a, RespondTo::Allowlist, &allowlist_b, + false, )); assert!(!managed_agent_access_policy_changed( RespondTo::OwnerOnly, &allowlist_a, RespondTo::OwnerOnly, &allowlist_b, + false, + )); + assert!(!managed_agent_access_policy_changed( + RespondTo::Anyone, + &[], + RespondTo::OwnerOnly, + &[], + true, + )); + assert!(!managed_agent_access_policy_changed( + RespondTo::Allowlist, + &allowlist_a, + RespondTo::Allowlist, + &allowlist_b, + true, )); } diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 922bee2e3e0..5f91622b44b 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -5,7 +5,15 @@ pub(crate) fn managed_agent_access_policy_changed( current_allowlist: &[String], prospective_mode: crate::managed_agents::RespondTo, prospective_allowlist: &[String], + enforced_owner_only: bool, ) -> bool { + // Stored policy remains portable across OSS and owner-only builds, but a + // marked build always projects both states to the same owner-only runtime + // gate. Do not restart a fleet merely because relay state differs in bytes + // that this build cannot execute. + if enforced_owner_only { + return false; + } prospective_mode != current_mode || (prospective_mode == crate::managed_agents::RespondTo::Allowlist && prospective_allowlist != current_allowlist) @@ -169,6 +177,7 @@ pub async fn update_managed_agent( &record.respond_to_allowlist, prospective_mode, &prospective_allowlist, + crate::managed_agents::owner_only_access_build(), ); ensure_access_policy_change_supported(record, access_policy_changed)?; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 42f720915a3..c322e6cb6e8 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -585,6 +585,7 @@ fn apply_inbound_managed_agent( &previous_allowlist, local.respond_to, &local.respond_to_allowlist, + crate::managed_agents::owner_only_access_build(), ); } false From 6a29a24306a28b659299257162124ed5be730d7b Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 19:16:45 -0600 Subject: [PATCH 4/6] fix(desktop): snapshot enforced agent access Project portable respond-to policy through the compiled owner-only capability when stamping and comparing managed-agent spawn configuration. This prevents delayed auto-restarts for raw policy edits that do not change the effective release gate while preserving OSS restart detection. Co-authored-by: Carl Signed-off-by: Wes --- .../src-tauri/src/managed_agents/runtime.rs | 2 + .../src/managed_agents/runtime/tests.rs | 1 + .../src/managed_agents/spawn_snapshot.rs | 26 +++-- .../managed_agents/spawn_snapshot/tests.rs | 99 ++++++++++++++++++- .../src-tauri/src/migration/backfill_tests.rs | 4 + 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e9955..865d31da1ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -252,6 +252,7 @@ pub fn build_managed_agent_summary( &teams, &key.relay_url, global_config, + super::owner_only_access_build(), ); (runtime, current) }); @@ -857,6 +858,7 @@ pub fn spawn_agent_child( system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), + enforced_owner_only: super::owner_only_access_build(), }, ); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 762b0fe2a61..f0c771af002 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1263,6 +1263,7 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun &[], "wss://relay.example", &Default::default(), + false, ), setup_mode: false, adapter_availability: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index ba2129c9841..357f5f1e26d 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -72,6 +72,9 @@ pub(crate) struct SpawnConfigInputs<'a> { pub system_prompt: Option<&'a str>, pub model: Option<&'a str>, pub provider: Option<&'a str>, + /// Compile-time distribution capability projected at this runtime boundary. + /// The stored record remains portable; only effective spawned access is stamped. + pub enforced_owner_only: bool, } /// The effective spawn configuration of one managed-agent process. @@ -136,7 +139,10 @@ impl SpawnConfigSnapshot { system_prompt, model, provider, + enforced_owner_only, } = inputs; + let (respond_to, respond_to_allowlist) = + super::projected_access_with_policy(record, enforced_owner_only); Self { acp_command: record.acp_command.clone(), command: descriptor.command.clone(), @@ -155,16 +161,14 @@ impl SpawnConfigSnapshot { .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) .flatten(), auth_tag: record.auth_tag.clone(), - respond_to: record.respond_to.as_str().to_string(), - respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then( - || { - // A list spawn would reject is captured raw: the stamped - // snapshot comes from a successful spawn, so any invalid - // edit correctly compares unequal. - super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) - .unwrap_or_else(|_| record.respond_to_allowlist.clone()) - }, - ), + respond_to: respond_to.as_str().to_string(), + respond_to_allowlist: (respond_to == super::types::RespondTo::Allowlist).then(|| { + // A list spawn would reject is captured raw: the stamped + // snapshot comes from a successful spawn, so any invalid + // edit correctly compares unequal. + super::types::validate_respond_to_allowlist(&respond_to_allowlist) + .unwrap_or(respond_to_allowlist) + }), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, // Hash the effective parallelism so over-cap edits that don't change @@ -213,6 +217,7 @@ pub(crate) fn prospective_spawn_config_snapshot( teams: &[TeamRecord], workspace_relay: &str, global: &GlobalAgentConfig, + enforced_owner_only: bool, ) -> SpawnConfigSnapshot { // Prospective re-snapshot: apply the same `apply_persona_snapshot` the // start/restore paths run right before spawning, so this describes what a @@ -262,6 +267,7 @@ pub(crate) fn prospective_spawn_config_snapshot( system_prompt: prompt.as_deref(), model: model.as_deref(), provider: provider.as_deref(), + enforced_owner_only, }) } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 20e02871eba..89bba15cee4 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -5,6 +5,25 @@ use std::collections::BTreeMap; /// Canonical projection of a prospective snapshot — the exact value the drift /// comparison reads, so these tests assert on drift itself rather than on a /// proxy for it. +fn snapshot_with_policy( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, + enforced_owner_only: bool, +) -> serde_json::Value { + prospective_spawn_config_snapshot( + record, + personas, + teams, + workspace_relay, + global, + enforced_owner_only, + ) + .canonical() +} + fn snapshot( record: &ManagedAgentRecord, personas: &[AgentDefinition], @@ -12,7 +31,7 @@ fn snapshot( workspace_relay: &str, global: &GlobalAgentConfig, ) -> serde_json::Value { - prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical() + snapshot_with_policy(record, personas, teams, workspace_relay, global, false) } fn record() -> ManagedAgentRecord { @@ -225,6 +244,84 @@ fn stored_record_relay_does_not_affect_snapshot() { ); } +#[test] +fn owner_only_mode_and_allowlist_edits_do_not_change_effective_snapshot() { + let mut before = record(); + before.respond_to = RespondTo::Allowlist; + before.respond_to_allowlist = vec!["a".repeat(64)]; + + let mut mode_edited = before.clone(); + mode_edited.respond_to = RespondTo::Anyone; + + let mut allowlist_edited = before.clone(); + allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)]; + + let effective_before = snapshot_with_policy( + &before, + &[], + &[], + "wss://ws.example", + &Default::default(), + true, + ); + for (label, edited) in [ + ("respond-to mode", mode_edited), + ("respond-to allowlist", allowlist_edited), + ] { + assert_eq!( + effective_before, + snapshot_with_policy( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + true, + ), + "portable {label} edit must not create restart drift when both spawns enforce owner-only", + ); + } +} + +#[test] +fn oss_mode_and_allowlist_edits_change_effective_snapshot() { + let mut before = record(); + before.respond_to = RespondTo::Allowlist; + before.respond_to_allowlist = vec!["a".repeat(64)]; + + let mut mode_edited = before.clone(); + mode_edited.respond_to = RespondTo::Anyone; + + let mut allowlist_edited = before.clone(); + allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)]; + + let effective_before = snapshot_with_policy( + &before, + &[], + &[], + "wss://ws.example", + &Default::default(), + false, + ); + for (label, edited) in [ + ("respond-to mode", mode_edited), + ("respond-to allowlist", allowlist_edited), + ] { + assert_ne!( + effective_before, + snapshot_with_policy( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + false, + ), + "OSS spawn must retain restart drift for effective {label} edits", + ); + } +} + #[test] fn respond_to_allowlist_edit_changes_snapshot() { let rec = record(); diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index d277a2aa5fc..754a40769c1 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -137,6 +137,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -153,6 +154,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); assert_eq!( @@ -187,6 +189,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -203,6 +206,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); assert_eq!(before.canonical(), after.canonical()); From e818b3db2cfe0bb9fb43c19b9d18ac32cc6bc600 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 19:31:15 -0600 Subject: [PATCH 5/6] fix(desktop): keep runtime test under size ratchet Use the inferred path default and remove a spacer so the touched runtime test stays below the desktop file-size ceiling. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/managed_agents/runtime/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index f0c771af002..edb4fad422e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1239,7 +1239,6 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun use std::process::{Command, Stdio}; // Spawn a real child so ManagedAgentProcess's Child field is satisfied. // `true` exits immediately with 0 — just a handle we need for type purposes. - // // Absolute `/usr/bin/true` on unix (present on both macOS and Linux): // parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a // bare `true` lookup during that window fails with NotFound (observed @@ -1256,7 +1255,7 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun .expect("spawn true for placeholder"); let process = crate::managed_agents::ManagedAgentProcess { child, - log_path: std::path::PathBuf::new(), + log_path: Default::default(), spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( &minimal_record(&"cc".repeat(32)), &[], From b07ea0b6bd4182fa6797d3e1d8adf650309bb88d Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 20:24:54 -0600 Subject: [PATCH 6/6] test(desktop): honor compiled access policy The inbound secret-preservation test also asserts whether a portable policy change restarts the runtime. Match that assertion to the compiled policy: OSS refreshes for owner-only to anyone, while owner-only builds correctly do not restart for an effective no-op. Co-authored-by: Carl Signed-off-by: Wes --- .../src/commands/personas/inbound/inbound_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index c7235bd034a..c0526222151 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -265,7 +265,11 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() { let mut agents = vec![local_agent()]; let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); - assert!(access_changed, "Anyone must trigger a runtime refresh"); + assert_eq!( + access_changed, + !crate::managed_agents::owner_only_access_build(), + "only an effective access change may trigger a runtime refresh" + ); let a = &agents[0]; // Secrets / harness / runtime — every one preserved from the local record. assert_eq!(