Skip to content
Open
5 changes: 5 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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 @@ -183,6 +184,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
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
6 changes: 5 additions & 1 deletion apps/server/src/modules/agent/memory/op-counter-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ app.addHook('onRequest', async (request) => {
principal: { principalId: 'carol', kind: 'user' },
member: true,
owner: false,
access: { can: async () => true, claim: async () => {} },
access: {
can: async () => true,
claim: async () => {},
workspaces: async () => 'all' as const,
},
});
});
registerOpCounterHook(app);
Expand Down
16 changes: 12 additions & 4 deletions apps/server/src/modules/artifact/send-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,11 @@ describe('sendBlob under per-Space access', () => {
'public, max-age=0',
]);
expect(
await cache({ can: async () => true, claim: async () => {} }),
await cache({
can: async () => true,
claim: async () => {},
workspaces: async () => 'all' as const,
}),
).toEqual(['private, no-cache', 304, 'private, no-cache']);
});

Expand Down Expand Up @@ -340,8 +344,12 @@ describe('sendBlob under per-Space access', () => {
// Bubble mode, even for a system owner; a route's own policy stays
// enforced beside the sandbox.
const sandbox = 'sandbox allow-scripts allow-forms allow-popups';
expect(await csp({ can: async () => true, claim: async () => {} })).toEqual(
[sandbox, ["connect-src 'none'", sandbox]],
);
expect(
await csp({
can: async () => true,
claim: async () => {},
workspaces: async () => 'all' as const,
}),
).toEqual([sandbox, ["connect-src 'none'", sandbox]]);
});
});
67 changes: 36 additions & 31 deletions apps/server/src/modules/canvas/canvas.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,38 +713,43 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => {
fastify.delete<{
Params: { canvasId: string };
Reply: ApiResult<DeleteCanvasResponse>;
}>('/:canvasId', async function (request, reply) {
const { canvasId } = request.params;
return withSpaceHierarchyLock(async () => {
if (isWorldCanvasId(canvasId)) {
return reply
.code(403)
.send({ message: 'World canvas cannot be deleted' });
}
try {
const preview = deletionPreview(
await getStructuredStore().spaces().list(),
canvasId,
);
if (preview.spaces.length > 1)
return reply.code(409).send({
code: 'SPACE_TREE_CONFIRMATION_REQUIRED',
message: 'Confirm the complete subtree before deleting this Space',
});
await deleteSpaceTree(canvasId, preview.token);
} catch (error) {
if (error instanceof SpaceHierarchyError)
return reply.code(error.status).send({
code: error.code,
message: error.message,
details: error.details,
});
throw error;
}
}>(
'/:canvasId',
{ config: { spaceAccess: 'manage' } },
async function (request, reply) {
const { canvasId } = request.params;
return withSpaceHierarchyLock(async () => {
if (isWorldCanvasId(canvasId)) {
return reply
.code(403)
.send({ message: 'World canvas cannot be deleted' });
}
try {
const preview = deletionPreview(
await getStructuredStore().spaces().list(),
canvasId,
);
if (preview.spaces.length > 1)
return reply.code(409).send({
code: 'SPACE_TREE_CONFIRMATION_REQUIRED',
message:
'Confirm the complete subtree before deleting this Space',
});
await deleteSpaceTree(canvasId, preview.token);
} catch (error) {
if (error instanceof SpaceHierarchyError)
return reply.code(error.status).send({
code: error.code,
message: error.message,
details: error.details,
});
throw error;
}

return reply.send({ success: true });
});
});
return reply.send({ success: true });
});
},
);

// Delete a node — removes its markdown sidecar.
//
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/modules/canvas/member-spaces.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('a member’s Spaces', () => {
const app = await host({
can: async (_level, { id }) => id === child.canvasId,
claim: async () => {},
workspaces: async () => 'all' as const,
});
const listed = (await app.inject('/canvas')).json().canvases;
await app.close();
Expand All @@ -77,7 +78,11 @@ describe('a member’s Spaces', () => {
principal: { principalId: 'carol', kind: 'user' },
member: true,
owner,
access: { can: async () => true, claim: async () => {} },
access: {
can: async () => true,
claim: async () => {},
workspaces: async () => 'all' as const,
},
});
});
await app.register(canvasRoutes, { prefix: '/canvas' });
Expand Down Expand Up @@ -115,6 +120,7 @@ describe('a member’s Spaces', () => {
claim: async () => {
throw new Error('grant store unavailable');
},
workspaces: async () => 'all',
});
const response = await app.inject({
method: 'POST',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const carol = (owner: boolean): ResolvedIdentity => ({
access: {
can: async (_level, { id }) => id === 'mine',
claim: async () => {},
workspaces: async () => 'all' as const,
},
});

Expand Down
140 changes: 74 additions & 66 deletions apps/server/src/modules/canvas/space-card.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,73 +128,81 @@ export function registerSpaceCardRoutes(app: FastifyInstance): void {
}
});

