import { EXTENSION_IFRAME_META_CSP } from "../../extensions/html-shell.js"; import { buildSessionReplayIframeBootstrap } from "../../extensions/session-replay-iframe.js"; import { AGENT_NATIVE_HOST_MESSAGE_TYPES } from "../host-bridge.js"; export const AGENT_NATIVE_EXTENSION_MESSAGE_TYPES = { STORAGE_REQUEST: "agentNative.extension.storage", STORAGE_RESPONSE: "agentNative.extension.storageResult", RESIZE: "agentNative.extension.resize", SLOT_CONTEXT: "agentNative.extension.slotContext", } as const; export type AgentNativeExtensionMessageType = (typeof AGENT_NATIVE_EXTENSION_MESSAGE_TYPES)[keyof typeof AGENT_NATIVE_EXTENSION_MESSAGE_TYPES]; export type AgentNativeExtensionStorageScope = "user" | "org" | "all" | string; export interface AgentNativeExtensionManifest { /** Slot IDs this extension may render into. Omit to let the host decide. */ slots?: readonly string[]; /** Host action names this extension is allowed to call. Omit to inherit the slot policy. */ requestedActions?: readonly string[]; /** Host command names this extension is allowed to call. Omit to inherit the slot policy. */ requestedCommands?: readonly string[]; /** Storage scopes this extension is allowed to use. Omit to inherit the slot policy. */ storageScopes?: readonly AgentNativeExtensionStorageScope[]; } export interface AgentNativeExtensionDefinition { id: string; name: string; content: string; description?: string; updatedAt?: string; manifest?: AgentNativeExtensionManifest; slots?: readonly string[]; requestedActions?: readonly string[]; requestedCommands?: readonly string[]; storageScopes?: readonly AgentNativeExtensionStorageScope[]; [key: string]: unknown; } export interface AgentNativeExtensionStorageOptions { scope?: AgentNativeExtensionStorageScope; limit?: number; [key: string]: unknown; } export interface AgentNativeExtensionStorageContext { extensionId: string; slotId?: string; scope?: Exclude; userId?: string; organizationId?: string; [key: string]: unknown; } export interface AgentNativeExtensionStorageRow { id: string; extensionId: string; collection: string; data: TData; scope: string; createdAt: string; updatedAt: string; } export interface AgentNativeExtensionStorage { list( collection: string, options: AgentNativeExtensionStorageOptions | undefined, context: AgentNativeExtensionStorageContext, ): | AgentNativeExtensionStorageRow[] | Promise; get( collection: string, id: string, options: AgentNativeExtensionStorageOptions | undefined, context: AgentNativeExtensionStorageContext, ): | AgentNativeExtensionStorageRow | null | Promise; set( collection: string, id: string, data: unknown, options: AgentNativeExtensionStorageOptions | undefined, context: AgentNativeExtensionStorageContext, ): AgentNativeExtensionStorageRow | Promise; remove( collection: string, id: string, options: AgentNativeExtensionStorageOptions | undefined, context: AgentNativeExtensionStorageContext, ): { removed: boolean } | Promise<{ removed: boolean }>; } export interface CreateHttpAgentNativeExtensionStorageOptions { /** Endpoint that receives storage operation POSTs. */ endpoint: string; fetch?: typeof fetch; headers?: | HeadersInit | (( context: AgentNativeExtensionStorageContext, ) => HeadersInit | Promise); credentials?: RequestCredentials; } export interface BuildAgentNativeExtensionHtmlOptions { extensionId: string; content: string; title?: string; slotId?: string; slotContext?: Record | null; themeCss?: string; isDark?: boolean; } function firstList( ...values: Array ): readonly T[] | undefined { return values.find((value) => Array.isArray(value)); } export function getAgentNativeExtensionManifest( extension: AgentNativeExtensionDefinition, ): AgentNativeExtensionManifest { return { slots: firstList(extension.manifest?.slots, extension.slots), requestedActions: firstList( extension.manifest?.requestedActions, extension.requestedActions, ), requestedCommands: firstList( extension.manifest?.requestedCommands, extension.requestedCommands, ), storageScopes: firstList( extension.manifest?.storageScopes, extension.storageScopes, ), }; } export function isAgentNativeExtensionAllowedInSlot( extension: AgentNativeExtensionDefinition, slotId: string | undefined, ): boolean { if (!slotId) return true; const slots = getAgentNativeExtensionManifest(extension).slots; if (!slots) return true; return slots.includes(slotId); } function escapeHtmlAttribute(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function safeJson(value: unknown): string { try { const json = JSON.stringify(value); return typeof json === "string" ? json.replace(/ { const raw = storage.getItem(key); if (!raw) return {}; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } } function writeBucket( storage: Storage, key: string, value: Record, ) { storage.setItem(key, JSON.stringify(value)); } function listLocalStorageBuckets( storage: Storage, namespace: string, extensionId: string, collection: string, ): string[] { const prefix = [ "agent-native", "extension-data", encodedSegment(namespace), encodedSegment(extensionId), "", ].join(":"); const suffix = `:${encodedSegment(collection)}`; const keys: string[] = []; for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); if (key?.startsWith(prefix) && key.endsWith(suffix)) keys.push(key); } return keys; } export function createLocalStorageAgentNativeExtensionStorage( namespace = "default", ): AgentNativeExtensionStorage { return { list(collection, options, context) { const storage = localStorageRef(); const scope = storageScope(options, context); const keys = scope === "all" ? listLocalStorageBuckets( storage, namespace, context.extensionId, collection, ) : [bucketKey(namespace, context.extensionId, scope, collection)]; const rows = keys.flatMap((key) => Object.values(readBucket(storage, key)), ); const limit = options?.limit ?? 100; return rows .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .slice(0, Math.max(0, limit)); }, get(collection, id, options, context) { const rows = this.list(collection, options, context); if (rows instanceof Promise) { return rows.then( (resolved) => resolved.find((row) => row.id === id) ?? null, ); } return rows.find((row) => row.id === id) ?? null; }, set(collection, id, data, options, context) { const storage = localStorageRef(); const scope = storageScope(options, context); if (scope === "all") { throw new Error('Extension data writes cannot use scope "all"'); } const key = bucketKey(namespace, context.extensionId, scope, collection); const bucket = readBucket(storage, key); const now = new Date().toISOString(); const row: AgentNativeExtensionStorageRow = { id, extensionId: context.extensionId, collection, data, scope, createdAt: bucket[id]?.createdAt ?? now, updatedAt: now, }; bucket[id] = row; writeBucket(storage, key, bucket); return row; }, remove(collection, id, options, context) { const storage = localStorageRef(); const scope = storageScope(options, context); if (scope === "all") { throw new Error('Extension data deletes cannot use scope "all"'); } const key = bucketKey(namespace, context.extensionId, scope, collection); const bucket = readBucket(storage, key); const removed = Boolean(bucket[id]); delete bucket[id]; writeBucket(storage, key, bucket); return { removed }; }, }; } async function resolveHttpStorageHeaders( headers: CreateHttpAgentNativeExtensionStorageOptions["headers"], context: AgentNativeExtensionStorageContext, ): Promise { return typeof headers === "function" ? headers(context) : headers; } async function readHttpStorageResponse( response: Response, ): Promise { const text = await response.text(); let body: unknown = text; if (text) { try { body = JSON.parse(text); } catch { body = text; } } if (!response.ok) { const message = body && typeof body === "object" && "error" in body ? String((body as { error: unknown }).error) : `Extension storage request failed: ${response.status}`; throw new Error(message); } if (body && typeof body === "object" && "result" in body) { return (body as { result: TResult }).result; } return body as TResult; } export function createHttpAgentNativeExtensionStorage({ endpoint, fetch: fetchImpl, headers, credentials = "same-origin", }: CreateHttpAgentNativeExtensionStorageOptions): AgentNativeExtensionStorage { const run = async ( operation: "list" | "get" | "set" | "remove", collection: string, context: AgentNativeExtensionStorageContext, options?: AgentNativeExtensionStorageOptions, id?: string, data?: unknown, ): Promise => { const requestFetch = fetchImpl ?? fetch; if (typeof requestFetch !== "function") { throw new Error("fetch is not available for extension storage"); } const resolvedHeaders = new Headers( await resolveHttpStorageHeaders(headers, context), ); resolvedHeaders.set("Content-Type", "application/json"); const response = await requestFetch(endpoint, { method: "POST", credentials, headers: resolvedHeaders, body: JSON.stringify({ operation, extensionId: context.extensionId, slotId: context.slotId, collection, id, data, options: options ?? {}, context, }), }); return readHttpStorageResponse(response); }; return { list(collection, options, context) { return run( "list", collection, context, options, ); }, get(collection, id, options, context) { return run( "get", collection, context, options, id, ); }, set(collection, id, data, options, context) { return run( "set", collection, context, options, id, data, ); }, remove(collection, id, options, context) { return run<{ removed: boolean }>( "remove", collection, context, options, id, ); }, }; } export function normalizeAgentNativeExtensionSandbox( sandbox: string | undefined, ): string { const tokens = new Set( (sandbox ?? "allow-scripts allow-forms allow-popups allow-downloads") .split(/\s+/) .filter(Boolean), ); tokens.delete("allow-same-origin"); tokens.add("allow-scripts"); tokens.add("allow-downloads"); return Array.from(tokens).join(" "); } export function buildAgentNativeExtensionHtml({ extensionId, content, title, slotId, slotContext, themeCss = "", isDark = false, }: BuildAgentNativeExtensionHtmlOptions): string { const extensionIdJson = safeJson(extensionId); const slotIdJson = safeJson(slotId ?? ""); const slotContextJson = safeJson(slotContext ?? {}); const messageTypesJson = safeJson({ host: AGENT_NATIVE_HOST_MESSAGE_TYPES, extension: AGENT_NATIVE_EXTENSION_MESSAGE_TYPES, }); const titleText = title ?? "Agent Native extension"; return ` ${escapeHtmlAttribute(titleText)} ${buildSessionReplayIframeBootstrap()} ${content} `; }