chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,119 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { jupyterCopyContentTool } from '@/tools/jupyter/copy_content'
import { jupyterCreateFileTool } from '@/tools/jupyter/create_file'
import { jupyterGetContentTool } from '@/tools/jupyter/get_content'
import { jupyterListContentsTool } from '@/tools/jupyter/list_contents'
import { jupyterRenameContentTool } from '@/tools/jupyter/rename_content'
const AUTH = {
serverUrl: 'http://localhost:8888',
token: 'token',
}
const CONTENT_MODEL = {
name: 'notes.txt',
path: 'docs/notes.txt',
type: 'file',
writable: true,
created: '2026-07-09T10:00:00Z',
last_modified: '2026-07-09T11:00:00Z',
size: 5,
mimetype: 'text/plain',
format: 'text',
content: 'hello',
}
describe('Jupyter content transforms', () => {
it('preserves existing output shapes for valid Contents API models', async () => {
await expect(
jupyterCreateFileTool.transformResponse?.(Response.json(CONTENT_MODEL), {
...AUTH,
path: CONTENT_MODEL.path,
type: 'file',
})
).resolves.toMatchObject({
success: true,
output: {
name: CONTENT_MODEL.name,
path: CONTENT_MODEL.path,
type: 'file',
createdAt: CONTENT_MODEL.created,
lastModified: CONTENT_MODEL.last_modified,
},
})
await expect(
jupyterCopyContentTool.transformResponse?.(Response.json(CONTENT_MODEL), {
...AUTH,
path: CONTENT_MODEL.path,
copyFromPath: 'notes.txt',
})
).resolves.toMatchObject({
success: true,
output: {
name: CONTENT_MODEL.name,
path: CONTENT_MODEL.path,
createdAt: CONTENT_MODEL.created,
},
})
await expect(
jupyterRenameContentTool.transformResponse?.(Response.json(CONTENT_MODEL), {
...AUTH,
path: 'notes.txt',
newPath: CONTENT_MODEL.path,
})
).resolves.toMatchObject({
success: true,
output: {
name: CONTENT_MODEL.name,
path: CONTENT_MODEL.path,
lastModified: CONTENT_MODEL.last_modified,
},
})
await expect(
jupyterListContentsTool.transformResponse?.(
Response.json({ ...CONTENT_MODEL, path: 'docs', content: [CONTENT_MODEL] }),
{ ...AUTH, path: 'docs' }
)
).resolves.toMatchObject({
success: true,
output: {
path: 'docs',
items: [
{
name: CONTENT_MODEL.name,
path: CONTENT_MODEL.path,
type: 'file',
writable: true,
created: CONTENT_MODEL.created,
lastModified: CONTENT_MODEL.last_modified,
size: CONTENT_MODEL.size,
mimetype: CONTENT_MODEL.mimetype,
format: CONTENT_MODEL.format,
},
],
},
})
await expect(
jupyterGetContentTool.transformResponse?.(Response.json(CONTENT_MODEL), {
...AUTH,
path: CONTENT_MODEL.path,
})
).resolves.toMatchObject({
success: true,
output: {
name: CONTENT_MODEL.name,
path: CONTENT_MODEL.path,
mimetype: CONTENT_MODEL.mimetype,
text: CONTENT_MODEL.content,
file: null,
},
})
})
})
+85
View File
@@ -0,0 +1,85 @@
import type { JupyterCopyContentParams, JupyterCopyContentResponse } from '@/tools/jupyter/types'
import {
assertSafeJupyterPath,
encodeJupyterPath,
parseJupyterContentModel,
} 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: '/api/tools/jupyter/proxy',
method: 'POST',
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) },
}),
},
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 = parseJupyterContentModel(await response.json()) ?? {}
return {
success: true,
output: {
name: data.name ?? '',
path: data.path ?? params?.path ?? '',
createdAt: data.created ?? 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 },
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { JupyterCreateFileParams, JupyterCreateFileResponse } from '@/tools/jupyter/types'
import { encodeJupyterPath, parseJupyterContentModel } from '@/tools/jupyter/utils'
import type { ToolConfig } from '@/tools/types'
const EMPTY_NOTEBOOK = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 }
export const jupyterCreateFileTool: ToolConfig<JupyterCreateFileParams, JupyterCreateFileResponse> =
{
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: '/api/tools/jupyter/proxy',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => {
let content: Record<string, unknown>
if (params.type === 'directory') {
content = { type: 'directory' }
} else if (params.type === 'notebook') {
if (!params.content) {
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 }
}
} else {
content = { type: 'file', format: 'text', content: params.content ?? '' }
}
return {
serverUrl: params.serverUrl,
token: params.token,
method: 'PUT',
path: `contents/${encodeJupyterPath(params.path)}`,
body: 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 = parseJupyterContentModel(await response.json()) ?? {}
return {
success: true,
output: {
name: data.name ?? '',
path: data.path ?? params?.path ?? '',
type: data.type ?? 'file',
createdAt: data.created ?? null,
lastModified: data.lastModified ?? 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 },
},
}
+110
View File
@@ -0,0 +1,110 @@
import type {
JupyterCreateSessionParams,
JupyterCreateSessionResponse,
} from '@/tools/jupyter/types'
import { assertSafeJupyterPath, mapJupyterSession } 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: '/api/tools/jupyter/proxy',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
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 } } : {}),
},
}),
},
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 },
},
},
},
}
+70
View File
@@ -0,0 +1,70 @@
import type {
JupyterDeleteContentParams,
JupyterDeleteContentResponse,
} from '@/tools/jupyter/types'
import { encodeJupyterPath } 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: '/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) => {
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' },
},
}
+69
View File
@@ -0,0 +1,69 @@
import type {
JupyterDeleteSessionParams,
JupyterDeleteSessionResponse,
} from '@/tools/jupyter/types'
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: '/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) => {
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' },
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { JupyterGetContentParams, JupyterGetContentResponse } from '@/tools/jupyter/types'
import { encodeJupyterPath, parseJupyterContentModel } from '@/tools/jupyter/utils'
import type { ToolConfig } from '@/tools/types'
export const jupyterGetContentTool: ToolConfig<JupyterGetContentParams, JupyterGetContentResponse> =
{
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: '/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) => {
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 = parseJupyterContentModel(await response.json()) ?? {}
const format = data.format
const name = data.name ?? params?.path?.split('/').pop() ?? 'file'
const mimetype = data.mimetype ?? null
if (format === 'base64' && typeof data.content === 'string') {
const buffer = Buffer.from(data.content, 'base64')
return {
success: true,
output: {
name,
path: data.path ?? 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)
: typeof data.content === 'string'
? data.content
: null
return {
success: true,
output: {
name,
path: data.path ?? 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,
},
},
}
+16
View File
@@ -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'
@@ -0,0 +1,69 @@
import type {
JupyterInterruptKernelParams,
JupyterInterruptKernelResponse,
} from '@/tools/jupyter/types'
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: '/api/tools/jupyter/proxy',
method: 'POST',
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) => {
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' },
},
}
+110
View File
@@ -0,0 +1,110 @@
import type { JupyterListContentsParams, JupyterListContentsResponse } from '@/tools/jupyter/types'
import { encodeJupyterPath, parseJupyterContentModel } 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: '/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) => {
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 = parseJupyterContentModel(await response.json()) ?? {}
const items = Array.isArray(data.content) ? data.content : []
return {
success: true,
output: {
items: items.map((item) => {
const content = parseJupyterContentModel(item) ?? {}
return {
name: content.name ?? '',
path: content.path ?? '',
type: content.type ?? 'file',
writable: content.writable ?? false,
created: content.created ?? null,
lastModified: content.lastModified ?? null,
size: content.size ?? null,
mimetype: content.mimetype ?? null,
format: content.format ?? null,
}
}),
path: data.path ?? 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',
},
},
}
+76
View File
@@ -0,0 +1,76 @@
import type { JupyterListKernelsParams, JupyterListKernelsResponse } from '@/tools/jupyter/types'
import { mapJupyterKernel } 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: '/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) => {
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 },
},
},
},
},
}
@@ -0,0 +1,93 @@
import type {
JupyterListKernelspecsParams,
JupyterListKernelspecsResponse,
} from '@/tools/jupyter/types'
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: '/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) => {
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<string, Record<string, unknown>>).map(
([name, entry]) => {
const spec = (entry.spec as Record<string, unknown> | 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 },
},
},
},
},
}
+99
View File
@@ -0,0 +1,99 @@
import type { JupyterListSessionsParams, JupyterListSessionsResponse } from '@/tools/jupyter/types'
import { mapJupyterSession } 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: '/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) => {
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,
},
},
},
},
},
},
},
}
+88
View File
@@ -0,0 +1,88 @@
import type {
JupyterRenameContentParams,
JupyterRenameContentResponse,
} from '@/tools/jupyter/types'
import {
assertSafeJupyterPath,
encodeJupyterPath,
parseJupyterContentModel,
} 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: '/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) },
}),
},
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 = parseJupyterContentModel(await response.json()) ?? {}
return {
success: true,
output: {
name: data.name ?? '',
path: data.path ?? params?.newPath ?? '',
lastModified: data.lastModified ?? 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 },
},
}
+71
View File
@@ -0,0 +1,71 @@
import type {
JupyterRestartKernelParams,
JupyterRestartKernelResponse,
} from '@/tools/jupyter/types'
import { mapJupyterKernel } 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: '/api/tools/jupyter/proxy',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
serverUrl: params.serverUrl,
token: params.token,
method: 'POST',
path: `kernels/${encodeURIComponent(params.kernelId)}/restart`,
}),
},
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 },
},
}
+69
View File
@@ -0,0 +1,69 @@
import type { JupyterStartKernelParams, JupyterStartKernelResponse } from '@/tools/jupyter/types'
import { mapJupyterKernel } 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: '/api/tools/jupyter/proxy',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
serverUrl: params.serverUrl,
token: params.token,
method: 'POST',
path: 'kernels',
body: 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 },
},
}
+64
View File
@@ -0,0 +1,64 @@
import type { JupyterStopKernelParams, JupyterStopKernelResponse } from '@/tools/jupyter/types'
import type { ToolConfig } from '@/tools/types'
export const jupyterStopKernelTool: ToolConfig<JupyterStopKernelParams, JupyterStopKernelResponse> =
{
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: '/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) => {
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' },
},
}
+226
View File
@@ -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 {
directory?: 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
}
}
+86
View File
@@ -0,0 +1,86 @@
import type { JupyterUploadFileParams, JupyterUploadFileResponse } from '@/tools/jupyter/types'
import type { ToolConfig } from '@/tools/types'
export const jupyterUploadFileTool: ToolConfig<JupyterUploadFileParams, JupyterUploadFileResponse> =
{
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',
},
directory: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Destination directory, relative to the server root. Leave blank to upload to the root directory.',
},
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,
directory: params.directory,
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 },
},
}
+52
View File
@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseJupyterContentModel } from '@/tools/jupyter/utils'
describe('parseJupyterContentModel', () => {
it('normalizes a Jupyter Contents API model without changing its values', () => {
const content = { cells: [] }
expect(
parseJupyterContentModel({
name: 'analysis.ipynb',
path: 'notebooks/analysis.ipynb',
type: 'notebook',
writable: true,
created: '2026-07-09T10:00:00Z',
last_modified: '2026-07-09T11:00:00Z',
size: 42,
mimetype: 'application/x-ipynb+json',
format: 'json',
content,
})
).toEqual({
name: 'analysis.ipynb',
path: 'notebooks/analysis.ipynb',
type: 'notebook',
writable: true,
created: '2026-07-09T10:00:00Z',
lastModified: '2026-07-09T11:00:00Z',
size: 42,
mimetype: 'application/x-ipynb+json',
format: 'json',
content,
})
})
it('rejects non-object models and omits fields with invalid types', () => {
expect(parseJupyterContentModel(null)).toBeNull()
expect(
parseJupyterContentModel({
name: 42,
path: 'valid/path',
size: '42',
content: null,
})
).toEqual({
path: 'valid/path',
content: null,
})
})
})
+229
View File
@@ -0,0 +1,229 @@
import { isPlainRecord } from '@sim/utils/object'
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<string, string> {
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'
}
}
/**
* 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 (including an encoded slash exposing a hidden segment).
*/
function assertNoJupyterPathTraversal(path: string | undefined): string[] {
const raw = path ?? ''
let decoded: string
try {
decoded = decodeURIComponent(raw)
} catch {
decoded = raw
}
if (decoded.split('/').some((segment) => segment === '.' || segment === '..')) {
throw new UnsafeJupyterPathError(raw)
}
return raw.split('/').filter((segment) => segment.length > 0)
}
/**
* 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
}
/**
* 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 JupyterContentModel {
name?: string
path?: string
type?: 'directory' | 'file' | 'notebook'
writable?: boolean
created?: string
lastModified?: string
size?: number
mimetype?: string
format?: 'json' | 'text' | 'base64'
content?: unknown
}
/** Parses the shared model returned by Jupyter's Contents API. */
export function parseJupyterContentModel(value: unknown): JupyterContentModel | null {
if (!isPlainRecord(value)) return null
const type =
value.type === 'directory' || value.type === 'file' || value.type === 'notebook'
? value.type
: undefined
const format =
value.format === 'json' || value.format === 'text' || value.format === 'base64'
? value.format
: undefined
return {
...(typeof value.name === 'string' ? { name: value.name } : {}),
...(typeof value.path === 'string' ? { path: value.path } : {}),
...(type ? { type } : {}),
...(typeof value.writable === 'boolean' ? { writable: value.writable } : {}),
...(typeof value.created === 'string' ? { created: value.created } : {}),
...(typeof value.last_modified === 'string' ? { lastModified: value.last_modified } : {}),
...(typeof value.size === 'number' ? { size: value.size } : {}),
...(typeof value.mimetype === 'string' ? { mimetype: value.mimetype } : {}),
...(format ? { format } : {}),
...('content' in value ? { content: value.content } : {}),
}
}
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<typeof mapJupyterKernel> | null
} {
return {
id: raw.id ?? '',
path: raw.path ?? '',
name: raw.name ?? '',
type: raw.type ?? '',
kernel: raw.kernel ? mapJupyterKernel(raw.kernel) : null,
}
}