From ef65a1c60b7797400ebcfa6ae1e738f9cf39f9bd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 31 Oct 2026 22:59:54 -0700 Subject: [PATCH 01/22] fix(builderlab): share the development server login across features Use the server-held Builderlab session for hosted communities, remote agents and Apps in broker-backed development. Project public session revisions into the existing sign-in UI, fence requests across account changes, and reuse the agents callback assets and OAuth protections. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 358 +++++++++++++++++---- browser-host/builderlab.test.mjs | 165 +++++++++- browser-host/relay-broker-api.test.mjs | 11 +- browser-host/relay-broker.d.mts | 2 + browser-host/relay-broker.mjs | 52 ++- docs/buzz-help.md | 4 + src-tauri/src/browser/builderlab.rs | 84 ++++- src/bundled/builderlab/apps/client.ts | 23 +- src/bundled/builderlab/apps/index.ts | 6 +- src/bundled/builderlab/dev-session.test.ts | 169 ++++++++++ src/bundled/builderlab/dev-session.ts | 257 +++++++++++++++ src/bundled/builderlab/index.test.tsx | 205 +++++++++--- src/bundled/builderlab/index.tsx | 86 ++--- src/bundled/builderlab/login/Login.tsx | 9 +- src/features/browser/api.ts | 6 +- src/features/browser/platform.ts | 8 +- vite.config.ts | 6 + 17 files changed, 1275 insertions(+), 176 deletions(-) create mode 100644 src/bundled/builderlab/dev-session.test.ts create mode 100644 src/bundled/builderlab/dev-session.ts diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 74c385306d..45242d883a 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -3,7 +3,11 @@ // in this Node process; the page only sees account metadata and API results. import { execFile } from "node:child_process"; import { createServer } from "node:http"; -import { randomUUID } from "node:crypto"; +import { randomUUID, randomBytes, createHash } from "node:crypto"; +import { readFileSync, openAsBlob } from "node:fs"; +import { realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { sep } from "node:path"; import { finalizeEvent } from "nostr-tools"; const API = "https://app.builderlab.xyz/api/goose"; @@ -12,8 +16,43 @@ export const BUILDERLAB_ORIGIN = "https://app.builderlab.xyz"; const LOGIN_TIMEOUT_MS = 10 * 60 * 1000; const MAX_RESPONSE_BYTES = 64 * 1024; const RESPONSE_STATUS = Symbol("builderlabResponseStatus"); -const COMPLETE_HTML = - "Buzz authentication complete

