diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c5bdbc5666..5efa3fe075f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", + "**/exact-key-profile.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..8a9750eccc0 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -200,7 +200,11 @@ with a TypeScript lookup table or an id comparison in a component. agent from Agents, a DM, or a channel must expose the same actions, tabs, fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile - content. + content. Explicit public-key targets are always exact, including stopped, + archived, and relay-only identities. Only explicit persona navigation may + select a representative or offer persona Start; a relay persona link cannot + borrow a local sibling's management controls. See + [the identity contract](../../../../docs/agent-profile-identity.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/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 710b5fc4be8..9e542be5a90 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "./pickProfileAgent.ts"; +import { pickProfileAgent } from "./pickProfileAgent.ts"; const NONE_ARCHIVED = () => false; @@ -68,60 +65,3 @@ test("a fail-open predicate keeps every instance eligible while loading", () => // Fail-open (all false) during the archive-snapshot window: normal ranking. assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); - -test("a direct-opened active instance is never redirected to a sibling", () => { - // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an - // access edit on Tyler would target the sibling. - const sibling = { - name: "Alpha Sibling", - pubkey: "a".repeat(64), - status: "running", - }; - const clicked = { - name: "Tyler Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), - clicked, - ); -}); - -test("a direct-opened inactive instance redirects to the active sibling", () => { - const historical = { - name: "Earlier Parity Agent", - pubkey: "a".repeat(64), - status: "stopped", - }; - const current = { - name: "Current Parity Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), - current, - ); -}); - -test("a direct-opened inactive instance with no active sibling stays put", () => { - const clicked = { - name: "Only Instance", - pubkey: "a".repeat(64), - status: "stopped", - }; - const otherStopped = { - name: "Another Stopped", - pubkey: "b".repeat(64), - status: "stopped", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), - clicked, - ); - assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked); -}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index dc2437c86ea..19de21f7903 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -5,8 +5,8 @@ import type { ManagedAgent } from "@/shared/api/types"; * Pick the instance that represents a persona throughout the UI. * * A persona can have several historical agent instances. Keeping this rule in - * one place prevents an avatar click on an older message from opening a - * different detail surface than the card in the Agents library. + * one place keeps persona navigation consistent. Explicit pubkey navigation + * never uses this selector: older messages still name their exact author. * * Relay-archived instances are never eligible, so an archived record early in * file order can't hijack the persona target. Returns `undefined` when every @@ -28,25 +28,3 @@ export function pickProfileAgent( return left.name.localeCompare(right.name); })[0]; } - -/** - * Resolve which instance a profile panel opened for `directAgent` should - * show, given every instance of the same persona. - * - * Access edits must target the exact instance the user clicked — resolving a - * running sidebar member to an alphabetically-earlier sibling would let a - * "tighten access" save widen the wrong agent. But when the clicked instance - * is inactive and the persona has an active instance elsewhere (an avatar on - * an old message from a retired instance), redirect to the active one so the - * panel matches the Agents library. The `isArchived` predicate keeps that - * redirect from ever landing on an archived sibling. - */ -export function pickDirectProfileAgent( - directAgent: ManagedAgent, - personaInstances: readonly ManagedAgent[], - isArchived: (pubkey: string) => boolean, -) { - if (isManagedAgentActive(directAgent)) return directAgent; - const canonical = pickProfileAgent(personaInstances, isArchived); - return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; -} diff --git a/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs index 072b8ccd231..888703dd1a0 100644 --- a/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs +++ b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs @@ -26,8 +26,6 @@ test("a persona target with a live sibling resolves to the live instance", () => directManagedAgent: undefined, isArchived: (pubkey) => pubkey === ARCHIVED_PK, personaInstances: [archived, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: undefined, }); @@ -42,8 +40,6 @@ test("a persona target with all instances archived resolves to undefined", () => directManagedAgent: undefined, isArchived: () => true, personaInstances: [first, second], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: undefined, }); @@ -58,8 +54,6 @@ test("an explicit archived pubkey stays exact even when a live sibling exists", directManagedAgent: archivedDirect, isArchived: (pubkey) => pubkey === ARCHIVED_PK, personaInstances: [archivedDirect, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: ARCHIVED_PK, }); @@ -75,15 +69,13 @@ test("an explicit archived pubkey with no managed record resolves to undefined s directManagedAgent: undefined, isArchived: (pubkey) => pubkey === HISTORICAL_PK, personaInstances: [], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: HISTORICAL_PK, }); assert.equal(resolved, undefined); }); -test("a preserved requested instance pins the exact record over canonicalization", () => { +test("an explicit stopped instance pins the exact record over canonicalization", () => { const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); const live = agent({ pubkey: LIVE_PK, status: "running" }); @@ -91,16 +83,14 @@ test("a preserved requested instance pins the exact record over canonicalization directManagedAgent: requested, isArchived: NONE_ARCHIVED, personaInstances: [requested, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: true, pubkey: HISTORICAL_PK, }); assert.equal(resolved, requested); }); -test("a non-archived historical pubkey canonicalizes to the live persona instance", () => { - // Rule 5: #5788 canonicalization is retained for non-archived navigation. +test("a non-archived historical pubkey stays exact, like archived and active keys", () => { + // History is authored by a key, not its current persona representative. const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); const live = agent({ pubkey: LIVE_PK, status: "running" }); @@ -108,15 +98,13 @@ test("a non-archived historical pubkey canonicalizes to the live persona instanc directManagedAgent: requested, isArchived: NONE_ARCHIVED, personaInstances: [requested, live], - preferDirectManagedAgent: false, - preserveRequestedInstance: false, pubkey: HISTORICAL_PK, }); - assert.equal(resolved, live); + assert.equal(resolved, requested); }); -test("preferDirectManagedAgent keeps a directly opened active instance exact", () => { +test("explicit navigation keeps a directly opened active instance exact", () => { // The panel's own default: an access edit must target the clicked instance, // not an alphabetically-earlier active sibling. const sibling = agent({ name: "Alpha", pubkey: LIVE_PK, status: "running" }); @@ -130,17 +118,14 @@ test("preferDirectManagedAgent keeps a directly opened active instance exact", ( directManagedAgent: clicked, isArchived: NONE_ARCHIVED, personaInstances: [sibling, clicked], - preferDirectManagedAgent: true, - preserveRequestedInstance: false, pubkey: HISTORICAL_PK, }); assert.equal(resolved, clicked); }); -test("an explicit archived pubkey stays exact even with preferDirectManagedAgent", () => { - // Rule 2 wins over the direct-preference redirect: a deliberately opened - // archived instance must not be redirected away from its unarchive control. +test("an explicit archived pubkey stays exact with a running sibling", () => { + // Archive state does not change what identity an explicit target names. const archivedDirect = agent({ pubkey: ARCHIVED_PK, status: "stopped" }); const live = agent({ pubkey: LIVE_PK, status: "running" }); @@ -148,10 +133,22 @@ test("an explicit archived pubkey stays exact even with preferDirectManagedAgent directManagedAgent: archivedDirect, isArchived: (pubkey) => pubkey === ARCHIVED_PK, personaInstances: [archivedDirect, live], - preferDirectManagedAgent: true, - preserveRequestedInstance: false, pubkey: ARCHIVED_PK, }); assert.equal(resolved, archivedDirect); }); + +for (const instances of [[], [agent({ status: "running" })]]) { + test(`relay-only A never resolves to local sibling B (${instances.length} siblings)`, () => { + assert.equal( + resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: NONE_ARCHIVED, + personaInstances: instances, + pubkey: HISTORICAL_PK, + }), + undefined, + ); + }); +} diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs new file mode 100644 index 00000000000..fdfb5e2d263 --- /dev/null +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +let renderHook; +let cleanup; +let createElement; +let QueryClient; +let QueryClientProvider; +let useCanonicalManagedAgentProfile; +const clients = []; +const A = "a".repeat(64); +const B = "b".repeat(64); +const sibling = { + pubkey: B, + personaId: "shared-persona", + status: "running", + name: "Local B", +}; + +before(async () => { + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + ({ renderHook, cleanup } = await import("@testing-library/react")); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ useCanonicalManagedAgentProfile } = await import( + "./useCanonicalManagedAgentProfile.ts" + )); +}); +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); +}); +after(() => dom.window.close()); + +function wrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + client.setQueryData(["identity"], { pubkey: "c".repeat(64) }); + client.setQueryData(["archivedIdentities"], { archived: [] }); + clients.push(client); + return ({ children }) => + createElement(QueryClientProvider, { client }, children); +} + +for (const managedAgents of [[], [sibling]]) { + test(`explicit remote A cannot borrow persona P or its ${managedAgents.length} local instances`, () => { + const { result, rerender } = renderHook( + (props) => useCanonicalManagedAgentProfile(props), + { + wrapper: wrapper(), + initialProps: { managedAgents, personaId: "shared-persona", pubkey: A }, + }, + ); + assert.equal(result.current.managedAgent, undefined); + assert.equal(result.current.linkedPersonaId, undefined); + assert.deepEqual(result.current.instanceBuckets, { + live: [], + archived: [], + }); + // Only deliberately navigating to the persona may choose B or offer Start. + rerender({ managedAgents, personaId: "shared-persona", pubkey: undefined }); + assert.equal(result.current.linkedPersonaId, "shared-persona"); + assert.equal(result.current.managedAgent, managedAgents[0]); + // Returning to the explicit key cannot retain the persona representative. + rerender({ managedAgents, personaId: "shared-persona", pubkey: A }); + assert.equal(result.current.managedAgent, undefined); + assert.equal(result.current.linkedPersonaId, undefined); + }); +} + +test("explicit local instance uses only its own definition link, with normalized key matching", () => { + const { result } = renderHook( + () => + useCanonicalManagedAgentProfile({ + managedAgents: [sibling], + personaId: "unrelated-persona", + pubkey: B.toUpperCase(), + }), + { wrapper: wrapper() }, + ); + assert.equal(result.current.managedAgent, sibling); + assert.equal(result.current.linkedPersonaId, sibling.personaId); +}); diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index 0393a795ce3..70bddcf2964 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -1,66 +1,25 @@ import * as React from "react"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "@/features/agents/lib/pickProfileAgent"; +import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import { useUserProfileQuery } from "@/features/profile/hooks"; -import { ownsAuthorAgent } from "@/features/profile/lib/identity"; -import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; /** - * Resolve the single managed instance a profile surface represents, honouring - * the archive-aware target-provenance rules. Pure so the resolution matrix is - * testable without mounting the panel; the hook supplies the live inputs. - * - * - `preserveRequestedInstance` + a direct match pins that exact record (an - * explicit Runtime → Instances selection). - * - A deliberately requested archived pubkey stays EXACT — exactness beats - * canonicalization iff the requested pubkey is archived — so its archive - * controller can unarchive that identity even when a live sibling exists. - * Returns the managed record when one exists; otherwise `undefined`, so the - * panel falls back to the requested pubkey verbatim (a historical archived - * key with no current managed record still resolves to itself). - * - `preferDirectManagedAgent` (the panel's own default) keeps a directly - * opened active instance exact so an access edit targets it, only redirecting - * an inactive click to a live sibling — see `pickDirectProfileAgent`. - * - Otherwise persona-target and non-archived historical navigation resolve - * through the shared archive-aware selector: all instances archived yields - * `undefined` (persona-only mode), else the canonical live instance. + * An explicit public key always names exactly that identity, whether active, + * stopped, archived, or absent from this device's managed inventory. Only a + * persona target may choose an archive-aware representative. A persona link is + * not an identity alias and never grants local management of a different key. */ export function resolveCanonicalManagedAgent(input: { directManagedAgent: ManagedAgent | undefined; isArchived: (pubkey: string) => boolean; personaInstances: readonly ManagedAgent[]; - preferDirectManagedAgent: boolean; - preserveRequestedInstance: boolean; pubkey: string | undefined; }): ManagedAgent | undefined { - const { - directManagedAgent, - isArchived, - personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, - pubkey, - } = input; - if (preserveRequestedInstance && directManagedAgent) { - return directManagedAgent; - } - if (pubkey && isArchived(pubkey)) { - return directManagedAgent; - } - if (preferDirectManagedAgent && directManagedAgent) { - return pickDirectProfileAgent( - directManagedAgent, - personaInstances, - isArchived, - ); - } - return pickProfileAgent(personaInstances, isArchived) ?? directManagedAgent; + const { directManagedAgent, isArchived, personaInstances, pubkey } = input; + if (pubkey) return directManagedAgent; + return pickProfileAgent(personaInstances, isArchived); } /** @@ -82,21 +41,11 @@ export function bucketPersonaInstances( } export function useCanonicalManagedAgentProfile(input: { - currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; personaId: string | undefined; - preferDirectManagedAgent?: boolean; - preserveRequestedInstance?: boolean; pubkey: string | undefined; }) { - const { - currentPubkey, - managedAgents, - personaId, - preferDirectManagedAgent = false, - preserveRequestedInstance = false, - pubkey, - } = input; + const { managedAgents, personaId, pubkey } = input; const directManagedAgent = React.useMemo(() => { if (!pubkey) return undefined; const target = normalizePubkey(pubkey); @@ -104,18 +53,9 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => normalizePubkey(agent.pubkey) === target, ); }, [managedAgents, pubkey]); - const requestedProfileQuery = useUserProfileQuery(pubkey); - const historicalPersonaId = useOwnedManagedAgentPersonaId({ - agentPubkey: pubkey, - enabled: Boolean( - pubkey && - !directManagedAgent && - ownsAuthorAgent(requestedProfileQuery.data, currentPubkey), - ), - ownerPubkey: currentPubkey, - }); - const linkedPersonaId = - personaId ?? directManagedAgent?.personaId ?? historicalPersonaId; + // Explicit identity targets can use only their own local definition link. + // Relay-only identities must not inherit a local persona's Start/Edit actions. + const linkedPersonaId = pubkey ? directManagedAgent?.personaId : personaId; const personaInstances = React.useMemo(() => { if (!linkedPersonaId) { return directManagedAgent ? [directManagedAgent] : []; @@ -131,18 +71,9 @@ export function useCanonicalManagedAgentProfile(input: { directManagedAgent, isArchived, personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, pubkey, }), - [ - directManagedAgent, - isArchived, - personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, - pubkey, - ], + [directManagedAgent, isArchived, personaInstances, pubkey], ); // Split the roster for the Instances list off the same predicate the selector // uses — see `bucketPersonaInstances` for the fail-open semantics. diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs deleted file mode 100644 index 4c6ec58f212..00000000000 --- a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs +++ /dev/null @@ -1,218 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; -import { JSDOM } from "jsdom"; - -import { relayClient } from "@/shared/api/relayClient"; -import { - KIND_IA_ARCHIVE_REQUEST, - KIND_MANAGED_AGENT, -} from "@/shared/constants/kinds"; -import { - personaIdFromOwnedManagedAgentArchive, - personaIdFromOwnedManagedAgentEvent, - useOwnedManagedAgentPersonaId, -} from "./useOwnedManagedAgentPersonaId.ts"; - -const OWNER_SECRET = new Uint8Array(32); -OWNER_SECRET[31] = 1; -const OTHER_SECRET = new Uint8Array(32); -OTHER_SECRET[31] = 2; -const OWNER = getPublicKey(OWNER_SECRET); -const AGENT = "a".repeat(64); - -function managedAgentEvent({ - agentPubkey = AGENT, - content = JSON.stringify({ persona_id: "persona-reviewer" }), - secret = OWNER_SECRET, -} = {}) { - return finalizeEvent( - { - created_at: 1, - kind: KIND_MANAGED_AGENT, - tags: [["d", agentPubkey]], - content, - }, - secret, - ); -} - -function managedAgentArchive({ - agentPubkey = AGENT, - personaId = "persona-reviewer", - secret = OWNER_SECRET, -} = {}) { - return finalizeEvent( - { - created_at: 2, - kind: KIND_IA_ARCHIVE_REQUEST, - tags: [["p", agentPubkey]], - content: JSON.stringify({ persona_id: personaId }), - }, - secret, - ); -} - -test("resolves an owner-signed historical agent key to its persona", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent(managedAgentEvent(), OWNER, AGENT), - "persona-reviewer", - ); -}); - -test("resolves a deleted historical agent key from its owner-signed archive request", () => { - assert.equal( - personaIdFromOwnedManagedAgentArchive(managedAgentArchive(), OWNER, AGENT), - "persona-reviewer", - ); -}); - -test("rejects archive aliases with the wrong owner or target", () => { - assert.equal( - personaIdFromOwnedManagedAgentArchive( - managedAgentArchive({ secret: OTHER_SECRET }), - OWNER, - AGENT, - ), - null, - ); - assert.equal( - personaIdFromOwnedManagedAgentArchive( - managedAgentArchive({ agentPubkey: "b".repeat(64) }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("rejects a managed-agent event from a different owner", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ secret: OTHER_SECRET }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("rejects a managed-agent event for a different agent key", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ agentPubkey: "b".repeat(64) }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("rejects empty or malformed persona ids", () => { - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ content: JSON.stringify({ persona_id: "" }) }), - OWNER, - AGENT, - ), - null, - ); - assert.equal( - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent({ content: "not-json" }), - OWNER, - AGENT, - ), - null, - ); -}); - -test("still resolves the live record when the archive lookup fails", async () => { - const dom = new JSDOM("", { - url: "http://localhost", - }); - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const originalFetchFirstEvent = relayClient.fetchFirstEvent; - relayClient.fetchFirstEvent = async (filter) => { - if (filter.kinds?.includes(KIND_IA_ARCHIVE_REQUEST)) { - throw new Error("archive lookup unavailable"); - } - return managedAgentEvent(); - }; - - try { - const { result } = renderHook(() => - useOwnedManagedAgentPersonaId({ - agentPubkey: AGENT, - enabled: true, - ownerPubkey: OWNER, - }), - ); - await act(async () => { - await Promise.resolve(); - }); - assert.equal(result.current, "persona-reviewer"); - } finally { - cleanup(); - relayClient.fetchFirstEvent = originalFetchFirstEvent; - dom.window.close(); - } -}); - -test("does not expose a persona result for stale lookup inputs", async () => { - const dom = new JSDOM("", { - url: "http://localhost", - }); - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); - const { act, cleanup, renderHook } = await import("@testing-library/react"); - const originalFetchFirstEvent = relayClient.fetchFirstEvent; - relayClient.fetchFirstEvent = async (filter) => - filter.kinds?.includes(KIND_MANAGED_AGENT) ? managedAgentEvent() : null; - const observed = []; - - try { - const { result, rerender, unmount } = renderHook( - (props) => { - const personaId = useOwnedManagedAgentPersonaId(props); - observed.push(personaId); - return personaId; - }, - { - initialProps: { - agentPubkey: AGENT, - enabled: true, - ownerPubkey: OWNER, - }, - }, - ); - await act(async () => { - await Promise.resolve(); - }); - assert.equal(result.current, "persona-reviewer"); - - const switchIndex = observed.length; - rerender({ - agentPubkey: "b".repeat(64), - enabled: false, - ownerPubkey: OWNER, - }); - assert.deepEqual(observed.slice(switchIndex), [null]); - assert.equal(result.current, null); - unmount(); - } finally { - cleanup(); - relayClient.fetchFirstEvent = originalFetchFirstEvent; - dom.window.close(); - } -}); diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts deleted file mode 100644 index 7eef5b21e05..00000000000 --- a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts +++ /dev/null @@ -1,164 +0,0 @@ -import * as React from "react"; -import { verifyEvent } from "nostr-tools/pure"; - -import { relayClient } from "@/shared/api/relayClient"; -import type { RelayEvent } from "@/shared/api/types"; -import { - KIND_IA_ARCHIVE_REQUEST, - KIND_MANAGED_AGENT, -} from "@/shared/constants/kinds"; -import { normalizePubkey } from "@/shared/lib/pubkey"; - -type ManagedAgentEventContent = { - persona_id?: unknown; -}; - -function eventHasValidSignature(event: RelayEvent): boolean { - try { - return verifyEvent({ - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - }); - } catch { - return false; - } -} - -function personaIdFromEventContent(event: RelayEvent): string | null { - try { - const content = JSON.parse(event.content) as ManagedAgentEventContent; - return typeof content.persona_id === "string" && - content.persona_id.trim().length > 0 - ? content.persona_id - : null; - } catch { - return null; - } -} - -export function personaIdFromOwnedManagedAgentEvent( - event: RelayEvent | null, - ownerPubkey: string, - agentPubkey: string, -): string | null { - if (!event || event.kind !== KIND_MANAGED_AGENT) return null; - - const owner = normalizePubkey(ownerPubkey); - const agent = normalizePubkey(agentPubkey); - if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; - if ( - !event.tags.some( - (tag) => tag[0] === "d" && normalizePubkey(tag[1] ?? "") === agent, - ) - ) { - return null; - } - if (!eventHasValidSignature(event)) return null; - - return personaIdFromEventContent(event); -} - -export function personaIdFromOwnedManagedAgentArchive( - event: RelayEvent | null, - ownerPubkey: string, - agentPubkey: string, -): string | null { - if (!event || event.kind !== KIND_IA_ARCHIVE_REQUEST) return null; - - const owner = normalizePubkey(ownerPubkey); - const agent = normalizePubkey(agentPubkey); - if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; - if ( - !event.tags.some( - (tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === agent, - ) - ) { - return null; - } - if (!eventHasValidSignature(event)) return null; - - return personaIdFromEventContent(event); -} - -type PersonaLookupResult = { - key: string; - personaId: string | null; -}; - -/** - * Resolve an owned historical agent key back to its persona. The live - * kind:30177 projection provides the alias before deletion; the owner-signed - * NIP-IA archive request preserves it after the projection is tombstoned. - */ -export function useOwnedManagedAgentPersonaId(input: { - agentPubkey: string | undefined; - enabled: boolean; - ownerPubkey: string | undefined; -}): string | null { - const { agentPubkey, enabled, ownerPubkey } = input; - const [result, setResult] = React.useState(null); - const normalizedOwner = normalizePubkey(ownerPubkey ?? ""); - const normalizedAgent = normalizePubkey(agentPubkey ?? ""); - const lookupKey = - enabled && normalizedOwner && normalizedAgent - ? `${normalizedOwner}:${normalizedAgent}` - : null; - - React.useEffect(() => { - let cancelled = false; - - if (!lookupKey) { - return () => { - cancelled = true; - }; - } - - void Promise.allSettled([ - relayClient.fetchFirstEvent({ - kinds: [KIND_MANAGED_AGENT], - authors: [normalizedOwner], - "#d": [normalizedAgent], - limit: 1, - }), - relayClient.fetchFirstEvent({ - kinds: [KIND_IA_ARCHIVE_REQUEST], - authors: [normalizedOwner], - "#p": [normalizedAgent], - limit: 1, - }), - ]).then(([managedAgentResult, archiveResult]) => { - if (cancelled) return; - const managedAgentEvent = - managedAgentResult.status === "fulfilled" - ? managedAgentResult.value - : null; - const archiveEvent = - archiveResult.status === "fulfilled" ? archiveResult.value : null; - setResult({ - key: lookupKey, - personaId: - personaIdFromOwnedManagedAgentEvent( - managedAgentEvent, - normalizedOwner, - normalizedAgent, - ) ?? - personaIdFromOwnedManagedAgentArchive( - archiveEvent, - normalizedOwner, - normalizedAgent, - ), - }); - }); - - return () => { - cancelled = true; - }; - }, [lookupKey, normalizedAgent, normalizedOwner]); - - return result?.key === lookupKey ? result.personaId : null; -} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f02b5aee097..7845b077b97 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -191,14 +191,12 @@ export function UserProfilePanel({ const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); const { instanceBuckets, linkedPersonaId, managedAgent } = useCanonicalManagedAgentProfile({ - currentPubkey, managedAgents: managedAgentsQuery.data, personaId: persona?.id, - preferDirectManagedAgent: true, - preserveRequestedInstance, pubkey, }); const resolvedPersonaFromSource = React.useMemo(() => { + if (pubkey && !managedAgent) return undefined; const personaId = linkedPersonaId ?? managedAgent?.personaId; if (personaId) { const refreshedPersona = personasQuery.data?.find( @@ -208,7 +206,7 @@ export function UserProfilePanel({ return refreshedPersona; } } - if (persona) { + if (!pubkey && persona) { return persona; } if (!managedAgent?.personaId) { @@ -217,14 +215,15 @@ export function UserProfilePanel({ return personasQuery.data?.find( (candidate) => candidate.id === managedAgent.personaId, ); - }, [linkedPersonaId, managedAgent?.personaId, persona, personasQuery.data]); + }, [linkedPersonaId, managedAgent, persona, personasQuery.data, pubkey]); const profileIdentityKey = - managedAgent?.pubkey ?? pubkey ?? `persona:${persona?.id ?? "unknown"}`; - const resolvedPersona = useRetainedPersona( + pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`; + const retainedPersona = useRetainedPersona( resolvedPersonaFromSource, profileIdentityKey, ); - const effectivePubkey = managedAgent?.pubkey ?? pubkey ?? null; + const resolvedPersona = pubkey && !managedAgent ? undefined : retainedPersona; + const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); @@ -331,6 +330,7 @@ export function UserProfilePanel({ const canOpenAgentLogs = isOwner === true && managedAgent?.backend.type === "local"; const canInstantiateAgent = + !pubkey && isOwner === true && resolvedPersona !== undefined && managedAgent === undefined; diff --git a/desktop/tests/e2e/exact-key-profile.spec.ts b/desktop/tests/e2e/exact-key-profile.spec.ts new file mode 100644 index 00000000000..c89122af28f --- /dev/null +++ b/desktop/tests/e2e/exact-key-profile.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const REMOTE = TEST_IDENTITIES.charlie.pubkey; +const LOCAL = "d".repeat(64); +const OWNER = "deadbeef".repeat(8); +const PERSONA = "shared-persona"; + +for (const hasSibling of [false, true]) { + test(`explicit relay-only identity has no local controls (${hasSibling ? "local sibling" : "persona only"})`, async ({ + page, + }, testInfo) => { + await installMockBridge(page, { + oaOwnerIsMe: true, + managedAgents: hasSibling + ? [ + { + pubkey: LOCAL, + name: "Local sibling B", + personaId: PERSONA, + status: "running", + channelNames: ["agents"], + }, + ] + : [], + personas: [ + { + id: PERSONA, + displayName: "Shared persona P", + isActive: true, + systemPrompt: "Local definition, not the remote identity.", + }, + ], + searchProfiles: [ + { + pubkey: REMOTE, + displayName: "Relay agent A", + ownerPubkey: OWNER, + isAgent: true, + }, + ], + }); + await page.goto(`/#/agents?profile=${REMOTE}`); + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(page.getByTestId("user-profile-name-row")).toContainText( + "Relay agent A", + ); + for (const testId of [ + "user-profile-agent-primary-action", + "user-profile-start-agent", + "user-profile-edit-agent", + "user-profile-add-to-channel", + ]) { + await expect(page.getByTestId(testId)).toHaveCount(0); + } + await expect(panel).not.toContainText( + "Local definition, not the remote identity.", + ); + await waitForAnimations(page); + await panel.screenshot({ + path: testInfo.outputPath("exact-relay-identity.png"), + }); + + // Persona-only navigation remains legitimate and intentionally different. + await page.getByTestId("auxiliary-panel-close").click(); + await page.getByTestId(`persona-agent-row-${PERSONA}`).click(); + await expect( + page.getByTestId( + hasSibling + ? "user-profile-agent-primary-action" + : "user-profile-start-agent", + ), + ).toBeVisible(); + await waitForAnimations(page); + await panel.screenshot({ + path: testInfo.outputPath("explicit-persona.png"), + }); + }); +} + +for (const allArchived of [false, true]) { + test(`archived exact key stays navigable (${allArchived ? "all archived" : "live sibling"})`, async ({ + page, + }) => { + await installMockBridge(page, { + oaOwnerIsMe: true, + archivedIdentities: allArchived ? [REMOTE, LOCAL] : [REMOTE], + managedAgents: [ + { + pubkey: REMOTE, + name: "Archived A", + personaId: PERSONA, + status: "stopped", + channelNames: ["agents"], + }, + { + pubkey: LOCAL, + name: "Sibling B", + personaId: PERSONA, + status: "running", + channelNames: ["agents"], + }, + ], + personas: [ + { + id: PERSONA, + displayName: "Shared persona P", + isActive: true, + systemPrompt: "Archive profile fixture.", + }, + ], + }); + await page.goto(`/#/agents?profile=${REMOTE}`); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible(); + await expect( + page.getByTestId("user-profile-agent-primary-action"), + ).toHaveAttribute("aria-label", "Start agent"); + await expect(page.getByTestId("user-profile-name-row")).toContainText( + "Archived A", + ); + // Persona navigation still excludes archived representatives. + await page.getByTestId("auxiliary-panel-close").click(); + await page.getByTestId(`persona-agent-row-${PERSONA}`).click(); + if (allArchived) { + await expect(page.getByTestId("user-profile-start-agent")).toBeVisible(); + await expect( + page.getByTestId("user-profile-agent-primary-action"), + ).toHaveCount(0); + } else { + await expect( + page.getByTestId("user-profile-agent-primary-action"), + ).toHaveAttribute("aria-label", "Stop"); + } + }); +} diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 3870718e4e9..945d9744694 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1169,6 +1169,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await expect(page.getByTestId("user-profile-message")).toBeVisible(); await expect(page.getByTestId("user-profile-huddle")).toHaveCount(0); await expect(page.getByTestId("user-profile-wave")).toHaveCount(0); + await expectHashSearchParam(page, "profile", agentPubkey); const agentPresenceBadge = page.getByTestId("user-profile-presence-badge"); await expect(agentPresenceBadge).toBeVisible(); await expect(agentPresenceBadge).toHaveAttribute("aria-label", "Online"); @@ -1234,6 +1235,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await agentPrimaryAction.click(); await expect(agentPrimaryAction).toHaveAttribute("aria-label", "Start agent"); await expect(agentPresenceBadge).toHaveAttribute("aria-label", "Offline"); + await expectHashSearchParam(page, "profile", agentPubkey); await expect(agentPrimaryAction).toHaveClass(/bg-foreground/); await expect(agentPrimaryAction).toHaveClass(/text-background/); await expect(page.getByTestId("user-profile-agent-restart")).toHaveCount(0); @@ -1834,9 +1836,9 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await expect(page.getByTestId("agent-memory-list")).toContainText("orphan"); }); -test("an older agent message opens the same persona instance as the Agents library", async ({ +test("an older agent message stays exact while persona navigation selects the live instance", async ({ page, -}) => { +}, testInfo) => { const personaId = "profile-parity-agent"; const historicalPubkey = TEST_IDENTITIES.charlie.pubkey; const currentPubkey = "d".repeat(64); @@ -1875,7 +1877,6 @@ test("an older agent message opens the same persona instance as the Agents libra await expect( page.getByTestId("user-profile-agent-primary-action"), ).toHaveAttribute("aria-label", "Stop"); - const agentsLibraryContract = await readOwnedAgentProfileContract(page); await page.getByTestId("user-profile-tab-runtime").click(); await page.getByTestId("user-profile-instances").click(); @@ -1889,6 +1890,8 @@ test("an older agent message opens the same persona instance as the Agents libra page.getByTestId(`user-profile-instance-${historicalPubkey}`), ).toContainText("Current"); + const exactInstanceContract = await readOwnedAgentProfileContract(page); + await page.getByTestId("auxiliary-panel-close").click(); await page.getByTestId("channel-agents").click(); const historicalMessage = page @@ -1898,10 +1901,15 @@ test("an older agent message opens the same persona instance as the Agents libra await historicalMessage.locator("button").first().click(); await expect( page.getByTestId("user-profile-agent-primary-action"), - ).toHaveAttribute("aria-label", "Stop"); + ).toHaveAttribute("aria-label", "Start agent"); const messageContract = await readOwnedAgentProfileContract(page); - expect(messageContract).toEqual(agentsLibraryContract); + expect(messageContract).toEqual(exactInstanceContract); + await page.getByTestId("user-profile-tab-info").click(); + await waitForAnimations(page); + await page.screenshot({ + path: testInfo.outputPath("historical-exact-instance.png"), + }); }); test("restored Inbox deep link hides the back arrow", async ({ page }) => { diff --git a/docs/agent-profile-identity.md b/docs/agent-profile-identity.md new file mode 100644 index 00000000000..82238d17ac5 --- /dev/null +++ b/docs/agent-profile-identity.md @@ -0,0 +1,41 @@ +# Agent profile identity + +## An explicit key is exact + +Opening a public key from a message, member, DM, deep link, or Instances row +always opens that identity. Active, stopped, archived, and relay-only keys obey +the same rule. A local managed record may supply controls only for that exact +key. An owner-signed kind 30177 `persona_id` (or an archive request's historical +persona link) is **not** an identity alias and does not grant access to a local +sibling's controls, definition, configuration, or Start action. + +Only explicit persona navigation (for example, the persona card in My Agents) +selects an archive-aware representative using `pickProfileAgent`. With no live +representative, it remains a persona-only surface and may offer Start. An +explicit relay-only key must not turn into that persona-only surface, even if a +matching definition exists locally. There is no synthetic/secretless +`ManagedAgent` record. + +This intentionally supersedes the old historical-message redirect: a message +from stopped A no longer opens running B merely because they share persona P. +The requested key takes precedence even if the caller also supplies persona +context. Local instance profiles may still show their own linked definition and +an explicitly navigable Instances list. Ownership alone permits owner-scoped +relay reads, not local management. + +Implementation: `useCanonicalManagedAgentProfile` and `UserProfilePanel`. +The obsolete historical-persona relay lookup and inactive-instance redirect +helper have been removed instead of adding another exception flag. + +## Regression gates + +- `profile/lib/resolveCanonicalManagedAgent.test.mjs`: exact identity across + active/stopped/archived/relay-only keys, and persona representative selection. +- `profile/lib/useCanonicalManagedAgentProfile.test.mjs`: remote A cannot borrow + persona P/local B, including persona-only navigation and returning to A. +- `tests/e2e/exact-key-profile.spec.ts`: relay-only profile controls versus + explicit persona navigation, with and without a local sibling; exact archived + keys with a live sibling and persona-only navigation when all are archived. +- `tests/e2e/profile.spec.ts`: historical messages match the exact Instances + selection rather than the current persona card; profile ingress parity. +- `tests/e2e/identity-archive.spec.ts`: archive/unarchive authority and flair.