Skip to content

feat: members use the Built-In agent with their own keys; People admin; session renewal - #357

Open
Yuge Zhang (ultmaster) wants to merge 17 commits into
microsoft:mainfrom
ultmaster:feat/228-member-agents
Open

Yuge Zhang (ultmaster) wants to merge 17 commits into
microsoft:mainfrom
ultmaster:feat/228-member-agents

Conversation

@ultmaster

Copy link
Copy Markdown
Collaborator

The second of two stacked PRs toward #228. It is stacked on #356, so its branch includes #356's nine commits; review from 53058b72 onward. After #356 merges this diff shrinks to the eight commits below. The plan for both is in docs/proposals/multi-user-service.md.

#356 gave each person their own Workspaces. With this PR members can also use AI, configure their own keys and stay signed in, and an administrator can manage people from the UI. With both merged, a Bubble deployment is ready for several people to use day to day.

Nothing changes unless Bubble is enabled. Local mode, administrators and the machine credential never open a personal settings scope, so they read the deployment's settings exactly as before. The new web paths are gated on Bubble identity.

What members get

  • The Built-In agent and its conversations, in any Space they may use, running on their own models and keys.
  • Their own settings. Their model keys, OAuth logins and connected-service keys are theirs alone. They never fall back to the deployment's environment variables, which belong to the operator. The member's Settings shows only Built-In setup and services.
  • Session renewal without leaving the page. The session renews before it expires. If it can't, a popup sign-in appears over the running app, and the refused saves are sent again.

Administrators get a People tab, backed by /api/people: admit or revoke a member, and give or remove a Workspace role. A person appears there after their first sign-in.

Why external agents stay with administrators

The proposal's first design gave each external agent run its own RFS token. Two things rule that out:

  • Every agent process already holds the daemon's own machine token. Agentlet hands it out as AGENTLET_TOKEN by design, and refuses any session that tries to set that variable. A member's external agent would hold an unrestricted credential next to any run token, and a prompt-injected agent could simply use it.
  • External agents run on the operator's machines, under the operator's own CLI logins. So they can't use per-person keys either.

The Built-In agent runs in-process and never calls RFS. Its tools run in the request's Workspace and caller scope, and now check the person who started the run:

  • A World conversation reads another Space only if that person may read it.
  • Workspace memory and Skills change only with huabu.manage on the Workspace, because they steer every agent there.
  • The Task Run tools are refused, because Task Runs launch external agents through the machine credential.

/api/acp, RFS, change-review configuration, Interactive View actions and any external binding on /api/agent stay administrator-only. The one exception is GET /api/acp/profiles, which lists only Built-In for members so the web app can start a conversation.

Design

  • Caller scope (modules/identity/personal-settings.ts). A second per-request AsyncLocalStorage slot, beside feat: request-scoped Workspaces — each person works in their own #356's Workspace scope, filled at admission. For a member it holds their principal (their settings namespace) and their access. Agent runs started by the request inherit it.
  • Personal settings.
    • The secret store maps a member's ids to person:<key>:<id>, with no environment fallback. The deployment's connection token and access password are never namespaced.
    • Model settings (llm.ts, with its model caches now kept per namespace) and agent defaults live under <data>/people/<key>/.
    • A member's default agent is always Built-In.
    • canChangeOwnSettings replaces the owner check on the model, agent-defaults, integrations, services and Ink OCR settings routes.
  • People. GET /api/people, PUT /api/people/:id/member, PUT /api/people/:id/workspaces/:workspaceId. All are administrator-only, and an administrator can't revoke their own access. The contract is in packages/shared/src/types/api/identity.ts.
  • Session lifecycle (utils/oidcLogin.ts, SignInGate.tsx).
    • Sign-in asks for offline_access, and the refresh token is kept in page memory only.
    • The session renews a minute before the access token expires, and on any 401. Failing that, the sign-in screen opens over the app and signs in through a popup. The popup posts the code back to the page, which redeems it with a verifier that never left its memory.
    • With HUABU_SIGN_OUT_PROVIDER=true, signing out also goes to the provider's end_session_endpoint.

Proof

Production-like run: the dev Server in Bubble mode against a throwaway Postgres 18, holding both Huabu's content and Bubble's records. Carol and Dave are bot principals with Bubble local tokens, each made a member. Their model settings point at a local OpenAI-compatible mock that records the bearer key of every request.

