diff --git a/browser-host/builderlab.mjs b/browser-host/builderlab.mjs index 74c385306d..9526ba32c6 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,46 @@ export function createBuilderlab({ key, fetch = globalThis.fetch, open = openBrowser, + api = API, + appsBase, }) { 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, + ...(loginError ? { error: loginError } : {}), + }); + 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 +277,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, }, @@ -215,85 +320,290 @@ 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); + pending = false; + loginError = undefined; + account = undefined; session?.abort(); session = new AbortController(); credential = undefined; + if (announce) notify(); }; revoke(); - return { + 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(); + }; + 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) { + signOut(false); + const started = generation; + pending = true; + notify(); + const attempt = signal + ? AbortSignal.any([signal, session.signal]) + : session.signal; + let loginCredential; 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 || + 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", + ); + 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", + ); + 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?.(); + // Bind before subsequent remote-agent operations. + void broker.bind(); if (started !== generation) throw new Error("Builderlab session changed"); + publishAccount(account); return account; } catch (error) { - if (started === generation) credential = undefined; - 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; + notify(); + } } }, - async login(signal) { - revoke(); + + 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. + if ( + typeof input.path === "string" && + input.path.startsWith("/v3/beekeeper/") + ) + await linking; + 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 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 exchanged.session_credential !== "string" || - !exchanged.session_credential + url.origin !== base.origin || + !url.pathname.startsWith(`${prefix}/`) || + !(native + ? !apps && + [ + "/v3/builderlab/decisions", + "/v1/auth/hosted-site/native-session", + ].includes(path) + : apps + ? /^\/v1\/agent\//.test(path) + : /^\/(?:v3\/beekeeper\/|v1\/buzz\/known-communities\/(?:list|add|remove)$)/.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 }), + }; + 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 ( + !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(url, { + method, + headers, + ...(body !== undefined ? { body } : {}), + redirect: "error", + signal: AbortSignal.any([ + 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, + ), + ]), + }); + 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 && !apps) 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 }, - 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); - 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; @@ -314,4 +624,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..62dd78ac85 100644 --- a/browser-host/builderlab.test.mjs +++ b/browser-host/builderlab.test.mjs @@ -1,5 +1,9 @@ -import { expect, it } from "vitest"; +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 { BUILDERLAB_ORIGIN, bindingEvent, @@ -54,9 +58,13 @@ 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 }); + requests.push({ path, url: new URL(url), init }); const reply = responses[path]; if (reply) return reply(init); if (path === "/v1/auth/login/exchange") @@ -66,6 +74,7 @@ function account(responses = {}) { }); if (path === "/v1/auth/me") return Response.json({ + subject: "auth0|owner", email: "a@example.com", name: "A", expires_at: "2030", @@ -80,8 +89,10 @@ function account(responses = {}) { return key; }, fetch, + appsBase: controlPlane(BUILDERLAB_ORIGIN), open: async (url) => { opened = new URL(url); + didOpen(opened); }, }); return { @@ -89,31 +100,50 @@ function account(responses = {}) { requests, key, opened: () => opened, + openedReady, signs: () => signs, }; } 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("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`); - return pending; + await globalThis.fetch( + `${returnTo}?state=${h.opened().searchParams.get("state")}&code=one-time`, + ); + 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 () => { 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 +237,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 +245,8 @@ it.each([ }); expect((await signIn(h)).capabilities).toEqual({ can_delete_buzz_communities: expected, + can_use_remote_agents: false, + can_use_builderlab_decisions: false, }); }); @@ -267,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) => { @@ -285,12 +373,15 @@ 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(); 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", @@ -317,7 +408,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 +421,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 +473,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 +486,433 @@ 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: "/v3/beekeeper/list-agents" }, + 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("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("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", + "/v1/auth/me", + "/v1/buzz/communities/list", + "/v1/buzz/nostr-identities/current", + "//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(); + 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 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(); + } +}); + +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, + true, + ); + 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, + true, + ), + ).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(); + }, +); + +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(); +}); + +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(); +}); + +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 }); + } + }, +); + +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/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs index 727d993583..859adb734e 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", @@ -3910,3 +3917,203 @@ test("inventory rejects foreign pages, ignores invalid heads and follows exact-k await h.close(); } }); + +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) => { + 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 }); + } + 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}`); + }, + }); + 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); + 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(); + } +}); + +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.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..14029a7e03 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); @@ -959,9 +960,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,12 +983,64 @@ export function relayBrokerPlugin({ req.setEncoding("utf8"); for await (const part of req) { raw += part; - if (raw.length > 4096) + if ( + raw.length > + (action === "decisions" + ? 128 * 1024 + : action === "request" + ? 1024 * 1024 + : 4096) + ) return json(res, 413, { error: "Request too large" }); } + const input = raw ? JSON.parse(raw) : {}; + 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[header], + path, + method: "POST", + body: raw, + community: req.headers["x-buzz-community"], + }, + 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, + 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), + auth: await builderlab.login(), }); if (action === "sign-out") { builderlab.signOut(); @@ -983,10 +1048,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-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/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 89b2cc41dc..a90089ff47 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: { @@ -190,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]", ), ); @@ -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/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.test.ts b/src/bundled/builderlab/dev-session.test.ts new file mode 100644 index 0000000000..5236749e72 --- /dev/null +++ b/src/bundled/builderlab/dev-session.test.ts @@ -0,0 +1,423 @@ +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 { + 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, + error?: string, + ) { + this.onmessage?.({ + data: JSON.stringify({ + revision, + pending, + ...(error ? { error } : {}), + 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: "/v3/beekeeper/list-agents", + }); + 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(); +}); + +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(); +}); + +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("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 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; + + 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, + ); + 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"); + + 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]); + } + expect(Events.all.at(-1)?.close).toHaveBeenCalled(); + }, +); diff --git a/src/bundled/builderlab/dev-session.ts b/src/bundled/builderlab/dev-session.ts new file mode 100644 index 0000000000..c89515941d --- /dev/null +++ b/src/bundled/builderlab/dev-session.ts @@ -0,0 +1,297 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +// 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, + 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; + error?: string; + 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) { + if (state.status !== "signed-in") + publish({ status: "signed-in", account: owner.account }); + return; + } + const at = ++version; + clear(); + if (!next.auth || next.pending) { + publish({ + status: next.pending ? "pending" : "signed-out", + ...(!next.pending && next.error ? { error: next.error } : {}), + }); + 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 }); + } + 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.", + }); + }); + }; + 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", + 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; + 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() { + 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, + 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 2b51b4a2b4..59c2509cf8 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,164 @@ 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, + can_use_builderlab_decisions: 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.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); + 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); + 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(); + 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" }), + ).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..96cd07c1f5 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), @@ -172,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 @@ -192,19 +199,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/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..991eff93ee --- /dev/null +++ b/tests/browser/builderlab-session.spec.mjs @@ -0,0 +1,168 @@ +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(); + } +}); + +// 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); + } +}); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 699799c6b2..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, @@ -2028,6 +2031,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("/"); @@ -2292,6 +2311,7 @@ export const test = base.extend({ next(); }); const broker = relayBrokerPlugin({ + builderlab, archiveFile: ":memory:", relayUrl: fixtureRelayUrl, communityAliases: fixtureAliases, @@ -2765,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 ?? [])]; 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" }], 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;