mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
🤖 ## 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>
364 lines
12 KiB
TypeScript
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);
|
|
});
|