Check Result
Carol and Dave PUT /api/llm/config (OpenRouter openai/gpt-4o-mini, base URL = mock, each with their own key) 200 each. Separate people/<key>/ directories. The deployment's GET /api/llm/config stays empty.
Carol POST /api/agent in her Space Streams "Answered with carol-own-key."; every mock request carried carol-own-key
Dave, same in his Space "Answered with dave-own-key."; only dave-own-key reached the mock
Dave POST /api/agent in Carol's Space 404
Dave with an external agentBinding 403 "External agents are available to administrators only"
Dave GET /api/acp/profiles 200, Built-In only (it was 403 before 2dd1da32, which left members unable to start a conversation)
Browser, Carol: Agent node → conversation Composer shows Built-In with "OpenAI: GPT-4o-mini". The reply "Answered with carol-own-key." is rendered, and the title came from her model too.
Browser, Carol: Settings Agent tab shows only her chat model (provider, base URL, saved key, default model), plus Services and General
Browser, admin: Settings → People Lists admin (Administrator), Carol (2 Workspaces, Owner) and Dave (1, Owner)
Browser, admin adds Dave to Carol's Workspace Dave gets the Viewer row; Dave's GET /api/workspaces now includes "Carol's Workspace"

Automated:

  • Multi-user scenarios (modules/identity/multi-user.*, Disk and Postgres): admit, revoke and give Workspace roles end to end. Covers 403 for non-administrators, 404 for an unknown person, 409 on self-revoke, and that an editor role lets someone into another Workspace and its removal takes them out.
  • personal-settings.test.ts: per-person secrets, no environment fallback for members, and the deployment's own credentials never namespaced. Also per-person settings paths, and Built-In as a member's default.
  • space-access.test.ts: members' Built-In conversations go through readable Spaces only; a thread needs its Space named; the external-agent, RFS and Interactive View routes stay with administrators.
  • SignInGate.test.tsx: a lapse keeps the app mounted under the sign-in screen; a held refresh token renews in place and resends the refused saves.
  • oidcLogin.test.ts: the popup code is accepted only from this origin and this login's state, then redeemed and renewed.

Limitations

  • External agents are administrator-only. Opening them to members needs a per-run credential, and Agentlet would have to stop giving agent processes the daemon token.
  • Some member features stay off. Memory updates and LLM enrichment of preprocessing don't run for members' edits, as in feat: per-user Workspace and Space access through Bubble #237. Interactive View actions remain administrator-only.
  • Session renewal and provider sign-out were not tried against real Auth0 or Entra. There were no provider credentials on this machine; only the unit tests above cover them. Entra SPA refresh tokens last about 24 hours, so a longer gap goes through the popup.
  • Refresh tokens don't survive a reload. One that hasn't renewed yet is gone after a page reload, and the next lapse goes through the popup, which is usually instant while the provider session lives.
  • A blocked popup shows an error. The person has to allow popups; the overlay offers no redirect fallback, which would lose unsaved edits.
  • One person's tabs share one current Workspace, as in feat: request-scoped Workspaces — each person works in their own #356.

Validation (on a1964258)

  • pnpm typecheck: pass
  • pnpm format: applied
  • pnpm lint:fix: 0 errors. The new warnings are only test-file non-null assertions, in the existing test style.
  • Server vitest run: 244 files, 3054 passed, 28 skipped
  • pnpm test:storage, with Postgres and Azurite containers: 17 files, 510 passed, including the Postgres multi-user scenarios
  • Web vitest run: 358 files, 4759 passed

🤖 Generated with Claude Code

Yuge Zhang (ultmaster) and others added 17 commits October 28, 2026 17:26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request or background job can now carry the Workspace it runs in
through AsyncLocalStorage. The Workspace getters, the SQL stores, the
Disk folder index and the SQL World id read that scope before the
process-active Workspace, and operation leases count per Workspace.
Local mode never opens a scope, so it behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bubble mode selects each admitted request's Workspace from the
caller's stored current Workspace, creating a personal one for a new
member. Roles become Workspace owner/editor/viewer plus Space owner,
with read/write/manage flowing down Workspace -> Space -> child Space
and huabu.admin on the system. Space grants name their Workspace
(<workspaceId>:<canvasId>); microsoft#237's grants are rewritten at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Routes can declare spaceAccess: 'manage'; the Space delete, delete-tree
and rename routes now do, so a Workspace editor can change Spaces but
not remove them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orkspaces

In Bubble mode the Workspace routes act for the caller alone: they list
the Workspaces the caller may enter, create one by name (on Disk, a
folder under the data directory) owned by its creator, switch only the
caller's current Workspace, and rename or unregister by huabu.manage.
Folder picking and paths stay with administrators. GET /api/workspace
reports namedWorkspaces so the web app can offer a name-based switcher.

