Skip to content
Draft
53 changes: 52 additions & 1 deletion browser-host/builderlab.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export function createBuilderlab({
}) {
let credential;
let account;
let capabilitiesCheckedAt;
let capabilityRead;
let pending = false;
let loginError;
let linking = Promise.resolve();
Expand All @@ -246,6 +248,7 @@ export function createBuilderlab({
revision,
pending,
auth: account ?? null,
capabilitiesCheckedAt,
...(loginError ? { error: loginError } : {}),
});
const notify = () => {
Expand Down Expand Up @@ -290,7 +293,7 @@ export function createBuilderlab({
}
throw new Error(`Builderlab request failed (HTTP ${response.status}).`);
};
const me = async (session, signal) => {
const me = async (session, signal, capabilitiesOnly = false) => {
const started = generation;
const response = await fetch(`${api}/v1/auth/me`, {
headers: { "X-BB-Session-Credential": session },
Expand All @@ -300,6 +303,7 @@ export function createBuilderlab({
if (!response.ok) {
if (
response.status === 401 &&
!capabilitiesOnly &&
started === generation &&
credential === session
)
Expand All @@ -308,6 +312,28 @@ export function createBuilderlab({
`Builderlab session check failed with HTTP ${response.status}`,
);
}
if (capabilitiesOnly) {
const value = await response.json();
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Builderlab returned invalid capabilities");
const { capabilities } = value;
if (
capabilities !== undefined &&
(!capabilities ||
typeof capabilities !== "object" ||
Array.isArray(capabilities))
)
throw new Error("Builderlab returned invalid capabilities");
return {
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,
},
};
}
const { subject, email, name, expires_at, capabilities } =
await response.json();
if (
Expand Down Expand Up @@ -341,6 +367,8 @@ export function createBuilderlab({
pending = false;
loginError = undefined;
account = undefined;
capabilitiesCheckedAt = undefined;
capabilityRead = undefined;
session?.abort();
session = new AbortController();
credential = undefined;
Expand All @@ -360,6 +388,7 @@ export function createBuilderlab({
};
const publishAccount = (value) => {
account = value;
capabilitiesCheckedAt = Date.now();
notify();
};
const broker = {
Expand All @@ -368,6 +397,28 @@ export function createBuilderlab({
listeners.add(listener);
return () => listeners.delete(listener);
},
async capabilities(expected) {
current(expected);
if (capabilityRead) return capabilityRead;
const started = generation;
const checkedAt = Date.now();
capabilityRead = (async () => {
try {
const result = await me(credential, session.signal, true);
if (started !== generation)
throw new Error("Builderlab session changed");
// Restored pages retain refreshed flags without changing the sign-in profile.
account = { ...account, capabilities: result.capabilities };
return { revision, ...result };
} finally {
if (started === generation) {
capabilitiesCheckedAt = checkedAt;
capabilityRead = undefined;
}
}
})();
return capabilityRead;
},
async auth() {
const current = credential;
if (!current) return null;
Expand Down
193 changes: 192 additions & 1 deletion browser-host/builderlab.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,16 @@ function account(responses = {}) {
};
}
async function signIn(h) {
const previous = h.opened();
// 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));
await vi.waitFor(() => {
expect(h.opened()).toBeDefined();
expect(h.opened()).not.toBe(previous);
});
const returnTo = h.opened().searchParams.get("redirect_uri");
expect(new URL(returnTo).hostname).toBe("127.0.0.1");
await globalThis.fetch(
Expand Down Expand Up @@ -924,3 +928,190 @@ it("redacts the login credential before publishing a shared failure or returning
h.builderlab.signOut();
}
});

it("keeps the broker sign-in and account projection when a capability-only check is refused", async () => {
let status = 200;
let invalid = false;
const h = account({
"/v1/auth/me": () =>
Response.json(
invalid
? []
: {
subject: "user",
email: "a@example.org",
expires_at: "2030",
capabilities: { can_use_remote_agents: true },
},
{ status },
),
});
await signIn(h);
const previous = h.builderlab.snapshot();
status = 401;
await expect(h.builderlab.capabilities(previous.revision)).rejects.toThrow(
"HTTP 401",
);
expect(h.builderlab.snapshot()).toEqual({
...previous,
capabilitiesCheckedAt: expect.any(Number),
});
status = 200;
invalid = true;
await expect(h.builderlab.capabilities(previous.revision)).rejects.toThrow(
"invalid capabilities",
);
expect(h.builderlab.snapshot()).toEqual({
...previous,
capabilitiesCheckedAt: expect.any(Number),
});
invalid = false;
await expect(h.builderlab.capabilities(previous.revision)).resolves.toEqual({
revision: previous.revision,
capabilities: {
can_use_remote_agents: true,
can_use_builderlab_decisions: false,
can_delete_buzz_communities: false,
},
});
expect(h.builderlab.snapshot()).toEqual({
...previous,
capabilitiesCheckedAt: expect.any(Number),
});
h.builderlab.signOut();
});

it("retains refreshed flags and their age for reloaded pages without changing the sign-in profile", async () => {
let now = Date.now();
const clock = vi.spyOn(Date, "now").mockImplementation(() => now);
let granted = false;
const held = Promise.withResolvers();
const started = Promise.withResolvers();
let pending;
let restored;
const h = account({
"/v1/auth/me": async () => {
if (granted) {
started.resolve();
await held.promise;
}
return Response.json({
subject: granted ? "replacement-profile" : "original",
email: granted ? "changed@example.org" : "original@example.org",
name: granted ? "Changed" : "Original",
expires_at: "2030",
capabilities: { can_use_remote_agents: granted },
});
},
});
try {
await signIn(h);
const before = h.builderlab.snapshot();
granted = true;
now += 60_000;
const checkedAt = now;
pending = h.builderlab.capabilities(before.revision);
await started.promise;
// A page restoring mid-read must see an expired cache and join this request.
expect(h.builderlab.snapshot()).toEqual(before);
restored = h.builderlab.capabilities(before.revision);
now += 1_000;
held.resolve();
await Promise.all([pending, restored]);
expect(
h.requests.filter((request) => request.path === "/v1/auth/me"),
).toHaveLength(2);
expect(h.builderlab.snapshot()).toMatchObject({
revision: before.revision,
capabilitiesCheckedAt: checkedAt,
auth: {
subject: "original",
email: "original@example.org",
name: "Original",
expiresAt: "2030",
capabilities: { can_use_remote_agents: true },
},
});
} finally {
held.resolve();
await Promise.allSettled([pending, restored]);
h.builderlab.signOut();
clock.mockRestore();
}
});

it("rejects a stale page before joining a replacement account's capability read and fences old cleanup", async () => {
let now = Date.now();
const clock = vi.spyOn(Date, "now").mockImplementation(() => now);
let subject = "A";
let held;
const first = {
started: Promise.withResolvers(),
reply: Promise.withResolvers(),
};
const second = {
started: Promise.withResolvers(),
reply: Promise.withResolvers(),
};
const h = account({
"/v1/auth/me": () => {
if (held) {
const read = held;
held = undefined;
read.started.resolve();
return read.reply.promise;
}
return Response.json({ subject, expires_at: "2030" });
},
});
const pending = [];
try {
await signIn(h);
const oldRevision = h.builderlab.snapshot().revision;
now += 60_000;
held = first;
const old = h.builderlab.capabilities(oldRevision);
const rejected = expect(old).rejects.toThrow("session changed");
pending.push(rejected);
await first.started.promise;
subject = "B";
now += 1_000;
await signIn(h);
const before = h.builderlab.snapshot();
now += 60_000;
held = second;
const current = h.builderlab.capabilities(before.revision);
pending.push(current);
await second.started.promise;
const stale = h.builderlab.capabilities(oldRevision).then(
() => "accepted",
(error) => error.message,
);
pending.push(stale);
first.reply.resolve(
Response.json({ capabilities: { can_use_remote_agents: false } }),
);
await rejected;
expect(h.builderlab.snapshot()).toEqual(before);
const joined = h.builderlab.capabilities(before.revision);
pending.push(joined);
second.reply.resolve(
Response.json({ capabilities: { can_use_remote_agents: true } }),
);
await Promise.all([current, joined]);
await expect(stale).resolves.toBe("Builderlab session changed");
expect(
h.requests.filter((request) => request.path === "/v1/auth/me"),
).toHaveLength(4);
expect(h.builderlab.snapshot()).toMatchObject({
revision: before.revision,
auth: { subject: "B", capabilities: { can_use_remote_agents: true } },
});
} finally {
first.reply.resolve(Response.json({}));
second.reply.resolve(Response.json({}));
await Promise.allSettled(pending);
h.builderlab.signOut();
clock.mockRestore();
}
});
28 changes: 27 additions & 1 deletion browser-host/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4101,6 +4101,7 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat
});
const instructions = "\\".repeat(20_000);
let received;
let accountReads = 0;
const nativeCalls = [];
const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, {
open: async (url) => openLogin(new URL(url)),
Expand All @@ -4111,12 +4112,14 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat
session_credential: "fixture-only",
expires_at: "2030",
});
if (path.endsWith("/v1/auth/me"))
if (path.endsWith("/v1/auth/me")) {
accountReads += 1;
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 },
Expand Down Expand Up @@ -4165,6 +4168,29 @@ test("Builderlab HTTP accepts full agent instructions and isolates the fixed nat
const { revision } = JSON.parse(
event.slice("data: ".length).split("\n\n")[0],
);
const capabilityCheck = await fetch(
`${h.base}/api/builderlab/capabilities?revision=${encodeURIComponent(revision)}`,
);
expect(capabilityCheck.status).toBe(200);
expect(await capabilityCheck.json()).toEqual({
revision,
capabilities: {
can_use_remote_agents: true,
can_use_builderlab_decisions: false,
can_delete_buzz_communities: false,
},
});
const reads = accountReads;
for (const query of ["", "?revision=previous"]) {
const refused = await fetch(
`${h.base}/api/builderlab/capabilities${query}`,
);
expect(refused.status).toBe(502);
expect(await refused.json()).toEqual({
error: "Builderlab session changed",
});
}
expect(accountReads).toBe(reads);
const body = JSON.stringify({
agent_pubkey: "a".repeat(64),
agent_instructions: instructions,
Expand Down
8 changes: 8 additions & 0 deletions browser-host/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,14 @@ export function relayBrokerPlugin({
send(builderlab.snapshot());
return;
}
if (action === "capabilities" && req.method === "GET")
return json(
res,
200,
await builderlab.capabilities(
url.searchParams.get("revision"),
),
);
if (action === "auth" && req.method === "GET")
return json(res, 200, { auth: await builderlab.auth() });
if (req.method !== "POST")
Expand Down
6 changes: 6 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,12 @@ available rather than silently discarding it.
depend on the type. Do not assume a provider is configured or signed in.
Builderlab shows **Unavailable for this account** when signed in without
remote-agent access. Manage its account in **Settings → Builderlab**.
Opening Builderlab settings or checking a gated feature refreshes permissions
at most once a minute; a failed check keeps the previous permissions.
Decisions API settings, decision requests, and Attention classifier readiness
checks also discover newly granted Decisions access, even while Builderlab is
absent from the provider list. Requests and Attention skip discovery while
your explicitly chosen provider is still listed; Settings always checks.
Claude runs with an Anthropic API key pasted under **Anthropic API key**
there, once for every Claude agent. When creating a Claude agent or editing
one that runs on this computer, click **Add key** beside **Harness** to open
Expand Down
Loading
Loading