arcane-os 0.1.0-dev.5
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/CHANGELOG.md +154 -0
- package/COMMERCIAL-LICENSE.md +13 -0
- package/LICENSE +661 -0
- package/NOTICE +70 -0
- package/README.md +448 -0
- package/bin/arcane-test.mjs +741 -0
- package/bin/arcane.mjs +5 -0
- package/docs/architecture.md +234 -0
- package/docs/compatibility.md +36 -0
- package/docs/event-manager.md +166 -0
- package/docs/platform-targets.md +108 -0
- package/docs/publishing.md +203 -0
- package/docs/reference/README.md +117 -0
- package/docs/reference/arcane-ollama.md +288 -0
- package/docs/reference/availability-and-normalization.md +123 -0
- package/docs/reference/behavioral-testing.md +86 -0
- package/docs/reference/cli.md +569 -0
- package/docs/reference/core/README.md +62 -0
- package/docs/reference/core/arcane-ai-contracts.md +873 -0
- package/docs/reference/core/arcane-api.md +601 -0
- package/docs/reference/core/arcane-entities.md +65 -0
- package/docs/reference/core/arcane-events.md +134 -0
- package/docs/reference/core/ollama-module.md +181 -0
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +1909 -0
- package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +1057 -0
- package/docs/reference/core/reference/arcane-api/core-and-events.md +320 -0
- package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +610 -0
- package/docs/reference/core/reference/arcane-api/namespaces.md +1157 -0
- package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +1423 -0
- package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +315 -0
- package/docs/reference/event-manager.md +957 -0
- package/docs/reference/inventory/package-api.json +2632 -0
- package/docs/reference/inventory/runtime-components.json +934 -0
- package/docs/reference/inventory/runtime-entities.json +26 -0
- package/docs/reference/inventory/runtime-modules.json +1249 -0
- package/docs/reference/protocols.md +242 -0
- package/docs/reference/runtime-components.md +1098 -0
- package/docs/reference/runtime-entities.md +303 -0
- package/docs/reference/runtime-modules.md +2010 -0
- package/docs/reference/sdk-api.md +4901 -0
- package/docs/roadmap.md +79 -0
- package/docs/work-amplification.md +124 -0
- package/node_modules/event-pubsub/CHANGELOG.md +55 -0
- package/node_modules/event-pubsub/MIGRATION.md +70 -0
- package/node_modules/event-pubsub/README.md +363 -0
- package/node_modules/event-pubsub/SECURITY.md +37 -0
- package/node_modules/event-pubsub/index.js +141 -0
- package/node_modules/event-pubsub/licence +21 -0
- package/node_modules/event-pubsub/package.json +59 -0
- package/node_modules/strong-type/README.md +408 -0
- package/node_modules/strong-type/assets/strong-type-header.png +0 -0
- package/node_modules/strong-type/index.js +1151 -0
- package/node_modules/strong-type/licence +21 -0
- package/node_modules/strong-type/node.js +125 -0
- package/node_modules/strong-type/package.json +61 -0
- package/package.json +95 -0
- package/runtime/ARCANE_RUNTIME_RELEASE.json +791 -0
- package/runtime/arcane/components/app-bar.html +468 -0
- package/runtime/arcane/components/assistant-panel.html +715 -0
- package/runtime/arcane/components/calculator.html +8 -0
- package/runtime/arcane/components/chart.html +655 -0
- package/runtime/arcane/components/chat.html +1225 -0
- package/runtime/arcane/components/conversation-view.html +13 -0
- package/runtime/arcane/components/dashboard-config.html +341 -0
- package/runtime/arcane/components/data-maintenance.html +112 -0
- package/runtime/arcane/components/data-view.html +92 -0
- package/runtime/arcane/components/directory-picker.html +197 -0
- package/runtime/arcane/components/document-inspector.html +252 -0
- package/runtime/arcane/components/file-drop.html +264 -0
- package/runtime/arcane/components/file-inspector.html +293 -0
- package/runtime/arcane/components/file-manager.html +1715 -0
- package/runtime/arcane/components/header.html +142 -0
- package/runtime/arcane/components/integration-settings.html +14 -0
- package/runtime/arcane/components/local-ai-status.html +360 -0
- package/runtime/arcane/components/markdown-document.html +1048 -0
- package/runtime/arcane/components/markdown-editor.html +360 -0
- package/runtime/arcane/components/media-embed.html +8 -0
- package/runtime/arcane/components/modal.html +402 -0
- package/runtime/arcane/components/output-panel.html +259 -0
- package/runtime/arcane/components/preferences-form.html +135 -0
- package/runtime/arcane/components/record-timeline.html +105 -0
- package/runtime/arcane/components/relationship-board.html +116 -0
- package/runtime/arcane/components/screen-capture.html +8 -0
- package/runtime/arcane/components/source-code-viewer.html +441 -0
- package/runtime/arcane/components/source-explanation.html +124 -0
- package/runtime/arcane/components/speech.html +365 -0
- package/runtime/arcane/components/summary-strip.html +177 -0
- package/runtime/arcane/components/table.html +77 -0
- package/runtime/arcane/components/task-progress.html +282 -0
- package/runtime/arcane/components/terminal-workspace.html +65 -0
- package/runtime/arcane/components/theme-editor.html +41 -0
- package/runtime/arcane/components/theme-switcher.html +46 -0
- package/runtime/arcane/components/unified-inbox.html +20 -0
- package/runtime/arcane/components/voice-transcription.html +476 -0
- package/runtime/arcane/components/weather-widget.html +8 -0
- package/runtime/arcane/components/web-navigator.html +239 -0
- package/runtime/arcane/css/communications.css +1 -0
- package/runtime/arcane/css/dashboard-config.css +45 -0
- package/runtime/arcane/css/document-site.css +981 -0
- package/runtime/arcane/css/layout.css +438 -0
- package/runtime/arcane/css/primitives.css +321 -0
- package/runtime/arcane/css/theme.css +112 -0
- package/runtime/arcane/css/utility-workspace.css +1 -0
- package/runtime/arcane/entities/ApiModelRecord.js +20 -0
- package/runtime/arcane/entities/Calculation.js +13 -0
- package/runtime/arcane/entities/Chat.js +581 -0
- package/runtime/arcane/entities/CommunicationMessage.js +29 -0
- package/runtime/arcane/entities/CommunicationThread.js +21 -0
- package/runtime/arcane/entities/Document.js +10 -0
- package/runtime/arcane/entities/File.js +143 -0
- package/runtime/arcane/entities/Image.js +135 -0
- package/runtime/arcane/entities/IntentEnvelope.js +834 -0
- package/runtime/arcane/entities/Preference.js +83 -0
- package/runtime/arcane/entities/TWiNPolicyDecision.js +1092 -0
- package/runtime/arcane/entities/TerminalSession.js +46 -0
- package/runtime/arcane/entities/Theme.js +107 -0
- package/runtime/arcane/entities/User.js +1046 -0
- package/runtime/arcane/entities/Weather.js +23 -0
- package/runtime/arcane/img/arcane-os-everywhere.png +0 -0
- package/runtime/arcane/img/arrow-left.png +0 -0
- package/runtime/arcane/img/arrow-right.png +0 -0
- package/runtime/arcane/img/doc.svg +5 -0
- package/runtime/arcane/img/folder.svg +4 -0
- package/runtime/arcane/img/image.svg +5 -0
- package/runtime/arcane/img/refresh.png +0 -0
- package/runtime/arcane/img/send.svg +9 -0
- package/runtime/arcane/img/trash.svg +5 -0
- package/runtime/arcane/img/upload.svg +5 -0
- package/runtime/arcane/modules/AI.js +2048 -0
- package/runtime/arcane/modules/AIPreferenceRuntime.js +32 -0
- package/runtime/arcane/modules/AIPreferenceTuple.js +92 -0
- package/runtime/arcane/modules/AIResponseLength.js +42 -0
- package/runtime/arcane/modules/AIResponseURLPolicy.js +626 -0
- package/runtime/arcane/modules/AnsiText.js +53 -0
- package/runtime/arcane/modules/ApiModelDatabase.js +25 -0
- package/runtime/arcane/modules/AppDataScope.js +245 -0
- package/runtime/arcane/modules/AppearancePreferences.js +28 -0
- package/runtime/arcane/modules/ArcaneCommunicationBridge.js +22 -0
- package/runtime/arcane/modules/ArcaneNavigationPolicy.js +135 -0
- package/runtime/arcane/modules/ArcaneNetworkPolicy.js +255 -0
- package/runtime/arcane/modules/AsyncBoundary.js +161 -0
- package/runtime/arcane/modules/BrowserTestSuite.js +326 -0
- package/runtime/arcane/modules/CalculatorEngine.js +20 -0
- package/runtime/arcane/modules/CaseEvidenceIndexer.js +134 -0
- package/runtime/arcane/modules/ChartLibrary.js +35 -0
- package/runtime/arcane/modules/ChatRecords.js +13 -0
- package/runtime/arcane/modules/CommunicationAppController.js +43 -0
- package/runtime/arcane/modules/CommunicationHub.js +16 -0
- package/runtime/arcane/modules/CommunicationPreferences.js +13 -0
- package/runtime/arcane/modules/CommunicationProviderRegistry.js +16 -0
- package/runtime/arcane/modules/ComponentContracts.js +586 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +288 -0
- package/runtime/arcane/modules/ConversationActionItems.js +488 -0
- package/runtime/arcane/modules/ConversationClosingReport.js +274 -0
- package/runtime/arcane/modules/ConversationTimebox.js +527 -0
- package/runtime/arcane/modules/CoreLocalModelCatalog.js +255 -0
- package/runtime/arcane/modules/DBLS.js +171 -0
- package/runtime/arcane/modules/DBOPFS.js +1154 -0
- package/runtime/arcane/modules/DBOPFSWorker.js +116 -0
- package/runtime/arcane/modules/DataMaintenance.js +91 -0
- package/runtime/arcane/modules/DevelopmentWorkspace.js +74 -0
- package/runtime/arcane/modules/DirectoryPicker.js +109 -0
- package/runtime/arcane/modules/DocumentNavigation.js +223 -0
- package/runtime/arcane/modules/Errors.js +1025 -0
- package/runtime/arcane/modules/GifEncoder.js +29 -0
- package/runtime/arcane/modules/HTMLImport.js +114 -0
- package/runtime/arcane/modules/InMemoryCommunicationProvider.js +14 -0
- package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +275 -0
- package/runtime/arcane/modules/LocalAIReadiness.js +870 -0
- package/runtime/arcane/modules/LocalAIReadinessController.js +156 -0
- package/runtime/arcane/modules/MD.js +111 -0
- package/runtime/arcane/modules/Mail.js +352 -0
- package/runtime/arcane/modules/MailTransport.mjs +180 -0
- package/runtime/arcane/modules/Marked.min.js +71 -0
- package/runtime/arcane/modules/MemoryRecords.js +44 -0
- package/runtime/arcane/modules/MessageAdvisory.js +38 -0
- package/runtime/arcane/modules/ModelDefinition.js +189 -0
- package/runtime/arcane/modules/Ollama.js +74 -0
- package/runtime/arcane/modules/OllamaModelIdentifier.js +21 -0
- package/runtime/arcane/modules/OllamaSettings.js +24 -0
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +14 -0
- package/runtime/arcane/modules/PreferenceStore.js +109 -0
- package/runtime/arcane/modules/QRCode.min.js +1 -0
- package/runtime/arcane/modules/Questionnaire.js +61 -0
- package/runtime/arcane/modules/RecordLinkIndex.js +24 -0
- package/runtime/arcane/modules/RecordPassageIndex.js +222 -0
- package/runtime/arcane/modules/RecordReviewStore.js +94 -0
- package/runtime/arcane/modules/RevocableProjectionLedger.js +1623 -0
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +37 -0
- package/runtime/arcane/modules/ScamRiskPolicy.js +62 -0
- package/runtime/arcane/modules/ScopedOPFSCache.js +183 -0
- package/runtime/arcane/modules/ScreenCapture.js +20 -0
- package/runtime/arcane/modules/SpeechPlayback.js +581 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +1248 -0
- package/runtime/arcane/modules/SystemAppearance.js +20 -0
- package/runtime/arcane/modules/SystemPlatformPresentation.js +51 -0
- package/runtime/arcane/modules/SystemToolRegistry.js +28 -0
- package/runtime/arcane/modules/TerminalClient.js +52 -0
- package/runtime/arcane/modules/TerminalCommandRegistry.js +50 -0
- package/runtime/arcane/modules/ThemeBootstrap.js +26 -0
- package/runtime/arcane/modules/ThemeManager.js +131 -0
- package/runtime/arcane/modules/TimeGuard.js +149 -0
- package/runtime/arcane/modules/ToolCallRouter.js +83 -0
- package/runtime/arcane/modules/WaitForComponent.js +102 -0
- package/runtime/arcane/modules/YouTubeMedia.js +16 -0
- package/runtime/arcane/modules/uPlot.LICENSE.txt +21 -0
- package/runtime/arcane/modules/uPlot.iife.min.js +2 -0
- package/runtime/arcane/modules/uPlot.min.css +1 -0
- package/runtime/arcane/security/arcane-network-policy.json +6 -0
- package/runtime/strong-type/index.js +352 -0
- package/runtime/strong-type/licence +21 -0
- package/runtime/strong-type/package.json +45 -0
- package/schemas/arcane-app-bundle.schema.json +125 -0
- package/schemas/arcane-app.schema.json +329 -0
- package/schemas/arcane-lock.schema.json +86 -0
- package/schemas/arcane-package.schema.json +224 -0
- package/schemas/cli-event.schema.json +122 -0
- package/schemas/event-stack.schema.json +152 -0
- package/schemas/native-build-plan.schema.json +176 -0
- package/schemas/target-adapter.schema.json +117 -0
- package/src/app-descriptor.mjs +500 -0
- package/src/cli/main.mjs +561 -0
- package/src/constants.mjs +31 -0
- package/src/dev-server.mjs +718 -0
- package/src/doctor.mjs +315 -0
- package/src/dom-event-instrumentation.mjs +594 -0
- package/src/errors.mjs +75 -0
- package/src/event-manager.mjs +1342 -0
- package/src/event-queue.mjs +138 -0
- package/src/events.mjs +219 -0
- package/src/index.mjs +177 -0
- package/src/integrated-provider-loader.mjs +432 -0
- package/src/native-plan.mjs +698 -0
- package/src/native-provider-loader.mjs +1126 -0
- package/src/packager/core.mjs +2691 -0
- package/src/process.mjs +353 -0
- package/src/release-bundle.mjs +2523 -0
- package/src/repository.mjs +90 -0
- package/src/runtime.mjs +452 -0
- package/src/scaffold.mjs +380 -0
- package/src/targets/index.mjs +436 -0
- package/src/templates/assets/app-icon.png +0 -0
- package/src/templates/workspace-template.mjs +388 -0
- package/src/testing-loader.mjs +9 -0
- package/src/testing.mjs +427 -0
- package/src/toolchain.mjs +1335 -0
- package/src/update-check.mjs +307 -0
- package/src/workspace.mjs +449 -0
|
@@ -0,0 +1,2691 @@
|
|
|
1
|
+
import {createHash,randomBytes} from 'node:crypto';
|
|
2
|
+
import {constants as FS_CONSTANTS} from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
realpath,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
stat,
|
|
13
|
+
writeFile
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import {pathToFileURL} from 'node:url';
|
|
17
|
+
import {setTimeout as delay} from 'node:timers/promises';
|
|
18
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
19
|
+
|
|
20
|
+
export const ROOT_CONFIG_NAME='arcane-packager.json';
|
|
21
|
+
export const APP_CONFIG_NAME='arcane-package.json';
|
|
22
|
+
export const RELEASE_MANIFEST_NAME='ARCANE_APP_RELEASE.json';
|
|
23
|
+
export const PACKAGER_VERSION='arcane-app-packager-v1';
|
|
24
|
+
|
|
25
|
+
const RENAME_RETRY_CODES=new Set(['EACCES','EBUSY','EPERM']);
|
|
26
|
+
const RENAME_RETRY_LIMIT=20;
|
|
27
|
+
const RENAME_RETRY_DELAY_MS=250;
|
|
28
|
+
const READ_ONLY_NO_FOLLOW=FS_CONSTANTS.O_RDONLY|(FS_CONSTANTS.O_NOFOLLOW??0);
|
|
29
|
+
const MAX_VERIFIED_APP_FILE_BYTES=64*1024*1024;
|
|
30
|
+
const MAX_SHARED_SNAPSHOT_FILE_COUNT=10000;
|
|
31
|
+
const MAX_SHARED_SNAPSHOT_FILE_BYTES=64*1024*1024;
|
|
32
|
+
const MAX_SHARED_SNAPSHOT_TOTAL_BYTES=64*1024*1024;
|
|
33
|
+
const APP_DESCRIPTOR_NAME='arcane-app.json';
|
|
34
|
+
const LEGACY_APP_REGISTRY_PATH=path.join(
|
|
35
|
+
'machine_bundles',
|
|
36
|
+
'arcane-os-machine-bundle',
|
|
37
|
+
'arcane-apps.json'
|
|
38
|
+
);
|
|
39
|
+
const issuedAppReleaseReceipts=new WeakMap();
|
|
40
|
+
const issuedSharedPayloadSnapshots=new WeakMap();
|
|
41
|
+
let appDescriptorContractsPromise;
|
|
42
|
+
|
|
43
|
+
function fileIdentity(info){
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
device:String(info.dev),
|
|
46
|
+
inode:String(info.ino),
|
|
47
|
+
bytes:Number(info.size),
|
|
48
|
+
modifiedNanoseconds:String(info.mtimeNs),
|
|
49
|
+
changedNanoseconds:String(info.ctimeNs),
|
|
50
|
+
links:String(info.nlink)
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function identityMatches(info,identity){
|
|
55
|
+
return String(info.dev)===identity.device
|
|
56
|
+
&&String(info.ino)===identity.inode
|
|
57
|
+
&&Number(info.size)===identity.bytes
|
|
58
|
+
&&String(info.mtimeNs)===identity.modifiedNanoseconds
|
|
59
|
+
&&String(info.ctimeNs)===identity.changedNanoseconds
|
|
60
|
+
&&String(info.nlink)===identity.links;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function openStableRegularFile(filePath,label,expectedIdentity){
|
|
64
|
+
const before=await lstat(filePath,{bigint:true});
|
|
65
|
+
if(before.isSymbolicLink()||!before.isFile()){
|
|
66
|
+
fail(`${label} must be a regular file, not a link or special entry.`);
|
|
67
|
+
}
|
|
68
|
+
if(expectedIdentity&&!identityMatches(before,expectedIdentity)){
|
|
69
|
+
fail(`${label} changed after its package inventory was selected.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let handle;
|
|
73
|
+
try{
|
|
74
|
+
handle=await open(filePath,READ_ONLY_NO_FOLLOW);
|
|
75
|
+
}catch(error){
|
|
76
|
+
if(error?.code==='ELOOP')fail(`${label} became a symbolic link.`);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
try{
|
|
80
|
+
const opened=await handle.stat({bigint:true});
|
|
81
|
+
if(!opened.isFile()||!identityMatches(opened,fileIdentity(before))){
|
|
82
|
+
fail(`${label} changed while it was being opened.`);
|
|
83
|
+
}
|
|
84
|
+
return {handle,identity:fileIdentity(opened)};
|
|
85
|
+
}catch(error){
|
|
86
|
+
await handle.close().catch(()=>{});
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readStableBytes(filePath,label,expectedIdentity){
|
|
92
|
+
const opened=await openStableRegularFile(filePath,label,expectedIdentity);
|
|
93
|
+
try{
|
|
94
|
+
const bytes=await opened.handle.readFile();
|
|
95
|
+
const after=await opened.handle.stat({bigint:true});
|
|
96
|
+
if(!identityMatches(after,opened.identity)){
|
|
97
|
+
fail(`${label} changed while it was being read.`);
|
|
98
|
+
}
|
|
99
|
+
return {bytes,identity:opened.identity};
|
|
100
|
+
}finally{
|
|
101
|
+
await opened.handle.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function renamePackageDirectory(source,destination){
|
|
106
|
+
for(let attempt=0;;attempt++){
|
|
107
|
+
try{
|
|
108
|
+
await rename(source,destination);
|
|
109
|
+
return;
|
|
110
|
+
}catch(error){
|
|
111
|
+
if(!RENAME_RETRY_CODES.has(error?.code)||attempt>=RENAME_RETRY_LIMIT){
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await delay(RENAME_RETRY_DELAY_MS);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const APP_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
121
|
+
const SAFE_SHARED_ID_PATTERN=/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
122
|
+
const WINDOWS_RESERVED_NAME=
|
|
123
|
+
/^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/iu;
|
|
124
|
+
const FORBIDDEN_SEGMENTS=new Set([
|
|
125
|
+
'.agents',
|
|
126
|
+
'.codex',
|
|
127
|
+
'.git',
|
|
128
|
+
'dist',
|
|
129
|
+
'local'
|
|
130
|
+
]);
|
|
131
|
+
const TEXT_CONTROL_PATTERN=/[\x00-\x1f\x7f]/;
|
|
132
|
+
const WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN=/[<>"|?*]/u;
|
|
133
|
+
const OLLAMA_MODEL_IDENTIFIER=
|
|
134
|
+
/^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}(?::[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/;
|
|
135
|
+
const MODEL_DEFINITION_PATTERN=/^(?:Modelfile|[A-Za-z0-9][A-Za-z0-9._-]{0,118}\.Modelfile)$/;
|
|
136
|
+
const MAX_LOCAL_AI_MODELS=64;
|
|
137
|
+
const MAX_MODEL_DEFINITION_BYTES=512*1024;
|
|
138
|
+
|
|
139
|
+
function fail(message,code){
|
|
140
|
+
const error=new Error(message);
|
|
141
|
+
if(code)error.code=code;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function throwIfAborted(signal){
|
|
146
|
+
if(!signal?.aborted){
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const error=signal.reason instanceof Error
|
|
151
|
+
?signal.reason
|
|
152
|
+
:new Error('Arcane package operation cancelled.');
|
|
153
|
+
if(!error.code){
|
|
154
|
+
error.code='ARCANE_CANCELLED';
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function emitOperation(onEvent,event){
|
|
160
|
+
if(typeof onEvent==='function'){
|
|
161
|
+
await onEvent(event);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isPlainObject(value){
|
|
166
|
+
return value!==null
|
|
167
|
+
&&typeof value==='object'
|
|
168
|
+
&&Object.getPrototypeOf(value)===Object.prototype;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function immutableJsonCopy(value){
|
|
172
|
+
if(Array.isArray(value)){
|
|
173
|
+
return Object.freeze(value.map(item=>immutableJsonCopy(item)));
|
|
174
|
+
}
|
|
175
|
+
if(isPlainObject(value)){
|
|
176
|
+
return Object.freeze(Object.fromEntries(
|
|
177
|
+
Object.entries(value).map(([key,item])=>[key,immutableJsonCopy(item)])
|
|
178
|
+
));
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function compareText(left,right){
|
|
184
|
+
return Buffer.compare(Buffer.from(String(left),'utf8'),Buffer.from(String(right),'utf8'));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assertOnlyKeys(value,allowed,label){
|
|
188
|
+
if(!isPlainObject(value)){
|
|
189
|
+
fail(`${label} must be a JSON object.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for(const key of Object.keys(value)){
|
|
193
|
+
if(!allowed.has(key)){
|
|
194
|
+
fail(`${label} has an unsupported key: ${key}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizeWorkspaceRoot(workspaceRoot){
|
|
200
|
+
if(typeof workspaceRoot!=='string'||!workspaceRoot.trim()){
|
|
201
|
+
fail('workspaceRoot must be a directory path.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return path.resolve(workspaceRoot);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function normalizeRelativePath(value,label='path'){
|
|
208
|
+
if(typeof value!=='string'||!value||value.includes('\\')||TEXT_CONTROL_PATTERN.test(value)){
|
|
209
|
+
fail(`Unsafe ${label}: ${String(value)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if(path.posix.isAbsolute(value)||/^[a-z]:/i.test(value)){
|
|
213
|
+
fail(`Unsafe ${label}: ${value}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const segments=value.split('/');
|
|
217
|
+
|
|
218
|
+
for(const segment of segments){
|
|
219
|
+
if(!segment||segment==='.'||segment==='..'||segment.includes(':')
|
|
220
|
+
||WINDOWS_UNSAFE_FILENAME_CHARACTER_PATTERN.test(segment)
|
|
221
|
+
||segment.endsWith('.')||segment.endsWith(' ')
|
|
222
|
+
||WINDOWS_RESERVED_NAME.test(segment)){
|
|
223
|
+
fail(`Unsafe ${label}: ${value}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return segments.join('/');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeRelativeRoot(value,label){
|
|
231
|
+
if(value==='.'){
|
|
232
|
+
return '.';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return normalizeRelativePath(value,label);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isInside(root,candidate,{allowEqual=false}={}){
|
|
239
|
+
const relative=path.relative(path.resolve(root),path.resolve(candidate));
|
|
240
|
+
return (allowEqual&&relative==='')
|
|
241
|
+
||Boolean(relative&&!relative.startsWith('..')&&!path.isAbsolute(relative));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function resolveInside(root,relative,label,{allowRoot=false}={}){
|
|
245
|
+
const normalized=relative==='.'&&allowRoot
|
|
246
|
+
?'.'
|
|
247
|
+
:normalizeRelativePath(relative,label);
|
|
248
|
+
const candidate=path.resolve(root,...(normalized==='.'?[]:normalized.split('/')));
|
|
249
|
+
|
|
250
|
+
if(!isInside(root,candidate,{allowEqual:allowRoot})){
|
|
251
|
+
fail(`${label} leaves its allowed root: ${relative}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return candidate;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function pathKey(relative){
|
|
258
|
+
return relative.toLocaleLowerCase('en-US');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pathIsSameOrDescendant(candidate,parent){
|
|
262
|
+
const candidateKey=pathKey(candidate);
|
|
263
|
+
const parentKey=pathKey(parent);
|
|
264
|
+
return candidateKey===parentKey||candidateKey.startsWith(`${parentKey}/`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isGlobLike(value){
|
|
268
|
+
return /[*?\[\]{}]/.test(value);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function validatePathList(value,label,{required=false}={}){
|
|
272
|
+
if(!Array.isArray(value)||(required&&value.length===0)){
|
|
273
|
+
fail(`${label} must be ${required?'a non-empty':'an'} array of literal relative paths.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if(value.length>512){
|
|
277
|
+
fail(`${label} is unreasonably large.`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const normalized=value.map((entry,index)=>{
|
|
281
|
+
const item=normalizeRelativePath(entry,`${label}[${index}]`);
|
|
282
|
+
|
|
283
|
+
if(isGlobLike(item)){
|
|
284
|
+
fail(`${label}[${index}] must be literal; directories already include descendants.`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return item;
|
|
288
|
+
});
|
|
289
|
+
const keys=new Set();
|
|
290
|
+
|
|
291
|
+
for(const item of normalized){
|
|
292
|
+
const key=pathKey(item);
|
|
293
|
+
|
|
294
|
+
if(keys.has(key)){
|
|
295
|
+
fail(`${label} contains a duplicate path: ${item}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
keys.add(key);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if(required){
|
|
302
|
+
for(let left=0;left<normalized.length;left++){
|
|
303
|
+
for(let right=left+1;right<normalized.length;right++){
|
|
304
|
+
if(pathIsSameOrDescendant(normalized[left],normalized[right])
|
|
305
|
+
||pathIsSameOrDescendant(normalized[right],normalized[left])){
|
|
306
|
+
fail(`${label} has overlapping paths: ${normalized[left]} and ${normalized[right]}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return normalized;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isAlwaysForbidden(relative){
|
|
316
|
+
return relative.split('/').some(segment=>{
|
|
317
|
+
const key=pathKey(segment);
|
|
318
|
+
return FORBIDDEN_SEGMENTS.has(key)||key==='.env'||key.startsWith('.env.');
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isAppSourceForbidden(relative){
|
|
323
|
+
return isAlwaysForbidden(relative)
|
|
324
|
+
||relative.split('/').some(segment=>pathKey(segment)==='node_modules');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isExcluded(relative,excludes){
|
|
328
|
+
return isAlwaysForbidden(relative)
|
|
329
|
+
||excludes.some(excluded=>pathIsSameOrDescendant(relative,excluded));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function assertSafePresentationText(value,label,maximum=160){
|
|
333
|
+
if(typeof value!=='string'||!value.trim()||value.length>maximum
|
|
334
|
+
||TEXT_CONTROL_PATTERN.test(value)||/[<>]/.test(value)){
|
|
335
|
+
fail(`${label} must be plain text no longer than ${maximum} characters.`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return value.trim();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function parseSemver(value){
|
|
342
|
+
if(typeof value!=='string'){
|
|
343
|
+
fail(`Invalid semantic version: ${String(value)}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const match=/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value);
|
|
347
|
+
|
|
348
|
+
if(!match){
|
|
349
|
+
fail(`Invalid semantic version: ${value}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const prerelease=match[4]?match[4].split('.'):[];
|
|
353
|
+
const build=match[5]?match[5].split('.'):[];
|
|
354
|
+
|
|
355
|
+
for(const identifier of prerelease){
|
|
356
|
+
if(/^\d+$/.test(identifier)&&identifier.length>1&&identifier.startsWith('0')){
|
|
357
|
+
fail(`Invalid semantic version: ${value}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const numbers=match.slice(1,4).map(Number);
|
|
362
|
+
|
|
363
|
+
if(numbers.some(number=>!Number.isSafeInteger(number))){
|
|
364
|
+
fail(`Semantic version component exceeds JavaScript's safe integer range: ${value}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
major:numbers[0],
|
|
369
|
+
minor:numbers[1],
|
|
370
|
+
patch:numbers[2],
|
|
371
|
+
prerelease,
|
|
372
|
+
build
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function formatSemver(version){
|
|
377
|
+
let rendered=`${version.major}.${version.minor}.${version.patch}`;
|
|
378
|
+
|
|
379
|
+
if(version.prerelease?.length){
|
|
380
|
+
rendered+=`-${version.prerelease.join('.')}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if(version.build?.length){
|
|
384
|
+
rendered+=`+${version.build.join('.')}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return rendered;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function validatePreid(preid){
|
|
391
|
+
const value=preid??'rc';
|
|
392
|
+
|
|
393
|
+
if(typeof value!=='string'||!/^[0-9A-Za-z-]+$/.test(value)
|
|
394
|
+
||(/^\d+$/.test(value)&&value.length>1&&value.startsWith('0'))){
|
|
395
|
+
fail(`Invalid prerelease identifier: ${String(value)}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function incrementSemver(value,bump,preid){
|
|
402
|
+
const current=parseSemver(value);
|
|
403
|
+
|
|
404
|
+
if(!['major','minor','patch','prerelease'].includes(bump)){
|
|
405
|
+
fail(`Unsupported semantic version bump: ${String(bump)}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if(bump==='major'){
|
|
409
|
+
return formatSemver({major:current.major+1,minor:0,patch:0});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if(bump==='minor'){
|
|
413
|
+
return formatSemver({major:current.major,minor:current.minor+1,patch:0});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if(bump==='patch'){
|
|
417
|
+
return formatSemver({major:current.major,minor:current.minor,patch:current.patch+1});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const requestedPreid=validatePreid(preid);
|
|
421
|
+
const next={
|
|
422
|
+
major:current.major,
|
|
423
|
+
minor:current.minor,
|
|
424
|
+
patch:current.patch,
|
|
425
|
+
prerelease:[]
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if(!current.prerelease.length){
|
|
429
|
+
next.patch+=1;
|
|
430
|
+
next.prerelease=[requestedPreid,'0'];
|
|
431
|
+
return formatSemver(next);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if(current.prerelease[0]!==requestedPreid){
|
|
435
|
+
next.prerelease=[requestedPreid,'0'];
|
|
436
|
+
return formatSemver(next);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
next.prerelease=[...current.prerelease];
|
|
440
|
+
let incremented=false;
|
|
441
|
+
|
|
442
|
+
for(let index=next.prerelease.length-1;index>=0;index--){
|
|
443
|
+
if(/^\d+$/.test(next.prerelease[index])){
|
|
444
|
+
const number=Number(next.prerelease[index]);
|
|
445
|
+
|
|
446
|
+
if(!Number.isSafeInteger(number)||number===Number.MAX_SAFE_INTEGER){
|
|
447
|
+
fail(`Prerelease number is too large to increment: ${value}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
next.prerelease[index]=String(number+1);
|
|
451
|
+
incremented=true;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if(!incremented){
|
|
457
|
+
next.prerelease.push('0');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return formatSemver(next);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function readJsonDocument(filePath,label=filePath,{expectedIdentity}={}){
|
|
464
|
+
let document;
|
|
465
|
+
try{
|
|
466
|
+
document=await readStableBytes(filePath,label,expectedIdentity);
|
|
467
|
+
}catch(error){
|
|
468
|
+
if(error?.code==='ENOENT')fail(`${label} does not exist.`);
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
try{
|
|
473
|
+
return {
|
|
474
|
+
value:JSON.parse(document.bytes.toString('utf8')),
|
|
475
|
+
bytes:document.bytes,
|
|
476
|
+
identity:document.identity
|
|
477
|
+
};
|
|
478
|
+
}catch(error){
|
|
479
|
+
fail(`${label} is not valid JSON: ${error.message}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function readJson(filePath,label=filePath){
|
|
484
|
+
return (await readJsonDocument(filePath,label)).value;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function validateSharedRoute(route,label){
|
|
488
|
+
assertOnlyKeys(route,new Set(['source','destination','include','exclude']),label);
|
|
489
|
+
const source=normalizeRelativeRoot(route.source,`${label}.source`);
|
|
490
|
+
const destination=normalizeRelativeRoot(route.destination,`${label}.destination`);
|
|
491
|
+
const include=validatePathList(route.include,`${label}.include`,{required:true});
|
|
492
|
+
const exclude=validatePathList(route.exclude??[],`${label}.exclude`);
|
|
493
|
+
|
|
494
|
+
if(source==='.'||source==='apps'||source.startsWith('apps/')
|
|
495
|
+
||source==='dist'||source.startsWith('dist/')
|
|
496
|
+
||source==='node_modules'
|
|
497
|
+
||isAlwaysForbidden(source)){
|
|
498
|
+
fail(`${label}.source is outside the permitted shared-payload boundary: ${source}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if(destination==='apps'||destination.startsWith('apps/')
|
|
502
|
+
||destination===RELEASE_MANIFEST_NAME){
|
|
503
|
+
fail(`${label}.destination overlaps a reserved package path: ${destination}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return Object.freeze({source,destination,include,exclude});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function loadRootConfigDocument(workspaceRoot){
|
|
510
|
+
const configPath=path.join(workspaceRoot,ROOT_CONFIG_NAME);
|
|
511
|
+
const document=await readJsonDocument(configPath,ROOT_CONFIG_NAME);
|
|
512
|
+
return {
|
|
513
|
+
...document,
|
|
514
|
+
value:validateRootConfig(document.value,configPath),
|
|
515
|
+
configPath
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function loadRootConfig(workspaceRoot){
|
|
520
|
+
return (await loadRootConfigDocument(workspaceRoot)).value;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
|
|
524
|
+
assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads']),ROOT_CONFIG_NAME);
|
|
525
|
+
|
|
526
|
+
if(value.schemaVersion!==1){
|
|
527
|
+
fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if(value.appsRoot!=='apps'||value.distRoot!=='dist'){
|
|
531
|
+
fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" and distRoot to "dist".`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if(!isPlainObject(value.sharedPayloads)){
|
|
535
|
+
fail(`${ROOT_CONFIG_NAME}.sharedPayloads must be an object.`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const sharedPayloads={};
|
|
539
|
+
|
|
540
|
+
for(const [id,routes] of Object.entries(value.sharedPayloads).sort(([left],[right])=>compareText(left,right))){
|
|
541
|
+
if(!SAFE_SHARED_ID_PATTERN.test(id)){
|
|
542
|
+
fail(`Unsafe shared payload id: ${id}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if(!Array.isArray(routes)||routes.length===0){
|
|
546
|
+
fail(`sharedPayloads.${id} must be a non-empty array.`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
sharedPayloads[id]=Object.freeze(routes.map((route,index)=>
|
|
550
|
+
validateSharedRoute(route,`sharedPayloads.${id}[${index}]`)
|
|
551
|
+
));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return Object.freeze({
|
|
555
|
+
schemaVersion:1,
|
|
556
|
+
appsRoot:'apps',
|
|
557
|
+
distRoot:'dist',
|
|
558
|
+
sharedPayloads:Object.freeze(sharedPayloads),
|
|
559
|
+
configPath
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function validateAppConfig(value,appId,rootConfig,configPath=`apps/${appId}/${APP_CONFIG_NAME}`){
|
|
564
|
+
assertOnlyKeys(
|
|
565
|
+
value,
|
|
566
|
+
new Set([
|
|
567
|
+
'schemaVersion',
|
|
568
|
+
'id',
|
|
569
|
+
'displayName',
|
|
570
|
+
'version',
|
|
571
|
+
'entry',
|
|
572
|
+
'strategy',
|
|
573
|
+
'security',
|
|
574
|
+
'localAIModelPolicy',
|
|
575
|
+
'include',
|
|
576
|
+
'exclude',
|
|
577
|
+
'shared',
|
|
578
|
+
'adapter'
|
|
579
|
+
]),
|
|
580
|
+
`${appId}/${APP_CONFIG_NAME}`
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
if(value.schemaVersion!==1){
|
|
584
|
+
fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if(value.id!==appId||!APP_ID_PATTERN.test(value.id)){
|
|
588
|
+
fail(`${appId}/${APP_CONFIG_NAME}.id must exactly match its apps directory.`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const displayName=assertSafePresentationText(
|
|
592
|
+
value.displayName,
|
|
593
|
+
`${appId}/${APP_CONFIG_NAME}.displayName`
|
|
594
|
+
);
|
|
595
|
+
parseSemver(value.version);
|
|
596
|
+
const entry=normalizeRelativePath(value.entry,`${appId}/${APP_CONFIG_NAME}.entry`);
|
|
597
|
+
const include=validatePathList(value.include,`${appId}/${APP_CONFIG_NAME}.include`,{required:true});
|
|
598
|
+
const exclude=validatePathList(value.exclude??[],`${appId}/${APP_CONFIG_NAME}.exclude`);
|
|
599
|
+
|
|
600
|
+
if(include.some(allowed=>pathIsSameOrDescendant(APP_CONFIG_NAME,allowed))){
|
|
601
|
+
fail(`${appId}/${APP_CONFIG_NAME}.include must not expose the authored package configuration.`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const localAIModelPolicy=value.localAIModelPolicy===undefined
|
|
605
|
+
?Object.freeze({verified_only:true,models:Object.freeze([])})
|
|
606
|
+
:validateLocalAIModelPolicy(value.localAIModelPolicy,`${appId}/${APP_CONFIG_NAME}.localAIModelPolicy`);
|
|
607
|
+
const security=validateAppSecurity(value.security,appId);
|
|
608
|
+
|
|
609
|
+
for(const model of localAIModelPolicy.models){
|
|
610
|
+
if(isAlwaysForbidden(model.definition)||isExcluded(model.definition,exclude)
|
|
611
|
+
||!include.some(allowed=>pathIsSameOrDescendant(model.definition,allowed))){
|
|
612
|
+
fail(`${appId}/${APP_CONFIG_NAME}.localAIModelPolicy model definition is not covered by its public include rules: ${model.definition}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if(isAlwaysForbidden(entry)||isExcluded(entry,exclude)
|
|
617
|
+
||!include.some(allowed=>pathIsSameOrDescendant(entry,allowed))){
|
|
618
|
+
fail(`${appId}/${APP_CONFIG_NAME}.entry is not covered by its public include rules.`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if(!['static','adapter'].includes(value.strategy)){
|
|
622
|
+
fail(`${appId}/${APP_CONFIG_NAME}.strategy must be "static" or "adapter".`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if(!Array.isArray(value.shared)||new Set(value.shared).size!==value.shared.length){
|
|
626
|
+
fail(`${appId}/${APP_CONFIG_NAME}.shared must be an array of unique shared payload ids.`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
for(const [index,sharedId] of value.shared.entries()){
|
|
630
|
+
if(typeof sharedId!=='string'||!Object.hasOwn(rootConfig.sharedPayloads,sharedId)){
|
|
631
|
+
fail(`${appId}/${APP_CONFIG_NAME}.shared[${index}] references an unknown shared payload: ${String(sharedId)}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let adapter=null;
|
|
636
|
+
|
|
637
|
+
if(value.strategy==='adapter'){
|
|
638
|
+
adapter=normalizeRelativePath(value.adapter,`${appId}/${APP_CONFIG_NAME}.adapter`);
|
|
639
|
+
|
|
640
|
+
if(!adapter.startsWith('scripts/')||path.posix.extname(adapter)!=='.mjs'){
|
|
641
|
+
fail(`${appId}/${APP_CONFIG_NAME}.adapter must be an app-local scripts/*.mjs module.`);
|
|
642
|
+
}
|
|
643
|
+
}else if(value.adapter!==undefined){
|
|
644
|
+
fail(`${appId}/${APP_CONFIG_NAME}.adapter is only valid with strategy "adapter".`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return Object.freeze({
|
|
648
|
+
schemaVersion:1,
|
|
649
|
+
id:appId,
|
|
650
|
+
displayName,
|
|
651
|
+
version:value.version,
|
|
652
|
+
entry,
|
|
653
|
+
strategy:value.strategy,
|
|
654
|
+
security,
|
|
655
|
+
localAIModelPolicy,
|
|
656
|
+
include:Object.freeze(include),
|
|
657
|
+
exclude:Object.freeze(exclude),
|
|
658
|
+
shared:Object.freeze([...value.shared]),
|
|
659
|
+
adapter,
|
|
660
|
+
configPath
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function validateOriginList(value,label,{allowLoopbackHttp=false,allowHttpsScheme=false}={}){
|
|
665
|
+
if(!Array.isArray(value)||value.length>16){
|
|
666
|
+
fail(`${label} must be an array with at most 16 origins.`);
|
|
667
|
+
}
|
|
668
|
+
const origins=value.map((origin,index)=>{
|
|
669
|
+
if(typeof origin!=='string'||origin!==origin.trim()){
|
|
670
|
+
fail(`${label}[${index}] is invalid.`);
|
|
671
|
+
}
|
|
672
|
+
if(origin==='https:'&&allowHttpsScheme)return origin;
|
|
673
|
+
let parsed;
|
|
674
|
+
try{
|
|
675
|
+
parsed=new URL(origin);
|
|
676
|
+
}catch{
|
|
677
|
+
fail(`${label}[${index}] is not a valid URL origin.`);
|
|
678
|
+
}
|
|
679
|
+
if(parsed.origin!==origin||parsed.hostname.endsWith('.')||parsed.username||parsed.password
|
|
680
|
+
||parsed.pathname!=='/'||parsed.search||parsed.hash){
|
|
681
|
+
fail(`${label}[${index}] must be a canonical allowed origin.`);
|
|
682
|
+
}
|
|
683
|
+
if(parsed.protocol==='http:'){
|
|
684
|
+
if(!allowLoopbackHttp||!['127.0.0.1','[::1]'].includes(parsed.hostname)){
|
|
685
|
+
fail(`${label}[${index}] may use HTTP only for a numeric loopback host.`);
|
|
686
|
+
}
|
|
687
|
+
}else if(parsed.protocol!=='https:'){
|
|
688
|
+
fail(`${label}[${index}] must use HTTPS or an approved loopback HTTP origin.`);
|
|
689
|
+
}
|
|
690
|
+
return parsed.origin;
|
|
691
|
+
});
|
|
692
|
+
if(new Set(origins).size!==origins.length)fail(`${label} must not contain duplicates.`);
|
|
693
|
+
if(JSON.stringify(origins)!==JSON.stringify([...origins].sort(compareText))){
|
|
694
|
+
fail(`${label} must be sorted for deterministic projection.`);
|
|
695
|
+
}
|
|
696
|
+
return Object.freeze(origins);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function validateAppSecurity(value,appId){
|
|
700
|
+
if(!isPlainObject(value))fail(`${appId}/${APP_CONFIG_NAME}.security must be an object.`);
|
|
701
|
+
const label=`${appId}/${APP_CONFIG_NAME}.security`;
|
|
702
|
+
assertOnlyKeys(value,new Set(['connectOrigins','frameOrigins','mediaOrigins']),label);
|
|
703
|
+
return Object.freeze({
|
|
704
|
+
connectOrigins:validateOriginList(value.connectOrigins,`${label}.connectOrigins`,{allowLoopbackHttp:true}),
|
|
705
|
+
frameOrigins:validateOriginList(value.frameOrigins,`${label}.frameOrigins`,{allowHttpsScheme:appId==='browser'}),
|
|
706
|
+
mediaOrigins:validateOriginList(value.mediaOrigins,`${label}.mediaOrigins`)
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function validateLocalAIModelPolicy(value,label){
|
|
711
|
+
assertOnlyKeys(value,new Set(['verified_only','models']),label);
|
|
712
|
+
|
|
713
|
+
if(typeof value.verified_only!=='boolean'){
|
|
714
|
+
fail(`${label}.verified_only must be a boolean.`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if(!Array.isArray(value.models)){
|
|
718
|
+
fail(`${label}.models must be an array.`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if(value.models.length>MAX_LOCAL_AI_MODELS){
|
|
722
|
+
fail(`${label}.models must contain no more than ${MAX_LOCAL_AI_MODELS} entries.`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const modelNames=new Set();
|
|
726
|
+
const definitions=new Set();
|
|
727
|
+
const models=value.models.map((model,index)=>{
|
|
728
|
+
const modelLabel=`${label}.models[${index}]`;
|
|
729
|
+
assertOnlyKeys(model,new Set(['name','definition']),modelLabel);
|
|
730
|
+
|
|
731
|
+
if(typeof model.name!=='string'||model.name!==model.name.trim()
|
|
732
|
+
||!OLLAMA_MODEL_IDENTIFIER.test(model.name)||model.name.toUpperCase()==='OPENAI'){
|
|
733
|
+
fail(`${modelLabel}.name must be a canonical bounded Ollama model identifier.`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if(typeof model.definition!=='string'||model.definition.length>128
|
|
737
|
+
||!MODEL_DEFINITION_PATTERN.test(model.definition)
|
|
738
|
+
||normalizeRelativePath(model.definition,`${modelLabel}.definition`)!==model.definition){
|
|
739
|
+
fail(`${modelLabel}.definition must be a safe app-relative Modelfile basename.`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const canonicalName=model.name.toLocaleLowerCase('en-US');
|
|
743
|
+
const canonicalAlias=canonicalName.includes(':')?canonicalName:`${canonicalName}:latest`;
|
|
744
|
+
const definitionKey=pathKey(model.definition);
|
|
745
|
+
|
|
746
|
+
if(modelNames.has(canonicalAlias)){
|
|
747
|
+
fail(`${label}.models contains a duplicate canonical model name: ${model.name}`);
|
|
748
|
+
}
|
|
749
|
+
if(definitions.has(definitionKey)){
|
|
750
|
+
fail(`${label}.models contains a duplicate definition: ${model.definition}`);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
modelNames.add(canonicalAlias);
|
|
754
|
+
definitions.add(definitionKey);
|
|
755
|
+
return Object.freeze({name:model.name,definition:model.definition});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
return Object.freeze({
|
|
759
|
+
verified_only:value.verified_only,
|
|
760
|
+
models:Object.freeze(models)
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function validateLocalAIModelDefinitions(appRoot,config){
|
|
765
|
+
for(const [index,model] of config.localAIModelPolicy.models.entries()){
|
|
766
|
+
const label=`${config.id}/${APP_CONFIG_NAME}.localAIModelPolicy.models[${index}].definition`;
|
|
767
|
+
const definitionPath=resolveInside(appRoot,model.definition,label);
|
|
768
|
+
let details;
|
|
769
|
+
|
|
770
|
+
try{
|
|
771
|
+
await assertNoLinks(appRoot,definitionPath,label);
|
|
772
|
+
details=await lstat(definitionPath);
|
|
773
|
+
}catch(error){
|
|
774
|
+
if(error?.code==='ENOENT'){
|
|
775
|
+
fail(`${label} does not exist: ${model.definition}`);
|
|
776
|
+
}
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if(details.isSymbolicLink()||!details.isFile()||details.size<1
|
|
781
|
+
||details.size>MAX_MODEL_DEFINITION_BYTES){
|
|
782
|
+
fail(`${label} must be a non-empty regular file no larger than 512 KiB.`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function assertNoLinks(root,candidate,label){
|
|
788
|
+
const resolvedRoot=path.resolve(root);
|
|
789
|
+
const resolvedCandidate=path.resolve(candidate);
|
|
790
|
+
|
|
791
|
+
if(!isInside(resolvedRoot,resolvedCandidate,{allowEqual:true})){
|
|
792
|
+
fail(`${label} leaves its allowed root.`);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const relative=path.relative(resolvedRoot,resolvedCandidate);
|
|
796
|
+
let current=resolvedRoot;
|
|
797
|
+
const rootInfo=await lstat(resolvedRoot);
|
|
798
|
+
|
|
799
|
+
if(rootInfo.isSymbolicLink()||!rootInfo.isDirectory()){
|
|
800
|
+
fail(`${label} root must be a real directory.`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
for(const segment of relative.split(path.sep).filter(Boolean)){
|
|
804
|
+
current=path.join(current,segment);
|
|
805
|
+
const info=await lstat(current);
|
|
806
|
+
|
|
807
|
+
if(info.isSymbolicLink()){
|
|
808
|
+
fail(`${label} contains a symbolic link or junction: ${current}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const [actualRoot,actualCandidate]=await Promise.all([
|
|
813
|
+
realpath(resolvedRoot),
|
|
814
|
+
realpath(resolvedCandidate)
|
|
815
|
+
]);
|
|
816
|
+
|
|
817
|
+
if(!isInside(actualRoot,actualCandidate,{allowEqual:true})){
|
|
818
|
+
fail(`${label} resolves outside its allowed root.`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function assertSafeDistBoundary(workspaceRoot,distRoot,{create=false}={}){
|
|
823
|
+
await assertNoLinks(workspaceRoot,workspaceRoot,'workspace');
|
|
824
|
+
let details;
|
|
825
|
+
|
|
826
|
+
try{
|
|
827
|
+
details=await lstat(distRoot);
|
|
828
|
+
}catch(error){
|
|
829
|
+
if(error?.code!=='ENOENT'){
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if(!create){
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
try{
|
|
838
|
+
await mkdir(distRoot);
|
|
839
|
+
}catch(createError){
|
|
840
|
+
if(createError?.code!=='EEXIST'){
|
|
841
|
+
throw createError;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
details=await lstat(distRoot);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if(details.isSymbolicLink()||!details.isDirectory()){
|
|
849
|
+
fail('dist must be a real workspace directory, not a link, junction, or special entry.');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
await assertNoLinks(workspaceRoot,distRoot,'dist');
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function assertOptionalSafeOutput(distRoot,outputRoot,appId){
|
|
857
|
+
let details;
|
|
858
|
+
|
|
859
|
+
try{
|
|
860
|
+
details=await lstat(outputRoot);
|
|
861
|
+
}catch(error){
|
|
862
|
+
if(error?.code==='ENOENT'){
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if(details.isSymbolicLink()||!details.isDirectory()){
|
|
870
|
+
fail(`dist/${appId} must be a real directory, not a link, junction, or special entry.`);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
await assertNoLinks(distRoot,outputRoot,`dist/${appId}`);
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function appDescriptorContracts(){
|
|
878
|
+
appDescriptorContractsPromise??=import('../app-descriptor.mjs');
|
|
879
|
+
return appDescriptorContractsPromise;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function optionalRegularFileIdentity(filePath,label){
|
|
883
|
+
let info;
|
|
884
|
+
try{
|
|
885
|
+
info=await lstat(filePath,{bigint:true});
|
|
886
|
+
}catch(error){
|
|
887
|
+
if(error?.code==='ENOENT')return null;
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
if(info.isSymbolicLink()||!info.isFile()){
|
|
891
|
+
fail(`${label} must be a regular file, not a link or special entry.`);
|
|
892
|
+
}
|
|
893
|
+
return fileIdentity(info);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function recordedIdentityMatches(left,right){
|
|
897
|
+
if(left===null||right===null)return left===right;
|
|
898
|
+
return left.device===right.device
|
|
899
|
+
&&left.inode===right.inode
|
|
900
|
+
&&left.bytes===right.bytes
|
|
901
|
+
&&left.modifiedNanoseconds===right.modifiedNanoseconds
|
|
902
|
+
&&left.changedNanoseconds===right.changedNanoseconds
|
|
903
|
+
&&left.links===right.links;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function createAppDescriptorAuthority(context,packageDocument,{signal}={}){
|
|
907
|
+
throwIfAborted(signal);
|
|
908
|
+
const contracts=await appDescriptorContracts();
|
|
909
|
+
const descriptorPath=path.join(context.appRoot,APP_DESCRIPTOR_NAME);
|
|
910
|
+
const descriptorIdentity=await optionalRegularFileIdentity(
|
|
911
|
+
descriptorPath,
|
|
912
|
+
`apps/${context.config.id}/${APP_DESCRIPTOR_NAME}`
|
|
913
|
+
);
|
|
914
|
+
let descriptor;
|
|
915
|
+
let source;
|
|
916
|
+
let sourcePath;
|
|
917
|
+
let sourceIdentity;
|
|
918
|
+
|
|
919
|
+
if(descriptorIdentity){
|
|
920
|
+
const descriptorDocument=await readJsonDocument(
|
|
921
|
+
descriptorPath,
|
|
922
|
+
`apps/${context.config.id}/${APP_DESCRIPTOR_NAME}`,
|
|
923
|
+
{expectedIdentity:descriptorIdentity}
|
|
924
|
+
);
|
|
925
|
+
descriptor=contracts.validateAppDescriptor(descriptorDocument.value,{
|
|
926
|
+
appId:context.config.id
|
|
927
|
+
});
|
|
928
|
+
if(!isDeepStrictEqual(
|
|
929
|
+
contracts.projectPackageManifest(descriptor),
|
|
930
|
+
packageDocument.value
|
|
931
|
+
)){
|
|
932
|
+
fail(`${APP_DESCRIPTOR_NAME} does not project exactly to ${APP_CONFIG_NAME}.`);
|
|
933
|
+
}
|
|
934
|
+
source='authored';
|
|
935
|
+
sourcePath=descriptorPath;
|
|
936
|
+
sourceIdentity=descriptorDocument.identity;
|
|
937
|
+
}else{
|
|
938
|
+
const registryPath=path.join(context.workspaceRoot,LEGACY_APP_REGISTRY_PATH);
|
|
939
|
+
const registryBefore=await optionalRegularFileIdentity(
|
|
940
|
+
registryPath,
|
|
941
|
+
'Arcane native app registry'
|
|
942
|
+
);
|
|
943
|
+
const loaded=await contracts.loadAppDescriptor({
|
|
944
|
+
workspaceRoot:context.workspaceRoot,
|
|
945
|
+
appRoot:context.appRoot,
|
|
946
|
+
appId:context.config.id,
|
|
947
|
+
packageManifest:packageDocument.value
|
|
948
|
+
});
|
|
949
|
+
const [descriptorAfter,registryAfter]=await Promise.all([
|
|
950
|
+
optionalRegularFileIdentity(
|
|
951
|
+
descriptorPath,
|
|
952
|
+
`apps/${context.config.id}/${APP_DESCRIPTOR_NAME}`
|
|
953
|
+
),
|
|
954
|
+
optionalRegularFileIdentity(registryPath,'Arcane native app registry')
|
|
955
|
+
]);
|
|
956
|
+
if(descriptorAfter!==null||!recordedIdentityMatches(registryBefore,registryAfter)){
|
|
957
|
+
fail(`The descriptor source for ${context.config.id} changed while it was selected.`);
|
|
958
|
+
}
|
|
959
|
+
descriptor=loaded.descriptor;
|
|
960
|
+
source=loaded.source;
|
|
961
|
+
sourcePath=registryBefore?registryPath:null;
|
|
962
|
+
sourceIdentity=registryBefore;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const canonicalDescriptor=immutableJsonCopy(descriptor);
|
|
966
|
+
return Object.freeze({
|
|
967
|
+
descriptor:canonicalDescriptor,
|
|
968
|
+
descriptorSha256:contracts.appDescriptorSha256(canonicalDescriptor),
|
|
969
|
+
source,
|
|
970
|
+
sourcePath,
|
|
971
|
+
sourceIdentity,
|
|
972
|
+
packageConfigPath:context.config.configPath,
|
|
973
|
+
packageConfigIdentity:packageDocument.identity
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function assertAppDescriptorAuthorityCurrent(context,{signal}={}){
|
|
978
|
+
const expected=context.descriptorAuthority;
|
|
979
|
+
if(!expected)fail('Canonical app descriptor authority is unavailable.');
|
|
980
|
+
const packageDocument=await readJsonDocument(
|
|
981
|
+
expected.packageConfigPath,
|
|
982
|
+
expected.packageConfigPath,
|
|
983
|
+
{expectedIdentity:expected.packageConfigIdentity}
|
|
984
|
+
);
|
|
985
|
+
const config=validateAppConfig(
|
|
986
|
+
packageDocument.value,
|
|
987
|
+
context.config.id,
|
|
988
|
+
context.rootConfig,
|
|
989
|
+
expected.packageConfigPath
|
|
990
|
+
);
|
|
991
|
+
const current=await createAppDescriptorAuthority(
|
|
992
|
+
{...context,config},
|
|
993
|
+
packageDocument,
|
|
994
|
+
{signal}
|
|
995
|
+
);
|
|
996
|
+
if(current.descriptorSha256!==expected.descriptorSha256
|
|
997
|
+
||current.source!==expected.source
|
|
998
|
+
||current.sourcePath!==expected.sourcePath
|
|
999
|
+
||!recordedIdentityMatches(current.sourceIdentity,expected.sourceIdentity)
|
|
1000
|
+
||!recordedIdentityMatches(
|
|
1001
|
+
current.packageConfigIdentity,
|
|
1002
|
+
expected.packageConfigIdentity
|
|
1003
|
+
)){
|
|
1004
|
+
fail(`The canonical descriptor authority for ${context.config.id} changed during packaging.`);
|
|
1005
|
+
}
|
|
1006
|
+
return expected;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function assertValidatedDescriptorAuthority(validation,descriptorAuthority){
|
|
1010
|
+
if(validation===undefined||validation?.app?.descriptor===undefined)return;
|
|
1011
|
+
const descriptor=validation?.app?.descriptor;
|
|
1012
|
+
const contracts=await appDescriptorContracts();
|
|
1013
|
+
if(contracts.appDescriptorSha256(descriptor)!==descriptorAuthority.descriptorSha256){
|
|
1014
|
+
fail('Source validation returned a different canonical Arcane application descriptor.');
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function getAppContext({
|
|
1019
|
+
workspaceRoot:requestedWorkspaceRoot,
|
|
1020
|
+
appId,
|
|
1021
|
+
bindDescriptorAuthority=false,
|
|
1022
|
+
signal
|
|
1023
|
+
}){
|
|
1024
|
+
const workspaceRoot=normalizeWorkspaceRoot(requestedWorkspaceRoot);
|
|
1025
|
+
|
|
1026
|
+
if(typeof appId!=='string'||!APP_ID_PATTERN.test(appId)){
|
|
1027
|
+
fail(`Invalid app id: ${String(appId)}`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const rootConfig=await loadRootConfig(workspaceRoot);
|
|
1031
|
+
const appsRoot=path.join(workspaceRoot,rootConfig.appsRoot);
|
|
1032
|
+
const appRoot=resolveInside(appsRoot,appId,'app id');
|
|
1033
|
+
let appInfo;
|
|
1034
|
+
|
|
1035
|
+
try{
|
|
1036
|
+
appInfo=await lstat(appRoot);
|
|
1037
|
+
}catch(error){
|
|
1038
|
+
if(error?.code==='ENOENT'){
|
|
1039
|
+
const available=(await readdir(appsRoot,{withFileTypes:true}))
|
|
1040
|
+
.filter(entry=>entry.isDirectory()&&APP_ID_PATTERN.test(entry.name))
|
|
1041
|
+
.map(entry=>entry.name)
|
|
1042
|
+
.sort(compareText);
|
|
1043
|
+
fail(`Unknown app "${appId}". Available apps: ${available.join(', ')||'[none]'}.`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if(appInfo.isSymbolicLink()||!appInfo.isDirectory()){
|
|
1050
|
+
fail(`apps/${appId} must be a real directory, not a link or special entry.`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
await assertNoLinks(appsRoot,appRoot,`apps/${appId}`);
|
|
1054
|
+
const configPath=path.join(appRoot,APP_CONFIG_NAME);
|
|
1055
|
+
const configDocument=await readJsonDocument(
|
|
1056
|
+
configPath,
|
|
1057
|
+
`apps/${appId}/${APP_CONFIG_NAME}`
|
|
1058
|
+
);
|
|
1059
|
+
const config=validateAppConfig(configDocument.value,appId,rootConfig,configPath);
|
|
1060
|
+
await validateLocalAIModelDefinitions(appRoot,config);
|
|
1061
|
+
const distRoot=path.join(workspaceRoot,rootConfig.distRoot);
|
|
1062
|
+
const outputRoot=resolveInside(distRoot,appId,'package output');
|
|
1063
|
+
const distExists=await assertSafeDistBoundary(workspaceRoot,distRoot);
|
|
1064
|
+
|
|
1065
|
+
if(distExists){
|
|
1066
|
+
await assertOptionalSafeOutput(distRoot,outputRoot,appId);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const context={workspaceRoot,rootConfig,appsRoot,appRoot,distRoot,outputRoot,config};
|
|
1070
|
+
if(!bindDescriptorAuthority)return context;
|
|
1071
|
+
return {
|
|
1072
|
+
...context,
|
|
1073
|
+
descriptorAuthority:await createAppDescriptorAuthority(context,configDocument,{signal})
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function enumerateRoute({
|
|
1078
|
+
workspaceRoot,
|
|
1079
|
+
sourceRoot,
|
|
1080
|
+
destinationRoot,
|
|
1081
|
+
include,
|
|
1082
|
+
exclude,
|
|
1083
|
+
label,
|
|
1084
|
+
appPayload=false,
|
|
1085
|
+
signal
|
|
1086
|
+
}){
|
|
1087
|
+
throwIfAborted(signal);
|
|
1088
|
+
await assertNoLinks(workspaceRoot,sourceRoot,`${label}.source`);
|
|
1089
|
+
const files=[];
|
|
1090
|
+
|
|
1091
|
+
async function visit(absolute,relative){
|
|
1092
|
+
throwIfAborted(signal);
|
|
1093
|
+
if(isExcluded(relative,exclude)||(appPayload&&isAppSourceForbidden(relative))){
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const info=await lstat(absolute,{bigint:true});
|
|
1098
|
+
|
|
1099
|
+
if(info.isSymbolicLink()){
|
|
1100
|
+
fail(`${label} contains a symbolic link or junction: ${relative}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if(info.isDirectory()){
|
|
1104
|
+
const entries=await readdir(absolute,{withFileTypes:true});
|
|
1105
|
+
|
|
1106
|
+
for(const entry of entries.sort((left,right)=>compareText(left.name,right.name))){
|
|
1107
|
+
throwIfAborted(signal);
|
|
1108
|
+
const childRelative=`${relative}/${entry.name}`;
|
|
1109
|
+
|
|
1110
|
+
if(isExcluded(childRelative,exclude)
|
|
1111
|
+
||(appPayload&&isAppSourceForbidden(childRelative))){
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if(entry.isSymbolicLink()){
|
|
1116
|
+
fail(`${label} contains a symbolic link or junction: ${childRelative}`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
await visit(path.join(absolute,entry.name),childRelative);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if(!info.isFile()){
|
|
1126
|
+
fail(`${label} contains a non-file entry: ${relative}`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const destination=destinationRoot==='.'
|
|
1130
|
+
?relative
|
|
1131
|
+
:`${destinationRoot}/${relative}`;
|
|
1132
|
+
const bytes=Number(info.size);
|
|
1133
|
+
if(!Number.isSafeInteger(bytes)||bytes<0){
|
|
1134
|
+
fail(`${label} contains a file whose size is not safely representable: ${relative}`);
|
|
1135
|
+
}
|
|
1136
|
+
files.push({
|
|
1137
|
+
source:absolute,
|
|
1138
|
+
sourceRelative:relative,
|
|
1139
|
+
destination:normalizeRelativePath(destination,`${label} destination`),
|
|
1140
|
+
bytes,
|
|
1141
|
+
identity:fileIdentity(info),
|
|
1142
|
+
label
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
for(const allowed of include){
|
|
1147
|
+
throwIfAborted(signal);
|
|
1148
|
+
if(isExcluded(allowed,exclude)||(appPayload&&isAppSourceForbidden(allowed))){
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const candidate=resolveInside(sourceRoot,allowed,`${label}.include`);
|
|
1153
|
+
|
|
1154
|
+
try{
|
|
1155
|
+
await assertNoLinks(sourceRoot,candidate,`${label}.include "${allowed}"`);
|
|
1156
|
+
}catch(error){
|
|
1157
|
+
if(error?.code==='ENOENT'){
|
|
1158
|
+
fail(`${label}.include does not exist: ${allowed}`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
throw error;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
await visit(candidate,allowed);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
return files;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function workspaceLocationIdentity(info){
|
|
1171
|
+
return Object.freeze({
|
|
1172
|
+
device:String(info.dev),
|
|
1173
|
+
inode:String(info.ino)
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function workspaceLocationMatches(info,identity){
|
|
1178
|
+
return String(info.dev)===identity.device&&String(info.ino)===identity.inode;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function normalizeSharedPayloadSelection(sharedPayloadIds,rootConfig,{required=false}={}){
|
|
1182
|
+
const selected=sharedPayloadIds===undefined
|
|
1183
|
+
?Object.keys(rootConfig.sharedPayloads)
|
|
1184
|
+
:sharedPayloadIds;
|
|
1185
|
+
|
|
1186
|
+
if(!Array.isArray(selected)||selected.length>256||(required&&selected.length===0)){
|
|
1187
|
+
fail('sharedPayloadIds must be a non-empty array with at most 256 entries.');
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const normalized=[];
|
|
1191
|
+
const seen=new Set();
|
|
1192
|
+
for(const [index,id] of selected.entries()){
|
|
1193
|
+
if(typeof id!=='string'||!SAFE_SHARED_ID_PATTERN.test(id)
|
|
1194
|
+
||!Object.hasOwn(rootConfig.sharedPayloads,id)){
|
|
1195
|
+
fail(`sharedPayloadIds[${index}] references an unknown shared payload: ${String(id)}`);
|
|
1196
|
+
}
|
|
1197
|
+
if(seen.has(id))fail(`sharedPayloadIds contains a duplicate shared payload: ${id}`);
|
|
1198
|
+
seen.add(id);
|
|
1199
|
+
normalized.push(id);
|
|
1200
|
+
}
|
|
1201
|
+
return Object.freeze(normalized.sort(compareText));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function assertSnapshotCoverage(state,requiredIds){
|
|
1205
|
+
for(const id of requiredIds){
|
|
1206
|
+
if(!Object.hasOwn(state.filesBySharedPayload,id)){
|
|
1207
|
+
fail(`Shared payload snapshot does not include the required payload: ${id}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function authenticateSharedPayloadSnapshotState(receipt,{
|
|
1213
|
+
workspaceRoot,
|
|
1214
|
+
sharedPayloadIds,
|
|
1215
|
+
signal
|
|
1216
|
+
}={}){
|
|
1217
|
+
throwIfAborted(signal);
|
|
1218
|
+
const state=issuedSharedPayloadSnapshots.get(receipt);
|
|
1219
|
+
if(!state)fail('Shared payload snapshot was not issued by this SDK process.');
|
|
1220
|
+
const requested=normalizeWorkspaceRoot(workspaceRoot);
|
|
1221
|
+
let workspaceInfo;
|
|
1222
|
+
let canonicalWorkspaceRoot;
|
|
1223
|
+
try{
|
|
1224
|
+
workspaceInfo=await lstat(requested,{bigint:true});
|
|
1225
|
+
canonicalWorkspaceRoot=await realpath(requested);
|
|
1226
|
+
}catch(error){
|
|
1227
|
+
fail(`Shared payload snapshot workspace is unavailable: ${error.message}`);
|
|
1228
|
+
}
|
|
1229
|
+
if(workspaceInfo.isSymbolicLink()||!workspaceInfo.isDirectory()
|
|
1230
|
+
||canonicalWorkspaceRoot!==state.canonicalWorkspaceRoot
|
|
1231
|
+
||receipt.canonicalWorkspaceRoot!==canonicalWorkspaceRoot
|
|
1232
|
+
||!workspaceLocationMatches(workspaceInfo,state.workspaceIdentity)){
|
|
1233
|
+
fail('Shared payload snapshot belongs to a different workspace identity.');
|
|
1234
|
+
}
|
|
1235
|
+
let configInfo;
|
|
1236
|
+
try{
|
|
1237
|
+
configInfo=await lstat(state.rootConfigPath,{bigint:true});
|
|
1238
|
+
}catch(error){
|
|
1239
|
+
fail(`Shared payload snapshot root configuration changed: ${error.message}`);
|
|
1240
|
+
}
|
|
1241
|
+
if(configInfo.isSymbolicLink()||!configInfo.isFile()
|
|
1242
|
+
||!identityMatches(configInfo,state.rootConfigIdentity)){
|
|
1243
|
+
fail('Shared payload snapshot root configuration changed after preparation.');
|
|
1244
|
+
}
|
|
1245
|
+
if(sharedPayloadIds!==undefined){
|
|
1246
|
+
if(!Array.isArray(sharedPayloadIds)||sharedPayloadIds.length>256){
|
|
1247
|
+
fail('sharedPayloadIds must be an array with at most 256 entries.');
|
|
1248
|
+
}
|
|
1249
|
+
const seen=new Set();
|
|
1250
|
+
for(const [index,id] of sharedPayloadIds.entries()){
|
|
1251
|
+
if(typeof id!=='string'||!SAFE_SHARED_ID_PATTERN.test(id)||seen.has(id)){
|
|
1252
|
+
fail(`sharedPayloadIds[${index}] is invalid or duplicated.`);
|
|
1253
|
+
}
|
|
1254
|
+
seen.add(id);
|
|
1255
|
+
}
|
|
1256
|
+
assertSnapshotCoverage(state,sharedPayloadIds);
|
|
1257
|
+
}
|
|
1258
|
+
throwIfAborted(signal);
|
|
1259
|
+
return state;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export async function prepareSharedPayloadSnapshot({
|
|
1263
|
+
workspaceRoot:requestedWorkspaceRoot,
|
|
1264
|
+
sharedPayloadIds,
|
|
1265
|
+
signal,
|
|
1266
|
+
onEvent
|
|
1267
|
+
}={}){
|
|
1268
|
+
await emitOperation(onEvent,{type:'shared-payload.snapshot.started'});
|
|
1269
|
+
throwIfAborted(signal);
|
|
1270
|
+
const resolvedWorkspaceRoot=normalizeWorkspaceRoot(requestedWorkspaceRoot);
|
|
1271
|
+
const initialWorkspaceInfo=await lstat(resolvedWorkspaceRoot,{bigint:true});
|
|
1272
|
+
if(initialWorkspaceInfo.isSymbolicLink()||!initialWorkspaceInfo.isDirectory()){
|
|
1273
|
+
fail('Shared payload snapshot workspace must be a real directory.');
|
|
1274
|
+
}
|
|
1275
|
+
const canonicalWorkspaceRoot=await realpath(resolvedWorkspaceRoot);
|
|
1276
|
+
await assertNoLinks(canonicalWorkspaceRoot,canonicalWorkspaceRoot,'shared payload snapshot workspace');
|
|
1277
|
+
const rootConfigDocument=await loadRootConfigDocument(canonicalWorkspaceRoot);
|
|
1278
|
+
const selectedIds=normalizeSharedPayloadSelection(
|
|
1279
|
+
sharedPayloadIds,
|
|
1280
|
+
rootConfigDocument.value,
|
|
1281
|
+
{required:true}
|
|
1282
|
+
);
|
|
1283
|
+
const retained=[];
|
|
1284
|
+
const retainedBySharedPayload=Object.fromEntries(selectedIds.map(id=>[id,[]]));
|
|
1285
|
+
let totalBytes=0;
|
|
1286
|
+
let completedFiles=0;
|
|
1287
|
+
|
|
1288
|
+
for(const sharedPayloadId of selectedIds){
|
|
1289
|
+
const routes=rootConfigDocument.value.sharedPayloads[sharedPayloadId];
|
|
1290
|
+
for(const [routeIndex,route] of routes.entries()){
|
|
1291
|
+
throwIfAborted(signal);
|
|
1292
|
+
const sourceRoot=resolveInside(
|
|
1293
|
+
canonicalWorkspaceRoot,
|
|
1294
|
+
route.source,
|
|
1295
|
+
`sharedPayloads.${sharedPayloadId}[${routeIndex}].source`
|
|
1296
|
+
);
|
|
1297
|
+
const files=await enumerateRoute({
|
|
1298
|
+
workspaceRoot:canonicalWorkspaceRoot,
|
|
1299
|
+
sourceRoot,
|
|
1300
|
+
destinationRoot:route.destination,
|
|
1301
|
+
include:route.include,
|
|
1302
|
+
exclude:route.exclude,
|
|
1303
|
+
label:`sharedPayloads.${sharedPayloadId}[${routeIndex}]`,
|
|
1304
|
+
signal
|
|
1305
|
+
});
|
|
1306
|
+
for(const file of files){
|
|
1307
|
+
throwIfAborted(signal);
|
|
1308
|
+
if(retained.length>=MAX_SHARED_SNAPSHOT_FILE_COUNT){
|
|
1309
|
+
fail(`Shared payload snapshot exceeds ${MAX_SHARED_SNAPSHOT_FILE_COUNT} files.`);
|
|
1310
|
+
}
|
|
1311
|
+
if(file.bytes>MAX_SHARED_SNAPSHOT_FILE_BYTES){
|
|
1312
|
+
fail(`Shared payload snapshot file exceeds ${MAX_SHARED_SNAPSHOT_FILE_BYTES} bytes: ${file.sourceRelative}`);
|
|
1313
|
+
}
|
|
1314
|
+
if(totalBytes+file.bytes>MAX_SHARED_SNAPSHOT_TOTAL_BYTES){
|
|
1315
|
+
fail(`Shared payload snapshot exceeds ${MAX_SHARED_SNAPSHOT_TOTAL_BYTES} retained bytes.`);
|
|
1316
|
+
}
|
|
1317
|
+
const stable=await readStableBytes(file.source,file.label,file.identity);
|
|
1318
|
+
if(stable.bytes.length!==file.bytes){
|
|
1319
|
+
fail(`Shared payload snapshot file changed size while retained: ${file.sourceRelative}`);
|
|
1320
|
+
}
|
|
1321
|
+
const digest=createHash('sha256').update(stable.bytes).digest('hex');
|
|
1322
|
+
const source=route.source==='.'
|
|
1323
|
+
?file.sourceRelative
|
|
1324
|
+
:`${route.source}/${file.sourceRelative}`;
|
|
1325
|
+
const record=Object.freeze({
|
|
1326
|
+
...file,
|
|
1327
|
+
sharedPayloadId,
|
|
1328
|
+
routeIndex,
|
|
1329
|
+
sourceRelative:normalizeRelativePath(source,'shared payload snapshot source'),
|
|
1330
|
+
retainedBytes:stable.bytes,
|
|
1331
|
+
sha256:digest
|
|
1332
|
+
});
|
|
1333
|
+
retained.push(record);
|
|
1334
|
+
retainedBySharedPayload[sharedPayloadId].push(record);
|
|
1335
|
+
totalBytes+=stable.bytes.length;
|
|
1336
|
+
completedFiles+=1;
|
|
1337
|
+
await emitOperation(onEvent,{
|
|
1338
|
+
type:'shared-payload.snapshot.progress',
|
|
1339
|
+
current:completedFiles,
|
|
1340
|
+
completedBytes:totalBytes,
|
|
1341
|
+
sharedPayloadId,
|
|
1342
|
+
path:record.sourceRelative
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const [finalWorkspaceInfo,finalConfigInfo]=await Promise.all([
|
|
1349
|
+
lstat(canonicalWorkspaceRoot,{bigint:true}),
|
|
1350
|
+
lstat(rootConfigDocument.configPath,{bigint:true})
|
|
1351
|
+
]);
|
|
1352
|
+
const workspaceIdentity=workspaceLocationIdentity(initialWorkspaceInfo);
|
|
1353
|
+
if(finalWorkspaceInfo.isSymbolicLink()||!finalWorkspaceInfo.isDirectory()
|
|
1354
|
+
||!workspaceLocationMatches(finalWorkspaceInfo,workspaceIdentity)
|
|
1355
|
+
||finalConfigInfo.isSymbolicLink()||!finalConfigInfo.isFile()
|
|
1356
|
+
||!identityMatches(finalConfigInfo,rootConfigDocument.identity)){
|
|
1357
|
+
fail('Shared payload snapshot workspace or root configuration changed during preparation.');
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const inventory=retained.map(record=>({
|
|
1361
|
+
sharedPayloadId:record.sharedPayloadId,
|
|
1362
|
+
routeIndex:record.routeIndex,
|
|
1363
|
+
source:record.sourceRelative,
|
|
1364
|
+
destination:record.destination,
|
|
1365
|
+
bytes:record.bytes,
|
|
1366
|
+
sha256:record.sha256
|
|
1367
|
+
})).sort((left,right)=>compareText(JSON.stringify(left),JSON.stringify(right)));
|
|
1368
|
+
const receipt=immutableJsonCopy({
|
|
1369
|
+
schemaVersion:1,
|
|
1370
|
+
kind:'arcane-shared-payload-snapshot',
|
|
1371
|
+
canonicalWorkspaceRoot,
|
|
1372
|
+
workspaceIdentity,
|
|
1373
|
+
rootConfig:Object.freeze({
|
|
1374
|
+
path:ROOT_CONFIG_NAME,
|
|
1375
|
+
identity:rootConfigDocument.identity,
|
|
1376
|
+
sha256:createHash('sha256').update(rootConfigDocument.bytes).digest('hex')
|
|
1377
|
+
}),
|
|
1378
|
+
sharedPayloadIds:selectedIds,
|
|
1379
|
+
files:inventory,
|
|
1380
|
+
fileCount:inventory.length,
|
|
1381
|
+
totalBytes,
|
|
1382
|
+
contentSha256:createHash('sha256').update(JSON.stringify(inventory)).digest('hex')
|
|
1383
|
+
});
|
|
1384
|
+
const filesBySharedPayload=Object.freeze(Object.fromEntries(
|
|
1385
|
+
Object.entries(retainedBySharedPayload).map(([id,files])=>[id,Object.freeze(files)])
|
|
1386
|
+
));
|
|
1387
|
+
issuedSharedPayloadSnapshots.set(receipt,Object.freeze({
|
|
1388
|
+
receipt,
|
|
1389
|
+
canonicalWorkspaceRoot,
|
|
1390
|
+
workspaceIdentity,
|
|
1391
|
+
rootConfigPath:rootConfigDocument.configPath,
|
|
1392
|
+
rootConfigIdentity:rootConfigDocument.identity,
|
|
1393
|
+
files:Object.freeze(retained),
|
|
1394
|
+
filesBySharedPayload
|
|
1395
|
+
}));
|
|
1396
|
+
await emitOperation(onEvent,{
|
|
1397
|
+
type:'shared-payload.snapshot.completed',
|
|
1398
|
+
fileCount:receipt.fileCount,
|
|
1399
|
+
totalBytes:receipt.totalBytes,
|
|
1400
|
+
contentSha256:receipt.contentSha256
|
|
1401
|
+
});
|
|
1402
|
+
return receipt;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export async function authenticateSharedPayloadSnapshot(receipt,options={}){
|
|
1406
|
+
await authenticateSharedPayloadSnapshotState(receipt,options);
|
|
1407
|
+
return receipt;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function collectPackageFiles(context,{signal,sharedPayloadState}={}){
|
|
1411
|
+
const {workspaceRoot,appRoot,config,rootConfig}=context;
|
|
1412
|
+
const files=await enumerateRoute({
|
|
1413
|
+
workspaceRoot,
|
|
1414
|
+
sourceRoot:appRoot,
|
|
1415
|
+
destinationRoot:`apps/${config.id}`,
|
|
1416
|
+
include:config.include,
|
|
1417
|
+
exclude:config.exclude,
|
|
1418
|
+
label:`apps.${config.id}`,
|
|
1419
|
+
appPayload:true,
|
|
1420
|
+
signal
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
if(sharedPayloadState){
|
|
1424
|
+
assertSnapshotCoverage(sharedPayloadState,config.shared);
|
|
1425
|
+
for(const sharedId of config.shared){
|
|
1426
|
+
files.push(...sharedPayloadState.filesBySharedPayload[sharedId]);
|
|
1427
|
+
}
|
|
1428
|
+
}else{
|
|
1429
|
+
for(const sharedId of config.shared){
|
|
1430
|
+
const routes=rootConfig.sharedPayloads[sharedId];
|
|
1431
|
+
|
|
1432
|
+
for(const [index,route] of routes.entries()){
|
|
1433
|
+
const sourceRoot=resolveInside(
|
|
1434
|
+
workspaceRoot,
|
|
1435
|
+
route.source,
|
|
1436
|
+
`sharedPayloads.${sharedId}[${index}].source`
|
|
1437
|
+
);
|
|
1438
|
+
files.push(...await enumerateRoute({
|
|
1439
|
+
workspaceRoot,
|
|
1440
|
+
sourceRoot,
|
|
1441
|
+
destinationRoot:route.destination,
|
|
1442
|
+
include:route.include,
|
|
1443
|
+
exclude:route.exclude,
|
|
1444
|
+
label:`sharedPayloads.${sharedId}[${index}]`,
|
|
1445
|
+
signal
|
|
1446
|
+
}));
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const destinations=new Map();
|
|
1452
|
+
|
|
1453
|
+
for(const file of files){
|
|
1454
|
+
throwIfAborted(signal);
|
|
1455
|
+
if(file.destination===RELEASE_MANIFEST_NAME||file.destination==='index.html'){
|
|
1456
|
+
fail(`${file.label} collides with generated package path: ${file.destination}`);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const key=pathKey(file.destination);
|
|
1460
|
+
|
|
1461
|
+
if(destinations.has(key)){
|
|
1462
|
+
fail(`Package destination collision: ${file.destination} from ${file.source} and ${destinations.get(key).source}.`);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
destinations.set(key,file);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const expectedEntry=`apps/${config.id}/${config.entry}`;
|
|
1469
|
+
|
|
1470
|
+
if(!destinations.has(pathKey(expectedEntry))){
|
|
1471
|
+
fail(`The configured entry file was not found in the package payload: ${expectedEntry}`);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return files.sort((left,right)=>compareText(left.destination,right.destination));
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function copyPackageFiles(files,outputRoot,{signal,onEvent}={}){
|
|
1478
|
+
let completedBytes=0;
|
|
1479
|
+
const buffer=Buffer.allocUnsafe(1024*1024);
|
|
1480
|
+
|
|
1481
|
+
for(const [index,file] of files.entries()){
|
|
1482
|
+
throwIfAborted(signal);
|
|
1483
|
+
const destination=resolveInside(outputRoot,file.destination,'package destination');
|
|
1484
|
+
await mkdir(path.dirname(destination),{recursive:true});
|
|
1485
|
+
if(file.retainedBytes!==undefined){
|
|
1486
|
+
if(!Buffer.isBuffer(file.retainedBytes)||file.retainedBytes.length!==file.bytes){
|
|
1487
|
+
fail(`Retained shared payload bytes are invalid for ${file.destination}.`);
|
|
1488
|
+
}
|
|
1489
|
+
const output=await open(destination,'wx');
|
|
1490
|
+
let copiedBytes=0;
|
|
1491
|
+
try{
|
|
1492
|
+
throwIfAborted(signal);
|
|
1493
|
+
await output.writeFile(file.retainedBytes);
|
|
1494
|
+
copiedBytes=file.retainedBytes.length;
|
|
1495
|
+
const outputAfter=await output.stat({bigint:true});
|
|
1496
|
+
if(Number(outputAfter.size)!==copiedBytes){
|
|
1497
|
+
fail(`Could not finish writing retained shared payload ${file.destination}.`);
|
|
1498
|
+
}
|
|
1499
|
+
}finally{
|
|
1500
|
+
await output.close().catch(()=>{});
|
|
1501
|
+
}
|
|
1502
|
+
completedBytes+=copiedBytes;
|
|
1503
|
+
await emitOperation(onEvent,{
|
|
1504
|
+
type:'package.copy.progress',
|
|
1505
|
+
current:index+1,
|
|
1506
|
+
total:files.length,
|
|
1507
|
+
completedBytes,
|
|
1508
|
+
path:file.destination
|
|
1509
|
+
});
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
const source=await openStableRegularFile(file.source,file.label,file.identity);
|
|
1513
|
+
let output;
|
|
1514
|
+
let copiedBytes=0;
|
|
1515
|
+
try{
|
|
1516
|
+
output=await open(destination,'wx');
|
|
1517
|
+
while(true){
|
|
1518
|
+
throwIfAborted(signal);
|
|
1519
|
+
const {bytesRead}=await source.handle.read(buffer,0,buffer.length,null);
|
|
1520
|
+
if(bytesRead===0)break;
|
|
1521
|
+
let written=0;
|
|
1522
|
+
while(written<bytesRead){
|
|
1523
|
+
const result=await output.write(buffer,written,bytesRead-written,null);
|
|
1524
|
+
if(result.bytesWritten<=0)fail(`Could not finish writing ${file.destination}.`);
|
|
1525
|
+
written+=result.bytesWritten;
|
|
1526
|
+
}
|
|
1527
|
+
copiedBytes+=bytesRead;
|
|
1528
|
+
}
|
|
1529
|
+
const [sourceAfter,outputAfter]=await Promise.all([
|
|
1530
|
+
source.handle.stat({bigint:true}),
|
|
1531
|
+
output.stat({bigint:true})
|
|
1532
|
+
]);
|
|
1533
|
+
if(!identityMatches(sourceAfter,file.identity)||copiedBytes!==file.bytes
|
|
1534
|
+
||Number(outputAfter.size)!==copiedBytes){
|
|
1535
|
+
fail(`${file.label} changed while ${file.destination} was being copied.`);
|
|
1536
|
+
}
|
|
1537
|
+
}finally{
|
|
1538
|
+
await output?.close().catch(()=>{});
|
|
1539
|
+
await source.handle.close().catch(()=>{});
|
|
1540
|
+
}
|
|
1541
|
+
completedBytes+=copiedBytes;
|
|
1542
|
+
await emitOperation(onEvent,{
|
|
1543
|
+
type:'package.copy.progress',
|
|
1544
|
+
current:index+1,
|
|
1545
|
+
total:files.length,
|
|
1546
|
+
completedBytes,
|
|
1547
|
+
path:file.destination
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function escapeHtml(value){
|
|
1553
|
+
return String(value)
|
|
1554
|
+
.replaceAll('&','&')
|
|
1555
|
+
.replaceAll('<','<')
|
|
1556
|
+
.replaceAll('>','>')
|
|
1557
|
+
.replaceAll('"','"');
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async function materializeBasePackage(context,outputRoot,files,{signal,onEvent}={}){
|
|
1561
|
+
throwIfAborted(signal);
|
|
1562
|
+
await mkdir(outputRoot,{recursive:true});
|
|
1563
|
+
await assertNoLinks(context.distRoot,outputRoot,'package staging root');
|
|
1564
|
+
await copyPackageFiles(files,outputRoot,{signal,onEvent});
|
|
1565
|
+
throwIfAborted(signal);
|
|
1566
|
+
const start=`./apps/${context.config.id}/${context.config.entry}`;
|
|
1567
|
+
const title=escapeHtml(context.config.displayName);
|
|
1568
|
+
await writeFile(
|
|
1569
|
+
path.join(outputRoot,'index.html'),
|
|
1570
|
+
[
|
|
1571
|
+
'<!doctype html>',
|
|
1572
|
+
'<meta charset="utf-8">',
|
|
1573
|
+
`<meta http-equiv="refresh" content="0; url=${escapeHtml(start)}">`,
|
|
1574
|
+
`<title>${title}</title>`,
|
|
1575
|
+
`<a href="${escapeHtml(start)}">Open ${title}</a>`,
|
|
1576
|
+
''
|
|
1577
|
+
].join('\n'),
|
|
1578
|
+
'utf8'
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
async function sha256WithIdentity(filePath,{signal,expectedIdentity,label=filePath}={}){
|
|
1583
|
+
throwIfAborted(signal);
|
|
1584
|
+
const hash=createHash('sha256');
|
|
1585
|
+
const opened=await openStableRegularFile(filePath,label,expectedIdentity);
|
|
1586
|
+
const buffer=Buffer.allocUnsafe(1024*1024);
|
|
1587
|
+
|
|
1588
|
+
try{
|
|
1589
|
+
while(true){
|
|
1590
|
+
throwIfAborted(signal);
|
|
1591
|
+
const {bytesRead}=await opened.handle.read(buffer,0,buffer.length,null);
|
|
1592
|
+
|
|
1593
|
+
if(bytesRead===0){
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
hash.update(buffer.subarray(0,bytesRead));
|
|
1598
|
+
}
|
|
1599
|
+
const after=await opened.handle.stat({bigint:true});
|
|
1600
|
+
if(!identityMatches(after,opened.identity))fail(`${label} changed while it was being hashed.`);
|
|
1601
|
+
}finally{
|
|
1602
|
+
await opened.handle.close();
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
return {sha256:hash.digest('hex'),identity:opened.identity};
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
async function sha256(filePath,options={}){
|
|
1609
|
+
return (await sha256WithIdentity(filePath,options)).sha256;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
async function listOutputFiles(root,{signal}={}){
|
|
1613
|
+
const files=[];
|
|
1614
|
+
|
|
1615
|
+
async function visit(directory,relativeRoot=''){
|
|
1616
|
+
throwIfAborted(signal);
|
|
1617
|
+
const entries=await readdir(directory,{withFileTypes:true});
|
|
1618
|
+
|
|
1619
|
+
for(const entry of entries.sort((left,right)=>compareText(left.name,right.name))){
|
|
1620
|
+
throwIfAborted(signal);
|
|
1621
|
+
const relative=relativeRoot?`${relativeRoot}/${entry.name}`:entry.name;
|
|
1622
|
+
const absolute=path.join(directory,entry.name);
|
|
1623
|
+
|
|
1624
|
+
if(isAlwaysForbidden(relative)){
|
|
1625
|
+
fail(`Package contains a globally forbidden path: ${relative}`);
|
|
1626
|
+
}
|
|
1627
|
+
if(path.posix.basename(relative)===APP_CONFIG_NAME){
|
|
1628
|
+
fail(`Package contains authored configuration that must remain outside the browser payload: ${relative}`);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if(entry.isSymbolicLink()){
|
|
1632
|
+
fail(`Package contains a symbolic link or junction: ${relative}`);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if(entry.isDirectory()){
|
|
1636
|
+
await visit(absolute,relative);
|
|
1637
|
+
}else if(entry.isFile()){
|
|
1638
|
+
files.push({absolute,relative});
|
|
1639
|
+
}else{
|
|
1640
|
+
fail(`Package contains a non-file entry: ${relative}`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
await visit(root);
|
|
1646
|
+
return files.sort((left,right)=>compareText(left.relative,right.relative));
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function inventoryEntries(root,{signal,onEvent}={}){
|
|
1650
|
+
const files=(await listOutputFiles(root,{signal})).filter(file=>
|
|
1651
|
+
file.relative!==RELEASE_MANIFEST_NAME
|
|
1652
|
+
);
|
|
1653
|
+
const entries=[];
|
|
1654
|
+
const identities=[];
|
|
1655
|
+
|
|
1656
|
+
let completedBytes=0;
|
|
1657
|
+
|
|
1658
|
+
for(const [index,file] of files.entries()){
|
|
1659
|
+
throwIfAborted(signal);
|
|
1660
|
+
const verified=await sha256WithIdentity(file.absolute,{
|
|
1661
|
+
signal,
|
|
1662
|
+
label:`package file ${file.relative}`
|
|
1663
|
+
});
|
|
1664
|
+
entries.push({
|
|
1665
|
+
path:file.relative,
|
|
1666
|
+
bytes:verified.identity.bytes,
|
|
1667
|
+
sha256:verified.sha256
|
|
1668
|
+
});
|
|
1669
|
+
identities.push(Object.freeze({path:file.relative,...verified.identity}));
|
|
1670
|
+
completedBytes+=verified.identity.bytes;
|
|
1671
|
+
await emitOperation(onEvent,{
|
|
1672
|
+
type:'package.hash.progress',
|
|
1673
|
+
current:index+1,
|
|
1674
|
+
total:files.length,
|
|
1675
|
+
completedBytes,
|
|
1676
|
+
path:file.relative
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
return {entries,identities};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async function assertArtifactState(root,identities,{signal}={}){
|
|
1684
|
+
throwIfAborted(signal);
|
|
1685
|
+
const requested=path.resolve(root);
|
|
1686
|
+
const rootBefore=await lstat(requested,{bigint:true});
|
|
1687
|
+
if(rootBefore.isSymbolicLink()||!rootBefore.isDirectory()){
|
|
1688
|
+
fail('App release root must be a real directory.');
|
|
1689
|
+
}
|
|
1690
|
+
const canonical=await realpath(requested);
|
|
1691
|
+
const actualPaths=(await listOutputFiles(canonical,{signal}))
|
|
1692
|
+
.map(file=>file.relative)
|
|
1693
|
+
.sort(compareText);
|
|
1694
|
+
const expectedPaths=identities.map(identity=>identity.path).sort(compareText);
|
|
1695
|
+
if(JSON.stringify(actualPaths)!==JSON.stringify(expectedPaths)){
|
|
1696
|
+
fail('App release inventory changed after verification.');
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
for(const identity of identities){
|
|
1700
|
+
throwIfAborted(signal);
|
|
1701
|
+
const filePath=resolveInside(canonical,identity.path,'verified app release path');
|
|
1702
|
+
const info=await lstat(filePath,{bigint:true});
|
|
1703
|
+
if(info.isSymbolicLink()||!info.isFile()||!identityMatches(info,identity)){
|
|
1704
|
+
fail(`App release file changed after verification: ${identity.path}`);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
const rootAfter=await lstat(canonical,{bigint:true});
|
|
1708
|
+
if(!rootAfter.isDirectory()||rootAfter.isSymbolicLink()
|
|
1709
|
+
||!identityMatches(rootAfter,fileIdentity(rootBefore))){
|
|
1710
|
+
fail('App release root changed while its verification state was authenticated.');
|
|
1711
|
+
}
|
|
1712
|
+
return {canonical,rootIdentity:fileIdentity(rootAfter)};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function appReleasePackageBinding(config){
|
|
1716
|
+
if(!isPlainObject(config)){
|
|
1717
|
+
fail('App release package binding is missing.');
|
|
1718
|
+
}
|
|
1719
|
+
return immutableJsonCopy({
|
|
1720
|
+
schemaVersion:1,
|
|
1721
|
+
id:config.id,
|
|
1722
|
+
displayName:config.displayName,
|
|
1723
|
+
version:config.version,
|
|
1724
|
+
entry:config.entry,
|
|
1725
|
+
strategy:config.strategy,
|
|
1726
|
+
security:config.security,
|
|
1727
|
+
localAIModelPolicy:config.localAIModelPolicy??{verified_only:true,models:[]},
|
|
1728
|
+
include:[...(config.include??[])],
|
|
1729
|
+
exclude:[...(config.exclude??[])],
|
|
1730
|
+
shared:[...(config.shared??[])],
|
|
1731
|
+
adapter:config.adapter??null
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
async function issueAppReleaseReceipt(root,release,identities,{
|
|
1736
|
+
signal,
|
|
1737
|
+
packageConfig,
|
|
1738
|
+
descriptorAuthority
|
|
1739
|
+
}={}){
|
|
1740
|
+
const state=await assertArtifactState(root,identities,{signal});
|
|
1741
|
+
const packageBinding=appReleasePackageBinding(packageConfig);
|
|
1742
|
+
if(!descriptorAuthority?.descriptor
|
|
1743
|
+
||!/^[a-f0-9]{64}$/u.test(descriptorAuthority.descriptorSha256)){
|
|
1744
|
+
fail('Canonical app descriptor authority is required to issue a release receipt.');
|
|
1745
|
+
}
|
|
1746
|
+
const receipt={
|
|
1747
|
+
schemaVersion:1,
|
|
1748
|
+
kind:'arcane-app-release-verification',
|
|
1749
|
+
generation:randomBytes(16).toString('hex'),
|
|
1750
|
+
canonicalLocation:state.canonical,
|
|
1751
|
+
builder:release.builder,
|
|
1752
|
+
app:immutableJsonCopy(release.app),
|
|
1753
|
+
policySha256:release.policySha256,
|
|
1754
|
+
files:immutableJsonCopy(release.files),
|
|
1755
|
+
fileCount:release.fileCount,
|
|
1756
|
+
totalBytes:release.totalBytes,
|
|
1757
|
+
contentSha256:release.contentSha256
|
|
1758
|
+
};
|
|
1759
|
+
Object.defineProperty(receipt,'identities',{
|
|
1760
|
+
value:Object.freeze([...identities]),
|
|
1761
|
+
enumerable:false,
|
|
1762
|
+
writable:false,
|
|
1763
|
+
configurable:false
|
|
1764
|
+
});
|
|
1765
|
+
Object.freeze(receipt);
|
|
1766
|
+
issuedAppReleaseReceipts.set(receipt,Object.freeze({
|
|
1767
|
+
canonicalLocation:state.canonical,
|
|
1768
|
+
rootIdentity:state.rootIdentity,
|
|
1769
|
+
identities:receipt.identities,
|
|
1770
|
+
packageBinding,
|
|
1771
|
+
descriptorAuthority:Object.freeze({
|
|
1772
|
+
descriptor:descriptorAuthority.descriptor,
|
|
1773
|
+
descriptorSha256:descriptorAuthority.descriptorSha256,
|
|
1774
|
+
source:descriptorAuthority.source
|
|
1775
|
+
})
|
|
1776
|
+
}));
|
|
1777
|
+
return receipt;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function authenticateAppReleaseReceiptState(receipt,{
|
|
1781
|
+
releaseRoot,
|
|
1782
|
+
expectedPackageConfig,
|
|
1783
|
+
signal
|
|
1784
|
+
}={}){
|
|
1785
|
+
const state=issuedAppReleaseReceipts.get(receipt);
|
|
1786
|
+
if(!state)fail('App release receipt was not issued by this SDK process.');
|
|
1787
|
+
if(typeof releaseRoot!=='string'||!releaseRoot.trim())fail('releaseRoot is required to authenticate an app release receipt.');
|
|
1788
|
+
const requested=path.resolve(releaseRoot);
|
|
1789
|
+
const canonical=await realpath(requested);
|
|
1790
|
+
if(canonical!==state.canonicalLocation||receipt.canonicalLocation!==canonical){
|
|
1791
|
+
fail('App release receipt belongs to a different release location.');
|
|
1792
|
+
}
|
|
1793
|
+
const rootInfo=await lstat(canonical,{bigint:true});
|
|
1794
|
+
if(rootInfo.isSymbolicLink()||!rootInfo.isDirectory()
|
|
1795
|
+
||!identityMatches(rootInfo,state.rootIdentity)){
|
|
1796
|
+
fail('App release root changed after its receipt was issued.');
|
|
1797
|
+
}
|
|
1798
|
+
if(expectedPackageConfig!==undefined
|
|
1799
|
+
&&JSON.stringify(appReleasePackageBinding(expectedPackageConfig))
|
|
1800
|
+
!==JSON.stringify(state.packageBinding)){
|
|
1801
|
+
fail('App release receipt belongs to a different authored package policy.');
|
|
1802
|
+
}
|
|
1803
|
+
await assertArtifactState(canonical,state.identities,{signal});
|
|
1804
|
+
return state;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
export async function authenticateAppReleaseReceipt(receipt,options={}){
|
|
1808
|
+
await authenticateAppReleaseReceiptState(receipt,options);
|
|
1809
|
+
return receipt;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
export async function authenticateAppReleaseAuthority(receipt,{
|
|
1813
|
+
releaseRoot,
|
|
1814
|
+
expectedPackageConfig,
|
|
1815
|
+
expectedDescriptor,
|
|
1816
|
+
signal
|
|
1817
|
+
}={}){
|
|
1818
|
+
const state=await authenticateAppReleaseReceiptState(receipt,{
|
|
1819
|
+
releaseRoot,
|
|
1820
|
+
expectedPackageConfig,
|
|
1821
|
+
signal
|
|
1822
|
+
});
|
|
1823
|
+
const authority=state.descriptorAuthority;
|
|
1824
|
+
if(!authority?.descriptor||!/^[a-f0-9]{64}$/u.test(authority.descriptorSha256)){
|
|
1825
|
+
fail('App release receipt is missing its canonical descriptor authority.');
|
|
1826
|
+
}
|
|
1827
|
+
if(expectedDescriptor!==undefined){
|
|
1828
|
+
const contracts=await appDescriptorContracts();
|
|
1829
|
+
if(contracts.appDescriptorSha256(expectedDescriptor)!==authority.descriptorSha256){
|
|
1830
|
+
fail('App release receipt belongs to a different canonical app descriptor.');
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return Object.freeze({
|
|
1834
|
+
receipt,
|
|
1835
|
+
descriptor:authority.descriptor,
|
|
1836
|
+
descriptorSha256:authority.descriptorSha256,
|
|
1837
|
+
source:authority.source
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
export async function readVerifiedAppReleaseFile(receipt,{
|
|
1842
|
+
releaseRoot,
|
|
1843
|
+
relativePath,
|
|
1844
|
+
signal
|
|
1845
|
+
}={}){
|
|
1846
|
+
throwIfAborted(signal);
|
|
1847
|
+
const state=issuedAppReleaseReceipts.get(receipt);
|
|
1848
|
+
if(!state)fail('App release receipt was not issued by this SDK process.');
|
|
1849
|
+
if(typeof releaseRoot!=='string'||!releaseRoot.trim()){
|
|
1850
|
+
fail('releaseRoot is required to read a verified app release file.');
|
|
1851
|
+
}
|
|
1852
|
+
const requested=path.resolve(releaseRoot);
|
|
1853
|
+
const canonical=await realpath(requested);
|
|
1854
|
+
if(canonical!==state.canonicalLocation||receipt.canonicalLocation!==canonical){
|
|
1855
|
+
fail('App release receipt belongs to a different release location.');
|
|
1856
|
+
}
|
|
1857
|
+
const rootInfo=await lstat(canonical,{bigint:true});
|
|
1858
|
+
if(rootInfo.isSymbolicLink()||!rootInfo.isDirectory()
|
|
1859
|
+
||!identityMatches(rootInfo,state.rootIdentity)){
|
|
1860
|
+
fail('App release root changed after its receipt was issued.');
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const normalized=normalizeRelativePath(relativePath,'verified app release path');
|
|
1864
|
+
const file=receipt.files.find(candidate=>pathKey(candidate.path)===pathKey(normalized));
|
|
1865
|
+
if(!file)fail(`Path is not in the verified app release inventory: ${normalized}.`);
|
|
1866
|
+
if(file.bytes>MAX_VERIFIED_APP_FILE_BYTES){
|
|
1867
|
+
fail(
|
|
1868
|
+
`Verified browser file exceeds the ${MAX_VERIFIED_APP_FILE_BYTES}-byte development serving limit: ${file.path}.`,
|
|
1869
|
+
'ARCANE_POLICY_DENIED'
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
const identity=state.identities.find(candidate=>
|
|
1873
|
+
pathKey(candidate.path)===pathKey(file.path)
|
|
1874
|
+
);
|
|
1875
|
+
if(!identity)fail(`Verified app release identity is missing for ${file.path}.`);
|
|
1876
|
+
const filePath=resolveInside(canonical,file.path,'verified app release path');
|
|
1877
|
+
const opened=await openStableRegularFile(filePath,`verified app release file ${file.path}`,identity);
|
|
1878
|
+
try{
|
|
1879
|
+
throwIfAborted(signal);
|
|
1880
|
+
const bytes=await opened.handle.readFile();
|
|
1881
|
+
throwIfAborted(signal);
|
|
1882
|
+
const after=await opened.handle.stat({bigint:true});
|
|
1883
|
+
if(!identityMatches(after,opened.identity)||bytes.length!==file.bytes){
|
|
1884
|
+
fail(`Verified app release file changed while it was being read: ${file.path}.`);
|
|
1885
|
+
}
|
|
1886
|
+
const digest=createHash('sha256').update(bytes).digest('hex');
|
|
1887
|
+
if(digest!==file.sha256){
|
|
1888
|
+
fail(`Verified app release file hash changed: ${file.path}.`);
|
|
1889
|
+
}
|
|
1890
|
+
const current=await lstat(filePath,{bigint:true});
|
|
1891
|
+
if(current.isSymbolicLink()||!current.isFile()||!identityMatches(current,identity)){
|
|
1892
|
+
fail(`Verified app release path changed while it was being read: ${file.path}.`);
|
|
1893
|
+
}
|
|
1894
|
+
const canonicalFile=await realpath(filePath);
|
|
1895
|
+
if(!isInside(canonical,canonicalFile)){
|
|
1896
|
+
fail(`Verified app release path left its release root: ${file.path}.`);
|
|
1897
|
+
}
|
|
1898
|
+
return bytes;
|
|
1899
|
+
}finally{
|
|
1900
|
+
await opened.handle.close();
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
function normalizedRoutePolicy(route){
|
|
1905
|
+
return {
|
|
1906
|
+
source:route.source,
|
|
1907
|
+
destination:route.destination,
|
|
1908
|
+
include:[...route.include].sort(compareText),
|
|
1909
|
+
exclude:[...route.exclude].sort(compareText)
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function packagePolicySha256(context,{signal}={}){
|
|
1914
|
+
throwIfAborted(signal);
|
|
1915
|
+
const {config,rootConfig,appRoot}=context;
|
|
1916
|
+
let adapter=null;
|
|
1917
|
+
|
|
1918
|
+
if(config.adapter){
|
|
1919
|
+
const adapterPath=resolveInside(appRoot,config.adapter,`${config.id} adapter`);
|
|
1920
|
+
await assertNoLinks(appRoot,adapterPath,`${config.id} adapter`);
|
|
1921
|
+
adapter={
|
|
1922
|
+
path:config.adapter,
|
|
1923
|
+
sha256:await sha256(adapterPath,{signal})
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
const shared=[...config.shared]
|
|
1928
|
+
.sort(compareText)
|
|
1929
|
+
.map(id=>({
|
|
1930
|
+
id,
|
|
1931
|
+
routes:rootConfig.sharedPayloads[id]
|
|
1932
|
+
.map(normalizedRoutePolicy)
|
|
1933
|
+
.sort((left,right)=>compareText(JSON.stringify(left),JSON.stringify(right)))
|
|
1934
|
+
}));
|
|
1935
|
+
const policy={
|
|
1936
|
+
strategy:config.strategy,
|
|
1937
|
+
security:config.security,
|
|
1938
|
+
localAIModelPolicy:{...config.localAIModelPolicy},
|
|
1939
|
+
include:[...config.include].sort(compareText),
|
|
1940
|
+
exclude:[...config.exclude].sort(compareText),
|
|
1941
|
+
shared,
|
|
1942
|
+
adapter
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
return createHash('sha256')
|
|
1946
|
+
.update(JSON.stringify(policy))
|
|
1947
|
+
.digest('hex');
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
async function writeReleaseManifest(root,context,version,{signal,onEvent}={}){
|
|
1951
|
+
const {config}=context;
|
|
1952
|
+
const inventory=await inventoryEntries(root,{signal,onEvent});
|
|
1953
|
+
const files=inventory.entries;
|
|
1954
|
+
const totalBytes=files.reduce((total,file)=>total+file.bytes,0);
|
|
1955
|
+
const contentSha256=createHash('sha256')
|
|
1956
|
+
.update(JSON.stringify(files))
|
|
1957
|
+
.digest('hex');
|
|
1958
|
+
const release={
|
|
1959
|
+
schemaVersion:1,
|
|
1960
|
+
builder:PACKAGER_VERSION,
|
|
1961
|
+
app:{
|
|
1962
|
+
id:config.id,
|
|
1963
|
+
displayName:config.displayName,
|
|
1964
|
+
version,
|
|
1965
|
+
entry:config.entry,
|
|
1966
|
+
start:`./apps/${config.id}/${config.entry}`,
|
|
1967
|
+
security:config.security,
|
|
1968
|
+
localAIModelPolicy:{...config.localAIModelPolicy}
|
|
1969
|
+
},
|
|
1970
|
+
policySha256:await packagePolicySha256(context,{signal}),
|
|
1971
|
+
fileCount:files.length,
|
|
1972
|
+
totalBytes,
|
|
1973
|
+
contentSha256,
|
|
1974
|
+
files
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1977
|
+
const manifestPath=path.join(root,RELEASE_MANIFEST_NAME);
|
|
1978
|
+
await writeFile(
|
|
1979
|
+
manifestPath,
|
|
1980
|
+
`${JSON.stringify(release,null,2)}\n`,
|
|
1981
|
+
{encoding:'utf8',flag:'wx'}
|
|
1982
|
+
);
|
|
1983
|
+
const manifest=await openStableRegularFile(manifestPath,RELEASE_MANIFEST_NAME);
|
|
1984
|
+
await manifest.handle.close();
|
|
1985
|
+
return {
|
|
1986
|
+
release,
|
|
1987
|
+
identities:Object.freeze([
|
|
1988
|
+
...inventory.identities,
|
|
1989
|
+
Object.freeze({path:RELEASE_MANIFEST_NAME,...manifest.identity})
|
|
1990
|
+
])
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function expectedReleaseApp(context,version){
|
|
1995
|
+
const {config}=context;
|
|
1996
|
+
return {
|
|
1997
|
+
id:config.id,
|
|
1998
|
+
displayName:config.displayName,
|
|
1999
|
+
version,
|
|
2000
|
+
entry:config.entry,
|
|
2001
|
+
start:`./apps/${config.id}/${config.entry}`,
|
|
2002
|
+
security:config.security,
|
|
2003
|
+
localAIModelPolicy:{...config.localAIModelPolicy}
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
async function verifyFreshStaticRelease(root,context,version,releaseState,{signal}={}){
|
|
2008
|
+
throwIfAborted(signal);
|
|
2009
|
+
const {config}=context;
|
|
2010
|
+
const expectedApp=expectedReleaseApp(context,version);
|
|
2011
|
+
const release=releaseState.release;
|
|
2012
|
+
|
|
2013
|
+
if(release?.schemaVersion!==1||release?.builder!==PACKAGER_VERSION
|
|
2014
|
+
||JSON.stringify(release?.app)!==JSON.stringify(expectedApp)
|
|
2015
|
+
||!Array.isArray(release?.files)
|
|
2016
|
+
||release.fileCount!==release.files.length){
|
|
2017
|
+
fail(`${config.id}/${RELEASE_MANIFEST_NAME} identity is invalid.`);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// This staging tree is owned by this operation and no adapter has run. The
|
|
2021
|
+
// release inventory was produced from the final bytes immediately before
|
|
2022
|
+
// this check, so reuse that receipt instead of hashing every file twice.
|
|
2023
|
+
const rootIndex=path.join(root,'index.html');
|
|
2024
|
+
const entry=resolveInside(root,expectedApp.start.slice(2),'package entry');
|
|
2025
|
+
const [indexInfo,entryInfo,manifestInfo]=await Promise.all([
|
|
2026
|
+
lstat(rootIndex),
|
|
2027
|
+
lstat(entry),
|
|
2028
|
+
lstat(path.join(root,RELEASE_MANIFEST_NAME))
|
|
2029
|
+
]);
|
|
2030
|
+
|
|
2031
|
+
if(!indexInfo.isFile()||indexInfo.isSymbolicLink()
|
|
2032
|
+
||!entryInfo.isFile()||entryInfo.isSymbolicLink()
|
|
2033
|
+
||!manifestInfo.isFile()||manifestInfo.isSymbolicLink()){
|
|
2034
|
+
fail(`Package entry files for ${config.id} are invalid.`);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
return releaseState;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
async function verifyGenericRelease(root,context,version,{signal,onEvent}={}){
|
|
2041
|
+
const {config}=context;
|
|
2042
|
+
const manifestDocument=await readJsonDocument(
|
|
2043
|
+
path.join(root,RELEASE_MANIFEST_NAME),
|
|
2044
|
+
`${config.id}/${RELEASE_MANIFEST_NAME}`
|
|
2045
|
+
);
|
|
2046
|
+
const release=manifestDocument.value;
|
|
2047
|
+
const expectedApp=expectedReleaseApp(context,version);
|
|
2048
|
+
|
|
2049
|
+
if(release?.schemaVersion!==1||release?.builder!==PACKAGER_VERSION
|
|
2050
|
+
||JSON.stringify(release?.app)!==JSON.stringify(expectedApp)
|
|
2051
|
+
||release?.policySha256!==await packagePolicySha256(context,{signal})
|
|
2052
|
+
||!Array.isArray(release?.files)){
|
|
2053
|
+
fail(`${config.id}/${RELEASE_MANIFEST_NAME} identity is invalid.`);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const inventory=await inventoryEntries(root,{signal,onEvent});
|
|
2057
|
+
const actualFiles=inventory.entries;
|
|
2058
|
+
const totalBytes=actualFiles.reduce((total,file)=>total+file.bytes,0);
|
|
2059
|
+
const contentSha256=createHash('sha256')
|
|
2060
|
+
.update(JSON.stringify(actualFiles))
|
|
2061
|
+
.digest('hex');
|
|
2062
|
+
|
|
2063
|
+
if(release.fileCount!==actualFiles.length
|
|
2064
|
+
||release.totalBytes!==totalBytes
|
|
2065
|
+
||release.contentSha256!==contentSha256
|
|
2066
|
+
||JSON.stringify(release.files)!==JSON.stringify(actualFiles)){
|
|
2067
|
+
fail(`${config.id}/${RELEASE_MANIFEST_NAME} does not match the package tree.`);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const rootIndex=path.join(root,'index.html');
|
|
2071
|
+
const entry=resolveInside(root,expectedApp.start.slice(2),'package entry');
|
|
2072
|
+
const [indexInfo,entryInfo]=await Promise.all([lstat(rootIndex),lstat(entry)]);
|
|
2073
|
+
|
|
2074
|
+
if(!indexInfo.isFile()||indexInfo.isSymbolicLink()
|
|
2075
|
+
||!entryInfo.isFile()||entryInfo.isSymbolicLink()){
|
|
2076
|
+
fail(`Package entry files for ${config.id} are invalid.`);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
return {
|
|
2080
|
+
release,
|
|
2081
|
+
identities:Object.freeze([
|
|
2082
|
+
...inventory.identities,
|
|
2083
|
+
Object.freeze({path:RELEASE_MANIFEST_NAME,...manifestDocument.identity})
|
|
2084
|
+
])
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
async function loadAdapter(context){
|
|
2089
|
+
if(context.config.strategy!=='adapter'){
|
|
2090
|
+
return null;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const adapterPath=resolveInside(
|
|
2094
|
+
context.appRoot,
|
|
2095
|
+
context.config.adapter,
|
|
2096
|
+
`${context.config.id} adapter`
|
|
2097
|
+
);
|
|
2098
|
+
await assertNoLinks(context.appRoot,adapterPath,`${context.config.id} adapter`);
|
|
2099
|
+
const details=await stat(adapterPath);
|
|
2100
|
+
|
|
2101
|
+
if(!details.isFile()){
|
|
2102
|
+
fail(`${context.config.id} adapter is not a regular file.`);
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
const adapterBytes=await readFile(adapterPath);
|
|
2106
|
+
|
|
2107
|
+
if(adapterBytes.includes(0x0d)){
|
|
2108
|
+
fail(`${context.config.id} adapter must use canonical LF line endings.`);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
const module=await import(`${pathToFileURL(adapterPath).href}?mtime=${details.mtimeMs}`);
|
|
2112
|
+
|
|
2113
|
+
if(typeof module.buildArcanePackage!=='function'
|
|
2114
|
+
||typeof module.verifyArcanePackage!=='function'){
|
|
2115
|
+
fail(`${context.config.id} adapter must export buildArcanePackage and verifyArcanePackage.`);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
return module;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
async function verifyBuiltPackage(context,outputRoot,version,adapter,{signal,onEvent}={}){
|
|
2122
|
+
throwIfAborted(signal);
|
|
2123
|
+
if(adapter){
|
|
2124
|
+
await adapter.verifyArcanePackage({
|
|
2125
|
+
workspaceRoot:context.workspaceRoot,
|
|
2126
|
+
appRoot:context.appRoot,
|
|
2127
|
+
outputRoot,
|
|
2128
|
+
config:context.config,
|
|
2129
|
+
version,
|
|
2130
|
+
signal,
|
|
2131
|
+
onEvent
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
return verifyGenericRelease(outputRoot,context,version,{signal,onEvent});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
async function writeAppVersion(context,version){
|
|
2139
|
+
parseSemver(version);
|
|
2140
|
+
const raw=await readJson(context.config.configPath,context.config.configPath);
|
|
2141
|
+
raw.version=version;
|
|
2142
|
+
const temporary=`${context.config.configPath}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
2143
|
+
const backup=`${context.config.configPath}.bak-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
2144
|
+
await writeFile(temporary,`${JSON.stringify(raw,null,2)}\n`,'utf8');
|
|
2145
|
+
|
|
2146
|
+
let originalMoved=false;
|
|
2147
|
+
let replacementInstalled=false;
|
|
2148
|
+
|
|
2149
|
+
try{
|
|
2150
|
+
await rename(context.config.configPath,backup);
|
|
2151
|
+
originalMoved=true;
|
|
2152
|
+
await rename(temporary,context.config.configPath);
|
|
2153
|
+
replacementInstalled=true;
|
|
2154
|
+
}catch(error){
|
|
2155
|
+
await rm(temporary,{force:true});
|
|
2156
|
+
|
|
2157
|
+
if(originalMoved&&!replacementInstalled){
|
|
2158
|
+
try{
|
|
2159
|
+
await rename(backup,context.config.configPath);
|
|
2160
|
+
}catch{
|
|
2161
|
+
// Preserve the original error; the backup path remains recoverable.
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
throw error;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
await rm(backup,{force:true}).catch(()=>{});
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function resolveTargetVersion(current,{bump,exactVersion,preid}={}){
|
|
2172
|
+
if(bump&&exactVersion){
|
|
2173
|
+
fail('Choose either a semantic version bump or an exact version, not both.');
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
if(exactVersion!==undefined){
|
|
2177
|
+
parseSemver(exactVersion);
|
|
2178
|
+
|
|
2179
|
+
if(exactVersion===current){
|
|
2180
|
+
fail(`Version is already ${current}.`);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
return exactVersion;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
return bump?incrementSemver(current,bump,preid):current;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
async function acquirePackageLock(distRoot,appId){
|
|
2190
|
+
const lockPath=path.join(distRoot,`.arcane-packager-${appId}.lock`);
|
|
2191
|
+
let handle;
|
|
2192
|
+
|
|
2193
|
+
try{
|
|
2194
|
+
handle=await open(lockPath,'wx');
|
|
2195
|
+
await handle.writeFile(`${JSON.stringify({pid:process.pid,app:appId})}\n`,'utf8');
|
|
2196
|
+
}catch(error){
|
|
2197
|
+
if(handle){
|
|
2198
|
+
await handle.close().catch(()=>{});
|
|
2199
|
+
await rm(lockPath,{force:true}).catch(()=>{});
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
if(error?.code==='EEXIST'){
|
|
2203
|
+
fail(`Another package operation for ${appId} is already running. If no process is active, remove the stale lock at ${lockPath}.`);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
throw error;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
let released=false;
|
|
2210
|
+
|
|
2211
|
+
return async()=>{
|
|
2212
|
+
if(released){
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
released=true;
|
|
2217
|
+
const cleanupErrors=[];
|
|
2218
|
+
|
|
2219
|
+
try{
|
|
2220
|
+
await handle.close();
|
|
2221
|
+
}catch(error){
|
|
2222
|
+
cleanupErrors.push(`close failed: ${error.message}`);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
try{
|
|
2226
|
+
await rm(lockPath,{force:true});
|
|
2227
|
+
}catch(error){
|
|
2228
|
+
cleanupErrors.push(`remove failed: ${error.message}`);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if(cleanupErrors.length){
|
|
2232
|
+
console.error(
|
|
2233
|
+
`Arcane packager completed but could not fully clean ${lockPath} (${cleanupErrors.join('; ')}). Remove the stale lock before the next operation.`
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
async function acquireOperationLock(workspaceRoot,appId){
|
|
2240
|
+
const resolvedWorkspace=normalizeWorkspaceRoot(workspaceRoot);
|
|
2241
|
+
|
|
2242
|
+
if(typeof appId!=='string'||!APP_ID_PATTERN.test(appId)){
|
|
2243
|
+
fail(`Invalid app id: ${String(appId)}`);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const rootConfig=await loadRootConfig(resolvedWorkspace);
|
|
2247
|
+
const distRoot=path.join(resolvedWorkspace,rootConfig.distRoot);
|
|
2248
|
+
await assertSafeDistBoundary(resolvedWorkspace,distRoot,{create:true});
|
|
2249
|
+
return acquirePackageLock(distRoot,appId);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
async function readDistVersion(outputRoot){
|
|
2253
|
+
try{
|
|
2254
|
+
const release=await readJson(path.join(outputRoot,RELEASE_MANIFEST_NAME));
|
|
2255
|
+
return typeof release?.app?.version==='string'?release.app.version:null;
|
|
2256
|
+
}catch{
|
|
2257
|
+
return null;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}){
|
|
2262
|
+
const workspaceRoot=normalizeWorkspaceRoot(requestedWorkspaceRoot);
|
|
2263
|
+
const rootConfig=await loadRootConfig(workspaceRoot);
|
|
2264
|
+
const appsRoot=path.join(workspaceRoot,rootConfig.appsRoot);
|
|
2265
|
+
const entries=await readdir(appsRoot,{withFileTypes:true});
|
|
2266
|
+
const apps=[];
|
|
2267
|
+
|
|
2268
|
+
for(const entry of entries.sort((left,right)=>compareText(left.name,right.name))){
|
|
2269
|
+
if(!APP_ID_PATTERN.test(entry.name)||(entry.isFile()&&!entry.isSymbolicLink())){
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
if(entry.isSymbolicLink()){
|
|
2274
|
+
apps.push({
|
|
2275
|
+
id:entry.name,
|
|
2276
|
+
displayName:entry.name,
|
|
2277
|
+
configured:false,
|
|
2278
|
+
status:'unsafe-link',
|
|
2279
|
+
version:null,
|
|
2280
|
+
distVersion:null
|
|
2281
|
+
});
|
|
2282
|
+
continue;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
if(!entry.isDirectory()){
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
const configPath=path.join(appsRoot,entry.name,APP_CONFIG_NAME);
|
|
2290
|
+
|
|
2291
|
+
try{
|
|
2292
|
+
const context=await getAppContext({workspaceRoot,appId:entry.name});
|
|
2293
|
+
apps.push({
|
|
2294
|
+
id:entry.name,
|
|
2295
|
+
displayName:context.config.displayName,
|
|
2296
|
+
configured:true,
|
|
2297
|
+
status:'ready',
|
|
2298
|
+
version:context.config.version,
|
|
2299
|
+
distVersion:await readDistVersion(context.outputRoot),
|
|
2300
|
+
strategy:context.config.strategy,
|
|
2301
|
+
entry:context.config.entry,
|
|
2302
|
+
output:path.relative(workspaceRoot,context.outputRoot).replaceAll('\\','/')
|
|
2303
|
+
});
|
|
2304
|
+
}catch(error){
|
|
2305
|
+
let configured=true;
|
|
2306
|
+
|
|
2307
|
+
try{
|
|
2308
|
+
await lstat(configPath);
|
|
2309
|
+
}catch{
|
|
2310
|
+
configured=false;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
let displayName=entry.name;
|
|
2314
|
+
|
|
2315
|
+
try{
|
|
2316
|
+
const manifest=await readJson(path.join(appsRoot,entry.name,'manifest.json'));
|
|
2317
|
+
if(typeof manifest?.name==='string'&&manifest.name.trim()){
|
|
2318
|
+
displayName=manifest.name.trim();
|
|
2319
|
+
}
|
|
2320
|
+
}catch{
|
|
2321
|
+
// An unconfigured app can still be listed without a PWA manifest.
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
apps.push({
|
|
2325
|
+
id:entry.name,
|
|
2326
|
+
displayName,
|
|
2327
|
+
configured,
|
|
2328
|
+
status:configured?'invalid':'unconfigured',
|
|
2329
|
+
version:null,
|
|
2330
|
+
distVersion:null,
|
|
2331
|
+
error:configured?error.message:undefined
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
return apps;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
export async function inspectApp({workspaceRoot,appId}){
|
|
2340
|
+
const context=await getAppContext({workspaceRoot,appId});
|
|
2341
|
+
const files=await collectPackageFiles(context);
|
|
2342
|
+
const totalBytes=files.reduce((total,file)=>total+file.bytes,0);
|
|
2343
|
+
const largestFiles=[...files]
|
|
2344
|
+
.sort((left,right)=>right.bytes-left.bytes||compareText(left.destination,right.destination))
|
|
2345
|
+
.slice(0,10)
|
|
2346
|
+
.map(file=>({path:file.destination,bytes:file.bytes}));
|
|
2347
|
+
|
|
2348
|
+
return {
|
|
2349
|
+
id:context.config.id,
|
|
2350
|
+
displayName:context.config.displayName,
|
|
2351
|
+
version:context.config.version,
|
|
2352
|
+
distVersion:await readDistVersion(context.outputRoot),
|
|
2353
|
+
strategy:context.config.strategy,
|
|
2354
|
+
entry:context.config.entry,
|
|
2355
|
+
output:path.relative(context.workspaceRoot,context.outputRoot).replaceAll('\\','/'),
|
|
2356
|
+
include:[...context.config.include],
|
|
2357
|
+
exclude:[...context.config.exclude],
|
|
2358
|
+
shared:[...context.config.shared],
|
|
2359
|
+
adapter:context.config.adapter,
|
|
2360
|
+
baseFileCount:files.length,
|
|
2361
|
+
baseBytes:totalBytes,
|
|
2362
|
+
largestFiles,
|
|
2363
|
+
note:context.config.strategy==='adapter'
|
|
2364
|
+
?'Counts cover the static base; the adapter can add generated public files.'
|
|
2365
|
+
:undefined
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
async function packageAppUnlocked({
|
|
2370
|
+
workspaceRoot,
|
|
2371
|
+
appId,
|
|
2372
|
+
bump,
|
|
2373
|
+
preid,
|
|
2374
|
+
exactVersion,
|
|
2375
|
+
dryRun=false,
|
|
2376
|
+
context:preparedContext,
|
|
2377
|
+
sharedPayloadSnapshot,
|
|
2378
|
+
authenticatedSharedPayloadState,
|
|
2379
|
+
signal,
|
|
2380
|
+
onEvent,
|
|
2381
|
+
validateSourceState
|
|
2382
|
+
}){
|
|
2383
|
+
throwIfAborted(signal);
|
|
2384
|
+
if(validateSourceState!==undefined&&typeof validateSourceState!=='function'){
|
|
2385
|
+
fail('validateSourceState must be a function when provided.');
|
|
2386
|
+
}
|
|
2387
|
+
const context=preparedContext??await getAppContext({
|
|
2388
|
+
workspaceRoot,
|
|
2389
|
+
appId,
|
|
2390
|
+
bindDescriptorAuthority:true,
|
|
2391
|
+
signal
|
|
2392
|
+
});
|
|
2393
|
+
const currentVersion=context.config.version;
|
|
2394
|
+
const version=resolveTargetVersion(currentVersion,{bump,exactVersion,preid});
|
|
2395
|
+
const files=await collectPackageFiles(context,{
|
|
2396
|
+
signal,
|
|
2397
|
+
sharedPayloadState:authenticatedSharedPayloadState
|
|
2398
|
+
});
|
|
2399
|
+
const preview={
|
|
2400
|
+
app:appId,
|
|
2401
|
+
currentVersion,
|
|
2402
|
+
version,
|
|
2403
|
+
bump:bump??null,
|
|
2404
|
+
dryRun:Boolean(dryRun),
|
|
2405
|
+
output:path.relative(context.workspaceRoot,context.outputRoot).replaceAll('\\','/'),
|
|
2406
|
+
baseFileCount:files.length,
|
|
2407
|
+
baseBytes:files.reduce((total,file)=>total+file.bytes,0),
|
|
2408
|
+
strategy:context.config.strategy
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2411
|
+
if(dryRun){
|
|
2412
|
+
throwIfAborted(signal);
|
|
2413
|
+
return preview;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const token=`${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
2417
|
+
const staging=resolveInside(context.distRoot,`.arcane-packager-${appId}-${token}`,'staging output');
|
|
2418
|
+
const stagingTemporary=`${staging}.tmp`;
|
|
2419
|
+
const backup=resolveInside(context.distRoot,`.arcane-packager-${appId}-backup-${token}`,'backup output');
|
|
2420
|
+
const failedOutput=resolveInside(context.distRoot,`.arcane-packager-${appId}-failed-${token}`,'failed output');
|
|
2421
|
+
let adapter=null;
|
|
2422
|
+
let movedExisting=false;
|
|
2423
|
+
let promoted=false;
|
|
2424
|
+
let operationSucceeded=false;
|
|
2425
|
+
let rollbackRestored=false;
|
|
2426
|
+
|
|
2427
|
+
try{
|
|
2428
|
+
await rm(staging,{recursive:true,force:true});
|
|
2429
|
+
await rm(stagingTemporary,{recursive:true,force:true});
|
|
2430
|
+
await rm(backup,{recursive:true,force:true}).catch(()=>{});
|
|
2431
|
+
await rm(failedOutput,{recursive:true,force:true}).catch(()=>{});
|
|
2432
|
+
adapter=await loadAdapter(context);
|
|
2433
|
+
throwIfAborted(signal);
|
|
2434
|
+
|
|
2435
|
+
if(adapter){
|
|
2436
|
+
let prepared=false;
|
|
2437
|
+
await adapter.buildArcanePackage({
|
|
2438
|
+
workspaceRoot:context.workspaceRoot,
|
|
2439
|
+
appRoot:context.appRoot,
|
|
2440
|
+
outputRoot:staging,
|
|
2441
|
+
config:context.config,
|
|
2442
|
+
version,
|
|
2443
|
+
signal,
|
|
2444
|
+
onEvent,
|
|
2445
|
+
deferFinalVerification:true,
|
|
2446
|
+
prepareBase:async outputRoot=>{
|
|
2447
|
+
if(prepared){
|
|
2448
|
+
fail(`${appId} adapter requested its base payload more than once.`);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
prepared=true;
|
|
2452
|
+
const requestedRoot=path.resolve(outputRoot);
|
|
2453
|
+
|
|
2454
|
+
if(requestedRoot!==path.resolve(staging)
|
|
2455
|
+
&&requestedRoot!==path.resolve(stagingTemporary)){
|
|
2456
|
+
fail(`${appId} adapter requested its base payload outside its assigned staging roots.`);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
await materializeBasePackage(context,outputRoot,files,{signal,onEvent});
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
if(!prepared){
|
|
2464
|
+
fail(`${appId} adapter did not materialize the configured public base payload.`);
|
|
2465
|
+
}
|
|
2466
|
+
}else{
|
|
2467
|
+
await materializeBasePackage(context,staging,files,{signal,onEvent});
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
throwIfAborted(signal);
|
|
2471
|
+
const releaseState=await writeReleaseManifest(staging,context,version,{signal,onEvent});
|
|
2472
|
+
const verifiedRelease=adapter
|
|
2473
|
+
?await verifyBuiltPackage(context,staging,version,adapter,{signal,onEvent})
|
|
2474
|
+
:await verifyFreshStaticRelease(staging,context,version,releaseState,{signal});
|
|
2475
|
+
|
|
2476
|
+
throwIfAborted(signal);
|
|
2477
|
+
if(validateSourceState){
|
|
2478
|
+
const validation=await validateSourceState({signal});
|
|
2479
|
+
await assertValidatedDescriptorAuthority(validation,context.descriptorAuthority);
|
|
2480
|
+
}
|
|
2481
|
+
await assertAppDescriptorAuthorityCurrent(context,{signal});
|
|
2482
|
+
if(sharedPayloadSnapshot!==undefined){
|
|
2483
|
+
await authenticateSharedPayloadSnapshotState(sharedPayloadSnapshot,{
|
|
2484
|
+
workspaceRoot:context.workspaceRoot,
|
|
2485
|
+
sharedPayloadIds:context.config.shared,
|
|
2486
|
+
signal
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
await assertArtifactState(staging,verifiedRelease.identities,{signal});
|
|
2490
|
+
throwIfAborted(signal);
|
|
2491
|
+
|
|
2492
|
+
try{
|
|
2493
|
+
await lstat(context.outputRoot);
|
|
2494
|
+
await renamePackageDirectory(context.outputRoot,backup);
|
|
2495
|
+
movedExisting=true;
|
|
2496
|
+
}catch(error){
|
|
2497
|
+
if(error?.code!=='ENOENT'){
|
|
2498
|
+
throw error;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
throwIfAborted(signal);
|
|
2503
|
+
await renamePackageDirectory(staging,context.outputRoot);
|
|
2504
|
+
promoted=true;
|
|
2505
|
+
|
|
2506
|
+
throwIfAborted(signal);
|
|
2507
|
+
const receipt=await issueAppReleaseReceipt(
|
|
2508
|
+
context.outputRoot,
|
|
2509
|
+
verifiedRelease.release,
|
|
2510
|
+
verifiedRelease.identities,
|
|
2511
|
+
{
|
|
2512
|
+
signal,
|
|
2513
|
+
packageConfig:{...context.config,version},
|
|
2514
|
+
descriptorAuthority:context.descriptorAuthority
|
|
2515
|
+
}
|
|
2516
|
+
);
|
|
2517
|
+
|
|
2518
|
+
if(version!==currentVersion){
|
|
2519
|
+
await writeAppVersion(context,version);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
operationSucceeded=true;
|
|
2523
|
+
return {
|
|
2524
|
+
...preview,
|
|
2525
|
+
dryRun:false,
|
|
2526
|
+
fileCount:verifiedRelease.release.fileCount,
|
|
2527
|
+
totalBytes:verifiedRelease.release.totalBytes,
|
|
2528
|
+
contentSha256:verifiedRelease.release.contentSha256,
|
|
2529
|
+
receipt
|
|
2530
|
+
};
|
|
2531
|
+
}catch(error){
|
|
2532
|
+
const rollbackErrors=[];
|
|
2533
|
+
let targetVacated=!promoted;
|
|
2534
|
+
|
|
2535
|
+
if(promoted){
|
|
2536
|
+
try{
|
|
2537
|
+
await renamePackageDirectory(context.outputRoot,failedOutput);
|
|
2538
|
+
targetVacated=true;
|
|
2539
|
+
}catch(moveError){
|
|
2540
|
+
targetVacated=false;
|
|
2541
|
+
rollbackErrors.push(`could not move the failed package aside: ${moveError.message}`);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
if(movedExisting&&targetVacated){
|
|
2546
|
+
try{
|
|
2547
|
+
await renamePackageDirectory(backup,context.outputRoot);
|
|
2548
|
+
rollbackRestored=true;
|
|
2549
|
+
}catch(restoreError){
|
|
2550
|
+
rollbackErrors.push(`could not restore the previous package from ${backup}: ${restoreError.message}`);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
if(rollbackErrors.length){
|
|
2555
|
+
error.message+=` Rollback warning: ${rollbackErrors.join('; ')}. Preserve ${backup} until manually recovered.`;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
throw error;
|
|
2559
|
+
}finally{
|
|
2560
|
+
await rm(staging,{recursive:true,force:true}).catch(()=>{});
|
|
2561
|
+
await rm(stagingTemporary,{recursive:true,force:true}).catch(()=>{});
|
|
2562
|
+
await rm(failedOutput,{recursive:true,force:true}).catch(()=>{});
|
|
2563
|
+
|
|
2564
|
+
if(operationSucceeded||rollbackRestored||!movedExisting){
|
|
2565
|
+
await rm(backup,{recursive:true,force:true}).catch(()=>{});
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
export async function packageApp(options){
|
|
2571
|
+
throwIfAborted(options?.signal);
|
|
2572
|
+
const context=await getAppContext({
|
|
2573
|
+
workspaceRoot:options?.workspaceRoot,
|
|
2574
|
+
appId:options?.appId,
|
|
2575
|
+
bindDescriptorAuthority:true,
|
|
2576
|
+
signal:options?.signal
|
|
2577
|
+
});
|
|
2578
|
+
throwIfAborted(options?.signal);
|
|
2579
|
+
const authenticatedSharedPayloadState=options?.sharedPayloadSnapshot===undefined
|
|
2580
|
+
?null
|
|
2581
|
+
:await authenticateSharedPayloadSnapshotState(options.sharedPayloadSnapshot,{
|
|
2582
|
+
workspaceRoot:context.workspaceRoot,
|
|
2583
|
+
sharedPayloadIds:context.config.shared,
|
|
2584
|
+
signal:options?.signal
|
|
2585
|
+
});
|
|
2586
|
+
if(options?.dryRun){
|
|
2587
|
+
return packageAppUnlocked({...options,context,authenticatedSharedPayloadState});
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
await assertSafeDistBoundary(context.workspaceRoot,context.distRoot,{create:true});
|
|
2591
|
+
const releaseLock=await acquirePackageLock(context.distRoot,options?.appId);
|
|
2592
|
+
|
|
2593
|
+
try{
|
|
2594
|
+
return await packageAppUnlocked({...options,context,authenticatedSharedPayloadState});
|
|
2595
|
+
}finally{
|
|
2596
|
+
await releaseLock();
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
export async function verifyApp({workspaceRoot,appId,signal,onEvent}){
|
|
2601
|
+
throwIfAborted(signal);
|
|
2602
|
+
const context=await getAppContext({
|
|
2603
|
+
workspaceRoot,
|
|
2604
|
+
appId,
|
|
2605
|
+
bindDescriptorAuthority:true,
|
|
2606
|
+
signal
|
|
2607
|
+
});
|
|
2608
|
+
const adapter=await loadAdapter(context);
|
|
2609
|
+
const releaseState=await verifyBuiltPackage(
|
|
2610
|
+
context,
|
|
2611
|
+
context.outputRoot,
|
|
2612
|
+
context.config.version,
|
|
2613
|
+
adapter,
|
|
2614
|
+
{signal,onEvent}
|
|
2615
|
+
);
|
|
2616
|
+
const release=releaseState.release;
|
|
2617
|
+
const receipt=await issueAppReleaseReceipt(
|
|
2618
|
+
context.outputRoot,
|
|
2619
|
+
release,
|
|
2620
|
+
releaseState.identities,
|
|
2621
|
+
{
|
|
2622
|
+
signal,
|
|
2623
|
+
packageConfig:context.config,
|
|
2624
|
+
descriptorAuthority:context.descriptorAuthority
|
|
2625
|
+
}
|
|
2626
|
+
);
|
|
2627
|
+
|
|
2628
|
+
return {
|
|
2629
|
+
app:appId,
|
|
2630
|
+
// The verified manifest is already bound to this exact configured
|
|
2631
|
+
// version; do not couple the public verify result to a nested app
|
|
2632
|
+
// representation used by a particular schema generation.
|
|
2633
|
+
version:context.config.version,
|
|
2634
|
+
output:path.relative(context.workspaceRoot,context.outputRoot).replaceAll('\\','/'),
|
|
2635
|
+
fileCount:release.fileCount,
|
|
2636
|
+
totalBytes:release.totalBytes,
|
|
2637
|
+
contentSha256:release.contentSha256,
|
|
2638
|
+
verified:true,
|
|
2639
|
+
receipt
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
async function bumpVersionUnlocked({
|
|
2644
|
+
workspaceRoot,
|
|
2645
|
+
appId,
|
|
2646
|
+
bump,
|
|
2647
|
+
preid,
|
|
2648
|
+
exactVersion,
|
|
2649
|
+
dryRun=false
|
|
2650
|
+
}){
|
|
2651
|
+
const context=await getAppContext({workspaceRoot,appId});
|
|
2652
|
+
const currentVersion=context.config.version;
|
|
2653
|
+
const version=resolveTargetVersion(currentVersion,{bump,exactVersion,preid});
|
|
2654
|
+
|
|
2655
|
+
if(version===currentVersion){
|
|
2656
|
+
fail('A bump level or exact version is required.');
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
if(!dryRun){
|
|
2660
|
+
await writeAppVersion(context,version);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
return {
|
|
2664
|
+
app:appId,
|
|
2665
|
+
currentVersion,
|
|
2666
|
+
version,
|
|
2667
|
+
bump:bump??null,
|
|
2668
|
+
dryRun:Boolean(dryRun)
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
export async function bumpVersion(options){
|
|
2673
|
+
if(options?.dryRun){
|
|
2674
|
+
return bumpVersionUnlocked(options);
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
await getAppContext({
|
|
2678
|
+
workspaceRoot:options?.workspaceRoot,
|
|
2679
|
+
appId:options?.appId
|
|
2680
|
+
});
|
|
2681
|
+
const releaseLock=await acquireOperationLock(
|
|
2682
|
+
options?.workspaceRoot,
|
|
2683
|
+
options?.appId
|
|
2684
|
+
);
|
|
2685
|
+
|
|
2686
|
+
try{
|
|
2687
|
+
return await bumpVersionUnlocked(options);
|
|
2688
|
+
}finally{
|
|
2689
|
+
await releaseLock();
|
|
2690
|
+
}
|
|
2691
|
+
}
|