d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
230 lines
7.3 KiB
TypeScript
230 lines
7.3 KiB
TypeScript
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,
|
|
}
|
|
}
|