feat(run-agent): let apps run an agent on behalf of a workspace member (#23470)
## Why `runAgent()` always runs with the agent's own role, so an app has no way to scope a run to the person who triggered it. Chat-style apps (Slack, Discord, Teams) need the opposite: the agent should never be able to do more than the member who asked. This is the server/SDK prerequisite for per-user permissions in the Slack app (#22984). It is self-contained and reviewable without any Slack context. > **Scope note.** Review surfaced two authorization problems adjacent to this code that are not part of the original feature — a cross-app agent hole (#21157) and two fail-open branches in application-token auth. Both are fixed here rather than deferred, since they sit directly on the path this PR changes. They are called out separately below so they can be reviewed on their own terms. ## The feature - **`runAsWorkspaceMemberId` (optional) on `RunAgentInput`** — shared type, DTO, and the generated GraphQL artifacts. - **`AgentActorContextService.buildRunAsWorkspaceMemberContext`** resolves member → userWorkspace → role and returns an actor context, a *user* auth context, and the role id. Mirrors what `WorkflowExecutionContextService` already does for acting on behalf of a user. - **`AgentRunService`** swaps the application auth context for the member's, passes their actor context, and attributes AI credit usage to them. - **`buildAgentRolePermissionConfig`** (new util) returns `intersectionOf: [agentRoleId, runAsRoleId]`, agent role first — explicit object grants in `database-tool.provider` resolve against the first role, so it defines which objects are in scope at all and later roles only narrow permissions on them. Collapses to a single entry when the member already holds the agent role, because the permission-flag checks reject an intersection listing the same role twice. - **`ToolContext`** gains an optional `rolePermissionConfig`. The lazy tool path resolved permissions from a single `roleId`, so without this the narrowing would not reach the tool catalog or call-time `execute_tool` — and lazy is the strategy `runAgent` uses. Falls back to the previous `unionOf: [roleId]` default when absent. - Docs: a "Running on behalf of a workspace member" section in `skills-and-agents.mdx`. Omitting the field preserves today's behavior exactly, which is what autonomous runs (scheduled jobs, database-event triggers) need. **Fails closed:** an unresolvable member errors rather than falling back to the agent role, which would grant more than the caller asked for. ## Who may name a member `runAsWorkspaceMemberId` names another person, so it needs an authorization rule of its own. An application token is not sufficient on its own: `frontComponent(id)` is guarded by `UserAuthGuard, NoPermissionGuard` and mints an `APPLICATION_ACCESS` token pair for the requesting user, so any authenticated user can obtain one for an installed app. Those tokens record who they were minted for, and the caller cannot strip that. The rule keys on that binding: | Token | May name | | --- | --- | | No application token | nothing — rejected | | Application token **with** a user binding | only that user's own member | | Application token with **no** user binding | any member | The third row is unattended app code — a database-event-triggered logic function is the Slack worker's path, and `client_credentials` or API-key-minted tokens land here too. ## Adjacent fixes **Cross-app agents** (pre-existing, #21157). The agent lookup was not scoped to the caller, so any app token could run any agent in the workspace, including one belonging to an app with wider permissions — while `skills-and-agents.mdx` promised an app can only run its own. Now rejected with `RUN_AGENT_NOT_ALLOWED`. Guarded on `isDefined(callerApplication)`, so callers without an app token are unaffected; `twenty-front` never calls `runAgent`. **Two fail-open branches in `validateApplicationToken`.** Both populated the auth context conditionally instead of failing closed, and both are now asserted, making the application path structurally identical to `validateAccessToken`: 1. An unresolvable user left the token presenting as *unbound*, so removing someone from a workspace widened their live token instead of revoking it, until it expired. 2. A missing workspace member let the token carry on with the application's own permissions after that member was removed or deactivated. Both mirror `validateAccessToken`, down to its `PENDING_CREATION` / `ONGOING_CREATION` escape hatch. **Behaviour change beyond this PR:** an application token whose user has been removed now 401s where it previously degraded to app-only. That is the point, and it matches access-token semantics, but it is shared auth and worth a careful look. ## Known limitation If the app's own agent role declares row-level predicates, those are not applied in run-as mode, because the query builders resolve row-level rules from a single role via the auth context. The member's own row-level rules do apply, which is the direction that matters here. Multi-role row-level support does not exist anywhere in the codebase today. ## Tests | Check | Result | | --- | --- | | ai-agent-execution, tool-provider, record-crud, user-workspace, full auth tree | 79 suites, 666 passed | | `nx typecheck twenty-server` | clean | | oxlint + oxfmt on the changed server files | clean | Both auth regression tests were verified against the pre-fix code — each fails when the fix is reverted, so they guard the behaviour rather than passing incidentally. ## Note for reviewers Rebased onto `main`, then merged `main` in once more after #23395 landed. The `getObjectsPermissionsFromRolePermissionConfig` intersection fix this PR originally carried has since landed on main independently, and main's version is stricter — it denies when an intersected role is missing from the cache rather than treating it as empty — so this PR takes main's and no longer touches that file. `RunAgentInput` now composes with the `prompt` | `messages` XOR from #23395: `runAsWorkspaceMemberId` sits on the base object, so it is available to both variants.
This commit is contained in:
@@ -4727,6 +4727,7 @@ input CreateEmailingDomainInput {
|
||||
input RunAgentInput {
|
||||
agentUniversalIdentifier: String!
|
||||
prompt: String
|
||||
runAsWorkspaceMemberId: UUID
|
||||
messages: [RunAgentMessageInput!]
|
||||
}
|
||||
|
||||
|
||||
@@ -6939,7 +6939,7 @@ export interface UpdateEmailGroupChannelInput {id: Scalars['UUID'],displayName?:
|
||||
|
||||
export interface CreateEmailingDomainInput {domain: Scalars['String']}
|
||||
|
||||
export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),messages?: (RunAgentMessageInput[] | null)}
|
||||
export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),runAsWorkspaceMemberId?: (Scalars['UUID'] | null),messages?: (RunAgentMessageInput[] | null)}
|
||||
|
||||
export interface RunAgentMessageInput {role: RunAgentMessageRole,content: Scalars['String']}
|
||||
|
||||
|
||||
@@ -12081,6 +12081,9 @@ export default {
|
||||
"prompt": [
|
||||
1
|
||||
],
|
||||
"runAsWorkspaceMemberId": [
|
||||
4
|
||||
],
|
||||
"messages": [
|
||||
493
|
||||
],
|
||||
|
||||
@@ -164,5 +164,44 @@ export default defineApplicationRole({
|
||||
guard on whether any target field is still empty before calling `runAgent()`.
|
||||
</Warning>
|
||||
|
||||
### Running on behalf of a workspace member
|
||||
|
||||
Pass `runAsWorkspaceMemberId` when the run is triggered by a person — a chat
|
||||
bot answering a message, for instance — so the agent acts as that member
|
||||
instead of as the app:
|
||||
|
||||
```ts src/logic-functions/answer-question.ts
|
||||
const { result } = await runAgent({
|
||||
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
|
||||
prompt: 'How many open opportunities do we have?',
|
||||
runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
|
||||
});
|
||||
```
|
||||
|
||||
The run is then restricted to what **both** the agent's role and the member's
|
||||
role allow, records it creates are attributed to that member, and the member's
|
||||
row-level permissions apply. Omit the field for autonomous runs (scheduled
|
||||
jobs, database-event triggers): those keep the agent's own role.
|
||||
|
||||
Your app is responsible for mapping the person who triggered the run to a
|
||||
workspace member. Naming one requires an app access token, and what a token may
|
||||
name depends on whether it carries a user:
|
||||
|
||||
- A token with **no user attached** may name any member. A logic function runs
|
||||
with one, and so do tokens minted through `client_credentials` or from an API
|
||||
key.
|
||||
- A token issued **on behalf of a user**, as a front component receives, may
|
||||
only name that user's own member.
|
||||
|
||||
Anything else — a plain user session, an API key without an app token — may not
|
||||
name a member at all.
|
||||
|
||||
<Warning>
|
||||
`runAgent()` throws when the workspace member cannot be resolved — an
|
||||
unknown or removed member, or one with no role. It never falls back to the
|
||||
agent's own role, since that would grant more access than the caller asked
|
||||
for.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -5239,6 +5239,7 @@ export type RunAgentInput = {
|
||||
agentUniversalIdentifier: Scalars['String']['input'];
|
||||
messages?: InputMaybe<Array<RunAgentMessageInput>>;
|
||||
prompt?: InputMaybe<Scalars['String']['input']>;
|
||||
runAsWorkspaceMemberId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
};
|
||||
|
||||
export type RunAgentMessageInput = {
|
||||
|
||||
+177
@@ -11,6 +11,7 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-t
|
||||
import { ImpersonationAuthorizationService } from 'src/engine/core-modules/impersonation/services/impersonation-authorization.service';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { JwtAuthStrategy } from './jwt.auth.strategy';
|
||||
|
||||
@@ -432,6 +433,182 @@ describe('JwtAuthStrategy', () => {
|
||||
expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject an application token bound to a user that cannot be resolved', async () => {
|
||||
const validApplicationId = randomUUID();
|
||||
const validWorkspaceId = randomUUID();
|
||||
const removedUserId = randomUUID();
|
||||
|
||||
const payload = {
|
||||
sub: validApplicationId,
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
applicationId: validApplicationId,
|
||||
workspaceId: validWorkspaceId,
|
||||
userId: removedUserId,
|
||||
userWorkspaceId: randomUUID(),
|
||||
};
|
||||
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
applicationStore[validWorkspaceId] = {
|
||||
[validApplicationId]: { id: validApplicationId },
|
||||
};
|
||||
|
||||
strategy = createStrategy();
|
||||
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
throw new Error('Expected validate to reject');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject an application token whose user is no longer a workspace member', async () => {
|
||||
const validApplicationId = randomUUID();
|
||||
const validWorkspaceId = randomUUID();
|
||||
const validUserId = randomUUID();
|
||||
const validUserWorkspaceId = randomUUID();
|
||||
|
||||
const payload = {
|
||||
sub: validApplicationId,
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
applicationId: validApplicationId,
|
||||
workspaceId: validWorkspaceId,
|
||||
userId: validUserId,
|
||||
userWorkspaceId: validUserWorkspaceId,
|
||||
};
|
||||
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
mockWorkspace.activationStatus = WorkspaceActivationStatus.ACTIVE;
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
applicationStore[validWorkspaceId] = {
|
||||
[validApplicationId]: { id: validApplicationId },
|
||||
};
|
||||
userStore[validUserId] = { id: validUserId };
|
||||
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
workspaceId: validWorkspaceId,
|
||||
user: { id: validUserId },
|
||||
workspace: { id: validWorkspaceId },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
strategy = createStrategy();
|
||||
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
throw new Error('Expected validate to reject');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.FORBIDDEN_EXCEPTION);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject an application token whose workspace member is soft-deleted', async () => {
|
||||
const validApplicationId = randomUUID();
|
||||
const validWorkspaceId = randomUUID();
|
||||
const validUserId = randomUUID();
|
||||
const validUserWorkspaceId = randomUUID();
|
||||
const validWorkspaceMemberId = randomUUID();
|
||||
|
||||
const payload = {
|
||||
sub: validApplicationId,
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
applicationId: validApplicationId,
|
||||
workspaceId: validWorkspaceId,
|
||||
userId: validUserId,
|
||||
userWorkspaceId: validUserWorkspaceId,
|
||||
};
|
||||
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
mockWorkspace.activationStatus = WorkspaceActivationStatus.ACTIVE;
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
applicationStore[validWorkspaceId] = {
|
||||
[validApplicationId]: { id: validApplicationId },
|
||||
};
|
||||
userStore[validUserId] = { id: validUserId };
|
||||
|
||||
workspaceCacheService.getOrRecompute.mockImplementation(
|
||||
async (workspaceId: string, cacheKeys: string[]) => {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
if (cacheKeys.includes('flatWorkspaceMemberMaps')) {
|
||||
result.flatWorkspaceMemberMaps = {
|
||||
byId: {
|
||||
[validWorkspaceMemberId]: {
|
||||
id: validWorkspaceMemberId,
|
||||
userId: validUserId,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
},
|
||||
idByUserId: { [validUserId]: validWorkspaceMemberId },
|
||||
};
|
||||
}
|
||||
|
||||
if (cacheKeys.includes('flatApplicationMaps')) {
|
||||
result.flatApplicationMaps = {
|
||||
byId: applicationStore[workspaceId] ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
workspaceId: validWorkspaceId,
|
||||
user: { id: validUserId },
|
||||
workspace: { id: validWorkspaceId },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
strategy = createStrategy();
|
||||
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
throw new Error('Expected validate to reject');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.FORBIDDEN_EXCEPTION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Impersonation validation', () => {
|
||||
|
||||
+50
-16
@@ -363,25 +363,59 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
expectedWorkspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (isDefined(userContext)) {
|
||||
context.user = userContext.user;
|
||||
context.userWorkspace = userContext.userWorkspace;
|
||||
context.userWorkspaceId = userContext.userWorkspace.id;
|
||||
assertIsDefinedOrThrow(
|
||||
userContext,
|
||||
new AuthException(
|
||||
'User or user workspace not found',
|
||||
AuthExceptionCode.USER_NOT_FOUND,
|
||||
{
|
||||
userFriendlyMessage: msg`User does not have access to this workspace`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const { flatWorkspaceMemberMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspace.id, [
|
||||
'flatWorkspaceMemberMaps',
|
||||
]);
|
||||
context.user = userContext.user;
|
||||
context.userWorkspace = userContext.userWorkspace;
|
||||
context.userWorkspaceId = userContext.userWorkspace.id;
|
||||
|
||||
const workspaceMemberId =
|
||||
flatWorkspaceMemberMaps.idByUserId[userContext.user.id];
|
||||
|
||||
if (isDefined(workspaceMemberId)) {
|
||||
context.workspaceMemberId = workspaceMemberId;
|
||||
context.workspaceMember =
|
||||
flatWorkspaceMemberMaps.byId[workspaceMemberId];
|
||||
}
|
||||
if (
|
||||
workspace.activationStatus ===
|
||||
WorkspaceActivationStatus.PENDING_CREATION ||
|
||||
workspace.activationStatus ===
|
||||
WorkspaceActivationStatus.ONGOING_CREATION
|
||||
) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const { flatWorkspaceMemberMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspace.id, [
|
||||
'flatWorkspaceMemberMaps',
|
||||
]);
|
||||
|
||||
const workspaceMemberId =
|
||||
flatWorkspaceMemberMaps.idByUserId[userContext.user.id];
|
||||
|
||||
const cachedWorkspaceMember = isDefined(workspaceMemberId)
|
||||
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
|
||||
: undefined;
|
||||
|
||||
const workspaceMember = isDefined(cachedWorkspaceMember?.deletedAt)
|
||||
? undefined
|
||||
: cachedWorkspaceMember;
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
workspaceMember,
|
||||
new AuthException(
|
||||
'User is not a member of the workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
{
|
||||
userFriendlyMessage: msg`User is not a member of the workspace.`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
context.workspaceMemberId = workspaceMemberId;
|
||||
context.workspaceMember = workspaceMember;
|
||||
}
|
||||
|
||||
return context;
|
||||
|
||||
+6
-3
@@ -159,11 +159,13 @@ export class ToolRegistryService {
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
locale?: keyof typeof APP_LOCALES;
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
},
|
||||
): Promise<ToolIndexEntry[]> {
|
||||
const context = this.buildContextFromToolContext({
|
||||
workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: options?.rolePermissionConfig,
|
||||
userId: options?.userId,
|
||||
userWorkspaceId: options?.userWorkspaceId,
|
||||
locale: options?.locale,
|
||||
@@ -407,9 +409,10 @@ export class ToolRegistryService {
|
||||
private buildContextFromToolContext(
|
||||
context: ToolContext,
|
||||
): ToolProviderContext {
|
||||
const rolePermissionConfig: RolePermissionConfig = {
|
||||
unionOf: [context.roleId],
|
||||
};
|
||||
const rolePermissionConfig: RolePermissionConfig =
|
||||
context.rolePermissionConfig ?? {
|
||||
unionOf: [context.roleId],
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: context.workspaceId,
|
||||
|
||||
+2
@@ -4,10 +4,12 @@ import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export type ToolContext = {
|
||||
workspaceId: string;
|
||||
roleId: string;
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
actorContext?: ActorMetadata;
|
||||
userId?: string;
|
||||
|
||||
+43
-14
@@ -435,6 +435,24 @@ export class UserWorkspaceService {
|
||||
};
|
||||
}
|
||||
|
||||
async getUserWorkspaceForUser({
|
||||
userId,
|
||||
workspaceId,
|
||||
relations = ['twoFactorAuthenticationMethods'],
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
relations?: string[];
|
||||
}): Promise<UserWorkspaceEntity | null> {
|
||||
return this.userWorkspaceRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserWorkspaceForUserOrThrow({
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -444,11 +462,9 @@ export class UserWorkspaceService {
|
||||
workspaceId: string;
|
||||
relations?: string[];
|
||||
}): Promise<UserWorkspaceEntity> {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
const userWorkspace = await this.getUserWorkspaceForUser({
|
||||
userId,
|
||||
workspaceId,
|
||||
relations,
|
||||
});
|
||||
|
||||
@@ -459,13 +475,13 @@ export class UserWorkspaceService {
|
||||
return userWorkspace;
|
||||
}
|
||||
|
||||
async getWorkspaceMemberOrThrow({
|
||||
async getWorkspaceMember({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceMemberId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<WorkspaceMemberWorkspaceEntity> {
|
||||
}): Promise<WorkspaceMemberWorkspaceEntity | null> {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
@@ -477,22 +493,35 @@ export class UserWorkspaceService {
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceMember = await workspaceMemberRepository.findOne({
|
||||
return workspaceMemberRepository.findOne({
|
||||
where: {
|
||||
id: workspaceMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMember)) {
|
||||
throw new Error('Workspace member not found');
|
||||
}
|
||||
|
||||
return workspaceMember;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceMemberOrThrow({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceMemberId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<WorkspaceMemberWorkspaceEntity> {
|
||||
const workspaceMember = await this.getWorkspaceMember({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMember)) {
|
||||
throw new Error('Workspace member not found');
|
||||
}
|
||||
|
||||
return workspaceMember;
|
||||
}
|
||||
|
||||
private async computeDefaultAvatarUrl(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
@@ -55,6 +56,7 @@ import { AgentRunService } from './services/agent-run.service';
|
||||
providers: [
|
||||
AgentAsyncExecutorService,
|
||||
AgentActorContextService,
|
||||
AiGraphqlApiExceptionInterceptor,
|
||||
AgentMessagePartResolver,
|
||||
AgentRunResolver,
|
||||
AgentRunService,
|
||||
|
||||
+8
@@ -9,10 +9,13 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
import { RunAgentMessageInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType('RunAgentInput')
|
||||
export class RunAgentInputDTO {
|
||||
@IsString()
|
||||
@@ -26,6 +29,11 @@ export class RunAgentInputDTO {
|
||||
@Field({ nullable: true })
|
||||
prompt?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
runAsWorkspaceMemberId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
|
||||
+12
-1
@@ -1,19 +1,24 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AuthApplication } from 'src/engine/decorators/auth/auth-application.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { RunAgentInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input';
|
||||
import { RunAgentResultDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-result.dto';
|
||||
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
|
||||
import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
@UseInterceptors(AiGraphqlApiExceptionInterceptor)
|
||||
@MetadataResolver()
|
||||
export class AgentRunResolver {
|
||||
constructor(private readonly agentRunService: AgentRunService) {}
|
||||
@@ -24,10 +29,16 @@ export class AgentRunResolver {
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@AuthApplication({ allowUndefined: true })
|
||||
callerApplication: FlatApplication | undefined,
|
||||
@AuthWorkspaceMemberId()
|
||||
workspaceMemberId: string | undefined,
|
||||
): Promise<RunAgentResultDTO> {
|
||||
return this.agentRunService.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: userWorkspaceId ?? null,
|
||||
requestWorkspaceMemberId: workspaceMemberId ?? null,
|
||||
callerApplication,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
|
||||
import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
|
||||
describe('AgentActorContextService', () => {
|
||||
let service: AgentActorContextService;
|
||||
let userWorkspaceService: {
|
||||
getWorkspaceMember: jest.Mock;
|
||||
getUserWorkspaceForUser: jest.Mock;
|
||||
};
|
||||
let userRoleService: { getRoleIdForUserWorkspace: jest.Mock };
|
||||
|
||||
const workspaceId = 'workspace-1';
|
||||
const workspaceMemberId = 'workspace-member-1';
|
||||
|
||||
const runAsArgs = { workspaceMemberId, workspaceId };
|
||||
|
||||
beforeEach(async () => {
|
||||
userWorkspaceService = {
|
||||
getWorkspaceMember: jest.fn().mockResolvedValue({
|
||||
id: workspaceMemberId,
|
||||
userId: 'user-1',
|
||||
name: { firstName: 'Ada', lastName: 'Lovelace' },
|
||||
}),
|
||||
getUserWorkspaceForUser: jest.fn().mockResolvedValue({
|
||||
id: 'user-workspace-1',
|
||||
userId: 'user-1',
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
},
|
||||
}),
|
||||
};
|
||||
userRoleService = {
|
||||
getRoleIdForUserWorkspace: jest.fn().mockResolvedValue('role-1'),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentActorContextService,
|
||||
{ provide: UserWorkspaceService, useValue: userWorkspaceService },
|
||||
{ provide: UserRoleService, useValue: userRoleService },
|
||||
{ provide: GlobalWorkspaceOrmManager, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(AgentActorContextService);
|
||||
});
|
||||
|
||||
it('reports a not-found error when the workspace member does not exist', async () => {
|
||||
userWorkspaceService.getWorkspaceMember.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.buildRunAsWorkspaceMemberContext(runAsArgs),
|
||||
).rejects.toMatchObject({
|
||||
code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a not-found error when the member has no user workspace', async () => {
|
||||
userWorkspaceService.getUserWorkspaceForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.buildRunAsWorkspaceMemberContext(runAsArgs),
|
||||
).rejects.toMatchObject({
|
||||
code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a not-found error when the member has no role assigned', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockRejectedValue(
|
||||
new PermissionsException(
|
||||
'No role found for userWorkspace',
|
||||
PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.buildRunAsWorkspaceMemberContext(runAsArgs),
|
||||
).rejects.toMatchObject({
|
||||
code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an infrastructure failure keep its own error type', async () => {
|
||||
const databaseError = new Error('connection terminated unexpectedly');
|
||||
|
||||
userWorkspaceService.getWorkspaceMember.mockRejectedValue(databaseError);
|
||||
|
||||
await expect(
|
||||
service.buildRunAsWorkspaceMemberContext(runAsArgs),
|
||||
).rejects.toBe(databaseError);
|
||||
});
|
||||
|
||||
it('lets an unrelated permissions failure keep its own error type', async () => {
|
||||
const permissionsError = new PermissionsException(
|
||||
'Permission denied',
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
|
||||
userRoleService.getRoleIdForUserWorkspace.mockRejectedValue(
|
||||
permissionsError,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.buildRunAsWorkspaceMemberContext(runAsArgs),
|
||||
).rejects.toBe(permissionsError);
|
||||
});
|
||||
|
||||
it('returns the member actor context, auth context and role', async () => {
|
||||
const result = await service.buildRunAsWorkspaceMemberContext(runAsArgs);
|
||||
|
||||
expect(result.roleId).toBe('role-1');
|
||||
expect(result.authContext).toMatchObject({
|
||||
type: 'user',
|
||||
workspaceMemberId,
|
||||
});
|
||||
});
|
||||
});
|
||||
+43
@@ -211,6 +211,49 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
|
||||
expect(system).not.toContain('create_one_workflow');
|
||||
});
|
||||
|
||||
it('intersects the agent role with the run-as role on the lazy path used by runAgent', async () => {
|
||||
roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
|
||||
toolRegistry.buildToolIndex.mockResolvedValueOnce([]);
|
||||
|
||||
await service.executeAgent({
|
||||
agent: buildAgent(),
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
baseSystemPrompt: 'base system prompt',
|
||||
workspaceId,
|
||||
runAsRoleId: 'run-as-role-id',
|
||||
toolLoadingStrategy: 'lazy',
|
||||
});
|
||||
|
||||
expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
agentRoleId,
|
||||
expect.objectContaining({
|
||||
rolePermissionConfig: {
|
||||
intersectionOf: [agentRoleId, 'run-as-role-id'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the lazy path on its default role resolution without a run-as role', async () => {
|
||||
roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
|
||||
toolRegistry.buildToolIndex.mockResolvedValueOnce([]);
|
||||
|
||||
await service.executeAgent({
|
||||
agent: buildAgent(),
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
baseSystemPrompt: 'base system prompt',
|
||||
workspaceId,
|
||||
toolLoadingStrategy: 'lazy',
|
||||
});
|
||||
|
||||
expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
agentRoleId,
|
||||
expect.objectContaining({ rolePermissionConfig: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resolve registry tools when the agent has no role (fail-closed)', async () => {
|
||||
roleTargetRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
|
||||
+226
@@ -2,10 +2,16 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
|
||||
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import {
|
||||
AiException,
|
||||
AiExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
|
||||
describe('AgentRunService', () => {
|
||||
@@ -13,6 +19,9 @@ describe('AgentRunService', () => {
|
||||
let agentRepository: { findOne: jest.Mock };
|
||||
let applicationService: { findById: jest.Mock };
|
||||
let agentAsyncExecutorService: { executeAgent: jest.Mock };
|
||||
let agentActorContextService: {
|
||||
buildRunAsWorkspaceMemberContext: jest.Mock;
|
||||
};
|
||||
|
||||
const workspace = { id: 'workspace-1' } as FlatWorkspace;
|
||||
|
||||
@@ -23,6 +32,22 @@ describe('AgentRunService', () => {
|
||||
prompt: 'Enrich record 123',
|
||||
};
|
||||
|
||||
const callerApplication = { id: 'app-1' } as FlatApplication;
|
||||
|
||||
const runAsActorContext = {
|
||||
source: 'AGENT',
|
||||
workspaceMemberId: 'workspace-member-1',
|
||||
};
|
||||
|
||||
const runAsAuthContext = {
|
||||
type: 'user',
|
||||
workspace,
|
||||
userWorkspaceId: 'member-user-workspace-1',
|
||||
user: { id: 'user-1' },
|
||||
workspaceMemberId: 'workspace-member-1',
|
||||
workspaceMember: { id: 'workspace-member-1' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
agentRepository = { findOne: jest.fn().mockResolvedValue(agent) };
|
||||
applicationService = {
|
||||
@@ -34,10 +59,21 @@ describe('AgentRunService', () => {
|
||||
hasNoMoreAvailableCredits: false,
|
||||
}),
|
||||
};
|
||||
agentActorContextService = {
|
||||
buildRunAsWorkspaceMemberContext: jest.fn().mockResolvedValue({
|
||||
actorContext: runAsActorContext,
|
||||
authContext: runAsAuthContext,
|
||||
roleId: 'member-role-1',
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentRunService,
|
||||
{
|
||||
provide: AgentActorContextService,
|
||||
useValue: agentActorContextService,
|
||||
},
|
||||
{
|
||||
provide: AgentAsyncExecutorService,
|
||||
useValue: agentAsyncExecutorService,
|
||||
@@ -60,6 +96,7 @@ describe('AgentRunService', () => {
|
||||
const result = await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -82,6 +119,7 @@ describe('AgentRunService', () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -97,10 +135,30 @@ describe('AgentRunService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resolve a run-as context when no workspace member is requested', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorContext: undefined,
|
||||
runAsRoleId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('converts a prompt into a single user message', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -109,6 +167,54 @@ describe('AgentRunService', () => {
|
||||
).toEqual([{ role: 'user', content: input.prompt }]);
|
||||
});
|
||||
|
||||
it('runs as the requested workspace member with their auth context and role', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
callerApplication,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
|
||||
});
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).toHaveBeenCalledWith({
|
||||
workspaceMemberId: 'workspace-member-1',
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorContext: runAsActorContext,
|
||||
authContext: runAsAuthContext,
|
||||
runAsRoleId: 'member-role-1',
|
||||
userWorkspaceId: 'member-user-workspace-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the run instead of falling back to the agent role when the member cannot be resolved', async () => {
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext.mockRejectedValue(
|
||||
new AiException(
|
||||
'Workspace member not found',
|
||||
AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
callerApplication,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
});
|
||||
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes messages to the executor when messages are provided instead of prompt', async () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, content: 'Hello' },
|
||||
@@ -119,6 +225,7 @@ describe('AgentRunService', () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input: {
|
||||
agentUniversalIdentifier: 'agent-uid',
|
||||
messages,
|
||||
@@ -145,6 +252,7 @@ describe('AgentRunService', () => {
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
input: {
|
||||
agentUniversalIdentifier: 'agent-uid',
|
||||
},
|
||||
@@ -155,11 +263,124 @@ describe('AgentRunService', () => {
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects running as a workspace member when the caller is not an application', async () => {
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
|
||||
}),
|
||||
).rejects.toThrow('requires an application access token');
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an application token issued for a user naming a different member', async () => {
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: 'workspace-member-2',
|
||||
callerApplication,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
|
||||
}),
|
||||
).rejects.toThrow('can only run an agent as that user');
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a user-bound application token whose member identity never resolved', async () => {
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
callerApplication,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
|
||||
}),
|
||||
).rejects.toThrow('can only run an agent as that user');
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an application token with no user binding to name any member', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
callerApplication,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'someone-elses-member-id' },
|
||||
});
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).toHaveBeenCalledWith({
|
||||
workspaceMemberId: 'someone-elses-member-id',
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects running an agent that belongs to another application', async () => {
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
callerApplication: { id: 'another-app' } as FlatApplication,
|
||||
input,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: AiExceptionCode.RUN_AGENT_NOT_ALLOWED,
|
||||
});
|
||||
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an application to run its own agent', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
callerApplication,
|
||||
input,
|
||||
});
|
||||
|
||||
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an application token issued for a user to run as that same user', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: 'workspace-member-1',
|
||||
callerApplication,
|
||||
input: { ...input, runAsWorkspaceMemberId: 'workspace-member-1' },
|
||||
});
|
||||
|
||||
expect(
|
||||
agentActorContextService.buildRunAsWorkspaceMemberContext,
|
||||
).toHaveBeenCalledWith({
|
||||
workspaceMemberId: 'workspace-member-1',
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when both prompt and messages are provided', async () => {
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
input: {
|
||||
agentUniversalIdentifier: 'agent-uid',
|
||||
prompt: 'Enrich record 123',
|
||||
@@ -176,6 +397,7 @@ describe('AgentRunService', () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -196,6 +418,7 @@ describe('AgentRunService', () => {
|
||||
const result = await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -214,6 +437,7 @@ describe('AgentRunService', () => {
|
||||
const result = await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -231,6 +455,7 @@ describe('AgentRunService', () => {
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
@@ -246,6 +471,7 @@ describe('AgentRunService', () => {
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
requestWorkspaceMemberId: null,
|
||||
input,
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
|
||||
+94
@@ -1,13 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util';
|
||||
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util';
|
||||
import {
|
||||
AiException,
|
||||
AiExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
import { type RunAsWorkspaceMemberContext } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
@@ -113,4 +122,89 @@ export class AgentActorContextService {
|
||||
userContext,
|
||||
};
|
||||
}
|
||||
|
||||
async buildRunAsWorkspaceMemberContext({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceMemberId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<RunAsWorkspaceMemberContext> {
|
||||
const workspaceMember = await this.userWorkspaceService.getWorkspaceMember({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!isDefined(workspaceMember)) {
|
||||
throw new AiException(
|
||||
`Workspace member ${workspaceMemberId} not found`,
|
||||
AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const userWorkspace =
|
||||
await this.userWorkspaceService.getUserWorkspaceForUser({
|
||||
userId: workspaceMember.userId,
|
||||
workspaceId,
|
||||
relations: ['workspace', 'user'],
|
||||
});
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
throw new AiException(
|
||||
`Workspace member ${workspaceMemberId} has no user workspace`,
|
||||
AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const roleId = await this.resolveRoleIdOrThrow({
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
workspaceId,
|
||||
workspaceMemberId,
|
||||
});
|
||||
|
||||
return {
|
||||
actorContext: buildCreatedByFromFullNameMetadata({
|
||||
fullNameMetadata: workspaceMember.name,
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
source: FieldActorSource.AGENT,
|
||||
}),
|
||||
authContext: buildUserAuthContext({
|
||||
workspace: fromWorkspaceEntityToFlat(userWorkspace.workspace),
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
user: fromUserEntityToFlat(userWorkspace.user),
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
workspaceMember,
|
||||
}),
|
||||
roleId,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRoleIdOrThrow({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
workspaceMemberId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
workspaceMemberId: string;
|
||||
}): Promise<string> {
|
||||
try {
|
||||
return await this.userRoleService.getRoleIdForUserWorkspace({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PermissionsException &&
|
||||
error.code === PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE
|
||||
) {
|
||||
throw new AiException(
|
||||
`Workspace member ${workspaceMemberId} has no role assigned`,
|
||||
AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -47,6 +47,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
|
||||
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
|
||||
import { type AgentToolLoadingStrategy } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type';
|
||||
import { buildAgentRolePermissionConfig } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import { STRUCTURED_OUTPUT_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/structured-output-system-prompt.const';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
@@ -144,11 +145,13 @@ export class AgentAsyncExecutorService {
|
||||
private async buildPreloadedRegistryTools({
|
||||
agent,
|
||||
agentRoleId,
|
||||
runAsRoleId,
|
||||
authContext,
|
||||
actorContext,
|
||||
}: {
|
||||
agent: AgentEntity;
|
||||
agentRoleId: string;
|
||||
runAsRoleId?: string;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
actorContext?: ActorMetadata;
|
||||
}): Promise<ToolSet> {
|
||||
@@ -157,7 +160,10 @@ export class AgentAsyncExecutorService {
|
||||
const toolProviderContext: ToolProviderContext = {
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId: agentRoleId,
|
||||
rolePermissionConfig: { intersectionOf: [agentRoleId] },
|
||||
rolePermissionConfig: buildAgentRolePermissionConfig({
|
||||
agentRoleId,
|
||||
runAsRoleId,
|
||||
}),
|
||||
requireExplicitObjectGrants: true,
|
||||
authContext,
|
||||
actorContext,
|
||||
@@ -179,19 +185,26 @@ export class AgentAsyncExecutorService {
|
||||
private async buildLazyRegistryTools({
|
||||
agent,
|
||||
agentRoleId,
|
||||
runAsRoleId,
|
||||
authContext,
|
||||
actorContext,
|
||||
}: {
|
||||
agent: AgentEntity;
|
||||
agentRoleId: string;
|
||||
runAsRoleId?: string;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
actorContext?: ActorMetadata;
|
||||
}): Promise<{ tools: ToolSet; catalogSection: string }> {
|
||||
const { userId, userWorkspaceId } = this.resolveUserIdentity(authContext);
|
||||
|
||||
const rolePermissionConfig = isDefined(runAsRoleId)
|
||||
? buildAgentRolePermissionConfig({ agentRoleId, runAsRoleId })
|
||||
: undefined;
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId: agentRoleId,
|
||||
rolePermissionConfig,
|
||||
authContext,
|
||||
actorContext,
|
||||
userId,
|
||||
@@ -201,7 +214,7 @@ export class AgentAsyncExecutorService {
|
||||
const fullCatalog = await this.toolRegistry.buildToolIndex(
|
||||
agent.workspaceId,
|
||||
agentRoleId,
|
||||
{ userId, userWorkspaceId },
|
||||
{ userId, userWorkspaceId, rolePermissionConfig },
|
||||
);
|
||||
|
||||
const allowedCategories = new Set(WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES);
|
||||
@@ -244,6 +257,7 @@ export class AgentAsyncExecutorService {
|
||||
authContext,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
runAsRoleId,
|
||||
operationType = UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||
toolLoadingStrategy = 'preload',
|
||||
}: {
|
||||
@@ -254,6 +268,7 @@ export class AgentAsyncExecutorService {
|
||||
authContext?: WorkspaceAuthContext;
|
||||
workspaceId: string;
|
||||
userWorkspaceId?: string | null;
|
||||
runAsRoleId?: string;
|
||||
operationType?: UsageOperationType;
|
||||
toolLoadingStrategy?: AgentToolLoadingStrategy;
|
||||
}): Promise<AgentExecutionResult> {
|
||||
@@ -317,6 +332,7 @@ export class AgentAsyncExecutorService {
|
||||
const lazyToolset = await this.buildLazyRegistryTools({
|
||||
agent,
|
||||
agentRoleId,
|
||||
runAsRoleId,
|
||||
authContext,
|
||||
actorContext,
|
||||
});
|
||||
@@ -327,6 +343,7 @@ export class AgentAsyncExecutorService {
|
||||
registryTools = await this.buildPreloadedRegistryTools({
|
||||
agent,
|
||||
agentRoleId,
|
||||
runAsRoleId,
|
||||
authContext,
|
||||
actorContext,
|
||||
});
|
||||
|
||||
+73
-3
@@ -5,13 +5,16 @@ import {
|
||||
type RunAgentMessage,
|
||||
type RunAgentResult,
|
||||
} from 'twenty-shared/application';
|
||||
import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { type RunAsWorkspaceMemberContext } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/run-as-workspace-member-context.type';
|
||||
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import {
|
||||
@@ -25,6 +28,7 @@ type RunAgentServiceInput = {
|
||||
agentUniversalIdentifier: string;
|
||||
prompt?: string | null;
|
||||
messages?: RunAgentMessage[] | null;
|
||||
runAsWorkspaceMemberId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -32,6 +36,7 @@ export class AgentRunService {
|
||||
private readonly logger = new Logger(AgentRunService.name);
|
||||
|
||||
constructor(
|
||||
private readonly agentActorContextService: AgentActorContextService,
|
||||
private readonly agentAsyncExecutorService: AgentAsyncExecutorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
@InjectWorkspaceScopedRepository(AgentEntity)
|
||||
@@ -41,10 +46,14 @@ export class AgentRunService {
|
||||
async run({
|
||||
workspace,
|
||||
requestUserWorkspaceId,
|
||||
requestWorkspaceMemberId,
|
||||
callerApplication,
|
||||
input,
|
||||
}: {
|
||||
workspace: FlatWorkspace;
|
||||
requestUserWorkspaceId: string | null;
|
||||
requestWorkspaceMemberId: string | null;
|
||||
callerApplication?: FlatApplication;
|
||||
input: RunAgentServiceInput;
|
||||
}): Promise<RunAgentResult> {
|
||||
const prompt = input.prompt;
|
||||
@@ -73,6 +82,16 @@ export class AgentRunService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(callerApplication) &&
|
||||
agent.applicationId !== callerApplication.id
|
||||
) {
|
||||
throw new AiException(
|
||||
`Agent ${input.agentUniversalIdentifier} belongs to another application`,
|
||||
AiExceptionCode.RUN_AGENT_NOT_ALLOWED,
|
||||
);
|
||||
}
|
||||
|
||||
const application = await this.applicationService.findById(
|
||||
agent.applicationId,
|
||||
);
|
||||
@@ -83,7 +102,15 @@ export class AgentRunService {
|
||||
);
|
||||
}
|
||||
|
||||
const authContext: WorkspaceAuthContext = {
|
||||
const runAsContext = await this.resolveRunAsContext({
|
||||
runAsWorkspaceMemberId: input.runAsWorkspaceMemberId,
|
||||
callerApplication,
|
||||
requestUserWorkspaceId,
|
||||
requestWorkspaceMemberId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const authContext: WorkspaceAuthContext = runAsContext?.authContext ?? {
|
||||
type: 'application',
|
||||
workspace,
|
||||
application,
|
||||
@@ -95,9 +122,12 @@ export class AgentRunService {
|
||||
agent,
|
||||
messages,
|
||||
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
|
||||
actorContext: runAsContext?.actorContext,
|
||||
authContext,
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId: requestUserWorkspaceId,
|
||||
userWorkspaceId:
|
||||
runAsContext?.authContext.userWorkspaceId ?? requestUserWorkspaceId,
|
||||
runAsRoleId: runAsContext?.roleId,
|
||||
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||
toolLoadingStrategy: 'lazy',
|
||||
},
|
||||
@@ -129,4 +159,44 @@ export class AgentRunService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRunAsContext({
|
||||
runAsWorkspaceMemberId,
|
||||
callerApplication,
|
||||
requestUserWorkspaceId,
|
||||
requestWorkspaceMemberId,
|
||||
workspaceId,
|
||||
}: {
|
||||
runAsWorkspaceMemberId?: string;
|
||||
callerApplication?: FlatApplication;
|
||||
requestUserWorkspaceId: string | null;
|
||||
requestWorkspaceMemberId: string | null;
|
||||
workspaceId: string;
|
||||
}): Promise<RunAsWorkspaceMemberContext | undefined> {
|
||||
if (!isDefined(runAsWorkspaceMemberId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isDefined(callerApplication)) {
|
||||
throw new AiException(
|
||||
'Running an agent as a workspace member requires an application access token',
|
||||
AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(requestUserWorkspaceId) &&
|
||||
requestWorkspaceMemberId !== runAsWorkspaceMemberId
|
||||
) {
|
||||
throw new AiException(
|
||||
'An application token issued for a user can only run an agent as that user',
|
||||
AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.agentActorContextService.buildRunAsWorkspaceMemberContext({
|
||||
workspaceMemberId: runAsWorkspaceMemberId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
|
||||
export type RunAsWorkspaceMemberContext = {
|
||||
actorContext: ActorMetadata;
|
||||
authContext: UserWorkspaceAuthContext;
|
||||
roleId: string;
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { buildAgentRolePermissionConfig } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/build-agent-role-permission-config.util';
|
||||
|
||||
describe('buildAgentRolePermissionConfig', () => {
|
||||
it('keeps the agent role alone when there is no run-as role', () => {
|
||||
expect(
|
||||
buildAgentRolePermissionConfig({ agentRoleId: 'agent-role-id' }),
|
||||
).toEqual({ intersectionOf: ['agent-role-id'] });
|
||||
});
|
||||
|
||||
it('intersects with the run-as role, agent role first', () => {
|
||||
expect(
|
||||
buildAgentRolePermissionConfig({
|
||||
agentRoleId: 'agent-role-id',
|
||||
runAsRoleId: 'run-as-role-id',
|
||||
}),
|
||||
).toEqual({ intersectionOf: ['agent-role-id', 'run-as-role-id'] });
|
||||
});
|
||||
|
||||
it('does not repeat the role when the member already has the agent role', () => {
|
||||
expect(
|
||||
buildAgentRolePermissionConfig({
|
||||
agentRoleId: 'agent-role-id',
|
||||
runAsRoleId: 'agent-role-id',
|
||||
}),
|
||||
).toEqual({ intersectionOf: ['agent-role-id'] });
|
||||
});
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export const buildAgentRolePermissionConfig = ({
|
||||
agentRoleId,
|
||||
runAsRoleId,
|
||||
}: {
|
||||
agentRoleId: string;
|
||||
runAsRoleId?: string;
|
||||
}): RolePermissionConfig => {
|
||||
if (!isDefined(runAsRoleId) || runAsRoleId === agentRoleId) {
|
||||
return { intersectionOf: [agentRoleId] };
|
||||
}
|
||||
|
||||
return { intersectionOf: [agentRoleId, runAsRoleId] };
|
||||
};
|
||||
@@ -21,6 +21,9 @@ export enum AiExceptionCode {
|
||||
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
|
||||
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
|
||||
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
|
||||
RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED = 'RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED',
|
||||
RUN_AS_WORKSPACE_MEMBER_NOT_FOUND = 'RUN_AS_WORKSPACE_MEMBER_NOT_FOUND',
|
||||
RUN_AGENT_NOT_ALLOWED = 'RUN_AGENT_NOT_ALLOWED',
|
||||
NO_FAILED_TURN_TO_RETRY = 'NO_FAILED_TURN_TO_RETRY',
|
||||
STREAM_INTERRUPTED = 'STREAM_INTERRUPTED',
|
||||
}
|
||||
@@ -59,6 +62,12 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
|
||||
return msg`Role not found.`;
|
||||
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
|
||||
return msg`This role cannot be assigned to agents.`;
|
||||
case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED:
|
||||
return msg`This action is not available for your request.`;
|
||||
case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND:
|
||||
return msg`Workspace member not found.`;
|
||||
case AiExceptionCode.RUN_AGENT_NOT_ALLOWED:
|
||||
return msg`This action is not available for your request.`;
|
||||
case AiExceptionCode.NO_FAILED_TURN_TO_RETRY:
|
||||
return msg`There is no failed message to retry.`;
|
||||
case AiExceptionCode.STREAM_INTERRUPTED:
|
||||
|
||||
+3
@@ -26,6 +26,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
|
||||
case AiExceptionCode.WORKSPACE_NOT_FOUND:
|
||||
case AiExceptionCode.MESSAGE_NOT_FOUND:
|
||||
case AiExceptionCode.ROLE_NOT_FOUND:
|
||||
case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_FOUND:
|
||||
throw new NotFoundError(error);
|
||||
case AiExceptionCode.CONTEXT_WINDOW_EXCEEDED:
|
||||
case AiExceptionCode.INVALID_AGENT_INPUT:
|
||||
@@ -38,6 +39,8 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
|
||||
throw new ConflictError(error);
|
||||
case AiExceptionCode.AGENT_IS_STANDARD:
|
||||
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
|
||||
case AiExceptionCode.RUN_AS_WORKSPACE_MEMBER_NOT_ALLOWED:
|
||||
case AiExceptionCode.RUN_AGENT_NOT_ALLOWED:
|
||||
throw new ForbiddenError(error);
|
||||
case AiExceptionCode.AGENT_EXECUTION_FAILED:
|
||||
case AiExceptionCode.API_KEY_NOT_CONFIGURED:
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RunAgentMessage = {
|
||||
|
||||
export type RunAgentInput = {
|
||||
agentUniversalIdentifier: string;
|
||||
runAsWorkspaceMemberId?: string;
|
||||
} & (
|
||||
| { prompt: string; messages?: never }
|
||||
| { messages: RunAgentMessage[]; prompt?: never }
|
||||
|
||||
Reference in New Issue
Block a user