capdag 1.199.512 → 1.205.540
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/cap-fab-renderer.js +2 -2
- package/capdag.js +1296 -100
- package/capdag.test.js +740 -413
- package/machine.pegjs +2 -2
- package/package.json +1 -1
package/capdag.js
CHANGED
|
@@ -1180,44 +1180,42 @@ const MediaDefErrorCodes = {
|
|
|
1180
1180
|
* - `record` marker: presence = has internal fields, absence = opaque (default)
|
|
1181
1181
|
*
|
|
1182
1182
|
* Examples:
|
|
1183
|
-
* - `media:pdf` → scalar, opaque (no markers)
|
|
1184
|
-
* - `media:
|
|
1185
|
-
* - `media:json;
|
|
1186
|
-
* - `media:json;list;record
|
|
1183
|
+
* - `media:ext=pdf` → scalar, opaque (no markers)
|
|
1184
|
+
* - `media:enc=utf-8;list` → list, opaque (has list marker)
|
|
1185
|
+
* - `media:fmt=json;record` → scalar, record (has record marker)
|
|
1186
|
+
* - `media:fmt=json;list;record` → list of records (has both markers)
|
|
1187
1187
|
*/
|
|
1188
1188
|
|
|
1189
1189
|
// Primitive types - URNs must match base.toml definitions
|
|
1190
1190
|
// Media URN for void (no input/output) - no coercion tags
|
|
1191
1191
|
const MEDIA_VOID = 'media:void';
|
|
1192
|
-
// Media URN for string type -
|
|
1193
|
-
const MEDIA_STRING = 'media:
|
|
1194
|
-
// Media URN for integer type
|
|
1195
|
-
const MEDIA_INTEGER = 'media:integer;
|
|
1196
|
-
// Media URN for number type
|
|
1197
|
-
const MEDIA_NUMBER = 'media:
|
|
1192
|
+
// Media URN for string type — bare UTF-8 text (enc=utf-8), scalar by default (no list marker)
|
|
1193
|
+
const MEDIA_STRING = 'media:enc=utf-8';
|
|
1194
|
+
// Media URN for integer type — numeric (math ops valid), scalar by default
|
|
1195
|
+
const MEDIA_INTEGER = 'media:integer;numeric';
|
|
1196
|
+
// Media URN for number type — numeric, scalar by default
|
|
1197
|
+
const MEDIA_NUMBER = 'media:numeric';
|
|
1198
1198
|
// Media URN for boolean type - uses "bool" not "boolean" per base.toml
|
|
1199
|
-
const MEDIA_BOOLEAN = 'media:bool;
|
|
1200
|
-
// Media URN for a generic record/object type - has internal key-value structure
|
|
1201
|
-
// Use MEDIA_JSON for
|
|
1199
|
+
const MEDIA_BOOLEAN = 'media:bool;enc=utf-8';
|
|
1200
|
+
// Media URN for a generic record/object type - has internal key-value structure,
|
|
1201
|
+
// no content-format claim. Use MEDIA_JSON for JSON-serialized objects.
|
|
1202
1202
|
const MEDIA_OBJECT = 'media:record';
|
|
1203
|
-
// Media URN for
|
|
1203
|
+
// Media URN for the top type - the most general media type (no constraints)
|
|
1204
1204
|
const MEDIA_IDENTITY = 'media:';
|
|
1205
1205
|
|
|
1206
1206
|
// List types - URNs must match base.toml definitions
|
|
1207
1207
|
// Media URN for generic list type
|
|
1208
1208
|
const MEDIA_LIST = 'media:list';
|
|
1209
|
-
// Media URN for
|
|
1210
|
-
const
|
|
1211
|
-
// Media URN for
|
|
1212
|
-
const
|
|
1213
|
-
// Media URN for
|
|
1214
|
-
const
|
|
1215
|
-
// Media URN for number list type - textable, numeric with list marker
|
|
1216
|
-
const MEDIA_NUMBER_LIST = 'media:list;numeric;textable';
|
|
1209
|
+
// Media URN for string list type — bare UTF-8 text with list marker
|
|
1210
|
+
const MEDIA_STRING_LIST = 'media:enc=utf-8;list';
|
|
1211
|
+
// Media URN for integer list type — numeric with list marker
|
|
1212
|
+
const MEDIA_INTEGER_LIST = 'media:integer;list;numeric';
|
|
1213
|
+
// Media URN for number list type — numeric with list marker
|
|
1214
|
+
const MEDIA_NUMBER_LIST = 'media:list;numeric';
|
|
1217
1215
|
// Media URN for boolean list type - uses "bool" with list marker
|
|
1218
|
-
const MEDIA_BOOLEAN_LIST = 'media:bool;list
|
|
1219
|
-
// Media URN for object list type - list of records (
|
|
1220
|
-
// Use a specific format like JSON array for
|
|
1216
|
+
const MEDIA_BOOLEAN_LIST = 'media:bool;enc=utf-8;list';
|
|
1217
|
+
// Media URN for object list type - list of records (no content-format claim).
|
|
1218
|
+
// Use a specific format like JSON array for serialized object lists.
|
|
1221
1219
|
const MEDIA_OBJECT_LIST = 'media:list;record';
|
|
1222
1220
|
|
|
1223
1221
|
// Semantic media types for specialized content
|
|
@@ -1274,104 +1272,104 @@ const MEDIA_EPUB = 'media:ext=epub';
|
|
|
1274
1272
|
|
|
1275
1273
|
// Text format types (PRIMARY naming - type IS the format)
|
|
1276
1274
|
// Media URN for Markdown text
|
|
1277
|
-
const MEDIA_MD = 'media:ext=md
|
|
1275
|
+
const MEDIA_MD = 'media:enc=utf-8;ext=md';
|
|
1278
1276
|
// Media URN for plain text
|
|
1279
|
-
const MEDIA_TXT = 'media:ext=txt
|
|
1277
|
+
const MEDIA_TXT = 'media:enc=utf-8;ext=txt';
|
|
1280
1278
|
// Media URN for reStructuredText
|
|
1281
|
-
const MEDIA_RST = 'media:ext=rst
|
|
1279
|
+
const MEDIA_RST = 'media:enc=utf-8;ext=rst';
|
|
1282
1280
|
// Media URN for log files
|
|
1283
|
-
const MEDIA_LOG = 'media:ext=log
|
|
1281
|
+
const MEDIA_LOG = 'media:enc=utf-8;ext=log';
|
|
1284
1282
|
// Media URN for HTML documents
|
|
1285
|
-
const MEDIA_HTML = 'media:ext=html
|
|
1283
|
+
const MEDIA_HTML = 'media:enc=utf-8;ext=html';
|
|
1286
1284
|
// Media URN for XML documents
|
|
1287
|
-
const MEDIA_XML = 'media:ext=xml
|
|
1285
|
+
const MEDIA_XML = 'media:enc=utf-8;ext=xml';
|
|
1288
1286
|
// Media URN for JSON data - has record marker (structured key-value)
|
|
1289
|
-
const MEDIA_JSON = 'media:json;record
|
|
1287
|
+
const MEDIA_JSON = 'media:fmt=json;record';
|
|
1290
1288
|
// Media URN for JSON with schema constraint (input for structured queries)
|
|
1291
|
-
const MEDIA_JSON_SCHEMA = 'media:json;json-schema;record
|
|
1289
|
+
const MEDIA_JSON_SCHEMA = 'media:fmt=json;json-schema;record';
|
|
1292
1290
|
// Media URN for YAML data - has record marker (structured key-value)
|
|
1293
|
-
const MEDIA_YAML = 'media:record
|
|
1291
|
+
const MEDIA_YAML = 'media:fmt=yaml;record';
|
|
1294
1292
|
|
|
1295
1293
|
// Format-specific variants for JSON, YAML, CSV
|
|
1296
|
-
const MEDIA_JSON_VALUE = 'media:json
|
|
1297
|
-
const MEDIA_JSON_RECORD = 'media:json;record
|
|
1298
|
-
const MEDIA_JSON_LIST = 'media:json;list
|
|
1299
|
-
const MEDIA_JSON_LIST_RECORD = 'media:json;list;record
|
|
1300
|
-
const MEDIA_YAML_VALUE = 'media:
|
|
1301
|
-
const MEDIA_YAML_RECORD = 'media:record
|
|
1302
|
-
const MEDIA_YAML_LIST = 'media:list
|
|
1303
|
-
const MEDIA_YAML_LIST_RECORD = 'media:list;record
|
|
1304
|
-
const MEDIA_CSV = 'media:
|
|
1305
|
-
const MEDIA_CSV_LIST = 'media:
|
|
1294
|
+
const MEDIA_JSON_VALUE = 'media:fmt=json';
|
|
1295
|
+
const MEDIA_JSON_RECORD = 'media:fmt=json;record';
|
|
1296
|
+
const MEDIA_JSON_LIST = 'media:fmt=json;list';
|
|
1297
|
+
const MEDIA_JSON_LIST_RECORD = 'media:fmt=json;list;record';
|
|
1298
|
+
const MEDIA_YAML_VALUE = 'media:fmt=yaml';
|
|
1299
|
+
const MEDIA_YAML_RECORD = 'media:fmt=yaml;record';
|
|
1300
|
+
const MEDIA_YAML_LIST = 'media:fmt=yaml;list';
|
|
1301
|
+
const MEDIA_YAML_LIST_RECORD = 'media:fmt=yaml;list;record';
|
|
1302
|
+
const MEDIA_CSV = 'media:fmt=csv;list;record';
|
|
1303
|
+
const MEDIA_CSV_LIST = 'media:fmt=csv;list;record';
|
|
1306
1304
|
|
|
1307
1305
|
// File path type — for arguments that represent filesystem paths.
|
|
1308
1306
|
// There is a single media URN; cardinality (single file vs many files)
|
|
1309
1307
|
// is carried on the wire via is_sequence, not via URN tags.
|
|
1310
|
-
const MEDIA_FILE_PATH = 'media:file-path
|
|
1308
|
+
const MEDIA_FILE_PATH = 'media:enc=utf-8;file-path';
|
|
1311
1309
|
|
|
1312
1310
|
// Semantic text input types - distinguished by their purpose/context
|
|
1313
1311
|
// Media URN for model spec (provider:model format, HuggingFace name, etc.) - scalar by default
|
|
1314
|
-
const MEDIA_MODEL_SPEC = 'media:model-spec
|
|
1312
|
+
const MEDIA_MODEL_SPEC = 'media:enc=utf-8;model-spec';
|
|
1315
1313
|
// Media URN for MLX model path - scalar by default
|
|
1316
|
-
const MEDIA_MLX_MODEL_PATH = 'media:mlx-model-path
|
|
1314
|
+
const MEDIA_MLX_MODEL_PATH = 'media:enc=utf-8;mlx-model-path';
|
|
1317
1315
|
// Media URN for model repository (input for list-models) - has record marker
|
|
1318
|
-
const MEDIA_MODEL_REPO = 'media:model-repo;record
|
|
1316
|
+
const MEDIA_MODEL_REPO = 'media:enc=utf-8;model-repo;record';
|
|
1319
1317
|
|
|
1320
1318
|
// CAPDAG output types - record marker for structured JSON objects, list marker for arrays
|
|
1321
1319
|
// Media URN for model dimension output - scalar by default (no list marker)
|
|
1322
|
-
const MEDIA_MODEL_DIM = 'media:integer;model-dim;numeric
|
|
1320
|
+
const MEDIA_MODEL_DIM = 'media:integer;model-dim;numeric';
|
|
1323
1321
|
// Media URN for model download output - has record marker
|
|
1324
|
-
const MEDIA_DOWNLOAD_OUTPUT = 'media:download-result;record
|
|
1322
|
+
const MEDIA_DOWNLOAD_OUTPUT = 'media:download-result;enc=utf-8;record';
|
|
1325
1323
|
// Media URN for model list output - has record marker
|
|
1326
|
-
const MEDIA_LIST_OUTPUT = 'media:model-list;record
|
|
1324
|
+
const MEDIA_LIST_OUTPUT = 'media:enc=utf-8;model-list;record';
|
|
1327
1325
|
// Media URN for model status output - has record marker
|
|
1328
|
-
const MEDIA_STATUS_OUTPUT = 'media:model-status;record
|
|
1326
|
+
const MEDIA_STATUS_OUTPUT = 'media:enc=utf-8;model-status;record';
|
|
1329
1327
|
// Media URN for model contents output - has record marker
|
|
1330
|
-
const MEDIA_CONTENTS_OUTPUT = 'media:model-contents;record
|
|
1328
|
+
const MEDIA_CONTENTS_OUTPUT = 'media:enc=utf-8;model-contents;record';
|
|
1331
1329
|
// Media URN for model availability output - has record marker
|
|
1332
|
-
const MEDIA_AVAILABILITY_OUTPUT = 'media:model-availability;record
|
|
1330
|
+
const MEDIA_AVAILABILITY_OUTPUT = 'media:enc=utf-8;model-availability;record';
|
|
1333
1331
|
// Media URN for model path output - has record marker
|
|
1334
|
-
const MEDIA_PATH_OUTPUT = 'media:model-path;record
|
|
1332
|
+
const MEDIA_PATH_OUTPUT = 'media:enc=utf-8;model-path;record';
|
|
1335
1333
|
// Media URN for embedding vector output - has record marker
|
|
1336
|
-
const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;record
|
|
1337
|
-
// Media URN for vision inference output — a concrete
|
|
1334
|
+
const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;enc=utf-8;record';
|
|
1335
|
+
// Media URN for vision inference output — a concrete plain-text terminal.
|
|
1338
1336
|
// Carries `image-description` (the vision-specific marker), `plain-text` (the
|
|
1339
1337
|
// finalised-text marker that opts into cap:save-as-txt's persistence path),
|
|
1340
1338
|
// and `file-type=txt` (binds the URN to the `.txt` extension).
|
|
1341
|
-
const MEDIA_IMAGE_DESCRIPTION = 'media:ext=txt;image-description;plain-text
|
|
1339
|
+
const MEDIA_IMAGE_DESCRIPTION = 'media:enc=utf-8;ext=txt;image-description;plain-text';
|
|
1342
1340
|
// Media URN for finalised plain text — the canonical input/output of cap:save-as-txt.
|
|
1343
1341
|
// Producers of user-facing prose (LLM text-generation, OCR's extracted text,
|
|
1344
1342
|
// summarisation) declare this URN as their `out` so the planner restricts the .txt
|
|
1345
1343
|
// persistence path to those caps. See fabric/media/plain-text.toml.
|
|
1346
|
-
const MEDIA_PLAIN_TEXT = 'media:ext=txt;plain-text
|
|
1344
|
+
const MEDIA_PLAIN_TEXT = 'media:enc=utf-8;ext=txt;plain-text';
|
|
1347
1345
|
// Media URN for transcription output - has record marker
|
|
1348
|
-
const MEDIA_TRANSCRIPTION_OUTPUT = 'media:record;
|
|
1349
|
-
// Media URN for decision output - JSON record
|
|
1350
|
-
const MEDIA_DECISION = 'media:decision;json;record
|
|
1351
|
-
// Media URN for
|
|
1352
|
-
const MEDIA_TEXTABLE_PAGE = 'media:ext=txt;page;plain-text
|
|
1353
|
-
// Media URN for Hugging Face API token (secret
|
|
1354
|
-
const MEDIA_HF_TOKEN = 'media:hf-token;secret
|
|
1346
|
+
const MEDIA_TRANSCRIPTION_OUTPUT = 'media:enc=utf-8;record;transcription';
|
|
1347
|
+
// Media URN for decision output - JSON record
|
|
1348
|
+
const MEDIA_DECISION = 'media:decision;fmt=json;record';
|
|
1349
|
+
// Media URN for a single page of finalised plain text extracted from a document
|
|
1350
|
+
const MEDIA_TEXTABLE_PAGE = 'media:enc=utf-8;ext=txt;page;plain-text';
|
|
1351
|
+
// Media URN for Hugging Face API token (secret)
|
|
1352
|
+
const MEDIA_HF_TOKEN = 'media:enc=utf-8;hf-token;secret';
|
|
1355
1353
|
// Media URN for a list of model architectures — JSON record
|
|
1356
|
-
const MEDIA_MODEL_ARCH_LIST = 'media:model-arch-list;
|
|
1354
|
+
const MEDIA_MODEL_ARCH_LIST = 'media:fmt=json;model-arch-list;record';
|
|
1357
1355
|
// Media URN for a model search request — JSON record
|
|
1358
|
-
const MEDIA_MODEL_SEARCH_REQUEST = 'media:model-search-request;
|
|
1356
|
+
const MEDIA_MODEL_SEARCH_REQUEST = 'media:fmt=json;model-search-request;record';
|
|
1359
1357
|
// Media URN for a model search response — JSON record
|
|
1360
|
-
const MEDIA_MODEL_SEARCH_RESPONSE = 'media:model-search-response;
|
|
1358
|
+
const MEDIA_MODEL_SEARCH_RESPONSE = 'media:fmt=json;model-search-response;record';
|
|
1361
1359
|
// Media URN for model filter resolution — JSON record
|
|
1362
|
-
const MEDIA_MODEL_FILTER_RESOLUTION = 'media:model-filter-resolution;
|
|
1360
|
+
const MEDIA_MODEL_FILTER_RESOLUTION = 'media:fmt=json;model-filter-resolution;record';
|
|
1363
1361
|
// Collection types
|
|
1364
1362
|
const MEDIA_COLLECTION = 'media:collection;record';
|
|
1365
1363
|
const MEDIA_COLLECTION_LIST = 'media:collection;list;record';
|
|
1366
1364
|
// Media URN for adapter selection output - JSON record
|
|
1367
|
-
const MEDIA_ADAPTER_SELECTION = 'media:adapter-selection;json;record';
|
|
1365
|
+
const MEDIA_ADAPTER_SELECTION = 'media:adapter-selection;fmt=json;record';
|
|
1368
1366
|
// Fabric registry lookup wire types (consumed/produced by cap:lookup-cap;fabric
|
|
1369
1367
|
// and cap:lookup-media-def;fabric, both implemented by fetchcartridge).
|
|
1370
|
-
const MEDIA_CAP_URN = 'media:cap-urn;
|
|
1371
|
-
const MEDIA_MEDIA_URN = 'media:media-urn
|
|
1372
|
-
const MEDIA_FABRIC_DEFVER = 'media:defver;
|
|
1373
|
-
const MEDIA_CAP_DEFINITION = 'media:cap-definition;json;record
|
|
1374
|
-
const MEDIA_MEDIA_DEFINITION = 'media:media-definition;
|
|
1368
|
+
const MEDIA_CAP_URN = 'media:cap-urn;enc=utf-8';
|
|
1369
|
+
const MEDIA_MEDIA_URN = 'media:enc=utf-8;media-urn';
|
|
1370
|
+
const MEDIA_FABRIC_DEFVER = 'media:defver;enc=utf-8';
|
|
1371
|
+
const MEDIA_CAP_DEFINITION = 'media:cap-definition;fmt=json;record';
|
|
1372
|
+
const MEDIA_MEDIA_DEFINITION = 'media:fmt=json;media-definition;record';
|
|
1375
1373
|
|
|
1376
1374
|
// =============================================================================
|
|
1377
1375
|
// STANDARD CAP URN CONSTANTS
|
|
@@ -1383,13 +1381,13 @@ const CAP_IDENTITY = 'cap:effect=none';
|
|
|
1383
1381
|
|
|
1384
1382
|
// Adapter-selection capability. Default implementation returns empty END (no match).
|
|
1385
1383
|
// Cartridges that inspect file content override this with a handler that returns {"media_urns": [...]}.
|
|
1386
|
-
const CAP_ADAPTER_SELECTION = 'cap:in="media:";out="media:adapter-selection;json;record"';
|
|
1384
|
+
const CAP_ADAPTER_SELECTION = 'cap:in="media:";out="media:adapter-selection;fmt=json;record"';
|
|
1387
1385
|
|
|
1388
1386
|
// Fabric registry lookup caps. Implemented by fetchcartridge.
|
|
1389
1387
|
// CAP_LOOKUP_CAP_FABRIC resolves a canonical cap URN to its full flattened
|
|
1390
1388
|
// cap definition; CAP_LOOKUP_MEDIA_DEF_FABRIC does the same for media defs.
|
|
1391
|
-
const CAP_LOOKUP_CAP_FABRIC = 'cap:in="media:cap-urn;
|
|
1392
|
-
const CAP_LOOKUP_MEDIA_DEF_FABRIC = 'cap:in="media:media-urn
|
|
1389
|
+
const CAP_LOOKUP_CAP_FABRIC = 'cap:in="media:cap-urn;enc=utf-8";fabric;lookup-cap;out="media:cap-definition;fmt=json;record"';
|
|
1390
|
+
const CAP_LOOKUP_MEDIA_DEF_FABRIC = 'cap:in="media:enc=utf-8;media-urn";fabric;lookup-media-def;out="media:fmt=json;media-definition;record"';
|
|
1393
1391
|
|
|
1394
1392
|
// =============================================================================
|
|
1395
1393
|
// MEDIA URN CLASS
|
|
@@ -1446,7 +1444,7 @@ class MediaUrn {
|
|
|
1446
1444
|
|
|
1447
1445
|
/**
|
|
1448
1446
|
* Parse a media URN string. Validates the prefix is 'media'.
|
|
1449
|
-
* @param {string} str - The media URN string (e.g., "media:pdf")
|
|
1447
|
+
* @param {string} str - The media URN string (e.g., "media:ext=pdf")
|
|
1450
1448
|
* @returns {MediaUrn}
|
|
1451
1449
|
* @throws {MediaUrnError} If prefix is not 'media'
|
|
1452
1450
|
*/
|
|
@@ -1455,9 +1453,6 @@ class MediaUrn {
|
|
|
1455
1453
|
return new MediaUrn(urn);
|
|
1456
1454
|
}
|
|
1457
1455
|
|
|
1458
|
-
/** @returns {boolean} True if the "textable" marker tag is NOT present (binary = not textable) */
|
|
1459
|
-
isBinary() { return this._urn.getTag('textable') === undefined; }
|
|
1460
|
-
|
|
1461
1456
|
// =========================================================================
|
|
1462
1457
|
// CARDINALITY (list marker)
|
|
1463
1458
|
// =========================================================================
|
|
@@ -1512,11 +1507,8 @@ class MediaUrn {
|
|
|
1512
1507
|
return value === '*';
|
|
1513
1508
|
}
|
|
1514
1509
|
|
|
1515
|
-
/** @returns {boolean} True if the
|
|
1516
|
-
isJson() { return this._urn.getTag('
|
|
1517
|
-
|
|
1518
|
-
/** @returns {boolean} True if the "textable" marker tag is present */
|
|
1519
|
-
isText() { return this._urn.getTag('textable') !== undefined; }
|
|
1510
|
+
/** @returns {boolean} True if the content format is JSON (`fmt=json`) */
|
|
1511
|
+
isJson() { return this._urn.getTag('fmt') === 'json'; }
|
|
1520
1512
|
|
|
1521
1513
|
/** @returns {boolean} True if the "void" marker tag is present —
|
|
1522
1514
|
* the **unit type** in the type-theoretic reading. media:void is
|
|
@@ -1546,19 +1538,19 @@ class MediaUrn {
|
|
|
1546
1538
|
isBool() { return this._urn.getTag('bool') !== undefined; }
|
|
1547
1539
|
|
|
1548
1540
|
/**
|
|
1549
|
-
* Returns true if this
|
|
1541
|
+
* Returns true if this value's content format is YAML (`fmt=yaml`).
|
|
1550
1542
|
* @returns {boolean}
|
|
1551
1543
|
*/
|
|
1552
1544
|
isYaml() {
|
|
1553
|
-
return this.
|
|
1545
|
+
return this._urn.getTag('fmt') === 'yaml';
|
|
1554
1546
|
}
|
|
1555
1547
|
|
|
1556
1548
|
/**
|
|
1557
|
-
* Returns true if this
|
|
1549
|
+
* Returns true if this value's content format is CSV (`fmt=csv`).
|
|
1558
1550
|
* @returns {boolean}
|
|
1559
1551
|
*/
|
|
1560
1552
|
isCsv() {
|
|
1561
|
-
return this.
|
|
1553
|
+
return this._urn.getTag('fmt') === 'csv';
|
|
1562
1554
|
}
|
|
1563
1555
|
|
|
1564
1556
|
/**
|
|
@@ -1788,6 +1780,44 @@ function getProfileURL(profileName) {
|
|
|
1788
1780
|
return `${getSchemaBaseURL()}/${profileName}`;
|
|
1789
1781
|
}
|
|
1790
1782
|
|
|
1783
|
+
/**
|
|
1784
|
+
* Validate a build-time `MFR_CARTRIDGE_REGISTRY_URL` value and reduce it to
|
|
1785
|
+
* the registry identity the build is published against.
|
|
1786
|
+
*
|
|
1787
|
+
* Mirrors capdag::bifaci::manifest::registry_url_from_build_env. The Rust
|
|
1788
|
+
* version is a compile-time `const fn` fed `option_env!(...)`; in JS the
|
|
1789
|
+
* equivalent input is the raw env value at process start
|
|
1790
|
+
* (`process.env.MFR_CARTRIDGE_REGISTRY_URL`), where an absent variable is
|
|
1791
|
+
* `undefined`.
|
|
1792
|
+
*
|
|
1793
|
+
* Valid states:
|
|
1794
|
+
* - `null`/`undefined` => dev build; registry identity is absent and the
|
|
1795
|
+
* build must use the on-disk `dev/` slot. Returns `null`.
|
|
1796
|
+
* - a non-empty string => published-registry build. Returned verbatim.
|
|
1797
|
+
*
|
|
1798
|
+
* Invalid state:
|
|
1799
|
+
* - the empty string => the caller exported the variable with an empty value.
|
|
1800
|
+
* This is neither a dev build nor a valid registry identity, and it MUST
|
|
1801
|
+
* fail hard (throw) so the build can never silently hash the empty string
|
|
1802
|
+
* into a fake registry slug. The Rust port panics here; the JS port throws
|
|
1803
|
+
* with the same message — no fallback.
|
|
1804
|
+
*
|
|
1805
|
+
* @param {string|null|undefined} raw - The raw build-env value.
|
|
1806
|
+
* @returns {string|null} `null` for a dev build, or the verbatim non-empty URL.
|
|
1807
|
+
* @throws {Error} when `raw` is the empty string.
|
|
1808
|
+
*/
|
|
1809
|
+
function registryUrlFromBuildEnv(raw) {
|
|
1810
|
+
if (raw === null || raw === undefined) {
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
if (raw === '') {
|
|
1814
|
+
throw new Error(
|
|
1815
|
+
'MFR_CARTRIDGE_REGISTRY_URL must be unset for dev builds or set to a non-empty registry URL for published builds; empty string is invalid'
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
return raw;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1791
1821
|
// =============================================================================
|
|
1792
1822
|
// MEDIA URN TAG UTILITIES
|
|
1793
1823
|
// =============================================================================
|
|
@@ -1843,10 +1873,10 @@ class MediaDef {
|
|
|
1843
1873
|
return this._parsedMediaUrn;
|
|
1844
1874
|
}
|
|
1845
1875
|
|
|
1846
|
-
/** @returns {boolean} True if binary (
|
|
1876
|
+
/** @returns {boolean} True if binary (no `enc` encoding tag — not text-representable) */
|
|
1847
1877
|
isBinary() {
|
|
1848
1878
|
const mu = this.parsedMediaUrn();
|
|
1849
|
-
return mu ? mu.
|
|
1879
|
+
return mu ? mu.getTag('enc') === undefined : false;
|
|
1850
1880
|
}
|
|
1851
1881
|
|
|
1852
1882
|
/** @returns {boolean} True if record structure (has record marker) */
|
|
@@ -1879,10 +1909,10 @@ class MediaDef {
|
|
|
1879
1909
|
return mu ? mu.isJson() : false;
|
|
1880
1910
|
}
|
|
1881
1911
|
|
|
1882
|
-
/** @returns {boolean} True if text (
|
|
1912
|
+
/** @returns {boolean} True if text-representable (has `enc` encoding tag) */
|
|
1883
1913
|
isText() {
|
|
1884
1914
|
const mu = this.parsedMediaUrn();
|
|
1885
|
-
return mu ? mu.
|
|
1915
|
+
return mu ? mu.getTag('enc') !== undefined : false;
|
|
1886
1916
|
}
|
|
1887
1917
|
|
|
1888
1918
|
/** @returns {boolean} True if image (image tag present) */
|
|
@@ -1967,7 +1997,7 @@ class MediaDef {
|
|
|
1967
1997
|
* Resolution: Look up mediaUrn in mediaDefs array (by urn field), FAIL HARD if not found.
|
|
1968
1998
|
* There is no built-in resolution - all media URNs must be in mediaDefs.
|
|
1969
1999
|
*
|
|
1970
|
-
* @param {string} mediaUrn - The media URN (e.g., "media:
|
|
2000
|
+
* @param {string} mediaUrn - The media URN (e.g., "media:enc=utf-8")
|
|
1971
2001
|
* @param {Array} mediaDefs - The mediaDefs array (each item has urn, media_type, title, etc.)
|
|
1972
2002
|
* @returns {MediaDef} The resolved MediaDef
|
|
1973
2003
|
* @throws {MediaDefError} If media URN cannot be resolved
|
|
@@ -2058,7 +2088,7 @@ function buildExtensionIndex(mediaDefs) {
|
|
|
2058
2088
|
*
|
|
2059
2089
|
* @example
|
|
2060
2090
|
* const urns = mediaUrnsForExtension('pdf', mediaDefs);
|
|
2061
|
-
* // May return ['media:pdf']
|
|
2091
|
+
* // May return ['media:ext=pdf']
|
|
2062
2092
|
*/
|
|
2063
2093
|
function mediaUrnsForExtension(extension, mediaDefs) {
|
|
2064
2094
|
const index = buildExtensionIndex(mediaDefs);
|
|
@@ -3808,7 +3838,7 @@ class CapResult {
|
|
|
3808
3838
|
class CapArgumentValue {
|
|
3809
3839
|
/**
|
|
3810
3840
|
* Create a new CapArgumentValue
|
|
3811
|
-
* @param {string} mediaUrn - Semantic identifier, e.g., "media:model-spec
|
|
3841
|
+
* @param {string} mediaUrn - Semantic identifier, e.g., "media:enc=utf-8;model-spec"
|
|
3812
3842
|
* @param {Uint8Array|Buffer} value - Value bytes (UTF-8 for text, raw for binary)
|
|
3813
3843
|
*/
|
|
3814
3844
|
constructor(mediaUrn, value) {
|
|
@@ -4358,6 +4388,126 @@ function isRegistrySlug(s) {
|
|
|
4358
4388
|
&& /^[0-9a-f]+$/.test(s);
|
|
4359
4389
|
}
|
|
4360
4390
|
|
|
4391
|
+
// =============================================================================
|
|
4392
|
+
// Host-compatibility resolution for registry cartridges
|
|
4393
|
+
// =============================================================================
|
|
4394
|
+
//
|
|
4395
|
+
// Mirrors capdag::bifaci::cartridge_repo: host_platform(), CompatStatus,
|
|
4396
|
+
// CartridgeBuild::primary_package and CartridgeInfo::resolve_for_host.
|
|
4397
|
+
|
|
4398
|
+
/**
|
|
4399
|
+
* The platform string ({os}-{arch}) of the runtime that calls this, in the
|
|
4400
|
+
* exact form the registry uses (`darwin-arm64`, `darwin-x86_64`,
|
|
4401
|
+
* `linux-x86_64`, `windows-x86_64`). Single source of truth: every consumer
|
|
4402
|
+
* that needs "what am I running on?" calls this rather than re-deriving the
|
|
4403
|
+
* os/arch mapping.
|
|
4404
|
+
*
|
|
4405
|
+
* Mirrors capdag::bifaci::cartridge_repo::host_platform. The Rust version
|
|
4406
|
+
* derives os/arch from `cfg!`/`std::env::consts` at compile time; the Node
|
|
4407
|
+
* equivalent reads `process.platform`/`process.arch` at runtime — the engine
|
|
4408
|
+
* binary literally runs on this platform, so this is the authoritative host
|
|
4409
|
+
* string for compatibility resolution. Node's `darwin`/`linux`/`win32` map to
|
|
4410
|
+
* the registry's `darwin`/`linux`/`windows`; Node's `arm64`/`x64` map to the
|
|
4411
|
+
* registry's `arm64`/`x86_64`. Any platform/arch Node reports that has no
|
|
4412
|
+
* mapping passes through unchanged, exactly as the Rust fallback does.
|
|
4413
|
+
*
|
|
4414
|
+
* @returns {string} The normalized `{os}-{arch}` host platform string.
|
|
4415
|
+
*/
|
|
4416
|
+
function hostPlatform() {
|
|
4417
|
+
const rawOs = process.platform;
|
|
4418
|
+
let os;
|
|
4419
|
+
if (rawOs === 'darwin') {
|
|
4420
|
+
os = 'darwin';
|
|
4421
|
+
} else if (rawOs === 'linux') {
|
|
4422
|
+
os = 'linux';
|
|
4423
|
+
} else if (rawOs === 'win32') {
|
|
4424
|
+
os = 'windows';
|
|
4425
|
+
} else {
|
|
4426
|
+
os = rawOs;
|
|
4427
|
+
}
|
|
4428
|
+
const rawArch = process.arch;
|
|
4429
|
+
let arch;
|
|
4430
|
+
if (rawArch === 'arm64') {
|
|
4431
|
+
arch = 'arm64';
|
|
4432
|
+
} else if (rawArch === 'x64') {
|
|
4433
|
+
arch = 'x86_64';
|
|
4434
|
+
} else {
|
|
4435
|
+
arch = rawArch;
|
|
4436
|
+
}
|
|
4437
|
+
return `${os}-${arch}`;
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
/**
|
|
4441
|
+
* Host-compatibility status of a registry cartridge, resolved against a
|
|
4442
|
+
* specific host platform string. Mirrors Rust CompatStatus.
|
|
4443
|
+
*/
|
|
4444
|
+
const CompatStatus = Object.freeze({
|
|
4445
|
+
// The latest version has a build for this host platform — install as-is.
|
|
4446
|
+
COMPATIBLE: 'compatible',
|
|
4447
|
+
// The latest version has no host build, but an older version does;
|
|
4448
|
+
// resolvedVersion names that older version. Install it, mark outdated.
|
|
4449
|
+
COMPATIBLE_OUTDATED: 'compatible_outdated',
|
|
4450
|
+
// No version has a build for this host platform. Nothing to install.
|
|
4451
|
+
INCOMPATIBLE: 'incompatible',
|
|
4452
|
+
});
|
|
4453
|
+
|
|
4454
|
+
/**
|
|
4455
|
+
* The installer package the host should use for a build, preferring the
|
|
4456
|
+
* platform's native format. Falls back to the legacy singular `package` when
|
|
4457
|
+
* `packages[]` is empty (pre-dual-write manifests). Returns `null` only when
|
|
4458
|
+
* the build ships no installer at all.
|
|
4459
|
+
*
|
|
4460
|
+
* Mirrors CartridgeBuild::primary_package. Builds in the JS model are plain
|
|
4461
|
+
* objects (`{platform, packages?, package?}`), so this is a free function
|
|
4462
|
+
* taking the build object rather than a method.
|
|
4463
|
+
*
|
|
4464
|
+
* @param {Object} build - A platform build object with `platform`, optional
|
|
4465
|
+
* `packages` array and optional legacy `package`.
|
|
4466
|
+
* @returns {Object|null} The chosen distribution-info object, or null.
|
|
4467
|
+
*/
|
|
4468
|
+
function primaryPackage(build) {
|
|
4469
|
+
const os = String(build.platform || '').split('-')[0];
|
|
4470
|
+
let preference;
|
|
4471
|
+
switch (os) {
|
|
4472
|
+
case 'darwin': preference = ['pkg']; break;
|
|
4473
|
+
case 'linux': preference = ['deb', 'rpm']; break;
|
|
4474
|
+
case 'windows': preference = ['msi', 'exe']; break;
|
|
4475
|
+
default: preference = []; break;
|
|
4476
|
+
}
|
|
4477
|
+
const packages = Array.isArray(build.packages) ? build.packages : [];
|
|
4478
|
+
for (const format of preference) {
|
|
4479
|
+
const pkg = packages.find(p => p.format === format);
|
|
4480
|
+
if (pkg) return pkg;
|
|
4481
|
+
}
|
|
4482
|
+
if (packages.length > 0) return packages[0];
|
|
4483
|
+
return build.package || null;
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
/**
|
|
4487
|
+
* The resolved verdict the engine attaches to an available cartridge: which
|
|
4488
|
+
* version/package the host should install (if any) and a human reason when it
|
|
4489
|
+
* is not the latest-and-greatest. Mirrors CartridgeCompatibilityResolution.
|
|
4490
|
+
*/
|
|
4491
|
+
class CartridgeCompatibilityResolution {
|
|
4492
|
+
/**
|
|
4493
|
+
* @param {Object} opts
|
|
4494
|
+
* @param {string} opts.status - A CompatStatus value.
|
|
4495
|
+
* @param {string} opts.hostPlatform - The host platform this was resolved for.
|
|
4496
|
+
* @param {string|null} [opts.resolvedVersion] - Newest version with a host
|
|
4497
|
+
* build (null when Incompatible).
|
|
4498
|
+
* @param {Object|null} [opts.resolvedPackage] - Host-preferred installer
|
|
4499
|
+
* package within resolvedVersion (null when Incompatible).
|
|
4500
|
+
* @param {string|null} [opts.reason] - Explanation when status is not Compatible.
|
|
4501
|
+
*/
|
|
4502
|
+
constructor({ status, hostPlatform, resolvedVersion = null, resolvedPackage = null, reason = null }) {
|
|
4503
|
+
this.status = status;
|
|
4504
|
+
this.hostPlatform = hostPlatform;
|
|
4505
|
+
this.resolvedVersion = resolvedVersion;
|
|
4506
|
+
this.resolvedPackage = resolvedPackage;
|
|
4507
|
+
this.reason = reason;
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4361
4511
|
/**
|
|
4362
4512
|
* Cartridge information from registry
|
|
4363
4513
|
*/
|
|
@@ -4440,6 +4590,81 @@ class CartridgeInfo {
|
|
|
4440
4590
|
}
|
|
4441
4591
|
return Array.from(platforms).sort();
|
|
4442
4592
|
}
|
|
4593
|
+
|
|
4594
|
+
/**
|
|
4595
|
+
* Find this cartridge's build for `hostPlatform` within a given version, if
|
|
4596
|
+
* any. The host package within it is then chosen by `primaryPackage`.
|
|
4597
|
+
* Mirrors CartridgeInfo::build_for_host.
|
|
4598
|
+
*
|
|
4599
|
+
* @param {string} version
|
|
4600
|
+
* @param {string} hostPlatform
|
|
4601
|
+
* @returns {Object|null}
|
|
4602
|
+
*/
|
|
4603
|
+
buildForHost(version, hostPlatform) {
|
|
4604
|
+
const versionData = this.versions[version];
|
|
4605
|
+
if (!versionData) return null;
|
|
4606
|
+
return (versionData.builds || []).find(b => b.platform === hostPlatform) || null;
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
/**
|
|
4610
|
+
* Resolve which version/package this host should install, scanning versions
|
|
4611
|
+
* newest-first (`availableVersions` is the authoritative newest-first
|
|
4612
|
+
* ordering). The newest version with a usable host build wins:
|
|
4613
|
+
* - it IS the latest version (`this.version`) → Compatible
|
|
4614
|
+
* - it is older than the latest → CompatibleOutdated
|
|
4615
|
+
* - no version has a usable host build → Incompatible
|
|
4616
|
+
*
|
|
4617
|
+
* A build whose primaryPackage is null ships no installer at all; it is
|
|
4618
|
+
* SKIPPED (not resolved to an un-downloadable version) and the scan
|
|
4619
|
+
* continues to older versions. "Latest" is `this.version` — the same field
|
|
4620
|
+
* `buildForPlatform` trusts — not `availableVersions[0]`. We do not paper
|
|
4621
|
+
* over a `this.version` with no host build by silently calling it latest.
|
|
4622
|
+
*
|
|
4623
|
+
* Mirrors CartridgeInfo::resolve_for_host.
|
|
4624
|
+
*
|
|
4625
|
+
* @param {string} hostPlatform - Normally `hostPlatform()`; passed in so the
|
|
4626
|
+
* resolution is unit-testable for arbitrary hosts.
|
|
4627
|
+
* @returns {CartridgeCompatibilityResolution}
|
|
4628
|
+
*/
|
|
4629
|
+
resolveForHost(hostPlatform) {
|
|
4630
|
+
const latest = this.version;
|
|
4631
|
+
|
|
4632
|
+
for (const ver of this.availableVersions) {
|
|
4633
|
+
const build = this.buildForHost(ver, hostPlatform);
|
|
4634
|
+
if (!build) continue;
|
|
4635
|
+
// primaryPackage returns null only when the build ships no installer at
|
|
4636
|
+
// all — a build entry with an empty packages[] and no legacy package.
|
|
4637
|
+
// That is a malformed registry build; skip it rather than resolve to a
|
|
4638
|
+
// version the host cannot actually download, and keep scanning older
|
|
4639
|
+
// versions for a usable one.
|
|
4640
|
+
const pkg = primaryPackage(build);
|
|
4641
|
+
if (!pkg) continue;
|
|
4642
|
+
if (ver === latest) {
|
|
4643
|
+
return new CartridgeCompatibilityResolution({
|
|
4644
|
+
status: CompatStatus.COMPATIBLE,
|
|
4645
|
+
hostPlatform,
|
|
4646
|
+
resolvedVersion: ver,
|
|
4647
|
+
resolvedPackage: pkg,
|
|
4648
|
+
reason: null,
|
|
4649
|
+
});
|
|
4650
|
+
}
|
|
4651
|
+
return new CartridgeCompatibilityResolution({
|
|
4652
|
+
status: CompatStatus.COMPATIBLE_OUTDATED,
|
|
4653
|
+
hostPlatform,
|
|
4654
|
+
resolvedVersion: ver,
|
|
4655
|
+
resolvedPackage: pkg,
|
|
4656
|
+
reason: `Latest ${latest} has no ${hostPlatform} build; newest compatible is ${ver}`,
|
|
4657
|
+
});
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
return new CartridgeCompatibilityResolution({
|
|
4661
|
+
status: CompatStatus.INCOMPATIBLE,
|
|
4662
|
+
hostPlatform,
|
|
4663
|
+
resolvedVersion: null,
|
|
4664
|
+
resolvedPackage: null,
|
|
4665
|
+
reason: `No installable ${hostPlatform} build available in any version`,
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
4443
4668
|
}
|
|
4444
4669
|
|
|
4445
4670
|
/**
|
|
@@ -5013,6 +5238,20 @@ const CartridgeAttachmentErrorKind = Object.freeze({
|
|
|
5013
5238
|
IDENTITY_REJECTED: 'identity_rejected',
|
|
5014
5239
|
ENTRY_POINT_MISSING: 'entry_point_missing',
|
|
5015
5240
|
QUARANTINED: 'quarantined',
|
|
5241
|
+
// On-disk install context disagrees with the cartridge.json the cartridge
|
|
5242
|
+
// declares — the slug folder doesn't match slug_for(registry_url), the
|
|
5243
|
+
// channel folder doesn't match the manifest's channel, or a bundled-provider
|
|
5244
|
+
// integrity proof failed. Structurally well-formed but cannot be trusted
|
|
5245
|
+
// because its placement on disk does not match what it claims to be.
|
|
5246
|
+
BAD_INSTALLATION: 'bad_installation',
|
|
5247
|
+
// Operator explicitly disabled this cartridge through the host UI.
|
|
5248
|
+
DISABLED: 'disabled',
|
|
5249
|
+
// The cartridge declares a non-null registry_url but the host could not
|
|
5250
|
+
// reach that registry to verify the cartridge is listed.
|
|
5251
|
+
REGISTRY_UNREACHABLE: 'registry_unreachable',
|
|
5252
|
+
// The cartridge was built against a different fabric registry manifest
|
|
5253
|
+
// version than this host is pinned to.
|
|
5254
|
+
FABRIC_MANIFEST_VERSION_MISMATCH: 'fabric_manifest_version_mismatch',
|
|
5016
5255
|
});
|
|
5017
5256
|
|
|
5018
5257
|
/**
|
|
@@ -5193,6 +5432,941 @@ class InstalledCartridgeRecord {
|
|
|
5193
5432
|
}
|
|
5194
5433
|
}
|
|
5195
5434
|
|
|
5435
|
+
// ============================================================================
|
|
5436
|
+
// Cartridge discovery — on-disk scan + identity validation + HELLO probe
|
|
5437
|
+
// ============================================================================
|
|
5438
|
+
//
|
|
5439
|
+
// Mirrors capdag::cartridge_discovery plus its dependencies
|
|
5440
|
+
// capdag::bifaci::cartridge_json (CartridgeJson, validate_registry_url_scheme,
|
|
5441
|
+
// hash_cartridge_directory) and the synchronous slug helper from
|
|
5442
|
+
// capdag::bifaci::cartridge_slug::slug_for.
|
|
5443
|
+
//
|
|
5444
|
+
// This is the single source of truth for classifying each installed cartridge
|
|
5445
|
+
// version directory as attachable (`directory`) or `incompatible`. Node
|
|
5446
|
+
// built-ins (`fs`, `path`, `crypto`, `child_process`) are required lazily
|
|
5447
|
+
// inside the functions so the browser bundle — which never performs disk
|
|
5448
|
+
// discovery — never references them.
|
|
5449
|
+
|
|
5450
|
+
/**
|
|
5451
|
+
* Synchronous on-disk slug for a registry URL. Mirrors
|
|
5452
|
+
* capdag::bifaci::cartridge_slug::slug_for byte-for-byte: `null`/`undefined`
|
|
5453
|
+
* (a dev cartridge) → the literal `DEV_SLUG`; otherwise the first
|
|
5454
|
+
* SLUG_HEX_LEN lowercase-hex characters of sha256(url bytes).
|
|
5455
|
+
*
|
|
5456
|
+
* The async `slugForRegistryUrl` above uses Web Crypto for browser parity;
|
|
5457
|
+
* discovery runs only under Node and needs a synchronous slug in tight
|
|
5458
|
+
* per-file loops, so this uses Node's synchronous `crypto.createHash`. Both
|
|
5459
|
+
* produce identical output.
|
|
5460
|
+
*
|
|
5461
|
+
* @param {string|null|undefined} registryUrl
|
|
5462
|
+
* @returns {string}
|
|
5463
|
+
*/
|
|
5464
|
+
function slugForSync(registryUrl) {
|
|
5465
|
+
if (registryUrl === null || registryUrl === undefined) {
|
|
5466
|
+
return DEV_SLUG;
|
|
5467
|
+
}
|
|
5468
|
+
const crypto = require('crypto');
|
|
5469
|
+
const hex = crypto.createHash('sha256').update(registryUrl, 'utf8').digest('hex');
|
|
5470
|
+
return hex.slice(0, SLUG_HEX_LEN);
|
|
5471
|
+
}
|
|
5472
|
+
|
|
5473
|
+
/**
|
|
5474
|
+
* How a cartridge was installed. Pure metadata — never consulted for any host
|
|
5475
|
+
* or engine routing decision. Mirrors Rust CartridgeInstallSource; the
|
|
5476
|
+
* on-disk JSON uses snake_case values.
|
|
5477
|
+
*/
|
|
5478
|
+
const CartridgeInstallSource = Object.freeze({
|
|
5479
|
+
REGISTRY: 'registry',
|
|
5480
|
+
DEV: 'dev',
|
|
5481
|
+
BUNDLE: 'bundle',
|
|
5482
|
+
APP_INSTALLER: 'app_installer',
|
|
5483
|
+
});
|
|
5484
|
+
|
|
5485
|
+
/**
|
|
5486
|
+
* Verdict from `validateRegistryUrlScheme`. Mirrors Rust
|
|
5487
|
+
* RegistryUrlSchemeResult: distinguishes OK from each rejection reason so
|
|
5488
|
+
* callers can render an actionable message. `kind` is one of `ok`,
|
|
5489
|
+
* `not_a_url`, `non_https`.
|
|
5490
|
+
*/
|
|
5491
|
+
const RegistryUrlSchemeResultKind = Object.freeze({
|
|
5492
|
+
OK: 'ok',
|
|
5493
|
+
NOT_A_URL: 'not_a_url',
|
|
5494
|
+
NON_HTTPS: 'non_https',
|
|
5495
|
+
});
|
|
5496
|
+
|
|
5497
|
+
/**
|
|
5498
|
+
* Validate that a non-null `registry_url` uses the `https` scheme — UNLESS
|
|
5499
|
+
* `devMode` is set, in which case any well-formed URL is accepted (so
|
|
5500
|
+
* developers can point at `http://localhost:port` during integration
|
|
5501
|
+
* testing). Mirrors capdag::bifaci::cartridge_json::validate_registry_url_scheme.
|
|
5502
|
+
*
|
|
5503
|
+
* Cheap parse: split once on `://`. The rule is "scheme must be the literal
|
|
5504
|
+
* bytes `https`" (case-insensitive); full URL validation is the caller's job.
|
|
5505
|
+
*
|
|
5506
|
+
* @param {string} url
|
|
5507
|
+
* @param {boolean} devMode
|
|
5508
|
+
* @returns {{kind: string, url?: string, scheme?: string}}
|
|
5509
|
+
*/
|
|
5510
|
+
function validateRegistryUrlScheme(url, devMode) {
|
|
5511
|
+
const idx = url.indexOf('://');
|
|
5512
|
+
if (idx < 0) {
|
|
5513
|
+
return { kind: RegistryUrlSchemeResultKind.NOT_A_URL, url };
|
|
5514
|
+
}
|
|
5515
|
+
const scheme = url.slice(0, idx);
|
|
5516
|
+
const rest = url.slice(idx + 3);
|
|
5517
|
+
if (rest.length === 0) {
|
|
5518
|
+
return { kind: RegistryUrlSchemeResultKind.NOT_A_URL, url };
|
|
5519
|
+
}
|
|
5520
|
+
if (devMode) {
|
|
5521
|
+
return { kind: RegistryUrlSchemeResultKind.OK };
|
|
5522
|
+
}
|
|
5523
|
+
if (scheme.toLowerCase() === 'https') {
|
|
5524
|
+
return { kind: RegistryUrlSchemeResultKind.OK };
|
|
5525
|
+
}
|
|
5526
|
+
return { kind: RegistryUrlSchemeResultKind.NON_HTTPS, scheme };
|
|
5527
|
+
}
|
|
5528
|
+
|
|
5529
|
+
/**
|
|
5530
|
+
* Error kinds for CartridgeJson reading/validation. Mirrors the variants of
|
|
5531
|
+
* Rust CartridgeJsonError. `RegistrySlugMismatch` is the three-place
|
|
5532
|
+
* consistency failure that discovery maps to BAD_INSTALLATION.
|
|
5533
|
+
*/
|
|
5534
|
+
const CartridgeJsonErrorKind = Object.freeze({
|
|
5535
|
+
NOT_FOUND: 'not_found',
|
|
5536
|
+
READ_FAILED: 'read_failed',
|
|
5537
|
+
INVALID_JSON: 'invalid_json',
|
|
5538
|
+
ENTRY_POINT_MISSING: 'entry_point_missing',
|
|
5539
|
+
ENTRY_POINT_NOT_EXECUTABLE: 'entry_point_not_executable',
|
|
5540
|
+
ENTRY_PATH_ESCAPE: 'entry_path_escape',
|
|
5541
|
+
REGISTRY_SLUG_MISMATCH: 'registry_slug_mismatch',
|
|
5542
|
+
});
|
|
5543
|
+
|
|
5544
|
+
/**
|
|
5545
|
+
* Error thrown by CartridgeJson.readFromDir. Carries a structured `kind` so
|
|
5546
|
+
* discovery can map a slug mismatch to BAD_INSTALLATION and everything else
|
|
5547
|
+
* to MANIFEST_INVALID. Mirrors Rust CartridgeJsonError.
|
|
5548
|
+
*/
|
|
5549
|
+
class CartridgeJsonError extends Error {
|
|
5550
|
+
constructor(kind, message) {
|
|
5551
|
+
super(message);
|
|
5552
|
+
this.name = 'CartridgeJsonError';
|
|
5553
|
+
this.kind = kind;
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5557
|
+
/**
|
|
5558
|
+
* Install-context metadata stored in `cartridge.json` inside each cartridge
|
|
5559
|
+
* version directory. Mirrors capdag::bifaci::cartridge_json::CartridgeJson.
|
|
5560
|
+
*
|
|
5561
|
+
* Identity tuple: `(registryUrl, channel, name, version)`. `registryUrl` is
|
|
5562
|
+
* required-but-nullable: a MISSING `registry_url` key is a parse error
|
|
5563
|
+
* (forces the new schema across every install path); only `null` means dev.
|
|
5564
|
+
*/
|
|
5565
|
+
class CartridgeJson {
|
|
5566
|
+
/**
|
|
5567
|
+
* @param {Object} fields
|
|
5568
|
+
*/
|
|
5569
|
+
constructor(fields) {
|
|
5570
|
+
this.name = fields.name;
|
|
5571
|
+
this.version = fields.version;
|
|
5572
|
+
this.channel = fields.channel;
|
|
5573
|
+
this.registryUrl = fields.registryUrl;
|
|
5574
|
+
this.entry = fields.entry;
|
|
5575
|
+
this.installedAt = fields.installedAt;
|
|
5576
|
+
this.installedFrom = fields.installedFrom !== undefined ? fields.installedFrom : null;
|
|
5577
|
+
this.sourceUrl = fields.sourceUrl || '';
|
|
5578
|
+
this.packageSha256 = fields.packageSha256 || '';
|
|
5579
|
+
this.packageSize = fields.packageSize || 0;
|
|
5580
|
+
this.fabricManifestVersion = fields.fabricManifestVersion || 0;
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
/**
|
|
5584
|
+
* Parse a cartridge.json object. Enforces the "required-but-nullable"
|
|
5585
|
+
* contract on `registry_url` — the JSON key MUST be present (missing key is
|
|
5586
|
+
* a parse error); the value MAY be null (dev) or a string (registry). Stock
|
|
5587
|
+
* JSON parsing would collapse absent and explicit-null, so the presence
|
|
5588
|
+
* check is explicit, mirroring the Rust manual deserializer.
|
|
5589
|
+
*
|
|
5590
|
+
* @param {Object} obj - The parsed JSON object.
|
|
5591
|
+
* @returns {CartridgeJson}
|
|
5592
|
+
* @throws {CartridgeJsonError} with kind INVALID_JSON on any schema violation.
|
|
5593
|
+
*/
|
|
5594
|
+
static fromObject(obj) {
|
|
5595
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
5596
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, 'cartridge.json must be a JSON object');
|
|
5597
|
+
}
|
|
5598
|
+
if (!Object.prototype.hasOwnProperty.call(obj, 'registry_url')) {
|
|
5599
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, 'cartridge.json missing required field: registry_url');
|
|
5600
|
+
}
|
|
5601
|
+
for (const required of ['name', 'version', 'channel', 'entry', 'installed_at']) {
|
|
5602
|
+
if (typeof obj[required] !== 'string') {
|
|
5603
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `cartridge.json missing required string field: ${required}`);
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
if (obj.channel !== CartridgeChannel.Release && obj.channel !== CartridgeChannel.Nightly) {
|
|
5607
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `cartridge.json invalid channel: ${obj.channel}`);
|
|
5608
|
+
}
|
|
5609
|
+
const reg = obj.registry_url;
|
|
5610
|
+
if (reg !== null && typeof reg !== 'string') {
|
|
5611
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, 'cartridge.json registry_url must be null or a string');
|
|
5612
|
+
}
|
|
5613
|
+
let installedFrom = null;
|
|
5614
|
+
if (obj.installed_from !== undefined && obj.installed_from !== null) {
|
|
5615
|
+
const valid = Object.values(CartridgeInstallSource);
|
|
5616
|
+
if (!valid.includes(obj.installed_from)) {
|
|
5617
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `cartridge.json unknown installed_from: ${obj.installed_from}`);
|
|
5618
|
+
}
|
|
5619
|
+
installedFrom = obj.installed_from;
|
|
5620
|
+
}
|
|
5621
|
+
return new CartridgeJson({
|
|
5622
|
+
name: obj.name,
|
|
5623
|
+
version: obj.version,
|
|
5624
|
+
channel: obj.channel,
|
|
5625
|
+
registryUrl: reg,
|
|
5626
|
+
entry: obj.entry,
|
|
5627
|
+
installedAt: obj.installed_at,
|
|
5628
|
+
installedFrom,
|
|
5629
|
+
sourceUrl: typeof obj.source_url === 'string' ? obj.source_url : '',
|
|
5630
|
+
packageSha256: typeof obj.package_sha256 === 'string' ? obj.package_sha256 : '',
|
|
5631
|
+
packageSize: typeof obj.package_size === 'number' ? obj.package_size : 0,
|
|
5632
|
+
fabricManifestVersion: typeof obj.fabric_manifest_version === 'number' ? obj.fabric_manifest_version : 0,
|
|
5633
|
+
});
|
|
5634
|
+
}
|
|
5635
|
+
|
|
5636
|
+
/**
|
|
5637
|
+
* Read and validate a `cartridge.json` from a version directory, enforcing
|
|
5638
|
+
* the three-place rule against `expectedSlug` (the slug folder the host
|
|
5639
|
+
* walked through). Mirrors CartridgeJson::read_from_dir.
|
|
5640
|
+
*
|
|
5641
|
+
* Validates: file exists and is valid JSON; the declared registry_url
|
|
5642
|
+
* hashes to `expectedSlug` (else RegistrySlugMismatch); the entry point
|
|
5643
|
+
* exists, does not escape the version directory, and is executable.
|
|
5644
|
+
*
|
|
5645
|
+
* @param {string} versionDir - Absolute path to the version directory.
|
|
5646
|
+
* @param {string} expectedSlug - The on-disk slug folder name.
|
|
5647
|
+
* @returns {CartridgeJson}
|
|
5648
|
+
* @throws {CartridgeJsonError}
|
|
5649
|
+
*/
|
|
5650
|
+
static readFromDir(versionDir, expectedSlug) {
|
|
5651
|
+
const fs = require('fs');
|
|
5652
|
+
const path = require('path');
|
|
5653
|
+
const jsonPath = path.join(versionDir, 'cartridge.json');
|
|
5654
|
+
|
|
5655
|
+
if (!fs.existsSync(jsonPath)) {
|
|
5656
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.NOT_FOUND, `cartridge.json not found at ${jsonPath}`);
|
|
5657
|
+
}
|
|
5658
|
+
|
|
5659
|
+
let contents;
|
|
5660
|
+
try {
|
|
5661
|
+
contents = fs.readFileSync(jsonPath, 'utf8');
|
|
5662
|
+
} catch (e) {
|
|
5663
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.READ_FAILED, `failed to read cartridge.json at ${jsonPath}: ${e.message}`);
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5666
|
+
let parsed;
|
|
5667
|
+
try {
|
|
5668
|
+
parsed = JSON.parse(contents);
|
|
5669
|
+
} catch (e) {
|
|
5670
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.INVALID_JSON, `invalid cartridge.json at ${jsonPath}: ${e.message}`);
|
|
5671
|
+
}
|
|
5672
|
+
const cj = CartridgeJson.fromObject(parsed);
|
|
5673
|
+
|
|
5674
|
+
// Three-place consistency rule (places 1 + 2): the folder slug must match
|
|
5675
|
+
// the slug derived from the provenance's registry_url. None+`dev` and
|
|
5676
|
+
// Some(url)+slug(url) are the only valid pairings.
|
|
5677
|
+
const derivedSlug = slugForSync(cj.registryUrl);
|
|
5678
|
+
if (derivedSlug !== expectedSlug) {
|
|
5679
|
+
throw new CartridgeJsonError(
|
|
5680
|
+
CartridgeJsonErrorKind.REGISTRY_SLUG_MISMATCH,
|
|
5681
|
+
`cartridge.json at ${jsonPath}: registry slug mismatch — registry_url=${JSON.stringify(cj.registryUrl)} ` +
|
|
5682
|
+
`hashes to slug='${derivedSlug}' but the directory tree placed it under '${expectedSlug}'`
|
|
5683
|
+
);
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5686
|
+
// Validate entry point exists.
|
|
5687
|
+
const entryPath = path.join(versionDir, cj.entry);
|
|
5688
|
+
if (!fs.existsSync(entryPath)) {
|
|
5689
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.ENTRY_POINT_MISSING, `cartridge.json at ${jsonPath}: entry point '${cj.entry}' does not exist`);
|
|
5690
|
+
}
|
|
5691
|
+
|
|
5692
|
+
// Validate entry path does not escape version directory.
|
|
5693
|
+
let canonicalDir;
|
|
5694
|
+
let canonicalEntry;
|
|
5695
|
+
try { canonicalDir = fs.realpathSync(versionDir); } catch (_e) { canonicalDir = path.resolve(versionDir); }
|
|
5696
|
+
try { canonicalEntry = fs.realpathSync(entryPath); } catch (_e) { canonicalEntry = path.resolve(entryPath); }
|
|
5697
|
+
const dirWithSep = canonicalDir.endsWith(path.sep) ? canonicalDir : canonicalDir + path.sep;
|
|
5698
|
+
if (canonicalEntry !== canonicalDir && !canonicalEntry.startsWith(dirWithSep)) {
|
|
5699
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.ENTRY_PATH_ESCAPE, `cartridge.json at ${jsonPath}: entry path '${cj.entry}' escapes version directory`);
|
|
5700
|
+
}
|
|
5701
|
+
|
|
5702
|
+
// Validate entry point is executable (unix permission bits).
|
|
5703
|
+
if (process.platform !== 'win32') {
|
|
5704
|
+
let mode;
|
|
5705
|
+
try {
|
|
5706
|
+
mode = fs.statSync(entryPath).mode;
|
|
5707
|
+
} catch (e) {
|
|
5708
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.READ_FAILED, `failed to read cartridge.json at ${jsonPath}: ${e.message}`);
|
|
5709
|
+
}
|
|
5710
|
+
if ((mode & 0o111) === 0) {
|
|
5711
|
+
throw new CartridgeJsonError(CartridgeJsonErrorKind.ENTRY_POINT_NOT_EXECUTABLE, `cartridge.json at ${jsonPath}: entry point '${cj.entry}' is not executable`);
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5714
|
+
|
|
5715
|
+
return cj;
|
|
5716
|
+
}
|
|
5717
|
+
|
|
5718
|
+
/** True when installed as a dev build (no registry URL). */
|
|
5719
|
+
isDevInstall() {
|
|
5720
|
+
return this.registryUrl === null || this.registryUrl === undefined;
|
|
5721
|
+
}
|
|
5722
|
+
|
|
5723
|
+
/** The on-disk slug this provenance must live under. */
|
|
5724
|
+
registrySlug() {
|
|
5725
|
+
return slugForSync(this.registryUrl);
|
|
5726
|
+
}
|
|
5727
|
+
|
|
5728
|
+
/**
|
|
5729
|
+
* Resolve the absolute path to the entry point binary.
|
|
5730
|
+
* @param {string} versionDir
|
|
5731
|
+
* @returns {string}
|
|
5732
|
+
*/
|
|
5733
|
+
resolveEntryPoint(versionDir) {
|
|
5734
|
+
const path = require('path');
|
|
5735
|
+
return path.join(versionDir, this.entry);
|
|
5736
|
+
}
|
|
5737
|
+
}
|
|
5738
|
+
|
|
5739
|
+
/**
|
|
5740
|
+
* Compute a deterministic SHA256 hash of a directory tree. Walks all files
|
|
5741
|
+
* recursively, sorts them by relative path, then hashes each file's relative
|
|
5742
|
+
* path (UTF-8 bytes) followed by its contents — a stable identity hash
|
|
5743
|
+
* regardless of filesystem ordering. `cartridge.json` itself is excluded (it
|
|
5744
|
+
* holds install-time metadata that varies between installs of identical
|
|
5745
|
+
* content). Mirrors capdag::bifaci::cartridge_json::hash_cartridge_directory.
|
|
5746
|
+
*
|
|
5747
|
+
* @param {string} dir - Absolute path to the directory.
|
|
5748
|
+
* @returns {string} Lowercase hex SHA256.
|
|
5749
|
+
*/
|
|
5750
|
+
function hashCartridgeDirectory(dir) {
|
|
5751
|
+
const fs = require('fs');
|
|
5752
|
+
const path = require('path');
|
|
5753
|
+
const crypto = require('crypto');
|
|
5754
|
+
|
|
5755
|
+
const files = [];
|
|
5756
|
+
const collect = (current) => {
|
|
5757
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
5758
|
+
const full = path.join(current, entry.name);
|
|
5759
|
+
if (entry.isDirectory()) {
|
|
5760
|
+
collect(full);
|
|
5761
|
+
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
5762
|
+
let relative = path.relative(dir, full);
|
|
5763
|
+
// Normalize to the same separator the Rust to_string_lossy yields on
|
|
5764
|
+
// the platform — on POSIX both use '/'. Windows separators differ but
|
|
5765
|
+
// the cartridge tree is POSIX in practice; keep relative verbatim.
|
|
5766
|
+
if (relative === 'cartridge.json') continue;
|
|
5767
|
+
files.push([relative, full]);
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
};
|
|
5771
|
+
collect(dir);
|
|
5772
|
+
files.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
5773
|
+
|
|
5774
|
+
const hasher = crypto.createHash('sha256');
|
|
5775
|
+
for (const [relativePath, fullPath] of files) {
|
|
5776
|
+
hasher.update(Buffer.from(relativePath, 'utf8'));
|
|
5777
|
+
hasher.update(fs.readFileSync(fullPath));
|
|
5778
|
+
}
|
|
5779
|
+
return hasher.digest('hex');
|
|
5780
|
+
}
|
|
5781
|
+
|
|
5782
|
+
// ---------------------------------------------------------------------------
|
|
5783
|
+
// Bundled-provider integrity (non-macOS).
|
|
5784
|
+
// ---------------------------------------------------------------------------
|
|
5785
|
+
//
|
|
5786
|
+
// Mirrors capdag::cartridge_discovery::verify_bundled_provider_hash and the
|
|
5787
|
+
// BUNDLED_PROVIDER_HASHES const codegen'd by build.rs. Under a plain build —
|
|
5788
|
+
// and in this mirror, which bundles no providers — the set is empty, so any
|
|
5789
|
+
// `installed_from: bundle` cartridge has no baked hash and is rejected.
|
|
5790
|
+
const BUNDLED_PROVIDER_HASHES = Object.freeze([]); // [name, version, hash] triples
|
|
5791
|
+
|
|
5792
|
+
/**
|
|
5793
|
+
* Look up the baked expected directory hash for a bundled provider, or null if
|
|
5794
|
+
* (name, version) was not recorded at build time.
|
|
5795
|
+
* @returns {string|null}
|
|
5796
|
+
*/
|
|
5797
|
+
function bundledProviderExpectedHash(name, version) {
|
|
5798
|
+
for (const [n, v, h] of BUNDLED_PROVIDER_HASHES) {
|
|
5799
|
+
if (n === name && v === version) return h;
|
|
5800
|
+
}
|
|
5801
|
+
return null;
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5804
|
+
/**
|
|
5805
|
+
* Verify a bundled provider's on-disk content against the hash baked into this
|
|
5806
|
+
* build. Returns null on success, or an error-reason string. Mirrors
|
|
5807
|
+
* verify_bundled_provider_hash. Non-macOS only: macOS bundled-provider
|
|
5808
|
+
* integrity is the OS code-signature, so the engine there neither bakes nor
|
|
5809
|
+
* checks these hashes.
|
|
5810
|
+
*
|
|
5811
|
+
* @returns {string|null} null on OK, else the failure reason.
|
|
5812
|
+
*/
|
|
5813
|
+
function verifyBundledProviderHash(name, version, versionDir) {
|
|
5814
|
+
const expected = bundledProviderExpectedHash(name, version);
|
|
5815
|
+
if (expected === null) {
|
|
5816
|
+
return `no baked hash for bundled provider ${name} ${version} — this build did not record it (MFR_BUNDLED_PROVIDER_HASHES)`;
|
|
5817
|
+
}
|
|
5818
|
+
let actual;
|
|
5819
|
+
try {
|
|
5820
|
+
actual = hashCartridgeDirectory(versionDir);
|
|
5821
|
+
} catch (e) {
|
|
5822
|
+
return `failed to hash bundled provider directory: ${e.message}`;
|
|
5823
|
+
}
|
|
5824
|
+
if (actual === expected) return null;
|
|
5825
|
+
return `content hash mismatch — baked ${expected}, on-disk ${actual}; the shipped provider differs from what this build was compiled to ship`;
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
/**
|
|
5829
|
+
* The identity a host accepts cartridges for. A cartridge whose cartridge.json
|
|
5830
|
+
* diverges from this on channel, registry URL, registry scheme, or fabric
|
|
5831
|
+
* manifest version is surfaced as incompatible — never hosted. Mirrors Rust
|
|
5832
|
+
* DiscoveryIdentity.
|
|
5833
|
+
*/
|
|
5834
|
+
class DiscoveryIdentity {
|
|
5835
|
+
/**
|
|
5836
|
+
* @param {Object} opts
|
|
5837
|
+
* @param {string} opts.channel - A CartridgeChannel value.
|
|
5838
|
+
* @param {string|null} opts.registryUrl - Some(url) for release/nightly
|
|
5839
|
+
* hosts, null for dev hosts.
|
|
5840
|
+
* @param {number} opts.fabricManifestVersion
|
|
5841
|
+
*/
|
|
5842
|
+
constructor({ channel, registryUrl, fabricManifestVersion }) {
|
|
5843
|
+
if (channel !== CartridgeChannel.Release && channel !== CartridgeChannel.Nightly) {
|
|
5844
|
+
throw new Error(`DiscoveryIdentity: invalid channel '${channel}'`);
|
|
5845
|
+
}
|
|
5846
|
+
this.channel = channel;
|
|
5847
|
+
this.registryUrl = registryUrl !== undefined ? registryUrl : null;
|
|
5848
|
+
this.fabricManifestVersion = fabricManifestVersion;
|
|
5849
|
+
}
|
|
5850
|
+
|
|
5851
|
+
/** This host's own baked-registry slug (`dev` when registryUrl is null). */
|
|
5852
|
+
slug() {
|
|
5853
|
+
return slugForSync(this.registryUrl);
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
|
|
5857
|
+
/**
|
|
5858
|
+
* A discovered cartridge version directory, classified. Mirrors Rust
|
|
5859
|
+
* DiscoveredCartridge — a tagged union with `kind` of `directory` (passed all
|
|
5860
|
+
* checks and its HELLO probe succeeded) or `incompatible` (found on disk but
|
|
5861
|
+
* failed a check; carries a structured `error`).
|
|
5862
|
+
*/
|
|
5863
|
+
class DiscoveredCartridge {
|
|
5864
|
+
constructor(kind, fields) {
|
|
5865
|
+
this.kind = kind; // 'directory' | 'incompatible'
|
|
5866
|
+
Object.assign(this, fields);
|
|
5867
|
+
}
|
|
5868
|
+
static directory(fields) { return new DiscoveredCartridge('directory', fields); }
|
|
5869
|
+
static incompatible(fields) { return new DiscoveredCartridge('incompatible', fields); }
|
|
5870
|
+
}
|
|
5871
|
+
|
|
5872
|
+
/** Current wall-clock time as Unix seconds. A pre-epoch clock returns 0. */
|
|
5873
|
+
function unixSecondsNow() {
|
|
5874
|
+
const s = Math.floor(Date.now() / 1000);
|
|
5875
|
+
return s >= 0 ? s : 0;
|
|
5876
|
+
}
|
|
5877
|
+
|
|
5878
|
+
// Default protocol limits / version, mirroring capdag::bifaci::frame.
|
|
5879
|
+
const BIFACI_PROTOCOL_VERSION = 2;
|
|
5880
|
+
const BIFACI_DEFAULT_MAX_FRAME = 3670016;
|
|
5881
|
+
const BIFACI_DEFAULT_MAX_CHUNK = 262144;
|
|
5882
|
+
const BIFACI_DEFAULT_MAX_REORDER_BUFFER = 64;
|
|
5883
|
+
const BIFACI_FRAME_TYPE_HELLO = 0;
|
|
5884
|
+
// Frame CBOR integer keys (capdag::bifaci::frame::keys).
|
|
5885
|
+
const BIFACI_KEY_VERSION = 0;
|
|
5886
|
+
const BIFACI_KEY_FRAME_TYPE = 1;
|
|
5887
|
+
const BIFACI_KEY_ID = 2;
|
|
5888
|
+
const BIFACI_KEY_SEQ = 3;
|
|
5889
|
+
const BIFACI_KEY_META = 5;
|
|
5890
|
+
|
|
5891
|
+
/**
|
|
5892
|
+
* Minimal CBOR encoder covering the value shapes the bifaci HELLO frame uses:
|
|
5893
|
+
* unsigned/negative integers, byte strings, text strings, booleans, and maps.
|
|
5894
|
+
* Faithful to RFC 8949 major-type encoding so the bytes are wire-compatible
|
|
5895
|
+
* with the Rust `ciborium` encoder. Map keys are emitted in the order given.
|
|
5896
|
+
*
|
|
5897
|
+
* @param {*} value - One of: number (integer), {__bytes: Buffer}, string,
|
|
5898
|
+
* boolean, or {__map: [[key, val], ...]}.
|
|
5899
|
+
* @returns {Buffer}
|
|
5900
|
+
*/
|
|
5901
|
+
function cborEncode(value) {
|
|
5902
|
+
const out = [];
|
|
5903
|
+
const pushTypeLen = (major, n) => {
|
|
5904
|
+
const mt = major << 5;
|
|
5905
|
+
if (n < 24) {
|
|
5906
|
+
out.push(mt | n);
|
|
5907
|
+
} else if (n < 0x100) {
|
|
5908
|
+
out.push(mt | 24, n);
|
|
5909
|
+
} else if (n < 0x10000) {
|
|
5910
|
+
out.push(mt | 25, (n >> 8) & 0xff, n & 0xff);
|
|
5911
|
+
} else if (n < 0x100000000) {
|
|
5912
|
+
out.push(mt | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
|
5913
|
+
} else {
|
|
5914
|
+
// 64-bit: split into high/low 32-bit halves.
|
|
5915
|
+
const hi = Math.floor(n / 0x100000000);
|
|
5916
|
+
const lo = n >>> 0;
|
|
5917
|
+
out.push(mt | 27,
|
|
5918
|
+
(hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff,
|
|
5919
|
+
(lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
|
|
5920
|
+
}
|
|
5921
|
+
};
|
|
5922
|
+
const enc = (v) => {
|
|
5923
|
+
if (typeof v === 'number') {
|
|
5924
|
+
if (!Number.isInteger(v)) throw new Error('cborEncode: only integers supported');
|
|
5925
|
+
if (v >= 0) pushTypeLen(0, v);
|
|
5926
|
+
else pushTypeLen(1, -v - 1);
|
|
5927
|
+
} else if (typeof v === 'boolean') {
|
|
5928
|
+
out.push(v ? 0xf5 : 0xf4);
|
|
5929
|
+
} else if (typeof v === 'string') {
|
|
5930
|
+
const buf = Buffer.from(v, 'utf8');
|
|
5931
|
+
pushTypeLen(3, buf.length);
|
|
5932
|
+
for (const b of buf) out.push(b);
|
|
5933
|
+
} else if (v && v.__bytes !== undefined) {
|
|
5934
|
+
const buf = Buffer.isBuffer(v.__bytes) ? v.__bytes : Buffer.from(v.__bytes);
|
|
5935
|
+
pushTypeLen(2, buf.length);
|
|
5936
|
+
for (const b of buf) out.push(b);
|
|
5937
|
+
} else if (v && v.__map !== undefined) {
|
|
5938
|
+
pushTypeLen(5, v.__map.length);
|
|
5939
|
+
for (const [k, val] of v.__map) { enc(k); enc(val); }
|
|
5940
|
+
} else {
|
|
5941
|
+
throw new Error('cborEncode: unsupported value');
|
|
5942
|
+
}
|
|
5943
|
+
};
|
|
5944
|
+
enc(value);
|
|
5945
|
+
return Buffer.from(out);
|
|
5946
|
+
}
|
|
5947
|
+
|
|
5948
|
+
/**
|
|
5949
|
+
* Minimal CBOR decoder returning a JS view of the value. Integers → number,
|
|
5950
|
+
* byte strings → {__bytes: Buffer}, text → string, bool → boolean, maps →
|
|
5951
|
+
* Map. Sufficient to parse a bifaci HELLO response frame. Throws on
|
|
5952
|
+
* unsupported/truncated input. Returns {value, offset}.
|
|
5953
|
+
*/
|
|
5954
|
+
function cborDecodeAt(buf, pos) {
|
|
5955
|
+
if (pos >= buf.length) throw new Error('cbor: unexpected end');
|
|
5956
|
+
const ib = buf[pos++];
|
|
5957
|
+
const major = ib >> 5;
|
|
5958
|
+
const minor = ib & 0x1f;
|
|
5959
|
+
const readLen = () => {
|
|
5960
|
+
if (minor < 24) return minor;
|
|
5961
|
+
if (minor === 24) { return buf[pos++]; }
|
|
5962
|
+
if (minor === 25) { const n = (buf[pos] << 8) | buf[pos + 1]; pos += 2; return n; }
|
|
5963
|
+
if (minor === 26) { const n = (buf[pos] * 0x1000000) + (buf[pos + 1] << 16) + (buf[pos + 2] << 8) + buf[pos + 3]; pos += 4; return n; }
|
|
5964
|
+
if (minor === 27) {
|
|
5965
|
+
let n = 0;
|
|
5966
|
+
for (let i = 0; i < 8; i++) { n = n * 256 + buf[pos++]; }
|
|
5967
|
+
return n;
|
|
5968
|
+
}
|
|
5969
|
+
throw new Error('cbor: bad length encoding');
|
|
5970
|
+
};
|
|
5971
|
+
switch (major) {
|
|
5972
|
+
case 0: { const n = readLen(); return { value: n, offset: pos }; }
|
|
5973
|
+
case 1: { const n = readLen(); return { value: -1 - n, offset: pos }; }
|
|
5974
|
+
case 2: { const len = readLen(); const b = buf.slice(pos, pos + len); pos += len; return { value: { __bytes: b }, offset: pos }; }
|
|
5975
|
+
case 3: { const len = readLen(); const s = buf.slice(pos, pos + len).toString('utf8'); pos += len; return { value: s, offset: pos }; }
|
|
5976
|
+
case 5: {
|
|
5977
|
+
const len = readLen();
|
|
5978
|
+
const m = new Map();
|
|
5979
|
+
for (let i = 0; i < len; i++) {
|
|
5980
|
+
const k = cborDecodeAt(buf, pos); pos = k.offset;
|
|
5981
|
+
const v = cborDecodeAt(buf, pos); pos = v.offset;
|
|
5982
|
+
m.set(k.value, v.value);
|
|
5983
|
+
}
|
|
5984
|
+
return { value: m, offset: pos };
|
|
5985
|
+
}
|
|
5986
|
+
case 7: {
|
|
5987
|
+
if (minor === 20) return { value: false, offset: pos };
|
|
5988
|
+
if (minor === 21) return { value: true, offset: pos };
|
|
5989
|
+
if (minor === 22) return { value: null, offset: pos };
|
|
5990
|
+
throw new Error('cbor: unsupported simple value');
|
|
5991
|
+
}
|
|
5992
|
+
default:
|
|
5993
|
+
throw new Error(`cbor: unsupported major type ${major}`);
|
|
5994
|
+
}
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5997
|
+
/**
|
|
5998
|
+
* Encode the engine-side HELLO frame as a length-prefixed CBOR frame, exactly
|
|
5999
|
+
* as capdag::bifaci::io::write_frame does: a 4-byte big-endian length prefix
|
|
6000
|
+
* followed by the CBOR-encoded frame map. Mirrors Frame::hello with default
|
|
6001
|
+
* Limits.
|
|
6002
|
+
*
|
|
6003
|
+
* @returns {Buffer}
|
|
6004
|
+
*/
|
|
6005
|
+
function encodeHelloFrame() {
|
|
6006
|
+
const meta = {
|
|
6007
|
+
__map: [
|
|
6008
|
+
['max_frame', BIFACI_DEFAULT_MAX_FRAME],
|
|
6009
|
+
['max_chunk', BIFACI_DEFAULT_MAX_CHUNK],
|
|
6010
|
+
['max_reorder_buffer', BIFACI_DEFAULT_MAX_REORDER_BUFFER],
|
|
6011
|
+
['version', BIFACI_PROTOCOL_VERSION],
|
|
6012
|
+
],
|
|
6013
|
+
};
|
|
6014
|
+
const frame = {
|
|
6015
|
+
__map: [
|
|
6016
|
+
[BIFACI_KEY_VERSION, BIFACI_PROTOCOL_VERSION],
|
|
6017
|
+
[BIFACI_KEY_FRAME_TYPE, BIFACI_FRAME_TYPE_HELLO],
|
|
6018
|
+
[BIFACI_KEY_ID, 0],
|
|
6019
|
+
[BIFACI_KEY_SEQ, 0],
|
|
6020
|
+
[BIFACI_KEY_META, meta],
|
|
6021
|
+
],
|
|
6022
|
+
};
|
|
6023
|
+
const body = cborEncode(frame);
|
|
6024
|
+
const prefix = Buffer.alloc(4);
|
|
6025
|
+
prefix.writeUInt32BE(body.length, 0);
|
|
6026
|
+
return Buffer.concat([prefix, body]);
|
|
6027
|
+
}
|
|
6028
|
+
|
|
6029
|
+
/**
|
|
6030
|
+
* Probe a cartridge binary for its capability surface. Spawns the binary,
|
|
6031
|
+
* performs the bifaci HELLO handshake (engine sends first), parses the
|
|
6032
|
+
* manifest from the cartridge's HELLO response, then kills the process. A
|
|
6033
|
+
* binary that fails to spawn, fails HELLO, or returns an unparseable manifest
|
|
6034
|
+
* is an error — the caller surfaces it as HandshakeFailed.
|
|
6035
|
+
*
|
|
6036
|
+
* Mirrors capdag::cartridge_discovery::probe_cartridge_cap_groups. The handshake
|
|
6037
|
+
* is the real length-prefixed CBOR frame exchange (capdag::bifaci::io); a
|
|
6038
|
+
* non-bifaci binary closes its pipes without emitting a HELLO frame, so the
|
|
6039
|
+
* read of the length-prefixed response hits EOF and the probe fails — exactly
|
|
6040
|
+
* as it must.
|
|
6041
|
+
*
|
|
6042
|
+
* @param {string} entryPath - Absolute path to the entry binary.
|
|
6043
|
+
* @returns {Promise<Array<Object>>} The manifest's cap_groups.
|
|
6044
|
+
* @throws {Error} when the handshake/probe fails.
|
|
6045
|
+
*/
|
|
6046
|
+
function probeCartridgeCapGroups(entryPath) {
|
|
6047
|
+
const { spawn } = require('child_process');
|
|
6048
|
+
return new Promise((resolve, reject) => {
|
|
6049
|
+
let child;
|
|
6050
|
+
try {
|
|
6051
|
+
child = spawn(entryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
6052
|
+
} catch (e) {
|
|
6053
|
+
reject(new Error(`Failed to spawn cartridge ${entryPath}: ${e.message}`));
|
|
6054
|
+
return;
|
|
6055
|
+
}
|
|
6056
|
+
|
|
6057
|
+
let settled = false;
|
|
6058
|
+
let buf = Buffer.alloc(0);
|
|
6059
|
+
let timer = null;
|
|
6060
|
+
|
|
6061
|
+
const finish = (err, value) => {
|
|
6062
|
+
if (settled) return;
|
|
6063
|
+
settled = true;
|
|
6064
|
+
if (timer) clearTimeout(timer);
|
|
6065
|
+
try { child.kill('SIGKILL'); } catch (_e) { /* already gone */ }
|
|
6066
|
+
if (err) reject(err); else resolve(value);
|
|
6067
|
+
};
|
|
6068
|
+
|
|
6069
|
+
child.on('error', (e) => finish(new Error(`cartridge ${entryPath} spawn error: ${e.message}`)));
|
|
6070
|
+
child.on('close', () => finish(new Error(`cartridge ${entryPath} HELLO failed: connection closed before receiving HELLO`)));
|
|
6071
|
+
|
|
6072
|
+
timer = setTimeout(() => finish(new Error(`cartridge ${entryPath} HELLO failed: timeout`)), 5000);
|
|
6073
|
+
|
|
6074
|
+
// Send our HELLO first (host side).
|
|
6075
|
+
try {
|
|
6076
|
+
child.stdin.write(encodeHelloFrame());
|
|
6077
|
+
} catch (e) {
|
|
6078
|
+
finish(new Error(`cartridge ${entryPath} HELLO failed: ${e.message}`));
|
|
6079
|
+
return;
|
|
6080
|
+
}
|
|
6081
|
+
|
|
6082
|
+
const tryParse = () => {
|
|
6083
|
+
if (buf.length < 4) return;
|
|
6084
|
+
const len = buf.readUInt32BE(0);
|
|
6085
|
+
if (buf.length < 4 + len) return;
|
|
6086
|
+
const body = buf.slice(4, 4 + len);
|
|
6087
|
+
let decoded;
|
|
6088
|
+
try {
|
|
6089
|
+
decoded = cborDecodeAt(body, 0).value;
|
|
6090
|
+
} catch (e) {
|
|
6091
|
+
finish(new Error(`cartridge ${entryPath} HELLO failed: ${e.message}`));
|
|
6092
|
+
return;
|
|
6093
|
+
}
|
|
6094
|
+
if (!(decoded instanceof Map)) {
|
|
6095
|
+
finish(new Error(`cartridge ${entryPath} HELLO failed: expected frame map`));
|
|
6096
|
+
return;
|
|
6097
|
+
}
|
|
6098
|
+
const frameType = decoded.get(BIFACI_KEY_FRAME_TYPE);
|
|
6099
|
+
if (frameType !== BIFACI_FRAME_TYPE_HELLO) {
|
|
6100
|
+
finish(new Error(`cartridge ${entryPath} HELLO failed: expected HELLO, got frame_type ${frameType}`));
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
const meta = decoded.get(BIFACI_KEY_META);
|
|
6104
|
+
const manifestVal = meta instanceof Map ? meta.get('manifest') : undefined;
|
|
6105
|
+
if (!manifestVal || manifestVal.__bytes === undefined) {
|
|
6106
|
+
finish(new Error(`cartridge ${entryPath} HELLO failed: Cartridge HELLO missing required manifest`));
|
|
6107
|
+
return;
|
|
6108
|
+
}
|
|
6109
|
+
let manifest;
|
|
6110
|
+
try {
|
|
6111
|
+
manifest = JSON.parse(Buffer.from(manifestVal.__bytes).toString('utf8'));
|
|
6112
|
+
} catch (e) {
|
|
6113
|
+
finish(new Error(`cartridge ${entryPath} invalid manifest: ${e.message}`));
|
|
6114
|
+
return;
|
|
6115
|
+
}
|
|
6116
|
+
finish(null, Array.isArray(manifest.cap_groups) ? manifest.cap_groups : []);
|
|
6117
|
+
};
|
|
6118
|
+
|
|
6119
|
+
child.stdout.on('data', (chunk) => {
|
|
6120
|
+
buf = Buffer.concat([buf, chunk]);
|
|
6121
|
+
tryParse();
|
|
6122
|
+
});
|
|
6123
|
+
});
|
|
6124
|
+
}
|
|
6125
|
+
|
|
6126
|
+
/**
|
|
6127
|
+
* Discover every cartridge under `{cartridgesRoot}/{slug}/{channel}/`. Scans
|
|
6128
|
+
* EVERY slug folder present on disk (full parity) — the host's baked
|
|
6129
|
+
* `identity.registryUrl` does NOT restrict which slugs are scanned; each
|
|
6130
|
+
* cartridge is validated in place against the slug folder it sits under (the
|
|
6131
|
+
* three-place rule in `CartridgeJson.readFromDir`). The channel folder IS
|
|
6132
|
+
* pinned to the host's channel. An empty/absent scan root yields an empty
|
|
6133
|
+
* roster; a real IO failure reading an existing root is an error.
|
|
6134
|
+
*
|
|
6135
|
+
* Mirrors capdag::cartridge_discovery::discover_cartridges.
|
|
6136
|
+
*
|
|
6137
|
+
* @param {string} cartridgesRoot
|
|
6138
|
+
* @param {DiscoveryIdentity} identity
|
|
6139
|
+
* @returns {Promise<Array<DiscoveredCartridge>>}
|
|
6140
|
+
*/
|
|
6141
|
+
async function discoverCartridges(cartridgesRoot, identity) {
|
|
6142
|
+
const fs = require('fs');
|
|
6143
|
+
const path = require('path');
|
|
6144
|
+
const discovered = [];
|
|
6145
|
+
|
|
6146
|
+
let rootStat;
|
|
6147
|
+
try { rootStat = fs.statSync(cartridgesRoot); } catch (_e) { return discovered; }
|
|
6148
|
+
if (!rootStat.isDirectory()) return discovered;
|
|
6149
|
+
|
|
6150
|
+
let slugEntries;
|
|
6151
|
+
try {
|
|
6152
|
+
slugEntries = fs.readdirSync(cartridgesRoot, { withFileTypes: true });
|
|
6153
|
+
} catch (e) {
|
|
6154
|
+
throw new Error(`read_dir(${cartridgesRoot}): ${e.message}`);
|
|
6155
|
+
}
|
|
6156
|
+
|
|
6157
|
+
for (const slugEntry of slugEntries) {
|
|
6158
|
+
const slugDir = path.join(cartridgesRoot, slugEntry.name);
|
|
6159
|
+
if (!slugEntry.isDirectory()) {
|
|
6160
|
+
// Unmanaged file in cartridges root — only registry-slug / dev
|
|
6161
|
+
// directories belong here. Skipped (logged in the reference).
|
|
6162
|
+
continue;
|
|
6163
|
+
}
|
|
6164
|
+
const expectedSlug = slugEntry.name;
|
|
6165
|
+
const scanRoot = path.join(slugDir, identity.channel);
|
|
6166
|
+
let scanStat;
|
|
6167
|
+
try { scanStat = fs.statSync(scanRoot); } catch (_e) { continue; }
|
|
6168
|
+
if (!scanStat.isDirectory()) continue;
|
|
6169
|
+
await scanChannelRoot(scanRoot, expectedSlug, identity, discovered);
|
|
6170
|
+
}
|
|
6171
|
+
|
|
6172
|
+
return discovered;
|
|
6173
|
+
}
|
|
6174
|
+
|
|
6175
|
+
/**
|
|
6176
|
+
* Scan one `{slug}/{channel}/` root: classify each cartridge name directory's
|
|
6177
|
+
* newest version against the host identity and the slug folder it sits under.
|
|
6178
|
+
* Mirrors capdag::cartridge_discovery::scan_channel_root.
|
|
6179
|
+
*/
|
|
6180
|
+
async function scanChannelRoot(scanRoot, expectedSlug, identity, discovered) {
|
|
6181
|
+
const fs = require('fs');
|
|
6182
|
+
const path = require('path');
|
|
6183
|
+
|
|
6184
|
+
let nameEntries;
|
|
6185
|
+
try {
|
|
6186
|
+
nameEntries = fs.readdirSync(scanRoot, { withFileTypes: true });
|
|
6187
|
+
} catch (e) {
|
|
6188
|
+
throw new Error(`read_dir(${scanRoot}): ${e.message}`);
|
|
6189
|
+
}
|
|
6190
|
+
|
|
6191
|
+
for (const entry of nameEntries) {
|
|
6192
|
+
const nameDir = path.join(scanRoot, entry.name);
|
|
6193
|
+
if (!entry.isDirectory()) continue;
|
|
6194
|
+
|
|
6195
|
+
let subEntries;
|
|
6196
|
+
try {
|
|
6197
|
+
subEntries = fs.readdirSync(nameDir, { withFileTypes: true });
|
|
6198
|
+
} catch (_e) {
|
|
6199
|
+
continue;
|
|
6200
|
+
}
|
|
6201
|
+
|
|
6202
|
+
const versionDirs = [];
|
|
6203
|
+
for (const sub of subEntries) {
|
|
6204
|
+
if (sub.isDirectory()) versionDirs.push(path.join(nameDir, sub.name));
|
|
6205
|
+
}
|
|
6206
|
+
if (versionDirs.length === 0) continue;
|
|
6207
|
+
|
|
6208
|
+
// Prefer the newest version (lexical-descending on the version folder name).
|
|
6209
|
+
versionDirs.sort((a, b) => {
|
|
6210
|
+
const va = path.basename(a);
|
|
6211
|
+
const vb = path.basename(b);
|
|
6212
|
+
return vb < va ? -1 : vb > va ? 1 : 0;
|
|
6213
|
+
});
|
|
6214
|
+
const versionDir = versionDirs[0];
|
|
6215
|
+
|
|
6216
|
+
const pathDerivedName = path.basename(nameDir);
|
|
6217
|
+
const pathDerivedVersion = path.basename(versionDir);
|
|
6218
|
+
const detectedAt = unixSecondsNow();
|
|
6219
|
+
|
|
6220
|
+
// read_from_dir enforces the three-place rule against the ACTUAL slug
|
|
6221
|
+
// folder (expectedSlug): the cartridge's declared registry_url must hash
|
|
6222
|
+
// to it. A slug mismatch is a BadInstallation; an unreadable/garbage
|
|
6223
|
+
// cartridge.json is ManifestInvalid. Both surfaced, never hosted.
|
|
6224
|
+
let cj;
|
|
6225
|
+
try {
|
|
6226
|
+
cj = CartridgeJson.readFromDir(versionDir, expectedSlug);
|
|
6227
|
+
} catch (e) {
|
|
6228
|
+
const kind = (e instanceof CartridgeJsonError && e.kind === CartridgeJsonErrorKind.REGISTRY_SLUG_MISMATCH)
|
|
6229
|
+
? CartridgeAttachmentErrorKind.BAD_INSTALLATION
|
|
6230
|
+
: CartridgeAttachmentErrorKind.MANIFEST_INVALID;
|
|
6231
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6232
|
+
version_dir: versionDir,
|
|
6233
|
+
id: pathDerivedName,
|
|
6234
|
+
channel: identity.channel,
|
|
6235
|
+
registry_url: identity.registryUrl,
|
|
6236
|
+
version: pathDerivedVersion,
|
|
6237
|
+
error: new CartridgeAttachmentError(
|
|
6238
|
+
kind,
|
|
6239
|
+
`cartridge.json failed to load under slug '${expectedSlug}': ${e.message}`,
|
|
6240
|
+
detectedAt
|
|
6241
|
+
),
|
|
6242
|
+
}));
|
|
6243
|
+
continue;
|
|
6244
|
+
}
|
|
6245
|
+
|
|
6246
|
+
if (cj.channel !== identity.channel) {
|
|
6247
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6248
|
+
version_dir: versionDir,
|
|
6249
|
+
id: cj.name,
|
|
6250
|
+
channel: cj.channel,
|
|
6251
|
+
registry_url: cj.registryUrl,
|
|
6252
|
+
version: cj.version,
|
|
6253
|
+
error: new CartridgeAttachmentError(
|
|
6254
|
+
CartridgeAttachmentErrorKind.BAD_INSTALLATION,
|
|
6255
|
+
`Channel mismatch: cartridge declares '${cj.channel}' but host is pinned to '${identity.channel}'. Release and nightly artefacts must not mix.`,
|
|
6256
|
+
detectedAt
|
|
6257
|
+
),
|
|
6258
|
+
}));
|
|
6259
|
+
continue;
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
// Scheme check is per-cartridge: a dev cartridge (null registry_url) never
|
|
6263
|
+
// reaches here; a registry cartridge must use https (devMode=false).
|
|
6264
|
+
if (cj.registryUrl !== null && cj.registryUrl !== undefined) {
|
|
6265
|
+
const res = validateRegistryUrlScheme(cj.registryUrl, false);
|
|
6266
|
+
if (res.kind === RegistryUrlSchemeResultKind.NON_HTTPS) {
|
|
6267
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6268
|
+
version_dir: versionDir,
|
|
6269
|
+
id: cj.name,
|
|
6270
|
+
channel: cj.channel,
|
|
6271
|
+
registry_url: cj.registryUrl,
|
|
6272
|
+
version: cj.version,
|
|
6273
|
+
error: new CartridgeAttachmentError(
|
|
6274
|
+
CartridgeAttachmentErrorKind.INCOMPATIBLE,
|
|
6275
|
+
`registry_url uses '${res.scheme}' scheme, must be https in non-dev builds. Rebuild the cartridge with an https registry URL.`,
|
|
6276
|
+
detectedAt
|
|
6277
|
+
),
|
|
6278
|
+
}));
|
|
6279
|
+
continue;
|
|
6280
|
+
}
|
|
6281
|
+
if (res.kind === RegistryUrlSchemeResultKind.NOT_A_URL) {
|
|
6282
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6283
|
+
version_dir: versionDir,
|
|
6284
|
+
id: cj.name,
|
|
6285
|
+
channel: cj.channel,
|
|
6286
|
+
registry_url: cj.registryUrl,
|
|
6287
|
+
version: cj.version,
|
|
6288
|
+
error: new CartridgeAttachmentError(
|
|
6289
|
+
CartridgeAttachmentErrorKind.INCOMPATIBLE,
|
|
6290
|
+
`registry_url '${res.url}' is not a well-formed URL.`,
|
|
6291
|
+
detectedAt
|
|
6292
|
+
),
|
|
6293
|
+
}));
|
|
6294
|
+
continue;
|
|
6295
|
+
}
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
if (cj.fabricManifestVersion !== identity.fabricManifestVersion) {
|
|
6299
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6300
|
+
version_dir: versionDir,
|
|
6301
|
+
id: cj.name,
|
|
6302
|
+
channel: cj.channel,
|
|
6303
|
+
registry_url: cj.registryUrl,
|
|
6304
|
+
version: cj.version,
|
|
6305
|
+
error: new CartridgeAttachmentError(
|
|
6306
|
+
CartridgeAttachmentErrorKind.FABRIC_MANIFEST_VERSION_MISMATCH,
|
|
6307
|
+
`Cartridge built against fabric manifest version ${cj.fabricManifestVersion}, but host is pinned to ${identity.fabricManifestVersion}. Rebuild the cartridge with MFR_FABRIC_MANIFEST_VERSION=${identity.fabricManifestVersion}.`,
|
|
6308
|
+
detectedAt
|
|
6309
|
+
),
|
|
6310
|
+
}));
|
|
6311
|
+
continue;
|
|
6312
|
+
}
|
|
6313
|
+
|
|
6314
|
+
// Bundled-provider integrity. A cartridge marked installed_from=bundle is
|
|
6315
|
+
// shipped INSIDE this build and has no upstream registry to verify against,
|
|
6316
|
+
// so it needs its own integrity proof. Platform-split: on macOS the OS
|
|
6317
|
+
// code-signature is the guard (baked-hash verification intentionally
|
|
6318
|
+
// skipped); on non-macOS the on-disk directory must hash to the baked
|
|
6319
|
+
// value, else BadInstallation.
|
|
6320
|
+
if (cj.installedFrom === CartridgeInstallSource.BUNDLE) {
|
|
6321
|
+
if (process.platform !== 'darwin') {
|
|
6322
|
+
const reason = verifyBundledProviderHash(cj.name, cj.version, versionDir);
|
|
6323
|
+
if (reason !== null) {
|
|
6324
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6325
|
+
version_dir: versionDir,
|
|
6326
|
+
id: cj.name,
|
|
6327
|
+
channel: cj.channel,
|
|
6328
|
+
registry_url: cj.registryUrl,
|
|
6329
|
+
version: cj.version,
|
|
6330
|
+
error: new CartridgeAttachmentError(
|
|
6331
|
+
CartridgeAttachmentErrorKind.BAD_INSTALLATION,
|
|
6332
|
+
`bundled provider integrity check failed: ${reason}`,
|
|
6333
|
+
detectedAt
|
|
6334
|
+
),
|
|
6335
|
+
}));
|
|
6336
|
+
continue;
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
}
|
|
6340
|
+
|
|
6341
|
+
const entryPoint = cj.resolveEntryPoint(versionDir);
|
|
6342
|
+
try {
|
|
6343
|
+
const capGroups = await probeCartridgeCapGroups(entryPoint);
|
|
6344
|
+
discovered.push(DiscoveredCartridge.directory({
|
|
6345
|
+
entry_point: entryPoint,
|
|
6346
|
+
version_dir: versionDir,
|
|
6347
|
+
id: cj.name,
|
|
6348
|
+
channel: cj.channel,
|
|
6349
|
+
registry_url: cj.registryUrl,
|
|
6350
|
+
version: cj.version,
|
|
6351
|
+
cap_groups: capGroups,
|
|
6352
|
+
}));
|
|
6353
|
+
} catch (e) {
|
|
6354
|
+
discovered.push(DiscoveredCartridge.incompatible({
|
|
6355
|
+
version_dir: versionDir,
|
|
6356
|
+
id: cj.name,
|
|
6357
|
+
channel: cj.channel,
|
|
6358
|
+
registry_url: cj.registryUrl,
|
|
6359
|
+
version: cj.version,
|
|
6360
|
+
error: new CartridgeAttachmentError(
|
|
6361
|
+
CartridgeAttachmentErrorKind.HANDSHAKE_FAILED,
|
|
6362
|
+
`HELLO handshake / cap discovery probe failed: ${e.message}`,
|
|
6363
|
+
detectedAt
|
|
6364
|
+
),
|
|
6365
|
+
}));
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
}
|
|
6369
|
+
|
|
5196
6370
|
// ============================================================================
|
|
5197
6371
|
// Machine Notation — compact, round-trippable DAG path identifiers
|
|
5198
6372
|
//
|
|
@@ -6416,7 +7590,7 @@ module.exports = {
|
|
|
6416
7590
|
MEDIA_OBJECT,
|
|
6417
7591
|
// List types
|
|
6418
7592
|
MEDIA_LIST,
|
|
6419
|
-
MEDIA_TEXTABLE_LIST,
|
|
7593
|
+
MEDIA_TEXTABLE_LIST: MEDIA_STRING_LIST,
|
|
6420
7594
|
MEDIA_STRING_LIST,
|
|
6421
7595
|
MEDIA_INTEGER_LIST,
|
|
6422
7596
|
MEDIA_NUMBER_LIST,
|
|
@@ -6540,6 +7714,28 @@ module.exports = {
|
|
|
6540
7714
|
CartridgeAttachmentError,
|
|
6541
7715
|
CartridgeRuntimeStats,
|
|
6542
7716
|
InstalledCartridgeRecord,
|
|
7717
|
+
// Host-compatibility resolution
|
|
7718
|
+
hostPlatform,
|
|
7719
|
+
CompatStatus,
|
|
7720
|
+
primaryPackage,
|
|
7721
|
+
CartridgeCompatibilityResolution,
|
|
7722
|
+
// Build-env registry identity
|
|
7723
|
+
registryUrlFromBuildEnv,
|
|
7724
|
+
// Cartridge discovery
|
|
7725
|
+
slugForSync,
|
|
7726
|
+
CartridgeInstallSource,
|
|
7727
|
+
RegistryUrlSchemeResultKind,
|
|
7728
|
+
validateRegistryUrlScheme,
|
|
7729
|
+
CartridgeJsonErrorKind,
|
|
7730
|
+
CartridgeJsonError,
|
|
7731
|
+
CartridgeJson,
|
|
7732
|
+
hashCartridgeDirectory,
|
|
7733
|
+
BUNDLED_PROVIDER_HASHES,
|
|
7734
|
+
verifyBundledProviderHash,
|
|
7735
|
+
DiscoveryIdentity,
|
|
7736
|
+
DiscoveredCartridge,
|
|
7737
|
+
probeCartridgeCapGroups,
|
|
7738
|
+
discoverCartridges,
|
|
6543
7739
|
// Registry slug
|
|
6544
7740
|
DEV_SLUG,
|
|
6545
7741
|
SLUG_HEX_LEN,
|