From 55caa9eec0f626a2d46d21de8a80d750e87c13ce Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:19:44 -0700 Subject: [PATCH 1/9] feat(jupyter): add Jupyter integration (contents, kernels, sessions) - 16 tools covering Contents, Kernels, Kernelspecs, and Sessions REST APIs - File upload/download via UserFile, following the Box upload pattern - Block with operation dropdown, token auth, and 8 catalog templates - Registered tools + block, generated docs, bumped API validation baseline --- apps/docs/components/icons.tsx | 69 +++ apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/jupyter.mdx | 383 ++++++++++++++++ .../content/docs/en/integrations/meta.json | 1 + .../sim/app/api/tools/jupyter/upload/route.ts | 119 +++++ apps/sim/blocks/blocks/jupyter.ts | 411 ++++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/components/icons.tsx | 69 +++ .../sim/lib/api/contracts/storage-transfer.ts | 18 + apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 85 +++- apps/sim/tools/jupyter/copy_content.ts | 86 ++++ apps/sim/tools/jupyter/create_file.ts | 121 ++++++ apps/sim/tools/jupyter/create_session.ts | 111 +++++ apps/sim/tools/jupyter/delete_content.ts | 72 +++ apps/sim/tools/jupyter/delete_session.ts | 65 +++ apps/sim/tools/jupyter/get_content.ts | 119 +++++ apps/sim/tools/jupyter/index.ts | 16 + apps/sim/tools/jupyter/interrupt_kernel.ts | 65 +++ apps/sim/tools/jupyter/list_contents.ts | 109 +++++ apps/sim/tools/jupyter/list_kernels.ts | 74 ++++ apps/sim/tools/jupyter/list_kernelspecs.ts | 88 ++++ apps/sim/tools/jupyter/list_sessions.ts | 97 +++++ apps/sim/tools/jupyter/rename_content.ts | 89 ++++ apps/sim/tools/jupyter/restart_kernel.ts | 70 +++ apps/sim/tools/jupyter/start_kernel.ts | 70 +++ apps/sim/tools/jupyter/stop_kernel.ts | 60 +++ apps/sim/tools/jupyter/types.ts | 226 ++++++++++ apps/sim/tools/jupyter/upload_file.ts | 86 ++++ apps/sim/tools/jupyter/utils.ts | 115 +++++ apps/sim/tools/registry.ts | 34 ++ scripts/check-api-validation-contracts.ts | 4 +- 32 files changed, 2936 insertions(+), 3 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/jupyter.mdx create mode 100644 apps/sim/app/api/tools/jupyter/upload/route.ts create mode 100644 apps/sim/blocks/blocks/jupyter.ts create mode 100644 apps/sim/tools/jupyter/copy_content.ts create mode 100644 apps/sim/tools/jupyter/create_file.ts create mode 100644 apps/sim/tools/jupyter/create_session.ts create mode 100644 apps/sim/tools/jupyter/delete_content.ts create mode 100644 apps/sim/tools/jupyter/delete_session.ts create mode 100644 apps/sim/tools/jupyter/get_content.ts create mode 100644 apps/sim/tools/jupyter/index.ts create mode 100644 apps/sim/tools/jupyter/interrupt_kernel.ts create mode 100644 apps/sim/tools/jupyter/list_contents.ts create mode 100644 apps/sim/tools/jupyter/list_kernels.ts create mode 100644 apps/sim/tools/jupyter/list_kernelspecs.ts create mode 100644 apps/sim/tools/jupyter/list_sessions.ts create mode 100644 apps/sim/tools/jupyter/rename_content.ts create mode 100644 apps/sim/tools/jupyter/restart_kernel.ts create mode 100644 apps/sim/tools/jupyter/start_kernel.ts create mode 100644 apps/sim/tools/jupyter/stop_kernel.ts create mode 100644 apps/sim/tools/jupyter/types.ts create mode 100644 apps/sim/tools/jupyter/upload_file.ts create mode 100644 apps/sim/tools/jupyter/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ea1eeea8a10..c213134b0e1 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -8412,3 +8412,72 @@ export function FlowiseIcon(props: SVGProps) { ) } + +export function JupyterIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index f5f3a71a108..38effba830e 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -115,6 +115,7 @@ import { JinaAIIcon, JiraIcon, JiraServiceManagementIcon, + JupyterIcon, KalshiIcon, KetchIcon, LangsmithIcon, @@ -364,6 +365,7 @@ export const blockTypeToIconMap: Record = { jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, + jupyter: JupyterIcon, kalshi: KalshiIcon, kalshi_v2: KalshiIcon, ketch: KetchIcon, diff --git a/apps/docs/content/docs/en/integrations/jupyter.mdx b/apps/docs/content/docs/en/integrations/jupyter.mdx new file mode 100644 index 00000000000..81c27d9aab5 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/jupyter.mdx @@ -0,0 +1,383 @@ +--- +title: Jupyter +description: Manage files, notebooks, kernels, and sessions on a Jupyter server +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels. + + + +## Actions + +### `jupyter_list_contents` + +List files, notebooks, and subdirectories at a path on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | No | Directory path to list, relative to the server root. Leave blank for root. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Directory entries at the requested path | +| ↳ `name` | string | Entry name | +| ↳ `path` | string | Entry path relative to server root | +| ↳ `type` | string | directory, file, or notebook | +| ↳ `writable` | boolean | Whether the entry is writable | +| ↳ `created` | string | Creation timestamp | +| ↳ `lastModified` | string | Last modified timestamp | +| ↳ `size` | number | Size in bytes | +| ↳ `mimetype` | string | MIME type \(files only\) | +| ↳ `format` | string | json, text, or base64 | +| `path` | string | The listed directory path | + +### `jupyter_get_content` + +Read a file or notebook from a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Path of the file or notebook to read, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | File or notebook name | +| `path` | string | Path relative to the server root | +| `mimetype` | string | MIME type of the content | +| `text` | string | Text content, for text files and notebooks \(JSON-stringified\) | +| `file` | file | Binary content stored as a file, for base64-format content | + +### `jupyter_create_file` + +Create a file, notebook, or directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Path to create, relative to the server root | +| `type` | string | Yes | Type of entry to create: file, notebook, or directory | +| `content` | string | No | Content to write. For a file, plain text. For a notebook, a JSON-stringified nbformat document \(defaults to an empty notebook\). Ignored for directories. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Created entry name | +| `path` | string | Created entry path | +| `type` | string | directory, file, or notebook | +| `createdAt` | string | Creation timestamp | +| `lastModified` | string | Last modified timestamp | + +### `jupyter_upload_file` + +Upload a file to a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Destination path, relative to the server root. A directory path ending in "/" uploads using the file name. | +| `file` | file | No | The file to upload \(UserFile object\) | +| `fileContent` | string | No | Legacy: base64 encoded file content | +| `fileName` | string | No | Optional filename override | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Uploaded file name | +| `path` | string | Uploaded file path | +| `size` | number | File size in bytes | +| `lastModified` | string | Last modified timestamp | + +### `jupyter_rename_content` + +Rename or move a file, notebook, or directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Current path of the entry, relative to the server root | +| `newPath` | string | Yes | New path for the entry, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | New entry name | +| `path` | string | New entry path | +| `lastModified` | string | Last modified timestamp | + +### `jupyter_delete_content` + +Delete a file, notebook, or directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Path of the entry to delete, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the entry was deleted | +| `path` | string | Deleted entry path | + +### `jupyter_copy_content` + +Duplicate a file or notebook into a directory on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Destination directory path, relative to the server root | +| `copyFromPath` | string | Yes | Path of the file or notebook to copy, relative to the server root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the copied entry | +| `path` | string | Path of the copied entry | +| `createdAt` | string | Creation timestamp | + +### `jupyter_list_kernels` + +List running kernels on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `kernels` | array | Running kernels | +| ↳ `id` | string | Kernel ID | +| ↳ `name` | string | Kernel spec name | +| ↳ `lastActivity` | string | Last activity timestamp | +| ↳ `executionState` | string | Kernel execution state | +| ↳ `connections` | number | Active connection count | + +### `jupyter_start_kernel` + +Start a new kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelName` | string | No | Kernel spec name to start \(e.g. python3\). Defaults to the server default. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Kernel ID | +| `name` | string | Kernel spec name | +| `lastActivity` | string | Last activity timestamp | +| `executionState` | string | Kernel execution state | +| `connections` | number | Active connection count | + +### `jupyter_stop_kernel` + +Shut down a running kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelId` | string | Yes | ID of the kernel to shut down | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the kernel was shut down | +| `kernelId` | string | Shut down kernel ID | + +### `jupyter_restart_kernel` + +Restart a running kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelId` | string | Yes | ID of the kernel to restart | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Kernel ID | +| `name` | string | Kernel spec name | +| `lastActivity` | string | Last activity timestamp | +| `executionState` | string | Kernel execution state | +| `connections` | number | Active connection count | + +### `jupyter_interrupt_kernel` + +Interrupt a running kernel on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `kernelId` | string | Yes | ID of the kernel to interrupt | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the interrupt was sent | +| `kernelId` | string | Interrupted kernel ID | + +### `jupyter_list_kernelspecs` + +List available kernel specs (languages/runtimes) on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `defaultKernelName` | string | Default kernel spec name | +| `kernelspecs` | array | Available kernel specs | +| ↳ `name` | string | Kernel spec name | +| ↳ `displayName` | string | Human-readable display name | +| ↳ `language` | string | Kernel language | +| ↳ `argv` | array | Launch command arguments | +| ↳ `interruptMode` | string | Interrupt mode | + +### `jupyter_list_sessions` + +List active sessions (notebook-to-kernel bindings) on a Jupyter server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessions` | array | Active sessions | +| ↳ `id` | string | Session ID | +| ↳ `path` | string | Notebook path bound to this session | +| ↳ `name` | string | Session name | +| ↳ `type` | string | Session type | +| ↳ `kernel` | object | Kernel bound to this session | +| ↳ `id` | string | Kernel ID | +| ↳ `name` | string | Kernel spec name | +| ↳ `lastActivity` | string | Last activity timestamp | +| ↳ `executionState` | string | Kernel execution state | +| ↳ `connections` | number | Active connection count | + +### `jupyter_create_session` + +Create a session that binds a notebook path to a (new or existing) kernel + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `path` | string | Yes | Notebook path to bind the session to, relative to the server root | +| `kernelName` | string | No | Kernel spec name to start for this session \(e.g. python3\) | +| `name` | string | No | Optional session name | +| `type` | string | No | Session type, defaults to 'notebook' | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Session ID | +| `path` | string | Notebook path bound to this session | +| `name` | string | Session name | +| `type` | string | Session type | +| `kernel` | object | Kernel bound to this session | +| ↳ `id` | string | Kernel ID | +| ↳ `name` | string | Kernel spec name | +| ↳ `lastActivity` | string | Last activity timestamp | +| ↳ `executionState` | string | Kernel execution state | +| ↳ `connections` | number | Active connection count | + +### `jupyter_delete_session` + +Delete a session on a Jupyter server (does not shut down its kernel) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | +| `token` | string | Yes | Jupyter server authentication token | +| `sessionId` | string | Yes | ID of the session to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the session was deleted | +| `sessionId` | string | Deleted session ID | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index ae36529830d..85ab2e676bc 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -116,6 +116,7 @@ "jina", "jira", "jira_service_management", + "jupyter", "kalshi", "ketch", "knowledge", diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts new file mode 100644 index 00000000000..a0890cebebb --- /dev/null +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -0,0 +1,119 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jupyterUploadContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JupyterUploadAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Jupyter upload attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(jupyterUploadContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + let fileBuffer: Buffer + let fileName: string + + if (data.file) { + const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + const userFile = userFiles[0] + + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + try { + const result = await downloadServableFileFromStorage(userFile, requestId, logger) + fileBuffer = result.buffer + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to download file') }, + { status: 500 } + ) + } + fileName = data.fileName || userFile.name + } else if (data.fileContent) { + fileBuffer = Buffer.from(data.fileContent, 'base64') + fileName = data.fileName || data.path.split('/').pop() || 'file' + } else { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const base = normalizeJupyterServerUrl(data.serverUrl) + const destinationPath = encodeJupyterPath( + data.path.endsWith('/') ? `${data.path}${fileName}` : data.path + ) + + const response = await fetch(`${base}/api/contents/${destinationPath}`, { + method: 'PUT', + headers: { + ...buildJupyterAuthHeaders(data.token), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'file', + format: 'base64', + content: fileBuffer.toString('base64'), + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Jupyter API error:`, { status: response.status, errorText }) + return NextResponse.json( + { success: false, error: `Jupyter API error: ${response.status} ${errorText}` }, + { status: response.status } + ) + } + + const uploaded = await response.json() + + logger.info(`[${requestId}] File uploaded to Jupyter: ${uploaded.path}`) + + return NextResponse.json({ + success: true, + output: { + name: uploaded.name ?? fileName, + path: uploaded.path ?? data.path, + size: uploaded.size ?? fileBuffer.length, + lastModified: uploaded.last_modified ?? null, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Unexpected error:`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Unknown error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/jupyter.ts b/apps/sim/blocks/blocks/jupyter.ts new file mode 100644 index 00000000000..6137ecd4753 --- /dev/null +++ b/apps/sim/blocks/blocks/jupyter.ts @@ -0,0 +1,411 @@ +import { JupyterIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' + +const PATH_OPERATIONS = [ + 'jupyter_list_contents', + 'jupyter_get_content', + 'jupyter_create_file', + 'jupyter_upload_file', + 'jupyter_rename_content', + 'jupyter_delete_content', + 'jupyter_copy_content', + 'jupyter_create_session', +] as const + +const REQUIRED_PATH_OPERATIONS = [ + 'jupyter_get_content', + 'jupyter_create_file', + 'jupyter_upload_file', + 'jupyter_rename_content', + 'jupyter_delete_content', + 'jupyter_copy_content', + 'jupyter_create_session', +] as const + +const KERNEL_ID_OPERATIONS = [ + 'jupyter_stop_kernel', + 'jupyter_restart_kernel', + 'jupyter_interrupt_kernel', +] as const + +export const JupyterBlock: BlockConfig = { + type: 'jupyter', + name: 'Jupyter', + description: 'Manage files, notebooks, kernels, and sessions on a Jupyter server', + longDescription: + 'Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels.', + docsLink: 'https://docs.sim.ai/integrations/jupyter', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#FFFFFF', + icon: JupyterIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Contents', id: 'jupyter_list_contents' }, + { label: 'Get Content', id: 'jupyter_get_content' }, + { label: 'Create File', id: 'jupyter_create_file' }, + { label: 'Upload File', id: 'jupyter_upload_file' }, + { label: 'Rename Content', id: 'jupyter_rename_content' }, + { label: 'Delete Content', id: 'jupyter_delete_content' }, + { label: 'Copy Content', id: 'jupyter_copy_content' }, + { label: 'List Kernels', id: 'jupyter_list_kernels' }, + { label: 'Start Kernel', id: 'jupyter_start_kernel' }, + { label: 'Stop Kernel', id: 'jupyter_stop_kernel' }, + { label: 'Restart Kernel', id: 'jupyter_restart_kernel' }, + { label: 'Interrupt Kernel', id: 'jupyter_interrupt_kernel' }, + { label: 'List Kernel Specs', id: 'jupyter_list_kernelspecs' }, + { label: 'List Sessions', id: 'jupyter_list_sessions' }, + { label: 'Create Session', id: 'jupyter_create_session' }, + { label: 'Delete Session', id: 'jupyter_delete_session' }, + ], + value: () => 'jupyter_list_contents', + }, + { + id: 'serverUrl', + title: 'Server URL', + type: 'short-input', + placeholder: 'http://localhost:8888', + required: true, + }, + { + id: 'token', + title: 'Token', + type: 'short-input', + placeholder: 'Enter your Jupyter server token', + password: true, + required: true, + }, + + // Path (shared across contents operations) + { + id: 'path', + title: 'Path', + type: 'short-input', + placeholder: 'notebooks/analysis.ipynb', + condition: { field: 'operation', value: [...PATH_OPERATIONS] }, + required: { field: 'operation', value: [...REQUIRED_PATH_OPERATIONS] }, + }, + + // Create File + { + id: 'type', + title: 'Type', + type: 'dropdown', + options: [ + { label: 'File', id: 'file' }, + { label: 'Notebook', id: 'notebook' }, + { label: 'Directory', id: 'directory' }, + ], + value: () => 'file', + condition: { field: 'operation', value: 'jupyter_create_file' }, + required: { field: 'operation', value: 'jupyter_create_file' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + placeholder: 'File text, or a JSON-stringified notebook document', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_create_file' }, + }, + + // Upload File + { + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + placeholder: 'Upload file to send to Jupyter', + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'jupyter_upload_file' }, + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + { + id: 'fileRef', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + placeholder: 'Reference file from previous blocks', + mode: 'advanced', + required: { field: 'operation', value: 'jupyter_upload_file' }, + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + { + id: 'uploadFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional filename override', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, + + // Rename Content + { + id: 'newPath', + title: 'New Path', + type: 'short-input', + placeholder: 'notebooks/renamed.ipynb', + condition: { field: 'operation', value: 'jupyter_rename_content' }, + required: { field: 'operation', value: 'jupyter_rename_content' }, + }, + + // Copy Content + { + id: 'copyFromPath', + title: 'Copy From Path', + type: 'short-input', + placeholder: 'notebooks/source.ipynb', + condition: { field: 'operation', value: 'jupyter_copy_content' }, + required: { field: 'operation', value: 'jupyter_copy_content' }, + }, + + // Kernels + { + id: 'kernelName', + title: 'Kernel Name', + type: 'short-input', + placeholder: 'python3', + condition: { field: 'operation', value: ['jupyter_start_kernel', 'jupyter_create_session'] }, + }, + { + id: 'kernelId', + title: 'Kernel ID', + type: 'short-input', + placeholder: 'Enter kernel ID', + condition: { field: 'operation', value: [...KERNEL_ID_OPERATIONS] }, + required: { field: 'operation', value: [...KERNEL_ID_OPERATIONS] }, + }, + + // Sessions + { + id: 'sessionName', + title: 'Session Name', + type: 'short-input', + placeholder: 'Optional session name', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_create_session' }, + }, + { + id: 'sessionType', + title: 'Session Type', + type: 'short-input', + placeholder: 'notebook', + mode: 'advanced', + condition: { field: 'operation', value: 'jupyter_create_session' }, + }, + { + id: 'sessionId', + title: 'Session ID', + type: 'short-input', + placeholder: 'Enter session ID', + condition: { field: 'operation', value: 'jupyter_delete_session' }, + required: { field: 'operation', value: 'jupyter_delete_session' }, + }, + ], + + tools: { + access: [ + 'jupyter_list_contents', + 'jupyter_get_content', + 'jupyter_create_file', + 'jupyter_upload_file', + 'jupyter_rename_content', + 'jupyter_delete_content', + 'jupyter_copy_content', + 'jupyter_list_kernels', + 'jupyter_start_kernel', + 'jupyter_stop_kernel', + 'jupyter_restart_kernel', + 'jupyter_interrupt_kernel', + 'jupyter_list_kernelspecs', + 'jupyter_list_sessions', + 'jupyter_create_session', + 'jupyter_delete_session', + ], + config: { + tool: (params) => params.operation as string, + params: (params) => { + const normalizedFile = normalizeFileInput(params.file, { single: true }) + if (normalizedFile) { + params.file = normalizedFile + } + + const { operation, ...rest } = params + + const baseParams: Record = { + serverUrl: rest.serverUrl, + token: rest.token, + } + + switch (operation) { + case 'jupyter_list_contents': + if (rest.path) baseParams.path = rest.path + break + case 'jupyter_get_content': + case 'jupyter_delete_content': + baseParams.path = rest.path + break + case 'jupyter_create_file': + baseParams.path = rest.path + baseParams.type = rest.type + if (rest.content) baseParams.content = rest.content + break + case 'jupyter_upload_file': + baseParams.path = rest.path + baseParams.file = rest.file + if (rest.uploadFileName) baseParams.fileName = rest.uploadFileName + break + case 'jupyter_rename_content': + baseParams.path = rest.path + baseParams.newPath = rest.newPath + break + case 'jupyter_copy_content': + baseParams.path = rest.path + baseParams.copyFromPath = rest.copyFromPath + break + case 'jupyter_start_kernel': + if (rest.kernelName) baseParams.kernelName = rest.kernelName + break + case 'jupyter_stop_kernel': + case 'jupyter_restart_kernel': + case 'jupyter_interrupt_kernel': + baseParams.kernelId = rest.kernelId + break + case 'jupyter_create_session': + baseParams.path = rest.path + if (rest.kernelName) baseParams.kernelName = rest.kernelName + if (rest.sessionName) baseParams.name = rest.sessionName + if (rest.sessionType) baseParams.type = rest.sessionType + break + case 'jupyter_delete_session': + baseParams.sessionId = rest.sessionId + break + } + + return baseParams + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + serverUrl: { type: 'string', description: 'Jupyter server base URL' }, + token: { type: 'string', description: 'Jupyter server authentication token' }, + path: { type: 'string', description: 'Path relative to the server root' }, + type: { type: 'string', description: 'file, notebook, or directory' }, + content: { type: 'string', description: 'File or notebook content' }, + file: { type: 'json', description: 'File to upload (canonical param)' }, + uploadFileName: { type: 'string', description: 'Optional filename override' }, + newPath: { type: 'string', description: 'New path for rename/move' }, + copyFromPath: { type: 'string', description: 'Source path to copy from' }, + kernelName: { type: 'string', description: 'Kernel spec name' }, + kernelId: { type: 'string', description: 'Kernel ID' }, + sessionName: { type: 'string', description: 'Session name' }, + sessionType: { type: 'string', description: 'Session type' }, + sessionId: { type: 'string', description: 'Session ID' }, + }, + + outputs: { + items: 'json', + path: 'string', + name: 'string', + type: 'string', + mimetype: 'string', + text: 'string', + file: 'file', + size: 'number', + createdAt: 'string', + lastModified: 'string', + success: 'boolean', + id: 'string', + lastActivity: 'string', + executionState: 'string', + connections: 'number', + kernels: 'json', + defaultKernelName: 'string', + kernelspecs: 'json', + sessions: 'json', + kernel: 'json', + kernelId: 'string', + sessionId: 'string', + }, +} + +export const JupyterBlockMeta = { + tags: ['automation', 'data-analytics'], + templates: [ + { + icon: JupyterIcon, + title: 'Jupyter notebook creator', + prompt: + 'Build a workflow that generates a Jupyter notebook from a data analysis request and creates it on a self-hosted Jupyter server.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Upload datasets to Jupyter', + prompt: + 'Build a workflow that uploads a file from a Table row to a Jupyter server as a dataset for later analysis.', + modules: ['tables', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Jupyter kernel health check', + prompt: + 'Build a scheduled workflow that lists running Jupyter kernels, restarts any kernel stuck in a busy state, and posts a summary to Slack.', + modules: ['scheduled', 'workflows'], + category: 'operations', + tags: ['automation', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: JupyterIcon, + title: 'Notebook directory sync report', + prompt: + 'Build a workflow that lists the contents of a Jupyter server directory and writes a summary of files and notebooks to a Table.', + modules: ['tables', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Read notebook and summarize', + prompt: + 'Build a workflow that reads a Jupyter notebook, has an agent summarize its cells, and sends the summary in Chat.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Provision a Jupyter session', + prompt: + 'Build a workflow that creates a Jupyter session bound to a new notebook and a fresh Python kernel whenever a new project request comes in.', + modules: ['workflows'], + category: 'engineering', + tags: ['automation'], + }, + { + icon: JupyterIcon, + title: 'Archive and clean up notebooks', + prompt: + 'Build a scheduled workflow that copies old notebooks on a Jupyter server into an archive directory, then deletes the originals.', + modules: ['scheduled', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index fd1d8de138a..89919671fc9 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -156,6 +156,7 @@ import { JiraServiceManagementBlock, JiraServiceManagementBlockMeta, } from '@/blocks/blocks/jira_service_management' +import { JupyterBlock, JupyterBlockMeta } from '@/blocks/blocks/jupyter' import { KalshiBlock, KalshiBlockMeta, @@ -477,6 +478,7 @@ export const BLOCK_REGISTRY: Record = { jina: JinaBlock, jira: JiraBlock, jira_service_management: JiraServiceManagementBlock, + jupyter: JupyterBlock, kalshi: KalshiBlock, kalshi_v2: KalshiV2Block, ketch: KetchBlock, @@ -769,6 +771,7 @@ export const BLOCK_META_REGISTRY: Record = { jina: JinaBlockMeta, jira: JiraBlockMeta, jira_service_management: JiraServiceManagementBlockMeta, + jupyter: JupyterBlockMeta, kalshi: KalshiBlockMeta, kalshi_v2: KalshiV2BlockMeta, ketch: KetchBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index ea1eeea8a10..c213134b0e1 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8412,3 +8412,72 @@ export function FlowiseIcon(props: SVGProps) { ) } + +export function JupyterIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index c9f98526c5a..68beabcf7c6 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -107,6 +107,15 @@ export const dropboxUploadBodySchema = z.object({ mute: z.boolean().optional().nullable(), }) +export const jupyterUploadBodySchema = z.object({ + serverUrl: z.string().min(1, 'Server URL is required'), + token: z.string().min(1, 'Token is required'), + path: z.string().min(1, 'Destination path is required'), + file: FileInputSchema.optional().nullable(), + fileContent: z.string().optional().nullable(), + fileName: z.string().optional().nullable(), +}) + export const wordpressUploadBodySchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), siteId: z.string().min(1, 'Site ID is required'), @@ -483,6 +492,13 @@ export const dropboxUploadContract = defineRouteContract({ response: { mode: 'json', schema: jsonResponseSchema }, }) +export const jupyterUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/jupyter/upload', + body: jupyterUploadBodySchema, + response: { mode: 'json', schema: jsonResponseSchema }, +}) + export const wordpressUploadContract = defineRouteContract({ method: 'POST', path: '/api/tools/wordpress/upload', @@ -733,6 +749,8 @@ export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput export type DropboxUploadResponse = ContractJsonResponse +export type JupyterUploadBody = ContractBodyInput +export type JupyterUploadResponse = ContractJsonResponse export type WordPressUploadBody = ContractBodyInput export type WordPressUploadResponse = ContractJsonResponse export type SftpDownloadBody = ContractBodyInput diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index e9df1348307..f7c5ddf8d19 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -114,6 +114,7 @@ import { JinaAIIcon, JiraIcon, JiraServiceManagementIcon, + JupyterIcon, KalshiIcon, KetchIcon, LangsmithIcon, @@ -349,6 +350,7 @@ export const blockTypeToIconMap: Record = { jina: JinaAIIcon, jira: JiraIcon, jira_service_management: JiraServiceManagementIcon, + jupyter: JupyterIcon, kalshi_v2: KalshiIcon, ketch: KetchIcon, knowledge: PackageSearchIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 9d543582283..8e741d71e57 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-07", + "updatedAt": "2026-07-09", "integrations": [ { "type": "onepassword", @@ -9749,6 +9749,89 @@ "integrationType": "support", "tags": ["customer-support", "ticketing", "incident-management"] }, + { + "type": "jupyter", + "slug": "jupyter", + "name": "Jupyter", + "description": "Manage files, notebooks, kernels, and sessions on a Jupyter server", + "longDescription": "Integrate a self-hosted Jupyter server into the workflow. Browse, read, create, upload, rename, copy, and delete files and notebooks; start, stop, restart, and interrupt kernels; and manage sessions that bind notebooks to kernels.", + "bgColor": "#FFFFFF", + "iconName": "JupyterIcon", + "docsUrl": "https://docs.sim.ai/integrations/jupyter", + "operations": [ + { + "name": "List Contents", + "description": "List files, notebooks, and subdirectories at a path on a Jupyter server" + }, + { + "name": "Get Content", + "description": "Read a file or notebook from a Jupyter server" + }, + { + "name": "Create File", + "description": "Create a file, notebook, or directory on a Jupyter server" + }, + { + "name": "Upload File", + "description": "Upload a file to a Jupyter server" + }, + { + "name": "Rename Content", + "description": "Rename or move a file, notebook, or directory on a Jupyter server" + }, + { + "name": "Delete Content", + "description": "Delete a file, notebook, or directory on a Jupyter server" + }, + { + "name": "Copy Content", + "description": "Duplicate a file or notebook into a directory on a Jupyter server" + }, + { + "name": "List Kernels", + "description": "List running kernels on a Jupyter server" + }, + { + "name": "Start Kernel", + "description": "Start a new kernel on a Jupyter server" + }, + { + "name": "Stop Kernel", + "description": "Shut down a running kernel on a Jupyter server" + }, + { + "name": "Restart Kernel", + "description": "Restart a running kernel on a Jupyter server" + }, + { + "name": "Interrupt Kernel", + "description": "Interrupt a running kernel on a Jupyter server" + }, + { + "name": "List Kernel Specs", + "description": "List available kernel specs (languages/runtimes) on a Jupyter server" + }, + { + "name": "List Sessions", + "description": "List active sessions (notebook-to-kernel bindings) on a Jupyter server" + }, + { + "name": "Create Session", + "description": "Create a session that binds a notebook path to a (new or existing) kernel" + }, + { + "name": "Delete Session", + "description": "Delete a session on a Jupyter server (does not shut down its kernel)" + } + ], + "operationCount": 16, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "devops", + "tags": ["automation", "data-analytics"] + }, { "type": "kalshi_v2", "slug": "kalshi", diff --git a/apps/sim/tools/jupyter/copy_content.ts b/apps/sim/tools/jupyter/copy_content.ts new file mode 100644 index 00000000000..8615c9716ce --- /dev/null +++ b/apps/sim/tools/jupyter/copy_content.ts @@ -0,0 +1,86 @@ +import type { JupyterCopyContentParams, JupyterCopyContentResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterCopyContentTool: ToolConfig< + JupyterCopyContentParams, + JupyterCopyContentResponse +> = { + id: 'jupyter_copy_content', + name: 'Jupyter Copy Content', + description: 'Duplicate a file or notebook into a directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Destination directory path, relative to the server root', + }, + copyFromPath: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path of the file or notebook to copy, relative to the server root', + }, + }, + + request: { + url: (params) => { + const base = normalizeJupyterServerUrl(params.serverUrl) + const path = encodeJupyterPath(params.path) + return `${base}/api/contents/${path}` + }, + method: 'POST', + headers: (params) => ({ + ...buildJupyterAuthHeaders(params.token), + 'Content-Type': 'application/json', + }), + body: (params) => ({ copy_from: params.copyFromPath }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { name: '', path: params?.path ?? '', createdAt: null }, + } + } + + const data = await response.json() + + return { + success: true, + output: { + name: (data.name as string | undefined) ?? '', + path: (data.path as string | undefined) ?? params?.path ?? '', + createdAt: (data.created as string | undefined) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the copied entry' }, + path: { type: 'string', description: 'Path of the copied entry' }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/create_file.ts b/apps/sim/tools/jupyter/create_file.ts new file mode 100644 index 00000000000..853f2318ea6 --- /dev/null +++ b/apps/sim/tools/jupyter/create_file.ts @@ -0,0 +1,121 @@ +import type { JupyterCreateFileParams, JupyterCreateFileResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +const EMPTY_NOTEBOOK = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 } + +export const jupyterCreateFileTool: ToolConfig = + { + id: 'jupyter_create_file', + name: 'Jupyter Create File', + description: 'Create a file, notebook, or directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to create, relative to the server root', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of entry to create: file, notebook, or directory', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Content to write. For a file, plain text. For a notebook, a JSON-stringified nbformat document (defaults to an empty notebook). Ignored for directories.', + }, + }, + + request: { + url: (params) => { + const base = normalizeJupyterServerUrl(params.serverUrl) + const path = encodeJupyterPath(params.path) + return `${base}/api/contents/${path}` + }, + method: 'PUT', + headers: (params) => ({ + ...buildJupyterAuthHeaders(params.token), + 'Content-Type': 'application/json', + }), + body: (params) => { + if (params.type === 'directory') { + return { type: 'directory' } + } + + if (params.type === 'notebook') { + let content: unknown = EMPTY_NOTEBOOK + if (params.content) { + try { + content = JSON.parse(params.content) + } catch { + content = EMPTY_NOTEBOOK + } + } + return { type: 'notebook', format: 'json', content } + } + + return { type: 'file', format: 'text', content: params.content ?? '' } + }, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { + name: '', + path: params?.path ?? '', + type: params?.type ?? 'file', + createdAt: null, + lastModified: null, + }, + } + } + + const data = await response.json() + + return { + success: true, + output: { + name: (data.name as string | undefined) ?? '', + path: (data.path as string | undefined) ?? params?.path ?? '', + type: (data.type as 'directory' | 'file' | 'notebook' | undefined) ?? 'file', + createdAt: (data.created as string | undefined) ?? null, + lastModified: (data.last_modified as string | undefined) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Created entry name' }, + path: { type: 'string', description: 'Created entry path' }, + type: { type: 'string', description: 'directory, file, or notebook' }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + lastModified: { type: 'string', description: 'Last modified timestamp', optional: true }, + }, + } diff --git a/apps/sim/tools/jupyter/create_session.ts b/apps/sim/tools/jupyter/create_session.ts new file mode 100644 index 00000000000..2f188783d24 --- /dev/null +++ b/apps/sim/tools/jupyter/create_session.ts @@ -0,0 +1,111 @@ +import type { + JupyterCreateSessionParams, + JupyterCreateSessionResponse, +} from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + mapJupyterSession, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterCreateSessionTool: ToolConfig< + JupyterCreateSessionParams, + JupyterCreateSessionResponse +> = { + id: 'jupyter_create_session', + name: 'Jupyter Create Session', + description: 'Create a session that binds a notebook path to a (new or existing) kernel', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Notebook path to bind the session to, relative to the server root', + }, + kernelName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Kernel spec name to start for this session (e.g. python3)', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional session name', + }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Session type, defaults to 'notebook'", + }, + }, + + request: { + url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions`, + method: 'POST', + headers: (params) => ({ + ...buildJupyterAuthHeaders(params.token), + 'Content-Type': 'application/json', + }), + body: (params) => ({ + path: params.path, + name: params.name, + type: params.type || 'notebook', + ...(params.kernelName ? { kernel: { name: params.kernelName } } : {}), + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jupyter API error: ${response.status} ${errorText}`) + } + + const data = await response.json() + + return { + success: true, + output: mapJupyterSession(data), + } + }, + + outputs: { + id: { type: 'string', description: 'Session ID' }, + path: { type: 'string', description: 'Notebook path bound to this session' }, + name: { type: 'string', description: 'Session name' }, + type: { type: 'string', description: 'Session type' }, + kernel: { + type: 'object', + description: 'Kernel bound to this session', + optional: true, + properties: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { + type: 'string', + description: 'Kernel execution state', + optional: true, + }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/delete_content.ts b/apps/sim/tools/jupyter/delete_content.ts new file mode 100644 index 00000000000..0dd46e7bf7f --- /dev/null +++ b/apps/sim/tools/jupyter/delete_content.ts @@ -0,0 +1,72 @@ +import type { + JupyterDeleteContentParams, + JupyterDeleteContentResponse, +} from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterDeleteContentTool: ToolConfig< + JupyterDeleteContentParams, + JupyterDeleteContentResponse +> = { + id: 'jupyter_delete_content', + name: 'Jupyter Delete Content', + description: 'Delete a file, notebook, or directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path of the entry to delete, relative to the server root', + }, + }, + + request: { + url: (params) => { + const base = normalizeJupyterServerUrl(params.serverUrl) + const path = encodeJupyterPath(params.path) + return `${base}/api/contents/${path}` + }, + method: 'DELETE', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, path: params?.path ?? '' }, + } + } + + return { + success: true, + output: { success: true, path: params?.path ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the entry was deleted' }, + path: { type: 'string', description: 'Deleted entry path' }, + }, +} diff --git a/apps/sim/tools/jupyter/delete_session.ts b/apps/sim/tools/jupyter/delete_session.ts new file mode 100644 index 00000000000..58bcf842be8 --- /dev/null +++ b/apps/sim/tools/jupyter/delete_session.ts @@ -0,0 +1,65 @@ +import type { + JupyterDeleteSessionParams, + JupyterDeleteSessionResponse, +} from '@/tools/jupyter/types' +import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterDeleteSessionTool: ToolConfig< + JupyterDeleteSessionParams, + JupyterDeleteSessionResponse +> = { + id: 'jupyter_delete_session', + name: 'Jupyter Delete Session', + description: 'Delete a session on a Jupyter server (does not shut down its kernel)', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + sessionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the session to delete', + }, + }, + + request: { + url: (params) => + `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions/${encodeURIComponent(params.sessionId)}`, + method: 'DELETE', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, sessionId: params?.sessionId ?? '' }, + } + } + + return { + success: true, + output: { success: true, sessionId: params?.sessionId ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the session was deleted' }, + sessionId: { type: 'string', description: 'Deleted session ID' }, + }, +} diff --git a/apps/sim/tools/jupyter/get_content.ts b/apps/sim/tools/jupyter/get_content.ts new file mode 100644 index 00000000000..e30bacb6e69 --- /dev/null +++ b/apps/sim/tools/jupyter/get_content.ts @@ -0,0 +1,119 @@ +import type { JupyterGetContentParams, JupyterGetContentResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterGetContentTool: ToolConfig = + { + id: 'jupyter_get_content', + name: 'Jupyter Get Content', + description: 'Read a file or notebook from a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path of the file or notebook to read, relative to the server root', + }, + }, + + request: { + url: (params) => { + const base = normalizeJupyterServerUrl(params.serverUrl) + const path = encodeJupyterPath(params.path) + return `${base}/api/contents/${path}?content=1` + }, + method: 'GET', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { + name: '', + path: params?.path ?? '', + mimetype: null, + text: null, + file: null, + }, + } + } + + const data = await response.json() + const format = data.format as string | undefined + const name = (data.name as string | undefined) ?? params?.path?.split('/').pop() ?? 'file' + const mimetype = (data.mimetype as string | undefined) ?? null + + if (format === 'base64' && typeof data.content === 'string') { + const buffer = Buffer.from(data.content, 'base64') + return { + success: true, + output: { + name, + path: (data.path as string | undefined) ?? params?.path ?? '', + mimetype, + text: null, + file: { + name, + mimeType: mimetype ?? 'application/octet-stream', + data: data.content, + size: buffer.length, + }, + }, + } + } + + const text = + format === 'json' || typeof data.content === 'object' + ? JSON.stringify(data.content) + : ((data.content as string | undefined) ?? null) + + return { + success: true, + output: { + name, + path: (data.path as string | undefined) ?? params?.path ?? '', + mimetype, + text, + file: null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'File or notebook name' }, + path: { type: 'string', description: 'Path relative to the server root' }, + mimetype: { type: 'string', description: 'MIME type of the content', optional: true }, + text: { + type: 'string', + description: 'Text content, for text files and notebooks (JSON-stringified)', + optional: true, + }, + file: { + type: 'file', + description: 'Binary content stored as a file, for base64-format content', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/jupyter/index.ts b/apps/sim/tools/jupyter/index.ts new file mode 100644 index 00000000000..9ddfd7e8021 --- /dev/null +++ b/apps/sim/tools/jupyter/index.ts @@ -0,0 +1,16 @@ +export { jupyterCopyContentTool } from '@/tools/jupyter/copy_content' +export { jupyterCreateFileTool } from '@/tools/jupyter/create_file' +export { jupyterCreateSessionTool } from '@/tools/jupyter/create_session' +export { jupyterDeleteContentTool } from '@/tools/jupyter/delete_content' +export { jupyterDeleteSessionTool } from '@/tools/jupyter/delete_session' +export { jupyterGetContentTool } from '@/tools/jupyter/get_content' +export { jupyterInterruptKernelTool } from '@/tools/jupyter/interrupt_kernel' +export { jupyterListContentsTool } from '@/tools/jupyter/list_contents' +export { jupyterListKernelsTool } from '@/tools/jupyter/list_kernels' +export { jupyterListKernelspecsTool } from '@/tools/jupyter/list_kernelspecs' +export { jupyterListSessionsTool } from '@/tools/jupyter/list_sessions' +export { jupyterRenameContentTool } from '@/tools/jupyter/rename_content' +export { jupyterRestartKernelTool } from '@/tools/jupyter/restart_kernel' +export { jupyterStartKernelTool } from '@/tools/jupyter/start_kernel' +export { jupyterStopKernelTool } from '@/tools/jupyter/stop_kernel' +export { jupyterUploadFileTool } from '@/tools/jupyter/upload_file' diff --git a/apps/sim/tools/jupyter/interrupt_kernel.ts b/apps/sim/tools/jupyter/interrupt_kernel.ts new file mode 100644 index 00000000000..d02c544e1bc --- /dev/null +++ b/apps/sim/tools/jupyter/interrupt_kernel.ts @@ -0,0 +1,65 @@ +import type { + JupyterInterruptKernelParams, + JupyterInterruptKernelResponse, +} from '@/tools/jupyter/types' +import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterInterruptKernelTool: ToolConfig< + JupyterInterruptKernelParams, + JupyterInterruptKernelResponse +> = { + id: 'jupyter_interrupt_kernel', + name: 'Jupyter Interrupt Kernel', + description: 'Interrupt a running kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the kernel to interrupt', + }, + }, + + request: { + url: (params) => + `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}/interrupt`, + method: 'POST', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, kernelId: params?.kernelId ?? '' }, + } + } + + return { + success: true, + output: { success: true, kernelId: params?.kernelId ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the interrupt was sent' }, + kernelId: { type: 'string', description: 'Interrupted kernel ID' }, + }, +} diff --git a/apps/sim/tools/jupyter/list_contents.ts b/apps/sim/tools/jupyter/list_contents.ts new file mode 100644 index 00000000000..ce5da44a6b0 --- /dev/null +++ b/apps/sim/tools/jupyter/list_contents.ts @@ -0,0 +1,109 @@ +import type { JupyterListContentsParams, JupyterListContentsResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListContentsTool: ToolConfig< + JupyterListContentsParams, + JupyterListContentsResponse +> = { + id: 'jupyter_list_contents', + name: 'Jupyter List Contents', + description: 'List files, notebooks, and subdirectories at a path on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Directory path to list, relative to the server root. Leave blank for root.', + }, + }, + + request: { + url: (params) => { + const base = normalizeJupyterServerUrl(params.serverUrl) + const path = encodeJupyterPath(params.path) + return `${base}/api/contents/${path}?type=directory` + }, + method: 'GET', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { items: [], path: params?.path ?? '' }, + } + } + + const data = await response.json() + const items = Array.isArray(data.content) ? data.content : [] + + return { + success: true, + output: { + items: items.map((item: Record) => ({ + name: item.name, + path: item.path, + type: item.type, + writable: Boolean(item.writable), + created: (item.created as string | undefined) ?? null, + lastModified: (item.last_modified as string | undefined) ?? null, + size: (item.size as number | undefined) ?? null, + mimetype: (item.mimetype as string | undefined) ?? null, + format: (item.format as string | undefined) ?? null, + })), + path: (data.path as string | undefined) ?? params?.path ?? '', + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'Directory entries at the requested path', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Entry name' }, + path: { type: 'string', description: 'Entry path relative to server root' }, + type: { type: 'string', description: 'directory, file, or notebook' }, + writable: { type: 'boolean', description: 'Whether the entry is writable' }, + created: { type: 'string', description: 'Creation timestamp', optional: true }, + lastModified: { + type: 'string', + description: 'Last modified timestamp', + optional: true, + }, + size: { type: 'number', description: 'Size in bytes', optional: true }, + mimetype: { type: 'string', description: 'MIME type (files only)', optional: true }, + format: { type: 'string', description: 'json, text, or base64', optional: true }, + }, + }, + }, + path: { + type: 'string', + description: 'The listed directory path', + }, + }, +} diff --git a/apps/sim/tools/jupyter/list_kernels.ts b/apps/sim/tools/jupyter/list_kernels.ts new file mode 100644 index 00000000000..14a6e635f45 --- /dev/null +++ b/apps/sim/tools/jupyter/list_kernels.ts @@ -0,0 +1,74 @@ +import type { JupyterListKernelsParams, JupyterListKernelsResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + mapJupyterKernel, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListKernelsTool: ToolConfig< + JupyterListKernelsParams, + JupyterListKernelsResponse +> = { + id: 'jupyter_list_kernels', + name: 'Jupyter List Kernels', + description: 'List running kernels on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + }, + + request: { + url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels`, + method: 'GET', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { kernels: [] }, + } + } + + const data = await response.json() + const kernels = Array.isArray(data) ? data : [] + + return { + success: true, + output: { kernels: kernels.map(mapJupyterKernel) }, + } + }, + + outputs: { + kernels: { + type: 'array', + description: 'Running kernels', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { type: 'string', description: 'Kernel execution state', optional: true }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/list_kernelspecs.ts b/apps/sim/tools/jupyter/list_kernelspecs.ts new file mode 100644 index 00000000000..90c834d2b40 --- /dev/null +++ b/apps/sim/tools/jupyter/list_kernelspecs.ts @@ -0,0 +1,88 @@ +import type { + JupyterListKernelspecsParams, + JupyterListKernelspecsResponse, +} from '@/tools/jupyter/types' +import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListKernelspecsTool: ToolConfig< + JupyterListKernelspecsParams, + JupyterListKernelspecsResponse +> = { + id: 'jupyter_list_kernelspecs', + name: 'Jupyter List Kernel Specs', + description: 'List available kernel specs (languages/runtimes) on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + }, + + request: { + url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernelspecs`, + method: 'GET', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { defaultKernelName: null, kernelspecs: [] }, + } + } + + const data = await response.json() + const specs = data.kernelspecs && typeof data.kernelspecs === 'object' ? data.kernelspecs : {} + + return { + success: true, + output: { + defaultKernelName: (data.default as string | undefined) ?? null, + kernelspecs: Object.entries(specs as Record>).map( + ([name, entry]) => { + const spec = (entry.spec as Record | undefined) ?? {} + return { + name: (entry.name as string | undefined) ?? name, + displayName: (spec.display_name as string | undefined) ?? name, + language: (spec.language as string | undefined) ?? null, + argv: Array.isArray(spec.argv) ? (spec.argv as string[]) : [], + interruptMode: (spec.interrupt_mode as string | undefined) ?? null, + } + } + ), + }, + } + }, + + outputs: { + defaultKernelName: { type: 'string', description: 'Default kernel spec name', optional: true }, + kernelspecs: { + type: 'array', + description: 'Available kernel specs', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Kernel spec name' }, + displayName: { type: 'string', description: 'Human-readable display name' }, + language: { type: 'string', description: 'Kernel language', optional: true }, + argv: { type: 'array', description: 'Launch command arguments' }, + interruptMode: { type: 'string', description: 'Interrupt mode', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/list_sessions.ts b/apps/sim/tools/jupyter/list_sessions.ts new file mode 100644 index 00000000000..7364bd08314 --- /dev/null +++ b/apps/sim/tools/jupyter/list_sessions.ts @@ -0,0 +1,97 @@ +import type { JupyterListSessionsParams, JupyterListSessionsResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + mapJupyterSession, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterListSessionsTool: ToolConfig< + JupyterListSessionsParams, + JupyterListSessionsResponse +> = { + id: 'jupyter_list_sessions', + name: 'Jupyter List Sessions', + description: 'List active sessions (notebook-to-kernel bindings) on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + }, + + request: { + url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions`, + method: 'GET', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { sessions: [] }, + } + } + + const data = await response.json() + const sessions = Array.isArray(data) ? data : [] + + return { + success: true, + output: { sessions: sessions.map(mapJupyterSession) }, + } + }, + + outputs: { + sessions: { + type: 'array', + description: 'Active sessions', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Session ID' }, + path: { type: 'string', description: 'Notebook path bound to this session' }, + name: { type: 'string', description: 'Session name' }, + type: { type: 'string', description: 'Session type' }, + kernel: { + type: 'object', + description: 'Kernel bound to this session', + optional: true, + properties: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { + type: 'string', + description: 'Last activity timestamp', + optional: true, + }, + executionState: { + type: 'string', + description: 'Kernel execution state', + optional: true, + }, + connections: { + type: 'number', + description: 'Active connection count', + optional: true, + }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jupyter/rename_content.ts b/apps/sim/tools/jupyter/rename_content.ts new file mode 100644 index 00000000000..cb0aaf289ad --- /dev/null +++ b/apps/sim/tools/jupyter/rename_content.ts @@ -0,0 +1,89 @@ +import type { + JupyterRenameContentParams, + JupyterRenameContentResponse, +} from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + encodeJupyterPath, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterRenameContentTool: ToolConfig< + JupyterRenameContentParams, + JupyterRenameContentResponse +> = { + id: 'jupyter_rename_content', + name: 'Jupyter Rename Content', + description: 'Rename or move a file, notebook, or directory on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Current path of the entry, relative to the server root', + }, + newPath: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New path for the entry, relative to the server root', + }, + }, + + request: { + url: (params) => { + const base = normalizeJupyterServerUrl(params.serverUrl) + const path = encodeJupyterPath(params.path) + return `${base}/api/contents/${path}` + }, + method: 'PATCH', + headers: (params) => ({ + ...buildJupyterAuthHeaders(params.token), + 'Content-Type': 'application/json', + }), + body: (params) => ({ path: params.newPath }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { name: '', path: params?.newPath ?? '', lastModified: null }, + } + } + + const data = await response.json() + + return { + success: true, + output: { + name: (data.name as string | undefined) ?? '', + path: (data.path as string | undefined) ?? params?.newPath ?? '', + lastModified: (data.last_modified as string | undefined) ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'New entry name' }, + path: { type: 'string', description: 'New entry path' }, + lastModified: { type: 'string', description: 'Last modified timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/restart_kernel.ts b/apps/sim/tools/jupyter/restart_kernel.ts new file mode 100644 index 00000000000..24afe1407eb --- /dev/null +++ b/apps/sim/tools/jupyter/restart_kernel.ts @@ -0,0 +1,70 @@ +import type { + JupyterRestartKernelParams, + JupyterRestartKernelResponse, +} from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + mapJupyterKernel, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterRestartKernelTool: ToolConfig< + JupyterRestartKernelParams, + JupyterRestartKernelResponse +> = { + id: 'jupyter_restart_kernel', + name: 'Jupyter Restart Kernel', + description: 'Restart a running kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the kernel to restart', + }, + }, + + request: { + url: (params) => + `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}/restart`, + method: 'POST', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jupyter API error: ${response.status} ${errorText}`) + } + + const data = await response.json() + + return { + success: true, + output: mapJupyterKernel(data), + } + }, + + outputs: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { type: 'string', description: 'Kernel execution state', optional: true }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/start_kernel.ts b/apps/sim/tools/jupyter/start_kernel.ts new file mode 100644 index 00000000000..106e12e009f --- /dev/null +++ b/apps/sim/tools/jupyter/start_kernel.ts @@ -0,0 +1,70 @@ +import type { JupyterStartKernelParams, JupyterStartKernelResponse } from '@/tools/jupyter/types' +import { + buildJupyterAuthHeaders, + mapJupyterKernel, + normalizeJupyterServerUrl, +} from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterStartKernelTool: ToolConfig< + JupyterStartKernelParams, + JupyterStartKernelResponse +> = { + id: 'jupyter_start_kernel', + name: 'Jupyter Start Kernel', + description: 'Start a new kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Kernel spec name to start (e.g. python3). Defaults to the server default.', + }, + }, + + request: { + url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels`, + method: 'POST', + headers: (params) => ({ + ...buildJupyterAuthHeaders(params.token), + 'Content-Type': 'application/json', + }), + body: (params) => (params.kernelName ? { name: params.kernelName } : {}), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Jupyter API error: ${response.status} ${errorText}`) + } + + const data = await response.json() + + return { + success: true, + output: mapJupyterKernel(data), + } + }, + + outputs: { + id: { type: 'string', description: 'Kernel ID' }, + name: { type: 'string', description: 'Kernel spec name' }, + lastActivity: { type: 'string', description: 'Last activity timestamp', optional: true }, + executionState: { type: 'string', description: 'Kernel execution state', optional: true }, + connections: { type: 'number', description: 'Active connection count', optional: true }, + }, +} diff --git a/apps/sim/tools/jupyter/stop_kernel.ts b/apps/sim/tools/jupyter/stop_kernel.ts new file mode 100644 index 00000000000..8541642ee86 --- /dev/null +++ b/apps/sim/tools/jupyter/stop_kernel.ts @@ -0,0 +1,60 @@ +import type { JupyterStopKernelParams, JupyterStopKernelResponse } from '@/tools/jupyter/types' +import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' +import type { ToolConfig } from '@/tools/types' + +export const jupyterStopKernelTool: ToolConfig = + { + id: 'jupyter_stop_kernel', + name: 'Jupyter Stop Kernel', + description: 'Shut down a running kernel on a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + kernelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the kernel to shut down', + }, + }, + + request: { + url: (params) => + `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}`, + method: 'DELETE', + headers: (params) => buildJupyterAuthHeaders(params.token), + }, + + transformResponse: async (response, params) => { + if (!response.ok && response.status !== 204) { + const errorText = await response.text() + return { + success: false, + error: `Jupyter API error: ${response.status} ${errorText}`, + output: { success: false, kernelId: params?.kernelId ?? '' }, + } + } + + return { + success: true, + output: { success: true, kernelId: params?.kernelId ?? '' }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the kernel was shut down' }, + kernelId: { type: 'string', description: 'Shut down kernel ID' }, + }, + } diff --git a/apps/sim/tools/jupyter/types.ts b/apps/sim/tools/jupyter/types.ts new file mode 100644 index 00000000000..6bbabfcbd1b --- /dev/null +++ b/apps/sim/tools/jupyter/types.ts @@ -0,0 +1,226 @@ +import type { ToolResponse } from '@/tools/types' + +export interface JupyterAuthParams { + serverUrl: string + token: string +} + +export interface JupyterContentItem { + name: string + path: string + type: 'directory' | 'file' | 'notebook' + writable: boolean + created: string | null + lastModified: string | null + size: number | null + mimetype: string | null + format: 'json' | 'text' | 'base64' | null +} + +export interface JupyterKernel { + id: string + name: string + lastActivity: string | null + executionState: string | null + connections: number | null +} + +export interface JupyterKernelSpec { + name: string + displayName: string + language: string | null + argv: string[] + interruptMode: string | null +} + +export interface JupyterSession { + id: string + path: string + name: string + type: string + kernel: JupyterKernel | null +} + +export interface JupyterListContentsParams extends JupyterAuthParams { + path?: string +} + +export interface JupyterListContentsResponse extends ToolResponse { + output: { + items: JupyterContentItem[] + path: string + } +} + +export interface JupyterGetContentParams extends JupyterAuthParams { + path: string +} + +export interface JupyterGetContentResponse extends ToolResponse { + output: { + name: string + path: string + mimetype: string | null + text: string | null + file: { + name: string + mimeType: string + data: string + size: number + } | null + } +} + +export interface JupyterCreateFileParams extends JupyterAuthParams { + path: string + type: 'file' | 'notebook' | 'directory' + content?: string +} + +export interface JupyterCreateFileResponse extends ToolResponse { + output: { + name: string + path: string + type: 'directory' | 'file' | 'notebook' + createdAt: string | null + lastModified: string | null + } +} + +export interface JupyterUploadFileParams extends JupyterAuthParams { + path: string + file?: unknown + fileContent?: string + fileName?: string +} + +export interface JupyterUploadFileResponse extends ToolResponse { + output: { + name: string + path: string + size: number | null + lastModified: string | null + } +} + +export interface JupyterRenameContentParams extends JupyterAuthParams { + path: string + newPath: string +} + +export interface JupyterRenameContentResponse extends ToolResponse { + output: { + name: string + path: string + lastModified: string | null + } +} + +export interface JupyterDeleteContentParams extends JupyterAuthParams { + path: string +} + +export interface JupyterDeleteContentResponse extends ToolResponse { + output: { + success: boolean + path: string + } +} + +export interface JupyterCopyContentParams extends JupyterAuthParams { + path: string + copyFromPath: string +} + +export interface JupyterCopyContentResponse extends ToolResponse { + output: { + name: string + path: string + createdAt: string | null + } +} + +export interface JupyterListKernelsParams extends JupyterAuthParams {} + +export interface JupyterListKernelsResponse extends ToolResponse { + output: { + kernels: JupyterKernel[] + } +} + +export interface JupyterStartKernelParams extends JupyterAuthParams { + kernelName?: string +} + +export interface JupyterStartKernelResponse extends ToolResponse { + output: JupyterKernel +} + +export interface JupyterStopKernelParams extends JupyterAuthParams { + kernelId: string +} + +export interface JupyterStopKernelResponse extends ToolResponse { + output: { + success: boolean + kernelId: string + } +} + +export interface JupyterRestartKernelParams extends JupyterAuthParams { + kernelId: string +} + +export interface JupyterRestartKernelResponse extends ToolResponse { + output: JupyterKernel +} + +export interface JupyterInterruptKernelParams extends JupyterAuthParams { + kernelId: string +} + +export interface JupyterInterruptKernelResponse extends ToolResponse { + output: { + success: boolean + kernelId: string + } +} + +export interface JupyterListKernelspecsParams extends JupyterAuthParams {} + +export interface JupyterListKernelspecsResponse extends ToolResponse { + output: { + defaultKernelName: string | null + kernelspecs: JupyterKernelSpec[] + } +} + +export interface JupyterListSessionsParams extends JupyterAuthParams {} + +export interface JupyterListSessionsResponse extends ToolResponse { + output: { + sessions: JupyterSession[] + } +} + +export interface JupyterCreateSessionParams extends JupyterAuthParams { + path: string + kernelName?: string + name?: string + type?: string +} + +export interface JupyterCreateSessionResponse extends ToolResponse { + output: JupyterSession +} + +export interface JupyterDeleteSessionParams extends JupyterAuthParams { + sessionId: string +} + +export interface JupyterDeleteSessionResponse extends ToolResponse { + output: { + success: boolean + sessionId: string + } +} diff --git a/apps/sim/tools/jupyter/upload_file.ts b/apps/sim/tools/jupyter/upload_file.ts new file mode 100644 index 00000000000..3d1901d933d --- /dev/null +++ b/apps/sim/tools/jupyter/upload_file.ts @@ -0,0 +1,86 @@ +import type { JupyterUploadFileParams, JupyterUploadFileResponse } from '@/tools/jupyter/types' +import type { ToolConfig } from '@/tools/types' + +export const jupyterUploadFileTool: ToolConfig = + { + id: 'jupyter_upload_file', + name: 'Jupyter Upload File', + description: 'Upload a file to a Jupyter server', + version: '1.0.0', + + params: { + serverUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL of the Jupyter server (e.g. http://localhost:8888)', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Jupyter server authentication token', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Destination path, relative to the server root. A directory path ending in "/" uploads using the file name.', + }, + file: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'The file to upload (UserFile object)', + }, + fileContent: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Legacy: base64 encoded file content', + }, + fileName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional filename override', + }, + }, + + request: { + url: '/api/tools/jupyter/upload', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + path: params.path, + file: params.file, + fileContent: params.fileContent, + fileName: params.fileName, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!data.success) { + throw new Error(data.error || 'Failed to upload file') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + name: { type: 'string', description: 'Uploaded file name' }, + path: { type: 'string', description: 'Uploaded file path' }, + size: { type: 'number', description: 'File size in bytes', optional: true }, + lastModified: { type: 'string', description: 'Last modified timestamp', optional: true }, + }, + } diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts new file mode 100644 index 00000000000..5fa4a8e311b --- /dev/null +++ b/apps/sim/tools/jupyter/utils.ts @@ -0,0 +1,115 @@ +const PROTOCOL_PATTERN = /^https?:\/\//i + +/** + * Error thrown when a user-supplied Jupyter server URL cannot be parsed into a + * safe http(s) origin to target with the caller's token. + */ +export class InvalidJupyterServerUrlError extends Error { + constructor(rawUrl: string) { + super(`Invalid Jupyter server URL: ${rawUrl}`) + this.name = 'InvalidJupyterServerUrlError' + } +} + +/** + * Normalizes a user-supplied Jupyter server URL: trims whitespace, defaults to + * `http://` when no scheme is given (most Jupyter servers run over plain HTTP + * on localhost or a private network), and strips any trailing slash and + * query/fragment. Self-hosted Jupyter servers have no fixed public host, so the + * URL is always user-supplied. + * + * @throws {InvalidJupyterServerUrlError} when the value is empty or not a valid http(s) URL. + */ +export function normalizeJupyterServerUrl(rawUrl: unknown): string { + const raw = typeof rawUrl === 'string' ? rawUrl.trim() : '' + if (!raw) throw new InvalidJupyterServerUrlError(String(rawUrl)) + + const withProtocol = PROTOCOL_PATTERN.test(raw) ? raw : `http://${raw}` + + let parsed: URL + try { + parsed = new URL(withProtocol) + } catch { + throw new InvalidJupyterServerUrlError(raw) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new InvalidJupyterServerUrlError(raw) + } + + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}` +} + +/** + * Builds the `Authorization` header Jupyter Server expects for token auth. + */ +export function buildJupyterAuthHeaders(token: string): Record { + return { Authorization: `token ${token}` } +} + +/** + * Encodes a Jupyter contents path segment-by-segment so slashes stay as path + * separators while special characters within a segment are escaped. + */ +export function encodeJupyterPath(path: string | undefined): string { + return (path ?? '') + .split('/') + .filter((segment) => segment.length > 0) + .map(encodeURIComponent) + .join('/') +} + +interface RawJupyterKernel { + id?: string + name?: string + last_activity?: string + execution_state?: string + connections?: number +} + +/** + * Maps a raw Jupyter kernel model (from Kernels/Sessions API responses) to + * Sim's shaped `JupyterKernel` output. + */ +export function mapJupyterKernel(raw: RawJupyterKernel): { + id: string + name: string + lastActivity: string | null + executionState: string | null + connections: number | null +} { + return { + id: raw.id ?? '', + name: raw.name ?? '', + lastActivity: raw.last_activity ?? null, + executionState: raw.execution_state ?? null, + connections: raw.connections ?? null, + } +} + +interface RawJupyterSession { + id?: string + path?: string + name?: string + type?: string + kernel?: RawJupyterKernel | null +} + +/** + * Maps a raw Jupyter session model to Sim's shaped `JupyterSession` output. + */ +export function mapJupyterSession(raw: RawJupyterSession): { + id: string + path: string + name: string + type: string + kernel: ReturnType | null +} { + return { + id: raw.id ?? '', + path: raw.path ?? '', + name: raw.name ?? '', + type: raw.type ?? '', + kernel: raw.kernel ? mapJupyterKernel(raw.kernel) : null, + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4fcda0f1cd4..4c2ce2bf0d9 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1970,6 +1970,24 @@ import { jsmTransitionRequestTool, jsmUpdateObjectTool, } from '@/tools/jsm' +import { + jupyterCopyContentTool, + jupyterCreateFileTool, + jupyterCreateSessionTool, + jupyterDeleteContentTool, + jupyterDeleteSessionTool, + jupyterGetContentTool, + jupyterInterruptKernelTool, + jupyterListContentsTool, + jupyterListKernelspecsTool, + jupyterListKernelsTool, + jupyterListSessionsTool, + jupyterRenameContentTool, + jupyterRestartKernelTool, + jupyterStartKernelTool, + jupyterStopKernelTool, + jupyterUploadFileTool, +} from '@/tools/jupyter' import { kalshiAmendOrderTool, kalshiAmendOrderV2Tool, @@ -5291,6 +5309,22 @@ export const tools: Record = { jsm_create_object: jsmCreateObjectTool, jsm_update_object: jsmUpdateObjectTool, jsm_delete_object: jsmDeleteObjectTool, + jupyter_list_contents: jupyterListContentsTool, + jupyter_get_content: jupyterGetContentTool, + jupyter_create_file: jupyterCreateFileTool, + jupyter_upload_file: jupyterUploadFileTool, + jupyter_rename_content: jupyterRenameContentTool, + jupyter_delete_content: jupyterDeleteContentTool, + jupyter_copy_content: jupyterCopyContentTool, + jupyter_list_kernels: jupyterListKernelsTool, + jupyter_start_kernel: jupyterStartKernelTool, + jupyter_stop_kernel: jupyterStopKernelTool, + jupyter_restart_kernel: jupyterRestartKernelTool, + jupyter_interrupt_kernel: jupyterInterruptKernelTool, + jupyter_list_kernelspecs: jupyterListKernelspecsTool, + jupyter_list_sessions: jupyterListSessionsTool, + jupyter_create_session: jupyterCreateSessionTool, + jupyter_delete_session: jupyterDeleteSessionTool, kalshi_get_markets: kalshiGetMarketsTool, kalshi_get_markets_v2: kalshiGetMarketsV2Tool, kalshi_get_market: kalshiGetMarketTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1aa4dc0495d..a3523233a05 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 919, - zodRoutes: 919, + totalRoutes: 920, + zodRoutes: 920, nonZodRoutes: 0, } as const From 1177326211990184fc52b5239174595efa978702 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:31:31 -0700 Subject: [PATCH 2/9] =?UTF-8?q?fix(jupyter):=20address=20Greptile=20review?= =?UTF-8?q?=20=E2=80=94=20SSRF=20guard,=20upload=20path=20ambiguity,=20sil?= =?UTF-8?q?ent=20notebook=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route uploads through validateUrlWithDNS + secureFetchWithPinnedIP (matches Grafana/1Password pattern) instead of a raw fetch to the user-supplied server URL - Replace the upload path/trailing-slash heuristic with an unambiguous directory + filename split - create_file no longer silently writes an empty notebook when notebook content is malformed JSON — it now errors clearly --- .../content/docs/en/integrations/jupyter.mdx | 2 +- .../sim/app/api/tools/jupyter/upload/route.ts | 25 ++++++++++++++----- apps/sim/blocks/blocks/jupyter.ts | 12 ++++++--- .../sim/lib/api/contracts/storage-transfer.ts | 2 +- apps/sim/tools/jupyter/create_file.ts | 15 +++++------ apps/sim/tools/jupyter/types.ts | 2 +- apps/sim/tools/jupyter/upload_file.ts | 8 +++--- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/jupyter.mdx b/apps/docs/content/docs/en/integrations/jupyter.mdx index 81c27d9aab5..81c333a73f2 100644 --- a/apps/docs/content/docs/en/integrations/jupyter.mdx +++ b/apps/docs/content/docs/en/integrations/jupyter.mdx @@ -102,7 +102,7 @@ Upload a file to a Jupyter server | --------- | ---- | -------- | ----------- | | `serverUrl` | string | Yes | Base URL of the Jupyter server \(e.g. http://localhost:8888\) | | `token` | string | Yes | Jupyter server authentication token | -| `path` | string | Yes | Destination path, relative to the server root. A directory path ending in "/" uploads using the file name. | +| `directory` | string | No | Destination directory, relative to the server root. Leave blank to upload to the root directory. | | `file` | file | No | The file to upload \(UserFile object\) | | `fileContent` | string | No | Legacy: base64 encoded file content | | `fileName` | string | No | Optional filename override | diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts index a0890cebebb..f5627c59e31 100644 --- a/apps/sim/app/api/tools/jupyter/upload/route.ts +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -4,6 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { jupyterUploadContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' @@ -64,17 +68,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileName = data.fileName || userFile.name } else if (data.fileContent) { fileBuffer = Buffer.from(data.fileContent, 'base64') - fileName = data.fileName || data.path.split('/').pop() || 'file' + fileName = data.fileName || 'file' } else { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } const base = normalizeJupyterServerUrl(data.serverUrl) - const destinationPath = encodeJupyterPath( - data.path.endsWith('/') ? `${data.path}${fileName}` : data.path - ) + const destinationDirectory = (data.directory ?? '').replace(/\/+$/, '') + const destinationPath = destinationDirectory ? `${destinationDirectory}/${fileName}` : fileName + const uploadUrl = `${base}/api/contents/${encodeJupyterPath(destinationPath)}` + + const urlValidation = await validateUrlWithDNS(uploadUrl, 'serverUrl', { allowHttp: true }) + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json( + { success: false, error: `Invalid Jupyter serverUrl: ${urlValidation.error}` }, + { status: 400 } + ) + } - const response = await fetch(`${base}/api/contents/${destinationPath}`, { + const response = await secureFetchWithPinnedIP(uploadUrl, urlValidation.resolvedIP, { method: 'PUT', headers: { ...buildJupyterAuthHeaders(data.token), @@ -85,6 +97,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { format: 'base64', content: fileBuffer.toString('base64'), }), + allowHttp: true, }) if (!response.ok) { @@ -104,7 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: true, output: { name: uploaded.name ?? fileName, - path: uploaded.path ?? data.path, + path: uploaded.path ?? destinationPath, size: uploaded.size ?? fileBuffer.length, lastModified: uploaded.last_modified ?? null, }, diff --git a/apps/sim/blocks/blocks/jupyter.ts b/apps/sim/blocks/blocks/jupyter.ts index 6137ecd4753..2febc557c73 100644 --- a/apps/sim/blocks/blocks/jupyter.ts +++ b/apps/sim/blocks/blocks/jupyter.ts @@ -7,7 +7,6 @@ const PATH_OPERATIONS = [ 'jupyter_list_contents', 'jupyter_get_content', 'jupyter_create_file', - 'jupyter_upload_file', 'jupyter_rename_content', 'jupyter_delete_content', 'jupyter_copy_content', @@ -17,7 +16,6 @@ const PATH_OPERATIONS = [ const REQUIRED_PATH_OPERATIONS = [ 'jupyter_get_content', 'jupyter_create_file', - 'jupyter_upload_file', 'jupyter_rename_content', 'jupyter_delete_content', 'jupyter_copy_content', @@ -118,6 +116,13 @@ export const JupyterBlock: BlockConfig = { }, // Upload File + { + id: 'directory', + title: 'Directory', + type: 'short-input', + placeholder: 'notebooks (leave blank for the root directory)', + condition: { field: 'operation', value: 'jupyter_upload_file' }, + }, { id: 'uploadFile', title: 'File', @@ -260,7 +265,7 @@ export const JupyterBlock: BlockConfig = { if (rest.content) baseParams.content = rest.content break case 'jupyter_upload_file': - baseParams.path = rest.path + if (rest.directory) baseParams.directory = rest.directory baseParams.file = rest.file if (rest.uploadFileName) baseParams.fileName = rest.uploadFileName break @@ -303,6 +308,7 @@ export const JupyterBlock: BlockConfig = { path: { type: 'string', description: 'Path relative to the server root' }, type: { type: 'string', description: 'file, notebook, or directory' }, content: { type: 'string', description: 'File or notebook content' }, + directory: { type: 'string', description: 'Upload destination directory' }, file: { type: 'json', description: 'File to upload (canonical param)' }, uploadFileName: { type: 'string', description: 'Optional filename override' }, newPath: { type: 'string', description: 'New path for rename/move' }, diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 68beabcf7c6..4a421203e4a 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -110,7 +110,7 @@ export const dropboxUploadBodySchema = z.object({ export const jupyterUploadBodySchema = z.object({ serverUrl: z.string().min(1, 'Server URL is required'), token: z.string().min(1, 'Token is required'), - path: z.string().min(1, 'Destination path is required'), + directory: z.string().optional().nullable(), file: FileInputSchema.optional().nullable(), fileContent: z.string().optional().nullable(), fileName: z.string().optional().nullable(), diff --git a/apps/sim/tools/jupyter/create_file.ts b/apps/sim/tools/jupyter/create_file.ts index 853f2318ea6..d4413cef61e 100644 --- a/apps/sim/tools/jupyter/create_file.ts +++ b/apps/sim/tools/jupyter/create_file.ts @@ -66,13 +66,14 @@ export const jupyterCreateFileTool: ToolConfig ({ serverUrl: params.serverUrl, token: params.token, - path: params.path, + directory: params.directory, file: params.file, fileContent: params.fileContent, fileName: params.fileName, From d1d326a918d96b29e6ce145e989bd768b2eefa02 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:32:42 -0700 Subject: [PATCH 3/9] fix(jupyter): request content=1 when listing directory contents Without it, Jupyter Server returns directory metadata with content: null, so jupyter_list_contents always reported an empty items array. --- apps/sim/tools/jupyter/list_contents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/tools/jupyter/list_contents.ts b/apps/sim/tools/jupyter/list_contents.ts index ce5da44a6b0..cea8675889f 100644 --- a/apps/sim/tools/jupyter/list_contents.ts +++ b/apps/sim/tools/jupyter/list_contents.ts @@ -40,7 +40,7 @@ export const jupyterListContentsTool: ToolConfig< url: (params) => { const base = normalizeJupyterServerUrl(params.serverUrl) const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}?type=directory` + return `${base}/api/contents/${path}?type=directory&content=1` }, method: 'GET', headers: (params) => buildJupyterAuthHeaders(params.token), From c27da338b66ff02137a89dfa8ac0cbc5f67bf96a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:41:08 -0700 Subject: [PATCH 4/9] fix(jupyter): reject path-traversal segments in Jupyter content paths encodeJupyterPath now rejects '.'/'..' segments across the whole path (shared by all 16 tools, not just upload); the upload route returns a clean 400 when it's hit. --- .../sim/app/api/tools/jupyter/upload/route.ts | 13 ++++++++- apps/sim/tools/jupyter/utils.ts | 28 +++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts index f5627c59e31..afa06be1271 100644 --- a/apps/sim/app/api/tools/jupyter/upload/route.ts +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -18,6 +18,7 @@ import { buildJupyterAuthHeaders, encodeJupyterPath, normalizeJupyterServerUrl, + UnsafeJupyterPathError, } from '@/tools/jupyter/utils' export const dynamic = 'force-dynamic' @@ -76,7 +77,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const base = normalizeJupyterServerUrl(data.serverUrl) const destinationDirectory = (data.directory ?? '').replace(/\/+$/, '') const destinationPath = destinationDirectory ? `${destinationDirectory}/${fileName}` : fileName - const uploadUrl = `${base}/api/contents/${encodeJupyterPath(destinationPath)}` + + let encodedDestinationPath: string + try { + encodedDestinationPath = encodeJupyterPath(destinationPath) + } catch (error) { + if (error instanceof UnsafeJupyterPathError) { + return NextResponse.json({ success: false, error: error.message }, { status: 400 }) + } + throw error + } + const uploadUrl = `${base}/api/contents/${encodedDestinationPath}` const urlValidation = await validateUrlWithDNS(uploadUrl, 'serverUrl', { allowHttp: true }) if (!urlValidation.isValid || !urlValidation.resolvedIP) { diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts index 5fa4a8e311b..88d2001afc3 100644 --- a/apps/sim/tools/jupyter/utils.ts +++ b/apps/sim/tools/jupyter/utils.ts @@ -47,16 +47,34 @@ export function buildJupyterAuthHeaders(token: string): Record { return { Authorization: `token ${token}` } } +/** + * Error thrown when a user-supplied Jupyter contents path contains a `.` or + * `..` segment that could traverse outside the intended directory. + */ +export class UnsafeJupyterPathError extends Error { + constructor(rawPath: string) { + super(`Invalid Jupyter path: ${rawPath}`) + this.name = 'UnsafeJupyterPathError' + } +} + /** * Encodes a Jupyter contents path segment-by-segment so slashes stay as path * separators while special characters within a segment are escaped. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`, which could + * otherwise traverse outside the intended directory on the target server. */ export function encodeJupyterPath(path: string | undefined): string { - return (path ?? '') - .split('/') - .filter((segment) => segment.length > 0) - .map(encodeURIComponent) - .join('/') + const segments = (path ?? '').split('/').filter((segment) => segment.length > 0) + + for (const segment of segments) { + if (segment === '.' || segment === '..') { + throw new UnsafeJupyterPathError(path ?? '') + } + } + + return segments.map(encodeURIComponent).join('/') } interface RawJupyterKernel { From de4194422e9378a6ab8225569c08ed3ccbc2d6b6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:47:56 -0700 Subject: [PATCH 5/9] fix(jupyter): close remaining path-traversal and redirect-credential gaps - extract the traversal check out of encodeJupyterPath into a shared assertion, and apply it to body-only path fields (rename newPath, copy copyFromPath, session path) that never flowed through URL encoding and so skipped the check - pass stripAuthOnRedirect to the upload route's secureFetchWithPinnedIP call so a malicious Jupyter server can't redirect the PUT to another origin and receive the caller's token --- .../sim/app/api/tools/jupyter/upload/route.ts | 1 + apps/sim/tools/jupyter/copy_content.ts | 3 +- apps/sim/tools/jupyter/create_session.ts | 3 +- apps/sim/tools/jupyter/rename_content.ts | 3 +- apps/sim/tools/jupyter/utils.ts | 36 +++++++++++++++---- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts index afa06be1271..1ed57e2758a 100644 --- a/apps/sim/app/api/tools/jupyter/upload/route.ts +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -109,6 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { content: fileBuffer.toString('base64'), }), allowHttp: true, + stripAuthOnRedirect: true, }) if (!response.ok) { diff --git a/apps/sim/tools/jupyter/copy_content.ts b/apps/sim/tools/jupyter/copy_content.ts index 8615c9716ce..7a13ddeb8a5 100644 --- a/apps/sim/tools/jupyter/copy_content.ts +++ b/apps/sim/tools/jupyter/copy_content.ts @@ -1,5 +1,6 @@ import type { JupyterCopyContentParams, JupyterCopyContentResponse } from '@/tools/jupyter/types' import { + assertSafeJupyterPath, buildJupyterAuthHeaders, encodeJupyterPath, normalizeJupyterServerUrl, @@ -53,7 +54,7 @@ export const jupyterCopyContentTool: ToolConfig< ...buildJupyterAuthHeaders(params.token), 'Content-Type': 'application/json', }), - body: (params) => ({ copy_from: params.copyFromPath }), + body: (params) => ({ copy_from: assertSafeJupyterPath(params.copyFromPath) }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/create_session.ts b/apps/sim/tools/jupyter/create_session.ts index 2f188783d24..5ee34370504 100644 --- a/apps/sim/tools/jupyter/create_session.ts +++ b/apps/sim/tools/jupyter/create_session.ts @@ -3,6 +3,7 @@ import type { JupyterCreateSessionResponse, } from '@/tools/jupyter/types' import { + assertSafeJupyterPath, buildJupyterAuthHeaders, mapJupyterSession, normalizeJupyterServerUrl, @@ -65,7 +66,7 @@ export const jupyterCreateSessionTool: ToolConfig< 'Content-Type': 'application/json', }), body: (params) => ({ - path: params.path, + path: assertSafeJupyterPath(params.path), name: params.name, type: params.type || 'notebook', ...(params.kernelName ? { kernel: { name: params.kernelName } } : {}), diff --git a/apps/sim/tools/jupyter/rename_content.ts b/apps/sim/tools/jupyter/rename_content.ts index cb0aaf289ad..a995e28d2ae 100644 --- a/apps/sim/tools/jupyter/rename_content.ts +++ b/apps/sim/tools/jupyter/rename_content.ts @@ -3,6 +3,7 @@ import type { JupyterRenameContentResponse, } from '@/tools/jupyter/types' import { + assertSafeJupyterPath, buildJupyterAuthHeaders, encodeJupyterPath, normalizeJupyterServerUrl, @@ -56,7 +57,7 @@ export const jupyterRenameContentTool: ToolConfig< ...buildJupyterAuthHeaders(params.token), 'Content-Type': 'application/json', }), - body: (params) => ({ path: params.newPath }), + body: (params) => ({ path: assertSafeJupyterPath(params.newPath) }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts index 88d2001afc3..24377f60bd5 100644 --- a/apps/sim/tools/jupyter/utils.ts +++ b/apps/sim/tools/jupyter/utils.ts @@ -59,13 +59,14 @@ export class UnsafeJupyterPathError extends Error { } /** - * Encodes a Jupyter contents path segment-by-segment so slashes stay as path - * separators while special characters within a segment are escaped. - * - * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`, which could + * Rejects `.` and `..` segments in a Jupyter contents path, which could * otherwise traverse outside the intended directory on the target server. + * Shared by every helper that sends a path to Jupyter, whether in a URL or a + * request body. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`. */ -export function encodeJupyterPath(path: string | undefined): string { +function assertNoJupyterPathTraversal(path: string | undefined): string[] { const segments = (path ?? '').split('/').filter((segment) => segment.length > 0) for (const segment of segments) { @@ -74,7 +75,30 @@ export function encodeJupyterPath(path: string | undefined): string { } } - return segments.map(encodeURIComponent).join('/') + return segments +} + +/** + * Encodes a Jupyter contents path segment-by-segment so slashes stay as path + * separators while special characters within a segment are escaped. Use for + * paths interpolated into a request URL. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`. + */ +export function encodeJupyterPath(path: string | undefined): string { + return assertNoJupyterPathTraversal(path).map(encodeURIComponent).join('/') +} + +/** + * Validates a Jupyter contents path with no URL-encoding. Use for paths sent + * as-is in a JSON request body (e.g. a PATCH/POST `path`/`copy_from` field) + * that never flow through `encodeJupyterPath`. + * + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`. + */ +export function assertSafeJupyterPath(path: string): string { + assertNoJupyterPathTraversal(path) + return path } interface RawJupyterKernel { From c6afd79407e1f4c3bdfe3cb5fbd7557fca973874 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 18:56:33 -0700 Subject: [PATCH 6/9] fix(jupyter): also reject percent-encoded traversal segments A segment like %2e%2e wouldn't match the literal '..' check. Now decodes each segment before comparing, in addition to the literal check, so an already-encoded traversal attempt is caught too. --- apps/sim/tools/jupyter/utils.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts index 24377f60bd5..0aa5adaa08d 100644 --- a/apps/sim/tools/jupyter/utils.ts +++ b/apps/sim/tools/jupyter/utils.ts @@ -58,19 +58,38 @@ export class UnsafeJupyterPathError extends Error { } } +/** + * Whether a path segment is `.`/`..`, checked both literally and after + * percent-decoding — a caller could submit an already-encoded `%2e%2e` to try + * to slip a traversal segment past a literal-only check. + */ +function isTraversalSegment(segment: string): boolean { + if (segment === '.' || segment === '..') return true + + let decoded: string + try { + decoded = decodeURIComponent(segment) + } catch { + return false + } + + return decoded === '.' || decoded === '..' +} + /** * Rejects `.` and `..` segments in a Jupyter contents path, which could * otherwise traverse outside the intended directory on the target server. * Shared by every helper that sends a path to Jupyter, whether in a URL or a * request body. * - * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`. + * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`, literally + * or percent-encoded. */ function assertNoJupyterPathTraversal(path: string | undefined): string[] { const segments = (path ?? '').split('/').filter((segment) => segment.length > 0) for (const segment of segments) { - if (segment === '.' || segment === '..') { + if (isTraversalSegment(segment)) { throw new UnsafeJupyterPathError(path ?? '') } } From 4c80189c501eeb854887d8bbf5fdc4e83d687342 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 19:11:26 -0700 Subject: [PATCH 7/9] fix(jupyter): route all 15 remaining tools through an internal proxy for HTTP/private-host support and no redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic external tool executor blocks plain-HTTP and non-localhost private-IP hosts by default, so every non-upload Jupyter operation could fail against typical self-hosted setups (LAN IP, docker hostname, or even literal localhost on a hosted deployment) even though the upload route worked via its own internal route. Added /api/tools/jupyter/proxy (DNS-pinned, allowHttp, maxRedirects: 0) that mirrors the upstream Jupyter response verbatim, matching the established pattern for self-hosted-arbitrary-host integrations (Grafana, 1Password) instead of the generic executor path. Each tool's request block now posts to the proxy instead of building a direct external URL; transformResponse and outputs are unchanged since the proxy response mirrors upstream status/body exactly. Also switches the upload route from stripAuthOnRedirect to maxRedirects: 0 — stronger, since it stops the uploaded file body (not just the token) from ever reaching a redirect target. --- apps/sim/app/api/tools/jupyter/proxy/route.ts | 81 +++++++++++++++++++ .../sim/app/api/tools/jupyter/upload/route.ts | 2 +- apps/sim/lib/api/contracts/tools/jupyter.ts | 23 ++++++ apps/sim/tools/jupyter/copy_content.ts | 24 +++--- apps/sim/tools/jupyter/create_file.ts | 53 ++++++------ apps/sim/tools/jupyter/create_session.ts | 28 +++---- apps/sim/tools/jupyter/delete_content.ts | 22 +++-- apps/sim/tools/jupyter/delete_session.ts | 14 ++-- apps/sim/tools/jupyter/get_content.ts | 22 +++-- apps/sim/tools/jupyter/interrupt_kernel.ts | 12 ++- apps/sim/tools/jupyter/list_contents.ts | 22 +++-- apps/sim/tools/jupyter/list_kernels.ts | 18 +++-- apps/sim/tools/jupyter/list_kernelspecs.ts | 13 ++- apps/sim/tools/jupyter/list_sessions.ts | 18 +++-- apps/sim/tools/jupyter/rename_content.ts | 26 +++--- apps/sim/tools/jupyter/restart_kernel.ts | 17 ++-- apps/sim/tools/jupyter/start_kernel.ts | 19 +++-- apps/sim/tools/jupyter/stop_kernel.ts | 14 ++-- scripts/check-api-validation-contracts.ts | 4 +- 19 files changed, 267 insertions(+), 165 deletions(-) create mode 100644 apps/sim/app/api/tools/jupyter/proxy/route.ts create mode 100644 apps/sim/lib/api/contracts/tools/jupyter.ts diff --git a/apps/sim/app/api/tools/jupyter/proxy/route.ts b/apps/sim/app/api/tools/jupyter/proxy/route.ts new file mode 100644 index 00000000000..1be15bcd9ea --- /dev/null +++ b/apps/sim/app/api/tools/jupyter/proxy/route.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { jupyterProxyContract } from '@/lib/api/contracts/tools/jupyter' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JupyterProxyAPI') + +/** + * Proxies Contents/Kernels/Kernelspecs/Sessions API calls to a self-hosted + * Jupyter server. Self-hosted servers have no fixed public host, so every + * request is server-side (DNS-pinned, http(s) allowed, redirects disabled) + * rather than going through the generic external tool executor, which blocks + * plain-HTTP and private-IP hosts by default. Mirrors the upstream status + * and body verbatim so callers can treat this exactly like a direct fetch. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Jupyter proxy attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(jupyterProxyContract, request, {}) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + const base = normalizeJupyterServerUrl(data.serverUrl) + const url = `${base}/api/${data.path}` + + const urlValidation = await validateUrlWithDNS(url, 'serverUrl', { allowHttp: true }) + if (!urlValidation.isValid || !urlValidation.resolvedIP) { + return NextResponse.json( + { success: false, error: `Invalid Jupyter serverUrl: ${urlValidation.error}` }, + { status: 400 } + ) + } + + const hasBody = data.body !== undefined && data.body !== null + + const upstream = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { + method: data.method, + headers: { + ...buildJupyterAuthHeaders(data.token), + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + }, + body: hasBody ? JSON.stringify(data.body) : undefined, + allowHttp: true, + maxRedirects: 0, + }) + + const text = await upstream.text() + + return new NextResponse(text.length > 0 ? text : null, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' }, + }) + } catch (error) { + logger.error(`[${requestId}] Unexpected error:`, error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Unknown error') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts index 1ed57e2758a..4820ffb4805 100644 --- a/apps/sim/app/api/tools/jupyter/upload/route.ts +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -109,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { content: fileBuffer.toString('base64'), }), allowHttp: true, - stripAuthOnRedirect: true, + maxRedirects: 0, }) if (!response.ok) { diff --git a/apps/sim/lib/api/contracts/tools/jupyter.ts b/apps/sim/lib/api/contracts/tools/jupyter.ts new file mode 100644 index 00000000000..5f13d2360df --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/jupyter.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const jupyterProxyBodySchema = z.object({ + serverUrl: z.string().min(1, 'Server URL is required'), + token: z.string().min(1, 'Token is required'), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string().min(1, 'Path is required'), + body: z.unknown().optional().nullable(), +}) + +export const jupyterProxyContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/jupyter/proxy', + body: jupyterProxyBodySchema, + // untyped-response: the route mirrors the upstream Jupyter server's response + // verbatim (status + body), which varies per Contents/Kernels/Sessions endpoint + response: { mode: 'json', schema: z.unknown() }, +}) + +export type JupyterProxyBody = ContractBodyInput +export type JupyterProxyResponse = ContractJsonResponse diff --git a/apps/sim/tools/jupyter/copy_content.ts b/apps/sim/tools/jupyter/copy_content.ts index 7a13ddeb8a5..87abbdbd0d3 100644 --- a/apps/sim/tools/jupyter/copy_content.ts +++ b/apps/sim/tools/jupyter/copy_content.ts @@ -1,10 +1,5 @@ import type { JupyterCopyContentParams, JupyterCopyContentResponse } from '@/tools/jupyter/types' -import { - assertSafeJupyterPath, - buildJupyterAuthHeaders, - encodeJupyterPath, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { assertSafeJupyterPath, encodeJupyterPath } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterCopyContentTool: ToolConfig< @@ -44,17 +39,16 @@ export const jupyterCopyContentTool: ToolConfig< }, request: { - url: (params) => { - const base = normalizeJupyterServerUrl(params.serverUrl) - const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}` - }, + url: '/api/tools/jupyter/proxy', method: 'POST', - headers: (params) => ({ - ...buildJupyterAuthHeaders(params.token), - 'Content-Type': 'application/json', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: `contents/${encodeJupyterPath(params.path)}`, + body: { copy_from: assertSafeJupyterPath(params.copyFromPath) }, }), - body: (params) => ({ copy_from: assertSafeJupyterPath(params.copyFromPath) }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/create_file.ts b/apps/sim/tools/jupyter/create_file.ts index d4413cef61e..5d59dfcff20 100644 --- a/apps/sim/tools/jupyter/create_file.ts +++ b/apps/sim/tools/jupyter/create_file.ts @@ -1,9 +1,5 @@ import type { JupyterCreateFileParams, JupyterCreateFileResponse } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - encodeJupyterPath, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { encodeJupyterPath } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' const EMPTY_NOTEBOOK = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 } @@ -50,35 +46,36 @@ export const jupyterCreateFileTool: ToolConfig { - const base = normalizeJupyterServerUrl(params.serverUrl) - const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}` - }, - method: 'PUT', - headers: (params) => ({ - ...buildJupyterAuthHeaders(params.token), - 'Content-Type': 'application/json', - }), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => { + let content: Record if (params.type === 'directory') { - return { type: 'directory' } - } - - if (params.type === 'notebook') { + content = { type: 'directory' } + } else if (params.type === 'notebook') { if (!params.content) { - return { type: 'notebook', format: 'json', content: EMPTY_NOTEBOOK } + content = { type: 'notebook', format: 'json', content: EMPTY_NOTEBOOK } + } else { + let notebook: unknown + try { + notebook = JSON.parse(params.content) + } catch { + throw new Error('Notebook content must be valid JSON-stringified nbformat') + } + content = { type: 'notebook', format: 'json', content: notebook } } - let content: unknown - try { - content = JSON.parse(params.content) - } catch { - throw new Error('Notebook content must be valid JSON-stringified nbformat') - } - return { type: 'notebook', format: 'json', content } + } else { + content = { type: 'file', format: 'text', content: params.content ?? '' } } - return { type: 'file', format: 'text', content: params.content ?? '' } + return { + serverUrl: params.serverUrl, + token: params.token, + method: 'PUT', + path: `contents/${encodeJupyterPath(params.path)}`, + body: content, + } }, }, diff --git a/apps/sim/tools/jupyter/create_session.ts b/apps/sim/tools/jupyter/create_session.ts index 5ee34370504..9a12123b50f 100644 --- a/apps/sim/tools/jupyter/create_session.ts +++ b/apps/sim/tools/jupyter/create_session.ts @@ -2,12 +2,7 @@ import type { JupyterCreateSessionParams, JupyterCreateSessionResponse, } from '@/tools/jupyter/types' -import { - assertSafeJupyterPath, - buildJupyterAuthHeaders, - mapJupyterSession, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { assertSafeJupyterPath, mapJupyterSession } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterCreateSessionTool: ToolConfig< @@ -59,17 +54,20 @@ export const jupyterCreateSessionTool: ToolConfig< }, request: { - url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions`, + url: '/api/tools/jupyter/proxy', method: 'POST', - headers: (params) => ({ - ...buildJupyterAuthHeaders(params.token), - 'Content-Type': 'application/json', - }), + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ - path: assertSafeJupyterPath(params.path), - name: params.name, - type: params.type || 'notebook', - ...(params.kernelName ? { kernel: { name: params.kernelName } } : {}), + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: 'sessions', + body: { + path: assertSafeJupyterPath(params.path), + name: params.name, + type: params.type || 'notebook', + ...(params.kernelName ? { kernel: { name: params.kernelName } } : {}), + }, }), }, diff --git a/apps/sim/tools/jupyter/delete_content.ts b/apps/sim/tools/jupyter/delete_content.ts index 0dd46e7bf7f..345d410acd3 100644 --- a/apps/sim/tools/jupyter/delete_content.ts +++ b/apps/sim/tools/jupyter/delete_content.ts @@ -2,11 +2,7 @@ import type { JupyterDeleteContentParams, JupyterDeleteContentResponse, } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - encodeJupyterPath, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { encodeJupyterPath } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterDeleteContentTool: ToolConfig< @@ -40,13 +36,15 @@ export const jupyterDeleteContentTool: ToolConfig< }, request: { - url: (params) => { - const base = normalizeJupyterServerUrl(params.serverUrl) - const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}` - }, - method: 'DELETE', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'DELETE', + path: `contents/${encodeJupyterPath(params.path)}`, + }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/delete_session.ts b/apps/sim/tools/jupyter/delete_session.ts index 58bcf842be8..58afd30e1fc 100644 --- a/apps/sim/tools/jupyter/delete_session.ts +++ b/apps/sim/tools/jupyter/delete_session.ts @@ -2,7 +2,6 @@ import type { JupyterDeleteSessionParams, JupyterDeleteSessionResponse, } from '@/tools/jupyter/types' -import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterDeleteSessionTool: ToolConfig< @@ -36,10 +35,15 @@ export const jupyterDeleteSessionTool: ToolConfig< }, request: { - url: (params) => - `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions/${encodeURIComponent(params.sessionId)}`, - method: 'DELETE', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'DELETE', + path: `sessions/${encodeURIComponent(params.sessionId)}`, + }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/get_content.ts b/apps/sim/tools/jupyter/get_content.ts index e30bacb6e69..55f0a31cba3 100644 --- a/apps/sim/tools/jupyter/get_content.ts +++ b/apps/sim/tools/jupyter/get_content.ts @@ -1,9 +1,5 @@ import type { JupyterGetContentParams, JupyterGetContentResponse } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - encodeJupyterPath, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { encodeJupyterPath } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterGetContentTool: ToolConfig = @@ -35,13 +31,15 @@ export const jupyterGetContentTool: ToolConfig { - const base = normalizeJupyterServerUrl(params.serverUrl) - const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}?content=1` - }, - method: 'GET', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: `contents/${encodeJupyterPath(params.path)}?content=1`, + }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/interrupt_kernel.ts b/apps/sim/tools/jupyter/interrupt_kernel.ts index d02c544e1bc..c5371a84bbc 100644 --- a/apps/sim/tools/jupyter/interrupt_kernel.ts +++ b/apps/sim/tools/jupyter/interrupt_kernel.ts @@ -2,7 +2,6 @@ import type { JupyterInterruptKernelParams, JupyterInterruptKernelResponse, } from '@/tools/jupyter/types' -import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterInterruptKernelTool: ToolConfig< @@ -36,10 +35,15 @@ export const jupyterInterruptKernelTool: ToolConfig< }, request: { - url: (params) => - `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}/interrupt`, + url: '/api/tools/jupyter/proxy', method: 'POST', - headers: (params) => buildJupyterAuthHeaders(params.token), + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: `kernels/${encodeURIComponent(params.kernelId)}/interrupt`, + }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/list_contents.ts b/apps/sim/tools/jupyter/list_contents.ts index cea8675889f..f8f8cce2fe0 100644 --- a/apps/sim/tools/jupyter/list_contents.ts +++ b/apps/sim/tools/jupyter/list_contents.ts @@ -1,9 +1,5 @@ import type { JupyterListContentsParams, JupyterListContentsResponse } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - encodeJupyterPath, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { encodeJupyterPath } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterListContentsTool: ToolConfig< @@ -37,13 +33,15 @@ export const jupyterListContentsTool: ToolConfig< }, request: { - url: (params) => { - const base = normalizeJupyterServerUrl(params.serverUrl) - const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}?type=directory&content=1` - }, - method: 'GET', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: `contents/${encodeJupyterPath(params.path)}?type=directory&content=1`, + }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/list_kernels.ts b/apps/sim/tools/jupyter/list_kernels.ts index 14a6e635f45..c5a93c45794 100644 --- a/apps/sim/tools/jupyter/list_kernels.ts +++ b/apps/sim/tools/jupyter/list_kernels.ts @@ -1,9 +1,5 @@ import type { JupyterListKernelsParams, JupyterListKernelsResponse } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - mapJupyterKernel, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { mapJupyterKernel } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterListKernelsTool: ToolConfig< @@ -31,9 +27,15 @@ export const jupyterListKernelsTool: ToolConfig< }, request: { - url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels`, - method: 'GET', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: 'kernels', + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/jupyter/list_kernelspecs.ts b/apps/sim/tools/jupyter/list_kernelspecs.ts index 90c834d2b40..5c3bb39e72d 100644 --- a/apps/sim/tools/jupyter/list_kernelspecs.ts +++ b/apps/sim/tools/jupyter/list_kernelspecs.ts @@ -2,7 +2,6 @@ import type { JupyterListKernelspecsParams, JupyterListKernelspecsResponse, } from '@/tools/jupyter/types' -import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterListKernelspecsTool: ToolConfig< @@ -30,9 +29,15 @@ export const jupyterListKernelspecsTool: ToolConfig< }, request: { - url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernelspecs`, - method: 'GET', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: 'kernelspecs', + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/jupyter/list_sessions.ts b/apps/sim/tools/jupyter/list_sessions.ts index 7364bd08314..5081f649e1c 100644 --- a/apps/sim/tools/jupyter/list_sessions.ts +++ b/apps/sim/tools/jupyter/list_sessions.ts @@ -1,9 +1,5 @@ import type { JupyterListSessionsParams, JupyterListSessionsResponse } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - mapJupyterSession, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { mapJupyterSession } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterListSessionsTool: ToolConfig< @@ -31,9 +27,15 @@ export const jupyterListSessionsTool: ToolConfig< }, request: { - url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions`, - method: 'GET', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'GET', + path: 'sessions', + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/jupyter/rename_content.ts b/apps/sim/tools/jupyter/rename_content.ts index a995e28d2ae..2e32900b1e6 100644 --- a/apps/sim/tools/jupyter/rename_content.ts +++ b/apps/sim/tools/jupyter/rename_content.ts @@ -2,12 +2,7 @@ import type { JupyterRenameContentParams, JupyterRenameContentResponse, } from '@/tools/jupyter/types' -import { - assertSafeJupyterPath, - buildJupyterAuthHeaders, - encodeJupyterPath, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { assertSafeJupyterPath, encodeJupyterPath } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterRenameContentTool: ToolConfig< @@ -47,17 +42,16 @@ export const jupyterRenameContentTool: ToolConfig< }, request: { - url: (params) => { - const base = normalizeJupyterServerUrl(params.serverUrl) - const path = encodeJupyterPath(params.path) - return `${base}/api/contents/${path}` - }, - method: 'PATCH', - headers: (params) => ({ - ...buildJupyterAuthHeaders(params.token), - 'Content-Type': 'application/json', + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'PATCH', + path: `contents/${encodeJupyterPath(params.path)}`, + body: { path: assertSafeJupyterPath(params.newPath) }, }), - body: (params) => ({ path: assertSafeJupyterPath(params.newPath) }), }, transformResponse: async (response, params) => { diff --git a/apps/sim/tools/jupyter/restart_kernel.ts b/apps/sim/tools/jupyter/restart_kernel.ts index 24afe1407eb..b70365b02fe 100644 --- a/apps/sim/tools/jupyter/restart_kernel.ts +++ b/apps/sim/tools/jupyter/restart_kernel.ts @@ -2,11 +2,7 @@ import type { JupyterRestartKernelParams, JupyterRestartKernelResponse, } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - mapJupyterKernel, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { mapJupyterKernel } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterRestartKernelTool: ToolConfig< @@ -40,10 +36,15 @@ export const jupyterRestartKernelTool: ToolConfig< }, request: { - url: (params) => - `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}/restart`, + url: '/api/tools/jupyter/proxy', method: 'POST', - headers: (params) => buildJupyterAuthHeaders(params.token), + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: `kernels/${encodeURIComponent(params.kernelId)}/restart`, + }), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/jupyter/start_kernel.ts b/apps/sim/tools/jupyter/start_kernel.ts index 106e12e009f..3c9985eb932 100644 --- a/apps/sim/tools/jupyter/start_kernel.ts +++ b/apps/sim/tools/jupyter/start_kernel.ts @@ -1,9 +1,5 @@ import type { JupyterStartKernelParams, JupyterStartKernelResponse } from '@/tools/jupyter/types' -import { - buildJupyterAuthHeaders, - mapJupyterKernel, - normalizeJupyterServerUrl, -} from '@/tools/jupyter/utils' +import { mapJupyterKernel } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterStartKernelTool: ToolConfig< @@ -37,13 +33,16 @@ export const jupyterStartKernelTool: ToolConfig< }, request: { - url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels`, + url: '/api/tools/jupyter/proxy', method: 'POST', - headers: (params) => ({ - ...buildJupyterAuthHeaders(params.token), - 'Content-Type': 'application/json', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'POST', + path: 'kernels', + body: params.kernelName ? { name: params.kernelName } : {}, }), - body: (params) => (params.kernelName ? { name: params.kernelName } : {}), }, transformResponse: async (response) => { diff --git a/apps/sim/tools/jupyter/stop_kernel.ts b/apps/sim/tools/jupyter/stop_kernel.ts index 8541642ee86..261e9f996ef 100644 --- a/apps/sim/tools/jupyter/stop_kernel.ts +++ b/apps/sim/tools/jupyter/stop_kernel.ts @@ -1,5 +1,4 @@ import type { JupyterStopKernelParams, JupyterStopKernelResponse } from '@/tools/jupyter/types' -import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' import type { ToolConfig } from '@/tools/types' export const jupyterStopKernelTool: ToolConfig = @@ -31,10 +30,15 @@ export const jupyterStopKernelTool: ToolConfig - `${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}`, - method: 'DELETE', - headers: (params) => buildJupyterAuthHeaders(params.token), + url: '/api/tools/jupyter/proxy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + serverUrl: params.serverUrl, + token: params.token, + method: 'DELETE', + path: `kernels/${encodeURIComponent(params.kernelId)}`, + }), }, transformResponse: async (response, params) => { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index a3523233a05..23c532a1dea 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 920, - zodRoutes: 920, + totalRoutes: 921, + zodRoutes: 921, nonZodRoutes: 0, } as const From fdd45a612c46f12c157bd9ca5c116650310998d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 19:20:44 -0700 Subject: [PATCH 8/9] fix(jupyter): validate proxy path at the trust boundary, reject path separators in upload filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The proxy route now independently validates the incoming path field for traversal segments instead of only relying on tool-side validation before the request reaches it — the route is a shared internal boundary, not something only our own tool code can call - The upload route's fileName can come from an advanced override or the legacy fileContent path and could itself contain '/' or '\', silently nesting the upload deeper than the directory param specified. Now rejected outright before joining. --- apps/sim/app/api/tools/jupyter/proxy/route.ts | 16 +++++++++++++++- apps/sim/app/api/tools/jupyter/upload/route.ts | 7 +++++++ apps/sim/tools/jupyter/utils.ts | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/jupyter/proxy/route.ts b/apps/sim/app/api/tools/jupyter/proxy/route.ts index 1be15bcd9ea..d30a68a4a49 100644 --- a/apps/sim/app/api/tools/jupyter/proxy/route.ts +++ b/apps/sim/app/api/tools/jupyter/proxy/route.ts @@ -10,7 +10,12 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils' +import { + assertSafeJupyterProxyPath, + buildJupyterAuthHeaders, + normalizeJupyterServerUrl, + UnsafeJupyterPathError, +} from '@/tools/jupyter/utils' export const dynamic = 'force-dynamic' @@ -41,6 +46,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const data = parsed.data.body + try { + assertSafeJupyterProxyPath(data.path) + } catch (error) { + if (error instanceof UnsafeJupyterPathError) { + return NextResponse.json({ success: false, error: error.message }, { status: 400 }) + } + throw error + } + const base = normalizeJupyterServerUrl(data.serverUrl) const url = `${base}/api/${data.path}` diff --git a/apps/sim/app/api/tools/jupyter/upload/route.ts b/apps/sim/app/api/tools/jupyter/upload/route.ts index 4820ffb4805..3c12cb21bbf 100644 --- a/apps/sim/app/api/tools/jupyter/upload/route.ts +++ b/apps/sim/app/api/tools/jupyter/upload/route.ts @@ -74,6 +74,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } + if (/[/\\]/.test(fileName)) { + return NextResponse.json( + { success: false, error: 'File name must not contain path separators' }, + { status: 400 } + ) + } + const base = normalizeJupyterServerUrl(data.serverUrl) const destinationDirectory = (data.directory ?? '').replace(/\/+$/, '') const destinationPath = destinationDirectory ? `${destinationDirectory}/${fileName}` : fileName diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts index 0aa5adaa08d..a4690970c49 100644 --- a/apps/sim/tools/jupyter/utils.ts +++ b/apps/sim/tools/jupyter/utils.ts @@ -120,6 +120,23 @@ export function assertSafeJupyterPath(path: string): string { return path } +/** + * Validates the `path` field of a `/api/tools/jupyter/proxy` request — an + * already-encoded relative path under `/api/` (e.g. + * `contents/notebooks%2Fa.ipynb?content=1`) that may carry a query string. + * Every tool already validates its own path segments before building this + * value, but the proxy route is a shared internal trust boundary reachable + * independent of any one tool's call site, so it re-validates rather than + * assuming the caller did. + * + * @throws {UnsafeJupyterPathError} when a segment of the path portion + * (everything before the first `?`) is `.` or `..`. + */ +export function assertSafeJupyterProxyPath(rawPath: string): void { + const [pathname] = rawPath.split('?') + assertNoJupyterPathTraversal(pathname) +} + interface RawJupyterKernel { id?: string name?: string From 280ad027aa199da6ed6dc91d1ccf9fa08d776fe8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 19:28:57 -0700 Subject: [PATCH 9/9] fix(jupyter): decode the whole path before splitting, not per-already-split segment A segment like foo%2f..%2fsecret has no literal slash, so splitting on literal '/' first and decoding each piece in isolation treats it as one opaque segment and never notices the '..' hiding behind the encoded slash. Decode the full path once, then split and check every segment the target server's own single URL-decode pass would see. --- apps/sim/tools/jupyter/utils.ts | 44 ++++++++++++++------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/apps/sim/tools/jupyter/utils.ts b/apps/sim/tools/jupyter/utils.ts index a4690970c49..517ec545c95 100644 --- a/apps/sim/tools/jupyter/utils.ts +++ b/apps/sim/tools/jupyter/utils.ts @@ -58,43 +58,37 @@ export class UnsafeJupyterPathError extends Error { } } -/** - * Whether a path segment is `.`/`..`, checked both literally and after - * percent-decoding — a caller could submit an already-encoded `%2e%2e` to try - * to slip a traversal segment past a literal-only check. - */ -function isTraversalSegment(segment: string): boolean { - if (segment === '.' || segment === '..') return true - - let decoded: string - try { - decoded = decodeURIComponent(segment) - } catch { - return false - } - - return decoded === '.' || decoded === '..' -} - /** * Rejects `.` and `..` segments in a Jupyter contents path, which could * otherwise traverse outside the intended directory on the target server. * Shared by every helper that sends a path to Jupyter, whether in a URL or a * request body. * + * Decodes the *entire* path once before splitting it, rather than splitting + * on literal `/` first and decoding each piece in isolation — a segment like + * `foo%2f..%2fsecret` has no literal slash, so a split-then-decode check + * would treat it as one opaque segment and never notice the `..` hiding + * behind the encoded slash. Decoding first exposes every segment the target + * server would actually see once its own single URL-decode pass runs. + * * @throws {UnsafeJupyterPathError} when a segment is `.` or `..`, literally - * or percent-encoded. + * or percent-encoded (including an encoded slash exposing a hidden segment). */ function assertNoJupyterPathTraversal(path: string | undefined): string[] { - const segments = (path ?? '').split('/').filter((segment) => segment.length > 0) + const raw = path ?? '' + + let decoded: string + try { + decoded = decodeURIComponent(raw) + } catch { + decoded = raw + } - for (const segment of segments) { - if (isTraversalSegment(segment)) { - throw new UnsafeJupyterPathError(path ?? '') - } + if (decoded.split('/').some((segment) => segment === '.' || segment === '..')) { + throw new UnsafeJupyterPathError(raw) } - return segments + return raw.split('/').filter((segment) => segment.length > 0) } /**