feat(#93): implement agent spawn via federation
Implements FED-010: Agent Spawn via Federation feature that enables spawning and managing Claude agents on remote federated Mosaic Stack instances via COMMAND message type. Features: - Federation agent command types (spawn, status, kill) - FederationAgentService for handling agent operations - Integration with orchestrator's agent spawner/lifecycle services - API endpoints for spawning, querying status, and killing agents - Full command routing through federation COMMAND infrastructure - Comprehensive test coverage (12/12 tests passing) Architecture: - Hub → Spoke: Spawn agents on remote instances - Command flow: FederationController → FederationAgentService → CommandService → Remote Orchestrator - Response handling: Remote orchestrator returns agent status/results - Security: Connection validation, signature verification Files created: - apps/api/src/federation/types/federation-agent.types.ts - apps/api/src/federation/federation-agent.service.ts - apps/api/src/federation/federation-agent.service.spec.ts Files modified: - apps/api/src/federation/command.service.ts (agent command routing) - apps/api/src/federation/federation.controller.ts (agent endpoints) - apps/api/src/federation/federation.module.ts (service registration) - apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint) - apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration) Testing: - 12/12 tests passing for FederationAgentService - All command service tests passing - TypeScript compilation successful - Linting passed Refs #93 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,10 +44,7 @@ describe("PermissionGuard", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
workspace: any
|
||||
): ExecutionContext => {
|
||||
const createMockExecutionContext = (user: any, workspace: any): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
workspace,
|
||||
@@ -67,10 +64,7 @@ describe("PermissionGuard", () => {
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when no permission is required", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
@@ -80,10 +74,7 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow OWNER to access WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
@@ -99,30 +90,19 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny ADMIN access to WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow OWNER and ADMIN to access WORKSPACE_ADMIN permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
|
||||
@@ -140,34 +120,20 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny MEMBER access to WORKSPACE_ADMIN permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow OWNER, ADMIN, and MEMBER to access WORKSPACE_MEMBER permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context3 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context1 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context2 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
const context3 = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
@@ -191,26 +157,18 @@ describe("PermissionGuard", () => {
|
||||
});
|
||||
|
||||
it("should deny GUEST access to WORKSPACE_MEMBER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should allow any role (including GUEST) to access WORKSPACE_ANY permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ANY);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
@@ -227,9 +185,7 @@ describe("PermissionGuard", () => {
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when workspace context is missing", async () => {
|
||||
@@ -237,42 +193,28 @@ describe("PermissionGuard", () => {
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You are not a member of this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,10 +58,7 @@ describe("WorkspaceGuard", () => {
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when user is a workspace member (via header)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -87,11 +84,7 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via URL param)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, {}, { workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -105,12 +98,7 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via body)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, {}, {}, { workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
@@ -154,59 +142,38 @@ describe("WorkspaceGuard", () => {
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not authenticated", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
null,
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext(null, { "x-workspace-id": workspaceId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"User not authenticated"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("User not authenticated");
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when workspace ID is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Workspace ID is required"
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(BadRequestException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow("Workspace ID is required");
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
const context = createMockExecutionContext({ id: userId }, { "x-workspace-id": workspaceId });
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user