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,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