app.patch('/:canvasId/title', async (request, reply) => {
const params = recentCanvasConversationParamsSchema.safeParse(
request.params,
);
const body = renameSpaceBodySchema.safeParse(request.body);
if (!params.success || !body.success)
return reply.code(400).send({ message: 'Invalid Space rename' });
const { canvasId } = params.data;
const lease = acquireWorkspaceOperationLease();
try {
return await withCanvasMutex(canvasId, async () => {
const handle = space(canvasId);
const current = await handle.read();
if (!current)
return reply.code(404).send({ message: 'Space not found' });
if (current.title !== body.data.expectedTitle)
return reply.code(409).send({
code: 'SPACE_TITLE_CHANGED',
message: 'The Space name changed. Refresh and try again.',
});
const renamed = await getStructuredStore()
.spaces()
.rename({ canvasId, title: body.data.title });
if (!renamed.ok)
return reply
.code(
renamed.reason === 'not-found'
? 404
: renamed.reason === 'world-forbidden'
? 403
: 409,
)
.send({
code:
renamed.reason === 'title-conflict'
? 'CANVAS_TITLE_CONFLICT'
: 'SPACE_RENAME_REJECTED',
message: 'Unable to rename this Space',
app.patch(
'/:canvasId/title',
{ config: { spaceAccess: 'manage' } },
async (request, reply) => {
const params = recentCanvasConversationParamsSchema.safeParse(
request.params,
);
const body = renameSpaceBodySchema.safeParse(request.body);
if (!params.success || !body.success)
return reply.code(400).send({ message: 'Invalid Space rename' });
const { canvasId } = params.data;
const lease = acquireWorkspaceOperationLease();
try {
return await withCanvasMutex(canvasId, async () => {
const handle = space(canvasId);
const current = await handle.read();
if (!current)
return reply.code(404).send({ message: 'Space not found' });
if (current.title !== body.data.expectedTitle)
return reply.code(409).send({
code: 'SPACE_TITLE_CHANGED',
message: 'The Space name changed. Refresh and try again.',
});
const record = {
...renamed.record,
version: current.version + 1,
updatedAt: Date.now(),
};
const written = await handle.write({
expectedVersion: current.version,
nextRecord: record,
nodeMutations: [],
const renamed = await getStructuredStore()
.spaces()
.rename({ canvasId, title: body.data.title });
if (!renamed.ok)
return reply
.code(
renamed.reason === 'not-found'
? 404
: renamed.reason === 'world-forbidden'
? 403
: 409,
)
.send({
code:
renamed.reason === 'title-conflict'
? 'CANVAS_TITLE_CONFLICT'
: 'SPACE_RENAME_REJECTED',
message: 'Unable to rename this Space',
});
const record = {
...renamed.record,
version: current.version + 1,
updatedAt: Date.now(),
};
const written = await handle.write({
expectedVersion: current.version,
nextRecord: record,
nodeMutations: [],
});
if (!written.ok)
throw new Error(
`Space rename metadata write failed: ${written.reason}`,
);
publishCanvasUpdate(canvasId, {
type: 'snapshot',
data: { version: record.version },
});
return reply.send({
title: record.title,
updatedAt: record.updatedAt,
});
});
if (!written.ok)
throw new Error(
`Space rename metadata write failed: ${written.reason}`,
);
publishCanvasUpdate(canvasId, {
type: 'snapshot',
data: { version: record.version },
} catch (error) {
request.log.error({ err: error, canvasId }, 'Space rename failed');
return reply.code(500).send({
code: 'SPACE_RENAME_FAILED',
message:
'Unable to confirm the Space rename. Refresh before retrying.',
});
return reply.send({ title: record.title, updatedAt: record.updatedAt });
});
} catch (error) {
request.log.error({ err: error, canvasId }, 'Space rename failed');
return reply.code(500).send({
code: 'SPACE_RENAME_FAILED',
message: 'Unable to confirm the Space rename. Refresh before retrying.',
});
} finally {
lease.release();
}
});
} finally {
lease.release();
}
},
);
}
32 changes: 18 additions & 14 deletions apps/server/src/modules/canvas/space-hierarchy.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,22 @@ export function registerSpaceHierarchyRoutes(fastify: FastifyInstance): void {
),
);
});
fastify.post('/:canvasId/delete-tree', async (request, reply) => {
const params = spaceHierarchyParamsSchema.safeParse(request.params);
const body = deleteSpaceTreeBodySchema.safeParse(request.body);
if (!params.success || !body.success)
return reply
.code(400)
.send({ message: 'Invalid subtree deletion request' });
return respond(reply, async () => ({
deletedCanvasIds: await deleteSpaceTree(
params.data.canvasId,
body.data.token,
),
}));
});
fastify.post(
'/:canvasId/delete-tree',
{ config: { spaceAccess: 'manage' } },
async (request, reply) => {
const params = spaceHierarchyParamsSchema.safeParse(request.params);
const body = deleteSpaceTreeBodySchema.safeParse(request.body);
if (!params.success || !body.success)
return reply
.code(400)
.send({ message: 'Invalid subtree deletion request' });
return respond(reply, async () => ({
deletedCanvasIds: await deleteSpaceTree(
params.data.canvasId,
body.data.token,
),
}));
},
);
}
1 change: 1 addition & 0 deletions apps/server/src/modules/canvas/space-hierarchy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,7 @@ describe('Space ownership', () => {
);
if (failClaim) throw new Error('grant store unavailable');
},
workspaces: async () => 'all',
},
});
});
Expand Down
Loading
Loading