A Disk and a Postgres suite run two members and an administrator
through the real routes at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent's reachback URL now names its Workspace
(/api/rfs/w/<workspaceId>/<canvasId>), and the RFS routes are also
served under that prefix in that Workspace. A run no longer follows its
starter's later switches or the process-active Workspace. The old
unscoped URL keeps working for runs started before the upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the Server reports namedWorkspaces, boot keeps the Workspace the
Server chose for the caller instead of activating the first registered
one, the setup page lists the caller's Workspaces and creates one by
name instead of picking a folder, and the header calls it a Workspace.
Local mode renders as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fold PR A into the architecture docs: the Workspace/Space role model,
Workspace-qualified Space grants and their migration, per-person
current Workspaces, the Workspace routes for members, the per-call
storage scope and the Workspace-scoped RFS URL. The proposal records
what PR A built and what it deliberately left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

8 open issues
What changed in this PR

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.
apps/​web/​src/​store/​workspaceStore.test.ts Tests server-selected and named Workspaces.
apps/​web/​src/​store/​settingsUiStore.ts Adds the People tab identifier.
apps/​web/​src/​store/​canvasStore.ts Adds post-renewal save recovery.
apps/​web/​src/​pages/​WorkspaceSetupPage.tsx Adds named-Workspace setup UI.
apps/​web/​src/​pages/​CanvasListPage.tsx Updates Workspace labels.
apps/​web/​src/​i18n/​resources/​zh-CN/​common.json Adds Chinese Workspace and People strings.
apps/​web/​src/​i18n/​resources/​en/​common.json Adds English Workspace and People strings.
apps/​web/​src/​components/​Shell/​SignInGate.tsx Adds renewal and in-place reauthentication.
apps/​web/​src/​components/​Shell/​SignInGate.test.tsx Tests renewal and preservation of the mounted app.
apps/​web/​src/​components/​Settings/​SettingsModal.tsx Adapts settings to member and administrator roles.
apps/​web/​src/​components/​Settings/​sections/​PeopleSettings.tsx Adds membership and Workspace-role controls.
apps/​web/​src/​components/​Panels/​Header/​AppMenu.tsx Connects provider logout and Workspace labels.
apps/​web/​src/​api/​workspace.ts Adds named-Workspace creation.
apps/​web/​src/​api/​people.ts Adds People API helpers.
apps/​web/​src/​api/​identity.ts Adds optional provider logout.
apps/​web/​src/​api/​_routes.ts Defines People endpoint paths.
apps/​server/​src/​security/​secret-store.ts Adds personal secret namespaces without environment fallback.
apps/​server/​src/​modules/​workspaces.route.ts Adds caller-specific Workspace management.
apps/​server/​src/​modules/​workspace/​scoped-routes.ts Executes reachback routes in a named Workspace.
apps/​server/​src/​modules/​workspace/​scoped-routes.test.ts Tests scoped routes and reachback URLs.
apps/​server/​src/​modules/​workspace/​context.ts Defines asynchronous Workspace scope.
apps/​server/​src/​modules/​workspace.ts Makes getters and leases Workspace-aware.
apps/​server/​src/​modules/​workspace.route.ts Reports caller-specific Workspace capabilities.
apps/​server/​src/​modules/​workspace.route.test.ts Updates access-contract fixtures.
apps/​server/​src/​modules/​workspace-activation.ts Creates Workspaces without process-wide activation.
apps/​server/​src/​modules/​storage/​storage.ts Opens scoped Workspaces and caches World IDs.
apps/​server/​src/​modules/​storage/​index.ts Exports scoped Workspace opening.
apps/​server/​src/​modules/​storage/​backends/​sqlite/​database.ts Resolves SQLite Workspace scope per call.
apps/​server/​src/​modules/​storage/​backends/​postgres/​database.ts Resolves PostgreSQL Workspace scope per call.
apps/​server/​src/​modules/​storage/​backends/​disk/​canvas-dirs.ts Separates directory indexes by Workspace.
apps/​server/​src/​modules/​services/​services.route.ts Permits personal service configuration.
apps/​server/​src/​modules/​services/​services.route.test.ts Updates settings-authorization mocks.
apps/​server/​src/​modules/​security/​space-access.ts Updates member-agent admission and manage checks.
apps/​server/​src/​modules/​security/​space-access.test.ts Tests member-agent and resource boundaries.
apps/​server/​src/​modules/​security/​owner.ts Adds personal-settings authorization.
apps/​server/​src/​modules/​remote_fs/​public-skill.ts Recognizes scoped bootstrap URLs.
apps/​server/​src/​modules/​integrations/​integrations.route.ts Permits personal integration-key updates.
apps/​server/​src/​modules/​integrations/​ink-ocr-config.route.ts Opens member access to OCR configuration.
apps/​server/​src/​modules/​identity/​workspace-selection.ts Selects and provisions personal Workspaces.
apps/​server/​src/​modules/​identity/​service.ts Extends access and People interfaces.
apps/​server/​src/​modules/​identity/​resources.ts Resolves resources across scoped Workspaces.
apps/​server/​src/​modules/​identity/​request.ts Stores request access and Workspace-selection callbacks.
apps/​server/​src/​modules/​identity/​preferences.ts Persists current Workspace per principal.
apps/​server/​src/​modules/​identity/​personal-settings.ts Defines caller and personal-settings scope.
apps/​server/​src/​modules/​identity/​personal-settings.test.ts Tests personal secrets, paths, and defaults.
apps/​server/​src/​modules/​identity/​people.route.ts Adds administrator-only People endpoints.
apps/​server/​src/​modules/​identity/​multi-user.test.ts Runs multi-user scenarios on Disk storage.
apps/​server/​src/​modules/​identity/​multi-user.scenarios.ts Covers isolation, switching, membership, and roles.
apps/​server/​src/​modules/​identity/​multi-user.remote.test.ts Runs multi-user scenarios on PostgreSQL.
apps/​server/​src/​modules/​identity/​index.ts Wires preferences and People services.
apps/​server/​src/​modules/​identity/​http.ts Establishes caller scopes during admission.
apps/​server/​src/​modules/​identity/​config.ts Reads provider-logout configuration.
apps/​server/​src/​modules/​identity/​bubble.ts Implements roles, grant migration, and People storage.
apps/​server/​src/​modules/​canvas/​space-hierarchy.test.ts Updates resource-access fixtures.
apps/​server/​src/​modules/​canvas/​space-hierarchy.route.ts Requires manage access for subtree deletion.
apps/​server/​src/​modules/​canvas/​space-card.route.ts Requires manage access for Space renaming.
apps/​server/​src/​modules/​canvas/​preprocess-access.route.test.ts Updates member-access fixtures.
apps/​server/​src/​modules/​canvas/​member-spaces.route.test.ts Updates member resource-access contracts.
apps/​server/​src/​modules/​canvas/​canvas.route.ts Requires manage access for Space deletion.
apps/​server/​src/​modules/​artifact/​send-blob.test.ts Updates restricted-access fixtures.
apps/​server/​src/​modules/​agent/​tools/​executor.ts Adds caller-aware tool restrictions.
apps/​server/​src/​modules/​agent/​memory/​op-counter-hook.test.ts Updates member-access fixtures.
apps/​server/​src/​modules/​agent/​llm.ts Adds namespaces for model configuration and runtime state.
apps/​server/​src/​modules/​agent/​llm.route.ts Permits members’ model and OAuth configuration.
apps/​server/​src/​modules/​agent/​agent.route.ts Rejects members’ external-agent bindings when starting turns.
apps/​server/​src/​modules/​agent/​agent-defaults.ts Scopes defaults and selects Built-In for members.
apps/​server/​src/​modules/​agent/​agent-defaults.route.ts Permits personal agent-default settings.
apps/​server/​src/​modules/​agent/​acp/​reachback-env.ts Includes Workspace identity in reachback URLs.
apps/​server/​src/​modules/​agent/​acp/​profiles.route.ts Returns a Built-In-only listing for members.
apps/​server/​src/​app.ts Mounts People and scoped reachback routes.

