You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: private agents and settings for every user - #357
Local mode on SQLite or Postgres storage refuses Server folder paths. These requests now return 409 STORAGE_CAPABILITY_UNAVAILABLE:
POST /api/workspaces with a path. Before, {name, path} created a Workspace by name and ignored the path.
POST /api/workspace/validate-path, which used to answer whether a path exists.
pick-folder and PUT /api/workspace.
The web app and desktop never send these on SQL storage, so this affects only scripts that call the API directly. Disk storage is unchanged.
Everything else in local mode and on desktop works as on main, and tests cover it:
deployment env keys and settings files;
every model provider, including LAN and localhost endpoints;
the server-local Agentlet;
devices connected with main's command;
existing Agent Profiles;
saved conversations;
agent reachback through $AGENTLET_TOKEN or $HUABU_RFS_TOKEN.
#356 gave Bubble users their own Workspaces. This PR gives every admitted user, including administrators, personal settings and private agent connections. Users configure their own Built-In model credentials or connect their own external devices and Agent Profiles. One user's settings, credentials and devices are never another user's defaults or shared execution resources.
Changes
Use the existing caller scope for model keys, OAuth, service credentials, model configuration, Utility Agent selection, review preferences, device names and runtime settings for every Bubble user. Personal authentication never falls back to deployment environment or file credentials. Member model traffic keeps Node's built-in fetch behind the public-network guard, so multipart image edits work.
Enroll external devices with signed user credentials and persist immutable device ownership. Profiles inherit that ownership. Apply the same checks to discovery, direct profile access, edits, deletion, CLI detection, file browsing, hostname placement, permission answers and canonical conversation controls, with no administrator bypass. Agenetes file-backed thread records keep their namespace owner.
Separate device transport credentials from scoped HUABU_RFS_TOKEN callbacks. Inject callback credentials at runtime, keep them out of persisted workloads, and recheck the device owner's admission and permissions on the named Workspace and Space. A callback is accepted only while its conversation is still an external agent's in that Space on a device its owner owns. Reject unowned legacy workloads during background recovery.
Version each user's device and callback credentials. A user resets their own from Devices and Agents, and an administrator can reset anyone's from People. A reset revokes only that person's connection commands and callbacks and disconnects their devices and running agents, without rotating the instance key.
Scope active runs, pending preparations and stop requests to their Workspace and Space. Anyone who may read a Space sees its running turn, and anyone who may write it may stop it; external agents still require their device owner, administrators included, and a conversation is always addressed through its Space. Space moves and deletes refuse while it runs. Stop works even when the Question node no longer resolves. Memory passes on one Space are serialized, with a coalesced follow-up running in its latest requester's settings. Identical preprocessing requests coalesce across a Space's viewers. Allow member utility work and Task Runs through the same resource and device checks; Workspace-wide memory work requires Workspace manage access.
On Disk storage, any user may open a Server folder by path as a Workspace of their own (capabilities.folderWorkspaces), and Workspace paths are shown. Disk is a single-machine and debugging setup, so there is no further guard. Postgres/Azure and SQLite storage refuse folder paths with 409 STORAGE_CAPABILITY_UNAVAILABLE. Native folder dialogs, path probing and moving the process-wide Workspace stay with local and administrator callers on localhost.
Give all users the existing Devices and Agents, Agent settings, Services and General tabs. Administrators additionally see People and deployment readiness. People admits users and manages Workspace roles through Bubble's existing policy.
Keep refresh tokens in per-tab sessionStorage; share the latest token over BroadcastChannel and serialize redemption with a Web Lock. A shared non-secret revision detects missed rotations after reload: recover the current token from a peer or require sign-in when no peer can supply it. When the revision cannot be written, renewal stays in the original document and the token is not shared or reused after reload. Initial identity or sign-in discovery failures keep the app closed and retry until the caller is established. Discard refresh results after sign-out, retain the provider for logout and through a failed sign-out, report popup failures, and retry retained saves through the existing save drain. While the Server is briefly unreachable, the sign-in cover rechecks with bounded backoff instead of waiting for a reload. A refused account ends the provider session on sign-out only when HUABU_SIGN_OUT_PROVIDER is set.
Bubble mode does not attach a shared server-local Agentlet or assign existing deployment settings and Profiles to an administrator. Existing users, including administrators, configure their own settings and reconnect devices using their personal connection command. Device and callback credentials issued by earlier builds of this branch are not accepted; copy a new connection command. Legacy Profiles remain available in local mode; Bubble uses a separate private-user Profile registry. Local mode keeps its implicit single user's existing behavior. Database, identity-provider configuration, network binding and the private instance signing key remain server infrastructure.
Bubble deployments (experimental)
Nothing below is migrated:
Settings and model access
Deployment env keys (OPENAI_*, ANTHROPIC_*, AZURE_OPENAI_*, TAVILY_API_KEY, RAPIDAPI_KEY, VISION_*) no longer apply to any user, administrators included. The same goes for settings saved on the Server: llm-config.json, OAuth tokens, integration and OCR keys, Agent defaults and review preferences.
Model traffic is public-network only. Ollama, LAN and private endpoints are refused.
The Google (Gemini, Vertex), Bedrock and Mistral providers are unavailable.
Devices and Profiles
HUABU_CONNECTION_TOKEN is no longer accepted over HTTP or RFS. It stays the signing key, so rotating it revokes every user's device and reachback credentials.
The Server's own Agentlet isn't started. Every device reconnects with its owner's personal connection command.
The first user to connect a device owns it, permanently: resetting that user's device credentials does not release it. Two people sharing one OS account connect with distinct --agentlet-id values.
Profiles live in user-agent-profiles/.
Agent reachback
Agents get a conversation-scoped $HUABU_RFS_TOKEN.
RFS still accepts the device's $AGENTLET_TOKEN, with the owner's own Workspace and Space access, so existing skills and scripts keep working.
Sign-in
Only Entra and Auth0 request offline_access.
The opt-in HUABU_SIGN_OUT_PROVIDER=true requires <origin>/ as a post-logout redirect URI.
Disk storage: every user sees Workspace paths and can open a Server folder as their own Workspace. A folder that is already someone's Workspace stays theirs.
API
New routes: /api/people/*, POST /api/acp/connection-credentials/reset and POST /api/people/:id/agent-credentials/reset.
New data files: agentlet-owners.json, agent-credential-versions.json and people/<hash>/….
The ACP permission answer requires ?canvasId=.
Deferred
Google (Gemini, Vertex), Bedrock and Mistral providers stay unavailable to Bubble users: their adapters bring their own HTTP transport and cannot honor the public-network guard.
Sharing UI beyond administrator Workspace grants.
Releasing a device to another user. Profiles and conversations are authorized through their device's owner, so release first needs them to carry their own owner.
Review follow-up
The re-review findings are fixed in 98d644e27…eb3f628f3, three open Copilot threads in 5cad0f548 and 057092889, Copilot's follow-up on Stop (an administrator naming a Space only in the body) in ac72a1c52, and Codex's credential-reset findings in b1a3e7bd3, with Copilot's follow-up on open RFS streams after a reset in 94f25c64f; the follow-up comment maps each one to its fix and regression test. Three of them were local-mode regressions against main:
conversation rename, model and reasoning-effort calls refused on an unresolved Question;
the local owner's run and Stop refused over a removed Profile;
Settings readiness and the connection-command source.
Validation
Full recursive workspace test suite at ec86ec2ba: 10,029 passed, including 3,227 server, 5,275 web and 814 shared tests; existing skips and todos remain. Full root checks and build passed locally.
Real Bubble admission, profile storage and Gateway WebSocket tests cover two members and two administrators, device takeover refusals, hostname placement, purpose-separated callbacks, conversation-bound callbacks, per-user credential reset with live disconnection, disabled accounts and unowned background recovery.
Storage contracts: 17 files, 518 tests passed with the real PostgreSQL and Azurite test services, including the multi-user scenarios for folder Workspaces and People credential reset.
Playwright member settings: 3 passed. They cover device connection and credential reset, Built-In setup, session error, administrator People with per-person reset, and opening a Server folder from setup. Desktop, mobile and dark screenshots were inspected; no blocking visual defects.
Session lifecycle and member-settings Playwright checks at ec86ec2ba: 7/7 passed. Actual Chromium quota exhaustion permits renewal only in the original document; peer and reloaded documents refuse reuse. Focused session/identity tests passed 56/56 and personal-settings tests passed 9/9.
Rerun at eb3f628f3: every CI step passed locally. That covers lint (0 errors), format, typecheck, every unit suite (3,220 server, 5,249 web, 198 agenetes, 128 client-runtime), the storage contracts (518 passed, 1 Disk-only scenario skipped on Postgres) with Testcontainers Postgres/Azurite, i18n, Agent Team skills, license headers and the full build. The Playwright member-settings spec passed 3/3.
Paid external model providers and real Auth0/Entra sign-in, renewal and provider logout were not exercised; deterministic tests fake provider transport.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Personal OCR settings still inherit deployment credentials, and broadened member access allows members to stop administrators’ external agent runs without thread-binding checks.
Extends Bubble multi-user support with members’ Built-In agent access, personal credentials, People administration, and in-page session renewal. The stacked diff also includes the request-scoped Workspace foundation.
Changes:
Adds personal settings and caller-aware Built-In agent permissions.
Introduces administrator-only People APIs and membership/Workspace-role controls.
Adds OIDC refresh, popup reauthentication, save recovery, and optional provider logout.
This pull request requires changes. Unresolved critical findings concern operator OCR credential inheritance and member control of external-agent runs. Moderate findings affect shared OAuth sessions and model caches, provider logout, member service configuration, save replay, and concurrent refresh requests. Popup-error feedback also remains a nit.
File
Description
packages/shared/src/types/api/workspace.ts
Adds named-Workspace capability.
packages/shared/src/types/api/identity.ts
Defines People and provider-logout contracts.
docs/README.md
Indexes the multi-user proposal.
docs/proposals/multi-user-service.md
Documents the stacked design and limitations.
docs/architecture/deployment-security.md
Updates identity, permissions, and session documentation.
docs/architecture/canvas-storage.md
Documents concurrent Workspace storage.
docs/architecture/agent-reachback.md
Documents Workspace-qualified reachback URLs.
apps/web/src/utils/oidcLogin.ts
Adds refresh, popup login, and logout discovery.
apps/web/src/utils/oidcLogin.test.ts
Tests popup validation and refresh exchange.
apps/web/src/store/workspaceStore.ts
Supports personal Workspace initialization and creation.
A Bubble administrator can list every principal with their system and
Workspace roles, admit or revoke a member, and give or remove a
Workspace role, without scripts against Bubble's API. Revoking one's
own access is refused. Changes apply on the next request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ials
A Bubble member's calls run in a personal settings namespace: the
secret store keeps their model keys, OAuth and connected credentials
under their own ids with no fallback to the deployment's environment,
and model settings and agent defaults live under data/people/<key>/.
Members may change their own settings; the deployment's stay with
administrators. A member's default agent is always the Built-In one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Members may now use /api/agent and conversations through a Space they
can use; external agents, RFS and change-review configuration stay with
administrators, since they act through the shared machine credential
on the operator's machines. The Built-In agent's tools check the
starter's access: World reads of another Space need read on it,
Workspace memory and Skills need manage on the Workspace, and Task Run
tools are refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign-in now asks for offline_access and keeps the refresh token in
page memory only. The page renews its session a minute before the
access token expires and on any 401; if it cannot, the sign-in screen
opens over the still-running app and signs in through a popup, then
resends the saves the lapse refused. With HUABU_SIGN_OUT_PROVIDER=true,
signing out also ends the identity provider's session through its
end_session_endpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Administrators of a Bubble deployment get a People tab to admit or
revoke people and give Workspace roles. A member's Agent tab shows only
Built-In setup, their own models and keys, and Settings no longer
loads the external agent and readiness data they cannot read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sation
The web app picks a new conversation's agent from GET /api/acp/profiles,
which refused members, so they could not start one. Members now read a
listing with no external profiles and Built-In as the selectable agent.
The App menu calls the switcher "Switch Workspace" for named
Workspaces, and the People panel's add picker aligns with its rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…enewal
Fold PR B into the deployment-security architecture doc and record in
the proposal why members run the Built-In agent while external agents,
which hold the daemon's machine token and the operator's CLI logins,
stay with administrators.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rework microsoft#357 on main after microsoft#356 merged. Preserve Workspace permissions and the current Settings navigation, isolate member OCR, OAuth and model discovery state, prevent external thread control, and repair refresh, logout and save recovery. Add server and browser regressions for the resulting behavior.
A member can attach to another Space's live Built-In conversation
Found in review of 5987c219a and confirmed with two throwaway tests (not pushed).
Why it happens
The member preHandler in apps/server/src/modules/agent/agent.route.ts and the Space guard only check the Space named in the request (canvasId). They also check that that Space's record for threadId isn't external.
Agenetes keeps live Deployment handles in one map keyed by threadId alone. createAgentRuntime().getOrCreate(threadId, …) in external/agenetes/packages/runtime/src/driver.ts does this, and instance.ts calls it for every Deployment create, whatever the namespace.
agentThreadService.invoke has no check that the thread belongs to the Space either. Its turn lock is keyed by threadId too.
Scenario
Carol has a live Built-In thread X in Space S.
Erin can view S, so she can read X from its nodes.
Erin sends POST /api/agent {canvasId: <Erin's Space>, threadId: X}.
Space access passes because Erin can write her own Space. record(<Erin's ns>, X) is null, so the external-thread guard passes too.
runAgent → agenetes.create(spec in Erin's namespace) returns Carol's live handle. That handle holds Carol's history, and its tools are bound to S.
Erin's turn now reads Carol's conversation and can write S. Afterwards Erin's namespace also holds a record for X, so the per-thread control routes (e.g. /threads/X/model) reach Carol's handle as well.
It also works the other way round. If Erin's request arrives while X isn't live (for example after a restart), her handle is registered under X, and Carol's next turn is served by it.
Repro
Agenetes level: mount mountAgenetes with a stub internal Deployment driver, then call create for namespace canvas/space-S, thread carol-thread, followed by namespace canvas/erin-space, same thread.
The second call returns the same handle object.
That handle was built from the space-S spec, and the driver's create ran only once.
record(canvas/erin-space, carol-thread) now exists.
Route level: with the agent.route.test.ts harness and a member identity (owner: false), POST /api/agent {canvasId: 'erin-space', threadId: 'carol-thread'} returns 200. invoke is reached with that pair.
Test Files 2 passed (2)
Tests 2 passed (2)
Suggested fix
Key live handles by namespace + threadId in the runtime, or refuse a Deployment create whose threadId is already live under a different namespace. Either way, get/close need to follow the same key. A route-level check alone wouldn't cover the restart direction above.
Generated by an AI code review (Claude Code), confirmed with local tests.
Rest of the review: 14 more findings, each checked with a throwaway test
This follows the takeover comment above and was checked at the same head, 5987c219a. Each finding was reproduced with a throwaway vitest test (not pushed), using the real code wherever possible. All 14 reproduce. Three are partly confirmed or carry caveats; those are noted in their entries.
Server
A. Members still get the operator's Azure settings: apps/server/src/modules/agent/llm.ts:568-572, :679-690 buildModel falls back to process.env.AZURE_OPENAI_API_ENDPOINT when an Azure config has no baseUrl. getModelsForProvider('azure-openai') lists AZURE_OPENAI_API_DEPLOYMENT_NAME. The member check in ensureConfig covers neither path. Test: operator AZURE_* env set, run as member Carol.
Carol is offered operator-deployment as her model.
After she saves azure-openai with her own key and no baseUrl, getLLMModel().baseUrl is the operator's endpoint.
ensureApiKey() returns Carol's key, so her key and prompts would go to the operator's endpoint.
B. A member can start an agent run without naming a Space: apps/server/src/modules/security/space-access.ts:117-124
A request is refused only for threadId without canvasId. With neither, no checks are queued. canvasId is optional in agentRequestSchema, and the run lands in the shared canvasAcpNamespace(''). Test: real registerSpaceAccess and real agent route, with a member whose access.can always returns false.
{content, canvasId: 'someone-elses'} returns 404, as expected.
{content} alone returns 200 and reaches invoke with canvasId: undefined. can is never called.
C. Changing a Workspace role can leave the person with no grant: apps/server/src/modules/identity/bubble.ts:134-149
Grants are removed first, then added, as separate store calls. Test: real openBubbleIdentity with an in-memory store. Erin is moved from editor to owner, and grants.add fails. Erin holds no Workspace grant at the moment of the add and still has none after it fails. Partly confirmed:list() (:89-97) keeps only the last role it sees for a Workspace. A person holding owner and viewer shows as viewer. Huabu's own routes never create two roles, though; it takes a grant written some other way, for example through Bubble directly.
D. A member can save an external agent profile: apps/server/src/modules/agent/agent-defaults.route.ts:96-122 Test: real defaults service with a member request scope.
Carol's PUT {profileId: 'ext-1'} returns 200, with ext-1 shown as selected, and it is written to her settings file.
The following GET returns huabu, because getAgentDefaults() forces it for members. The UI and the server disagree.
An unknown id returns 400 profile_not_found, so a member can find out which profile ids the operator has.
E. Members see the operator's agentlet daemon status: apps/server/src/modules/agent/acp/profiles.route.ts:226-234 Test: real Space guard with a member scope. The member branch runs and returns profiles: [], but the response still carries the operator's status under agentlet, including lastError and nextRestartAt. A fixed { online: false } for members would avoid that.
Web: SignInGate.tsx
F. Signing in again in the popup doesn't check that it's the same person: :192-203 Test: Carol's session lapses and the popup returns Dave's token.
The session POST carries Dave's bearer token, and the identity store holds Dave.
Carol's mounted app is still the same tree, nothing reloads, and resend() is called once with her unsaved edits.
resend was a mock, so what the server does with those edits wasn't exercised.
Suggested fix: compare principalId before and after, and reload the page if it changed.
G. Renewal loops with no limit when the server rejects refreshed tokens: :174-186 with api/_client.ts:133-138
A 401 from startSession fires AUTH_REQUIRED_EVENT, which runs recheck, which refreshes and calls adopt again. Test: the server keeps rejecting fresh tokens. One event produced 100 refresh grants and 100 session POSTs within 40 React ticks, stopped only by the test's fetch cap. Without the cap, the vitest worker ran out of memory.
H. The renewal timer overflows for long-lived tokens: :91-95
With expires_in: 2592000 (30 days), the delay (expiresIn - 60) * 1000 is 2,591,940,000 ms, which is more than 2^31-1. Browsers and Node then fire the timer almost at once. Test: real timers produced 132 refresh grants in 300 ms. Suggested fix: cap the delay.
I. Sign out on the no-access and denied screens skips provider sign-out: :312
On the no-access screen, signOut() is called with no arguments even though identity says providerSignOut: true.
On the denied screen the call is the same, but state.identity doesn't exist there, because getIdentity threw on the 403. The flag has to come from somewhere else.
J. A background 401 clears the popup error message: :174-186
With no refresh token held, recheck calls check() with no error. Test: with the popup blocked, "The browser blocked the sign-in window" appears. One more AUTH_REQUIRED_EVENT leaves the sign-in screen up but removes the message.
Web: oidcLogin.ts / api/identity.ts
K. A network error throws away the refresh token: apps/web/src/utils/oidcLogin.ts:281-282 Test: the first refresh fails because fetch throws. Once the network is back, a second refresh returns null without calling the token endpoint. Suggested fix: clear held only when the provider rejects the grant, such as invalid_grant.
L. offline_access is now sent to every provider: oidcLogin.ts:77-84 Test: a generic-oidc authenticator with scopes: [] still requests offline_access. Base 14822a92c didn't request it, and no authenticator flag turns it off. Not verified locally: that some providers reject it with invalid_scope.
M. A successful popup sign-in can be reported as "window closed": popup side oidcLogin.ts:186-197, opener poll :234-238
The popup posts the code and calls window.close() straight away. The opener's 500 ms poll then rejects as soon as popup.closed is true and removes the message listener. Test: the poll is forced to run before the message is handled. Sign-in rejects with "The sign-in window was closed", and the code is never redeemed. In a real browser this depends on timing, so it happens only sometimes.
N. signOut rejects after the session has already ended: apps/web/src/api/identity.ts:93-101 listInteractiveAuthenticators() runs after endSession() and forgetTokens(). A non-OK HTTP response returns null, but a network failure throws. Test: the session DELETE succeeds and the /bubble/ fetch throws. signOut rejects with "Failed to fetch", so AppMenu shows "sign out failed" even though the tokens are already gone. The list request is also unnecessary when heldAuthenticator() is already known.
Generated by an AI code review (Claude Code); every item above was confirmed with local tests.
Addressed the current review findings in 1bac189cd.
For the cross-Space conversation takeover, the runtime retains namespace ownership with each live handle, refuses cross-namespace creation before persisting a record, and validates scoped lookup and closure. Regression tests cover both namespace orderings and concurrent creation. Skill dispatch and Space moves also close only their own namespace's handle.
A–E: Azure names/endpoints no longer fall back to deployment environment settings for members; member agent requests require a Space; Workspace role changes add the replacement grant first and display the strongest grant; member defaults reject external profiles before registry lookup; member discovery omits operator daemon status.
F–J: Session recovery verifies the original principal before resuming or replaying saves, reloads on account changes, stays blocked if identity cannot be verified, prevents recursive renewal from session rejection, bounds renewal timers, honors provider logout on no-access/denied screens, and retains popup error messages across background 401 events.
K–N: Transient refresh failures retain the refresh token while invalid grants discard it; generic OIDC uses configured scopes; the opener consumes a successful callback before closing the popup; provider discovery failures cannot turn completed local logout into an error, and discovery is skipped when the provider is already known.
The inline findings are covered by individual thread replies, including final-binding authorization under the turn lease, source-Space artifact authorization, model-network restrictions, serialized session adoption/logout, persisted non-secret provider selection, and renewal focus management.
Member model requests use connection-time public-network checks for DNS and redirects. Streaming uses SSE and excludes deployment provider environment options. Providers whose upstream adapters cannot honor a guarded transport are unavailable to members; administrator configurations remain available.
Validation passed: recursive suites plus the final server suite (3,179 server, 5,234 web and 813 shared tests), all 518 storage contracts, both Playwright member/People scenarios, typecheck, lint (0 errors), formatting, i18n parity, Agent Team skill checks, copyright checks, and production builds. Playwright verifies renewal focus trapping, inert background, and focus restoration; six desktop/mobile/light/dark captures were inspected. Live Auth0/Entra flows were not exercised; provider transport remains mocked in regression tests. An unchanged subtitle-contrast concern was left outside this review's scope.
A member can send POST /api/agent/stop/<victimThread> with only {"canvasId":"my-writable-space"} in the body: both guards authorize that Space, but the handler reads only the query Space. It then passes undefined to stopAndWait(), which treats it as a wildcard and can stop another person's run, including an external agent. Require the stop endpoint's query Space for members so authorization and execution use the same address.
Concurrent grant replacements can leave stale owner privileges
Concurrent owner and viewer assignments can read the same existing grants, add both new roles, and remove only the old rows. Both requests succeed, but owner privileges remain; a concurrent removal can likewise miss a newly added grant. Serialize the entire replacement per principal/Workspace or make it atomic in storage, with a regression test for concurrent replacements.
Re-check at 1bac189cd: all 15 findings addressed, one smaller issue left
I reran the throwaway tests from the two comments above against 1bac189cd. Each finding no longer reproduces: either its test now fails on the corrected behaviour, or what it simulated can no longer happen (#11: the popup no longer closes itself). I read each failure message and the diff to make sure no test failed just because it stopped matching the code.
Still open: a viewer can block a Space's conversation (left over from #1)
The runtime now records each live handle's namespace, so the original takeover fails with Thread is live in another namespace and nothing leaks. The reverse order still causes trouble:
Carol's thread X in Space S isn't live, for example after an idle close or a restart.
Erin, a viewer of S, sends POST /api/agent {canvasId: <Erin's Space>, threadId: X}. That creates a fresh handle for X in Erin's namespace, with no access to Carol's history or tools. It also writes a durable record for X there.
Carol's next turn on X is refused with Thread is live in another namespace until Erin's handle closes. Carol can't evict it herself, because close(X, <S's namespace>) throws too.
Test: with mountAgenetes and a stub Deployment driver:
create X in Erin's namespace, then in S's namespace: the second call rejects;
close X with S's namespace: rejects;
close X with Erin's namespace, then create X in S's namespace again: succeeds.
The route layer still lets Erin's request through. A route-level check that a supplied threadId belongs to the named Space would close this. Another option is to make thread ids unique per namespace by construction, for example by having the server mint the id instead of the client.
Two trade-offs to confirm are intended
bug: frame auto resizing #7, denied screen: Sign out now passes providerSignOut: true unconditionally, so it signs out of the identity provider even on deployments that haven't enabled HUABU_SIGN_OUT_PROVIDER.
English Word line break #9:offline_access is now requested only for entra and auth0. That fixes the invalid_scope regression. Other OIDC providers, generic-oidc included, get no refresh token, so their sessions can't renew in place and fall back to the popup. If some of those deployments support refresh tokens, an authenticator option would let them opt in.
The rest, confirmed fixed
2 popup account change triggers a reload · 3 no operator Azure fallback for members · 4 member /api/agent without canvasId returns 400 · 5 startSession no longer re-dispatches, and renewals don't overlap · 6 timer delay capped at 2^31-1 · 8 refresh token dropped only on invalid_grant · 10 earlier error kept · 11 opener closes the popup after the message · 12 list failure is caught and skipped when the provider is known · 13 grant added before the old one is removed, and list() keeps the highest role · 14 member PUT of an external profile returns 403 before the registry lookup · 15 members get { online: false }
I only reran these throwaway tests; the PR's own test suites weren't run.
Generated by an AI code review (Claude Code), re-checked with local tests.
Use fixed member defaults, centralize fs_write authorization, and resolve Settings navigation in one place.
Allow member defaults reads through the Space guard, retain live conversations across Space renames, require a query Space for member stops, and serialize Workspace role replacements.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because critical Workspace ownership gaps, unguarded server-side URL fetching, and unauthorized file attachment reads were omitted from the published review.
With renewal shared across tabs, a tab whose session POST was still in
flight could set the cookie again after another tab's sign-out deleted
it. Session writes and the deletion now take turns under one Web Lock,
and sign-out leaves a non-secret localStorage marker that refuses later
refresh-token renewals until someone completes a sign-in.
Also exercise the cross-tab refresh lock, which the test DOM lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-reviewed 7413d00a1768024a5f5c41ce87ce04c0365cac34 against main 14822a92c9739f08579ddf2426c56e84909b4ab1. I recommend holding merge for one newly reproduced session-renewal regression. GitHub reports CLEAN/MERGEABLE and all four checks are successful, but those checks do not cover the failure below.
P1: A reloading tab can replay a rotated refresh token and invalidate renewal in every tab
A tab restores held from its own sessionStorage and asks peers for their current token only when it has no stored token. BroadcastChannel messages are not retained for a document that is reloading or has not initialized its channel. The Web Lock serializes redemptions but does not refresh stale state: waiting one timer tick cannot recover a broadcast that the tab missed.
Reproduced twice with two real Chromium tabs, real sessionStorage/BroadcastChannel/Web Locks, and a deterministic external token endpoint implementing rotation and token-family reuse detection:
Both tabs hold refresh-1; tab B reloads, retaining its sessionStorage, and its module initialization is delayed.
Tab A renews successfully with refresh-1, receives refresh-2, and broadcasts it while B has no channel.
B initializes with stored refresh-1, sends no want, and then obtains the refresh lock and submits that already-used token.
The provider refuses B and invalidates the token family. A's subsequent use of refresh-2 is refused too.
The assertion that the reloaded tab renews successfully fails. This can force reauthentication across all tabs; Auth0 documents invalidation of the entire token family on reuse. The provider behavior was simulated here; this is not a claim that a real Auth0 tenant was exercised.
Please synchronize the latest token state before redemption under the refresh coordinator, including when a tab has a persisted token, and prevent stale peers from replacing newer state. Add a regression that reloads one tab across another tab's rotation; the current tests only prove delivery to an already-listening peer.
Copilot and earlier findings
Since the earlier review, Copilot added the open-RFS-stream reset finding and the cross-tab session-adoption/logout finding. Both are addressed, and all 26 review threads are resolved at this head.
Independently verified the logout fix using two Chromium tabs: DELETE waits for a delayed renewal POST from the other tab, the cookie is absent afterward, late renewal is refused, and an explicit new sign-in permits subsequent renewal.
The earlier late-Gateway-handshake and device/Profile-reclaim findings have fixes and passing regression coverage. Device ownership now survives credential reset.
Copilot's latest summary mentions a possible SQL cross-Workspace concurrency concern without a concrete inline finding. I could not reproduce a failure for independent conversations: an additional probe using the real mounted Agenetes runtime, Workspace scopes and conversation stores passed 60 concurrent turns and 60 out-of-request state reports across two Workspaces on each of SQLite and PostgreSQL. This does not establish that every concurrency interleaving is safe.
PR description mismatch
The Deferred section still says refresh tokens remain in page memory and do not survive reload. The code and updated architecture/proposal now persist them in sessionStorage and share them between tabs. Please update the PR description so reviewers see the actual credential-lifetime behavior. This is separate from the runtime blocker above.
Verification and limits
Review scope: base/head history and the high-risk identity/personal-settings, private-device/Profile, credential-reset/RFS, Agenetes Workspace/namespace and browser-session paths, with focused re-review of changes since ac72a1c52.
pnpm -r --if-present --no-bail run test: 10,026 passed, with existing skips/todos; includes the device/reset and browser-session regression suites.
pnpm run test:storage-backends: 518 passed, one expected Disk-only skip, using the repository's disposable PostgreSQL/Azurite services.
pnpm run typecheck: passed; consumed Agentlet/Agenetes/client-runtime outputs rebuilt.
pnpm --filter @huabu/web exec playwright test e2e/member-settings.spec.ts e2e/session-recovery.spec.ts: 4/4 passed.
Additional real-browser logout probe: passed. Additional real-browser reload/rotation probe: failed as described above.
Additional two-Workspace concurrency probes on SQLite/PostgreSQL: passed.
Latest-head CI quality/build, both storage-profile e2e jobs, and CLA: successful. Local lint/format/full build were not repeated separately in this review.
Real Auth0/Entra sign-in, provider refresh/logout, paid model providers and packaged desktop runtimes were not exercised. This review made no product edits or commits.
The change stays in the existing OIDC helper. Tokens remain in per-tab sessionStorage; one non-secret revision in localStorage tells a tab whether its token is current. Inside the existing Web Lock, a stale tab asks peers for the current token and requires sign-in if none answers. Old peer offers are ignored. This removes the zero-delay timer that previously guessed when a rotation broadcast had arrived. The existing session documentation and the PR description now reflect the implemented storage and reload behavior.
Verification on this commit:
Both new Chromium regressions failed before the production fix and pass afterward: a reloading tab misses a rotation, then either recovers from a current peer or safely declines renewal when that peer has closed. The fixture invalidates the whole token family on reuse; browser storage, BroadcastChannel, Web Locks and the OIDC helper are real.
pnpm run check and pnpm run build: passed, including 10,026 passing workspace tests and the existing skips/todos. Lint has existing warnings and no errors; changed-file lint adds no warnings.
pnpm --filter @huabu/web exec playwright test e2e/member-settings.spec.ts e2e/session-recovery.spec.ts: 6/6 passed.
All 53 focused identity/session component tests passed. The full 5,272-test web suite also passed again after final test cleanup, including coverage that delayed stale peer offers cannot replace the current token.
Separate two-tab Chromium logout probe: passed. Logout waits for the other tab's session write, leaves the cookie deleted, refuses late renewal, and permits renewal after a new explicit sign-in.
The reproduced P1 is resolved; no further blocker was found in the changed session paths. New-head CI is pending. Live Auth0/Entra refresh/logout, paid model providers and packaged desktop runtimes remain untested; storage-backend contracts were not rerun locally for this browser-only fix (they passed in the preceding review).
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Changes to multi-user credential isolation and session lifecycle require live maintainer validation because real Auth0/Entra sign-in, token renewal, and provider logout were not exercised.
Re-review of 039dcd8 against base 14822a9. Hold merge: one reproduced P1 and a failed CI gate.
Scope: current base/head diff and history, session recovery and save replay, personal settings and credential isolation, device/Profile ownership and resets, Workspace selection/folder adoption, and agent namespace/handle lifecycle. The latest Copilot review on this head has no open issues, and all 26 review threads are resolved; its remaining note concerns live provider validation.
On the first load, /api/identity returning 503 sets the gate to ready while the identity store and originalPrincipal remain empty. The app can load a shared Space and accumulate edits. If the session then lapses and recovery identifies a different account, the account-change check is skipped because there is no original principal; recover() calls the real save drain. Where both accounts can edit the Space, the old account's pending changes can be submitted as the new account.
Reproduction: first identity response 503 → real app/canvas starts → retain a note edit behind 401 save refusals → recover identity as dave. A component probe reported {"identity":"dave","replayed":1,"reloaded":0}. A separate Chromium probe with the real canvas store, save drain and disposable Disk backend persisted "Retained while the session was expired" while the gate's identity was dave; the assertion that storage should retain "Saved before expiry" failed. Identity responses were fixtures; provider authentication was not live.
Suggested small fix: preserve the existing gate's requirement to establish the original principal before replay. If the app has already started without one, recover by reloading without draining its pending edits. Cover this first-load failure path as well as the existing known-principal recovery cases.
P2 — A failed revision write disables renewal in the signing-in document
The new revision write catches QuotaExceededError, but the fallback only handles a failing storage read. With readable, full localStorage, sign-in retains a valid refresh token in memory and sessionStorage while the revision remains absent. currentRefresh() treats that as no token and returns null forever, so this document cannot renew its session. This contradicts the intended in-document fallback and is an edge case in my last fix.
Reproduced with actual Chromium quota exhaustion, without mocking Storage: sign-in succeeded; two renewal calls returned false and made zero refresh requests. The normal-storage control renewed twice successfully.
Suggested small fix: handle inability to persist the marker as unavailable shared coordination, keeping safe in-document renewal and refusing token sharing/reload reuse without a usable marker.
P2 — Personal-settings test cleanup races the asynchronous logger; CI is red
The new fixture changes HUABU_DATA_DIR, resets modules, then deletes its directory after each test. Imports initialize the real logger's asynchronous file destination inside that directory. CI run 42213371917 failed with an unhandled ENOENT opening /tmp/huabu-personal-settings-fK6vAY/logs/server.log, attributed to this test file. All 3,227 server assertions passed, but the unhandled error failed the unit-test step and skipped the storage-profile jobs.
The 9-test personal-settings suite passed locally, so the failure is timing-sensitive. Keep the logger outside the per-test directory or finish its destination lifecycle before deleting the fixture, then verify CI passes.
Personal-settings suite: 9/9 passed locally; current-head CI remains failed as above.
Both component and real-browser account-recovery probes reproduced the new P1. Real-browser quota probe reproduced the storage fallback defect.
The preceding fix's full local checks/build and 6 browser checks passed on this same head; they were not repeated wholesale during this review.
Live Auth0/Entra sign-in/renewal/logout, paid models, packaged desktop and a new local PostgreSQL/Azure matrix run remain untested. No product files were changed; reproduction files are temporary.
These findings can be addressed in the existing gate, token helper and test fixture; no new framework or architectural refactor is needed.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because an unpublished critical finding indicates that overwriting persisted ownership can bypass workspace isolation after a restart.
Fixed the three findings from the latest re-review in ec86ec2ba6fd7a6f836e15eb685ca6ef93d869a3. Reviewed the resulting changes against 039dcd8874f2baf8173ca431f5cf2bca958d0281, including startup/recovery, refresh-token coordination, the personal-settings fixture and existing callers. No additional blocking defect found in this scope.
Initial identity/discovery failure: the existing sign-in gate keeps the app closed and retries until it establishes the caller. This prevents edits from accumulating without a known owner. Recovery retries saves only for an app that had already started, and a running app with no recorded principal reloads before admitting a Bubble account.
Readable but full localStorage: one document-local flag allows the current token holder to keep renewing after its revision write fails. It removes the obsolete shared marker and stops offering that token to peers. Other documents and reloads cannot use this fallback; an explicit sign-in after storage becomes writable resumes normal coordination.
Personal-settings CI failure: the settings test fixture uses a disabled logger, so module resets no longer create asynchronous Pino file destinations inside temporary directories removed by cleanup. Production logging and the real settings/secret-store behavior remain intact.
Verification at this head:
Initial identity/discovery regression tests failed before the guard change. The quota regression failed against the previous helper and passes with this fix.
pnpm run check passed: 10,029 workspace tests, formatting, typecheck, lint (0 errors), i18n parity and license checks. The full build passed with the same docs environment as CI.
Focused session/identity tests passed 56/56 and personal-settings tests passed 9/9.
Member-settings and session-recovery Playwright tests passed 7/7, including a real browser waiting through initial identity failures before allowing edits.
A separate Chromium quota-exhaustion probe renewed twice in the original document; a peer and a reload refused reuse. The normal-storage control also renewed twice.
CI run 42249040039 passed on this exact head: the full quality job, Disk/Disk acceptance and PostgreSQL/Azure acceptance. The CLA check also passed. GitHub reports MERGEABLE and CLEAN.
Copilot posted a new summary at this head, mentioning an unpublished persisted-ownership isolation bypass after restart. It supplies no code location, reproduction or inline finding; all 26 review threads are resolved. I traced the relevant recovered-namespace normalization through conversation-stores.ts, workspace/paths.ts, the storage adapters and the HTTP callers. Normalization occurs after resolving the requested Workspace and reading its storage; SQL extension lookup checks the bound Workspace and Space first, and HTTP callers construct namespaces on the Server rather than accepting an arbitrary namespace from the client.
A separate restart probe on real Disk and SQLite storage passed 2/2: after closing/reopening storage and remounting Agenetes, a foreign namespace was rejected, the other Workspace could not read the stored conversation, and the original owner recovered the original persisted spec. Disk also kept two Workspaces with the same Space/thread IDs separate. No bypass was reproduced through this path; an unspecified unpublished path remains unassessed. No additional production change was made for that summary.
Real Auth0/Entra sign-in, renewal and provider logout, paid model calls and packaged desktop execution were not exercised; provider transport in the session tests is a deterministic fixture.
Reviewed head:ec86ec2ba6fd7a6f836e15eb685ca6ef93d869a3 (base 14822a92c9739f08579ddf2426c56e84909b4ab1) Scope: full base/head diff, 169 files (about 3,650 production lines), reviewed from 10 angles plus a sweep pass. Verification limits: these are unverified candidate findings. No separate verification pass was run, no tests were run, and nothing was reproduced. Treat each item as a lead to confirm, not as a confirmed defect.
Authorization and isolation
Stale grants in long-running agent work: apps/server/src/modules/identity/personal-settings.ts:52. useCaller stores the request's identity.access in a slot that outlives the request (agent runs, and memory follow-ups via AsyncLocalStorage.snapshot). That access object keeps one memoized Bubble runtime.policy.scoped() (bubble.ts:408) for good. If an admin revokes a read grant or a manage role partway through a run, later tool calls (executor withReadCanvasId, fs_write on Workspace memory/Skills) still pass access.can. Bubble's contract says a scope lasts one operation.
Folder Workspace adoption by parent or child path: apps/server/src/modules/workspaces.route.ts:311. Authorization only covers an exactly registered directory, or a symlink alias whose manifest is registered. Another user can register /srv/ws or /srv/ws/a/<space-dir> as a new Workspace they own, which likely exposes the first user's Space files. Also, prepareWorkspacePath/ensureWorkspaceManifestOnDisk (lines 325-333) write into the folder before the alias authorization runs.
Revoked members' devices stay connected: apps/server/src/modules/identity/people.route.ts:56. setMember(false) or disabling an account does not disconnect agentlets. Admission is checked only at handshake (agent-credentials.ts:228), and the background branch of canUseAgentlet checks ownership only, so recovered workloads keep going to the revoked person's device with fresh rfs tokens.
Cross-namespace thread handle teardown: apps/server/src/modules/agent/agent-thread.service.ts:143. releaseForeignThreadHandle closes another namespace's live handle whenever the caller's namespace has a record with the same client-chosen threadId. Together with the process-global per-threadId turn lease, a user who knows a thread id can tear down or block another user's conversation. Namespace-scoped get/close callers (the permission route, space-move) also do not handle namespace_conflict, which gives 500 / MOVE_AGENT_CLOSE_FAILED.
Reachback credential not bound to its conversation: apps/server/src/modules/identity/http.ts:180. The conversation-scoped rfs token is matched on workspaceId/canvasId only, and claims.threadId is never compared with the route's :threadId. The device credential path (line 195) accepts any Space the owner can reach.
Signing key handed out in background contexts: apps/server/src/modules/identity/agent-credentials.ts:257. agentReachbackEnvironment can hand the instance HMAC signing key to an unowned agentlet. In background contexts, the only thing that stops this under Bubble is process.env.HUABU_IDENTITY_PROVIDER === 'bubble' (line 204), not the identity service the server actually runs with.
setMember race: apps/server/src/modules/identity/bubble.ts:124. It does list-then-add/remove without the keyed mutex that setWorkspaceRole uses. A concurrent revoke and admit can leave the person admitted while the revoke reports 204, and concurrent admits can create duplicate grants.
Correctness
Unreadable artifact source aborts the whole turn: apps/server/src/modules/artifact/utils.ts:68. assertArtifactSourceReadable throws outside the try, and attachments.ts (line 248, and the rethrow at line 111) propagates it. An image or file that points into a Space the runner cannot read fails the agent turn or preprocessing, where it should be skipped the way a missing blob is.
Failed sign-out drops every tab's session: apps/web/src/api/identity.ts:172. signOut calls forgetTokens(), which clears the shared revision and broadcasts forget, before endSession succeeds. If endSession fails, the UI says the session is still alive, but no tab holds a refresh token, so all tabs fall to the sign-in overlay when the access token lapses.
Preprocessing dedupe ignores whose settings run it: apps/server/src/modules/preprocessing/dispatcher.ts:111. dedupeKey includes the Workspace but not the personal-settings owner. One user's request can join another user's in-flight run and get that person's result or failure (for example, no model configured, or an offline Utility Agent device).
Efficiency
apps/server/src/modules/agent/acp/reachback-conversation.ts:18: isCurrentReachback does a repository get, an openWorkspace and a durable thread-record read on every rfs-token request and every 30 s stream revalidation, and the scoped route then opens the Workspace again.
apps/web/src/utils/oidcLogin.ts:276: providerMetadata re-fetches .well-known/openid-configuration on every token exchange, refresh and provider sign-out, with no caching.
Cleanup and design
apps/web/src/api/identity.ts:100: withSessionLock duplicates withRefreshLock in oidcLogin.ts:205 and differs only in the lock name.
apps/server/src/modules/security/space-access.ts:25: the invokesAgent route config is now dead, because its only reader was removed. The field and its use at interactive-view.route.ts:115 remain.
apps/server/src/modules/agent/llm.ts:646: personal-mode credential isolation is spread across personalSettingsOwner() special cases in llm.ts, ink-ocr-config.ts and pi-models.ts. A single personal-aware environment/credential accessor would enforce this in one place, so a missed process.env/getEnvApiKey read can't leak operator keys to members.
Authentication retest at head ec86ec2ba6fd7a6f836e15eb685ca6ef93d869a3 on 2026-11-07. Scope: Huabu's real browser → provider → Bubble identity → Huabu admission and renewal path, using an isolated Disk content store and PostgreSQL identity store. No tracked source changes.
Live Microsoft Entra ID checks passed with two disposable cloud users. Both completed MFA enrollment and authorization-code sign-in with PKCE, received refresh tokens, and resolved to distinct Bubble principals. The following behavior passed:
A new authenticated account has no Huabu access until granted membership (403 ACCESS_REQUIRED).
Members get separate personal Workspaces, can create/read their own Spaces, and cannot read another user's Space or activate their Workspace (404); People administration is denied to members.
Real refresh-token redemption and session replacement succeed for both accounts; concurrent renewal from two tabs succeeds.
Removing the Huabu cookie and reloading recovers the same principal through live renewal. This simulates cookie loss; it does not measure natural access-token expiry.
An Entra ID token is rejected as an API credential (401).
Revoking membership prevents access immediately and remains effective after successful token renewal.
Local sign-out clears the cookie and prevents a peer tab from renewing; provider sign-out visits Entra's real logout endpoint and clears the Huabu cookie.
Both disposable Entra users were deleted and verified absent from the live user directory. Passwords, MFA seeds, and browser session files were removed after testing.
Focused supporting checks passed: 57 browser auth tests, 79 server identity/multi-user tests, and 12 PostgreSQL identity/restart/multi-user tests. Each server profile excludes one intentional Disk-folder-only case on SQL storage. Server socket tests were rerun outside the sandbox after listen EPERM. An unrelated far-zoom subprocess timeout from the initial expanded web invocation passed when its 20-test file was rerun in isolation.
Auth0 remains pending configuration. The existing octostaff tenant's OIDC discovery succeeds and publishes a provider logout endpoint. Its recorded Starfish client rejects Huabu's http://localhost:5173/auth/callback with Callback URL mismatch; its client-credentials request for the Management API returns 403 access_denied because no client grant exists. The local Huabu environment has been prepared with the existing tenant/audience/claim mappings, but real Auth0 user creation, sign-in, rotation/renewal, and logout cannot yet be assessed without the intended SPA client and Management API credential. These are deployment/test prerequisites, not reproduced Huabu code regressions.
Verification limits: no natural 24-hour Entra SPA refresh-token expiry, real Auth0 lifecycle, packaged desktop, or paid model calls were exercised. No new Huabu auth regression was reproduced within the completed Entra checks.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because critical cross-Space metadata exposure and cross-Workspace persisted ownership risks were omitted from the published review.
Auth0 live-test follow-up at f40a6ff89897a7dc12e8487b215ceced7810e118 (2026-11-07). The live Auth0 checks passed.
The Bubble SPA already had Huabu's callback, web origin, and logout URLs configured. Huabu's ignored local .env held the Starfish client ID; replacing it with the Bubble SPA client ID resolved the authorization rejection. No tracked source files or tenant application settings changed.
With two disposable Auth0 database users, real browser PKCE, an isolated Huabu instance, and disposable PostgreSQL, verification passed for:
Both users signing in as distinct non-administrator principals, receiving refresh tokens, and being denied application access until granted membership.
Refresh-token rotation for both users, session renewal, and retention of the same principal.
Separate personal Workspaces and private Spaces: each user could read their own Space, could not discover or read the other's Space, could not activate the other's Workspace (404), and could not access administrator routes (403).
Concurrent renewal in two tabs and recovery after deliberately removing the Huabu session cookie.
Rejection of an Auth0 ID token as an API access token (401).
Membership revocation remaining effective after successful renewal: private application routes continued returning 403.
Local logout clearing the Huabu cookie and preventing a peer tab from renewing.
Provider logout visiting Auth0's OIDC logout endpoint, returning to Huabu with identity 401, and clearing local cookie and renewal state. A silent authorization probe recognized an authenticated provider session before logout (consent_required) and required login afterward (login_required), consistent with Auth0's documented silent-authentication responses.
The Management API client also worked: both users were created (201), read and matched (200), deleted (204), and verified absent (404). No verification email was requested. Earlier checks at this same head refused the Management API token as a Huabu identity/session (401, no session cookie) and passed 57 existing web auth tests plus 19 server identity tests.
The earlier Entra two-user live results also passed; Entra was not rerun during this Auth0 follow-up. Coverage limits: cookie recovery was induced by cookie removal, rather than waiting for natural expiry; long-duration access/refresh expiry and packaged desktop flows were not exercised.
Cleanup completed: both Auth0 users are absent, the isolated frontend/backend and PostgreSQL container stopped, and temporary passwords, tokens, browser states, and runtime data were removed. The existing workstation frontend remained available. The corrected SPA client ID remains in the local .env.
Three-user Auth0 QA completed with Member A, Member B, and a real federated administrator, using separate backend/browser subagents and an independent screenshot reviewer. One P1 and four P2 UI findings; no backend authorization defect reproduced.
S05 — P1: a ready peer tab retains A's data and writes under B after account change. Open A in two tabs; provider-logout A and sign B in from the main tab; foreground the old peer. Its application stores and private Home remain A while native identity/Workspace requests resolve B. New Space stays enabled and a physical click creates B's Space (POST 201; B reads 200; A reads 404), while A's cached state remains. A second agent reproduced this in a fresh browser context. Ready tabs do not run the foreground principal check; OIDC peer messages clear token memory without invalidating the ready application gate. Gate lifecycle. Backend authorization correctly follows B; the failure is UI account binding.
S03 — P2: editing affordances ignore viewer/editor permissions. B can type into a viewer Note preview and attempt an editor-only Space rename. Both denied writes return 403 and leave persisted data unchanged, but the attempted edits remain visible. The viewer error describes a locked/unwritable file and offers Retry. Title control, hardcoded editable preview. Independent backend probes confirmed content and metadata permission guards hold.
S02 — P2: Settings lets keyboard focus escape. Shift+Tab from the first dialog control reaches background Space Actions; Tab from the last control eventually reaches the background Application menu while aria-modal="true" remains. Independently reproduced. Modal lifecycle lacks Tab containment required by the WAI modal pattern.
S01 — P2: light-theme guidance contrast is too low. Access required, first Workspace setup, and missing/inaccessible Space guidance render small text as #767676 on #f5f5f5: measured 4.166:1, below the WCAG normal-text minimum. This finding covers the measured light-theme combinations. Color tokens.
S04 — P2: unconfigured Built-In Agent guidance incorrectly requires an external Agent. A newly admitted user with no personal model or external Profiles sees “Connect an external Agent before starting a new conversation.” Guidance should offer Built-In Pi's personal provider/key setup, which is the documented new-user default. Conversation selection. No provider invocation occurred; this finding covers first-run guidance.
Verification: 10,029 workspace tests passed, plus 518 PostgreSQL/Azurite storage checks. Lint (390 existing warnings), formatting, typecheck, i18n, Agent Team tests/validation, license headers, and the complete CI-configured build passed. Exact host reruns resolved sandboxed subprocess denials/timeouts without changing assertions. All 31 unique screenshots were inspected; the UI run had zero uncaught application page errors.
Live stories covered admission/admin boundaries; private Spaces/search/settings/keys; viewer/editor/revocation/CAS; real Gateway device/Profile ownership and credential reset/reclaim; two backend restarts; browser editing/reload/preferences; session rotation, concurrent-tab renewal, cookie-loss recovery, same-account reauthentication, account changes, and local/provider logout. A targeted SSE revocation probe closed 29,992.615 ms after the completed 204 response (30,006.397 ms from request start, including request processing), and immediate revoked access returned 403. Fresh federated-admin fixtures were corrected to sign in first, then grant system ownership through the documented grant endpoint.
Coverage limits: Entra was not rerun in this Auth0 three-user audit; earlier two-user Entra evidence remains separate. Paid/external Agent turns, configured Built-In turns, populated device/Profile UI cards, natural long-duration expiry, packaged desktop, and seeded queued-unsaved-write replay were not exercised. Default suite skips/todos are recorded in the checklist.
Cleanup complete: all three temporary Auth0 users deleted and verified absent; owned browsers, servers, containers, private runtime data and temporary credentials removed; the user's existing local frontend preserved. No application fixes were made. The evidence commit contains the requested checklist and credential-free screenshots.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Cross-user authorization and credential delegation span multiple layers, while real Auth0/Entra sign-in, renewal, and logout remain untested, requiring human security review and deployment validation.
No open issues 🎉
Review effort: Balanced
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
Breaking change outside Bubble
Local mode on SQLite or Postgres storage refuses Server folder paths. These requests now return
409 STORAGE_CAPABILITY_UNAVAILABLE:POST /api/workspaceswith apath. Before,{name, path}created a Workspace by name and ignored the path.POST /api/workspace/validate-path, which used to answer whether a path exists.pick-folderandPUT /api/workspace.The web app and desktop never send these on SQL storage, so this affects only scripts that call the API directly. Disk storage is unchanged.
Everything else in local mode and on desktop works as on main, and tests cover it:
$AGENTLET_TOKENor$HUABU_RFS_TOKEN.#356 gave Bubble users their own Workspaces. This PR gives every admitted user, including administrators, personal settings and private agent connections. Users configure their own Built-In model credentials or connect their own external devices and Agent Profiles. One user's settings, credentials and devices are never another user's defaults or shared execution resources.
Changes
fetchbehind the public-network guard, so multipart image edits work.HUABU_RFS_TOKENcallbacks. Inject callback credentials at runtime, keep them out of persisted workloads, and recheck the device owner's admission and permissions on the named Workspace and Space. A callback is accepted only while its conversation is still an external agent's in that Space on a device its owner owns. Reject unowned legacy workloads during background recovery.capabilities.folderWorkspaces), and Workspace paths are shown. Disk is a single-machine and debugging setup, so there is no further guard. Postgres/Azure and SQLite storage refuse folder paths with409 STORAGE_CAPABILITY_UNAVAILABLE. Native folder dialogs, path probing and moving the process-wide Workspace stay with local and administrator callers on localhost.sessionStorage; share the latest token overBroadcastChanneland serialize redemption with a Web Lock. A shared non-secret revision detects missed rotations after reload: recover the current token from a peer or require sign-in when no peer can supply it. When the revision cannot be written, renewal stays in the original document and the token is not shared or reused after reload. Initial identity or sign-in discovery failures keep the app closed and retry until the caller is established. Discard refresh results after sign-out, retain the provider for logout and through a failed sign-out, report popup failures, and retry retained saves through the existing save drain. While the Server is briefly unreachable, the sign-in cover rechecks with bounded backoff instead of waiting for a reload. A refused account ends the provider session on sign-out only whenHUABU_SIGN_OUT_PROVIDERis set.Upgrade behavior
Bubble mode does not attach a shared server-local Agentlet or assign existing deployment settings and Profiles to an administrator. Existing users, including administrators, configure their own settings and reconnect devices using their personal connection command. Device and callback credentials issued by earlier builds of this branch are not accepted; copy a new connection command. Legacy Profiles remain available in local mode; Bubble uses a separate private-user Profile registry. Local mode keeps its implicit single user's existing behavior. Database, identity-provider configuration, network binding and the private instance signing key remain server infrastructure.
Bubble deployments (experimental)
Nothing below is migrated:
OPENAI_*,ANTHROPIC_*,AZURE_OPENAI_*,TAVILY_API_KEY,RAPIDAPI_KEY,VISION_*) no longer apply to any user, administrators included. The same goes for settings saved on the Server:llm-config.json, OAuth tokens, integration and OCR keys, Agent defaults and review preferences.HUABU_CONNECTION_TOKENis no longer accepted over HTTP or RFS. It stays the signing key, so rotating it revokes every user's device and reachback credentials.--agentlet-idvalues.user-agent-profiles/.$HUABU_RFS_TOKEN.$AGENTLET_TOKEN, with the owner's own Workspace and Space access, so existing skills and scripts keep working.offline_access.HUABU_SIGN_OUT_PROVIDER=truerequires<origin>/as a post-logout redirect URI./api/people/*,POST /api/acp/connection-credentials/resetandPOST /api/people/:id/agent-credentials/reset.agentlet-owners.json,agent-credential-versions.jsonandpeople/<hash>/….?canvasId=.Deferred
Review follow-up
The re-review findings are fixed in
98d644e27…eb3f628f3, three open Copilot threads in5cad0f548and057092889, Copilot's follow-up on Stop (an administrator naming a Space only in the body) inac72a1c52, and Codex's credential-reset findings inb1a3e7bd3, with Copilot's follow-up on open RFS streams after a reset in94f25c64f; the follow-up comment maps each one to its fix and regression test. Three of them were local-mode regressions against main:source.Validation
ec86ec2ba: 10,029 passed, including 3,227 server, 5,275 web and 814 shared tests; existing skips and todos remain. Full root checks and build passed locally.ec86ec2ba: 7/7 passed. Actual Chromium quota exhaustion permits renewal only in the original document; peer and reloaded documents refuse reuse. Focused session/identity tests passed 56/56 and personal-settings tests passed 9/9.eb3f628f3: every CI step passed locally. That covers lint (0 errors), format, typecheck, every unit suite (3,220 server, 5,249 web, 198 agenetes, 128 client-runtime), the storage contracts (518 passed, 1 Disk-only scenario skipped on Postgres) with Testcontainers Postgres/Azurite, i18n, Agent Team skills, license headers and the full build. The Playwright member-settings spec passed 3/3.Paid external model providers and real Auth0/Entra sign-in, renewal and provider logout were not exercised; deterministic tests fake provider transport.
🤖 Generated with Claude Code