From 4c544f908434f72422062de34070f9cd53131a16 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 3 Sep 2026 08:05:19 +0300 Subject: [PATCH 01/10] fix(agents): agent tools work with the account you gave them (#15223) --- packages/core/execution/src/lib/engine/rpc.ts | 11 +- .../src/lib/workers/worker-contract.ts | 3 + .../test/workers/long-running-rpc.test.ts | 8 ++ packages/core/shared/package.json | 2 +- .../test/ee/agent-tool-classification.test.ts | 19 ++++ .../redis/distributed-store-factory.ts | 5 + .../src/app/ee/agent/agent-approval-gate.ts | 25 +++-- .../ee/agent/agent-conversation-controller.ts | 33 ++++-- .../src/app/ee/agent/agent-rpc-handlers.ts | 62 +++++++---- .../app/ee/agent/tools/piece-tool-runner.ts | 40 +++++-- .../ee/agent/agent-approval-gate.test.ts | 102 ++++++++++++++++++ .../app/ee/agent/agent-rpc-handlers.test.ts | 86 ++++++++++++--- .../app/ee/agent/piece-tool-runner.test.ts | 51 +++++++-- .../jobs/ee/agent/agent-worker-tools.ts | 48 +++++++-- .../jobs/ee/agent/execute-agent-run.ts | 6 ++ .../jobs/ee/agent/agent-worker-tools.test.ts | 38 +++++++ .../components/action-preview-card.tsx | 14 +-- .../web/src/features/chat/lib/use-chat.ts | 16 ++- 18 files changed, 482 insertions(+), 87 deletions(-) create mode 100644 packages/core/execution/test/workers/long-running-rpc.test.ts create mode 100644 packages/server/api/test/integration/ee/agent/agent-approval-gate.test.ts diff --git a/packages/core/execution/src/lib/engine/rpc.ts b/packages/core/execution/src/lib/engine/rpc.ts index 501798364653..5e5f79959ebb 100644 --- a/packages/core/execution/src/lib/engine/rpc.ts +++ b/packages/core/execution/src/lib/engine/rpc.ts @@ -97,8 +97,14 @@ export function apErrorOf(error: unknown): RpcApError | undefined { if (!isObject(source) || typeof source['code'] !== 'string') { return undefined } - const entityType = (isObject(source['params']) ? source['params'] : source)['entityType'] - return { code: source['code'], ...spreadIfNotUndefined('entityType', typeof entityType === 'string' ? entityType : undefined) } + const params = isObject(source['params']) ? source['params'] : source + const entityType = params['entityType'] + const message = params['message'] + return { + code: source['code'], + ...spreadIfNotUndefined('entityType', typeof entityType === 'string' ? entityType : undefined), + ...spreadIfNotUndefined('message', typeof message === 'string' ? message : undefined), + } } function isRpcErrorEnvelope(value: unknown): value is { __rpcError: string, __rpcApError?: unknown } { @@ -110,6 +116,7 @@ export type RpcTimeout = number | ((method: string) => number) export type RpcApError = { code: string entityType?: string + message?: string } type RpcLog = { diff --git a/packages/core/execution/src/lib/workers/worker-contract.ts b/packages/core/execution/src/lib/workers/worker-contract.ts index 7dd10f021ae2..682e70e0bb15 100644 --- a/packages/core/execution/src/lib/workers/worker-contract.ts +++ b/packages/core/execution/src/lib/workers/worker-contract.ts @@ -227,6 +227,9 @@ export type ExecutePieceToolRequest = { export type ExecutePieceToolResponse = { result: unknown + resolvedInput: Record + actionDisplayName: string + connectionLabel?: string } export type ExecuteKnowledgeBaseToolRequest = { diff --git a/packages/core/execution/test/workers/long-running-rpc.test.ts b/packages/core/execution/test/workers/long-running-rpc.test.ts new file mode 100644 index 000000000000..728cf852ed48 --- /dev/null +++ b/packages/core/execution/test/workers/long-running-rpc.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { LONG_RUNNING_RPC_METHODS } from '../../src/lib/workers/worker-contract' + +describe('which RPC methods are given the long deadline', () => { + it.each(['executePieceTool', 'executeFlowTool', 'executeKnowledgeBaseTool', 'executeAgentTool'])('gives %s the long deadline', (method) => { + expect(LONG_RUNNING_RPC_METHODS).toContain(method) + }) +}) diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 0a595b6e648a..23bcac62cb82 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.156.0", + "version": "0.157.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/test/ee/agent-tool-classification.test.ts b/packages/core/shared/test/ee/agent-tool-classification.test.ts index 4f5dac66e115..37cea4372372 100644 --- a/packages/core/shared/test/ee/agent-tool-classification.test.ts +++ b/packages/core/shared/test/ee/agent-tool-classification.test.ts @@ -100,3 +100,22 @@ describe('agentToolClassification.requiresActionPreview — taint (untrusted con expect(agentToolClassification.requiresActionPreview({ actionName: 'send_channel_message', tainted: true })).toBe(true) }) }) + +describe('agentToolClassification.requiresActionPreview: an action whose name proves nothing', () => { + const UNMATCHED_WRITES = ['refund_payment', 'capture_payment_intent', 'cancel_subscription', 'add_row_to_sheet', 'upload_file', 'deactivate_user', 'archive_email', 'run_workflow'] + + it.each(UNMATCHED_WRITES)('asks before %s, which carries no write verb in its name', (actionName) => { + expect(agentToolClassification.requiresActionPreview({ actionName })).toBe(true) + }) + + it('asks before a custom api call that is not a provably safe method', () => { + expect(agentToolClassification.requiresActionPreview({ actionName: 'custom_api_call', input: { method: 'DELETE' } })).toBe(true) + expect(agentToolClassification.requiresActionPreview({ actionName: 'custom_api_call', input: {} })).toBe(true) + }) + + it('still lets a read through, so the asking stays worth reading', () => { + expect(agentToolClassification.requiresActionPreview({ actionName: 'gmail_search_mail' })).toBe(false) + expect(agentToolClassification.requiresActionPreview({ actionName: 'get_row' })).toBe(false) + expect(agentToolClassification.requiresActionPreview({ actionName: 'custom_api_call', input: { method: 'GET' } })).toBe(false) + }) +}) diff --git a/packages/server/api/src/app/database/redis/distributed-store-factory.ts b/packages/server/api/src/app/database/redis/distributed-store-factory.ts index bf54064e55ea..029d185067f4 100644 --- a/packages/server/api/src/app/database/redis/distributed-store-factory.ts +++ b/packages/server/api/src/app/database/redis/distributed-store-factory.ts @@ -107,6 +107,11 @@ export const distributedStoreFactory = (getRedisClient: () => Promise) => } }, + async removeField(key: string, field: string): Promise { + const redisClient = await getRedisClient() + await redisClient.hdel(key, field) + }, + async deleteKeyIfFieldValueMatches(key: string, field: string, expectedValue: unknown): Promise { const redisClient = await getRedisClient() const lua = ` diff --git a/packages/server/api/src/app/ee/agent/agent-approval-gate.ts b/packages/server/api/src/app/ee/agent/agent-approval-gate.ts index 0834b8e4b3b7..31652d2a213c 100644 --- a/packages/server/api/src/app/ee/agent/agent-approval-gate.ts +++ b/packages/server/api/src/app/ee/agent/agent-approval-gate.ts @@ -11,7 +11,7 @@ const CHANNEL_PREFIX = 'tool-approval:' const CANCEL_KEY_PREFIX = 'chat-cancel:' const AVAILABLE_CONNECTIONS_PREFIX = 'chat-conn-avail:' const SELECTED_CONNECTION_PREFIX = 'chat-conn-sel:' -const PENDING_GATE_PREFIX = 'chat-pending-gate:' +const PENDING_GATE_PREFIX = 'chat-pending-gate:v2:' function decisionKey(gateId: string): string { return `${KEY_PREFIX}${gateId}` @@ -25,13 +25,13 @@ async function resolveGate({ gateId, approved, payload, log }: { gateId: string, // Bind the decision to the exact inputs the user saw in the preview, so a consumer can verify // the action it's about to run matches what was approved (not a different payload reusing the id). const conversationId = await distributedStore.get(`${PENDING_GATE_PREFIX}gate:${gateId}`) - const pendingGate = conversationId ? await distributedStore.get(`${PENDING_GATE_PREFIX}${conversationId}`) : null - const approvedInput = pendingGate?.gateId === gateId ? pendingGate.toolInput : undefined + const pendingGate = conversationId ? (await readPendingGates({ conversationId }))[gateId] : undefined + const approvedInput = pendingGate?.toolInput const wasSet = await distributedStore.putIfAbsent(decisionKey(gateId), { approved, payload, approvedInput }, GATE_TTL_SECONDS) if (wasSet) { await pubsub.publish(channelName(gateId), JSON.stringify({ approved, payload })) if (conversationId) { - await distributedStore.delete(`${PENDING_GATE_PREFIX}${conversationId}`) + await distributedStore.removeField(`${PENDING_GATE_PREFIX}${conversationId}`, gateId) await distributedStore.delete(`${PENDING_GATE_PREFIX}gate:${gateId}`) } log?.info({ gate: { id: gateId }, decision: approved ? 'approved' : 'denied' }, '[agentApprovalGate] Gate decided') @@ -138,13 +138,21 @@ async function storePendingGate({ conversationId, gate }: { gate: PendingGate }): Promise { await Promise.all([ - distributedStore.put(`${PENDING_GATE_PREFIX}${conversationId}`, gate, GATE_TTL_SECONDS), + distributedStore.merge(`${PENDING_GATE_PREFIX}${conversationId}`, { [gate.gateId]: gate }, GATE_TTL_SECONDS), distributedStore.put(`${PENDING_GATE_PREFIX}gate:${gate.gateId}`, conversationId, GATE_TTL_SECONDS), ]) } -async function getPendingGate({ conversationId }: { conversationId: string }): Promise { - return distributedStore.get(`${PENDING_GATE_PREFIX}${conversationId}`) +async function readPendingGates({ conversationId }: { conversationId: string }): Promise> { + return await distributedStore.hgetJson>(`${PENDING_GATE_PREFIX}${conversationId}`) ?? {} +} + +async function getPendingGates({ conversationId }: { conversationId: string }): Promise { + return Object.values(await readPendingGates({ conversationId })) +} + +async function conversationIdForGate({ gateId }: { gateId: string }): Promise { + return distributedStore.get(`${PENDING_GATE_PREFIX}gate:${gateId}`) } async function clearPendingGate({ conversationId }: { conversationId: string }): Promise { @@ -163,7 +171,8 @@ export const agentApprovalGate = { storeSelectedConnection, getSelectedConnection, storePendingGate, - getPendingGate, + getPendingGates, + conversationIdForGate, clearPendingGate, } diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts index fb23b95d1c78..8796be65e162 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -219,6 +219,14 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => app.post('/tool-approvals/:gateId', ToolApprovalRoute, async (request, reply) => { request.log.info({ gate: { id: request.params.gateId }, approved: request.body.approved }, '[agentConversationController] Tool approval received') + const gateConversationId = await agentApprovalGate.conversationIdForGate({ gateId: request.params.gateId }) + if (!isNil(gateConversationId)) { + await agentConversationService(request.log).getConversationOrThrow({ + id: gateConversationId, + platformId: request.principal.platform.id, + userId: request.principal.id, + }) + } await agentApprovalGate.resolveGate({ gateId: request.params.gateId, approved: request.body.approved, @@ -255,12 +263,12 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => const platformId = request.principal.platform.id const userId = request.principal.id const conversation = await agentConversationService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) - const gate = await agentApprovalGate.getPendingGate({ conversationId }) - // A preempted run can leave (or race in) a pending gate keyed by conversation; only surface - // the gate when it belongs to the run that currently owns the conversation. - const gateRunId = gate?.runId - const staleGate = !isNil(gateRunId) && !isNil(conversation.activeRunId) && gateRunId !== conversation.activeRunId - return reply.status(StatusCodes.OK).send(staleGate ? null : gate) + const gates = await agentApprovalGate.getPendingGates({ conversationId }) + // A preempted run can leave (or race in) a pending gate; only surface one that belongs to + // the run that currently owns the conversation. A turn can open several at once, so the + // client is handed one at a time and asks again once it has been answered. + const ownedByThisRun = gates.filter((gate) => isNil(gate.runId) || isNil(conversation.activeRunId) || gate.runId === conversation.activeRunId) + return reply.status(StatusCodes.OK).send(ownedByThisRun[0] ?? null) }) app.get('/conversations/:id/connections', GetPickerConnectionsRoute, async (request, reply) => { @@ -269,13 +277,16 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => const userId = request.principal.id const conversation = await agentConversationService(request.log).getConversationOrThrow({ id: conversationId, platformId, userId }) const pieceName = request.query.pieceName - const pinned = await pinnedAccounts({ conversation, pieceName, platformId, userId, log: request.log }) - const cached = await agentApprovalGate.getAvailableConnections({ conversationId, pieceName }) - if (cached.length > 0) { + const [pinned, allProjects] = await Promise.all([ + pinnedAccounts({ conversation, pieceName, platformId, userId, log: request.log }), + agentHelpers.getUserProjects({ platformId, userId, log: request.log }), + ]) + const projects = isNil(pinned) ? allProjects : allProjects.filter((project) => project.id === pinned.projectId) + const { data: result } = await tryCatch(() => findConnectionsForPiece({ pieceName, projects, platformId, log: request.log })) + if (isNil(result)) { + const cached = await agentApprovalGate.getAvailableConnections({ conversationId, pieceName }) return reply.status(StatusCodes.OK).send(connectionOffer({ connections: cached, pinned })) } - const projects = await agentHelpers.getUserProjects({ platformId, userId, log: request.log }) - const result = await findConnectionsForPiece({ pieceName, projects, platformId, log: request.log }) if (!('pickConnection' in result)) { return reply.status(StatusCodes.OK).send(connectionOffer({ connections: [], pinned })) } diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index ccf3ce20f39c..21ef35ad2fc3 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -1,6 +1,6 @@ -import { ActivepiecesError, ErrorCode, isNil, Permission, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' +import { ActivepiecesError, connectionTemplate, ErrorCode, isNil, Permission, sanitizeObjectForPostgresql, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' -import { AgentConfigResponse, AgentConversation, AgentConversationStatus, AgentRunSource, agentToolClassification, ExecuteAgentToolRequest, ExecuteAgentToolResponse, ExecuteFlowToolRequest, ExecuteFlowToolResponse, ExecuteKnowledgeBaseToolRequest, ExecuteKnowledgeBaseToolResponse, ExecutePieceToolRequest, ExecutePieceToolResponse, FileCompression, FileType, FlowActionType, flowStructureUtil, GetAgentConfigRequest, GetEnabledAiToolsResponse, HeartbeatAgentConversationRequest, PersistedAgentMessage, PersistedAgentPartType, PersistedAgentRole, ResumeFlowStepRequest, SaveAgentFileRequest, SaveAgentFileResponse, SaveAgentMessagesRequest, SendAgentEmailRequest, SendAgentEmailResponse, UpdateAgentProgressRequest, UpdateFlowStepProgressRequest, UpdateProjectContextRequest } from '@activepieces/shared' +import { AgentConfigResponse, AgentConversation, AgentConversationStatus, AgentPieceToolMetadata, AgentRunSource, agentToolClassification, ExecuteAgentToolRequest, ExecuteAgentToolResponse, ExecuteFlowToolRequest, ExecuteFlowToolResponse, ExecuteKnowledgeBaseToolRequest, ExecuteKnowledgeBaseToolResponse, ExecutePieceToolRequest, ExecutePieceToolResponse, FileCompression, FileType, FlowActionType, flowStructureUtil, GetAgentConfigRequest, GetEnabledAiToolsResponse, HeartbeatAgentConversationRequest, PersistedAgentMessage, PersistedAgentPartType, PersistedAgentRole, ResumeFlowStepRequest, SaveAgentFileRequest, SaveAgentFileResponse, SaveAgentMessagesRequest, SendAgentEmailRequest, SendAgentEmailResponse, UpdateAgentProgressRequest, UpdateFlowStepProgressRequest, UpdateProjectContextRequest } from '@activepieces/shared' import { embed, ModelMessage } from 'ai' import { FastifyBaseLogger } from 'fastify' import { aiToolConfigService } from '../../ai/ai-tool-config-service' @@ -479,28 +479,30 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ }, async executePieceTool(input: ExecutePieceToolRequest): Promise { - const conversation = await agentHelpers.conversationRepo().findOneBy({ id: input.conversationId }) - if (isNil(conversation) || !CONFIGURED_TOOL_SOURCES.includes(conversation.source) || isNil(conversation.projectId)) { - throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to run a configured piece tool' } }) - } - const { projectId, platformId } = conversation + const { projectId, platformId } = await configuredToolConversationOrThrow({ conversationId: input.conversationId }) const model = await agentHelpers.resolveFastModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) - const { data: run, error: runError } = await tryCatch(() => pieceToolRunner.runFromInstruction({ - model, - piece: { pieceName: input.piece.pieceName, actionName: input.piece.actionName, pieceVersion: input.piece.pieceVersion }, - instruction: input.instruction, - projectId, - platformId, - log, - ...spreadIfDefined('predefinedInput', input.piece.predefinedInput), - })) + const piece = { pieceName: input.piece.pieceName, actionName: input.piece.actionName, ...spreadIfDefined('pieceVersion', input.piece.pieceVersion) } + const connection = await connectionForConfiguredTool({ piece: input.piece, projectId, platformId, log }) + const { data: run, error: runError } = await tryCatch(async () => { + const { resolvedInput, actionDisplayName } = await pieceToolRunner.resolveInput({ + model, + piece, + instruction: input.instruction, + projectId, + platformId, + log, + ...spreadIfDefined('predefinedInput', input.piece.predefinedInput), + ...spreadIfDefined('connectionExternalId', connection.externalId), + }) + const { result } = await pieceToolRunner.runResolved({ piece, resolvedInput, projectId, log }) + return { result, resolvedInput: pieceToolRunner.withoutCredential(resolvedInput), actionDisplayName } + }) if (!isNil(runError) || isNil(run)) { log.error({ error: runError, tool: { name: input.toolName }, piece: { name: input.piece.pieceName, version: input.piece.pieceVersion ?? null }, action: { name: input.piece.actionName } }, '[agentRpc#executePieceTool] Configured action could not run') throw runError } - const { result, resolvedInput } = run - log.info({ conversation: { id: input.conversationId }, tool: { name: input.toolName, input: resolvedInput }, connection: { externalId: input.piece.predefinedInput?.auth }, piece: { name: input.piece.pieceName } }, '[agentRpc#executePieceTool] Ran a configured piece action') - return { result } + log.info({ conversation: { id: input.conversationId }, tool: { name: input.toolName, input: run.resolvedInput }, connection: { externalId: connection.externalId ?? null }, piece: { name: input.piece.pieceName } }, '[agentRpc#executePieceTool] Ran a configured piece action') + return { result: run.result, resolvedInput: run.resolvedInput, actionDisplayName: run.actionDisplayName, ...spreadIfDefined('connectionLabel', connection.label) } }, async executeKnowledgeBaseTool(input: ExecuteKnowledgeBaseToolRequest): Promise { @@ -788,6 +790,28 @@ function emailApprovalMatches({ approvedInput, recipients, subject, body }: { return sameRecipients && approvedInput.subject === subject && approvedInput.body === body } +async function connectionForConfiguredTool({ piece, projectId, platformId, log }: { + piece: AgentPieceToolMetadata + projectId: string + platformId: string + log: FastifyBaseLogger +}): Promise<{ externalId?: string, label?: string }> { + const pinned = connectionTemplate.unwrapExternalId(piece.predefinedInput?.auth) ?? undefined + if (isNil(pinned)) { + return {} + } + const connection = await appConnectionService(log).getOneWithoutValue({ projectId, platformId, externalId: pinned }) + return { externalId: pinned, ...spreadIfDefined('label', connection?.displayName) } +} + +async function configuredToolConversationOrThrow({ conversationId }: { conversationId: string }): Promise<{ projectId: string, platformId: string }> { + const conversation = await agentHelpers.conversationRepo().findOneBy({ id: conversationId }) + if (isNil(conversation) || !CONFIGURED_TOOL_SOURCES.includes(conversation.source) || isNil(conversation.projectId)) { + throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to run a configured piece tool' } }) + } + return { projectId: conversation.projectId, platformId: conversation.platformId } +} + async function loadOrStartConversation({ conversationId, platformId, userId, source, projectId, modelName }: { conversationId: string platformId: string diff --git a/packages/server/api/src/app/ee/agent/tools/piece-tool-runner.ts b/packages/server/api/src/app/ee/agent/tools/piece-tool-runner.ts index 89b23c5db20d..187967da1720 100644 --- a/packages/server/api/src/app/ee/agent/tools/piece-tool-runner.ts +++ b/packages/server/api/src/app/ee/agent/tools/piece-tool-runner.ts @@ -1,5 +1,5 @@ import { PredefinedInputsStructure } from '@activepieces/core-piece-types' -import { ActivepiecesError, ErrorCode, isNil } from '@activepieces/core-utils' +import { ActivepiecesError, connectionTemplate, ErrorCode, isNil, spreadIfDefined } from '@activepieces/core-utils' import { McpToolResult } from '@activepieces/shared' import { LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' @@ -8,12 +8,13 @@ import { mcpUtils } from '../../../mcp/tools/mcp-utils' import { pieceMetadataService } from '../../../pieces/metadata/piece-metadata-service' import { pieceInputFiller, ResolveProperty } from './piece-input-filler' -async function runFromInstruction({ piece, instruction, predefinedInput, model, projectId, platformId, connectionExternalId, log }: RunFromInstructionParams): Promise { - const { properties, pieceVersion } = await resolveAction({ piece, platformId, log }) +async function resolveInput({ piece, instruction, predefinedInput, model, projectId, platformId, connectionExternalId, log }: ResolveInputParams): Promise { + const { properties, pieceVersion, actionDisplayName } = await resolveAction({ piece, platformId, log }) + const account = connectionExternalId ?? connectionTemplate.unwrapExternalId(predefinedInput?.auth) ?? undefined const resolvedInput = await pieceInputFiller.fillInput({ - action: { name: piece.actionName, properties, ...(isNil(connectionExternalId) ? {} : { connectionExternalId }) }, + action: { name: piece.actionName, properties, ...spreadIfDefined('connectionExternalId', account) }, instruction, - ...(isNil(predefinedInput) ? {} : { predefinedInput }), + ...(isNil(predefinedInput) && isNil(account) ? {} : { predefinedInput: { fields: predefinedInput?.fields ?? {}, ...spreadIfDefined('auth', account) } }), ports: { resolveProperty: propertyResolverFor({ piece, pieceVersion, projectId, platformId, log }), completeObject: pieceInputFiller.modelCompleter(model), @@ -22,6 +23,20 @@ async function runFromInstruction({ piece, instruction, predefinedInput, model, assertUrlStaysOnThePieceHost({ actionName: piece.actionName, input: resolvedInput }) + return { resolvedInput, actionDisplayName } +} + +function withoutCredential(input: Record): Record { + return { ...input, ...(isNil(input.auth) ? {} : { auth: REDACTED_AUTH }) } +} + +async function runResolved({ piece, resolvedInput, projectId, connectionExternalId, log }: { + piece: PieceActionRef + resolvedInput: Record + projectId: string + connectionExternalId?: string + log: FastifyBaseLogger +}): Promise { const result = await executePieceActionRun({ projectId, pieceName: piece.pieceName, @@ -31,7 +46,7 @@ async function runFromInstruction({ piece, instruction, predefinedInput, model, ...(isNil(connectionExternalId) ? {} : { connectionExternalId }), }) - return { result, resolvedInput: { ...resolvedInput, ...(isNil(resolvedInput.auth) ? {} : { auth: REDACTED_AUTH }) } } + return { result, resolvedInput: withoutCredential(resolvedInput) } } async function resolveAction({ piece, platformId, log }: { piece: PieceActionRef, platformId: string, log: FastifyBaseLogger }) { @@ -47,7 +62,7 @@ async function resolveAction({ piece, platformId, log }: { piece: PieceActionRef params: { entityType: 'PieceAction', entityId: `${piece.pieceName}:${piece.actionName}` }, }) } - return { properties: action.props, pieceVersion: metadata.version } + return { properties: action.props, pieceVersion: metadata.version, actionDisplayName: action.displayName } } function propertyResolverFor({ piece, pieceVersion, projectId, platformId, log }: { @@ -92,7 +107,9 @@ const ABSOLUTE_URL = /^https?:\/\//i const REDACTED_AUTH = 'Redacted' export const pieceToolRunner = { - runFromInstruction, + resolveInput, + runResolved, + withoutCredential, } export type PieceActionRef = { @@ -101,7 +118,7 @@ export type PieceActionRef = { pieceVersion?: string } -export type RunFromInstructionParams = { +export type ResolveInputParams = { piece: PieceActionRef instruction: string predefinedInput?: PredefinedInputsStructure @@ -112,6 +129,11 @@ export type RunFromInstructionParams = { log: FastifyBaseLogger } +export type ResolvedPieceInput = { + resolvedInput: Record + actionDisplayName: string +} + export type PieceToolRun = { result: McpToolResult resolvedInput: Record diff --git a/packages/server/api/test/integration/ee/agent/agent-approval-gate.test.ts b/packages/server/api/test/integration/ee/agent/agent-approval-gate.test.ts new file mode 100644 index 000000000000..7e5ff8b05bfa --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-approval-gate.test.ts @@ -0,0 +1,102 @@ +import { AgentRunSource, apId, DefaultProjectRole } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentApprovalGate } from '../../../../src/app/ee/agent/agent-approval-gate' +import { db } from '../../../helpers/db' +import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +beforeAll(async () => { + process.env.AP_AGENTS_ENABLED = 'true' + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function conversation(ctx: TestContext): Promise { + const conversationId = apId() + await db.save('agent_conversation', { + id: conversationId, + created: new Date().toISOString(), + updated: new Date().toISOString(), + platformId: ctx.platform.id, + projectId: ctx.project.id, + userId: ctx.user.id, + source: AgentRunSource.AGENT, + status: 'STREAMING', + messages: [], + uiMessages: [], + }) + return conversationId +} + +async function openGate({ conversationId, gateId, actionName }: { conversationId: string, gateId: string, actionName: string }): Promise { + await agentApprovalGate.storePendingGate({ + conversationId, + gate: { gateId, toolName: `slack-${actionName}`, displayName: actionName, toolInput: { pieceName: '@activepieces/piece-slack', actionName, input: { to: 'jane@customer.com' } } }, + }) +} + +async function conversationWithPendingGate(ctx: TestContext): Promise { + const conversationId = await conversation(ctx) + const gateId = `gate-${conversationId}` + await openGate({ conversationId, gateId, actionName: 'send_email' }) + return gateId +} + +describe('approving an agent action belongs to the person it was shown to', () => { + it('lets the conversation owner approve their own pending action', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const gateId = await conversationWithPendingGate(ctx) + + const response = await ctx.post(`/v1/agents/tool-approvals/${gateId}`, { approved: true }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + it('refuses someone else in the platform approving an action they were never shown', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const gateId = await conversationWithPendingGate(ctx) + const other = await createMemberContext(app, ctx, { projectRole: DefaultProjectRole.ADMIN }) + + const response = await other.post(`/v1/agents/tool-approvals/${gateId}`, { approved: true }) + + expect(response.statusCode).not.toBe(StatusCodes.OK) + expect(await agentApprovalGate.checkDecision({ gateId })).toBe('pending') + }) +}) + +describe('a turn that opens several actions at once keeps a card for each', () => { + it('hands over the second action once the first has been answered', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const conversationId = await conversation(ctx) + await openGate({ conversationId, gateId: 'gate-first', actionName: 'set_user_status' }) + await openGate({ conversationId, gateId: 'gate-second', actionName: 'send_channel_message' }) + + const both = await agentApprovalGate.getPendingGates({ conversationId }) + expect(both.map((gate) => gate.gateId).sort()).toEqual(['gate-first', 'gate-second']) + + await agentApprovalGate.resolveGate({ gateId: 'gate-first', approved: true }) + + const left = await agentApprovalGate.getPendingGates({ conversationId }) + expect(left.map((gate) => gate.gateId)).toEqual(['gate-second']) + }) + + it('binds each decision to the action it was shown for, not to the other one', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const conversationId = await conversation(ctx) + await openGate({ conversationId, gateId: 'gate-status', actionName: 'set_user_status' }) + await openGate({ conversationId, gateId: 'gate-message', actionName: 'send_channel_message' }) + + await agentApprovalGate.resolveGate({ gateId: 'gate-status', approved: true }) + const decision = await agentApprovalGate.checkDecision({ gateId: 'gate-status' }) + + expect(decision !== 'pending' && decision.approvedInput?.actionName).toBe('set_user_status') + expect(await agentApprovalGate.checkDecision({ gateId: 'gate-message' })).toBe('pending') + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts index 896a17cd3b6b..e87241a6ee12 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts @@ -51,8 +51,17 @@ vi.mock('../../../../../src/app/ee/agent/agent-approval-gate', () => ({ agentApprovalGate: {}, })) -const { mockRunFromInstruction, mockUpdateStepProgress } = vi.hoisted(() => ({ - mockRunFromInstruction: vi.fn().mockResolvedValue({ result: { ok: true }, resolvedInput: {} }), +const { mockGetOneWithoutValue } = vi.hoisted(() => ({ + mockGetOneWithoutValue: vi.fn().mockResolvedValue(null), +})) + +vi.mock('../../../../../src/app/app-connection/app-connection-service/app-connection-service', () => ({ + appConnectionService: () => ({ getOneWithoutValue: mockGetOneWithoutValue }), +})) + +const { mockRunResolved, mockResolveInput, mockUpdateStepProgress } = vi.hoisted(() => ({ + mockRunResolved: vi.fn().mockResolvedValue({ result: { ok: true }, resolvedInput: {} }), + mockResolveInput: vi.fn().mockResolvedValue({ resolvedInput: { to: 'jane@customer.com' }, actionDisplayName: 'Send Email' }), mockUpdateStepProgress: vi.fn(), })) @@ -61,7 +70,7 @@ vi.mock('../../../../../src/app/flows/flow-run/engine-run-callback-service', () })) vi.mock('../../../../../src/app/ee/agent/tools/piece-tool-runner', () => ({ - pieceToolRunner: { runFromInstruction: mockRunFromInstruction }, + pieceToolRunner: { runResolved: mockRunResolved, resolveInput: mockResolveInput, withoutCredential: (input: Record) => input }, })) const { mockGetOnePopulated } = vi.hoisted(() => ({ @@ -385,38 +394,46 @@ describe('agentRpcHandlers.executeAgentTool — the owner\'s own memory is not a }) }) -describe('agentRpcHandlers.executePieceTool — only a flow-step run may run a configured action', () => { +describe('agentRpcHandlers.executePieceTool — a configured action runs in its own project', () => { + const GMAIL_SEND = { pieceName: '@activepieces/piece-gmail', actionName: 'send_email', pieceVersion: '0.1.0' } + async function runPieceTool(conversation: unknown) { - mockRunFromInstruction.mockClear() + mockRunResolved.mockClear() + mockResolveInput.mockClear() mockFindOneBy.mockResolvedValue(conversation) + mockGetOneWithoutValue.mockResolvedValue({ id: 'ac-1', externalId: 'conn-1', displayName: 'Sales Inbox' }) const { agentRpcHandlers } = await import('../../../../../src/app/ee/agent/agent-rpc-handlers') return agentRpcHandlers(noopLogger as never).executePieceTool({ conversationId: 'conv-1', toolName: 'send_email', instruction: 'email the summary', - piece: { pieceName: '@activepieces/piece-gmail', pieceVersion: '0.1.0', actionName: 'send_email' }, + piece: { ...GMAIL_SEND, predefinedInput: { auth: 'conn-1', fields: {} } }, }) } it('runs the action in the conversation\'s own project', async () => { await runPieceTool({ id: 'conv-1', source: 'FLOW_STEP', projectId: 'proj-1', platformId: 'plat-1' }) - expect(mockRunFromInstruction).toHaveBeenCalledTimes(1) - const call = mockRunFromInstruction.mock.calls[0][0] - expect(call.projectId).toBe('proj-1') - expect(call.piece).toEqual({ pieceName: '@activepieces/piece-gmail', actionName: 'send_email', pieceVersion: '0.1.0' }) + expect(mockRunResolved).toHaveBeenCalledTimes(1) + expect(mockRunResolved.mock.calls[0][0].projectId).toBe('proj-1') + }) + + it('names the account it ran as, so the receipt can say whose it was', async () => { + const response = await runPieceTool({ id: 'conv-1', source: 'AGENT', projectId: 'proj-1', platformId: 'plat-1' }) + + expect(response.connectionLabel).toBe('Sales Inbox') }) it('refuses when the conversation is a chat', async () => { await expect(runPieceTool({ id: 'conv-1', source: 'CHAT', projectId: 'proj-1' })).rejects.toThrow() - expect(mockRunFromInstruction).not.toHaveBeenCalled() + expect(mockRunResolved).not.toHaveBeenCalled() }) it('refuses a flow-step run with no project, so the action is never run unscoped', async () => { await expect(runPieceTool({ id: 'conv-1', source: 'FLOW_STEP', projectId: null })).rejects.toThrow() - expect(mockRunFromInstruction).not.toHaveBeenCalled() + expect(mockRunResolved).not.toHaveBeenCalled() }) }) @@ -601,3 +618,48 @@ describe('agentRpcHandlers.executeKnowledgeBaseTool — an oversized embedding i expect(mockKbSearch).toHaveBeenCalledWith(expect.objectContaining({ queryEmbedding: expect.objectContaining({ length: 768 }) })) }) }) + +describe('agentRpcHandlers.executePieceTool — which account a configured action runs as', () => { + const AGENT_CHAT = { id: 'conv-1', source: 'AGENT', projectId: 'proj-1', platformId: 'plat-1' } + const PINNED = 'conn-author-pinned' + + async function run({ pinnedExists, pinnedAuth = PINNED }: { pinnedExists: boolean, pinnedAuth?: string }) { + mockResolveInput.mockClear() + mockGetOneWithoutValue.mockClear() + mockFindOneBy.mockResolvedValue(AGENT_CHAT) + mockGetOneWithoutValue.mockResolvedValue(pinnedExists ? { id: 'ac-1', externalId: PINNED, displayName: 'Sales Inbox' } : null) + const { agentRpcHandlers } = await import('../../../../../src/app/ee/agent/agent-rpc-handlers') + const response = await agentRpcHandlers(noopLogger as never).executePieceTool({ + conversationId: 'conv-1', + toolName: 'gmail-send_email', + instruction: 'email the summary', + piece: { pieceName: '@activepieces/piece-gmail', pieceVersion: '0.1.0', actionName: 'send_email', predefinedInput: { auth: pinnedAuth, fields: {} } }, + }) + return { call: mockResolveInput.mock.calls[0][0], response, lookup: mockGetOneWithoutValue.mock.calls[0]?.[0] } + } + + it('hands the pinned account to dynamic property resolution, so a dropdown has one to list options with', async () => { + const { call } = await run({ pinnedExists: true }) + + expect(call.connectionExternalId).toBe(PINNED) + }) + + it('looks the account up inside the conversation project only', async () => { + const { lookup } = await run({ pinnedExists: true }) + + expect(lookup).toMatchObject({ projectId: 'proj-1', platformId: 'plat-1', externalId: PINNED }) + }) + + it('still runs as the pinned account when it cannot be named, rather than picking another one', async () => { + const { call, response } = await run({ pinnedExists: false }) + + expect(call.connectionExternalId).toBe(PINNED) + expect(response.connectionLabel).toBeUndefined() + }) + + it('asks for no account when the author pinned none', async () => { + const { call } = await run({ pinnedExists: false, pinnedAuth: '' }) + + expect(call.connectionExternalId).toBeUndefined() + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/piece-tool-runner.test.ts b/packages/server/api/test/unit/app/ee/agent/piece-tool-runner.test.ts index 7c00c357fcdf..70d39b0908e8 100644 --- a/packages/server/api/test/unit/app/ee/agent/piece-tool-runner.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/piece-tool-runner.test.ts @@ -32,9 +32,14 @@ function metadataWith(props: Record) { return { version: '1.4.0', actions: { send_message: { props } } } } -async function run(overrides: Record = {}) { +async function prepareAndRun(params: Record) { const { pieceToolRunner } = await import('../../../../../src/app/ee/agent/tools/piece-tool-runner') - return pieceToolRunner.runFromInstruction({ + const { resolvedInput } = await pieceToolRunner.resolveInput(params as never) + return pieceToolRunner.runResolved({ piece: params.piece as never, resolvedInput, projectId: params.projectId as string, log: log as never }) +} + +async function run(overrides: Record = {}) { + return prepareAndRun({ piece: { pieceName: '@activepieces/piece-slack', actionName: 'send_message' }, instruction: 'say hello in general', model: {} as never, @@ -45,7 +50,7 @@ async function run(overrides: Record = {}) { } as never) } -describe('pieceToolRunner.runFromInstruction', () => { +describe('pieceToolRunner: preparing then running a configured action', () => { beforeEach(() => { vi.clearAllMocks() mockGetOrThrow.mockResolvedValue(metadataWith({ text: { displayName: 'Text', required: true, type: PropertyType.SHORT_TEXT } })) @@ -124,7 +129,7 @@ describe('pieceToolRunner.runFromInstruction', () => { }) }) -describe('pieceToolRunner.runFromInstruction — a custom API call stays on the connection\'s own host', () => { +describe('pieceToolRunner: a custom API call stays on the connection\'s own host', () => { beforeEach(() => { vi.clearAllMocks() mockGetOrThrow.mockResolvedValue({ version: '1.4.0', actions: { custom_api_call: { props: { url: { displayName: 'URL', required: true, type: PropertyType.SHORT_TEXT } } } } }) @@ -133,8 +138,7 @@ describe('pieceToolRunner.runFromInstruction — a custom API call stays on the async function callWithUrl(url: unknown) { mockCompleter.mockResolvedValue({ url }) - const { pieceToolRunner } = await import('../../../../../src/app/ee/agent/tools/piece-tool-runner') - return pieceToolRunner.runFromInstruction({ + return prepareAndRun({ piece: { pieceName: '@activepieces/piece-slack', actionName: 'custom_api_call' }, instruction: 'call the api', model: {} as never, @@ -162,3 +166,38 @@ describe('pieceToolRunner.runFromInstruction — a custom API call stays on the expect(mockExecutePieceActionRun).toHaveBeenCalledTimes(1) }) }) + +describe('pieceToolRunner.resolveInput: which account fills the input and lists the options', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOrThrow.mockResolvedValue(metadataWith({ text: { displayName: 'Text', required: true, type: PropertyType.SHORT_TEXT } })) + mockCompleter.mockResolvedValue({ text: 'hello' }) + mockExecutePieceActionRun.mockResolvedValue({ content: [{ type: 'text', text: 'sent' }] }) + }) + + async function resolve(overrides: Record) { + const { pieceToolRunner } = await import('../../../../../src/app/ee/agent/tools/piece-tool-runner') + const { resolvedInput } = await pieceToolRunner.resolveInput({ + piece: { pieceName: '@activepieces/piece-slack', actionName: 'send_message' }, + instruction: 'say hello in general', + model: {} as never, + projectId: 'proj-1', + platformId: 'plat-1', + log: log as never, + ...overrides, + } as never) + return resolvedInput + } + + it('runs as the pinned account when nothing overrides it', async () => { + expect(await resolve({ predefinedInput: { auth: 'conn-pinned', fields: {} } })).toMatchObject({ auth: 'conn-pinned' }) + }) + + it('runs as the overriding account, and the pin does not win it back', async () => { + expect(await resolve({ predefinedInput: { auth: 'conn-pinned', fields: {} }, connectionExternalId: 'conn-override' })).toMatchObject({ auth: 'conn-override' }) + }) + + it('runs as the overriding account when the author pinned none', async () => { + expect(await resolve({ connectionExternalId: 'conn-override' })).toMatchObject({ auth: 'conn-override' }) + }) +}) diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts index 073b10a8eb6a..10bac83701a0 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts @@ -1,6 +1,6 @@ import { chunk, isNil, isObject, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { largeResultUtils, MAX_TOOL_RESULT_BYTES, safeHttp } from '@activepieces/server-utils' -import { ActionPreviewEvent, ActionReceiptEvent, AgentEventType, AgentKnowledgeBaseTool, AgentOutputField, AgentOutputFieldType, AgentPhase, AgentPieceTool, AgentPieceToolMetadata, agentToolClassification, apId, BatchItemResult, BuildPlanEvent, FileProducedEvent, ImageGeneratedEvent, KnowledgeBaseSourceType, ResolvedAgentFlowTool, SaveAgentFileResponse, SendAgentEmailResponse, SendAgentEventRequest, TASK_COMPLETION_TOOL_NAME, ToolProgressEvent } from '@activepieces/shared' +import { ActionPreviewEvent, ActionReceiptEvent, AgentEventType, AgentKnowledgeBaseTool, AgentOutputField, AgentOutputFieldType, AgentPhase, AgentPieceTool, AgentPieceToolMetadata, agentToolClassification, apErrorOf, apId, BatchItemResult, BuildPlanEvent, ExecutePieceToolResponse, FileProducedEvent, ImageGeneratedEvent, KnowledgeBaseSourceType, ResolvedAgentFlowTool, SaveAgentFileResponse, SendAgentEmailResponse, SendAgentEventRequest, TASK_COMPLETION_TOOL_NAME, ToolProgressEvent } from '@activepieces/shared' import { jsonSchema, JSONSchema7, tool, ToolExecutionOptions, ToolSet } from 'ai' import { FastifyBaseLogger } from 'fastify' import { stripHtml } from 'string-strip-html' @@ -155,21 +155,36 @@ function gateNoResponseMessage(step: string): string { return `⏳ The user hasn't responded to the ${step} yet (it timed out) — they did NOT decline, they're just away. Decide based on how essential this step is: if the task can continue without it, skip only this step, keep going, and briefly tell the user what you skipped and why. If it is required to proceed, stop here and tell the user this step needs their approval — ask them to approve it to continue. Never assume approval or perform the gated action on your own.` } -function createDisplayTools({ waitForApproval, displayToolTimeoutMs, onConnectionSelected, onConnectorReconnected, onGateOpened }: { +function createDisplayTools({ waitForApproval, displayToolTimeoutMs, onConnectionSelected, onConnectorReconnected, onGateOpened, accountAlreadyChosenFor }: { waitForApproval: (params: { gateId: string, timeoutMs?: number }) => Promise displayToolTimeoutMs: number onConnectionSelected?: (params: { pieceName: string, connectionExternalId: string, label: string, projectId: string }) => Promise onConnectorReconnected?: (connectorUuid: string) => void onGateOpened?: (params: { gateId: string, toolName: string, displayName: string, toolInput: Record }) => Promise + accountAlreadyChosenFor?: (pieceName: string) => boolean }): ToolSet { - function blockingExecute({ dismissMessage, successKey, toolName, getDisplayName, onApproved }: { + function refuseIfAccountAlreadyChosen(input: Record): { content: { type: string, text: string }[] } | undefined { + const piece = typeof input['piece'] === 'string' ? input['piece'] : '' + if (isNil(accountAlreadyChosenFor) || !accountAlreadyChosenFor(normalizePieceName(piece))) { + return undefined + } + const displayName = typeof input['displayName'] === 'string' ? input['displayName'] : piece + return { content: [{ type: 'text', text: `This agent already runs on the ${displayName} account its author chose, so there is nothing to connect or reconnect here and this card was not shown. Use the ${displayName} tool. If it fails, say exactly what failed — do not describe it as a connection problem unless the failure says the credentials were rejected.` }] } + } + + function blockingExecute({ dismissMessage, successKey, toolName, getDisplayName, onApproved, refuseWhen }: { dismissMessage: string | ((input: Record) => string) successKey?: string toolName: string getDisplayName?: (input: Record) => string onApproved?: (params: { input: Record, payload?: Record }) => Promise> + refuseWhen?: (input: Record) => { content: { type: string, text: string }[] } | undefined }) { return async (input: Record, options: ToolExecutionOptions) => { + const refusal = refuseWhen?.(input) + if (!isNil(refusal)) { + return refusal + } if (onGateOpened) { const fallbackName = typeof input['displayName'] === 'string' ? input['displayName'] : toolName await tryCatch(() => onGateOpened({ @@ -203,6 +218,7 @@ function createDisplayTools({ waitForApproval, displayToolTimeoutMs, onConnectio }), execute: blockingExecute({ toolName: 'ap_show_connection_required', + refuseWhen: refuseIfAccountAlreadyChosen, dismissMessage: 'The user chose not to connect this service. Stop and ask: "Would you like me to continue building with a placeholder you can connect later, or would you prefer to stop here?"', onApproved: async ({ input, payload = {} }) => { const connectionExternalId = payload['connectionExternalId'] @@ -252,6 +268,7 @@ function createDisplayTools({ waitForApproval, displayToolTimeoutMs, onConnectio }), execute: blockingExecute({ toolName: 'ap_show_connection_picker', + refuseWhen: refuseIfAccountAlreadyChosen, dismissMessage: (input) => `The user chose not to select a ${typeof input['displayName'] === 'string' ? input['displayName'] : 'service'} account. Do not pick one on their behalf. Ask: "Would you like me to continue building with a placeholder you can connect later, or would you prefer to stop here?"`, onApproved: async ({ input, payload = {} }) => { const connectionExternalId = payload['connectionExternalId'] @@ -1428,9 +1445,11 @@ export type AgentEventEmitter = { emitBuildPlan(data: BuildPlanEvent): void } -function createConfiguredPieceTools({ tools, runPieceTool, log }: { +function createConfiguredPieceTools({ tools, runPieceTool, taintState, eventEmitter, log }: { tools: AgentPieceTool[] - runPieceTool: (input: { toolName: string, instruction: string, piece: AgentPieceToolMetadata }) => Promise<{ result: unknown }> + runPieceTool: (input: { toolName: string, instruction: string, piece: AgentPieceToolMetadata }) => Promise + taintState: TaintState + eventEmitter: AgentEventEmitter log: FastifyBaseLogger }): ToolSet { let callsMade = 0 @@ -1441,7 +1460,7 @@ function createConfiguredPieceTools({ tools, runPieceTool, log }: { inputSchema: z.object({ instruction: z.string().describe('What this action should do, including any values it needs, in plain language'), }), - execute: async ({ instruction }) => { + execute: async ({ instruction }, options) => { callsMade += 1 if (callsMade > MAX_CONFIGURED_TOOL_CALLS) { log.warn({ tool: { name: configured.toolName }, callsMade }, '[configuredPieceTool] Refused, this run has already run enough actions') @@ -1452,10 +1471,23 @@ function createConfiguredPieceTools({ tools, runPieceTool, log }: { const reachedTheServer = String(error).includes('handler threw') log.warn({ error, tool: { name: configured.toolName }, reachedTheServer }, '[configuredPieceTool] Action did not return a result') return { content: [{ type: 'text', text: reachedTheServer - ? `That action failed: ${String(error)}` + ? `That action failed, and this is not a connection problem: ${apErrorOf(error)?.message ?? String(error)}` : `That action was sent but did not report back in time, so it may already have run. Do not call it again. Tell the user it needs checking. (${String(error)})` }] } } - if (!isSuccessResult(data.result)) { + const succeeded = isSuccessResult(data.result) + taintState.tainted = true + if (!agentToolClassification.isReadOnlyActionCall({ actionName: configured.pieceMetadata.actionName, input: data.resolvedInput ?? {} })) { + eventEmitter.emitActionReceipt({ + toolCallId: options.toolCallId, + actionDisplayName: data.actionDisplayName ?? configured.pieceMetadata.actionName, + pieceName: configured.pieceMetadata.pieceName, + ...spreadIfDefined('connectionLabel', data.connectionLabel), + status: succeeded ? 'success' : 'failed', + output: data.result, + timestamp: new Date().toISOString(), + }) + } + if (!succeeded) { log.warn({ tool: { name: configured.toolName } }, '[configuredPieceTool] Action reported a failure') return { content: [{ type: 'text', text: `That action failed: ${extractUserFacingError({ result: data.result })}` }] } } diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index f2b664952dad..5610f5edc298 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -585,9 +585,13 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS })) } + const piecesTheAuthorGaveAnAccount = new Set(source !== AgentRunSource.AGENT ? [] : configuredPieceTools + .filter((tool) => !isNil(tool.pieceMetadata.predefinedInput?.auth)) + .map((tool) => tool.pieceMetadata.pieceName)) const displayTools = agentWorkerTools.createDisplayTools({ waitForApproval, displayToolTimeoutMs: DISPLAY_TOOL_TIMEOUT_MS, + accountAlreadyChosenFor: (pieceName) => piecesTheAuthorGaveAnAccount.has(pieceName), onConnectionSelected: async ({ pieceName, connectionExternalId, label, projectId: connProjectId }) => { selectedConnectionByPiece.set(pieceName, connectionExternalId) await tryCatch(() => ctx.apiClient.executeAgentTool({ @@ -648,6 +652,8 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS const configuredTools = agentWorkerTools.createConfiguredPieceTools({ tools: dryRun || discoveryOnly ? [] : configuredPieceTools, runPieceTool: ({ toolName, instruction, piece }) => ctx.apiClient.executePieceTool({ conversationId, toolName, instruction, piece, provider, providerConfigId }), + taintState, + eventEmitter, log, }) const configuredFlowToolSet = agentWorkerTools.createConfiguredFlowTools({ diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts index a2c565e40bf0..fe26215b122c 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts @@ -620,3 +620,41 @@ describe('agentWorkerTools', () => { }) }) }) + +describe('an agent does not offer to connect an account its author already chose', () => { + function pickerTools({ accountAlreadyChosenFor }: { accountAlreadyChosenFor?: (pieceName: string) => boolean }) { + const gatesOpened: string[] = [] + const tools = agentWorkerTools.createDisplayTools({ + waitForApproval: async () => ({ outcome: 'approved' as const, payload: { connectionExternalId: 'conn-1', label: 'Sales Inbox', projectId: 'proj-1' } }), + displayToolTimeoutMs: 1000, + onGateOpened: async ({ toolName }) => { gatesOpened.push(toolName) }, + ...(accountAlreadyChosenFor ? { accountAlreadyChosenFor } : {}), + }) + return { tools, gatesOpened } + } + + async function showPicker({ toolName, accountAlreadyChosenFor }: { toolName: string, accountAlreadyChosenFor?: (pieceName: string) => boolean }) { + const { tools, gatesOpened } = pickerTools({ accountAlreadyChosenFor }) + const result = await tools[toolName].execute({ piece: 'google-sheets', displayName: 'Google Sheets' }, { toolCallId: 'call-1' }) + return { result, gatesOpened } + } + + it.each(['ap_show_connection_picker', 'ap_show_connection_required'])('refuses %s for a piece the author gave an account, without blocking the turn', async (toolName) => { + const { result, gatesOpened } = await showPicker({ toolName, accountAlreadyChosenFor: (pieceName) => pieceName === '@activepieces/piece-google-sheets' }) + + expect(JSON.stringify(result)).toContain('nothing to connect or reconnect') + expect(gatesOpened).toEqual([]) + }) + + it('still shows the card for a piece with no account chosen for it', async () => { + const { gatesOpened } = await showPicker({ toolName: 'ap_show_connection_picker', accountAlreadyChosenFor: () => false }) + + expect(gatesOpened).toEqual(['ap_show_connection_picker']) + }) + + it('still shows the card where no agent chose accounts at all, which is ordinary chat', async () => { + const { gatesOpened } = await showPicker({ toolName: 'ap_show_connection_picker' }) + + expect(gatesOpened).toEqual(['ap_show_connection_picker']) + }) +}) diff --git a/packages/web/src/app/routes/chat-with-ai/components/action-preview-card.tsx b/packages/web/src/app/routes/chat-with-ai/components/action-preview-card.tsx index c5b30438353e..533a8f0247eb 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/action-preview-card.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/action-preview-card.tsx @@ -42,7 +42,7 @@ export function ActionPreviewCard({ : preview.actionDisplayName } > - {preview.pieceName && preview.connectionLabel && ( + {preview.pieceName && (
- - {t('Using: {connectionLabel}', { - connectionLabel: preview.connectionLabel, - })} - + {preview.connectionLabel && ( + + {t('Using: {connectionLabel}', { + connectionLabel: preview.connectionLabel, + })} + + )}
)} diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index 6213fb28faf9..434289656f59 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -97,15 +97,21 @@ function buildToolCallMetaFromGate( return {}; } const gateInput = gate.toolInput ?? {}; + const pieceName = + typeof gateInput.pieceName === 'string' ? gateInput.pieceName : ''; + const actionName = + typeof gateInput.actionName === 'string' ? gateInput.actionName : ''; let actionPreview: ActionPreviewEvent | null = null; - if (gate.toolName === 'ap_execute_action') { + if (pieceName && actionName) { actionPreview = { toolCallId: gate.gateId, - pieceName: - typeof gateInput.pieceName === 'string' ? gateInput.pieceName : '', - actionName: - typeof gateInput.actionName === 'string' ? gateInput.actionName : '', + pieceName, + actionName, actionDisplayName: gate.displayName, + connectionLabel: + typeof gateInput.connectionLabel === 'string' + ? gateInput.connectionLabel + : undefined, input: typeof gateInput.input === 'object' && gateInput.input !== null ? (gateInput.input as Record) From 153fe01ec2a6031ee51dc9187a27212e0440c35b Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 3 Sep 2026 09:24:57 +0300 Subject: [PATCH 02/10] fix(agents): tidy the agents list and agent header (#15231) --- .../web/public/locales/en/translation.json | 4 +- .../web/src/app/routes/agents/id/index.tsx | 76 ++++------- packages/web/src/app/routes/agents/index.tsx | 129 +++++++++++++----- .../app/routes/chat-with-ai/ai-chat-box.tsx | 3 +- .../web/src/features/agents/agent-card.tsx | 2 +- .../web/src/features/agents/agent-table.tsx | 111 +++++++++++++++ 6 files changed, 240 insertions(+), 85 deletions(-) create mode 100644 packages/web/src/features/agents/agent-table.tsx diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 22e23aaea94b..4d2f10a4bfcb 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -12,6 +12,7 @@ "Ask your platform admin to connect an AI provider.": "Ask your platform admin to connect an AI provider.", "Ask your platform admin to turn on a provider for chat.": "Ask your platform admin to turn on a provider for chat.", "Back to agents": "Back to agents", + "Create it in": "Create it in", "Create your first agent": "Create your first agent", "Connect an account": "Connect an account", "Delete this agent": "Delete this agent", @@ -2746,7 +2747,6 @@ "Back to the agent": "Back to the agent", "Describe a change": "Describe a change", "Adjust the instructions, add or remove tools, or change the model. For example: “Only reply to paying customers” or “Add Slack and Notion”.": "Adjust the instructions, add or remove tools, or change the model. For example: “Only reply to paying customers” or “Add Slack and Notion”.", - "Save and go live": "Save and go live", "Needs a model before it can run": "Needs a model before it can run", "Live — flows using this agent run these settings": "Live — flows using this agent run these settings", "Live — every flow using this agent just got the update": "Live — every flow using this agent just got the update", @@ -2755,7 +2755,7 @@ "Keep editing": "Keep editing", "Not live yet": "Not live yet", "Changes not live yet": "Changes not live yet", - "These changes are not live. Save and go live to hand them to this agent and every flow using it.": "These changes are not live. Save and go live to hand them to this agent and every flow using it.", + "These changes are not live. Publish to hand them to this agent and every flow using it.": "These changes are not live. Publish to hand them to this agent and every flow using it.", "Service account JSON": "Service account JSON", "Google Cloud project ID": "Google Cloud project ID", "Region": "Region", diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index 77cb3e5ca3f3..6be268d42edd 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -20,18 +20,14 @@ import { t } from 'i18next'; import { ChevronLeft, ChevronRight, - ChevronsLeft, - ChevronsRight, FlaskConical, Loader2, Pencil, - Rocket, SearchX, Settings2, Sparkles, Trash2, } from 'lucide-react'; -import { motion } from 'motion/react'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { @@ -54,6 +50,8 @@ import { EmptyMedia, EmptyTitle, } from '@/components/custom/empty'; +import { HistoryIcon } from '@/components/icons/history'; +import { PanelLeftCloseIcon } from '@/components/icons/panel-left-close'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -898,12 +896,12 @@ const AgentEditScreen = ({ onSubmit={submitIfIdle} className="flex h-full w-full min-h-0 flex-col" > -
+
@@ -914,25 +912,26 @@ const AgentEditScreen = ({ className="group relative shrink-0 rounded-[14px] outline-none focus-visible:ring-2 focus-visible:ring-ring" > - - + + -
+
- + {HEADER_STATUS_COPY[ agentEditState.headerStatus({ @@ -961,24 +960,9 @@ const AgentEditScreen = ({ type="submit" loading={updateAgent.isPending} disabled={!hasChanges || stageDraft.isPending} - className="h-[38px] shrink-0 gap-2 overflow-hidden rounded-lg px-[18px]" + className="h-[38px] shrink-0 rounded-lg px-4" > - - - - {justLaunched ? t('Live') : t('Save and go live')} + {justLaunched ? t('Live') : t('Publish')}
@@ -1247,7 +1231,16 @@ const AgentEditorContent = () => {
-
+
+ + - -
- + +
+ {agent.displayName} - + {agent.description ?? t('No description yet')}
- {agent.draft.modelName && ( - - - {agent.draft.modelName} - - )}
- {!needsProvider && (allProjects ?? []).length > 1 && ( -
- {t('New agents go to')} - setPickedProjectId(value)} - options={projectOptions} - disabled={isBuilding} - placeholder={t('Search projects')} - contentWidth="260px" - triggerClassName="h-7 w-auto max-w-[220px] gap-1 border-0 bg-transparent px-1.5 text-[13px] font-medium shadow-none hover:bg-accent" - /> -
- )} + + {!needsProvider && hasPrompt && (allProjects ?? []).length > 1 && ( + +
+ {t('New agents go to')} + setPickedProjectId(value)} + options={projectOptions} + disabled={isBuilding} + placeholder={t('Search projects')} + contentWidth="260px" + triggerClassName="h-7 w-auto max-w-[220px] gap-1 border-0 bg-transparent px-1.5 text-[13px] font-medium shadow-none hover:bg-accent" + /> +
+
+ )} +
{buildError !== null && (

{api.extractServerErrorMessage( @@ -552,6 +582,45 @@ const AgentsPageContent = () => { />

+ {personalProject === undefined ? ( + + + + + + + {t('Create it in')} + + {projectOptions.map((option) => ( + createBlankAgent(option.value)} + > + {option.label} + + ))} + + + ) : ( + + )}
@@ -571,28 +640,20 @@ const AgentsPageContent = () => { narrowedByProject={search.trim().length === 0} /> ) : null + ) : layout === 'list' ? ( + ) : ( -
+
{agents.map((agent: AgentSummary) => ( - navigate(`/projects/${agent.projectId}/agents/${agent.id}`) - } + projectDotColor={projectDotColorFor(agent)} + onClick={() => openAgent(agent)} /> ))}
diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index 4d75d7761212..3914e5cf2541 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -1,4 +1,4 @@ -import { SeekPage } from '@activepieces/core-utils'; +import { isNil, SeekPage } from '@activepieces/core-utils'; import { AgentConversation, AgentMessageSource, @@ -227,6 +227,7 @@ function ChatBoxContent({ const showPersonalizationDonut = isEmpty && !incognito && + isNil(agentId) && !showOnboardingCard && !personalization.isResolving && personalization.status !== null && diff --git a/packages/web/src/features/agents/agent-card.tsx b/packages/web/src/features/agents/agent-card.tsx index 009facd7230f..fb6f68a52459 100644 --- a/packages/web/src/features/agents/agent-card.tsx +++ b/packages/web/src/features/agents/agent-card.tsx @@ -59,7 +59,7 @@ export const AgentCard = ({ />
-
+
{agent.displayName} diff --git a/packages/web/src/features/agents/agent-table.tsx b/packages/web/src/features/agents/agent-table.tsx new file mode 100644 index 000000000000..9b30c98a4f1e --- /dev/null +++ b/packages/web/src/features/agents/agent-table.tsx @@ -0,0 +1,111 @@ +import { AgentSummary, AgentVisibility } from '@activepieces/shared'; +import { t } from 'i18next'; +import { Lock } from 'lucide-react'; + +import { TextWithTooltip } from '@/components/custom/text-with-tooltip'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import { AgentActionsMenu } from './agent-actions-menu'; +import { AgentMark } from './agent-mark'; +import { AgentToolStack } from './agent-tool-stack'; + +const PRIVATE_DOT_COLOR = '#A3A3A3'; + +export const AgentTable = ({ + agents, + projectDotColorFor, + onOpen, +}: AgentTableProps) => ( +
+ + + + {t('Agent')} + + {t('Description')} + + {t('Tools')} + {t('Project')} + + + + + {agents.map((agent) => ( + onOpen(agent)} + > + +
+ + + + + {agent.displayName} + + + {agent.visibility === AgentVisibility.RESTRICTED && ( + + )} + +
+
+ + + + {agent.description ?? t('No description yet')} + + + + + + + + + + {agent.projectIsPrivate + ? t('Personal Project') + : agent.projectDisplayName} + + + event.stopPropagation()}> + + +
+ ))} +
+
+
+); + +type AgentTableProps = { + agents: AgentSummary[]; + projectDotColorFor: (agent: AgentSummary) => string | undefined; + onOpen: (agent: AgentSummary) => void; +}; From dfa7e0ec14d0a6371447fa2770ba42d025d33e03 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 3 Sep 2026 09:40:57 +0300 Subject: [PATCH 03/10] fix(agents): charge for an agent draft the model garbled (#15232) --- .../api/src/app/ee/agent/agent-draft-ai.ts | 3 +- .../app/ee/agent/agent-draft-billing.test.ts | 101 ++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/server/api/test/unit/app/ee/agent/agent-draft-billing.test.ts diff --git a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts index 049d34b1a2aa..adec92627179 100644 --- a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts +++ b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts @@ -62,6 +62,8 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ }) } + await debitDraft({ platformId, projectId, log }) + const parsed = parseDraft(raw) if (isNil(parsed)) { log.error({ platform: { id: platformId }, reply: raw.slice(0, REPLY_LOG_LIMIT) }, '[agentDraftAi] The model replied with something that is not a draft') @@ -70,7 +72,6 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ params: { message: 'Could not draft an agent from that description, try rewording it' }, }) } - await debitDraft({ platformId, projectId, log }) return { ...parsed, tools: resolveToolPicks({ picks: parsed.tools, candidates }), diff --git a/packages/server/api/test/unit/app/ee/agent/agent-draft-billing.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-draft-billing.test.ts new file mode 100644 index 000000000000..f5d295042b3f --- /dev/null +++ b/packages/server/api/test/unit/app/ee/agent/agent-draft-billing.test.ts @@ -0,0 +1,101 @@ +import { AIProviderName } from '@activepieces/core-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateText, mockTrackBilling, mockGetOrCreateForPlatform, mockResolveTierModel } = vi.hoisted(() => ({ + mockGenerateText: vi.fn(), + mockTrackBilling: vi.fn().mockResolvedValue(undefined), + mockGetOrCreateForPlatform: vi.fn().mockResolvedValue({ plan: 'free', licenseKey: null }), + mockResolveTierModel: vi.fn().mockResolvedValue({ model: {}, modelId: 'fast-model', provider: 'activepieces' }), +})) + +vi.mock('ai', async (importOriginal) => ({ + ...(await importOriginal>()), + generateText: mockGenerateText, +})) + +vi.mock('../../../../../src/app/platform/billing-and-telemetry', () => ({ + trackBillingAndSendTelemetry: mockTrackBilling, +})) + +vi.mock('../../../../../src/app/platform/billing-provider', () => ({ + CreditUsageSource: { AGENT_DRAFT: 'agent_draft' }, +})) + +vi.mock('../../../../../src/app/ee/platform/platform-plan/platform-plan.service', () => ({ + platformPlanService: () => ({ getOrCreateForPlatform: mockGetOrCreateForPlatform }), +})) + +vi.mock('../../../../../src/app/app-connection/app-connection-service/app-connection-service', () => ({ + appConnectionService: () => ({ listConnectedPieces: vi.fn().mockResolvedValue([]) }), +})) + +vi.mock('../../../../../src/app/pieces/metadata/piece-metadata-service', () => ({ + pieceMetadataService: () => ({ get: vi.fn().mockResolvedValue(null) }), +})) + +vi.mock('../../../../../src/app/ee/agent/agent-helpers', () => ({ + agentHelpers: { + resolveTierModel: mockResolveTierModel, + runScopeOrThrow: ({ projectId }: { projectId: string }) => ({ type: 'project', projectId }), + resolveChatProviderName: vi.fn().mockResolvedValue(AIProviderName.ACTIVEPIECES), + resolveTier: vi.fn().mockReturnValue({ id: 'fast', creditWeight: 3 }), + resolveModelIdForProvider: vi.fn().mockReturnValue('model-x'), + }, +})) + +const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + +async function draft() { + const { agentDraftAi } = await import('../../../../../src/app/ee/agent/agent-draft-ai') + return agentDraftAi(log as never).draft({ platformId: 'plat-1', projectId: 'proj-1', prompt: 'watch the deploys' }) +} + +const A_VALID_DRAFT = JSON.stringify({ + displayName: 'Deploy watcher', + description: 'Watches deploys', + icon: 'bot', + color: 'PURPLE', + instructions: 'Watch the deploys and report failures.', + tools: [], +}) + +describe('drafting an agent charges for the model call the provider actually ran', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOrCreateForPlatform.mockResolvedValue({ plan: 'free', licenseKey: null }) + mockResolveTierModel.mockResolvedValue({ model: {}, modelId: 'fast-model', provider: 'activepieces' }) + }) + + it('charges for a draft it could use', async () => { + mockGenerateText.mockResolvedValue({ text: A_VALID_DRAFT }) + + await draft() + + expect(mockTrackBilling).toHaveBeenCalledTimes(1) + }) + + it('charges when the model replied with something that is not a draft', async () => { + mockGenerateText.mockResolvedValue({ text: 'I would love to help you build an agent!' }) + + await expect(draft()).rejects.toThrow() + + expect(mockTrackBilling).toHaveBeenCalledTimes(1) + }) + + it('charges nothing when the model call itself failed, since nothing was served', async () => { + mockGenerateText.mockRejectedValue(new Error('provider is down')) + + await expect(draft()).rejects.toThrow() + + expect(mockTrackBilling).not.toHaveBeenCalled() + }) + + it('charges nothing when no provider could be resolved at all', async () => { + mockResolveTierModel.mockRejectedValue(new Error('no provider')) + + await expect(draft()).rejects.toThrow() + + expect(mockGenerateText).not.toHaveBeenCalled() + expect(mockTrackBilling).not.toHaveBeenCalled() + }) +}) From 16b713cc49c6362ba105c52c291d8b549cc852b8 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 3 Sep 2026 09:48:15 +0300 Subject: [PATCH 04/10] docs(agents): match the docs to the agents screen that shipped (#15233) --- docs/about/changelog.mdx | 2 +- docs/agents/create.mdx | 10 ++++++++-- docs/agents/manage.mdx | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/about/changelog.mdx b/docs/about/changelog.mdx index 6dbd52ce8b27..cae20fa6675d 100755 --- a/docs/about/changelog.mdx +++ b/docs/about/changelog.mdx @@ -21,7 +21,7 @@ name, brief, talk to, and reuse. and adds one that sends, posts or files only when the sentence asks for it. - **Edit with AI**: describe a change in the editor — *"only reply to paying customers"* — and the instructions and tools are rewritten for you. Edits stay a draft until you press - **Save and go live**, and going live updates every flow already using the agent on its + **Publish**, and publishing updates every flow already using the agent on its next run, with nothing to republish. **Where an agent lives** diff --git a/docs/agents/create.mdx b/docs/agents/create.mdx index 70fa861cd71b..53228604bf43 100644 --- a/docs/agents/create.mdx +++ b/docs/agents/create.mdx @@ -24,9 +24,15 @@ Drafting needs an [AI provider connected](/admin-guide/guides/setup-ai-providers Ask for what you want done, not for the tools. The draft only picks an action that reads your data unless the sentence asks for something to be sent, posted or filed, so *"tell me when something changes"* gets an agent that reports, not one that emails. +## Start from a blank one + +**New agent**, beside the view toggles on the list, skips the drafting and opens an empty agent for you to write yourself. It goes to your personal project, since an agent you have not deliberately placed belongs to you; where a platform does not create personal projects, the button asks which project to use. Either way you can [move it later](/agents/manage). + +A blank agent has no model yet, so it opens straight into its editor with that still to pick. + ## Choose where it lives -Under the prompt box, **New agents go to** names the project the agent will belong to, and lets you change it before you create it. That matters because the project is the boundary: an agent reaches only its own project's connections, flows, tables and knowledge. +Start typing in the prompt box and **New agents go to** appears beneath it, naming the project the agent will belong to and letting you change it before you create it. That matters because the project is the boundary: an agent reaches only its own project's connections, flows, tables and knowledge. If you have one project, there is nothing to choose and the control stays out of the way. See [managing an agent](/agents/manage) for moving one later. @@ -68,7 +74,7 @@ The editor has two tabs beside the Configure panel. -Edits are a draft until you press **Save and go live**. The header says which state you are in: *Changes not live yet* while you are editing, *Live* once saved, and *Needs a model to run* if no model is picked. Going live updates every flow already using the agent on its next run, with nothing to republish. +Edits are a draft until you press **Publish**. The header says which state you are in: *Changes not live yet* while you are editing, *Live* once published, and *Needs a model to run* if no model is picked. Publishing updates every flow already using the agent on its next run, with nothing to republish. Brief it like a capable new colleague. Say the goal and the edges, not every keystroke. The two people forget most: what "done" looks like, and which calls it should hand back to a human. diff --git a/docs/agents/manage.mdx b/docs/agents/manage.mdx index 073936ba0cea..f4ccb77007bb 100644 --- a/docs/agents/manage.mdx +++ b/docs/agents/manage.mdx @@ -9,11 +9,11 @@ icon: "folder-tree" An agent belongs to one project, and that decides what it can reach: the connections it authenticates with, the flows it can call, the tables and files it looks things up in. Two projects with the same Gmail connection name are two different mailboxes. -The **Agents** page spans every project you can see, so the card tells you which project each agent belongs to. The project filter beside the search box narrows the list, and it also moves the destination under the prompt box to that project. Whatever you pick in **New agents go to** wins, so check that line before you create anything. +The **Agents** page spans every project you can see, so each agent's row tells you which project it belongs to. The project filter beside the search box narrows the list, and it also sets where a described agent will be created. Start typing in the prompt box and **New agents go to** appears beneath it, naming that destination; whatever you pick there wins, so check it before you create anything. ## Move it to another project -From the `...` menu on a card, or from **Project** in the agent's own Advanced section, pick **Move to another project**. +From the `...` menu beside the agent, or from **Project** in the agent's own Advanced section, pick **Move to another project**. The agent takes its instructions, tools and conversations with it. Before you confirm, the dialog says what the move costs: From 99697e0849c69805ad0cae0d505cd9a13efec29e Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 3 Sep 2026 09:58:37 +0300 Subject: [PATCH 05/10] fix(agents): a blank agent lands in the same place whichever way you start it (#15234) --- packages/web/src/app/routes/agents/index.tsx | 71 ++++------------ .../agents/new-blank-agent-button.tsx | 83 +++++++++++++++++++ 2 files changed, 100 insertions(+), 54 deletions(-) create mode 100644 packages/web/src/features/agents/new-blank-agent-button.tsx diff --git a/packages/web/src/app/routes/agents/index.tsx b/packages/web/src/app/routes/agents/index.tsx index d55fdf963fa9..22156738fd26 100644 --- a/packages/web/src/app/routes/agents/index.tsx +++ b/packages/web/src/app/routes/agents/index.tsx @@ -5,7 +5,6 @@ import { ColorName, MAX_DRAFT_PROMPT_LENGTH, PROJECT_COLOR_PALETTE, - ProjectType, } from '@activepieces/shared'; import { t } from 'i18next'; import { @@ -37,8 +36,6 @@ import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, @@ -54,6 +51,7 @@ import { useAgentsAvailable, } from '@/features/agents/hooks/agents-hooks'; import { createAgentUtils } from '@/features/agents/lib/create-agent-utils'; +import { NewBlankAgentButton } from '@/features/agents/new-blank-agent-button'; import { aiProviderQueries } from '@/features/platform-admin/hooks/ai-provider-hooks'; import { getProjectName, projectCollectionUtils } from '@/features/projects'; import { useIsPlatformAdmin } from '@/hooks/authorization-hooks'; @@ -221,10 +219,6 @@ const AgentsPageContent = () => { ); }; - const personalProject = (allProjects ?? []).find( - (entry) => entry.type === ProjectType.PERSONAL, - ); - const createBlankAgent = (projectId: string) => { if (createAgent.isPending) { return; @@ -345,15 +339,14 @@ const AgentsPageContent = () => {

))} {chatIsOffOnEveryProvider && ( - + icon={} + label={t('Write one by hand instead')} + /> )}
{ />
- {personalProject === undefined ? ( - - - - - - - {t('Create it in')} - - {projectOptions.map((option) => ( - createBlankAgent(option.value)} - > - {option.label} - - ))} - - - ) : ( - - )} + } + label={t('New agent')} + />
diff --git a/packages/web/src/features/agents/new-blank-agent-button.tsx b/packages/web/src/features/agents/new-blank-agent-button.tsx new file mode 100644 index 000000000000..8c103e443c58 --- /dev/null +++ b/packages/web/src/features/agents/new-blank-agent-button.tsx @@ -0,0 +1,83 @@ +import { Project, ProjectType } from '@activepieces/shared'; +import { t } from 'i18next'; +import { ReactNode } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { getProjectName } from '@/features/projects'; + +export const NewBlankAgentButton = ({ + projects, + pending, + onCreate, + variant = 'outline', + size, + className, + icon, + label, +}: NewBlankAgentButtonProps) => { + const personalProject = projects.find( + (project) => project.type === ProjectType.PERSONAL, + ); + + if (personalProject !== undefined) { + return ( + + ); + } + + return ( + + + + + + + {t('Create it in')} + + {projects.map((project) => ( + onCreate(project.id)} + > + {getProjectName(project)} + + ))} + + + ); +}; + +type NewBlankAgentButtonProps = { + projects: Project[]; + pending: boolean; + onCreate: (projectId: string) => void; + variant?: 'outline'; + size?: 'sm'; + className?: string; + icon: ReactNode; + label: string; +}; From e7b4b76eed694eb79970fba84b9efd27aee7f8ac Mon Sep 17 00:00:00 2001 From: Bartosz Majewski <30874844+majewskibartosz@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:02:42 +0200 Subject: [PATCH 06/10] fix(pieces): AWS IAM Role connections no longer save as Connected when the role cannot be assumed (#14665) Co-authored-by: Amr Elmohamady Co-authored-by: Claude Opus 4.7 Co-authored-by: Kishan Parmar Co-authored-by: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> --- .../pieces-engine/building-pieces.md | 2 + docs/install/reference/breaking-changes.mdx | 12 ++ .../community/amazon-bedrock/package.json | 8 +- .../community/amazon-bedrock/src/index.ts | 2 +- .../amazon-bedrock/src/lib/auth.test.ts | 103 +++++++++++++ .../community/amazon-bedrock/src/lib/auth.ts | 22 ++- .../amazon-bedrock/src/lib/common.ts | 86 +++++++---- .../community/amazon-bedrock/vitest.config.ts | 17 +++ .../pieces/community/amazon-s3/package.json | 2 +- .../pieces/community/amazon-s3/src/index.ts | 2 +- .../community/amazon-s3/src/lib/auth.ts | 23 ++- .../community/amazon-s3/src/lib/common.ts | 69 ++++++--- .../amazon-secrets-manager/package.json | 2 +- .../amazon-secrets-manager/src/index.ts | 2 +- .../src/lib/common/auth.ts | 22 ++- .../src/lib/common/client.ts | 69 ++++++--- .../pieces/community/aws-bedrock/package.json | 2 +- .../pieces/community/aws-bedrock/src/index.ts | 2 +- .../community/aws-bedrock/src/lib/auth.ts | 22 ++- .../community/aws-bedrock/src/lib/common.ts | 86 +++++++---- packages/pieces/framework/package.json | 2 +- .../pieces/framework/src/lib/context/index.ts | 15 ++ .../src/lib/property/authentication/common.ts | 4 +- .../engine/src/lib/helper/piece-helper.ts | 60 ++++++-- .../piece-helper-server-context.test.ts | 143 ++++++++++++++++++ 25 files changed, 634 insertions(+), 145 deletions(-) create mode 100644 packages/pieces/community/amazon-bedrock/src/lib/auth.test.ts create mode 100644 packages/pieces/community/amazon-bedrock/vitest.config.ts create mode 100644 packages/server/engine/test/helper/piece-helper-server-context.test.ts diff --git a/brain/knowledge/pieces-engine/building-pieces.md b/brain/knowledge/pieces-engine/building-pieces.md index ef85061e294e..f50e8a424978 100644 --- a/brain/knowledge/pieces-engine/building-pieces.md +++ b/brain/knowledge/pieces-engine/building-pieces.md @@ -26,6 +26,8 @@ Authentication, triggers (polling/webhook), properties + validation, flow contro - **Pass the whole `context` to `pollingHelper`, never `{ store, auth, propsValue }`.** The destructured form is the dominant shape in the repo (306 of 403 `onEnable` call sites) and it type-checks, so it reads as idiomatic — but the helper's param type is wider than those three fields, and TypeScript only rejects excess properties, never missing optional ones. So each field added to the polling context is silently absent in every destructuring trigger. `context.isRepublish` is the first one that changes behaviour: `pollingHelper.onEnable` uses it to keep the existing `lastPoll`/`lastItem` instead of resetting to now, so a destructuring trigger still drops every event between its last poll and a republish ([triggers.md](../flows-execution/triggers.md) has the platform-side thread). Scaffolding (`npm run cli triggers create`), `docs/build-pieces/`, and the piece-builder skill all pass `context`, so new triggers are fine — the trap is copying from a neighbouring piece, since the wrong shape is the majority there. The legacy sites are being fixed **on touch** rather than by one repo-wide codemod: whoever edits a piece switches that piece's calls over, which rides an existing version bump and rebuild instead of forcing one on ~300 pieces nobody is running. - **Porting postgres `new-row.ts` to another SQL piece: the `LIMIT 5` is cold-start only — do not carry it onto the resume branch.** `constructQuery` (`postgres/src/lib/triggers/new-row.ts:41`) has two shapes, and the asymmetry between them is load-bearing: the no-checkpoint branch seeds with `ORDER BY %I DESC LIMIT 5` (`:46,48`), while the resume branch is deliberately **unbounded** — `WHERE %I >= %L ORDER BY %I DESC`, no LIMIT (`:58,60`). It has to be, because `DedupeStrategy.LAST_ITEM` (`:17`) recovers the checkpoint by scanning *the page it just fetched* (`pieces/common/src/lib/polling/index.ts:99`, `items.findIndex((f) => f.id === lastItemId)`) and emits everything ahead of it. Bound the resume page and the checkpoint row can fall off the end, where `findIndex → -1` is read as "no checkpoint" and the entire page re-emits ([triggers.md](../flows-execution/triggers.md) has the same mechanic from the republish side). So a literal `LIMIT 5` → `TOP (5)` is a behaviour change, not a dialect translation — and the moment you *do* want a bounded resume page you are off `pollingHelper` altogether and owe a keyset cursor that carries its position in the store instead of recovering it by scanning: `microsoft-sql-server/src/lib/common/cursor.ts` is the worked example (`TOP (@limit)` on every page, versioned cursor, explicit tiebreaker key). Two more sharp edges if you copy this template: the item id is `orderValue + '|' + md5(JSON.stringify(row))` (`:24-28`), so **any edit to the checkpoint row changes its id and invalidates the checkpoint**, and `lastItem.split('|')[0]` (`:42`) truncates any order value containing a literal `|` — fine for timestamps and serial ids, wrong for ordering on a text column. - **Streaming a file *into* a piece is `Property.File({ streaming: true })`.** It resolves to an `ApStreamingFile` with `body: Readable` (pieces-framework ≥ 0.35.0, [000014](../../decisions/000014-streaming-file-inputs-resolve-to-a-lazy-apstreamingfile.md)) and accepts a URL, a base64 data URL, the builder's file picker, or a previous step's file — a strict superset of a URL text field, with the fetch owned by the engine. `amazon-s3/upload-file.ts` and `subflows/stream-csv-to-flow.ts` are the references. Three things to know: the engine's `fileProcessor` swallows fetch failures and returns `null`, which for a `required: true` prop surfaces as the confusing `Expected file url or base64 with mimeType` validation error rather than a fetch error (so no `isNil` guard in your `run()` is needed — the action never starts); the engine's fetch has **no timeout**, so a source that connects then stalls burns `FLOW_TIMEOUT_SECONDS`; and `.pipe()` does not forward `'error'`, so you still need `file.body.on('error', ...)` or a mid-stream network drop becomes an uncaught exception in the sandbox. +- **A piece's `validate()` catch block swallows engine failures as invalid connections unless you explicitly re-throw.** The auth server context hands `validate()` a `mintOidcToken({ audience })` callback whose engine-side implementation calls the internal `/v1/worker/oidc-token` endpoint; when that endpoint 5xxs, the engine throws a `PieceServerContextError` (from `@activepieces/pieces-framework`). It looks like any other `Error`, so a naive `try { await getTemporaryCredentials(...) } catch (e) { return { valid: false, error: format(e) } }` reports the platform outage as `INVALID_APP_CONNECTION` — user sees "your connection is bad", oncall gets no page. The contract every OIDC piece must follow is `if (isPieceServerContextError(error)) throw error;` before the catch's return; `executeValidateAuth` (`packages/server/engine/src/lib/helper/piece-helper.ts`) re-wraps it as `EngineGenericError`, which `tryCatchAndThrowOnEngineError` routes as `ExecutionErrorType.ENGINE` and pages. The reference implementations are the four AWS OIDC pieces (`amazon-bedrock`, `aws-bedrock`, `amazon-s3`, `amazon-secrets-manager`) — same shape in all of them. Runtime paths (executing a step, refreshing a token) intentionally do NOT go through this class — a runtime OIDC failure fails one step as a user-level error, which is correct because a persistent platform issue would show up across many flows and be caught operationally. +- **AWS region strings flow into the STS endpoint hostname.** The `@aws-sdk/client-sts` builds the STS endpoint URL by interpolating `sts..amazonaws.com`, and does not itself check the shape. A crafted region like `us-east-1.evil.com` redirects the STS call to an attacker-controlled host — a real SSRF-shaped issue since the piece runs in the engine sandbox with outbound network access and the JWT it's about to send is a valid, platform-signed OIDC token. Validate the region against `/^[a-z]{2}(-[a-z]+)+-\d$/` **before** any code that touches it (before minting the OIDC token, before building the STS client, before the cache key). All four AWS OIDC pieces enforce this in `getTemporaryCredentials`. The same shape applies to any AWS SDK client built with a user-controlled region. - **Streaming only removes *our* memory ceiling — check the destination's per-request cap before calling an upload action fixed.** A body that streams cleanly out of the sandbox still gets rejected whole by the API: Dropbox's `/2/files/upload` answers `409 {".tag": "payload_too_large"}` above 150 MB, Graph's simple `PUT …/content` above 250 MB. The tell that it's the service and not us is the shape — an endpoint-specific 409 with a documented error tag, and an axios/undici request echo whose `body` is just a `_readableState` blob (our stream, sent fine). The fix is a chunked upload session, not a bigger buffer: `dropbox/upload-file.ts` and `microsoft-onedrive/upload-file.ts` are the references, both chunking through the shared `streamUtils.readChunks({ readable, chunkSize })` from `@activepieces/pieces-common` — reuse it rather than writing a third stream chunker. Two rules that fall out of doing it: **route unknown-size sources through the session too** (`size` is best-effort and absent on chunked or compressed sources, so you cannot prove they fit — and the old fallback of buffering to learn the size is the OOM this streaming work exists to remove), and keep the chunk size a multiple of the service's preferred unit (4 MiB for Dropbox, 320 KiB for OneDrive). Chunk bodies are `Buffer`s, so unlike a one-shot stream body they keep `httpClient`'s retries. **Whether you can chunk an unknown-size source at all depends on how the session addresses its parts:** Dropbox's is offset-based (`cursor.offset`, no total ever declared) so it streams straight through, while Graph's wants the file's total length in every fragment's `Content-Range` — so `microsoft-sharepoint` and `microsoft-onedrive` must `readableToBuffer` once to learn the length, then re-wrap with `Readable.from` so both branches still take a stream. That buffer is the OOM this work removes, so it is a last resort, not the pattern: reach for the offset-based session whenever the API offers one. SharePoint's cap is generous enough (250 MB one-shot vs OneDrive's 4 MiB) that the buffer only ever runs for a size-less source. - **On Windows, a new action/trigger name (or any metadata-shape change) needs the dev server process killed, not restarted.** `clearPieceModuleCache` — the only thing that busts the CommonJS `require()` cache backing dev piece metadata — is called exclusively from the chokidar watcher's rebuild handler (`dev-piece-watcher.ts`), and that watcher does not fire reliably on Windows for tool-made edits. A "normal restart" reuses the same PID (confirm with `netstat`/`Get-Process` bound to the dev port), so the server keeps serving the stale metadata. Find the PID bound to the dev API port and `Stop-Process -Id -Force`, then start fresh — every other change (prop text, logic inside `run()`) hot-reloads fine; only new action/trigger names or output-shape changes hit this. - **`Property.Array`'s `properties` sub-schema never threads into its resolved `propsValue` type — confirmed in `packages/pieces/framework/src/lib/property/index.ts`.** `propsValue.someArrayProp` types as plain `unknown[]` regardless of what `properties` declares, so casting to the declared row type at the point of use is the only option; there is no framework-provided type-safe path around it. Document the cast in a comment so it doesn't read as an oversight on a later pass. diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index afa1b0365474..bb47c9e55771 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -157,6 +157,18 @@ Two cases are deliberately unchanged. A run that succeeds without reaching a Ret Nothing to configure or migrate. If you have a caller or reverse proxy that treats `408` as the signal for a failed synchronous flow, or retry logic keyed on `408`, switch it to `5xx` — a failed run no longer produces a `408`, and the reply now arrives in seconds rather than after the timeout. +#### AWS connections using the IAM Role / OIDC method are now verified against AWS before they save + +Saving an AWS connection (Bedrock, S3, Secrets Manager) with the **IAM Role / OIDC** method previously only checked that the Role ARN was well formed, so any well-formed ARN saved with status "Connected" even when the role did not exist or could not be assumed. The connection now performs the same `sts:AssumeRoleWithWebIdentity` call the flow makes at runtime, and the save fails with the AWS error when the role cannot be assumed. + +Two consequences. A connection that was accepted before is now rejected if its IAM OIDC provider, trust policy, or `AP_FRONTEND_URL` issuer is not set up correctly. And because the save now calls AWS, an instance with no outbound access to `sts..amazonaws.com` cannot create these connections at all. + +The region on these connections is also validated against the AWS region format at both save time and every flow step (the region flows into the STS endpoint hostname, so the check is enforced everywhere it is used). The STS call now times out after 10 seconds instead of waiting for the job timeout. + +#### What you need to do + +Only if you use the IAM Role / OIDC method on an AWS piece. Allow outbound HTTPS to `sts..amazonaws.com` from the machine running the worker, and make sure `AP_FRONTEND_URL` matches the Provider URL of your IAM OIDC identity provider, since the issuer in the signed token is derived from it. Existing saved connections keep working and are not revalidated automatically — pressing **Recheck** on a connection whose trust policy is wrong will now move it to an error state, which reflects what already happens when a flow runs. + ## 0.88.2 ### What has changed? diff --git a/packages/pieces/community/amazon-bedrock/package.json b/packages/pieces/community/amazon-bedrock/package.json index dabdc4e2264f..fa8f5ab88baf 100644 --- a/packages/pieces/community/amazon-bedrock/package.json +++ b/packages/pieces/community/amazon-bedrock/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-amazon-bedrock", - "version": "0.2.3", + "version": "0.3.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", @@ -17,11 +17,13 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "vitest": "3.2.6" }, "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" } } diff --git a/packages/pieces/community/amazon-bedrock/src/index.ts b/packages/pieces/community/amazon-bedrock/src/index.ts index 94d11eefb750..8b23f63ca626 100644 --- a/packages/pieces/community/amazon-bedrock/src/index.ts +++ b/packages/pieces/community/amazon-bedrock/src/index.ts @@ -13,7 +13,7 @@ export const awsBedrock = createPiece({ displayName: 'AWS Bedrock', description: 'Build generative AI applications with foundation models', auth: awsBedrockCombinedAuth, - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.90.1', logoUrl: 'https://cdn.activepieces.com/pieces/amazon-bedrock.png', categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], authors: ["onyedikachi-david"], diff --git a/packages/pieces/community/amazon-bedrock/src/lib/auth.test.ts b/packages/pieces/community/amazon-bedrock/src/lib/auth.test.ts new file mode 100644 index 000000000000..07d1d1a15034 --- /dev/null +++ b/packages/pieces/community/amazon-bedrock/src/lib/auth.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() })); + +vi.mock('@aws-sdk/client-sts', () => ({ + STSClient: vi.fn(() => ({ send: sendMock })), + AssumeRoleWithWebIdentityCommand: vi.fn((input: unknown) => ({ input })), +})); + +import { PieceServerContextError } from '@activepieces/pieces-framework'; +import { awsBedrockOidcAuth } from './auth'; + +const mintOidcToken = vi.fn(); + +const server = { + apiUrl: 'http://127.0.0.1:4200/api/', + publicUrl: 'http://127.0.0.1:4200/api/', + mintOidcToken, +}; + +describe('awsBedrockOidcAuth.validate', () => { + beforeEach(() => { + sendMock.mockReset(); + mintOidcToken.mockReset(); + mintOidcToken.mockResolvedValue('signed-oidc-token'); + }); + + it('rejects a malformed role ARN without calling AWS', async () => { + const result = await awsBedrockOidcAuth.validate?.({ + auth: { roleArn: 'not-an-arn', region: 'us-east-1' }, + server, + }); + + expect(result).toEqual({ + valid: false, + error: 'Invalid IAM Role ARN format. Expected: arn:aws:iam::123456789012:role/RoleName', + }); + expect(mintOidcToken).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('returns the STS error when the role cannot be assumed', async () => { + const stsError = new Error('The web identity token provided could not be validated.'); + stsError.name = 'InvalidIdentityToken'; + sendMock.mockRejectedValue(stsError); + + const result = await awsBedrockOidcAuth.validate?.({ + auth: { roleArn: 'arn:aws:iam::123456789012:role/does-not-exist', region: 'us-east-1' }, + server, + }); + + expect(result).toEqual({ + valid: false, + error: 'InvalidIdentityToken: The web identity token provided could not be validated.', + }); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(mintOidcToken).toHaveBeenCalledWith({ audience: 'sts.amazonaws.com' }); + expect(sendMock.mock.calls[0][0].input).toMatchObject({ + RoleArn: 'arn:aws:iam::123456789012:role/does-not-exist', + WebIdentityToken: 'signed-oidc-token', + }); + }); + + it('re-throws PieceServerContextError so an engine failure surfaces as ENGINE, not INVALID_APP_CONNECTION', async () => { + mintOidcToken.mockRejectedValue(new PieceServerContextError('Failed to get OIDC token: Forbidden')); + + await expect( + awsBedrockOidcAuth.validate?.({ + auth: { roleArn: 'arn:aws:iam::123456789012:role/token-endpoint-down', region: 'us-east-1' }, + server, + }), + ).rejects.toBeInstanceOf(PieceServerContextError); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('accepts when the role assumption succeeds', async () => { + sendMock.mockResolvedValue({ + Credentials: { + AccessKeyId: 'AKIATEST', + SecretAccessKey: 'secret', + SessionToken: 'session', + Expiration: new Date(Date.now() + 3_600_000), + }, + }); + + const result = await awsBedrockOidcAuth.validate?.({ + auth: { roleArn: 'arn:aws:iam::123456789012:role/assumable-role', region: 'us-east-1' }, + server, + }); + + expect(result).toEqual({ valid: true }); + }); + + it('rejects a region that is not an AWS region before reaching AWS', async () => { + const result = await awsBedrockOidcAuth.validate?.({ + auth: { roleArn: 'arn:aws:iam::123456789012:role/valid-shape', region: 'us-east-1.evil.com' }, + server, + }); + + expect(result).toEqual({ valid: false, error: 'Invalid AWS region: us-east-1.evil.com' }); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pieces/community/amazon-bedrock/src/lib/auth.ts b/packages/pieces/community/amazon-bedrock/src/lib/auth.ts index 2c7b4bf925cf..732db27144be 100644 --- a/packages/pieces/community/amazon-bedrock/src/lib/auth.ts +++ b/packages/pieces/community/amazon-bedrock/src/lib/auth.ts @@ -1,5 +1,6 @@ -import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { isPieceServerContextError, PieceAuth, Property } from '@activepieces/pieces-framework'; import { BedrockClient, ListFoundationModelsCommand } from '@aws-sdk/client-bedrock'; +import { getTemporaryCredentials } from './common'; const bedrockOidcDescription = ` Connect your AWS account using an IAM Role via OIDC — no permanent keys required. @@ -120,7 +121,7 @@ export const awsBedrockOidcAuth = PieceAuth.OIDC({ required: true, }), }, - validate: async ({ auth }) => { + validate: async ({ auth, server }) => { if (!auth.roleArn) { return { valid: false, error: 'Role ARN is required for IAM Role authentication.' }; } @@ -128,11 +129,26 @@ export const awsBedrockOidcAuth = PieceAuth.OIDC({ if (!arnRegex.test(auth.roleArn)) { return { valid: false, error: 'Invalid IAM Role ARN format. Expected: arn:aws:iam::123456789012:role/RoleName' }; } - return { valid: true }; + try { + await getTemporaryCredentials({ auth: { roleArn: auth.roleArn, region: auth.region }, server }); + return { valid: true }; + } catch (error) { + if (isPieceServerContextError(error)) throw error; + return { valid: false, error: formatAssumeRoleError(error) }; + } }, required: true, }); +function formatAssumeRoleError(error: unknown): string { + const fallback = 'Failed to assume the IAM role. Check the role ARN and trust policy.'; + if (error instanceof Error) { + if (error.name && error.name !== 'Error') return `${error.name}: ${error.message}`; + return error.message || fallback; + } + return fallback; +} + export const awsBedrockCombinedAuth = [awsBedrockAuth, awsBedrockOidcAuth]; export type BedrockAccessKeyAuthProps = { diff --git a/packages/pieces/community/amazon-bedrock/src/lib/common.ts b/packages/pieces/community/amazon-bedrock/src/lib/common.ts index e828af77bedf..0f9363931cf8 100644 --- a/packages/pieces/community/amazon-bedrock/src/lib/common.ts +++ b/packages/pieces/community/amazon-bedrock/src/lib/common.ts @@ -14,7 +14,7 @@ import { VideoFormat, } from '@aws-sdk/client-bedrock-runtime'; import { AssumeRoleWithWebIdentityCommand, STSClient } from '@aws-sdk/client-sts'; -import { ApFile, ServerContext } from '@activepieces/pieces-framework'; +import { ApFile, AuthValidationServerContext, ServerContext } from '@activepieces/pieces-framework'; import { BedrockAuthProps, BedrockOidcAuthProps } from './auth'; const AWS_STS_AUDIENCE = 'sts.amazonaws.com'; @@ -28,11 +28,12 @@ export async function createBedrockRuntimeClient({ }): Promise { if (isOidcAuth(auth)) { const credentials = await getTemporaryCredentials({ auth, server }); - return new BedrockRuntimeClient({ credentials, region: auth.region }); + return new BedrockRuntimeClient({ credentials, region: auth.region, ...BEDROCK_CLIENT_TIMEOUTS }); } return new BedrockRuntimeClient({ credentials: { accessKeyId: auth.accessKeyId, secretAccessKey: auth.secretAccessKey }, region: auth.region, + ...BEDROCK_CLIENT_TIMEOUTS, }); } @@ -61,11 +62,12 @@ export async function getBedrockModelOptions( return { disabled: true, options: [], placeholder: 'Unable to load models for IAM Role auth in this context.' }; } const credentials = await getTemporaryCredentials({ auth, server }); - client = new BedrockClient({ credentials, region: auth.region }); + client = new BedrockClient({ credentials, region: auth.region, ...BEDROCK_CLIENT_TIMEOUTS }); } else { client = new BedrockClient({ credentials: { accessKeyId: auth.accessKeyId, secretAccessKey: auth.secretAccessKey }, region: auth.region, + ...BEDROCK_CLIENT_TIMEOUTS, }); } @@ -154,7 +156,7 @@ export async function getBedrockModelOptions( return { disabled: true, options: [], - placeholder: 'Failed to load models. Check your credentials.', + placeholder: `Failed to load models: ${formatBedrockError(error)}`, }; } } @@ -165,36 +167,32 @@ export async function getTemporaryCredentials({ durationSeconds = DEFAULT_STS_DURATION_SECONDS, }: { auth: BedrockOidcAuthProps; - server: ServerContext; + server: ServerContext | AuthValidationServerContext; durationSeconds?: number; }): Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken: string | undefined }> { if (!auth.roleArn) { throw new Error('Role ARN is required for IAM Role authentication'); } + if (!AWS_REGION_REGEX.test(auth.region ?? '')) { + throw new Error(`Invalid AWS region: ${auth.region}`); + } const clampedDuration = Math.min(Math.max(durationSeconds, MIN_STS_DURATION_SECONDS), MAX_STS_DURATION_SECONDS); - // Scoped by server.token (unique per flow execution) so credentials are never reused - // across projects/tenants, only across steps within the same run. - const cacheKey = `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}`; - const cached = credentialsCache.get(cacheKey); - if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { - return cached.credentials; + const cacheKey = 'token' in server ? `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}` : undefined; + if (cacheKey) { + const cached = credentialsCache.get(cacheKey); + if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { + return cached.credentials; + } } - const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { - method: 'POST', - headers: { - Authorization: `Bearer ${server.token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), - }); - if (!response.ok) { - throw new Error(`Failed to get OIDC token: ${response.statusText}`); - } - const { token } = (await response.json()) as { token: string }; + const token = await resolveOidcToken({ server }); - const sts = new STSClient({ region: auth.region }); + const sts = new STSClient({ + region: auth.region, + maxAttempts: 2, + requestHandler: { requestTimeout: STS_REQUEST_TIMEOUT_MS, connectionTimeout: STS_CONNECT_TIMEOUT_MS }, + }); const { Credentials } = await sts.send( new AssumeRoleWithWebIdentityCommand({ RoleArn: auth.roleArn, @@ -211,12 +209,33 @@ export async function getTemporaryCredentials({ secretAccessKey: Credentials.SecretAccessKey, sessionToken: Credentials.SessionToken, }; - const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; - sweepExpiredCredentials(); - credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + if (cacheKey) { + const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; + sweepExpiredCredentials(); + credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + } return credentials; } +async function resolveOidcToken({ server }: { server: ServerContext | AuthValidationServerContext }): Promise { + if ('mintOidcToken' in server) { + return server.mintOidcToken({ audience: AWS_STS_AUDIENCE }); + } + const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${server.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), + }); + if (!response.ok) { + throw new Error(`Failed to get OIDC token: ${response.statusText}`); + } + const { token } = (await response.json()) as { token: string }; + return token; +} + function sweepExpiredCredentials() { const now = Date.now(); for (const [key, value] of credentialsCache) { @@ -352,7 +371,10 @@ export function formatBedrockError(error: unknown): string { case 'ServiceQuotaExceededException': return 'You have exceeded your AWS Bedrock service quota.'; default: - return err.message ?? 'An unexpected error occurred.'; + if (err.message && name !== 'Error' && name !== 'UnknownError') { + return `${name}: ${err.message}`; + } + return err.message || 'An unexpected error occurred.'; } } @@ -360,6 +382,14 @@ const DEFAULT_STS_DURATION_SECONDS = 3600; const CREDENTIALS_EXPIRY_MARGIN_MS = 5 * 60 * 1000; const credentialsCache = new Map(); +const AWS_REGION_REGEX = /^[a-z]{2}(-[a-z]+)+-\d$/; +const STS_REQUEST_TIMEOUT_MS = 10_000; +const STS_CONNECT_TIMEOUT_MS = 5_000; +const BEDROCK_CLIENT_TIMEOUTS = { + maxAttempts: 2, + requestHandler: { requestTimeout: 15_000, connectionTimeout: 5_000 }, +} as const; + export const MIN_STS_DURATION_SECONDS = 900; export const MAX_STS_DURATION_SECONDS = 43200; diff --git a/packages/pieces/community/amazon-bedrock/vitest.config.ts b/packages/pieces/community/amazon-bedrock/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/amazon-bedrock/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/community/amazon-s3/package.json b/packages/pieces/community/amazon-s3/package.json index 61e9d47a2561..071295c78013 100644 --- a/packages/pieces/community/amazon-s3/package.json +++ b/packages/pieces/community/amazon-s3/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-amazon-s3", - "version": "0.6.8", + "version": "0.7.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/amazon-s3/src/index.ts b/packages/pieces/community/amazon-s3/src/index.ts index 7dd0740b80fb..05990cfc0804 100644 --- a/packages/pieces/community/amazon-s3/src/index.ts +++ b/packages/pieces/community/amazon-s3/src/index.ts @@ -18,7 +18,7 @@ export const amazonS3 = createPiece({ description: 'Scalable storage in the cloud', logoUrl: 'https://cdn.activepieces.com/pieces/amazon-s3.png', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.90.1', authors: ["Willianwg", "kishanprmr", "MoShizzle", "AbdulTheActivePiecer", "khaledmashaly", "abuaboud", "Kevinyu-alan", "hugh-codes"], categories: [PieceCategory.DEVELOPER_TOOLS], auth: amazonS3CombinedAuth, diff --git a/packages/pieces/community/amazon-s3/src/lib/auth.ts b/packages/pieces/community/amazon-s3/src/lib/auth.ts index fd9f1ad7272e..4f4ae4156ffa 100644 --- a/packages/pieces/community/amazon-s3/src/lib/auth.ts +++ b/packages/pieces/community/amazon-s3/src/lib/auth.ts @@ -1,5 +1,5 @@ -import { PieceAuth, Property } from '@activepieces/pieces-framework'; -import { createS3 } from './common'; +import { isPieceServerContextError, PieceAuth, Property } from '@activepieces/pieces-framework'; +import { createS3, getTemporaryCredentials } from './common'; const accessKeyDescription = ` Connect your Amazon S3 account using AWS Access Key credentials. @@ -239,7 +239,7 @@ export const amazonS3OidcAuth = PieceAuth.OIDC({ required: true, }), }, - validate: async ({ auth }) => { + validate: async ({ auth, server }) => { if (!auth.roleArn) { return { valid: false, error: 'Role ARN is required for IAM Role authentication.' }; } @@ -247,13 +247,28 @@ export const amazonS3OidcAuth = PieceAuth.OIDC({ if (!arnRegex.test(auth.roleArn)) { return { valid: false, error: 'Invalid IAM Role ARN format. Expected: arn:aws:iam::123456789012:role/RoleName' }; } - return { valid: true }; + try { + await getTemporaryCredentials({ auth: { roleArn: auth.roleArn, bucket: auth.bucket, region: auth.region }, server }); + return { valid: true }; + } catch (error) { + if (isPieceServerContextError(error)) throw error; + return { valid: false, error: formatAssumeRoleError(error) }; + } }, required: true, }); export const amazonS3CombinedAuth = [amazonS3Auth, amazonS3OidcAuth]; +function formatAssumeRoleError(error: unknown): string { + const fallback = 'Failed to assume the IAM role. Check the role ARN and trust policy.'; + if (error instanceof Error) { + if (error.name && error.name !== 'Error') return `${error.name}: ${error.message}`; + return error.message || fallback; + } + return fallback; +} + export type AccessKeyAuthProps = { accessKeyId: string | undefined; secretAccessKey: string | undefined; diff --git a/packages/pieces/community/amazon-s3/src/lib/common.ts b/packages/pieces/community/amazon-s3/src/lib/common.ts index 02097480743c..4a722bc13f45 100644 --- a/packages/pieces/community/amazon-s3/src/lib/common.ts +++ b/packages/pieces/community/amazon-s3/src/lib/common.ts @@ -2,7 +2,7 @@ import { isNil } from '@activepieces/pieces-framework'; import { S3 } from '@aws-sdk/client-s3'; import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { AssumeRoleWithWebIdentityCommand, STSClient } from '@aws-sdk/client-sts'; -import { ServerContext } from '@activepieces/pieces-framework'; +import { AuthValidationServerContext, ServerContext } from '@activepieces/pieces-framework'; import { AccessKeyAuthProps, OidcAuthProps, S3AuthProps } from './auth'; const AWS_STS_AUDIENCE = 'sts.amazonaws.com'; @@ -77,36 +77,32 @@ export async function getTemporaryCredentials({ durationSeconds = DEFAULT_STS_DURATION_SECONDS, }: { auth: OidcAuthProps; - server: ServerContext; + server: ServerContext | AuthValidationServerContext; durationSeconds?: number; }) { if (!auth.roleArn) { throw new Error('Role ARN is required for IAM Role authentication'); } + if (!AWS_REGION_REGEX.test(auth.region ?? '')) { + throw new Error(`Invalid AWS region: ${auth.region}`); + } const clampedDuration = Math.min(Math.max(durationSeconds, MIN_STS_DURATION_SECONDS), MAX_STS_DURATION_SECONDS); - // Scoped by server.token (unique per flow execution) so credentials are never reused - // across projects/tenants, only across steps within the same run. - const cacheKey = `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}`; - const cached = credentialsCache.get(cacheKey); - if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { - return cached.credentials; + const cacheKey = 'token' in server ? `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}` : undefined; + if (cacheKey) { + const cached = credentialsCache.get(cacheKey); + if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { + return cached.credentials; + } } - const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { - method: 'POST', - headers: { - Authorization: `Bearer ${server.token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), - }); - if (!response.ok) { - throw new Error(`Failed to get OIDC token: ${response.statusText}`); - } - const { token } = (await response.json()) as { token: string }; + const token = await resolveOidcToken({ server }); - const sts = new STSClient({ region: auth.region }); + const sts = new STSClient({ + region: auth.region, + maxAttempts: 2, + requestHandler: { requestTimeout: STS_REQUEST_TIMEOUT_MS, connectionTimeout: STS_CONNECT_TIMEOUT_MS }, + }); const { Credentials } = await sts.send( new AssumeRoleWithWebIdentityCommand({ RoleArn: auth.roleArn, @@ -123,12 +119,33 @@ export async function getTemporaryCredentials({ secretAccessKey: Credentials.SecretAccessKey, sessionToken: Credentials.SessionToken, }; - const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; - sweepExpiredCredentials(); - credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + if (cacheKey) { + const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; + sweepExpiredCredentials(); + credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + } return credentials; } +async function resolveOidcToken({ server }: { server: ServerContext | AuthValidationServerContext }): Promise { + if ('mintOidcToken' in server) { + return server.mintOidcToken({ audience: AWS_STS_AUDIENCE }); + } + const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${server.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), + }); + if (!response.ok) { + throw new Error(`Failed to get OIDC token: ${response.statusText}`); + } + const { token } = (await response.json()) as { token: string }; + return token; +} + function sweepExpiredCredentials() { const now = Date.now(); for (const [key, value] of credentialsCache) { @@ -142,6 +159,10 @@ const DEFAULT_STS_DURATION_SECONDS = 3600; const CREDENTIALS_EXPIRY_MARGIN_MS = 5 * 60 * 1000; const credentialsCache = new Map(); +const AWS_REGION_REGEX = /^[a-z]{2}(-[a-z]+)+-\d$/; +const STS_REQUEST_TIMEOUT_MS = 10_000; +const STS_CONNECT_TIMEOUT_MS = 5_000; + export const MIN_STS_DURATION_SECONDS = 900; export const MAX_STS_DURATION_SECONDS = 43200; diff --git a/packages/pieces/community/amazon-secrets-manager/package.json b/packages/pieces/community/amazon-secrets-manager/package.json index 7ae058a04b83..d4f287a63121 100644 --- a/packages/pieces/community/amazon-secrets-manager/package.json +++ b/packages/pieces/community/amazon-secrets-manager/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-amazon-secrets-manager", - "version": "0.1.3", + "version": "0.2.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/amazon-secrets-manager/src/index.ts b/packages/pieces/community/amazon-secrets-manager/src/index.ts index 5fafe0a8a71c..f2c7298458da 100644 --- a/packages/pieces/community/amazon-secrets-manager/src/index.ts +++ b/packages/pieces/community/amazon-secrets-manager/src/index.ts @@ -11,7 +11,7 @@ import { PieceCategory } from '@activepieces/pieces-framework'; export const amazonSecretsManager = createPiece({ displayName: 'AWS Secrets Manager', auth: awsSecretsManagerCombinedAuth, - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.90.1', logoUrl: 'https://cdn.activepieces.com/pieces/amazon-secrets-manager.png', authors: ['sanket-a11y'], categories: [PieceCategory.DEVELOPER_TOOLS], diff --git a/packages/pieces/community/amazon-secrets-manager/src/lib/common/auth.ts b/packages/pieces/community/amazon-secrets-manager/src/lib/common/auth.ts index 28888dbed750..2697941a5ee4 100644 --- a/packages/pieces/community/amazon-secrets-manager/src/lib/common/auth.ts +++ b/packages/pieces/community/amazon-secrets-manager/src/lib/common/auth.ts @@ -1,8 +1,9 @@ -import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { isPieceServerContextError, PieceAuth, Property } from '@activepieces/pieces-framework'; import { SecretsManagerClient, ListSecretsCommand, } from '@aws-sdk/client-secrets-manager'; +import { getTemporaryCredentials } from './client'; const secretsOidcDescription = ` Connect your AWS account using an IAM Role via OIDC — no permanent keys required. @@ -220,7 +221,7 @@ export const awsSecretsManagerOidcAuth = PieceAuth.OIDC({ required: true, }), }, - validate: async ({ auth }) => { + validate: async ({ auth, server }) => { if (!auth.roleArn) { return { valid: false, error: 'Role ARN is required for IAM Role authentication.' }; } @@ -228,13 +229,28 @@ export const awsSecretsManagerOidcAuth = PieceAuth.OIDC({ if (!arnRegex.test(auth.roleArn)) { return { valid: false, error: 'Invalid IAM Role ARN format. Expected: arn:aws:iam::123456789012:role/RoleName' }; } - return { valid: true }; + try { + await getTemporaryCredentials({ auth: { roleArn: auth.roleArn, region: auth.region }, server }); + return { valid: true }; + } catch (error) { + if (isPieceServerContextError(error)) throw error; + return { valid: false, error: formatAssumeRoleError(error) }; + } }, required: true, }); export const awsSecretsManagerCombinedAuth = [awsSecretsManagerAuth, awsSecretsManagerOidcAuth]; +function formatAssumeRoleError(error: unknown): string { + const fallback = 'Failed to assume the IAM role. Check the role ARN and trust policy.'; + if (error instanceof Error) { + if (error.name && error.name !== 'Error') return `${error.name}: ${error.message}`; + return error.message || fallback; + } + return fallback; +} + export type SecretsAccessKeyAuthProps = { accessKeyId: string; secretAccessKey: string; diff --git a/packages/pieces/community/amazon-secrets-manager/src/lib/common/client.ts b/packages/pieces/community/amazon-secrets-manager/src/lib/common/client.ts index ef2a61658b21..8097fe558808 100644 --- a/packages/pieces/community/amazon-secrets-manager/src/lib/common/client.ts +++ b/packages/pieces/community/amazon-secrets-manager/src/lib/common/client.ts @@ -1,6 +1,6 @@ import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { AssumeRoleWithWebIdentityCommand, STSClient } from '@aws-sdk/client-sts'; -import { ServerContext } from '@activepieces/pieces-framework'; +import { AuthValidationServerContext, ServerContext } from '@activepieces/pieces-framework'; import { SecretsAuthProps, SecretsOidcAuthProps } from './auth'; const AWS_STS_AUDIENCE = 'sts.amazonaws.com'; @@ -32,36 +32,32 @@ export async function getTemporaryCredentials({ durationSeconds = DEFAULT_STS_DURATION_SECONDS, }: { auth: SecretsOidcAuthProps; - server: ServerContext; + server: ServerContext | AuthValidationServerContext; durationSeconds?: number; }) { if (!auth.roleArn) { throw new Error('Role ARN is required for IAM Role authentication'); } + if (!AWS_REGION_REGEX.test(auth.region ?? '')) { + throw new Error(`Invalid AWS region: ${auth.region}`); + } const clampedDuration = Math.min(Math.max(durationSeconds, MIN_STS_DURATION_SECONDS), MAX_STS_DURATION_SECONDS); - // Scoped by server.token (unique per flow execution) so credentials are never reused - // across projects/tenants, only across steps within the same run. - const cacheKey = `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}`; - const cached = credentialsCache.get(cacheKey); - if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { - return cached.credentials; + const cacheKey = 'token' in server ? `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}` : undefined; + if (cacheKey) { + const cached = credentialsCache.get(cacheKey); + if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { + return cached.credentials; + } } - const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { - method: 'POST', - headers: { - Authorization: `Bearer ${server.token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), - }); - if (!response.ok) { - throw new Error(`Failed to get OIDC token: ${response.statusText}`); - } - const { token } = (await response.json()) as { token: string }; + const token = await resolveOidcToken({ server }); - const sts = new STSClient({ region: auth.region }); + const sts = new STSClient({ + region: auth.region, + maxAttempts: 2, + requestHandler: { requestTimeout: STS_REQUEST_TIMEOUT_MS, connectionTimeout: STS_CONNECT_TIMEOUT_MS }, + }); const { Credentials } = await sts.send( new AssumeRoleWithWebIdentityCommand({ RoleArn: auth.roleArn, @@ -78,12 +74,33 @@ export async function getTemporaryCredentials({ secretAccessKey: Credentials.SecretAccessKey, sessionToken: Credentials.SessionToken, }; - const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; - sweepExpiredCredentials(); - credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + if (cacheKey) { + const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; + sweepExpiredCredentials(); + credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + } return credentials; } +async function resolveOidcToken({ server }: { server: ServerContext | AuthValidationServerContext }): Promise { + if ('mintOidcToken' in server) { + return server.mintOidcToken({ audience: AWS_STS_AUDIENCE }); + } + const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${server.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), + }); + if (!response.ok) { + throw new Error(`Failed to get OIDC token: ${response.statusText}`); + } + const { token } = (await response.json()) as { token: string }; + return token; +} + function sweepExpiredCredentials() { const now = Date.now(); for (const [key, value] of credentialsCache) { @@ -97,6 +114,10 @@ const DEFAULT_STS_DURATION_SECONDS = 3600; const CREDENTIALS_EXPIRY_MARGIN_MS = 5 * 60 * 1000; const credentialsCache = new Map(); +const AWS_REGION_REGEX = /^[a-z]{2}(-[a-z]+)+-\d$/; +const STS_REQUEST_TIMEOUT_MS = 10_000; +const STS_CONNECT_TIMEOUT_MS = 5_000; + export const MIN_STS_DURATION_SECONDS = 900; export const MAX_STS_DURATION_SECONDS = 43200; diff --git a/packages/pieces/community/aws-bedrock/package.json b/packages/pieces/community/aws-bedrock/package.json index 9442cb348e31..26f74a40c3ee 100644 --- a/packages/pieces/community/aws-bedrock/package.json +++ b/packages/pieces/community/aws-bedrock/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-aws-bedrock", - "version": "0.2.4", + "version": "0.3.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/aws-bedrock/src/index.ts b/packages/pieces/community/aws-bedrock/src/index.ts index 792c1cb764bb..5e1fbe4536fe 100644 --- a/packages/pieces/community/aws-bedrock/src/index.ts +++ b/packages/pieces/community/aws-bedrock/src/index.ts @@ -12,7 +12,7 @@ export const awsBedrock = createPiece({ displayName: 'AWS Bedrock (Deprecated)', description: 'Build generative AI applications with foundation models', auth: awsBedrockCombinedAuth, - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.90.1', logoUrl: 'https://cdn.activepieces.com/pieces/amazon-bedrock.png', deprecated: true, categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], diff --git a/packages/pieces/community/aws-bedrock/src/lib/auth.ts b/packages/pieces/community/aws-bedrock/src/lib/auth.ts index 2c7b4bf925cf..732db27144be 100644 --- a/packages/pieces/community/aws-bedrock/src/lib/auth.ts +++ b/packages/pieces/community/aws-bedrock/src/lib/auth.ts @@ -1,5 +1,6 @@ -import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { isPieceServerContextError, PieceAuth, Property } from '@activepieces/pieces-framework'; import { BedrockClient, ListFoundationModelsCommand } from '@aws-sdk/client-bedrock'; +import { getTemporaryCredentials } from './common'; const bedrockOidcDescription = ` Connect your AWS account using an IAM Role via OIDC — no permanent keys required. @@ -120,7 +121,7 @@ export const awsBedrockOidcAuth = PieceAuth.OIDC({ required: true, }), }, - validate: async ({ auth }) => { + validate: async ({ auth, server }) => { if (!auth.roleArn) { return { valid: false, error: 'Role ARN is required for IAM Role authentication.' }; } @@ -128,11 +129,26 @@ export const awsBedrockOidcAuth = PieceAuth.OIDC({ if (!arnRegex.test(auth.roleArn)) { return { valid: false, error: 'Invalid IAM Role ARN format. Expected: arn:aws:iam::123456789012:role/RoleName' }; } - return { valid: true }; + try { + await getTemporaryCredentials({ auth: { roleArn: auth.roleArn, region: auth.region }, server }); + return { valid: true }; + } catch (error) { + if (isPieceServerContextError(error)) throw error; + return { valid: false, error: formatAssumeRoleError(error) }; + } }, required: true, }); +function formatAssumeRoleError(error: unknown): string { + const fallback = 'Failed to assume the IAM role. Check the role ARN and trust policy.'; + if (error instanceof Error) { + if (error.name && error.name !== 'Error') return `${error.name}: ${error.message}`; + return error.message || fallback; + } + return fallback; +} + export const awsBedrockCombinedAuth = [awsBedrockAuth, awsBedrockOidcAuth]; export type BedrockAccessKeyAuthProps = { diff --git a/packages/pieces/community/aws-bedrock/src/lib/common.ts b/packages/pieces/community/aws-bedrock/src/lib/common.ts index e828af77bedf..0f9363931cf8 100644 --- a/packages/pieces/community/aws-bedrock/src/lib/common.ts +++ b/packages/pieces/community/aws-bedrock/src/lib/common.ts @@ -14,7 +14,7 @@ import { VideoFormat, } from '@aws-sdk/client-bedrock-runtime'; import { AssumeRoleWithWebIdentityCommand, STSClient } from '@aws-sdk/client-sts'; -import { ApFile, ServerContext } from '@activepieces/pieces-framework'; +import { ApFile, AuthValidationServerContext, ServerContext } from '@activepieces/pieces-framework'; import { BedrockAuthProps, BedrockOidcAuthProps } from './auth'; const AWS_STS_AUDIENCE = 'sts.amazonaws.com'; @@ -28,11 +28,12 @@ export async function createBedrockRuntimeClient({ }): Promise { if (isOidcAuth(auth)) { const credentials = await getTemporaryCredentials({ auth, server }); - return new BedrockRuntimeClient({ credentials, region: auth.region }); + return new BedrockRuntimeClient({ credentials, region: auth.region, ...BEDROCK_CLIENT_TIMEOUTS }); } return new BedrockRuntimeClient({ credentials: { accessKeyId: auth.accessKeyId, secretAccessKey: auth.secretAccessKey }, region: auth.region, + ...BEDROCK_CLIENT_TIMEOUTS, }); } @@ -61,11 +62,12 @@ export async function getBedrockModelOptions( return { disabled: true, options: [], placeholder: 'Unable to load models for IAM Role auth in this context.' }; } const credentials = await getTemporaryCredentials({ auth, server }); - client = new BedrockClient({ credentials, region: auth.region }); + client = new BedrockClient({ credentials, region: auth.region, ...BEDROCK_CLIENT_TIMEOUTS }); } else { client = new BedrockClient({ credentials: { accessKeyId: auth.accessKeyId, secretAccessKey: auth.secretAccessKey }, region: auth.region, + ...BEDROCK_CLIENT_TIMEOUTS, }); } @@ -154,7 +156,7 @@ export async function getBedrockModelOptions( return { disabled: true, options: [], - placeholder: 'Failed to load models. Check your credentials.', + placeholder: `Failed to load models: ${formatBedrockError(error)}`, }; } } @@ -165,36 +167,32 @@ export async function getTemporaryCredentials({ durationSeconds = DEFAULT_STS_DURATION_SECONDS, }: { auth: BedrockOidcAuthProps; - server: ServerContext; + server: ServerContext | AuthValidationServerContext; durationSeconds?: number; }): Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken: string | undefined }> { if (!auth.roleArn) { throw new Error('Role ARN is required for IAM Role authentication'); } + if (!AWS_REGION_REGEX.test(auth.region ?? '')) { + throw new Error(`Invalid AWS region: ${auth.region}`); + } const clampedDuration = Math.min(Math.max(durationSeconds, MIN_STS_DURATION_SECONDS), MAX_STS_DURATION_SECONDS); - // Scoped by server.token (unique per flow execution) so credentials are never reused - // across projects/tenants, only across steps within the same run. - const cacheKey = `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}`; - const cached = credentialsCache.get(cacheKey); - if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { - return cached.credentials; + const cacheKey = 'token' in server ? `${server.token}:${auth.roleArn}:${auth.region}:${clampedDuration}` : undefined; + if (cacheKey) { + const cached = credentialsCache.get(cacheKey); + if (cached && cached.expiresAtMS - Date.now() > CREDENTIALS_EXPIRY_MARGIN_MS) { + return cached.credentials; + } } - const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { - method: 'POST', - headers: { - Authorization: `Bearer ${server.token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), - }); - if (!response.ok) { - throw new Error(`Failed to get OIDC token: ${response.statusText}`); - } - const { token } = (await response.json()) as { token: string }; + const token = await resolveOidcToken({ server }); - const sts = new STSClient({ region: auth.region }); + const sts = new STSClient({ + region: auth.region, + maxAttempts: 2, + requestHandler: { requestTimeout: STS_REQUEST_TIMEOUT_MS, connectionTimeout: STS_CONNECT_TIMEOUT_MS }, + }); const { Credentials } = await sts.send( new AssumeRoleWithWebIdentityCommand({ RoleArn: auth.roleArn, @@ -211,12 +209,33 @@ export async function getTemporaryCredentials({ secretAccessKey: Credentials.SecretAccessKey, sessionToken: Credentials.SessionToken, }; - const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; - sweepExpiredCredentials(); - credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + if (cacheKey) { + const expiresAtMS = Credentials.Expiration?.getTime() ?? Date.now() + clampedDuration * 1000; + sweepExpiredCredentials(); + credentialsCache.set(cacheKey, { credentials, expiresAtMS }); + } return credentials; } +async function resolveOidcToken({ server }: { server: ServerContext | AuthValidationServerContext }): Promise { + if ('mintOidcToken' in server) { + return server.mintOidcToken({ audience: AWS_STS_AUDIENCE }); + } + const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${server.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ audience: AWS_STS_AUDIENCE }), + }); + if (!response.ok) { + throw new Error(`Failed to get OIDC token: ${response.statusText}`); + } + const { token } = (await response.json()) as { token: string }; + return token; +} + function sweepExpiredCredentials() { const now = Date.now(); for (const [key, value] of credentialsCache) { @@ -352,7 +371,10 @@ export function formatBedrockError(error: unknown): string { case 'ServiceQuotaExceededException': return 'You have exceeded your AWS Bedrock service quota.'; default: - return err.message ?? 'An unexpected error occurred.'; + if (err.message && name !== 'Error' && name !== 'UnknownError') { + return `${name}: ${err.message}`; + } + return err.message || 'An unexpected error occurred.'; } } @@ -360,6 +382,14 @@ const DEFAULT_STS_DURATION_SECONDS = 3600; const CREDENTIALS_EXPIRY_MARGIN_MS = 5 * 60 * 1000; const credentialsCache = new Map(); +const AWS_REGION_REGEX = /^[a-z]{2}(-[a-z]+)+-\d$/; +const STS_REQUEST_TIMEOUT_MS = 10_000; +const STS_CONNECT_TIMEOUT_MS = 5_000; +const BEDROCK_CLIENT_TIMEOUTS = { + maxAttempts: 2, + requestHandler: { requestTimeout: 15_000, connectionTimeout: 5_000 }, +} as const; + export const MIN_STS_DURATION_SECONDS = 900; export const MAX_STS_DURATION_SECONDS = 43200; diff --git a/packages/pieces/framework/package.json b/packages/pieces/framework/package.json index 73503e92a6a7..0332864200ba 100644 --- a/packages/pieces/framework/package.json +++ b/packages/pieces/framework/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/pieces-framework", - "version": "0.38.0", + "version": "0.39.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/framework/src/lib/context/index.ts b/packages/pieces/framework/src/lib/context/index.ts index 21d2e6dfd962..f6b13ead6394 100644 --- a/packages/pieces/framework/src/lib/context/index.ts +++ b/packages/pieces/framework/src/lib/context/index.ts @@ -176,6 +176,21 @@ export type ServerContext = { token: string; }; +export type AuthValidationServerContext = Omit & { + mintOidcToken: (params: { audience: string }) => Promise; +}; + +export class PieceServerContextError extends Error { + constructor(message: string) { + super(message); + this.name = 'PieceServerContextError'; + } +} + +export function isPieceServerContextError(error: unknown): error is PieceServerContextError { + return error instanceof Error && error.name === 'PieceServerContextError'; +} + export type CreateWaitpointParams = { type: 'DELAY' | 'WEBHOOK'; version?: 'V0' | 'V1'; diff --git a/packages/pieces/framework/src/lib/property/authentication/common.ts b/packages/pieces/framework/src/lib/property/authentication/common.ts index 6f7e086cd6c8..eb2e58f5de2e 100644 --- a/packages/pieces/framework/src/lib/property/authentication/common.ts +++ b/packages/pieces/framework/src/lib/property/authentication/common.ts @@ -1,5 +1,5 @@ import * as z from "zod/mini"; -import { ServerContext } from '../../context'; +import { AuthValidationServerContext, ServerContext } from '../../context'; export const BasePieceAuthSchema = z.object({ displayName: z.string(), @@ -10,7 +10,7 @@ export const BasePieceAuthSchema = z.object({ export type BasePieceAuthSchema = { displayName: string; description?: string; - validate?: (params: { auth: AuthValueSchema; server: Omit }) => Promise< + validate?: (params: { auth: AuthValueSchema; server: AuthValidationServerContext }) => Promise< | { valid: true } | { valid: false; diff --git a/packages/server/engine/src/lib/helper/piece-helper.ts b/packages/server/engine/src/lib/helper/piece-helper.ts index 30df4dfb5841..d66c0de0dc3d 100644 --- a/packages/server/engine/src/lib/helper/piece-helper.ts +++ b/packages/server/engine/src/lib/helper/piece-helper.ts @@ -1,16 +1,20 @@ import path from 'path' import { isNil } from '@activepieces/core-utils' import { + AuthValidationServerContext, DropdownProperty, DynamicProperties, ExecutePropsResult, getAuthPropertyForValue, + isPieceServerContextError, MultiSelectDropdownProperty, PieceAuthProperty, PieceMetadata, PiecePropertyMap, + PieceServerContextError, pieceTranslation, PropertyType, + ServerContext, StaticPropsValue } from '@activepieces/pieces-framework' import { AppConnectionType, AppConnectionValue, EngineGenericError, ExecuteExtractPieceMetadata, ExecutePropsOptions, ExecuteRefreshTokenAuthOperation, ExecuteRefreshTokenAuthResponse, ExecuteResolveConnectionIdentifierOperation, ExecuteResolveConnectionIdentifierResponse, ExecuteValidateAuthOperation, ExecuteValidateAuthResponse } from '@activepieces/shared' import { EngineConstants } from '../handler/context/engine-constants' @@ -56,9 +60,8 @@ export const pieceHelper = { const ctx = { searchValue: operation.searchValue, server: { + ...buildServerContext({ internalApiUrl: constants.internalApiUrl, publicApiUrl: operation.publicApiUrl }), token: constants.engineToken, - apiUrl: constants.internalApiUrl, - publicUrl: operation.publicApiUrl, }, project: { id: constants.projectId, @@ -133,13 +136,20 @@ export const pieceHelper = { const { piece: piecePackage } = params const piece = await pieceLoader.loadPieceOrThrow({ pieceName: piecePackage.pieceName, pieceVersion: piecePackage.pieceVersion, devPieces }) - const server = buildServerContext(params) - return validateAuth({ - authValue: params.auth, - pieceAuth: piece.auth, - server, - }) - + const server = buildAuthValidationServerContext(params) + try { + return await validateAuth({ + authValue: params.auth, + pieceAuth: piece.auth, + server, + }) + } + catch (error) { + if (isPieceServerContextError(error)) { + throw new EngineGenericError('OidcTokenRequestFailedError', error.message) + } + throw error + } }, async executeResolveConnectionIdentifier( @@ -346,21 +356,41 @@ const resolveConnectionIdentifier = async ({ } type ValidateAuthParams = { - server: { - apiUrl: string - publicUrl: string - } + server: AuthValidationServerContext authValue: AppConnectionValue pieceAuth: PieceAuthProperty | PieceAuthProperty[] | undefined } -type ResolveConnectionIdentifierParams = ValidateAuthParams & { +type ResolveConnectionIdentifierParams = Omit & { + server: Omit connectionType: AppConnectionType } -function buildServerContext({ internalApiUrl, publicApiUrl }: { internalApiUrl: string, publicApiUrl: string }) { +function buildServerContext({ internalApiUrl, publicApiUrl }: { internalApiUrl: string, publicApiUrl: string }): Omit { return { apiUrl: internalApiUrl.endsWith('/') ? internalApiUrl : internalApiUrl + '/', publicUrl: publicApiUrl, } +} + +function buildAuthValidationServerContext({ internalApiUrl, publicApiUrl, engineToken }: { internalApiUrl: string, publicApiUrl: string, engineToken: string }): AuthValidationServerContext { + const server = buildServerContext({ internalApiUrl, publicApiUrl }) + return { + ...server, + mintOidcToken: async ({ audience }) => { + const response = await fetch(`${server.apiUrl}v1/worker/oidc-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${engineToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ audience }), + }) + if (!response.ok) { + throw new PieceServerContextError(`Failed to get OIDC token: ${response.statusText}`) + } + const { token } = await response.json() as { token: string } + return token + }, + } } \ No newline at end of file diff --git a/packages/server/engine/test/helper/piece-helper-server-context.test.ts b/packages/server/engine/test/helper/piece-helper-server-context.test.ts new file mode 100644 index 000000000000..a2e2ab5f3346 --- /dev/null +++ b/packages/server/engine/test/helper/piece-helper-server-context.test.ts @@ -0,0 +1,143 @@ +import { PropertyType } from '@activepieces/pieces-framework' +import { AppConnectionType, EngineGenericError } from '@activepieces/shared' +import type { ExecuteValidateAuthOperation } from '@activepieces/shared' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLoadPieceOrThrow } = vi.hoisted(() => ({ + mockLoadPieceOrThrow: vi.fn(), +})) +vi.mock('../../src/lib/helper/piece-loader', () => ({ + pieceLoader: { + loadPieceOrThrow: mockLoadPieceOrThrow, + getPackageAlias: vi.fn(), + getPiecePath: vi.fn(), + getPropOrThrow: vi.fn(), + }, +})) + +import { pieceHelper } from '../../src/lib/helper/piece-helper' + +const ENGINE_TOKEN = 'engine-token-under-test' + +function makeOperation(): ExecuteValidateAuthOperation { + return { + piece: { + pieceName: '@activepieces/piece-under-test', + pieceVersion: '1.0.0', + }, + auth: { + type: AppConnectionType.OIDC, + props: { roleArn: 'arn:aws:iam::123456789012:role/Example' }, + }, + platformId: 'platform-1', + engineToken: ENGINE_TOKEN, + internalApiUrl: 'http://127.0.0.1:3000/api', + publicApiUrl: 'http://127.0.0.1:4200/api/', + timeoutInSeconds: 30, + } as unknown as ExecuteValidateAuthOperation +} + +describe('piece-helper server context', () => { + beforeEach(() => { + mockLoadPieceOrThrow.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('gives the validate hook an OIDC-minting capability instead of the engine token', async () => { + const validate = vi.fn().mockResolvedValue({ valid: true }) + mockLoadPieceOrThrow.mockResolvedValue({ + auth: { type: PropertyType.OIDC, validate }, + }) + + const response = await pieceHelper.executeValidateAuth({ params: makeOperation(), devPieces: [] }) + + expect(response).toEqual({ valid: true }) + expect(validate).toHaveBeenCalledTimes(1) + const server = validate.mock.calls[0][0].server + expect(server.apiUrl).toBe('http://127.0.0.1:3000/api/') + expect(server.publicUrl).toBe('http://127.0.0.1:4200/api/') + expect(server.token).toBeUndefined() + expect(typeof server.mintOidcToken).toBe('function') + }) + + it('mints the OIDC token with the engine token, never handing it to the piece', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ token: 'signed-oidc-token' }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + let minted: string | undefined + const validate = vi.fn().mockImplementation(async ({ server }) => { + minted = await server.mintOidcToken({ audience: 'sts.amazonaws.com' }) + return { valid: true } + }) + mockLoadPieceOrThrow.mockResolvedValue({ + auth: { type: PropertyType.OIDC, validate }, + }) + + await pieceHelper.executeValidateAuth({ params: makeOperation(), devPieces: [] }) + + expect(minted).toBe('signed-oidc-token') + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://127.0.0.1:3000/api/v1/worker/oidc-token') + expect(init.headers.Authorization).toBe(`Bearer ${ENGINE_TOKEN}`) + expect(JSON.parse(init.body)).toEqual({ audience: 'sts.amazonaws.com' }) + }) + + it('converts a piece-thrown PieceServerContextError to EngineGenericError so oncall pages instead of the user seeing INVALID_APP_CONNECTION', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('Forbidden', { status: 403 }), + )) + const validate = vi.fn().mockImplementation(async ({ server }) => { + await server.mintOidcToken({ audience: 'sts.amazonaws.com' }) + return { valid: true } + }) + mockLoadPieceOrThrow.mockResolvedValue({ + auth: { type: PropertyType.OIDC, validate }, + }) + + await expect( + pieceHelper.executeValidateAuth({ params: makeOperation(), devPieces: [] }), + ).rejects.toBeInstanceOf(EngineGenericError) + }) + + it('withholds the engine token from the getConnectionIdentifier hook', async () => { + const getConnectionIdentifier = vi.fn().mockResolvedValue('account-label') + mockLoadPieceOrThrow.mockResolvedValue({ + auth: { type: PropertyType.OIDC, getConnectionIdentifier }, + }) + + const response = await pieceHelper.executeResolveConnectionIdentifier({ + params: { ...makeOperation(), connectionType: AppConnectionType.OIDC }, + devPieces: [], + }) + + expect(response).toEqual({ identifier: 'account-label' }) + expect(getConnectionIdentifier.mock.calls[0][0].server).toEqual({ + apiUrl: 'http://127.0.0.1:3000/api/', + publicUrl: 'http://127.0.0.1:4200/api/', + }) + }) + + it('withholds the engine token from the custom-auth refresh hook', async () => { + const generate = vi.fn().mockResolvedValue({ access_token: 'refreshed', expires_in: 3600 }) + mockLoadPieceOrThrow.mockResolvedValue({ + auth: { type: PropertyType.CUSTOM_AUTH, refresh: { generate } }, + }) + + await pieceHelper.executeRefreshTokenAuth({ + params: { + ...makeOperation(), + auth: { type: AppConnectionType.CUSTOM_AUTH, props: { apiKey: 'k' } }, + } as unknown as ExecuteValidateAuthOperation, + devPieces: [], + }) + + expect(generate.mock.calls[0][0].server).toEqual({ + apiUrl: 'http://127.0.0.1:3000/api/', + publicUrl: 'http://127.0.0.1:4200/api/', + }) + }) +}) From 63fc4bf1d7f373d45e47b1b274b9bb2b5153c0a2 Mon Sep 17 00:00:00 2001 From: Chaker Atallah <74781393+MrChaker@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:51:14 +0100 Subject: [PATCH 07/10] chore: release 0.90.1 (#15237) --- docker-compose.yml | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 14254b283d8c..14e746aa5c59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: ghcr.io/activepieces/activepieces:0.90.0 + image: ghcr.io/activepieces/activepieces:0.90.1 container_name: activepieces-app restart: unless-stopped ports: @@ -16,7 +16,7 @@ services: networks: - activepieces worker: - image: ghcr.io/activepieces/activepieces:0.90.0 + image: ghcr.io/activepieces/activepieces:0.90.1 restart: unless-stopped depends_on: - app diff --git a/package.json b/package.json index 9a151a087a50..5c771a74bc0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activepieces", - "version": "0.90.0", + "version": "0.90.1", "packageManager": "bun@1.4.0", "trustedDependencies": [ "sqlite3", From e40c337eaea4cd51731c199fa3aaea77fc6882fe Mon Sep 17 00:00:00 2001 From: Chaker Atallah <74781393+MrChaker@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:52:03 +0100 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20piece=20upgrade=20migrateFlowVersi?= =?UTF-8?q?on=20ignored=20when=20no=20projectId=20or=20pl=E2=80=A6=20(#152?= =?UTF-8?q?36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flow-version/piece-upgrade.service.ts | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts index 24c9932adb6a..c878cb5eaa33 100644 --- a/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts +++ b/packages/server/api/src/app/flows/flow-version/piece-upgrade.service.ts @@ -2,7 +2,6 @@ import { isNil, spreadIfDefined, unique } from '@activepieces/core-utils' import { ApplicationEventName, Flow, FlowAction, FlowActionType, FlowPiecesUpgradedEvent, flowStructureUtil, FlowTrigger, FlowTriggerType, FlowVersion } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { repoFactory } from '../../core/db/repo-factory' -import { redisConnections } from '../../database/redis-connections' import { AuditEventEntity } from '../../ee/audit-logs/audit-event-entity' import { applicationEvents } from '../../helper/application-events' import { projectService } from '../../project/project-service' @@ -18,12 +17,6 @@ export const pieceUpgradeService = (log: FastifyBaseLogger) => ({ return Promise.all(unique(flowIds).map((flowId) => revertFlow({ flowId, log }))) }, async migrateFlowVersion({ flowVersion, projectId, platformId }: MigrateFlowVersionParams): Promise { - if (isNil(projectId) || isNil(platformId)) { - return { migrated: false, flowVersion } - } - if (!await isPlatformMigrationEnabled(platformId)) { - return { migrated: false, flowVersion } - } const { newFlowVersion, decisions } = await resolveFlowVersionUpgrades({ flowVersion, log }) if (decisions.length > 0) { await sendUpgradeAuditEvent({ platformId, projectId, flowId: flowVersion.flowId, flowVersionId: flowVersion.id, decisions, log }) @@ -34,16 +27,6 @@ export const pieceUpgradeService = (log: FastifyBaseLogger) => ({ const auditEventRepo = repoFactory(AuditEventEntity) const flowVersionRepo = repoFactory(FlowVersionEntity) -const PIECE_UPGRADE_ENABLED_PLATFORMS_KEY = 'piece_upgrade_enabled_platforms' - -async function isPlatformMigrationEnabled(platformId: string): Promise { - const redis = await redisConnections.useExisting() - const gateExists = await redis.exists(PIECE_UPGRADE_ENABLED_PLATFORMS_KEY) - if (gateExists === 0) { - return true - } - return await redis.sismember(PIECE_UPGRADE_ENABLED_PLATFORMS_KEY, platformId) === 1 -} async function revertFlow({ flowId, log }: RevertFlowParams): Promise { const flow = await flowRepo().findOneBy({ id: flowId }) @@ -225,6 +208,8 @@ async function resolveFlowVersionUpgrades({ flowVersion, log }: ResolveFlowVersi } async function sendUpgradeAuditEvent({ platformId, projectId, flowId, flowVersionId, decisions, log }: SendUpgradeAuditEventParams): Promise { + projectId = projectId ?? await flowRepo().findOneByOrFail({ id: flowId }).then((flow) => flow.projectId) + platformId = platformId ?? await projectService(log).getPlatformId(projectId!) applicationEvents(log).sendUserEvent({ platformId, projectId }, { action: ApplicationEventName.FLOW_PIECES_UPGRADED, data: { @@ -386,8 +371,8 @@ type FlowVersionUpgrades = { } type SendUpgradeAuditEventParams = { - platformId: string - projectId: string + platformId?: string + projectId?: string flowId: string flowVersionId: string decisions: StepUpgradeDecision[] From 1a332e604f96c9ce506fcd5c1440cdd8cb84ea7f Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 3 Sep 2026 10:54:40 +0300 Subject: [PATCH 09/10] fix(agents): an agent stops claiming a document does not say what it never read (#15235) --- bun.lock | 2 +- .../src/app/ee/agent/agent-rpc-handlers.ts | 7 +- .../knowledge-base/knowledge-base.service.ts | 8 ++ .../ee/agent/agent-knowledge-honesty.test.ts | 94 +++++++++++++++++++ .../app/ee/agent/agent-rpc-handlers.test.ts | 7 +- 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 packages/server/api/test/integration/ee/agent/agent-knowledge-honesty.test.ts diff --git a/bun.lock b/bun.lock index 8ed1d4b8a2d4..36b37c98e2a0 100644 --- a/bun.lock +++ b/bun.lock @@ -163,7 +163,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.156.0", + "version": "0.157.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index 21ef35ad2fc3..2213f5f130ab 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -511,7 +511,12 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to search a knowledge base' } }) } const { projectId, platformId } = conversation - await knowledgeBaseService(log).getFileOrThrow({ projectId, id: input.knowledgeBaseFileId }) + const file = await knowledgeBaseService(log).getFileOrThrow({ projectId, id: input.knowledgeBaseFileId }) + const searchable = await knowledgeBaseService(log).isSearchable({ projectId, knowledgeBaseFileId: input.knowledgeBaseFileId }) + if (!searchable) { + log.warn({ conversation: { id: input.conversationId }, project: { id: projectId }, knowledgeBaseFile: { id: input.knowledgeBaseFileId } }, '[agentRpc#executeKnowledgeBaseTool] The file has no searchable text, so the search was not run') + return { result: `"${file.displayName}" is attached but has never been indexed, so its text cannot be searched and you have not read any of it. Tell the user exactly that. Do not say the file does not contain what they asked for, and do not suggest re-uploading it: that will not index it either.` } + } const { model, providerOptions } = await agentHelpers.resolveEmbeddingModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) const { embedding } = await embed({ model, value: input.query, providerOptions }) const results = await knowledgeBaseService(log).search({ diff --git a/packages/server/api/src/app/knowledge-base/knowledge-base.service.ts b/packages/server/api/src/app/knowledge-base/knowledge-base.service.ts index 01c7d56ac932..fe505f9dbaa5 100644 --- a/packages/server/api/src/app/knowledge-base/knowledge-base.service.ts +++ b/packages/server/api/src/app/knowledge-base/knowledge-base.service.ts @@ -183,6 +183,14 @@ export const knowledgeBaseService = (log: FastifyBaseLogger) => ({ return kbChunkRepo().count({ where: { projectId: params.projectId, knowledgeBaseFileId: params.knowledgeBaseFileId } }) }, + async isSearchable(params: { projectId: string, knowledgeBaseFileId: string }): Promise { + return kbChunkRepo().existsBy({ + projectId: params.projectId, + knowledgeBaseFileId: params.knowledgeBaseFileId, + embedding: Not(IsNull()), + }) + }, + async extractChunks(params: { projectId: string, knowledgeBaseFileId: string }): Promise { const kbFile = await kbFileRepo().findOneBy({ id: params.knowledgeBaseFileId, diff --git a/packages/server/api/test/integration/ee/agent/agent-knowledge-honesty.test.ts b/packages/server/api/test/integration/ee/agent/agent-knowledge-honesty.test.ts new file mode 100644 index 000000000000..52f908d1b4f1 --- /dev/null +++ b/packages/server/api/test/integration/ee/agent/agent-knowledge-honesty.test.ts @@ -0,0 +1,94 @@ +import { AgentRunSource, apId, FileCompression, FileType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { agentRpcHandlers } from '../../../../src/app/ee/agent/agent-rpc-handlers' +import { knowledgeBaseService } from '../../../../src/app/knowledge-base/knowledge-base.service' +import { db } from '../../../helpers/db' +import { createMockFile } from '../../../helpers/mocks' +import { createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance + +beforeAll(async () => { + process.env.AP_AGENTS_ENABLED = 'true' + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +async function conversationWithFile(ctx: TestContext): Promise<{ conversationId: string, knowledgeBaseFileId: string }> { + const conversationId = apId() + await db.save('agent_conversation', { + id: conversationId, + created: new Date().toISOString(), + updated: new Date().toISOString(), + platformId: ctx.platform.id, + projectId: ctx.project.id, + userId: ctx.user.id, + source: AgentRunSource.AGENT, + status: 'STREAMING', + messages: [], + uiMessages: [], + }) + const file = createMockFile({ + projectId: ctx.project.id, + platformId: ctx.platform.id, + data: Buffer.from('The office closes at six.'), + type: FileType.KNOWLEDGE_BASE, + compression: FileCompression.NONE, + fileName: 'handbook.txt', + }) + await db.save('file', file) + const fileId = file.id + const kbFile = await knowledgeBaseService(app.log).createFile({ + projectId: ctx.project.id, + fileId, + displayName: 'Employee Handbook', + }) + return { conversationId, knowledgeBaseFileId: kbFile.id } +} + +async function search({ conversationId, knowledgeBaseFileId }: { conversationId: string, knowledgeBaseFileId: string }) { + return agentRpcHandlers(app.log).executeKnowledgeBaseTool({ + conversationId, + toolName: 'knowledge-employee-handbook', + knowledgeBaseFileId, + query: 'when does the office close', + }) +} + +describe('an agent asked about a file it cannot search says so', () => { + // The old answer was "No relevant information found", which the agent passed on as the document + // not mentioning the thing. It had not read a word of it. + it('says the file is not indexed rather than reporting the answer is absent', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const target = await conversationWithFile(ctx) + + const { result } = await search(target) + + expect(String(result)).toContain('never been indexed') + expect(String(result)).toContain('Employee Handbook') + expect(String(result)).not.toContain('No relevant information found') + }) + + it('does not send the person off to re-upload, which would not index it either', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const target = await conversationWithFile(ctx) + + const { result } = await search(target) + + expect(String(result)).toContain('will not index it either') + }) + + it('does not read a file belonging to another project', async () => { + const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const other = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + const mine = await conversationWithFile(ctx) + const theirs = await conversationWithFile(other) + + await expect(search({ conversationId: mine.conversationId, knowledgeBaseFileId: theirs.knowledgeBaseFileId })).rejects.toThrow() + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts index e87241a6ee12..175c9ce432d9 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts @@ -29,13 +29,14 @@ const { mockAssertProjectSwitchKeepsKey } = vi.hoisted(() => ({ mockAssertProjectSwitchKeepsKey: vi.fn().mockResolvedValue(undefined), })) -const { mockGetFileOrThrow, mockKbSearch } = vi.hoisted(() => ({ - mockGetFileOrThrow: vi.fn().mockResolvedValue({ id: 'kb-1' }), +const { mockGetFileOrThrow, mockKbSearch, mockIsSearchable } = vi.hoisted(() => ({ + mockGetFileOrThrow: vi.fn().mockResolvedValue({ id: 'kb-1', displayName: 'Employee Handbook' }), mockKbSearch: vi.fn().mockResolvedValue([]), + mockIsSearchable: vi.fn().mockResolvedValue(true), })) vi.mock('../../../../../src/app/knowledge-base/knowledge-base.service', () => ({ - knowledgeBaseService: () => ({ getFileOrThrow: mockGetFileOrThrow, search: mockKbSearch }), + knowledgeBaseService: () => ({ getFileOrThrow: mockGetFileOrThrow, search: mockKbSearch, isSearchable: mockIsSearchable }), })) const { mockEmbed } = vi.hoisted(() => ({ From f5c91d639a87a83f093c281c1be9be271e76c856 Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:01:34 +0300 Subject: [PATCH 10/10] docs: expand platform admin guides and serve media from CDN (#15153) Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> Co-authored-by: Othman Emad --- .../engineering-handbook-playbooks.md | 1 + docs/admin-guide/guides/ai-capabilities.mdx | 52 +++ docs/admin-guide/guides/connections.mdx | 44 +++ docs/admin-guide/guides/general.mdx | 14 + .../admin-guide/guides/global-connections.mdx | 74 ++++ docs/admin-guide/guides/manage-pieces.mdx | 18 +- docs/admin-guide/guides/permissions.mdx | 92 ++++- .../admin-guide/guides/setup-ai-providers.mdx | 138 +++++-- docs/admin-guide/guides/sso.mdx | 373 ++++++++---------- .../admin-guide/guides/structure-projects.mdx | 144 ++++++- docs/admin-guide/guides/templates.mdx | 22 ++ docs/docs.json | 140 ++++--- docs/mcp/overview.mdx | 18 +- 13 files changed, 795 insertions(+), 335 deletions(-) create mode 100644 docs/admin-guide/guides/ai-capabilities.mdx create mode 100644 docs/admin-guide/guides/connections.mdx create mode 100644 docs/admin-guide/guides/general.mdx create mode 100644 docs/admin-guide/guides/global-connections.mdx create mode 100644 docs/admin-guide/guides/templates.mdx diff --git a/brain/knowledge/engineering/engineering-handbook-playbooks.md b/brain/knowledge/engineering/engineering-handbook-playbooks.md index 9539672cecbf..30eb1a950856 100644 --- a/brain/knowledge/engineering/engineering-handbook-playbooks.md +++ b/brain/knowledge/engineering/engineering-handbook-playbooks.md @@ -22,3 +22,4 @@ Postmortems (Redis/queue overload, infra upgrade — March 2026) and product int ## Gotchas - **`DISCORD_ON_CALL_WEBHOOK` is the single repo secret behind every on-call Discord notification** — cloud production deploys (`continuous-delivery-cloud.yml`), self-hosted releases (`release-self-hosted.yml`), and release-pieces failure alerts all post through it. Rotating it repoints all of them at once; there is no per-workflow webhook. Self-hosted release notifications skip `-rc` tags unless `publish_rc_release` is set, mirroring the release-drafter condition so the message never links a release page that doesn't exist. +- **Docs media lives on the CDN, never in the repo — and videos and images sit on two different paths.** A video embeds as `
+
- **Cloud platforms** - - Azure - - AWS Bedrock + **Cloud platforms** - **Gateways** - - OpenRouter - - Cloudflare AI Gateway + * Azure + * AWS Bedrock - **Anything else** - - Other (OpenAI Compatible) -
+ **Gateways** + + * OpenRouter + * Cloudflare AI Gateway + + **Anything else** + + * Other (OpenAI Compatible) +
-**Other (OpenAI Compatible)** covers any endpoint that speaks the OpenAI API, including self-hosted models and gateways not listed here. Point it at your own base URL. + **Other (OpenAI Compatible)** covers any endpoint that speaks the OpenAI API, including self-hosted models and gateways not listed here. Point it at your own base URL. ## Cost control and logging +Spend on your own key goes to your provider, so put a gateway in front of it when you want limits and an audit trail: + +- Set rate limits and budgets +- Log and monitor every AI request +- Track usage across projects + +**OpenRouter** and **Cloudflare AI Gateway** are set up like any other provider. Any other gateway that speaks the OpenAI API works through **Other (OpenAI Compatible)**, so LiteLLM, Portkey, Helicone, and self-hosted routers are all fine. + +## Supported providers + + +
+ **Model providers** + + * OpenAI + * Anthropic + * Google Gemini + * Mistral AI + * DeepSeek + * xAI + * Qwen + * Z.ai + * MiniMax + * Moonshot AI +
+ +
+ **Cloud platforms** + + * Azure + * AWS Bedrock + + **Gateways** + + * OpenRouter + * Cloudflare AI Gateway + + **Anything else** + + * Other (OpenAI Compatible) +
+
+ + + **Other (OpenAI Compatible)** covers any endpoint that speaks the OpenAI API, including self-hosted models and gateways not listed here. Point it at your own base URL. + + +## Cost control and logging Spend on your own key goes to your provider, so put a gateway in front of it when you want limits and an audit trail: @@ -57,8 +133,12 @@ Spend on your own key goes to your provider, so put a gateway in front of it whe **OpenRouter** and **Cloudflare AI Gateway** are set up like any other provider. Any other gateway that speaks the OpenAI API works through **Other (OpenAI Compatible)**, so LiteLLM, Portkey, Helicone, and self-hosted routers are all fine. -**Azure** and **AWS Bedrock** give you the same thing through your cloud account's own quotas and logs. +**Azure** and **AWS Bedrock** give you the same thing through your cloud account's own quotas and logs**Azure** and **AWS Bedrock** give you the same thing through your cloud account's own quotas and logs. + + + You can add several providers at once and pick the model per agent or per step, so the expensive models only run on the work that needs them. + -You can add several providers at once and pick the model per agent or per step, so the expensive models only run on the work that needs them. + You can add several providers at once and pick the model per agent or per step, so the expensive models only run on the work that needs them. diff --git a/docs/admin-guide/guides/sso.mdx b/docs/admin-guide/guides/sso.mdx index 578f20ff4d49..a4f9927a26be 100644 --- a/docs/admin-guide/guides/sso.mdx +++ b/docs/admin-guide/guides/sso.mdx @@ -1,7 +1,7 @@ --- title: "How to Setup SSO" description: "Configure Single Sign-On (SSO) to enable secure, centralized authentication for your Activepieces platform" -icon: 'key' +icon: "key" --- @@ -33,7 +33,7 @@ You can enforce SSO by specifying your organization's email domain. When SSO enf - All authentication is routed through your designated identity provider -We recommend testing SSO with a small group of users before enforcing it organization-wide. + We recommend testing SSO with a small group of users before enforcing it organization-wide. ## SSO Domain @@ -52,7 +52,7 @@ On the cloud sign-in page, clicking **Sign in with SAML** opens a dialog asking - Each domain can be claimed by only one platform on Cloud. -On self-hosted Enterprise instances the SAML button on the sign-in page redirects directly to the configured identity provider, so the **SSO Domain** field is effectively ignored at login. You can still leave it empty. + On self-hosted Enterprise instances the SAML button on the sign-in page redirects directly to the configured identity provider, so the **SSO Domain** field is effectively ignored at login. You can still leave it empty. ## Supported SSO Providers @@ -62,222 +62,197 @@ Activepieces supports multiple SSO providers to integrate with your existing ide ### Google - - Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create a new one). - - - Navigate to **APIs & Services** → **Credentials** → **Create Credentials** → **OAuth client ID**. - - Select **Web application** as the application type. - - - Copy the **Redirect URL** from the Activepieces SSO configuration screen and add it to the **Authorized redirect URIs** in Google Cloud Console. - - - Copy the **Client ID** and **Client Secret** from Google and paste them into the corresponding fields in Activepieces. - - - Click **Finish** to complete the setup. - - - -### GitHub - - - - Go to [GitHub Developer Settings](https://github.com/settings/developers) → **OAuth Apps** → **New OAuth App**. - - - Fill in the application details: - - **Application name**: Choose a recognizable name (e.g., "Activepieces SSO") - - **Homepage URL**: Enter your Activepieces instance URL - - - Copy the **Redirect URL** from the Activepieces SSO configuration screen and paste it into the **Authorization callback URL** field. - - - Click **Register application** to create the OAuth App. - - - After registration, click **Generate a new client secret** and copy it immediately (it won't be shown again). - - - Copy the **Client ID** and **Client Secret** and paste them into the corresponding fields in Activepieces. - - - Click **Finish** to complete the setup. - + + Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create a new one). + + + Navigate to **APIs & Services** → **Credentials** → **Create Credentials** → **OAuth client ID**. + + Select **Web application** as the application type. + + + Copy the **Redirect URL** from the Activepieces SSO configuration screen and add it to the **Authorized redirect URIs** in Google Cloud Console. + + + Copy the **Client ID** and **Client Secret** from Google and paste them into the corresponding fields in Activepieces. + + + Click **Finish** to complete the setup. + ### SAML with Okta - - Go to the [Okta Admin Portal](https://login.okta.com/) → **Applications** → **Create App Integration**. - - - Choose **SAML 2.0** as the sign-on method and click **Next**. - - - Enter an **App name** (e.g., "Activepieces") and optionally upload a logo. Click **Next**. - - - - **Single sign-on URL**: Copy the SSO URL from the Activepieces configuration screen - - **Audience URI (SP Entity ID)**: Enter `Activepieces` - - **Name ID format**: Select `EmailAddress` - - - Add the following attribute mappings: - - | Name | Value | - |------|-------| - | `firstName` | `user.firstName` | - | `lastName` | `user.lastName` | - | `email` | `user.email` | - - - Click **Next**, select the appropriate feedback option, and click **Finish**. - - - Go to the **Sign On** tab → **View SAML setup instructions** or **View IdP metadata**. Copy the Identity Provider metadata XML. - - - - Paste the **IdP Metadata** XML into the corresponding field - - Copy the **X.509 Certificate** from Okta and paste it into the **Signing Key** field - - (Optional, Cloud) Set the **SSO Domain** to your organization's public domain (e.g. `acme.com`) so users can sign in by entering it on the cloud sign-in page. See [SSO Domain](#sso-domain) above. - - - Click **Save** to complete the setup. - + + Go to the [Okta Admin Portal](https://login.okta.com/) → **Applications** → **Create App Integration**. + + + Choose **SAML 2.0** as the sign-on method and click **Next**. + + + Enter an **App name** (e.g., "Activepieces") and optionally upload a logo. Click **Next**. + + + - **Single sign-on URL**: Copy the SSO URL from the Activepieces configuration screen + - **Audience URI (SP Entity ID)**: Enter `Activepieces` + - **Name ID format**: Select `EmailAddress` + + + Add the following attribute mappings: + + | Name | Value | + | --- | --- | + | `firstName` | `user.firstName` | + | `lastName` | `user.lastName` | + | `email` | `user.email` | + + + Click **Next**, select the appropriate feedback option, and click **Finish**. + + + Go to the **Sign On** tab → **View SAML setup instructions** or **View IdP metadata**. Copy the Identity Provider metadata XML. + + + - Paste the **IdP Metadata** XML into the corresponding field + - Copy the **X.509 Certificate** from Okta and paste it into the **Signing Key** field + - (Optional, Cloud) Set the **SSO Domain** to your organization's public domain (e.g. `acme.com`) so users can sign in by entering it on the cloud sign-in page. See [SSO Domain](#sso-domain) above. + + + Click **Save** to complete the setup. + ### SAML with Microsoft Entra ID (Azure AD) - - Go to the [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** → **Enterprise applications** → **New application** → **Create your own application**. - - Name it (e.g., "Activepieces") and select **Integrate any other application you don't find in the gallery (Non-gallery)**. - - - Open the application → **Single sign-on** → select **SAML**. - - - Edit **Basic SAML Configuration**: - - **Identifier (Entity ID)**: `Activepieces` - - **Reply URL (Assertion Consumer Service URL)**: paste the SSO URL from the Activepieces configuration screen - - - Edit **Attributes & Claims** and add these additional claims (leave **Namespace** empty): - - | Claim name | Source attribute | - |------------|------------------| - | `firstName` | `user.givenname` | - | `lastName` | `user.surname` | - | `email` | `user.mail` | - - - In the **SAML Certificates** section, copy the **App Federation Metadata Url**. - - You can paste this URL directly into the **IdP Metadata** field in Activepieces — Activepieces will fetch the metadata XML automatically. Alternatively, open the URL in a browser, save the XML, and paste its contents. - - - Download the **Certificate (Base64)** from the **SAML Certificates** section. Open the file and copy its contents (including the `-----BEGIN CERTIFICATE-----` / `-----END CERTIFICATE-----` markers) into the **Signing Key** field in Activepieces. - - - Go to **Users and groups** in the application and assign the users or groups that should be allowed to sign in. - - - Click **Save** in Activepieces to complete the setup. - + + Go to the [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** → **Enterprise applications** → **New application** → **Create your own application**. + + Name it (e.g., "Activepieces") and select **Integrate any other application you don't find in the gallery (Non-gallery)**. + + + Open the application → **Single sign-on** → select **SAML**. + + + Edit **Basic SAML Configuration**: + + - **Identifier (Entity ID)**: `Activepieces` + - **Reply URL (Assertion Consumer Service URL)**: paste the SSO URL from the Activepieces configuration screen + + + Edit **Attributes & Claims** and add these additional claims (leave **Namespace** empty): + + | Claim name | Source attribute | + | --- | --- | + | `firstName` | `user.givenname` | + | `lastName` | `user.surname` | + | `email` | `user.mail` | + + + In the **SAML Certificates** section, copy the **App Federation Metadata Url**. + + You can paste this URL directly into the **IdP Metadata** field in Activepieces — Activepieces will fetch the metadata XML automatically. Alternatively, open the URL in a browser, save the XML, and paste its contents. + + + Download the **Certificate (Base64)** from the **SAML Certificates** section. Open the file and copy its contents (including the `-----BEGIN CERTIFICATE-----` / `-----END CERTIFICATE-----` markers) into the **Signing Key** field in Activepieces. + + + Go to **Users and groups** in the application and assign the users or groups that should be allowed to sign in. + + + Click **Save** in Activepieces to complete the setup. + ### SAML with JumpCloud - - Go to the [JumpCloud Admin Portal](https://console.jumpcloud.com/) → **SSO Applications** → **Add New Application** → **Custom SAML App**. - - - Copy the **ACS URL** from the Activepieces configuration screen and paste it into the **ACS URLs** field in JumpCloud. - - ![JumpCloud ACS URL](/resources/screenshots/jumpcloud/acl-url.png) - - - Set the **SP Entity ID** (Audience URI) to `Activepieces`. - - - Configure the following attribute mappings: - - | Service Provider Attribute | JumpCloud Attribute | - |---------------------------|---------------------| - | `firstName` | `firstname` | - | `lastName` | `lastname` | - | `email` | `email` | - - ![JumpCloud User Attributes](/resources/screenshots/jumpcloud/user-attribute.png) - - - JumpCloud does not include the `HTTP-Redirect` binding by default. You **must** enable this option. - - ![JumpCloud Redirect Binding](/resources/screenshots/jumpcloud/declare-login.png) - - - Without HTTP-Redirect binding, the SSO integration will not work correctly. - - - - Click **Save**, then refresh the page and click **Export Metadata**. - - ![JumpCloud Export Metadata](/resources/screenshots/jumpcloud/export-metadata.png) - - - Verify that the exported XML contains `Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"` to ensure the binding was properly enabled. - - - - Paste the exported metadata XML into the **IdP Metadata** field in Activepieces. - - - Locate the `` element in the IdP metadata and extract its value. Format it as a PEM certificate: - - ``` - -----BEGIN CERTIFICATE----- - [PASTE THE CERTIFICATE VALUE HERE] - -----END CERTIFICATE----- - ``` - - Paste this into the **Signing Key** field. - - - In JumpCloud, assign the application to the appropriate users or user groups. - - ![JumpCloud Assign App](/resources/screenshots/jumpcloud/user-groups.png) - - - Click **Finish** to complete the setup. - + + Go to the [JumpCloud Admin Portal](https://console.jumpcloud.com/) → **SSO Applications** → **Add New Application** → **Custom SAML App**. + + + Copy the **ACS URL** from the Activepieces configuration screen and paste it into the **ACS URLs** field in JumpCloud. + + ![JumpCloud ACS URL](/resources/screenshots/jumpcloud/acl-url.png) + + + Set the **SP Entity ID** (Audience URI) to `Activepieces`. + + + Configure the following attribute mappings: + + | Service Provider Attribute | JumpCloud Attribute | + | --- | --- | + | `firstName` | `firstname` | + | `lastName` | `lastname` | + | `email` | `email` | + + ![JumpCloud User Attributes](/resources/screenshots/jumpcloud/user-attribute.png) + + + JumpCloud does not include the `HTTP-Redirect` binding by default. You **must** enable this option. + + ![JumpCloud Redirect Binding](/resources/screenshots/jumpcloud/declare-login.png) + + + Without HTTP-Redirect binding, the SSO integration will not work correctly. + + + + Click **Save**, then refresh the page and click **Export Metadata**. + + ![JumpCloud Export Metadata](/resources/screenshots/jumpcloud/export-metadata.png) + + + Verify that the exported XML contains `Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"` to ensure the binding was properly enabled. + + + + Paste the exported metadata XML into the **IdP Metadata** field in Activepieces. + + + Locate the `` element in the IdP metadata and extract its value. Format it as a PEM certificate: + + ```text + -----BEGIN CERTIFICATE----- + [PASTE THE CERTIFICATE VALUE HERE] + -----END CERTIFICATE----- + ``` + + Paste this into the **Signing Key** field. + + + In JumpCloud, assign the application to the appropriate users or user groups. + + ![JumpCloud Assign App](/resources/screenshots/jumpcloud/user-groups.png) + + + Click **Finish** to complete the setup. + ## Troubleshooting - - - Verify the redirect URL is correctly configured in your identity provider - - Ensure users are assigned to the application in your identity provider - - Check that email domains match the SSO enforcement settings - - - - Confirm the IdP metadata is complete and correctly formatted - - If you pasted a metadata URL, make sure it is publicly reachable (Activepieces fetches it server-side) - - Verify the signing certificate is properly formatted with BEGIN/END markers - - Ensure all required attributes (firstName, lastName, email) are mapped - - - - Enable the HTTP-Redirect binding option in JumpCloud - - Re-export the metadata after enabling the binding - - Verify the binding appears in the exported XML - + + - Verify the redirect URL is correctly configured in your identity provider + - Ensure users are assigned to the application in your identity provider + - Check that email domains match the SSO enforcement settings + + + + - Confirm the IdP metadata is complete and correctly formatted + - If you pasted a metadata URL, make sure it is publicly reachable (Activepieces fetches it server-side) + - Verify the signing certificate is properly formatted with BEGIN/END markers + - Ensure all required attributes (firstName, lastName, email) are mapped + + + + - Enable the HTTP-Redirect binding option in JumpCloud + - Re-export the metadata after enabling the binding + - Verify the binding appears in the exported XML + ## Need Help? diff --git a/docs/admin-guide/guides/structure-projects.mdx b/docs/admin-guide/guides/structure-projects.mdx index 87572e3cfe1a..349f76170668 100644 --- a/docs/admin-guide/guides/structure-projects.mdx +++ b/docs/admin-guide/guides/structure-projects.mdx @@ -1,15 +1,143 @@ --- -title: "How to Structure Projects" -description: "" -icon: "building" +title: "Projects" +icon: "house" --- -Projects in Activepieces are the main units for organizing your automations and resources within your organization. Every project contains its own flows, connections, and tables. Access to these resources is shared among everyone who has access to that project. +### **What is a project:** -There are two types of projects: -- **Personal Projects**: Each user invited to your organization automatically receives a personal project. This is a private space where only that user can create and manage flows, connections, and tables. -- **Team Projects**: Team projects are shared spaces that can be created and managed from this page. Multiple users can be invited to a team project, allowing them to collaborate, share access to flows, connections, and tables, and work together. +Projects help you organize automations and resources for different teams or areas of work. Each project has its own flows, connections, and tables, which are only shared with users who have access to that project.  -When organizing your work, create team projects for group collaboration and utilize personal projects for individual or private tasks. \ No newline at end of file +Being invited to the platform does not automatically give a user access to every project. Users only see the projects they have access to, while **platform admins can access all projects**. + +### **Why you need projects: ** + +For example, each department can have its own project, so other departments can’t access its connections or credentials. + +In the following parts under this project section, we will explore everything that you do within a project. + +### **Personal and Team Projects: ** + +In Activepieces, you have two types of projects: + +**Personal projects:** A personal project has a single member, its owner, so no other member of the platform sees its flows, connections, or tables. It gives users a safe space to experiment and try new ideas without affecting shared company projects or data. However, as with every project, platform admins and operators can still access it. Operators are automatically granted Editor access to every project on the platform, including personal ones, letting them view and modify its flows, connections, tables, and variables. + +New users get a personal project on signup by default. Platform admins can turn this off with **Automatic personal project creation** on the **Platform Admin → Projects** page — useful when you provision users into team projects manually, for example through SSO or SCIM. While it is off, new users start without a personal project and land on a team project they have been given access to instead. + +![personal-project-creation](https://cdn.activepieces.com/assets/personal-project-creation.png "personal-project-creation") + +**Team Projects:** Team projects are shared workspaces where multiple users can collaborate on automations. Members of a team project can share access to its flows, connections, and tables. Team projects are useful for organizing and managing automation work that needs to be shared across a team. + +