Files
buzz/desktop/tests/e2e/agent-access-warning.spec.ts
Logan Johnson bfc3848558 fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) (#7488)
🤖
## Summary

Identity keys in the desktop app are displayed as raw 64-character hex.
A person's key shows up as something like `953d3363…` — unreadable,
impossible to recognize as the same identity on another screen, and a
hazard when copied by hand. Nostr (the protocol Buzz runs on) has a
human-readable spelling for identity keys — the `npub1…` form — but the
desktop app did not use it consistently.

This is the foundation of the desktop npub changes: it adds the shared
pieces every identity surface builds on, and two follow-up slices stack
directly on this branch — #7489 converts the identity controls (profile,
settings, allowlist, workflow key fields) and #7495 converts the
everyday display surfaces (mentions, member lists, sidebar, and other
name fallbacks).

After this change:

- The shared identity widget shows the compact npub form —
`npub1j57...fjmv` — instead of a hex prefix, everywhere it renders (for
example the owned-agent public-key row on a profile). Copying it puts
the full npub on the clipboard.
- Copy is a real interaction, verified end-to-end: both popover variants
put the exact canonical npub on the actual clipboard — never the raw hex
the popover also lists, never a truncation — and a portaled popover's
clicks no longer steal focus from the new-DM To-field mid-copy. Pointer
copy, a natural Space-then-Enter path, and inner/outer Escape are
covered.
- Anything that isn't a valid identity key fails neutrally: short or
corrupt values — including degenerate values that technically encode to
a checksum-valid npub but aren't real identity keys — show "Unavailable"
with no copy button, instead of a misleading value.
- Both valid npub spellings display: all-lowercase `npub1…` and
all-uppercase `NPUB1…` (Bech32, npub's encoding, permits either casing)
both render the same canonical lowercase npub. Mixed case is rejected by
the display path as written — `canonicalNpub` and the widget don't
case-normalize input — while input parsing (`parsePubkeyInput`) keeps
its trim-and-lowercase normalization and accepts mixed-case npubs; both
paths require the decoded payload to be exactly a 64-character identity
key.
- Identity-key input is strict on payload: an npub whose decoded payload
isn't exactly a 64-character identity key is rejected, matching the
validation the app's Rust side already applies to agent allowlists.

Intentional scope boundary: only surfaces that render through the shared
widget change here. Outer profile copy, settings identity cards, the
respond-to allowlist, and workflow key fields still show hex — they move
to npub in the controls follow-up (#7489). Nothing else changes identity
representation: display names, private keys, event IDs, and the hex the
app stores, sends, and matches internally are untouched; only the
user-facing spelling of an identity key changes.

## Details

- `desktop/src/shared/lib/pubkey.ts` — `canonicalNpub()`: strict
canonical full-npub helper (64-char hex in any case, or a
checksum-validated npub, returns the canonical npub; anything else
returns `null`); `truncateNpub()`: the compact display form; existing
exports unchanged.
- `desktop/src/shared/ui/PubKey.tsx` — the shared widget's identity gate
validates through `canonicalNpub`; the popover copies the npub only.
- `desktop/src/shared/lib/nostrUtils.ts` — `parsePubkeyInput` rejects
npubs whose payload is not exactly a 64-character identity key.
- `desktop/src/features/messages/ui/NewMessageScreen.tsx` — the To-field
focuses its search input only for clicks that land inside the field
itself, so portaled recipient popovers keep their focus while open (a
popover click previously dismissed it mid-copy).
- Unit suites cover the helper, widget, and parser (including the
degenerate-encode and uppercase regressions); the e2e specs that render
these rows assert the npub display.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for npub identity display —
no existing match.
- Stack: #7489 is based on this branch and builds on these primitives;
it does not stand alone on main.

### Testing

At head `b3310c248` (base: main `44316ff72`; 12 files, +440/−39):

- Focused unit suites (pubkey, PubKey, parsePubkeyInput): 20/20 green;
mutation-checked — removing the decoded-length predicate fails the
short/empty checksum-valid-npub assertions in `canonicalNpub` and the
widget, and a wrong-identity clipboard value fails the new copy
assertions.
- `pnpm typecheck` and `pnpm check`: pass; full desktop unit suite
6459/6459 at this exact head.
- Targeted e2e at this exact head: 8/8 across the two specs that own the
clipboard flows — `agent-access-warning.spec.ts` (compact variant,
agent-access owner hint) and `pubkey-display-screenshots.spec.ts` (full
variant, new-DM recipient verification: pointer copy, popover surviving
the copy, inner/outer Escape, Space-then-Enter).
- No Rust-side or build files change in this PR, so those results are
unaffected.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 10:32:26 -04:00

364 lines
12 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/agent-access-warning";
async function choosePersonaAccess(
page: import("@playwright/test").Page,
optionName: string,
) {
await page.locator("#agent-respond-to").click();
await page.getByRole("menuitemradio", { name: optionName }).click();
}
async function openAgentAccessDialog(
page: import("@playwright/test").Page,
agentPubkey: string,
) {
if (!(await page.getByTestId("members-sidebar").isVisible())) {
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
}
const row = page.getByTestId(`sidebar-member-${agentPubkey}`);
const menu = page.getByTestId(`sidebar-member-menu-${agentPubkey}`);
await row.hover();
await menu.focus();
await menu.press("Enter");
await page.getByTestId(`sidebar-edit-respond-to-${agentPubkey}`).click();
await expect(
page.getByRole("dialog", { name: "Manage agent access" }),
).toBeVisible();
}
test("open agent access explains the available access before save", async ({
page,
}) => {
const agent = TEST_IDENTITIES.charlie;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Hack Day Helper",
status: "running",
channelNames: ["general"],
respondTo: "anyone",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
const accessBadge = page.getByTestId(
`sidebar-managed-agent-respond-to-${agent.pubkey}`,
);
await expect(accessBadge).toBeVisible();
await expect(accessBadge).toHaveText("Anyone");
await openAgentAccessDialog(page, agent.pubkey);
const accessSelect = page.getByTestId("agent-respond-to-select");
await expect(accessSelect).toHaveValue("anyone");
const saveAccess = page.getByRole("button", { name: "Save access" });
await expect(saveAccess).toBeVisible();
const commandsBeforeSave = await page.evaluate(
() => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0,
);
await accessSelect.selectOption("owner-only");
await expect(page.getByTestId("agent-access-warning")).toHaveCount(0);
await waitForAnimations(page);
await page
.getByRole("dialog", { name: "Manage agent access" })
.screenshot({ path: `${SHOTS}/open-access-warning.png` });
await saveAccess.click();
await expect(
page.getByRole("dialog", { name: "Manage agent access" }),
).not.toBeVisible();
await expect
.poll(async () =>
page.evaluate((start) => {
const commands = window.__BUZZ_E2E_COMMAND_LOG__ ?? [];
return commands
.slice(start)
.some(
(entry) =>
entry.command === "update_managed_agent" &&
(entry.payload as { input?: { respondTo?: string } })?.input
?.respondTo === "owner-only",
);
}, commandsBeforeSave),
)
.toBe(true);
await expect(accessBadge).toHaveText("Only me");
await openAgentAccessDialog(page, agent.pubkey);
await expect(accessSelect).toHaveValue("owner-only");
// Selected people narrows the audience but not the access, so the warning
// persists with its own audience phrase.
await accessSelect.selectOption("allowlist");
const warning = page.getByTestId("agent-access-warning");
await expect(warning).toBeVisible();
await expect(warning).toContainText(
"Selected people can use this agent to access your computer, including files, accounts, and connected tools.",
);
const picker = page.getByTestId("agent-respond-to-allowlist");
await expect(
picker.getByText("Selected people", { exact: true }),
).toBeVisible();
// The warning sits below the picker so it never blocks the selection the
// user came here to make.
await waitForAnimations(page);
const pickerBox = await picker.boundingBox();
const warningBox = await warning.boundingBox();
expect(pickerBox?.y).toBeDefined();
expect(warningBox?.y).toBeGreaterThan(pickerBox?.y ?? 0);
await page
.getByRole("dialog", { name: "Manage agent access" })
.screenshot({ path: `${SHOTS}/selected-people-warning.png` });
// Compact-variant clipboard regression (D1a): the owner hint's compact
// PubKey must expand to and copy the viewer's complete canonical npub —
// the truncated trigger is only a recognition aid. The real bridge writes
// the browser clipboard and the poll reads it back.
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page
.getByTestId("agent-respond-to")
.getByRole("button", { name: "Show full public key" })
.click();
const copyNpubButton = page.getByRole("button", { name: "Copy npub" });
await copyNpubButton.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(npubEncode("deadbeef".repeat(8)));
await expect(
page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }),
).toBeVisible();
// Dismiss just the key popover: the access dialog stays open for the
// remaining mode assertions below.
await page.keyboard.press("Escape");
await expect(copyNpubButton).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Manage agent access" }),
).toBeVisible();
// Only me shares nothing, so the warning goes away entirely.
await accessSelect.selectOption("owner-only");
await expect(warning).toHaveCount(0);
});
test("full agent editor tightens the exact sidebar agent instance", async ({
page,
}) => {
const agent = TEST_IDENTITIES.tyler;
const preferredSiblingPubkey = "d".repeat(64);
const personaId = "shared-sidebar-agent";
await installMockBridge(page, {
acpRuntimesCatalog: [
{
availability: "available",
command: "goose",
default_args: [],
id: "goose",
install_hint: "",
label: "Goose",
mcp_command: "",
},
],
globalAgentConfig: {
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" },
model: "claude-opus-4-5",
preferred_runtime: "goose",
provider: "anthropic",
},
managedAgents: [
{
pubkey: agent.pubkey,
name: "Tyler Agent",
personaId,
status: "running",
channelNames: ["general"],
respondTo: "anyone",
},
{
pubkey: preferredSiblingPubkey,
name: "Preferred Sibling",
personaId,
status: "running",
channelNames: [],
respondTo: "anyone",
},
],
personas: [
{
displayName: "Shared Sidebar Agent",
id: personaId,
isActive: true,
respondTo: "anyone",
runtime: "goose",
systemPrompt: "Test exact instance editing.",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
const accessBadge = page.getByTestId(
`sidebar-managed-agent-respond-to-${agent.pubkey}`,
);
await expect(accessBadge).toHaveText("Anyone");
await page.getByTestId(`sidebar-member-${agent.pubkey}`).click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await page.getByTestId("user-profile-edit-agent").click();
const dialog = page.getByRole("dialog", { name: "Edit agent" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Advanced" }).click();
await choosePersonaAccess(page, "Only me (default)");
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
const saveChanges = dialog.getByRole("button", { name: "Save changes" });
await expect(saveChanges).toBeEnabled();
await saveChanges.click();
await expect(dialog).not.toBeVisible();
const updateCommand = await page.evaluate(
(pubkey) =>
window.__BUZZ_E2E_COMMAND_LOG__?.findLast(
(entry) =>
entry.command === "update_managed_agent" &&
(entry.payload as { input?: { pubkey?: string } })?.input?.pubkey ===
pubkey,
),
agent.pubkey,
);
expect(updateCommand?.payload).toMatchObject({
input: { pubkey: agent.pubkey, respondTo: "owner-only" },
});
expect(updateCommand?.payload).not.toMatchObject({
input: { pubkey: preferredSiblingPubkey },
});
await page.getByTestId("channel-members-trigger").click();
await expect(accessBadge).toHaveText("Only me");
// The definition still says "anyone" after the instance-only save above.
// Reopening the same linked agent for an unrelated prompt edit must seed
// access from the exact instance, or the submit silently widens it again.
await page.getByTestId(`sidebar-member-${agent.pubkey}`).click();
await page.getByTestId("user-profile-edit-agent").click();
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Advanced" }).click();
await expect(page.locator("#agent-respond-to")).toHaveText(
"Only me (default)",
);
await page
.locator("#persona-system-prompt")
.fill("Test unrelated prompt editing after tightening access.");
await dialog.getByRole("button", { name: "Save changes" }).click();
await expect(dialog).not.toBeVisible();
const unrelatedEditCommand = await page.evaluate(
(pubkey) =>
window.__BUZZ_E2E_COMMAND_LOG__?.findLast(
(entry) =>
entry.command === "update_managed_agent" &&
(entry.payload as { input?: { pubkey?: string } })?.input?.pubkey ===
pubkey,
),
agent.pubkey,
);
expect(unrelatedEditCommand?.payload).toMatchObject({
input: { pubkey: agent.pubkey },
});
expect(unrelatedEditCommand?.payload).not.toMatchObject({
input: { respondTo: "anyone" },
});
});
test("a provider-backed agent's warning names the server, not this computer", async ({
page,
}) => {
const agent = TEST_IDENTITIES.charlie;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Remote Helper",
status: "running",
channelNames: ["general"],
respondTo: "owner-only",
backend: { type: "provider", id: "blox", config: {} },
},
],
});
await page.goto("/");
await openAgentAccessDialog(page, agent.pubkey);
await page.getByTestId("agent-respond-to-select").selectOption("anyone");
const warning = page.getByTestId("agent-access-warning");
await expect(warning).toContainText(
"Anyone can use this agent to access the server it runs on, including any accounts and tools available there.",
);
// The local wording must not leak into a remote-backed agent.
await expect(warning).not.toContainText("your computer");
});
test("persona-backed edit warns before saving open access", async ({
page,
}) => {
const agent = TEST_IDENTITIES.tyler;
await installMockBridge(page, {
managedAgents: [
{
pubkey: agent.pubkey,
name: "Tyler Agent",
status: "stopped",
channelNames: ["agents"],
respondTo: "owner-only",
},
],
});
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "Tyler Agent agent profile" }).click();
await page.getByTestId("user-profile-edit-agent").click();
const dialog = page.getByTestId("edit-agent-dialog");
await expect(dialog).toBeVisible();
await expect(page.locator("#agent-respond-to")).toHaveText(
"Only me (default)",
);
await choosePersonaAccess(page, "Anyone");
await expect(dialog.getByTestId("agent-access-warning")).toContainText(
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
);
const commandsBeforeSave = await page.evaluate(
() => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0,
);
await page.getByTestId("edit-agent-dialog-submit").click();
await expect(dialog).not.toBeVisible();
await expect
.poll(async () =>
page.evaluate((start) => {
const commands = window.__BUZZ_E2E_COMMAND_LOG__ ?? [];
return commands
.slice(start)
.some(
(entry) =>
entry.command === "update_managed_agent" &&
(entry.payload as { input?: { respondTo?: string } })?.input
?.respondTo === "anyone",
);
}, commandsBeforeSave),
)
.toBe(true);
});