fix(desktop): keep explicit agent profiles bound to their exact key (#7131)

🤖

## Summary

In Buzz Desktop, clicking a message from stopped agent A could open
running agent B—and B's controls—because both shared a persona (an agent
definition). This now opens the author you clicked and only that agent's
own controls, so you can inspect an old message without being redirected
to a different running agent.

An explicit public key—the identifier for one agent—now stays exact
across message authors, members, DMs, deep links and Instances rows,
including stopped, archived and relay-only agents. Local controls come
only from a matching local record for that key. A relay-only A cannot
borrow B's Start/Stop/Edit controls or configuration.

Deliberately opening a **persona** is different: it can still select a
representative that respects archived instances or offer Start when none
remains. The change removes competing historical-persona redirects
rather than adding another identity exception.

### Related issue

Independent base: `main`; no stack parent or child among the
replacements. Extracted from
[#7114](https://github.com/block/buzz/pull/7114), retained as historical
source (`98fe33ec`).

[Behavior
contract](9c4b6523ce/docs/agent-profile-identity.md).
Originating [Buzz
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848)
· channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`.

### Testing

Synthetic Playwright mock-bridge state. After screenshots exercise this
independent profile extraction (`df6612b1`); no availability or
cloud-marker implementation is included.

#### Before: historical A redirects to running B
Unchanged main product code (`bc006f67`) with the same updated
historical-message fixture fails: clicking Earlier Parity Agent opens
Current Parity Agent and its Stop control.


![01-before-historical-redirect](https://raw.githubusercontent.com/block/buzz/2acd989f6eae9e7bc1ce5ae5eeafcbc1704f605f/pr-7131--01-before-historical-redirect.png)

#### After: historical A opens A
The clicked author remains Earlier Parity Agent, with A's public key and
its own Start control. The current sibling is not substituted.


![02-after-historical-exact](https://raw.githubusercontent.com/block/buzz/2acd989f6eae9e7bc1ce5ae5eeafcbc1704f605f/pr-7131--02-after-historical-exact.png)

#### Exact relay-only A while local sibling B exists
A's public key and owner-scoped profile are visible; no local
Start/Stop/Edit/Add control or sibling definition is borrowed.


![03-exact-relay-with-local-sibling](https://raw.githubusercontent.com/block/buzz/2acd989f6eae9e7bc1ce5ae5eeafcbc1704f605f/pr-7131--03-exact-relay-with-local-sibling.png)

#### Explicit persona navigation may select local B
Deliberately opening the persona selects its local representative, with
B's key and legitimate Stop/Restart/Edit controls.


![04-explicit-local-persona](https://raw.githubusercontent.com/block/buzz/2acd989f6eae9e7bc1ce5ae5eeafcbc1704f605f/pr-7131--04-explicit-local-persona.png)

#### Explicit persona without an instance may offer Start
This is a deliberately opened persona, not a relay-only key turned into
a persona surface.


![05-explicit-persona-without-instance](https://raw.githubusercontent.com/block/buzz/2acd989f6eae9e7bc1ce5ae5eeafcbc1704f605f/pr-7131--05-explicit-persona-without-instance.png)

[Original screenshot
publication](https://github.com/block/buzz/pull/7131#issuecomment-5482310890);
all five immutable image URLs and captions retained here. The final
documentation-only commit does not change this UI. These are synthetic
browser fixtures, not live runtime health evidence.

To check manually, open an old message from stopped A while same-persona
B is running; compare the displayed key and controls. Then open the
persona itself and verify that representative selection still works.

#### Evidence and limitations

**5,793 desktop tests**, **56 profile/archive browser cases**,
type/static/size checks and repository-wide `just ci` passed. The
historical-message regression fails on unchanged main by opening B
instead of A. [Published-head CI
passed](https://github.com/block/buzz/actions/runs/33422207592).

The [advisory security
check](https://github.com/block/buzz/actions/runs/33422240973) timed out
without a result; it is not a passing check. No availability,
cloud-marker, discovery or mention-routing change is included. These
screenshots do not establish remote delivery, agent execution or
termination.

#### Security authorization history (audit, not clearance)

The [security
gate](https://github.com/block/buzz/pull/7131#issuecomment-5482300054)
remains visible and unresolved. Existing authorization-request comments
were posted by `loganj`: [old-head
request](https://github.com/block/buzz/pull/7131#issuecomment-5482311163)
for `df6612b1db5a6f8d128cef955fd66a80b6828cb8` at 2026-08-31 17:55:11
UTC, then [current-head
request](https://github.com/block/buzz/pull/7131#issuecomment-5482320424)
for `9c4b6523ceaef0f3d92906fcdb5d9a3b9ede7e17` at 17:55:57 UTC. The
existing [issue-comment workflow
run](https://github.com/block/buzz/actions/runs/33422240973) ended
cancelled after the previously reported timeout; it did not produce a
completed security review. Latest exact-head Run/Post Codex jobs are
skipped, not security approval. Historical comments remain available at
their original links; consolidating their audit here does not withdraw
authorization or clear the gate. An authorized security workflow owner
must arrange the missing exact-range result.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-02 16:20:49 -04:00
committed by GitHub
parent 434dafe958
commit 83e5962984
13 changed files with 337 additions and 587 deletions

View File

@@ -27,6 +27,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",

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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,
);
});
}

View File

@@ -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("<!doctype html><html><body></body></html>", {
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);
});

View File

@@ -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.

View File

@@ -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("<!doctype html><html><body></body></html>", {
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("<!doctype html><html><body></body></html>", {
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();
}
});

View File

@@ -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<PersonaLookupResult | null>(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;
}

View File

@@ -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;

View File

@@ -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");
}
});
}

View File

@@ -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 }) => {

View File

@@ -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.