Review effort: Balanced

'/config',
async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
'/api/agent-change-review',
'/api/rfs',
];
const ADMIN_AGENT_ROUTES = ['/api/acp', '/api/agent-change-review', '/api/rfs'];
Reply: ApiResult<OAuthDeviceCodeResponse>;
}>('/oauth/device-code', async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
*/
let cachedUtilityModel: Model<Api> | null = null;
/** One runtime state per settings namespace: the deployment's, or a member's. */
const runtimeStates = new Map<string, RuntimeState>();
Comment on lines +96 to +103
forgetTokens();
// The provider's own session would otherwise sign the next person straight
// back in as this one, which matters on a shared computer.
if (options.providerSignOut) {
const authenticators = (await listInteractiveAuthenticators()) ?? [];
const authenticator =
heldAuthenticator() ??
(authenticators.length === 1 ? authenticators[0] : null);
Comment on lines +1144 to +1145
if (structureSaveErrors.has(canvasId)) structureScheduler.schedule();
await drainPendingSaves();
Comment on lines +264 to +267
return await requestTokens(current.authenticator, {
grant_type: 'refresh_token',
refresh_token: current.refreshToken,
});
Comment on lines +193 to +197
async (authenticator: InteractiveAuthenticator) => {
await adopt(await signInWithPopup(authenticator));
await check();
await resend();
},

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants