diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 2623a884..ce585c8e 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -943,6 +943,12 @@ "description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.", "version": "1.0.2" }, + { + "name": "jupyter-notebooks", + "source": "plugins/jupyter-notebooks", + "description": "Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas.", + "version": "1.0.0" + }, { "name": "keep-the-why", "description": "Preserves or recovers the reasoning behind a codebase — architectural decisions, rejected alternatives, workarounds, incident learnings, operational constraints, and historical context the code itself cannot explain.", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 71c54c3b..f53f61f3 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -73,6 +73,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [java-development](../plugins/java-development/README.md) | Comprehensive collection of prompts and instructions for Java development including Spring Boot, Quarkus, testing, documentation, and best practices. | 0 items | java, springboot, quarkus, jpa, junit, javadoc | | [java-mcp-development](../plugins/java-mcp-development/README.md) | Complete toolkit for building Model Context Protocol servers in Java using the official MCP Java SDK with reactive streams and Spring Boot integration. | 0 items | java, mcp, model-context-protocol, server-development, sdk, reactive-streams, spring-boot, reactor | | [java-modernization-studio](../plugins/java-modernization-studio/README.md) | Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts. | 1 items | app-modernization, assessment-dashboard, azure-migration, java-modernization, legacy-java, modernization-cockpit, validation-gates | +| [jupyter-notebooks](../plugins/jupyter-notebooks/README.md) | Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas. | 1 items | data-analysis, interactive-notebooks, jupyter, python | | [kotlin-mcp-development](../plugins/kotlin-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 0 items | kotlin, mcp, model-context-protocol, kotlin-multiplatform, server-development, ktor | | [mcp-m365-copilot](../plugins/mcp-m365-copilot/README.md) | Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot | 0 items | mcp, m365-copilot, declarative-agents, api-plugins, model-context-protocol, adaptive-cards | | [napkin](../plugins/napkin/README.md) | Visual whiteboard collaboration for Copilot CLI. Opens an interactive whiteboard in your browser where you can draw, sketch, and add sticky notes — then share everything back with Copilot. Copilot sees your drawings and responds with analysis, suggestions, and ideas. | 0 items | whiteboard, visual, collaboration, brainstorming, non-technical, drawing, sticky-notes, accessibility, copilot-cli, ux | diff --git a/extensions/jupyter-notebooks/README.md b/extensions/jupyter-notebooks/README.md new file mode 100644 index 00000000..7aef6e75 --- /dev/null +++ b/extensions/jupyter-notebooks/README.md @@ -0,0 +1,38 @@ +# Jupyter Notebooks + +Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive GitHub Copilot canvas. + +![Jupyter Notebooks canvas showing Markdown, Python code, and execution output](assets/preview.png) + +## What it does + +- Opens existing `.ipynb` files from the current workspace. +- Creates code, Markdown, and raw cells with reorder, duplicate, undo, and redo controls. +- Runs Python cells through a fresh isolated process with bounded resources and captured rich output. +- Saves, renames, and duplicates notebooks as standard `.ipynb` files. +- Creates persistent checkpoints that can be restored from the canvas. + +## Requirements + +- Python 3 available as `python3` or `python`. +- The `@github/copilot-sdk` package installed with the extension. + +## Install + +Ask Copilot to install the committed extension: + +```text +Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/jupyter-notebooks +``` + +Reload extensions, then ask Copilot to open the `jupyter-notebooks` canvas. + +## Agent actions + +The canvas provides actions for notebook lifecycle, workspace files, cell editing, execution, runtime selection, and checkpoints. Use `get_notebook` to inspect the current state before editing a cell. + +## Security and local data + +Notebook execution uses Python isolated mode in a fresh temporary directory. The runner applies CPU, file-size, process, and memory limits where the platform supports them, caps captured output, and stops execution after 30 seconds. + +The local canvas server binds to `127.0.0.1`, protects requests with a per-instance capability token, validates action origins, and sends a restrictive content security policy. Notebook state and checkpoints stay in the active Copilot workspace. diff --git a/extensions/jupyter-notebooks/assets/preview.png b/extensions/jupyter-notebooks/assets/preview.png new file mode 100644 index 00000000..2eb497d2 Binary files /dev/null and b/extensions/jupyter-notebooks/assets/preview.png differ diff --git a/extensions/jupyter-notebooks/copilot-extension.json b/extensions/jupyter-notebooks/copilot-extension.json new file mode 100644 index 00000000..6d66d169 --- /dev/null +++ b/extensions/jupyter-notebooks/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "jupyter-notebooks", + "version": 1 +} diff --git a/extensions/jupyter-notebooks/extension.mjs b/extensions/jupyter-notebooks/extension.mjs new file mode 100644 index 00000000..aca8438a --- /dev/null +++ b/extensions/jupyter-notebooks/extension.mjs @@ -0,0 +1,4 @@ +// Extension: jupyter-notebooks +// The implementation is isolated so this required discovery entrypoint stays focused. + +import "./notebook-canvas.mjs"; diff --git a/extensions/jupyter-notebooks/notebook-canvas.mjs b/extensions/jupyter-notebooks/notebook-canvas.mjs new file mode 100644 index 00000000..bd624a8e --- /dev/null +++ b/extensions/jupyter-notebooks/notebook-canvas.mjs @@ -0,0 +1,3901 @@ +// Jupyter notebook canvas implementation. + +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const extensionRoot = path.dirname(fileURLToPath(import.meta.url)); +const servers = new Map(); +const clientsByNotebook = new Map(); +const notebookQueues = new Map(); +const activeRuns = new Map(); +const workspaceSnapshots = new Map(); +const historyByNotebook = new Map(); +let workspaceRoot; +let notebooksDir; +let checkpointsDir; +let storageReady = Promise.resolve(); + +const notebookIdPattern = "^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$"; +const cellIdPattern = "^[A-Za-z0-9_-]{1,64}$"; +const notebookIdRegex = new RegExp(notebookIdPattern); +const cellIdRegex = new RegExp(cellIdPattern); +const openInputSchema = { + type: "object", + additionalProperties: false, + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + title: { type: "string", minLength: 1, maxLength: 120 }, + }, +}; + +const notebookActionSchema = { + type: "object", + additionalProperties: false, + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + }, +}; + +function cellIdSchema(required = true) { + return { + type: "object", + additionalProperties: false, + required: required ? ["cellId"] : [], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + cellId: { type: "string", pattern: cellIdPattern }, + }, + }; +} + +function textToSource(value) { + return typeof value === "string" ? value : ""; +} + +async function migrateLegacyDirectory(source, destination) { + let entries; + try { + entries = await readdir(source, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") { + return; + } + throw error; + } + await mkdir(destination, { recursive: true }); + for (const entry of entries) { + const sourcePath = path.join(source, entry.name); + const destinationPath = path.join(destination, entry.name); + if (entry.isDirectory()) { + await migrateLegacyDirectory(sourcePath, destinationPath); + continue; + } + if (!entry.isFile()) { + continue; + } + try { + await readFile(destinationPath); + await rm(sourcePath, { force: true }); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + try { + await rename(sourcePath, destinationPath); + } catch (renameError) { + if (renameError?.code !== "EXDEV") { + throw renameError; + } + await writeFile(destinationPath, await readFile(sourcePath), { mode: 0o600 }); + await rm(sourcePath, { force: true }); + } + } + } + await rm(source, { force: true, recursive: true }); +} + +async function initializeStorage(sessionWorkspacePath) { + if (!sessionWorkspacePath) { + throw new Error("Notebook canvas requires session workspace storage."); + } + workspaceRoot = path.resolve(sessionWorkspacePath); + const dataRoot = path.join(workspaceRoot, ".notebook-canvas"); + notebooksDir = path.join(dataRoot, "notebooks"); + checkpointsDir = path.join(dataRoot, "checkpoints"); + await mkdir(dataRoot, { recursive: true }); + await migrateLegacyDirectory(path.join(extensionRoot, "notebooks"), notebooksDir); + await migrateLegacyDirectory(path.join(extensionRoot, "checkpoints"), checkpointsDir); +} + +function createCellId(usedIds = new Set()) { + let id; + do { + id = `cell-${randomUUID().slice(0, 8)}`; + } while (usedIds.has(id)); + return id; +} + +function getCellId(cell, usedIds) { + const candidates = [cell.id, cell.metadata?.copilotCellId]; + let id = candidates.find((candidate) => ( + typeof candidate === "string" + && cellIdRegex.test(candidate) + && (!usedIds || !usedIds.has(candidate)) + )); + if (!id) { + id = createCellId(usedIds); + } + cell.id = id; + cell.metadata = { ...(cell.metadata ?? {}), copilotCellId: id }; + usedIds?.add(id); + return id; +} + +function normalizeSource(source) { + if (Array.isArray(source)) { + return source.join(""); + } + return typeof source === "string" ? source : ""; +} + +function normalizeNotebook(raw, notebookId, title, createStarter = false) { + const notebook = raw && typeof raw === "object" ? raw : {}; + let changed = !raw || typeof raw !== "object"; + const originalMetadata = notebook.metadata && typeof notebook.metadata === "object" + ? notebook.metadata + : {}; + const originalCopilot = originalMetadata.copilot && typeof originalMetadata.copilot === "object" + ? originalMetadata.copilot + : {}; + changed ||= notebook.nbformat !== 4; + changed ||= typeof notebook.nbformat_minor !== "number"; + changed ||= !originalMetadata.kernelspec || !originalMetadata.language_info; + changed ||= originalCopilot.notebookId !== notebookId; + changed ||= typeof originalCopilot.title !== "string"; + changed ||= !Number.isInteger(originalCopilot.executionCount); + changed ||= !Number.isInteger(originalCopilot.revision); + notebook.nbformat = 4; + notebook.nbformat_minor = typeof notebook.nbformat_minor === "number" ? notebook.nbformat_minor : 5; + notebook.metadata = { + ...originalMetadata, + kernelspec: originalMetadata.kernelspec ?? { + display_name: "Python 3", + language: "python", + name: "python3", + }, + language_info: originalMetadata.language_info ?? { + name: "python", + file_extension: ".py", + mimetype: "text/x-python", + }, + copilot: { + ...originalCopilot, + notebookId, + title: originalCopilot.title ?? title ?? "Untitled notebook", + executionCount: Number(originalCopilot.executionCount ?? 0), + revision: Number.isInteger(originalCopilot.revision) ? originalCopilot.revision : 0, + }, + }; + if (!Array.isArray(notebook.cells)) { + notebook.cells = []; + changed = true; + } + const usedIds = new Set(); + for (const cell of notebook.cells) { + if (!["code", "markdown", "raw"].includes(cell.cell_type)) { + cell.cell_type = "raw"; + changed = true; + } + cell.metadata = cell.metadata && typeof cell.metadata === "object" ? cell.metadata : {}; + const originalId = cell.id; + getCellId(cell, usedIds); + changed ||= originalId !== cell.id; + if (Array.isArray(cell.source) || typeof cell.source !== "string") { + cell.source = normalizeSource(cell.source); + changed = true; + } + if (cell.cell_type === "code") { + if (!Array.isArray(cell.outputs)) { + cell.outputs = []; + changed = true; + } + if (!Number.isInteger(cell.execution_count) && cell.execution_count !== null) { + cell.execution_count = null; + changed = true; + } + } else { + if ("outputs" in cell || "execution_count" in cell) { + changed = true; + } + delete cell.outputs; + delete cell.execution_count; + } + } + if (createStarter && notebook.cells.length === 0) { + notebook.cells.push(createCell("code", "print(\"Hello from the notebook canvas\")")); + changed = true; + } + return { notebook, changed }; +} + +function notebookPath(notebookId) { + if (typeof notebookId !== "string" || !notebookIdRegex.test(notebookId)) { + throw new CanvasError("notebook_id_invalid", "Notebook ID contains unsupported characters."); + } + return path.join(notebooksDir, `${notebookId}.ipynb`); +} + +function serializeNotebook(notebook) { + return `${JSON.stringify(notebook, null, 2)}\n`; +} + +function contentHash(content) { + return createHash("sha256").update(content).digest("hex"); +} + +function normalizeWorkspacePath(relativePath) { + if (!workspaceRoot) { + throw new CanvasError("workspace_unavailable", "This session does not expose a workspace."); + } + if (typeof relativePath !== "string" || relativePath.length === 0 || relativePath.length > 240) { + throw new CanvasError("workspace_path_invalid", "Choose a workspace-relative .ipynb path."); + } + const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\//, ""); + if ( + path.posix.isAbsolute(normalized) + || normalized.split("/").some((segment) => segment === ".." || segment.length === 0) + || !normalized.toLowerCase().endsWith(".ipynb") + ) { + throw new CanvasError("workspace_path_invalid", "Notebook paths must stay inside the workspace and end in .ipynb."); + } + const resolved = path.resolve(workspaceRoot, ...normalized.split("/")); + const rootWithSeparator = `${path.resolve(workspaceRoot)}${path.sep}`; + if (!resolved.startsWith(rootWithSeparator)) { + throw new CanvasError("workspace_path_invalid", "Notebook path escapes the workspace."); + } + return { relative: normalized, resolved }; +} + +async function readWorkspaceNotebook(relativePath) { + const target = normalizeWorkspacePath(relativePath); + const content = await readFile(target.resolved, "utf8"); + let parsed; + try { + parsed = JSON.parse(content); + } catch { + throw new CanvasError("notebook_json_invalid", `${target.relative} is not a valid notebook JSON file.`); + } + return { ...target, content, parsed }; +} + +async function assertWorkspaceUnchanged(notebookId, notebook) { + const relativePath = notebook.metadata?.copilot?.workspacePath; + if (!relativePath) { + return; + } + const target = normalizeWorkspacePath(relativePath); + const expected = workspaceSnapshots.get(notebookId) ?? contentHash(serializeNotebook(notebook)); + try { + const actual = contentHash(await readFile(target.resolved, "utf8")); + if (actual !== expected) { + throw new CanvasError( + "workspace_file_conflict", + `${target.relative} changed outside the canvas. Open it again or save to a different path.`, + ); + } + } catch (error) { + if (error?.code === "ENOENT") { + throw new CanvasError("workspace_file_missing", `${target.relative} was removed outside the canvas.`); + } + throw error; + } +} + +async function writeWorkspaceDocument(relativePath, content, { overwrite = true } = {}) { + const target = normalizeWorkspacePath(relativePath); + if (!overwrite) { + try { + await readFile(target.resolved, "utf8"); + throw new CanvasError("workspace_file_exists", `${target.relative} already exists.`); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + } + await mkdir(path.dirname(target.resolved), { recursive: true }); + const temporary = path.join(path.dirname(target.resolved), `.${path.basename(target.resolved)}.${randomUUID()}.tmp`); + try { + await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, target.resolved); + } finally { + await rm(temporary, { force: true }); + } + return target; +} + +async function writeWorkspaceCopy(notebookId, notebook, { overwrite = true } = {}) { + const relativePath = notebook.metadata?.copilot?.workspacePath; + if (!relativePath) { + return; + } + const content = serializeNotebook(notebook); + await writeWorkspaceDocument(relativePath, content, { overwrite }); + workspaceSnapshots.set(notebookId, contentHash(content)); +} + +async function assertWorkspaceTargetAvailable(relativePath, overwrite) { + const target = normalizeWorkspacePath(relativePath); + if (overwrite) { + return target; + } + try { + await readFile(target.resolved, "utf8"); + throw new CanvasError("workspace_file_exists", `${target.relative} already exists.`); + } catch (error) { + if (error?.code === "ENOENT") { + return target; + } + throw error; + } +} + +async function listWorkspaceNotebooks() { + if (!workspaceRoot) { + return []; + } + const results = []; + async function visit(directory, depth) { + if (depth > 4 || results.length >= 100) { + return; + } + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".") || ["node_modules", "dist", "build"].includes(entry.name)) { + continue; + } + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(absolute, depth + 1); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".ipynb")) { + results.push(path.relative(workspaceRoot, absolute).split(path.sep).join("/")); + } + } + } + await visit(workspaceRoot, 0); + return results.sort((a, b) => a.localeCompare(b)); +} + +function checkpointPath(notebookId, checkpointId) { + if (!notebookIdRegex.test(notebookId) || !/^[A-Za-z0-9_-]{1,80}$/.test(checkpointId)) { + throw new CanvasError("checkpoint_id_invalid", "Invalid checkpoint identifier."); + } + return path.join(checkpointsDir, notebookId, `${checkpointId}.json`); +} + +async function createCheckpoint(notebookId, notebook, label) { + const checkpointId = `cp-${Date.now()}-${randomUUID().slice(0, 6)}`; + const record = { + checkpointId, + createdAt: new Date().toISOString(), + label: textToSource(label).slice(0, 80) || "Manual checkpoint", + notebook, + }; + const target = checkpointPath(notebookId, checkpointId); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, serializeNotebook(record), { encoding: "utf8", mode: 0o600 }); + return { checkpointId, createdAt: record.createdAt, label: record.label }; +} + +async function listCheckpoints(notebookId) { + const directory = path.join(checkpointsDir, notebookId); + try { + const files = (await readdir(directory)).filter((name) => name.endsWith(".json")); + const checkpoints = []; + for (const file of files) { + const record = JSON.parse(await readFile(path.join(directory, file), "utf8")); + checkpoints.push({ + checkpointId: record.checkpointId, + createdAt: record.createdAt, + label: record.label, + }); + } + return checkpoints.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 50); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } +} + +async function getAvailableRuntimes() { + const runtimes = []; + for (const command of ["python3", "python"]) { + const version = await new Promise((resolve) => { + const child = spawn(command, ["--version"], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code === 0 ? output.trim() : null)); + }); + if (version) { + runtimes.push({ id: command, label: version }); + } + } + return runtimes; +} + +async function applyHistory(notebookId, direction, expectedRevision) { + return withNotebookLock(notebookId, async () => { + const current = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(current.metadata.copilot.revision ?? 0); + if (Number.isInteger(expectedRevision) && expectedRevision !== currentRevision) { + throw new CanvasError("revision_conflict", "Notebook changed before history could be applied."); + } + await assertWorkspaceUnchanged(notebookId, current); + const history = historyByNotebook.get(notebookId) ?? { redo: [], undo: [] }; + const source = direction === "undo" ? history.undo : history.redo; + const destination = direction === "undo" ? history.redo : history.undo; + const snapshot = source.pop(); + if (!snapshot) { + throw new CanvasError("history_empty", `Nothing to ${direction}.`); + } + destination.push(structuredClone(current)); + const workspacePath = current.metadata?.copilot?.workspacePath; + if (workspacePath) { + snapshot.metadata.copilot.workspacePath = workspacePath; + } else { + delete snapshot.metadata.copilot.workspacePath; + } + snapshot.metadata.copilot.revision = currentRevision + 1; + historyByNotebook.set(notebookId, history); + await writeNotebookUnlocked(notebookId, snapshot); + await writeWorkspaceCopy(notebookId, snapshot); + broadcast(notebookId, snapshot); + return { notebookId, notebook: summarizeNotebook(snapshot) }; + }); +} + +function withNotebookLock(notebookId, operation) { + const previous = notebookQueues.get(notebookId) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(operation); + notebookQueues.set(notebookId, current); + return current.finally(() => { + if (notebookQueues.get(notebookId) === current) { + notebookQueues.delete(notebookId); + } + }); +} + +async function writeNotebookUnlocked(notebookId, notebook) { + await mkdir(notebooksDir, { recursive: true }); + const target = notebookPath(notebookId); + const temporary = path.join(notebooksDir, `.${notebookId}.${randomUUID()}.tmp`); + try { + await writeFile(temporary, serializeNotebook(notebook), { encoding: "utf8", mode: 0o600 }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } +} + +async function loadNotebookUnlocked(notebookId = "default", title) { + await mkdir(notebooksDir, { recursive: true }); + try { + const raw = JSON.parse(await readFile(notebookPath(notebookId), "utf8")); + const normalized = normalizeNotebook(raw, notebookId, title); + if (normalized.notebook.metadata?.copilot?.workspacePath) { + workspaceSnapshots.set(notebookId, contentHash(serializeNotebook(normalized.notebook))); + } + if (normalized.changed) { + await writeNotebookUnlocked(notebookId, normalized.notebook); + } + return normalized.notebook; + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + const { notebook } = normalizeNotebook({}, notebookId, title, true); + await writeNotebookUnlocked(notebookId, notebook); + return notebook; + } +} + +function loadNotebook(notebookId = "default", title) { + return withNotebookLock(notebookId, () => loadNotebookUnlocked(notebookId, title)); +} + +function createCell(cellType, source = "") { + const normalizedCellType = ["code", "markdown", "raw"].includes(cellType) ? cellType : "code"; + const cell = { + id: `cell-${randomUUID().slice(0, 8)}`, + cell_type: normalizedCellType, + metadata: {}, + source: textToSource(source), + }; + if (cell.cell_type === "code") { + cell.execution_count = null; + cell.outputs = []; + } + return cell; +} + +function summarizeNotebook(notebook) { + const language = notebook.metadata?.language_info?.name + ?? notebook.metadata?.kernelspec?.language + ?? "unknown"; + return { + title: notebook.metadata?.copilot?.title ?? "Untitled notebook", + revision: Number(notebook.metadata?.copilot?.revision ?? 0), + language, + runtimeLabel: language.toLowerCase() === "python" ? "Isolated Python" : "Execution unavailable", + executionMode: "stateless-run-through", + workspacePath: notebook.metadata?.copilot?.workspacePath ?? null, + runtime: notebook.metadata?.copilot?.runtime ?? "python3", + cells: notebook.cells.map((cell) => ({ + id: getCellId(cell), + cellType: cell.cell_type, + source: normalizeSource(cell.source), + executionCount: cell.execution_count ?? null, + outputs: cell.outputs ?? [], + })), + }; +} + +function resolveNotebookId(ctx, input) { + const fromInput = input && typeof input.notebookId === "string" ? input.notebookId : undefined; + const fromBoundServer = ctx.notebookId ?? servers.get(ctx.instanceId)?.notebookId; + if (fromBoundServer && fromInput && fromBoundServer !== fromInput) { + throw new CanvasError("notebook_mismatch", "This canvas instance is bound to a different notebook."); + } + return fromBoundServer ?? fromInput ?? "default"; +} + +function mutateNotebook(notebookId, mutator, expectedRevision, options = {}) { + return withNotebookLock(notebookId, async () => { + const notebook = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(notebook.metadata.copilot.revision ?? 0); + if (Number.isInteger(expectedRevision) && expectedRevision !== currentRevision) { + throw new CanvasError( + "revision_conflict", + `Notebook changed since revision ${expectedRevision}; current revision is ${currentRevision}. Your draft was not overwritten.`, + ); + } + if (!options.skipWorkspacePreflight) { + await assertWorkspaceUnchanged(notebookId, notebook); + } + const history = historyByNotebook.get(notebookId) ?? { redo: [], undo: [] }; + history.undo.push(structuredClone(notebook)); + history.undo = history.undo.slice(-50); + history.redo = []; + historyByNotebook.set(notebookId, history); + const result = await mutator(notebook); + notebook.metadata.copilot.revision = currentRevision + 1; + await writeNotebookUnlocked(notebookId, notebook); + await writeWorkspaceCopy(notebookId, notebook, { overwrite: options.workspaceOverwrite ?? true }); + broadcast(notebookId, notebook); + return { notebookId, notebook: summarizeNotebook(notebook), ...(result ?? {}) }; + }); +} + +function findCell(notebook, cellId) { + const cell = notebook.cells.find((candidate) => getCellId(candidate) === cellId); + if (!cell) { + throw new CanvasError("cell_not_found", `No cell found with id ${cellId}`); + } + return cell; +} + +function findCellIndex(notebook, cellId) { + const index = notebook.cells.findIndex((candidate) => getCellId(candidate) === cellId); + if (index === -1) { + throw new CanvasError("cell_not_found", `No cell found with id ${cellId}`); + } + return index; +} + +function outputFromRun(result) { + const outputs = []; + if (result.stdout) { + outputs.push({ output_type: "stream", name: "stdout", text: result.stdout }); + } + if (result.stderr) { + outputs.push({ output_type: "stream", name: "stderr", text: result.stderr }); + } + if (result.resultData && Object.keys(result.resultData).length > 0) { + outputs.push({ + output_type: "execute_result", + execution_count: result.executionCount, + data: result.resultData, + metadata: {}, + }); + } + for (const display of result.displays ?? []) { + outputs.push({ + output_type: "display_data", + data: display, + metadata: {}, + }); + } + if (result.error) { + outputs.push({ + output_type: "error", + ename: result.error.ename, + evalue: result.error.evalue, + traceback: result.error.traceback, + }); + } + return outputs; +} + +const pythonRunner = String.raw` +import ast +import base64 +import contextlib +import io +import json +import os +import pathlib +import sys +import traceback +try: + import resource +except ImportError: + resource = None + +payload = json.load(sys.stdin) +namespace = {} +results = [] +MAX_CAPTURE = 250_000 +WORKSPACE = pathlib.Path.cwd().resolve() +if resource is not None: + try: + resource.setrlimit(resource.RLIMIT_CPU, (25, 30)) + resource.setrlimit(resource.RLIMIT_FSIZE, (10 * 1024 * 1024, 10 * 1024 * 1024)) + resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64)) + except (AttributeError, OSError, ValueError): + pass +READ_ROOTS = [WORKSPACE] +for entry in [sys.base_prefix, sys.prefix, *sys.path]: + if entry: + try: + READ_ROOTS.append(pathlib.Path(entry).resolve()) + except (OSError, TypeError): + pass + +class LimitedBuffer(io.StringIO): + def __init__(self, limit): + super().__init__() + self.limit = limit + self.written = 0 + self.truncated = False + + def write(self, value): + value = str(value) + remaining = self.limit - self.written + if remaining <= 0: + self.truncated = True + return len(value) + chunk = value[:remaining] + self.written += len(chunk) + if len(chunk) < len(value): + self.truncated = True + super().write(chunk) + return len(value) + + def value(self): + suffix = "\n[output truncated]" if self.truncated else "" + return self.getvalue() + suffix + +def resolve_path(value): + if isinstance(value, int): + return None + try: + return pathlib.Path(value).resolve() + except (OSError, TypeError, ValueError): + raise PermissionError("Unable to resolve filesystem path") + +def under(path_value, roots): + if path_value is None: + return True + return any(path_value == root or root in path_value.parents for root in roots) + +def audit(event, args): + if event.startswith(("socket.", "subprocess.", "ctypes.", "pty.")) or event in { + "os.system", "os.posix_spawn", "os.spawn", "os.fork", "os.forkpty" + }: + raise PermissionError(f"{event} is disabled in notebook execution") + if event == "open": + target = resolve_path(args[0]) + mode = str(args[1]) if len(args) > 1 else "r" + write_mode = any(flag in mode for flag in ("w", "a", "x", "+")) + roots = [WORKSPACE] if write_mode else READ_ROOTS + if not under(target, roots): + raise PermissionError("Filesystem access outside the isolated workspace is disabled") + if event in {"os.listdir", "os.scandir", "os.chdir"} and args: + if not under(resolve_path(args[0]), READ_ROOTS): + raise PermissionError("Directory access outside the isolated workspace is disabled") + if event in { + "os.remove", "os.rmdir", "os.mkdir", "os.chmod", "os.chown", "os.truncate", + "os.link", "os.symlink" + } and args: + if not under(resolve_path(args[0]), [WORKSPACE]): + raise PermissionError("Filesystem changes outside the isolated workspace are disabled") + if event in {"os.rename", "os.replace"}: + if any(not under(resolve_path(value), [WORKSPACE]) for value in args[:2]): + raise PermissionError("Filesystem changes outside the isolated workspace are disabled") + +sys.addaudithook(audit) + +def rich_repr(value): + data = {} + if value is None: + return data + try: + html_method = getattr(value, "_repr_html_", None) + if callable(html_method): + html = html_method() + if html: + data["text/html"] = str(html)[:MAX_CAPTURE] + except Exception: + pass + try: + png_method = getattr(value, "_repr_png_", None) + if callable(png_method): + png = png_method() + if png: + if isinstance(png, tuple): + png = png[0] + data["image/png"] = base64.b64encode(png).decode("ascii") + except Exception: + pass + rendered = repr(value) + data["text/plain"] = rendered[:MAX_CAPTURE] + if len(rendered) > MAX_CAPTURE: + data["text/plain"] += "\n[result truncated]" + return data + +def capture_figures(): + displays = [] + pyplot = sys.modules.get("matplotlib.pyplot") + if pyplot is None: + return displays + for number in pyplot.get_fignums(): + buffer = io.BytesIO() + pyplot.figure(number).savefig(buffer, format="png", bbox_inches="tight") + displays.append({"image/png": base64.b64encode(buffer.getvalue()).decode("ascii")}) + buffer.close() + pyplot.close("all") + return displays + +def run_cell(cell, index): + stdout = LimitedBuffer(MAX_CAPTURE) + stderr = LimitedBuffer(MAX_CAPTURE) + result_data = {} + displays = [] + error = None + code = cell.get("code") or "" + try: + tree = ast.parse(code, filename=f"", mode="exec") + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + if tree.body and isinstance(tree.body[-1], ast.Expr): + last_expr = ast.Expression(tree.body[-1].value) + tree.body = tree.body[:-1] + ast.fix_missing_locations(tree) + ast.fix_missing_locations(last_expr) + if tree.body: + exec(compile(tree, f"", "exec"), namespace, namespace) + value = eval(compile(last_expr, f"", "eval"), namespace, namespace) + result_data = rich_repr(value) + else: + exec(compile(tree, f"", "exec"), namespace, namespace) + displays = capture_figures() + except Exception as exc: + error = { + "ename": exc.__class__.__name__, + "evalue": str(exc), + "traceback": traceback.format_exc().splitlines(), + } + return { + "id": cell.get("id"), + "stdout": stdout.value(), + "stderr": stderr.value(), + "resultData": result_data, + "displays": displays, + "error": error, + } + +for index, cell in enumerate(payload.get("cells", [])): + results.append(run_cell(cell, index)) + if results[-1]["error"] is not None and payload.get("stopOnError", True): + break + +print(json.dumps({"results": results})) +`; + +async function runPython(payload, notebookId, preferredRuntime) { + const executionDir = await mkdtemp(path.join(tmpdir(), "copilot-notebook-")); + const errors = []; + const commands = preferredRuntime ? [preferredRuntime] : ["python3", "python"]; + try { + for (const command of commands) { + try { + return await runPythonCommand(command, payload, notebookId, executionDir); + } catch (error) { + if (error?.code === "ENOENT") { + errors.push(`${command}: not found`); + continue; + } + throw error; + } + } + throw new CanvasError("python_not_found", `Unable to run notebook cells because Python was not found (${errors.join(", ")}).`); + } finally { + await rm(executionDir, { recursive: true, force: true }); + } +} + +function terminateProcessTree(child, detached, signal) { + try { + if (detached && child.pid) { + process.kill(-child.pid, signal); + } else { + child.kill(signal); + } + } catch (error) { + if (error?.code !== "ESRCH") { + throw error; + } + } +} + +function runPythonCommand(command, payload, notebookId, executionDir) { + return new Promise((resolve, reject) => { + const detached = process.platform !== "win32"; + const child = spawn(command, ["-I", "-c", pythonRunner], { + cwd: executionDir, + detached, + env: { + HOME: executionDir, + LANG: "C.UTF-8", + PATH: process.env.PATH ?? "/usr/bin:/bin", + PYTHONIOENCODING: "utf-8", + PYTHONUNBUFFERED: "1", + TMPDIR: executionDir, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let forceKillTimer; + + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + clearTimeout(forceKillTimer); + if (activeRuns.get(notebookId)?.child === child) { + activeRuns.delete(notebookId); + } + callback(); + }; + const stop = (message, code = "execution_interrupted") => { + if (settled) { + return; + } + terminateProcessTree(child, detached, "SIGTERM"); + finish(() => reject(new CanvasError(code, message))); + forceKillTimer = setTimeout(() => { + try { + terminateProcessTree(child, detached, "SIGKILL"); + } catch { + // The process may already have exited. + } + }, 1000); + forceKillTimer.unref?.(); + }; + activeRuns.set(notebookId, { child, stop }); + const timeout = setTimeout(() => { + stop("Notebook execution timed out after 30 seconds.", "cell_timeout"); + }, 30000); + + child.on("error", (error) => { + finish(() => reject(error)); + }); + child.stdout.on("data", (chunk) => { + stdout += chunk; + if (Buffer.byteLength(stdout) > 2_000_000) { + stop("Notebook output exceeded the 2 MB limit.", "output_too_large"); + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + if (Buffer.byteLength(stderr) > 250_000) { + stop("Notebook error output exceeded the 250 KB limit.", "output_too_large"); + } + }); + child.on("close", (code) => { + if (settled) { + return; + } + if (code !== 0) { + finish(() => reject(new CanvasError("python_failed", stderr || `Python exited with code ${code}`))); + return; + } + try { + const parsed = JSON.parse(stdout); + finish(() => resolve(parsed)); + } catch { + finish(() => reject(new CanvasError("runner_output_invalid", "The Python runner returned invalid JSON."))); + } + }); + child.stdin.end(JSON.stringify(payload)); + }); +} + +async function runNotebookCells(notebook, targetCellId, notebookId) { + const language = String( + notebook.metadata?.language_info?.name + ?? notebook.metadata?.kernelspec?.language + ?? "", + ).toLowerCase(); + if (language !== "python") { + throw new CanvasError("kernel_unsupported", `Execution is only available for Python notebooks, not ${language || "unknown"} notebooks.`); + } + const targetIndex = targetCellId ? findCellIndex(notebook, targetCellId) : notebook.cells.length - 1; + const executableCells = notebook.cells + .slice(0, targetIndex + 1) + .filter((cell) => cell.cell_type === "code") + .map((cell) => ({ id: getCellId(cell), code: normalizeSource(cell.source) })); + + broadcastStatus(notebookId, { + status: "running", + cellId: executableCells[0]?.id ?? null, + cellCount: executableCells.length, + message: `Running ${executableCells.length} ${executableCells.length === 1 ? "cell" : "cells"}`, + }); + try { + const run = await runPython( + { cells: executableCells, stopOnError: true }, + notebookId, + notebook.metadata?.copilot?.runtime, + ); + const byId = new Map(run.results.map((result) => [result.id, result])); + for (const cell of notebook.cells.slice(0, targetIndex + 1)) { + if (cell.cell_type !== "code") { + continue; + } + const result = byId.get(getCellId(cell)); + if (!result) { + continue; + } + const executionCount = Number(notebook.metadata.copilot.executionCount ?? 0) + 1; + notebook.metadata.copilot.executionCount = executionCount; + cell.execution_count = executionCount; + cell.outputs = outputFromRun({ ...result, executionCount }); + } + } finally { + broadcastStatus(notebookId, { status: "idle", cellId: null, message: "Execution finished" }); + } +} + +function addClient(notebookId, instanceId, res, entry) { + let clients = clientsByNotebook.get(notebookId); + if (!clients) { + clients = new Set(); + clientsByNotebook.set(notebookId, clients); + } + const client = { instanceId, res }; + clients.add(client); + entry.eventClients.add(res); + res.on("close", () => { + clients.delete(client); + entry.eventClients.delete(res); + if (clients.size === 0) { + clientsByNotebook.delete(notebookId); + } + }); +} + +function broadcast(notebookId, notebook) { + const clients = clientsByNotebook.get(notebookId); + if (!clients) { + return; + } + const data = JSON.stringify(summarizeNotebook(notebook)); + for (const client of clients) { + if (!client.res.destroyed && !client.res.writableEnded) { + client.res.write(`event: notebook\ndata: ${data}\n\n`); + } + } +} + +function broadcastStatus(notebookId, status) { + const clients = clientsByNotebook.get(notebookId); + if (!clients) { + return; + } + const data = JSON.stringify(status); + for (const client of clients) { + if (!client.res.destroyed && !client.res.writableEnded) { + client.res.write(`event: execution\ndata: ${data}\n\n`); + } + } +} + +async function readJson(req) { + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 1_000_000) { + throw new CanvasError("request_too_large", "Request body exceeded the 1 MB limit."); + } + } + try { + return body ? JSON.parse(body) : {}; + } catch { + throw new CanvasError("request_json_invalid", "Request body must be valid JSON."); + } +} + +function sendJson(res, status, payload) { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + res.end(JSON.stringify(payload)); +} + +function tokenMatches(provided, expected) { + if (typeof provided !== "string") { + return false; + } + const actualBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + +function requireCapability(req, requestUrl, entry) { + const provided = req.headers["x-notebook-token"] ?? requestUrl.searchParams.get("token"); + if (!tokenMatches(provided, entry.token)) { + throw new CanvasError("request_unauthorized", "Missing or invalid canvas capability token."); + } +} + +function requireSameOrigin(req, entry) { + if (req.headers.origin !== entry.origin) { + throw new CanvasError("request_origin_invalid", "Notebook actions must originate from this canvas."); + } +} + +function setDocumentSecurityHeaders(res) { + res.setHeader( + "Content-Security-Policy", + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'", + ); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-Content-Type-Options", "nosniff"); +} + +function assertString(value, name, { required = false, maxLength = 500_000, pattern } = {}) { + if (value === undefined && !required) { + return; + } + if (typeof value !== "string" || (required && value.length === 0) || value.length > maxLength) { + throw new CanvasError("action_input_invalid", `${name} must be a string of at most ${maxLength} characters.`); + } + if (pattern && !pattern.test(value)) { + throw new CanvasError("action_input_invalid", `${name} has an invalid format.`); + } +} + +function validateHttpActionPayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new CanvasError("action_input_invalid", "Action input must be a JSON object."); + } + const actionFields = { + get_notebook: [], + list_workspace_notebooks: [], + save_workspace: ["expectedRevision"], + save_as: ["path", "overwrite", "expectedRevision"], + open_workspace: ["path", "expectedRevision"], + new_notebook: ["title", "expectedRevision"], + rename_workspace: ["path", "overwrite", "expectedRevision"], + duplicate_workspace: ["path", "overwrite"], + move_cell: ["cellId", "direction", "expectedRevision"], + duplicate_cell: ["cellId", "expectedRevision"], + undo: ["expectedRevision"], + redo: ["expectedRevision"], + get_runtimes: [], + set_runtime: ["runtime", "expectedRevision"], + restart_runtime: ["expectedRevision"], + create_checkpoint: ["label"], + list_checkpoints: [], + restore_checkpoint: ["checkpointId", "expectedRevision"], + set_title: ["title", "expectedRevision"], + add_cell: ["afterCellId", "cellType", "source", "expectedRevision"], + update_cell: ["cellId", "cellType", "source", "expectedRevision"], + delete_cell: ["cellId", "expectedRevision"], + run_cell: ["cellId", "expectedRevision"], + run_all: ["expectedRevision"], + clear_outputs: ["expectedRevision"], + interrupt: [], + }; + const action = payload.action; + if (typeof action !== "string" || !Object.hasOwn(actionFields, action)) { + throw new CanvasError("unknown_action", "Unknown notebook action."); + } + const allowed = new Set(["action", ...actionFields[action]]); + const unexpected = Object.keys(payload).find((key) => !allowed.has(key)); + if (unexpected) { + throw new CanvasError("action_input_invalid", `Unexpected action field: ${unexpected}`); + } + if (payload.expectedRevision !== undefined && (!Number.isInteger(payload.expectedRevision) || payload.expectedRevision < 0)) { + throw new CanvasError("action_input_invalid", "expectedRevision must be a non-negative integer."); + } + assertString(payload.cellId, "cellId", { + required: ["update_cell", "delete_cell", "run_cell", "move_cell", "duplicate_cell"].includes(action), + maxLength: 64, + pattern: cellIdRegex, + }); + assertString(payload.afterCellId, "afterCellId", { maxLength: 64, pattern: cellIdRegex }); + assertString(payload.title, "title", { required: action === "set_title", maxLength: 120 }); + assertString(payload.path, "path", { + required: ["save_as", "open_workspace", "rename_workspace", "duplicate_workspace"].includes(action), + maxLength: 240, + }); + assertString(payload.source, "source"); + assertString(payload.runtime, "runtime", { required: action === "set_runtime", maxLength: 32 }); + assertString(payload.label, "label", { maxLength: 80 }); + assertString(payload.checkpointId, "checkpointId", { + required: action === "restore_checkpoint", + maxLength: 80, + pattern: /^[A-Za-z0-9_-]{1,80}$/, + }); + if (payload.direction !== undefined && !["up", "down"].includes(payload.direction)) { + throw new CanvasError("action_input_invalid", "direction must be up or down."); + } + if (action === "move_cell" && payload.direction === undefined) { + throw new CanvasError("action_input_invalid", "direction is required."); + } + if (payload.overwrite !== undefined && typeof payload.overwrite !== "boolean") { + throw new CanvasError("action_input_invalid", "overwrite must be a boolean."); + } + if (payload.cellType !== undefined && !["code", "markdown", "raw"].includes(payload.cellType)) { + throw new CanvasError("action_input_invalid", "cellType must be code, markdown, or raw."); + } + if (action === "add_cell" && payload.cellType === undefined) { + throw new CanvasError("action_input_invalid", "cellType is required."); + } + if (action === "update_cell" && payload.cellType === undefined && payload.source === undefined) { + throw new CanvasError("action_input_invalid", "update_cell requires source or cellType."); + } + return { action, input: Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "action")) }; +} + +async function handleCanvasAction(ctx, action, input = {}) { + await storageReady; + const notebookId = resolveNotebookId(ctx, input); + if (action === "get_notebook") { + const notebook = await loadNotebook(notebookId); + return { notebookId, path: notebookPath(notebookId), notebook: summarizeNotebook(notebook) }; + } + if (action === "list_workspace_notebooks") { + return { notebookId, files: await listWorkspaceNotebooks() }; + } + if (action === "save_workspace") { + return withNotebookLock(notebookId, async () => { + const notebook = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(notebook.metadata.copilot.revision ?? 0); + if (Number.isInteger(input.expectedRevision) && input.expectedRevision !== currentRevision) { + throw new CanvasError("revision_conflict", "Notebook changed before it could be saved."); + } + if (!notebook.metadata.copilot.workspacePath) { + throw new CanvasError("workspace_path_required", "Choose Save As before saving this notebook."); + } + await assertWorkspaceUnchanged(notebookId, notebook); + await writeWorkspaceCopy(notebookId, notebook); + return { notebookId, notebook: summarizeNotebook(notebook), saved: true }; + }); + } + if (action === "save_as") { + const target = await assertWorkspaceTargetAvailable(input.path, input.overwrite === true); + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.workspacePath = target.relative; + }, input.expectedRevision, { workspaceOverwrite: true }); + } + if (action === "rename_workspace") { + const target = await assertWorkspaceTargetAvailable(input.path, input.overwrite === true); + const current = await loadNotebook(notebookId); + const previousPath = current.metadata?.copilot?.workspacePath; + if (!previousPath) { + throw new CanvasError("workspace_path_required", "Save the notebook before renaming it."); + } + const result = await mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.workspacePath = target.relative; + }, input.expectedRevision, { workspaceOverwrite: true }); + if (previousPath !== target.relative) { + await rm(normalizeWorkspacePath(previousPath).resolved, { force: true }); + } + return result; + } + if (action === "duplicate_workspace") { + const target = await assertWorkspaceTargetAvailable(input.path, input.overwrite === true); + const notebook = await loadNotebook(notebookId); + await assertWorkspaceUnchanged(notebookId, notebook); + const duplicate = structuredClone(notebook); + duplicate.metadata.copilot.workspacePath = target.relative; + duplicate.metadata.copilot.title = path.basename(target.relative, ".ipynb"); + await writeWorkspaceDocument(target.relative, serializeNotebook(duplicate), { overwrite: true }); + return { notebookId, duplicatedPath: target.relative, files: await listWorkspaceNotebooks() }; + } + if (action === "open_workspace") { + const source = await readWorkspaceNotebook(input.path); + return withNotebookLock(notebookId, async () => { + const current = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(current.metadata.copilot.revision ?? 0); + if (Number.isInteger(input.expectedRevision) && input.expectedRevision !== currentRevision) { + throw new CanvasError("revision_conflict", "Notebook changed before the selected file could be opened."); + } + const history = historyByNotebook.get(notebookId) ?? { redo: [], undo: [] }; + history.undo.push(structuredClone(current)); + history.redo = []; + historyByNotebook.set(notebookId, history); + const { notebook } = normalizeNotebook( + source.parsed, + notebookId, + path.basename(source.relative, ".ipynb"), + ); + notebook.metadata.copilot.workspacePath = source.relative; + notebook.metadata.copilot.revision = currentRevision + 1; + await writeNotebookUnlocked(notebookId, notebook); + await writeWorkspaceCopy(notebookId, notebook); + broadcast(notebookId, notebook); + return { notebookId, notebook: summarizeNotebook(notebook), openedPath: source.relative }; + }); + } + if (action === "new_notebook") { + return mutateNotebook(notebookId, (notebook) => { + const fresh = normalizeNotebook({}, notebookId, input.title || "Untitled notebook", true).notebook; + notebook.cells = fresh.cells; + notebook.metadata = fresh.metadata; + delete notebook.metadata.copilot.workspacePath; + }, input.expectedRevision); + } + if (action === "move_cell") { + return mutateNotebook(notebookId, (notebook) => { + const index = findCellIndex(notebook, input.cellId); + const target = input.direction === "up" ? index - 1 : index + 1; + if (target < 0 || target >= notebook.cells.length) { + return; + } + [notebook.cells[index], notebook.cells[target]] = [notebook.cells[target], notebook.cells[index]]; + }, input.expectedRevision); + } + if (action === "duplicate_cell") { + return mutateNotebook(notebookId, (notebook) => { + const index = findCellIndex(notebook, input.cellId); + const duplicate = structuredClone(notebook.cells[index]); + duplicate.id = createCellId(new Set(notebook.cells.map((cell) => getCellId(cell)))); + duplicate.metadata = { ...(duplicate.metadata ?? {}), copilotCellId: duplicate.id }; + if (duplicate.cell_type === "code") { + duplicate.execution_count = null; + duplicate.outputs = []; + } + notebook.cells.splice(index + 1, 0, duplicate); + return { cellId: duplicate.id }; + }, input.expectedRevision); + } + if (action === "undo" || action === "redo") { + return applyHistory(notebookId, action, input.expectedRevision); + } + if (action === "get_runtimes") { + return { notebookId, runtimes: await getAvailableRuntimes() }; + } + if (action === "set_runtime") { + const runtimes = await getAvailableRuntimes(); + if (!runtimes.some((runtime) => runtime.id === input.runtime)) { + throw new CanvasError("runtime_unavailable", "The selected Python runtime is unavailable."); + } + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.runtime = input.runtime; + }, input.expectedRevision); + } + if (action === "restart_runtime") { + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.executionCount = 0; + for (const cell of notebook.cells) { + if (cell.cell_type === "code") { + cell.execution_count = null; + cell.outputs = []; + } + } + }, input.expectedRevision); + } + if (action === "create_checkpoint") { + const notebook = await loadNotebook(notebookId); + const checkpoint = await createCheckpoint(notebookId, notebook, input.label); + return { notebookId, checkpoint, checkpoints: await listCheckpoints(notebookId) }; + } + if (action === "list_checkpoints") { + return { notebookId, checkpoints: await listCheckpoints(notebookId) }; + } + if (action === "restore_checkpoint") { + const record = JSON.parse(await readFile(checkpointPath(notebookId, input.checkpointId), "utf8")); + return mutateNotebook(notebookId, (notebook) => { + const workspacePath = notebook.metadata?.copilot?.workspacePath; + notebook.cells = structuredClone(record.notebook.cells); + notebook.metadata = structuredClone(record.notebook.metadata); + if (workspacePath) { + notebook.metadata.copilot.workspacePath = workspacePath; + } else { + delete notebook.metadata.copilot.workspacePath; + } + }, input.expectedRevision); + } + if (action === "interrupt") { + const run = activeRuns.get(notebookId); + if (!run) { + return { notebookId, interrupted: false }; + } + run.stop("Notebook execution interrupted."); + return { notebookId, interrupted: true }; + } + if (action === "set_title") { + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.title = textToSource(input.title).slice(0, 120) || "Untitled notebook"; + }, input.expectedRevision); + } + if (action === "add_cell") { + return mutateNotebook(notebookId, (notebook) => { + const cell = createCell(input.cellType, input.source); + if (input.afterCellId) { + const index = findCellIndex(notebook, input.afterCellId); + notebook.cells.splice(index + 1, 0, cell); + } else { + notebook.cells.push(cell); + } + return { cellId: getCellId(cell) }; + }, input.expectedRevision); + } + if (action === "update_cell") { + return mutateNotebook(notebookId, (notebook) => { + const cell = findCell(notebook, input.cellId); + if (typeof input.cellType === "string" && input.cellType !== cell.cell_type) { + cell.cell_type = input.cellType; + if (cell.cell_type === "code") { + cell.execution_count = null; + cell.outputs = []; + } else { + delete cell.execution_count; + delete cell.outputs; + } + } + if (typeof input.source === "string") { + cell.source = input.source; + } + }, input.expectedRevision); + } + if (action === "delete_cell") { + return mutateNotebook(notebookId, (notebook) => { + const index = findCellIndex(notebook, input.cellId); + notebook.cells.splice(index, 1); + if (notebook.cells.length === 0) { + notebook.cells.push(createCell("code", "")); + } + }, input.expectedRevision); + } + if (action === "run_cell") { + return mutateNotebook(notebookId, async (notebook) => { + await runNotebookCells(notebook, input.cellId, notebookId); + }, input.expectedRevision); + } + if (action === "run_all") { + return mutateNotebook(notebookId, async (notebook) => { + await runNotebookCells(notebook, undefined, notebookId); + }, input.expectedRevision); + } + if (action === "clear_outputs") { + return mutateNotebook(notebookId, (notebook) => { + for (const cell of notebook.cells) { + if (cell.cell_type === "code") { + cell.outputs = []; + cell.execution_count = null; + } + } + }, input.expectedRevision); + } + throw new CanvasError("unknown_action", `Unknown notebook action: ${action}`); +} + +async function handleHttpAction(ctx, req, res) { + const payload = validateHttpActionPayload(await readJson(req)); + const result = await handleCanvasAction(ctx, payload.action, payload.input); + sendJson(res, 200, result); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function renderHtml(notebookId) { + return ` + + + + + Notebook + + + +
+
+ +
+
+ workspace + / + ${escapeHtml(notebookId)}.ipynb +
+ + +
+
+ + + + +
+
+
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ + Opening notebook +
+
+
+ + +
+ +
+ 0 cells + + Isolated Python + + Autosaved internally +
+
+
+
+ +
+
Open notebook
+
+ + + +
+
+ + +
+
+
+ + +`; +} + +async function startServer(instanceId, notebookId) { + const ctx = { instanceId, notebookId }; + const entry = { + eventClients: new Set(), + notebookId, + origin: "", + server: undefined, + sockets: new Set(), + token: randomBytes(32).toString("base64url"), + url: "", + }; + const server = createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + if (req.method === "GET" && requestUrl.pathname === "/") { + setDocumentSecurityHeaders(res); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(renderHtml(notebookId)); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/favicon.ico") { + res.writeHead(204, { "Cache-Control": "public, max-age=86400" }); + res.end(); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/api/notebook") { + requireCapability(req, requestUrl, entry); + const notebook = await loadNotebook(notebookId); + sendJson(res, 200, { notebookId, notebook: summarizeNotebook(notebook) }); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/download") { + requireCapability(req, requestUrl, entry); + const notebook = await loadNotebook(notebookId); + const filename = path.basename( + notebook.metadata?.copilot?.workspacePath ?? `${notebookId}.ipynb`, + ).replaceAll('"', ""); + res.writeHead(200, { + "Content-Type": "application/x-ipynb+json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + res.end(serializeNotebook(notebook)); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/events") { + requireCapability(req, requestUrl, entry); + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Content-Type-Options": "nosniff", + }); + addClient(notebookId, instanceId, res, entry); + const notebook = await loadNotebook(notebookId); + res.write(`event: notebook\ndata: ${JSON.stringify(summarizeNotebook(notebook))}\n\n`); + return; + } + if (req.method === "POST" && requestUrl.pathname === "/api/action") { + requireCapability(req, requestUrl, entry); + requireSameOrigin(req, entry); + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + throw new CanvasError("request_content_type_invalid", "Notebook actions require application/json."); + } + await handleHttpAction(ctx, req, res); + return; + } + sendJson(res, 404, { message: "Not found" }); + } catch (error) { + const status = error?.code === "request_unauthorized" + ? 401 + : error?.code === "request_origin_invalid" + ? 403 + : error?.code === "revision_conflict" + ? 409 + : error instanceof CanvasError + ? 400 + : 500; + sendJson(res, status, { code: error.code ?? "canvas_error", message: error.message }); + } + }); + entry.server = server; + server.on("connection", (socket) => { + entry.sockets.add(socket); + socket.on("close", () => entry.sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + entry.origin = `http://127.0.0.1:${port}`; + entry.url = `${entry.origin}/#token=${encodeURIComponent(entry.token)}`; + return entry; +} + +async function closeServerEntry(entry) { + const run = activeRuns.get(entry.notebookId); + run?.stop("Notebook canvas closed during execution."); + for (const client of entry.eventClients) { + if (!client.writableEnded) { + client.write("event: close\ndata: {}\n\n"); + client.end(); + } + } + entry.eventClients.clear(); + await new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + entry.server.close(finish); + entry.server.closeIdleConnections?.(); + const forceClose = setTimeout(() => { + for (const socket of entry.sockets) { + socket.destroy(); + } + finish(); + }, 750); + forceClose.unref?.(); + }); +} + +function declaredNotebookAction(name, description, properties = {}, required = []) { + return { + name, + description, + inputSchema: { + type: "object", + additionalProperties: false, + required, + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + ...properties, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, name, ctx.input), + }; +} + +const productActions = [ + declaredNotebookAction( + "list_workspace_notebooks", + "List .ipynb files available in the current workspace.", + ), + declaredNotebookAction( + "save_workspace", + "Save the notebook to its current workspace path.", + ), + declaredNotebookAction( + "save_as", + "Save a workspace copy of the notebook under a new .ipynb path.", + { + path: { type: "string", minLength: 1, maxLength: 240 }, + overwrite: { type: "boolean" }, + }, + ["path"], + ), + declaredNotebookAction( + "open_workspace", + "Open a workspace .ipynb file in this canvas.", + { path: { type: "string", minLength: 1, maxLength: 240 } }, + ["path"], + ), + declaredNotebookAction( + "new_notebook", + "Replace the canvas with a new empty notebook.", + { + title: { type: "string", minLength: 1, maxLength: 120 }, + }, + ), + declaredNotebookAction( + "rename_workspace", + "Move the current workspace notebook to a new .ipynb path.", + { + path: { type: "string", minLength: 1, maxLength: 240 }, + overwrite: { type: "boolean" }, + }, + ["path"], + ), + declaredNotebookAction( + "duplicate_workspace", + "Create a workspace copy of the notebook at a new .ipynb path.", + { + path: { type: "string", minLength: 1, maxLength: 240 }, + overwrite: { type: "boolean" }, + }, + ["path"], + ), + declaredNotebookAction( + "move_cell", + "Move a cell one position up or down.", + { + cellId: { type: "string", pattern: cellIdPattern }, + direction: { type: "string", enum: ["up", "down"] }, + }, + ["cellId", "direction"], + ), + declaredNotebookAction( + "duplicate_cell", + "Duplicate a notebook cell directly below the original.", + { cellId: { type: "string", pattern: cellIdPattern } }, + ["cellId"], + ), + declaredNotebookAction("undo", "Undo the most recent notebook mutation."), + declaredNotebookAction("redo", "Redo the most recently undone notebook mutation."), + declaredNotebookAction("get_runtimes", "List available local Python runtimes."), + declaredNotebookAction( + "set_runtime", + "Select the Python executable used for future notebook runs.", + { runtime: { type: "string", enum: ["python3", "python"] } }, + ["runtime"], + ), + declaredNotebookAction("restart_runtime", "Interrupt active work and clear outputs for a fresh stateless run."), + declaredNotebookAction( + "create_checkpoint", + "Create a persistent checkpoint of the current notebook.", + { label: { type: "string", maxLength: 80 } }, + ), + declaredNotebookAction("list_checkpoints", "List persistent checkpoints for the current notebook."), + declaredNotebookAction( + "restore_checkpoint", + "Restore a persistent checkpoint into the current notebook.", + { checkpointId: { type: "string", minLength: 1, maxLength: 80 } }, + ["checkpointId"], + ), +]; + +const canvas = createCanvas({ + id: "jupyter-notebooks", + displayName: "Notebook", + description: "Create notebook cells and run Python code through a fresh constrained process.", + inputSchema: openInputSchema, + actions: [ + ...productActions, + { + name: "get_notebook", + description: "Return the current notebook cells, outputs, and backing .ipynb path.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "get_notebook", ctx.input), + }, + { + name: "set_title", + description: "Set the notebook title.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["title"], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + title: { type: "string", minLength: 1, maxLength: 120 }, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, "set_title", ctx.input), + }, + { + name: "add_cell", + description: "Add a code, Markdown, or raw cell, optionally after an existing cell.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["cellType"], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + afterCellId: { type: "string", pattern: cellIdPattern }, + cellType: { type: "string", enum: ["code", "markdown", "raw"] }, + source: { type: "string" }, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, "add_cell", ctx.input), + }, + { + name: "update_cell", + description: "Update a cell source and optionally convert between code, Markdown, and raw.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["cellId"], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + cellId: { type: "string", pattern: cellIdPattern }, + cellType: { type: "string", enum: ["code", "markdown", "raw"] }, + source: { type: "string" }, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, "update_cell", ctx.input), + }, + { + name: "delete_cell", + description: "Delete a notebook cell.", + inputSchema: cellIdSchema(), + handler: (ctx) => handleCanvasAction(ctx, "delete_cell", ctx.input), + }, + { + name: "run_cell", + description: "Run code cells from the beginning through the selected cell in a fresh constrained Python process.", + inputSchema: cellIdSchema(), + handler: (ctx) => handleCanvasAction(ctx, "run_cell", ctx.input), + }, + { + name: "run_all", + description: "Run all code cells in order in a fresh constrained Python process.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "run_all", ctx.input), + }, + { + name: "clear_outputs", + description: "Clear all code cell outputs and execution counts.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "clear_outputs", ctx.input), + }, + { + name: "interrupt", + description: "Interrupt the active Python execution for this notebook.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "interrupt", ctx.input), + }, + ], + open: async (ctx) => { + await storageReady; + const notebookId = ctx.input?.notebookId ?? "default"; + await loadNotebook(notebookId, ctx.input?.title); + let entry = servers.get(ctx.instanceId); + if (!entry || entry.notebookId !== notebookId) { + if (entry) { + await closeServerEntry(entry); + } + entry = await startServer(ctx.instanceId, notebookId); + servers.set(ctx.instanceId, entry); + } + return { + title: "Notebook", + status: "Ready", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await closeServerEntry(entry); + } + }, +}); + +const session = await joinSession({ canvases: [canvas] }); +storageReady = initializeStorage(session.workspacePath); +await storageReady; diff --git a/extensions/jupyter-notebooks/package.json b/extensions/jupyter-notebooks/package.json new file mode 100644 index 00000000..6640f52e --- /dev/null +++ b/extensions/jupyter-notebooks/package.json @@ -0,0 +1,16 @@ +{ + "name": "jupyter-notebooks", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "description": "Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas.", + "keywords": [ + "data-analysis", + "interactive-notebooks", + "jupyter", + "python" + ], + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/plugins/jupyter-notebooks/README.md b/plugins/jupyter-notebooks/README.md new file mode 100644 index 00000000..5502ed88 --- /dev/null +++ b/plugins/jupyter-notebooks/README.md @@ -0,0 +1,17 @@ +# Jupyter Notebooks Plugin + +Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive GitHub Copilot canvas. + +## Installation + +```bash +copilot plugin install jupyter-notebooks@awesome-copilot +``` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/README.md b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/README.md new file mode 100644 index 00000000..7aef6e75 --- /dev/null +++ b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/README.md @@ -0,0 +1,38 @@ +# Jupyter Notebooks + +Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive GitHub Copilot canvas. + +![Jupyter Notebooks canvas showing Markdown, Python code, and execution output](assets/preview.png) + +## What it does + +- Opens existing `.ipynb` files from the current workspace. +- Creates code, Markdown, and raw cells with reorder, duplicate, undo, and redo controls. +- Runs Python cells through a fresh isolated process with bounded resources and captured rich output. +- Saves, renames, and duplicates notebooks as standard `.ipynb` files. +- Creates persistent checkpoints that can be restored from the canvas. + +## Requirements + +- Python 3 available as `python3` or `python`. +- The `@github/copilot-sdk` package installed with the extension. + +## Install + +Ask Copilot to install the committed extension: + +```text +Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/jupyter-notebooks +``` + +Reload extensions, then ask Copilot to open the `jupyter-notebooks` canvas. + +## Agent actions + +The canvas provides actions for notebook lifecycle, workspace files, cell editing, execution, runtime selection, and checkpoints. Use `get_notebook` to inspect the current state before editing a cell. + +## Security and local data + +Notebook execution uses Python isolated mode in a fresh temporary directory. The runner applies CPU, file-size, process, and memory limits where the platform supports them, caps captured output, and stops execution after 30 seconds. + +The local canvas server binds to `127.0.0.1`, protects requests with a per-instance capability token, validates action origins, and sends a restrictive content security policy. Notebook state and checkpoints stay in the active Copilot workspace. diff --git a/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/assets/preview.png b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/assets/preview.png new file mode 100644 index 00000000..2eb497d2 Binary files /dev/null and b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/assets/preview.png differ diff --git a/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/copilot-extension.json b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/copilot-extension.json new file mode 100644 index 00000000..6d66d169 --- /dev/null +++ b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "jupyter-notebooks", + "version": 1 +} diff --git a/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/extension.mjs b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/extension.mjs new file mode 100644 index 00000000..aca8438a --- /dev/null +++ b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/extension.mjs @@ -0,0 +1,4 @@ +// Extension: jupyter-notebooks +// The implementation is isolated so this required discovery entrypoint stays focused. + +import "./notebook-canvas.mjs"; diff --git a/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/notebook-canvas.mjs b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/notebook-canvas.mjs new file mode 100644 index 00000000..bd624a8e --- /dev/null +++ b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/notebook-canvas.mjs @@ -0,0 +1,3901 @@ +// Jupyter notebook canvas implementation. + +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const extensionRoot = path.dirname(fileURLToPath(import.meta.url)); +const servers = new Map(); +const clientsByNotebook = new Map(); +const notebookQueues = new Map(); +const activeRuns = new Map(); +const workspaceSnapshots = new Map(); +const historyByNotebook = new Map(); +let workspaceRoot; +let notebooksDir; +let checkpointsDir; +let storageReady = Promise.resolve(); + +const notebookIdPattern = "^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$"; +const cellIdPattern = "^[A-Za-z0-9_-]{1,64}$"; +const notebookIdRegex = new RegExp(notebookIdPattern); +const cellIdRegex = new RegExp(cellIdPattern); +const openInputSchema = { + type: "object", + additionalProperties: false, + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + title: { type: "string", minLength: 1, maxLength: 120 }, + }, +}; + +const notebookActionSchema = { + type: "object", + additionalProperties: false, + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + }, +}; + +function cellIdSchema(required = true) { + return { + type: "object", + additionalProperties: false, + required: required ? ["cellId"] : [], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + cellId: { type: "string", pattern: cellIdPattern }, + }, + }; +} + +function textToSource(value) { + return typeof value === "string" ? value : ""; +} + +async function migrateLegacyDirectory(source, destination) { + let entries; + try { + entries = await readdir(source, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") { + return; + } + throw error; + } + await mkdir(destination, { recursive: true }); + for (const entry of entries) { + const sourcePath = path.join(source, entry.name); + const destinationPath = path.join(destination, entry.name); + if (entry.isDirectory()) { + await migrateLegacyDirectory(sourcePath, destinationPath); + continue; + } + if (!entry.isFile()) { + continue; + } + try { + await readFile(destinationPath); + await rm(sourcePath, { force: true }); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + try { + await rename(sourcePath, destinationPath); + } catch (renameError) { + if (renameError?.code !== "EXDEV") { + throw renameError; + } + await writeFile(destinationPath, await readFile(sourcePath), { mode: 0o600 }); + await rm(sourcePath, { force: true }); + } + } + } + await rm(source, { force: true, recursive: true }); +} + +async function initializeStorage(sessionWorkspacePath) { + if (!sessionWorkspacePath) { + throw new Error("Notebook canvas requires session workspace storage."); + } + workspaceRoot = path.resolve(sessionWorkspacePath); + const dataRoot = path.join(workspaceRoot, ".notebook-canvas"); + notebooksDir = path.join(dataRoot, "notebooks"); + checkpointsDir = path.join(dataRoot, "checkpoints"); + await mkdir(dataRoot, { recursive: true }); + await migrateLegacyDirectory(path.join(extensionRoot, "notebooks"), notebooksDir); + await migrateLegacyDirectory(path.join(extensionRoot, "checkpoints"), checkpointsDir); +} + +function createCellId(usedIds = new Set()) { + let id; + do { + id = `cell-${randomUUID().slice(0, 8)}`; + } while (usedIds.has(id)); + return id; +} + +function getCellId(cell, usedIds) { + const candidates = [cell.id, cell.metadata?.copilotCellId]; + let id = candidates.find((candidate) => ( + typeof candidate === "string" + && cellIdRegex.test(candidate) + && (!usedIds || !usedIds.has(candidate)) + )); + if (!id) { + id = createCellId(usedIds); + } + cell.id = id; + cell.metadata = { ...(cell.metadata ?? {}), copilotCellId: id }; + usedIds?.add(id); + return id; +} + +function normalizeSource(source) { + if (Array.isArray(source)) { + return source.join(""); + } + return typeof source === "string" ? source : ""; +} + +function normalizeNotebook(raw, notebookId, title, createStarter = false) { + const notebook = raw && typeof raw === "object" ? raw : {}; + let changed = !raw || typeof raw !== "object"; + const originalMetadata = notebook.metadata && typeof notebook.metadata === "object" + ? notebook.metadata + : {}; + const originalCopilot = originalMetadata.copilot && typeof originalMetadata.copilot === "object" + ? originalMetadata.copilot + : {}; + changed ||= notebook.nbformat !== 4; + changed ||= typeof notebook.nbformat_minor !== "number"; + changed ||= !originalMetadata.kernelspec || !originalMetadata.language_info; + changed ||= originalCopilot.notebookId !== notebookId; + changed ||= typeof originalCopilot.title !== "string"; + changed ||= !Number.isInteger(originalCopilot.executionCount); + changed ||= !Number.isInteger(originalCopilot.revision); + notebook.nbformat = 4; + notebook.nbformat_minor = typeof notebook.nbformat_minor === "number" ? notebook.nbformat_minor : 5; + notebook.metadata = { + ...originalMetadata, + kernelspec: originalMetadata.kernelspec ?? { + display_name: "Python 3", + language: "python", + name: "python3", + }, + language_info: originalMetadata.language_info ?? { + name: "python", + file_extension: ".py", + mimetype: "text/x-python", + }, + copilot: { + ...originalCopilot, + notebookId, + title: originalCopilot.title ?? title ?? "Untitled notebook", + executionCount: Number(originalCopilot.executionCount ?? 0), + revision: Number.isInteger(originalCopilot.revision) ? originalCopilot.revision : 0, + }, + }; + if (!Array.isArray(notebook.cells)) { + notebook.cells = []; + changed = true; + } + const usedIds = new Set(); + for (const cell of notebook.cells) { + if (!["code", "markdown", "raw"].includes(cell.cell_type)) { + cell.cell_type = "raw"; + changed = true; + } + cell.metadata = cell.metadata && typeof cell.metadata === "object" ? cell.metadata : {}; + const originalId = cell.id; + getCellId(cell, usedIds); + changed ||= originalId !== cell.id; + if (Array.isArray(cell.source) || typeof cell.source !== "string") { + cell.source = normalizeSource(cell.source); + changed = true; + } + if (cell.cell_type === "code") { + if (!Array.isArray(cell.outputs)) { + cell.outputs = []; + changed = true; + } + if (!Number.isInteger(cell.execution_count) && cell.execution_count !== null) { + cell.execution_count = null; + changed = true; + } + } else { + if ("outputs" in cell || "execution_count" in cell) { + changed = true; + } + delete cell.outputs; + delete cell.execution_count; + } + } + if (createStarter && notebook.cells.length === 0) { + notebook.cells.push(createCell("code", "print(\"Hello from the notebook canvas\")")); + changed = true; + } + return { notebook, changed }; +} + +function notebookPath(notebookId) { + if (typeof notebookId !== "string" || !notebookIdRegex.test(notebookId)) { + throw new CanvasError("notebook_id_invalid", "Notebook ID contains unsupported characters."); + } + return path.join(notebooksDir, `${notebookId}.ipynb`); +} + +function serializeNotebook(notebook) { + return `${JSON.stringify(notebook, null, 2)}\n`; +} + +function contentHash(content) { + return createHash("sha256").update(content).digest("hex"); +} + +function normalizeWorkspacePath(relativePath) { + if (!workspaceRoot) { + throw new CanvasError("workspace_unavailable", "This session does not expose a workspace."); + } + if (typeof relativePath !== "string" || relativePath.length === 0 || relativePath.length > 240) { + throw new CanvasError("workspace_path_invalid", "Choose a workspace-relative .ipynb path."); + } + const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\//, ""); + if ( + path.posix.isAbsolute(normalized) + || normalized.split("/").some((segment) => segment === ".." || segment.length === 0) + || !normalized.toLowerCase().endsWith(".ipynb") + ) { + throw new CanvasError("workspace_path_invalid", "Notebook paths must stay inside the workspace and end in .ipynb."); + } + const resolved = path.resolve(workspaceRoot, ...normalized.split("/")); + const rootWithSeparator = `${path.resolve(workspaceRoot)}${path.sep}`; + if (!resolved.startsWith(rootWithSeparator)) { + throw new CanvasError("workspace_path_invalid", "Notebook path escapes the workspace."); + } + return { relative: normalized, resolved }; +} + +async function readWorkspaceNotebook(relativePath) { + const target = normalizeWorkspacePath(relativePath); + const content = await readFile(target.resolved, "utf8"); + let parsed; + try { + parsed = JSON.parse(content); + } catch { + throw new CanvasError("notebook_json_invalid", `${target.relative} is not a valid notebook JSON file.`); + } + return { ...target, content, parsed }; +} + +async function assertWorkspaceUnchanged(notebookId, notebook) { + const relativePath = notebook.metadata?.copilot?.workspacePath; + if (!relativePath) { + return; + } + const target = normalizeWorkspacePath(relativePath); + const expected = workspaceSnapshots.get(notebookId) ?? contentHash(serializeNotebook(notebook)); + try { + const actual = contentHash(await readFile(target.resolved, "utf8")); + if (actual !== expected) { + throw new CanvasError( + "workspace_file_conflict", + `${target.relative} changed outside the canvas. Open it again or save to a different path.`, + ); + } + } catch (error) { + if (error?.code === "ENOENT") { + throw new CanvasError("workspace_file_missing", `${target.relative} was removed outside the canvas.`); + } + throw error; + } +} + +async function writeWorkspaceDocument(relativePath, content, { overwrite = true } = {}) { + const target = normalizeWorkspacePath(relativePath); + if (!overwrite) { + try { + await readFile(target.resolved, "utf8"); + throw new CanvasError("workspace_file_exists", `${target.relative} already exists.`); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + } + await mkdir(path.dirname(target.resolved), { recursive: true }); + const temporary = path.join(path.dirname(target.resolved), `.${path.basename(target.resolved)}.${randomUUID()}.tmp`); + try { + await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, target.resolved); + } finally { + await rm(temporary, { force: true }); + } + return target; +} + +async function writeWorkspaceCopy(notebookId, notebook, { overwrite = true } = {}) { + const relativePath = notebook.metadata?.copilot?.workspacePath; + if (!relativePath) { + return; + } + const content = serializeNotebook(notebook); + await writeWorkspaceDocument(relativePath, content, { overwrite }); + workspaceSnapshots.set(notebookId, contentHash(content)); +} + +async function assertWorkspaceTargetAvailable(relativePath, overwrite) { + const target = normalizeWorkspacePath(relativePath); + if (overwrite) { + return target; + } + try { + await readFile(target.resolved, "utf8"); + throw new CanvasError("workspace_file_exists", `${target.relative} already exists.`); + } catch (error) { + if (error?.code === "ENOENT") { + return target; + } + throw error; + } +} + +async function listWorkspaceNotebooks() { + if (!workspaceRoot) { + return []; + } + const results = []; + async function visit(directory, depth) { + if (depth > 4 || results.length >= 100) { + return; + } + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".") || ["node_modules", "dist", "build"].includes(entry.name)) { + continue; + } + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(absolute, depth + 1); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".ipynb")) { + results.push(path.relative(workspaceRoot, absolute).split(path.sep).join("/")); + } + } + } + await visit(workspaceRoot, 0); + return results.sort((a, b) => a.localeCompare(b)); +} + +function checkpointPath(notebookId, checkpointId) { + if (!notebookIdRegex.test(notebookId) || !/^[A-Za-z0-9_-]{1,80}$/.test(checkpointId)) { + throw new CanvasError("checkpoint_id_invalid", "Invalid checkpoint identifier."); + } + return path.join(checkpointsDir, notebookId, `${checkpointId}.json`); +} + +async function createCheckpoint(notebookId, notebook, label) { + const checkpointId = `cp-${Date.now()}-${randomUUID().slice(0, 6)}`; + const record = { + checkpointId, + createdAt: new Date().toISOString(), + label: textToSource(label).slice(0, 80) || "Manual checkpoint", + notebook, + }; + const target = checkpointPath(notebookId, checkpointId); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, serializeNotebook(record), { encoding: "utf8", mode: 0o600 }); + return { checkpointId, createdAt: record.createdAt, label: record.label }; +} + +async function listCheckpoints(notebookId) { + const directory = path.join(checkpointsDir, notebookId); + try { + const files = (await readdir(directory)).filter((name) => name.endsWith(".json")); + const checkpoints = []; + for (const file of files) { + const record = JSON.parse(await readFile(path.join(directory, file), "utf8")); + checkpoints.push({ + checkpointId: record.checkpointId, + createdAt: record.createdAt, + label: record.label, + }); + } + return checkpoints.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 50); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } +} + +async function getAvailableRuntimes() { + const runtimes = []; + for (const command of ["python3", "python"]) { + const version = await new Promise((resolve) => { + const child = spawn(command, ["--version"], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code === 0 ? output.trim() : null)); + }); + if (version) { + runtimes.push({ id: command, label: version }); + } + } + return runtimes; +} + +async function applyHistory(notebookId, direction, expectedRevision) { + return withNotebookLock(notebookId, async () => { + const current = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(current.metadata.copilot.revision ?? 0); + if (Number.isInteger(expectedRevision) && expectedRevision !== currentRevision) { + throw new CanvasError("revision_conflict", "Notebook changed before history could be applied."); + } + await assertWorkspaceUnchanged(notebookId, current); + const history = historyByNotebook.get(notebookId) ?? { redo: [], undo: [] }; + const source = direction === "undo" ? history.undo : history.redo; + const destination = direction === "undo" ? history.redo : history.undo; + const snapshot = source.pop(); + if (!snapshot) { + throw new CanvasError("history_empty", `Nothing to ${direction}.`); + } + destination.push(structuredClone(current)); + const workspacePath = current.metadata?.copilot?.workspacePath; + if (workspacePath) { + snapshot.metadata.copilot.workspacePath = workspacePath; + } else { + delete snapshot.metadata.copilot.workspacePath; + } + snapshot.metadata.copilot.revision = currentRevision + 1; + historyByNotebook.set(notebookId, history); + await writeNotebookUnlocked(notebookId, snapshot); + await writeWorkspaceCopy(notebookId, snapshot); + broadcast(notebookId, snapshot); + return { notebookId, notebook: summarizeNotebook(snapshot) }; + }); +} + +function withNotebookLock(notebookId, operation) { + const previous = notebookQueues.get(notebookId) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(operation); + notebookQueues.set(notebookId, current); + return current.finally(() => { + if (notebookQueues.get(notebookId) === current) { + notebookQueues.delete(notebookId); + } + }); +} + +async function writeNotebookUnlocked(notebookId, notebook) { + await mkdir(notebooksDir, { recursive: true }); + const target = notebookPath(notebookId); + const temporary = path.join(notebooksDir, `.${notebookId}.${randomUUID()}.tmp`); + try { + await writeFile(temporary, serializeNotebook(notebook), { encoding: "utf8", mode: 0o600 }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } +} + +async function loadNotebookUnlocked(notebookId = "default", title) { + await mkdir(notebooksDir, { recursive: true }); + try { + const raw = JSON.parse(await readFile(notebookPath(notebookId), "utf8")); + const normalized = normalizeNotebook(raw, notebookId, title); + if (normalized.notebook.metadata?.copilot?.workspacePath) { + workspaceSnapshots.set(notebookId, contentHash(serializeNotebook(normalized.notebook))); + } + if (normalized.changed) { + await writeNotebookUnlocked(notebookId, normalized.notebook); + } + return normalized.notebook; + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + const { notebook } = normalizeNotebook({}, notebookId, title, true); + await writeNotebookUnlocked(notebookId, notebook); + return notebook; + } +} + +function loadNotebook(notebookId = "default", title) { + return withNotebookLock(notebookId, () => loadNotebookUnlocked(notebookId, title)); +} + +function createCell(cellType, source = "") { + const normalizedCellType = ["code", "markdown", "raw"].includes(cellType) ? cellType : "code"; + const cell = { + id: `cell-${randomUUID().slice(0, 8)}`, + cell_type: normalizedCellType, + metadata: {}, + source: textToSource(source), + }; + if (cell.cell_type === "code") { + cell.execution_count = null; + cell.outputs = []; + } + return cell; +} + +function summarizeNotebook(notebook) { + const language = notebook.metadata?.language_info?.name + ?? notebook.metadata?.kernelspec?.language + ?? "unknown"; + return { + title: notebook.metadata?.copilot?.title ?? "Untitled notebook", + revision: Number(notebook.metadata?.copilot?.revision ?? 0), + language, + runtimeLabel: language.toLowerCase() === "python" ? "Isolated Python" : "Execution unavailable", + executionMode: "stateless-run-through", + workspacePath: notebook.metadata?.copilot?.workspacePath ?? null, + runtime: notebook.metadata?.copilot?.runtime ?? "python3", + cells: notebook.cells.map((cell) => ({ + id: getCellId(cell), + cellType: cell.cell_type, + source: normalizeSource(cell.source), + executionCount: cell.execution_count ?? null, + outputs: cell.outputs ?? [], + })), + }; +} + +function resolveNotebookId(ctx, input) { + const fromInput = input && typeof input.notebookId === "string" ? input.notebookId : undefined; + const fromBoundServer = ctx.notebookId ?? servers.get(ctx.instanceId)?.notebookId; + if (fromBoundServer && fromInput && fromBoundServer !== fromInput) { + throw new CanvasError("notebook_mismatch", "This canvas instance is bound to a different notebook."); + } + return fromBoundServer ?? fromInput ?? "default"; +} + +function mutateNotebook(notebookId, mutator, expectedRevision, options = {}) { + return withNotebookLock(notebookId, async () => { + const notebook = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(notebook.metadata.copilot.revision ?? 0); + if (Number.isInteger(expectedRevision) && expectedRevision !== currentRevision) { + throw new CanvasError( + "revision_conflict", + `Notebook changed since revision ${expectedRevision}; current revision is ${currentRevision}. Your draft was not overwritten.`, + ); + } + if (!options.skipWorkspacePreflight) { + await assertWorkspaceUnchanged(notebookId, notebook); + } + const history = historyByNotebook.get(notebookId) ?? { redo: [], undo: [] }; + history.undo.push(structuredClone(notebook)); + history.undo = history.undo.slice(-50); + history.redo = []; + historyByNotebook.set(notebookId, history); + const result = await mutator(notebook); + notebook.metadata.copilot.revision = currentRevision + 1; + await writeNotebookUnlocked(notebookId, notebook); + await writeWorkspaceCopy(notebookId, notebook, { overwrite: options.workspaceOverwrite ?? true }); + broadcast(notebookId, notebook); + return { notebookId, notebook: summarizeNotebook(notebook), ...(result ?? {}) }; + }); +} + +function findCell(notebook, cellId) { + const cell = notebook.cells.find((candidate) => getCellId(candidate) === cellId); + if (!cell) { + throw new CanvasError("cell_not_found", `No cell found with id ${cellId}`); + } + return cell; +} + +function findCellIndex(notebook, cellId) { + const index = notebook.cells.findIndex((candidate) => getCellId(candidate) === cellId); + if (index === -1) { + throw new CanvasError("cell_not_found", `No cell found with id ${cellId}`); + } + return index; +} + +function outputFromRun(result) { + const outputs = []; + if (result.stdout) { + outputs.push({ output_type: "stream", name: "stdout", text: result.stdout }); + } + if (result.stderr) { + outputs.push({ output_type: "stream", name: "stderr", text: result.stderr }); + } + if (result.resultData && Object.keys(result.resultData).length > 0) { + outputs.push({ + output_type: "execute_result", + execution_count: result.executionCount, + data: result.resultData, + metadata: {}, + }); + } + for (const display of result.displays ?? []) { + outputs.push({ + output_type: "display_data", + data: display, + metadata: {}, + }); + } + if (result.error) { + outputs.push({ + output_type: "error", + ename: result.error.ename, + evalue: result.error.evalue, + traceback: result.error.traceback, + }); + } + return outputs; +} + +const pythonRunner = String.raw` +import ast +import base64 +import contextlib +import io +import json +import os +import pathlib +import sys +import traceback +try: + import resource +except ImportError: + resource = None + +payload = json.load(sys.stdin) +namespace = {} +results = [] +MAX_CAPTURE = 250_000 +WORKSPACE = pathlib.Path.cwd().resolve() +if resource is not None: + try: + resource.setrlimit(resource.RLIMIT_CPU, (25, 30)) + resource.setrlimit(resource.RLIMIT_FSIZE, (10 * 1024 * 1024, 10 * 1024 * 1024)) + resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64)) + except (AttributeError, OSError, ValueError): + pass +READ_ROOTS = [WORKSPACE] +for entry in [sys.base_prefix, sys.prefix, *sys.path]: + if entry: + try: + READ_ROOTS.append(pathlib.Path(entry).resolve()) + except (OSError, TypeError): + pass + +class LimitedBuffer(io.StringIO): + def __init__(self, limit): + super().__init__() + self.limit = limit + self.written = 0 + self.truncated = False + + def write(self, value): + value = str(value) + remaining = self.limit - self.written + if remaining <= 0: + self.truncated = True + return len(value) + chunk = value[:remaining] + self.written += len(chunk) + if len(chunk) < len(value): + self.truncated = True + super().write(chunk) + return len(value) + + def value(self): + suffix = "\n[output truncated]" if self.truncated else "" + return self.getvalue() + suffix + +def resolve_path(value): + if isinstance(value, int): + return None + try: + return pathlib.Path(value).resolve() + except (OSError, TypeError, ValueError): + raise PermissionError("Unable to resolve filesystem path") + +def under(path_value, roots): + if path_value is None: + return True + return any(path_value == root or root in path_value.parents for root in roots) + +def audit(event, args): + if event.startswith(("socket.", "subprocess.", "ctypes.", "pty.")) or event in { + "os.system", "os.posix_spawn", "os.spawn", "os.fork", "os.forkpty" + }: + raise PermissionError(f"{event} is disabled in notebook execution") + if event == "open": + target = resolve_path(args[0]) + mode = str(args[1]) if len(args) > 1 else "r" + write_mode = any(flag in mode for flag in ("w", "a", "x", "+")) + roots = [WORKSPACE] if write_mode else READ_ROOTS + if not under(target, roots): + raise PermissionError("Filesystem access outside the isolated workspace is disabled") + if event in {"os.listdir", "os.scandir", "os.chdir"} and args: + if not under(resolve_path(args[0]), READ_ROOTS): + raise PermissionError("Directory access outside the isolated workspace is disabled") + if event in { + "os.remove", "os.rmdir", "os.mkdir", "os.chmod", "os.chown", "os.truncate", + "os.link", "os.symlink" + } and args: + if not under(resolve_path(args[0]), [WORKSPACE]): + raise PermissionError("Filesystem changes outside the isolated workspace are disabled") + if event in {"os.rename", "os.replace"}: + if any(not under(resolve_path(value), [WORKSPACE]) for value in args[:2]): + raise PermissionError("Filesystem changes outside the isolated workspace are disabled") + +sys.addaudithook(audit) + +def rich_repr(value): + data = {} + if value is None: + return data + try: + html_method = getattr(value, "_repr_html_", None) + if callable(html_method): + html = html_method() + if html: + data["text/html"] = str(html)[:MAX_CAPTURE] + except Exception: + pass + try: + png_method = getattr(value, "_repr_png_", None) + if callable(png_method): + png = png_method() + if png: + if isinstance(png, tuple): + png = png[0] + data["image/png"] = base64.b64encode(png).decode("ascii") + except Exception: + pass + rendered = repr(value) + data["text/plain"] = rendered[:MAX_CAPTURE] + if len(rendered) > MAX_CAPTURE: + data["text/plain"] += "\n[result truncated]" + return data + +def capture_figures(): + displays = [] + pyplot = sys.modules.get("matplotlib.pyplot") + if pyplot is None: + return displays + for number in pyplot.get_fignums(): + buffer = io.BytesIO() + pyplot.figure(number).savefig(buffer, format="png", bbox_inches="tight") + displays.append({"image/png": base64.b64encode(buffer.getvalue()).decode("ascii")}) + buffer.close() + pyplot.close("all") + return displays + +def run_cell(cell, index): + stdout = LimitedBuffer(MAX_CAPTURE) + stderr = LimitedBuffer(MAX_CAPTURE) + result_data = {} + displays = [] + error = None + code = cell.get("code") or "" + try: + tree = ast.parse(code, filename=f"", mode="exec") + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + if tree.body and isinstance(tree.body[-1], ast.Expr): + last_expr = ast.Expression(tree.body[-1].value) + tree.body = tree.body[:-1] + ast.fix_missing_locations(tree) + ast.fix_missing_locations(last_expr) + if tree.body: + exec(compile(tree, f"", "exec"), namespace, namespace) + value = eval(compile(last_expr, f"", "eval"), namespace, namespace) + result_data = rich_repr(value) + else: + exec(compile(tree, f"", "exec"), namespace, namespace) + displays = capture_figures() + except Exception as exc: + error = { + "ename": exc.__class__.__name__, + "evalue": str(exc), + "traceback": traceback.format_exc().splitlines(), + } + return { + "id": cell.get("id"), + "stdout": stdout.value(), + "stderr": stderr.value(), + "resultData": result_data, + "displays": displays, + "error": error, + } + +for index, cell in enumerate(payload.get("cells", [])): + results.append(run_cell(cell, index)) + if results[-1]["error"] is not None and payload.get("stopOnError", True): + break + +print(json.dumps({"results": results})) +`; + +async function runPython(payload, notebookId, preferredRuntime) { + const executionDir = await mkdtemp(path.join(tmpdir(), "copilot-notebook-")); + const errors = []; + const commands = preferredRuntime ? [preferredRuntime] : ["python3", "python"]; + try { + for (const command of commands) { + try { + return await runPythonCommand(command, payload, notebookId, executionDir); + } catch (error) { + if (error?.code === "ENOENT") { + errors.push(`${command}: not found`); + continue; + } + throw error; + } + } + throw new CanvasError("python_not_found", `Unable to run notebook cells because Python was not found (${errors.join(", ")}).`); + } finally { + await rm(executionDir, { recursive: true, force: true }); + } +} + +function terminateProcessTree(child, detached, signal) { + try { + if (detached && child.pid) { + process.kill(-child.pid, signal); + } else { + child.kill(signal); + } + } catch (error) { + if (error?.code !== "ESRCH") { + throw error; + } + } +} + +function runPythonCommand(command, payload, notebookId, executionDir) { + return new Promise((resolve, reject) => { + const detached = process.platform !== "win32"; + const child = spawn(command, ["-I", "-c", pythonRunner], { + cwd: executionDir, + detached, + env: { + HOME: executionDir, + LANG: "C.UTF-8", + PATH: process.env.PATH ?? "/usr/bin:/bin", + PYTHONIOENCODING: "utf-8", + PYTHONUNBUFFERED: "1", + TMPDIR: executionDir, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let forceKillTimer; + + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + clearTimeout(forceKillTimer); + if (activeRuns.get(notebookId)?.child === child) { + activeRuns.delete(notebookId); + } + callback(); + }; + const stop = (message, code = "execution_interrupted") => { + if (settled) { + return; + } + terminateProcessTree(child, detached, "SIGTERM"); + finish(() => reject(new CanvasError(code, message))); + forceKillTimer = setTimeout(() => { + try { + terminateProcessTree(child, detached, "SIGKILL"); + } catch { + // The process may already have exited. + } + }, 1000); + forceKillTimer.unref?.(); + }; + activeRuns.set(notebookId, { child, stop }); + const timeout = setTimeout(() => { + stop("Notebook execution timed out after 30 seconds.", "cell_timeout"); + }, 30000); + + child.on("error", (error) => { + finish(() => reject(error)); + }); + child.stdout.on("data", (chunk) => { + stdout += chunk; + if (Buffer.byteLength(stdout) > 2_000_000) { + stop("Notebook output exceeded the 2 MB limit.", "output_too_large"); + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + if (Buffer.byteLength(stderr) > 250_000) { + stop("Notebook error output exceeded the 250 KB limit.", "output_too_large"); + } + }); + child.on("close", (code) => { + if (settled) { + return; + } + if (code !== 0) { + finish(() => reject(new CanvasError("python_failed", stderr || `Python exited with code ${code}`))); + return; + } + try { + const parsed = JSON.parse(stdout); + finish(() => resolve(parsed)); + } catch { + finish(() => reject(new CanvasError("runner_output_invalid", "The Python runner returned invalid JSON."))); + } + }); + child.stdin.end(JSON.stringify(payload)); + }); +} + +async function runNotebookCells(notebook, targetCellId, notebookId) { + const language = String( + notebook.metadata?.language_info?.name + ?? notebook.metadata?.kernelspec?.language + ?? "", + ).toLowerCase(); + if (language !== "python") { + throw new CanvasError("kernel_unsupported", `Execution is only available for Python notebooks, not ${language || "unknown"} notebooks.`); + } + const targetIndex = targetCellId ? findCellIndex(notebook, targetCellId) : notebook.cells.length - 1; + const executableCells = notebook.cells + .slice(0, targetIndex + 1) + .filter((cell) => cell.cell_type === "code") + .map((cell) => ({ id: getCellId(cell), code: normalizeSource(cell.source) })); + + broadcastStatus(notebookId, { + status: "running", + cellId: executableCells[0]?.id ?? null, + cellCount: executableCells.length, + message: `Running ${executableCells.length} ${executableCells.length === 1 ? "cell" : "cells"}`, + }); + try { + const run = await runPython( + { cells: executableCells, stopOnError: true }, + notebookId, + notebook.metadata?.copilot?.runtime, + ); + const byId = new Map(run.results.map((result) => [result.id, result])); + for (const cell of notebook.cells.slice(0, targetIndex + 1)) { + if (cell.cell_type !== "code") { + continue; + } + const result = byId.get(getCellId(cell)); + if (!result) { + continue; + } + const executionCount = Number(notebook.metadata.copilot.executionCount ?? 0) + 1; + notebook.metadata.copilot.executionCount = executionCount; + cell.execution_count = executionCount; + cell.outputs = outputFromRun({ ...result, executionCount }); + } + } finally { + broadcastStatus(notebookId, { status: "idle", cellId: null, message: "Execution finished" }); + } +} + +function addClient(notebookId, instanceId, res, entry) { + let clients = clientsByNotebook.get(notebookId); + if (!clients) { + clients = new Set(); + clientsByNotebook.set(notebookId, clients); + } + const client = { instanceId, res }; + clients.add(client); + entry.eventClients.add(res); + res.on("close", () => { + clients.delete(client); + entry.eventClients.delete(res); + if (clients.size === 0) { + clientsByNotebook.delete(notebookId); + } + }); +} + +function broadcast(notebookId, notebook) { + const clients = clientsByNotebook.get(notebookId); + if (!clients) { + return; + } + const data = JSON.stringify(summarizeNotebook(notebook)); + for (const client of clients) { + if (!client.res.destroyed && !client.res.writableEnded) { + client.res.write(`event: notebook\ndata: ${data}\n\n`); + } + } +} + +function broadcastStatus(notebookId, status) { + const clients = clientsByNotebook.get(notebookId); + if (!clients) { + return; + } + const data = JSON.stringify(status); + for (const client of clients) { + if (!client.res.destroyed && !client.res.writableEnded) { + client.res.write(`event: execution\ndata: ${data}\n\n`); + } + } +} + +async function readJson(req) { + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 1_000_000) { + throw new CanvasError("request_too_large", "Request body exceeded the 1 MB limit."); + } + } + try { + return body ? JSON.parse(body) : {}; + } catch { + throw new CanvasError("request_json_invalid", "Request body must be valid JSON."); + } +} + +function sendJson(res, status, payload) { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + res.end(JSON.stringify(payload)); +} + +function tokenMatches(provided, expected) { + if (typeof provided !== "string") { + return false; + } + const actualBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + +function requireCapability(req, requestUrl, entry) { + const provided = req.headers["x-notebook-token"] ?? requestUrl.searchParams.get("token"); + if (!tokenMatches(provided, entry.token)) { + throw new CanvasError("request_unauthorized", "Missing or invalid canvas capability token."); + } +} + +function requireSameOrigin(req, entry) { + if (req.headers.origin !== entry.origin) { + throw new CanvasError("request_origin_invalid", "Notebook actions must originate from this canvas."); + } +} + +function setDocumentSecurityHeaders(res) { + res.setHeader( + "Content-Security-Policy", + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'", + ); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-Content-Type-Options", "nosniff"); +} + +function assertString(value, name, { required = false, maxLength = 500_000, pattern } = {}) { + if (value === undefined && !required) { + return; + } + if (typeof value !== "string" || (required && value.length === 0) || value.length > maxLength) { + throw new CanvasError("action_input_invalid", `${name} must be a string of at most ${maxLength} characters.`); + } + if (pattern && !pattern.test(value)) { + throw new CanvasError("action_input_invalid", `${name} has an invalid format.`); + } +} + +function validateHttpActionPayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new CanvasError("action_input_invalid", "Action input must be a JSON object."); + } + const actionFields = { + get_notebook: [], + list_workspace_notebooks: [], + save_workspace: ["expectedRevision"], + save_as: ["path", "overwrite", "expectedRevision"], + open_workspace: ["path", "expectedRevision"], + new_notebook: ["title", "expectedRevision"], + rename_workspace: ["path", "overwrite", "expectedRevision"], + duplicate_workspace: ["path", "overwrite"], + move_cell: ["cellId", "direction", "expectedRevision"], + duplicate_cell: ["cellId", "expectedRevision"], + undo: ["expectedRevision"], + redo: ["expectedRevision"], + get_runtimes: [], + set_runtime: ["runtime", "expectedRevision"], + restart_runtime: ["expectedRevision"], + create_checkpoint: ["label"], + list_checkpoints: [], + restore_checkpoint: ["checkpointId", "expectedRevision"], + set_title: ["title", "expectedRevision"], + add_cell: ["afterCellId", "cellType", "source", "expectedRevision"], + update_cell: ["cellId", "cellType", "source", "expectedRevision"], + delete_cell: ["cellId", "expectedRevision"], + run_cell: ["cellId", "expectedRevision"], + run_all: ["expectedRevision"], + clear_outputs: ["expectedRevision"], + interrupt: [], + }; + const action = payload.action; + if (typeof action !== "string" || !Object.hasOwn(actionFields, action)) { + throw new CanvasError("unknown_action", "Unknown notebook action."); + } + const allowed = new Set(["action", ...actionFields[action]]); + const unexpected = Object.keys(payload).find((key) => !allowed.has(key)); + if (unexpected) { + throw new CanvasError("action_input_invalid", `Unexpected action field: ${unexpected}`); + } + if (payload.expectedRevision !== undefined && (!Number.isInteger(payload.expectedRevision) || payload.expectedRevision < 0)) { + throw new CanvasError("action_input_invalid", "expectedRevision must be a non-negative integer."); + } + assertString(payload.cellId, "cellId", { + required: ["update_cell", "delete_cell", "run_cell", "move_cell", "duplicate_cell"].includes(action), + maxLength: 64, + pattern: cellIdRegex, + }); + assertString(payload.afterCellId, "afterCellId", { maxLength: 64, pattern: cellIdRegex }); + assertString(payload.title, "title", { required: action === "set_title", maxLength: 120 }); + assertString(payload.path, "path", { + required: ["save_as", "open_workspace", "rename_workspace", "duplicate_workspace"].includes(action), + maxLength: 240, + }); + assertString(payload.source, "source"); + assertString(payload.runtime, "runtime", { required: action === "set_runtime", maxLength: 32 }); + assertString(payload.label, "label", { maxLength: 80 }); + assertString(payload.checkpointId, "checkpointId", { + required: action === "restore_checkpoint", + maxLength: 80, + pattern: /^[A-Za-z0-9_-]{1,80}$/, + }); + if (payload.direction !== undefined && !["up", "down"].includes(payload.direction)) { + throw new CanvasError("action_input_invalid", "direction must be up or down."); + } + if (action === "move_cell" && payload.direction === undefined) { + throw new CanvasError("action_input_invalid", "direction is required."); + } + if (payload.overwrite !== undefined && typeof payload.overwrite !== "boolean") { + throw new CanvasError("action_input_invalid", "overwrite must be a boolean."); + } + if (payload.cellType !== undefined && !["code", "markdown", "raw"].includes(payload.cellType)) { + throw new CanvasError("action_input_invalid", "cellType must be code, markdown, or raw."); + } + if (action === "add_cell" && payload.cellType === undefined) { + throw new CanvasError("action_input_invalid", "cellType is required."); + } + if (action === "update_cell" && payload.cellType === undefined && payload.source === undefined) { + throw new CanvasError("action_input_invalid", "update_cell requires source or cellType."); + } + return { action, input: Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "action")) }; +} + +async function handleCanvasAction(ctx, action, input = {}) { + await storageReady; + const notebookId = resolveNotebookId(ctx, input); + if (action === "get_notebook") { + const notebook = await loadNotebook(notebookId); + return { notebookId, path: notebookPath(notebookId), notebook: summarizeNotebook(notebook) }; + } + if (action === "list_workspace_notebooks") { + return { notebookId, files: await listWorkspaceNotebooks() }; + } + if (action === "save_workspace") { + return withNotebookLock(notebookId, async () => { + const notebook = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(notebook.metadata.copilot.revision ?? 0); + if (Number.isInteger(input.expectedRevision) && input.expectedRevision !== currentRevision) { + throw new CanvasError("revision_conflict", "Notebook changed before it could be saved."); + } + if (!notebook.metadata.copilot.workspacePath) { + throw new CanvasError("workspace_path_required", "Choose Save As before saving this notebook."); + } + await assertWorkspaceUnchanged(notebookId, notebook); + await writeWorkspaceCopy(notebookId, notebook); + return { notebookId, notebook: summarizeNotebook(notebook), saved: true }; + }); + } + if (action === "save_as") { + const target = await assertWorkspaceTargetAvailable(input.path, input.overwrite === true); + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.workspacePath = target.relative; + }, input.expectedRevision, { workspaceOverwrite: true }); + } + if (action === "rename_workspace") { + const target = await assertWorkspaceTargetAvailable(input.path, input.overwrite === true); + const current = await loadNotebook(notebookId); + const previousPath = current.metadata?.copilot?.workspacePath; + if (!previousPath) { + throw new CanvasError("workspace_path_required", "Save the notebook before renaming it."); + } + const result = await mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.workspacePath = target.relative; + }, input.expectedRevision, { workspaceOverwrite: true }); + if (previousPath !== target.relative) { + await rm(normalizeWorkspacePath(previousPath).resolved, { force: true }); + } + return result; + } + if (action === "duplicate_workspace") { + const target = await assertWorkspaceTargetAvailable(input.path, input.overwrite === true); + const notebook = await loadNotebook(notebookId); + await assertWorkspaceUnchanged(notebookId, notebook); + const duplicate = structuredClone(notebook); + duplicate.metadata.copilot.workspacePath = target.relative; + duplicate.metadata.copilot.title = path.basename(target.relative, ".ipynb"); + await writeWorkspaceDocument(target.relative, serializeNotebook(duplicate), { overwrite: true }); + return { notebookId, duplicatedPath: target.relative, files: await listWorkspaceNotebooks() }; + } + if (action === "open_workspace") { + const source = await readWorkspaceNotebook(input.path); + return withNotebookLock(notebookId, async () => { + const current = await loadNotebookUnlocked(notebookId); + const currentRevision = Number(current.metadata.copilot.revision ?? 0); + if (Number.isInteger(input.expectedRevision) && input.expectedRevision !== currentRevision) { + throw new CanvasError("revision_conflict", "Notebook changed before the selected file could be opened."); + } + const history = historyByNotebook.get(notebookId) ?? { redo: [], undo: [] }; + history.undo.push(structuredClone(current)); + history.redo = []; + historyByNotebook.set(notebookId, history); + const { notebook } = normalizeNotebook( + source.parsed, + notebookId, + path.basename(source.relative, ".ipynb"), + ); + notebook.metadata.copilot.workspacePath = source.relative; + notebook.metadata.copilot.revision = currentRevision + 1; + await writeNotebookUnlocked(notebookId, notebook); + await writeWorkspaceCopy(notebookId, notebook); + broadcast(notebookId, notebook); + return { notebookId, notebook: summarizeNotebook(notebook), openedPath: source.relative }; + }); + } + if (action === "new_notebook") { + return mutateNotebook(notebookId, (notebook) => { + const fresh = normalizeNotebook({}, notebookId, input.title || "Untitled notebook", true).notebook; + notebook.cells = fresh.cells; + notebook.metadata = fresh.metadata; + delete notebook.metadata.copilot.workspacePath; + }, input.expectedRevision); + } + if (action === "move_cell") { + return mutateNotebook(notebookId, (notebook) => { + const index = findCellIndex(notebook, input.cellId); + const target = input.direction === "up" ? index - 1 : index + 1; + if (target < 0 || target >= notebook.cells.length) { + return; + } + [notebook.cells[index], notebook.cells[target]] = [notebook.cells[target], notebook.cells[index]]; + }, input.expectedRevision); + } + if (action === "duplicate_cell") { + return mutateNotebook(notebookId, (notebook) => { + const index = findCellIndex(notebook, input.cellId); + const duplicate = structuredClone(notebook.cells[index]); + duplicate.id = createCellId(new Set(notebook.cells.map((cell) => getCellId(cell)))); + duplicate.metadata = { ...(duplicate.metadata ?? {}), copilotCellId: duplicate.id }; + if (duplicate.cell_type === "code") { + duplicate.execution_count = null; + duplicate.outputs = []; + } + notebook.cells.splice(index + 1, 0, duplicate); + return { cellId: duplicate.id }; + }, input.expectedRevision); + } + if (action === "undo" || action === "redo") { + return applyHistory(notebookId, action, input.expectedRevision); + } + if (action === "get_runtimes") { + return { notebookId, runtimes: await getAvailableRuntimes() }; + } + if (action === "set_runtime") { + const runtimes = await getAvailableRuntimes(); + if (!runtimes.some((runtime) => runtime.id === input.runtime)) { + throw new CanvasError("runtime_unavailable", "The selected Python runtime is unavailable."); + } + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.runtime = input.runtime; + }, input.expectedRevision); + } + if (action === "restart_runtime") { + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.executionCount = 0; + for (const cell of notebook.cells) { + if (cell.cell_type === "code") { + cell.execution_count = null; + cell.outputs = []; + } + } + }, input.expectedRevision); + } + if (action === "create_checkpoint") { + const notebook = await loadNotebook(notebookId); + const checkpoint = await createCheckpoint(notebookId, notebook, input.label); + return { notebookId, checkpoint, checkpoints: await listCheckpoints(notebookId) }; + } + if (action === "list_checkpoints") { + return { notebookId, checkpoints: await listCheckpoints(notebookId) }; + } + if (action === "restore_checkpoint") { + const record = JSON.parse(await readFile(checkpointPath(notebookId, input.checkpointId), "utf8")); + return mutateNotebook(notebookId, (notebook) => { + const workspacePath = notebook.metadata?.copilot?.workspacePath; + notebook.cells = structuredClone(record.notebook.cells); + notebook.metadata = structuredClone(record.notebook.metadata); + if (workspacePath) { + notebook.metadata.copilot.workspacePath = workspacePath; + } else { + delete notebook.metadata.copilot.workspacePath; + } + }, input.expectedRevision); + } + if (action === "interrupt") { + const run = activeRuns.get(notebookId); + if (!run) { + return { notebookId, interrupted: false }; + } + run.stop("Notebook execution interrupted."); + return { notebookId, interrupted: true }; + } + if (action === "set_title") { + return mutateNotebook(notebookId, (notebook) => { + notebook.metadata.copilot.title = textToSource(input.title).slice(0, 120) || "Untitled notebook"; + }, input.expectedRevision); + } + if (action === "add_cell") { + return mutateNotebook(notebookId, (notebook) => { + const cell = createCell(input.cellType, input.source); + if (input.afterCellId) { + const index = findCellIndex(notebook, input.afterCellId); + notebook.cells.splice(index + 1, 0, cell); + } else { + notebook.cells.push(cell); + } + return { cellId: getCellId(cell) }; + }, input.expectedRevision); + } + if (action === "update_cell") { + return mutateNotebook(notebookId, (notebook) => { + const cell = findCell(notebook, input.cellId); + if (typeof input.cellType === "string" && input.cellType !== cell.cell_type) { + cell.cell_type = input.cellType; + if (cell.cell_type === "code") { + cell.execution_count = null; + cell.outputs = []; + } else { + delete cell.execution_count; + delete cell.outputs; + } + } + if (typeof input.source === "string") { + cell.source = input.source; + } + }, input.expectedRevision); + } + if (action === "delete_cell") { + return mutateNotebook(notebookId, (notebook) => { + const index = findCellIndex(notebook, input.cellId); + notebook.cells.splice(index, 1); + if (notebook.cells.length === 0) { + notebook.cells.push(createCell("code", "")); + } + }, input.expectedRevision); + } + if (action === "run_cell") { + return mutateNotebook(notebookId, async (notebook) => { + await runNotebookCells(notebook, input.cellId, notebookId); + }, input.expectedRevision); + } + if (action === "run_all") { + return mutateNotebook(notebookId, async (notebook) => { + await runNotebookCells(notebook, undefined, notebookId); + }, input.expectedRevision); + } + if (action === "clear_outputs") { + return mutateNotebook(notebookId, (notebook) => { + for (const cell of notebook.cells) { + if (cell.cell_type === "code") { + cell.outputs = []; + cell.execution_count = null; + } + } + }, input.expectedRevision); + } + throw new CanvasError("unknown_action", `Unknown notebook action: ${action}`); +} + +async function handleHttpAction(ctx, req, res) { + const payload = validateHttpActionPayload(await readJson(req)); + const result = await handleCanvasAction(ctx, payload.action, payload.input); + sendJson(res, 200, result); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function renderHtml(notebookId) { + return ` + + + + + Notebook + + + +
+
+ +
+
+ workspace + / + ${escapeHtml(notebookId)}.ipynb +
+ + +
+
+ + + + +
+
+
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ + Opening notebook +
+
+
+ + +
+ +
+ 0 cells + + Isolated Python + + Autosaved internally +
+
+
+
+ +
+
Open notebook
+
+ + + +
+
+ + +
+
+
+ + +`; +} + +async function startServer(instanceId, notebookId) { + const ctx = { instanceId, notebookId }; + const entry = { + eventClients: new Set(), + notebookId, + origin: "", + server: undefined, + sockets: new Set(), + token: randomBytes(32).toString("base64url"), + url: "", + }; + const server = createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + if (req.method === "GET" && requestUrl.pathname === "/") { + setDocumentSecurityHeaders(res); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(renderHtml(notebookId)); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/favicon.ico") { + res.writeHead(204, { "Cache-Control": "public, max-age=86400" }); + res.end(); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/api/notebook") { + requireCapability(req, requestUrl, entry); + const notebook = await loadNotebook(notebookId); + sendJson(res, 200, { notebookId, notebook: summarizeNotebook(notebook) }); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/download") { + requireCapability(req, requestUrl, entry); + const notebook = await loadNotebook(notebookId); + const filename = path.basename( + notebook.metadata?.copilot?.workspacePath ?? `${notebookId}.ipynb`, + ).replaceAll('"', ""); + res.writeHead(200, { + "Content-Type": "application/x-ipynb+json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + res.end(serializeNotebook(notebook)); + return; + } + if (req.method === "GET" && requestUrl.pathname === "/events") { + requireCapability(req, requestUrl, entry); + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Content-Type-Options": "nosniff", + }); + addClient(notebookId, instanceId, res, entry); + const notebook = await loadNotebook(notebookId); + res.write(`event: notebook\ndata: ${JSON.stringify(summarizeNotebook(notebook))}\n\n`); + return; + } + if (req.method === "POST" && requestUrl.pathname === "/api/action") { + requireCapability(req, requestUrl, entry); + requireSameOrigin(req, entry); + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + throw new CanvasError("request_content_type_invalid", "Notebook actions require application/json."); + } + await handleHttpAction(ctx, req, res); + return; + } + sendJson(res, 404, { message: "Not found" }); + } catch (error) { + const status = error?.code === "request_unauthorized" + ? 401 + : error?.code === "request_origin_invalid" + ? 403 + : error?.code === "revision_conflict" + ? 409 + : error instanceof CanvasError + ? 400 + : 500; + sendJson(res, status, { code: error.code ?? "canvas_error", message: error.message }); + } + }); + entry.server = server; + server.on("connection", (socket) => { + entry.sockets.add(socket); + socket.on("close", () => entry.sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + entry.origin = `http://127.0.0.1:${port}`; + entry.url = `${entry.origin}/#token=${encodeURIComponent(entry.token)}`; + return entry; +} + +async function closeServerEntry(entry) { + const run = activeRuns.get(entry.notebookId); + run?.stop("Notebook canvas closed during execution."); + for (const client of entry.eventClients) { + if (!client.writableEnded) { + client.write("event: close\ndata: {}\n\n"); + client.end(); + } + } + entry.eventClients.clear(); + await new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + entry.server.close(finish); + entry.server.closeIdleConnections?.(); + const forceClose = setTimeout(() => { + for (const socket of entry.sockets) { + socket.destroy(); + } + finish(); + }, 750); + forceClose.unref?.(); + }); +} + +function declaredNotebookAction(name, description, properties = {}, required = []) { + return { + name, + description, + inputSchema: { + type: "object", + additionalProperties: false, + required, + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + ...properties, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, name, ctx.input), + }; +} + +const productActions = [ + declaredNotebookAction( + "list_workspace_notebooks", + "List .ipynb files available in the current workspace.", + ), + declaredNotebookAction( + "save_workspace", + "Save the notebook to its current workspace path.", + ), + declaredNotebookAction( + "save_as", + "Save a workspace copy of the notebook under a new .ipynb path.", + { + path: { type: "string", minLength: 1, maxLength: 240 }, + overwrite: { type: "boolean" }, + }, + ["path"], + ), + declaredNotebookAction( + "open_workspace", + "Open a workspace .ipynb file in this canvas.", + { path: { type: "string", minLength: 1, maxLength: 240 } }, + ["path"], + ), + declaredNotebookAction( + "new_notebook", + "Replace the canvas with a new empty notebook.", + { + title: { type: "string", minLength: 1, maxLength: 120 }, + }, + ), + declaredNotebookAction( + "rename_workspace", + "Move the current workspace notebook to a new .ipynb path.", + { + path: { type: "string", minLength: 1, maxLength: 240 }, + overwrite: { type: "boolean" }, + }, + ["path"], + ), + declaredNotebookAction( + "duplicate_workspace", + "Create a workspace copy of the notebook at a new .ipynb path.", + { + path: { type: "string", minLength: 1, maxLength: 240 }, + overwrite: { type: "boolean" }, + }, + ["path"], + ), + declaredNotebookAction( + "move_cell", + "Move a cell one position up or down.", + { + cellId: { type: "string", pattern: cellIdPattern }, + direction: { type: "string", enum: ["up", "down"] }, + }, + ["cellId", "direction"], + ), + declaredNotebookAction( + "duplicate_cell", + "Duplicate a notebook cell directly below the original.", + { cellId: { type: "string", pattern: cellIdPattern } }, + ["cellId"], + ), + declaredNotebookAction("undo", "Undo the most recent notebook mutation."), + declaredNotebookAction("redo", "Redo the most recently undone notebook mutation."), + declaredNotebookAction("get_runtimes", "List available local Python runtimes."), + declaredNotebookAction( + "set_runtime", + "Select the Python executable used for future notebook runs.", + { runtime: { type: "string", enum: ["python3", "python"] } }, + ["runtime"], + ), + declaredNotebookAction("restart_runtime", "Interrupt active work and clear outputs for a fresh stateless run."), + declaredNotebookAction( + "create_checkpoint", + "Create a persistent checkpoint of the current notebook.", + { label: { type: "string", maxLength: 80 } }, + ), + declaredNotebookAction("list_checkpoints", "List persistent checkpoints for the current notebook."), + declaredNotebookAction( + "restore_checkpoint", + "Restore a persistent checkpoint into the current notebook.", + { checkpointId: { type: "string", minLength: 1, maxLength: 80 } }, + ["checkpointId"], + ), +]; + +const canvas = createCanvas({ + id: "jupyter-notebooks", + displayName: "Notebook", + description: "Create notebook cells and run Python code through a fresh constrained process.", + inputSchema: openInputSchema, + actions: [ + ...productActions, + { + name: "get_notebook", + description: "Return the current notebook cells, outputs, and backing .ipynb path.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "get_notebook", ctx.input), + }, + { + name: "set_title", + description: "Set the notebook title.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["title"], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + title: { type: "string", minLength: 1, maxLength: 120 }, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, "set_title", ctx.input), + }, + { + name: "add_cell", + description: "Add a code, Markdown, or raw cell, optionally after an existing cell.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["cellType"], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + afterCellId: { type: "string", pattern: cellIdPattern }, + cellType: { type: "string", enum: ["code", "markdown", "raw"] }, + source: { type: "string" }, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, "add_cell", ctx.input), + }, + { + name: "update_cell", + description: "Update a cell source and optionally convert between code, Markdown, and raw.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["cellId"], + properties: { + notebookId: { type: "string", pattern: notebookIdPattern }, + cellId: { type: "string", pattern: cellIdPattern }, + cellType: { type: "string", enum: ["code", "markdown", "raw"] }, + source: { type: "string" }, + }, + }, + handler: (ctx) => handleCanvasAction(ctx, "update_cell", ctx.input), + }, + { + name: "delete_cell", + description: "Delete a notebook cell.", + inputSchema: cellIdSchema(), + handler: (ctx) => handleCanvasAction(ctx, "delete_cell", ctx.input), + }, + { + name: "run_cell", + description: "Run code cells from the beginning through the selected cell in a fresh constrained Python process.", + inputSchema: cellIdSchema(), + handler: (ctx) => handleCanvasAction(ctx, "run_cell", ctx.input), + }, + { + name: "run_all", + description: "Run all code cells in order in a fresh constrained Python process.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "run_all", ctx.input), + }, + { + name: "clear_outputs", + description: "Clear all code cell outputs and execution counts.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "clear_outputs", ctx.input), + }, + { + name: "interrupt", + description: "Interrupt the active Python execution for this notebook.", + inputSchema: notebookActionSchema, + handler: (ctx) => handleCanvasAction(ctx, "interrupt", ctx.input), + }, + ], + open: async (ctx) => { + await storageReady; + const notebookId = ctx.input?.notebookId ?? "default"; + await loadNotebook(notebookId, ctx.input?.title); + let entry = servers.get(ctx.instanceId); + if (!entry || entry.notebookId !== notebookId) { + if (entry) { + await closeServerEntry(entry); + } + entry = await startServer(ctx.instanceId, notebookId); + servers.set(ctx.instanceId, entry); + } + return { + title: "Notebook", + status: "Ready", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await closeServerEntry(entry); + } + }, +}); + +const session = await joinSession({ canvases: [canvas] }); +storageReady = initializeStorage(session.workspacePath); +await storageReady; diff --git a/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/package.json b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/package.json new file mode 100644 index 00000000..6640f52e --- /dev/null +++ b/plugins/jupyter-notebooks/com.github.copilot/extensions/jupyter-notebooks/package.json @@ -0,0 +1,16 @@ +{ + "name": "jupyter-notebooks", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "description": "Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas.", + "keywords": [ + "data-analysis", + "interactive-notebooks", + "jupyter", + "python" + ], + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/plugins/jupyter-notebooks/plugin.json b/plugins/jupyter-notebooks/plugin.json new file mode 100644 index 00000000..637f9445 --- /dev/null +++ b/plugins/jupyter-notebooks/plugin.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "jupyter-notebooks", + "description": "Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas.", + "version": "1.0.0", + "author": { + "name": "Pierce Boggan", + "url": "https://github.com/pierceboggan" + }, + "repository": "https://github.com/github/awesome-copilot", + "license": "MIT", + "keywords": [ + "data-analysis", + "interactive-notebooks", + "jupyter", + "python" + ], + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } +}