diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index ea9dc1ef6..7fe6ea646 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -8,6 +8,9 @@ The bundled `block.builderlab` plugin adds Settings → Integrations → Builder sign-out, and disposal. - `login/` owns the login experience: pending state, cancellation, retry, email display, and sign-out. +- `agents/` lists the signed-in account's remote agents beneath login. Requests + reuse the OAuth credential through native host HTTP; sign-out and navigation + discard late results. Listing does not enroll agents into Buzz communities. The plugin uses the `BUZZ_BUILDERLAB_URL` [build input](../../../docs/configuration.md#builderlab-url-build-input) as its server address and appends `/api/goose`. diff --git a/src/bundled/builderlab/agents/RemoteAgents.test.tsx b/src/bundled/builderlab/agents/RemoteAgents.test.tsx new file mode 100644 index 000000000..a1988025f --- /dev/null +++ b/src/bundled/builderlab/agents/RemoteAgents.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { HostResponse } from "../../../features/host/service"; +import { createOAuthSession } from "../oauth/session"; +import { deferred } from "../test-helpers"; +import { createAgentClient } from "./client"; +import { RemoteAgents } from "./RemoteAgents"; + +afterEach(cleanup); +const row = { + agent_id: "one", + agent_name: "Helper", + agent_pubkey: "ab".repeat(32), + status: 2, +}; +const response = (agents: unknown[], status = 200): HostResponse => ({ + status, + headers: {}, + body: JSON.stringify({ status: 1, agents }), +}); +async function fixture() { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const session = createOAuthSession(async () => ({ + value: "secret", + account: { email: "a@example.com" }, + })); + await session.signIn(); + const request = vi.fn(async () => response([row])); + const client = createAgentClient({ request, runCommand: vi.fn() }, session); + return { session, client, request }; +} +afterEach(() => vi.unstubAllEnvs()); +it("loads automatically, refreshes and hides account data on sign-out", async () => { + const h = await fixture(); + render( true} />); + expect(await screen.findByText("Helper · Active")).toBeInTheDocument(); + expect(screen.getByText(row.agent_pubkey)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("secret"); + h.request.mockResolvedValue(response([])); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Refresh agents" })); + expect(await screen.findByText("No remote agents yet.")).toBeInTheDocument(); + act(() => h.session.signOut()); + expect( + screen.queryByRole("region", { name: "Remote agents" }), + ).not.toBeInTheDocument(); +}); +it("shows a held loading state and retries a failed read", async () => { + const h = await fixture(); + const held = deferred(); + h.request.mockReturnValueOnce(held.promise); + render( true} />); + expect(screen.getByRole("status")).toHaveTextContent("Loading remote agents"); + expect( + screen.getByRole("button", { name: "Refresh agents" }), + ).toHaveAttribute("aria-disabled", "true"); + await act(async () => held.resolve(response([], 503))); + expect(screen.getByRole("alert")).toHaveTextContent("HTTP 503"); + await userEvent.setup().click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("Helper · Active")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); +}); +it.each(["sign-out", "unmount"])( + "ignores held reads after %s", + async (action) => { + const h = await fixture(); + const held = deferred(); + h.request.mockReturnValue(held.promise); + const mounted = render( true} />); + if (action === "sign-out") act(() => h.session.signOut()); + else mounted.unmount(); + await act(async () => held.resolve(response([row]))); + expect(screen.queryByText("Helper · Active")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }, +); diff --git a/src/bundled/builderlab/agents/RemoteAgents.tsx b/src/bundled/builderlab/agents/RemoteAgents.tsx new file mode 100644 index 000000000..9e845cdf7 --- /dev/null +++ b/src/bundled/builderlab/agents/RemoteAgents.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { Button } from "../../../shared/design-system/ui/Button"; +import type { LoginSnapshot, OAuthSession } from "../oauth/session"; +import type { AgentClient, RemoteAgent } from "./client"; + +export function RemoteAgents({ + client, + session, + active, +}: { + client: AgentClient; + session: OAuthSession; + active(): boolean; +}) { + const login = useSyncExternalStore(session.subscribe, session.snapshot); + return login.status === "signed-in" ? ( + + ) : null; +} + +function AgentList({ + client, + account, + active, +}: { + client: AgentClient; + account: LoginSnapshot["account"]; + active(): boolean; +}) { + const [revision, setRevision] = useState(0); + const [agents, setAgents] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + // biome-ignore lint/correctness/useExhaustiveDependencies: Account changes and explicit refreshes must restart the read. + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setAgents(undefined); + setError(undefined); + void client.list(controller.signal).then( + (rows) => { + if (!controller.signal.aborted) { + setAgents(rows); + setLoading(false); + } + }, + (reason) => { + if (!controller.signal.aborted) { + setError( + reason instanceof Error ? reason.message : "Could not load agents.", + ); + setLoading(false); + } + }, + ); + return () => controller.abort(); + }, [client, account, revision]); + return ( +
+

Remote agents

+ {loading && ( +

+ Loading remote agents… +

+ )} + {error && ( +

+ {error} +

+ )} + {agents?.length === 0 && ( +

No remote agents yet.

+ )} + {agents && agents.length > 0 && ( +
    + {agents.map((agent) => ( +
  • +
    + {agent.name} · {agent.status} +
    + + {agent.pubkey} + +
  • + ))} +
+ )} +
+ +
+
+ ); +} diff --git a/src/bundled/builderlab/agents/client.test.ts b/src/bundled/builderlab/agents/client.test.ts new file mode 100644 index 000000000..04efb85db --- /dev/null +++ b/src/bundled/builderlab/agents/client.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Host, HostResponse } from "../../../features/host/service"; +import { createOAuthSession } from "../oauth/session"; +import { deferred } from "../test-helpers"; +import { createAgentClient } from "./client"; + +beforeEach(() => + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"), +); +afterEach(() => vi.unstubAllEnvs()); +const row = { + agent_id: "agent-1", + agent_name: "Helper", + agent_pubkey: "ab".repeat(32), + status: "AGENT_STATUS_ACTIVE", +}; +const response = (value: unknown, status = 200): HostResponse => ({ + status, + headers: {}, + body: JSON.stringify(value), +}); +async function fixture() { + const session = createOAuthSession(async () => ({ + value: "secret", + account: { email: "a@example.com" }, + })); + await session.signIn(); + const host: Host = { + runCommand: vi.fn(), + request: vi.fn(async () => + response({ status: "LIST_AGENTS_STATUS_SUCCESS", agents: [row] }), + ), + }; + return { + session, + host, + client: createAgentClient(host, session), + signal: new AbortController().signal, + }; +} +it("lists the authenticated account through the configured native host", async () => { + const h = await fixture(); + expect(await h.client.list(h.signal)).toEqual([ + { + id: row.agent_id, + name: row.agent_name, + pubkey: row.agent_pubkey, + status: "Active", + }, + ]); + expect(h.host.request).toHaveBeenCalledWith({ + url: "https://builderlab.example/api/goose/v3/beekeeper/list-agents", + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-BB-Session-Credential": "secret", + }, + body: "{}", + }); +}); +it.each([{}, { agents: [] }])( + "accepts an empty protobuf list %j", + async (value) => { + const h = await fixture(); + vi.mocked(h.host.request).mockResolvedValue( + response({ status: 1, ...value }), + ); + expect(await h.client.list(h.signal)).toEqual([]); + }, +); +it.each([ + ["AGENT_STATUS_UNATTESTED", "Unattested"], + [1, "Unattested"], + ["AGENT_STATUS_REVOKED", "Revoked"], + [3, "Revoked"], + ["FUTURE_STATUS", "Unknown"], + [undefined, "Unknown"], +])( + "handles agent status %j without treating unknown as active", + async (status, expected) => { + const h = await fixture(); + vi.mocked(h.host.request).mockResolvedValue( + response({ status: 1, agents: [{ ...row, status }] }), + ); + expect((await h.client.list(h.signal))[0]?.status).toBe(expected); + }, +); +it.each([ + null, + { status: 2 }, + { status: 1, agents: {} }, + { status: 1, agents: [null] }, + { status: 1, agents: [{ ...row, agent_pubkey: "bad" }] }, +])("rejects invalid responses %j", async (value) => { + const h = await fixture(); + vi.mocked(h.host.request).mockResolvedValue(response(value)); + await expect(h.client.list(h.signal)).rejects.toThrow( + /invalid|did not return/, + ); +}); +it.each([401, 403, 500])( + "reports HTTP %s without disclosing the provider body", + async (status) => { + const h = await fixture(); + vi.mocked(h.host.request).mockResolvedValue( + response({ error: "private-secret" }, status), + ); + const error = await h.client.list(h.signal).catch((error: Error) => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain("private-secret"); + expect(h.session.snapshot().status).toBe( + status === 401 ? "signed-out" : "signed-in", + ); + }, +); +it.each(["sign-out", "new-login", "cancel", "dispose"])( + "discards a held result after %s", + async (action) => { + const h = await fixture(); + const held = deferred(); + vi.mocked(h.host.request).mockReturnValue(held.promise); + const controller = new AbortController(); + const pending = h.client.list(controller.signal); + if (action === "cancel") controller.abort(); + else if (action === "dispose") h.session.dispose(); + else { + h.session.signOut(); + if (action === "new-login") await h.session.signIn(); + } + held.resolve( + response( + { status: 1, agents: [row] }, + action === "new-login" ? 401 : 200, + ), + ); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + if (action === "new-login") + expect(h.session.snapshot().status).toBe("signed-in"); + }, +); +it("does not dispatch signed-out or canceled requests", async () => { + const h = await fixture(); + const controller = new AbortController(); + controller.abort(); + await expect(h.client.list(controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }); + h.session.signOut(); + await expect(h.client.list(h.signal)).rejects.toThrow("Sign in"); + expect(h.host.request).not.toHaveBeenCalled(); +}); diff --git a/src/bundled/builderlab/agents/client.ts b/src/bundled/builderlab/agents/client.ts new file mode 100644 index 000000000..474e7bf43 --- /dev/null +++ b/src/bundled/builderlab/agents/client.ts @@ -0,0 +1,105 @@ +import type { Host, HostResponse } from "../../../features/host/service"; +import { oauthTarget } from "../oauth/browser"; +import type { OAuthSession } from "../oauth/session"; + +export type RemoteAgent = Readonly<{ + id: string; + name: string; + pubkey: string; + status: "Active" | "Unattested" | "Revoked" | "Unknown"; +}>; + +function resultStatus(value: unknown) { + return typeof value === "string" + ? (value.split("_").at(-1) ?? "") + : typeof value === "number" + ? value + : ""; +} + +function agentStatus(value: unknown): RemoteAgent["status"] { + const status = String(value ?? "") + .split("_") + .at(-1); + if (status === "2" || status === "ACTIVE") return "Active"; + if (status === "1" || status === "UNATTESTED") return "Unattested"; + if (status === "3" || status === "REVOKED") return "Revoked"; + return "Unknown"; +} + +export function createAgentClient(host: Host, session: OAuthSession) { + async function request(path: string, body: unknown, signal: AbortSignal) { + signal.throwIfAborted(); + const credential = session.credential(); + const check = () => { + signal.throwIfAborted(); + if ( + session.snapshot().status !== "signed-in" || + session.credential() !== credential + ) + throw new DOMException("Builderlab session changed.", "AbortError"); + }; + let response: HostResponse; + try { + response = await host.request({ + url: `${oauthTarget()}/v3/beekeeper/${path}`, + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-BB-Session-Credential": credential.value, + }, + body: JSON.stringify(body), + }); + } catch { + check(); + throw new Error("Could not reach Builderlab. Try again."); + } + check(); + if (response.status === 401) { + session.signOut(); + throw new Error("Your Builderlab session expired. Sign in again."); + } + if (response.status === 403) + throw new Error("This Builderlab account cannot manage remote agents."); + if (response.status < 200 || response.status >= 300) + throw new Error(`Builderlab request failed (HTTP ${response.status}).`); + try { + const result = JSON.parse(response.body); + if (!result || typeof result !== "object" || Array.isArray(result)) + throw new Error(); + return result; + } catch { + throw new Error("Builderlab returned an invalid response."); + } + } + return { + async list(signal: AbortSignal): Promise { + const result = await request("list-agents", {}, signal); + if (![1, "SUCCESS"].includes(resultStatus(result.status))) + throw new Error("Builderlab did not return an agent list."); + // Protobuf JSON can omit an empty repeated field. + const agents = result.agents ?? []; + if ( + !Array.isArray(agents) || + agents.some( + (row) => + !row || + typeof row.agent_id !== "string" || + !row.agent_id.trim() || + typeof row.agent_name !== "string" || + typeof row.agent_pubkey !== "string" || + !/^[0-9a-f]{64}$/.test(row.agent_pubkey), + ) + ) + throw new Error("Builderlab returned an invalid agent list."); + return agents.map((row) => ({ + id: row.agent_id, + name: row.agent_name, + pubkey: row.agent_pubkey, + status: agentStatus(row.status), + })); + }, + }; +} +export type AgentClient = ReturnType; diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index e84ebff89..201e51508 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -14,7 +14,7 @@ import manifest from "./manifest.json"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); vi.mock("@tauri-apps/api/core", () => native); beforeEach(() => - vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"), + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"), ); afterEach(() => { cleanup(); @@ -55,8 +55,7 @@ it("an unconfigured desktop build shows setup guidance and cannot start login", } }); -it("wires login through the host and clears the session on disable/re-enable", async () => { - const origin = "https://app.builderlab.xyz"; +it("binds login and list to the plugin host and clears the session on disable/re-enable", async () => { native.invoke.mockImplementation(async (command, input) => { if (command === "oauth_callback_begin") return { @@ -73,10 +72,12 @@ it("wires login through the host and clears the session on disable/re-enable", a body: JSON.stringify( input.request.url.endsWith("/exchange") ? { session_credential: "private-token" } - : { - subject: "user", - email: "a@example.com", - }, + : input.request.url.endsWith("/list-agents") + ? { status: 1, agents: [] } + : { + subject: "user", + email: "a@example.com", + }, ), }; throw new Error(`Unexpected command ${command}`); @@ -108,39 +109,28 @@ it("wires login through the host and clears the session on disable/re-enable", a expect( await screen.findByRole("button", { name: "Sign out" }), ).toBeEnabled(); + expect( + await screen.findByText("No remote agents yet."), + ).toBeInTheDocument(); expect(screen.getByRole("status")).toHaveTextContent( "Signed in as a@example.com.", ); expect(document.body).not.toHaveTextContent("private-token"); - const begin = native.invoke.mock.calls.find( - ([command]) => command === "oauth_callback_begin", - )?.[1]; - expect(begin).not.toHaveProperty("id"); expect(native.invoke).toHaveBeenCalledWith("oauth_callback_wait", { id: "native-attempt-id", }); - expect(begin.callbackPath).toMatch(/^\/callback\/[0-9a-f-]{36}$/); - expect(begin.callbackParameter).toBe("returnTo"); - expect(begin.useState).toBe(false); - const authorization = new URL(begin.authorizationUrl); - expect(authorization.origin).toBe(origin); - expect(authorization.searchParams.has("returnTo")).toBe(false); - expect(authorization.searchParams.has("state")).toBe(false); const requests = native.invoke.mock.calls .filter(([command]) => command === "plugin_host_request") .map(([, input]) => input); - expect(requests).toHaveLength(2); - expect(requests.map((input) => input.request.url)).toEqual([ - `${origin}/api/goose/v1/auth/login/exchange`, - `${origin}/api/goose/v1/auth/me`, - ]); + expect(requests).toHaveLength(3); expect( requests.every( (input) => input.id === "block.builderlab" && input.revision === "bundled", ), ).toBe(true); - expect(requests[1].request.headers["X-BB-Session-Credential"]).toBe( + // Prove the account login supplies this consumer's credential through the real plugin wiring. + expect(requests.at(-1).request.headers["X-BB-Session-Credential"]).toBe( "private-token", ); await act(async () => { diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index a984e78c2..5b824c6d8 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -6,6 +6,8 @@ import { oauthTarget, } from "./oauth/browser"; import { createOAuthSession } from "./oauth/session"; +import { createAgentClient } from "./agents/client"; +import { RemoteAgents } from "./agents/RemoteAgents"; export const inject = ["host", "settingsCards"]; export const apply: PluginModule["apply"] = (ctx) => { @@ -22,21 +24,25 @@ export const apply: PluginModule["apply"] = (ctx) => { browserCredential(ctx.host, signal), ); ctx.effect(() => () => session.dispose()); + const agents = createAgentClient(ctx.host, session); ctx.settingsCards.register({ id: "login", title: "Builderlab", group: "Integrations", component: ({ active }) => ( - + <> + + + ), }); }; diff --git a/src/bundled/builderlab/oauth/browser.test.ts b/src/bundled/builderlab/oauth/browser.test.ts index 12208d0e2..0520e65c5 100644 --- a/src/bundled/builderlab/oauth/browser.test.ts +++ b/src/bundled/builderlab/oauth/browser.test.ts @@ -3,7 +3,7 @@ import type { Host } from "../../../features/host/service"; import { browserCredential, type BrowserBridge, oauthTarget } from "./browser"; import { deferred } from "../test-helpers"; beforeEach(() => - vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"), + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"), ); afterEach(() => vi.unstubAllEnvs()); @@ -38,13 +38,14 @@ function fixture() { return { host, bridge, controller: new AbortController() }; } it("acquires a verified credential through the code-only callback", async () => { - const origin = "https://app.builderlab.xyz"; + const origin = "https://builderlab.example"; const { host, bridge, controller } = fixture(); expect(await browserCredential(host, controller.signal, bridge)).toEqual({ value: "private-token", account: { email: "a@example.com" }, }); const options = vi.mocked(bridge.begin).mock.calls[0]?.[0]; + expect(options).not.toHaveProperty("id"); const login = new URL(options?.authorizationUrl ?? ""); expect(login.origin).toBe(origin); expect(login.pathname).toBe("/api/goose/v1/auth/login"); @@ -226,11 +227,11 @@ it.each(["begin", "wait", "exchange", "account"] as const)( ); it("uses the configured public URL without a deployment default", async () => { - expect(oauthTarget("https://app.builderlab.xyz/")).toBe( - "https://app.builderlab.xyz/api/goose", + expect(oauthTarget("https://builderlab.example/")).toBe( + "https://builderlab.example/api/goose", ); - expect(oauthTarget("https://app.builderlab.xyz/deployment/")).toBe( - "https://app.builderlab.xyz/deployment/api/goose", + expect(oauthTarget("https://builderlab.example/deployment/")).toBe( + "https://builderlab.example/deployment/api/goose", ); expect(oauthTarget("https://login.example:8443/deployment/")).toBe( "https://login.example:8443/deployment/api/goose", @@ -244,10 +245,10 @@ it("uses the configured public URL without a deployment default", async () => { expect(host.request).not.toHaveBeenCalled(); }); it.each([ - "http://app.builderlab.xyz", - "https://user:secret@app.builderlab.xyz", - "https://app.builderlab.xyz/?query=x", - "https://app.builderlab.xyz/#x", + "http://builderlab.example", + "https://user:secret@builderlab.example", + "https://builderlab.example/?query=x", + "https://builderlab.example/#x", ])("refuses unsafe configuration %s", (value) => { expect(() => oauthTarget(value)).toThrow("does not support"); });