mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Why Hack-day feedback exposed a dangerous mismatch between the UI and the underlying access model. The `Anyone` respond-to mode appeared as a neutral dropdown choice, while a Buzz agent may act with the files, accounts, and tools available on the machine where it runs. People reasonably read this as sharing a bot in a channel. The current UI did not explain that it can also share the agent's available access. ## What - Reframes `respond-to` as **agent access** in user-facing UI. - Uses plain audience labels: **Only me**, **Anyone**, and **Selected people**. - Warns for **both** sharing modes, not just `Anyone` — `Selected people` also hands host access to someone other than the owner, so only the audience phrase differs: > Anyone can use this agent to access your computer, including files, accounts, and connected tools. > Selected people can use this agent to access your computer, including files, accounts, and connected tools. - Names the machine the agent actually runs on. A provider-backed (remote) agent reads: > Anyone can use this agent to access the server it runs on, including any accounts and tools available there. The remote wording deliberately omits the owner's files — those aren't theirs to describe on a host they don't own. - Places the warning below the selector for `Anyone`, but **after** the people picker for `Selected people`, so it never sits between the user and the selection they came to make. - Removes Nostr, harness, pubkey, and `!shutdown` jargon from the primary decision copy. Direct pubkey entry remains available as an advanced path. - Replaces the green open-access avatar dot with an amber warning marker and accessible text. Selected access uses a separate blue status. - Aligns the sidebar action and profile field with the same language. - Records the shared-field disclosure contract in `desktop/src/features/agents/AGENTS.md` so future surfaces do not silently omit it. ## Design decisions **Persistent inline warning, not a confirmation modal.** The setting does not autosave; the consequence remains visible beside the selection until the person chooses **Save access**. This gives the information before commitment without adding a dismiss-and-confirm ritual that would repeat in every create/edit surface. **An unknown run location falls back to the local wording.** It does not hedge with "computer or server". A remote host requires an installed `buzz-backend-*` provider, and without one `WhereToRunSection` never renders — so "server" would name a concept the owner has never been shown. When it *is* remote, they picked that host from the selector themselves. Surfaces never synthesize a run location they don't have. **One resolution site, published through context.** `AgentDialog` resolves the run location (`runLocationForBackend` from `ManagedAgent.backend`, `runLocationForRunOn` from the create flow's `WhereToRunDraft`) and publishes it via `AgentRunLocationContext`. It is not threaded as a prop through `AgentDefinitionDialog` (1047 lines) or `AgentInstanceEditDialog` (1228 lines) — neither uses the value, and both are already over the file-size ceiling. Surfaces outside that tree (`EditRespondToDialog`) pass the prop directly. The copy follows the writing system's guidance for high-sensitivity decisions: lead with the material consequence, use plain actor/action language, keep helper text adjacent and persistent, and never rely on color alone. ## Scope Desktop only. The web and mobile clients do not currently expose this setting. No protocol, gate, runtime, persistence, or backend behavior changes. This does not add team-scoped remote agents. It makes the current local-or-remote access model honest while that product work remains separate. ## Validation - `pnpm exec biome check` and `pnpm exec tsc --noEmit` — clean - `lib/agentAccessWarning.test.mjs` (8/8) — every mode × run-location copy variant, both resolvers, unknown-reads-as-local, blank `runOn` is not a provider - `ui/respondToFieldContract.test.mjs` (8/8) — plain labels, both warning positions, source-order guard that the `allowlist` warning follows the picker, helper-not-inline-copy guard - `agent-access-warning.spec.ts` (3/3) — native local, provider-backed remote (asserts the server sentence and *not* "your computer"), persona-backed edit; includes a bounding-box check that the `Selected people` warning renders below the picker --------- Signed-off-by: David Hamilton <daveh@squareup.com> Signed-off-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
6.9 KiB
TypeScript
206 lines
6.9 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
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: "owner-only",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/");
|
|
await openAgentAccessDialog(page, agent.pubkey);
|
|
|
|
const accessSelect = page.getByTestId("agent-respond-to-select");
|
|
await expect(accessSelect).toHaveValue("owner-only");
|
|
await expect(page.getByTestId("agent-access-warning")).toHaveCount(0);
|
|
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("anyone");
|
|
const warning = page.getByTestId("agent-access-warning");
|
|
await expect(warning).toBeVisible();
|
|
await expect(warning).toContainText(
|
|
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
|
|
);
|
|
|
|
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 === "anyone",
|
|
);
|
|
}, commandsBeforeSave),
|
|
)
|
|
.toBe(true);
|
|
|
|
await openAgentAccessDialog(page, agent.pubkey);
|
|
await expect(accessSelect).toHaveValue("anyone");
|
|
// Selected people narrows the audience but not the access, so the warning
|
|
// persists with its own audience phrase.
|
|
await accessSelect.selectOption("allowlist");
|
|
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` });
|
|
|
|
// Only me shares nothing, so the warning goes away entirely.
|
|
await accessSelect.selectOption("owner-only");
|
|
await expect(warning).toHaveCount(0);
|
|
});
|
|
|
|
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);
|
|
});
|