diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c571472a36b..1e497360359 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -78,6 +78,7 @@ export default defineConfig({ "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", "**/mention-spacing.spec.ts", + "**/cloud-provenance.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 274760725f7..ff8df71cd30 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -216,6 +216,11 @@ with a TypeScript lookup table or an id comparison in a component. Unqueried persona siblings are unknown. No presence state grants deletion or Stop authority; native local stop-before-remove remains independent. See [the availability contract](../../../../docs/agent-availability.md). + The shared cloud marker means “Not managed on this device” only + after ownership and successful local inventory are known. It does not imply + hosting location, availability, or permission. Keep all identity surfaces on + the shared provenance context, without per-row directory subscriptions. See + [the provenance contract](../../../../docs/agent-management-provenance.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in diff --git a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs index f57c7f8154f..a45ddb5e64c 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs +++ b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isOtherSetupAgent } from "./otherSetupAgent.ts"; +import { + isOtherSetupAgent, + isOwnedAgentNotManagedOnDevice, +} from "./otherSetupAgent.ts"; const OWNER = "a".repeat(64); const AGENT = "b".repeat(64); @@ -20,7 +23,7 @@ test("fails closed while the local managed directory is unresolved", () => { ); }); -test("labels a viewer-owned non-local identity as another setup", () => { +test("labels a viewer-owned identity as not managed on this device", () => { assert.equal( isOtherSetupAgent({ agentDirectoriesReady: true, @@ -33,3 +36,38 @@ test("labels a viewer-owned non-local identity as another setup", () => { true, ); }); + +test("a locally managed provider is not labeled as another device", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: true, + currentPubkey: OWNER, + managedAgents: [{ pubkey: AGENT, backend: { type: "provider" } }], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + false, + ); +}); + +for (const [name, overrides, expected] of [ + ["owned absent key", {}, true], + ["loading local inventory", { localInventoryReady: false }, false], + ["exact local provider record", { isLocallyManaged: true }, false], + ["different owner", { ownerPubkey: "b".repeat(64) }, false], + ["unknown ownership", { ownerPubkey: null }, false], +]) { + test(`shared provenance: ${name}`, () => { + assert.equal( + isOwnedAgentNotManagedOnDevice({ + currentPubkey: "a".repeat(64), + ownerPubkey: "A".repeat(64), + localInventoryReady: true, + isLocallyManaged: false, + ...overrides, + }), + expected, + ); + }); +} diff --git a/desktop/src/features/agents/lib/otherSetupAgent.ts b/desktop/src/features/agents/lib/otherSetupAgent.ts index 63438a983fd..f94215e1f3a 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.ts +++ b/desktop/src/features/agents/lib/otherSetupAgent.ts @@ -1,6 +1,7 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +/** Owned identity absent from the loaded local inventory; not evidence of hosting location. */ export function isOtherSetupAgent({ agentDirectoriesReady, currentPubkey, @@ -32,8 +33,31 @@ export function isOtherSetupAgent({ )?.ownerPubkey; const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey; + return isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady: agentDirectoriesReady, + isLocallyManaged: false, + }); +} + +/** Presentation provenance only; neither hosting location nor availability. */ +export function isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady, + isLocallyManaged, +}: { + currentPubkey?: string; + ownerPubkey?: string | null; + localInventoryReady: boolean; + isLocallyManaged: boolean; +}): boolean { return Boolean( - ownerPubkey && + localInventoryReady && + !isLocallyManaged && + currentPubkey && + ownerPubkey && normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), ); } diff --git a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx index 19ef31bd75b..11b7eeed3b0 100644 --- a/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx +++ b/desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx @@ -1,8 +1,10 @@ import { Cloud } from "lucide-react"; +import { useIsOtherSetupAgent } from "../useKnownAgentPubkeys"; + import { cn } from "@/shared/lib/cn"; -const OTHER_SETUP_LABEL = "From another Buzz setup"; +const OTHER_SETUP_LABEL = "Not managed on this device"; export function OtherSetupAgentMarker({ className, @@ -23,3 +25,21 @@ export function OtherSetupAgentMarker({ ); } + +/** Connected marker for identity details; shares the app's directory subscriptions. */ +export function AgentManagementMarker({ + pubkey, + ownerPubkey, + className, + testId, +}: { + pubkey?: string | null; + ownerPubkey?: string | null; + className?: string; + testId?: string; +}) { + const show = useIsOtherSetupAgent(pubkey, ownerPubkey); + return show ? ( + + ) : null; +} diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs b/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs new file mode 100644 index 00000000000..7c2c82f0bfc --- /dev/null +++ b/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import { createElement } from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + KnownAgentPubkeysProvider, + useIsOtherSetupAgent, +} from "./useKnownAgentPubkeys.tsx"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}); +after(() => dom.window.close()); + +test("provenance context follows exact local inventory and rejects failed cached reads", async () => { + const { act, renderHook, cleanup } = await import("@testing-library/react"); + const owner = "a".repeat(64), + remote = "b".repeat(64), + local = "c".repeat(64); + const client = new QueryClient({ + defaultOptions: { + queries: { enabled: false, retry: false, staleTime: Infinity }, + }, + }); + client.setQueryData(["identity"], { pubkey: owner }); + client.setQueryData( + ["managed-agents"], + [{ pubkey: local, status: "stopped" }], + ); + client.setQueryData( + ["relay-agents"], + [{ pubkey: remote, ownerPubkey: owner }], + ); + const wrapper = ({ children }) => + createElement( + QueryClientProvider, + { client }, + createElement(KnownAgentPubkeysProvider, null, children), + ); + const { result } = renderHook( + () => [ + useIsOtherSetupAgent(remote), + useIsOtherSetupAgent(local, owner), + useIsOtherSetupAgent("d".repeat(64), owner), + ], + { wrapper }, + ); + assert.deepEqual(result.current, [true, false, true]); + await act(async () => + client.setQueryData( + ["managed-agents"], + [{ pubkey: remote, status: "deployed" }], + ), + ); + assert.deepEqual(result.current, [false, true, true]); + await act(async () => { + client + .getQueryCache() + .find({ queryKey: ["managed-agents"] }) + .setState({ error: new Error("inventory unavailable"), status: "error" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + assert.deepEqual(result.current, [false, false, false]); + cleanup(); + client.clear(); +}); diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx index e9fe7b9a9b0..49f80054bc2 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.tsx +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -5,10 +5,25 @@ import { useRelayAgentsQuery, } from "@/features/agents/hooks"; import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; -import { useStableSet } from "@/shared/hooks/useStableReference"; +import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { isAgentDirectoryReady } from "./lib/agentAutocompleteEligibility"; +import { isOwnedAgentNotManagedOnDevice } from "./lib/otherSetupAgent"; const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet = new Set(); +const AgentManagementContext = React.createContext<{ + currentPubkey?: string; + localInventoryReady: boolean; + localPubkeys: ReadonlySet; + relayOwners: ReadonlyMap; +}>({ + localInventoryReady: false, + localPubkeys: new Set(), + relayOwners: new Map(), +}); + const KnownAgentPubkeysContext = React.createContext>( EMPTY_KNOWN_AGENT_PUBKEYS, ); @@ -39,8 +54,36 @@ export function KnownAgentPubkeysProvider({ }: { children: React.ReactNode; }) { - const managedAgents = useManagedAgentsQuery().data; - const relayAgents = useRelayAgentsQuery().data; + const managedQuery = useManagedAgentsQuery(); + const relayQuery = useRelayAgentsQuery(); + const currentPubkey = useIdentityQuery().data?.pubkey; + const managedAgents = managedQuery.data; + const relayAgents = relayQuery.data; + const localPubkeys = useStableSet( + new Set( + (managedAgents ?? []).map((agent) => normalizePubkey(agent.pubkey)), + ), + ); + const relayOwners = useStableMap( + new Map( + isAgentDirectoryReady(relayQuery) + ? (relayAgents ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent.ownerPubkey, + ]) + : [], + ), + ); + const localInventoryReady = isAgentDirectoryReady(managedQuery); + const management = React.useMemo( + () => ({ + currentPubkey, + localInventoryReady, + localPubkeys, + relayOwners, + }), + [currentPubkey, localInventoryReady, localPubkeys, relayOwners], + ); const merged = React.useMemo( () => mergeKnownAgentPubkeys(managedAgents, relayAgents), @@ -50,7 +93,9 @@ export function KnownAgentPubkeysProvider({ return ( - {children} + + {children} + ); } @@ -82,3 +127,21 @@ export function KnownAgentPubkeysProvider({ export function useKnownAgentPubkeys(): ReadonlySet { return React.useContext(KnownAgentPubkeysContext); } + +/** Shared provenance without per-row query observers; exact keys, never personas. */ +export function useIsOtherSetupAgent( + pubkey?: string | null, + profileOwnerPubkey?: string | null, +): boolean { + const state = React.useContext(AgentManagementContext); + const key = normalizePubkey(pubkey ?? ""); + return ( + Boolean(key) && + isOwnedAgentNotManagedOnDevice({ + currentPubkey: state.currentPubkey, + ownerPubkey: profileOwnerPubkey ?? state.relayOwners.get(key), + localInventoryReady: state.localInventoryReady, + isLocallyManaged: state.localPubkeys.has(key), + }) + ); +} diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index b059debfc6a..7edeb842c8b 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -1,3 +1,4 @@ +import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { Bot } from "lucide-react"; import type { UserSearchResult } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -60,6 +61,10 @@ export function AddMemberSearchResultRow({