From c70db47aaa7f3584bb615b41a24bb0d0b81cf165 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Sun, 8 Nov 2026 08:15:36 +1100 Subject: [PATCH 1/8] fix(builderlab): refresh capabilities at feature gates Cache capability checks for one minute, retain permissions on failure, and keep sign-in identity stable. Signed-off-by: Jarrod Sibbison Co-authored-by: Codex --- browser-host/builderlab.mjs | 17 +- browser-host/builderlab.test.mjs | 36 ++++ browser-host/relay-broker-api.test.mjs | 7 + browser-host/relay-broker.mjs | 2 + docs/buzz-help.md | 2 + src/bundled/builderlab/README.md | 13 +- .../builderlab/adapter-type/ModelSettings.tsx | 12 +- .../builderlab/agents/RemoteAgents.tsx | 10 +- src/bundled/builderlab/agents/client.test.ts | 38 ++-- src/bundled/builderlab/agents/client.ts | 15 +- src/bundled/builderlab/agents/turn-status.ts | 1 + src/bundled/builderlab/capabilities.ts | 74 +++++++ .../communities/HostedCommunities.tsx | 13 +- .../builderlab/communities/native.test.ts | 44 ++--- src/bundled/builderlab/communities/native.ts | 27 +-- src/bundled/builderlab/decisions.test.tsx | 4 + src/bundled/builderlab/decisions.tsx | 29 ++- src/bundled/builderlab/dev-session.test.ts | 47 +++++ src/bundled/builderlab/dev-session.ts | 47 ++++- src/bundled/builderlab/index.test.tsx | 40 ++-- src/bundled/builderlab/index.tsx | 10 +- .../builderlab/oauth/first-run.test.ts | 1 + src/bundled/builderlab/session.test.ts | 187 ++++++++++++++++++ src/bundled/builderlab/session.ts | 66 ++++++- src/bundled/builderlab/test-helpers.ts | 1 + 25 files changed, 606 insertions(+), 137 deletions(-) create mode 100644 src/bundled/builderlab/capabilities.ts diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 82fdd2840b..0634185f7c 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -290,7 +290,7 @@ export function createBuilderlab({ } throw new Error(`Builderlab request failed (HTTP ${response.status}).`); }; - const me = async (session, signal) => { + const me = async (session, signal, capabilitiesOnly = false) => { const started = generation; const response = await fetch(`${api}/v1/auth/me`, { headers: { "X-BB-Session-Credential": session }, @@ -300,6 +300,7 @@ export function createBuilderlab({ if (!response.ok) { if ( response.status === 401 && + !capabilitiesOnly && started === generation && credential === session ) @@ -308,6 +309,13 @@ export function createBuilderlab({ `Builderlab session check failed with HTTP ${response.status}`, ); } + if (capabilitiesOnly) { + const value = await response.json(); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Builderlab returned invalid capabilities"); + const { capabilities } = value; + return { capabilities }; + } const { subject, email, name, expires_at, capabilities } = await response.json(); if ( @@ -368,6 +376,13 @@ export function createBuilderlab({ listeners.add(listener); return () => listeners.delete(listener); }, + async capabilities() { + if (!credential) throw new Error("Sign in to Builderlab first"); + const started = generation; + const result = await me(credential, session.signal, true); + if (started !== generation) throw new Error("Builderlab session changed"); + return result; + }, async auth() { const current = credential; if (!current) return null; diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 7ace78e925..da316a936d 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -924,3 +924,39 @@ it("redacts the login credential before publishing a shared failure or returning h.builderlab.signOut(); } }); + +it("keeps the broker sign-in and account projection when a capability-only check is refused", async () => { + let status = 200; + let invalid = false; + const h = account({ + "/v1/auth/me": () => + Response.json( + invalid + ? [] + : { + subject: "user", + email: "a@example.org", + expires_at: "2030", + capabilities: { can_use_remote_agents: true }, + }, + { status }, + ), + }); + await signIn(h); + const previous = h.builderlab.snapshot(); + status = 401; + await expect(h.builderlab.capabilities()).rejects.toThrow("HTTP 401"); + expect(h.builderlab.snapshot()).toEqual(previous); + status = 200; + invalid = true; + await expect(h.builderlab.capabilities()).rejects.toThrow( + "invalid capabilities", + ); + expect(h.builderlab.snapshot()).toEqual(previous); + invalid = false; + await expect(h.builderlab.capabilities()).resolves.toEqual({ + capabilities: { can_use_remote_agents: true }, + }); + expect(h.builderlab.snapshot()).toEqual(previous); + h.builderlab.signOut(); +}); diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index aef364d5a1..c2b9f29aa6 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -4165,6 +4165,13 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat const { revision } = JSON.parse( event.slice("data: ".length).split("\n\n")[0], ); + const capabilityCheck = await fetch( + `${h.base}/api/builderlab/capabilities`, + ); + expect(capabilityCheck.status).toBe(200); + expect(await capabilityCheck.json()).toEqual({ + capabilities: { can_use_remote_agents: true }, + }); const body = JSON.stringify({ agent_pubkey: "a".repeat(64), agent_instructions: instructions, diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 3f552422bc..2abe4dddb9 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -994,6 +994,8 @@ export function relayBrokerPlugin({ send(builderlab.snapshot()); return; } + if (action === "capabilities" && req.method === "GET") + return json(res, 200, await builderlab.capabilities()); if (action === "auth" && req.method === "GET") return json(res, 200, { auth: await builderlab.auth() }); if (req.method !== "POST") diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 0d3af642dd..7442240bfc 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -745,6 +745,8 @@ available rather than silently discarding it. depend on the type. Do not assume a provider is configured or signed in. Builderlab shows **Unavailable for this account** when signed in without remote-agent access. Manage its account in **Settings → Builderlab**. + Opening Builderlab settings or checking a gated feature refreshes permissions + at most once a minute; a failed check keeps the previous permissions. Claude runs with an Anthropic API key pasted under **Anthropic API key** there, once for every Claude agent. When creating a Claude agent or editing one that runs on this computer, click **Add key** beside **Harness** to open diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index 3bd13759d9..0f85a9a2e8 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -175,6 +175,9 @@ agents are not migrated into this type. Its sign-out lives in Identity → Organization sign-in, which `login/` links to, because community sign-in shares the session. In live development it projects the development broker's sign-in instead, through `brokerApi`. +- `capabilities.ts` shares a demand-driven, one-minute permission refresh across + capability gates. Concurrent checks join one request; failed attempts keep + cached values and wait a minute before another attempt. No polling is added. - `oauth/` names the build's Builderlab API (`oauthTarget`). `oauth/first-run.ts` lets the app's first-launch welcome screen run the same native NIP-FI sign-in before the plugin's JavaScript starts; the app's later calls go @@ -237,16 +240,18 @@ The login snapshot retains `capabilities.can_use_remote_agents`; only explicit `true` enables remote agents. Otherwise, Harness defaults and Builderlab Settings show “Unavailable for this account”; the provider dialog explains the same restriction. The Harness defaults row has no action. Account management stays in -Settings → Builderlab, and no Beekeeper requests are sent. Sign out and back in -to refresh this capability. +Settings → Builderlab, and no Beekeeper requests are sent. Permission checks +refresh capabilities from `/v1/auth/me` with a one-minute cache shared by the +plugin; failed checks retain the previous values. Only capabilities change; +account identity and display details remain those read at sign-in. The plugin also offers Builderlab as a Decisions API provider. It reads `capabilities.can_use_builderlab_decisions` the same way, but only explicit `true` registers the provider, and only while the user is signed in. Otherwise Builderlab is not listed in Settings > Decisions API and no decision request goes to it. While it is registered, it answers unless the user chose another -provider; when it goes away, TypeSafe answers. Sign out and back in to refresh -this capability. The plugin names the NIP-FI session by its revision, and +provider; when it goes away, TypeSafe answers. It uses the same capability refresh +cache. The plugin names the NIP-FI session by its revision, and native `decide` posts to the identity origin's API with that session as `Authorization: Bearer`, as for the build's other Builderlab calls; the token never reaches the webview. A 401 there refuses only that call, and diff --git a/src/bundled/builderlab/adapter-type/ModelSettings.tsx b/src/bundled/builderlab/adapter-type/ModelSettings.tsx index 6288294890..2062c07170 100644 --- a/src/bundled/builderlab/adapter-type/ModelSettings.tsx +++ b/src/bundled/builderlab/adapter-type/ModelSettings.tsx @@ -1,11 +1,5 @@ -import { - useEffect, - useId, - useRef, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; +import { useCapabilities } from "../capabilities"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AgentSaveButton } from "../../../features/agents/AgentSaveButton"; import { ModelFamilyMenu } from "../../../features/agents/ModelFamilyMenu"; @@ -65,7 +59,7 @@ export function ModelSettings({ | { agent: RemoteAgent; subject: string; email?: string } | { pending: boolean; error: unknown; retry(): void }; }) { - const login = useSyncExternalStore(session.subscribe, session.snapshot); + const login = useCapabilities(session); const [retrySignIn, setRetrySignIn] = useState< "switching" | "waiting" | "ready" >(); diff --git a/src/bundled/builderlab/agents/RemoteAgents.tsx b/src/bundled/builderlab/agents/RemoteAgents.tsx index c34f10c296..56f17a5b39 100644 --- a/src/bundled/builderlab/agents/RemoteAgents.tsx +++ b/src/bundled/builderlab/agents/RemoteAgents.tsx @@ -1,9 +1,5 @@ -import { - useEffect, - useEffectEvent, - useState, - useSyncExternalStore, -} from "react"; +import { useCapabilities } from "../capabilities"; +import { useEffect, useEffectEvent, useState } from "react"; import { ArrowsClockwiseIcon, RobotIcon, @@ -24,7 +20,7 @@ export function RemoteAgents({ session: BuilderlabSession; active(): boolean; }) { - const login = useSyncExternalStore(session.subscribe, session.snapshot); + const login = useCapabilities(session); const [revision, setRevision] = useState(0); if (login.status === "signed-in" && !login.account?.canUseRemoteAgents) return ( diff --git a/src/bundled/builderlab/agents/client.test.ts b/src/bundled/builderlab/agents/client.test.ts index b42c9f4ec7..04a25e1f6b 100644 --- a/src/bundled/builderlab/agents/client.test.ts +++ b/src/bundled/builderlab/agents/client.test.ts @@ -38,6 +38,7 @@ async function fixture(subject = "user", canUseRemoteAgents = true) { email: "a@example.com", canUseRemoteAgents, canUseBuilderlabDecisions: false, + canDeleteBuzzCommunities: false, }, }), undefined, @@ -207,22 +208,33 @@ it.each(["sign-out", "new-login", "cancel", "dispose"])( async (action) => { const h = await fixture(); const held = deferred(); - vi.mocked(h.host.request).mockReturnValue(held.promise); + const started = deferred(); + vi.mocked(h.host.request).mockImplementation(() => { + started.resolve(); + return 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(); + const rejected = expect(pending).rejects.toMatchObject({ + name: "AbortError", + }); + await started.promise; + try { + if (action === "cancel") controller.abort(); + else if (action === "dispose") h.session.dispose(); + else { + h.session.signOut(); + if (action === "new-login") await h.session.signIn(); + } + } finally { + held.resolve( + response( + { status: 1, agents: [row] }, + action === "new-login" ? 401 : 200, + ), + ); } - held.resolve( - response( - { status: 1, agents: [row] }, - action === "new-login" ? 401 : 200, - ), - ); - await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await rejected; if (action === "new-login") expect(h.session.snapshot().status).toBe("signed-in"); }, diff --git a/src/bundled/builderlab/agents/client.ts b/src/bundled/builderlab/agents/client.ts index 9bb4048da6..b4edb7710f 100644 --- a/src/bundled/builderlab/agents/client.ts +++ b/src/bundled/builderlab/agents/client.ts @@ -160,7 +160,12 @@ export function createAgentClient( signal: AbortSignal, ): Promise> { signal.throwIfAborted(); - const credential = remoteCredential(); + const credential = session.credential(); + const refresh = session.refreshCapabilities(); + // Stop is urgent; refresh its cache without delaying cancellation. + if (path !== "cancel-agent-turn") await refresh; + check(credential, signal); + remoteCredential(); let response: HostResponse; try { response = await session.request(credential, { @@ -202,7 +207,8 @@ export function createAgentClient( body: unknown, signal: AbortSignal, ) { - const credential = remoteCredential(); + signal.throwIfAborted(); + const credential = session.credential(); return retryRead( (signal) => { check(credential, signal); @@ -583,7 +589,10 @@ export function createAgentClient( signal.throwIfAborted(); const agentName = name.trim(); if (!AGENT_NAME.test(agentName)) throw new Error(INVALID_NAME); - const credential = remoteCredential(); + const credential = session.credential(); + await session.refreshCapabilities(); + check(credential, signal); + remoteCredential(); const { account } = credential; const intent = registrationIntent( oauthTarget(), diff --git a/src/bundled/builderlab/agents/turn-status.ts b/src/bundled/builderlab/agents/turn-status.ts index fedcadd55b..35e9b7d567 100644 --- a/src/bundled/builderlab/agents/turn-status.ts +++ b/src/bundled/builderlab/agents/turn-status.ts @@ -30,6 +30,7 @@ export function startTurnStatusRecovery({ function attach() { const connection = relay.snapshot(); const signedIn = session.snapshot(); + if (signedIn.status === "signed-in") void session.refreshCapabilities(); const next = connection.status === "ready" && !connection.cached && diff --git a/src/bundled/builderlab/capabilities.ts b/src/bundled/builderlab/capabilities.ts new file mode 100644 index 0000000000..82fe25f2d1 --- /dev/null +++ b/src/bundled/builderlab/capabilities.ts @@ -0,0 +1,74 @@ +import { useEffect, useSyncExternalStore } from "react"; +import type { BuilderlabSession } from "./session"; + +export type Capabilities = Readonly<{ + canUseRemoteAgents: boolean; + canUseBuilderlabDecisions: boolean; + canDeleteBuzzCommunities?: boolean; +}>; + +/** Refresh only permissions. Failed attempts also cool down for one minute. */ +export function createCapabilityRefresh( + previous: Capabilities, + read: () => Promise, + update: (capabilities: Capabilities) => void, +) { + let checkedAt = Date.now(); + let pending: Promise | undefined; + return function refresh(): Promise { + if (pending) return pending; + if (Date.now() - checkedAt < 60_000) return Promise.resolve(); + checkedAt = Date.now(); + pending = (async () => { + try { + const value = await read(); + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const capabilities = (value as { capabilities?: unknown }).capabilities; + if ( + capabilities !== undefined && + (!capabilities || + typeof capabilities !== "object" || + Array.isArray(capabilities)) + ) + return; + const flags = capabilities as Record | undefined; + const next: Capabilities = { + canUseRemoteAgents: flags?.can_use_remote_agents === true, + canUseBuilderlabDecisions: + flags?.can_use_builderlab_decisions === true, + canDeleteBuzzCommunities: flags?.can_delete_buzz_communities === true, + }; + if ( + next.canUseRemoteAgents === previous.canUseRemoteAgents && + next.canUseBuilderlabDecisions === + previous.canUseBuilderlabDecisions && + next.canDeleteBuzzCommunities === previous.canDeleteBuzzCommunities + ) + return; + previous = next; + update(next); + } catch { + // A failed capability check does not end sign-in or erase permissions. + } finally { + pending = undefined; + } + })(); + return pending; + }; +} + +const signedOut = Object.freeze({ status: "signed-out" as const }); +const noSubscribe = () => () => {}; +const noSnapshot = () => signedOut; + +/** Refresh on entry to permission-gated surfaces, including disabled ones. */ +export function useCapabilities(session?: BuilderlabSession) { + const state = useSyncExternalStore( + session?.subscribe ?? noSubscribe, + session?.snapshot ?? noSnapshot, + ); + useEffect(() => { + if (state.status === "signed-in") void session?.refreshCapabilities(); + }, [session, state]); + return state; +} diff --git a/src/bundled/builderlab/communities/HostedCommunities.tsx b/src/bundled/builderlab/communities/HostedCommunities.tsx index 933d1ddf19..b02720d75f 100644 --- a/src/bundled/builderlab/communities/HostedCommunities.tsx +++ b/src/bundled/builderlab/communities/HostedCommunities.tsx @@ -1,3 +1,5 @@ +import type { BuilderlabSession } from "../session"; +import { useCapabilities } from "../capabilities"; import { SettingsGroup } from "../../../shared/design-system/ui/SettingsGroup"; import { type ReactNode, @@ -70,12 +72,15 @@ export function HostedCommunities({ api, active, sharedSignIn = false, + session, }: { api: Api; active(): boolean; /** The surrounding page owns sign-in, sign-out and the account row. */ sharedSignIn?: boolean; + session?: BuilderlabSession; }) { + const login = useCapabilities(session); const activeRef = useRef(active); activeRef.current = active; const [auth, setAuth] = useState(); @@ -382,6 +387,7 @@ export function HostedCommunities({ // Acting requires this device's key to be known and to match the account's. const ready = bound !== null && bound === local; const deletionEnabled = + login.account?.canDeleteBuzzCommunities ?? auth?.capabilities?.can_delete_buzz_communities === true; // A handoff belongs to the bound identity: whenever it changes, by a local // action or a refresh, drop the handoff and any clipboard result in flight. @@ -636,7 +642,12 @@ export function HostedCommunities({ variant="ghost" size="sm" disabled={busy} - onClick={() => void run("refresh", () => load(true))} + onClick={() => + void run("refresh", async () => { + await session?.refreshCapabilities(); + return load(true); + }) + } >