Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/oauth-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (`
|--------|-------------|
| `getAll()` | `OR.Execution` or `OR.Execution.Read` |
| `getById()` | `OR.Execution` or `OR.Execution.Read` |
| `downloadCitationSource()` | `NA` |

### Conversations

Expand Down
36 changes: 35 additions & 1 deletion src/models/conversational-agent/conversational-agent.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {
AgentGetResponse,
AgentGetByIdResponse
} from './agents';
import type { ConversationServiceModel } from './conversations';
import type { CitationSourceMedia, ConversationServiceModel } from './conversations';
import type { FeatureFlags } from './feature-flags.types';
import type { UserSettingsServiceModel } from './user';
import type { ConnectionStatus } from '@/core/websocket';
Expand Down Expand Up @@ -141,6 +141,40 @@ export interface ConversationalAgentServiceModel {
*/
getById(id: number, folderId: number): Promise<AgentGetByIdResponse>;

/**
* Downloads the document behind a media citation as an authenticated `Blob`.
*
* Media citation sources expose a `downloadUrl` pointing at an auth-gated
* UiPath endpoint; opening it directly sends no token and fails with `401`.
* This performs the request with the SDK's access token and returns the bytes,
* with the `Blob` type resolved from the source `mimeType` (falling back to the
* response Content-Type, then the title's file extension). Use `source.title`
* as the file name.
*
* HTML content is intentionally returned as `application/octet-stream` (a
* download) rather than `text/html`, so previewing the blob inline can't
* execute citation markup in your app's origin.
*
* @param source - A media citation source (`CitationSourceMedia`) with a `downloadUrl`
* @returns Promise resolving to the document as a `Blob`
* @throws ValidationError if the source has no `downloadUrl`
* @throws A typed HTTP error for error responses — e.g. `AuthenticationError`
* (401), `AuthorizationError` (403), `NotFoundError` (404) — or
* `NetworkError` on a connection failure, matching other SDK calls
*
* @example
* ```typescript
* import { isCitationSourceMedia } from '@uipath/uipath-typescript/conversational-agent';
*
* if (isCitationSourceMedia(source)) {
* const blob = await conversationalAgent.downloadCitationSource(source);
* const url = URL.createObjectURL(blob);
* window.open(url, '_blank');
* }
* ```
*/
downloadCitationSource(source: CitationSourceMedia): Promise<Blob>;

/**
* Registers a handler that is called whenever the WebSocket connection status changes.
*
Expand Down
62 changes: 62 additions & 0 deletions src/services/conversational-agent/conversational-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
import type { IUiPath } from '@/core/types';
import type { ConnectionStatusChangedHandler } from '@/core/websocket';
import { track } from '@/core/telemetry';
import { ValidationError } from '@/core/errors';
import { ErrorFactory } from '@/core/errors/error-factory';
import { errorResponseParser } from '@/core/errors/parser';
import { BaseService } from '@/services/base';

// Models
import type {
ConversationalAgentOptions,
ConversationalAgentServiceModel,
CitationSourceMedia,
FeatureFlags,
RawAgentGetResponse,
RawAgentGetByIdResponse,
Expand All @@ -30,6 +34,7 @@
// Local imports
import { ConversationService } from './conversations';
import { buildConversationalAgentHeaders } from './helpers/header';
import { resolveCitationMimeType } from './helpers/citation';
import { UserSettingsService } from './user';

/**
Expand All @@ -42,6 +47,9 @@
/** Service for reading and updating the current user's profile/context settings. See {@link UserSettingsServiceModel}. */
public readonly user: UserSettingsService;

/** Configured SDK origin, used to keep citation downloads on the tenant's host. */
private readonly baseUrl?: string;

/**
* Creates an instance of the ConversationalAgent service.
*
Expand All @@ -51,6 +59,7 @@
constructor(instance: IUiPath, options?: ConversationalAgentOptions) {
super(instance, buildConversationalAgentHeaders(options));

this.baseUrl = instance.config.baseUrl;
// Create conversation service with WebSocket support
this.conversations = new ConversationService(instance, options);
this.user = new UserSettingsService(instance, options);
Expand Down Expand Up @@ -80,6 +89,59 @@
);
}

@track('ConversationalAgent.DownloadCitationSource')
async downloadCitationSource(source: CitationSourceMedia): Promise<Blob> {
Comment thread
scottcmg marked this conversation as resolved.
if (!source.downloadUrl) {
throw new ValidationError({
message: 'Citation source has no downloadUrl to download'
});
}

// Only attach the token to the tenant's own origin. downloadUrl is
// server-provided; if one were ever malformed or injected to point at
// another host, do not send the user's token to that host.
const base = this.baseUrl ? new URL(this.baseUrl) : undefined;
let target: URL;
try {
target = new URL(source.downloadUrl, base);
} catch {
throw new ValidationError({
message: `Invalid citation downloadUrl`
});
}
// Fail closed: if the base origin is unknown, or the target isn't on it,
// don't forward the token to an unverifiable host.
if (!base || target.origin !== base.origin) {

Check warning on line 114 in src/services/conversational-agent/conversational-agent.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-typescript&issues=AZ9YCIY1OsIJlDz0Cts1&open=AZ9YCIY1OsIJlDz0Cts1&pullRequest=599
throw new ValidationError({
message: `Refusing to send credentials to a download URL outside the configured origin`
});
}

const token = await this.getValidAuthToken();

let response: Response;
try {
// The downloadUrl targets the reference service, so only the bearer token
// applies — no conversational agent-specific headers are needed.
response = await fetch(target.href, {
headers: { Authorization: `Bearer ${token}` }
});
} catch (error) {
throw ErrorFactory.createNetworkError(error);
}

if (!response.ok) {
const errorInfo = await errorResponseParser.parse(response);
throw ErrorFactory.createFromHttpStatus(response.status, errorInfo);
}

const blob = await response.blob();
const mimeType = resolveCitationMimeType(source, blob.type);
return mimeType && mimeType !== blob.type
? blob.slice(0, blob.size, mimeType)
: blob;
}

async getFeatureFlags(): Promise<FeatureFlags> {
const response = await this.get<FeatureFlags>(FEATURE_ENDPOINTS.FEATURE_FLAGS);
return response.data;
Expand Down
62 changes: 62 additions & 0 deletions src/services/conversational-agent/helpers/citation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Helpers for resolving a citation media source into a downloadable document.
*/
import type { CitationSourceMedia } from '@/models/conversational-agent';

// The file extensions the context service can reference, mapped to their MIME
// types. Used only as a fallback for the octet-stream case below, so the
// extension set mirrors exactly what the reference service supports.
const EXTENSION_TO_MIME_TYPE: Record<string, string> = {
'.pdf': 'application/pdf',
'.csv': 'text/csv',
'.json': 'application/json',
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
'.xls': 'application/vnd.ms-excel',
'.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.txt': 'text/plain',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.html': 'text/html' // ECS lists it, but clamped to octet-stream below
};

const OCTET_STREAM = 'application/octet-stream';

// MIME types that execute script when a downloaded blob is previewed inline: a
// blob URL runs in the embedder's origin, so an <iframe>/window.open preview of
// citation HTML could run its script in the consuming app. We never hand these
// back with a renderable type — forcing octet-stream makes the browser download
// rather than execute (matching how the reference service itself serves HTML).
const UNSAFE_INLINE_TYPES = new Set(['text/html', 'application/xhtml+xml']);

/**
* Determines the best MIME type for the downloaded document. Prefers the
* source's declared `mimeType`, then the server's Content-Type, and finally
* falls back to the file extension in the title — because the context service
* reference endpoint frequently responds with `application/octet-stream`.
*
* HTML-family types are always downgraded to `application/octet-stream` so a
* consumer previewing the blob inline can't be made to execute citation markup.
*/
export function resolveCitationMimeType(
source: CitationSourceMedia,
responseType: string
): string {
const resolved = selectMimeType(source, responseType);
const essence = resolved.split(';')[0].trim().toLowerCase();
return UNSAFE_INLINE_TYPES.has(essence) ? OCTET_STREAM : resolved;
}

function selectMimeType(source: CitationSourceMedia, responseType: string): string {
if (source.mimeType) {
return source.mimeType;
}
if (responseType && responseType !== OCTET_STREAM) {
return responseType;
}
const extension = source.title?.toLowerCase().match(/\.[^.]*$/)?.[0];

Check warning on line 60 in src/services/conversational-agent/helpers/citation.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-typescript&issues=AZ9YCIVTOsIJlDz0Cts0&open=AZ9YCIVTOsIJlDz0Cts0&pullRequest=599
return (extension && EXTENSION_TO_MIME_TYPE[extension]) || responseType || OCTET_STREAM;
}
52 changes: 52 additions & 0 deletions tests/unit/helpers/conversational-agent/citation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file path does not mirror the src/ structure. Per the architecture docs: "tests/unit/ mirrors src/ structure".

  • Source: src/services/conversational-agent/helpers/citation.ts
  • Expected test path: tests/unit/services/conversational-agent/helpers/citation.test.ts
  • Actual path: tests/unit/helpers/conversational-agent/citation.test.ts

The file should be moved to match the source tree.

import { resolveCitationMimeType } from '@/services/conversational-agent/helpers/citation';
import type { CitationSourceMedia } from '@/models/conversational-agent';

const media = (over: Partial<CitationSourceMedia> = {}): CitationSourceMedia => ({
title: 'doc.pdf',
number: 1,
downloadUrl: 'https://alpha.uipath.com/org/tenant/ecs_/v1.1/reference/abc',
...over,
});

describe('resolveCitationMimeType', () => {
it('prefers the source mimeType', () => {
expect(resolveCitationMimeType(media({ mimeType: 'application/pdf' }), 'text/html')).toBe(
'application/pdf',
);
});

it('uses the response Content-Type when no source mimeType and it is meaningful', () => {
expect(resolveCitationMimeType(media({ mimeType: undefined }), 'image/png')).toBe('image/png');
});

it('falls back to the title extension when the response is octet-stream', () => {
expect(
resolveCitationMimeType(media({ mimeType: undefined, title: 'report.pdf' }), 'application/octet-stream'),
).toBe('application/pdf');
});

it('falls back to octet-stream when nothing else is known', () => {
expect(
resolveCitationMimeType(media({ mimeType: undefined, title: 'noext' }), 'application/octet-stream'),
).toBe('application/octet-stream');
});

it('downgrades an .html extension fallback to octet-stream (no inline execution)', () => {
expect(
resolveCitationMimeType(media({ mimeType: undefined, title: 'page.html' }), 'application/octet-stream'),
).toBe('application/octet-stream');
});

it('downgrades a declared text/html source mimeType to octet-stream', () => {
expect(resolveCitationMimeType(media({ mimeType: 'text/html', title: 'page.html' }), '')).toBe(
'application/octet-stream',
);
});

it('downgrades a text/html response Content-Type (with charset) to octet-stream', () => {
expect(
resolveCitationMimeType(media({ mimeType: undefined, title: 'page' }), 'text/html; charset=utf-8'),
).toBe('application/octet-stream');
});
});
Loading
Loading