nebula-notebook 0.2.16 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{errorwidget-DfcjRDKM.js → errorwidget-t1RLHQYw.js} +1 -1
- package/dist/assets/{index-CyyuPG9B.js → index-BMuaZuus.js} +1 -1
- package/dist/assets/index-Bvhz0ltE.css +32 -0
- package/dist/assets/{index-LZ-zzybU.js → index-Cq1BG1_T.js} +205 -200
- package/dist/assets/{index-DrhpYYGt.js → index-DTGJj27s.js} +1 -1
- package/dist/assets/{index-COAUb-T8.js → index-DzeeB-u3.js} +1 -1
- package/dist/assets/{services-shim-CCJm9nl7.js → services-shim-Dg9Zi6xv.js} +1 -1
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +3 -1
- package/node-server/dist/fs/fs-service.js +104 -8
- package/node-server/dist/fs/index.d.ts +1 -0
- package/node-server/dist/fs/index.js +1 -0
- package/node-server/dist/fs/sealed-path.d.ts +41 -0
- package/node-server/dist/fs/sealed-path.js +228 -0
- package/node-server/dist/fs/types.d.ts +4 -2
- package/node-server/dist/fs/types.js +2 -2
- package/node-server/dist/index.js +22 -13
- package/node-server/dist/kernel/kernel-service.d.ts +2 -2
- package/node-server/dist/kernel/kernel-service.js +15 -5
- package/node-server/dist/kernel/types.d.ts +16 -0
- package/node-server/dist/notebook/operation-router.js +13 -2
- package/node-server/dist/provenance/canonical-json.d.ts +36 -0
- package/node-server/dist/provenance/canonical-json.js +218 -0
- package/node-server/dist/provenance/provenance-store.d.ts +63 -0
- package/node-server/dist/provenance/provenance-store.js +598 -0
- package/node-server/dist/provenance/replay-seal-service.d.ts +223 -0
- package/node-server/dist/provenance/replay-seal-service.js +1702 -0
- package/node-server/dist/provenance/types.d.ts +65 -0
- package/node-server/dist/provenance/types.js +2 -0
- package/node-server/dist/routes/fs.js +27 -0
- package/node-server/dist/routes/kernel.js +15 -0
- package/node-server/dist/routes/notebook.js +54 -0
- package/node-server/dist/routes/replay-seal.d.ts +9 -0
- package/node-server/dist/routes/replay-seal.js +66 -0
- package/node-server/dist/server/bind-host.d.ts +12 -0
- package/node-server/dist/server/bind-host.js +56 -0
- package/node-server/dist/server/cors-origin.d.ts +1 -0
- package/node-server/dist/server/cors-origin.js +32 -0
- package/package.json +1 -1
- package/dist/assets/index-Czch8hB-.css +0 -32
|
@@ -50,6 +50,72 @@ const default_kernel_1 = require("../kernel/default-kernel");
|
|
|
50
50
|
const registry_1 = require("./notebook-formats/registry");
|
|
51
51
|
const percent_1 = require("./notebook-formats/percent");
|
|
52
52
|
const display_data_1 = require("../output/display-data");
|
|
53
|
+
const sealed_path_1 = require("./sealed-path");
|
|
54
|
+
const JUPYTER_CELL_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
55
|
+
function isValidJupyterCellId(value) {
|
|
56
|
+
return typeof value === 'string' && JUPYTER_CELL_ID_PATTERN.test(value);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve stable, unique IDs for every cell in an imported notebook.
|
|
60
|
+
*
|
|
61
|
+
* Native nbformat IDs own the namespace before legacy metadata fallbacks are
|
|
62
|
+
* considered. This prevents an earlier legacy-only or malformed cell from
|
|
63
|
+
* stealing a valid native ID from a later cell. The first occurrence of a
|
|
64
|
+
* duplicate native ID remains canonical; later duplicates are repaired using
|
|
65
|
+
* an otherwise-unclaimed legacy ID or a deterministic position-based ID.
|
|
66
|
+
*/
|
|
67
|
+
function resolveJupyterCellIds(cells) {
|
|
68
|
+
const resolved = new Array(cells.length);
|
|
69
|
+
const nativeOwners = new Map();
|
|
70
|
+
cells.forEach((cell, index) => {
|
|
71
|
+
if (isValidJupyterCellId(cell.id) && !nativeOwners.has(cell.id)) {
|
|
72
|
+
nativeOwners.set(cell.id, index);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const reservedNativeIds = new Set(nativeOwners.keys());
|
|
76
|
+
const used = new Set();
|
|
77
|
+
for (const [id, index] of nativeOwners) {
|
|
78
|
+
resolved[index] = id;
|
|
79
|
+
used.add(id);
|
|
80
|
+
}
|
|
81
|
+
cells.forEach((cell, index) => {
|
|
82
|
+
if (resolved[index] !== undefined)
|
|
83
|
+
return;
|
|
84
|
+
const legacyId = cell.metadata?.nebula_id;
|
|
85
|
+
if (isValidJupyterCellId(legacyId)
|
|
86
|
+
&& !reservedNativeIds.has(legacyId)
|
|
87
|
+
&& !used.has(legacyId)) {
|
|
88
|
+
resolved[index] = legacyId;
|
|
89
|
+
used.add(legacyId);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
cells.forEach((_cell, index) => {
|
|
93
|
+
if (resolved[index] !== undefined)
|
|
94
|
+
return;
|
|
95
|
+
const base = `cell-${index}`;
|
|
96
|
+
let candidate = base;
|
|
97
|
+
let suffix = 1;
|
|
98
|
+
while (reservedNativeIds.has(candidate) || used.has(candidate)) {
|
|
99
|
+
candidate = `${base}-${suffix}`;
|
|
100
|
+
suffix += 1;
|
|
101
|
+
}
|
|
102
|
+
resolved[index] = candidate;
|
|
103
|
+
used.add(candidate);
|
|
104
|
+
});
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
function validateJupyterCellIds(cells) {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
cells.forEach((cell, index) => {
|
|
110
|
+
if (!isValidJupyterCellId(cell.id)) {
|
|
111
|
+
throw new Error(`Invalid Jupyter cell ID at index ${index}: expected 1-64 ASCII letters, digits, hyphens, or underscores`);
|
|
112
|
+
}
|
|
113
|
+
if (seen.has(cell.id)) {
|
|
114
|
+
throw new Error(`Duplicate Jupyter cell ID at index ${index}: ${cell.id}`);
|
|
115
|
+
}
|
|
116
|
+
seen.add(cell.id);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
53
119
|
const NEBULA_DIR = path.join(os.homedir(), '.nebula');
|
|
54
120
|
const USER_CONFIG_PATH = path.join(NEBULA_DIR, 'config.json');
|
|
55
121
|
const PROJECT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', '.nebula-config.json');
|
|
@@ -317,6 +383,10 @@ class FilesystemService {
|
|
|
317
383
|
* Prevents partial/corrupt files on interruption.
|
|
318
384
|
*/
|
|
319
385
|
atomicWriteFileSync(targetPath, data) {
|
|
386
|
+
// Keep the immutability check at the lowest shared write primitive as a
|
|
387
|
+
// backstop for future notebook mutation APIs. ReplaySealService bypasses
|
|
388
|
+
// FilesystemService and writes its staging/seal artifacts directly.
|
|
389
|
+
(0, sealed_path_1.assertPathMutable)(targetPath, { operation: 'write' });
|
|
320
390
|
const dir = path.dirname(targetPath);
|
|
321
391
|
if (!fs.existsSync(dir)) {
|
|
322
392
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -361,6 +431,7 @@ class FilesystemService {
|
|
|
361
431
|
* notebook file itself stays fully durable.
|
|
362
432
|
*/
|
|
363
433
|
async atomicWriteFile(targetPath, data, opts = {}) {
|
|
434
|
+
(0, sealed_path_1.assertPathMutable)(targetPath, { operation: 'write' });
|
|
364
435
|
const durable = opts.durable !== false;
|
|
365
436
|
const dir = path.dirname(targetPath);
|
|
366
437
|
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
@@ -611,6 +682,7 @@ class FilesystemService {
|
|
|
611
682
|
*/
|
|
612
683
|
writeFile(filePath, content, fileType = 'text') {
|
|
613
684
|
const normalizedPath = this.normalizePath(filePath);
|
|
685
|
+
(0, sealed_path_1.assertPathMutable)(normalizedPath, { operation: 'write' });
|
|
614
686
|
// Create parent directories if needed
|
|
615
687
|
const parentDir = path.dirname(normalizedPath);
|
|
616
688
|
if (parentDir && !fs.existsSync(parentDir)) {
|
|
@@ -629,6 +701,7 @@ class FilesystemService {
|
|
|
629
701
|
*/
|
|
630
702
|
createFile(filePath, isDirectory = false) {
|
|
631
703
|
const normalizedPath = this.normalizePath(filePath);
|
|
704
|
+
(0, sealed_path_1.assertPathMutable)(normalizedPath, { operation: 'create a file in' });
|
|
632
705
|
if (fs.existsSync(normalizedPath)) {
|
|
633
706
|
throw new Error(`Path already exists: ${normalizedPath}`);
|
|
634
707
|
}
|
|
@@ -779,6 +852,7 @@ class FilesystemService {
|
|
|
779
852
|
*/
|
|
780
853
|
deleteFile(filePath) {
|
|
781
854
|
const normalizedPath = this.normalizePath(filePath);
|
|
855
|
+
(0, sealed_path_1.assertPathMutable)(normalizedPath, { operation: 'delete', protectDescendants: true });
|
|
782
856
|
if (!fs.existsSync(normalizedPath)) {
|
|
783
857
|
throw new Error(`Path not found: ${normalizedPath}`);
|
|
784
858
|
}
|
|
@@ -830,6 +904,8 @@ class FilesystemService {
|
|
|
830
904
|
renameFile(oldPath, newPath) {
|
|
831
905
|
const normalizedOld = this.normalizePath(oldPath);
|
|
832
906
|
const normalizedNew = this.normalizePath(newPath);
|
|
907
|
+
(0, sealed_path_1.assertPathMutable)(normalizedOld, { operation: 'move', protectDescendants: true });
|
|
908
|
+
(0, sealed_path_1.assertPathMutable)(normalizedNew, { operation: 'move a file into' });
|
|
833
909
|
if (!fs.existsSync(normalizedOld)) {
|
|
834
910
|
throw new Error(`Path not found: ${normalizedOld}`);
|
|
835
911
|
}
|
|
@@ -900,6 +976,7 @@ class FilesystemService {
|
|
|
900
976
|
*/
|
|
901
977
|
duplicateFile(filePath) {
|
|
902
978
|
const normalizedPath = this.normalizePath(filePath);
|
|
979
|
+
(0, sealed_path_1.assertPathMutable)(normalizedPath, { operation: 'duplicate', protectDescendants: true });
|
|
903
980
|
if (!fs.existsSync(normalizedPath)) {
|
|
904
981
|
throw new Error(`File not found: ${normalizedPath}`);
|
|
905
982
|
}
|
|
@@ -946,6 +1023,7 @@ class FilesystemService {
|
|
|
946
1023
|
*/
|
|
947
1024
|
async uploadFile(destDir, tempFilePath, originalName, onConflict = 'rename') {
|
|
948
1025
|
const normalizedDir = this.normalizePath(destDir);
|
|
1026
|
+
(0, sealed_path_1.assertPathMutable)(normalizedDir, { operation: 'upload into' });
|
|
949
1027
|
if (!fs.existsSync(normalizedDir)) {
|
|
950
1028
|
throw new Error(`Directory not found: ${normalizedDir}`);
|
|
951
1029
|
}
|
|
@@ -955,6 +1033,7 @@ class FilesystemService {
|
|
|
955
1033
|
}
|
|
956
1034
|
// Determine final path
|
|
957
1035
|
let finalPath = path.join(normalizedDir, originalName);
|
|
1036
|
+
(0, sealed_path_1.assertPathMutable)(finalPath, { operation: 'upload' });
|
|
958
1037
|
if (fs.existsSync(finalPath) && onConflict !== 'overwrite') {
|
|
959
1038
|
if (onConflict === 'fail') {
|
|
960
1039
|
throw new Error(`already exists: ${finalPath}`);
|
|
@@ -1157,10 +1236,11 @@ class FilesystemService {
|
|
|
1157
1236
|
const notebook = JSON.parse(raw);
|
|
1158
1237
|
const metadataKernel = notebook.metadata?.kernelspec?.name;
|
|
1159
1238
|
const kernelspec = metadataKernel || 'python3';
|
|
1239
|
+
const cellIds = resolveJupyterCellIds(notebook.cells || []);
|
|
1160
1240
|
const cells = notebook.cells.map((nbCell, i) => {
|
|
1161
1241
|
let cellType = nbCell.cell_type === 'markdown' ? 'markdown' : 'code';
|
|
1162
1242
|
const content = this.sourceToString(nbCell.source);
|
|
1163
|
-
const cellId =
|
|
1243
|
+
const cellId = cellIds[i];
|
|
1164
1244
|
const outputs = this.convertOutputs(nbCell.outputs, i);
|
|
1165
1245
|
const cell = {
|
|
1166
1246
|
id: cellId,
|
|
@@ -1225,10 +1305,12 @@ class FilesystemService {
|
|
|
1225
1305
|
*/
|
|
1226
1306
|
async saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
|
|
1227
1307
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1308
|
+
(0, sealed_path_1.assertPathMutable)(normalizedPath, { operation: 'save' });
|
|
1228
1309
|
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1229
1310
|
if (formatAdapter) {
|
|
1230
1311
|
return await this.saveTextNotebookCells(formatAdapter, normalizedPath, cells, kernelName, notebookMetadata);
|
|
1231
1312
|
}
|
|
1313
|
+
validateJupyterCellIds(cells);
|
|
1232
1314
|
// Load existing notebook metadata if file exists.
|
|
1233
1315
|
// Prefer a fast scan for the top-level metadata object so we avoid
|
|
1234
1316
|
// parsing the full notebook on every save. Fall back to full JSON
|
|
@@ -1269,11 +1351,14 @@ class FilesystemService {
|
|
|
1269
1351
|
if (sentinelCells.length > 0) {
|
|
1270
1352
|
try {
|
|
1271
1353
|
const existing = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1354
|
+
const existingCells = existing.cells || [];
|
|
1355
|
+
const existingCellIds = resolveJupyterCellIds(existingCells);
|
|
1356
|
+
existingCells.forEach((jc, index) => {
|
|
1357
|
+
priorOutputs.set(existingCellIds[index], {
|
|
1358
|
+
outputs: jc.outputs || [],
|
|
1359
|
+
execution_count: jc.execution_count ?? null,
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1277
1362
|
}
|
|
1278
1363
|
catch {
|
|
1279
1364
|
return { success: false, mtime: 0, needsFull: true };
|
|
@@ -1284,10 +1369,10 @@ class FilesystemService {
|
|
|
1284
1369
|
}
|
|
1285
1370
|
}
|
|
1286
1371
|
const nbCells = cells.map((cell) => {
|
|
1287
|
-
const preservedMetadata = cell._metadata || {};
|
|
1372
|
+
const preservedMetadata = { ...(cell._metadata || {}) };
|
|
1373
|
+
delete preservedMetadata.nebula_id;
|
|
1288
1374
|
const cellMetadata = {
|
|
1289
1375
|
...preservedMetadata,
|
|
1290
|
-
nebula_id: cell.id,
|
|
1291
1376
|
};
|
|
1292
1377
|
if (cell.scrolled !== undefined) {
|
|
1293
1378
|
cellMetadata.scrolled = cell.scrolled;
|
|
@@ -1296,6 +1381,7 @@ class FilesystemService {
|
|
|
1296
1381
|
cellMetadata.scrolled_height = cell.scrolledHeight;
|
|
1297
1382
|
}
|
|
1298
1383
|
const nbCell = {
|
|
1384
|
+
id: cell.id,
|
|
1299
1385
|
cell_type: cell.type,
|
|
1300
1386
|
source: this.stringToSource(cell.content),
|
|
1301
1387
|
metadata: cellMetadata,
|
|
@@ -1359,6 +1445,7 @@ class FilesystemService {
|
|
|
1359
1445
|
* Uses a small journal + atomic writes to avoid partial files.
|
|
1360
1446
|
*/
|
|
1361
1447
|
async saveNotebookBundle(notebookPath, cells, kernelName, history, session, notebookMetadata) {
|
|
1448
|
+
(0, sealed_path_1.assertPathMutable)(this.normalizePath(notebookPath), { operation: 'save' });
|
|
1362
1449
|
return await this.withWriteLock(notebookPath, async () => {
|
|
1363
1450
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1364
1451
|
const historyPath = history ? this.getHistoryPath(notebookPath) : undefined;
|
|
@@ -1499,6 +1586,7 @@ class FilesystemService {
|
|
|
1499
1586
|
* Update notebook-level metadata without modifying cells
|
|
1500
1587
|
*/
|
|
1501
1588
|
async updateNotebookMetadata(notebookPath, metadataUpdates) {
|
|
1589
|
+
(0, sealed_path_1.assertPathMutable)(this.normalizePath(notebookPath), { operation: 'update metadata for' });
|
|
1502
1590
|
return await this.withWriteLock(notebookPath, async () => {
|
|
1503
1591
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1504
1592
|
try {
|
|
@@ -1603,6 +1691,7 @@ class FilesystemService {
|
|
|
1603
1691
|
* Set agent permission and ensure newly-permitted notebooks are immediately editable.
|
|
1604
1692
|
*/
|
|
1605
1693
|
async setAgentPermission(notebookPath, permitted) {
|
|
1694
|
+
(0, sealed_path_1.assertPathMutable)(this.normalizePath(notebookPath), { operation: 'change permissions for' });
|
|
1606
1695
|
return await this.withWriteLock(notebookPath, async () => {
|
|
1607
1696
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1608
1697
|
if (!fs.existsSync(normalizedPath)) {
|
|
@@ -1765,6 +1854,7 @@ class FilesystemService {
|
|
|
1765
1854
|
* Save operation history for a notebook
|
|
1766
1855
|
*/
|
|
1767
1856
|
async saveHistory(notebookPath, history) {
|
|
1857
|
+
(0, sealed_path_1.assertPathMutable)(this.normalizePath(notebookPath), { operation: 'write history for' });
|
|
1768
1858
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
1769
1859
|
// Create .nebula directory if needed
|
|
1770
1860
|
const nebulaDir = path.dirname(historyPath);
|
|
@@ -1828,6 +1918,11 @@ class FilesystemService {
|
|
|
1828
1918
|
* of corrupting).
|
|
1829
1919
|
*/
|
|
1830
1920
|
reconcileExternalTextEdits(notebookPath, history) {
|
|
1921
|
+
// Reconciliation is normally a helpful read-time repair, but it persists
|
|
1922
|
+
// history/last-save sidecars. A sealed snapshot must stay byte-for-byte
|
|
1923
|
+
// immutable even when reached through an otherwise read-only API.
|
|
1924
|
+
if ((0, sealed_path_1.classifySealedPath)(this.normalizePath(notebookPath)).sealed)
|
|
1925
|
+
return history;
|
|
1831
1926
|
const adapter = (0, registry_1.getFormatAdapter)(notebookPath);
|
|
1832
1927
|
if (!adapter || history.length === 0)
|
|
1833
1928
|
return history;
|
|
@@ -1942,6 +2037,7 @@ class FilesystemService {
|
|
|
1942
2037
|
* Save session state for a notebook
|
|
1943
2038
|
*/
|
|
1944
2039
|
async saveSession(notebookPath, session) {
|
|
2040
|
+
(0, sealed_path_1.assertPathMutable)(this.normalizePath(notebookPath), { operation: 'write session state for' });
|
|
1945
2041
|
const sessionPath = this.getSessionPath(notebookPath);
|
|
1946
2042
|
// Create .nebula directory if needed
|
|
1947
2043
|
const nebulaDir = path.dirname(sessionPath);
|
|
@@ -19,3 +19,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
__exportStar(require("./types"), exports);
|
|
21
21
|
__exportStar(require("./fs-service"), exports);
|
|
22
|
+
__exportStar(require("./sealed-path"), exports);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface SealedPathInfo {
|
|
2
|
+
sealed: true;
|
|
3
|
+
sealId: string;
|
|
4
|
+
sealedRoot: string;
|
|
5
|
+
canonicalPath: string;
|
|
6
|
+
}
|
|
7
|
+
export type PathSealClassification = SealedPathInfo | {
|
|
8
|
+
sealed: false;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* A public Nebula API attempted to mutate immutable replay evidence.
|
|
12
|
+
* ReplaySealService intentionally writes with node:fs and does not cross this
|
|
13
|
+
* public mutation boundary.
|
|
14
|
+
*/
|
|
15
|
+
export declare class SealedPathLockedError extends Error {
|
|
16
|
+
readonly name = "SealedPathLockedError";
|
|
17
|
+
readonly code = "sealed_read_only";
|
|
18
|
+
readonly statusCode = 403;
|
|
19
|
+
readonly sealId: string;
|
|
20
|
+
readonly lockedPath: string;
|
|
21
|
+
constructor(info: SealedPathInfo, operation?: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Derive sealed state from the filesystem path, never from caller-supplied UI
|
|
25
|
+
* mode. Both the lexical path and its resolved target are checked: a symlink
|
|
26
|
+
* cannot smuggle a write into a seal, while a symlink inside a seal cannot
|
|
27
|
+
* make its lexical entry mutable.
|
|
28
|
+
*/
|
|
29
|
+
export declare function classifySealedPath(filePath: string): PathSealClassification;
|
|
30
|
+
/** Find a seal that a recursive delete/rename of `directory` would destroy. */
|
|
31
|
+
export declare function findSealedDescendant(directory: string): SealedPathInfo | null;
|
|
32
|
+
export declare function assertPathMutable(filePath: string, options?: {
|
|
33
|
+
operation?: string;
|
|
34
|
+
protectDescendants?: boolean;
|
|
35
|
+
}): void;
|
|
36
|
+
export declare function sealedErrorBody(error: SealedPathLockedError): {
|
|
37
|
+
code: string;
|
|
38
|
+
detail: string;
|
|
39
|
+
seal_id: string;
|
|
40
|
+
path: string;
|
|
41
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SealedPathLockedError = void 0;
|
|
37
|
+
exports.classifySealedPath = classifySealedPath;
|
|
38
|
+
exports.findSealedDescendant = findSealedDescendant;
|
|
39
|
+
exports.assertPathMutable = assertPathMutable;
|
|
40
|
+
exports.sealedErrorBody = sealedErrorBody;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
/**
|
|
44
|
+
* A public Nebula API attempted to mutate immutable replay evidence.
|
|
45
|
+
* ReplaySealService intentionally writes with node:fs and does not cross this
|
|
46
|
+
* public mutation boundary.
|
|
47
|
+
*/
|
|
48
|
+
class SealedPathLockedError extends Error {
|
|
49
|
+
name = 'SealedPathLockedError';
|
|
50
|
+
code = 'sealed_read_only';
|
|
51
|
+
statusCode = 403;
|
|
52
|
+
sealId;
|
|
53
|
+
lockedPath;
|
|
54
|
+
constructor(info, operation = 'modify') {
|
|
55
|
+
super(`Cannot ${operation} sealed evidence ${info.sealId}: ${info.canonicalPath}`);
|
|
56
|
+
this.sealId = info.sealId;
|
|
57
|
+
this.lockedPath = info.canonicalPath;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.SealedPathLockedError = SealedPathLockedError;
|
|
61
|
+
function classifyAbsolutePath(absolutePath) {
|
|
62
|
+
const normalized = path.resolve(absolutePath);
|
|
63
|
+
const root = path.parse(normalized).root;
|
|
64
|
+
const components = normalized.slice(root.length).split(path.sep).filter(Boolean);
|
|
65
|
+
for (let index = 0; index <= components.length - 3; index += 1) {
|
|
66
|
+
if (components[index] !== '.nebula' || components[index + 1] !== 'seals')
|
|
67
|
+
continue;
|
|
68
|
+
const sealId = components[index + 2];
|
|
69
|
+
if (!sealId)
|
|
70
|
+
continue;
|
|
71
|
+
return {
|
|
72
|
+
sealed: true,
|
|
73
|
+
sealId,
|
|
74
|
+
sealedRoot: path.join(root, ...components.slice(0, index + 3)),
|
|
75
|
+
canonicalPath: normalized,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Replay metadata outside the immutable snapshot directory is equally part
|
|
79
|
+
// of the scientific record. Public FS/notebook routes must not rewrite the
|
|
80
|
+
// hash chain, its content-addressed blobs, or durable replay job registry.
|
|
81
|
+
for (let index = 0; index < components.length; index += 1) {
|
|
82
|
+
if (components[index] !== '.nebula')
|
|
83
|
+
continue;
|
|
84
|
+
const remainder = components.slice(index + 1);
|
|
85
|
+
const isLedger = remainder.length === 1
|
|
86
|
+
&& remainder[0].endsWith('.provenance.jsonl');
|
|
87
|
+
const isBlob = remainder.length >= 3
|
|
88
|
+
&& remainder[0] === 'provenance'
|
|
89
|
+
&& remainder[1] === 'blobs';
|
|
90
|
+
const isReplayRegistry = remainder.length >= 2
|
|
91
|
+
&& remainder[0] === 'replay-seal-jobs';
|
|
92
|
+
if (!isLedger && !isBlob && !isReplayRegistry)
|
|
93
|
+
continue;
|
|
94
|
+
const evidenceId = isLedger
|
|
95
|
+
? remainder[0]
|
|
96
|
+
: isReplayRegistry
|
|
97
|
+
? 'replay-seal-jobs'
|
|
98
|
+
: 'provenance-blobs';
|
|
99
|
+
return {
|
|
100
|
+
sealed: true,
|
|
101
|
+
sealId: evidenceId,
|
|
102
|
+
sealedRoot: path.join(root, ...components.slice(0, index + 2)),
|
|
103
|
+
canonicalPath: normalized,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { sealed: false };
|
|
107
|
+
}
|
|
108
|
+
/** Resolve symlinks even when the final destination does not exist yet. */
|
|
109
|
+
function realpathThroughNearestExistingAncestor(absolutePath) {
|
|
110
|
+
let cursor = path.resolve(absolutePath);
|
|
111
|
+
const suffix = [];
|
|
112
|
+
while (!fs.existsSync(cursor)) {
|
|
113
|
+
const parent = path.dirname(cursor);
|
|
114
|
+
if (parent === cursor)
|
|
115
|
+
return path.resolve(absolutePath);
|
|
116
|
+
suffix.unshift(path.basename(cursor));
|
|
117
|
+
cursor = parent;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return path.resolve(fs.realpathSync.native(cursor), ...suffix);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return path.resolve(absolutePath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Derive sealed state from the filesystem path, never from caller-supplied UI
|
|
128
|
+
* mode. Both the lexical path and its resolved target are checked: a symlink
|
|
129
|
+
* cannot smuggle a write into a seal, while a symlink inside a seal cannot
|
|
130
|
+
* make its lexical entry mutable.
|
|
131
|
+
*/
|
|
132
|
+
function classifySealedPath(filePath) {
|
|
133
|
+
const lexical = classifyAbsolutePath(path.resolve(filePath));
|
|
134
|
+
if (lexical.sealed)
|
|
135
|
+
return lexical;
|
|
136
|
+
return classifyAbsolutePath(realpathThroughNearestExistingAncestor(filePath));
|
|
137
|
+
}
|
|
138
|
+
function sealRootInDirectory(directory) {
|
|
139
|
+
const candidateRoots = path.basename(directory) === '.nebula'
|
|
140
|
+
? [path.join(directory, 'seals')]
|
|
141
|
+
: [path.join(directory, '.nebula', 'seals')];
|
|
142
|
+
const nebulaDirectory = path.basename(directory) === '.nebula'
|
|
143
|
+
? directory
|
|
144
|
+
: path.join(directory, '.nebula');
|
|
145
|
+
try {
|
|
146
|
+
const metadataEntries = fs.readdirSync(nebulaDirectory, { withFileTypes: true });
|
|
147
|
+
const protectedEntry = metadataEntries.find((item) => item.name.endsWith('.provenance.jsonl')
|
|
148
|
+
|| item.name === 'provenance'
|
|
149
|
+
|| item.name === 'replay-seal-jobs');
|
|
150
|
+
if (protectedEntry) {
|
|
151
|
+
const info = classifySealedPath(path.join(nebulaDirectory, protectedEntry.name));
|
|
152
|
+
if (info.sealed)
|
|
153
|
+
return info;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// No server-owned provenance metadata below this directory.
|
|
158
|
+
}
|
|
159
|
+
for (const sealsDir of candidateRoots) {
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = fs.readdirSync(sealsDir, { withFileTypes: true });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const entry = entries.find(item => item.name !== '.' && item.name !== '..');
|
|
168
|
+
if (!entry)
|
|
169
|
+
continue;
|
|
170
|
+
const info = classifySealedPath(path.join(sealsDir, entry.name));
|
|
171
|
+
if (info.sealed)
|
|
172
|
+
return info;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/** Find a seal that a recursive delete/rename of `directory` would destroy. */
|
|
177
|
+
function findSealedDescendant(directory) {
|
|
178
|
+
const normalized = path.resolve(directory);
|
|
179
|
+
let stat;
|
|
180
|
+
try {
|
|
181
|
+
stat = fs.lstatSync(normalized);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
187
|
+
return null;
|
|
188
|
+
const stack = [normalized];
|
|
189
|
+
while (stack.length > 0) {
|
|
190
|
+
const current = stack.pop();
|
|
191
|
+
const direct = sealRootInDirectory(current);
|
|
192
|
+
if (direct)
|
|
193
|
+
return direct;
|
|
194
|
+
let entries;
|
|
195
|
+
try {
|
|
196
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
203
|
+
continue;
|
|
204
|
+
if (entry.name === '.nebula')
|
|
205
|
+
continue; // Checked directly above.
|
|
206
|
+
stack.push(path.join(current, entry.name));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function assertPathMutable(filePath, options = {}) {
|
|
212
|
+
const info = classifySealedPath(filePath);
|
|
213
|
+
if (info.sealed)
|
|
214
|
+
throw new SealedPathLockedError(info, options.operation);
|
|
215
|
+
if (options.protectDescendants) {
|
|
216
|
+
const descendant = findSealedDescendant(filePath);
|
|
217
|
+
if (descendant)
|
|
218
|
+
throw new SealedPathLockedError(descendant, options.operation);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function sealedErrorBody(error) {
|
|
222
|
+
return {
|
|
223
|
+
code: error.code,
|
|
224
|
+
detail: error.message,
|
|
225
|
+
seal_id: error.sealId,
|
|
226
|
+
path: error.lockedPath,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -91,8 +91,8 @@ export interface SaveNotebookResult {
|
|
|
91
91
|
/**
|
|
92
92
|
* Sentinel the client puts in place of a code cell's outputs when they are
|
|
93
93
|
* unchanged since its last successful save. The server re-uses the outputs
|
|
94
|
-
* already in the file (matched by
|
|
95
|
-
* megabytes of unchanged base64 images over slow uplinks.
|
|
94
|
+
* already in the file (matched by canonical Jupyter cell ID), so autosaves
|
|
95
|
+
* don't re-upload megabytes of unchanged base64 images over slow uplinks.
|
|
96
96
|
*/
|
|
97
97
|
export declare const OUTPUTS_UNCHANGED_SENTINEL = "__nebula-outputs-unchanged-v1__";
|
|
98
98
|
export interface JupyterCellMetadata {
|
|
@@ -102,6 +102,8 @@ export interface JupyterCellMetadata {
|
|
|
102
102
|
[key: string]: unknown;
|
|
103
103
|
}
|
|
104
104
|
export interface JupyterCell {
|
|
105
|
+
/** Stable nbformat 4.5 cell identifier. Older notebooks may omit it. */
|
|
106
|
+
id?: string;
|
|
105
107
|
cell_type: 'code' | 'markdown' | 'raw';
|
|
106
108
|
source: string | string[];
|
|
107
109
|
metadata: JupyterCellMetadata;
|
|
@@ -7,7 +7,7 @@ exports.OUTPUTS_UNCHANGED_SENTINEL = void 0;
|
|
|
7
7
|
/**
|
|
8
8
|
* Sentinel the client puts in place of a code cell's outputs when they are
|
|
9
9
|
* unchanged since its last successful save. The server re-uses the outputs
|
|
10
|
-
* already in the file (matched by
|
|
11
|
-
* megabytes of unchanged base64 images over slow uplinks.
|
|
10
|
+
* already in the file (matched by canonical Jupyter cell ID), so autosaves
|
|
11
|
+
* don't re-upload megabytes of unchanged base64 images over slow uplinks.
|
|
12
12
|
*/
|
|
13
13
|
exports.OUTPUTS_UNCHANGED_SENTINEL = '__nebula-outputs-unchanged-v1__';
|
|
@@ -91,6 +91,8 @@ const notebook_websocket_1 = require("./notebook/notebook-websocket");
|
|
|
91
91
|
// Idle auto-release (client mode, opt-in via NEBULA_IDLE_EXIT_MINUTES)
|
|
92
92
|
const idle_exit_1 = require("./idle-exit");
|
|
93
93
|
const pty_manager_1 = require("./terminal/pty-manager");
|
|
94
|
+
const bind_host_1 = require("./server/bind-host");
|
|
95
|
+
const cors_origin_1 = require("./server/cors-origin");
|
|
94
96
|
const PORT = process.env.PORT || process.env.NODE_SERVER_PORT || 3000;
|
|
95
97
|
const DEV_MODE = process.env.DEV_MODE === 'true' || process.argv.includes('--dev');
|
|
96
98
|
const BODY_LIMIT = process.env.NEBULA_BODY_LIMIT ||
|
|
@@ -138,6 +140,7 @@ const getArgValue = (name) => {
|
|
|
138
140
|
}
|
|
139
141
|
return null;
|
|
140
142
|
};
|
|
143
|
+
let BIND_HOST = (0, bind_host_1.resolveBindHost)(process.argv, process.env);
|
|
141
144
|
const WORKDIR = getArgValue('--workdir') || process.env.NEBULA_WORKDIR || process.env.npm_config_workdir;
|
|
142
145
|
const PRESERVE_KERNELS = resolveBooleanFlag(['--preserve-kernels', '--preserve-kernel'], ['--no-preserve-kernels', '--no-preserve-kernel'], ['NEBULA_PRESERVE_KERNELS'], ['npm_config_preserve_kernels'], DEV_MODE);
|
|
143
146
|
const REATTACH_KERNELS = resolveBooleanFlag(['--reattach-kernels', '--reattach-kernel'], ['--no-reattach-kernels', '--no-reattach-kernel'], ['NEBULA_REATTACH_KERNELS'], ['npm_config_reattach_kernels'], DEV_MODE);
|
|
@@ -297,20 +300,19 @@ async function createApp() {
|
|
|
297
300
|
origin: (origin, cb) => {
|
|
298
301
|
if (!origin)
|
|
299
302
|
return cb(null, true); // non-browser client or same-origin
|
|
300
|
-
if (
|
|
303
|
+
if ((0, cors_origin_1.isTrustedBrowserOrigin)(origin, extraOrigins))
|
|
301
304
|
return cb(null, true);
|
|
302
|
-
try {
|
|
303
|
-
const { hostname } = new URL(origin);
|
|
304
|
-
if (hostname === 'localhost' || hostname === '::1' || hostname === '127.0.0.1' || hostname.startsWith('127.')) {
|
|
305
|
-
return cb(null, true);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
catch { /* malformed Origin — treat as disallowed */ }
|
|
309
305
|
cb(null, false); // no CORS headers -> browser blocks the cross-origin page
|
|
310
306
|
},
|
|
311
307
|
credentials: true,
|
|
312
308
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
313
|
-
allowedHeaders: [
|
|
309
|
+
allowedHeaders: [
|
|
310
|
+
'Content-Type',
|
|
311
|
+
'Authorization',
|
|
312
|
+
'X-API-Key',
|
|
313
|
+
'X-API-Provider',
|
|
314
|
+
'Idempotency-Key',
|
|
315
|
+
],
|
|
314
316
|
});
|
|
315
317
|
// Register multipart support (replaces multer)
|
|
316
318
|
await fastify.register(multipart_1.default, {
|
|
@@ -480,15 +482,21 @@ async function main() {
|
|
|
480
482
|
process.exit(1);
|
|
481
483
|
}
|
|
482
484
|
}
|
|
483
|
-
const
|
|
485
|
+
const explicitNoAuth = process.argv.includes('--noauth') ||
|
|
484
486
|
process.argv.includes('--no-auth') ||
|
|
485
487
|
process.env.NO_AUTH === 'true' ||
|
|
486
488
|
process.env.NEBULA_NO_AUTH === 'true' ||
|
|
487
489
|
process.env.npm_config_noauth === 'true' ||
|
|
488
490
|
process.env.npm_config_noauth === '1' ||
|
|
489
491
|
process.env.npm_config_no_auth === 'true' ||
|
|
490
|
-
process.env.npm_config_no_auth === '1'
|
|
491
|
-
|
|
492
|
+
process.env.npm_config_no_auth === '1';
|
|
493
|
+
if (explicitNoAuth) {
|
|
494
|
+
// Loopback default when no host was chosen; refuses an explicit
|
|
495
|
+
// non-loopback host. Client mode is exempt: it binds the network on
|
|
496
|
+
// purpose (login node must reach it) behind the cluster secret.
|
|
497
|
+
BIND_HOST = (0, bind_host_1.resolveNoAuthBindHost)(process.argv, process.env);
|
|
498
|
+
}
|
|
499
|
+
const authDisabled = explicitNoAuth || CLIENT_MODE;
|
|
492
500
|
if (authDisabled) {
|
|
493
501
|
auth_2.authService.disableAuth();
|
|
494
502
|
console.log(`[Auth] Disabled (${CLIENT_MODE ? 'client mode' : '--noauth'})`);
|
|
@@ -531,7 +539,7 @@ async function main() {
|
|
|
531
539
|
// Setup static file serving
|
|
532
540
|
await setupStaticServing(fastify);
|
|
533
541
|
// Start HTTP server on main port
|
|
534
|
-
await fastify.listen({ port: Number(PORT), host:
|
|
542
|
+
await fastify.listen({ port: Number(PORT), host: BIND_HOST });
|
|
535
543
|
// Get the raw Node.js server for WebSocket handling
|
|
536
544
|
const server = fastify.server;
|
|
537
545
|
// Setup WebSocket routing (kernel)
|
|
@@ -599,6 +607,7 @@ async function main() {
|
|
|
599
607
|
' Nebula Notebook is running',
|
|
600
608
|
'',
|
|
601
609
|
` Open: http://localhost:${PORT}`,
|
|
610
|
+
` Bind: ${BIND_HOST}:${PORT}`,
|
|
602
611
|
` Network: http://${host}:${PORT}`,
|
|
603
612
|
` Files: ${rootDir}`,
|
|
604
613
|
` Compute: ${schedulerDetected
|