diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e908916649..a3c235ccbfa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -340,7 +340,7 @@ jobs: build: needs: [prepare-matrix, run-clickhouse-migrations] timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 environment: ${{ fromJson(needs.prepare-matrix.outputs.env_matrix).environment[0] }} permissions: contents: read @@ -374,9 +374,7 @@ jobs: run: pnpm ci - name: Set Up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - with: - driver-opts: "image=moby/buildkit:v0.13.1" + uses: useblacksmith/setup-docker-builder@a592b831ebb20e68f7cf47329cf2c3c67b8a7655 # v1 - name: Prepare Variables run: echo "BULL_MQ_PRO_NPM_TOKEN=${{ secrets.BULL_MQ_PRO_NPM_TOKEN }}" >> $GITHUB_ENV diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts index 93e6bd0d7b8..be33105cb6e 100644 --- a/apps/api/src/app/agents/agents.controller.ts +++ b/apps/api/src/app/agents/agents.controller.ts @@ -199,6 +199,7 @@ export class AgentsController { } @Post('/verify-credentials') + @ExternalApiAccessible() @ApiResponse(VerifyManagedCredentialsResponseDto) @ApiOperation({ summary: 'Verify managed-runtime credentials', diff --git a/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts b/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts index d3cd35c2f5f..1a42cbf5140 100644 --- a/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts +++ b/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts @@ -76,6 +76,19 @@ describe('Verify Managed Credentials API #novu-v2', () => { expect(mockProvider.validateCredentials.firstCall.args[0]).to.deep.equal({ apiKey: FAKE_API_KEY }); }); + it('accepts API key authentication for CLI connect flows', async () => { + const res = await session.testAgent + .post('/v1/agents/verify-credentials') + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ + providerId: AgentRuntimeProviderIdEnum.Anthropic, + apiKey: FAKE_API_KEY, + }); + + expect(res.status).to.equal(201); + expect(res.body.data?.valid ?? res.body.valid).to.equal(true); + }); + it('returns 401 when the provider rejects the API key', async () => { mockProvider.validateCredentials.rejects( new AgentRuntimeUnauthorizedError('Invalid API key', AgentRuntimeProviderIdEnum.Anthropic) diff --git a/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts b/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts index 53437db9570..de52ffa754e 100644 --- a/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts +++ b/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts @@ -1,4 +1,13 @@ -import { CLAUDE_ANTHROPIC_SKILLS, CLAUDE_BUILTIN_TOOLS, MCP_SERVERS } from '@novu/shared'; +import { + CLAUDE_ANTHROPIC_SKILLS, + CLAUDE_BUILTIN_TOOLS, + MANAGED_AGENT_IDENTIFIER_MAX_LENGTH, + MANAGED_AGENT_NAME_MAX_LENGTH, + MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH, + MAX_GENERATED_MCP_SERVERS, + MAX_GENERATED_SKILLS, + MCP_SERVERS, +} from '@novu/shared'; import { z } from 'zod'; /** @@ -10,26 +19,24 @@ const TOOL_TYPES = CLAUDE_BUILTIN_TOOLS.map((tool) => tool.type) as [string, ... const MCP_SERVER_IDS = MCP_SERVERS.map((server) => server.id) as [string, ...string[]]; const SKILL_IDS = CLAUDE_ANTHROPIC_SKILLS.map((skill) => skill.skillId) as [string, ...string[]]; -/** Anthropic caps `mcp_servers` length; pick a conservative limit. */ -export const MAX_GENERATED_MCP_SERVERS = 5; -export const MAX_GENERATED_SKILLS = 4; +export { MAX_GENERATED_MCP_SERVERS, MAX_GENERATED_SKILLS }; export const managedAgentGenerationSchema = z.object({ name: z .string() .min(1) - .max(60) + .max(MANAGED_AGENT_NAME_MAX_LENGTH) .describe('Human readable agent name. Title case, 2–4 words (e.g., "PR Security Reviewer").'), identifier: z .string() .min(1) - .max(60) + .max(MANAGED_AGENT_IDENTIFIER_MAX_LENGTH) .regex(/^[a-z0-9-]+$/, 'Identifier must be lowercase kebab-case') .describe('Stable kebab-case identifier derived from the name (e.g., "pr-security-reviewer").'), systemPrompt: z .string() .min(1) - .max(4000) + .max(MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH) .describe( 'The full system prompt sent to Claude. Speak in second person to the agent ("You are…"). Describe role, scope, tone, and the workflow it should follow. Reference the available tools/MCPs/skills naturally without hard-coding them.' ), diff --git a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts b/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts index 0c431c8bdeb..750de73f295 100644 --- a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts +++ b/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts @@ -8,6 +8,7 @@ export interface ToolProgressPayload { toolName?: string; mcpServerName?: string; status?: 'running' | 'complete' | 'error'; + details?: string; toolInput?: Record; } diff --git a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts b/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts index cadf27d7bac..6ad2d067999 100644 --- a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts @@ -81,7 +81,7 @@ export class HandlePlanProgress { const toolName = toolProgress.toolName || existing?.toolName || 'Tool'; const mcpServerName = toolProgress.mcpServerName || existing?.mcpServerName; const status: PlanTaskStatus = toolProgress.status === 'running' ? 'in_progress' : toolProgress.status; - const details = formatToolInputSummary(toolProgress.toolInput) || existing?.details; + const details = toolProgress.details || formatToolInputSummary(toolProgress.toolInput) || existing?.details; tasks.set(toolProgress.toolUseId, { toolUseId: toolProgress.toolUseId, toolName, mcpServerName, status, details }); @@ -136,13 +136,8 @@ export class HandlePlanProgress { return; } - const finalStatus: PlanTaskStatus = toolProgress.action === 'fail' ? 'error' : 'complete'; const title = toolProgress.action === 'fail' ? 'Something went wrong' : 'Finished thinking'; - const tasks = this.collectTasks(existingActivities); - for (const task of tasks.values()) { - task.status = finalStatus; - } await this.postOrEditPlan(command, planMessageId, this.toModel(title, tasks, true)); } diff --git a/apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts b/apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts index 442c179767e..8f46ec7a5a1 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts +++ b/apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts @@ -84,27 +84,19 @@ export function extractPendingToolApprovals(response: ThalamusResponse): Pending } function formatToolLabel(t: PendingToolApproval): string { - const name = t.mcpServerName ? `${t.toolName} from ${t.mcpServerName}` : t.toolName; const input = t.input ? `: ${summariseInput(t.input)}` : ''; - return `${name}${input}`; + if (t.mcpServerName) { + return `${t.mcpServerName} -> ${t.toolName}${input}`; + } + + return `${t.toolName}${input}`; } -export function buildToolApprovalCard(pendingTools: PendingToolApproval[], turnId: string): Record { - const tool = pendingTools[0]; - const serverLabel = tool.mcpServerName ? ` from ${tool.mcpServerName}` : ''; +export function buildToolApprovalCard(tool: PendingToolApproval, turnId: string): Record { const toolLabel = formatToolLabel(tool); - const mcpDisplayName = tool.mcpServerName ?? 'MCP'; - - const inputSummary = tool.input ? summariseInput(tool.input) : ''; - const description = inputSummary - ? `I'd like to call \`${tool.toolName}\`${serverLabel}:\n\`\`\`\n${inputSummary}\n\`\`\`` - : `I'd like to call \`${tool.toolName}\`${serverLabel}.`; - - const children: Record[] = [{ type: 'text', content: description }]; - children.push( - { type: 'divider' }, + const children: Record[] = [ { type: 'actions', children: [ @@ -123,80 +115,47 @@ export function buildToolApprovalCard(pendingTools: PendingToolApproval[], turnI value: toolLabel, }, ], - } - ); - - if (pendingTools.length === 1) { - children.push( - { type: 'divider' }, - { - type: 'actions', - children: [ - { - type: 'button', - id: buildPersistTrustActionId('approve-tool', tool, turnId), - label: `Approve & always allow ${tool.toolName}`, - style: 'default', - value: toolLabel, - }, - { - type: 'button', - id: buildPersistTrustActionId('approve-server', tool, turnId), - label: `Approve & always allow all ${mcpDisplayName} tools`, - style: 'default', - value: toolLabel, - }, - ], - } - ); - } - - if (pendingTools.length > 1) { - const allIds = pendingTools.map((t) => t.toolUseId).join(','); - const allLabels = pendingTools.map((t) => formatToolLabel(t)).join('\n'); - children.push({ + }, + { type: 'divider' }, + { type: 'actions', children: [ { type: 'button', - id: `${TOOL_APPROVAL_ACTION_PREFIX}:approve:${allIds}:${turnId}`, - label: `Approve All (${pendingTools.length})`, - style: 'primary', - value: allLabels, + id: buildPersistTrustActionId('approve-tool', tool, turnId), + label: `Approve & Always allow ${tool.toolName}`, + style: 'default', + value: toolLabel, }, { type: 'button', - id: `${TOOL_APPROVAL_ACTION_PREFIX}:deny:${allIds}:${turnId}`, - label: `Deny All (${pendingTools.length})`, - style: 'danger', - value: allLabels, + id: buildPersistTrustActionId('approve-server', tool, turnId), + label: `Approve & Always allow all from ${tool.mcpServerName}`, + style: 'default', + value: toolLabel, }, ], - }); - } + }, + ]; return { type: 'card', title: 'Tool Approval', + subtitle: toolLabel, children, }; } -export function buildToolApprovalVerdictCard( - approved: boolean, - toolCount: number, - toolDescription?: string -): Record { +export function buildToolApprovalVerdictCard(approved: boolean, toolDescription?: string): Record { const emoji = approved ? '✅' : '🚫'; const verb = approved ? 'Approved' : 'Denied'; - const suffix = toolCount > 1 ? ` all ${toolCount} tools` : ''; const subtitle = toolDescription || undefined; return { type: 'card', title: 'Tool Approval', subtitle, - children: [{ type: 'text', content: `${emoji} ${verb}${suffix}` }], + children: [{ type: 'text', content: `${emoji} ${verb}` }], }; } diff --git a/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts b/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts index 7e6df020631..77c082ff155 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts +++ b/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts @@ -1,13 +1,19 @@ import { Injectable } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; -import { AgentMcpServerRepository, McpConnectionRepository, SubscriberRepository } from '@novu/dal'; +import { + AgentMcpServerRepository, + ConversationRepository, + McpConnectionRepository, + SubscriberRepository, +} from '@novu/dal'; import { ManagedAgentService } from '../../services/managed-agent.service'; +import { ManagedAgentProviderFactory } from '../../services/managed-agent-provider-factory'; import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command'; import { HandleAgentReply } from '../handle-agent-reply/handle-agent-reply.usecase'; import { HandlePlanProgressCommand } from '../handle-plan-progress/handle-plan-progress.command'; import { HandlePlanProgress } from '../handle-plan-progress/handle-plan-progress.usecase'; -import { buildToolApprovalVerdictCard } from './approval-card.builder'; +import { buildToolApprovalVerdictCard, type ParsedToolApprovalAction } from './approval-card.builder'; import { ConfirmToolApprovalCommand } from './confirm-tool-approval.command'; import { mergeToolTrustPatch, resolveTrustForPendingTool } from './tool-trust.helper'; @@ -17,6 +23,8 @@ export class ConfirmToolApproval { private readonly subscriberRepository: SubscriberRepository, private readonly agentMcpServerRepository: AgentMcpServerRepository, private readonly mcpConnectionRepository: McpConnectionRepository, + private readonly conversationRepository: ConversationRepository, + private readonly providerFactory: ManagedAgentProviderFactory, private readonly managedAgentService: ManagedAgentService, private readonly handleAgentReply: HandleAgentReply, private readonly handlePlanProgress: HandlePlanProgress, @@ -27,53 +35,10 @@ export class ConfirmToolApproval { async execute(command: ConfirmToolApprovalCommand): Promise { const { parsed } = command; - let persistTrust: { connectionId: string; toolName: string; scope: 'tool' | 'server' } | undefined; - - // "Approve & always allow …": save always_allow on the MCP connection (from action.id), - // then tell the runtime to continue the paused agent turn. - if (parsed.approved && parsed.persistScope && command.subscriberId) { - const subscriber = await this.subscriberRepository.findBySubscriberId( - command.environmentId, - command.subscriberId - ); - const toolName = parsed.toolName; - const mcpServerName = parsed.mcpServerName; - - if (subscriber && mcpServerName && toolName) { - const resolution = await resolveTrustForPendingTool({ - findOAuthEnablementsForAgent: (params) => this.agentMcpServerRepository.findOAuthEnablementsForAgent(params), - findSubscriberConnection: (params) => this.mcpConnectionRepository.findSubscriberConnection(params), - params: { - environmentId: command.environmentId, - organizationId: command.organizationId, - agentId: command.agentId, - subscriberMongoId: subscriber._id, - mcpServerName, - toolName, - }, - }); - - if (resolution) { - persistTrust = { - connectionId: resolution.connection._id, - toolName, - scope: parsed.persistScope, - }; - } - } - } - if (parsed.approved && persistTrust) { - await this.mcpConnectionRepository.mergeToolTrust({ - connectionId: persistTrust.connectionId, - environmentId: command.environmentId, - organizationId: command.organizationId, - patch: mergeToolTrustPatch({ - scope: persistTrust.scope, - toolName: persistTrust.toolName, - }), - }); - } + await this.persistTrustIfNeeded(command, parsed); + + const toolUseIds = await this.resolveConfirmationToolUseIds(command, parsed); await this.managedAgentService.resumeWithToolResults({ conversationId: command.conversationId, @@ -83,28 +48,169 @@ export class ConfirmToolApproval { integrationIdentifier: command.integrationIdentifier, subscriberId: command.subscriberId, platform: command.platform, - toolUseIds: parsed.toolUseIds, + toolUseIds, approved: parsed.approved, turnId: parsed.turnId, }); - if (command.sourceMessageId) { - const verdictCard = buildToolApprovalVerdictCard(parsed.approved, parsed.toolUseIds.length, command.actionValue); - this.handleAgentReply - .execute( - HandleAgentReplyCommand.create({ - userId: command.userId, - environmentId: command.environmentId, - organizationId: command.organizationId, - conversationId: command.conversationId, - agentIdentifier: command.agentIdentifier, - integrationIdentifier: command.integrationIdentifier, - edit: { messageId: command.sourceMessageId, content: { card: verdictCard } }, - }) - ) - .catch((err) => { - this.logger.warn(err, 'Failed to update tool approval card with verdict'); - }); + this.updateCardWithVerdict(command, parsed.approved); + this.updatePlanProgress(command, parsed); + } + + private async persistTrustIfNeeded( + command: ConfirmToolApprovalCommand, + parsed: ParsedToolApprovalAction + ): Promise { + if (!parsed.approved || !parsed.persistScope || !command.subscriberId) { + return; + } + + const toolName = parsed.toolName; + const mcpServerName = parsed.mcpServerName; + + if (!toolName || !mcpServerName) { + return; + } + + const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); + + if (!subscriber) { + return; + } + + const resolution = await resolveTrustForPendingTool({ + findOAuthEnablementsForAgent: (params) => this.agentMcpServerRepository.findOAuthEnablementsForAgent(params), + findSubscriberConnection: (params) => this.mcpConnectionRepository.findSubscriberConnection(params), + params: { + environmentId: command.environmentId, + organizationId: command.organizationId, + agentId: command.agentId, + subscriberMongoId: subscriber._id, + mcpServerName, + toolName, + }, + }); + + if (!resolution) { + return; + } + + await this.mcpConnectionRepository.mergeToolTrust({ + connectionId: resolution.connection._id, + environmentId: command.environmentId, + organizationId: command.organizationId, + patch: mergeToolTrustPatch({ + scope: parsed.persistScope, + toolName, + }), + }); + } + + private async resolveConfirmationToolUseIds( + command: ConfirmToolApprovalCommand, + parsed: ParsedToolApprovalAction + ): Promise { + if (parsed.persistScope !== 'server' || !parsed.mcpServerName) { + return parsed.toolUseIds; + } + + const sessionId = await this.getExternalSessionId(command); + + if (!sessionId) { + return parsed.toolUseIds; + } + + const runtimeProvider = await this.providerFactory.tryGetByAgentIdentifier( + command.agentIdentifier, + command.environmentId + ); + + if (!runtimeProvider) { + return parsed.toolUseIds; + } + + try { + const pendingTools = await runtimeProvider.getAllPendingToolApprovals(sessionId); + const mcpToolUseIds = pendingTools + .filter((tool) => tool.mcpServerName === parsed.mcpServerName) + .map((tool) => tool.toolUseId); + + if (mcpToolUseIds.length > 0) { + return mcpToolUseIds; + } + } catch (err) { + this.logger.warn( + { err: err instanceof Error ? err.message : String(err), conversationId: command.conversationId }, + 'getAllPendingToolApprovals failed; confirming clicked tool only' + ); + } + + return parsed.toolUseIds; + } + + private async getExternalSessionId(command: ConfirmToolApprovalCommand): Promise { + const conversation = await this.conversationRepository.findOne( + { + _id: command.conversationId, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['externalSessionId'] + ); + + return conversation?.externalSessionId; + } + + private updateCardWithVerdict(command: ConfirmToolApprovalCommand, approved: boolean): void { + if (!command.sourceMessageId) { + return; + } + + const verdictCard = buildToolApprovalVerdictCard(approved, command.actionValue); + this.handleAgentReply + .execute( + HandleAgentReplyCommand.create({ + userId: command.userId, + environmentId: command.environmentId, + organizationId: command.organizationId, + conversationId: command.conversationId, + agentIdentifier: command.agentIdentifier, + integrationIdentifier: command.integrationIdentifier, + edit: { messageId: command.sourceMessageId, content: { card: verdictCard } }, + }) + ) + .catch((err) => { + this.logger.warn(err, 'Failed to update tool approval card with verdict'); + }); + } + + private updatePlanProgress(command: ConfirmToolApprovalCommand, parsed: ParsedToolApprovalAction): void { + if (!parsed.approved) { + for (const toolUseId of parsed.toolUseIds) { + this.handlePlanProgress + .execute( + HandlePlanProgressCommand.create({ + userId: command.userId, + environmentId: command.environmentId, + organizationId: command.organizationId, + conversationId: command.conversationId, + agentIdentifier: command.agentIdentifier, + integrationIdentifier: command.integrationIdentifier, + toolProgress: { + turnId: parsed.turnId, + action: 'tool-use', + toolUseId, + status: 'error', + details: 'Denied', + }, + }) + ) + .catch((err) => { + this.logger.warn(err, 'Failed to update plan card after tool denial'); + }); + } + + return; } this.handlePlanProgress @@ -118,7 +224,7 @@ export class ConfirmToolApproval { integrationIdentifier: command.integrationIdentifier, toolProgress: { turnId: parsed.turnId, - action: parsed.approved ? 'approved' : 'denied', + action: 'approved', }, }) ) diff --git a/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts b/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts index b983c01cde3..12a6248a90a 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts +++ b/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import type { PendingToolApproval } from '@novu/application-generic'; +import type { IAgentRuntimeProvider, PendingToolApproval } from '@novu/application-generic'; import { PinoLogger } from '@novu/application-generic'; import { AgentMcpServerRepository, @@ -46,29 +46,7 @@ export class HandlePendingToolApprovals { return; } - // Which tools need approval? - // 1. extractPendingToolApprovals — read response.actionsRequired from this webhook (fast, no API call). - // 2. getAllPendingToolApprovals — fallback when the webhook says requires-action but omits tool details - // (e.g. user already approved some tools in the same session and others are still waiting). - let pendingTools = extractPendingToolApprovals(command.response); - - if (pendingTools.length === 0) { - try { - pendingTools = await runtimeProvider.getAllPendingToolApprovals(command.sessionId); - } catch (err) { - this.logger.warn( - { err: err instanceof Error ? err.message : String(err), sessionId: command.sessionId }, - 'getAllPendingToolApprovals failed; cannot render Approve/Deny card' - ); - captureAgentWarning(err, { - component: 'handle-pending-tool-approvals', - operation: 'get-all-pending-tool-approvals', - sessionId: command.sessionId, - }); - - return; - } - } + const pendingTools = await this.fetchPendingTools(command, runtimeProvider); if (pendingTools.length === 0) { this.logger.warn( @@ -79,51 +57,128 @@ export class HandlePendingToolApprovals { return; } - // Split by mcp_connection.toolTrust: auto-approve matches, card for the rest. const { trustedTools, needsPromptTools } = await this.partitionByTrust(command, pendingTools); - // Trusted: tell Anthropic yes without posting anything to the chat thread. if (trustedTools.length > 0) { try { - await this.managedAgentService.resumeWithToolResults({ - conversationId: command.conversationId, - environmentId: command.environmentId, - organizationId: command.organizationId, - agentIdentifier: command.agentIdentifier, - integrationIdentifier: command.integrationIdentifier, - subscriberId: command.subscriberId, - platform: command.platform as AgentPlatformEnum | undefined, - toolUseIds: trustedTools.map((tool) => tool.toolUseId), - approved: true, - turnId: command.turnId, - }); - } catch (err) { - this.logger.warn( - { - err: err instanceof Error ? err.message : String(err), - sessionId: command.sessionId, - toolUseIds: trustedTools.map((t) => t.toolUseId), - }, - 'Auto-confirm for trusted MCP tools failed; falling back to approval card' - ); - captureAgentWarning(err, { - component: 'handle-pending-tool-approvals', - operation: 'auto-confirm-trusted-tools', - sessionId: command.sessionId, - }); - - await this.deliverCard(command, pendingTools); + await this.autoConfirmTrustedTools(command, trustedTools); + } catch { + await this.deliverAutoConfirmFailure(command); return; } + + // Resume succeeded — the follow-up requires-action webhook will post the next card. + return; } - if (needsPromptTools.length === 0) { + const nextTool = needsPromptTools[0]; + + if (!nextTool) { return; } - // Untrusted (or mixed batch remainder): post Approve/Deny card to the thread. - await this.deliverCard(command, needsPromptTools); + // No trusted tools in this batch — prompt for the first one only (sequential approval). + await this.deliverApprovalCard(command, nextTool); + } + + private async fetchPendingTools( + command: HandlePendingToolApprovalsCommand, + runtimeProvider: IAgentRuntimeProvider + ): Promise { + const fromResponse = extractPendingToolApprovals(command.response); + + if (fromResponse.length > 0) { + return fromResponse; + } + + try { + return await runtimeProvider.getAllPendingToolApprovals(command.sessionId); + } catch (err) { + this.logger.warn( + { err: err instanceof Error ? err.message : String(err), sessionId: command.sessionId }, + 'getAllPendingToolApprovals failed; cannot render Approve/Deny card' + ); + captureAgentWarning(err, { + component: 'handle-pending-tool-approvals', + operation: 'get-all-pending-tool-approvals', + sessionId: command.sessionId, + }); + + return []; + } + } + + private async autoConfirmTrustedTools( + command: HandlePendingToolApprovalsCommand, + trustedTools: PendingToolApproval[] + ): Promise { + try { + await this.managedAgentService.resumeWithToolResults({ + conversationId: command.conversationId, + environmentId: command.environmentId, + organizationId: command.organizationId, + agentIdentifier: command.agentIdentifier, + integrationIdentifier: command.integrationIdentifier, + subscriberId: command.subscriberId, + platform: command.platform as AgentPlatformEnum | undefined, + toolUseIds: trustedTools.map((tool) => tool.toolUseId), + approved: true, + turnId: command.turnId, + }); + } catch (err) { + this.logger.warn( + { + err: err instanceof Error ? err.message : String(err), + sessionId: command.sessionId, + toolUseIds: trustedTools.map((t) => t.toolUseId), + }, + 'Auto-confirm for trusted MCP tools failed' + ); + captureAgentWarning(err, { + component: 'handle-pending-tool-approvals', + operation: 'auto-confirm-trusted-tools', + sessionId: command.sessionId, + }); + + throw err; + } + } + + private async deliverAutoConfirmFailure(command: HandlePendingToolApprovalsCommand): Promise { + const message = 'The agent is temporarily unavailable. Please try again later.'; + + try { + await this.handleAgentReply.execute( + HandleAgentReplyCommand.create({ + userId: command.userId, + organizationId: command.organizationId, + environmentId: command.environmentId, + conversationId: command.conversationId, + agentIdentifier: command.agentIdentifier, + integrationIdentifier: command.integrationIdentifier, + reply: { markdown: message }, + }) + ); + await this.handlePlanProgress.execute( + HandlePlanProgressCommand.create({ + userId: command.userId, + organizationId: command.organizationId, + environmentId: command.environmentId, + conversationId: command.conversationId, + agentIdentifier: command.agentIdentifier, + integrationIdentifier: command.integrationIdentifier, + toolProgress: { turnId: command.turnId, action: 'fail' }, + }) + ); + } catch (deliveryErr) { + this.logger.error(deliveryErr, `Failed to deliver auto-confirm error for session ${command.sessionId}`); + captureAgentException(deliveryErr, { + component: 'handle-pending-tool-approvals', + operation: 'deliver-auto-confirm-failure', + sessionId: command.sessionId, + }); + } } private async partitionByTrust( @@ -175,9 +230,9 @@ export class HandlePendingToolApprovals { return { trustedTools, needsPromptTools }; } - private async deliverCard( + private async deliverApprovalCard( command: HandlePendingToolApprovalsCommand, - pendingTools: PendingToolApproval[] + tool: PendingToolApproval ): Promise { try { await this.handleAgentReply.execute( @@ -188,7 +243,7 @@ export class HandlePendingToolApprovals { conversationId: command.conversationId, agentIdentifier: command.agentIdentifier, integrationIdentifier: command.integrationIdentifier, - reply: { card: buildToolApprovalCard(pendingTools, command.turnId) }, + reply: { card: buildToolApprovalCard(tool, command.turnId) }, }) ); } catch (err) { diff --git a/apps/dashboard/.example.env b/apps/dashboard/.example.env index 826522f33e5..96260903bf9 100644 --- a/apps/dashboard/.example.env +++ b/apps/dashboard/.example.env @@ -10,10 +10,11 @@ VITE_SELF_HOSTED= VITE_PLAIN_SUPPORT_CHAT_APP_ID= VITE_NOVU_ENTERPRISE=false -# Novu Connect hostname split — Platform is the Clerk primary, Connect is the satellite. +# Novu Connect hostname split — Platform is the Clerk primary; Connect runs as a sibling +# subdomain. Both hosts must share an eTLD+1 (e.g. *.novu.co) so Clerk's session cookies +# (Domain=) are visible on both — that's the whole mechanism, no Clerk-side config needed. # Leave both empty to disable Connect (self-hosted / community builds). # Local dev: Platform at http://localhost:4201, Connect at http://connect.localhost:4201. -# Production Connect: add DNS CNAME `clerk.` → frontend-api.clerk.services. VITE_NOVU_PLATFORM_HOSTNAME= VITE_NOVU_CONNECT_HOSTNAME= diff --git a/apps/dashboard/netlify.toml b/apps/dashboard/netlify.toml index 0e7bb7b1096..bbeab970aa8 100644 --- a/apps/dashboard/netlify.toml +++ b/apps/dashboard/netlify.toml @@ -18,8 +18,8 @@ to = "/index.html" status = 200 -# Clerk satellite handshakes redirect through /auth/* — prevent Netlify CDN from caching -# stale 200/index.html responses during cross-domain session sync (known Clerk + Netlify issue). +# Auth pages must never be cached by the Netlify CDN. Clerk's React SDK uses hash routing for +# multi-step flows, and stale 200/index.html responses can leak prior auth state across users. [[headers]] for = "/auth/*" [headers.values] diff --git a/apps/dashboard/src/api/agents.ts b/apps/dashboard/src/api/agents.ts index d9eeaad2a7c..19bc9e76a41 100644 --- a/apps/dashboard/src/api/agents.ts +++ b/apps/dashboard/src/api/agents.ts @@ -323,8 +323,14 @@ export async function updateAgent( return response.data; } -export function deleteAgent(environment: IEnvironment, identifier: string): Promise { - return del(`/agents/${encodeURIComponent(identifier)}`, { environment }); +export function deleteAgent( + environment: IEnvironment, + identifier: string, + options?: { deleteFromProvider?: boolean } +): Promise { + const params = options?.deleteFromProvider ? '?deleteFromProvider=true' : ''; + + return del(`/agents/${encodeURIComponent(identifier)}${params}`, { environment }); } /** Picked integration fields on an agent–integration link (matches API `integration`). */ diff --git a/apps/dashboard/src/components/agents/agents-list.tsx b/apps/dashboard/src/components/agents/agents-list.tsx index 55c156eaada..ee2d2fb3ead 100644 --- a/apps/dashboard/src/components/agents/agents-list.tsx +++ b/apps/dashboard/src/components/agents/agents-list.tsx @@ -128,9 +128,9 @@ export function AgentsList() { const { submit: submitCreateAgent, isPending: isCreatingAgent } = useCreateAgentMutation(); const deleteMutation = useMutation({ - mutationFn: (identifier: string) => - deleteAgent(requireEnvironment(currentEnvironment, 'No environment selected'), identifier), - onSuccess: async (_, identifier) => { + mutationFn: ({ identifier, deleteFromProvider }: { identifier: string; deleteFromProvider?: boolean }) => + deleteAgent(requireEnvironment(currentEnvironment, 'No environment selected'), identifier, { deleteFromProvider }), + onSuccess: async (_, { identifier }) => { setAgentToDelete(null); showSuccessToast('Agent deleted', 'The agent was removed.'); @@ -377,14 +377,15 @@ export function AgentsList() { setAgentToDelete(null); } }} - onConfirm={() => { + onConfirm={({ deleteFromProvider }) => { if (agentToDelete) { - deleteMutation.mutate(agentToDelete.identifier); + deleteMutation.mutate({ identifier: agentToDelete.identifier, deleteFromProvider }); } }} agentName={agentToDelete?.name ?? ''} agentIdentifier={agentToDelete?.identifier ?? ''} isDeleting={deleteMutation.isPending} + isManagedRuntime={agentToDelete?.runtime === 'managed'} /> ); diff --git a/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts b/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts index da6a06e14ed..35ad8203d58 100644 --- a/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts +++ b/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts @@ -1,6 +1,25 @@ import { AgentRuntimeProviderIdEnum, type IIntegration, IntegrationKindEnum } from '@novu/shared'; import { isDemoIntegration } from '@/components/integrations/components/utils/helpers'; +function compareClaudeManagedIntegrations(left: IIntegration, right: IIntegration): number { + const leftIsDemo = isDemoIntegration(left.providerId); + const rightIsDemo = isDemoIntegration(right.providerId); + + if (leftIsDemo && !rightIsDemo) { + return 1; + } + + if (!leftIsDemo && rightIsDemo) { + return -1; + } + + // MongoDB ObjectId's first 4 bytes (8 hex chars) encode the creation timestamp, + // so a lexicographic descending compare on `_id` yields newest-first ordering. + // This ensures the most recently added credential is what `getPreferredClaudeManagedIntegration` + // returns and what the connector dropdown surfaces at the top. + return right._id.localeCompare(left._id); +} + const CLAUDE_MANAGED_PROVIDER_IDS: ReadonlySet = new Set([ AgentRuntimeProviderIdEnum.NovuAnthropic, AgentRuntimeProviderIdEnum.Anthropic, @@ -41,17 +60,9 @@ export function getClaudeManagedAgentIntegrations( integrations: IIntegration[] | undefined, providerId?: AgentRuntimeProviderIdEnum ): IIntegration[] { - return (integrations ?? []).filter((integration) => isClaudeManagedAgentIntegration(integration, providerId)).sort((left, right) => { - if (left.providerId === AgentRuntimeProviderIdEnum.NovuAnthropic) { - return -1; - } - - if (right.providerId === AgentRuntimeProviderIdEnum.NovuAnthropic) { - return 1; - } - - return 0; - }); + return (integrations ?? []) + .filter((integration) => isClaudeManagedAgentIntegration(integration, providerId)) + .sort(compareClaudeManagedIntegrations); } export function getPreferredClaudeManagedIntegration( diff --git a/apps/dashboard/src/components/agents/delete-agent-dialog.tsx b/apps/dashboard/src/components/agents/delete-agent-dialog.tsx index d7acfbda7bc..3e77ee5935e 100644 --- a/apps/dashboard/src/components/agents/delete-agent-dialog.tsx +++ b/apps/dashboard/src/components/agents/delete-agent-dialog.tsx @@ -1,12 +1,16 @@ +import { useState } from 'react'; import { ConfirmationModal } from '@/components/confirmation-modal'; +import { Checkbox } from '@/components/primitives/checkbox'; +import { Label } from '@/components/primitives/label'; type DeleteAgentDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; - onConfirm: () => void; + onConfirm: (options: { deleteFromProvider: boolean }) => void; agentName: string; agentIdentifier: string; isDeleting?: boolean; + isManagedRuntime?: boolean; }; export function DeleteAgentDialog({ @@ -16,18 +20,45 @@ export function DeleteAgentDialog({ agentName, agentIdentifier, isDeleting, + isManagedRuntime, }: DeleteAgentDialogProps) { + const [deleteFromProvider, setDeleteFromProvider] = useState(false); + + function handleOpenChange(isOpen: boolean) { + if (!isOpen) { + setDeleteFromProvider(false); + } + onOpenChange(isOpen); + } + return ( onConfirm({ deleteFromProvider })} title="Delete agent?" description={ - <> - This will permanently delete {agentName}{' '} - ({agentIdentifier}) and remove its integration links. - +
+

