diff --git a/core-web/apps/ai-evals/project.json b/core-web/apps/ai-evals/project.json index 4cbc2f697f8c..fb55a4f83bd7 100644 --- a/core-web/apps/ai-evals/project.json +++ b/core-web/apps/ai-evals/project.json @@ -8,6 +8,7 @@ "executor": "@nx/esbuild:esbuild", "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", + "dependsOn": [{ "target": "generate-spec", "projects": ["sdk-ai"] }], "options": { "platform": "node", "outputPath": "dist/apps/ai-evals", diff --git a/core-web/apps/ai-evals/src/tools.ts b/core-web/apps/ai-evals/src/tools.ts index 78e08cdfb282..2cb488ca9496 100644 --- a/core-web/apps/ai-evals/src/tools.ts +++ b/core-web/apps/ai-evals/src/tools.ts @@ -1,7 +1,9 @@ import { tool } from 'ai'; import { z } from 'zod/v4'; -import { createApiAdapter, createExecutor, getSpec } from '@dotcms/agentic-tools'; +import { createExecutor } from '@dotcms/ai/sandbox'; +import { createApiAdapter } from '@dotcms/ai/adapter'; +import { getSpec } from '@dotcms/ai/spec'; function sandboxResult( result: Awaited['execute']>> diff --git a/core-web/apps/mcp-server/README.md b/core-web/apps/mcp-server/README.md index b76914e42a67..be786282e8d7 100644 --- a/core-web/apps/mcp-server/README.md +++ b/core-web/apps/mcp-server/README.md @@ -200,7 +200,7 @@ AI: [Analyzes your Product fields and generates a complete component] ## Available Tools -The dotCMS MCP Server provides two core tools that enable comprehensive content management through AI: +The dotCMS MCP Server provides tools that enable comprehensive content management through AI: ### Search @@ -242,6 +242,42 @@ return pick(result.contentlets, ['identifier', 'title', 'modDate']) **Helper utilities available**: `pick(arr, fields)`, `table(arr)`, `count(arr, field)`, `sum(arr, field)`, `first(arr, n)` +### Download Assets + +**Tool**: `download_assets` + +**Purpose**: Download a dotCMS asset folder to the MCP server filesystem while returning only a small JSON manifest. + +```json +{ + "path": "/application/themes/travel", + "dest": "/absolute/local/path/themes/travel", + "recursive": true, + "overwrite": "skip", + "include": "*.vtl,*.scss" +} +``` + +The tool enumerates file assets with `/api/content/_search`, downloads bytes with `/api/v2/assets/{identifier}` through the server-side runtime, writes files under `dest`, and preserves relative folder structure. File bytes are not returned to the model. + +### Upload Assets + +**Tool**: `upload_assets` + +**Purpose**: Upload a local directory to dotCMS file assets using `/api/v2/assets/publish` or `/api/v2/assets/save`. + +```json +{ + "src": "/absolute/local/path/themes/travel", + "dest": "//demo.dotcms.com/application/themes/travel", + "include": "*.vtl,*.scss", + "publish": true, + "verify": true +} +``` + +The upload destination must be host-qualified. When `publish` and `verify` are true, the tool checks live status with `/api/v1/content/{identifier}` and retries publish for files that did not become live. + ### Pre-loaded Instance Context Both `search` and `execute` automatically pre-load a minimal snapshot of the connected dotCMS instance and inject it into the sandbox as globals. The AI does not need to make discovery calls before doing work. @@ -291,28 +327,61 @@ git clone https://github.com/dotCMS/core.git cd core/core-web # Install dependencies -yarn install +pnpm install -# Build the server (spec.json is already committed — no live dotCMS instance needed) -yarn nx build mcp-server +# Build the server. The build regenerates the OpenAPI spec first (via dependsOn) — by +# default from https://demo.dotcms.com; see "Building against a local dotCMS instance" below. +pnpm nx build mcp-server ``` > [!NOTE] -> Files are located in `core-web/apps/mcp-server` (tools/config) and `core-web/libs/agentic-tools` (runtime primitives + spec). We use [Nx monorepo](https://nx.dev/). +> Files are located in `core-web/apps/mcp-server` (tools/config) and `core-web/libs/sdk/ai` (runtime primitives + spec). We use [Nx monorepo](https://nx.dev/). #### Refreshing the OpenAPI Spec -The processed spec lives in `libs/agentic-tools/src/generated/spec.json` and is committed to git. You only need to regenerate it when the dotCMS REST API changes: +The processed spec lives at `libs/sdk/ai/src/generated/spec.json`. It is **build-generated and git-ignored** — the `build`/`serve`/`test` targets regenerate it via `dependsOn`, so you rarely run this by hand. The source is resolved in this order: an explicit CLI arg, then `DOTCMS_SPEC_URL`, then `${DOTCMS_URL}/api/openapi.json`, then the demo instance. ```bash # Defaults to https://demo.dotcms.com/api/openapi.json -yarn nx run agentic-tools:generate-spec +pnpm nx run sdk-ai:generate-spec + +# Override with a different instance — CLI arg (URL or local file path): +pnpm nx run sdk-ai:generate-spec -- http://localhost:8080/api/openapi.json +pnpm nx run sdk-ai:generate-spec -- ./openapi.json +``` + +There is nothing to commit — the spec is regenerated at build time. CI builds it from the demo instance by default. -# Override with a different instance (e.g. local): -yarn nx run agentic-tools:generate-spec -- http://localhost:8080/api/openapi.json +#### Building against a local dotCMS instance + +The `search` tool exposes whatever endpoints are in the bundled `spec.json`, so to describe your +**local** instance you must regenerate the spec as part of the build. + +> [!IMPORTANT] +> Don't run `generate-spec` and then `build` as two separate steps. The `build` target re-runs +> `generate-spec` itself (via `dependsOn`), and with no source set it falls back to the demo +> instance — overwriting the spec you just generated. Pass the source so the build's own +> `generate-spec` uses it. + +Set `DOTCMS_SPEC_URL` — it's an environment variable, so it flows into the `generate-spec` task +that `build` runs automatically (a CLI `--` arg would not). One command: + +```bash +# Regenerate the spec from your local instance AND build, in one step +DOTCMS_SPEC_URL=http://localhost:8080/api/openapi.json pnpm nx build mcp-server + +# Then run it against the same instance (provide a local API token) +npx @modelcontextprotocol/inspector \ + -e DOTCMS_URL=http://localhost:8080 \ + -e AUTH_TOKEN=your-local-api-token \ + node dist/apps/mcp-server/stdio.js ``` -Then commit the updated `spec.json`. CI does not need a live dotCMS instance to build. +> [!NOTE] +> The spec source (what `search` describes) and `DOTCMS_URL` (where the tools send requests at +> runtime) are separate. Point them at the same instance so the spec matches what the API serves. +> If `DOTCMS_URL` is already exported in your shell, `generate-spec` will reuse it +> (`${DOTCMS_URL}/api/openapi.json`) and you can drop `DOTCMS_SPEC_URL` entirely. #### 2. Use MCP Inspector for debug @@ -365,13 +434,17 @@ apps/mcp-server/ # MCP server (thin xmcp wrappers) ├── src/ │ ├── tools/ │ │ ├── search.ts # API spec exploration tool -│ │ └── execute.ts # API execution tool +│ │ ├── execute.ts # API execution tool +│ │ ├── download_assets.ts +│ │ └── upload_assets.ts +│ ├── lib/ +│ │ └── assets-transfer.ts │ └── prompts/ # Prompt templates (xmcp convention) ├── xmcp.config.ts # xmcp bundler configuration ├── jest.config.ts # Test configuration └── project.json # Nx project configuration -libs/agentic-tools/ # Portable runtime primitives +libs/sdk/ai/ # Portable runtime primitives ├── scripts/ │ └── generate-spec.ts # OpenAPI spec processor (run manually to refresh) ├── src/ @@ -413,24 +486,24 @@ libs/agentic-tools/ # Portable runtime primitives ### Development Commands ```bash -# Build for production (spec.json already committed — no live dotCMS needed) -yarn nx build mcp-server +# Build for production (regenerates the spec from demo by default; see local-build section) +pnpm nx build mcp-server # Development mode (with hot reload) -yarn nx serve mcp-server +pnpm nx serve mcp-server # Lint the code -yarn nx lint mcp-server +pnpm nx lint mcp-server # Run all tests -yarn nx test mcp-server +pnpm nx test mcp-server # Run tests in watch mode -yarn nx test mcp-server --watch +pnpm nx test mcp-server --watch # Refresh the OpenAPI spec (run when dotCMS API changes, then commit spec.json) # Defaults to https://demo.dotcms.com/api/openapi.json -yarn nx run agentic-tools:generate-spec +pnpm nx run sdk-ai:generate-spec ``` ### Contributing Guidelines @@ -478,7 +551,7 @@ GitHub pull requests are the preferred method to contribute code to dotCMS. We w 2. Create a feature branch (`git checkout -b feature/amazing-mcp-feature`) 3. Make your changes in the `apps/mcp-server` directory 4. Add tests for new functionality -5. Run the test suite (`yarn nx test mcp-server`) +5. Run the test suite (`pnpm nx test mcp-server`) 6. Commit your changes (`git commit -m 'Add amazing MCP feature'`) 7. Push to the branch (`git push origin feature/amazing-mcp-feature`) 8. Open a Pull Request diff --git a/core-web/apps/mcp-server/project.json b/core-web/apps/mcp-server/project.json index ce7885f0bdf4..f9b7d72108a9 100644 --- a/core-web/apps/mcp-server/project.json +++ b/core-web/apps/mcp-server/project.json @@ -8,7 +8,7 @@ "build": { "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/apps/mcp-server"], - "dependsOn": [{ "target": "generate-spec", "projects": ["agentic-tools"] }], + "dependsOn": [{ "target": "generate-spec", "projects": ["sdk-ai"] }], "options": { "commands": [ "npx xmcp build", @@ -22,7 +22,7 @@ }, "serve": { "executor": "nx:run-commands", - "dependsOn": [{ "target": "generate-spec", "projects": ["agentic-tools"] }], + "dependsOn": [{ "target": "generate-spec", "projects": ["sdk-ai"] }], "options": { "command": "npx xmcp dev", "cwd": "apps/mcp-server" @@ -30,7 +30,7 @@ }, "test": { "executor": "@nx/jest:jest", - "dependsOn": [{ "target": "generate-spec", "projects": ["agentic-tools"] }], + "dependsOn": [{ "target": "generate-spec", "projects": ["sdk-ai"] }], "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "apps/mcp-server/jest.config.ts", diff --git a/core-web/apps/mcp-server/src/lib/assets-transfer.ts b/core-web/apps/mcp-server/src/lib/assets-transfer.ts new file mode 100644 index 000000000000..18e993eba74a --- /dev/null +++ b/core-web/apps/mcp-server/src/lib/assets-transfer.ts @@ -0,0 +1,588 @@ +import { constants } from 'node:fs'; +import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; + +import { createRuntime, isBinaryResponseEnvelope } from '@dotcms/ai/runtime'; + +import { errorMessage } from './runtime'; + +type DotCMSRuntime = ReturnType; +type OverwriteMode = 'skip' | 'overwrite' | 'error'; + +export interface AssetManifestFile { + path: string; + bytes: number; + identifier?: string; +} + +export interface AssetManifestFailure { + path: string; + error: string; +} + +export interface AssetManifestSkipped { + path: string; + reason: string; +} + +interface AssetContentlet { + identifier?: string; + path?: string; +} + +interface LocalFile { + abs: string; + rel: string; + bytes: number; +} + +const SEARCH_LIMIT = 500; +const MIME_BY_EXT: Record = { + '.css': 'text/css', + '.eot': 'application/vnd.ms-fontobject', + '.gif': 'image/gif', + '.html': 'text/html', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.scss': 'text/x-scss', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.vtl': 'text/x-velocity', + '.woff': 'font/woff', + '.woff2': 'font/woff2' +}; + +export async function downloadAssets(options: { + dotcms: DotCMSRuntime; + path: string; + dest: string; + recursive: boolean; + overwrite: OverwriteMode; + include?: string; +}) { + const input = normalizeDotCMSPath(options.path); + const dest = await prepareWritableDir(options.dest); + const files: AssetManifestFile[] = []; + const failures: AssetManifestFailure[] = []; + const skipped: AssetManifestSkipped[] = []; + const warnings: string[] = []; + const directAssetPath = looksLikeAssetPath(input.path); + + // Each download is wrapped in the same try/catch so one failure records a failure and + // doesn't abort the batch — both the single-asset path and the folder loop go through it. + const download = async ( + rel: string, + fetchBytes: () => Promise, + identifier?: string + ) => { + try { + const result = await writeDownloadedFile( + { dest, rel, overwrite: options.overwrite }, + await fetchBytes(), + identifier + ); + if (result.kind === 'written') files.push(result.file); + else skipped.push(result.skip); + } catch (error) { + failures.push({ path: rel, error: errorMessage(error) }); + } + }; + + if (directAssetPath) { + await download(basename(input.path), () => + downloadAssetBytes(options.dotcms, { + path: '/api/v2/assets', + query: { path: assetQueryPath(input) } + }) + ); + } else { + const assets = await enumerateAssets( + options.dotcms, + input.path, + options.recursive, + options.include + ); + + if (assets.length === 0) { + warnings.push(zeroMatchWarning(options.path, input)); + } + + for (const asset of assets) { + const assetPath = asset.path ? normalizeDotCMSPath(asset.path).path : ''; + const rel = relativeAssetPath(input.path, assetPath) || assetPath || '(unknown)'; + const identifier = asset.identifier; + + await download( + rel, + () => { + if (!identifier || !relativeAssetPath(input.path, assetPath)) { + throw new Error('Asset is missing identifier or path'); + } + return downloadAssetBytes(options.dotcms, { + path: `/api/v2/assets/${encodeURIComponent(identifier)}` + }); + }, + identifier + ); + } + } + + return sortManifest({ + path: input.path, + dest, + count: files.length, + bytes: sumBytes(files), + files, + failures, + skipped, + warnings + }); +} + +type WriteResult = + | { kind: 'written'; file: AssetManifestFile } + | { kind: 'skipped'; skip: AssetManifestSkipped }; + +async function writeDownloadedFile( + options: { rel: string; dest: string; overwrite: OverwriteMode }, + bytes: Buffer, + identifier?: string +): Promise { + const outputPath = safeJoin(options.dest, options.rel); + if (await exists(outputPath)) { + if (options.overwrite === 'skip') { + return { kind: 'skipped', skip: { path: options.rel, reason: 'exists' } }; + } + if (options.overwrite === 'error') { + throw new Error('Destination file already exists'); + } + } + + await mkdir(resolve(outputPath, '..'), { recursive: true }); + await writeFile(outputPath, bytes); + return { kind: 'written', file: { path: options.rel, bytes: bytes.byteLength, identifier } }; +} + +export async function uploadAssets(options: { + dotcms: DotCMSRuntime; + src: string; + dest: string; + include?: string; + publish: boolean; + verify: boolean; +}) { + const src = await prepareReadableDir(options.src); + const dest = normalizeDotCMSPath(options.dest); + + if (!dest.siteQualified) { + throw new Error( + 'Upload destination must be host-qualified, e.g. //demo.dotcms.com/application/themes/travel' + ); + } + + const localFiles = await collectLocalFiles(src, options.include); + const files: AssetManifestFile[] = []; + const failures: AssetManifestFailure[] = []; + const skipped: AssetManifestSkipped[] = []; + const warnings: string[] = []; + + if (localFiles.length === 0) { + warnings.push( + options.include + ? `No files under "${src}" matched the include filter "${options.include}".` + : `No files found under "${src}".` + ); + } + + for (const file of localFiles) { + try { + if (file.bytes === 0) { + skipped.push({ path: file.rel, reason: 'empty file' }); + continue; + } + + const uploaded = await uploadOneAsset( + options.dotcms, + file, + `${dest.siteQualified}/${file.rel}`, + options.publish + ); + files.push(uploaded); + } catch (error) { + failures.push({ path: file.rel, error: errorMessage(error) }); + } + } + + const notLive = + options.publish && options.verify ? await verifyLive(options.dotcms, files) : []; + + return sortManifest({ + src, + dest: dest.siteQualified, + count: files.length, + bytes: sumBytes(files), + files, + failures, + skipped, + notLive, + warnings + }); +} + +async function enumerateAssets( + dotcms: DotCMSRuntime, + folder: string, + recursive: boolean, + include?: string +): Promise { + const matches = includeMatcher(include); + const assets: AssetContentlet[] = []; + const seen = new Set(); + + for (let offset = 0; ; offset += SEARCH_LIMIT) { + const response = await dotcms.request({ + method: 'POST', + path: '/api/content/_search', + body: { + query: `+baseType:4 +path:${folder}/*`, + sort: 'path asc', + limit: SEARCH_LIMIT, + offset + } + }); + const page = extractContentlets(response); + + for (const asset of page) { + if (!asset.identifier || !asset.path || seen.has(asset.identifier)) { + continue; + } + + const rel = relativeAssetPath(folder, normalizeDotCMSPath(asset.path).path); + if (!rel || (!recursive && rel.includes('/')) || !matches(rel)) { + continue; + } + + seen.add(asset.identifier); + assets.push(asset); + } + + if (page.length < SEARCH_LIMIT) { + break; + } + } + + return assets; +} + +/** Fetch an asset's raw bytes — by identifier (`/api/v2/assets/{id}`) or by path query. */ +async function downloadAssetBytes( + dotcms: DotCMSRuntime, + request: { path: string; query?: Record } +): Promise { + const response = await dotcms.request({ ...request, responseType: 'base64' }); + + if (!isBinaryResponseEnvelope(response)) { + throw new Error('Expected a binary asset response'); + } + + const bytes = Buffer.from(response.base64, 'base64'); + if (bytes.byteLength === 0) { + throw new Error('Downloaded asset was empty'); + } + + return bytes; +} + +async function uploadOneAsset( + dotcms: DotCMSRuntime, + file: LocalFile, + destPath: string, + publish: boolean +): Promise { + const bytes = await readFile(file.abs); + const response = (await dotcms.request({ + method: 'PUT', + path: publish ? '/api/v2/assets/publish' : '/api/v2/assets/save', + formData: { + path: destPath, + file: { + name: basename(file.rel), + type: mimeFor(file.rel), + data: bytes.toString('base64') + } + } + })) as { entity?: { identifier?: string } }; + + return { + path: file.rel, + bytes: file.bytes, + identifier: response.entity?.identifier + }; +} + +async function verifyLive( + dotcms: DotCMSRuntime, + files: AssetManifestFile[] +): Promise { + let pending = files.filter((file) => file.identifier); + + for (let round = 0; round < 3 && pending.length > 0; round++) { + const notLive: AssetManifestFile[] = []; + + for (const file of pending) { + if (!(await isLive(dotcms, file.identifier as string))) { + notLive.push(file); + } + } + + if (notLive.length === 0) { + return []; + } + + for (const file of notLive) { + await dotcms.request({ + method: 'PUT', + path: '/api/v1/workflow/actions/default/fire/PUBLISH', + body: { contentlet: { identifier: file.identifier } } + }); + } + + pending = notLive; + } + + return pending; +} + +async function isLive(dotcms: DotCMSRuntime, identifier: string): Promise { + const response = (await dotcms.request({ + path: `/api/v1/content/${encodeURIComponent(identifier)}`, + query: { depth: 0 } + })) as { entity?: { live?: boolean; contentlets?: Array<{ live?: boolean }> } }; + const entity = response.entity; + const contentlet = entity?.contentlets?.[0] || entity; + + return contentlet?.live === true; +} + +async function collectLocalFiles(src: string, include?: string): Promise { + const matches = includeMatcher(include); + const files: LocalFile[] = []; + + async function walk(dir: string) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + + if (entry.isDirectory()) { + await walk(abs); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const rel = relative(src, abs).split(sep).join(posix.sep); + if (!matches(rel)) { + continue; + } + + const info = await stat(abs); + files.push({ abs, rel, bytes: info.size }); + } + } + + await walk(src); + + return files.sort((a, b) => a.rel.localeCompare(b.rel)); +} + +function normalizeDotCMSPath(input: string): { siteQualified?: string; path: string } { + const value = input.trim().replace(/\/+$/, ''); + + if (value.startsWith('//')) { + const firstSlash = value.slice(2).indexOf('/'); + if (firstSlash < 0) { + throw new Error(`Site-qualified path "${input}" must include a path`); + } + + return { siteQualified: value, path: value.slice(firstSlash + 2) }; + } + + if (!value.startsWith('/')) { + throw new Error(`dotCMS path "${input}" must start with "/" or "//host/"`); + } + + return { path: value }; +} + +/** The path to send to the `/api/v2/assets?path=` query — host-qualified when available. */ +function assetQueryPath(normalized: { siteQualified?: string; path: string }): string { + return normalized.siteQualified || normalized.path; +} + +/** + * Message for a folder enumeration that matched 0 assets. The common cause is the `//host/path` + * ambiguity: a `//`-prefixed input has its FIRST segment consumed as the site, so `//application/themes` + * searches the path `/themes` on site `application` — which usually doesn't exist. Surface exactly + * that so the agent can correct it instead of treating an empty result as success. + */ +function zeroMatchWarning( + rawInput: string, + parsed: { siteQualified?: string; path: string } +): string { + const base = `No assets matched "${parsed.path}" — check the path. The result is empty, not a success.`; + const trimmed = rawInput.trim(); + if (trimmed.startsWith('//')) { + const site = parsed.siteQualified?.slice( + 2, + parsed.siteQualified.length - parsed.path.length + ); + // The plain-path form is the input with one leading slash removed — i.e. the FULL path + // including the segment that "//" consumed as the site (e.g. "//application/themes" → "/application/themes"). + const asPlainPath = trimmed.slice(1).replace(/\/+$/, ''); + return ( + `${base} Note: "${rawInput}" was read as site="${site}", path="${parsed.path}" ` + + `(a leading "//" treats the first segment as the dotCMS site). ` + + `If you meant a path on the default site, use "${asPlainPath}"; ` + + `if you meant a host-qualified path, keep "///".` + ); + } + return base; +} + +function relativeAssetPath(folder: string, assetPath: string): string { + const prefix = `${folder.replace(/\/+$/, '')}/`; + return assetPath.startsWith(prefix) ? assetPath.slice(prefix.length) : ''; +} + +function looksLikeAssetPath(path: string): boolean { + return extname(path) !== ''; +} + +async function prepareWritableDir(dest: string): Promise { + if (!isAbsolute(dest)) { + throw new Error(`Destination must be an absolute path: ${dest}`); + } + + const resolved = resolve(dest); + await mkdir(resolved, { recursive: true }); + await access(resolved, constants.W_OK); + + return resolved; +} + +async function prepareReadableDir(src: string): Promise { + if (!isAbsolute(src)) { + throw new Error(`Source must be an absolute path: ${src}`); + } + + const resolved = resolve(src); + const info = await stat(resolved); + if (!info.isDirectory()) { + throw new Error(`Source must be a directory: ${src}`); + } + + await access(resolved, constants.R_OK); + + return resolved; +} + +function safeJoin(root: string, rel: string): string { + if (posix.isAbsolute(rel) || rel.split('/').includes('..')) { + throw new Error(`Unsafe relative path: ${rel}`); + } + + const output = resolve(root, rel); + const back = relative(root, output); + + if (back === '..' || back.startsWith(`..${sep}`) || isAbsolute(back)) { + throw new Error(`Resolved path escapes destination: ${rel}`); + } + + return output; +} + +function includeMatcher(include?: string): (rel: string) => boolean { + const patterns = include + ?.split(',') + .map((pattern) => pattern.trim()) + .filter(Boolean); + + if (!patterns?.length) { + return () => true; + } + + // Compile each pattern ONCE here, not per-file — this matcher runs on every asset/file. + const regexes = patterns.map(globToRegExp); + + return (rel: string) => regexes.some((re) => re.test(rel)); +} + +function globToRegExp(pattern: string): RegExp { + const normalized = pattern.split(sep).join(posix.sep); + const source = normalized.replace(/[|\\{}()[\]^$+?.]/g, '\\$&').replace(/\*/g, '[^/]*'); + + return new RegExp(`${normalized.includes('/') ? '^' : '(^|/)'}${source}$`, 'i'); +} + +function extractContentlets(response: unknown): AssetContentlet[] { + const root = response as { + entity?: { + jsonObjectView?: { contentlets?: unknown }; + contentlets?: unknown; + results?: unknown; + }; + contentlets?: unknown; + }; + const candidates = [ + root.entity?.jsonObjectView?.contentlets, + root.entity?.contentlets, + root.entity?.results, + root.contentlets + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate as AssetContentlet[]; + } + } + + return []; +} + +async function exists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +function mimeFor(path: string): string { + return MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream'; +} + +function sumBytes(files: AssetManifestFile[]): number { + return files.reduce((sum, file) => sum + file.bytes, 0); +} + +function sortManifest< + T extends { + files: AssetManifestFile[]; + failures: AssetManifestFailure[]; + skipped?: AssetManifestSkipped[]; + notLive?: AssetManifestFile[]; + } +>(manifest: T): T { + const byPath = (a: { path: string }, b: { path: string }) => a.path.localeCompare(b.path); + manifest.files.sort(byPath); + manifest.failures.sort(byPath); + manifest.skipped?.sort(byPath); + manifest.notLive?.sort(byPath); + return manifest; +} diff --git a/core-web/apps/mcp-server/src/lib/runtime.ts b/core-web/apps/mcp-server/src/lib/runtime.ts new file mode 100644 index 000000000000..a1d43efab67f --- /dev/null +++ b/core-web/apps/mcp-server/src/lib/runtime.ts @@ -0,0 +1,30 @@ +import { createRuntime } from '@dotcms/ai/runtime'; + +type DotCMSRuntime = ReturnType; + +/** + * Build a runtime from the MCP server's environment. One place owns the `DOTCMS_URL` / + * `AUTH_TOKEN` reading, the default session id, and the standard context-error logging — so + * every tool (`execute`, `search`, `download_assets`, `upload_assets`) constructs the runtime + * the same way instead of re-deriving it (and silently drifting on which options they set). + */ +export function runtimeFromEnv( + sessionId?: string, + opts?: { timeout?: number; includeSpec?: boolean } +): DotCMSRuntime { + return createRuntime({ + url: process.env.DOTCMS_URL ?? '', + token: process.env.AUTH_TOKEN ?? '', + sessionId: sessionId ?? '__default__', + timeout: opts?.timeout, + includeSpec: opts?.includeSpec, + onContextError: (label, error) => { + console.error(`[context] failed to load ${label}: ${errorMessage(error)}`); + } + }); +} + +/** Normalize any thrown value to a message string. */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/core-web/apps/mcp-server/src/tools/download_assets.ts b/core-web/apps/mcp-server/src/tools/download_assets.ts new file mode 100644 index 000000000000..c69312885e00 --- /dev/null +++ b/core-web/apps/mcp-server/src/tools/download_assets.ts @@ -0,0 +1,75 @@ +import { type InferSchema, type ToolExtraArguments, type ToolMetadata } from 'xmcp'; +import { z } from 'zod'; + +import { downloadAssets } from '../lib/assets-transfer'; +import { errorMessage, runtimeFromEnv } from '../lib/runtime'; + +export const schema = { + path: z + .string() + .min(1) + .describe( + 'dotCMS folder or asset path to download, e.g. /application/themes/travel or //demo.dotcms.com/application/themes/travel/css/styles.scss' + ), + dest: z.string().min(1).describe('Absolute local directory the MCP server writes files into'), + recursive: z.boolean().default(true).describe('Include files in nested folders'), + overwrite: z + .enum(['skip', 'overwrite', 'error']) + .default('skip') + .describe('Behavior when a destination file already exists'), + include: z + .string() + .optional() + .describe('Optional comma-separated glob filter, e.g. *.vtl,*.scss') +}; + +export const metadata: ToolMetadata = { + name: 'download_assets', + description: `Download one or more dotCMS file assets (themes, VTL, CSS, JS, images, fonts, …) to a local directory. + +ALWAYS use this tool to pull files out of dotCMS — do NOT hand-roll downloads with the +\`execute\` tool, a direct API call, or a custom script. This is the supported path and it is +strictly better for two reasons: + 1. File bytes are written to disk by the server — they NEVER pass through your context. Use + this whenever you'd otherwise read file content into a tool call; it keeps that content + out of the conversation entirely. + 2. Auth is already configured on the server. You do NOT need a dotCMS token, a \`.env\` file, or + any local credentials — never go looking for them. + +Use it whenever you need dotCMS files on the local disk — whether the user explicitly asks to +"download/pull/export," OR you decided you need the existing files to inspect or edit them as +part of a larger task. Example: the user says "update the theme's CSS"; you download the current +theme files, edit them locally, then upload them back. Getting files out of dotCMS is always a +step you take with this tool — not something you wait to be told to do, and not something you improvise. + +Provide the dotCMS folder or asset path and an absolute destination directory (\`dest\`). Optional +\`include\` globs limit which files are fetched. The tool preserves relative paths and returns only +a JSON manifest — never the file bytes.`, + annotations: { + title: 'Download dotCMS Assets', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } +}; + +export default async function handler( + args: InferSchema, + extra?: ToolExtraArguments +) { + try { + const manifest = await downloadAssets({ + dotcms: runtimeFromEnv(extra?.sessionId), + path: args.path, + dest: args.dest, + recursive: args.recursive, + overwrite: args.overwrite, + include: args.include + }); + + return JSON.stringify(manifest, null, 2); + } catch (error) { + return `Error: ${errorMessage(error)}`; + } +} diff --git a/core-web/apps/mcp-server/src/tools/execute.ts b/core-web/apps/mcp-server/src/tools/execute.ts index 28d42a0ae090..508d1d8e285d 100644 --- a/core-web/apps/mcp-server/src/tools/execute.ts +++ b/core-web/apps/mcp-server/src/tools/execute.ts @@ -1,7 +1,7 @@ import { type InferSchema, type ToolExtraArguments, type ToolMetadata } from 'xmcp'; import { z } from 'zod'; -import { createApiAdapter, createExecutor, getSharedContextCache } from '@dotcms/agentic-tools'; +import { createRuntime } from '@dotcms/ai/runtime'; export const schema = { code: z @@ -39,11 +39,18 @@ Pre-loaded instance context (available as globals — no API calls needed to rea Always use the \`search\` tool first to discover the correct endpoint path and request/response schema before calling \`execute\`. +Transferring file assets? Do NOT use this tool. Use the dedicated \`upload_assets\` / +\`download_assets\` tools instead — they stream file bytes between disk and dotCMS on the +server side, so the content never enters your context (and you never need a token or \`.env\`). +Reach for \`execute\` only for JSON/API work; inlining base64 file bytes here just bloats your +context. The \`formData\`/base64 path below exists only for small, programmatic payloads — not +for transferring real files, themes, or directories. + Tips: - Use \`pick(arr, fields)\` to return only the fields you need — responses can be very large -- For file uploads use \`formData\` with \`{ name, type, data }\` (base64) or \`{ name, type, url }\` (remote URL) +- For a small programmatic upload (NOT real files — use \`upload_assets\` for those) use \`formData\` with \`{ name, type, data }\` (base64) or \`{ name, type, url }\` (remote URL) -Binary responses (file assets — images, fonts, PDFs, etc.): +Binary responses (small/programmatic reads only — for real files use \`download_assets\`): - Endpoints that return non-text bodies (e.g. GET \`/api/v2/assets/{identifier}\` and \`/dA/{id}\`, content-type \`application/octet-stream\` or \`image/*\`) come back as an envelope: \`{ __dotcmsBinary: true, contentType, base64, byteLength }\`. - The \`base64\` field IS the raw file bytes — base64-decode it to recover the exact file. Do NOT treat it as text; the bytes are intact (not UTF-8-mangled). - JSON and textual responses (\`text/*\`, xml, js, \`+json\`/\`+xml\`) are returned as parsed objects / strings as before — only binary bodies use the envelope. @@ -91,32 +98,20 @@ export default async function handler( ) { const timeout = Number(process.env.SANDBOX_TIMEOUT) || 15000; - const executor = createExecutor(); - const apiAdapter = createApiAdapter({ - dotcmsUrl: process.env.DOTCMS_URL ?? '', - authToken: process.env.AUTH_TOKEN ?? '' - }); - executor.registerAdapter(apiAdapter); - - const sessionId = extra?.sessionId ?? '__default__'; - const cache = getSharedContextCache({ - onError: (label, error) => { + // The front door absorbs the executor + adapter + context-cache wiring and injects + // dotCMS instance context automatically. Auth tokens never enter the sandbox. + const dotcms = createRuntime({ + url: process.env.DOTCMS_URL ?? '', + token: process.env.AUTH_TOKEN ?? '', + sessionId: extra?.sessionId ?? '__default__', + timeout, + onContextError: (label, error) => { const msg = error instanceof Error ? error.message : String(error); console.error(`[context] failed to load ${label}: ${msg}`); } }); - const context = await cache.get(sessionId, apiAdapter); - - const result = await executor.execute(code, { - sandbox: { timeout }, - adapters: ['api'], - variables: { - contentTypes: context.contentTypes, - sites: context.sites, - languages: context.languages, - currentUser: context.currentUser - } - }); + + const result = await dotcms.run(code); // code === the model's output if (!result.success) { const errorMsg = result.error diff --git a/core-web/apps/mcp-server/src/tools/search.ts b/core-web/apps/mcp-server/src/tools/search.ts index a743f6d7b89a..440c164ed927 100644 --- a/core-web/apps/mcp-server/src/tools/search.ts +++ b/core-web/apps/mcp-server/src/tools/search.ts @@ -1,12 +1,8 @@ import { type InferSchema, type ToolExtraArguments, type ToolMetadata } from 'xmcp'; import { z } from 'zod'; -import { - createApiAdapter, - createExecutor, - getSharedContextCache, - getSpec -} from '@dotcms/agentic-tools'; +import { createRuntime } from '@dotcms/ai/runtime'; +import { getSpec } from '@dotcms/ai/spec'; export const schema = { code: z @@ -57,37 +53,26 @@ export default async function handler( { code }: InferSchema, extra?: ToolExtraArguments ) { - const executor = createExecutor(); const spec = getSpec(); if (!spec || typeof spec !== 'object' || Object.keys(spec).length === 0) { return 'Error: OpenAPI spec is not available. The server may not have been built with a generated spec (run the generate-spec step), so the search tool cannot run.'; } - const apiAdapter = createApiAdapter({ - dotcmsUrl: process.env.DOTCMS_URL ?? '', - authToken: process.env.AUTH_TOKEN ?? '' - }); - - const sessionId = extra?.sessionId ?? '__default__'; - const cache = getSharedContextCache({ - onError: (label, error) => { + // The front door injects the instance context AND the `spec` global (includeSpec). + const dotcms = createRuntime({ + url: process.env.DOTCMS_URL ?? '', + token: process.env.AUTH_TOKEN ?? '', + sessionId: extra?.sessionId ?? '__default__', + timeout: 10000, + includeSpec: true, + onContextError: (label, error) => { const msg = error instanceof Error ? error.message : String(error); console.error(`[context] failed to load ${label}: ${msg}`); } }); - const context = await cache.get(sessionId, apiAdapter); - - const result = await executor.execute(code, { - variables: { - spec, - contentTypes: context.contentTypes, - sites: context.sites, - languages: context.languages, - currentUser: context.currentUser - }, - sandbox: { timeout: 10000 } - }); + + const result = await dotcms.run(code); if (!result.success) { const errorMsg = result.error diff --git a/core-web/apps/mcp-server/src/tools/upload_assets.ts b/core-web/apps/mcp-server/src/tools/upload_assets.ts new file mode 100644 index 000000000000..6e96be1c0898 --- /dev/null +++ b/core-web/apps/mcp-server/src/tools/upload_assets.ts @@ -0,0 +1,81 @@ +import { type InferSchema, type ToolExtraArguments, type ToolMetadata } from 'xmcp'; +import { z } from 'zod'; + +import { uploadAssets } from '../lib/assets-transfer'; +import { errorMessage, runtimeFromEnv } from '../lib/runtime'; + +export const schema = { + src: z.string().min(1).describe('Absolute local directory the MCP server reads files from'), + dest: z + .string() + .min(1) + .describe( + 'Host-qualified dotCMS destination folder, e.g. //demo.dotcms.com/application/themes/travel' + ), + include: z + .string() + .optional() + .describe('Optional comma-separated glob filter, e.g. *.vtl,*.scss'), + publish: z + .boolean() + .default(true) + .describe('Use /api/v2/assets/publish when true, otherwise /api/v2/assets/save'), + verify: z + .boolean() + .default(true) + .describe('After publishing, verify live status through /api/v1/content/{identifier}') +}; + +export const metadata: ToolMetadata = { + name: 'upload_assets', + description: `Upload one or more local files as dotCMS file assets (themes, VTL, CSS, JS, images, fonts, …). + +ALWAYS use this tool to put files into dotCMS — do NOT hand-roll uploads with the \`execute\` +tool, a direct API call, or a custom script. This is the supported path and it is strictly +better for two reasons: + 1. File bytes are read from disk by the server and streamed straight to dotCMS — they NEVER + pass through your context. Use this whenever you'd otherwise inline file content (e.g. large + VTL/CSS blobs) into a tool call; it keeps that content out of the conversation entirely. + 2. Auth is already configured on the server. You do NOT need a dotCMS token, a \`.env\` file, or + any local credentials — never go looking for them. + +Use it whenever local files need to land in dotCMS — whether the user explicitly asks to +"upload/push/publish," OR you decided to write files as part of a larger task and now need them +in dotCMS. Example: the user says "create a theme"; you write the theme files to a local +directory, then call this tool to upload them. Putting files in dotCMS is always a step you +take with this tool — not something you wait to be told to do, and not something you improvise. + +Provide an absolute source directory (\`src\`) and a host-qualified destination (\`dest\`, e.g. +\`//demo.dotcms.com/application/themes/travel\`). Optional \`include\` globs limit which files go. +The tool preserves relative paths and returns only a JSON manifest — never the file bytes. + +Tip: to avoid inlining large templates, write them to files on disk and upload them with this +tool, then reference them from a container/template via \`#dotParse\`.`, + annotations: { + title: 'Upload dotCMS Assets', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + } +}; + +export default async function handler( + args: InferSchema, + extra?: ToolExtraArguments +) { + try { + const manifest = await uploadAssets({ + dotcms: runtimeFromEnv(extra?.sessionId), + src: args.src, + dest: args.dest, + include: args.include, + publish: args.publish, + verify: args.verify + }); + + return JSON.stringify(manifest, null, 2); + } catch (error) { + return `Error: ${errorMessage(error)}`; + } +} diff --git a/core-web/apps/mcp-server/xmcp.config.ts b/core-web/apps/mcp-server/xmcp.config.ts index d2e79170d8fb..20d425037e2a 100644 --- a/core-web/apps/mcp-server/xmcp.config.ts +++ b/core-web/apps/mcp-server/xmcp.config.ts @@ -13,7 +13,11 @@ const config: XmcpConfig = { } rspackConfig.resolve = rspackConfig.resolve || {}; rspackConfig.resolve.alias = { - '@dotcms/agentic-tools': process.cwd() + '/../../libs/agentic-tools/src/index.ts' + // The front door lives at the /runtime subpath; @dotcms/ai is a pure namespace. + '@dotcms/ai/runtime': process.cwd() + '/../../libs/sdk/ai/src/runtime.ts', + '@dotcms/ai/sandbox': process.cwd() + '/../../libs/sdk/ai/src/sandbox/index.ts', + '@dotcms/ai/adapter': process.cwd() + '/../../libs/sdk/ai/src/adapter/index.ts', + '@dotcms/ai/spec': process.cwd() + '/../../libs/sdk/ai/src/spec/index.ts' }; return rspackConfig; } diff --git a/core-web/libs/agentic-tools/.eslintrc.json b/core-web/libs/agentic-tools/.eslintrc.json deleted file mode 100644 index 35e199a6e864..000000000000 --- a/core-web/libs/agentic-tools/.eslintrc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": ["../../.eslintrc.base.json"], - "ignorePatterns": ["!**/*", "**/node_modules/**", "node_modules/**"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": {} - }, - { - "files": ["*.ts", "*.tsx"], - "rules": {} - }, - { - "files": ["*.js", "*.jsx"], - "rules": {} - } - ] -} diff --git a/core-web/libs/agentic-tools/README.md b/core-web/libs/agentic-tools/README.md deleted file mode 100644 index c6b63a4e993c..000000000000 --- a/core-web/libs/agentic-tools/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# @dotcms/agentic-tools - -Portable runtime primitives for building AI agents that interact with dotCMS. Used by `apps/mcp-server` and `apps/ai-evals` — any future consumer that needs sandbox execution or authenticated dotCMS API calls. - -## What's in here - -| Module | Export | Purpose | -|---|---|---| -| `executor` | `createExecutor`, `Executor` | Orchestrates sandbox + adapters | -| `http-client` | `createApiAdapter` | Authenticated HTTP adapter for dotCMS | -| `sandbox` | `createSandbox` | Dual-runtime worker (Node.js / Bun) | -| `spec` | `getSpec` | Loads the pre-processed OpenAPI spec | -| `types` | various interfaces | Shared TypeScript types | - -## Architecture - -``` -Consumer (mcp-server, ai-evals, ...) - └── createExecutor() - ├── createSandbox() ← worker thread (Node.js or Bun) - └── createApiAdapter(config) ← auth injected here, never in sandbox -``` - -**Sandbox isolation**: user code runs in a Worker. It can call `api.request(...)` which posts a message to the main thread. The main thread executes the actual HTTP call with the injected auth token and posts the result back. Auth tokens are never sent into the sandbox. - -**Dual-runtime**: `createSandbox()` auto-detects the runtime via `typeof globalThis.Bun` and picks the right worker implementation. - -## Usage - -```typescript -import { createExecutor, createApiAdapter, getSpec } from '@dotcms/agentic-tools'; - -const apiAdapter = createApiAdapter({ - dotcmsUrl: 'https://demo.dotcms.com', - authToken: 'your-api-token' -}); - -const executor = createExecutor(); -executor.registerAdapter(apiAdapter); - -// Run sandboxed code with access to the api adapter -const result = await executor.execute(` - const data = await api.request({ path: '/api/v1/contenttype' }); - return data; -`, { - adapters: ['api'], - sandbox: { timeout: 10000 } -}); -``` - -## OpenAPI Spec - -`getSpec()` returns the pre-processed dotCMS OpenAPI spec committed at `src/generated/spec.json`. - -**To refresh the spec** (requires a running dotCMS instance): - -```bash -# Defaults to https://demo.dotcms.com/api/openapi.json when no arg is passed -yarn nx run agentic-tools:generate-spec - -# Override with a different instance: -yarn nx run agentic-tools:generate-spec -- http://localhost:8080/api/openapi.json -``` - -Then **commit the updated `src/generated/spec.json`**. The spec is static build-time data — consumers do not need a live dotCMS instance to build or run. - -The script filters the full OpenAPI spec down to the endpoints in `ALLOWED_PREFIXES` (see `scripts/generate-spec.ts`), dereferences all `$ref` pointers, and strips response schemas to keep the file small. - -## Commands - -```bash -yarn nx run agentic-tools:build # Compile TypeScript -yarn nx run agentic-tools:test # Run tests -yarn nx run agentic-tools:lint # Lint src + scripts -yarn nx run agentic-tools:generate-spec -- # Refresh spec.json -``` diff --git a/core-web/libs/agentic-tools/jest.config.ts b/core-web/libs/agentic-tools/jest.config.ts deleted file mode 100644 index 543c4654b8ad..000000000000 --- a/core-web/libs/agentic-tools/jest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable */ -export default { - displayName: 'agentic-tools', - preset: '../../jest.preset.js', - testEnvironment: 'node', - transform: { - '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] - }, - moduleFileExtensions: ['ts', 'js', 'html', 'json'], - testMatch: ['**/*.spec.ts', '**/*.test.ts'], - coverageDirectory: '../../coverage/libs/agentic-tools' -}; diff --git a/core-web/libs/agentic-tools/package.json b/core-web/libs/agentic-tools/package.json deleted file mode 100644 index 5a12479688e5..000000000000 --- a/core-web/libs/agentic-tools/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@dotcms/agentic-tools", - "version": "0.0.1", - "private": true, - "type": "commonjs", - "main": "./src/index.js", - "types": "./src/index.d.ts", - "dependencies": { - "tslib": "^2.3.0" - } -} diff --git a/core-web/libs/agentic-tools/project.json b/core-web/libs/agentic-tools/project.json deleted file mode 100644 index d417ee1f8212..000000000000 --- a/core-web/libs/agentic-tools/project.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "agentic-tools", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/agentic-tools/src", - "projectType": "library", - "tags": ["scope:agentic"], - "targets": { - "generate-spec": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/generate-spec.ts", - "cwd": "libs/agentic-tools" - } - }, - "build": { - "executor": "@nx/js:tsc", - "outputs": ["{options.outputPath}"], - "dependsOn": ["generate-spec"], - "options": { - "outputPath": "dist/libs/agentic-tools", - "tsConfig": "libs/agentic-tools/tsconfig.lib.json", - "packageJson": "libs/agentic-tools/package.json", - "main": "libs/agentic-tools/src/index.ts", - "assets": ["libs/agentic-tools/*.md"] - } - }, - "lint": { - "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"], - "options": { - "lintFilePatterns": [ - "libs/agentic-tools/src/**/*.ts", - "libs/agentic-tools/scripts/**/*.ts" - ] - } - }, - "test": { - "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], - "dependsOn": ["generate-spec"], - "options": { - "jestConfig": "libs/agentic-tools/jest.config.ts", - "passWithNoTests": true - } - } - } -} diff --git a/core-web/libs/agentic-tools/src/index.ts b/core-web/libs/agentic-tools/src/index.ts deleted file mode 100644 index 4110219556e5..000000000000 --- a/core-web/libs/agentic-tools/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { Executor, createExecutor } from './lib/executor'; -export type { ExecutorOptions } from './lib/executor'; - -export { createApiAdapter, isBinaryResponseEnvelope } from './lib/http-client'; -export type { ApiAdapterConfig, BinaryResponseEnvelope } from './lib/http-client'; - -export { createSandbox } from './lib/sandbox'; -export type { ISandbox, SandboxFactory } from './lib/sandbox/interface'; - -export { getSpec } from './lib/spec'; - -export { loadDotCMSContext } from './lib/context'; -export type { - DotCMSContext, - ContentTypeSummary, - SiteSummary, - LanguageSummary, - CurrentUserSummary -} from './lib/context'; - -export { ContextCache, getSharedContextCache } from './lib/context-cache'; - -export type { - Adapter, - AdapterMethod, - AdapterMethodParameter, - SandboxConfig, - SandboxResult, - ExecutionContext -} from './lib/types'; diff --git a/core-web/libs/agentic-tools/src/lib/http-client.ts b/core-web/libs/agentic-tools/src/lib/http-client.ts deleted file mode 100644 index 0366eab8b612..000000000000 --- a/core-web/libs/agentic-tools/src/lib/http-client.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { Adapter, AdapterMethod } from './types'; - -interface FileFieldDescriptor { - name: string; // filename, e.g. "logo.png" - type: string; // MIME type, e.g. "image/png" - data?: string; // base64-encoded content (mutually exclusive with url) - url?: string; // URL to fetch content from (mutually exclusive with data) -} - -type FormDataFieldValue = string | FileFieldDescriptor; - -interface RequestOptions { - method?: string; - path: string; - query?: Record; - body?: unknown; - formData?: Record; - headers?: Record; - // How to decode the response body. Defaults to content-type auto-detection: - // JSON content types are parsed; textual types come back as strings; everything - // else (images, fonts, etc.) comes back as a base64 binary envelope so the bytes - // survive the JSON.stringify boundary in the consuming sandbox. Set 'base64' to - // force the binary path regardless of the declared content-type. - responseType?: 'auto' | 'base64'; -} - -function isFileDescriptor(value: unknown): value is FileFieldDescriptor { - const obj = value as Record; - return ( - typeof value === 'object' && - value !== null && - typeof obj.name === 'string' && - typeof obj.type === 'string' && - (typeof obj.data === 'string' || typeof obj.url === 'string') - ); -} - -// Max size (bytes) for a remote file fetched via a `url` descriptor — guards -// against memory exhaustion from an attacker-controlled endpoint. -const MAX_REMOTE_FILE_BYTES = 25 * 1024 * 1024; // 25 MB -// Timeout (ms) for the remote fetch, so a slow/hanging URL cannot stall the host. -const REMOTE_FILE_FETCH_TIMEOUT_MS = 15000; - -// Max size (bytes) for a binary response body returned as a base64 envelope. -// base64 inflates the payload ~33% and the whole thing flows through -// JSON.stringify in the consuming sandbox, so large assets can blow up memory -// and model context — cap it like the upload side already does. -const MAX_BINARY_RESPONSE_BYTES = 25 * 1024 * 1024; // 25 MB - -/** - * Tagged envelope returned for non-textual response bodies. The raw bytes are - * base64-encoded so they survive the `JSON.stringify` serialization boundary in - * `execute.ts` intact — `response.text()` would corrupt any non-UTF-8 byte into - * the U+FFFD replacement char. Consumers detect `__dotcmsBinary` and decode. - */ -export interface BinaryResponseEnvelope { - __dotcmsBinary: true; - contentType: string; - base64: string; - byteLength: number; -} - -/** - * Type guard for the binary response envelope. Consumers can use this to detect - * a binary body and `Buffer.from(envelope.base64, 'base64')` to recover the bytes. - */ -export function isBinaryResponseEnvelope(value: unknown): value is BinaryResponseEnvelope { - if (typeof value !== 'object' || value === null) { - return false; - } - const obj = value as Record; - return ( - obj.__dotcmsBinary === true && - typeof obj.base64 === 'string' && - typeof obj.contentType === 'string' && - typeof obj.byteLength === 'number' - ); -} - -/** - * Decide whether a content-type should be decoded as text. Everything that is - * not JSON (handled separately) and not in this textual set is treated as - * binary and returned as a base64 envelope. - */ -function isTextualContentType(contentType: string): boolean { - const ct = contentType.toLowerCase(); - return ( - ct.startsWith('text/') || - ct.includes('application/xml') || - ct.includes('application/javascript') || - ct.includes('application/x-www-form-urlencoded') || - ct.includes('+json') || - ct.includes('+xml') - ); -} - -/** - * Read a response body as a base64 binary envelope, enforcing the size cap. - */ -async function readBinaryResponse( - response: Response, - contentType: string -): Promise { - // Reject early via Content-Length so we never buffer an oversized body into - // memory. The header can be absent or lie, so the post-read check below stays - // as the authoritative backstop. - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > MAX_BINARY_RESPONSE_BYTES) { - throw new Error( - `Binary response (${declaredLength} bytes) exceeds the ${MAX_BINARY_RESPONSE_BYTES}-byte limit` - ); - } - const buffer = await response.arrayBuffer(); - if (buffer.byteLength > MAX_BINARY_RESPONSE_BYTES) { - throw new Error( - `Binary response (${buffer.byteLength} bytes) exceeds the ${MAX_BINARY_RESPONSE_BYTES}-byte limit` - ); - } - return { - __dotcmsBinary: true, - contentType, - base64: Buffer.from(buffer).toString('base64'), - byteLength: buffer.byteLength - }; -} - -/** - * Validates a user-supplied file URL before fetching it, to mitigate SSRF. - * Sandbox code can put any string in `desc.url`, and the fetch runs on the - * host with host network access — so we restrict it to public http(s) targets - * and reject loopback, link-local, and private (RFC 1918 / unique-local) hosts. - */ -function assertSafeRemoteUrl(rawUrl: string): URL { - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - throw new Error(`Invalid file URL: "${rawUrl}"`); - } - - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - throw new Error(`File URL must use http(s); got "${parsed.protocol}"`); - } - - const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); - - // Block obvious metadata / loopback hostnames. - if ( - host === 'localhost' || - host.endsWith('.localhost') || - host === 'metadata.google.internal' - ) { - throw new Error(`File URL host "${host}" is not allowed`); - } - - // IPv4 private / loopback / link-local / unspecified ranges. - const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (ipv4) { - const [a, b] = ipv4.slice(1).map(Number); - const isPrivate = - a === 0 || // 0.0.0.0/8 (unspecified) - a === 10 || // 10.0.0.0/8 - a === 127 || // 127.0.0.0/8 (loopback) - (a === 169 && b === 254) || // 169.254.0.0/16 (link-local, incl. cloud metadata) - (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 - (a === 192 && b === 168); // 192.168.0.0/16 - if (isPrivate) { - throw new Error(`File URL resolves to a private/loopback address: "${host}"`); - } - } - - // IPv6 loopback (::1), unspecified (::) and unique-local (fc00::/7) / link-local (fe80::/10). - if (host.includes(':')) { - if (host === '::1' || host === '::' || /^f[cd]/.test(host) || /^fe[89ab]/.test(host)) { - throw new Error(`File URL resolves to a private/loopback IPv6 address: "${host}"`); - } - } - - return parsed; -} - -async function resolveFileDescriptor(desc: FileFieldDescriptor): Promise { - if (desc.data) { - const binary = Buffer.from(desc.data, 'base64'); - return new Blob([new Uint8Array(binary)], { type: desc.type }); - } - if (desc.url) { - const safeUrl = assertSafeRemoteUrl(desc.url); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REMOTE_FILE_FETCH_TIMEOUT_MS); - try { - const response = await fetch(safeUrl.toString(), { - signal: controller.signal, - redirect: 'error' // a redirect could escape the SSRF guard - }); - if (!response.ok) { - throw new Error(`Failed to fetch file from "${desc.url}": ${response.status}`); - } - const buffer = await response.arrayBuffer(); - if (buffer.byteLength > MAX_REMOTE_FILE_BYTES) { - throw new Error( - `Remote file "${desc.url}" exceeds the ${MAX_REMOTE_FILE_BYTES}-byte limit` - ); - } - return new Blob([buffer], { type: desc.type }); - } finally { - clearTimeout(timer); - } - } - throw new Error(`File descriptor "${desc.name}" must have either "data" (base64) or "url"`); -} - -export interface ApiAdapterConfig { - dotcmsUrl: string; - authToken: string; -} - -/** - * Create the "api" adapter for making authenticated HTTP calls to dotCMS. - * Auth tokens are injected by the main thread — never exposed to the sandbox. - */ -export function createApiAdapter(config: ApiAdapterConfig): Adapter { - const baseUrl = config.dotcmsUrl; - const apiToken = config.authToken; - - if (!baseUrl) { - throw new Error('dotcmsUrl is required'); - } - if (!apiToken) { - throw new Error('authToken is required'); - } - - const requestMethod: AdapterMethod = { - name: 'request', - description: 'Make an authenticated HTTP request to the dotCMS API', - parameters: [ - { - name: 'options', - type: 'object', - description: 'Request options: { method, path, query, body, formData, headers }', - required: true - } - ], - async execute(...args: unknown[]): Promise { - const options = (args[0] || {}) as RequestOptions; - const method = (options.method || 'GET').toUpperCase(); - const urlPath = options.path || '/'; - - // Validate that the path is a relative path and cannot override the base URL - if (!urlPath.startsWith('/')) { - throw new Error("options.path must be a relative path starting with '/'"); - } - // Explicitly reject protocol-relative URLs like "//attacker.example/path" - if (urlPath.startsWith('//')) { - throw new Error('options.path must not be a protocol-relative URL'); - } - // Reject values that look like they start with a URL scheme (e.g. "http:", "https:") - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(urlPath)) { - throw new Error('options.path must not be an absolute URL'); - } - // Build URL with query params - const url = new URL(urlPath, baseUrl); - if (options.query) { - for (const [key, value] of Object.entries(options.query)) { - url.searchParams.set(key, String(value)); - } - } - - // Build headers — auth token injected here, never in sandbox - const headers: Record = { - Accept: 'application/json, */*;q=0.1', - Origin: new URL(baseUrl).origin, - ...options.headers, - Authorization: `Bearer ${apiToken}` // always last — cannot be overridden - }; - - // Build fetch options - const fetchOptions: RequestInit = { method, headers }; - - if (options.formData && options.body) { - throw new Error("Cannot specify both 'body' and 'formData'"); - } - - if (options.formData && method !== 'GET' && method !== 'HEAD') { - const form = new FormData(); - for (const [fieldName, fieldValue] of Object.entries(options.formData)) { - if (typeof fieldValue === 'string') { - form.append(fieldName, fieldValue); - } else if (isFileDescriptor(fieldValue)) { - const blob = await resolveFileDescriptor(fieldValue); - form.append(fieldName, blob, fieldValue.name); - } else { - throw new Error( - `Invalid formData field "${fieldName}": must be a string or { name, type, data|url }` - ); - } - } - // Do NOT set Content-Type — fetch() auto-generates it with the multipart boundary - delete headers['Content-Type']; - fetchOptions.body = form; - } else if (options.body && method !== 'GET' && method !== 'HEAD') { - headers['Content-Type'] = 'application/json'; - fetchOptions.body = JSON.stringify(options.body); - } - - const response = await fetch(url.toString(), fetchOptions); - - // Parse response. - const contentType = response.headers.get('content-type') || ''; - - // On error, always read the body as text regardless of the declared - // content-type — dotCMS errors come back as HTML/text and we want a - // readable message, not a base64 envelope of the error page. - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`HTTP ${response.status} ${response.statusText}: ${errorBody}`); - } - - const forceBinary = options.responseType === 'base64'; - - if (!forceBinary && contentType.includes('application/json')) { - return await response.json(); - } - if (!forceBinary && isTextualContentType(contentType)) { - return await response.text(); - } - // Non-JSON, non-textual (or explicitly requested): return a base64 - // envelope so the raw bytes survive JSON.stringify intact. - return await readBinaryResponse(response, contentType); - } - }; - - return { - name: 'api', - description: 'Authenticated HTTP client for dotCMS REST API', - version: '1.0.0', - methods: new Map([['request', requestMethod]]) - }; -} diff --git a/core-web/libs/agentic-tools/src/lib/sandbox/bun-worker.ts b/core-web/libs/agentic-tools/src/lib/sandbox/bun-worker.ts deleted file mode 100644 index 3a5a63fd74ea..000000000000 --- a/core-web/libs/agentic-tools/src/lib/sandbox/bun-worker.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { ExecutionContext, SandboxConfig, SandboxResult } from '../types'; -import type { ISandbox } from './interface'; - -const DEFAULT_CONFIG: Required = { - timeout: 5000, - globals: {} -}; - -export class BunWorkerSandbox implements ISandbox { - private config: Required; - - constructor(config?: SandboxConfig) { - this.config = { ...DEFAULT_CONFIG, ...config }; - } - - getConfig(): SandboxConfig { - return { ...this.config }; - } - - configure(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - async execute(code: string, context: ExecutionContext): Promise> { - const startTime = performance.now(); - - return new Promise((resolve) => { - const adapterMethods: Record = {}; - for (const [name, methods] of Object.entries(context.adapters)) { - adapterMethods[name] = Object.keys(methods); - } - - const workerCode = this.buildWorkerCode(); - - const blob = new Blob([workerCode], { type: 'application/javascript' }); - const url = URL.createObjectURL(blob); - const worker = new Worker(url); - - let resolved = false; - const cleanup = () => { - if (!resolved) { - resolved = true; - worker.terminate(); - URL.revokeObjectURL(url); - } - }; - - const timeoutId = setTimeout(() => { - if (!resolved) { - cleanup(); - resolve({ - success: false, - error: { - name: 'TimeoutError', - message: `Execution timed out after ${this.config.timeout}ms` - }, - logs: [], - executionTime: performance.now() - startTime - }); - } - }, this.config.timeout); - - worker.onmessage = async (event: MessageEvent) => { - const { type, ...data } = event.data; - - if (type === 'ready') { - worker.postMessage({ type: 'execute', data: { code } }); - } else if (type === 'adapter_call') { - const { adapter, method, args, id } = data; - // Posting to a worker that was already terminated (e.g. by a - // timeout while this adapter call was in flight) throws and - // would escape as an unhandled rejection, crashing the host. - const postResult = (payload: Record) => { - if (resolved) { - return; - } - try { - worker.postMessage({ type: 'adapter_result', data: payload }); - } catch { - /* worker gone — nothing to deliver the result to */ - } - }; - try { - const adapterObj = context.adapters[adapter]; - if (!adapterObj || !adapterObj[method]) { - throw new Error(`Adapter method not found: ${adapter}.${method}`); - } - const result = await (adapterObj[method] as (...a: unknown[]) => unknown)( - ...args - ); - postResult({ id, result }); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - postResult({ id, error }); - } - } else if (type === 'result') { - clearTimeout(timeoutId); - cleanup(); - resolve({ - success: data.success, - value: data.value as T, - error: data.error, - logs: data.logs || [], - executionTime: performance.now() - startTime - }); - } - }; - - worker.onerror = (error: ErrorEvent) => { - clearTimeout(timeoutId); - cleanup(); - resolve({ - success: false, - error: { - name: 'WorkerError', - message: error.message || 'Unknown worker error' - }, - logs: [], - executionTime: performance.now() - startTime - }); - }; - - worker.postMessage({ - type: 'init', - data: { - variables: context.variables || {}, - adapterMethods, - globals: this.config.globals - } - }); - }); - } - - private buildWorkerCode(): string { - return ` - // Block direct network access — all calls must go through adapters - const __networkError = () => { throw new Error('Network access is disabled in sandbox'); }; - const __disableGlobalApi = (name) => { - try { - Object.defineProperty(globalThis, name, { value: __networkError, writable: false, configurable: false }); - } catch { /* ignore if already frozen */ } - }; - __disableGlobalApi('fetch'); - __disableGlobalApi('XMLHttpRequest'); - __disableGlobalApi('WebSocket'); - __disableGlobalApi('EventSource'); - if (globalThis.navigator && typeof globalThis.navigator === 'object') { - try { - Object.defineProperty(globalThis.navigator, 'sendBeacon', { value: __networkError, writable: false, configurable: false }); - } catch { /* ignore */ } - } - - // Block require and clean process environment. - // Bun Web Workers inherit the parent environment, so without this the - // sandboxed code could read secrets such as AUTH_TOKEN from process.env. - try { - Object.defineProperty(globalThis, 'require', { value: undefined, writable: false, configurable: false }); - } catch { /* ignore */ } - if (typeof process !== 'undefined') { process.env = {}; } - - const logs = []; - const pendingCalls = new Map(); - let callId = 0; - - const console = { - log: (...args) => logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - warn: (...args) => logs.push('[WARN] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - error: (...args) => logs.push('[ERROR] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - info: (...args) => logs.push('[INFO] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - }; - globalThis.console = console; - - globalThis.pick = (arr, fields) => { - if (!Array.isArray(arr)) return arr; - return arr.map(item => { - const result = {}; - for (const field of fields) { - const parts = field.split('.'); - let value = item; - let key = parts[parts.length - 1]; - for (const part of parts) value = value?.[part]; - result[key] = value; - } - return result; - }); - }; - - globalThis.table = (arr, maxRows = 10) => { - if (!Array.isArray(arr) || arr.length === 0) return '(empty)'; - const items = arr.slice(0, maxRows); - const keys = Object.keys(items[0]); - const header = '| ' + keys.join(' | ') + ' |'; - const sep = '|' + keys.map(() => '---').join('|') + '|'; - const rows = items.map(item => '| ' + keys.map(k => String(item[k] ?? '')).join(' | ') + ' |'); - let result = [header, sep, ...rows].join('\\n'); - if (arr.length > maxRows) result += '\\n... +' + (arr.length - maxRows) + ' more rows'; - return result; - }; - - globalThis.count = (arr, field) => { - if (!Array.isArray(arr)) return {}; - return arr.reduce((acc, item) => { - const key = String(item[field] ?? 'unknown'); - acc[key] = (acc[key] || 0) + 1; - return acc; - }, {}); - }; - - globalThis.sum = (arr, field) => { - if (!Array.isArray(arr)) return 0; - return arr.reduce((acc, item) => acc + (Number(item[field]) || 0), 0); - }; - - globalThis.first = (arr, n = 5) => { - if (!Array.isArray(arr)) return arr; - return arr.slice(0, n); - }; - - self.onmessage = async (event) => { - const { type, data } = event.data; - - if (type === 'init') { - const { variables, adapterMethods, globals } = data; - - for (const [key, value] of Object.entries(variables || {})) { - globalThis[key] = value; - } - - for (const [key, value] of Object.entries(globals || {})) { - globalThis[key] = value; - } - - globalThis.adapters = {}; - for (const [adapterName, methods] of Object.entries(adapterMethods)) { - const adapterObj = {}; - for (const methodName of methods) { - adapterObj[methodName] = async (...args) => { - const id = ++callId; - return new Promise((resolve, reject) => { - pendingCalls.set(id, { resolve, reject }); - self.postMessage({ - type: 'adapter_call', - adapter: adapterName, - method: methodName, - args, - id - }); - }); - }; - } - globalThis.adapters[adapterName] = adapterObj; - globalThis[adapterName] = adapterObj; - } - - self.postMessage({ type: 'ready' }); - } - - else if (type === 'adapter_result') { - const { id, result, error } = data; - const pending = pendingCalls.get(id); - if (pending) { - pendingCalls.delete(id); - if (error) pending.reject(new Error(error)); - else pending.resolve(result); - } - } - - else if (type === 'execute') { - try { - const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; - const fn = new AsyncFunction(data.code); - const result = await fn(); - self.postMessage({ type: 'result', success: true, value: result, logs }); - } catch (err) { - self.postMessage({ - type: 'result', - success: false, - error: { name: err.name, message: err.message, stack: err.stack }, - logs - }); - } - } - }; - `; - } - - dispose(): void { - // Workers are created per execution, nothing to dispose - } -} - -export function createSandbox(config?: SandboxConfig): ISandbox { - return new BunWorkerSandbox(config); -} diff --git a/core-web/libs/agentic-tools/src/lib/sandbox/index.ts b/core-web/libs/agentic-tools/src/lib/sandbox/index.ts deleted file mode 100644 index 0034c98c522c..000000000000 --- a/core-web/libs/agentic-tools/src/lib/sandbox/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SandboxConfig } from '../types'; -import type { ISandbox } from './interface'; - -export { type ISandbox } from './interface'; - -export function createSandbox(config?: SandboxConfig): ISandbox { - if (typeof (globalThis as Record).Bun !== 'undefined') { - const { BunWorkerSandbox } = require('./bun-worker'); - return new BunWorkerSandbox(config); - } - - const { NodeWorkerSandbox } = require('./node-worker'); - return new NodeWorkerSandbox(config); -} diff --git a/core-web/libs/agentic-tools/src/lib/sandbox/node-worker.ts b/core-web/libs/agentic-tools/src/lib/sandbox/node-worker.ts deleted file mode 100644 index cf6a758bb5e6..000000000000 --- a/core-web/libs/agentic-tools/src/lib/sandbox/node-worker.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { Worker } from 'node:worker_threads'; - -import type { ExecutionContext, SandboxConfig, SandboxResult } from '../types'; -import type { ISandbox } from './interface'; - -const DEFAULT_CONFIG: Required = { - timeout: 5000, - globals: {} -}; - -export class NodeWorkerSandbox implements ISandbox { - private config: Required; - - constructor(config?: SandboxConfig) { - this.config = { ...DEFAULT_CONFIG, ...config }; - } - - getConfig(): SandboxConfig { - return { ...this.config }; - } - - configure(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - async execute(code: string, context: ExecutionContext): Promise> { - const startTime = performance.now(); - - return new Promise((resolve) => { - const adapterMethods: Record = {}; - for (const [name, methods] of Object.entries(context.adapters)) { - adapterMethods[name] = Object.keys(methods); - } - - const workerCode = this.buildWorkerCode(); - - const worker = new Worker(workerCode, { eval: true, env: {} }); - - let resolved = false; - const cleanup = () => { - if (!resolved) { - resolved = true; - worker.terminate(); - } - }; - - const timeoutId = setTimeout(() => { - if (!resolved) { - cleanup(); - resolve({ - success: false, - error: { - name: 'TimeoutError', - message: `Execution timed out after ${this.config.timeout}ms` - }, - logs: [], - executionTime: performance.now() - startTime - }); - } - }, this.config.timeout); - - worker.on('message', async (msg) => { - const { type, ...data } = msg; - - if (type === 'ready') { - worker.postMessage({ type: 'execute', data: { code } }); - } else if (type === 'adapter_call') { - const { adapter, method, args, id } = data; - // Posting to a worker that was already terminated (e.g. by a - // timeout while this adapter call was in flight) throws and - // would escape as an unhandled rejection, crashing the host. - const postResult = (payload: Record) => { - if (resolved) { - return; - } - try { - worker.postMessage({ type: 'adapter_result', data: payload }); - } catch { - /* worker gone — nothing to deliver the result to */ - } - }; - try { - const adapterObj = context.adapters[adapter]; - if (!adapterObj || !adapterObj[method]) { - throw new Error(`Adapter method not found: ${adapter}.${method}`); - } - const result = await (adapterObj[method] as (...a: unknown[]) => unknown)( - ...args - ); - postResult({ id, result }); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - postResult({ id, error }); - } - } else if (type === 'result') { - clearTimeout(timeoutId); - cleanup(); - resolve({ - success: data.success, - value: data.value as T, - error: data.error, - logs: data.logs || [], - executionTime: performance.now() - startTime - }); - } - }); - - worker.on('error', (error) => { - clearTimeout(timeoutId); - cleanup(); - resolve({ - success: false, - error: { - name: 'WorkerError', - message: error.message || 'Unknown worker error' - }, - logs: [], - executionTime: performance.now() - startTime - }); - }); - - worker.postMessage({ - type: 'init', - data: { - variables: context.variables || {}, - adapterMethods, - globals: this.config.globals - } - }); - }); - } - - private buildWorkerCode(): string { - return ` - const { parentPort } = require('worker_threads'); - - // Block direct network access — all calls must go through adapters - const __networkError = () => { throw new Error('Network access is disabled in sandbox'); }; - const __disableGlobalApi = (name) => { - try { - Object.defineProperty(globalThis, name, { value: __networkError, writable: false, configurable: false }); - } catch { /* ignore if already frozen */ } - }; - __disableGlobalApi('fetch'); - __disableGlobalApi('XMLHttpRequest'); - __disableGlobalApi('WebSocket'); - __disableGlobalApi('EventSource'); - if (globalThis.navigator && typeof globalThis.navigator === 'object') { - try { - Object.defineProperty(globalThis.navigator, 'sendBeacon', { value: __networkError, writable: false, configurable: false }); - } catch { /* ignore */ } - } - - // Block require and clean process environment - try { - Object.defineProperty(globalThis, 'require', { value: undefined, writable: false, configurable: false }); - } catch { /* ignore */ } - if (typeof process !== 'undefined') { process.env = {}; } - - const logs = []; - const pendingCalls = new Map(); - let callId = 0; - - const console = { - log: (...args) => logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - warn: (...args) => logs.push('[WARN] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - error: (...args) => logs.push('[ERROR] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - info: (...args) => logs.push('[INFO] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), - }; - globalThis.console = console; - - globalThis.pick = (arr, fields) => { - if (!Array.isArray(arr)) return arr; - return arr.map(item => { - const result = {}; - for (const field of fields) { - const parts = field.split('.'); - let value = item; - let key = parts[parts.length - 1]; - for (const part of parts) value = value?.[part]; - result[key] = value; - } - return result; - }); - }; - - globalThis.table = (arr, maxRows = 10) => { - if (!Array.isArray(arr) || arr.length === 0) return '(empty)'; - const items = arr.slice(0, maxRows); - const keys = Object.keys(items[0]); - const header = '| ' + keys.join(' | ') + ' |'; - const sep = '|' + keys.map(() => '---').join('|') + '|'; - const rows = items.map(item => '| ' + keys.map(k => String(item[k] ?? '')).join(' | ') + ' |'); - let result = [header, sep, ...rows].join('\\n'); - if (arr.length > maxRows) result += '\\n... +' + (arr.length - maxRows) + ' more rows'; - return result; - }; - - globalThis.count = (arr, field) => { - if (!Array.isArray(arr)) return {}; - return arr.reduce((acc, item) => { - const key = String(item[field] ?? 'unknown'); - acc[key] = (acc[key] || 0) + 1; - return acc; - }, {}); - }; - - globalThis.sum = (arr, field) => { - if (!Array.isArray(arr)) return 0; - return arr.reduce((acc, item) => acc + (Number(item[field]) || 0), 0); - }; - - globalThis.first = (arr, n = 5) => { - if (!Array.isArray(arr)) return arr; - return arr.slice(0, n); - }; - - parentPort.on('message', async (msg) => { - const { type, data } = msg; - - if (type === 'init') { - const { variables, adapterMethods, globals } = data; - - for (const [key, value] of Object.entries(variables || {})) { - globalThis[key] = value; - } - - for (const [key, value] of Object.entries(globals || {})) { - globalThis[key] = value; - } - - globalThis.adapters = {}; - for (const [adapterName, methods] of Object.entries(adapterMethods)) { - const adapterObj = {}; - for (const methodName of methods) { - adapterObj[methodName] = async (...args) => { - const id = ++callId; - return new Promise((resolve, reject) => { - pendingCalls.set(id, { resolve, reject }); - parentPort.postMessage({ - type: 'adapter_call', - adapter: adapterName, - method: methodName, - args, - id - }); - }); - }; - } - globalThis.adapters[adapterName] = adapterObj; - globalThis[adapterName] = adapterObj; - } - - parentPort.postMessage({ type: 'ready' }); - } - - else if (type === 'adapter_result') { - const { id, result, error } = data; - const pending = pendingCalls.get(id); - if (pending) { - pendingCalls.delete(id); - if (error) pending.reject(new Error(error)); - else pending.resolve(result); - } - } - - else if (type === 'execute') { - try { - const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; - const fn = new AsyncFunction(data.code); - const result = await fn(); - parentPort.postMessage({ type: 'result', success: true, value: result, logs }); - } catch (err) { - parentPort.postMessage({ - type: 'result', - success: false, - error: { name: err.name, message: err.message, stack: err.stack }, - logs - }); - } - } - }); - `; - } - - dispose(): void { - // Workers are created per execution, nothing to dispose - } -} diff --git a/core-web/libs/agentic-tools/src/lib/types.ts b/core-web/libs/agentic-tools/src/lib/types.ts deleted file mode 100644 index 1c01706de89a..000000000000 --- a/core-web/libs/agentic-tools/src/lib/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Configuration for an adapter method parameter - */ -export interface AdapterMethodParameter { - name: string; - type: 'string' | 'number' | 'boolean' | 'object' | 'array'; - description?: string; - required?: boolean; - default?: unknown; -} - -/** - * A method exposed by an adapter - */ -export interface AdapterMethod { - name: string; - description?: string; - parameters: AdapterMethodParameter[]; - execute: (...args: unknown[]) => unknown | Promise; -} - -/** - * A registered adapter instance - */ -export interface Adapter { - name: string; - description?: string; - version: string; - methods: Map; - config?: unknown; -} - -/** - * Configuration for the sandbox execution environment - */ -export interface SandboxConfig { - timeout?: number; - globals?: Record; -} - -/** - * Result from sandbox code execution - */ -export interface SandboxResult { - success: boolean; - value?: T; - error?: { - name: string; - message: string; - stack?: string; - }; - logs: string[]; - executionTime: number; -} - -/** - * Context passed to sandbox execution - */ -export interface ExecutionContext { - adapters: Record unknown | Promise>>; - variables?: Record; -} diff --git a/core-web/libs/sdk/ai/.eslintrc.json b/core-web/libs/sdk/ai/.eslintrc.json new file mode 100644 index 000000000000..814681769346 --- /dev/null +++ b/core-web/libs/sdk/ai/.eslintrc.json @@ -0,0 +1,42 @@ +{ + "extends": ["../../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*", "**/node_modules/**", "node_modules/**"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["src/sandbox/**/*.ts"], + "excludedFiles": ["src/sandbox/**/*.spec.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "../adapter", + "../adapter/*", + "../spec", + "../spec/*", + "../runtime", + "../generated/*" + ], + "message": "@dotcms/ai/sandbox is the generic engine and must not import dotCMS-specific code (adapter, spec, runtime). Keep the generic/dotCMS boundary intact." + } + ] + } + ] + } + } + ] +} diff --git a/core-web/libs/agentic-tools/.gitignore b/core-web/libs/sdk/ai/.gitignore similarity index 100% rename from core-web/libs/agentic-tools/.gitignore rename to core-web/libs/sdk/ai/.gitignore diff --git a/core-web/libs/sdk/ai/README.md b/core-web/libs/sdk/ai/README.md new file mode 100644 index 000000000000..a7f5157a6f24 --- /dev/null +++ b/core-web/libs/sdk/ai/README.md @@ -0,0 +1,137 @@ +# @dotcms/ai + +Every other CMS hands an AI a *fixed menu of tools* — it can only do what the vendor pre-built. `@dotcms/ai` does the opposite: the model writes code, and the runtime runs it in a sandbox against the whole dotCMS API, with auth and policy owned in one place. The ceiling isn't a tool list; it's the API itself. + +You bring whatever drives it — a model, an agent framework, an automation tool like n8n. This is the execution layer beneath them: no LLM inside, it only runs the code, safely. + +It's also the layer dotCMS's own MCP server and first-party agents run on. We ship on it, not just publish it. + +### Governed by construction + +Safety isn't a setting you turn on; it's the shape of the runtime: + +- **Your token never enters the sandbox.** Auth is injected on the host side; the executing code cannot read it. +- **Adapters are the only way out.** Sandbox code reaches the network/host *only* through an adapter you grant — direct `fetch`/`require`/`process.env` are removed. +- **You decide the surface.** An allow-list (or typed `defineAdapter` operations) bounds what any code — model-written or not — can reach. Expose `scan` and `read`; never expose `delete`. + +## Install + +```bash +npm install @dotcms/ai +``` + +## The front door — one runtime, two verbs + +```ts +import { createRuntime } from '@dotcms/ai/runtime'; + +const dotcms = createRuntime({ + url, // dotCMS instance URL + token, // dotCMS auth token — NEVER enters the sandbox + allow, // optional allow-list/policy (string[] of path prefixes, or a predicate) + sessionId, // context-cache + isolation key + includeSpec, // inject the `spec` global for the search use case + timeout // sandbox wall-clock timeout (ms) +}); + +await dotcms.request(opts); // DIRECT — you write the call. No worker. +await dotcms.run(code); // SANDBOXED — a model wrote `code`. +``` + +**The one rule that keeps the surface small:** `request` is the default. `run` is only for code you did **not** write (a model did). If you write the call yourself, you never need `run`. `run(code)` is implemented *as* "spin a worker whose `api.request` forwards to `dotcms.request`" — the two verbs share one adapter, one auth path, one allow-list, one error model, and cannot drift. + +## Package topology — one package, subpaths as seams + +| Subpath | Audience | Contains | Generic? | +|---|---|---|---| +| `@dotcms/ai/runtime` | Most callers — the front door | `createRuntime`, `defineAdapter`, errors | dotCMS-wired | +| `@dotcms/ai/sandbox` | Power users / custom adapters | `createSandbox`, `defineAdapter`, `Executor`, types, errors | **fully generic, lint-enforced** | +| `@dotcms/ai/adapter` | Power users | `dotcmsAdapter`, `requestCore`, context loading + cache | dotCMS-specific | +| `@dotcms/ai/spec` | The search use case | the OpenAPI spec (opt-in; keeps the ~550KB off the default path) | dotCMS-specific | + +`@dotcms/ai` is a pure namespace — there is no bare import; everything is reached through a subpath. It is an **umbrella** for growth: future AI surfaces (RAG, embeddings, custom agents, harness) land as new subpaths under the same package. + +## Custom, typed operations — `defineAdapter` + +Instead of permitting paths on a generic `request`, expose **named operations** an LLM can call by name, with Zod-validated input and a declared output contract. This is the governed path in practice — the model sees `scan`, not `/api/**`: + +```ts +import { defineAdapter, createSandbox } from '@dotcms/ai/sandbox'; +import { z } from 'zod'; + +const a11y = defineAdapter({ + name: 'a11y', + methods: { + scan: { + description: 'Scan a page URL; returns axe findings', + input: z.object({ url: z.string().url() }), + output: z.object({ findings: z.object({ violations: z.array(z.any()) }).loose() }), + handler: ({ url }, { request }) => + request({ method: 'POST', path: '/api/v1/page-scanner/a11y/check', body: { url } }) + } + } +}); + +const sandbox = createSandbox({ + adapters: [a11y], + timeout: 120_000, + request: (opts) => dotcms.request(opts) // host capability; the runtime provides one +}); +await sandbox.run(`return (await a11y.scan({ url: 'https://demo.dotcms.com/' })).findings.violations;`); +``` + +- **`input` is mandatory** — it is the *trust* boundary (args come from model code; validate before the handler runs). +- **`output` is required for any model-facing adapter** — it is the *tool-contract* boundary (the result schema the LLM plans against; becomes the auto-generated tool definition). Use **loose/passthrough** output schemas so a new REST field doesn't break the contract. Adapters *without* `output` are typed as not model-exposable and are withheld from the auto-generated tool descriptions (`describeAdapterForLLM`). + +## Error model + +A single typed hierarchy, surfaced identically from `request()` and `run()` (one `requestCore`): `ValidationError`, `PolicyError`, `HttpError` (carries status + body), `TimeoutError`, `AbortError`, `SandboxError`, `RuntimeError` — all subclasses of `DotCMSError`, each with a stable `code` and a serializable `toJSON()`. The model-facing string an MCP tool builds is *formatting on top of* this model. + +```ts +import { isDotCMSError, HttpError } from '@dotcms/ai/runtime'; +try { await dotcms.request({ path: '/api/v1/site' }); } +catch (e) { if (e instanceof HttpError) console.error(e.status, e.body); } +``` + +## Threat model — capability confinement, NOT adversarial isolation + +The governance above is **capability confinement for trusted code generators** — it stops your own model from doing something it shouldn't, not an attacker from breaking out. + +- **Stops accidental egress:** `fetch`/`XMLHttpRequest`/`WebSocket`/`EventSource`/`sendBeacon` throw; `require` removed; dynamic `import()` is blocked at the source level (so `import('node:fs')`/`import('node:net')` can't re-open host access); `process.env` emptied; worker spawned with `env:{}`. +- **Stops runaway cost:** wall-clock timeout, `resourceLimits` memory/stack caps, and an `AbortSignal` threaded to adapter calls so a timeout aborts in-flight host work. +- **Does NOT stop hostile code.** User code runs via `new AsyncFunction(code)` in the same V8 isolate as the worker harness — hostile code can reach shared globals, and the `import()` block is a source-level guard (not hardened against deliberate obfuscation). The intended threat is "our own model hallucinates a `DELETE` or an infinite loop," not "an attacker submits malicious JS." + +**If you must run genuinely untrusted code, bring your own process/microVM isolation.** + +## Support matrix + +- **Node** ≥ 20, **Bun** (native Web Workers). Both worker backends behave identically. +- **OpenAPI spec ↔ server version:** `@dotcms/ai/spec` is generated from a *specific* dotCMS instance (see "Regenerating the spec"). It is a filtered snapshot, not a live contract — regenerate it against your target server if its REST surface differs from the one you built against. +- **Semver:** subpaths are part of the public API; a breaking change to any subpath is a major. + +## Regenerating the spec + +`src/generated/spec.json` is **build-generated and git-ignored** — it is NOT committed. The +`build`/`test`/`serve` targets run `sdk-ai:generate-spec` automatically (via `dependsOn`), so +you rarely run it by hand; do so only to refresh the local copy or inspect the output. + +```bash +# Defaults to https://demo.dotcms.com/api/openapi.json +pnpm nx run sdk-ai:generate-spec + +# Override with a different instance (URL or local file path): +pnpm nx run sdk-ai:generate-spec -- http://localhost:8080/api/openapi.json +``` + +The script filters the spec to the endpoints in `ALLOWED_PREFIXES` (see `scripts/generate-spec.ts`), +dereferences `$ref`s, and strips response schemas to keep the file small. Because the spec is +regenerated at build time, there is nothing to commit. + +## Commands + +```bash +pnpm nx run sdk-ai:build # Build (ESM + CJS, dual) +pnpm nx run sdk-ai:test # Run tests +pnpm nx run sdk-ai:lint # Lint src + scripts +pnpm nx run sdk-ai:generate-spec -- # Refresh spec.json +``` diff --git a/core-web/libs/sdk/ai/jest.config.ts b/core-web/libs/sdk/ai/jest.config.ts new file mode 100644 index 000000000000..878bed6c101f --- /dev/null +++ b/core-web/libs/sdk/ai/jest.config.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +export default { + displayName: 'sdk-ai', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleFileExtensions: ['ts', 'js', 'html', 'json'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + coverageDirectory: '../../../coverage/libs/sdk/ai', + // The generated spec is data, not code — keep it out of coverage instrumentation. + collectCoverageFrom: ['src/**/*.ts', '!src/generated/**', '!src/**/*.spec.ts'] +}; diff --git a/core-web/libs/sdk/ai/package.json b/core-web/libs/sdk/ai/package.json new file mode 100644 index 000000000000..e21c3530f10c --- /dev/null +++ b/core-web/libs/sdk/ai/package.json @@ -0,0 +1,49 @@ +{ + "name": "@dotcms/ai", + "version": "0.1.0", + "description": "The dotCMS agentic runtime — run model-written or human-written code safely against a dotCMS instance, with auth and policy owned in one place.", + "repository": { + "type": "git", + "url": "git+https://github.com/dotCMS/core.git#main", + "directory": "core-web/libs/sdk/ai" + }, + "dependencies": { + "tslib": "^2.3.0", + "zod": "^4.1.9" + }, + "engines": { + "node": ">=20.0.0" + }, + "exports": { + "./package.json": "./package.json", + "./runtime": "./src/runtime.ts", + "./sandbox": "./src/sandbox/index.ts", + "./adapter": "./src/adapter/index.ts", + "./spec": "./src/spec/index.ts" + }, + "typesVersions": { + "*": { + "runtime": ["./src/runtime.d.ts"], + "sandbox": ["./src/sandbox/index.d.ts"], + "adapter": ["./src/adapter/index.d.ts"], + "spec": ["./src/spec/index.d.ts"] + } + }, + "keywords": [ + "dotCMS", + "CMS", + "AI", + "agent", + "agentic", + "runtime", + "sandbox", + "MCP", + "model-context-protocol" + ], + "author": "dotcms ", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotCMS/core/issues" + }, + "homepage": "https://github.com/dotCMS/core/tree/main/core-web/libs/sdk/ai/README.md" +} diff --git a/core-web/libs/sdk/ai/project.json b/core-web/libs/sdk/ai/project.json new file mode 100644 index 000000000000..1bde0783abec --- /dev/null +++ b/core-web/libs/sdk/ai/project.json @@ -0,0 +1,69 @@ +{ + "name": "sdk-ai", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/sdk/ai/src", + "projectType": "library", + "tags": ["scope:ai"], + "targets": { + "generate-spec": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsx scripts/generate-spec.ts", + "cwd": "libs/sdk/ai" + } + }, + "build": { + "executor": "@nx/rollup:rollup", + "outputs": ["{options.outputPath}"], + "dependsOn": ["generate-spec"], + "options": { + "format": ["esm", "cjs"], + "compiler": "tsc", + "generateExportsField": true, + "additionalEntryPoints": [ + "libs/sdk/ai/src/sandbox/index.ts", + "libs/sdk/ai/src/adapter/index.ts", + "libs/sdk/ai/src/spec/index.ts" + ], + "outputPath": "dist/libs/sdk/ai", + "assets": [ + { + "input": "libs/sdk/ai", + "output": ".", + "glob": "*.md" + } + ], + "main": "libs/sdk/ai/src/runtime.ts", + "tsConfig": "libs/sdk/ai/tsconfig.lib.json" + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/publish.mjs sdk-ai {args.ver} {args.tag}" + }, + "dependsOn": ["build"] + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/libs/sdk/ai" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/sdk/ai/src/**/*.ts", "libs/sdk/ai/scripts/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "dependsOn": ["generate-spec"], + "options": { + "jestConfig": "libs/sdk/ai/jest.config.ts", + "passWithNoTests": true + } + } + } +} diff --git a/core-web/libs/agentic-tools/scripts/generate-spec.ts b/core-web/libs/sdk/ai/scripts/generate-spec.ts similarity index 91% rename from core-web/libs/agentic-tools/scripts/generate-spec.ts rename to core-web/libs/sdk/ai/scripts/generate-spec.ts index 0d356715e6f6..e48a31947d22 100644 --- a/core-web/libs/agentic-tools/scripts/generate-spec.ts +++ b/core-web/libs/sdk/ai/scripts/generate-spec.ts @@ -80,8 +80,26 @@ function matchesPattern(pathKey: string, pattern: string): boolean { return regex.test(pathKey); } +/** + * Resolve the OpenAPI spec source (a URL or local file path), in priority order: + * 1. an explicit CLI arg (`... generate-spec -- `) + * 2. `DOTCMS_SPEC_URL` — env vars are inherited by the `generate-spec` task that `build` + * runs via `dependsOn` (CLI args are NOT), so this is what lets + * `DOTCMS_SPEC_URL=… nx build mcp-server` regenerate from a local instance in one command. + * 3. `${DOTCMS_URL}/api/openapi.json` — convenience: reuse the same instance the runtime targets. + * 4. the demo instance (so CI builds with no env set produce the committed spec). + */ function resolveSpecSource(): string { - return process.argv[2] || DEFAULT_SPEC_URL; + if (process.argv[2]) { + return process.argv[2]; + } + if (process.env.DOTCMS_SPEC_URL) { + return process.env.DOTCMS_SPEC_URL; + } + if (process.env.DOTCMS_URL) { + return `${process.env.DOTCMS_URL.replace(/\/+$/, '')}${DEFAULT_SPEC_PATH}`; + } + return DEFAULT_SPEC_URL; } async function fetchSpec(source: string): Promise { diff --git a/core-web/libs/agentic-tools/src/lib/context-cache.ts b/core-web/libs/sdk/ai/src/adapter/context-cache.ts similarity index 56% rename from core-web/libs/agentic-tools/src/lib/context-cache.ts rename to core-web/libs/sdk/ai/src/adapter/context-cache.ts index 038e0c6151c1..f46e76878233 100644 Binary files a/core-web/libs/agentic-tools/src/lib/context-cache.ts and b/core-web/libs/sdk/ai/src/adapter/context-cache.ts differ diff --git a/core-web/libs/agentic-tools/src/lib/context.ts b/core-web/libs/sdk/ai/src/adapter/context.ts similarity index 99% rename from core-web/libs/agentic-tools/src/lib/context.ts rename to core-web/libs/sdk/ai/src/adapter/context.ts index 34fb26a26171..89a9ba3629e4 100644 --- a/core-web/libs/agentic-tools/src/lib/context.ts +++ b/core-web/libs/sdk/ai/src/adapter/context.ts @@ -1,4 +1,4 @@ -import type { Adapter } from './types'; +import type { Adapter } from '../sandbox/types'; export interface ContentTypeSummary { id: string; diff --git a/core-web/libs/agentic-tools/src/lib/http-client.spec.ts b/core-web/libs/sdk/ai/src/adapter/http-client.spec.ts similarity index 99% rename from core-web/libs/agentic-tools/src/lib/http-client.spec.ts rename to core-web/libs/sdk/ai/src/adapter/http-client.spec.ts index d655d578c537..9fab7accaae5 100644 --- a/core-web/libs/agentic-tools/src/lib/http-client.spec.ts +++ b/core-web/libs/sdk/ai/src/adapter/http-client.spec.ts @@ -1,6 +1,6 @@ import { createApiAdapter, isBinaryResponseEnvelope } from './http-client'; -import type { Adapter, AdapterMethod } from './types'; +import type { Adapter, AdapterMethod } from '../sandbox/types'; /** * A real 1x1 red PNG. Its first byte is 0x89, which is not valid UTF-8 — the diff --git a/core-web/libs/sdk/ai/src/adapter/http-client.ts b/core-web/libs/sdk/ai/src/adapter/http-client.ts new file mode 100644 index 000000000000..83be5e41bb0e --- /dev/null +++ b/core-web/libs/sdk/ai/src/adapter/http-client.ts @@ -0,0 +1,100 @@ +import { + type RequestCallEvent, + type RequestOptions, + type RequestPolicy, + requestCore +} from './request-core'; + +import type { Adapter, AdapterMethod } from '../sandbox/types'; + +export { + type BinaryResponseEnvelope, + type RequestOptions, + type RequestPolicy, + type RequestCallEvent, + isBinaryResponseEnvelope +} from './request-core'; + +export interface ApiAdapterConfig { + dotcmsUrl: string; + authToken: string; + /** Optional policy/allow-list consulted before each request reaches the wire. */ + policy?: RequestPolicy; + /** Aborts in-flight requests when the surrounding execution (e.g. the sandbox) is torn down. */ + signal?: AbortSignal; + /** Observability hook fired around each call (token/sensitive bodies never included). */ + onCall?: (event: RequestCallEvent) => void; +} + +/** + * Create the "api" adapter for making authenticated HTTP calls to dotCMS. + * + * This is the dotCMS door out of the sandbox. The adapter's single `request` method is a + * thin wrapper over the shared {@link requestCore} — so a request driven by the sandboxed + * `api.request` and one driven by the runtime's direct `request()` are byte-for-byte the + * same code path (one auth path, one allow-list, one error model). Auth tokens are injected + * by the host — never exposed to the sandbox. + */ +export function createApiAdapter(config: ApiAdapterConfig): Adapter { + if (!config.dotcmsUrl) { + throw new Error('dotcmsUrl is required'); + } + if (!config.authToken) { + throw new Error('authToken is required'); + } + + const requestMethod: AdapterMethod = { + name: 'request', + description: 'Make an authenticated HTTP request to the dotCMS API', + parameters: [ + { + name: 'options', + type: 'object', + description: 'Request options: { method, path, query, body, formData, headers }', + required: true + } + ], + execute(...args: unknown[]): Promise { + const options = (args[0] || {}) as RequestOptions; + return requestCore(options, { + baseUrl: config.dotcmsUrl, + authToken: config.authToken, + policy: config.policy, + signal: config.signal, + onCall: config.onCall + }); + } + }; + + return { + name: 'api', + description: 'Authenticated HTTP client for dotCMS REST API', + version: '1.0.0', + methods: new Map([['request', requestMethod]]) + }; +} + +/** + * The dotCMS adapter — the building block exported from `@dotcms/ai/adapter`. + * + * Returns both the underlying {@link Adapter} (for the sandbox) and a direct `request` + * function (the no-worker path). A power-user can wrap this value — e.g. with an allow-list — + * before handing it to the runtime, because it is a plain object you can compose. + */ +export interface DotCMSAdapter { + /** The adapter object the sandbox executor consumes (`api.request` inside sandbox code). */ + adapter: Adapter; + /** The direct, no-worker request path — the same shared core the sandbox bridges to. */ + request(options: RequestOptions): Promise; +} + +export function dotcmsAdapter(config: ApiAdapterConfig): DotCMSAdapter { + const adapter = createApiAdapter(config); + // Delegate the direct path to the adapter's own `request` method (which calls requestCore + // with auth bound) — don't rebuild the request context the adapter already holds. + const request = adapter.methods.get('request')?.execute; + return { + adapter, + request: (options: RequestOptions): Promise => Promise.resolve(request?.(options)) + }; +} diff --git a/core-web/libs/sdk/ai/src/adapter/index.ts b/core-web/libs/sdk/ai/src/adapter/index.ts new file mode 100644 index 000000000000..245df56e4346 --- /dev/null +++ b/core-web/libs/sdk/ai/src/adapter/index.ts @@ -0,0 +1,29 @@ +/** + * `@dotcms/ai/adapter` — the dotCMS-specific building blocks: the dotCMS door out of the + * sandbox (`dotcmsAdapter` / `createApiAdapter`), the shared request core, instance-context + * loading, and the context cache. dotCMS-wired by design (the generic engine is `/sandbox`). + */ + +export { createApiAdapter, dotcmsAdapter, isBinaryResponseEnvelope } from './http-client'; +export type { + ApiAdapterConfig, + DotCMSAdapter, + BinaryResponseEnvelope, + RequestOptions, + RequestPolicy, + RequestCallEvent +} from './http-client'; + +export { requestCore } from './request-core'; +export type { RequestCoreContext } from './request-core'; + +export { loadDotCMSContext } from './context'; +export type { + DotCMSContext, + ContentTypeSummary, + SiteSummary, + LanguageSummary, + CurrentUserSummary +} from './context'; + +export { ContextCache, getSharedContextCache } from './context-cache'; diff --git a/core-web/libs/sdk/ai/src/adapter/request-core.ts b/core-web/libs/sdk/ai/src/adapter/request-core.ts new file mode 100644 index 000000000000..5a7fb96d4062 --- /dev/null +++ b/core-web/libs/sdk/ai/src/adapter/request-core.ts @@ -0,0 +1,433 @@ +import { + AbortError, + HttpError, + PolicyError, + RuntimeError, + ValidationError +} from '../sandbox/errors'; + +/** + * The single shared request core. + * + * "One adapter, one auth path, one allow-list" is only true if BOTH verbs of the runtime + * route through one function that owns *all* of: path/method validation, the policy check, + * auth injection, `fetch`, response decoding (incl. the binary envelope), the error model, + * and abort/timeout. `runtime.request()` calls `requestCore` directly; the sandbox's + * `api.request` is a thin worker→host bridge to this same function. Neither verb may add + * behavior the other lacks — that is the contract, not an aspiration. + */ + +interface FileFieldDescriptor { + name: string; // filename, e.g. "logo.png" + type: string; // MIME type, e.g. "image/png" + data?: string; // base64-encoded content (mutually exclusive with url) + url?: string; // URL to fetch content from (mutually exclusive with data) +} + +type FormDataFieldValue = string | FileFieldDescriptor; + +export interface RequestOptions { + method?: string; + path: string; + query?: Record; + body?: unknown; + formData?: Record; + headers?: Record; + // How to decode the response body. Defaults to content-type auto-detection: + // JSON content types are parsed; textual types come back as strings; everything + // else (images, fonts, etc.) comes back as a base64 binary envelope so the bytes + // survive the JSON.stringify boundary in the consuming sandbox. Set 'base64' to + // force the binary path regardless of the declared content-type. + responseType?: 'auto' | 'base64'; +} + +/** + * A policy hook consulted before a request reaches the wire. Return `false` (or throw) to + * reject. The single place a caller-owned allow-list plugs in — both verbs honor it because + * both flow through `requestCore`. + */ +export type RequestPolicy = (req: { method: string; path: string }) => boolean | void; + +/** Host-side context the request core needs. Auth lives here, never in the caller's code. */ +export interface RequestCoreContext { + baseUrl: string; + authToken: string; + /** Optional policy/allow-list consulted before every call. */ + policy?: RequestPolicy; + /** Aborts the in-flight fetch when the surrounding execution (e.g. sandbox) is torn down. */ + signal?: AbortSignal; + /** Observability hook — fired around each call with redacted metadata (never the token). */ + onCall?: (event: RequestCallEvent) => void; +} + +export interface RequestCallEvent { + method: string; + path: string; + status?: number; + durationMs: number; + ok: boolean; + errorCode?: string; +} + +function isFileDescriptor(value: unknown): value is FileFieldDescriptor { + const obj = value as Record; + return ( + typeof value === 'object' && + value !== null && + typeof obj.name === 'string' && + typeof obj.type === 'string' && + (typeof obj.data === 'string' || typeof obj.url === 'string') + ); +} + +// Max size (bytes) for a remote file fetched via a `url` descriptor — guards +// against memory exhaustion from an attacker-controlled endpoint. +const MAX_REMOTE_FILE_BYTES = 25 * 1024 * 1024; // 25 MB +// Timeout (ms) for the remote fetch, so a slow/hanging URL cannot stall the host. +const REMOTE_FILE_FETCH_TIMEOUT_MS = 15000; + +// Max size (bytes) for a binary response body returned as a base64 envelope. +// base64 inflates the payload ~33% and the whole thing flows through +// JSON.stringify in the consuming sandbox, so large assets can blow up memory +// and model context — cap it like the upload side already does. +const MAX_BINARY_RESPONSE_BYTES = 25 * 1024 * 1024; // 25 MB + +/** + * Tagged envelope returned for non-textual response bodies. The raw bytes are + * base64-encoded so they survive the `JSON.stringify` serialization boundary in + * the consuming sandbox intact — `response.text()` would corrupt any non-UTF-8 byte + * into the U+FFFD replacement char. Consumers detect `__dotcmsBinary` and decode. + */ +export interface BinaryResponseEnvelope { + __dotcmsBinary: true; + contentType: string; + base64: string; + byteLength: number; +} + +/** + * Type guard for the binary response envelope. Consumers can use this to detect + * a binary body and `Buffer.from(envelope.base64, 'base64')` to recover the bytes. + */ +export function isBinaryResponseEnvelope(value: unknown): value is BinaryResponseEnvelope { + if (typeof value !== 'object' || value === null) { + return false; + } + const obj = value as Record; + return ( + obj.__dotcmsBinary === true && + typeof obj.base64 === 'string' && + typeof obj.contentType === 'string' && + typeof obj.byteLength === 'number' + ); +} + +/** + * Decide whether a content-type should be decoded as text. Everything that is + * not JSON (handled separately) and not in this textual set is treated as + * binary and returned as a base64 envelope. + */ +function isTextualContentType(contentType: string): boolean { + const ct = contentType.toLowerCase(); + return ( + ct.startsWith('text/') || + ct.includes('application/xml') || + ct.includes('application/javascript') || + ct.includes('application/x-www-form-urlencoded') || + ct.includes('+json') || + ct.includes('+xml') + ); +} + +/** + * Read a response body as a base64 binary envelope, enforcing the size cap. + */ +async function readBinaryResponse( + response: Response, + contentType: string +): Promise { + // Reject early via Content-Length so we never buffer an oversized body into + // memory. The header can be absent or lie, so the post-read check below stays + // as the authoritative backstop. + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_BINARY_RESPONSE_BYTES) { + throw new ValidationError( + `Binary response (${declaredLength} bytes) exceeds the ${MAX_BINARY_RESPONSE_BYTES}-byte limit` + ); + } + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MAX_BINARY_RESPONSE_BYTES) { + throw new ValidationError( + `Binary response (${buffer.byteLength} bytes) exceeds the ${MAX_BINARY_RESPONSE_BYTES}-byte limit` + ); + } + return { + __dotcmsBinary: true, + contentType, + base64: Buffer.from(buffer).toString('base64'), + byteLength: buffer.byteLength + }; +} + +/** + * Validates a user-supplied file URL before fetching it, to mitigate SSRF. + * Sandbox code can put any string in `desc.url`, and the fetch runs on the + * host with host network access — so we restrict it to public http(s) targets + * and reject loopback, link-local, and private (RFC 1918 / unique-local) hosts. + * + * KNOWN LIMITATION (DNS rebinding): this validates the literal hostname/IP in the URL, not + * the address it ultimately resolves to. A hostname that resolves to a private/link-local/ + * metadata IP at fetch time bypasses this check. `redirect: 'error'` blocks the redirect + * vector, but not rebinding. This matches the package threat model — capability confinement + * for trusted code generators, not adversarial isolation. A consumer accepting genuinely + * untrusted file URLs must front this with a hardened fetcher that resolves and pins the IP. + */ +function assertSafeRemoteUrl(rawUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new ValidationError(`Invalid file URL: "${rawUrl}"`); + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new ValidationError(`File URL must use http(s); got "${parsed.protocol}"`); + } + + const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + + // Block obvious metadata / loopback hostnames. + if ( + host === 'localhost' || + host.endsWith('.localhost') || + host === 'metadata.google.internal' + ) { + throw new ValidationError(`File URL host "${host}" is not allowed`); + } + + // IPv4 private / loopback / link-local / unspecified ranges. + const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const [a, b] = ipv4.slice(1).map(Number); + const isPrivate = + a === 0 || // 0.0.0.0/8 (unspecified) + a === 10 || // 10.0.0.0/8 + a === 127 || // 127.0.0.0/8 (loopback) + (a === 169 && b === 254) || // 169.254.0.0/16 (link-local, incl. cloud metadata) + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 + (a === 192 && b === 168); // 192.168.0.0/16 + if (isPrivate) { + throw new ValidationError(`File URL resolves to a private/loopback address: "${host}"`); + } + } + + // IPv6 loopback (::1), unspecified (::) and unique-local (fc00::/7) / link-local (fe80::/10). + if (host.includes(':')) { + if (host === '::1' || host === '::' || /^f[cd]/.test(host) || /^fe[89ab]/.test(host)) { + throw new ValidationError( + `File URL resolves to a private/loopback IPv6 address: "${host}"` + ); + } + } + + return parsed; +} + +async function resolveFileDescriptor( + desc: FileFieldDescriptor, + signal?: AbortSignal +): Promise { + if (desc.data) { + const binary = Buffer.from(desc.data, 'base64'); + return new Blob([new Uint8Array(binary)], { type: desc.type }); + } + if (desc.url) { + const safeUrl = assertSafeRemoteUrl(desc.url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REMOTE_FILE_FETCH_TIMEOUT_MS); + // If the surrounding execution is aborted, also abort this nested fetch. + const onParentAbort = () => controller.abort(); + signal?.addEventListener('abort', onParentAbort, { once: true }); + try { + const response = await fetch(safeUrl.toString(), { + signal: controller.signal, + redirect: 'error' // a redirect could escape the SSRF guard + }); + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + `Failed to fetch file from "${desc.url}"` + ); + } + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MAX_REMOTE_FILE_BYTES) { + throw new ValidationError( + `Remote file "${desc.url}" exceeds the ${MAX_REMOTE_FILE_BYTES}-byte limit` + ); + } + return new Blob([buffer], { type: desc.type }); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onParentAbort); + } + } + throw new ValidationError( + `File descriptor "${desc.name}" must have either "data" (base64) or "url"` + ); +} + +/** + * Execute one authenticated request against dotCMS. This is the function both verbs share. + * Auth is injected here, on the host side; the executing code never sees the token. + */ +export async function requestCore( + options: RequestOptions, + ctx: RequestCoreContext +): Promise { + const startedAt = performance.now(); + const method = (options.method || 'GET').toUpperCase(); + const urlPath = options.path || '/'; + + const emit = (status: number | undefined, ok: boolean, errorCode?: string) => { + ctx.onCall?.({ + method, + path: urlPath, + status, + ok, + errorCode, + durationMs: performance.now() - startedAt + }); + }; + + try { + if (!ctx.baseUrl) { + throw new RuntimeError('dotcmsUrl is required'); + } + if (!ctx.authToken) { + throw new RuntimeError('authToken is required'); + } + + // Validate that the path is a relative path and cannot override the base URL. + if (!urlPath.startsWith('/')) { + throw new ValidationError("options.path must be a relative path starting with '/'"); + } + // Explicitly reject protocol-relative URLs like "//attacker.example/path". + if (urlPath.startsWith('//')) { + throw new ValidationError('options.path must not be a protocol-relative URL'); + } + // Reject values that look like they start with a URL scheme (e.g. "http:", "https:"). + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(urlPath)) { + throw new ValidationError('options.path must not be an absolute URL'); + } + + // Policy / allow-list check — before anything touches the wire. + if (ctx.policy) { + let allowed: boolean | void; + try { + allowed = ctx.policy({ method, path: urlPath }); + } catch (err) { + throw new PolicyError( + err instanceof Error ? err.message : String(err), + method, + urlPath + ); + } + if (allowed === false) { + throw new PolicyError( + `Request ${method} ${urlPath} rejected by policy`, + method, + urlPath + ); + } + } + + // Already aborted before we even start? + if (ctx.signal?.aborted) { + throw new AbortError(`Request ${method} ${urlPath} was aborted before it started`); + } + + // Build URL with query params. + const url = new URL(urlPath, ctx.baseUrl); + if (options.query) { + for (const [key, value] of Object.entries(options.query)) { + url.searchParams.set(key, String(value)); + } + } + + // Build headers — auth token injected here, never in sandbox. + const headers: Record = { + Accept: 'application/json, */*;q=0.1', + Origin: new URL(ctx.baseUrl).origin, + ...options.headers, + Authorization: `Bearer ${ctx.authToken}` // always last — cannot be overridden + }; + + const fetchOptions: RequestInit = { method, headers, signal: ctx.signal }; + + if (options.formData && options.body) { + throw new ValidationError("Cannot specify both 'body' and 'formData'"); + } + + if (options.formData && method !== 'GET' && method !== 'HEAD') { + const form = new FormData(); + for (const [fieldName, fieldValue] of Object.entries(options.formData)) { + if (typeof fieldValue === 'string') { + form.append(fieldName, fieldValue); + } else if (isFileDescriptor(fieldValue)) { + const blob = await resolveFileDescriptor(fieldValue, ctx.signal); + form.append(fieldName, blob, fieldValue.name); + } else { + throw new ValidationError( + `Invalid formData field "${fieldName}": must be a string or { name, type, data|url }` + ); + } + } + // Do NOT set Content-Type — fetch() auto-generates it with the multipart boundary. + delete headers['Content-Type']; + fetchOptions.body = form; + } else if (options.body && method !== 'GET' && method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + fetchOptions.body = JSON.stringify(options.body); + } + + const response = await fetch(url.toString(), fetchOptions); + + const contentType = response.headers.get('content-type') || ''; + + // On error, always read the body as text regardless of the declared + // content-type — dotCMS errors come back as HTML/text and we want a + // readable message, not a base64 envelope of the error page. + if (!response.ok) { + const errorBody = await response.text(); + throw new HttpError(response.status, response.statusText, errorBody); + } + + const forceBinary = options.responseType === 'base64'; + + let result: unknown; + if (!forceBinary && contentType.includes('application/json')) { + result = await response.json(); + } else if (!forceBinary && isTextualContentType(contentType)) { + result = await response.text(); + } else { + // Non-JSON, non-textual (or explicitly requested): return a base64 + // envelope so the raw bytes survive JSON.stringify intact. + result = await readBinaryResponse(response, contentType); + } + + emit(response.status, true); + return result; + } catch (err) { + // Normalize fetch's AbortError (a DOMException with name "AbortError") into our typed one. + if (err && typeof err === 'object' && (err as { name?: string }).name === 'AbortError') { + const aborted = new AbortError(`Request ${method} ${urlPath} was aborted`); + emit(undefined, false, aborted.code); + throw aborted; + } + const code = (err as { code?: string }).code; + const status = (err as HttpError).status; + emit(typeof status === 'number' ? status : undefined, false, code); + throw err; + } +} diff --git a/core-web/libs/sdk/ai/src/runtime.spec.ts b/core-web/libs/sdk/ai/src/runtime.spec.ts new file mode 100644 index 000000000000..865287355ac3 --- /dev/null +++ b/core-web/libs/sdk/ai/src/runtime.spec.ts @@ -0,0 +1,150 @@ +import { createRuntime } from './runtime'; +import { HttpError, PolicyError } from './sandbox/errors'; + +/** Build a minimal JSON Response stub. */ +function jsonResponse(body: unknown, init?: { ok?: boolean; status?: number }): Response { + const ok = init?.ok ?? true; + const status = init?.status ?? 200; + return { + ok, + status, + statusText: ok ? 'OK' : 'Error', + headers: { + get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null) + }, + json: async () => body, + text: async () => JSON.stringify(body) + } as unknown as Response; +} + +describe('createRuntime.request (direct, no worker)', () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + it('requires url and token', () => { + expect(() => createRuntime({ url: '', token: 't' })).toThrow(/url/); + expect(() => createRuntime({ url: 'https://x', token: '' })).toThrow(/token/); + }); + + it('injects the bearer token on the host side and returns parsed JSON', async () => { + fetchMock.mockResolvedValue(jsonResponse({ entity: [{ id: '1' }] })); + const dotcms = createRuntime({ url: 'https://demo.dotcms.com', token: 'secret-tok' }); + + const result = await dotcms.request({ path: '/api/v1/site' }); + + expect(result).toEqual({ entity: [{ id: '1' }] }); + const [, init] = fetchMock.mock.calls[0]; + expect((init.headers as Record).Authorization).toBe('Bearer secret-tok'); + }); + + it('maps a non-2xx response to a typed HttpError', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: { get: () => 'text/html' }, + text: async () => 'nope' + } as unknown as Response); + const dotcms = createRuntime({ url: 'https://demo.dotcms.com', token: 't' }); + + await expect(dotcms.request({ path: '/api/v1/missing' })).rejects.toBeInstanceOf(HttpError); + }); + + it('rejects a call that fails the allow-list with a PolicyError, before any fetch', async () => { + const dotcms = createRuntime({ + url: 'https://demo.dotcms.com', + token: 't', + allow: ['/api/v1/site'] // only sites allowed + }); + + await expect(dotcms.request({ path: '/api/v1/contenttype' })).rejects.toBeInstanceOf( + PolicyError + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('allows a call whose path matches an allow-list prefix', async () => { + fetchMock.mockResolvedValue(jsonResponse({ ok: true })); + const dotcms = createRuntime({ + url: 'https://demo.dotcms.com', + token: 't', + allow: ['/api/v1/site'] + }); + + await expect(dotcms.request({ path: '/api/v1/site/123' })).resolves.toEqual({ ok: true }); + }); + + it('fires the onCall observability hook without leaking the token', async () => { + fetchMock.mockResolvedValue(jsonResponse({ ok: true })); + const events: unknown[] = []; + const dotcms = createRuntime({ + url: 'https://demo.dotcms.com', + token: 'super-secret', + onCall: (e) => events.push(e) + }); + + await dotcms.request({ path: '/api/v1/site' }); + + expect(events).toHaveLength(1); + const serialized = JSON.stringify(events[0]); + expect(serialized).not.toContain('super-secret'); + expect(serialized).toContain('/api/v1/site'); + }); + + it('passes a caller-supplied AbortSignal through the direct request path', async () => { + const controller = new AbortController(); + // fetch that rejects only when its signal aborts (a hanging request that honors abort). + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + ); + }) + ); + const dotcms = createRuntime({ url: 'https://demo.dotcms.com', token: 't' }); + + const p = dotcms.request({ path: '/api/v1/site' }, { signal: controller.signal }); + controller.abort(); + await expect(p).rejects.toMatchObject({ code: 'ABORT' }); + }); +}); + +describe('createRuntime.run — context-load timeout', () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + it('does not hang when context loading stalls — the run timeout aborts the load', async () => { + let aborts = 0; + // Every context fetch hangs until its abort signal fires (a stalled instance). + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + aborts++; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }) + ); + const timeout = 150; + const dotcms = createRuntime({ url: 'https://demo.dotcms.com', token: 't', timeout }); + + // The run must RESOLVE (not hang) — the load timeout aborts the stalled context fetch. + // The loaders degrade to empty context on abort, so the trivial body then runs fine. + const start = Date.now(); + const result = await dotcms.run(`return 1;`); + const elapsed = Date.now() - start; + + expect(aborts).toBeGreaterThan(0); // the stalled load WAS aborted + expect(elapsed).toBeLessThan(2000); // resolved promptly, did not hang + expect(result.value).toBe(1); + }, 5000); +}); diff --git a/core-web/libs/sdk/ai/src/runtime.ts b/core-web/libs/sdk/ai/src/runtime.ts new file mode 100644 index 000000000000..0c704d33811a --- /dev/null +++ b/core-web/libs/sdk/ai/src/runtime.ts @@ -0,0 +1,236 @@ +import { ContextCache } from './adapter/context-cache'; +import { createApiAdapter } from './adapter/http-client'; +import { + type RequestCallEvent, + type RequestOptions, + type RequestPolicy, + requestCore +} from './adapter/request-core'; +import { serializeError } from './sandbox/errors'; +import { Executor } from './sandbox/executor'; + +import type { DotCMSContext } from './adapter/context'; +import type { SandboxResult } from './sandbox/types'; + +/** + * Policy controlling which requests are permitted. Either a list of allowed path prefixes + * (a request is allowed if its path starts with any entry) or a predicate consulted per call. + * Both verbs honor it, because both flow through the one shared request core. + */ +export type RuntimeAllow = string[] | RequestPolicy; + +export interface DotCMSRuntimeConfig { + /** dotCMS instance URL. */ + url: string; + /** Server-side token. NEVER enters the sandbox — injected host-side on every call. */ + token: string; + /** Optional allow-list / policy applied to every request. */ + allow?: RuntimeAllow; + /** Context-cache + isolation key. Defaults to `__default__`. */ + sessionId?: string; + /** Inject the OpenAPI `spec` global into `run(code)` (the search use case). */ + includeSpec?: boolean; + /** Sandbox wall-clock timeout (ms) for `run(code)`. Defaults to 15000. */ + timeout?: number; + /** Observability hook fired around each request (token/sensitive bodies never logged). */ + onCall?: (event: RequestCallEvent) => void; + /** Error handler for instance-context load failures (per loader). */ + onContextError?: (label: string, error: unknown) => void; + /** + * Whether to leak stack traces (which can contain host paths) to executed/model code. + * Defaults to false — stacks are withheld from `run(code)` results. + */ + includeStacks?: boolean; +} + +export interface DotCMSRuntime { + /** + * DIRECT — you write the call. No worker. Use this whenever you author the call yourself. + * Routes through the same shared request core as `run`. Pass `opts.signal` to make the + * call abortable (e.g. to wrap it in your own timeout) — the direct path has no surrounding + * timeout of its own. + */ + request(options: RequestOptions, opts?: { signal?: AbortSignal }): Promise; + /** + * SANDBOXED — runs `code` you did NOT write (a model did) in a confined worker whose + * `api.request` forwards to the same request core. Returns the structured sandbox result. + */ + run(code: string): Promise>; + /** Load (or read cached) instance context for this runtime's session+url. */ + loadContext(): Promise; +} + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_SESSION_ID = '__default__'; + +/** Normalize the `allow` config into the policy predicate the request core understands. */ +function toPolicy(allow: RuntimeAllow | undefined): RequestPolicy | undefined { + if (!allow) return undefined; + if (typeof allow === 'function') return allow; + const prefixes = allow; + return ({ path }) => prefixes.some((prefix) => path.startsWith(prefix)); +} + +/** + * The front door. One runtime, two verbs. + * + * `request` is the default — use it when you write the call. `run` is only for code you did + * not write (a model did). `run(code)` is implemented *as* "spin a worker whose `api.request` + * forwards to this runtime's request core", so the two verbs cannot drift: one is built on + * the other, sharing one adapter, one auth path, one allow-list, one error model. + * + * Note: this is an execution runtime, not an agent. There is no LLM, no inference, no + * prompting inside it. The agent is what the *user* builds on top. + */ +export function createRuntime(config: DotCMSRuntimeConfig): DotCMSRuntime { + if (!config.url) throw new Error('createRuntime: `url` is required'); + if (!config.token) throw new Error('createRuntime: `token` is required'); + + const sessionId = config.sessionId ?? DEFAULT_SESSION_ID; + const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + const policy = toPolicy(config.allow); + + // The runtime OWNS its context cache instance (keyed on sessionId+url internally), so two + // runtimes for different instances never collide on a shared module singleton. + const contextCache = new ContextCache({ onError: config.onContextError }); + + // Single source of the adapter/request-core wiring — url→baseUrl/dotcmsUrl, token→authToken, + // policy, and onCall live in ONE place so `request`, `run`, and `loadContext` can't drift + // (the design's "one auth path"). Only the per-execution abort `signal` varies. + const adapterConfig = (signal?: AbortSignal) => ({ + dotcmsUrl: config.url, + authToken: config.token, + policy, + signal, + onCall: config.onCall + }); + + const loadContext = (signal?: AbortSignal): Promise => + contextCache.get(sessionId, config.url, createApiAdapter(adapterConfig(signal))); + + const request = (options: RequestOptions, opts?: { signal?: AbortSignal }): Promise => + requestCore(options, { + baseUrl: config.url, + authToken: config.token, + policy, + signal: opts?.signal, + onCall: config.onCall + }); + + async function run(code: string): Promise> { + // One AbortController per execution: when the sandbox times out (or tears down), + // we abort it, which propagates through the adapter's signal to any in-flight fetch. + const controller = new AbortController(); + + const adapter = createApiAdapter(adapterConfig(controller.signal)); + + // dotCMS instance context is THE defining feature of a dotCMS runtime, so the + // runtime loads and injects it — absorbing the per-tool wiring consumers used to repeat. + // Context loading is covered by the SAME timeout+abort as the sandbox run, so a hanging + // context API request can't make run() hang past `timeout` (the load shares the + // controller, whose signal the adapter already carries into the fetch). + const loadTimer = setTimeout(() => controller.abort(), timeout); + let context: DotCMSContext; + try { + context = await contextCache.get(sessionId, config.url, adapter); + } catch (err) { + return { + success: false, + error: serializeError(err), + logs: [], + executionTime: 0 + }; + } finally { + clearTimeout(loadTimer); + } + + const variables: Record = { + contentTypes: context.contentTypes, + sites: context.sites, + languages: context.languages, + currentUser: context.currentUser + }; + if (config.includeSpec) { + // Dynamic import so the ~550KB generated spec is only pulled in by consumers that + // actually opt into it — a bare `@dotcms/ai/runtime` import never drags in the spec. + const { getSpec } = await import('./spec/spec'); + variables.spec = getSpec(); + } + + const executor = new Executor({ + config: { + adapters: [adapter], + sandbox: { + timeout, + // A sandbox teardown (timeout, error, completion) aborts in-flight host work. + onTeardown: () => controller.abort() + } + } + }); + + const result = await executor.execute(code, { + adapters: ['api'], + variables + }); + + // Withhold host stack traces from executed code unless explicitly opted in. + if (!config.includeStacks && result.error?.stack) { + delete result.error.stack; + } + + return result; + } + + return { + request, + run, + loadContext: () => loadContext() + }; +} + +// ---- Re-exports a runtime consumer wants alongside the front door -------------------- +// (errors to branch on, the result shape, the binary-response helper, the typed builder, +// and the instance-context types injected into `run(code)`). Kept here so callers of +// `@dotcms/ai/runtime` don't have to reach into the lower subpaths for the common cases. + +// The typed builder for custom, schema-validated, model-facing operations. +export { defineAdapter, describeAdapterForLLM } from './sandbox/define-adapter'; +export type { + AdapterContext, + AdapterDef, + AdapterMethodDef, + DefinedAdapter +} from './sandbox/define-adapter'; + +// The one typed error hierarchy, surfaced identically from `request()` and `run()`. +export { + DotCMSError, + ValidationError, + PolicyError, + HttpError, + TimeoutError, + AbortError, + SandboxError, + RuntimeError, + isDotCMSError, + serializeError +} from './sandbox/errors'; +export type { DotCMSErrorCode, SerializedDotCMSError } from './sandbox/errors'; + +// Result shape returned by `run()`, and the binary-response helpers callers need to decode it. +export type { SandboxResult, SandboxResultError } from './sandbox/types'; +export { isBinaryResponseEnvelope } from './adapter/request-core'; +export type { + BinaryResponseEnvelope, + RequestOptions, + RequestCallEvent +} from './adapter/request-core'; + +// Instance-context types injected into `run(code)` as globals. +export type { + DotCMSContext, + ContentTypeSummary, + SiteSummary, + LanguageSummary, + CurrentUserSummary +} from './adapter/context'; diff --git a/core-web/libs/sdk/ai/src/sandbox/bun-worker.ts b/core-web/libs/sdk/ai/src/sandbox/bun-worker.ts new file mode 100644 index 000000000000..b35e95fff64c --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/bun-worker.ts @@ -0,0 +1,157 @@ +import { serializeError } from './errors'; +import { BUN_WORKER_CODE, DEFAULT_RESOURCE_LIMITS } from './worker-harness'; + +import type { ISandbox } from './interface'; +import type { + AdapterCallMessage, + ExecutionContext, + ResolvedSandboxConfig, + ResultMessage, + SandboxConfig, + SandboxResult +} from './types'; + +const DEFAULT_CONFIG: ResolvedSandboxConfig = { + timeout: 5000, + globals: {}, + // Kept for config parity with the Node backend; Bun's Web Worker cannot enforce these + // (no `resourceLimits` option), so they are advisory here — documented, not silently dropped. + resourceLimits: DEFAULT_RESOURCE_LIMITS +}; + +export class BunWorkerSandbox implements ISandbox { + private config: ResolvedSandboxConfig; + + constructor(config?: SandboxConfig) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + getConfig(): SandboxConfig { + return { ...this.config }; + } + + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + async execute(code: string, context: ExecutionContext): Promise> { + const startTime = performance.now(); + + return new Promise((resolve) => { + const adapterMethods: Record = {}; + for (const [name, methods] of Object.entries(context.adapters)) { + adapterMethods[name] = Object.keys(methods); + } + + const workerCode = BUN_WORKER_CODE; + + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + + let resolved = false; + const cleanup = () => { + if (!resolved) { + resolved = true; + worker.terminate(); + URL.revokeObjectURL(url); + // Let the host abort any in-flight adapter work (threaded AbortSignal). + this.config.onTeardown?.(); + } + }; + + const timeoutId = setTimeout(() => { + if (!resolved) { + cleanup(); + resolve({ + success: false, + error: { + name: 'TimeoutError', + code: 'TIMEOUT', + message: `Execution timed out after ${this.config.timeout}ms` + }, + logs: [], + executionTime: performance.now() - startTime + }); + } + }, this.config.timeout); + + worker.onmessage = async (event: MessageEvent) => { + const { type, ...data } = event.data as { type: string } & Record; + + if (type === 'ready') { + worker.postMessage({ type: 'execute', data: { code } }); + } else if (type === 'adapter_call') { + const { adapter, method, args, id } = data as unknown as AdapterCallMessage; + // Posting to a worker that was already terminated (e.g. by a + // timeout while this adapter call was in flight) throws and + // would escape as an unhandled rejection, crashing the host. + const postResult = (payload: Record) => { + if (resolved) { + return; + } + try { + worker.postMessage({ type: 'adapter_result', data: payload }); + } catch { + /* worker gone — nothing to deliver the result to */ + } + }; + try { + const adapterObj = context.adapters[adapter]; + if (!adapterObj || !adapterObj[method]) { + throw new Error(`Adapter method not found: ${adapter}.${method}`); + } + const result = await (adapterObj[method] as (...a: unknown[]) => unknown)( + ...args + ); + postResult({ id, result }); + } catch (err) { + // Send the serialized error (typed shape round-trips); drop `stack` + // so host stacks don't cross into the sandbox. + const error = serializeError(err); + delete error.stack; + postResult({ id, error }); + } + } else if (type === 'result') { + clearTimeout(timeoutId); + cleanup(); + const r = data as unknown as ResultMessage; + resolve({ + success: r.success, + value: r.value as T, + error: r.error, + logs: r.logs || [], + executionTime: performance.now() - startTime + }); + } + }; + + worker.onerror = (error: ErrorEvent) => { + clearTimeout(timeoutId); + cleanup(); + resolve({ + success: false, + error: { + name: 'WorkerError', + code: 'RUNTIME', + message: error.message || 'Unknown worker error' + }, + logs: [], + executionTime: performance.now() - startTime + }); + }; + + worker.postMessage({ + type: 'init', + data: { + variables: context.variables || {}, + adapterMethods, + globals: this.config.globals + } + }); + }); + } + dispose(): void { + // Workers are created per execution, nothing to dispose + } +} diff --git a/core-web/libs/sdk/ai/src/sandbox/define-adapter.spec.ts b/core-web/libs/sdk/ai/src/sandbox/define-adapter.spec.ts new file mode 100644 index 000000000000..83b4399ac41c --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/define-adapter.spec.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { + type AdapterContext, + defineAdapter, + describeAdapterForLLM, + isDefinedAdapter +} from './define-adapter'; +import { ValidationError } from './errors'; + +const ctx: AdapterContext = { + request: async (opts) => ({ echoed: opts }) +}; + +describe('defineAdapter', () => { + const a11y = defineAdapter({ + name: 'a11y', + methods: { + scan: { + description: 'Scan a page URL', + input: z.object({ url: z.url() }), + output: z.object({ echoed: z.object({}).loose() }).loose(), + handler: ({ url }, { request }) => + request({ + method: 'POST', + path: '/api/v1/page-scanner/a11y/check', + body: { url } + }) + } + } + }); + + it('produces a plain Adapter usable by the executor', () => { + expect(isDefinedAdapter(a11y)).toBe(true); + expect(a11y.name).toBe('a11y'); + expect(a11y.methods.has('scan')).toBe(true); + }); + + it('validates input before the handler runs', async () => { + const bound = a11y.withContext(ctx); + const scan = bound.methods.get('scan'); + await expect(scan?.execute({ url: 'not-a-url' })).rejects.toBeInstanceOf(ValidationError); + }); + + it('passes validated input to the handler and validates output', async () => { + const bound = a11y.withContext(ctx); + const scan = bound.methods.get('scan'); + const result = await scan?.execute({ url: 'https://demo.dotcms.com/' }); + expect(result).toEqual({ + echoed: { + method: 'POST', + path: '/api/v1/page-scanner/a11y/check', + body: { url: 'https://demo.dotcms.com/' } + } + }); + }); + + it('throws if used without a bound context', async () => { + const scan = a11y.methods.get('scan'); + await expect(scan?.execute({ url: 'https://demo.dotcms.com/' })).rejects.toBeInstanceOf( + ValidationError + ); + }); + + it('marks an adapter with output schemas as model-exposable', () => { + expect(a11y.modelExposable).toBe(true); + }); + + it('marks an adapter missing an output schema as not model-exposable', () => { + const internal = defineAdapter({ + name: 'internal', + methods: { + plumb: { + description: 'internal only', + input: z.object({ x: z.number() }), + handler: ({ x }) => x * 2 + } + } + }); + expect(internal.modelExposable).toBe(false); + // ...and the LLM description withholds the output-less method. + expect(describeAdapterForLLM(internal)).toEqual([]); + }); + + it('describes model-facing methods as tool definitions', () => { + const tools = describeAdapterForLLM(a11y); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('a11y.scan'); + expect(tools[0].description).toBe('Scan a page URL'); + expect(tools[0].inputSchema).toBeDefined(); + expect(tools[0].outputSchema).toBeDefined(); + }); +}); diff --git a/core-web/libs/sdk/ai/src/sandbox/define-adapter.ts b/core-web/libs/sdk/ai/src/sandbox/define-adapter.ts new file mode 100644 index 000000000000..8f82b2bdcdc3 --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/define-adapter.ts @@ -0,0 +1,214 @@ +import { z } from 'zod'; + +import { ValidationError } from './errors'; + +import type { Adapter, AdapterMethod } from './types'; + +/** + * The host capabilities the runtime injects into every adapter handler. `request` is the + * shared request core with auth already bound — a handler reaches dotCMS through it, never + * through a free-floating global. `signal` aborts in-flight work when the sandbox times out. + */ +export interface AdapterContext { + request: (opts: RequestLike) => Promise; + signal?: AbortSignal; +} + +/** + * Minimal request shape an adapter handler passes to `ctx.request`. Kept structural (not a + * hard import of the dotCMS RequestOptions) so `/sandbox` stays free of dotCMS specifics. + */ +export interface RequestLike { + method?: string; + path: string; + query?: Record; + body?: unknown; + formData?: Record; + headers?: Record; + responseType?: 'auto' | 'base64'; +} + +/** + * A single method definition on an adapter. `input` is mandatory — it is the *trust* + * boundary: arguments arrive from model-authored code inside the sandbox and must be + * validated before the handler runs. `output` is the *tool-contract* boundary: required for + * any model-facing adapter (it becomes the result schema the LLM plans against), optional + * only for internal host-to-host plumbing the model never sees. + */ +export interface AdapterMethodDef< + TInput extends z.ZodTypeAny = z.ZodTypeAny, + TOutput extends z.ZodTypeAny = z.ZodTypeAny +> { + description: string; + input: TInput; + output?: TOutput; + handler: (input: z.infer, ctx: AdapterContext) => Promise | unknown; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface AdapterDef< + TMethods extends Record> = Record< + string, + AdapterMethodDef + > +> { + name: string; + description?: string; + version?: string; + methods: TMethods; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +/** + * A defined adapter. It IS a plain `Adapter` (so the executor consumes it unchanged) plus: + * - `__schemas`: the per-method Zod input/output, used to auto-generate tool definitions; + * - `withContext(ctx)`: binds the injected host capabilities, returning a runnable adapter. + * + * The schemas being declared is what lets the runtime *describe the adapter to an LLM + * automatically* — that declaration IS the tool definition (the bridge to MCP/tool-calling). + */ +export interface DefinedAdapter extends Adapter { + readonly __defined: true; + readonly __schemas: Record< + string, + { description: string; input: z.ZodTypeAny; output?: z.ZodTypeAny } + >; + /** Every method declares an `output` schema → safe to expose to a model. */ + readonly modelExposable: boolean; + /** Bind host capabilities (the injected `ctx`) and return a runnable adapter. */ + withContext(ctx: AdapterContext): Adapter; +} + +function validateInput(methodName: string, schema: z.ZodTypeAny, raw: unknown): unknown { + const result = schema.safeParse(raw); + if (!result.success) { + throw new ValidationError( + `Invalid arguments for "${methodName}": ${result.error.message}`, + result.error.issues + ); + } + return result.data; +} + +function validateOutput(schema: z.ZodTypeAny | undefined, value: unknown): unknown { + if (!schema) return value; + const result = schema.safeParse(value); + if (!result.success) { + // Output drift is a contract problem, not untrusted input — surface it as a + // validation error so a dotCMS response change shows up at the boundary rather + // than as a silent `undefined` downstream. + throw new ValidationError( + `Adapter result did not match its output schema: ${result.error.message}`, + result.error.issues + ); + } + return result.data; +} + +/** + * Build a typed, schema-validated adapter. Handlers get `(input, ctx)` — `input` already + * validated, `ctx.request` already auth-bound by the runtime. + * + * The returned value is a plain `Adapter` (the `methods` Map is populated with thunks that + * throw until `withContext` is called), so it can be wrapped (e.g. by an allow-list) before + * it reaches the runtime, exactly like the built-in `dotcmsAdapter`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function defineAdapter>>( + def: AdapterDef +): DefinedAdapter { + const schemas: DefinedAdapter['__schemas'] = {}; + let modelExposable = true; + + for (const [methodName, m] of Object.entries(def.methods)) { + schemas[methodName] = { description: m.description, input: m.input, output: m.output }; + if (!m.output) modelExposable = false; + } + + const makeMethods = (ctx?: AdapterContext): Map => { + const methods = new Map(); + for (const [methodName, m] of Object.entries(def.methods)) { + methods.set(methodName, { + name: methodName, + description: m.description, + parameters: [ + { + name: 'input', + type: 'object', + description: m.description, + required: true + } + ], + async execute(...args: unknown[]): Promise { + if (!ctx) { + throw new ValidationError( + `Adapter "${def.name}" was used without a bound context. ` + + `Call adapter.withContext(ctx) before running it.` + ); + } + const input = validateInput(methodName, m.input, args[0]); + const result = await m.handler(input as never, ctx); + return validateOutput(m.output, result); + } + }); + } + return methods; + }; + + const base: DefinedAdapter = { + name: def.name, + description: def.description, + version: def.version ?? '1.0.0', + methods: makeMethods(), + __defined: true, + __schemas: schemas, + modelExposable, + withContext(ctx: AdapterContext): Adapter { + return { + name: def.name, + description: def.description, + version: def.version ?? '1.0.0', + methods: makeMethods(ctx) + }; + } + }; + + return base; +} + +/** Type guard: distinguishes a `defineAdapter` result from a hand-built `Adapter`. */ +export function isDefinedAdapter(adapter: Adapter): adapter is DefinedAdapter { + return (adapter as DefinedAdapter).__defined === true; +} + +/** + * Render a defined adapter's methods as JSON-Schema-ish tool definitions an LLM can consume. + * This is the bridge to MCP / tool-calling: the declared Zod schemas become the tool's + * input/output contract, replacing hand-written description prose. + * + * Only methods that declare an `output` schema are emitted — a method without one is treated + * as internal plumbing and withheld from the model. + */ +export function describeAdapterForLLM(adapter: DefinedAdapter): Array<{ + name: string; + description: string; + inputSchema: unknown; + outputSchema?: unknown; +}> { + const tools: Array<{ + name: string; + description: string; + inputSchema: unknown; + outputSchema?: unknown; + }> = []; + for (const [methodName, s] of Object.entries(adapter.__schemas)) { + if (!s.output) continue; // internal-only method — not model-exposable + tools.push({ + name: `${adapter.name}.${methodName}`, + description: s.description, + inputSchema: z.toJSONSchema(s.input), + outputSchema: z.toJSONSchema(s.output) + }); + } + return tools; +} diff --git a/core-web/libs/sdk/ai/src/sandbox/errors.ts b/core-web/libs/sdk/ai/src/sandbox/errors.ts new file mode 100644 index 000000000000..b9657fdd7f7d --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/errors.ts @@ -0,0 +1,187 @@ +/** + * The single typed error hierarchy for `@dotcms/ai`. + * + * Both verbs of the runtime — `request()` (direct) and `run()` (sandboxed) — surface + * the *same* error types, because both route through one shared request core. The + * model-facing string an MCP tool builds is *formatting on top of* these errors, not a + * separate error model. + * + * Every error carries a stable, machine-readable `code` and a `toJSON()` so it survives + * the worker→host serialization boundary (a thrown error becomes `{ name, message, stack }` + * by default; these types preserve `code` and the structured detail too). + */ + +export type DotCMSErrorCode = + | 'VALIDATION' // adapter input failed its schema / request options invalid + | 'POLICY' // allow-list / policy rejected the call before it reached the wire + | 'HTTP' // dotCMS returned a non-2xx response + | 'TIMEOUT' // sandbox wall-clock (or a per-call timeout) elapsed + | 'ABORT' // the call was aborted (e.g. the sandbox timed out mid-flight) + | 'SANDBOX' // the model-authored code threw while executing + | 'RUNTIME'; // anything else originating in the runtime itself + +/** Serializable shape every DotCMSError flattens to (and that crosses the worker boundary). */ +export interface SerializedDotCMSError { + name: string; + code: DotCMSErrorCode; + message: string; + /** + * Present only when `includeStack` is enabled on the runtime. Host stack traces can + * contain absolute host paths, so they are withheld from model-authored code by default. + */ + stack?: string; + /** Type-specific structured detail (HTTP status + body, validation issues, etc.). */ + detail?: Record; +} + +export abstract class DotCMSError extends Error { + abstract readonly code: DotCMSErrorCode; + + constructor(message: string, options?: { cause?: unknown }) { + super(message); + this.name = new.target.name; + if (options?.cause !== undefined) { + // Standard ES2022 `cause`, but assigned defensively for older lib targets. + (this as { cause?: unknown }).cause = options.cause; + } + // Restore the prototype chain so `instanceof` works after transpilation to ES5/ES2017. + Object.setPrototypeOf(this, new.target.prototype); + } + + /** Type-specific structured detail; overridden by subclasses that carry data. */ + detail(): Record | undefined { + return undefined; + } + + toJSON(): SerializedDotCMSError { + return { + name: this.name, + code: this.code, + message: this.message, + stack: this.stack, + detail: this.detail() + }; + } +} + +/** Adapter input failed validation, or request options were malformed. */ +export class ValidationError extends DotCMSError { + readonly code = 'VALIDATION' as const; + /** Field-level issues, when the source was a Zod (or similar) schema. */ + readonly issues?: unknown[]; + + constructor(message: string, issues?: unknown[]) { + super(message); + this.issues = issues; + } + + detail(): Record | undefined { + return this.issues ? { issues: this.issues } : undefined; + } +} + +/** The allow-list / policy rejected the call before it reached the network. */ +export class PolicyError extends DotCMSError { + readonly code = 'POLICY' as const; + readonly method: string; + readonly path: string; + + constructor(message: string, method: string, path: string) { + super(message); + this.method = method; + this.path = path; + } + + detail(): Record { + return { method: this.method, path: this.path }; + } +} + +/** dotCMS responded with a non-2xx status. Carries the status and the (text) body. */ +export class HttpError extends DotCMSError { + readonly code = 'HTTP' as const; + readonly status: number; + readonly statusText: string; + readonly body: string; + + constructor(status: number, statusText: string, body: string) { + super(`HTTP ${status} ${statusText}: ${body}`); + this.status = status; + this.statusText = statusText; + this.body = body; + } + + detail(): Record { + return { status: this.status, statusText: this.statusText, body: this.body }; + } +} + +/** A timeout elapsed (sandbox wall-clock, per-adapter-call, or context load). */ +export class TimeoutError extends DotCMSError { + readonly code = 'TIMEOUT' as const; + readonly timeoutMs?: number; + + constructor(message: string, timeoutMs?: number) { + super(message); + this.timeoutMs = timeoutMs; + } + + detail(): Record | undefined { + return this.timeoutMs === undefined ? undefined : { timeoutMs: this.timeoutMs }; + } +} + +/** The call was aborted in-flight (typically because the sandbox timed out). */ +export class AbortError extends DotCMSError { + readonly code = 'ABORT' as const; +} + +/** Model-authored code threw while executing inside the sandbox. */ +export class SandboxError extends DotCMSError { + readonly code = 'SANDBOX' as const; + /** The original error's name (e.g. "TypeError") as seen inside the sandbox. */ + readonly originalName?: string; + + constructor(message: string, originalName?: string, stack?: string) { + super(message); + this.originalName = originalName; + if (stack) this.stack = stack; + } + + detail(): Record | undefined { + return this.originalName ? { originalName: this.originalName } : undefined; + } +} + +/** Catch-all for runtime-internal failures that don't fit a more specific type. */ +export class RuntimeError extends DotCMSError { + readonly code = 'RUNTIME' as const; +} + +/** Type guard for any error in the hierarchy. */ +export function isDotCMSError(value: unknown): value is DotCMSError { + return value instanceof DotCMSError; +} + +/** + * Normalize any thrown value into a serializable error shape. Used at the worker→host + * boundary and when formatting a result, so callers always get a consistent structure + * regardless of what was thrown. + */ +export function serializeError(value: unknown): SerializedDotCMSError { + if (isDotCMSError(value)) { + return value.toJSON(); + } + if (value instanceof Error) { + // Preserve a code already set on a plain Error (e.g. Node system errors, or an + // adapter that tags its own); fall back to RUNTIME. + const existingCode = (value as { code?: unknown }).code; + return { + name: value.name, + code: (typeof existingCode === 'string' ? existingCode : 'RUNTIME') as DotCMSErrorCode, + message: value.message, + stack: value.stack + }; + } + return { name: 'Error', code: 'RUNTIME', message: String(value) }; +} diff --git a/core-web/libs/agentic-tools/src/lib/executor.ts b/core-web/libs/sdk/ai/src/sandbox/executor.ts similarity index 94% rename from core-web/libs/agentic-tools/src/lib/executor.ts rename to core-web/libs/sdk/ai/src/sandbox/executor.ts index 14ce2e40d7b1..d5bd8301cc9a 100644 --- a/core-web/libs/agentic-tools/src/lib/executor.ts +++ b/core-web/libs/sdk/ai/src/sandbox/executor.ts @@ -1,5 +1,6 @@ -import { type ISandbox, createSandbox } from './sandbox'; +import { createWorkerSandbox } from './factory'; +import type { ISandbox } from './interface'; import type { Adapter, ExecutionContext, SandboxConfig, SandboxResult } from './types'; export interface ExecutorOptions { @@ -20,7 +21,7 @@ export class Executor { timeout: 5000, globals: {} }; - this.sandboxFactory = options.sandboxFactory ?? createSandbox; + this.sandboxFactory = options.sandboxFactory ?? createWorkerSandbox; if (options.config?.adapters) { for (const adapter of options.config.adapters) { diff --git a/core-web/libs/sdk/ai/src/sandbox/factory.ts b/core-web/libs/sdk/ai/src/sandbox/factory.ts new file mode 100644 index 000000000000..827a35ac2864 --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/factory.ts @@ -0,0 +1,23 @@ +import { BunWorkerSandbox } from './bun-worker'; +import { NodeWorkerSandbox } from './node-worker'; + +import type { ISandbox } from './interface'; +import type { SandboxConfig } from './types'; + +/** + * Low-level worker-sandbox factory. Auto-detects the runtime: native Web Workers on Bun, + * `worker_threads` on Node. Returns the raw {@link ISandbox} whose `execute(code, context)` + * the {@link Executor} drives. Most callers want the higher-level `createSandbox` barrel + * export (which returns a `run(code)` surface) instead of this. + * + * Backends are statically imported (not `require`d) so this works in both the ESM and CJS + * builds — `require` is undefined in an ESM module. Importing both is cheap: each file only + * declares a class, and `node:worker_threads` (used by the Node backend) is available on Bun too. + */ +export function createWorkerSandbox(config?: SandboxConfig): ISandbox { + if (typeof (globalThis as Record).Bun !== 'undefined') { + return new BunWorkerSandbox(config); + } + + return new NodeWorkerSandbox(config); +} diff --git a/core-web/libs/sdk/ai/src/sandbox/index.ts b/core-web/libs/sdk/ai/src/sandbox/index.ts new file mode 100644 index 000000000000..7ee60509e66e --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/index.ts @@ -0,0 +1,121 @@ +/** + * `@dotcms/ai/sandbox` — the generic execution engine. ZERO dotCMS code lives here (a + * lint-enforced module boundary); this subpath is fully generic. The dotCMS wiring lives in + * `@dotcms/ai/adapter`, and the front door that combines them in `@dotcms/ai`. + */ +import { type AdapterContext, type RequestLike, isDefinedAdapter } from './define-adapter'; +import { Executor } from './executor'; + +import type { Adapter, SandboxResourceLimits, SandboxResult } from './types'; + +// ---- Generic engine surface ---------------------------------------------------------- + +export { Executor, createExecutor } from './executor'; +export type { ExecutorOptions } from './executor'; + +export { createWorkerSandbox } from './factory'; +export type { ISandbox, SandboxFactory } from './interface'; + +export { defineAdapter, isDefinedAdapter, describeAdapterForLLM } from './define-adapter'; +export type { + AdapterContext, + AdapterDef, + AdapterMethodDef, + DefinedAdapter, + RequestLike +} from './define-adapter'; + +export { + DotCMSError, + ValidationError, + PolicyError, + HttpError, + TimeoutError, + AbortError, + SandboxError, + RuntimeError, + isDotCMSError, + serializeError +} from './errors'; +export type { DotCMSErrorCode, SerializedDotCMSError } from './errors'; + +export type { + Adapter, + AdapterMethod, + AdapterMethodParameter, + SandboxConfig, + SandboxResourceLimits, + SandboxResult, + SandboxResultError, + ExecutionContext +} from './types'; + +// ---- High-level sandbox --------------------------------------------------------------- + +export interface CreateSandboxConfig { + /** Adapters granted to the sandboxed code — the only doors out. */ + adapters: Adapter[]; + /** Wall-clock timeout (ms). */ + timeout?: number; + /** Extra globals injected into the sandbox. */ + globals?: Record; + /** Per-execution memory/stack caps. */ + resourceLimits?: SandboxResourceLimits; + /** + * Host `request` capability injected into `defineAdapter` handlers as `ctx.request`. When + * provided, defined adapters are bound to it (and the per-run abort signal) before running. + * For the generic engine this is optional; the dotCMS front door always provides one. + */ + request?: (opts: RequestLike) => Promise; +} + +export interface Sandbox { + /** Run code in the confined worker. Returns the structured result. */ + run(code: string): Promise>; +} + +/** + * Create a sandbox over a set of adapters — the generic entry point shown in §6.2. + * + * `defineAdapter` results are bound to the injected host `request` (and a per-run abort + * signal) via `withContext`; plain hand-built adapters are passed through untouched. Each + * `run()` gets its own `AbortController`, so a timeout aborts in-flight host work. + */ +export function createSandbox(config: CreateSandboxConfig): Sandbox { + return { + async run(code: string): Promise> { + const controller = new AbortController(); + const ctx: AdapterContext = { + request: config.request ?? notProvided, + signal: controller.signal + }; + + const adapters = config.adapters.map((a) => + isDefinedAdapter(a) ? a.withContext(ctx) : a + ); + + const executor = new Executor({ + config: { + adapters, + sandbox: { + timeout: config.timeout, + globals: config.globals, + resourceLimits: config.resourceLimits, + onTeardown: () => controller.abort() + } + } + }); + + return executor.execute(code, { + adapters: adapters.map((a) => a.name) + }); + } + }; +} + +const notProvided = (): Promise => { + throw new Error( + 'This adapter needs a host `request` capability. Pass `request` to createSandbox(), ' + + 'or use createRuntime() from "@dotcms/ai/runtime" which provides one.' + ); +}; diff --git a/core-web/libs/agentic-tools/src/lib/sandbox/interface.ts b/core-web/libs/sdk/ai/src/sandbox/interface.ts similarity index 95% rename from core-web/libs/agentic-tools/src/lib/sandbox/interface.ts rename to core-web/libs/sdk/ai/src/sandbox/interface.ts index fd7af6cdfdac..7aff29670df1 100644 --- a/core-web/libs/agentic-tools/src/lib/sandbox/interface.ts +++ b/core-web/libs/sdk/ai/src/sandbox/interface.ts @@ -1,4 +1,4 @@ -import type { ExecutionContext, SandboxConfig, SandboxResult } from '../types'; +import type { ExecutionContext, SandboxConfig, SandboxResult } from './types'; export interface ISandbox { execute(code: string, context: ExecutionContext): Promise>; diff --git a/core-web/libs/sdk/ai/src/sandbox/node-worker.ts b/core-web/libs/sdk/ai/src/sandbox/node-worker.ts new file mode 100644 index 000000000000..30710d59b5c8 --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/node-worker.ts @@ -0,0 +1,168 @@ +import { Worker } from 'node:worker_threads'; + +import { serializeError } from './errors'; +import { DEFAULT_RESOURCE_LIMITS, NODE_WORKER_CODE } from './worker-harness'; + +import type { ISandbox } from './interface'; +import type { + AdapterCallMessage, + ExecutionContext, + ResolvedSandboxConfig, + ResultMessage, + SandboxConfig, + SandboxResult, + WorkerMessage +} from './types'; + +const DEFAULT_CONFIG: ResolvedSandboxConfig = { + timeout: 5000, + globals: {}, + resourceLimits: DEFAULT_RESOURCE_LIMITS +}; + +export class NodeWorkerSandbox implements ISandbox { + private config: ResolvedSandboxConfig; + + constructor(config?: SandboxConfig) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + getConfig(): SandboxConfig { + return { ...this.config }; + } + + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + async execute(code: string, context: ExecutionContext): Promise> { + const startTime = performance.now(); + + return new Promise((resolve) => { + const adapterMethods: Record = {}; + for (const [name, methods] of Object.entries(context.adapters)) { + adapterMethods[name] = Object.keys(methods); + } + + const workerCode = NODE_WORKER_CODE; + + const limits = this.config.resourceLimits; + const worker = new Worker(workerCode, { + eval: true, + env: {}, + resourceLimits: limits + ? { + maxOldGenerationSizeMb: limits.maxOldGenerationSizeMb, + maxYoungGenerationSizeMb: limits.maxYoungGenerationSizeMb, + stackSizeMb: limits.stackSizeMb + } + : undefined + }); + + let resolved = false; + const cleanup = () => { + if (!resolved) { + resolved = true; + worker.terminate(); + // Let the host abort any in-flight adapter work (threaded AbortSignal). + this.config.onTeardown?.(); + } + }; + + const timeoutId = setTimeout(() => { + if (!resolved) { + cleanup(); + resolve({ + success: false, + error: { + name: 'TimeoutError', + code: 'TIMEOUT', + message: `Execution timed out after ${this.config.timeout}ms` + }, + logs: [], + executionTime: performance.now() - startTime + }); + } + }, this.config.timeout); + + worker.on('message', async (msg: WorkerMessage) => { + const { type, ...data } = msg; + + if (type === 'ready') { + worker.postMessage({ type: 'execute', data: { code } }); + } else if (type === 'adapter_call') { + const { adapter, method, args, id } = data as unknown as AdapterCallMessage; + // Posting to a worker that was already terminated (e.g. by a + // timeout while this adapter call was in flight) throws and + // would escape as an unhandled rejection, crashing the host. + const postResult = (payload: Record) => { + if (resolved) { + return; + } + try { + worker.postMessage({ type: 'adapter_result', data: payload }); + } catch { + /* worker gone — nothing to deliver the result to */ + } + }; + try { + const adapterObj = context.adapters[adapter]; + if (!adapterObj || !adapterObj[method]) { + throw new Error(`Adapter method not found: ${adapter}.${method}`); + } + const result = await (adapterObj[method] as (...a: unknown[]) => unknown)( + ...args + ); + postResult({ id, result }); + } catch (err) { + // Send the serialized error (name + code + detail) so the typed shape + // round-trips the boundary. Drop `stack` — the worker rebuilds a fresh + // Error and never reads it, and host stacks shouldn't cross into the sandbox. + const error = serializeError(err); + delete error.stack; + postResult({ id, error }); + } + } else if (type === 'result') { + clearTimeout(timeoutId); + cleanup(); + const r = data as unknown as ResultMessage; + resolve({ + success: r.success, + value: r.value as T, + error: r.error, + logs: r.logs || [], + executionTime: performance.now() - startTime + }); + } + }); + + worker.on('error', (error: Error) => { + clearTimeout(timeoutId); + cleanup(); + resolve({ + success: false, + error: { + name: 'WorkerError', + code: 'RUNTIME', + message: error.message || 'Unknown worker error' + }, + logs: [], + executionTime: performance.now() - startTime + }); + }); + + worker.postMessage({ + type: 'init', + data: { + variables: context.variables || {}, + adapterMethods, + globals: this.config.globals + } + }); + }); + } + + dispose(): void { + // Workers are created per execution, nothing to dispose + } +} diff --git a/core-web/libs/sdk/ai/src/sandbox/sandbox.spec.ts b/core-web/libs/sdk/ai/src/sandbox/sandbox.spec.ts new file mode 100644 index 000000000000..0b21f64dfcfc --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/sandbox.spec.ts @@ -0,0 +1,202 @@ +import { Executor } from './executor'; + +import type { Adapter } from './types'; + +/** + * Confinement + routing tests for the worker sandbox. These exercise a REAL Node + * `worker_threads` worker (the hardest-to-reason-about part of the runtime, and the one + * with zero coverage before). They prove the network blocks hold, the timeout terminates, + * abort propagates to in-flight adapter work, and adapter routing works end to end. + */ +describe('sandbox confinement', () => { + function run(code: string, opts?: { timeout?: number; adapters?: Adapter[] }) { + const executor = new Executor({ + config: { + adapters: opts?.adapters ?? [], + sandbox: { timeout: opts?.timeout ?? 5000 } + } + }); + return executor.execute(code, { + adapters: (opts?.adapters ?? []).map((a) => a.name) + }); + } + + it('blocks direct fetch from inside the sandbox', async () => { + const result = await run(`return await fetch('https://example.com');`); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/Network access is disabled/i); + }); + + it('blocks XMLHttpRequest / WebSocket / EventSource', async () => { + const result = await run(` + const blocked = []; + for (const name of ['XMLHttpRequest', 'WebSocket', 'EventSource']) { + try { new globalThis[name](); blocked.push(name + ':ALLOWED'); } + catch (e) { blocked.push(name + ':blocked'); } + } + return blocked; + `); + expect(result.success).toBe(true); + expect(result.value).toEqual([ + 'XMLHttpRequest:blocked', + 'WebSocket:blocked', + 'EventSource:blocked' + ]); + }); + + it('removes require', async () => { + const result = await run(`return typeof require;`); + expect(result.success).toBe(true); + expect(result.value).toBe('undefined'); + }); + + it('blocks dynamic import() so node builtins cannot be re-opened', async () => { + const result = await run( + `const fs = await import('node:fs'); return typeof fs.readFileSync;` + ); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/dynamic import\(\) is disabled/i); + }); + + it('empties process.env', async () => { + const result = await run(`return Object.keys(process.env).length;`); + expect(result.success).toBe(true); + expect(result.value).toBe(0); + }); + + it('returns a value from executed code', async () => { + const result = await run(`return 1 + 2;`); + expect(result.success).toBe(true); + expect(result.value).toBe(3); + }); + + it('captures console logs', async () => { + const result = await run(`console.log('hello'); console.warn('careful'); return 'ok';`); + expect(result.success).toBe(true); + expect(result.logs).toContain('hello'); + expect(result.logs).toContain('[WARN] careful'); + }); + + it('terminates on timeout and reports a TIMEOUT error', async () => { + const result = await run(`while (true) {}`, { timeout: 200 }); + expect(result.success).toBe(false); + expect(result.error?.code).toBe('TIMEOUT'); + }, 10000); + + it('surfaces a thrown error from sandbox code', async () => { + const result = await run(`throw new Error('boom');`); + expect(result.success).toBe(false); + expect(result.error?.message).toBe('boom'); + }); +}); + +describe('sandbox adapter routing', () => { + /** A fake adapter that records calls and echoes its args back. */ + function makeEchoAdapter(record: unknown[][]): Adapter { + return { + name: 'echo', + description: 'test', + version: '1.0.0', + methods: new Map([ + [ + 'ping', + { + name: 'ping', + parameters: [], + execute: (...args: unknown[]) => { + record.push(args); + return { pong: args[0] }; + } + } + ] + ]) + }; + } + + it('routes an adapter call from the sandbox to the host and back', async () => { + const calls: unknown[][] = []; + const adapter = makeEchoAdapter(calls); + const executor = new Executor({ + config: { adapters: [adapter], sandbox: { timeout: 5000 } } + }); + const result = await executor.execute(`return await echo.ping({ n: 42 });`, { + adapters: ['echo'] + }); + expect(result.success).toBe(true); + expect(result.value).toEqual({ pong: { n: 42 } }); + expect(calls).toEqual([[{ n: 42 }]]); + }); + + it('propagates an adapter error code into the sandbox', async () => { + const adapter: Adapter = { + name: 'boom', + version: '1.0.0', + methods: new Map([ + [ + 'go', + { + name: 'go', + parameters: [], + execute: () => { + const e = new Error('nope') as Error & { code?: string }; + e.code = 'POLICY'; + throw e; + } + } + ] + ]) + }; + const executor = new Executor({ + config: { adapters: [adapter], sandbox: { timeout: 5000 } } + }); + const result = await executor.execute( + `try { await boom.go(); } catch (e) { return { msg: e.message, code: e.code }; }`, + { adapters: ['boom'] } + ); + expect(result.success).toBe(true); + expect(result.value).toEqual({ msg: 'nope', code: 'POLICY' }); + }); + + it('aborts in-flight adapter work when the sandbox times out', async () => { + let aborted = false; + const slowAdapter: Adapter = { + name: 'slow', + version: '1.0.0', + methods: new Map([ + [ + 'wait', + { + name: 'wait', + parameters: [], + execute: () => + new Promise((resolve) => { + // Resolves only after a long delay; the abort below should fire first. + const t = setTimeout(() => resolve('done'), 2000); + // Don't keep the event loop alive once the test ends. + (t as { unref?: () => void }).unref?.(); + }) + } + ] + ]) + }; + + const controller = new AbortController(); + controller.signal.addEventListener('abort', () => { + aborted = true; + }); + + const executor = new Executor({ + config: { + adapters: [slowAdapter], + sandbox: { timeout: 200, onTeardown: () => controller.abort() } + } + }); + + const result = await executor.execute(`return await slow.wait();`, { adapters: ['slow'] }); + expect(result.success).toBe(false); + expect(result.error?.code).toBe('TIMEOUT'); + // The onTeardown hook fired, so a runtime wiring this to its adapter signal would + // have aborted the in-flight fetch. + expect(aborted).toBe(true); + }, 10000); +}); diff --git a/core-web/libs/sdk/ai/src/sandbox/types.ts b/core-web/libs/sdk/ai/src/sandbox/types.ts new file mode 100644 index 000000000000..fb7abc308c65 --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/types.ts @@ -0,0 +1,122 @@ +/** + * Configuration for an adapter method parameter + */ +export interface AdapterMethodParameter { + name: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + description?: string; + required?: boolean; + default?: unknown; +} + +/** + * A method exposed by an adapter + */ +export interface AdapterMethod { + name: string; + description?: string; + parameters: AdapterMethodParameter[]; + execute: (...args: unknown[]) => unknown | Promise; +} + +/** + * A registered adapter instance + */ +export interface Adapter { + name: string; + description?: string; + version: string; + methods: Map; + config?: unknown; +} + +/** + * Per-execution resource caps for the worker. Maps onto Node's `worker_threads` + * `resourceLimits`; ignored fields are simply not applied on runtimes that lack them. + */ +export interface SandboxResourceLimits { + /** Max old-generation heap (MB). The main memory cap. */ + maxOldGenerationSizeMb?: number; + /** Max young-generation heap (MB). */ + maxYoungGenerationSizeMb?: number; + /** Worker stack size (MB). */ + stackSizeMb?: number; +} + +/** + * Configuration for the sandbox execution environment. + * + * The boundary this enforces is **capability confinement for trusted code generators, NOT a + * defense against adversarial code** (see the package threat model). It blocks accidental + * egress and runaway cost; it is not a hardened isolate. + */ +export interface SandboxConfig { + /** Wall-clock timeout (ms) for one execution. On expiry the worker is terminated. */ + timeout?: number; + /** Extra globals injected into the worker (e.g. `spec`). */ + globals?: Record; + /** Per-execution memory/stack caps. */ + resourceLimits?: SandboxResourceLimits; + /** + * Fired when the execution is torn down (timeout, error, or completion) so the host can + * abort in-flight adapter work (the threaded `AbortController.abort()`). The runtime wires + * this to the same signal it gave the adapter, so a sandbox timeout stops an in-flight fetch. + */ + onTeardown?: () => void; +} + +/** The serializable error shape carried back from an execution. Mirrors SerializedDotCMSError. */ +export interface SandboxResultError { + name: string; + message: string; + /** Machine-readable error code from the typed hierarchy, when available. */ + code?: string; + /** Only present when the runtime opts into leaking stacks to executed code. */ + stack?: string; + /** Type-specific structured detail. */ + detail?: Record; +} + +/** Fully-resolved sandbox config (timeout/globals always present). Shared by both backends. */ +export type ResolvedSandboxConfig = SandboxConfig & { + timeout: number; + globals: Record; +}; + +// ---- Worker host↔worker message protocol (shared by the Node and Bun backends) -------- + +export interface WorkerMessage { + type: string; + [key: string]: unknown; +} +export interface AdapterCallMessage { + adapter: string; + method: string; + args: unknown[]; + id: number; +} +export interface ResultMessage { + success: boolean; + value?: unknown; + error?: SandboxResultError; + logs?: string[]; +} + +/** + * Result from sandbox code execution + */ +export interface SandboxResult { + success: boolean; + value?: T; + error?: SandboxResultError; + logs: string[]; + executionTime: number; +} + +/** + * Context passed to sandbox execution + */ +export interface ExecutionContext { + adapters: Record unknown | Promise>>; + variables?: Record; +} diff --git a/core-web/libs/sdk/ai/src/sandbox/worker-harness.ts b/core-web/libs/sdk/ai/src/sandbox/worker-harness.ts new file mode 100644 index 000000000000..5f77560b1bf5 --- /dev/null +++ b/core-web/libs/sdk/ai/src/sandbox/worker-harness.ts @@ -0,0 +1,212 @@ +/** + * The sandbox worker harness — the code that runs INSIDE the worker. + * + * It is identical for both backends (Node `worker_threads` and Bun Web Workers); the only + * difference is the transport, so each backend prepends a tiny bootstrap that defines two + * functions the harness calls — `__post(msg)` and `__onMessage(handler)` — and (for Node) + * neutralizes `require`. Keeping the harness in ONE place means a protocol change (e.g. the + * error round-tripping below) is written once, not copy-pasted into two ~150-line strings. + * + * Hoisted to a module constant (it is fully static) so it is not re-allocated per execution. + */ +const HARNESS_BODY = ` + // Block direct network access — all calls must go through adapters + const __networkError = () => { throw new Error('Network access is disabled in sandbox'); }; + const __disableGlobalApi = (name) => { + try { + Object.defineProperty(globalThis, name, { value: __networkError, writable: false, configurable: false }); + } catch { /* ignore if already frozen */ } + }; + __disableGlobalApi('fetch'); + __disableGlobalApi('XMLHttpRequest'); + __disableGlobalApi('WebSocket'); + __disableGlobalApi('EventSource'); + if (globalThis.navigator && typeof globalThis.navigator === 'object') { + try { + Object.defineProperty(globalThis.navigator, 'sendBeacon', { value: __networkError, writable: false, configurable: false }); + } catch { /* ignore */ } + } + + // Clean process environment so sandboxed code cannot read host secrets. + if (typeof process !== 'undefined') { process.env = {}; } + + const logs = []; + const pendingCalls = new Map(); + let callId = 0; + + const console = { + log: (...args) => logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), + warn: (...args) => logs.push('[WARN] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), + error: (...args) => logs.push('[ERROR] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), + info: (...args) => logs.push('[INFO] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')), + }; + globalThis.console = console; + + globalThis.pick = (arr, fields) => { + if (!Array.isArray(arr)) return arr; + return arr.map(item => { + const result = {}; + for (const field of fields) { + const parts = field.split('.'); + let value = item; + let key = parts[parts.length - 1]; + for (const part of parts) value = value?.[part]; + result[key] = value; + } + return result; + }); + }; + + globalThis.table = (arr, maxRows = 10) => { + if (!Array.isArray(arr) || arr.length === 0) return '(empty)'; + const items = arr.slice(0, maxRows); + const keys = Object.keys(items[0]); + const header = '| ' + keys.join(' | ') + ' |'; + const sep = '|' + keys.map(() => '---').join('|') + '|'; + const rows = items.map(item => '| ' + keys.map(k => String(item[k] ?? '')).join(' | ') + ' |'); + let result = [header, sep, ...rows].join('\\n'); + if (arr.length > maxRows) result += '\\n... +' + (arr.length - maxRows) + ' more rows'; + return result; + }; + + globalThis.count = (arr, field) => { + if (!Array.isArray(arr)) return {}; + return arr.reduce((acc, item) => { + const key = String(item[field] ?? 'unknown'); + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + }; + + globalThis.sum = (arr, field) => { + if (!Array.isArray(arr)) return 0; + return arr.reduce((acc, item) => acc + (Number(item[field]) || 0), 0); + }; + + globalThis.first = (arr, n = 5) => { + if (!Array.isArray(arr)) return arr; + return arr.slice(0, n); + }; + + __onMessage(async (msg) => { + const { type, data } = msg; + + if (type === 'init') { + const { variables, adapterMethods, globals } = data; + + for (const [key, value] of Object.entries(variables || {})) { + globalThis[key] = value; + } + + for (const [key, value] of Object.entries(globals || {})) { + globalThis[key] = value; + } + + globalThis.adapters = {}; + for (const [adapterName, methods] of Object.entries(adapterMethods)) { + const adapterObj = {}; + for (const methodName of methods) { + adapterObj[methodName] = async (...args) => { + const id = ++callId; + return new Promise((resolve, reject) => { + pendingCalls.set(id, { resolve, reject }); + __post({ + type: 'adapter_call', + adapter: adapterName, + method: methodName, + args, + id + }); + }); + }; + } + globalThis.adapters[adapterName] = adapterObj; + globalThis[adapterName] = adapterObj; + } + + __post({ type: 'ready' }); + } + + else if (type === 'adapter_result') { + const { id, result, error } = data; + const pending = pendingCalls.get(id); + if (pending) { + pendingCalls.delete(id); + if (error) { + // 'error' is the serialized DotCMSError shape ({ name, code, message, detail }). + // Rebuild a plain Error (class identity can't cross the boundary) but keep the + // code/name/detail so model code can branch on them. + const e = new Error(error.message); + if (error.name) e.name = error.name; + if (error.code) e.code = error.code; + if (error.detail) e.detail = error.detail; + pending.reject(e); + } + else pending.resolve(result); + } + } + + else if (type === 'execute') { + try { + // Block dynamic import() — \`require\` is already removed, but \`import('node:fs')\` + // / \`import('node:net')\` would re-open filesystem/network access and bypass the + // adapter boundary. This is a source-level guard (matches the "confinement for + // trusted code generators" threat model; not hardened against deliberate obfuscation). + if (/\\bimport\\s*\\(/.test(data.code)) { + throw new Error('Dynamic import() is disabled in the sandbox; reach the host only through adapters.'); + } + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const fn = new AsyncFunction(data.code); + const result = await fn(); + __post({ type: 'result', success: true, value: result, logs }); + } catch (err) { + __post({ + type: 'result', + success: false, + error: { name: err.name, message: err.message, code: err.code, detail: err.detail, stack: err.stack }, + logs + }); + } + } + }); +`; + +/** + * Build the full worker source for a backend. `bootstrap` defines `__post` / `__onMessage` + * (and, on Node, neutralizes `require`) before the shared harness body runs. + */ +function buildWorkerSource(bootstrap: string): string { + return `${bootstrap}\n${HARNESS_BODY}`; +} + +/** Node `worker_threads` bootstrap: transport via `parentPort`, plus `require` removal. */ +const NODE_WORKER_CODE = buildWorkerSource(` + const { parentPort } = require('worker_threads'); + const __post = (m) => parentPort.postMessage(m); + const __onMessage = (handler) => parentPort.on('message', handler); + try { + Object.defineProperty(globalThis, 'require', { value: undefined, writable: false, configurable: false }); + } catch { /* ignore */ } +`); + +/** Bun / Web Worker bootstrap: transport via `self`. */ +const BUN_WORKER_CODE = buildWorkerSource(` + const __post = (m) => self.postMessage(m); + const __onMessage = (handler) => { self.onmessage = (event) => handler(event.data); }; + try { + Object.defineProperty(globalThis, 'require', { value: undefined, writable: false, configurable: false }); + } catch { /* ignore */ } +`); + +/** + * Default per-execution resource caps, shared by both backends so a runaway/ballooning + * script can't exhaust the host. (Node applies these via `worker_threads.resourceLimits`; + * Bun applies what its Worker supports.) + */ +export const DEFAULT_RESOURCE_LIMITS = { + maxOldGenerationSizeMb: 256, + maxYoungGenerationSizeMb: 32, + stackSizeMb: 4 +} as const; + +export { NODE_WORKER_CODE, BUN_WORKER_CODE }; diff --git a/core-web/libs/sdk/ai/src/spec/index.ts b/core-web/libs/sdk/ai/src/spec/index.ts new file mode 100644 index 000000000000..47bcf09a6688 --- /dev/null +++ b/core-web/libs/sdk/ai/src/spec/index.ts @@ -0,0 +1,7 @@ +/** + * `@dotcms/ai/spec` — the filtered dotCMS OpenAPI spec, behind its own subpath so importing + * the adapter doesn't drag in the ~hundreds-of-KB JSON. Opt-in for the search use case. The + * spec is build-generated (not committed) from a specific dotCMS instance; see the support + * matrix in the README for the spec ↔ server-version coupling. + */ +export { getSpec } from './spec'; diff --git a/core-web/libs/agentic-tools/src/lib/spec.ts b/core-web/libs/sdk/ai/src/spec/spec.ts similarity index 100% rename from core-web/libs/agentic-tools/src/lib/spec.ts rename to core-web/libs/sdk/ai/src/spec/spec.ts diff --git a/core-web/libs/agentic-tools/tsconfig.json b/core-web/libs/sdk/ai/tsconfig.json similarity index 87% rename from core-web/libs/agentic-tools/tsconfig.json rename to core-web/libs/sdk/ai/tsconfig.json index d6fcf92e4792..f01f8321d01d 100644 --- a/core-web/libs/agentic-tools/tsconfig.json +++ b/core-web/libs/sdk/ai/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "module": "commonjs", "moduleResolution": "node", diff --git a/core-web/libs/agentic-tools/tsconfig.lib.json b/core-web/libs/sdk/ai/tsconfig.lib.json similarity index 86% rename from core-web/libs/agentic-tools/tsconfig.lib.json rename to core-web/libs/sdk/ai/tsconfig.lib.json index bd3cb7eff516..a840735e7df1 100644 --- a/core-web/libs/agentic-tools/tsconfig.lib.json +++ b/core-web/libs/sdk/ai/tsconfig.lib.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "declaration": true, "types": ["node"], "resolveJsonModule": true diff --git a/core-web/libs/agentic-tools/tsconfig.spec.json b/core-web/libs/sdk/ai/tsconfig.spec.json similarity index 74% rename from core-web/libs/agentic-tools/tsconfig.spec.json rename to core-web/libs/sdk/ai/tsconfig.spec.json index c354ed6394f6..cd5ae549b3bf 100644 --- a/core-web/libs/agentic-tools/tsconfig.spec.json +++ b/core-web/libs/sdk/ai/tsconfig.spec.json @@ -1,8 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "module": "commonjs", + "resolveJsonModule": true, "types": ["jest", "node"] }, "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 9385bd89c861..1074d83775c7 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -103,7 +103,10 @@ "@shared/*": ["apps/dotcms-ui/src/app/shared/*"], "@tests/*": ["apps/dotcms-ui/src/app/test/*"], "sdk-create-app": ["libs/sdk/create-app/src/index.ts"], - "@dotcms/agentic-tools": ["libs/agentic-tools/src/index.ts"] + "@dotcms/ai/runtime": ["libs/sdk/ai/src/runtime.ts"], + "@dotcms/ai/sandbox": ["libs/sdk/ai/src/sandbox/index.ts"], + "@dotcms/ai/adapter": ["libs/sdk/ai/src/adapter/index.ts"], + "@dotcms/ai/spec": ["libs/sdk/ai/src/spec/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/container/ContainerResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/container/ContainerResource.java index c84559d376d8..14298c6f390d 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/container/ContainerResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/container/ContainerResource.java @@ -774,6 +774,7 @@ public final Response containerContents(@Context final HttpServletRequest req, @ @POST @JSONP @NoCache + @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation( operationId = "saveContainer", @@ -869,6 +870,7 @@ private Response saveNewAndPublish(final ContainerForm containerForm, final User @PUT @JSONP @NoCache + @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation( operationId = "updateContainer", diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/site/SiteResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/site/SiteResource.java index 47911cbb6ac4..b382f3ff6d62 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/site/SiteResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/site/SiteResource.java @@ -47,6 +47,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -57,6 +58,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; @@ -1027,6 +1029,7 @@ public Response findHostByName(@Context final HttpServletRequest httpServletRequ @POST @JSONP @NoCache + @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "createSite", summary = "Create a new site", @@ -1043,6 +1046,9 @@ public Response findHostByName(@Context final HttpServletRequest httpServletRequ }) public Response createNewSite(@Context final HttpServletRequest httpServletRequest, @Context final HttpServletResponse httpServletResponse, + @RequestBody(description = "Site properties to create. 'siteName' (the hostname) is required.", + required = true, + content = @Content(schema = @Schema(implementation = SiteForm.class))) final SiteForm newSiteForm) throws DotDataException, DotSecurityException, AlreadyExistException, LanguageException { @@ -1387,6 +1393,7 @@ public Response getSiteVariables(@Context final HttpServletRequest httpServletRe @PUT @JSONP @NoCache + @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "updateSite", summary = "Update an existing site", @@ -1406,6 +1413,9 @@ public Response updateSite(@Context final HttpServletRequest httpServletRequest, @Context final HttpServletResponse httpServletResponse, @Parameter(description = "Identifier of the site to update (siteId/hostId are used interchangeably)", required = true) @QueryParam("id") final String siteIdentifier, + @RequestBody(description = "Updated site properties. 'siteName' (the hostname) is required.", + required = true, + content = @Content(schema = @Schema(implementation = SiteForm.class))) final SiteForm newSiteForm) throws DotDataException, DotSecurityException, LanguageException { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/template/TemplateResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/template/TemplateResource.java index e1196ce2cc81..b2c00b05b457 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/template/TemplateResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/template/TemplateResource.java @@ -311,6 +311,7 @@ public final Response getWorkingById(@Context final HttpServletRequest httpRequ @POST @JSONP @NoCache + @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation( operationId = "createTemplate", @@ -363,6 +364,7 @@ public final Response saveNew(@Context final HttpServletRequest request, @PUT @JSONP @NoCache + @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation( operationId = "updateTemplate", diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 8a2da8bf0ccf..9c87d83b733b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -5393,7 +5393,7 @@ paths: operationId: saveContainer requestBody: content: - '*/*': + application/json: schema: $ref: "#/components/schemas/ContainerForm" description: "Container configuration data including title, code, content\ @@ -5423,7 +5423,7 @@ paths: operationId: updateContainer requestBody: content: - '*/*': + application/json: schema: $ref: "#/components/schemas/ContainerForm" description: Updated container configuration data including identifier and @@ -15153,9 +15153,11 @@ paths: operationId: createSite requestBody: content: - '*/*': + application/json: schema: $ref: "#/components/schemas/SiteForm" + description: Site properties to create. 'siteName' (the hostname) is required. + required: true responses: "200": content: @@ -15189,9 +15191,11 @@ paths: type: string requestBody: content: - '*/*': + application/json: schema: $ref: "#/components/schemas/SiteForm" + description: Updated site properties. 'siteName' (the hostname) is required. + required: true responses: "200": content: @@ -17055,7 +17059,7 @@ paths: operationId: createTemplate requestBody: content: - '*/*': + application/json: schema: $ref: "#/components/schemas/TemplateForm" description: Template data to create @@ -17086,7 +17090,7 @@ paths: operationId: updateTemplate requestBody: content: - '*/*': + application/json: schema: $ref: "#/components/schemas/TemplateForm" description: Template data to update. Must include the template identifier.