You're signed in. You can close this window and return to Buzz."; +// Use the agents' callback page and assets, without a second visual implementation. +const callbackRoot = new URL( + "../src-tauri/src/oauth_callback/", + import.meta.url, +); +function callbackPage(status) { + const read = (name) => readFileSync(new URL(name, callbackRoot)); + const style = read("page.css") + .toString() + .replace("{{font}}", read("inter-latin.woff2").toString("base64")); + const script = read("page.js").toString(); + const hash = (value) => createHash("sha256").update(value).digest("base64"); + return { + body: read("page.html") + .toString() + .replace("", ``) + .replace("", ``) + .replaceAll("{{complete}}", status === 200 ? "true" : "false") + .replaceAll("{{closeDelay}}", "3") + .replace( + "{{brand}}", + readFileSync( + new URL("../public/builderlab-mark.svg", import.meta.url), + ).toString(), + ) + .replace("{{artwork}}", read("cubes.webp").toString("base64")) + .replace( + "{{heading}}", + status === 200 ? "Signing you in…" : "Sign-in needs attention", + ) + .replace( + "{{message}}", + status === 200 ? "" : "Return to Buzz to retry sign-in.", + ), + policy: `default-src 'none'; img-src data:; font-src data:; style-src 'sha256-${hash(style)}'; script-src 'sha256-${hash(script)}'; base-uri 'none'; frame-ancestors 'none'`, + }; +} /** Account routes forwarded with allowlisted string fields only. */ const ROUTES = { @@ -40,7 +79,12 @@ export const builderlabResponseStatus = (value) => value?.[RESPONSE_STATUS] ?? 200; /** Signs the kind 24243 challenge exactly as block/buzz desktop does, after the same checks. */ -export function bindingEvent(key, challenge, now = Date.now()) { +export function bindingEvent( + key, + challenge, + now = Date.now(), + expectedOrigin = BUILDERLAB_ORIGIN, +) { const { challenge_id, nonce, verification_code, origin, expires_at } = challenge ?? {}; if ( @@ -52,7 +96,7 @@ export function bindingEvent(key, challenge, now = Date.now()) { !/^[A-Za-z0-9_-]{43}$/.test(nonce) || typeof verification_code !== "string" || !/^\d{6}$/.test(verification_code) || - origin !== BUILDERLAB_ORIGIN || + origin !== expectedOrigin || typeof expires_at !== "string" || !(Date.parse(expires_at) > now) ) @@ -88,9 +132,10 @@ const openBrowser = (url) => ); /** Waits for one loopback redirect carrying `code`, like the desktop callback listener. */ -function awaitCallback(signal, open) { +function awaitCallback(signal, open, api, verifier) { return new Promise((resolve, reject) => { const nonce = randomUUID().replaceAll("-", ""); + const state = randomUUID(); let settle; let settled = false; const server = createServer((req, res) => { @@ -108,19 +153,29 @@ function awaitCallback(signal, open) { res.writeHead(404).end("Not found"); return; } + const codes = url.searchParams.getAll("code"); + if ( + url.searchParams.getAll("state").length !== 1 || + url.searchParams.get("state") !== state || + (!url.searchParams.has("error") && + (codes.length !== 1 || !codes[0] || codes[0].length > 4096)) + ) { + res.writeHead(400).end("Invalid callback"); + return; + } + const failed = url.searchParams.has("error"); + const page = callbackPage(failed ? 400 : 200); res - .writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) - .end(COMPLETE_HTML); - const code = url.searchParams.get("code"); + .writeHead(failed ? 400 : 200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": page.policy, + }) + .end(page.body); settle( - code - ? null - : new Error( - url.searchParams.get("error_description") ?? - url.searchParams.get("error") ?? - "Authentication callback did not include a code", - ), - code, + failed ? new Error("Browser sign-in failed. Try again.") : null, + codes[0], ); }); const timer = setTimeout( @@ -143,10 +198,16 @@ function awaitCallback(signal, open) { server.once("error", (error) => settle(error)); server.listen(0, "127.0.0.1", () => { const returnTo = `http://127.0.0.1:${server.address().port}/callback/${nonce}`; - const login = new URL(`${API}/v1/auth/login`); + const login = new URL(`${api}/v1/auth/login`); login.searchParams.set("type", "cli"); - login.searchParams.set("product", "buzz"); - login.searchParams.set("returnTo", returnTo); + login.searchParams.set("product", "builderlab"); + login.searchParams.set("state", state); + login.searchParams.set( + "code_challenge", + createHash("sha256").update(verifier).digest("base64url"), + ); + login.searchParams.set("code_challenge_method", "S256"); + login.searchParams.set("redirect_uri", returnTo); open(login.href).catch((error) => settle(error)); }); }); @@ -156,21 +217,40 @@ export function createBuilderlab({ key, fetch = globalThis.fetch, open = openBrowser, + api = API, + appsBase = api === API ? "https://compose-ctrl.builderlab.xyz" : undefined, }) { let credential; + let account; + let pending = false; + let linking = Promise.resolve(); + let expiry; + let revision = randomUUID(); + const listeners = new Set(); + const snapshot = () => ({ revision, pending, auth: account ?? null }); + const notify = () => { + for (const listener of listeners) listener(snapshot()); + }; + const current = (expected) => { + if (!credential || expected !== revision) + throw new Error("Builderlab session changed"); + }; const request = async (path, body, session = credential) => { if (!session) throw new Error("Sign in to Builderlab first"); - const response = await fetch(`${API}${path}`, { + const started = generation; + const response = await fetch(`${api}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-BB-Session-Credential": session, - Origin: BUILDERLAB_ORIGIN, + Origin: new URL(api).origin, }, body: JSON.stringify(body), redirect: "error", signal: AbortSignal.timeout(60000), }); + if (started !== generation) throw new Error("Builderlab session changed"); + if (response.status === 401) revoke(); const text = await response.text(); if (Buffer.byteLength(text) > MAX_RESPONSE_BYTES) throw new Error("Builderlab response was too large"); @@ -191,21 +271,40 @@ export function createBuilderlab({ throw new Error(`Builderlab request failed (HTTP ${response.status}).`); }; const me = async (session, signal) => { - const response = await fetch(`${API}/v1/auth/me`, { + const started = generation; + const response = await fetch(`${api}/v1/auth/me`, { headers: { "X-BB-Session-Credential": session }, redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]), }); - if (!response.ok) + if (!response.ok) { + if ( + response.status === 401 && + started === generation && + credential === session + ) + revoke(); throw new Error( `Builderlab session check failed with HTTP ${response.status}`, ); - const { email, name, expires_at, capabilities } = await response.json(); + } + const { subject, email, name, expires_at, capabilities } = + await response.json(); + if ( + typeof subject !== "string" || + !subject.trim() || + !Number.isFinite(Date.parse(expires_at)) + ) + throw new Error("Builderlab returned an invalid account"); return { + subject, email, name, expiresAt: expires_at, capabilities: { + can_use_remote_agents: capabilities?.can_use_remote_agents === true, + can_use_builderlab_decisions: + capabilities?.can_use_builderlab_decisions === true, can_delete_buzz_communities: capabilities?.can_delete_buzz_communities === true, }, @@ -217,74 +316,218 @@ export function createBuilderlab({ let session; const revoke = () => { generation += 1; + revision = randomUUID(); + clearTimeout(expiry); + pending = false; + account = undefined; session?.abort(); session = new AbortController(); credential = undefined; + notify(); }; revoke(); - return { + const publishAccount = (value) => { + account = value; + notify(); + }; + const broker = { + snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, async auth() { const current = credential; if (!current) return null; const started = generation; + // Transport failures do not revoke a valid shared sign-in. + await linking; + const account = await me(current, session.signal); + if (started !== generation) throw new Error("Builderlab session changed"); + return account; + }, + async login(signal) { + revoke(); + const started = generation; + pending = true; + notify(); + const attempt = AbortSignal.any([signal, session.signal]); try { - const account = await me(current, session.signal); + const verifier = randomBytes(32).toString("base64url"); + const code = await awaitCallback(attempt, open, api, verifier); + const response = await fetch(`${api}/v1/auth/login/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: verifier }), + redirect: "error", + signal: AbortSignal.any([attempt, AbortSignal.timeout(30000)]), + }); + if (!response.ok) + throw new Error( + `Builderlab code exchange failed with HTTP ${response.status}`, + ); + const exchanged = await response.json(); + if ( + typeof exchanged.session_credential !== "string" || + !exchanged.session_credential + ) + throw new Error( + "Builderlab code exchange returned an empty credential", + ); + const account = await me(exchanged.session_credential, attempt); + if (account.expiresAt !== exchanged.expires_at) + throw new Error( + "Builderlab session expiry did not match code exchange", + ); + if (attempt.aborted || started !== generation) + throw new Error("Builderlab authentication canceled"); + credential = exchanged.session_credential; + const delay = Date.parse(account.expiresAt) - Date.now(); + if (delay <= 0) throw new Error("Builderlab sign-in expired"); + expiry = setTimeout(revoke, Math.min(delay, 2_147_483_647)); + expiry.unref?.(); + // Binding remains server-side. Its failure is shown by the existing identity UI. + linking = broker.bind().catch(() => {}); if (started !== generation) throw new Error("Builderlab session changed"); + publishAccount(account); return account; } catch (error) { - if (started === generation) credential = undefined; + if (started === generation) revoke(); throw error; + } finally { + if (started === generation) { + pending = false; + notify(); + } } }, - async login(signal) { + + signOut() { + const previous = credential; revoke(); + if (previous) + void fetch(`${api}/v1/auth/logout`, { + method: "POST", + headers: { "X-BB-Session-Credential": previous }, + redirect: "error", + signal: AbortSignal.timeout(30000), + }).catch(() => {}); + }, + async request(input, signal) { + current(input.revision); const started = generation; - const attempt = AbortSignal.any([signal, session.signal]); - const code = await awaitCallback(attempt, open); - const response = await fetch(`${API}/v1/auth/login/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), - redirect: "error", - signal: AbortSignal.any([attempt, AbortSignal.timeout(30000)]), - }); - if (!response.ok) + const secret = credential; + const apps = input.target === "apps"; + if (apps && !appsBase) throw new Error( - `Builderlab code exchange failed with HTTP ${response.status}`, + "Apps are not configured for this Builderlab environment", ); - const exchanged = await response.json(); + const path = input.path; if ( - typeof exchanged.session_credential !== "string" || - !exchanged.session_credential + typeof path !== "string" || + path.includes("#") || + path.includes("\\") || + path.includes("..") || + path.includes("%") || + !(apps + ? /^\/v1\/agent\//.test(path) + : /^\/(?:v3\/beekeeper\/|v1\/(?:auth\/me$|auth\/hosted-site\/native-session$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( + path, + )) ) - throw new Error( - "Builderlab code exchange returned an empty credential", - ); - const account = await me(exchanged.session_credential, attempt); - if (account.expiresAt !== exchanged.expires_at) - throw new Error( - "Builderlab session expiry did not match code exchange", + throw new Error("Unsupported Builderlab request"); + const method = input.method ?? "GET"; + if ( + !(apps ? ["GET", "POST", "PUT", "DELETE"] : ["GET", "POST"]).includes( + method, + ) + ) + throw new Error("Unsupported Builderlab method"); + const headers = { + Accept: "application/json", + ...(apps + ? { + Authorization: `BBIdentity ${secret}`, + "X-Hotpod-Agent-Client-Version": "0.2.0", + } + : { "X-BB-Session-Credential": secret, Origin: new URL(api).origin }), + }; + let body = input.body; + if (input.file) { + if ( + !apps || + !/^\/v1\/agent\/apps\/[A-Za-z0-9_-]+\/deploy$/.test(path) || + method !== "POST" + ) + throw new Error("Unsupported Apps upload"); + const home = await realpath(homedir()); + const file = await realpath(input.file); + if ( + file !== input.file || + !file.startsWith(`${home}${sep}`) || + !file.endsWith(".tar.gz") || + !(await stat(file)).isFile() || + (await stat(file)).size > 100 * 1024 * 1024 + ) + throw new Error( + "Apps artifact must be a .tar.gz file under home, at most 100 MB", + ); + body = new FormData(); + body.set( + "artifact", + await openAsBlob(file, { type: "application/gzip" }), + "artifact.tar.gz", ); - if (attempt.aborted || started !== generation) - throw new Error("Builderlab authentication canceled"); - credential = exchanged.session_credential; - return account; + } else if (body !== undefined) { + if (typeof body !== "string") + throw new Error("Invalid Builderlab body"); + headers["Content-Type"] = "application/json"; + } + current(input.revision); + const response = await fetch(`${apps ? appsBase : api}${path}`, { + method, + headers, + ...(body !== undefined ? { body } : {}), + redirect: "error", + signal: AbortSignal.any([ + signal, + session.signal, + AbortSignal.timeout( + input.file || path.endsWith("/initialize") ? 180000 : 60000, + ), + ]), + }); + const text = await response.text(); + if (Buffer.byteLength(text) > 16 * 1024 * 1024) + throw new Error("Builderlab response was too large"); + if (started !== generation && !input.file) + throw new Error("Builderlab session changed"); + if (response.status === 401 && started === generation) revoke(); + return { + status: response.status, + headers: {}, + body: text.replaceAll(secret, "[redacted]"), + }; }, - signOut: revoke, async bind() { const session = credential; const started = generation; const challenge = await request( "/v1/buzz/nostr-identities/challenge", - { origin: BUILDERLAB_ORIGIN }, + { origin: new URL(api).origin }, session, ); if (challenge.error) return challenge; // A sign-out or new sign-in while the challenge was pending must not let the // old credential claim this device's key. if (started !== generation) throw new Error("Builderlab session changed"); - const event = bindingEvent(key(), challenge); + const event = bindingEvent( + key(), + challenge, + Date.now(), + new URL(api).origin, + ); return request( "/v1/buzz/nostr-identities/verify", { @@ -314,4 +557,5 @@ export function createBuilderlab({ return request(path, body); }, }; + return broker; } diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 3cf198e9f1..da49272e8f 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -66,6 +66,7 @@ function account(responses = {}) { }); if (path === "/v1/auth/me") return Response.json({ + subject: "auth0|owner", email: "a@example.com", name: "A", expires_at: "2030", @@ -95,9 +96,11 @@ function account(responses = {}) { async function signIn(h) { const pending = h.builderlab.login(new AbortController().signal); while (!h.opened()) await new Promise((resolve) => setTimeout(resolve, 5)); - const returnTo = h.opened().searchParams.get("returnTo"); + const returnTo = h.opened().searchParams.get("redirect_uri"); expect(new URL(returnTo).hostname).toBe("127.0.0.1"); - await globalThis.fetch(`${returnTo}?code=one-time`); + await globalThis.fetch( + `${returnTo}?state=${h.opened().searchParams.get("state")}&code=one-time`, + ); return pending; } @@ -105,15 +108,25 @@ it("completes browser sign-in through a loopback callback without exposing the c const h = account(); const auth = await signIn(h); expect(auth).toEqual({ + subject: "auth0|owner", email: "a@example.com", name: "A", expiresAt: "2030", - capabilities: { can_delete_buzz_communities: false }, + capabilities: { + can_delete_buzz_communities: false, + can_use_remote_agents: false, + can_use_builderlab_decisions: false, + }, }); expect(JSON.stringify(auth)).not.toContain("secret"); expect(h.opened().pathname).toBe("/api/goose/v1/auth/login"); - expect(h.opened().searchParams.get("product")).toBe("buzz"); - expect(JSON.parse(h.requests[0].init.body)).toEqual({ code: "one-time" }); + expect(h.opened().searchParams.get("product")).toBe("builderlab"); + const exchange = JSON.parse(h.requests[0].init.body); + expect(exchange.code).toBe("one-time"); + const { createHash } = await import("node:crypto"); + expect( + createHash("sha256").update(exchange.code_verifier).digest("base64url"), + ).toBe(h.opened().searchParams.get("code_challenge")); await h.builderlab.call("list", {}); expect(h.requests.at(-1).init.headers).toMatchObject({ "X-BB-Session-Credential": "secret", @@ -207,6 +220,7 @@ it.each([ const h = account({ "/v1/auth/me": () => Response.json({ + subject: "auth0|owner", email: "a@example.com", expires_at: "2030", capabilities: { can_delete_buzz_communities: value }, @@ -214,6 +228,8 @@ it.each([ }); expect((await signIn(h)).capabilities).toEqual({ can_delete_buzz_communities: expected, + can_use_remote_agents: false, + can_use_builderlab_decisions: false, }); }); @@ -285,7 +301,9 @@ it.each(["cancel", "sign-out"])( const abort = new AbortController(); const pending = h.builderlab.login(abort.signal).catch((error) => error); while (!h.opened()) await new Promise((resolve) => setTimeout(resolve, 5)); - await globalThis.fetch(`${h.opened().searchParams.get("returnTo")}?code=x`); + await globalThis.fetch( + `${h.opened().searchParams.get("redirect_uri")}?state=${h.opened().searchParams.get("state")}&code=x`, + ); await started.promise; if (mode === "cancel") abort.abort(); else h.builderlab.signOut(); @@ -317,7 +335,11 @@ it.each(["error", "success"])( await release.promise; if (outcome === "error") return Response.json({}, { status: 401 }); } - return Response.json({ email: session, expires_at: "2030" }); + return Response.json({ + subject: session, + email: session, + expires_at: "2030", + }); }, }); const signInAs = async (code) => { @@ -326,7 +348,7 @@ it.each(["error", "success"])( while (h.opened() === before) await new Promise((resolve) => setTimeout(resolve, 5)); await globalThis.fetch( - `${h.opened().searchParams.get("returnTo")}?code=${code}`, + `${h.opened().searchParams.get("redirect_uri")}?state=${h.opened().searchParams.get("state")}&code=${code}`, ); return pending; }; @@ -378,7 +400,7 @@ it("rejects malformed and wrong callbacks without ending the listener", async () const h = account(); const pending = h.builderlab.login(new AbortController().signal); while (!h.opened()) await new Promise((resolve) => setTimeout(resolve, 5)); - const returnTo = new URL(h.opened().searchParams.get("returnTo")); + const returnTo = new URL(h.opened().searchParams.get("redirect_uri")); const { request } = await import("node:http"); const raw = (path) => new Promise((resolve, reject) => @@ -391,6 +413,129 @@ it("rejects malformed and wrong callbacks without ending the listener", async () ); expect(await raw("//[")).toBe(400); expect(await raw("/callback/wrong?code=x")).toBe(404); - await globalThis.fetch(`${returnTo}?code=one-time`); + await globalThis.fetch( + `${returnTo}?state=${h.opened().searchParams.get("state")}&code=one-time`, + ); expect(await pending).toMatchObject({ email: "a@example.com" }); }); + +it("keeps one public server revision for agents, Apps and reloaded clients, and never forwards caller credentials", async () => { + const h = account({ + "/v3/beekeeper/list-agents": () => Response.json({ status: 1, agents: [] }), + "/v1/agent/apps": () => Response.json({ apps: [] }), + }); + const states = []; + const stop = h.builderlab.subscribe((state) => states.push(state)); + await signIn(h); + const signedIn = h.builderlab.snapshot(); + expect(signedIn).toMatchObject({ + pending: false, + auth: { subject: "auth0|owner" }, + }); + expect(JSON.stringify(states)).not.toContain("secret"); + const revision = signedIn.revision; + await h.builderlab.request( + { + revision, + path: "/v3/beekeeper/list-agents", + method: "POST", + body: "{}", + headers: { "X-BB-Session-Credential": "untrusted" }, + }, + new AbortController().signal, + ); + expect(h.requests.at(-1).init.headers["X-BB-Session-Credential"]).toBe( + "secret", + ); + await h.builderlab.request( + { revision, target: "apps", path: "/v1/agent/apps?scope=owned" }, + new AbortController().signal, + ); + expect(h.requests.at(-1).init.headers.Authorization).toBe( + "BBIdentity secret", + ); + await expect(h.builderlab.auth()).resolves.toMatchObject({ + subject: "auth0|owner", + }); + expect(h.builderlab.snapshot().revision).toBe(revision); + h.builderlab.signOut(); + await expect( + h.builderlab.request( + { revision, path: "/v1/auth/me" }, + new AbortController().signal, + ), + ).rejects.toThrow("session changed"); + expect(states.at(-1).auth).toBeNull(); + stop(); +}); + +it("expires every consumer together on 401, while a transient account check preserves the server login", async () => { + let unavailable = false; + const h = account({ + "/v1/auth/me": () => + unavailable + ? Promise.reject(new Error("offline")) + : Response.json({ subject: "owner", expires_at: "2030" }), + "/v3/beekeeper/list-agents": () => Response.json({}, { status: 401 }), + }); + await signIn(h); + const revision = h.builderlab.snapshot().revision; + unavailable = true; + await expect(h.builderlab.auth()).rejects.toThrow("offline"); + expect(h.builderlab.snapshot().revision).toBe(revision); + const reply = await h.builderlab.request( + { revision, path: "/v3/beekeeper/list-agents", method: "POST", body: "{}" }, + new AbortController().signal, + ); + expect(reply.status).toBe(401); + expect(h.builderlab.snapshot()).toMatchObject({ auth: null, pending: false }); +}); + +it.each([ + "/v1/auth/login/exchange", + "//evil.example/", + "/v3/beekeeper/../auth/me", + "/v3/beekeeper/%2e%2e/auth/me", +])("refuses proxy path %s before dispatch", async (path) => { + const h = account(); + await signIn(h); + const count = h.requests.length; + await expect( + h.builderlab.request( + { revision: h.builderlab.snapshot().revision, path }, + new AbortController().signal, + ), + ).rejects.toThrow("Unsupported"); + expect(h.requests).toHaveLength(count); + h.builderlab.signOut(); +}); + +it("ends the shared session at its advertised expiry", async () => { + const { vi } = await import("vitest"); + const expires = new Date(Date.now() + 60_000).toISOString(); + const h = account({ + "/v1/auth/login/exchange": () => + Response.json({ session_credential: "secret", expires_at: expires }), + "/v1/auth/me": () => + Response.json({ subject: "owner", expires_at: expires }), + }); + const login = h.builderlab.login(new AbortController().signal); + while (!h.opened()) await new Promise((resolve) => setTimeout(resolve, 5)); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + try { + await globalThis.fetch( + `${h.opened().searchParams.get("redirect_uri")}?state=${h.opened().searchParams.get("state")}&code=expiry`, + ); + await login; + expect(h.builderlab.snapshot().auth).not.toBeNull(); + await vi.advanceTimersByTimeAsync(60_000); + expect(h.builderlab.snapshot()).toMatchObject({ + auth: null, + pending: false, + }); + expect(await h.builderlab.auth()).toBeNull(); + } finally { + vi.useRealTimers(); + h.builderlab.signOut(); + } +}); diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 727d993583..b82be6ac32 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -168,6 +168,7 @@ test.each([ }); if (path.endsWith("/v1/auth/me")) return Response.json({ + subject: "auth0|owner", email: "fixture@example.com", expires_at: "2030", }); @@ -185,9 +186,15 @@ test.each([ body: "{}", }); const opened = new URL(await loginOpened); - const callback = opened.searchParams.get("returnTo"); + const callback = opened.searchParams.get("redirect_uri"); expect(callback).toBeTruthy(); - expect((await fetch(`${callback}?code=fixture`)).status).toBe(200); + expect( + ( + await fetch( + `${callback}?state=${opened.searchParams.get("state")}&code=fixture`, + ) + ).status, + ).toBe(200); expect((await login).status).toBe(200); const response = await fetch(`${h.base}/api/builderlab/delete`, { method: "POST", diff --git a/browser-host/relay-broker.d.mts b/browser-host/relay-broker.d.mts index f62b9f1b3c..353212fc92 100644 --- a/browser-host/relay-broker.d.mts +++ b/browser-host/relay-broker.d.mts @@ -15,6 +15,8 @@ export function relayBrokerPlugin(options?: { upstreamFetch?: typeof fetch; socketFactory?: (url: string) => WebSocket; builderlab?: { + api?: string; + appsBase?: string | undefined; fetch?: typeof fetch; open?: (url: string) => Promise; }; diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 62449caf3b..71e13acbe1 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -959,9 +959,21 @@ export function relayBrokerPlugin({ if (res.destroyed) release(); try { if (url.pathname.startsWith("/api/builderlab/")) { - // Hosted communities plugin only; the session credential never leaves Node. + // One development sign-in for hosting, agents and apps; its credential stays in Node. const action = url.pathname.slice("/api/builderlab/".length); try { + if (action === "events" && req.method === "GET") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + }); + const send = (state) => + res.write(`data: ${JSON.stringify(state)}\n\n`); + const stop = builderlab.subscribe(send); + res.once("close", stop); + send(builderlab.snapshot()); + return; + } if (action === "auth" && req.method === "GET") return json(res, 200, { auth: await builderlab.auth() }); if (req.method !== "POST") @@ -970,9 +982,40 @@ export function relayBrokerPlugin({ req.setEncoding("utf8"); for await (const part of req) { raw += part; - if (raw.length > 4096) + if (raw.length > (action === "request" ? 64 * 1024 : 4096)) return json(res, 413, { error: "Request too large" }); } + const input = raw ? JSON.parse(raw) : {}; + if (action === "viewer") { + const reply = await builderlab.request( + { + revision: req.headers["x-buzz-builderlab-revision"], + path: "/v1/auth/hosted-site/native-session", + method: "POST", + body: JSON.stringify(input), + }, + cancel.signal, + ); + res + .writeHead(reply.status, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }) + .end(reply.body); + return; + } + if (action === "request") + return json( + res, + 200, + await builderlab.request(input, cancel.signal), + ); + // Scoped callers cannot submit an operation for a replacement account. + if ( + input.revision !== undefined && + input.revision !== builderlab.snapshot().revision + ) + return json(res, 409, { error: "Builderlab session changed" }); if (action === "login") return json(res, 200, { auth: await builderlab.login(cancel.signal), @@ -983,10 +1026,7 @@ export function relayBrokerPlugin({ } if (action === "bind") return json(res, 200, await builderlab.bind()); - const result = await builderlab.call( - action, - raw ? JSON.parse(raw) : {}, - ); + const result = await builderlab.call(action, input); return result ? json(res, builderlabResponseStatus(result), result) : json(res, 404, { error: "Unknown Builderlab route" }); diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 72463d6e26..fae352100a 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -139,3 +139,7 @@ the interface unless an available tool actually supports that action. When helping someone explore a community, use only your available access and say when information is incomplete or inaccessible. Never join, invite, share, or change community content merely to explain how Buzz works. + +### Builderlab in development + +With the pinned-viewer development server, Settings → Plugins → Builderlab manages one sign-in for hosted communities, remote agents and apps. It survives app reloads while the server runs. Signing out or session expiry signs all three out. The credential stays on the server and is not saved to disk. diff --git a/src-tauri/src/browser/builderlab.rs b/src-tauri/src/browser/builderlab.rs index 324159b4df..fc8189d38b 100644 --- a/src-tauri/src/browser/builderlab.rs +++ b/src-tauri/src/browser/builderlab.rs @@ -17,16 +17,35 @@ const FI_SIGN_IN_AGAIN: &str = "Sign in again in Settings → Identity → Organ pub enum ViewerAuth { Credential(String), Federated(String), + #[cfg(debug_assertions)] + Broker(BrokerAuth), +} +#[cfg(debug_assertions)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrokerAuth { + origin: String, + revision: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawViewerAuth { credential: Option, federated: Option, + #[cfg(debug_assertions)] + broker: Option, } impl TryFrom for ViewerAuth { type Error = &'static str; fn try_from(raw: RawViewerAuth) -> Result { + #[cfg(debug_assertions)] + if let Some(broker) = raw.broker { + return if raw.credential.is_none() && raw.federated.is_none() { + Ok(Self::Broker(broker)) + } else { + Err("expected exactly one viewer authentication source") + }; + } match (raw.credential, raw.federated) { (Some(credential), None) => Ok(Self::Credential(credential)), (None, Some(revision)) => Ok(Self::Federated(revision)), @@ -124,6 +143,23 @@ fn exchange_request( const SIGN_IN_AGAIN: &str = "Sign in to Builderlab again in Settings"; const PATH: &str = "v1/auth/hosted-site/native-session"; match auth { + #[cfg(debug_assertions)] + ViewerAuth::Broker(broker) => { + let origin = Url::parse(&broker.origin).map_err(|_| "Invalid development origin")?; + if origin.scheme() != "http" + || !matches!(origin.host_str(), Some("127.0.0.1" | "localhost")) + || origin.port().is_none() + || origin.origin().ascii_serialization() != broker.origin + { + return Err("Invalid development origin".into()); + } + Ok(( + format!("{}/api/builderlab/viewer", broker.origin), + HeaderName::from_static("x-buzz-builderlab-revision"), + HeaderValue::from_str(&broker.revision) + .map_err(|_| "Invalid development session")?, + )) + } ViewerAuth::Credential(credential) => { let credential = Zeroizing::new(credential); if !(32..=512).contains(&credential.len()) @@ -169,16 +205,29 @@ pub(super) async fn prepare( } let root = app_root(domain, target)?; let federated = matches!(auth, ViewerAuth::Federated(_)); + #[cfg(not(debug_assertions))] + let development = false; + #[cfg(debug_assertions)] + let development = matches!(auth, ViewerAuth::Broker(_)); let (url, header, credential) = exchange_request(auth, fi)?; let client = reqwest::Client::builder() - .https_only(true) + .https_only(!development) .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(15)) .build() .map_err(|_| "Builderlab sign-in is unavailable")?; let body = Zeroizing::new(serde_json::json!({ "return_to": root }).to_string()); - let mut response = client - .post(url) + let mut request = client.post(&url); + if development { + request = request.header( + "Origin", + Url::parse(&url) + .map_err(|_| "Invalid development origin")? + .origin() + .ascii_serialization(), + ); + } + let mut response = request .header(header, credential) .header("Content-Type", "application/json") .body(body.to_string()) @@ -274,6 +323,35 @@ mod tests { } } + #[cfg(debug_assertions)] + #[test] + fn development_viewer_auth_accepts_only_loopback_origins_and_a_public_revision() { + let fi = signed_in(); + for origin in ["http://127.0.0.1:1430", "http://localhost:1430"] { + let auth: ViewerAuth = serde_json::from_value(serde_json::json!({ "broker": { "origin": origin, "revision": "public-revision" } })).unwrap(); + let (url, header, value) = exchange_request(auth, &fi).unwrap(); + assert_eq!(url, format!("{origin}/api/builderlab/viewer")); + assert_eq!(header.as_str(), "x-buzz-builderlab-revision"); + assert_eq!(value.to_str().unwrap(), "public-revision"); + } + for origin in [ + "https://evil.example", + "http://127.0.0.1:1430/path", + "http://user@localhost:1430", + "http://localhost", + ] { + assert!(exchange_request( + ViewerAuth::Broker(BrokerAuth { + origin: origin.into(), + revision: "r".into() + }), + &fi + ) + .is_err()); + } + assert!(serde_json::from_value::(serde_json::json!({"credential":"c", "broker":{"origin":"http://127.0.0.1:1430","revision":"r"}})).is_err()); + } + #[test] fn federated_exchange_sends_the_bearer_only_for_the_current_revision() { let fi = signed_in(); diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index 89b2cc41dc..f04c0b5daa 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -3,7 +3,7 @@ // session goes as `Authorization: BBIdentity `, and the gateway // exchanges it for the control plane's own token. import type { Host, HostResponse } from "../../../features/host/service"; -import type { OAuthSession } from "../oauth/session"; +import type { BuilderlabSession } from "../session"; /** The Apps protocol `bl` speaks, which the control plane checks. */ const PROTOCOL = "0.2.0"; @@ -29,11 +29,13 @@ export type AppsClient = ReturnType; export function createAppsClient( host: Host, - session: OAuthSession, + session: BuilderlabSession, base: string, + transport?: Pick, ) { + const http = transport ?? host; // One client belongs to one tool operation, including all its HTTP steps. - let owner: ReturnType | undefined; + let owner: ReturnType | undefined; const credential = () => { if (session.snapshot().status !== "signed-in") throw new Error(`Builderlab is not signed in. ${SIGN_IN}`); @@ -41,6 +43,9 @@ export function createAppsClient( if (owner && current !== owner) throw new Error("Builderlab sign-in changed. Start a new Apps request."); owner = current; + if (transport) return ""; + if (!("value" in current) || typeof current.value !== "string") + throw new Error(SIGN_IN); return current.value; }; const headers = (secret: string) => ({ @@ -72,12 +77,14 @@ export function createAppsClient( // Never hand the session on, even if a server echoed it. throw new Error( `${what} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`.replaceAll( - secret, + secret || "\u0000", "[redacted]", ), ); } - return JSON.parse(JSON.stringify(body).replaceAll(secret, "[redacted]")); + return JSON.parse( + JSON.stringify(body).replaceAll(secret || "\u0000", "[redacted]"), + ); }; const call = async ( method: "GET" | "POST" | "PUT" | "DELETE", @@ -86,7 +93,7 @@ export function createAppsClient( body?: Json, ): Promise => { const secret = credential(); - const response = await host.request({ + const response = await http.request({ url: `${base}${path}`, method, headers: { @@ -198,9 +205,9 @@ export function createAppsClient( }, /** Uploads `artifact` as the app's next version. */ async deploy(id: string, artifact: string) { - if (!host.upload) throw new Error("Deploying needs the Buzz desktop app"); + if (!http.upload) throw new Error("Deploying needs the Buzz desktop app"); const secret = credential(); - const response = await host.upload({ + const response = await http.upload({ url: `${base}${app(id, "deploy")}`, headers: headers(secret), file: { diff --git a/src/bundled/builderlab/apps/index.ts b/src/bundled/builderlab/apps/index.ts index ec4e71bc0c..a90b6f39aa 100644 --- a/src/bundled/builderlab/apps/index.ts +++ b/src/bundled/builderlab/apps/index.ts @@ -4,6 +4,7 @@ import { createElement, useSyncExternalStore } from "react"; import type { PluginModule } from "../../../plugins/api"; import type { PanelProps } from "../../../features/panels/service"; +import type { Host } from "../../../features/host/service"; import type { OAuthSession } from "../oauth/session"; import type { BuilderlabSession } from "../session"; import { createAppsClient, type Audience, type RuntimeProfile } from "./client"; @@ -51,6 +52,7 @@ export function applyApps( /** The legacy sign-in the control-plane tools authenticate with; an FI * build has none yet, so it offers no tools. */ legacy?: OAuthSession, + dev?: Pick, ) { // Cordis wraps service functions on access; keep React's component identity. const View = ctx.browser.View; @@ -94,8 +96,8 @@ export function applyApps( component: AppView, }); const base = controlPlane(); - if (!base || !legacy) return; - const apps = () => createAppsClient(ctx.host, legacy, base); + if (!base || (!legacy && !dev)) return; + const apps = () => createAppsClient(ctx.host, legacy ?? session, base, dev); const reply = (value: unknown) => JSON.stringify(value, null, 2); ctx.agents.addTool({ name: "apps_contract", diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts new file mode 100644 index 0000000000..87e8f8efca --- /dev/null +++ b/src/bundled/builderlab/dev-session.test.ts @@ -0,0 +1,169 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createDevSession, brokerBuilderlab } from "./dev-session"; +import { createAgentClient } from "./agents/client"; +import { createAppsClient } from "./apps/client"; +import { deferred } from "./test-helpers"; + +class Events { + static all: Events[] = []; + onmessage?: (event: { data: string }) => void; + onerror?: () => void; + close = vi.fn(); + constructor(readonly url: string) { + Events.all.push(this); + } + emit(revision: string, subject: string | null, pending = false) { + this.onmessage?.({ + data: JSON.stringify({ + revision, + pending, + auth: subject + ? { + subject, + email: `${subject}@example.com`, + expiresAt: "2030", + capabilities: { can_use_remote_agents: true }, + } + : null, + }), + }); + } +} +function setup() { + vi.stubGlobal("EventSource", Events); + vi.stubGlobal("window", { location: { origin: "http://127.0.0.1:1430" } }); + const fetch = vi.fn(async (_url: string, _init?: RequestInit) => + Response.json({ status: 200, headers: {}, body: "{}" }), + ); + vi.stubGlobal("fetch", fetch); + const dev = createDevSession(); + const events = Events.all.at(-1); + if (!events) throw new Error("Missing server event stream"); + return { ...dev, events, fetch }; +} +async function signedIn( + session: ReturnType["session"], +) { + await vi.waitFor(() => expect(session.snapshot().status).toBe("signed-in")); +} +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + Events.all = []; +}); + +it("uses the broker only in its development mode, leaving FI and production on their existing owner", () => { + for (const [dev, live, fi, expected] of [ + [true, "1", "0", true], + [true, "0", "0", false], + [true, "1", "1", false], + [false, "1", "0", false], + ] as const) { + vi.stubEnv("DEV", dev); + vi.stubEnv("VITE_BUZZ_LIVE", live); + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", fi); + expect(brokerBuilderlab()).toBe(expected); + } +}); + +it("reloads its public session projection without starting or ending the server login", async () => { + const h = setup(); + h.events.emit("one", "A"); + await signedIn(h.session); + expect(h.session.credential()).toEqual({ + revision: "one", + account: expect.objectContaining({ subject: "A" }), + }); + expect(h.session.viewerSession()).toMatchObject({ + broker: { origin: "http://127.0.0.1:1430", revision: "one" }, + }); + const viewer = h.session.viewerSession(); + h.session.dispose(); + expect(viewer.signal.aborted).toBe(true); + expect(h.events.close).toHaveBeenCalled(); + const second = createDevSession(); + const events = Events.all.at(-1); + if (!events) throw new Error("Missing restored event stream"); + events.emit("one", "A"); + await signedIn(second.session); + expect(h.fetch).not.toHaveBeenCalled(); + expect(second.session.snapshot().account?.subject).toBe("A"); + second.session.dispose(); +}); + +it("shares agent and Apps transport without an app-held credential and refuses stale operation results", async () => { + const h = setup(); + h.events.emit("one", "A"); + await signedIn(h.session); + h.fetch.mockResolvedValueOnce( + Response.json({ + status: 200, + headers: {}, + body: JSON.stringify({ status: 1, agents: [] }), + }), + ); + await expect( + createAgentClient(h.session, null).list(new AbortController().signal), + ).resolves.toEqual([]); + const owner = h.session.credential(); + const apps = createAppsClient( + {} as never, + h.session, + "https://compose-ctrl.builderlab.xyz", + h.apps, + ); + h.fetch.mockResolvedValueOnce( + Response.json({ + status: 200, + headers: {}, + body: JSON.stringify({ apps: [] }), + }), + ); + await apps.list(); + expect( + h.fetch.mock.calls.map((call) => + JSON.parse((call[1] as RequestInit).body as string), + ), + ).toMatchObject([ + { revision: "one", path: "/v3/beekeeper/list-agents", method: "POST" }, + { revision: "one", target: "apps", path: "/v1/agent/apps?scope=owned" }, + ]); + const held = deferred(); + h.fetch.mockImplementationOnce(() => held.promise); + const pending = h.session.request(owner, { path: "/v1/auth/me" }); + const rejected = expect(pending).rejects.toThrow("session changed"); + const oldViewer = h.session.viewerSession(); + h.events.emit("two", "B"); + await signedIn(h.session); + expect(oldViewer.signal.aborted).toBe(true); + held.resolve(Response.json({ status: 200, headers: {}, body: "{}" })); + await rejected; + await expect(apps.list()).rejects.toThrow("sign-in changed"); + expect(h.session.snapshot().account?.subject).toBe("B"); + h.events.emit("three", null); + expect(h.session.snapshot().status).toBe("signed-out"); + h.session.dispose(); +}); + +it("cancels local viewers on sign-out and reconnects to the authoritative state after a stream interruption", async () => { + const h = setup(); + h.events.emit("one", "A"); + await signedIn(h.session); + const viewer = h.session.viewerSession(); + h.events.onerror?.(); + expect(viewer.signal.aborted).toBe(true); + expect(h.session.snapshot()).toMatchObject({ + status: "pending", + checking: true, + }); + h.events.emit("one", "A"); + await signedIn(h.session); + h.session.signOut(); + expect(h.fetch).toHaveBeenCalledWith( + "/api/builderlab/sign-out", + expect.objectContaining({ body: JSON.stringify({ revision: "one" }) }), + ); + h.events.emit("two", null); + expect(h.session.snapshot()).toEqual({ status: "signed-out" }); + h.session.dispose(); +}); diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts new file mode 100644 index 0000000000..cf19e3e614 --- /dev/null +++ b/src/bundled/builderlab/dev-session.ts @@ -0,0 +1,257 @@ +// Public projection of the development server's login. No credential is held here. +import type { + Host, + HostRequest, + HostUpload, +} from "../../features/host/service"; +import type { BuilderlabViewerSession } from "../../features/browser/api"; +import { + accountStorage, + nativeBuilderlab, + type Account, + type BuilderlabSession, + type Credential, + type LoginSnapshot, +} from "./session"; +import { broker, createApi } from "./communities/api"; + +export const brokerBuilderlab = () => + import.meta.env.DEV && + import.meta.env.VITE_BUZZ_LIVE === "1" && + !nativeBuilderlab(); + +type State = { + revision: string; + pending: boolean; + auth: null | { + subject: string; + email?: string; + expiresAt: string; + capabilities?: { + can_use_remote_agents?: boolean; + can_use_builderlab_decisions?: boolean; + }; + }; +}; + +export function createDevSession() { + let state: LoginSnapshot = { status: "pending", checking: true }; + let owner: (Credential & { revision: string }) | undefined; + let viewer: BuilderlabViewerSession | undefined; + let lifetime = new AbortController(); + let disposed = false; + let version = 0; + let revision: string | undefined; + const listeners = new Set<() => void>(); + const publish = (next: LoginSnapshot) => { + state = Object.freeze(next); + for (const listener of listeners) listener(); + }; + const clear = () => { + lifetime.abort(); + lifetime = new AbortController(); + owner = undefined; + viewer = undefined; + }; + const check = (expected: Credential) => { + if (disposed || expected !== owner) + throw new DOMException("Builderlab session changed.", "AbortError"); + }; + async function project(next: State) { + if (disposed) return; + revision = next.revision; + if (owner?.revision === next.revision && next.auth && !next.pending) return; + const at = ++version; + clear(); + if (!next.auth || next.pending) { + publish({ status: next.pending ? "pending" : "signed-out" }); + return; + } + publish({ status: "pending", checking: true }); + const storage = await accountStorage(next.auth.subject); + if (disposed || version !== at) return; + const account: Account = Object.freeze({ + subject: next.auth.subject, + email: next.auth.email ?? "", + canUseRemoteAgents: + next.auth.capabilities?.can_use_remote_agents === true, + canUseBuilderlabDecisions: + next.auth.capabilities?.can_use_builderlab_decisions === true, + }); + owner = Object.freeze({ account, revision: next.revision }); + viewer = { + broker: { origin: window.location.origin, revision: next.revision }, + storage, + signal: lifetime.signal, + }; + publish({ status: "signed-in", account }); + } + const events = new EventSource("/api/builderlab/events"); + events.onmessage = (event) => { + void project(JSON.parse(event.data) as State).catch(() => { + clear(); + publish({ + status: "signed-out", + error: "Could not read the development server's Builderlab sign-in.", + }); + }); + }; + events.onerror = () => { + if (disposed) return; + ++version; + clear(); + publish({ status: "pending", checking: true }); + }; + async function send(action: string, body: object = {}, signal?: AbortSignal) { + const response = await fetch(`/api/builderlab/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...(signal ? { signal } : {}), + }); + const result = await response.json(); + if (!response.ok) + throw new Error( + typeof result.error === "string" + ? result.error + : "Builderlab request failed.", + ); + return result; + } + const session: BuilderlabSession = { + snapshot: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + credential() { + if (!owner || disposed) throw new Error("Sign in to Builderlab first."); + return owner; + }, + async request(expected, input) { + check(expected); + const response = await send("request", { + ...input, + revision: owner?.revision, + }); + check(expected); + return response; + }, + // The server processes 401s and expiry for every consumer together. + expired() {}, + viewerSession() { + if (!viewer || disposed) throw new Error("Sign in to Builderlab first."); + return viewer; + }, + async ensureSignedIn(signal) { + signal.throwIfAborted(); + if (disposed) throw new Error("Builderlab session is closed."); + if (owner) return; + await new Promise((resolve, reject) => { + const done = () => { + if (!disposed && state.status === "pending") return; + cleanup(); + if (owner) resolve(); + else reject(new Error(state.error ?? "Sign-in canceled.")); + }; + const abort = () => { + cleanup(); + reject(signal.reason); + }; + const cleanup = () => { + listeners.delete(done); + signal.removeEventListener("abort", abort); + }; + listeners.add(done); + signal.addEventListener("abort", abort, { once: true }); + if (state.status !== "pending") void session.signIn(); + }); + }, + async signIn() { + if (disposed || state.status === "pending") return; + publish({ status: "pending" }); + try { + await send("login"); + } catch (error) { + if (!disposed && !owner) + publish({ + status: "signed-out", + error: + error instanceof Error + ? error.message + : "Sign-in failed. Try again.", + }); + } + }, + cancel() { + session.signOut(); + }, + // Closing Settings or reloading the app does not own the server's login. + abandon() {}, + signOut() { + if (disposed) return; + const at = ++version; + clear(); + publish({ status: "pending", checking: true }); + void send("sign-out", { revision }).catch(() => { + if (!disposed && version === at) + publish({ + status: "signed-out", + error: + "Could not sign out on the development server. Reconnect and try again.", + }); + }); + }, + dispose() { + disposed = true; + ++version; + events.close(); + clear(); + publish({ status: "signed-out" }); + listeners.clear(); + }, + }; + const transport = async (input: HostRequest | HostUpload) => { + const expected = session.credential(); + const url = new URL(input.url); + const response = await send("request", { + revision: owner?.revision, + target: "apps", + path: `${url.pathname}${url.search}`, + method: "file" in input ? "POST" : input.method, + ...("file" in input ? { file: input.file.path } : { body: input.body }), + }); + // Accepted uploads retain their IDs; the next request checks its original account. + if (!("file" in input)) check(expected); + return response; + }; + const communities = createApi({ + ...broker, + async auth() { + const expected = session.credential(); + const value = await broker.auth(); + check(expected); + return value; + }, + async send(action, body, signal) { + const expected = session.credential(); + const response = await broker.send( + action, + { ...body, revision: owner?.revision } as Record, + signal, + ); + check(expected); + return response; + }, + }); + return { + session, + communities, + apps: { request: transport, upload: transport } satisfies Pick< + Host, + "request" | "upload" + >, + }; +} diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index 2b51b4a2b4..2c43f97845 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -319,14 +319,10 @@ it.each([undefined, false, true])( }, ); -it.each([ - ["1", true], - ["0", true], - ["0", false], -] as const)( - "desktop login respects identity ownership in VITE_BUZZ_LIVE=%s with remote agents=%s", - async (live, canUseRemoteAgents) => { - vi.stubEnv("VITE_BUZZ_LIVE", live); +it.each([true, false])( + "desktop login respects native identity ownership with remote agents=%s", + async (canUseRemoteAgents) => { + vi.stubEnv("VITE_BUZZ_LIVE", "0"); // Without the development broker, its communities report unavailable. vi.stubGlobal( "fetch", @@ -411,49 +407,33 @@ it.each([ canUseRemoteAgents ? "No remote agents yet." : "Coming soon", ), ).toBeInTheDocument(); - if (live === "1") - // Live development keeps communities on the broker. - expect(fetch).toHaveBeenCalledWith("/api/builderlab/auth"); - else { - // Communities share the one sign-in and its identity link. - expect( - await screen.findByText("No hosted communities yet"), - ).toBeInTheDocument(); - expect(screen.getByText(/Buzz identity connected/)).toBeVisible(); - expect( - screen.getAllByRole("button", { name: "Sign out" }), - ).toHaveLength(1); - expect(fetch).not.toHaveBeenCalled(); - } - if (live === "0") { - await linkFinished.promise; - await waitFor(() => - expect(native.invoke).toHaveBeenCalledWith("nsec_backup_retry", { - source: "builderlab", - }), - ); - expect(native.invoke).toHaveBeenCalledWith("nsec_backup_session", { + // Communities share the one sign-in and its identity link. + expect( + await screen.findByText("No hosted communities yet"), + ).toBeInTheDocument(); + expect(screen.getByText(/Buzz identity connected/)).toBeVisible(); + expect(screen.getAllByRole("button", { name: "Sign out" })).toHaveLength( + 1, + ); + expect(fetch).not.toHaveBeenCalled(); + await linkFinished.promise; + await waitFor(() => + expect(native.invoke).toHaveBeenCalledWith("nsec_backup_retry", { source: "builderlab", - generation: expect.any(Number), - credential: "synthetic-credential", - }); - } + }), + ); + expect(native.invoke).toHaveBeenCalledWith("nsec_backup_session", { + source: "builderlab", + generation: expect.any(Number), + credential: "synthetic-credential", + }); await user.click(screen.getByRole("button", { name: "Sign out" })); expect( await screen.findByRole("button", { name: "Sign in with Builderlab" }), ).toBeEnabled(); - if (live === "0") - expect(backupCalls().at(-1)?.[1]).toMatchObject({ credential: null }); + expect(backupCalls().at(-1)?.[1]).toMatchObject({ credential: null }); await runtime.dispose(); - if (live === "1") { - expect(backupCalls()).toEqual([]); - expect( - native.invoke.mock.calls.filter( - ([command]) => command === "identity_restore", - ), - ).toEqual([]); - } else - expect(backupCalls().at(-1)?.[1]).toMatchObject({ credential: null }); + expect(backupCalls().at(-1)?.[1]).toMatchObject({ credential: null }); } finally { cleanup(); await runtime.dispose(); @@ -1281,3 +1261,138 @@ it("opens Manage plugins from its agents' Activity tab while Agent Activity is o await community.dispose(); } }); + +it("uses one development-server login for the Settings account, hosting and remote agents, without native OAuth", async () => { + vi.stubEnv("VITE_BUZZ_LIVE", "1"); + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "0"); + let receive: ((event: { data: string }) => void) | undefined; + const state = (signedIn: boolean) => ({ + revision: signedIn ? "server-one" : "server-two", + pending: false, + auth: signedIn + ? { + subject: "server-account", + email: "dev@example.com", + expiresAt: "2030", + capabilities: { can_use_remote_agents: true }, + } + : null, + }); + const emit = (signedIn: boolean) => + receive?.({ data: JSON.stringify(state(signedIn)) }); + const close = vi.fn(); + vi.stubGlobal( + "EventSource", + class { + close = close; + set onmessage(value: typeof receive) { + receive = value; + queueMicrotask(() => emit(false)); + } + }, + ); + const viewer = "01".repeat(32); + const requests: { path?: string; revision?: string }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + if (url === "/api/relay/identity") return Response.json({ viewer }); + if (url === "/api/builderlab/auth") + return Response.json({ auth: { expiresAt: "2030" } }); + const input = JSON.parse(String(init?.body ?? "{}")); + if (url.endsWith("/login")) { + emit(true); + return Response.json({}); + } + if (url.endsWith("/sign-out")) { + emit(false); + return Response.json({}); + } + if (url.endsWith("/identity")) + return Response.json({ identity: { pubkey_hex: viewer } }); + if (url.endsWith("/list")) + return Response.json({ + communities: [], + quota_used: 0, + quota_limit: 1, + can_create: true, + }); + if (url.endsWith("/request")) { + requests.push(input); + return Response.json({ + status: 200, + headers: {}, + body: JSON.stringify( + input.path.endsWith("/list-agents") + ? { status: 1, agents: [] } + : { communities: [] }, + ), + }); + } + throw new Error(`Unexpected request: ${url}`); + }), + ); + native.invoke.mockRejectedValue( + new Error("This dev mode must not invoke native OAuth or backup"), + ); + const root = new Context(); + const runtime = new PluginRuntime(root, async () => builderlab); + new HostService(root); + provideApps(root); + const cards = new SettingsCardsService(root); + new DecisionsService(root, localStorage); + const community = communityFixture(null); + root.provide("relay", community.relay as never); + root.provide("navigation", { open: vi.fn() } as never); + provideKnownCommunities(root, community.viewer); + provideAgents(root, community); + try { + runtime.reconcile([ + { + manifest: { ...manifest, apiVersion: 1 }, + source: "bundled", + revision: "bundled", + enabled: true, + previous: null, + reloadable: false, + error: null, + }, + ]); + await waitFor(() => expect(cards.snapshot()).toHaveLength(1)); + const card = cards.snapshot()[0]; + if (!card) throw new Error("Missing Builderlab settings card"); + const Card = card.component; + render( true} />); + await userEvent.click( + await screen.findByRole("button", { name: "Sign in with Builderlab" }), + ); + expect( + await screen.findByText("Signed in as dev@example.com."), + ).toBeVisible(); + expect(await screen.findByText("No hosted communities yet")).toBeVisible(); + expect(await screen.findByText("No remote agents yet.")).toBeVisible(); + expect(screen.getAllByRole("button", { name: "Sign out" })).toHaveLength(1); + expect(screen.getByText(/Buzz identity connected/)).toBeVisible(); + expect(requests).toContainEqual( + expect.objectContaining({ + revision: "server-one", + path: "/v3/beekeeper/list-agents", + }), + ); + expect(native.invoke).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "Sign out" })); + expect( + await screen.findByRole("button", { name: "Sign in with Builderlab" }), + ).toBeEnabled(); + expect( + screen.queryByText("No hosted communities yet"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("No remote agents yet.")).not.toBeInTheDocument(); + } finally { + cleanup(); + await runtime.dispose(); + await root.fiber.dispose(); + await community.dispose(); + } + expect(close).toHaveBeenCalled(); +}); diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index c9ffa93c79..9ddcd0d074 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -11,6 +11,7 @@ import { browserLoginAvailable, oauthTarget, } from "./oauth/browser"; +import { brokerBuilderlab, createDevSession } from "./dev-session"; import { createOAuthSession } from "./oauth/session"; import { createNativeSession, nativeBuilderlab } from "./session"; import { createAgentClient } from "./agents/client"; @@ -51,38 +52,43 @@ export const apply: PluginModule["apply"] = (ctx) => { ? error.message : "Builderlab sign-in is unavailable."; } - // An FI build signs in with the native NIP-FI session, whose token stays - // in OS secure storage; otherwise the OAuth credential lives in memory. - const legacy = nativeBuilderlab() - ? undefined - : createOAuthSession( - (signal) => { - if (!browserLoginAvailable()) - throw new Error( - "Open the Buzz desktop app to sign in with Builderlab.", - ); - return browserCredential(ctx.host, signal); - }, - (credential) => { - // Local guest teardown is immediate; the parent logout also revokes every server-side viewer child. - void ctx.host - .request({ - url: `${oauthTarget()}/v1/auth/logout`, - method: "POST", - headers: { "X-BB-Session-Credential": credential.value }, - }) - .catch(() => {}); - }, - ctx.host, - ); - const session = legacy ?? createNativeSession(ctx.federatedIdentity); + // The dev broker and FI host own their sessions; only the legacy build + // uses renderer-held OAuth. + const dev = brokerBuilderlab() ? createDevSession() : undefined; + const legacy = + nativeBuilderlab() || dev + ? undefined + : createOAuthSession( + (signal) => { + if (!browserLoginAvailable()) + throw new Error( + "Open the Buzz desktop app to sign in with Builderlab.", + ); + return browserCredential(ctx.host, signal); + }, + (credential) => { + // Local guest teardown is immediate; the parent logout also revokes every server-side viewer child. + void ctx.host + .request({ + url: `${oauthTarget()}/v1/auth/logout`, + method: "POST", + headers: { "X-BB-Session-Credential": credential.value }, + }) + .catch(() => {}); + }, + ctx.host, + ); + const session = + dev?.session ?? legacy ?? createNativeSession(ctx.federatedIdentity); ctx.effect(() => () => session.dispose()); - const loginAvailable = browserLoginAvailable() && !unavailable; + const loginAvailable = (!!dev || browserLoginAvailable()) && !unavailable; const openSettings = (section: string) => void ctx.navigation.open({ version: 1, kind: "settings", section }); // An FI build signs out where its shared organization sign-in is managed. - const openIdentity = legacy ? undefined : () => openSettings("identity"); - applyApps(ctx, session, loginAvailable, legacy); + const openIdentity = !nativeBuilderlab() + ? undefined + : () => openSettings("identity"); + applyApps(ctx, session, loginAvailable, legacy, dev?.apps); const handoff = (signedIn: boolean) => legacy ? handoffBackupSession( @@ -106,7 +112,7 @@ export const apply: PluginModule["apply"] = (ctx) => { // The account's community list follows this sign-in; see docs/communities.md. // Where no sign-in is possible, no owner runs, so the rail has no sync to // report on. - if (browserLoginAvailable() && !unavailable) + if (loginAvailable) ctx.effect(() => startKnownCommunitiesSync({ client: createKnownCommunitiesClient(session), @@ -192,19 +198,27 @@ export const apply: PluginModule["apply"] = (ctx) => { ); // Desktop communities use this sign-in. Live development with a pinned // viewer keeps the broker, which signs in and signs bindings on its own. - const communities = !nativeIdentityEnabled() - ? brokerApi - : loginAvailable - ? createApi(createNativeBackend(session, () => linking)) - : null; + const communities = + dev?.communities ?? + (!nativeIdentityEnabled() + ? brokerApi + : loginAvailable + ? createApi(createNativeBackend(session, () => linking)) + : null); const Communities = ({ active }: { active(): boolean }) => { const state = useSyncExternalStore(session.subscribe, session.snapshot); if (!communities) return null; if (communities === brokerApi) return ; + const viewer = + state.status === "signed-in" && dev ? session.viewerSession() : undefined; return state.status === "signed-in" ? ( { session={session} available={loginAvailable} unavailableReason={ - browserLoginAvailable() + dev || browserLoginAvailable() ? unavailable : "Open the Buzz desktop app to sign in with Builderlab." } diff --git a/src/bundled/builderlab/login/Login.tsx b/src/bundled/builderlab/login/Login.tsx index 903cb3b20c..de9e626481 100644 --- a/src/bundled/builderlab/login/Login.tsx +++ b/src/bundled/builderlab/login/Login.tsx @@ -1,12 +1,15 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { Header } from "../../../shared/design-system/ui/Header"; import { Button } from "../../../shared/design-system/ui/Button"; +import { brokerBuilderlab } from "../dev-session"; import { nativeBuilderlab, type BuilderlabSession } from "../session"; const kept = () => - nativeBuilderlab() - ? "Buzz keeps your sign-in in your system keychain until you sign out." - : "Your credential stays in memory until you sign out or close Buzz."; + brokerBuilderlab() + ? "Your sign-in stays on the development server through app reloads, until you sign out or the session expires." + : nativeBuilderlab() + ? "Buzz keeps your sign-in in your system keychain until you sign out." + : "Your credential stays in memory until you sign out or close Buzz."; export function Login({ session, diff --git a/src/features/browser/api.ts b/src/features/browser/api.ts index 08b4d8c3d4..4f152ef52a 100644 --- a/src/features/browser/api.ts +++ b/src/features/browser/api.ts @@ -3,7 +3,11 @@ import type { ComponentType } from "react"; /** One signed-in Builderlab lifetime; never passed to website JavaScript. * `federated` is the public NIP-FI session revision; native holds its token. */ export type BuilderlabViewerSession = Readonly< - ({ credential: string } | { federated: string }) & { + ( + | { credential: string } + | { federated: string } + | { broker: { origin: string; revision: string } } + ) & { storage: string; signal: AbortSignal; } diff --git a/src/features/browser/platform.ts b/src/features/browser/platform.ts index f42676572f..02f9dc4310 100644 --- a/src/features/browser/platform.ts +++ b/src/features/browser/platform.ts @@ -87,9 +87,11 @@ export function createBrowserPlatform(): BrowserPlatform { bounds, storage: storage ?? null, builderlab: builderlab - ? "federated" in builderlab - ? { federated: builderlab.federated } - : { credential: builderlab.credential } + ? "broker" in builderlab + ? { broker: builderlab.broker } + : "federated" in builderlab + ? { federated: builderlab.federated } + : { credential: builderlab.credential } : null, }); if (builderlab) { diff --git a/vite.config.ts b/vite.config.ts index ca28165464..6c0670ee6d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -118,6 +118,12 @@ export default defineConfig(async ({ command, mode }) => { authorizedViewer: env.BUZZ_DEV_VIEWER, relayUrl: defaultRelay, communityAliases: aliases, + builderlab: { + api: `${builderlabUrl.replace(/\/$/, "")}/api/goose`, + appsBase: ( + await import("./src/bundled/builderlab/apps/environment.ts") + ).controlPlane(builderlabUrl), + }, }), ); const profileReadyToken = process.env.BUZZ_PROFILE_VITE_READY_TOKEN; From 76cff0c477fb405a3b2b12d5bc4052448653f6e4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 31 Oct 2026 23:05:51 -0700 Subject: [PATCH 02/22] fix(builderlab): preserve decisions with the shared development login Keep the existing native Decisions request and answer checks while proxying development authorization through the server-held Builderlab session. Reject unsafe exchanged credentials and cover callback state and shared-session decision routing. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 18 ++++++- browser-host/builderlab.test.mjs | 72 +++++++++++++++++++++++++++ browser-host/relay-broker.mjs | 28 ++++++++++- src-tauri/src/decisions.rs | 66 ++++++++++++++++++++++-- src-tauri/src/decisions_tests.rs | 37 ++++++++++++++ src/bundled/builderlab/decisions.tsx | 13 +++-- src/bundled/builderlab/dev-session.ts | 13 +++++ src/bundled/builderlab/index.test.tsx | 36 ++++++++++++-- src/bundled/builderlab/index.tsx | 7 +-- 9 files changed, 272 insertions(+), 18 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 45242d883a..aff9d4dd94 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -369,7 +369,12 @@ export function createBuilderlab({ const exchanged = await response.json(); if ( typeof exchanged.session_credential !== "string" || - !exchanged.session_credential + !exchanged.session_credential || + exchanged.session_credential.length > 4096 || + [...exchanged.session_credential].some( + (character) => + character.charCodeAt(0) <= 32 || character.charCodeAt(0) > 126, + ) ) throw new Error( "Builderlab code exchange returned an empty credential", @@ -432,7 +437,7 @@ export function createBuilderlab({ path.includes("%") || !(apps ? /^\/v1\/agent\//.test(path) - : /^\/(?:v3\/beekeeper\/|v1\/(?:auth\/me$|auth\/hosted-site\/native-session$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( + : /^\/(?:v3\/(?:beekeeper\/|builderlab\/decisions$)|v1\/(?:auth\/me$|auth\/hosted-site\/native-session$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( path, )) ) @@ -453,6 +458,15 @@ export function createBuilderlab({ } : { "X-BB-Session-Credential": secret, Origin: new URL(api).origin }), }; + if (path === "/v3/builderlab/decisions") { + if ( + typeof input.community !== "string" || + !/^[a-z0-9.-]+(?::[0-9]+)?$/i.test(input.community) || + input.community.length > 253 + ) + throw new Error("Invalid decision community"); + headers["X-Buzz-Community"] = input.community; + } let body = input.body; if (input.file) { if ( diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index da49272e8f..7c193a31ed 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -54,6 +54,10 @@ it.each([ function account(responses = {}) { const requests = []; let opened; + let didOpen; + const openedReady = new Promise((resolve) => { + didOpen = resolve; + }); const fetch = async (url, init = {}) => { const path = new URL(url).pathname.replace("/api/goose", ""); requests.push({ path, init }); @@ -83,6 +87,7 @@ function account(responses = {}) { fetch, open: async (url) => { opened = new URL(url); + didOpen(opened); }, }); return { @@ -90,6 +95,7 @@ function account(responses = {}) { requests, key, opened: () => opened, + openedReady, signs: () => signs, }; } @@ -539,3 +545,69 @@ it("ends the shared session at its advertised expiry", async () => { h.builderlab.signOut(); } }); + +it("forwards native-validated Decisions through the same server session and community", async () => { + const h = account(); + await signIn(h); + await h.builderlab.request( + { + revision: h.builderlab.snapshot().revision, + path: "/v3/builderlab/decisions", + method: "POST", + community: "buzz.example.com", + body: "{}", + }, + new AbortController().signal, + ); + expect(h.requests.at(-1).init.headers).toMatchObject({ + "X-BB-Session-Credential": "secret", + "X-Buzz-Community": "buzz.example.com", + }); + await expect( + h.builderlab.request( + { + revision: h.builderlab.snapshot().revision, + path: "/v3/builderlab/decisions", + method: "POST", + community: "x\r\nforged", + body: "{}", + }, + new AbortController().signal, + ), + ).rejects.toThrow("Invalid decision community"); + h.builderlab.signOut(); +}); + +it("requires the agents OAuth state and reuses their branded callback page", async () => { + const h = account(); + const login = h.builderlab.login(new AbortController().signal); + const opened = await h.openedReady; + const callback = opened.searchParams.get("redirect_uri"); + expect( + (await globalThis.fetch(`${callback}?state=wrong&code=x`)).status, + ).toBe(400); + const response = await globalThis.fetch( + `${callback}?state=${opened.searchParams.get("state")}&code=x`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toContain("Signing you in…"); + expect(response.headers.get("content-security-policy")).toContain( + "script-src 'sha256-", + ); + expect(response.headers.get("cache-control")).toBe("no-store"); + await login; + h.builderlab.signOut(); +}); + +it.each(["secret\ncredential", "secret credential"])( + "refuses unsafe credential bytes before any authenticated request", + async (session_credential) => { + const h = account({ + "/v1/auth/login/exchange": () => + Response.json({ session_credential, expires_at: "2030" }), + }); + await expect(signIn(h)).rejects.toThrow("empty credential"); + expect(h.requests.map((entry) => entry.path)).not.toContain("/v1/auth/me"); + expect(h.builderlab.snapshot().auth).toBeNull(); + }, +); diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 71e13acbe1..287c582aec 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -982,10 +982,36 @@ export function relayBrokerPlugin({ req.setEncoding("utf8"); for await (const part of req) { raw += part; - if (raw.length > (action === "request" ? 64 * 1024 : 4096)) + if ( + raw.length > + (action === "decisions" + ? 128 * 1024 + : action === "request" + ? 64 * 1024 + : 4096) + ) return json(res, 413, { error: "Request too large" }); } const input = raw ? JSON.parse(raw) : {}; + if (action === "decisions") { + const reply = await builderlab.request( + { + revision: req.headers["x-bb-session-credential"], + path: "/v3/builderlab/decisions", + method: "POST", + body: raw, + community: req.headers["x-buzz-community"], + }, + cancel.signal, + ); + res + .writeHead(reply.status, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }) + .end(reply.body); + return; + } if (action === "viewer") { const reply = await builderlab.request( { diff --git a/src-tauri/src/decisions.rs b/src-tauri/src/decisions.rs index 790a0f7c0b..2aef5bd966 100644 --- a/src-tauri/src/decisions.rs +++ b/src-tauri/src/decisions.rs @@ -228,9 +228,20 @@ impl DecisionsHost { .as_ref() .filter(|s| !s.trim().is_empty()) .ok_or(Failure::MissingCredentials)?; - client - .post(endpoint) - .header("x-bb-session-credential", session.as_str()) + let post = client.post(endpoint); + #[cfg(debug_assertions)] + let post = if endpoint.starts_with("http://") { + post.header( + "Origin", + reqwest::Url::parse(endpoint) + .map_err(|_| Failure::ServiceError)? + .origin() + .ascii_serialization(), + ) + } else { + post + }; + post.header("x-bb-session-credential", session.as_str()) .header("x-buzz-community", community.as_deref().unwrap_or_default()) } }; @@ -540,6 +551,25 @@ fn client() -> Option<&'static reqwest::Client> { .as_ref() } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DevelopmentSession { + origin: String, + revision: String, +} +#[cfg(debug_assertions)] +fn development_endpoint(origin: &str) -> Result { + let url = reqwest::Url::parse(origin).map_err(|_| "Invalid development origin")?; + if url.scheme() != "http" + || !matches!(url.host_str(), Some("127.0.0.1" | "localhost")) + || url.port().is_none() + || url.origin().ascii_serialization() != origin + { + return Err("Invalid development origin".into()); + } + Ok(format!("{origin}/api/builderlab/decisions")) +} + #[tauri::command] pub(crate) async fn decide( keys: tauri::State<'_, JevHost>, @@ -547,7 +577,37 @@ pub(crate) async fn decide( backend: Backend, request: Request, session: Option, + broker: Option, ) -> Result { + if let Some(broker) = broker { + #[cfg(not(debug_assertions))] + { + let _ = (broker.origin, broker.revision); + return Err("Development sign-in unavailable".into()); + } + #[cfg(debug_assertions)] + { + if backend != Backend::Builderlab || session.is_some() || broker.revision.is_empty() { + return Err("Invalid development session".into()); + } + let scoped = DecisionsHost { + endpoints: Endpoints { + typesafe: host.endpoints.typesafe.clone(), + builderlab: Some(development_endpoint(&broker.origin)?), + }, + slots: host.slots.clone(), + timeout: host.timeout, + }; + return Ok(scoped + .decide( + &keys, + backend, + Some(Zeroizing::new(broker.revision)), + request, + ) + .await); + } + } let session = session.map(Zeroizing::new); Ok(host.decide(&keys, backend, session, request).await) } diff --git a/src-tauri/src/decisions_tests.rs b/src-tauri/src/decisions_tests.rs index 27d2e5a89c..0121b63811 100644 --- a/src-tauri/src/decisions_tests.rs +++ b/src-tauri/src/decisions_tests.rs @@ -704,3 +704,40 @@ fn the_webview_names_backends_and_answers_as_documented() { json!({ "type": "yes_no", "probability": 0.5 }) ); } + +#[cfg(debug_assertions)] +#[tokio::test] +async fn development_decisions_keep_native_validation_and_send_only_a_public_revision_to_loopback() +{ + let reply = json!({ "model": "test", "answers": { "a": { "noul": { "value": 0.7 } } } }); + let (url, seen) = server(vec![Some((200, "", reply.to_string()))]).await; + let origin = reqwest::Url::parse(&url) + .unwrap() + .origin() + .ascii_serialization(); + let endpoint = development_endpoint(&origin).unwrap(); + let (_, keys) = keys(None); + let host = decisions(&endpoint); + let result = host + .decide( + &keys, + Backend::Builderlab, + session("public-revision"), + in_community(request( + json!({ "a": { "type": "yes_no", "instructions": "test" } }), + )), + ) + .await; + assert!(matches!(result, Decided::Answered { .. })); + let head = &seen.lock().unwrap()[0].0; + assert!(head.contains("x-bb-session-credential: public-revision")); + assert!(head.contains(&format!("origin: {origin}"))); + for invalid in [ + "https://evil.example", + "http://127.0.0.1:1430/path", + "http://user@localhost:1430", + "http://localhost", + ] { + assert!(development_endpoint(invalid).is_err()); + } +} diff --git a/src/bundled/builderlab/decisions.tsx b/src/bundled/builderlab/decisions.tsx index 04065af5a9..5ef62eddd2 100644 --- a/src/bundled/builderlab/decisions.tsx +++ b/src/bundled/builderlab/decisions.tsx @@ -12,7 +12,7 @@ import type { } from "../../features/decisions/service"; import { Login } from "./login/Login"; import type { Credential } from "./oauth/browser"; -import type { OAuthSession } from "./oauth/session"; +import type { BuilderlabSession } from "./session"; /** Native `decide` on Builderlab, with the sign-in to send. */ export type BuilderlabDecide = ( @@ -31,7 +31,7 @@ export const nativeBuilderlabDecide = (): BuilderlabDecide | undefined => * unless the user chose another. Builds without native calls never offer it. */ export function offerBuilderlabDecisions( ctx: Context, - session: OAuthSession, + session: BuilderlabSession, loginAvailable: boolean, native: BuilderlabDecide | undefined = nativeBuilderlabDecide(), ) { @@ -58,7 +58,7 @@ export function offerBuilderlabDecisions( } export function builderlabProvider( - session: OAuthSession, + session: BuilderlabSession, loginAvailable: boolean, native: BuilderlabDecide | undefined = nativeBuilderlabDecide(), ): DecisionProvider { @@ -91,7 +91,12 @@ export function builderlabProvider( return { outcome: "failed", reason: "unauthorized" }; let reply: unknown; try { - reply = await native(request, credential.value); + reply = await native( + request, + "value" in credential && typeof credential.value === "string" + ? credential.value + : "", + ); } catch (error) { // Tauri rejects arguments that do not deserialize before any call: // a malformed request. Any other rejection is the IPC itself failing. diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts index cf19e3e614..1292dd20e1 100644 --- a/src/bundled/builderlab/dev-session.ts +++ b/src/bundled/builderlab/dev-session.ts @@ -1,3 +1,4 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; // Public projection of the development server's login. No credential is held here. import type { Host, @@ -249,6 +250,18 @@ export function createDevSession() { return { session, communities, + decide: isTauri() + ? async (request: unknown) => { + const viewer = session.viewerSession(); + if (!("broker" in viewer)) + throw new Error("Development session unavailable"); + return invoke("decide", { + backend: "builderlab", + request, + broker: viewer.broker, + }); + } + : undefined, apps: { request: transport, upload: transport } satisfies Pick< Host, "request" | "upload" diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index 2c43f97845..59c2509cf8 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -1274,7 +1274,10 @@ it("uses one development-server login for the Settings account, hosting and remo subject: "server-account", email: "dev@example.com", expiresAt: "2030", - capabilities: { can_use_remote_agents: true }, + capabilities: { + can_use_remote_agents: true, + can_use_builderlab_decisions: true, + }, } : null, }); @@ -1332,15 +1335,22 @@ it("uses one development-server login for the Settings account, hosting and remo throw new Error(`Unexpected request: ${url}`); }), ); - native.invoke.mockRejectedValue( - new Error("This dev mode must not invoke native OAuth or backup"), - ); + native.invoke.mockImplementation(async (command) => { + if (command === "decide") + return { + outcome: "answered", + backend: "builderlab", + model: "test", + answers: { a: { type: "yes_no", probability: 0.7 } }, + }; + throw new Error("This dev mode must not invoke native OAuth or backup"); + }); const root = new Context(); const runtime = new PluginRuntime(root, async () => builderlab); new HostService(root); provideApps(root); const cards = new SettingsCardsService(root); - new DecisionsService(root, localStorage); + const decisions = new DecisionsService(root, localStorage); const community = communityFixture(null); root.provide("relay", community.relay as never); root.provide("navigation", { open: vi.fn() } as never); @@ -1380,6 +1390,22 @@ it("uses one development-server login for the Settings account, hosting and remo }), ); expect(native.invoke).not.toHaveBeenCalled(); + const [provider] = decisions.providers(); + if (!provider) throw new Error("Missing shared-session Decisions provider"); + const question = { + timeoutMs: 30_000, + state: "test", + community: "wss://buzz.example.com", + questions: { a: { type: "yes_no" as const, instructions: "test" } }, + }; + await expect( + provider.decide(question, { signal: new AbortController().signal }), + ).resolves.toMatchObject({ outcome: "answered" }); + expect(native.invoke).toHaveBeenCalledWith("decide", { + backend: "builderlab", + request: question, + broker: { origin: window.location.origin, revision: "server-one" }, + }); await userEvent.click(screen.getByRole("button", { name: "Sign out" })); expect( await screen.findByRole("button", { name: "Sign in with Builderlab" }), diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index 9ddcd0d074..96cd07c1f5 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -178,9 +178,10 @@ export const apply: PluginModule["apply"] = (ctx) => { }, }); ctx.effect(() => () => stopProvider()); - // Native `decide` sends the legacy credential; an FI build has none to - // hand it, so it does not offer Builderlab decisions. - if (legacy) offerBuilderlabDecisions(ctx, legacy, loginAvailable); + // Native validation stays in place; the dev transport uses a public revision. + // FI builds still do not offer this legacy decision backend. + if (legacy || dev) + offerBuilderlabDecisions(ctx, session, loginAvailable, dev?.decide); ctx.provide("builderlab", hostedAgentSettings(session, agents)); } // Link the Buzz key at sign-in. Best effort: activation links again and From 2bf09ce10d5bb66be3eef9b56c664b876c16aac2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 31 Oct 2026 23:14:56 -0700 Subject: [PATCH 03/22] fix(builderlab): settle identity linking before agent requests Prevent agent startup from overtaking the shared session identity binding. Observe rejected login fixtures immediately while preserving their assertions and error propagation. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 6 +++++ browser-host/builderlab.test.mjs | 45 ++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index aff9d4dd94..c87921dd54 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -420,6 +420,12 @@ export function createBuilderlab({ }).catch(() => {}); }, async request(input, signal) { + // Activation must not overtake the sign-in's automatic key binding. + if ( + typeof input.path === "string" && + input.path.startsWith("/v3/beekeeper/") + ) + await linking; current(input.revision); const started = generation; const secret = credential; diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 7c193a31ed..f2207c779b 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -100,14 +100,20 @@ function account(responses = {}) { }; } async function signIn(h) { - const pending = h.builderlab.login(new AbortController().signal); + // Observe rejection before the callback's network round trip; still propagate it to the caller. + const pending = h.builderlab.login(new AbortController().signal).then( + (auth) => ({ auth }), + (error) => ({ error }), + ); while (!h.opened()) await new Promise((resolve) => setTimeout(resolve, 5)); const returnTo = h.opened().searchParams.get("redirect_uri"); expect(new URL(returnTo).hostname).toBe("127.0.0.1"); await globalThis.fetch( `${returnTo}?state=${h.opened().searchParams.get("state")}&code=one-time`, ); - return pending; + const result = await pending; + if ("error" in result) throw result.error; + return result.auth; } it("completes browser sign-in through a loopback callback without exposing the credential", async () => { @@ -611,3 +617,38 @@ it.each(["secret\ncredential", "secret credential"])( expect(h.builderlab.snapshot().auth).toBeNull(); }, ); + +it("settles the sign-in identity binding before dispatching remote-agent operations", async () => { + const started = deferred(); + const release = deferred(); + let released = false; + const h = account({ + "/v1/buzz/nostr-identities/challenge": async () => { + started.resolve(); + await release.promise; + return Response.json({ + ...challenge, + expires_at: "2099-01-01T00:00:00Z", + }); + }, + "/v3/beekeeper/list-agents": () => { + expect(released).toBe(true); + return Response.json({ status: 1, agents: [] }); + }, + }); + await signIn(h); + await started.promise; + const request = h.builderlab.request( + { + revision: h.builderlab.snapshot().revision, + path: "/v3/beekeeper/list-agents", + method: "POST", + body: "{}", + }, + new AbortController().signal, + ); + released = true; + release.resolve(); + await expect(request).resolves.toMatchObject({ status: 200 }); + h.builderlab.signOut(); +}); From 2362490e079d6b68e1e36f2d9aa05f0bbcfe90ed Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 31 Oct 2026 23:21:29 -0700 Subject: [PATCH 04/22] fix(builderlab): accept full agent instructions in the proxy Allow the JSON envelope for the supported 20,000-character instruction payload. Exercise the real HTTP broker with escaped instructions so a smaller proxy limit cannot reject valid settings. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/relay-broker-api.test.mjs | 82 ++++++++++++++++++++++++++ browser-host/relay-broker.mjs | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index b82be6ac32..cc74d789fa 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -3917,3 +3917,85 @@ test("inventory rejects foreign pages, ignores invalid heads and follows exact-k await h.close(); } }); + +test("Builderlab HTTP accepts maximum-length agent instructions after JSON envelope escaping", async () => { + let openLogin; + const opened = new Promise((resolve) => { + openLogin = resolve; + }); + const instructions = "\\".repeat(20_000); + let received; + const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { + open: async (url) => openLogin(new URL(url)), + fetch: async (url, init) => { + const path = new URL(url).pathname; + if (path.endsWith("/v1/auth/login/exchange")) + return Response.json({ + session_credential: "fixture-only", + expires_at: "2030", + }); + if (path.endsWith("/v1/auth/me")) + return Response.json({ + subject: "fixture-account", + expires_at: "2030", + capabilities: { can_use_remote_agents: true }, + }); + if (path.endsWith("/nostr-identities/challenge")) + return Response.json({ + error: { code: "missing_mapping", setup_needed: true }, + }); + if (path.endsWith("/v3/beekeeper/update-agent")) { + received = JSON.parse(init.body); + return Response.json({ status: 1 }); + } + throw new Error(`Unexpected Builderlab request ${path}`); + }, + }); + try { + const login = fetch(`${h.base}/api/builderlab/login`, { + method: "POST", + body: "{}", + }); + const authorization = await opened; + await fetch( + `${authorization.searchParams.get("redirect_uri")}?code=fixture&state=${authorization.searchParams.get("state")}`, + ); + expect((await login).status).toBe(200); + const stream = await fetch(`${h.base}/api/builderlab/events`); + const reader = stream.body.getReader(); + const decoder = new TextDecoder(); + let event = ""; + try { + while (!event.includes("\n\n")) { + const part = await reader.read(); + if (part.done) throw new Error("Missing development session metadata"); + event += decoder.decode(part.value, { stream: true }); + } + } finally { + await reader.cancel(); + } + const { revision } = JSON.parse( + event.slice("data: ".length).split("\n\n")[0], + ); + const body = JSON.stringify({ + agent_pubkey: "a".repeat(64), + agent_instructions: instructions, + }); + const envelope = JSON.stringify({ + revision, + path: "/v3/beekeeper/update-agent", + method: "POST", + body, + }); + expect(envelope.length).toBeGreaterThan(64 * 1024); + const response = await fetch(`${h.base}/api/builderlab/request`, { + method: "POST", + body: envelope, + }); + expect(response.status).toBe(200); + expect((await response.json()).status).toBe(200); + expect(received.agent_instructions).toBe(instructions); + } finally { + await h.close(); + } +}); diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 287c582aec..92848e7523 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -987,7 +987,7 @@ export function relayBrokerPlugin({ (action === "decisions" ? 128 * 1024 : action === "request" - ? 64 * 1024 + ? 1024 * 1024 : 4096) ) return json(res, 413, { error: "Request too large" }); From 8d660fd7307039b96917835dd0e8b1350afcd1c3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 31 Oct 2026 23:31:57 -0700 Subject: [PATCH 05/22] fix(builderlab): keep pending login owned by the development server Publish login startup atomically as pending and keep the attempt alive when its initiating page disconnects. Explicit sign-out or server shutdown still cancels it. Cover the public state transition and the real HTTP disconnect flow. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 10 ++-- browser-host/builderlab.test.mjs | 20 +++++++ browser-host/relay-broker-api.test.mjs | 76 ++++++++++++++++++++++++++ browser-host/relay-broker.mjs | 3 +- 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index c87921dd54..0dc1fd2694 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -314,7 +314,7 @@ export function createBuilderlab({ // write, clear or describe the current session. let generation = 0; let session; - const revoke = () => { + const revoke = (announce = true) => { generation += 1; revision = randomUUID(); clearTimeout(expiry); @@ -323,7 +323,7 @@ export function createBuilderlab({ session?.abort(); session = new AbortController(); credential = undefined; - notify(); + if (announce) notify(); }; revoke(); const publishAccount = (value) => { @@ -347,11 +347,13 @@ export function createBuilderlab({ return account; }, async login(signal) { - revoke(); + revoke(false); const started = generation; pending = true; notify(); - const attempt = AbortSignal.any([signal, session.signal]); + const attempt = signal + ? AbortSignal.any([signal, session.signal]) + : session.signal; try { const verifier = randomBytes(32).toString("base64url"); const code = await awaitCallback(attempt, open, api, verifier); diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index f2207c779b..32ae0f79e3 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -652,3 +652,23 @@ it("settles the sign-in identity binding before dispatching remote-agent operati await expect(request).resolves.toMatchObject({ status: 200 }); h.builderlab.signOut(); }); + +it("announces a new sign-in atomically as pending, without canceling observers", async () => { + const h = account(); + const states = []; + const stop = h.builderlab.subscribe((state) => states.push(state)); + const login = h.builderlab.login(new AbortController().signal); + const authorization = await h.openedReady; + expect(states).toHaveLength(1); + expect(states[0]).toMatchObject({ pending: true, auth: null }); + await globalThis.fetch( + `${authorization.searchParams.get("redirect_uri")}?code=fixture&state=${authorization.searchParams.get("state")}`, + ); + await login; + expect(states.at(-1)).toMatchObject({ + pending: false, + auth: { subject: "auth0|owner" }, + }); + stop(); + h.builderlab.signOut(); +}); diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index cc74d789fa..25d68f6ab7 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -3999,3 +3999,79 @@ test("Builderlab HTTP accepts maximum-length agent instructions after JSON envel await h.close(); } }); + +test("Builderlab pending sign-in survives its initiating page disconnecting", async () => { + let openLogin; + const opened = new Promise((resolve) => { + openLogin = resolve; + }); + const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { + open: async (url) => openLogin(new URL(url)), + fetch: async (url) => { + const path = new URL(url).pathname; + if (path.endsWith("/v1/auth/login/exchange")) + return Response.json({ + session_credential: "fixture-only", + expires_at: "2030", + }); + if (path.endsWith("/v1/auth/me")) + return Response.json({ + subject: "fixture-account", + expires_at: "2030", + }); + if (path.endsWith("/nostr-identities/challenge")) + return Response.json({ + error: { code: "missing_mapping", setup_needed: true }, + }); + throw new Error(`Unexpected Builderlab request ${path}`); + }, + }); + const disconnected = new Promise((resolve) => { + h.server.on("request", (req, res) => { + if (req.url === "/api/builderlab/login") res.once("close", resolve); + }); + }); + const abort = new AbortController(); + let reader; + try { + const login = fetch(`${h.base}/api/builderlab/login`, { + method: "POST", + body: "{}", + signal: abort.signal, + }).catch((error) => error); + const authorization = await opened; + abort.abort(); + await login; + await disconnected; + const stream = await fetch(`${h.base}/api/builderlab/events`); + reader = stream.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + const next = async () => { + while (!buffered.includes("\n\n")) { + const part = await reader.read(); + if (part.done) throw new Error("Development session stream ended"); + buffered += decoder.decode(part.value, { stream: true }); + } + const end = buffered.indexOf("\n\n"); + const event = JSON.parse(buffered.slice("data: ".length, end)); + buffered = buffered.slice(end + 2); + return event; + }; + expect(await next()).toMatchObject({ pending: true, auth: null }); + await fetch( + `${authorization.searchParams.get("redirect_uri")}?code=fixture&state=${authorization.searchParams.get("state")}`, + ); + let signedIn; + do { + signedIn = await next(); + } while (signedIn.pending); + expect(signedIn.auth).toMatchObject({ subject: "fixture-account" }); + const auth = await (await fetch(`${h.base}/api/builderlab/auth`)).json(); + expect(auth.auth).toMatchObject({ subject: "fixture-account" }); + } finally { + abort.abort(); + await reader?.cancel(); + await h.close(); + } +}); diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 92848e7523..0e3d589284 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -910,6 +910,7 @@ export function relayBrokerPlugin({ ...builderlabOptions, }); server.httpServer?.once("close", () => { + builderlab.signOut(); for (const { close } of streams.values()) close(); archive?.close(); key.fill(0); @@ -1044,7 +1045,7 @@ export function relayBrokerPlugin({ return json(res, 409, { error: "Builderlab session changed" }); if (action === "login") return json(res, 200, { - auth: await builderlab.login(cancel.signal), + auth: await builderlab.login(), }); if (action === "sign-out") { builderlab.signOut(); From 01acd0e53c31116027cade3b5ee578de8fd347af Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 00:55:12 -0700 Subject: [PATCH 06/22] test(builderlab): model the shared login in browser fixtures Serve the signed-out session event stream in the synthetic live host used by development-React journeys. Keep console error checks strict and preserve startup account restoration. Co-authored-by: Carl Signed-off-by: Taylor Ho --- tests/browser/fixture.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 699799c6b2..71c2453bcc 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -2028,6 +2028,22 @@ export const test = base.extend({ }) : undefined; const middleware = async (request, response, next) => { + // Development-React journeys restore the shared login at startup too. + // This synthetic host has no Builderlab account; keep its stream open + // until the page disconnects, just like the real broker's signed-out state. + if ( + request.method === "GET" && + request.url === "/api/builderlab/events" + ) { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + }); + response.write( + `data: ${JSON.stringify({ revision: "fixture", pending: false, auth: null })}\n\n`, + ); + return; + } if (!request.url?.startsWith("/api/relay/")) return next(); try { const parts = request.url.split("/"); From f334e46d222dd6ab123d1007943d9916ecffab98 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 00:55:26 -0700 Subject: [PATCH 07/22] fix(builderlab): preserve the shared login after an Apps refusal Keep an Apps gateway 401 local to that operation, matching the existing Apps client. Verify the shared account revision and subsequent agent requests remain usable. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 2 +- browser-host/builderlab.test.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 0dc1fd2694..f02ad9edd2 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -525,7 +525,7 @@ export function createBuilderlab({ throw new Error("Builderlab response was too large"); if (started !== generation && !input.file) throw new Error("Builderlab session changed"); - if (response.status === 401 && started === generation) revoke(); + if (response.status === 401 && started === generation && !apps) revoke(); return { status: response.status, headers: {}, diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 32ae0f79e3..ef69c33358 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -503,6 +503,35 @@ it("expires every consumer together on 401, while a transient account check pres expect(h.builderlab.snapshot()).toMatchObject({ auth: null, pending: false }); }); +it("keeps the shared login and agent requests working after an Apps 401", async () => { + const h = account({ + "/v1/agent/apps": () => Response.json({}, { status: 401 }), + "/v3/beekeeper/list-agents": () => Response.json({ agents: [] }), + }); + await signIn(h); + const before = h.builderlab.snapshot(); + const signal = new AbortController().signal; + expect( + await h.builderlab.request( + { revision: before.revision, target: "apps", path: "/v1/agent/apps" }, + signal, + ), + ).toMatchObject({ status: 401 }); + expect(h.builderlab.snapshot()).toEqual(before); + expect( + await h.builderlab.request( + { + revision: before.revision, + path: "/v3/beekeeper/list-agents", + method: "POST", + body: "{}", + }, + signal, + ), + ).toMatchObject({ status: 200 }); + h.builderlab.signOut(); +}); + it.each([ "/v1/auth/login/exchange", "//evil.example/", From ee145e938114c5c4f7d47c7ed9f816bc2571360d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 00:55:38 -0700 Subject: [PATCH 08/22] fix(builderlab): report server identity binding failures Retain structured server binding results and return failures through the identity read used by activation. Exercise the existing agent client to verify the actionable account-link error is preserved. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 19 +++++++---- browser-host/builderlab.test.mjs | 56 ++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index f02ad9edd2..13fe2b67ef 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -393,7 +393,7 @@ export function createBuilderlab({ if (delay <= 0) throw new Error("Builderlab sign-in expired"); expiry = setTimeout(revoke, Math.min(delay, 2_147_483_647)); expiry.unref?.(); - // Binding remains server-side. Its failure is shown by the existing identity UI. + // Retain structured binding failures for the existing identity UI. linking = broker.bind().catch(() => {}); if (started !== generation) throw new Error("Builderlab session changed"); @@ -423,11 +423,12 @@ export function createBuilderlab({ }, async request(input, signal) { // Activation must not overtake the sign-in's automatic key binding. - if ( - typeof input.path === "string" && - input.path.startsWith("/v3/beekeeper/") - ) - await linking; + const binding = + input.path === "/v1/buzz/nostr-identities/current" || + (typeof input.path === "string" && + input.path.startsWith("/v3/beekeeper/")) + ? await linking + : undefined; current(input.revision); const started = generation; const secret = credential; @@ -450,6 +451,12 @@ export function createBuilderlab({ )) ) throw new Error("Unsupported Builderlab request"); + if (binding?.error && path === "/v1/buzz/nostr-identities/current") + return { + status: builderlabResponseStatus(binding), + headers: {}, + body: JSON.stringify(binding).replaceAll(secret, "[redacted]"), + }; const method = input.method ?? "GET"; if ( !(apps ? ["GET", "POST", "PUT", "DELETE"] : ["GET", "POST"]).includes( diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index ef69c33358..7d1f6826d7 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -1,5 +1,6 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { createAgentClient } from "../src/bundled/builderlab/agents/client"; import { BUILDERLAB_ORIGIN, bindingEvent, @@ -532,6 +533,58 @@ it("keeps the shared login and agent requests working after an Apps 401", async h.builderlab.signOut(); }); +it("passes the server binding failure through the identity read used by activation", async () => { + const error = { code: "identity_already_bound" }; + const h = account({ + "/v1/buzz/nostr-identities/challenge": () => + Response.json({ error }, { status: 409 }), + "/v1/buzz/nostr-identities/current": () => + Response.json({ error: { setup_needed: true } }, { status: 404 }), + }); + await signIn(h); + const reply = await h.builderlab.request( + { + revision: h.builderlab.snapshot().revision, + path: "/v1/buzz/nostr-identities/current", + method: "POST", + body: "{}", + }, + new AbortController().signal, + ); + expect(reply.status).toBe(409); + expect(JSON.parse(reply.body)).toEqual({ error }); + expect(h.requests.map(({ path }) => path)).not.toContain( + "/v1/buzz/nostr-identities/current", + ); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", BUILDERLAB_ORIGIN); + try { + const credential = { account: {} }; + const client = createAgentClient( + { + credential: () => credential, + snapshot: () => ({ status: "signed-in" }), + request: (_credential, input) => + h.builderlab.request( + { ...input, revision: h.builderlab.snapshot().revision }, + new AbortController().signal, + ), + }, + { + key: async () => getPublicKey(h.key), + signBinding: async () => { + throw new Error("The server owns binding"); + }, + }, + ); + await expect(client.link(new AbortController().signal)).rejects.toThrow( + "This Builderlab account is linked to a different Buzz identity.", + ); + } finally { + vi.unstubAllEnvs(); + } + h.builderlab.signOut(); +}); + it.each([ "/v1/auth/login/exchange", "//evil.example/", @@ -552,7 +605,6 @@ it.each([ }); it("ends the shared session at its advertised expiry", async () => { - const { vi } = await import("vitest"); const expires = new Date(Date.now() + 60_000).toISOString(); const h = account({ "/v1/auth/login/exchange": () => From 20159c71cd204fa99c082800e135cf704ede1c52 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 00:56:12 -0700 Subject: [PATCH 09/22] fix(builderlab): preserve encoded IDs in authenticated requests Resolve request URLs before enforcing the configured origin, API prefix and route allowlist. Preserve encoded segments and query values while rejecting traversal outside the allowed routes. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 27 ++++++++++++++++++++------- browser-host/builderlab.test.mjs | 22 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 13fe2b67ef..5d0704cec8 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -437,13 +437,26 @@ export function createBuilderlab({ throw new Error( "Apps are not configured for this Builderlab environment", ); - const path = input.path; + const base = new URL(apps ? appsBase : api); + const prefix = base.pathname.replace(/\/$/, ""); + let url; + try { + if ( + typeof input.path !== "string" || + !input.path.startsWith("/") || + input.path.startsWith("//") || + input.path.includes("#") || + input.path.includes("\\") + ) + throw new Error(); + url = new URL(`${base.href.replace(/\/$/, "")}${input.path}`); + } catch { + throw new Error("Unsupported Builderlab request"); + } + const path = url.pathname.slice(prefix.length); if ( - typeof path !== "string" || - path.includes("#") || - path.includes("\\") || - path.includes("..") || - path.includes("%") || + url.origin !== base.origin || + !url.pathname.startsWith(`${prefix}/`) || !(apps ? /^\/v1\/agent\//.test(path) : /^\/(?:v3\/(?:beekeeper\/|builderlab\/decisions$)|v1\/(?:auth\/me$|auth\/hosted-site\/native-session$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( @@ -514,7 +527,7 @@ export function createBuilderlab({ headers["Content-Type"] = "application/json"; } current(input.revision); - const response = await fetch(`${apps ? appsBase : api}${path}`, { + const response = await fetch(url, { method, headers, ...(body !== undefined ? { body } : {}), diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 7d1f6826d7..faadf4d556 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -1,5 +1,6 @@ import { expect, it, vi } from "vitest"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { controlPlane } from "../src/bundled/builderlab/apps/environment"; import { createAgentClient } from "../src/bundled/builderlab/agents/client"; import { BUILDERLAB_ORIGIN, @@ -61,7 +62,7 @@ function account(responses = {}) { }); const fetch = async (url, init = {}) => { const path = new URL(url).pathname.replace("/api/goose", ""); - requests.push({ path, init }); + requests.push({ path, url: new URL(url), init }); const reply = responses[path]; if (reply) return reply(init); if (path === "/v1/auth/login/exchange") @@ -585,11 +586,30 @@ it("passes the server binding failure through the identity read used by activati h.builderlab.signOut(); }); +it("preserves encoded Apps IDs and query values after resolving the allowed URL", async () => { + const h = account(); + await signIn(h); + const path = + "/v1/agent/apps/app/communities/" + + encodeURIComponent("community:id") + + "?" + + new URLSearchParams({ version_id: "version/id with spaces" }); + await h.builderlab.request( + { revision: h.builderlab.snapshot().revision, target: "apps", path }, + new AbortController().signal, + ); + expect(h.requests.at(-1).url.href).toBe( + `${controlPlane(BUILDERLAB_ORIGIN)}${path}`, + ); + h.builderlab.signOut(); +}); + it.each([ "/v1/auth/login/exchange", "//evil.example/", "/v3/beekeeper/../auth/me", "/v3/beekeeper/%2e%2e/auth/me", + "/v3/beekeeper/../../../../v1/auth/me", ])("refuses proxy path %s before dispatch", async (path) => { const h = account(); await signIn(h); From 39923aad42958fd9675117585629230450ffd282 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 00:56:27 -0700 Subject: [PATCH 10/22] fix(builderlab): isolate native session exchange routes Keep viewer and Decisions exchange off the renderer generic proxy while retaining the fixed host routes. Cover both HTTP boundaries and state the trusted-local-caller limits accurately. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 19 +++++++---- browser-host/builderlab.test.mjs | 4 +++ browser-host/relay-broker-api.test.mjs | 44 +++++++++++++++++++++++++- browser-host/relay-broker.mjs | 2 ++ src/bundled/builderlab/dev-session.ts | 3 +- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 5d0704cec8..6c3063acc4 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -421,7 +421,8 @@ export function createBuilderlab({ signal: AbortSignal.timeout(30000), }).catch(() => {}); }, - async request(input, signal) { + // Only the broker's fixed viewer/Decisions handlers opt into native routes. + async request(input, signal, native = false) { // Activation must not overtake the sign-in's automatic key binding. const binding = input.path === "/v1/buzz/nostr-identities/current" || @@ -457,11 +458,17 @@ export function createBuilderlab({ if ( url.origin !== base.origin || !url.pathname.startsWith(`${prefix}/`) || - !(apps - ? /^\/v1\/agent\//.test(path) - : /^\/(?:v3\/(?:beekeeper\/|builderlab\/decisions$)|v1\/(?:auth\/me$|auth\/hosted-site\/native-session$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( - path, - )) + !(native + ? !apps && + [ + "/v3/builderlab/decisions", + "/v1/auth/hosted-site/native-session", + ].includes(path) + : apps + ? /^\/v1\/agent\//.test(path) + : /^\/(?:v3\/beekeeper\/|v1\/(?:auth\/me$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( + path, + )) ) throw new Error("Unsupported Builderlab request"); if (binding?.error && path === "/v1/buzz/nostr-identities/current") diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index faadf4d556..c0c7c58c92 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -609,6 +609,8 @@ it.each([ "//evil.example/", "/v3/beekeeper/../auth/me", "/v3/beekeeper/%2e%2e/auth/me", + "/v1/auth/hosted-site/native-session", + "/v3/builderlab/decisions", "/v3/beekeeper/../../../../v1/auth/me", ])("refuses proxy path %s before dispatch", async (path) => { const h = account(); @@ -665,6 +667,7 @@ it("forwards native-validated Decisions through the same server session and comm body: "{}", }, new AbortController().signal, + true, ); expect(h.requests.at(-1).init.headers).toMatchObject({ "X-BB-Session-Credential": "secret", @@ -680,6 +683,7 @@ it("forwards native-validated Decisions through the same server session and comm body: "{}", }, new AbortController().signal, + true, ), ).rejects.toThrow("Invalid decision community"); h.builderlab.signOut(); diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 25d68f6ab7..859adb734e 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -3918,13 +3918,14 @@ test("inventory rejects foreign pages, ignores invalid heads and follows exact-k } }); -test("Builderlab HTTP accepts maximum-length agent instructions after JSON envelope escaping", async () => { +test("Builderlab HTTP accepts full agent instructions and isolates the fixed native routes", async () => { let openLogin; const opened = new Promise((resolve) => { openLogin = resolve; }); const instructions = "\\".repeat(20_000); let received; + const nativeCalls = []; const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { open: async (url) => openLogin(new URL(url)), fetch: async (url, init) => { @@ -3948,6 +3949,17 @@ test("Builderlab HTTP accepts maximum-length agent instructions after JSON envel received = JSON.parse(init.body); return Response.json({ status: 1 }); } + if ( + path.endsWith("/v3/builderlab/decisions") || + path.endsWith("/v1/auth/hosted-site/native-session") + ) { + nativeCalls.push({ + path, + body: JSON.parse(init.body), + headers: init.headers, + }); + return Response.json({ fixture: "native reply" }); + } throw new Error(`Unexpected Builderlab request ${path}`); }, }); @@ -3995,6 +4007,36 @@ test("Builderlab HTTP accepts maximum-length agent instructions after JSON envel expect(response.status).toBe(200); expect((await response.json()).status).toBe(200); expect(received.agent_instructions).toBe(instructions); + for (const [action, path, header] of [ + ["decisions", "/v3/builderlab/decisions", "X-BB-Session-Credential"], + [ + "viewer", + "/v1/auth/hosted-site/native-session", + "X-Buzz-Builderlab-Revision", + ], + ]) { + const refused = await fetch(`${h.base}/api/builderlab/request`, { + method: "POST", + body: JSON.stringify({ revision, path, method: "POST", body: "{}" }), + }); + expect(await refused.json()).toEqual({ + error: "Unsupported Builderlab request", + }); + const forwarded = await fetch(`${h.base}/api/builderlab/${action}`, { + method: "POST", + headers: { [header]: revision, "X-Buzz-Community": "buzz.example" }, + body: JSON.stringify({ fixture: action }), + }); + expect(forwarded.status).toBe(200); + expect(await forwarded.json()).toEqual({ fixture: "native reply" }); + expect(nativeCalls.at(-1)).toMatchObject({ + path: `/api/goose${path}`, + body: { fixture: action }, + headers: { "X-BB-Session-Credential": "fixture-only" }, + }); + } + expect(nativeCalls).toHaveLength(2); + expect(nativeCalls[0].headers["X-Buzz-Community"]).toBe("buzz.example"); } finally { await h.close(); } diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 0e3d589284..303830b826 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -1004,6 +1004,7 @@ export function relayBrokerPlugin({ community: req.headers["x-buzz-community"], }, cancel.signal, + true, ); res .writeHead(reply.status, { @@ -1022,6 +1023,7 @@ export function relayBrokerPlugin({ body: JSON.stringify(input), }, cancel.signal, + true, ); res .writeHead(reply.status, { diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts index 1292dd20e1..5839888916 100644 --- a/src/bundled/builderlab/dev-session.ts +++ b/src/bundled/builderlab/dev-session.ts @@ -1,5 +1,6 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; -// Public projection of the development server's login. No credential is held here. +// Public projection of the development server's login; the parent credential stays in Node. +// Trusted local callers can still use its dedicated native viewer/Decisions routes. import type { Host, HostRequest, From ceb3704d5fe7b33206ea5fa9f9f1e5ec58d1b4e0 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 00:56:41 -0700 Subject: [PATCH 11/22] refactor(builderlab): share fixed native forwarding Use one forwarding block for the two fixed native exchanges and document their public revision header. Require the configured Apps control plane instead of duplicating its tenant map. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 2 +- browser-host/builderlab.test.mjs | 1 + browser-host/relay-broker.mjs | 37 +++++++++++++------------------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 6c3063acc4..7c9055b88d 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -218,7 +218,7 @@ export function createBuilderlab({ fetch = globalThis.fetch, open = openBrowser, api = API, - appsBase = api === API ? "https://compose-ctrl.builderlab.xyz" : undefined, + appsBase, }) { let credential; let account; diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index c0c7c58c92..6ad1b7bcb1 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -87,6 +87,7 @@ function account(responses = {}) { return key; }, fetch, + appsBase: controlPlane(BUILDERLAB_ORIGIN), open: async (url) => { opened = new URL(url); didOpen(opened); diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index 303830b826..14029a7e03 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -994,11 +994,23 @@ export function relayBrokerPlugin({ return json(res, 413, { error: "Request too large" }); } const input = raw ? JSON.parse(raw) : {}; - if (action === "decisions") { + const nativeRoute = { + // Native Decisions puts the public revision in its existing credential header. + decisions: [ + "/v3/builderlab/decisions", + "x-bb-session-credential", + ], + viewer: [ + "/v1/auth/hosted-site/native-session", + "x-buzz-builderlab-revision", + ], + }[action]; + if (nativeRoute) { + const [path, header] = nativeRoute; const reply = await builderlab.request( { - revision: req.headers["x-bb-session-credential"], - path: "/v3/builderlab/decisions", + revision: req.headers[header], + path, method: "POST", body: raw, community: req.headers["x-buzz-community"], @@ -1014,25 +1026,6 @@ export function relayBrokerPlugin({ .end(reply.body); return; } - if (action === "viewer") { - const reply = await builderlab.request( - { - revision: req.headers["x-buzz-builderlab-revision"], - path: "/v1/auth/hosted-site/native-session", - method: "POST", - body: JSON.stringify(input), - }, - cancel.signal, - true, - ); - res - .writeHead(reply.status, { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }) - .end(reply.body); - return; - } if (action === "request") return json( res, From b23ebdbfa02fac32de10aab7e270741b5b03a94b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 01:22:27 -0700 Subject: [PATCH 12/22] fix(builderlab): refresh binding state after link retries Have each bind attempt replace the shared pending result, including manual Link retries, while preserving error reporting to the caller. Reproduce a failed sign-in bind followed by a successful HTTP bind and a fresh bound identity read. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 62 ++++++++------- browser-host/relay-broker-api.test.mjs | 100 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 28 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 7c9055b88d..65de457a06 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -394,7 +394,7 @@ export function createBuilderlab({ expiry = setTimeout(revoke, Math.min(delay, 2_147_483_647)); expiry.unref?.(); // Retain structured binding failures for the existing identity UI. - linking = broker.bind().catch(() => {}); + void broker.bind(); if (started !== generation) throw new Error("Builderlab session changed"); publishAccount(account); @@ -559,33 +559,39 @@ export function createBuilderlab({ body: text.replaceAll(secret, "[redacted]"), }; }, - async bind() { - const session = credential; - const started = generation; - const challenge = await request( - "/v1/buzz/nostr-identities/challenge", - { origin: new URL(api).origin }, - session, - ); - if (challenge.error) return challenge; - // A sign-out or new sign-in while the challenge was pending must not let the - // old credential claim this device's key. - if (started !== generation) throw new Error("Builderlab session changed"); - const event = bindingEvent( - key(), - challenge, - Date.now(), - new URL(api).origin, - ); - return request( - "/v1/buzz/nostr-identities/verify", - { - challenge_id: challenge.challenge_id, - nonce: challenge.nonce, - signed_payload: JSON.stringify(event), - }, - session, - ); + bind() { + const done = (async () => { + const session = credential; + const started = generation; + const challenge = await request( + "/v1/buzz/nostr-identities/challenge", + { origin: new URL(api).origin }, + session, + ); + if (challenge.error) return challenge; + // A sign-out or new sign-in while the challenge was pending must not let the + // old credential claim this device's key. + if (started !== generation) + throw new Error("Builderlab session changed"); + const event = bindingEvent( + key(), + challenge, + Date.now(), + new URL(api).origin, + ); + return request( + "/v1/buzz/nostr-identities/verify", + { + challenge_id: challenge.challenge_id, + nonce: challenge.nonce, + signed_payload: JSON.stringify(event), + }, + session, + ); + })(); + // Every attempt, including a manual retry, replaces the previous result. + linking = done.catch(() => {}); + return done; }, async call(action, input) { const route = Object.hasOwn(ROUTES, action) ? ROUTES[action] : undefined; diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 859adb734e..c652cb95cc 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -4042,6 +4042,106 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat } }); +test("Builderlab Link retry replaces a failed sign-in binding for agent identity reads", async () => { + let openLogin; + const opened = new Promise((resolve) => { + openLogin = resolve; + }); + let retry = false; + let bound = false; + const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { + open: async (url) => openLogin(new URL(url)), + fetch: async (url) => { + const path = new URL(url).pathname; + if (path.endsWith("/v1/auth/login/exchange")) + return Response.json({ + session_credential: "fixture-only", + expires_at: "2030", + }); + if (path.endsWith("/v1/auth/me")) + return Response.json({ + subject: "fixture-account", + expires_at: "2030", + }); + if (path.endsWith("/nostr-identities/challenge")) + return retry + ? Response.json({ + challenge_id: "0b7e3c1e-7a51-4c4a-9d3e-2f0b8a6c1d22", + nonce: "a".repeat(43), + verification_code: "123456", + origin: "https://app.builderlab.xyz", + expires_at: "2030-01-01T00:00:00Z", + }) + : Response.json( + { error: { code: "identity_already_bound" } }, + { status: 409 }, + ); + if (path.endsWith("/nostr-identities/verify")) bound = true; + if ( + path.endsWith("/nostr-identities/current") || + path.endsWith("/nostr-identities/verify") + ) + return Response.json( + bound + ? { identity: { pubkey_hex: h.event.pubkey } } + : { error: { setup_needed: true } }, + ); + throw new Error(`Unexpected Builderlab request ${path}`); + }, + }); + try { + const login = fetch(`${h.base}/api/builderlab/login`, { + method: "POST", + body: "{}", + }); + const authorization = await opened; + await fetch( + `${authorization.searchParams.get("redirect_uri")}?code=fixture&state=${authorization.searchParams.get("state")}`, + ); + expect((await login).status).toBe(200); + const stream = await fetch(`${h.base}/api/builderlab/events`); + let event = ""; + for await (const chunk of stream.body) { + event += new TextDecoder().decode(chunk); + if (event.includes("\n\n")) break; + } + const { revision } = JSON.parse( + event.slice("data: ".length).split("\n\n")[0], + ); + const current = async () => + ( + await fetch(`${h.base}/api/builderlab/request`, { + method: "POST", + body: JSON.stringify({ + revision, + path: "/v1/buzz/nostr-identities/current", + method: "POST", + body: "{}", + }), + }) + ).json(); + expect(JSON.parse((await current()).body)).toEqual({ + error: { code: "identity_already_bound" }, + }); + retry = true; + const linked = await fetch(`${h.base}/api/builderlab/bind`, { + method: "POST", + body: JSON.stringify({ revision }), + }); + expect(linked.status).toBe(200); + expect(await linked.json()).toEqual({ + identity: { pubkey_hex: h.event.pubkey }, + }); + const refreshed = await current(); + expect(refreshed.status).toBe(200); + expect(JSON.parse(refreshed.body)).toEqual({ + identity: { pubkey_hex: h.event.pubkey }, + }); + } finally { + await h.close(); + } +}); + test("Builderlab pending sign-in survives its initiating page disconnecting", async () => { let openLogin; const opened = new Promise((resolve) => { From 7b89bdf02f5b6834bcf7827190e016467f5756da Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 01:53:19 -0800 Subject: [PATCH 13/22] fix(builderlab): retain accepted upload receipts across sign-out Keep dispatched uploads on their captured account while preserving disconnect cancellation and deadlines. Cover receipt delivery after sign-out or account switch and prevent readiness checks under a replacement dev account. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 3 +- browser-host/builderlab.test.mjs | 76 ++++++++++++++++++++++ src/bundled/builderlab/dev-session.test.ts | 63 ++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 65de457a06..bf39c67c85 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -541,7 +541,8 @@ export function createBuilderlab({ redirect: "error", signal: AbortSignal.any([ signal, - session.signal, + // A dispatched upload keeps its receipt after sign-out/account switch. + ...(input.file ? [] : [session.signal]), AbortSignal.timeout( input.file || path.endsWith("/initialize") ? 180000 : 60000, ), diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 6ad1b7bcb1..9debe3bc7c 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -1,4 +1,7 @@ import { expect, it, vi } from "vitest"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { controlPlane } from "../src/bundled/builderlab/apps/environment"; import { createAgentClient } from "../src/bundled/builderlab/agents/client"; @@ -778,3 +781,76 @@ it("announces a new sign-in atomically as pending, without canceling observers", stop(); h.builderlab.signOut(); }); + +it.each(["sign-out", "account-switch", "disconnect"])( + "preserves a dispatched Apps upload across %s while retaining disconnect cancellation", + async (mode) => { + const directory = await mkdtemp( + join(await realpath(homedir()), ".buzz-upload-test-"), + ); + const file = join(directory, "artifact.tar.gz"); + await writeFile(file, "fixture archive"); + const accepted = deferred(); + const release = deferred(); + const receipt = { version_id: "version-A", deployment_id: "deployment-A" }; + const h = account({ + "/v1/agent/apps/demo/deploy": (init) => { + accepted.resolve(init); + return { + status: 201, + async text() { + await release.promise; + init.signal.throwIfAborted(); + return JSON.stringify(receipt); + }, + }; + }, + }); + const caller = new AbortController(); + try { + await signIn(h); + const pending = h.builderlab + .request( + { + revision: h.builderlab.snapshot().revision, + target: "apps", + path: "/v1/agent/apps/demo/deploy", + method: "POST", + file, + }, + caller.signal, + ) + .then( + (result) => ({ result }), + (error) => ({ error }), + ); + const upstream = await accepted.promise; + expect(upstream.headers.Authorization).toBe("BBIdentity secret"); + if (mode === "disconnect") caller.abort(); + else { + h.builderlab.signOut(); + if (mode === "account-switch") { + const before = h.opened(); + const login = h.builderlab.login(new AbortController().signal); + await vi.waitFor(() => expect(h.opened()).not.toBe(before)); + await globalThis.fetch( + `${h.opened().searchParams.get("redirect_uri")}?state=${h.opened().searchParams.get("state")}&code=replacement`, + ); + await login; + } + } + expect(upstream.signal.aborted).toBe(mode === "disconnect"); + release.resolve(); + const outcome = await pending; + if (mode === "disconnect") expect(outcome.error?.name).toBe("AbortError"); + else { + expect(outcome.result?.status).toBe(201); + expect(JSON.parse(outcome.result.body)).toEqual(receipt); + } + } finally { + release.resolve(); + h.builderlab.signOut(); + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index 87e8f8efca..fce4e05a0e 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -2,6 +2,8 @@ import { afterEach, expect, it, vi } from "vitest"; import { createDevSession, brokerBuilderlab } from "./dev-session"; import { createAgentClient } from "./agents/client"; import { createAppsClient } from "./apps/client"; +import { applyApps } from "./apps"; +import type { AddedTool } from "../../buzz-mcp/session-tools"; import { deferred } from "./test-helpers"; class Events { @@ -167,3 +169,64 @@ it("cancels local viewers on sign-out and reconnects to the authoritative state expect(h.session.snapshot()).toEqual({ status: "signed-out" }); h.session.dispose(); }); + +it("returns accepted deployment IDs without checking readiness under a replacement dev account", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlabstaging.xyz"); + const h = setup(); + const tools = new Map(); + const ctx = { + panels: { register() {} }, + browser: { available: true, View: () => null }, + host: {}, + agents: { addTool: (tool: AddedTool) => tools.set(tool.name, tool) }, + }; + applyApps(ctx as never, h.session, true, undefined, h.apps); + h.events.emit("one", "A"); + await signedIn(h.session); + const accepted = deferred(); + const receipt = deferred(); + h.fetch.mockResolvedValueOnce( + Response.json({ + status: 200, + headers: {}, + body: JSON.stringify({ minimum_client_version: "0.2.0" }), + }), + ); + h.fetch.mockImplementationOnce(() => { + accepted.resolve(); + return receipt.promise; + }); + const tool = tools.get("apps_deploy"); + if (!tool) throw new Error("Missing apps_deploy tool"); + const pending = tool.call( + {} as never, + {}, + { + app_id: "demo", + artifact: "/Users/me/artifact.tar.gz", + }, + ); + try { + await accepted.promise; + h.events.emit("two", "B"); + await signedIn(h.session); + } finally { + receipt.resolve( + Response.json({ + status: 201, + headers: {}, + body: JSON.stringify({ version_id: "v1", deployment_id: "d1" }), + }), + ); + } + expect(JSON.parse(String(await pending))).toMatchObject({ + app_id: "demo", + version_id: "v1", + deployment_id: "d1", + ready: false, + readiness_error: expect.stringMatching(/sign-in changed/), + }); + expect(h.fetch).toHaveBeenCalledTimes(2); + expect(h.session.snapshot().account?.subject).toBe("B"); + h.session.dispose(); +}); From 940249a725272b49fdf2276ee79633d35858a59c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 01:53:33 -0800 Subject: [PATCH 14/22] fix(builderlab): preserve dev create recovery errors Use the existing empty-secret redaction guard for create failures. Cover readable reserve and initialize errors with the exact recovery app ID through the dev transport. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/builderlab/apps/client.test.ts | 32 ++++++++++++++++++++-- src/bundled/builderlab/apps/client.ts | 2 +- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/bundled/builderlab/apps/client.test.ts b/src/bundled/builderlab/apps/client.test.ts index f1f4fc1436..6df0fafa92 100644 --- a/src/bundled/builderlab/apps/client.test.ts +++ b/src/bundled/builderlab/apps/client.test.ts @@ -112,7 +112,7 @@ it("preserves the reserved app ID when sign-out interrupts initialization", asyn expect(request).toHaveBeenCalledTimes(3); }); -async function setup(responses: HostResponse[], signedIn = true) { +async function setup(responses: HostResponse[], signedIn = true, dev = false) { const session = createOAuthSession(async () => ({ value: "secret-session", account: { @@ -131,7 +131,10 @@ async function setup(responses: HostResponse[], signedIn = true) { return response; }; const host = { request: vi.fn(next), upload: vi.fn(next) } as unknown as Host; - return { client: createAppsClient(host, session, BASE), requests }; + return { + client: createAppsClient(host, session, BASE, dev ? host : undefined), + requests, + }; } it("plans and reserves a static app with the Builderlab session", async () => { @@ -506,3 +509,28 @@ it("rejects URLs outside the deployed app hostname shapes", () => { ]) expect(isAppUrl(url), url).toBe(false); }); + +it.each(["reserve", "initialize"] as const)( + "keeps dev create errors and the recovery app ID readable after %s fails", + async (step) => { + const { client, requests } = await setup( + [ + contract(), + json(200, { + app_id: "recovery-app", + initialize: { required: true, recommended: true }, + }), + ...(step === "initialize" + ? [json(200, { app_id: "recovery-app" })] + : []), + json(503, { error: { message: "gateway unavailable" } }), + ], + true, + true, + ); + await expect(client.create("Demo")).rejects.toThrow( + `Create stopped during ${step} for app_id=recovery-app: ${step === "reserve" ? "Reserving" : "Initializing"} failed (HTTP 503): gateway unavailable. The reservation may already exist; inspect or resume this exact app before creating a replacement.`, + ); + expect(requests).toHaveLength(step === "reserve" ? 3 : 4); + }, +); diff --git a/src/bundled/builderlab/apps/client.ts b/src/bundled/builderlab/apps/client.ts index f04c0b5daa..a90089ff47 100644 --- a/src/bundled/builderlab/apps/client.ts +++ b/src/bundled/builderlab/apps/client.ts @@ -197,7 +197,7 @@ export function createAppsClient( error instanceof Error ? error.message : "Request failed"; throw new Error( `Create stopped during ${step} for app_id=${id}: ${detail}. The reservation may already exist; inspect or resume this exact app before creating a replacement.`.replaceAll( - secret, + secret || "\u0000", "[redacted]", ), ); From 2fbca6a4358588c9ab53708de43e0c2ecaead01d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 01:54:02 -0800 Subject: [PATCH 15/22] fix(builderlab): ignore stale login errors after cancellation Fence login failures with the existing session version so a late response cannot overwrite cancellation or a newer state. Cover signed-out SSE before a late 502 and current-attempt failures. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/builderlab/dev-session.test.ts | 28 ++++++++++++++++++++++ src/bundled/builderlab/dev-session.ts | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index fce4e05a0e..e840bf9b72 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -230,3 +230,31 @@ it("returns accepted deployment IDs without checking readiness under a replaceme expect(h.session.snapshot().account?.subject).toBe("B"); h.session.dispose(); }); + +it.each([false, true])( + "reports a login failure only if its attempt is still current (canceled: %s)", + async (canceled) => { + const h = setup(); + h.events.emit("one", null); + const response = deferred(); + h.fetch.mockImplementationOnce(() => response.promise); + const pending = h.session.signIn(); + expect(h.session.snapshot().status).toBe("pending"); + if (canceled) { + h.session.cancel(); + h.events.emit("two", null); + } + response.resolve( + Response.json( + { error: "Builderlab authentication canceled" }, + { status: 502 }, + ), + ); + await pending; + expect(h.session.snapshot()).toEqual({ + status: "signed-out", + ...(canceled ? {} : { error: "Builderlab authentication canceled" }), + }); + h.session.dispose(); + }, +); diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts index 5839888916..968ef2751c 100644 --- a/src/bundled/builderlab/dev-session.ts +++ b/src/bundled/builderlab/dev-session.ts @@ -173,11 +173,12 @@ export function createDevSession() { }, async signIn() { if (disposed || state.status === "pending") return; + const at = ++version; publish({ status: "pending" }); try { await send("login"); } catch (error) { - if (!disposed && !owner) + if (!disposed && version === at && !owner) publish({ status: "signed-out", error: From 60d18e3c2934955914210bbc71acb1bd80348cd4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 01:54:37 -0800 Subject: [PATCH 16/22] refactor(builderlab): remove unused dev identity cache Keep the remote-agent binding wait while removing unreachable generic current/auth/list routes and their cache-only tests. Use a supported agent route in stale-result tests and verify the removed routes are rejected before dispatch. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 21 ++--- browser-host/builderlab.test.mjs | 58 +----------- browser-host/relay-broker-api.test.mjs | 100 --------------------- src/bundled/builderlab/dev-session.test.ts | 4 +- 4 files changed, 14 insertions(+), 169 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index bf39c67c85..f7d595da43 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -393,7 +393,7 @@ export function createBuilderlab({ if (delay <= 0) throw new Error("Builderlab sign-in expired"); expiry = setTimeout(revoke, Math.min(delay, 2_147_483_647)); expiry.unref?.(); - // Retain structured binding failures for the existing identity UI. + // Bind before subsequent remote-agent operations. void broker.bind(); if (started !== generation) throw new Error("Builderlab session changed"); @@ -424,12 +424,11 @@ export function createBuilderlab({ // Only the broker's fixed viewer/Decisions handlers opt into native routes. async request(input, signal, native = false) { // Activation must not overtake the sign-in's automatic key binding. - const binding = - input.path === "/v1/buzz/nostr-identities/current" || - (typeof input.path === "string" && - input.path.startsWith("/v3/beekeeper/")) - ? await linking - : undefined; + if ( + typeof input.path === "string" && + input.path.startsWith("/v3/beekeeper/") + ) + await linking; current(input.revision); const started = generation; const secret = credential; @@ -466,17 +465,11 @@ export function createBuilderlab({ ].includes(path) : apps ? /^\/v1\/agent\//.test(path) - : /^\/(?:v3\/beekeeper\/|v1\/(?:auth\/me$|buzz\/(?:communities\/list$|known-communities\/(?:list|add|remove)$)|buzz\/nostr-identities\/current$))/.test( + : /^\/(?:v3\/beekeeper\/|v1\/buzz\/known-communities\/(?:list|add|remove)$)/.test( path, )) ) throw new Error("Unsupported Builderlab request"); - if (binding?.error && path === "/v1/buzz/nostr-identities/current") - return { - status: builderlabResponseStatus(binding), - headers: {}, - body: JSON.stringify(binding).replaceAll(secret, "[redacted]"), - }; const method = input.method ?? "GET"; if ( !(apps ? ["GET", "POST", "PUT", "DELETE"] : ["GET", "POST"]).includes( diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index 9debe3bc7c..e4c1f562c1 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -4,7 +4,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { controlPlane } from "../src/bundled/builderlab/apps/environment"; -import { createAgentClient } from "../src/bundled/builderlab/agents/client"; import { BUILDERLAB_ORIGIN, bindingEvent, @@ -479,7 +478,7 @@ it("keeps one public server revision for agents, Apps and reloaded clients, and h.builderlab.signOut(); await expect( h.builderlab.request( - { revision, path: "/v1/auth/me" }, + { revision, path: "/v3/beekeeper/list-agents" }, new AbortController().signal, ), ).rejects.toThrow("session changed"); @@ -538,58 +537,6 @@ it("keeps the shared login and agent requests working after an Apps 401", async h.builderlab.signOut(); }); -it("passes the server binding failure through the identity read used by activation", async () => { - const error = { code: "identity_already_bound" }; - const h = account({ - "/v1/buzz/nostr-identities/challenge": () => - Response.json({ error }, { status: 409 }), - "/v1/buzz/nostr-identities/current": () => - Response.json({ error: { setup_needed: true } }, { status: 404 }), - }); - await signIn(h); - const reply = await h.builderlab.request( - { - revision: h.builderlab.snapshot().revision, - path: "/v1/buzz/nostr-identities/current", - method: "POST", - body: "{}", - }, - new AbortController().signal, - ); - expect(reply.status).toBe(409); - expect(JSON.parse(reply.body)).toEqual({ error }); - expect(h.requests.map(({ path }) => path)).not.toContain( - "/v1/buzz/nostr-identities/current", - ); - vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", BUILDERLAB_ORIGIN); - try { - const credential = { account: {} }; - const client = createAgentClient( - { - credential: () => credential, - snapshot: () => ({ status: "signed-in" }), - request: (_credential, input) => - h.builderlab.request( - { ...input, revision: h.builderlab.snapshot().revision }, - new AbortController().signal, - ), - }, - { - key: async () => getPublicKey(h.key), - signBinding: async () => { - throw new Error("The server owns binding"); - }, - }, - ); - await expect(client.link(new AbortController().signal)).rejects.toThrow( - "This Builderlab account is linked to a different Buzz identity.", - ); - } finally { - vi.unstubAllEnvs(); - } - h.builderlab.signOut(); -}); - it("preserves encoded Apps IDs and query values after resolving the allowed URL", async () => { const h = account(); await signIn(h); @@ -610,6 +557,9 @@ it("preserves encoded Apps IDs and query values after resolving the allowed URL" it.each([ "/v1/auth/login/exchange", + "/v1/auth/me", + "/v1/buzz/communities/list", + "/v1/buzz/nostr-identities/current", "//evil.example/", "/v3/beekeeper/../auth/me", "/v3/beekeeper/%2e%2e/auth/me", diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index c652cb95cc..859adb734e 100644 --- a/browser-host/relay-broker-api.test.mjs +++ b/browser-host/relay-broker-api.test.mjs @@ -4042,106 +4042,6 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat } }); -test("Builderlab Link retry replaces a failed sign-in binding for agent identity reads", async () => { - let openLogin; - const opened = new Promise((resolve) => { - openLogin = resolve; - }); - let retry = false; - let bound = false; - const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, { - open: async (url) => openLogin(new URL(url)), - fetch: async (url) => { - const path = new URL(url).pathname; - if (path.endsWith("/v1/auth/login/exchange")) - return Response.json({ - session_credential: "fixture-only", - expires_at: "2030", - }); - if (path.endsWith("/v1/auth/me")) - return Response.json({ - subject: "fixture-account", - expires_at: "2030", - }); - if (path.endsWith("/nostr-identities/challenge")) - return retry - ? Response.json({ - challenge_id: "0b7e3c1e-7a51-4c4a-9d3e-2f0b8a6c1d22", - nonce: "a".repeat(43), - verification_code: "123456", - origin: "https://app.builderlab.xyz", - expires_at: "2030-01-01T00:00:00Z", - }) - : Response.json( - { error: { code: "identity_already_bound" } }, - { status: 409 }, - ); - if (path.endsWith("/nostr-identities/verify")) bound = true; - if ( - path.endsWith("/nostr-identities/current") || - path.endsWith("/nostr-identities/verify") - ) - return Response.json( - bound - ? { identity: { pubkey_hex: h.event.pubkey } } - : { error: { setup_needed: true } }, - ); - throw new Error(`Unexpected Builderlab request ${path}`); - }, - }); - try { - const login = fetch(`${h.base}/api/builderlab/login`, { - method: "POST", - body: "{}", - }); - const authorization = await opened; - await fetch( - `${authorization.searchParams.get("redirect_uri")}?code=fixture&state=${authorization.searchParams.get("state")}`, - ); - expect((await login).status).toBe(200); - const stream = await fetch(`${h.base}/api/builderlab/events`); - let event = ""; - for await (const chunk of stream.body) { - event += new TextDecoder().decode(chunk); - if (event.includes("\n\n")) break; - } - const { revision } = JSON.parse( - event.slice("data: ".length).split("\n\n")[0], - ); - const current = async () => - ( - await fetch(`${h.base}/api/builderlab/request`, { - method: "POST", - body: JSON.stringify({ - revision, - path: "/v1/buzz/nostr-identities/current", - method: "POST", - body: "{}", - }), - }) - ).json(); - expect(JSON.parse((await current()).body)).toEqual({ - error: { code: "identity_already_bound" }, - }); - retry = true; - const linked = await fetch(`${h.base}/api/builderlab/bind`, { - method: "POST", - body: JSON.stringify({ revision }), - }); - expect(linked.status).toBe(200); - expect(await linked.json()).toEqual({ - identity: { pubkey_hex: h.event.pubkey }, - }); - const refreshed = await current(); - expect(refreshed.status).toBe(200); - expect(JSON.parse(refreshed.body)).toEqual({ - identity: { pubkey_hex: h.event.pubkey }, - }); - } finally { - await h.close(); - } -}); - test("Builderlab pending sign-in survives its initiating page disconnecting", async () => { let openLogin; const opened = new Promise((resolve) => { diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index e840bf9b72..fba97605f3 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -132,7 +132,9 @@ it("shares agent and Apps transport without an app-held credential and refuses s ]); const held = deferred(); h.fetch.mockImplementationOnce(() => held.promise); - const pending = h.session.request(owner, { path: "/v1/auth/me" }); + const pending = h.session.request(owner, { + path: "/v3/beekeeper/list-agents", + }); const rejected = expect(pending).rejects.toThrow("session changed"); const oldViewer = h.session.viewerSession(); h.events.emit("two", "B"); From aeee00b513ff1284570e2b7dc0d8a74aa3f8d37f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 02:51:28 -0800 Subject: [PATCH 17/22] fix(builderlab): preserve failures from the current login attempt Fence login errors with attempt identity instead of the SSE projection version. Keep genuine failures visible after pending and signed-out events while invalidating canceled, replaced and disposed attempts. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/builderlab/dev-session.test.ts | 57 ++++++++++++++++------ src/bundled/builderlab/dev-session.ts | 7 ++- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index fba97605f3..37cae5c7bf 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -233,30 +233,59 @@ it("returns accepted deployment IDs without checking readiness under a replaceme h.session.dispose(); }); -it.each([false, true])( - "reports a login failure only if its attempt is still current (canceled: %s)", - async (canceled) => { +it.each(["current", "canceled", "disposed"] as const)( + "handles login failure after pending and signed-out SSE when the attempt is %s", + async (mode) => { const h = setup(); h.events.emit("one", null); const response = deferred(); h.fetch.mockImplementationOnce(() => response.promise); const pending = h.session.signIn(); + // The broker announces pending, then revokes a failed login before its HTTP error. + h.events.emit("two", null, true); expect(h.session.snapshot().status).toBe("pending"); - if (canceled) { - h.session.cancel(); - h.events.emit("two", null); - } - response.resolve( - Response.json( - { error: "Builderlab authentication canceled" }, - { status: 502 }, - ), - ); + if (mode === "canceled") h.session.cancel(); + if (mode === "disposed") h.session.dispose(); + h.events.emit("three", null); + const error = "Builderlab code exchange failed with HTTP 503"; + response.resolve(Response.json({ error }, { status: 502 })); await pending; expect(h.session.snapshot()).toEqual({ status: "signed-out", - ...(canceled ? {} : { error: "Builderlab authentication canceled" }), + ...(mode === "current" ? { error } : {}), }); h.session.dispose(); }, ); + +it("ignores an older login error while preserving a replacement attempt's failure", async () => { + const h = setup(); + h.events.emit("one", null); + const oldResponse = deferred(); + const newResponse = deferred(); + h.fetch.mockImplementationOnce(() => oldResponse.promise); + const oldLogin = h.session.signIn(); + h.events.emit("two", null, true); + h.events.emit("three", null); + h.fetch.mockImplementationOnce(() => newResponse.promise); + const newLogin = h.session.signIn(); + h.events.emit("four", null, true); + try { + oldResponse.resolve( + Response.json({ error: "Old login failed" }, { status: 502 }), + ); + await oldLogin; + expect(h.session.snapshot()).toEqual({ status: "pending" }); + h.events.emit("five", null); + } finally { + newResponse.resolve( + Response.json({ error: "New login failed" }, { status: 502 }), + ); + } + await newLogin; + expect(h.session.snapshot()).toEqual({ + status: "signed-out", + error: "New login failed", + }); + h.session.dispose(); +}); diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts index 968ef2751c..bbaa7bd7bd 100644 --- a/src/bundled/builderlab/dev-session.ts +++ b/src/bundled/builderlab/dev-session.ts @@ -43,6 +43,7 @@ export function createDevSession() { let lifetime = new AbortController(); let disposed = false; let version = 0; + let loginAttempt = 0; let revision: string | undefined; const listeners = new Set<() => void>(); const publish = (next: LoginSnapshot) => { @@ -173,12 +174,12 @@ export function createDevSession() { }, async signIn() { if (disposed || state.status === "pending") return; - const at = ++version; + const at = ++loginAttempt; publish({ status: "pending" }); try { await send("login"); } catch (error) { - if (!disposed && version === at && !owner) + if (!disposed && loginAttempt === at && !owner) publish({ status: "signed-out", error: @@ -195,6 +196,7 @@ export function createDevSession() { abandon() {}, signOut() { if (disposed) return; + ++loginAttempt; const at = ++version; clear(); publish({ status: "pending", checking: true }); @@ -209,6 +211,7 @@ export function createDevSession() { }, dispose() { disposed = true; + ++loginAttempt; ++version; events.close(); clear(); From 3c4c849a07f94f198a5c9b0252c6e1a02fa322c9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 03:32:27 -0800 Subject: [PATCH 18/22] fix(builderlab): publish login failures from the shared broker Keep login outcomes in the broker generation and project failure text through its event stream. Remove per-renderer attempt errors so delayed HTTP responses cannot overwrite another page. Cover the shared revision, credential redaction and a two-page race in Chromium and WebKit. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 25 ++++- browser-host/builderlab.test.mjs | 57 +++++++++++ src/bundled/builderlab/dev-session.test.ts | 33 +++++- src/bundled/builderlab/dev-session.ts | 21 ++-- tests/browser/build.mjs | 7 ++ tests/browser/builderlab-session.spec.mjs | 111 +++++++++++++++++++++ tests/browser/fixture.mjs | 5 + 7 files changed, 238 insertions(+), 21 deletions(-) create mode 100644 tests/browser/builderlab-session.spec.mjs diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index f7d595da43..5ab741964b 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -223,11 +223,17 @@ export function createBuilderlab({ let credential; let account; let pending = false; + let loginError; let linking = Promise.resolve(); let expiry; let revision = randomUUID(); const listeners = new Set(); - const snapshot = () => ({ revision, pending, auth: account ?? null }); + const snapshot = () => ({ + revision, + pending, + auth: account ?? null, + ...(loginError ? { error: loginError } : {}), + }); const notify = () => { for (const listener of listeners) listener(snapshot()); }; @@ -319,6 +325,7 @@ export function createBuilderlab({ revision = randomUUID(); clearTimeout(expiry); pending = false; + loginError = undefined; account = undefined; session?.abort(); session = new AbortController(); @@ -354,6 +361,7 @@ export function createBuilderlab({ const attempt = signal ? AbortSignal.any([signal, session.signal]) : session.signal; + let loginCredential; try { const verifier = randomBytes(32).toString("base64url"); const code = await awaitCallback(attempt, open, api, verifier); @@ -381,7 +389,8 @@ export function createBuilderlab({ throw new Error( "Builderlab code exchange returned an empty credential", ); - const account = await me(exchanged.session_credential, attempt); + loginCredential = exchanged.session_credential; + const account = await me(loginCredential, attempt); if (account.expiresAt !== exchanged.expires_at) throw new Error( "Builderlab session expiry did not match code exchange", @@ -400,8 +409,16 @@ export function createBuilderlab({ publishAccount(account); return account; } catch (error) { - if (started === generation) revoke(); - throw error; + const message = ( + error instanceof Error ? error.message : "Builderlab sign-in failed" + ).replaceAll(loginCredential || "\u0000", "[redacted]"); + if (started === generation) { + const canceled = attempt.aborted; + revoke(false); + loginError = canceled ? undefined : message; + notify(); + } + throw new Error(message); } finally { if (started === generation) { pending = false; diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index e4c1f562c1..ff243fa1fe 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -326,6 +326,7 @@ it.each(["cancel", "sign-out"])( else h.builderlab.signOut(); release.resolve(); expect(await pending).toBeInstanceOf(Error); + expect(h.builderlab.snapshot()).not.toHaveProperty("error"); expect(await h.builderlab.auth()).toBeNull(); await expect(h.builderlab.call("list", {})).rejects.toThrow( "Sign in to Builderlab first", @@ -804,3 +805,59 @@ it.each(["sign-out", "account-switch", "disconnect"])( } }, ); + +it("publishes login failures in the shared revision and clears them on a new attempt or sign-out", async () => { + const h = account({ + "/v1/auth/login/exchange": () => Response.json({}, { status: 503 }), + }); + const states = []; + const stop = h.builderlab.subscribe((state) => states.push(state)); + try { + await expect(signIn(h)).rejects.toThrow( + "code exchange failed with HTTP 503", + ); + expect(states).toHaveLength(2); + expect(states[0]).toMatchObject({ pending: true, auth: null }); + expect(states[1]).toMatchObject({ + pending: false, + auth: null, + error: "Builderlab code exchange failed with HTTP 503", + }); + expect(states[1].revision).not.toBe(states[0].revision); + const before = h.opened(); + const replacement = h.builderlab + .login(new AbortController().signal) + .catch((error) => error); + await vi.waitFor(() => expect(h.opened()).not.toBe(before)); + expect(h.builderlab.snapshot()).toMatchObject({ + pending: true, + auth: null, + }); + expect(h.builderlab.snapshot()).not.toHaveProperty("error"); + h.builderlab.signOut(); + await replacement; + expect(h.builderlab.snapshot()).toMatchObject({ + pending: false, + auth: null, + }); + expect(h.builderlab.snapshot()).not.toHaveProperty("error"); + } finally { + stop(); + h.builderlab.signOut(); + } +}); + +it("redacts the login credential before publishing a shared failure or returning it to HTTP", async () => { + const h = account({ + "/v1/auth/me": () => { + throw new Error("Could not check secret"); + }, + }); + try { + await expect(signIn(h)).rejects.toThrow("Could not check [redacted]"); + expect(h.builderlab.snapshot().error).toBe("Could not check [redacted]"); + expect(JSON.stringify(h.builderlab.snapshot())).not.toContain("secret"); + } finally { + h.builderlab.signOut(); + } +}); diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index 37cae5c7bf..5325abd5c4 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -14,11 +14,17 @@ class Events { constructor(readonly url: string) { Events.all.push(this); } - emit(revision: string, subject: string | null, pending = false) { + emit( + revision: string, + subject: string | null, + pending = false, + error?: string, + ) { this.onmessage?.({ data: JSON.stringify({ revision, pending, + ...(error ? { error } : {}), auth: subject ? { subject, @@ -246,8 +252,8 @@ it.each(["current", "canceled", "disposed"] as const)( expect(h.session.snapshot().status).toBe("pending"); if (mode === "canceled") h.session.cancel(); if (mode === "disposed") h.session.dispose(); - h.events.emit("three", null); const error = "Builderlab code exchange failed with HTTP 503"; + h.events.emit("three", null, false, mode === "current" ? error : undefined); response.resolve(Response.json({ error }, { status: 502 })); await pending; expect(h.session.snapshot()).toEqual({ @@ -276,7 +282,7 @@ it("ignores an older login error while preserving a replacement attempt's failur ); await oldLogin; expect(h.session.snapshot()).toEqual({ status: "pending" }); - h.events.emit("five", null); + h.events.emit("five", null, false, "New login failed"); } finally { newResponse.resolve( Response.json({ error: "New login failed" }, { status: 502 }), @@ -289,3 +295,24 @@ it("ignores an older login error while preserving a replacement attempt's failur }); h.session.dispose(); }); + +it("keeps another page's replacement login pending after this page's late HTTP failure", async () => { + const h = setup(); + h.events.emit("initial", null); + const response = deferred(); + h.fetch.mockImplementationOnce(() => response.promise); + const login = h.session.signIn(); + h.events.emit("attempt-A", null, true); + h.events.emit("failed-A", null, false, "Login A failed"); + // A different renderer starts B: no second local signIn() occurs here. + h.events.emit("attempt-B", null, true); + response.resolve(Response.json({ error: "Login A failed" }, { status: 502 })); + await login; + expect(h.session.snapshot()).toEqual({ status: "pending" }); + h.events.emit("failed-B", null, false, "Login B failed"); + expect(h.session.snapshot()).toEqual({ + status: "signed-out", + error: "Login B failed", + }); + h.session.dispose(); +}); diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts index bbaa7bd7bd..cf472ae4b6 100644 --- a/src/bundled/builderlab/dev-session.ts +++ b/src/bundled/builderlab/dev-session.ts @@ -25,6 +25,7 @@ export const brokerBuilderlab = () => type State = { revision: string; pending: boolean; + error?: string; auth: null | { subject: string; email?: string; @@ -43,7 +44,6 @@ export function createDevSession() { let lifetime = new AbortController(); let disposed = false; let version = 0; - let loginAttempt = 0; let revision: string | undefined; const listeners = new Set<() => void>(); const publish = (next: LoginSnapshot) => { @@ -67,7 +67,10 @@ export function createDevSession() { const at = ++version; clear(); if (!next.auth || next.pending) { - publish({ status: next.pending ? "pending" : "signed-out" }); + publish({ + status: next.pending ? "pending" : "signed-out", + ...(!next.pending && next.error ? { error: next.error } : {}), + }); return; } publish({ status: "pending", checking: true }); @@ -174,19 +177,11 @@ export function createDevSession() { }, async signIn() { if (disposed || state.status === "pending") return; - const at = ++loginAttempt; publish({ status: "pending" }); try { await send("login"); - } catch (error) { - if (!disposed && loginAttempt === at && !owner) - publish({ - status: "signed-out", - error: - error instanceof Error - ? error.message - : "Sign-in failed. Try again.", - }); + } catch { + // The broker's revisioned event stream owns login outcomes across all pages. } }, cancel() { @@ -196,7 +191,6 @@ export function createDevSession() { abandon() {}, signOut() { if (disposed) return; - ++loginAttempt; const at = ++version; clear(); publish({ status: "pending", checking: true }); @@ -211,7 +205,6 @@ export function createDevSession() { }, dispose() { disposed = true; - ++loginAttempt; ++version; events.close(); clear(); diff --git a/tests/browser/build.mjs b/tests/browser/build.mjs index 5f95c98ac2..d5b40b1f5d 100644 --- a/tests/browser/build.mjs +++ b/tests/browser/build.mjs @@ -13,6 +13,7 @@ const root = fileURLToPath(new URL("../../", import.meta.url)); export async function buildApp( { developmentReact, + builderlabLogin, pluginFixtures, companionFixture, localAgentActivity, @@ -69,6 +70,12 @@ export async function buildApp( : []), ], define: { + ...(builderlabLogin + ? { + "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": + '"https://app.builderlab.xyz"', + } + : {}), ...(developmentReact ? { "import.meta.env.DEV": "true" } : {}), "import.meta.env.VITE_BUZZ_LIVE": '"1"', "import.meta.env.VITE_BUZZ_COMMUNITY_ALIASES": diff --git a/tests/browser/builderlab-session.spec.mjs b/tests/browser/builderlab-session.spec.mjs new file mode 100644 index 0000000000..cd77185873 --- /dev/null +++ b/tests/browser/builderlab-session.spec.mjs @@ -0,0 +1,111 @@ +import { test as base, expect } from "./fixture.mjs"; +import { stopRoutes } from "./playwright-test.mjs"; +import { open } from "./timeline.mjs"; +import { openSettings, selectSettingsSection } from "./navigation.mjs"; + +const deferred = () => { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; +const test = base.extend({ + builderlab: async ({ browserName: _browserName }, use) => { + const opened = []; + await use({ + opened, + open: async (url) => { + opened.push(new URL(url)); + }, + fetch: async () => Response.json({}, { status: 503 }), + }); + }, +}); +test.use({ + productionBroker: true, + builderlabLogin: true, + developmentReact: true, + historyCounts: { alpha: 1, beta: 0 }, +}); + +// Two real EventSources share the production broker; a held HTTP failure must +// not change the other page's newer pending state or replace its Cancel action. +test("a late failed login response cannot overwrite another page's pending login", async ({ + page, + context, + app, + builderlab, +}) => { + const other = await context.newPage(); + app.watchPageErrors(other); + const failed = deferred(); + const release = deferred(); + const loginUrl = `${app.origin}/api/builderlab/login`; + app.report.builderlabFailures = [loginUrl]; // Exactly this injected 502, once. + await page.route(loginUrl, async (route) => { + const response = await route.fetch(); + failed.resolve(response.status()); + await release.promise; + await route.fulfill({ response }); + }); + const signIn = (target) => + target.getByRole("button", { + name: "Sign in with Builderlab", + exact: true, + }); + const cancel = (target) => + target.getByRole("button", { name: "Cancel sign-in", exact: true }); + try { + for (const target of [page, other]) { + await open(target, app); + await openSettings(target); + await selectSettingsSection(target, "Builderlab"); + await expect(signIn(target)).toBeVisible(); + } + await signIn(page).click(); + await expect.poll(() => builderlab.opened.length).toBe(1); + const authorization = builderlab.opened[0]; + await fetch( + `${authorization.searchParams.get("redirect_uri")}?state=${authorization.searchParams.get("state")}&code=failed-A`, + ); + expect(await failed.promise).toBe(502); + await expect(signIn(page)).toBeVisible(); + await expect(signIn(other)).toBeVisible(); + await signIn(other).click(); + await expect.poll(() => builderlab.opened.length).toBe(2); + await expect(cancel(page)).toBeVisible(); + await expect(cancel(other)).toBeVisible(); + const delivered = page.waitForResponse( + (response) => response.url() === loginUrl && response.status() === 502, + ); + release.resolve(); + await (await delivered).finished(); + // Let React commit work queued by the delivered response before checking the action. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + await expect(cancel(page)).toBeVisible(); + await expect(cancel(other)).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + const canceled = other.waitForResponse( + (response) => response.url() === loginUrl && response.status() === 502, + ); + await cancel(page).click(); + await (await canceled).finished(); + await expect(signIn(page)).toBeVisible(); + await expect(signIn(other)).toBeVisible(); + } finally { + release.resolve(); + await fetch(`${app.origin}/api/builderlab/sign-out`, { + method: "POST", + headers: { Origin: app.origin }, + body: "{}", + }); + await stopRoutes(context); + await other.close(); + } +}); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 71c2453bcc..9f4e5afc16 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -34,6 +34,7 @@ export const historySize = 640; // broker/subscriber and model only the upstream relay policy with ephemeral keys. export const test = base.extend({ productionBroker: [false, { option: true }], + builderlab: [{}, { option: true }], /** The production broker's live setup deadline. It runs in Node, so the * page clock cannot hold it; a test that keeps EOSE pending sets it longer. */ liveSetupTimeoutMs: [undefined, { option: true }], @@ -95,6 +96,7 @@ export const test = base.extend({ historyRowsPerSecond: [1, { option: true }], channelIds: [channels, { option: true }], developmentReact: [false, { option: true, scope: "worker" }], + builderlabLogin: [false, { option: true, scope: "worker" }], pluginFixtures: [false, { option: true, scope: "worker" }], localAgentActivity: [false, { option: true, scope: "worker" }], pairingFixture: [false, { option: true, scope: "worker" }], @@ -107,6 +109,7 @@ export const test = base.extend({ browserName, browser, productionBroker, + builderlab, liveSetupTimeoutMs, actionProfile, agentMessageDeletion, @@ -2308,6 +2311,7 @@ export const test = base.extend({ next(); }); const broker = relayBrokerPlugin({ + builderlab, archiveFile: ":memory:", relayUrl: fixtureRelayUrl, communityAliases: fixtureAliases, @@ -2781,6 +2785,7 @@ export const test = base.extend({ ...(report.sidebarRemovalFailures ?? []), ...(report.sidebarPreferenceFailures ?? []), ...(report.startupFailures ?? []), + ...(report.builderlabFailures ?? []), ]; // Activity reads retry a 502 quietly, so their journeys inject a 403. const activityFailures = [...(report.sidebarActivityFailures ?? [])]; From 67111ba938a905d20102fe124402bf24993f2f1a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 03:52:43 -0800 Subject: [PATCH 19/22] fix(browser-tests): declare the builderlab build option Register the shared build worker option in the standalone message fixture so recording-only and full browser selections can be collected. Co-authored-by: Carl Signed-off-by: Taylor Ho --- tests/browser/new-message.spec.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index 46a6fe3046..ca8ce3139f 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -14,6 +14,7 @@ import { watchPageErrors } from "./page-errors.mjs"; // Ephemeral identities and a network fence prevent any live message or profile write. const test = base.extend({ developmentReact: [false, { scope: "worker" }], + builderlabLogin: [false, { scope: "worker" }], pluginFixtures: [false, { scope: "worker" }], companionFixture: [false, { scope: "worker" }], pairingFixture: [false, { scope: "worker" }], From c30da0420450028f78ae1094aafa2897c927c89a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 1 Nov 2026 04:37:22 -0800 Subject: [PATCH 20/22] fix(builderlab): reconcile rejected login dispatches Renew the authoritative event subscription when a failed login POST has no newer projection. Confirm idle state before showing a retryable dispatch error, and ignore retired streams, cancellation and disposed sessions. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/builderlab/dev-session.test.ts | 134 +++++++++++++++++++++ src/bundled/builderlab/dev-session.ts | 59 ++++++--- tests/browser/builderlab-session.spec.mjs | 57 +++++++++ 3 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index 5325abd5c4..8795962101 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -316,3 +316,137 @@ it("keeps another page's replacement login pending after this page's late HTTP f }); h.session.dispose(); }); + +it.each(["HTTP", "network"])( + "reconciles a rejected %s login POST with no broker event and permits retry", + async (mode) => { + const h = setup(); + h.events.emit("idle", null); + if (mode === "HTTP") + h.fetch.mockResolvedValueOnce( + Response.json({ error: "Request refused" }, { status: 502 }), + ); + else h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await h.session.signIn(); + expect(h.events.close).toHaveBeenCalledOnce(); + const fresh = Events.all.at(-1); + if (!fresh || fresh === h.events) + throw new Error("Missing fresh broker snapshot subscription"); + expect(h.session.snapshot()).toMatchObject({ + status: "pending", + checking: true, + }); + // Retired callbacks cannot describe the replacement subscription. + h.events.emit("retired", null, true); + h.events.onerror?.(); + fresh.emit("idle", null); + expect(h.session.snapshot()).toEqual({ + status: "signed-out", + error: "Could not start Builderlab sign-in. Try again.", + }); + const retry = h.session.signIn(); + fresh.emit("retry", null, true); + await retry; + expect(h.fetch).toHaveBeenCalledTimes(2); + expect(h.session.snapshot()).toEqual({ status: "pending" }); + h.session.dispose(); + }, +); + +it.each(["pending", "signed-in", "failed"])( + "uses the broker's fresh %s state after an unaccounted login failure", + async (mode) => { + const h = setup(); + h.events.emit("idle", null); + h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await h.session.signIn(); + const fresh = Events.all.at(-1); + if (!fresh || fresh === h.events) + throw new Error("Missing fresh broker snapshot subscription"); + fresh.emit( + "replacement", + mode === "signed-in" ? "B" : null, + mode === "pending", + mode === "failed" ? "Broker failure" : undefined, + ); + if (mode === "signed-in") await signedIn(h.session); + expect(h.session.snapshot()).toMatchObject( + mode === "signed-in" + ? { status: "signed-in", account: { subject: "B" } } + : mode === "pending" + ? { status: "pending" } + : { status: "signed-out", error: "Broker failure" }, + ); + if (mode !== "failed") + expect(h.session.snapshot()).not.toHaveProperty("error"); + if (mode === "pending") { + fresh.emit("replacement-ended", null); + expect(h.session.snapshot()).toEqual({ status: "signed-out" }); + } + h.session.dispose(); + }, +); + +it("restores an unchanged signed-in owner after a login dispatch fails", async () => { + const h = setup(); + h.events.emit("one", "A"); + await signedIn(h.session); + const owner = h.session.credential(); + const viewer = h.session.viewerSession(); + h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await h.session.signIn(); + const fresh = Events.all.at(-1); + if (!fresh || fresh === h.events) + throw new Error("Missing fresh broker snapshot subscription"); + fresh.emit("one", "A"); + expect(h.session.snapshot()).toEqual({ + status: "signed-in", + account: owner.account, + }); + expect(h.session.credential()).toBe(owner); + expect(h.session.viewerSession()).toBe(viewer); + expect(viewer.signal.aborted).toBe(false); + h.session.dispose(); +}); + +it.each(["shared update", "cancel", "dispose"])( + "does not reconcile an old failed POST after %s", + async (mode) => { + const h = setup(); + h.events.emit("idle", null); + const response = deferred(); + h.fetch.mockImplementationOnce(() => response.promise); + const login = h.session.signIn(); + if (mode === "shared update") h.events.emit("B", null, true); + else if (mode === "cancel") { + h.session.cancel(); + h.events.emit("canceled", null); + } else h.session.dispose(); + response.resolve(Response.json({ error: "Old failure" }, { status: 502 })); + await login; + expect(Events.all).toHaveLength(1); + expect(h.session.snapshot()).toEqual({ + status: mode === "shared update" ? "pending" : "signed-out", + }); + h.session.dispose(); + }, +); + +it.each(["cancel", "dispose"])( + "drops the recovery diagnostic after %s before its fresh snapshot arrives", + async (mode) => { + const h = setup(); + h.events.emit("idle", null); + h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await h.session.signIn(); + const fresh = Events.all.at(-1); + if (!fresh || fresh === h.events) + throw new Error("Missing fresh broker snapshot subscription"); + if (mode === "cancel") h.session.cancel(); + else h.session.dispose(); + fresh.emit("current", null); + expect(h.session.snapshot()).toEqual({ status: "signed-out" }); + h.session.dispose(); + expect(fresh.close).toHaveBeenCalled(); + }, +); diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts index cf472ae4b6..c89515941d 100644 --- a/src/bundled/builderlab/dev-session.ts +++ b/src/bundled/builderlab/dev-session.ts @@ -63,7 +63,11 @@ export function createDevSession() { async function project(next: State) { if (disposed) return; revision = next.revision; - if (owner?.revision === next.revision && next.auth && !next.pending) return; + if (owner?.revision === next.revision && next.auth && !next.pending) { + if (state.status !== "signed-in") + publish({ status: "signed-in", account: owner.account }); + return; + } const at = ++version; clear(); if (!next.auth || next.pending) { @@ -92,22 +96,41 @@ export function createDevSession() { }; publish({ status: "signed-in", account }); } - const events = new EventSource("/api/builderlab/events"); - events.onmessage = (event) => { - void project(JSON.parse(event.data) as State).catch(() => { - clear(); - publish({ - status: "signed-out", - error: "Could not read the development server's Builderlab sign-in.", + let events: EventSource; + const connect = (dispatchError?: string) => { + const initialVersion = version; + const source = new EventSource("/api/builderlab/events"); + events = source; + source.onmessage = (event) => { + if (disposed || events !== source) return; + const next = JSON.parse(event.data) as State; + if ( + dispatchError && + version === initialVersion && + !next.pending && + !next.auth + ) + next.error ??= dispatchError; + dispatchError = undefined; + const projection = project(next); + const at = version; + void projection.catch(() => { + if (disposed || events !== source || version !== at) return; + clear(); + publish({ + status: "signed-out", + error: "Could not read the development server's Builderlab sign-in.", + }); }); - }); - }; - events.onerror = () => { - if (disposed) return; - ++version; - clear(); - publish({ status: "pending", checking: true }); + }; + source.onerror = () => { + if (disposed || events !== source) return; + ++version; + clear(); + publish({ status: "pending", checking: true }); + }; }; + connect(); async function send(action: string, body: object = {}, signal?: AbortSignal) { const response = await fetch(`/api/builderlab/${action}`, { method: "POST", @@ -177,11 +200,17 @@ export function createDevSession() { }, async signIn() { if (disposed || state.status === "pending") return; + const at = ++version; publish({ status: "pending" }); try { await send("login"); } catch { // The broker's revisioned event stream owns login outcomes across all pages. + if (disposed || version !== at) return; + // No event accounted for this dispatch. A new stream starts with fresh broker state. + events.close(); + connect("Could not start Builderlab sign-in. Try again."); + publish({ status: "pending", checking: true }); } }, cancel() { diff --git a/tests/browser/builderlab-session.spec.mjs b/tests/browser/builderlab-session.spec.mjs index cd77185873..991eff93ee 100644 --- a/tests/browser/builderlab-session.spec.mjs +++ b/tests/browser/builderlab-session.spec.mjs @@ -109,3 +109,60 @@ test("a late failed login response cannot overwrite another page's pending login await other.close(); } }); + +// The broker cannot emit an outcome for a POST that never reached it. A fresh +// subscription must restore idle state and allow another click without Cancel/reload. +test("a login POST rejected before broker admission recovers and can be retried", async ({ + page, + context, + app, + builderlab, +}) => { + const loginUrl = `${app.origin}/api/builderlab/login`; + app.report.builderlabFailures = [loginUrl, loginUrl]; // Injected rejection, then canceled retry. + let rejected = false; + await page.route(loginUrl, (route) => { + if (rejected) return route.continue(); + rejected = true; + return route.fulfill({ + status: 502, + json: { error: "Fixture admission rejection" }, + }); + }); + const signIn = page.getByRole("button", { + name: "Sign in with Builderlab", + exact: true, + }); + const cancel = page.getByRole("button", { + name: "Cancel sign-in", + exact: true, + }); + try { + await open(page, app); + await openSettings(page); + await selectSettingsSection(page, "Builderlab"); + await signIn.click(); + await expect(signIn).toBeVisible(); + await expect(page.getByRole("alert")).toContainText( + "Could not start Builderlab sign-in. Try again.", + ); + expect(builderlab.opened).toHaveLength(0); + await signIn.click(); + await expect.poll(() => builderlab.opened.length).toBe(1); + await expect(cancel).toBeVisible(); + const ended = page.waitForResponse( + (response) => response.url() === loginUrl && response.status() === 502, + ); + await cancel.click(); + await (await ended).finished(); + await expect(signIn).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + } finally { + await fetch(`${app.origin}/api/builderlab/sign-out`, { + method: "POST", + headers: { Origin: app.origin }, + body: "{}", + }); + await stopRoutes(context); + } +}); From 113d024d3ea72081d8d6651c6364d43771caa2df Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 2 Nov 2026 11:19:01 -0800 Subject: [PATCH 21/22] fix(builderlab): revoke replaced login credentials upstream Reuse the existing best-effort sign-out path when login replaces an account, while preserving the atomic pending notification. Cover a held or rejected upstream logout without blocking the replacement browser. Co-authored-by: Carl Signed-off-by: Taylor Ho --- browser-host/builderlab.mjs | 25 ++++++++------- browser-host/builderlab.test.mjs | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 5ab741964b..9526ba32c6 100644 --- a/browser-host/builderlab.mjs +++ b/browser-host/builderlab.mjs @@ -333,6 +333,17 @@ export function createBuilderlab({ if (announce) notify(); }; revoke(); + const signOut = (announce = true) => { + const previous = credential; + revoke(announce); + if (previous) + void fetch(`${api}/v1/auth/logout`, { + method: "POST", + headers: { "X-BB-Session-Credential": previous }, + redirect: "error", + signal: AbortSignal.timeout(30000), + }).catch(() => {}); + }; const publishAccount = (value) => { account = value; notify(); @@ -354,7 +365,7 @@ export function createBuilderlab({ return account; }, async login(signal) { - revoke(false); + signOut(false); const started = generation; pending = true; notify(); @@ -427,17 +438,7 @@ export function createBuilderlab({ } }, - signOut() { - const previous = credential; - revoke(); - if (previous) - void fetch(`${api}/v1/auth/logout`, { - method: "POST", - headers: { "X-BB-Session-Credential": previous }, - redirect: "error", - signal: AbortSignal.timeout(30000), - }).catch(() => {}); - }, + signOut, // Only the broker's fixed viewer/Decisions handlers opt into native routes. async request(input, signal, native = false) { // Activation must not overtake the sign-in's automatic key binding. diff --git a/browser-host/builderlab.test.mjs b/browser-host/builderlab.test.mjs index ff243fa1fe..62dd78ac85 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -300,6 +300,61 @@ const deferred = () => { return { promise, resolve }; }; +it.each(["held", "rejected"])( + "logs out a replaced credential without blocking login on a %s upstream logout", + async (outcome) => { + const release = deferred(); + const finished = deferred(); + const h = account({ + "/v1/auth/logout": async () => { + try { + await release.promise; + if (outcome === "rejected") throw new Error("Logout unavailable"); + return Response.json({}); + } finally { + finished.resolve(); + } + }, + }); + await signIn(h); + const before = h.opened(); + const states = []; + const stop = h.builderlab.subscribe((state) => states.push(state)); + const abort = new AbortController(); + const login = h.builderlab.login(abort.signal).catch((error) => error); + try { + expect( + h.requests.filter(({ path }) => path === "/v1/auth/logout"), + ).toMatchObject([ + { + init: { + method: "POST", + headers: { "X-BB-Session-Credential": "secret" }, + redirect: "error", + }, + }, + ]); + expect(states).toHaveLength(1); + expect(states[0]).toMatchObject({ pending: true, auth: null }); + // Opening the replacement browser does not wait for upstream revocation. + await vi.waitFor(() => expect(h.opened()).not.toBe(before)); + release.resolve(); + await finished.promise; + expect(h.builderlab.snapshot()).toMatchObject({ + pending: true, + auth: null, + }); + expect(h.builderlab.snapshot()).not.toHaveProperty("error"); + } finally { + release.resolve(); + abort.abort(); + await login; + stop(); + h.builderlab.signOut(); + } + }, +); + it.each(["cancel", "sign-out"])( "a %s during a held code exchange never restores the session", async (mode) => { From 45d859a787d5bf5cb56c6dfd31596e45ac745ffd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 2 Nov 2026 11:22:04 -0800 Subject: [PATCH 22/22] test(builderlab): consolidate login dispatch outcome coverage Replace the per-round race cases with one outcome-by-lifecycle matrix. Retain fresh-state precedence, unchanged owner identity, retry admission, retired-stream guards and both cancellation timings; keep the browser journeys unchanged. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/builderlab/dev-session.test.ts | 375 ++++++++++----------- 1 file changed, 173 insertions(+), 202 deletions(-) diff --git a/src/bundled/builderlab/dev-session.test.ts b/src/bundled/builderlab/dev-session.test.ts index 8795962101..5236749e72 100644 --- a/src/bundled/builderlab/dev-session.test.ts +++ b/src/bundled/builderlab/dev-session.test.ts @@ -239,214 +239,185 @@ it("returns accepted deployment IDs without checking readiness under a replaceme h.session.dispose(); }); -it.each(["current", "canceled", "disposed"] as const)( - "handles login failure after pending and signed-out SSE when the attempt is %s", - async (mode) => { +type LoginCase = { + outcome: "HTTP" | "network" | "broker"; + lifecycle: "current" | "canceled" | "replaced" | "disposed"; + fresh?: "pending" | "signed-in" | "failed" | "same-owner"; + after?: "snapshot"; + local?: true; +}; +const loginCases: LoginCase[] = [ + ...(["HTTP", "network", "broker"] as const).flatMap((outcome) => + (["current", "canceled", "replaced", "disposed"] as const).map( + (lifecycle) => ({ outcome, lifecycle }), + ), + ), + ...(["pending", "signed-in", "failed", "same-owner"] as const).map( + (fresh) => ({ + outcome: "network" as const, + lifecycle: "current" as const, + fresh, + }), + ), + { outcome: "broker", lifecycle: "replaced", local: true }, + { outcome: "network", lifecycle: "canceled", after: "snapshot" }, + { outcome: "network", lifecycle: "disposed", after: "snapshot" }, +]; + +// A dispatch can fail before admission or after its broker events. Only an +// unaccounted current failure renews the stream; shared state always wins. +it.each( + loginCases.map((row) => ({ + fresh: "idle" as const, + after: "response" as const, + local: false, + ...row, + })), +)( + "projects $outcome failure: $lifecycle, fresh=$fresh, invalidation=$after, local=$local", + async ({ outcome, lifecycle, fresh, after, local }) => { const h = setup(); - h.events.emit("one", null); + h.events.emit("initial", fresh === "same-owner" ? "A" : null); + if (fresh === "same-owner") await signedIn(h.session); + const owner = fresh === "same-owner" ? h.session.credential() : undefined; + const viewer = + fresh === "same-owner" ? h.session.viewerSession() : undefined; const response = deferred(); + const replacementResponse = deferred(); h.fetch.mockImplementationOnce(() => response.promise); - const pending = h.session.signIn(); - // The broker announces pending, then revokes a failed login before its HTTP error. - h.events.emit("two", null, true); - expect(h.session.snapshot().status).toBe("pending"); - if (mode === "canceled") h.session.cancel(); - if (mode === "disposed") h.session.dispose(); - const error = "Builderlab code exchange failed with HTTP 503"; - h.events.emit("three", null, false, mode === "current" ? error : undefined); - response.resolve(Response.json({ error }, { status: 502 })); - await pending; - expect(h.session.snapshot()).toEqual({ - status: "signed-out", - ...(mode === "current" ? { error } : {}), - }); - h.session.dispose(); - }, -); - -it("ignores an older login error while preserving a replacement attempt's failure", async () => { - const h = setup(); - h.events.emit("one", null); - const oldResponse = deferred(); - const newResponse = deferred(); - h.fetch.mockImplementationOnce(() => oldResponse.promise); - const oldLogin = h.session.signIn(); - h.events.emit("two", null, true); - h.events.emit("three", null); - h.fetch.mockImplementationOnce(() => newResponse.promise); - const newLogin = h.session.signIn(); - h.events.emit("four", null, true); - try { - oldResponse.resolve( - Response.json({ error: "Old login failed" }, { status: 502 }), - ); - await oldLogin; - expect(h.session.snapshot()).toEqual({ status: "pending" }); - h.events.emit("five", null, false, "New login failed"); - } finally { - newResponse.resolve( - Response.json({ error: "New login failed" }, { status: 502 }), - ); - } - await newLogin; - expect(h.session.snapshot()).toEqual({ - status: "signed-out", - error: "New login failed", - }); - h.session.dispose(); -}); - -it("keeps another page's replacement login pending after this page's late HTTP failure", async () => { - const h = setup(); - h.events.emit("initial", null); - const response = deferred(); - h.fetch.mockImplementationOnce(() => response.promise); - const login = h.session.signIn(); - h.events.emit("attempt-A", null, true); - h.events.emit("failed-A", null, false, "Login A failed"); - // A different renderer starts B: no second local signIn() occurs here. - h.events.emit("attempt-B", null, true); - response.resolve(Response.json({ error: "Login A failed" }, { status: 502 })); - await login; - expect(h.session.snapshot()).toEqual({ status: "pending" }); - h.events.emit("failed-B", null, false, "Login B failed"); - expect(h.session.snapshot()).toEqual({ - status: "signed-out", - error: "Login B failed", - }); - h.session.dispose(); -}); + const login = h.session.signIn(); + let replacement: Promise | undefined; + const brokerError = "Builderlab code exchange failed with HTTP 503"; + const invalidate = () => { + if (lifecycle === "canceled") { + h.session.cancel(); + if (after === "response") h.events.emit("canceled", null); + } else if (lifecycle === "disposed") h.session.dispose(); + else if (lifecycle === "replaced") { + if (outcome === "broker") + h.events.emit("failed-A", null, false, brokerError); + if (local) { + h.fetch.mockImplementationOnce(() => replacementResponse.promise); + replacement = h.session.signIn(); + } + // Shared replacement rows deliberately have no second local signIn(). + h.events.emit("attempt-B", null, true); + } + }; + try { + if (outcome === "broker") { + h.events.emit("attempt-A", null, true); + expect(h.session.snapshot()).toEqual({ status: "pending" }); + } + if (after === "response") invalidate(); + if (outcome === "broker" && lifecycle !== "replaced") + h.events.emit( + "ended-A", + null, + false, + lifecycle === "current" ? brokerError : undefined, + ); + if (outcome === "network") + response.reject(new TypeError("Failed to fetch")); + else + response.resolve( + Response.json({ error: brokerError }, { status: 502 }), + ); + await login; -it.each(["HTTP", "network"])( - "reconciles a rejected %s login POST with no broker event and permits retry", - async (mode) => { - const h = setup(); - h.events.emit("idle", null); - if (mode === "HTTP") - h.fetch.mockResolvedValueOnce( - Response.json({ error: "Request refused" }, { status: 502 }), + const renewed = + outcome !== "broker" && + (lifecycle === "current" || after === "snapshot"); + expect(Events.all).toHaveLength(renewed ? 2 : 1); + expect(h.events.close).toHaveBeenCalledTimes( + renewed || lifecycle === "disposed" ? 1 : 0, ); - else h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); - await h.session.signIn(); - expect(h.events.close).toHaveBeenCalledOnce(); - const fresh = Events.all.at(-1); - if (!fresh || fresh === h.events) - throw new Error("Missing fresh broker snapshot subscription"); - expect(h.session.snapshot()).toMatchObject({ - status: "pending", - checking: true, - }); - // Retired callbacks cannot describe the replacement subscription. - h.events.emit("retired", null, true); - h.events.onerror?.(); - fresh.emit("idle", null); - expect(h.session.snapshot()).toEqual({ - status: "signed-out", - error: "Could not start Builderlab sign-in. Try again.", - }); - const retry = h.session.signIn(); - fresh.emit("retry", null, true); - await retry; - expect(h.fetch).toHaveBeenCalledTimes(2); - expect(h.session.snapshot()).toEqual({ status: "pending" }); - h.session.dispose(); - }, -); + const current = Events.all.at(-1); + if (!current) throw new Error("Missing broker event stream"); + if (renewed) { + expect(current).not.toBe(h.events); + expect(h.session.snapshot()).toEqual({ + status: "pending", + checking: true, + }); + // Neither retired callback may mutate the fresh subscription or owner. + h.events.emit("retired", null, true); + h.events.onerror?.(); + expect(h.session.snapshot()).toEqual({ + status: "pending", + checking: true, + }); + if (after === "snapshot") invalidate(); + current.emit( + fresh === "same-owner" ? "initial" : "fresh", + fresh === "same-owner" ? "A" : fresh === "signed-in" ? "B" : null, + fresh === "pending", + fresh === "failed" ? "Broker failure" : undefined, + ); + if (fresh === "signed-in" || fresh === "same-owner") + await signedIn(h.session); + } + const error = + lifecycle !== "current" + ? undefined + : outcome === "broker" + ? brokerError + : fresh === "failed" + ? "Broker failure" + : fresh === "idle" + ? "Could not start Builderlab sign-in. Try again." + : undefined; + if (fresh === "same-owner") { + expect(h.session.snapshot()).toEqual({ + status: "signed-in", + account: owner?.account, + }); + expect(h.session.credential()).toBe(owner); + expect(h.session.viewerSession()).toBe(viewer); + expect(viewer?.signal.aborted).toBe(false); + } else if (fresh === "signed-in") + expect(h.session.snapshot()).toMatchObject({ + status: "signed-in", + account: { subject: "B" }, + }); + else + expect(h.session.snapshot()).toEqual({ + status: + lifecycle === "replaced" || + (lifecycle === "current" && fresh === "pending") + ? "pending" + : "signed-out", + ...(error ? { error } : {}), + }); + if (!error) expect(h.session.snapshot()).not.toHaveProperty("error"); -it.each(["pending", "signed-in", "failed"])( - "uses the broker's fresh %s state after an unaccounted login failure", - async (mode) => { - const h = setup(); - h.events.emit("idle", null); - h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); - await h.session.signIn(); - const fresh = Events.all.at(-1); - if (!fresh || fresh === h.events) - throw new Error("Missing fresh broker snapshot subscription"); - fresh.emit( - "replacement", - mode === "signed-in" ? "B" : null, - mode === "pending", - mode === "failed" ? "Broker failure" : undefined, - ); - if (mode === "signed-in") await signedIn(h.session); - expect(h.session.snapshot()).toMatchObject( - mode === "signed-in" - ? { status: "signed-in", account: { subject: "B" } } - : mode === "pending" - ? { status: "pending" } - : { status: "signed-out", error: "Broker failure" }, - ); - if (mode !== "failed") - expect(h.session.snapshot()).not.toHaveProperty("error"); - if (mode === "pending") { - fresh.emit("replacement-ended", null); - expect(h.session.snapshot()).toEqual({ status: "signed-out" }); + if (lifecycle === "replaced") { + h.events.emit("failed-B", null, false, "New login failed"); + replacementResponse.resolve( + Response.json({ error: "New login failed" }, { status: 502 }), + ); + await replacement; + expect(h.session.snapshot()).toEqual({ + status: "signed-out", + error: "New login failed", + }); + expect(Events.all).toHaveLength(1); + } else if (fresh === "pending") { + current.emit("replacement-ended", null); + expect(h.session.snapshot()).toEqual({ status: "signed-out" }); + } else if (renewed && lifecycle === "current" && fresh === "idle") { + const retry = h.session.signIn(); + current.emit("retry", null, true); + await retry; + expect(h.fetch).toHaveBeenCalledTimes(2); + expect(h.session.snapshot()).toEqual({ status: "pending" }); + } + } finally { + h.session.dispose(); + response.resolve(Response.json({})); + replacementResponse.resolve(Response.json({})); + await Promise.all([login, replacement]); } - h.session.dispose(); - }, -); - -it("restores an unchanged signed-in owner after a login dispatch fails", async () => { - const h = setup(); - h.events.emit("one", "A"); - await signedIn(h.session); - const owner = h.session.credential(); - const viewer = h.session.viewerSession(); - h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); - await h.session.signIn(); - const fresh = Events.all.at(-1); - if (!fresh || fresh === h.events) - throw new Error("Missing fresh broker snapshot subscription"); - fresh.emit("one", "A"); - expect(h.session.snapshot()).toEqual({ - status: "signed-in", - account: owner.account, - }); - expect(h.session.credential()).toBe(owner); - expect(h.session.viewerSession()).toBe(viewer); - expect(viewer.signal.aborted).toBe(false); - h.session.dispose(); -}); - -it.each(["shared update", "cancel", "dispose"])( - "does not reconcile an old failed POST after %s", - async (mode) => { - const h = setup(); - h.events.emit("idle", null); - const response = deferred(); - h.fetch.mockImplementationOnce(() => response.promise); - const login = h.session.signIn(); - if (mode === "shared update") h.events.emit("B", null, true); - else if (mode === "cancel") { - h.session.cancel(); - h.events.emit("canceled", null); - } else h.session.dispose(); - response.resolve(Response.json({ error: "Old failure" }, { status: 502 })); - await login; - expect(Events.all).toHaveLength(1); - expect(h.session.snapshot()).toEqual({ - status: mode === "shared update" ? "pending" : "signed-out", - }); - h.session.dispose(); - }, -); - -it.each(["cancel", "dispose"])( - "drops the recovery diagnostic after %s before its fresh snapshot arrives", - async (mode) => { - const h = setup(); - h.events.emit("idle", null); - h.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); - await h.session.signIn(); - const fresh = Events.all.at(-1); - if (!fresh || fresh === h.events) - throw new Error("Missing fresh broker snapshot subscription"); - if (mode === "cancel") h.session.cancel(); - else h.session.dispose(); - fresh.emit("current", null); - expect(h.session.snapshot()).toEqual({ status: "signed-out" }); - h.session.dispose(); - expect(fresh.close).toHaveBeenCalled(); + expect(Events.all.at(-1)?.close).toHaveBeenCalled(); }, );