Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5044806
docs: propose the multi-user service in two stacked PRs
ultmaster Oct 28, 2026
6094f74
feat(workspace): run each call in its own Workspace scope
ultmaster Oct 28, 2026
adc4fc0
feat(identity): per-person current Workspace and a new role model
ultmaster Oct 28, 2026
2c15df6
feat(security): require manage to rename or delete a Space
ultmaster Oct 28, 2026
a975966
feat(workspaces): let each person list, create and switch their own W…
ultmaster Oct 28, 2026
b1b527e
feat(rfs): keep an agent run in the Workspace it started in
ultmaster Oct 28, 2026
4a58f0c
feat(web): switch and create named Workspaces
ultmaster Oct 28, 2026
6a79a20
chore: tidy imports and a test type after repository checks
ultmaster Oct 28, 2026
0116f51
docs: describe per-person Workspaces and the new roles
ultmaster Oct 28, 2026
53058b7
feat(identity): administer people through /api/people
ultmaster Oct 28, 2026
cbe0a44
feat(identity): give each member their own settings, keys and credent…
ultmaster Oct 28, 2026
2482eaf
feat(agent): let members run the Built-In agent in their own access
ultmaster Oct 28, 2026
8a8d7b8
feat(web): renew a lapsing session without leaving the page
ultmaster Oct 28, 2026
af08032
feat(web): People settings for administrators, own settings for members
ultmaster Oct 28, 2026
2dd1da3
fix(agent): offer members the Built-In agent when they start a conver…
ultmaster Oct 28, 2026
c97a2bf
chore(identity): narrow instead of asserting non-null
ultmaster Oct 28, 2026
a196425
docs: describe member agents, personal settings, People and session r…
ultmaster Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { resetExternalNoteSessions } from './modules/canvas/external-watcher.js'
import externalNoteRoutes from './modules/canvas/external.route.js';
import syncRoutes from './modules/canvas/sync.route.js';
import { createIdentity, registerIdentity } from './modules/identity/index.js';
import peopleRoutes from './modules/identity/people.route.js';
import integrationsRoutes from './modules/integrations/integrations.route.js';
import interactiveViewRoutes from './modules/interactive-view/interactive-view.route.js';
import rfsRoutes from './modules/remote_fs/rfs.route.js';
Expand All @@ -59,6 +60,7 @@ import { registerSpaceAccess } from './modules/security/space-access.js';
import serviceRoutes from './modules/services/services.route.js';
import { closeStorage } from './modules/storage/index.js';
import webRoutes from './modules/web/web.route.js';
import { workspaceScopedRoutes } from './modules/workspace/scoped-routes.js';
import { registerWorkspaceRequiredGuard } from './modules/workspace-required.js';
import { initWorkspaceFromEnv } from './modules/workspace.js';
import workspaceRoutes from './modules/workspace.route.js';
Expand Down Expand Up @@ -144,6 +146,10 @@ registerIdentity(app, {
// An embedded Bubble serves its own API beside Huabu's, behind the same host,
// origin and rate-limit guards; clients address it as `<origin>/bubble`.
if (identity.bubble) app.register(identity.bubble, { prefix: '/bubble' });
app.register(peopleRoutes, {
prefix: '/api/people',
service: identity.service,
});

// Register @fastify/static to enable `reply.sendFile()`.
// Actual artifact serving uses a dynamic root resolved at request time
Expand Down Expand Up @@ -183,6 +189,10 @@ app.register(skillsRoutes, { prefix: '/api/skills' });
app.register(workspaceRoutes, { prefix: '/api/workspace' });
app.register(workspacesRoutes, { prefix: '/api/workspaces' });
app.register(rfsRoutes, { prefix: '/api/rfs' });
// An agent's reachback names the Workspace its run started in.
app.register(workspaceScopedRoutes(rfsRoutes), {
prefix: '/api/rfs/w/:workspaceId',
});

// ── External agent (ACP) transport host ───────────────────────────────
// Mount the Agenetes agentlet transport host (`@agenetes/agentlet-host`).
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/modules/agent/acp/profiles.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from '@agenetes/agentlet-host';

import {
HUABU_AGENT_PROFILE_ID,
agentProfileParamsSchema,
createAcpProfileBodySchema,
patchAgentProfileBodySchema,
Expand All @@ -52,6 +53,7 @@ import {
readProfileCapabilities,
} from './profile-capabilities.js';
import { invalidateProfileSchemaCache } from './profile-schema-cache.js';
import { personalSettingsOwner } from '../../identity/personal-settings.js';
import { isOwnerRequest } from '../../security/owner.js';
import {
getAgentDefaults,
Expand Down Expand Up @@ -220,6 +222,17 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Reply: ApiResult<AcpProfilesListResponse> }>(
'/profiles',
async (request, reply) => {
// A member runs the Built-In agent only: no external profile, device or
// daemon of the operator's is theirs to see.
if (personalSettingsOwner())
return {
profiles: [],
selectableProfileIds: [HUABU_AGENT_PROFILE_ID],
connectedDevices: [],
folderPickerAgentletId: null,
agentlet: getDaemonSupervisor().getStatus(),
agentDefaults: getAgentDefaults(),
};
if (denyRemote(request, reply)) return;
const registry = getAgentProfileRegistry();
if (!registry) {
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/modules/agent/acp/reachback-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* host over the canvas-scoped Remote File System (RFS) and attribute its
* change-review activity to the right conversation:
*
* - `HUABU_RFS_URL` — `<publicOrigin>/api/rfs/<canvasId>`,
* - `HUABU_RFS_URL` — `<publicOrigin>/api/rfs/w/<workspaceId>/<canvasId>`,
* the canvas-scoped RFS base (capabilities / query /
* download / upload / execute / agent / skill). Bakes the canvasId into the path
* so the agent needs no separate canvas variable.
Expand All @@ -27,6 +27,7 @@

import { getHostServerPort } from '../../../host-port.js';
import { resolveConfiguredPublicOrigin } from '../../security/public-origin.js';
import { getWorkspaceHandle } from '../../workspace.js';

/**
* Build the reachback env for an agent serving `threadId` in `canvasId`.
Expand All @@ -45,8 +46,11 @@ export function buildReachbackEnv(
if (port > 0) origin = `http://127.0.0.1:${port}`;
}
if (origin) {
// Named so the run keeps its Workspace after its starter switches.
const workspaceId = getWorkspaceHandle()?.workspaceId;
const scope = workspaceId ? `w/${encodeURIComponent(workspaceId)}/` : '';
env.HUABU_RFS_URL = new URL(
`/api/rfs/${encodeURIComponent(canvasId)}`,
`/api/rfs/${scope}${encodeURIComponent(canvasId)}`,
origin,
).toString();
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/modules/agent/agent-defaults.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {

import { getProfileSchemaCache } from './acp/profile-schema-cache.js';
import { getAgentDefaults, setAgentDefaults } from './agent-defaults.js';
import { isOwnerRequest } from '../security/owner.js';
import { canChangeOwnSettings } from '../security/owner.js';

import type {
AgentDefaults,
Expand Down Expand Up @@ -75,7 +75,7 @@ function projectDefaults(defaults: AgentDefaults): AgentDefaultsResponse {

const agentDefaultsRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message:
'Forbidden: Utility Agent settings require owner authorization',
Expand Down
22 changes: 19 additions & 3 deletions apps/server/src/modules/agent/agent-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { agentDefaultsSchema } from '@huabu/shared';

import { getDataDir } from '../../data-dir.js';
import { atomicWriteJson } from '../../utils/fs.js';
import {
personalSettingsOwner,
settingsFilePath,
} from '../identity/personal-settings.js';

import type { AgentDefaults, AgentProfileView } from '@huabu/shared';

Expand All @@ -35,7 +39,9 @@ interface AgentDefaultsStorage {
}

function configPath(): string {
return join(getDataDir(), 'agent-defaults.json');
return personalSettingsOwner()
? settingsFilePath('agent-defaults.json')
: join(getDataDir(), 'agent-defaults.json');
}

const diskStorage: AgentDefaultsStorage = {
Expand Down Expand Up @@ -125,9 +131,19 @@ export class AgentDefaultsService {
}

const service = new AgentDefaultsService();
export const getAgentDefaults = (): AgentDefaults => service.getAgentDefaults();
/**
* A member's own defaults always pick the Built-In agent: external agents run
* on the operator's machines under the operator's logins, so they stay the
* administrators'.
*/
export const getAgentDefaults = (): AgentDefaults => {
const defaults = service.getAgentDefaults();
return personalSettingsOwner()
? { ...defaults, profileId: 'huabu' }
: defaults;
};
export const requireDefaultAgentProfileId = (): string =>
service.requireDefaultAgentProfileId();
personalSettingsOwner() ? 'huabu' : service.requireDefaultAgentProfileId();
export const setAgentDefaults = (config: AgentDefaults): AgentDefaults =>
service.setAgentDefaults(config);
export const initializeAgentDefaults = (
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/modules/agent/agent.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
import { buildHistoryFromTurns } from '../agent/conversation/transcript/history.js';
import { getLLMModel } from '../agent/llm.js';
import { hydrateCanvasNodes } from '../canvas/canvas-executor.js';
import { personalSettingsOwner } from '../identity/personal-settings.js';
import { space } from '../storage/index.js';
import { canvasAcpNamespace } from '../workspace/paths.js';

Expand Down Expand Up @@ -831,6 +832,14 @@ const agentRoutes: FastifyPluginAsync = async (
agentTarget,
fixedTarget,
});
// External agents run on the operator's machines under the operator's
// logins and reach every Space through the shared machine credential, so
// a member runs the Built-In agent, with their own model keys.
if (effectiveBinding?.kind === 'external' && personalSettingsOwner())
return reply.code(403).send({
message: 'External agents are available to administrators only',
code: 'AGENT_ACCESS_REQUIRED',
});
if (effectiveBinding?.kind === 'external') {
request.log.info(
{
Expand Down
14 changes: 7 additions & 7 deletions apps/server/src/modules/agent/llm.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
verifyOAuthCredentials,
} from './oauth.js';
import { getRootErrorMessage } from '../../utils/error-message.js';
import { isOwnerRequest } from '../security/owner.js';
import { canChangeOwnSettings } from '../security/owner.js';

import type {
ApiResult,
Expand Down Expand Up @@ -59,7 +59,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
app.put<{ Body: LLMConfigUpdate; Reply: ApiResult<LLMConfig> }>(
'/config',
async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: LLM settings can only be changed from localhost',
});
Expand All @@ -86,7 +86,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
app.put<{ Body: LLMImageConfigUpdate; Reply: ApiResult<LLMImageConfig> }>(
'/image-config',
async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: LLM settings can only be changed from localhost',
});
Expand Down Expand Up @@ -116,7 +116,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
app.put<{ Body: LLMUtilityConfigUpdate; Reply: ApiResult<LLMUtilityConfig> }>(
'/utility-config',
async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({
message: 'Forbidden: LLM settings can only be changed from localhost',
});
Expand Down Expand Up @@ -165,7 +165,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
Body: OAuthProviderBody;
Reply: ApiResult<OAuthDeviceCodeResponse>;
}>('/oauth/device-code', async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({ message: 'Forbidden' });
}

Expand All @@ -191,7 +191,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
Body: OAuthProviderBody;
Reply: ApiResult<OAuthPollResponse>;
}>('/oauth/poll', async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({ message: 'Forbidden' });
}

Expand Down Expand Up @@ -234,7 +234,7 @@ const llmRoutes: FastifyPluginAsync = async (app) => {
Body: OAuthProviderBody;
Reply: ApiResult<OAuthLogoutResponse>;
}>('/oauth/logout', async (request, reply) => {
if (!isOwnerRequest(request)) {
if (!canChangeOwnSettings(request)) {
return reply.status(403).send({ message: 'Forbidden' });
}

Expand Down
Loading
Loading