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
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:
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user