+ This will permanently delete {agentName}{' '} + ({agentIdentifier}) and remove its integration links. +

+ {isManagedRuntime && ( +
+ setDeleteFromProvider(checked === true)} + /> + +
+ )} +
} confirmButtonText="Delete agent" isLoading={isDeleting} diff --git a/apps/dashboard/src/components/auth/auto-create-connect-organization.tsx b/apps/dashboard/src/components/auth/auto-create-connect-organization.tsx index bfc7debbe37..be1426acd11 100644 --- a/apps/dashboard/src/components/auth/auto-create-connect-organization.tsx +++ b/apps/dashboard/src/components/auth/auto-create-connect-organization.tsx @@ -174,7 +174,7 @@ export function AutoCreateConnectOrganization() { } catch (error) { // The cached membership was tombstoned (e.g. deleted in another tab). Treat as manual create // so we never leave the session pointing at an id Clerk has already removed — otherwise the - // satellite handshake redirects to Platform's sign-in URL. + // Connect host's auth guard would bounce the user to Platform's sign-in URL. if (isMissingOrganizationError(error)) { return { type: 'manual' }; } diff --git a/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx b/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx index e1c628719e0..f56fad6e507 100644 --- a/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx +++ b/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx @@ -104,12 +104,12 @@ function StepRow({ ); } -export function SetThingsUpSection({ isLoading }: { isLoading?: boolean }) { +export function SetThingsUpSection() { const navigate = useNavigate(); const { currentEnvironment } = useEnvironment(); - const { steps: hookSteps, isComplete } = useConnectSetupSteps(); + const { steps: hookSteps, shouldShowOnboarding, isLoading } = useConnectSetupSteps(); - if (isComplete) { + if (!shouldShowOnboarding) { return null; } diff --git a/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts b/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts index 1b068c66a8a..6d20e15c165 100644 --- a/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts +++ b/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts @@ -34,7 +34,12 @@ export type ConnectSetupStep = { export type UseConnectSetupStepsResult = { steps: ConnectSetupStep[]; isComplete: boolean; + /** True while prerequisite queries are still resolving onboarding state. */ isLoading: boolean; + /** True only when onboarding is incomplete and setup state has been resolved. */ + shouldShowOnboarding: boolean; + /** Drives welcome copy — avoids onboarding messaging flash for returning users while loading. */ + showOnboardingMessaging: boolean; }; const AGENTS_PEEK_PARAMS = { after: undefined, before: undefined, limit: 2, identifier: '' }; @@ -132,14 +137,21 @@ export function useConnectSetupSteps(): UseConnectSetupStepsResult { [addAgentCompleted, onlyAgent?.identifier, sendMessageCompleted, setupChannelCompleted, setupChannelCtaAvailable] ); - // Only block on agents loading — conversations / integrations errors should never hide the section. - // The persisted `CONNECT_ONBOARDING_COMPLETED` flag is written from `agent-details.tsx` once the - // user finishes setting up an agent there; here we only read it to keep the section hidden. - const isLoading = agentsQuery.isLoading; + const isOnboardingCompletedInStorage = localStorage.getItem(CONNECT_ONBOARDING_COMPLETED) === 'true'; + const hasResolvedAgents = agentsQuery.isSuccess || agentsQuery.isError; + const hasResolvedIntegrations = !onlyAgent || agentIntegrationsQuery.isSuccess || agentIntegrationsQuery.isError; + const hasResolvedConversations = !hasAgent || conversationsQuery.isSuccess || conversationsQuery.isError; + const isSetupResolved = hasResolvedAgents && hasResolvedIntegrations && hasResolvedConversations; + const isComplete = isOnboardingCompletedInStorage || agentSetupComplete || hasAnyConversation; + const isLoading = !isOnboardingCompletedInStorage && !isSetupResolved; + const shouldShowOnboarding = isSetupResolved && !isComplete; + const showOnboardingMessaging = isSetupResolved ? !isComplete : !isOnboardingCompletedInStorage; return { steps, - isComplete: localStorage.getItem(CONNECT_ONBOARDING_COMPLETED) === 'true' || agentSetupComplete, - isLoading: isLoading || !agentsQuery.data, + isComplete, + isLoading, + shouldShowOnboarding, + showOnboardingMessaging, }; } diff --git a/apps/dashboard/src/components/dashboard-shell/cross-app-link.tsx b/apps/dashboard/src/components/dashboard-shell/cross-app-link.tsx index d31ab3f8785..cc5c0957908 100644 --- a/apps/dashboard/src/components/dashboard-shell/cross-app-link.tsx +++ b/apps/dashboard/src/components/dashboard-shell/cross-app-link.tsx @@ -11,10 +11,9 @@ type CrossAppLinkProps = { children: ReactNode; }; -// Hands off to the browser for cross-origin hrefs; Clerk's satellite domain SDK picks up the -// session sync via its built-in handshake when the satellite page loads. Avoid `clerk.redirectWithAuth` -// here — it short-circuits the satellite SDK's handshake and produced a `__clerk_synced=false` -// redirect loop between Platform and Connect. +// Hands off to the browser for cross-origin hrefs. Primary and Connect share Clerk session +// cookies via the registrable domain, so the destination page picks up the session natively +// from a plain navigation. export function CrossAppLink({ href, openInNewTab, className, onClick, children, ...rest }: CrossAppLinkProps) { const isHrefSafe = isSafeNavigationHref(href); const isCrossOrigin = isHrefSafe && IS_HOSTNAME_SPLIT_ENABLED && isAbsoluteUrl(href); diff --git a/apps/dashboard/src/components/settings/organization-settings.tsx b/apps/dashboard/src/components/settings/organization-settings.tsx index 3b69eb28a0e..1d54f1dbaa7 100644 --- a/apps/dashboard/src/components/settings/organization-settings.tsx +++ b/apps/dashboard/src/components/settings/organization-settings.tsx @@ -13,7 +13,7 @@ import { ROUTES } from '@/utils/routes'; import { NovuBrandingSwitch } from './novu-branding-switch'; // After deleting (or leaving) an org, Clerk falls back to `` when this -// prop is unset. On the Connect satellite that points to Platform's sign-in, which kicks the +// prop is unset. On the Connect host that points to Platform's sign-in, which kicks the // user out of Connect entirely — they'd land on Platform's picker even when they still have // Connect work to do. Pinning it to the local `/auth/organization-list` keeps them on the // current product; `AuthProvider` then clears any cross-product org Clerk auto-activates and diff --git a/apps/dashboard/src/components/settings/settings-tabs.tsx b/apps/dashboard/src/components/settings/settings-tabs.tsx index e2845cb0eee..86bcbe6ed4d 100644 --- a/apps/dashboard/src/components/settings/settings-tabs.tsx +++ b/apps/dashboard/src/components/settings/settings-tabs.tsx @@ -25,7 +25,7 @@ import { UserProfile as BetterAuthUserProfile } from '@/utils/better-auth/index' import { ROUTES } from '@/utils/routes'; // Pin Clerk's post-leave/delete redirect to the local `/auth/organization-list`. Without this, -// Clerk falls back to ``, which on the Connect satellite points at +// Clerk falls back to ``, which on the Connect host points at // Platform's sign-in — so deleting a Connect org would kick the user out of Connect even when // they still have Connect work to do. Keeping it same-host lets `AuthProvider` clear any cross- // product org Clerk auto-activates and lets the picker render this product's empty state. diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index 4e59555be27..d32ba5cc4ff 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -36,7 +36,8 @@ export const LEGACY_DASHBOARD_URL = export const DASHBOARD_URL = window._env_?.VITE_DASHBOARD_URL || import.meta.env.VITE_DASHBOARD_URL; -// Connect satellite hostname. Empty when Connect is not deployed (self-hosted/dev). +// Connect host. Must share registrable domain with `NOVU_PLATFORM_HOSTNAME` so Clerk session +// cookies (Domain=) are visible on both. Empty when Connect is not deployed (self-hosted/dev). export const NOVU_CONNECT_HOSTNAME = window._env_?.VITE_NOVU_CONNECT_HOSTNAME || import.meta.env.VITE_NOVU_CONNECT_HOSTNAME || ''; @@ -56,7 +57,7 @@ function getHostnameWithoutPort(host: string): string { export { getHostnameWithoutPort }; // Fail fast when the hostname split is half-configured. Without `NOVU_PLATFORM_HOSTNAME`, -// satellite → primary handoffs (Clerk sign-in, cross-product redirects) silently break. +// Connect → Platform handoffs (Clerk sign-in, cross-product redirects) silently break. if (NOVU_CONNECT_HOSTNAME && !NOVU_PLATFORM_HOSTNAME) { throw new Error( 'NOVU_PLATFORM_HOSTNAME is required when NOVU_CONNECT_HOSTNAME is set. ' + diff --git a/apps/dashboard/src/context/ee-auth-provider.tsx b/apps/dashboard/src/context/ee-auth-provider.tsx index 1c4586a756a..a2f2e2b5da2 100644 --- a/apps/dashboard/src/context/ee-auth-provider.tsx +++ b/apps/dashboard/src/context/ee-auth-provider.tsx @@ -2,12 +2,10 @@ import { buttonVariants } from '@/components/primitives/button'; import { CLERK_PUBLISHABLE_KEY, EE_AUTH_PROVIDER, - getHostnameWithoutPort, IS_ENTERPRISE, IS_HOSTNAME_SPLIT_ENABLED, IS_NOVU_CONNECT, IS_SELF_HOSTED, - NOVU_CONNECT_HOSTNAME, } from '@/config'; import { isAbsoluteUrl } from '@/utils/apps'; import { buildAfterSignOutUrl } from '@/utils/cross-product-sign-out'; @@ -74,35 +72,22 @@ export const EEAuthProvider = (props: EEAuthProviderProps) => { } }; - // Sign-in flows are only allowed on the primary; satellite must point `signInUrl`/`signUpUrl` - // back to it. Primary lists the Connect origin in `allowedRedirectOrigins` for post-auth bounce. - const isSatellite = IS_HOSTNAME_SPLIT_ENABLED && IS_NOVU_CONNECT; - - const satelliteSignInUrl = buildPrimarySignInUrl({ product: CONNECT_PRODUCT_VALUE }); - const satelliteSignUpUrl = buildPrimarySignUpUrl({ product: CONNECT_PRODUCT_VALUE }); - - const signInUrl = isSatellite ? satelliteSignInUrl : ROUTES.SIGN_IN; - const signUpUrl = isSatellite ? satelliteSignUpUrl : ROUTES.SIGN_UP; - - const satelliteProps = isSatellite - ? { - isSatellite: true as const, - domain: getHostnameWithoutPort(NOVU_CONNECT_HOSTNAME), - // Clerk v6 / Core 3 flipped the satellite default from auto-sync ON to OFF (#7597), so - // Connect now treats every first page load as anonymous unless cookies are already - // present — primary's sign-in then bounces back to a still-anonymous satellite and we - // loop. We rely on the Core 2 behavior (auto-handshake with primary on first load) so - // an already-signed-in user on Platform is recognized on Connect without re-auth. - // See https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3 - satelliteAutoSync: true, - } - : {}; + // Sign-in/up only renders on the primary; the Connect host bounces visitors there. Primary + // writes Clerk session cookies on `Domain=`, so both hosts read the same + // session natively from a plain navigation — no Clerk-side configuration needed. + const isCrossProductHost = IS_HOSTNAME_SPLIT_ENABLED && IS_NOVU_CONNECT; + + const signInUrl = isCrossProductHost + ? buildPrimarySignInUrl({ product: CONNECT_PRODUCT_VALUE }) + : ROUTES.SIGN_IN; + const signUpUrl = isCrossProductHost + ? buildPrimarySignUpUrl({ product: CONNECT_PRODUCT_VALUE }) + : ROUTES.SIGN_UP; const allowedRedirectOrigins = buildClerkAllowedRedirectOrigins(); return ( <_ClerkProvider - {...satelliteProps} routerPush={(to) => navigateClerk(to)} routerReplace={(to) => navigateClerk(to, true)} publishableKey={CLERK_PUBLISHABLE_KEY} diff --git a/apps/dashboard/src/hooks/use-cross-app-navigation.ts b/apps/dashboard/src/hooks/use-cross-app-navigation.ts index 62fde19a764..26c9344567d 100644 --- a/apps/dashboard/src/hooks/use-cross-app-navigation.ts +++ b/apps/dashboard/src/hooks/use-cross-app-navigation.ts @@ -1,9 +1,9 @@ import { useCallback } from 'react'; import { isSafeNavigationHref } from '@/utils/apps'; -// Plain cross-origin navigation. The Connect satellite Clerk SDK handles session sync via its -// built-in handshake on the destination page — wrapping with `clerk.redirectWithAuth` here caused -// a `__clerk_synced=false` redirect loop with Platform. +// Plain cross-origin navigation. Primary and Connect share session cookies via the registrable +// domain, so the destination page reads the existing Clerk session natively from a normal +// browser navigation. export function useCrossAppNavigation() { return useCallback((href: string, openInNewTab = false) => { // Whitelist http(s) / relative hrefs so callers can't smuggle `javascript:` / `data:` URLs in. diff --git a/apps/dashboard/src/pages/agent-details.tsx b/apps/dashboard/src/pages/agent-details.tsx index 21b85754f6e..ecbff6506cd 100644 --- a/apps/dashboard/src/pages/agent-details.tsx +++ b/apps/dashboard/src/pages/agent-details.tsx @@ -132,9 +132,9 @@ export function AgentDetailsPage() { useSetConnectBreadcrumbLeaf(connectBreadcrumbLeaf); const deleteMutation = useMutation({ - mutationFn: (identifier: string) => - deleteAgent(requireEnvironment(currentEnvironment, 'No environment selected'), identifier), - onSuccess: async (_, identifier) => { + mutationFn: ({ identifier, deleteFromProvider }: { identifier: string; deleteFromProvider?: boolean }) => + deleteAgent(requireEnvironment(currentEnvironment, 'No environment selected'), identifier, { deleteFromProvider }), + onSuccess: async (_, { identifier }) => { setAgentToDelete(null); showSuccessToast('Agent deleted', 'The agent was removed.'); track( @@ -386,14 +386,15 @@ export function AgentDetailsPage() { setAgentToDelete(null); } }} - onConfirm={() => { + onConfirm={({ deleteFromProvider }) => { if (agentToDelete) { - deleteMutation.mutate(agentToDelete.identifier); + deleteMutation.mutate({ identifier: agentToDelete.identifier, deleteFromProvider }); } }} agentName={agentToDelete?.name ?? ''} agentIdentifier={agentToDelete?.identifier ?? ''} isDeleting={deleteMutation.isPending} + isManagedRuntime={agentToDelete?.runtime === 'managed'} /> { function CliAuthContent() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const clerk = useClerk(); + const { user } = useUser(); const { currentEnvironment, environments, switchEnvironment } = useEnvironment(); const apiKeysQuery = useFetchApiKeys(); const has = useHasPermission(); @@ -78,14 +81,9 @@ function CliAuthContent() { const deviceCodeOk = isValidDeviceCode(deviceCode); const canReadApiKeys = has({ permission: PermissionsEnum.API_KEY_READ }); - // Two callers today: `novu-wizard` (default) and `novu-connect` (agent - // provisioning). Each gets its own subtitle + scope copy so the dashboard - // explains what the user is actually authorizing. const isConnect = callerName === 'novu-connect'; const callerDisplayName = isConnect ? 'Novu Connect' : 'Novu Wizard'; - const callerSubtitle = isConnect - ? 'to provision your AI agent and connect it to the channels you pick.' - : 'in order to integrate Novu into your project.'; + const signedInEmail = user?.primaryEmailAddress?.emailAddress; const apiKey = apiKeysQuery.data?.data?.[0]?.key; @@ -124,6 +122,18 @@ function CliAuthContent() { navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: currentEnvironment?.slug ?? 'default' })); } + const handleSignOut = useCallback(async () => { + const fallbackUrl = buildAfterSignOutUrl(); + + try { + await clerk.signOut({ redirectUrl: fallbackUrl }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Please try again.'; + showErrorToast(`Unable to sign out. ${message}`, 'Sign out failed'); + window.location.assign(fallbackUrl); + } + }, [clerk]); + const isLoading = apiKeysQuery.isLoading || !currentEnvironment; const reason = (() => { @@ -163,22 +173,22 @@ function CliAuthContent() { return (
- - -
- -

Authorize Novu CLI

+
+
+ + +
+

+ {callerDisplayName} would like to access your +
+ account and be able to: +

+
-

- {callerDisplayName} is requesting access to your{' '} - {currentEnvironment?.name ?? '...'} environment {callerSubtitle} -

- - - + {reason ? ( -
- +
+ {reason}
) : null} @@ -191,14 +201,14 @@ function CliAuthContent() { animate={{ opacity: 1, y: 0, height: 'auto' }} exit={{ opacity: 0, y: -4, height: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }} - className="overflow-hidden" + className="w-full overflow-hidden" > -
+
@@ -212,48 +222,145 @@ function CliAuthContent() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4, height: 0, marginTop: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }} - className="flex items-center justify-end gap-2 overflow-hidden" + className="flex w-full max-w-[300px] flex-col items-center gap-3 overflow-hidden" > - - +
+ + +
)} - - + + {signedInEmail ? ( +

+ Signed in as + {signedInEmail} + · + +

+ ) : null} +
+
+
+ ); +} + +function CliAuthHeader({ + isConnect, + callerDisplayName, +}: { + isConnect: boolean; + callerDisplayName: string; +}) { + if (isConnect) { + return ; + } + + return ( +
+ + {callerDisplayName}
); } +type ScopePart = { + text: string; + bold?: boolean; +}; + +type ScopeItem = { + parts: ScopePart[]; +}; + function ScopeList({ isConnect }: { isConnect: boolean }) { - const scopes = isConnect + const scopes: ScopeItem[] = isConnect ? [ - 'Read your Novu API key for the selected environment', - 'Create and manage agents on your behalf', - 'Connect channels (Slack, Telegram, and more) to your agent', + { + parts: [{ text: 'Read', bold: true }, { text: ' your Novu API key for the selected environment' }], + }, + { + parts: [ + { text: 'Create', bold: true }, + { text: ' and ' }, + { text: 'manage', bold: true }, + { text: ' agents on your behalf' }, + ], + }, + { + parts: [{ text: 'Connect', bold: true }, { text: ' channels to your agent' }], + }, ] : [ - 'Read your Novu API key for the selected environment', - 'Trigger workflows on your behalf during the integration', - 'Create or update workflows via Novu MCP', + { + parts: [{ text: 'Read', bold: true }, { text: ' your Novu API key for the selected environment' }], + }, + { + parts: [{ text: 'Trigger', bold: true }, { text: ' workflows on your behalf during the integration' }], + }, + { + parts: [ + { text: 'Create', bold: true }, + { text: ' or ' }, + { text: 'update', bold: true }, + { text: ' workflows via Novu MCP' }, + ], + }, ]; return ( -
    +
      {scopes.map((scope) => ( -
    • - - {scope} +
    • part.text).join('')} className="flex min-h-6 items-center gap-2"> + +
    • ))}
    ); } + +function ScopeText({ parts }: { parts: ScopePart[] }) { + return ( + + {parts.map((part) => ( + + ))} + + ); +} + +function ScopeTextPart({ part }: { part: ScopePart }) { + if (part.bold) { + return {part.text}; + } + + return <>{part.text}; +} diff --git a/apps/dashboard/src/pages/connect/connect-dashboard.tsx b/apps/dashboard/src/pages/connect/connect-dashboard.tsx index 2c57cb1fa04..88e44f34f5a 100644 --- a/apps/dashboard/src/pages/connect/connect-dashboard.tsx +++ b/apps/dashboard/src/pages/connect/connect-dashboard.tsx @@ -9,17 +9,17 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { PageMeta } from '@/components/page-meta'; export function ConnectDashboardPage() { - const { isComplete, isLoading } = useConnectSetupSteps(); + const { isComplete, showOnboardingMessaging } = useConnectSetupSteps(); return ( <>
    - +
    - +