arcane-os 0.1.0-dev.5 → 0.1.0

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/src/workspace.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {createHash} from 'node:crypto';
1
2
  import {lstat,readFile,readdir,realpath} from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import {
@@ -9,6 +10,11 @@ import {
9
10
  SDK_NAME as EXPECTED_SDK_NAME,
10
11
  SDK_VERSION as EXPECTED_SDK_VERSION
11
12
  } from './constants.mjs';
13
+ import {inspectImportMapHtml} from './import-map.mjs';
14
+ import {
15
+ SDK_BROWSER_RUNTIME_CONTENT_SHA256,
16
+ SDK_BROWSER_RUNTIME_MANIFEST_SHA256
17
+ } from './sdk-browser-runtime.mjs';
12
18
 
13
19
  const APP_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
14
20
  const SHA256_PATTERN=/^[a-f0-9]{64}$/;
@@ -65,20 +71,89 @@ async function assertRealDirectory(directory,label){
65
71
  if(info.isSymbolicLink()||!info.isDirectory())fail(`${label} must be a real directory: ${directory}.`);
66
72
  }
67
73
 
74
+ function sameDirectoryIdentity(left,right){
75
+ return left.device===right.device&&left.inode===right.inode;
76
+ }
77
+
78
+ function sameDirectoryPath(left,right){
79
+ const normalize=value=>{
80
+ const resolved=path.resolve(value);
81
+ return process.platform==='win32'?resolved.toLocaleLowerCase('en-US'):resolved;
82
+ };
83
+ return normalize(left)===normalize(right);
84
+ }
85
+
86
+ async function captureRealDirectoryIdentity(directory,label){
87
+ const requested=path.resolve(directory);
88
+ let requestedInfo;
89
+ try{requestedInfo=await lstat(requested,{bigint:true});}
90
+ catch(error){
91
+ if(error?.code==='ENOENT')fail(`${label} does not exist: ${requested}.`);
92
+ throw error;
93
+ }
94
+ if(requestedInfo.isSymbolicLink()||!requestedInfo.isDirectory()){
95
+ fail(`${label} must be a real directory: ${requested}.`);
96
+ }
97
+ const canonical=await realpath(requested);
98
+ const canonicalInfo=await lstat(canonical,{bigint:true});
99
+ const requestedIdentity=Object.freeze({
100
+ device:requestedInfo.dev,
101
+ inode:requestedInfo.ino
102
+ });
103
+ const canonicalIdentity=Object.freeze({
104
+ device:canonicalInfo.dev,
105
+ inode:canonicalInfo.ino
106
+ });
107
+ if(canonicalInfo.isSymbolicLink()||!canonicalInfo.isDirectory()
108
+ ||!sameDirectoryIdentity(requestedIdentity,canonicalIdentity)){
109
+ fail(`${label} must resolve to one physical directory: ${requested}.`);
110
+ }
111
+ return Object.freeze({
112
+ requested,
113
+ requestedIdentity,
114
+ canonical,
115
+ identity:canonicalIdentity
116
+ });
117
+ }
118
+
119
+ async function assertRealDirectoryIdentity(captured,label){
120
+ let canonical;
121
+ let requestedInfo;
122
+ let canonicalInfo;
123
+ try{
124
+ [canonical,requestedInfo,canonicalInfo]=await Promise.all([
125
+ realpath(captured.requested),
126
+ lstat(captured.requested,{bigint:true}),
127
+ lstat(captured.canonical,{bigint:true})
128
+ ]);
129
+ }catch(error){
130
+ if(error?.code==='ENOENT'){
131
+ fail(`${label} changed while focused validation was active.`,
132
+ 'ARCANE_INTEGRITY_FAILED');
133
+ }
134
+ throw error;
135
+ }
136
+ const requestedIdentity={device:requestedInfo.dev,inode:requestedInfo.ino};
137
+ const canonicalIdentity={device:canonicalInfo.dev,inode:canonicalInfo.ino};
138
+ if(!sameDirectoryPath(canonical,captured.canonical)
139
+ ||requestedInfo.isSymbolicLink()||!requestedInfo.isDirectory()
140
+ ||canonicalInfo.isSymbolicLink()||!canonicalInfo.isDirectory()
141
+ ||!sameDirectoryIdentity(requestedIdentity,captured.requestedIdentity)
142
+ ||!sameDirectoryIdentity(requestedIdentity,captured.identity)
143
+ ||!sameDirectoryIdentity(canonicalIdentity,captured.identity)){
144
+ fail(`${label} changed while focused validation was active.`,'ARCANE_INTEGRITY_FAILED');
145
+ }
146
+ }
147
+
68
148
  function classifyRootConfig(config){
69
149
  const validated=validatePackagerRootConfig(config,ROOT_CONFIG_NAME);
70
150
  const routes=validated.sharedPayloads['browser-runtime'];
71
151
  if(!Array.isArray(routes))fail(`${ROOT_CONFIG_NAME} must define browser-runtime routes.`);
72
152
  const external=[
73
153
  {
74
- source:'node_modules/arcane-os/runtime/arcane',
154
+ source:'arcane',
75
155
  destination:'arcane',
76
- include:['components','css','entities','img','modules','security']
77
- },
78
- {
79
- source:'node_modules/arcane-os/runtime/strong-type',
80
- destination:'node_modules/strong-type',
81
- include:['index.js','licence','package.json']
156
+ include:['components','css','dependencies','entities','img','modules','sdk','security']
82
157
  },
83
158
  {
84
159
  source:'node_modules/arcane-os',
@@ -87,6 +162,13 @@ function classifyRootConfig(config){
87
162
  }
88
163
  ];
89
164
  const integrated=[
165
+ {
166
+ source:'arcane',
167
+ destination:'arcane',
168
+ include:['components','css','dependencies','entities','img','modules','sdk','security']
169
+ }
170
+ ];
171
+ const integratedLegacy=[
90
172
  {
91
173
  source:'arcane',
92
174
  destination:'arcane',
@@ -106,14 +188,20 @@ function classifyRootConfig(config){
106
188
  &&Array.isArray(route.exclude)&&route.exclude.length===0;
107
189
  });
108
190
  let workspaceMode;
191
+ let browserRuntimeLayout;
109
192
  if(matches(external)){
110
193
  workspaceMode='external';
194
+ browserRuntimeLayout='physical-v1';
111
195
  }else if(matches(integrated)){
112
196
  workspaceMode='integrated';
197
+ browserRuntimeLayout='physical-v1';
198
+ }else if(matches(integratedLegacy)){
199
+ workspaceMode='integrated';
200
+ browserRuntimeLayout='integrated-legacy';
113
201
  }else{
114
202
  fail(`${ROOT_CONFIG_NAME} browser-runtime routes must match the external SDK or integrated Arcane workspace contract.`);
115
203
  }
116
- return Object.freeze({...validated,workspaceMode});
204
+ return Object.freeze({...validated,workspaceMode,browserRuntimeLayout});
117
205
  }
118
206
 
119
207
  async function discoverAppsInRoot(root,config){
@@ -220,12 +308,57 @@ export async function resolveWorkspace({workspaceRoot=process.cwd(),appId}={}){
220
308
  }
221
309
 
222
310
  function validateLock(lock){
223
- if(!isObject(lock)||lock.schemaVersion!==1||!isObject(lock.sdk)
311
+ const browser=lock?.sdkBrowserRuntime;
312
+ const browserSource=browser?.source;
313
+ const dependencies=browserSource?.dependencies;
314
+ const exactKeys=(value,keys)=>isObject(value)
315
+ &&Object.keys(value).sort().join('\0')===[...keys].sort().join('\0');
316
+ const expectedDependencies=[
317
+ {
318
+ name:'event-pubsub',
319
+ version:'6.1.0',
320
+ resolved:'https://registry.npmjs.org/event-pubsub/-/event-pubsub-6.1.0.tgz',
321
+ integrity:'sha512-FEMlhTxwqGM0hztTixG6FhVFXqp7Eq1ltk5mSreK6Mhy3xWWpLAzEUR6OMvMdNqT3jgSxA8JDhnhyAG3X4Xy7Q=='
322
+ },
323
+ {
324
+ name:'strong-type',
325
+ version:'2.0.0',
326
+ resolved:'https://registry.npmjs.org/strong-type/-/strong-type-2.0.0.tgz',
327
+ integrity:'sha512-HHrY9qYC7yn+5mlewiI3k9RQM9gZqGQsqbomZcd10Ks0h4RlX01nnkWbCe4AsVPCI6KaFvpkWm1nHMD+Ykup6g=='
328
+ }
329
+ ];
330
+ const dependenciesMatch=Array.isArray(dependencies)&&dependencies.length===2
331
+ &&dependencies.every((actual,index)=>{
332
+ const expected=expectedDependencies[index];
333
+ return exactKeys(actual,['name','version','resolved','integrity'])
334
+ &&actual.name===expected.name&&actual.version===expected.version
335
+ &&actual.resolved===expected.resolved&&actual.integrity===expected.integrity;
336
+ });
337
+ if(!exactKeys(lock,['schemaVersion','sdk','runtime','sdkBrowserRuntime','protocols'])
338
+ ||lock.schemaVersion!==1
339
+ ||!exactKeys(lock.sdk,['name','version'])
224
340
  ||lock.sdk.name!==EXPECTED_SDK_NAME||lock.sdk.version!==EXPECTED_SDK_VERSION
225
- ||!isObject(lock.runtime)||!SHA256_PATTERN.test(lock.runtime.contentSha256)
341
+ ||!exactKeys(lock.runtime,['manifest','contentSha256','upstreamCommit'])
342
+ ||!SHA256_PATTERN.test(lock.runtime.contentSha256)
226
343
  ||!/^([a-f0-9]{40})$/.test(lock.runtime.upstreamCommit)
227
344
  ||lock.runtime.manifest!=='node_modules/arcane-os/runtime/ARCANE_RUNTIME_RELEASE.json'
228
- ||!isObject(lock.protocols)||lock.protocols.arcane!=='arcane/1'
345
+ ||!exactKeys(browser,[
346
+ 'manifest','manifestSha256','contentSha256','builder','sdkVersion','source'
347
+ ])
348
+ ||browser.manifest!=='node_modules/arcane-os/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json'
349
+ ||browser.manifestSha256!==SDK_BROWSER_RUNTIME_MANIFEST_SHA256
350
+ ||browser.contentSha256!==SDK_BROWSER_RUNTIME_CONTENT_SHA256
351
+ ||browser.builder!=='arcane-sdk-browser-runtime-v1'
352
+ ||browser.sdkVersion!==EXPECTED_SDK_VERSION
353
+ ||!exactKeys(browserSource,[
354
+ 'authority','repository','protocol','browserEntry','dependencies'
355
+ ])||browserSource.authority!=='arcane-os-sdk'
356
+ ||browserSource.repository!=='https://github.com/TheWizardNexus/arcane-os-sdk.git'
357
+ ||browserSource.protocol!=='arcane-sdk-browser-runtime/1'
358
+ ||browserSource.browserEntry!=='arcane-os/event-manager'
359
+ ||!dependenciesMatch
360
+ ||!exactKeys(lock.protocols,['arcane','cliEvents','targetAdapter'])
361
+ ||lock.protocols.arcane!=='arcane/1'
229
362
  ||lock.protocols.cliEvents!=='arcane-cli-events/1'
230
363
  ||lock.protocols.targetAdapter!=='arcane-target-adapter/1'){
231
364
  fail('arcane.lock.json is incompatible with this SDK. Run arcane init only after reviewing missing files; existing locks are never overwritten.');
@@ -233,33 +366,94 @@ function validateLock(lock){
233
366
  return lock;
234
367
  }
235
368
 
236
- function assertHtmlContract(source,appId,{entry='index.html',strictStyles=true}={}){
369
+ function sameBrowserRuntimeSource(actual,pinned){
370
+ const exactKeys=(value,keys)=>isObject(value)
371
+ &&Object.keys(value).sort().join('\0')===[...keys].sort().join('\0');
372
+ const keys=['authority','repository','protocol','browserEntry','dependencies'];
373
+ if(!exactKeys(actual,keys)||!exactKeys(pinned,keys)
374
+ ||actual.authority!==pinned.authority
375
+ ||actual.repository!==pinned.repository
376
+ ||actual.protocol!==pinned.protocol
377
+ ||actual.browserEntry!==pinned.browserEntry
378
+ ||!Array.isArray(actual.dependencies)||!Array.isArray(pinned.dependencies)
379
+ ||actual.dependencies.length!==pinned.dependencies.length){
380
+ return false;
381
+ }
382
+ const dependencyKeys=['name','version','resolved','integrity'];
383
+ return actual.dependencies.every((dependency,index)=>{
384
+ const expected=pinned.dependencies[index];
385
+ return exactKeys(dependency,dependencyKeys)&&exactKeys(expected,dependencyKeys)
386
+ &&dependency.name===expected.name&&dependency.version===expected.version
387
+ &&dependency.resolved===expected.resolved
388
+ &&dependency.integrity===expected.integrity;
389
+ });
390
+ }
391
+
392
+ function assertHtmlContract(source,appId,{
393
+ entry='index.html',
394
+ strictStyles=true,
395
+ allowMissingManagedImportMap=false
396
+ }={}){
237
397
  const entryLabel=`apps/${appId}/${entry}`;
238
- if(!new RegExp(`<meta\\s+name=["']arcane-app-id["']\\s+content=["']${appId}["']`).test(source)){
239
- fail(`${entryLabel} must declare matching arcane-app-id metadata.`);
240
- }
241
- if(!/<base\s+href=["']\.\.\/\.\.\/["']/.test(source)){
242
- fail(`${entryLabel} must declare <base href="../../">.`);
243
- }
244
- const theme=source.indexOf('./arcane/css/theme.css');
245
- const primitives=source.indexOf('./arcane/css/primitives.css');
246
- const appStyleMatches=[...source.matchAll(new RegExp(`(?:\\./|/)apps/${appId.replaceAll('-','\\-')}/[^"']+\\.css(?:\\?[^"']*)?(?=["'])`, 'g'))];
247
- const appStyle=appStyleMatches.length?Math.min(...appStyleMatches.map(match=>match.index)):-1;
248
- const bootstrap=source.indexOf('./arcane/modules/ThemeBootstrap.js');
249
- const appModuleMatches=[...source.matchAll(new RegExp(`(?:\\./|/)apps/${appId.replaceAll('-','\\-')}/[^"']+\\.(?:js|mjs)(?:\\?[^"']*)?(?=["'])`, 'g'))];
250
- const appModule=appModuleMatches.length?Math.min(...appModuleMatches.map(match=>match.index)):-1;
398
+ const htmlContract=inspectImportMapHtml(source);
399
+ const appIdMetadata=htmlContract.metas.filter(meta=>meta.name==='arcane-app-id');
400
+ if(appIdMetadata.length!==1||appIdMetadata[0].content!==appId){
401
+ fail(`${entryLabel} must declare exactly one active matching arcane-app-id metadata element.`);
402
+ }
403
+ if(htmlContract.bases.length!==1||htmlContract.bases[0].href!=='../../'){
404
+ fail(`${entryLabel} must declare exactly one active <base href="../../">.`);
405
+ }
406
+ const resourcePath=value=>value.split(/[?#]/u,1)[0];
407
+ const styles=htmlContract.links.filter(link=>link.rel
408
+ .split(/[\t\n\f\r ]+/u).includes('stylesheet'));
409
+ const positionOfStyle=expected=>styles.find(link=>resourcePath(link.href)===expected)?.start??-1;
410
+ const theme=positionOfStyle('./arcane/css/theme.css');
411
+ const primitives=positionOfStyle('./arcane/css/primitives.css');
412
+ const escapedAppId=appId.replace(/[.*+?^${}()|[\]\\]/gu,'\\$&');
413
+ const appStyle=styles.find(link=>new RegExp(
414
+ `^(?:\\./|/)apps/${escapedAppId}/[^/]+\\.css$`,
415
+ 'u'
416
+ ).test(resourcePath(link.href)))?.start??-1;
417
+ const modules=htmlContract.scripts.filter(script=>script.type==='module'&&script.src);
418
+ const bootstrap=modules.find(script=>resourcePath(script.src)
419
+ ==='./arcane/modules/ThemeBootstrap.js')?.start??-1;
420
+ if(htmlContract.managedMaps.length>1){
421
+ fail(`${entryLabel} must contain at most one active managed Arcane import map.`);
422
+ }
423
+ const managedImportMap=htmlContract.managedMaps[0]?.start??-1;
424
+ const firstModule=htmlContract.firstModulePosition;
425
+ const appModule=modules.find(script=>new RegExp(
426
+ `^(?:\\./|/)apps/${escapedAppId}/.+\\.(?:js|mjs)$`,
427
+ 'u'
428
+ ).test(resourcePath(script.src)))?.start??-1;
251
429
  if(theme<0){
252
430
  fail(`${entryLabel} must load the shared Arcane theme.css.`);
253
431
  }
432
+ if(appModule<0){
433
+ fail(`${entryLabel} must load an active app-local module script.`);
434
+ }
254
435
  if(strictStyles&&(primitives<=theme||appStyle<=primitives)){
255
436
  fail(`${entryLabel} must load theme.css, primitives.css, and app CSS in that order.`);
256
437
  }
257
438
  if(!strictStyles&&((primitives>=0&&primitives<=theme)||(appStyle>=0&&appStyle<=theme))){
258
439
  fail(`${entryLabel} must load shared and app CSS after theme.css.`);
259
440
  }
260
- if(bootstrap<0||(appModule>=0&&appModule<=bootstrap)){
441
+ if(bootstrap>=0&&appModule>=0&&appModule<=bootstrap){
261
442
  fail(`${entryLabel} must load ThemeBootstrap.js before app-local module scripts.`);
262
443
  }
444
+ if((managedImportMap>=0&&htmlContract.bases[0].end>managedImportMap)
445
+ ||(firstModule>=0&&htmlContract.bases[0].end>firstModule)){
446
+ fail(`${entryLabel} must place its base element before import maps and module loads.`);
447
+ }
448
+ if(bootstrap<0&&(
449
+ (managedImportMap<0&&!allowMissingManagedImportMap)
450
+ ||(managedImportMap>=0&&appModule>=0&&appModule<=managedImportMap)
451
+ )){
452
+ fail(
453
+ `${entryLabel} must install its managed Arcane import map before app-local `
454
+ +'module scripts when ThemeBootstrap.js is imported by name.'
455
+ );
456
+ }
263
457
  }
264
458
 
265
459
  export async function validateDiscoveredApplication({
@@ -267,19 +461,31 @@ export async function validateDiscoveredApplication({
267
461
  workspaceMode,
268
462
  workspaceConfig,
269
463
  app,
464
+ allowMissingManagedImportMap=false,
270
465
  signal,
271
466
  onEvent
272
467
  }={}){
273
468
  throwIfAborted(signal);
274
- if(!app||typeof app.appId!=='string'||typeof app.appRoot!=='string'
469
+ if(!app||typeof app.appId!=='string'||!APP_ID_PATTERN.test(app.appId)
470
+ ||typeof app.appRoot!=='string'
275
471
  ||!app.manifest||!app.descriptor){
276
472
  fail('A discovered Arcane application is required for focused validation.');
277
473
  }
278
- const canonicalWorkspaceRoot=path.resolve(workspaceRoot);
474
+ const capturedWorkspace=await captureRealDirectoryIdentity(workspaceRoot,'Workspace');
475
+ const canonicalWorkspaceRoot=capturedWorkspace.canonical;
476
+ const capturedAppsRoot=await captureRealDirectoryIdentity(
477
+ path.join(canonicalWorkspaceRoot,'apps'),
478
+ 'Workspace apps root'
479
+ );
279
480
  const expectedAppRoot=path.join(canonicalWorkspaceRoot,'apps',app.appId);
280
- if(path.resolve(app.appRoot)!==expectedAppRoot){
481
+ const [capturedExpectedApp,capturedDiscoveredApp]=await Promise.all([
482
+ captureRealDirectoryIdentity(expectedAppRoot,`apps/${app.appId}`),
483
+ captureRealDirectoryIdentity(app.appRoot,`Discovered app ${app.appId}`)
484
+ ]);
485
+ if(!sameDirectoryIdentity(capturedExpectedApp.identity,capturedDiscoveredApp.identity)){
281
486
  fail(`Discovered app ${app.appId} does not belong to the selected workspace.`);
282
487
  }
488
+ const canonicalAppRoot=capturedExpectedApp.canonical;
283
489
  let config=workspaceConfig;
284
490
  if(!config){
285
491
  const profile=await inspectWorkspaceProfile(canonicalWorkspaceRoot);
@@ -291,7 +497,7 @@ export async function validateDiscoveredApplication({
291
497
  if(!isObject(config?.sharedPayloads)){
292
498
  fail('The selected Arcane workspace configuration is unavailable for focused validation.');
293
499
  }
294
- const configPath=path.join(app.appRoot,APP_CONFIG_NAME);
500
+ const configPath=path.join(canonicalAppRoot,APP_CONFIG_NAME);
295
501
  const rawManifest=await readJson(
296
502
  configPath,
297
503
  `apps/${app.appId}/${APP_CONFIG_NAME}`
@@ -302,7 +508,7 @@ export async function validateDiscoveredApplication({
302
508
  }
303
509
  const loadedDescriptor=await loadAppDescriptor({
304
510
  workspaceRoot:canonicalWorkspaceRoot,
305
- appRoot:app.appRoot,
511
+ appRoot:canonicalAppRoot,
306
512
  appId:app.appId,
307
513
  packageManifest:rawManifest
308
514
  });
@@ -315,27 +521,35 @@ export async function validateDiscoveredApplication({
315
521
  }
316
522
  const freshApp=Object.freeze({
317
523
  appId:app.appId,
318
- appRoot:app.appRoot,
524
+ appRoot:canonicalAppRoot,
319
525
  manifest,
320
526
  descriptor,
321
527
  descriptorSource:loadedDescriptor.source,
322
528
  descriptorPath:loadedDescriptor.descriptorPath
323
529
  });
324
- const entryPath=path.join(app.appRoot,manifest.entry);
530
+ const entryPath=path.join(canonicalAppRoot,manifest.entry);
325
531
  const info=await lstat(entryPath);
326
532
  if(info.isSymbolicLink()||!info.isFile()){
327
533
  fail(`apps/${app.appId}/${manifest.entry} must be a real file.`);
328
534
  }
329
535
  assertHtmlContract(await readFile(entryPath,'utf8'),app.appId,{
330
536
  entry:manifest.entry,
331
- strictStyles:workspaceMode==='external'
537
+ strictStyles:workspaceMode==='external',
538
+ allowMissingManagedImportMap
332
539
  });
540
+ const assertCapturedDirectories=()=>Promise.all([
541
+ assertRealDirectoryIdentity(capturedWorkspace,'Workspace'),
542
+ assertRealDirectoryIdentity(capturedAppsRoot,'Workspace apps root'),
543
+ assertRealDirectoryIdentity(capturedExpectedApp,`apps/${app.appId}`),
544
+ assertRealDirectoryIdentity(capturedDiscoveredApp,`Discovered app ${app.appId}`)
545
+ ]);
546
+ await assertCapturedDirectories();
333
547
  const receipt=Object.freeze({
334
548
  valid:true,
335
549
  workspaceRoot:canonicalWorkspaceRoot,
336
550
  workspaceMode,
337
551
  appId:app.appId,
338
- appRoot:app.appRoot,
552
+ appRoot:canonicalAppRoot,
339
553
  app:freshApp
340
554
  });
341
555
  await emit(onEvent,{
@@ -343,10 +557,17 @@ export async function validateDiscoveredApplication({
343
557
  workspaceRoot:canonicalWorkspaceRoot,
344
558
  appId:app.appId
345
559
  });
560
+ await assertCapturedDirectories();
346
561
  return receipt;
347
562
  }
348
563
 
349
- export async function validateWorkspace({workspaceRoot=process.cwd(),appId,signal,onEvent}={}){
564
+ export async function validateWorkspace({
565
+ workspaceRoot=process.cwd(),
566
+ appId,
567
+ allowMissingManagedImportMap=false,
568
+ signal,
569
+ onEvent
570
+ }={}){
350
571
  throwIfAborted(signal);
351
572
  const resolved=await resolveWorkspace({workspaceRoot,appId});
352
573
  await emit(onEvent,{type:'workspace.validate.started',workspaceRoot:resolved.workspaceRoot,appId:resolved.appId});
@@ -409,12 +630,39 @@ export async function validateWorkspace({workspaceRoot=process.cwd(),appId,signa
409
630
  ||installed.source?.commit!==lock.runtime.upstreamCommit){
410
631
  fail('Installed SDK runtime does not match arcane.lock.json.');
411
632
  }
633
+ const browserManifestPath=path.join(
634
+ resolved.workspaceRoot,
635
+ 'node_modules',
636
+ 'arcane-os',
637
+ 'browser-runtime',
638
+ 'ARCANE_SDK_BROWSER_RELEASE.json'
639
+ );
640
+ const browserBytes=await readFile(browserManifestPath);
641
+ let installedBrowser;
642
+ try{installedBrowser=JSON.parse(browserBytes.toString('utf8'));}
643
+ catch(error){
644
+ fail(`Installed SDK browser runtime manifest is not valid JSON: ${error.message}`);
645
+ }
646
+ if(createHash('sha256').update(browserBytes).digest('hex')
647
+ !==lock.sdkBrowserRuntime.manifestSha256
648
+ ||installedBrowser.contentSha256!==lock.sdkBrowserRuntime.contentSha256
649
+ ||installedBrowser.builder!==lock.sdkBrowserRuntime.builder
650
+ ||installedBrowser.sdkVersion!==lock.sdkBrowserRuntime.sdkVersion
651
+ ||!sameBrowserRuntimeSource(
652
+ installedBrowser.source,
653
+ lock.sdkBrowserRuntime.source
654
+ )){
655
+ fail('Installed SDK browser runtime does not match arcane.lock.json.');
656
+ }
412
657
  });
413
658
  }else{
414
659
  await add('workspace-runtime',async()=>{
660
+ const strongType=resolved.config.browserRuntimeLayout==='integrated-legacy'
661
+ ?path.join('node_modules','strong-type')
662
+ :path.join('arcane','dependencies','strong-type');
415
663
  for(const [relative,label] of [
416
664
  ['arcane','Integrated Arcane runtime'],
417
- [path.join('node_modules','strong-type'),'Integrated strong-type runtime']
665
+ [strongType,'Integrated strong-type runtime']
418
666
  ]){
419
667
  const info=await lstat(path.join(resolved.workspaceRoot,relative));
420
668
  if(info.isSymbolicLink()||!info.isDirectory()){
@@ -429,6 +677,7 @@ export async function validateWorkspace({workspaceRoot=process.cwd(),appId,signa
429
677
  workspaceMode,
430
678
  workspaceConfig:resolved.config,
431
679
  app:resolved.app,
680
+ allowMissingManagedImportMap,
432
681
  signal,
433
682
  onEvent
434
683
  });