diff --git a/.gitignore b/.gitignore index e5ebb997f6..567187498a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ deepseek.key # Generated Computer Use executor binary; provenance metadata stays tracked. apps/desktop/resources/bin/ apps/desktop/bundled-git.json +apps/desktop/bundled-npm.json +apps/desktop/.generated/bundled-npm/ # Generated desktop release inputs and outputs. apps/desktop/resources/tools/ diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 66ee739547..7279776432 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -16,6 +16,14 @@ export default { from: 'bundled-git.json', to: 'bundled-git.json', }, + { + from: '.generated/bundled-npm/npm', + to: 'npm', + }, + { + from: '.generated/bundled-npm/bundled-npm.json', + to: 'bundled-npm.json', + }, { from: 'bundled-tools.json', to: 'bundled-tools.json', @@ -66,6 +74,10 @@ export default { from: 'resources/licenses/npm/THIRD_PARTY_NOTICES.txt', to: 'licenses/npm/THIRD_PARTY_NOTICES.txt', }, + { + from: '../../node_modules/npm/LICENSE', + to: 'licenses/npm-cli/LICENSE', + }, { from: 'src/renderer/public/THIRD_PARTY_LICENSES.txt', to: 'licenses/renderer/THIRD_PARTY_LICENSES.txt', diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 1f9ef2f496..c42201c682 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -2,7 +2,8 @@ Maka Desktop — Production npm Third-Party Notices ==================================================== Generated by scripts/generate-third-party-notices.mjs from the exact -@maka/desktop production dependency closure and package-lock.json. +@maka/desktop production dependency closure, the pinned bundled npm runtime, +and package-lock.json. Do not edit this file by hand. Policy: every package must resolve to an ASF-compatible SPDX license. Compound @@ -10749,6 +10750,250 @@ SOFTWARE. ================================================================================ +Package: npm@12.0.2 +Declared license: Artistic-2.0 +Selected license: Artistic-2.0 +Repository: git+https://github.com/npm/cli.git + +--- LICENSE --- +The npm application +Copyright (c) npm, Inc. and Contributors +Licensed on the terms of The Artistic License 2.0 + +Node package dependencies of the npm application +Copyright (c) their respective copyright owners +Licensed on their respective license terms + +The npm public registry at https://registry.npmjs.org +and the npm website at https://www.npmjs.com +Operated by npm, Inc. +Use governed by terms published on https://www.npmjs.com + +"Node.js" +Trademark Joyent, Inc., https://joyent.com +Neither npm nor npm, Inc. are affiliated with Joyent, Inc. + +The Node.js application +Project of Node Foundation, https://nodejs.org + +The npm Logo +Copyright (c) Mathias Pettersson and Brian Hammond + +"Gubblebum Blocky" typeface +Copyright (c) Tjarda Koster, https://jelloween.deviantart.com +Used with permission + + +-------- + + +The Artistic License 2.0 + +Copyright (c) 2000-2006, The Perl Foundation. + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +Preamble + +This license establishes the terms under which a given free software +Package may be copied, modified, distributed, and/or redistributed. +The intent is that the Copyright Holder maintains some artistic +control over the development of that Package while still keeping the +Package available as open source and free software. + +You are always permitted to make arrangements wholly outside of this +license directly with the Copyright Holder of a given Package. If the +terms of this license do not permit the full use that you propose to +make of the Package, you should contact the Copyright Holder and seek +a different licensing arrangement. + +Definitions + + "Copyright Holder" means the individual(s) or organization(s) + named in the copyright notice for the entire Package. + + "Contributor" means any party that has contributed code or other + material to the Package, in accordance with the Copyright Holder's + procedures. + + "You" and "your" means any person who would like to copy, + distribute, or modify the Package. + + "Package" means the collection of files distributed by the + Copyright Holder, and derivatives of that collection and/or of + those files. A given Package may consist of either the Standard + Version, or a Modified Version. + + "Distribute" means providing a copy of the Package or making it + accessible to anyone else, or in the case of a company or + organization, to others outside of your company or organization. + + "Distributor Fee" means any fee that you charge for Distributing + this Package or providing support for this Package to another + party. It does not mean licensing fees. + + "Standard Version" refers to the Package if it has not been + modified, or has been modified only in ways explicitly requested + by the Copyright Holder. + + "Modified Version" means the Package, if it has been changed, and + such changes were not explicitly requested by the Copyright + Holder. + + "Original License" means this Artistic License as Distributed with + the Standard Version of the Package, in its current version or as + it may be modified by The Perl Foundation in the future. + + "Source" form means the source code, documentation source, and + configuration files for the Package. + + "Compiled" form means the compiled bytecode, object code, binary, + or any other form resulting from mechanical transformation or + translation of the Source form. + + +Permission for Use and Modification Without Distribution + +(1) You are permitted to use the Standard Version and create and use +Modified Versions for any purpose without restriction, provided that +you do not Distribute the Modified Version. + + +Permissions for Redistribution of the Standard Version + +(2) You may Distribute verbatim copies of the Source form of the +Standard Version of this Package in any medium without restriction, +either gratis or for a Distributor Fee, provided that you duplicate +all of the original copyright notices and associated disclaimers. At +your discretion, such verbatim copies may or may not include a +Compiled form of the Package. + +(3) You may apply any bug fixes, portability changes, and other +modifications made available from the Copyright Holder. The resulting +Package will still be considered the Standard Version, and as such +will be subject to the Original License. + + +Distribution of Modified Versions of the Package as Source + +(4) You may Distribute your Modified Version as Source (either gratis +or for a Distributor Fee, and with or without a Compiled form of the +Modified Version) provided that you clearly document how it differs +from the Standard Version, including, but not limited to, documenting +any non-standard features, executables, or modules, and provided that +you do at least ONE of the following: + + (a) make the Modified Version available to the Copyright Holder + of the Standard Version, under the Original License, so that the + Copyright Holder may include your modifications in the Standard + Version. + + (b) ensure that installation of your Modified Version does not + prevent the user installing or running the Standard Version. In + addition, the Modified Version must bear a name that is different + from the name of the Standard Version. + + (c) allow anyone who receives a copy of the Modified Version to + make the Source form of the Modified Version available to others + under + + (i) the Original License or + + (ii) a license that permits the licensee to freely copy, + modify and redistribute the Modified Version using the same + licensing terms that apply to the copy that the licensee + received, and requires that the Source form of the Modified + Version, and of any works derived from it, be made freely + available in that license fees are prohibited but Distributor + Fees are allowed. + + +Distribution of Compiled Forms of the Standard Version +or Modified Versions without the Source + +(5) You may Distribute Compiled forms of the Standard Version without +the Source, provided that you include complete instructions on how to +get the Source of the Standard Version. Such instructions must be +valid at the time of your distribution. If these instructions, at any +time while you are carrying out such distribution, become invalid, you +must provide new instructions on demand or cease further distribution. +If you provide valid instructions or cease distribution within thirty +days after you become aware that the instructions are invalid, then +you do not forfeit any of your rights under this license. + +(6) You may Distribute a Modified Version in Compiled form without +the Source, provided that you comply with Section 4 with respect to +the Source of the Modified Version. + + +Aggregating or Linking the Package + +(7) You may aggregate the Package (either the Standard Version or +Modified Version) with other packages and Distribute the resulting +aggregation provided that you do not charge a licensing fee for the +Package. Distributor Fees are permitted, and licensing fees for other +components in the aggregation are permitted. The terms of this license +apply to the use and Distribution of the Standard or Modified Versions +as included in the aggregation. + +(8) You are permitted to link Modified and Standard Versions with +other works, to embed the Package in a larger work of your own, or to +build stand-alone binary or bytecode versions of applications that +include the Package, and Distribute the result without restriction, +provided the result does not expose a direct interface to the Package. + + +Items That are Not Considered Part of a Modified Version + +(9) Works (including, but not limited to, modules and scripts) that +merely extend or make use of the Package, do not, by themselves, cause +the Package to be a Modified Version. In addition, such works are not +considered parts of the Package itself, and are not subject to the +terms of this license. + + +General Provisions + +(10) Any use, modification, and distribution of the Standard or +Modified Versions is governed by this Artistic License. By using, +modifying or distributing the Package, you accept this license. Do not +use, modify, or distribute the Package, if you do not accept this +license. + +(11) If your Modified Version has been derived from a Modified +Version made by someone other than you, you are nevertheless required +to ensure that your Modified Version complies with the requirements of +this license. + +(12) This license does not grant you the right to use any trademark, +service mark, tradename, or logo of the Copyright Holder. + +(13) This license includes the non-exclusive, worldwide, +free-of-charge patent license to make, have made, use, offer to sell, +sell, import and otherwise transfer the Package with respect to any +patent claims licensable by the Copyright Holder that are necessarily +infringed by the Package. If you institute patent litigation +(including a cross-claim or counterclaim) against any party alleging +that the Package constitutes direct or contributory patent +infringement, then this Artistic License to you shall terminate on the +date that such litigation is filed. + +(14) Disclaimer of Warranty: +THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS +IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR +NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL +LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +-------- + +================================================================================ + Package: object-assign@4.1.1 Declared license: MIT Selected license: MIT @@ -12682,6 +12927,70 @@ SOFTWARE. ================================================================================ +Package: tar@7.5.22 +Declared license: BlueOak-1.0.0 +Selected license: BlueOak-1.0.0 +Repository: https://github.com/isaacs/node-tar.git + +--- LICENSE.md --- +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +***As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim.*** + +================================================================================ + Package: tiny-typed-emitter@2.1.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 76e40a4079..71ee835a2d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -250,6 +250,13 @@ owner = await startRuntimeHostDesktopOwner( candidateEntrypoint: new URL( import.meta.resolve("@maka/runtime-host/execution-candidate-main"), ), + ...(app.isPackaged + ? { + bundledGitResourcesRoot: process.resourcesPath, + bundledNpmResourcesRoot: process.resourcesPath, + dependencyNodeExecutablePath: process.execPath, + } + : {}), ipcMain, workspaceRoot, attachmentApprovals, diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index df83c70f4a..a2fa777292 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -73,6 +73,9 @@ export interface DesktopRuntimeHostCandidateStartInput extends DesktopRuntimeHos readonly connectTimeoutMs?: number; readonly handshakeTimeoutMs?: number; readonly candidateEntrypoint?: string | URL; + readonly bundledGitResourcesRoot?: string; + readonly bundledNpmResourcesRoot?: string; + readonly dependencyNodeExecutablePath?: string; } export type DesktopRuntimeHostCandidateStartResult = @@ -415,6 +418,15 @@ function connectInput( ...(input.candidateEntrypoint === undefined ? {} : { candidateEntrypoint: input.candidateEntrypoint }), + ...(input.bundledGitResourcesRoot === undefined + ? {} + : { bundledGitResourcesRoot: input.bundledGitResourcesRoot }), + ...(input.bundledNpmResourcesRoot === undefined + ? {} + : { bundledNpmResourcesRoot: input.bundledNpmResourcesRoot }), + ...(input.dependencyNodeExecutablePath === undefined + ? {} + : { dependencyNodeExecutablePath: input.dependencyNodeExecutablePath }), }; } diff --git a/docs/architecture/managed-workspace-environment-provisioning-v1.zh-CN.md b/docs/architecture/managed-workspace-environment-provisioning-v1.zh-CN.md new file mode 100644 index 0000000000..5773229b3e --- /dev/null +++ b/docs/architecture/managed-workspace-environment-provisioning-v1.zh-CN.md @@ -0,0 +1,278 @@ +--- +document_status: implementation-contract +status: draft-production-consumer-pending +date: 2026-08-08 +milestone: M1.3 +--- + +# Managed Workspace Environment Provisioning v1 + +## 1. 目标与非目标 + +M1.3 只解决一个主要不变量: + +> managed execution 只能消费由 Maka 拥有、身份可验证、与当前 canonical tree 明确匹配的环境;能力缺失时必须在 execution scope 签发前 fail closed,不能复制或复用 attached checkout 中的 ignored 内容。 + +首版环境分为三类,但按独立切片交付: + +1. dependency environment:由 lockfile 和受信 package-manager runtime 物化,可跨相同 identity 的 managed worktree 共享; +2. secret projection:只在单次 execution scope 内注入,不进入 Git、缓存、receipt 或 RuntimeEvent; +3. scratch:Maka-owned、按 execution 生命周期租用,不能混入 canonical tree。 + +本合同当前只授权第 1 类进入施工。secret 与 scratch 要等出现真实的 Shell/Build 消费者后再分别设计,不能预埋无消费者抽象。 + +M1.3 不做: + +- 从 source checkout 复制或链接 `node_modules`、`.env`、build output; +- 把项目依赖打进 Maka 安装包或 Session Bundle; +- 静默调用用户 PATH 上的 npm/pnpm/yarn; +- 在依赖环境内执行 Agent 任意命令; +- 启用 Write/Edit/Shell 或改变 M2 的 workspace mutation authority; +- 把 dependency cache 当作 workspace checkpoint 或 RuntimeEvent truth。 + +## 2. Owner、事实与原子性边界 + +### 2.1 Owner + +`ManagedWorkspaceOwner` 是 dependency environment admission 的唯一 owner。它负责: + +- 从已接受的 baseline tree 读取 tracked manifest/lockfile; +- 计算 environment identity; +- 调用显式配置且已验证的 package-manager producer; +- 创建、验证、租用和回收 Maka-owned environment artifact; +- 在 scope 签发前最终重验 worktree binding/head,并持有已验证 environment lease; +- 给 filesystem worker 增加只读 dependency root 权限。 + +package manager 负责包内容下载、完整性校验与其内部 store;Maka 不再发明一套 npm/pnpm CAS。Maka 只拥有 environment 的身份、发布、租约、绑定和 GC。 + +### 2.2 权威事实 + +dependency environment 的权威事实是 artifact 权限域之外、只能经 authority API 更新的专用 SQLite receipt,而不是 artifact 同目录 JSON、worktree 内的 symlink/junction,也不是 package manager 的控制台输出。producer 只获得单次随机 staging project 与 scratch 路径,不获得 receipt database 或 storage root 路径。 + +artifact receipt 至少包含: + +```text +protocolVersion +environmentId +lockfilePath +lockfileSha256 +manifestSha256 +packageManagerName +packageManagerVersion +nodeVersion +nodeAbi +platform +arch +producerRuntimeIdentitySha256 +producerPolicyIdentitySha256 +policyVersion +dependencyRootName +contentTreeSha256 +contentBytes +contentEntries +``` + +v1 不新增 durable worktree-environment binding 行。Owner 在一次 admission 内组合已验证的 +`baselineTreeOid + environmentId lease`,然后把 cwd 与 dependency root 只放进 owner-token 保护的 active scope。 +`baselineTreeOid` 不进入共享 artifact receipt,否则两个依赖完全相同、只改了业务源码的 baseline 会被错误地禁止复用同一 environment。 + +`environmentId` 对上述决定内容与执行兼容性的字段做 domain-separated canonical hash。producer runtime identity 同时绑定已验证 bundled npm manifest 的完整 runtime tree digest、Node executable digest、Node version/ABI 和 platform/arch;producer policy identity 来自精确的 hermetic capability profile。时间、绝对 source path、sessionId 和 worktreeId 不进入 identity,因此同一平台上相同依赖状态可以共享;平台、架构、Node ABI、producer runtime 或 policy 不同则不能共享。 + +### 2.3 原子性边界 + +创建采用 staging + atomic artifact publish + durable authority commit: + +```text +compute identity + -> acquire environmentId lease + -> provision into Maka-owned staging project + (write staging package.json/package-lock.json; never receive or write worktree path) + -> move only node_modules out of the producer-owned tree + -> verify complete inventory/content tree and fsync artifact + -> atomic rename artifact staging -> environments/ + -> commit receipt through the dedicated SQLite authority + -> reopen and verify database receipt + artifact tree + -> issue lease/binding +``` + +它不是 Git、SQLite、package registry 与 filesystem 的共同事务,因此采用可收敛的两阶段顺序:artifact 已发布而 receipt 未提交时,重启删除 orphan artifact 并重新物化;receipt 已提交而 artifact 缺失时,重启删除 orphan receipt。只有两者都存在且重新验证一致时才允许签发 lease。receipt database 使用独立格式 epoch,不进入主 RuntimeEvent schema。 + +该 authority 防止 producer 或仅能写 artifact 域的代码协调改写内容与 receipt;它不声称抵抗已经获得整个 storage root 和 receipt database 任意写权限的本机攻击者。后者属于 app-data/OS 权限与未来签名 authority 的威胁模型。 + +## 3. Identity 与缓存策略 + +### 3.1 v1 identity + +```text +dependency_environment_v1( + baseline tracked package manifest bytes, + baseline tracked lockfile bytes, + package manager name + exact version, + Node version + ABI, + platform + arch, + verified producer runtime tree digest, + hermetic producer policy digest, + environment policy version +) +``` + +不能只使用 lockfile hash:同一 lockfile 在不同 Node ABI、操作系统、CPU 架构或 package-manager 算法下可能产生不同 native package 与 install layout。 + +### 3.2 磁盘与包体 + +- Maka release 不携带项目 `node_modules`;安装包不会随用户项目依赖增长。 +- environment 是运行时磁盘缓存,不是内存副本。 +- 不同 identity 可以同时存在;相同 identity 只物化一份并由多个 managed worktree 共享。 +- v1 不复用跨 provision 的 package-manager cache。每次 provision 的 HOME、download cache、TEMP/TMP/TMPDIR 与 Node compile cache 都位于该次随机 staging transaction 内,成功或失败后整体清理。后续若引入共享下载 CAS,必须作为新的可审计 authority 单独设计。 +- v1 默认软配额为 2 GiB;receipt 记录内容字节数,每次 acquire 更新 artifact 的最后租用时间,release 后按 LRU + 删除未租用 artifact,active lease 与本次刚释放的 artifact 不得删除。配额是缓存治理,不进入 environment identity; + `.staging` 由新 owner 启动时独立清理。 +- artifact cache 可以被不同 baseline 复用;workspace-specific binding 则只存在于一次 owner-bound execution scope 中,组合 + `baselineTreeOid + environmentId lease`,两者不是同一个概念,也不新增 durable binding 表。 +- v1 的 lease/GC 依附于持有 OS 级独占 storage-root write lock 的 `ManagedWorkspaceOwner`。同一 storage root 不允许第二个 + Desktop/CLI writer owner 同时进入,因此不会出现另一个合法 owner 跨进程删除 active artifact;未来若允许多 writer,必须先把 + lease/GC 升级成跨进程协议,不能复用当前进程内计数。 + +## 4. Owner-bound logical binding + +M1.3 v1 不在 managed worktree 中创建 `node_modules` symlink/junction。只读 worker bridge 在 owner-bound scope +内部把 `node_modules/**` 逻辑路由到该 scope 持有 lease 的 dependency root。这样 dependency environment 不会成为 +Git ignored/untracked drift,也不会让下一次 canonical-tree admission 误判。 + +logical binding 必须满足: + +1. binding 路径由 policy 固定(Node v1 为 `node_modules`); +2. baseline tree 在该路径没有 tracked entry; +3. scope 只能指向当前 owner 已验证并持有 lease 的 artifact; +4. raw dependency root 只存在于 owner-token 保护的 internal scope state,不进入公共 profile、模型消息或 RuntimeEvent; +5. worker boundary 只增加 exact artifact root 的只读权限,不能借 binding 获得 storage root 的广泛访问; +6. `../`、空 segment 与 escape 形状在 worker dispatch 前拒绝;相对路径与位于 managed cwd + `node_modules/**` 下的等价绝对路径必须归一到同一个 dependency root,其他绝对路径不获得 dependency 权限; +7. Glob/Grep 等可能返回 absolute path 的结果必须在返回 host 前重新映射为 `node_modules/**`。 + +不能简单把 `node_modules` 从 `git status` 校验中全局忽略,也不能在 worktree 中放一个持久链接后让 Git verifier +对白名单路径视而不见。v1 的 logical binding 不改变 worktree,因此 Git drift authority 保持原样。 + +平台实现: + +| 平台 | v1 binding | 保证 | +|---|---|---| +| Linux | owner-scope logical route | sandbox 对 exact dependency root 只读授权 | +| macOS | owner-scope logical route | 同 Linux;canonicalize `/var`/`/private/var` alias | +| Windows | owner-scope logical route | storage authority 可用;managed worker sandbox 仍按 M1.2 的 Windows 有限支持矩阵 fail closed | + +Linux/macOS 只允许目标仍位于 dependency root 内的相对 symlink,并把 link path/target 纳入 tree digest;Windows 拒绝所有 symlink/reparse point,并在 publish 和 reopen 的完整树验证中枚举、拒绝 NTFS named stream。worker bridge 在 dispatch 前拒绝 `:`、NUL 与 traversal segment,避免未进入 tree digest 的 ADS 被读取。 + +M1.3 不宣称能物理阻止用户修改 app-data 中的 artifact。owner 在每次新 acquisition 时验证完整 inventory/content digest; +发现 artifact identity 漂移时停止签发 scope,并保留目录作为诊断证据。未来 Shell/Build 若要求操作系统级 mount/overlay,必须 +作为新的平台 I/O 不变量设计,不能把 v1 logical read binding 悄悄升级成可写链接。 + +## 5. Producer policy + +生产 producer 必须绑定可验证的 package-manager runtime。M1.3 随 Desktop release 打包固定的 npm 12.0.2 +JavaScript runtime,并用全量文件 inventory、逐文件 SHA-256、Node version/ABI 与 platform/arch 共同确定 authority: + +producer 不是一个可任意扩展的通用函数。authority 只接受精确的 `hermetic_dependency_builder_v1` capability: + +```text +network: registry_https_only +filesystem: maka_owned_staging_only +secrets: none +childProcess: verified_runtime_only +lifecycleScripts: disabled +``` + +该 capability 的 policy digest 与 bundled runtime tree digest 同时进入 environment identity 和 durable receipt。新增 producer +必须定义新的显式 capability/policy identity 并单独证明其网络、文件系统、secret 与 child-process 边界;不能在现有 capability +下加入 `curl`、`git clone`、任意 shell 或系统 package manager fallback。这里的 capability 是 admission contract;实际生产路径仍只 +组装经过完整 manifest 验证的 bundled npm producer,不把类型声明冒充操作系统 sandbox。 + +- M1.3 authority/binding 不得调用系统 package manager; +- 测试 producer 只能用于 production-shaped fixture,不能成为默认生产 fallback; +- 没有 configured producer 时,请求 dependency provisioning 必须返回稳定的 `managed_dependency_producer_unavailable`; +- npm producer 只接受 manifest 显式声明的 `npm@12.0.2` 与非 workspace 的 package-lock v3; +- producer 从实际 Node/Electron executable 探测 version、ABI、platform 与 arch;Node 不满足 npm 12 的 + `^22.22.2 || ^24.15.0 || >=26.0.0` 时在 environment identity/acquire 前 fail closed; +- `prepare:bundled-npm` 必须确定性替换并固定验证 npm 12.0.2 closure 中的 tar `7.5.19 -> 7.5.22`、brace-expansion `5.0.7 -> 5.0.9`、ip-address `10.2.0 -> 10.4.0` 与 undici `6.27.0 -> 6.28.0`;source/replacement 版本或 advisory 列表不精确匹配即停止发行,最终 manifest 对替换后的完整树取证; +- repository release 输入的最终 approved runtime tree digest 固定为 `sha256:930e2370422a82f7650387d37a4c6148bbf185e5def442ba8669550252b468e8`;prepare 不能仅从可变 `node_modules` 生成一份自洽 manifest,tree 与批准值不同必须停止发行; +- 安装固定使用官方 npm registry、隔离 HOME/user/global config、TEMP/TMP/TMPDIR 与 `NODE_COMPILE_CACHE`,执行 `npm ci --ignore-scripts --no-audit --no-fund`;取消、十分钟超时、2 GiB/25 万 entry provision 上限会终止 npm 并删除整个 staging; +- lockfile 中的 link、install script 与非官方 registry resolved URL 在 provisioning 前 fail closed; +- release gate 从最终物化的 bundled npm closure 构造独立 audit lock,并执行 `npm audit --omit=dev --audit-level=high`;root package 中 dev/prod 分类不得让实际发货代码逃过审计; +- packaged-app verifier 必须重新验证完整 manifest tree,并用打包后的 Node/Electron executable 实际启动 `npm-cli.js` smoke;只检查两个文件存在不构成发布证明; +- npm runtime 采用 Artistic-2.0,完整 dependency closure 进入发行版 third-party notices,不改变 Maka 的 Apache-2.0 源码许可; +- 每种 lockfile 必须有显式 adapter;未知或多重冲突 lockfile fail closed。 + +首个生产 producer 建议只支持项目声明的 exact npm 版本和 `package-lock.json`,再分别增加 pnpm/yarn;不能用一个“自动猜 package manager”的宽松入口。 + +### 5.1 M1.3 发行体积证据 + +Windows x64 开发树中,固定 npm 12.0.2 runtime(含四项 security replacement)物化后为 +2,233 个普通文件、14,596,126 bytes;完整 runtime manifest 为 409,851 bytes。合并 npm closure 后的 +third-party notices 为 675,354 bytes,相对基线增加约 25 KiB。也就是说,未压缩 release resource 的 +确定性增量约 15.02 MB;最终安装包增量取决于 +electron-builder 的目标格式与压缩率,必须由 Windows/macOS release CI 分别记录,不能用未压缩数字冒充安装包结果。 + +## 6. 生命周期与失败状态 + +稳定失败码至少包括: + +```text +managed_dependency_manifest_unsupported +managed_dependency_lockfile_missing +managed_dependency_identity_conflict +managed_dependency_producer_unavailable +managed_dependency_provision_failed +managed_dependency_artifact_corrupt +managed_dependency_binding_conflict +managed_dependency_binding_drifted +managed_dependency_environment_busy +``` + +回滚方式: + +- pre-publish crash:删除过期 staging;canonical tree 未改变; +- post-artifact-publish/pre-receipt crash:删除无 receipt 的 orphan artifact,并重新物化; +- post-receipt/pre-bind crash:保留完整 artifact + receipt,重启后重新验证并复用; +- post-bind/pre-scope crash:logical binding 未持久化;重启后重新验证 artifact;无 durable half-scope; +- binding drift:scope 立即失效,不修改 source checkout; +- artifact corruption:所有引用它的新 admission fail closed;v1 不在可能仍有 active lease 时移动目录,待 lease-aware + quarantine 有明确生产需求后再增加; +- producer failure:保留诊断,删除 staging,不 fallback 到 source `node_modules` 或系统 package manager。 + +## 7. Crash 与对抗矩阵 + +首个可合并切片必须覆盖: + +| 中断/攻击点 | 重启后的唯一合法结果 | +|---|---| +| identity 计算后、lease 前 | 可安全重试 | +| staging 创建后、producer 前 | orphan staging 可 GC | +| producer 中途退出 | 不发布 artifact | +| artifact publish rename 前后 | 最多一个 canonical artifact;无 receipt 时重启删除并重建 | +| SQLite receipt commit 前后 | 无 receipt 的 artifact 删除重建;有 receipt 的完整 pair 可重验复用 | +| logical binding/scope 签发前后 | 无 durable half-binding;新进程必须重新 acquire artifact | +| scope 签发前进程退出 | 无 durable half-scope | +| 第二个 Desktop/CLI writer 同时打开同一 storage root | 在 root-owner OS lock 处失败,不进入 provision/GC | +| source checkout 有 `node_modules` | 永不读取、复制或链接它 | +| 伪造/过期/cross-owner scope | worker dispatch 前 fail closed | +| 用户修改 shared artifact | 所有新 admission fail closed,保留现场供诊断 | +| lockfile/Node ABI/platform 改变 | 产生新 identity,不复用旧 artifact | +| acquire 已发布但 lease 尚未返回时触发 GC | pending reservation 保护该 digest,不得删除 | +| npm 写入超限、超时或收到 AbortSignal | 终止受控 npm 进程,删除 transaction,不发布 artifact/receipt | + +## 8. M1.3 交付切片与合并门槛 + +当前 integration branch 同时跨越 storage authority、host lifecycle、platform I/O 与 release packaging,按仓库纪律只能保持 Draft,不能作为单一最终 PR 合并。交付必须按 owner 与回滚边界平铺为: + +1. **Storage authority**:canonical identity/path、SQLite receipt authority、publish/reopen convergence、ADS/reparse rejection、pending reservation 与 GC;回滚仅删除 dependency artifact cache 与专用 receipt database。 +2. **Producer boundary**:每次 provision 私有 HOME/cache/temp、manifest/lockfile policy、Abort/timeout/quota 与 staging cleanup;回滚为禁用 producer,storage authority 继续 fail closed。 +3. **Bundled runtime supply chain**:固定 closure、security replacement、最终 shipped-tree audit、manifest 与 packaged CLI smoke;回滚为不打包 npm,并让 producer unavailable。 +4. **Production wiring**:Desktop/CLI 从真实 managed baseline 创建 execution handle,以 `dependency_environment_v1` 执行真实 Read/Glob/Grep,并验证 owner drain/close;回滚为禁用该用户入口,不得 fallback 到 attached checkout 或系统 npm。 + +前 3 个切片的机制与 production-shaped 测试不能替代第 4 个切片。当前 candidate 参数已经能把 packaged Git/npm authority 送入 Runtime Host,但生产代码尚未从真实用户任务调用 `openManagedWorkspaceBaseline`、创建 managed execution profile 并消费 `workspaceExecution.executeReadOnly`;因此当前状态仍是 Draft,不能描述为用户可用的 M1.3 闭环。 + +最终可合并状态必须满足:同一生产 owner 从真实 baseline 创建 handle,把 bundled producer 生成的 artifact 发布、验证、共享并只读绑定到 managed scope;Read/Glob/Grep 走实际 worker;没有 producer 时 fail closed;默认生产路径不调用系统 package manager。 + +### M1.3 之后 + +secret projection 与 scratch 只有在 M2/Shell 出现生产消费者后才各自开独立 PR。它们不能被塞进 dependency PR,也不能改变 canonical Git tree。 diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index a483b6bde3..8ca7280cdf 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -573,8 +573,13 @@ M1 拆成独立不变量,避免一次 PR 同时跨越 host lifecycle、runtime 2. **M1.2 owner-bound worker 与 runtime-host composition**:storage-internal bridge 只能用同 owner 的 active scope 解析 cwd,M2 前只允许 Read/Glob/Grep;建立不可混淆的 managed/attached typed profile 与 startup/drain/shutdown 顺序,关闭顺序固定为 tool operations → managed owner → root owner; -3. **M1.3 environment provisioning**:单独设计 ignored dependency、secret 与 scratch overlay。M1.1 不复制 - `.env`、`node_modules` 或 build output,也不以 attached checkout silent fallback 掩盖能力缺失。 +3. **M1.3 environment provisioning**:按 + [Managed Workspace Environment Provisioning v1](./managed-workspace-environment-provisioning-v1.zh-CN.md) + 施工。在一个 M1.3 PR 内按独立 commit 依次交付 dependency environment artifact authority、owner-bound + 只读 binding 与 bundled package-manager producer,最终 PR 不接受没有 producer 或没有 managed worker + 消费者的半成品。M1.1 不复制 `.env`、`node_modules` 或 build output,也不以 attached + checkout、系统 package manager 或 source ignored inputs silent fallback 掩盖能力缺失。secret 与 scratch + 只有在出现真实 Shell/Build 消费者后才分别施工。 M1.1 合同见 [Managed Workspace Execution Admission v1](./runtime-managed-workspace-execution-admission-v1.zh-CN.md)。 diff --git a/package-lock.json b/package-lock.json index b3b22d6036..69c1f18e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,10 +27,15 @@ "@astryxdesign/core": "0.3.0", "@biomejs/biome": "2.5.6", "@types/node": "^26.1.2", + "brace-expansion": "5.0.9", "dugite": "3.2.2", + "ip-address": "10.4.0", "knip": "^6.26.0", + "npm": "12.0.2", "patch-package": "8.0.1", - "typescript": "^7.0.2" + "tar": "7.5.22", + "typescript": "^7.0.2", + "undici": "6.28.0" }, "engines": { "node": ">=22.19.0" @@ -1002,9 +1007,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1022,9 +1024,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1042,9 +1041,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1062,9 +1058,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -5367,16 +5360,16 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -10115,6 +10108,1968 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm/-/npm-12.0.2.tgz", + "integrity": "sha512-uIXokLlBj6FpNUTQX1PmT5pz7BlIN9QlixX+zdaSNHsd0qUXsbDLr50xzY6Sw7cJVr0uzHKDOle0swmPW/p5Qw==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/git", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "bin-links", + "cacache", + "chalk", + "ci-info", + "diff", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^10.0.2", + "@npmcli/config": "^11.0.1", + "@npmcli/fs": "^6.0.0", + "@npmcli/git": "^8.0.0", + "@npmcli/map-workspaces": "^6.0.0", + "@npmcli/metavuln-calculator": "^10.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "@npmcli/redact": "^5.0.0", + "@npmcli/run-script": "^11.0.0", + "@sigstore/tuf": "^5.0.0", + "abbrev": "^5.0.0", + "archy": "~1.0.0", + "bin-links": "^7.0.0", + "cacache": "^21.0.1", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "diff": "^8.0.2", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^10.1.1", + "ini": "^7.0.0", + "init-package-json": "^9.0.0", + "is-cidr": "^7.0.0", + "json-parse-even-better-errors": "^6.0.0", + "libnpmaccess": "^11.0.0", + "libnpmdiff": "^9.0.2", + "libnpmexec": "^11.0.2", + "libnpmfund": "^8.0.2", + "libnpmorg": "^9.0.0", + "libnpmpack": "^10.0.2", + "libnpmpublish": "^12.0.0", + "libnpmsearch": "^10.0.0", + "libnpmteam": "^9.0.0", + "libnpmversion": "^9.0.0", + "make-fetch-happen": "^16.0.1", + "minimatch": "^10.2.5", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^13.0.0", + "nopt": "^10.0.1", + "npm-audit-report": "^8.0.0", + "npm-install-checks": "^9.0.0", + "npm-package-arg": "^14.0.0", + "npm-pick-manifest": "^12.0.0", + "npm-profile": "^13.0.1", + "npm-registry-fetch": "^20.0.1", + "npm-user-validate": "^5.0.0", + "p-map": "^7.0.4", + "pacote": "^22.0.0", + "parse-conflict-json": "^6.0.0", + "proc-log": "^7.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^6.0.0", + "semver": "^7.8.5", + "spdx-expression-parse": "^4.0.0", + "ssri": "^14.0.0", + "supports-color": "^10.2.2", + "tar": "^7.5.19", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^8.0.0", + "which": "^7.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^9.0.0", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^10.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/agent/node_modules/agent-base": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/@npmcli/agent/node_modules/http-proxy-agent": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "10.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^6.0.0", + "@npmcli/installed-package-contents": "^5.0.0", + "@npmcli/map-workspaces": "^6.0.0", + "@npmcli/metavuln-calculator": "^10.0.0", + "@npmcli/name-from-folder": "^5.0.0", + "@npmcli/node-gyp": "^6.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^5.0.0", + "@npmcli/run-script": "^11.0.0", + "bin-links": "^7.0.0", + "cacache": "^21.0.1", + "common-ancestor-path": "^2.0.0", + "diff": "^8.0.2", + "hosted-git-info": "^10.1.1", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^10.0.1", + "npm-install-checks": "^9.0.0", + "npm-package-arg": "^14.0.0", + "npm-pick-manifest": "^12.0.0", + "npm-registry-fetch": "^20.0.1", + "pacote": "^22.0.0", + "parse-conflict-json": "^6.0.0", + "proc-log": "^7.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^14.0.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^8.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "11.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^6.0.0", + "@npmcli/package-json": "^8.0.0", + "ci-info": "^4.0.0", + "ini": "^7.0.0", + "nopt": "^10.0.1", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "ini": "^7.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^12.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "which": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^6.0.0", + "npm-normalize-package-bin": "^6.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents/node_modules/npm-bundled": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents/node_modules/npm-normalize-package-bin": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^5.0.0", + "@npmcli/package-json": "^8.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^21.0.1", + "json-parse-even-better-errors": "^6.0.0", + "pacote": "^22.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^8.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^10.1.1", + "json-parse-even-better-errors": "^6.0.0", + "proc-log": "^7.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "11.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^6.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "node-gyp": "^13.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^16.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.2.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/bin-links": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^9.0.0", + "npm-normalize-package-bin": "^6.0.0", + "proc-log": "^7.0.0", + "read-cmd-shim": "^7.0.0", + "write-file-atomic": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/cmd-shim": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/npm-normalize-package-bin": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/bin-links/node_modules/read-cmd-shim": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "21.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^6.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^14.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22" + } + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "10.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^8.0.0", + "npm-package-arg": "^14.0.0", + "promzard": "^4.0.0", + "read": "^6.0.0", + "semver": "^7.7.2", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "10.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^6.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "11.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^14.0.0", + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^10.0.2", + "@npmcli/installed-package-contents": "^5.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^14.0.0", + "pacote": "^22.0.0", + "tar": "^7.5.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "11.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^10.0.2", + "@npmcli/package-json": "^8.0.0", + "@npmcli/run-script": "^11.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^14.0.0", + "pacote": "^22.0.0", + "proc-log": "^7.0.0", + "read": "^6.0.0", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^10.0.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "10.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^10.0.2", + "@npmcli/run-script": "^11.0.0", + "npm-package-arg": "^14.0.0", + "pacote": "^22.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "12.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^8.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^14.0.0", + "npm-registry-fetch": "^20.0.1", + "proc-log": "^7.0.0", + "semver": "^7.3.7", + "sigstore": "^5.0.0", + "ssri": "^14.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^20.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^8.0.0", + "@npmcli/run-script": "^11.0.0", + "json-parse-even-better-errors": "^6.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "11.5.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "16.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^5.0.0", + "@npmcli/redact": "^5.0.0", + "cacache": "^21.0.0", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^6.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^7.0.0", + "ssri": "^14.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "13.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^7.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "10.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "14.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^10.1.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^8.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "11.3.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^13.0.6", + "ignore-walk": "^9.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist/node_modules/ignore-walk": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "12.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^9.0.0", + "npm-normalize-package-bin": "^6.0.0", + "npm-package-arg": "^14.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest/node_modules/npm-normalize-package-bin": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^20.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "20.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^5.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^16.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^6.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^14.0.0", + "proc-log": "^7.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "22.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^8.0.0", + "@npmcli/installed-package-contents": "^5.0.0", + "@npmcli/package-json": "^8.0.0", + "@npmcli/promise-spawn": "^10.0.0", + "@npmcli/run-script": "^11.0.0", + "cacache": "^21.0.1", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^14.0.0", + "npm-packlist": "^11.2.0", + "npm-pick-manifest": "^12.0.0", + "npm-registry-fetch": "^20.0.1", + "proc-log": "^7.0.0", + "sigstore": "^5.0.0", + "ssri": "^14.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^6.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^5.0.0", + "@sigstore/tuf": "^5.0.0", + "@sigstore/verify": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.9", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "10.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "14.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "7.5.19", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@gar/promise-retry": "^1.0.3", + "@tufjs/models": "5.0.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/undici": { + "version": "6.27.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/which": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 6a61267d48..6660b3d6bf 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test:dist": "npm run test:scripts:full && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:dist:serial": "npm run test:scripts:full && node scripts/run-workspace-tests-parallel.mjs --serial", "test:fast": "npm run build:test && npm run test:scripts && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", - "test:scripts": "node --test scripts/electron-builder-config.test.mjs scripts/sync-model-metadata.test.mjs scripts/fixture-env.test.mjs scripts/electron-lifecycle.test.mjs scripts/check-story-annotations.test.mjs scripts/check-dead-css.test.mjs scripts/build-astryx-theme.test.mjs scripts/ci-test-plan.test.mjs scripts/run-headless-tests.test.mjs scripts/run-workspace-tests-parallel.test.mjs scripts/storybook-visual-smoke.test.mjs scripts/cu-e2e-scenarios.test.mjs scripts/cu-report-sanitize.test.mjs scripts/computer-use-provenance.test.mjs scripts/cu-trace-analyse.test.mjs scripts/prepare-bundled-git.test.mjs scripts/prepare-bundled-git-source.test.mjs scripts/windows-test-inventory.test.mjs scripts/windows-smoke.test.mjs scripts/windows-baseline-workflow.test.mjs scripts/code-mode-build-order.test.mjs apps/desktop/scripts/dev-app-runtime.test.mjs", + "test:scripts": "node --test scripts/electron-builder-config.test.mjs scripts/sync-model-metadata.test.mjs scripts/fixture-env.test.mjs scripts/electron-lifecycle.test.mjs scripts/check-story-annotations.test.mjs scripts/check-dead-css.test.mjs scripts/build-astryx-theme.test.mjs scripts/ci-test-plan.test.mjs scripts/run-headless-tests.test.mjs scripts/run-workspace-tests-parallel.test.mjs scripts/storybook-visual-smoke.test.mjs scripts/cu-e2e-scenarios.test.mjs scripts/cu-report-sanitize.test.mjs scripts/computer-use-provenance.test.mjs scripts/cu-trace-analyse.test.mjs scripts/prepare-bundled-git.test.mjs scripts/prepare-bundled-git-source.test.mjs scripts/prepare-bundled-npm.test.mjs scripts/windows-test-inventory.test.mjs scripts/windows-smoke.test.mjs scripts/windows-baseline-workflow.test.mjs scripts/code-mode-build-order.test.mjs apps/desktop/scripts/dev-app-runtime.test.mjs", "test:scripts:extended": "node --test scripts/cu-provider-matrix.test.mjs scripts/cu-process-restart-harness.test.mjs scripts/cu-real-model-launcher.test.mjs scripts/macos-arm64-release.test.mjs scripts/windows-x64-release.test.mjs scripts/measure-session-bundle.test.mjs", "test:scripts:full": "npm run test:scripts && npm run test:scripts:extended", "dev": "npm --workspace @maka/desktop run dev:hmr --", @@ -60,6 +60,9 @@ "smoke:windows": "npm run build && node scripts/windows-smoke.mjs", "prepare:bundled-git": "node scripts/prepare-bundled-git.mjs", "prepare:bundled-git-source": "node scripts/prepare-bundled-git-source.mjs", + "prepare:bundled-npm": "node scripts/prepare-bundled-npm.mjs", + "verify:bundled-npm": "node scripts/verify-bundled-npm-runtime.mjs", + "audit:bundled-npm": "npm audit --prefix apps/desktop/.generated/bundled-npm/audit --omit=dev --audit-level=high", "e2e:computer-use-real": "node scripts/cu-real-ax-model-e2e-launcher.mjs", "e2e:computer-use-process-restart": "MAKA_CU_AX_MODEL_SCENARIO=restart-recovery node scripts/cu-real-ax-model-e2e-launcher.mjs", "e2e:computer-use-process-restart-soak": "node scripts/cu-process-restart-e2e-launcher.mjs", @@ -83,10 +86,15 @@ "@astryxdesign/core": "0.3.0", "@biomejs/biome": "2.5.6", "@types/node": "^26.1.2", + "brace-expansion": "5.0.9", "dugite": "3.2.2", + "ip-address": "10.4.0", "knip": "^6.26.0", + "npm": "12.0.2", "patch-package": "8.0.1", - "typescript": "^7.0.2" + "tar": "7.5.22", + "typescript": "^7.0.2", + "undici": "6.28.0" }, "allowScripts": { "esbuild@0.27.7": true, diff --git a/packages/runtime-host/src/__tests__/bundled-npm-dependency-producer.test.ts b/packages/runtime-host/src/__tests__/bundled-npm-dependency-producer.test.ts new file mode 100644 index 0000000000..15f860c003 --- /dev/null +++ b/packages/runtime-host/src/__tests__/bundled-npm-dependency-producer.test.ts @@ -0,0 +1,425 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + chmod, + copyFile, + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { + isBundledNpmNodeVersionSupported, + resolveBundledNpmDependencyProducer, +} from '../server/bundled-npm-dependency-producer.js'; + +test('admits only Node versions supported by the pinned npm runtime', () => { + assert.equal(isBundledNpmNodeVersionSupported('22.22.1'), false); + assert.equal(isBundledNpmNodeVersionSupported('22.22.2'), true); + assert.equal(isBundledNpmNodeVersionSupported('23.99.0'), false); + assert.equal(isBundledNpmNodeVersionSupported('24.14.9'), false); + assert.equal(isBundledNpmNodeVersionSupported('24.15.0'), true); + assert.equal(isBundledNpmNodeVersionSupported('25.0.0'), false); + assert.equal(isBundledNpmNodeVersionSupported('26.0.0'), true); + assert.equal(isBundledNpmNodeVersionSupported('invalid'), false); +}); + +test('runs the exact bundled npm runtime with scripts disabled', async (t) => { + const fixture = await bundledNpmFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + }); + assert.deepEqual(producer.nodeRuntime, { + version: process.versions.node, + abi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }); + assert.equal(producer.capability.kind, 'hermetic_dependency_builder_v1'); + assert.match(producer.capability.runtimeIdentitySha256, /^sha256:[0-9a-f]{64}$/u); + assert.notEqual(producer.capability.runtimeIdentitySha256, fixture.runtimeIdentitySha256); + assert.equal(producer.capability.network, 'registry_https_only'); + assert.equal(producer.capability.filesystem, 'maka_owned_staging_only'); + assert.equal(producer.capability.secrets, 'none'); + assert.equal(producer.capability.childProcess, 'verified_runtime_only'); + const stagingRoot = join(fixture.root, 'staging'); + const outputRoot = join(stagingRoot, 'node_modules'); + await createProducerStaging(stagingRoot); + + await producer.provision({ + identity: dependencyIdentity(), + outputRoot, + scratchRoot: join(stagingRoot, '.maka-runtime'), + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"name":"fixture","lockfileVersion":3,"packages":{}}\n'), + }); + + assert.equal(await readFile(join(outputRoot, 'fixture-package', 'index.js'), 'utf8'), 'safe\n'); + const invocation = JSON.parse( + await readFile(join(stagingRoot, 'invocation.json'), 'utf8'), + ) as Record; + assert.equal(invocation.ignoreScripts, true); + assert.equal(invocation.audit, false); + assert.equal(invocation.fund, false); + assert.equal(invocation.registry, 'https://registry.npmjs.org/'); + assert.equal(invocation.userconfig, join(stagingRoot, '.maka-runtime', 'home', 'npmrc')); + assert.equal(invocation.globalconfig, join(stagingRoot, '.maka-runtime', 'home', 'global-npmrc')); + assert.equal(invocation.temp, join(stagingRoot, '.maka-runtime', 'temp')); + assert.equal(invocation.compileCache, join(stagingRoot, '.maka-runtime', 'node-compile-cache')); +}); + +test('revalidates the complete npm runtime before every provision', async (t) => { + const fixture = await bundledNpmFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + }); + await writeFile(fixture.cliPath, 'throw new Error("tampered");\n', 'utf8'); + const outputRoot = join(fixture.root, 'tampered-staging', 'node_modules'); + await createProducerStaging(dirname(outputRoot)); + + await assert.rejects( + producer.provision({ + identity: dependencyIdentity(), + outputRoot, + scratchRoot: join(dirname(outputRoot), '.maka-runtime'), + manifestBytes: Buffer.from('{"packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"lockfileVersion":3,"packages":{}}\n'), + }), + /integrity mismatch/u, + ); +}); + +test('rejects Node runtime drift before provisioning', async (t) => { + const fixture = await bundledNpmFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + }); + await writeFile(fixture.nodeExecutablePath, 'tampered node runtime\n', 'utf8'); + const outputRoot = join(fixture.root, 'node-tampered-staging', 'node_modules'); + await createProducerStaging(dirname(outputRoot)); + + await assert.rejects( + producer.provision({ + identity: dependencyIdentity(), + outputRoot, + scratchRoot: join(dirname(outputRoot), '.maka-runtime'), + manifestBytes: Buffer.from('{"packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"lockfileVersion":3,"packages":{}}\n'), + }), + /Node runtime integrity mismatch/u, + ); +}); + +test('rejects registry dependency entries without lockfile integrity evidence', async (t) => { + const fixture = await bundledNpmFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + }); + const outputRoot = join(fixture.root, 'unsafe-staging', 'node_modules'); + await createProducerStaging(dirname(outputRoot)); + + await assert.rejects( + producer.provision({ + identity: dependencyIdentity(), + outputRoot, + scratchRoot: join(dirname(outputRoot), '.maka-runtime'), + manifestBytes: Buffer.from('{"packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from( + '{"lockfileVersion":3,"packages":{"":{"name":"fixture"},"node_modules/pkg":{"version":"1.0.0","resolved":"https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz"}}}\n', + ), + }), + /unsafe dependency entry/u, + ); +}); + +test('aborts the owned npm process and keeps temp state inside staging', async (t) => { + const fixture = await bundledNpmFixture('slow'); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + }); + const projectRoot = join(fixture.root, 'abort-staging'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + await createProducerStaging(projectRoot); + const controller = new AbortController(); + const task = producer.provision({ + identity: dependencyIdentity(), + outputRoot, + scratchRoot, + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"name":"fixture","lockfileVersion":3,"packages":{}}\n'), + abortSignal: controller.signal, + }); + await waitForFile(join(projectRoot, 'started')); + controller.abort(); + await assert.rejects(task, /aborted/u); + assert.equal(await readFile(join(projectRoot, 'temp-path'), 'utf8'), join(scratchRoot, 'temp')); +}); + +test('kills provisioning when the staging tree exceeds its configured quota', async (t) => { + const fixture = await bundledNpmFixture('large'); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + maxProvisionBytes: 1024, + }); + const projectRoot = join(fixture.root, 'quota-staging'); + const outputRoot = join(projectRoot, 'node_modules'); + await createProducerStaging(projectRoot); + await assert.rejects( + producer.provision({ + identity: dependencyIdentity(), + outputRoot, + scratchRoot: join(projectRoot, '.maka-runtime'), + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"name":"fixture","lockfileVersion":3,"packages":{}}\n'), + }), + /filesystem quota/u, + ); +}); + +test('counts empty files toward the provisioning entry quota', async (t) => { + const fixture = await bundledNpmFixture('many'); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + maxProvisionEntries: 20, + }); + const projectRoot = join(fixture.root, 'entry-quota-staging'); + await createProducerStaging(projectRoot); + await assert.rejects( + producer.provision({ + identity: dependencyIdentity(), + outputRoot: join(projectRoot, 'node_modules'), + scratchRoot: join(projectRoot, '.maka-runtime'), + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"name":"fixture","lockfileVersion":3,"packages":{}}\n'), + }), + /filesystem quota/u, + ); +}); + +test('rejects a pre-positioned scratch symlink or junction before npm starts', async (t) => { + const fixture = await bundledNpmFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: fixture.resourcesRoot, + nodeExecutablePath: fixture.nodeExecutablePath, + }); + const projectRoot = join(fixture.root, 'redirected-staging'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const outsideRoot = join(fixture.root, 'outside'); + await Promise.all([ + mkdir(join(projectRoot, 'node_modules'), { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + mkdir(outsideRoot, { recursive: true }), + ]); + await symlink( + outsideRoot, + join(scratchRoot, 'home'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await assert.rejects( + producer.provision({ + identity: dependencyIdentity(), + outputRoot: join(projectRoot, 'node_modules'), + scratchRoot, + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from('{"name":"fixture","lockfileVersion":3,"packages":{}}\n'), + }), + /scratch entry was not created by this provision/u, + ); + await assert.rejects(readFile(join(outsideRoot, 'npmrc'), 'utf8'), { code: 'ENOENT' }); +}); + +function dependencyIdentity() { + return { + protocolVersion: 1 as const, + environmentId: `sha256:${'1'.repeat(64)}` as const, + manifestPath: 'package.json', + manifestSha256: `sha256:${'2'.repeat(64)}` as const, + lockfilePath: 'package-lock.json', + lockfileSha256: `sha256:${'3'.repeat(64)}` as const, + packageManagerName: 'npm' as const, + packageManagerVersion: '12.0.2', + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: `sha256:${'4'.repeat(64)}` as const, + producerPolicyIdentitySha256: `sha256:${'5'.repeat(64)}` as const, + policyVersion: 'managed_dependency_environment_v1' as const, + }; +} + +async function createProducerStaging(projectRoot: string): Promise { + await Promise.all([ + mkdir(join(projectRoot, 'node_modules'), { recursive: true }), + mkdir(join(projectRoot, '.maka-runtime'), { recursive: true }), + ]); +} + +async function bundledNpmFixture(mode: 'normal' | 'slow' | 'large' | 'many' = 'normal') { + const root = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-producer-')); + const resourcesRoot = join(root, 'resources'); + const npmRoot = join(resourcesRoot, 'npm'); + const cliPath = join(npmRoot, 'bin', 'npm-cli.js'); + const cacheRoot = join(root, 'cache'); + const nodeExecutablePath = join(root, process.platform === 'win32' ? 'node.exe' : 'node'); + await copyFile(process.execPath, nodeExecutablePath); + if (process.platform !== 'win32') await chmod(nodeExecutablePath, 0o755); + await mkdir(join(npmRoot, 'bin'), { recursive: true }); + await writeFile(join(npmRoot, 'LICENSE'), 'Artistic-2.0 fixture\n'); + await writeFile( + join(npmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ); + await writeFile( + cliPath, + [ + "const fs = require('node:fs');", + "const path = require('node:path');", + 'const args = process.argv.slice(2);', + 'const root = process.cwd();', + ...(mode === 'slow' + ? [ + "fs.writeFileSync(path.join(root, 'started'), '1');", + "fs.writeFileSync(path.join(root, 'temp-path'), process.env.TEMP || process.env.TMPDIR);", + 'setInterval(() => {}, 1000);', + ] + : []), + ...(mode === 'large' + ? [ + "fs.writeFileSync(path.join(root, 'oversized'), Buffer.alloc(4096));", + 'setInterval(() => {}, 1000);', + ] + : []), + ...(mode === 'many' + ? [ + "const many = path.join(root, 'many-empty-files');", + 'fs.mkdirSync(many, { recursive: true });', + "for (let index = 0; index < 100; index += 1) fs.writeFileSync(path.join(many, String(index)), '');", + 'setInterval(() => {}, 1000);', + ] + : []), + "fs.mkdirSync(path.join(root, 'node_modules', 'fixture-package'), { recursive: true });", + "fs.writeFileSync(path.join(root, 'node_modules', 'fixture-package', 'index.js'), 'safe\\n');", + "fs.writeFileSync(path.join(root, 'invocation.json'), JSON.stringify({", + " ignoreScripts: args.includes('--ignore-scripts'),", + " audit: !args.includes('--no-audit'),", + " fund: !args.includes('--no-fund'),", + ' registry: process.env.npm_config_registry,', + ' userconfig: process.env.npm_config_userconfig,', + ' globalconfig: process.env.npm_config_globalconfig,', + ' temp: process.env.TEMP || process.env.TMPDIR,', + ' compileCache: process.env.NODE_COMPILE_CACHE,', + '}));', + ].join('\n'), + 'utf8', + ); + const files = await Promise.all( + ['LICENSE', 'bin/npm-cli.js', 'package.json'].map(async (path) => { + const bytes = await readFile(join(npmRoot, ...path.split('/'))); + return { path, bytes: bytes.byteLength, sha256: sha256(bytes) }; + }), + ); + const identity = JSON.stringify({ + protocol: 'maka_bundled_npm_runtime_identity_v1', + npmVersion: '12.0.2', + platform: process.platform, + arch: process.arch, + securityPatches: securityPatches(), + files, + }); + const runtimeIdentitySha256 = sha256(Buffer.from(identity)); + await writeFile( + join(resourcesRoot, 'bundled-npm.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: '12.0.2', + platform: process.platform, + arch: process.arch, + securityPatches: securityPatches(), + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + files, + runtimeIdentitySha256, + distributionReady: true, + })}\n`, + ); + return { + root, + resourcesRoot, + cacheRoot, + cliPath, + nodeExecutablePath, + runtimeIdentitySha256, + }; +} + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + await readFile(path); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for ${path}`); +} + +function securityPatches() { + return [ + { + packageName: 'tar', + fromVersion: '7.5.19', + toVersion: '7.5.22', + advisories: ['GHSA-r292-9mhp-454m'], + }, + { + packageName: 'brace-expansion', + fromVersion: '5.0.7', + toVersion: '5.0.9', + advisories: ['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895'], + }, + { + packageName: 'ip-address', + fromVersion: '10.2.0', + toVersion: '10.4.0', + advisories: ['GHSA-mwp4-54f8-5fhr', 'GHSA-4xrf-jv44-h6hh', 'GHSA-22jq-vg5j-6vgg'], + }, + { + packageName: 'undici', + fromVersion: '6.27.0', + toVersion: '6.28.0', + advisories: ['GHSA-8xcm-r25x-g524', 'GHSA-m8rv-5g2x-5cg5', 'GHSA-v3r7-h72x-cjcm'], + }, + ]; +} + +function sha256(value: Uint8Array): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/packages/runtime-host/src/__tests__/candidate-cli.test.ts b/packages/runtime-host/src/__tests__/candidate-cli.test.ts new file mode 100644 index 0000000000..cafb6f5ddb --- /dev/null +++ b/packages/runtime-host/src/__tests__/candidate-cli.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseRuntimeHostCandidateArguments } from '../candidate-cli.js'; + +test('parses packaged Git and npm authorities for the detached execution candidate', () => { + assert.deepEqual( + parseRuntimeHostCandidateArguments([ + '--root', + '/storage', + '--expected-root-id', + 'a'.repeat(64), + '--bundled-git-resources-root', + '/resources', + '--bundled-npm-resources-root', + '/resources', + '--dependency-node-executable-path', + '/app/Maka', + ]), + { + rootPath: '/storage', + expectedRootId: 'a'.repeat(64), + idleGraceMs: undefined, + handshakeTimeoutMs: undefined, + bundledGitResourcesRoot: '/resources', + bundledNpmResourcesRoot: '/resources', + dependencyNodeExecutablePath: '/app/Maka', + }, + ); +}); + +test('rejects duplicate packaged authority arguments', () => { + assert.throws( + () => + parseRuntimeHostCandidateArguments([ + '--root', + '/storage', + '--expected-root-id', + 'a'.repeat(64), + '--bundled-npm-resources-root', + '/one', + '--bundled-npm-resources-root', + '/two', + ]), + /Invalid Runtime Host candidate argument/u, + ); +}); diff --git a/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts b/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts index 8983e21841..642c34786b 100644 --- a/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts @@ -67,6 +67,23 @@ test('never falls back a managed profile to attached execution', async () => { await composition.close(); }); +test('preserves an explicit managed dependency provisioning profile through admission', async () => { + const calls: string[] = []; + const admissionOptions: unknown[] = []; + const handle = Object.freeze({ kind: 'managed_workspace_execution_handle_v1' as const }); + const scope = Object.freeze({ kind: 'managed_workspace_execution_scope_v1' as const }); + const managedOwner = fakeManagedOwner({ handle, scope, calls, admissionOptions }); + const composition = createRuntimeHostWorkspaceExecutionComposition({ managedOwner }); + const profile = createManagedWorkspaceExecutionProfile(handle, { + provisioning: 'dependency_environment_v1', + }); + + await composition.executeReadOnly(profile, { kind: 'read', path: 'node_modules/pkg/index.js' }); + + assert.deepEqual(admissionOptions, [{ provisioning: 'dependency_environment_v1' }]); + await composition.close(); +}); + test('rejects forged or malformed profiles before worker dispatch', async () => { let workerCalls = 0; const composition = createRuntimeHostWorkspaceExecutionComposition({ @@ -131,14 +148,16 @@ function fakeManagedOwner(input: { scope: ManagedWorkspaceExecutionScope; calls: string[]; workerBlocked?: Promise; + admissionOptions?: unknown[]; }): ManagedWorkspaceOwner { return { state: 'ready', async openManagedWorkspaceBaseline() { throw new Error('not used'); }, - async withManagedWorkspaceExecution(handle, operation) { + async withManagedWorkspaceExecution(handle, operation, options) { assert.equal(handle, input.handle); + input.admissionOptions?.push(options); input.calls.push('managed:admit'); return await operation(input.scope); }, diff --git a/packages/runtime-host/src/candidate-cli.ts b/packages/runtime-host/src/candidate-cli.ts index 7f0bb45e9b..b80875af58 100644 --- a/packages/runtime-host/src/candidate-cli.ts +++ b/packages/runtime-host/src/candidate-cli.ts @@ -1,13 +1,22 @@ import type { RuntimeHostCandidateOptions } from './server/candidate.js'; +export interface RuntimeHostExecutionCandidateArguments extends RuntimeHostCandidateOptions { + readonly bundledGitResourcesRoot?: string; + readonly bundledNpmResourcesRoot?: string; + readonly dependencyNodeExecutablePath?: string; +} + export function parseRuntimeHostCandidateArguments( args: readonly string[], -): RuntimeHostCandidateOptions { +): RuntimeHostExecutionCandidateArguments { const allowedKeys = new Set([ 'root', 'expected-root-id', 'idle-grace-ms', 'handshake-timeout-ms', + 'bundled-git-resources-root', + 'bundled-npm-resources-root', + 'dependency-node-executable-path', ]); const values = new Map(); for (let index = 0; index < args.length; index += 2) { @@ -33,6 +42,9 @@ export function parseRuntimeHostCandidateArguments( expectedRootId, idleGraceMs: readOptionalInteger(values, 'idle-grace-ms'), handshakeTimeoutMs: readOptionalInteger(values, 'handshake-timeout-ms'), + bundledGitResourcesRoot: values.get('bundled-git-resources-root'), + bundledNpmResourcesRoot: values.get('bundled-npm-resources-root'), + dependencyNodeExecutablePath: values.get('dependency-node-executable-path'), }; } diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index c2f0b145c2..98a6dd7b90 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -32,6 +32,9 @@ export interface ConnectOrSpawnRuntimeHostInput { connectTimeoutMs?: number; handshakeTimeoutMs?: number; candidateEntrypoint?: string | URL; + bundledGitResourcesRoot?: string; + bundledNpmResourcesRoot?: string; + dependencyNodeExecutablePath?: string; } interface ConnectOrSpawnRuntimeHostDependencies { @@ -110,6 +113,15 @@ export async function connectOrSpawnRuntimeHostWithDependencies( ...(input.candidateEntrypoint === undefined ? {} : { entrypoint: input.candidateEntrypoint }), + ...(input.bundledGitResourcesRoot === undefined + ? {} + : { bundledGitResourcesRoot: input.bundledGitResourcesRoot }), + ...(input.bundledNpmResourcesRoot === undefined + ? {} + : { bundledNpmResourcesRoot: input.bundledNpmResourcesRoot }), + ...(input.dependencyNodeExecutablePath === undefined + ? {} + : { dependencyNodeExecutablePath: input.dependencyNodeExecutablePath }), }); await settleBeforeDeadline(launch.spawned, deadline); } catch { diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index a9a0fa8fa8..34b1669dd1 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -9,6 +9,9 @@ export interface DetachedCandidateInput { executable?: string; entrypoint?: string | URL; env?: NodeJS.ProcessEnv; + bundledGitResourcesRoot?: string; + bundledNpmResourcesRoot?: string; + dependencyNodeExecutablePath?: string; } export interface DetachedCandidateAttempt { @@ -35,6 +38,9 @@ export function launchDetachedRuntimeHostCandidate( ]; appendArgument(args, '--idle-grace-ms', input.idleGraceMs); appendArgument(args, '--handshake-timeout-ms', input.handshakeTimeoutMs); + appendArgument(args, '--bundled-git-resources-root', input.bundledGitResourcesRoot); + appendArgument(args, '--bundled-npm-resources-root', input.bundledNpmResourcesRoot); + appendArgument(args, '--dependency-node-executable-path', input.dependencyNodeExecutablePath); // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. const child = spawn(executable, args, { diff --git a/packages/runtime-host/src/server/bundled-npm-dependency-producer.ts b/packages/runtime-host/src/server/bundled-npm-dependency-producer.ts new file mode 100644 index 0000000000..12cb75b1bb --- /dev/null +++ b/packages/runtime-host/src/server/bundled-npm-dependency-producer.ts @@ -0,0 +1,692 @@ +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, mkdir, open, readFile, readdir, realpath, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, normalize, relative } from 'node:path'; +import { promisify } from 'node:util'; +import type { + ManagedDependencyEnvironmentProducer, + ManagedDependencyEnvironmentProducerInput, +} from '@maka/storage/managed-workspace-owner'; +import { createManagedDependencyEnvironmentProducerCapability } from '@maka/storage/managed-workspace-owner'; + +const execFileAsync = promisify(execFile); +const EXPECTED_NPM_VERSION = '12.0.2'; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const PRODUCER_RUNTIME_IDENTITY_DOMAIN = 'maka.bundled_npm.producer_runtime.v1\0'; +const EXPECTED_SECURITY_PATCHES = Object.freeze([ + Object.freeze({ + packageName: 'tar', + fromVersion: '7.5.19', + toVersion: '7.5.22', + advisories: Object.freeze(['GHSA-r292-9mhp-454m']), + }), + Object.freeze({ + packageName: 'brace-expansion', + fromVersion: '5.0.7', + toVersion: '5.0.9', + advisories: Object.freeze(['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895']), + }), + Object.freeze({ + packageName: 'ip-address', + fromVersion: '10.2.0', + toVersion: '10.4.0', + advisories: Object.freeze([ + 'GHSA-mwp4-54f8-5fhr', + 'GHSA-4xrf-jv44-h6hh', + 'GHSA-22jq-vg5j-6vgg', + ]), + }), + Object.freeze({ + packageName: 'undici', + fromVersion: '6.27.0', + toVersion: '6.28.0', + advisories: Object.freeze([ + 'GHSA-8xcm-r25x-g524', + 'GHSA-m8rv-5g2x-5cg5', + 'GHSA-v3r7-h72x-cjcm', + ]), + }), +]); +const MANIFEST_KEYS = [ + 'schemaVersion', + 'protocol', + 'provider', + 'npmVersion', + 'platform', + 'arch', + 'securityPatches', + 'runtimeRootRelativePath', + 'cliRelativePath', + 'files', + 'runtimeIdentitySha256', + 'distributionReady', +] as const; + +export interface ResolveBundledNpmDependencyProducerInput { + readonly resourcesRoot: string; + readonly nodeExecutablePath: string; + readonly manifestPath?: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly maxProvisionBytes?: number; + readonly maxProvisionEntries?: number; +} + +export async function resolveBundledNpmDependencyProducer( + input: ResolveBundledNpmDependencyProducerInput, +): Promise { + const resourcesRoot = normalize(await realpath(input.resourcesRoot)); + const nodeExecutablePath = normalize(await realpath(input.nodeExecutablePath)); + await requireRegularFile(nodeExecutablePath, 'Node runtime'); + const nodeExecutableSha256 = await sha256File(nodeExecutablePath); + const nodeRuntime = await inspectNodeRuntime(nodeExecutablePath); + if ((await sha256File(nodeExecutablePath)) !== nodeExecutableSha256) { + throw new Error('Bundled npm Node runtime changed during identity inspection'); + } + if (!isBundledNpmNodeVersionSupported(nodeRuntime.version)) { + throw new Error(`Bundled npm does not support Node ${nodeRuntime.version}`); + } + const manifestPath = normalize( + await realpath(input.manifestPath ?? join(resourcesRoot, 'bundled-npm.json')), + ); + assertWithin(resourcesRoot, manifestPath, 'Bundled npm manifest'); + const manifest = decodeManifest(JSON.parse(await readFile(manifestPath, 'utf8'))); + if ( + manifest.platform !== (input.platform ?? nodeRuntime.platform) || + manifest.arch !== (input.arch ?? nodeRuntime.arch) || + manifest.platform !== nodeRuntime.platform || + manifest.arch !== nodeRuntime.arch + ) { + throw new Error('Bundled npm runtime platform does not match this process'); + } + const npmRoot = normalize(join(resourcesRoot, ...manifest.runtimeRootRelativePath.split('/'))); + const cliPath = normalize(join(resourcesRoot, ...manifest.cliRelativePath.split('/'))); + assertWithin(resourcesRoot, npmRoot, 'Bundled npm root'); + assertWithin(npmRoot, cliPath, 'Bundled npm CLI'); + await verifyRuntime(npmRoot, manifest); + const producerRuntimeIdentitySha256 = computeProducerRuntimeIdentity({ + npmRuntimeIdentitySha256: manifest.runtimeIdentitySha256, + nodeExecutableSha256, + nodeRuntime, + }); + + return Object.freeze({ + capability: createManagedDependencyEnvironmentProducerCapability(producerRuntimeIdentitySha256), + packageManagerName: 'npm' as const, + packageManagerVersion: manifest.npmVersion, + nodeRuntime, + async provision(provisionInput: ManagedDependencyEnvironmentProducerInput) { + await verifyRuntime(npmRoot, manifest); + // Keep the executable proof immediately adjacent to the owned spawn. + await verifyNodeRuntime(nodeExecutablePath, nodeExecutableSha256); + await provisionWithNpm({ + input: provisionInput, + nodeExecutablePath, + cliPath, + maxProvisionBytes: input.maxProvisionBytes ?? 2 * 1024 * 1024 * 1024, + maxProvisionEntries: input.maxProvisionEntries ?? 250_000, + }); + }, + }); +} + +function computeProducerRuntimeIdentity(input: { + readonly npmRuntimeIdentitySha256: `sha256:${string}`; + readonly nodeExecutableSha256: `sha256:${string}`; + readonly nodeRuntime: ManagedDependencyEnvironmentProducer['nodeRuntime']; +}): `sha256:${string}` { + return sha256( + Buffer.concat([ + Buffer.from(PRODUCER_RUNTIME_IDENTITY_DOMAIN, 'utf8'), + Buffer.from( + JSON.stringify({ + npmRuntimeIdentitySha256: input.npmRuntimeIdentitySha256, + nodeExecutableSha256: input.nodeExecutableSha256, + nodeVersion: input.nodeRuntime.version, + nodeAbi: input.nodeRuntime.abi, + platform: input.nodeRuntime.platform, + arch: input.nodeRuntime.arch, + }), + 'utf8', + ), + ]), + ); +} + +async function verifyNodeRuntime( + nodeExecutablePath: string, + expectedSha256: `sha256:${string}`, +): Promise { + await requireRegularFile(nodeExecutablePath, 'Node runtime'); + if ((await sha256File(nodeExecutablePath)) !== expectedSha256) { + throw new Error('Bundled npm Node runtime integrity mismatch'); + } +} + +export function isBundledNpmNodeVersionSupported(version: string): boolean { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (major >= 26) return true; + if (major === 24) return minor > 15 || (minor === 15 && patch >= 0); + if (major === 22) return minor > 22 || (minor === 22 && patch >= 2); + return false; +} + +interface BundledNpmManifestFileV1 { + readonly path: string; + readonly bytes: number; + readonly sha256: `sha256:${string}`; +} + +interface BundledNpmManifestV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_bundled_npm_runtime_v1'; + readonly provider: 'desktop/npm-cli'; + readonly npmVersion: typeof EXPECTED_NPM_VERSION; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly securityPatches: typeof EXPECTED_SECURITY_PATCHES; + readonly runtimeRootRelativePath: 'npm'; + readonly cliRelativePath: 'npm/bin/npm-cli.js'; + readonly files: readonly BundledNpmManifestFileV1[]; + readonly runtimeIdentitySha256: `sha256:${string}`; + readonly distributionReady: true; +} + +function decodeManifest(value: unknown): BundledNpmManifestV1 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Bundled npm manifest is invalid'); + } + const input = value as Record; + const files = input.files; + if ( + Object.keys(input).sort().join('\0') !== [...MANIFEST_KEYS].sort().join('\0') || + input.schemaVersion !== 1 || + input.protocol !== 'maka_bundled_npm_runtime_v1' || + input.provider !== 'desktop/npm-cli' || + input.npmVersion !== EXPECTED_NPM_VERSION || + (input.platform !== 'win32' && input.platform !== 'darwin' && input.platform !== 'linux') || + typeof input.arch !== 'string' || + !/^[a-z0-9_]+$/u.test(input.arch) || + !isExpectedSecurityPatches(input.securityPatches) || + input.runtimeRootRelativePath !== 'npm' || + input.cliRelativePath !== 'npm/bin/npm-cli.js' || + !Array.isArray(files) || + files.length === 0 || + files.length > 20_000 || + typeof input.runtimeIdentitySha256 !== 'string' || + !SHA256_PATTERN.test(input.runtimeIdentitySha256) || + input.distributionReady !== true + ) { + throw new Error('Bundled npm manifest is invalid'); + } + const decodedFiles = files.map(decodeManifestFile); + const sorted = [...decodedFiles].sort((left, right) => + Buffer.from(left.path).compare(Buffer.from(right.path)), + ); + if (decodedFiles.some((file, index) => file.path !== sorted[index]?.path)) { + throw new Error('Bundled npm file manifest is not canonical'); + } + if (new Set(decodedFiles.map((file) => file.path)).size !== decodedFiles.length) { + throw new Error('Bundled npm file manifest contains duplicate paths'); + } + const manifest = { ...input, files: decodedFiles } as unknown as BundledNpmManifestV1; + const identity = JSON.stringify({ + protocol: 'maka_bundled_npm_runtime_identity_v1', + npmVersion: manifest.npmVersion, + platform: manifest.platform, + arch: manifest.arch, + securityPatches: manifest.securityPatches, + files: manifest.files, + }); + if (sha256(Buffer.from(identity)) !== manifest.runtimeIdentitySha256) { + throw new Error('Bundled npm runtime identity is invalid'); + } + return Object.freeze(manifest); +} + +function isExpectedSecurityPatches(value: unknown): boolean { + return JSON.stringify(value) === JSON.stringify(EXPECTED_SECURITY_PATCHES); +} + +function decodeManifestFile(value: unknown): BundledNpmManifestFileV1 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Bundled npm file manifest is invalid'); + } + const input = value as Record; + if ( + Object.keys(input).sort().join('\0') !== 'bytes\0path\0sha256' || + typeof input.path !== 'string' || + !safeRelativePath(input.path) || + typeof input.bytes !== 'number' || + !Number.isSafeInteger(input.bytes) || + input.bytes < 0 || + typeof input.sha256 !== 'string' || + !SHA256_PATTERN.test(input.sha256) + ) { + throw new Error('Bundled npm file manifest is invalid'); + } + return input as unknown as BundledNpmManifestFileV1; +} + +async function verifyRuntime(root: string, manifest: BundledNpmManifestV1): Promise { + const actualPaths: string[] = []; + await inventoryRuntimePaths(root, root, actualPaths); + actualPaths.sort((left, right) => Buffer.from(left).compare(Buffer.from(right))); + const expectedPaths = manifest.files.map((file) => file.path); + if (actualPaths.join('\0') !== expectedPaths.join('\0')) { + throw new Error('Bundled npm runtime integrity mismatch: file inventory changed'); + } + for (const file of manifest.files) { + const path = join(root, ...file.path.split('/')); + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size !== file.bytes) { + throw new Error(`Bundled npm runtime integrity mismatch: ${file.path}`); + } + if ((await sha256File(path)) !== file.sha256) { + throw new Error(`Bundled npm runtime integrity mismatch: ${file.path}`); + } + } +} + +async function inventoryRuntimePaths(root: string, directory: string, output: string[]) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => Buffer.from(left.name).compare(Buffer.from(right.name))); + for (const entry of entries) { + const path = join(directory, entry.name); + const info = await lstat(path); + if (entry.isDirectory() && !info.isSymbolicLink()) { + await inventoryRuntimePaths(root, path, output); + } else if (entry.isFile() && !info.isSymbolicLink()) { + output.push(relative(root, path).replaceAll('\\', '/')); + } else { + throw new Error('Bundled npm runtime integrity mismatch: unsupported entry'); + } + } +} + +async function provisionWithNpm(input: { + readonly input: ManagedDependencyEnvironmentProducerInput; + readonly nodeExecutablePath: string; + readonly cliPath: string; + readonly maxProvisionBytes: number; + readonly maxProvisionEntries: number; +}) { + assertSafeNpmInputs(input.input); + const projectRoot = dirname(input.input.outputRoot); + const scratchRoot = await ensureCanonicalDirectory(input.input.scratchRoot); + const canonicalProjectRoot = normalize(await realpath(projectRoot)); + assertWithin(canonicalProjectRoot, scratchRoot, 'Bundled npm scratch root'); + const [homeRoot, npmCache, temporaryRoot, compileCacheRoot] = await Promise.all([ + createOwnedScratchDirectory(scratchRoot, 'home'), + createOwnedScratchDirectory(scratchRoot, 'cache'), + createOwnedScratchDirectory(scratchRoot, 'temp'), + createOwnedScratchDirectory(scratchRoot, 'node-compile-cache'), + ]); + const userConfig = join(homeRoot, 'npmrc'); + const globalConfig = join(homeRoot, 'global-npmrc'); + const exactConfig = 'registry=https://registry.npmjs.org/\n'; + await ensureExactConfigFile(userConfig, exactConfig); + await ensureExactConfigFile(globalConfig, exactConfig); + await writeFile(join(projectRoot, 'package.json'), input.input.manifestBytes, { flag: 'wx' }); + await writeFile(join(projectRoot, 'package-lock.json'), input.input.lockfileBytes, { + flag: 'wx', + }); + await runNpmProcess( + input.nodeExecutablePath, + [ + input.cliPath, + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=true', + `--cache=${npmCache}`, + `--userconfig=${userConfig}`, + `--globalconfig=${globalConfig}`, + ], + { + cwd: projectRoot, + env: hermeticNpmEnvironment( + homeRoot, + userConfig, + globalConfig, + temporaryRoot, + compileCacheRoot, + ), + signal: input.input.abortSignal, + monitorRoot: projectRoot, + maxBytes: input.maxProvisionBytes, + maxEntries: input.maxProvisionEntries, + }, + ); +} + +function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): void { + if ( + input.identity.packageManagerName !== 'npm' || + input.identity.packageManagerVersion !== EXPECTED_NPM_VERSION + ) { + throw new Error('Bundled npm producer identity mismatch'); + } + if ( + input.manifestBytes.byteLength > 1024 * 1024 || + input.lockfileBytes.byteLength > 64 * 1024 * 1024 + ) { + throw new Error('Bundled npm producer input exceeds its bounded size policy'); + } + const manifest = JSON.parse(Buffer.from(input.manifestBytes).toString('utf8')) as { + packageManager?: unknown; + workspaces?: unknown; + }; + const lockfile = JSON.parse(Buffer.from(input.lockfileBytes).toString('utf8')) as { + lockfileVersion?: unknown; + packages?: unknown; + }; + if ( + manifest.packageManager !== `npm@${EXPECTED_NPM_VERSION}` || + manifest.workspaces !== undefined || + lockfile.lockfileVersion !== 3 || + !lockfile.packages || + typeof lockfile.packages !== 'object' + ) { + throw new Error('Bundled npm producer accepts only exact non-workspace package-lock v3 input'); + } + const packageEntries = Object.entries(lockfile.packages as Record); + if (packageEntries.length > 25_000) { + throw new Error('Bundled npm producer lockfile exceeds its package-count policy'); + } + for (const [packagePath, value] of packageEntries) { + if (!value || typeof value !== 'object') continue; + const entry = value as { + resolved?: unknown; + integrity?: unknown; + link?: unknown; + hasInstallScript?: unknown; + }; + if ( + entry.link === true || + entry.hasInstallScript === true || + (typeof entry.resolved === 'string' && + !entry.resolved.startsWith('https://registry.npmjs.org/')) + ) { + throw new Error('Bundled npm producer rejected an unsafe dependency entry'); + } + if ( + packagePath !== '' && + (!packagePath.startsWith('node_modules/') || + typeof entry.resolved !== 'string' || + typeof entry.integrity !== 'string' || + !/^sha(?:1|256|384|512)-[A-Za-z0-9+/=]+$/u.test(entry.integrity)) + ) { + throw new Error('Bundled npm producer rejected an unsafe dependency entry'); + } + } +} + +function hermeticNpmEnvironment( + homeRoot: string, + userConfig: string, + globalConfig: string, + temporaryRoot: string, + compileCacheRoot: string, +): NodeJS.ProcessEnv { + return { + HOME: homeRoot, + USERPROFILE: homeRoot, + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + npm_config_registry: 'https://registry.npmjs.org/', + npm_config_userconfig: userConfig, + npm_config_globalconfig: globalConfig, + TEMP: temporaryRoot, + TMP: temporaryRoot, + TMPDIR: temporaryRoot, + NODE_COMPILE_CACHE: compileCacheRoot, + ...(process.platform === 'win32' + ? { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR } + : {}), + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), + }; +} + +async function inspectNodeRuntime( + nodeExecutablePath: string, +): Promise { + const { stdout } = await execFileAsync( + nodeExecutablePath, + [ + '-p', + 'JSON.stringify({version:process.versions.node,abi:process.versions.modules,platform:process.platform,arch:process.arch})', + ], + { + env: process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}, + timeout: 15_000, + maxBuffer: 64 * 1024, + windowsHide: true, + }, + ); + const value = JSON.parse(stdout.trim()) as Record; + if ( + typeof value.version !== 'string' || + typeof value.abi !== 'string' || + (value.platform !== 'win32' && value.platform !== 'darwin' && value.platform !== 'linux') || + typeof value.arch !== 'string' + ) { + throw new Error('Bundled npm Node runtime identity is invalid'); + } + return Object.freeze({ + version: value.version, + abi: value.abi, + platform: value.platform, + arch: value.arch, + }); +} + +async function ensureExactConfigFile(path: string, content: string): Promise { + try { + await writeFile(path, content, { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + if ((await readFile(path, 'utf8')) !== content) { + throw new Error('Bundled npm hermetic configuration was modified'); + } + } +} + +async function ensureCanonicalDirectory(path: string): Promise { + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Bundled npm scratch root is not an owned directory'); + } + return normalize(await realpath(path)); +} + +async function createOwnedScratchDirectory(root: string, name: string): Promise { + const path = join(root, name); + try { + await mkdir(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + throw new Error('Bundled npm scratch entry was not created by this provision', { + cause: error, + }); + } + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Bundled npm scratch entry is not an owned directory'); + } + const canonical = normalize(await realpath(path)); + assertWithin(root, canonical, 'Bundled npm scratch entry'); + return canonical; +} + +async function requireRegularFile(path: string, label: string): Promise { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} is unavailable`); +} + +function safeRelativePath(path: string): boolean { + return ( + Boolean(path) && + !isAbsolute(path) && + !path.includes('\\') && + path.split('/').every((segment) => segment && segment !== '.' && segment !== '..') + ); +} + +function assertWithin(root: string, path: string, label: string): void { + const rel = relative(root, path); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw new Error(`${label} escapes its authority root`); +} + +async function sha256File(path: string): Promise<`sha256:${string}`> { + const hash = createHash('sha256'); + const handle = await open(path, 'r'); + try { + const buffer = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (true) { + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + offset += bytesRead; + } + } finally { + await handle.close(); + } + return `sha256:${hash.digest('hex')}`; +} + +async function runNpmProcess( + executable: string, + args: readonly string[], + options: { + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly signal?: AbortSignal; + readonly monitorRoot: string; + readonly maxBytes: number; + readonly maxEntries: number; + }, +): Promise { + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) { + throw new TypeError('Bundled npm provision byte quota must be a positive safe integer'); + } + if (!Number.isSafeInteger(options.maxEntries) || options.maxEntries <= 0) { + throw new TypeError('Bundled npm provision entry quota must be a positive safe integer'); + } + options.signal?.throwIfAborted(); + const child = spawn(executable, [...args], { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const output = createBoundedOutput(8 * 1024 * 1024); + child.stdout.on('data', (chunk: Buffer) => output.append(chunk)); + child.stderr.on('data', (chunk: Buffer) => output.append(chunk)); + let terminalError: Error | undefined; + const stop = (error: Error) => { + terminalError ??= error; + child.kill('SIGKILL'); + }; + const onAbort = () => stop(new Error('Bundled npm dependency provisioning was aborted')); + options.signal?.addEventListener('abort', onAbort, { once: true }); + const timeout = setTimeout( + () => stop(new Error('Bundled npm dependency provisioning timed out')), + 10 * 60 * 1_000, + ); + const monitor = setInterval(() => { + void measureTree(options.monitorRoot) + .then(({ bytes, entries }) => { + if (bytes > options.maxBytes || entries > options.maxEntries) { + stop(new Error('Bundled npm dependency provisioning exceeded its filesystem quota')); + } + }) + .catch((error: unknown) => stop(asError(error))); + }, 250); + try { + const exitCode = await new Promise((resolvePromise, rejectPromise) => { + child.once('error', rejectPromise); + child.once('close', (code) => resolvePromise(code ?? -1)); + }); + if (terminalError) throw terminalError; + if (exitCode !== 0) { + throw new Error( + output.text + ? `Bundled npm dependency provisioning failed: ${output.text}` + : `Bundled npm dependency provisioning failed with exit code ${exitCode}`, + ); + } + const measured = await measureTree(options.monitorRoot); + if (measured.bytes > options.maxBytes || measured.entries > options.maxEntries) { + throw new Error('Bundled npm dependency provisioning exceeded its filesystem quota'); + } + } finally { + clearTimeout(timeout); + clearInterval(monitor); + options.signal?.removeEventListener('abort', onAbort); + } +} + +function createBoundedOutput(maxBytes: number) { + let bytes = 0; + const chunks: Buffer[] = []; + return { + append(chunk: Buffer) { + if (bytes >= maxBytes) return; + const accepted = chunk.subarray(0, Math.min(chunk.byteLength, maxBytes - bytes)); + chunks.push(Buffer.from(accepted)); + bytes += accepted.byteLength; + }, + get text() { + return Buffer.concat(chunks).toString('utf8').trim(); + }, + }; +} + +async function measureTree(root: string): Promise<{ readonly bytes: number; readonly entries: number }> { + let bytes = 0; + let entries = 0; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop()!; + let directoryEntries; + try { + directoryEntries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + for (const entry of directoryEntries) { + entries += 1; + const path = join(directory, entry.name); + let info; + try { + info = await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (entry.isDirectory() && !info.isSymbolicLink()) pending.push(path); + else if (entry.isFile() && !info.isSymbolicLink()) bytes += info.size; + else throw new Error('Bundled npm producer created an unsupported filesystem entry'); + } + } + return Object.freeze({ bytes, entries }); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function sha256(value: Uint8Array): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/packages/runtime-host/src/server/execution-candidate.ts b/packages/runtime-host/src/server/execution-candidate.ts index 8b709cc7aa..8aed61f49a 100644 --- a/packages/runtime-host/src/server/execution-candidate.ts +++ b/packages/runtime-host/src/server/execution-candidate.ts @@ -3,8 +3,12 @@ import { tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; import type { RuntimeHostCandidateOptions } from './candidate.js'; -import type { VerifiedGitRuntimeInput } from '@maka/storage/managed-workspace-owner'; +import type { + ManagedDependencyEnvironmentProducer, + VerifiedGitRuntimeInput, +} from '@maka/storage/managed-workspace-owner'; import { resolveBundledGitRuntime } from './bundled-git-runtime.js'; +import { resolveBundledNpmDependencyProducer } from './bundled-npm-dependency-producer.js'; import { createExecutionRuntimeHostComposition } from './execution-composition.js'; import { RuntimeHostKernel } from './host-kernel.js'; @@ -16,6 +20,10 @@ export interface ExecutionRuntimeHostCandidateOptions extends RuntimeHostCandida readonly managedWorkspaceGitRuntime?: VerifiedGitRuntimeInput; /** Packaged resource root containing bundled-git.json and the Git toolchain. */ readonly bundledGitResourcesRoot?: string; + readonly managedWorkspaceDependencyProducer?: ManagedDependencyEnvironmentProducer; + /** Packaged resource root containing bundled-npm.json and the npm runtime. */ + readonly bundledNpmResourcesRoot?: string; + readonly dependencyNodeExecutablePath?: string; } export async function startExecutionRuntimeHostCandidate( @@ -27,6 +35,15 @@ export async function startExecutionRuntimeHostCandidate( const managedWorkspaceGitRuntime = options.bundledGitResourcesRoot ? await resolveBundledGitRuntime({ resourcesRoot: options.bundledGitResourcesRoot }) : options.managedWorkspaceGitRuntime; + if (options.managedWorkspaceDependencyProducer && options.bundledNpmResourcesRoot) { + throw new Error('Managed dependency producer must have exactly one authority'); + } + const managedWorkspaceDependencyProducer = options.bundledNpmResourcesRoot + ? await resolveBundledNpmDependencyProducer({ + resourcesRoot: options.bundledNpmResourcesRoot, + nodeExecutablePath: options.dependencyNodeExecutablePath ?? process.execPath, + }) + : options.managedWorkspaceDependencyProducer; const capability = await resolveExistingStorageRoot({ path: options.rootPath, kind: 'interactive', @@ -41,6 +58,7 @@ export async function startExecutionRuntimeHostCandidate( compositionFactory: (context) => createExecutionRuntimeHostComposition(context, { ...(managedWorkspaceGitRuntime ? { managedWorkspaceGitRuntime } : {}), + ...(managedWorkspaceDependencyProducer ? { managedWorkspaceDependencyProducer } : {}), }), }); return { kind: 'winner', host }; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 60b8e94051..dfed13653a 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -51,6 +51,7 @@ import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { openManagedWorkspaceOwner, + type ManagedDependencyEnvironmentProducer, type ManagedWorkspaceFilesystemWorker, type ManagedWorkspaceOwner, type VerifiedGitRuntimeInput, @@ -126,6 +127,7 @@ export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition export interface CreateExecutionRuntimeHostCompositionOptions { readonly managedWorkspaceGitRuntime?: VerifiedGitRuntimeInput; + readonly managedWorkspaceDependencyProducer?: ManagedDependencyEnvironmentProducer; } export function runtimeHostFilesystemWorkerRuntime(versions: { @@ -241,6 +243,9 @@ export async function createExecutionRuntimeHostComposition( rootOwner: context.owner, gitRuntime: options.managedWorkspaceGitRuntime, filesystemWorker: managedFilesystemWorker, + ...(options.managedWorkspaceDependencyProducer + ? { dependencyEnvironmentProducer: options.managedWorkspaceDependencyProducer } + : {}), }); } workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ diff --git a/packages/runtime-host/src/server/workspace-execution-composition.ts b/packages/runtime-host/src/server/workspace-execution-composition.ts index 53d80b867d..5bedc2426b 100644 --- a/packages/runtime-host/src/server/workspace-execution-composition.ts +++ b/packages/runtime-host/src/server/workspace-execution-composition.ts @@ -2,6 +2,7 @@ import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { ManagedWorkspaceExecutionHandle, + ManagedWorkspaceExecutionOptions, ManagedWorkspaceFilesystemWorker, ManagedWorkspaceOwner, ManagedWorkspaceReadOnlyOperation, @@ -16,6 +17,7 @@ export type RuntimeHostWorkspaceExecutionProfile = | { readonly kind: 'managed_worktree_v1'; readonly executionHandle: ManagedWorkspaceExecutionHandle; + readonly provisioning: NonNullable; }; export type RuntimeHostWorkspaceExecutionErrorCode = @@ -64,8 +66,13 @@ export function createAttachedWorkspaceExecutionProfile( export function createManagedWorkspaceExecutionProfile( executionHandle: ManagedWorkspaceExecutionHandle, + options: ManagedWorkspaceExecutionOptions = {}, ): RuntimeHostWorkspaceExecutionProfile { - return Object.freeze({ kind: 'managed_worktree_v1', executionHandle }); + return Object.freeze({ + kind: 'managed_worktree_v1', + executionHandle, + provisioning: options.provisioning ?? 'canonical_tree_only_v1', + }); } export function createRuntimeHostWorkspaceExecutionComposition( @@ -126,6 +133,10 @@ export function createRuntimeHostWorkspaceExecutionComposition( profile.executionHandle, (scope) => input.managedOwner!.executeReadOnlyFilesystemOperation(scope, operation, abortSignal), + { + provisioning: profile.provisioning, + ...(abortSignal ? { abortSignal } : {}), + }, ); } if (!input.filesystemWorker) { @@ -171,12 +182,15 @@ function isWorkspaceExecutionProfile( kind?: unknown; cwd?: unknown; executionHandle?: { kind?: unknown }; + provisioning?: unknown; }; if (candidate.kind === 'attached_checkout_v1') { return typeof candidate.cwd === 'string' && candidate.cwd.length > 0; } return ( candidate.kind === 'managed_worktree_v1' && - candidate.executionHandle?.kind === 'managed_workspace_execution_handle_v1' + candidate.executionHandle?.kind === 'managed_workspace_execution_handle_v1' && + (candidate.provisioning === 'canonical_tree_only_v1' || + candidate.provisioning === 'dependency_environment_v1') ); } diff --git a/packages/storage/src/__tests__/fixtures/managed-dependency-environment-crash-child.ts b/packages/storage/src/__tests__/fixtures/managed-dependency-environment-crash-child.ts new file mode 100644 index 0000000000..2acf42d95a --- /dev/null +++ b/packages/storage/src/__tests__/fixtures/managed-dependency-environment-crash-child.ts @@ -0,0 +1,62 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, + type ManagedDependencyEnvironmentFailpoint, +} from '../../managed-dependency-environment.js'; + +const storageRoot = process.env.MAKA_DEPENDENCY_CRASH_ROOT; +const failpoint = process.env.MAKA_DEPENDENCY_CRASH_POINT as + | ManagedDependencyEnvironmentFailpoint + | 'during_environment_provision' + | undefined; +if (!storageRoot || !failpoint) throw new Error('Missing dependency crash fixture input'); + +const producerCapability = createManagedDependencyEnvironmentProducerCapability( + `sha256:${'a'.repeat(64)}`, +); + +const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: producerCapability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producerCapability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, +}; +const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: producerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + await mkdir(joinPath(input.outputRoot, 'fixture-package'), { recursive: true }); + await writeFile(joinPath(input.outputRoot, 'fixture-package', 'index.js'), 'safe\n'); + if (failpoint === 'during_environment_provision') process.exit(73); + }, + }, + failpoint(point) { + if (point === failpoint) process.exit(73); + }, +}); +await authority.acquire(computeManagedDependencyEnvironmentIdentity(source), source); +throw new Error('Crash failpoint was not reached'); + +function joinPath(...parts: string[]): string { + return parts.join(process.platform === 'win32' ? '\\' : '/'); +} diff --git a/packages/storage/src/__tests__/managed-dependency-environment-crash.test.ts b/packages/storage/src/__tests__/managed-dependency-environment-crash.test.ts new file mode 100644 index 0000000000..dc22cbc135 --- /dev/null +++ b/packages/storage/src/__tests__/managed-dependency-environment-crash.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, + type ManagedDependencyEnvironmentFailpoint, +} from '../managed-dependency-environment.js'; + +const execFileAsync = promisify(execFile); +const producerCapability = createManagedDependencyEnvironmentProducerCapability( + `sha256:${'a'.repeat(64)}`, +); +const childEntrypoint = fileURLToPath( + new URL('./fixtures/managed-dependency-environment-crash-child.js', import.meta.url), +); + +for (const failpoint of [ + 'during_environment_provision', + 'after_environment_receipt_durable', + 'after_environment_publish', +] as const satisfies readonly ( + | ManagedDependencyEnvironmentFailpoint + | 'during_environment_provision' +)[]) { + test(`converges after process exit at ${failpoint}`, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-crash-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + await assert.rejects( + execFileAsync(process.execPath, [childEntrypoint], { + env: { + ...process.env, + MAKA_DEPENDENCY_CRASH_ROOT: storageRoot, + MAKA_DEPENDENCY_CRASH_POINT: failpoint, + }, + windowsHide: true, + }), + (error: unknown) => error instanceof Error && 'code' in error && Number(error.code) === 73, + ); + + let provisionCalls = 0; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: producerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + provisionCalls += 1; + await mkdir(join(input.outputRoot, 'fixture-package'), { recursive: true }); + await writeFile(join(input.outputRoot, 'fixture-package', 'index.js'), 'safe\n'); + }, + }, + }); + const source = dependencySource(); + const lease = await authority.acquire( + computeManagedDependencyEnvironmentIdentity(source), + source, + ); + assert.equal( + await readFile(join(lease.dependencyRoot, 'fixture-package', 'index.js'), 'utf8'), + 'safe\n', + ); + assert.equal(provisionCalls, failpoint === 'after_environment_receipt_durable' ? 0 : 1); + assert.deepEqual( + await readdir(join(storageRoot, 'managed-workspaces', 'dependency-environments', '.staging')), + [], + ); + await lease.release(); + await authority.close(); + }); +} + +function dependencySource() { + return { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: producerCapability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producerCapability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; +} diff --git a/packages/storage/src/__tests__/managed-dependency-environment.test.ts b/packages/storage/src/__tests__/managed-dependency-environment.test.ts new file mode 100644 index 0000000000..58d99670d3 --- /dev/null +++ b/packages/storage/src/__tests__/managed-dependency-environment.test.ts @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict'; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, +} from '../managed-dependency-environment.js'; + +const FIXTURE_PRODUCER_RUNTIME_IDENTITY = `sha256:${'a'.repeat(64)}` as const; +const FIXTURE_PRODUCER_CAPABILITY = createManagedDependencyEnvironmentProducerCapability( + FIXTURE_PRODUCER_RUNTIME_IDENTITY, +); + +test('computes one shared environment identity for equivalent dependency inputs', () => { + const input = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: 'linux' as const, + arch: 'x64' as const, + producerRuntimeIdentitySha256: `sha256:${'1'.repeat(64)}` as const, + producerPolicyIdentitySha256: `sha256:${'2'.repeat(64)}` as const, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + + const first = computeManagedDependencyEnvironmentIdentity(input); + const second = computeManagedDependencyEnvironmentIdentity({ ...input }); + + assert.match(first.environmentId, /^sha256:[0-9a-f]{64}$/); + assert.equal(first.environmentId, second.environmentId); + assert.equal(first.manifestSha256, second.manifestSha256); + assert.equal(first.lockfileSha256, second.lockfileSha256); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ ...input, nodeAbi: '138' }).environmentId, + ); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ ...input, platform: 'darwin' }).environmentId, + ); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ + ...input, + producerRuntimeIdentitySha256: `sha256:${'3'.repeat(64)}`, + }).environmentId, + ); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ + ...input, + producerPolicyIdentitySha256: `sha256:${'4'.repeat(64)}`, + }).environmentId, + ); +}); + +test('rejects a producer that does not declare the exact hermetic capability', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-capability-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + + await assert.rejects( + createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: { + ...FIXTURE_PRODUCER_CAPABILITY, + network: 'unrestricted' as never, + }, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision() {}, + }, + }), + /producer capability is invalid/u, + ); +}); + +test('rejects a published environment whose dependency content was modified', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-tamper-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await mkdir(join(input.outputRoot, 'fixture-package'), { recursive: true }); + await writeFile(join(input.outputRoot, 'fixture-package', 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const identityInput = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(identityInput); + const firstAuthority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + const lease = await firstAuthority.acquire(identity, identityInput); + await writeFile(join(lease.dependencyRoot, 'fixture-package', 'index.js'), 'tampered\n', 'utf8'); + await lease.release(); + await firstAuthority.close(); + + const reopened = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + await assert.rejects(reopened.acquire(identity, identityInput), /does not match its receipt/u); + await reopened.close(); +}); + +test('keeps the receipt in a constrained authority outside the producer-owned artifact domain', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-receipt-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await writeFile(join(input.outputRoot, 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + const lease = await authority.acquire(identity, source); + const artifactRoot = dirname(lease.dependencyRoot); + const authorityDatabasePath = join( + storageRoot, + 'managed-workspaces', + 'dependency-environment-authority-v1.sqlite', + ); + await lease.release(); + await assert.rejects(readFile(join(artifactRoot, 'environment-receipt.json'), 'utf8'), { + code: 'ENOENT', + }); + await access(authorityDatabasePath); + const reopened = await authority.acquire(identity, source); + await reopened.release(); + await authority.close(); +}); + +test('rejects a coordinated artifact and co-located receipt rewrite', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-coordinated-tamper-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await writeFile(join(input.outputRoot, 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + const lease = await authority.acquire(identity, source); + const artifactRoot = dirname(lease.dependencyRoot); + await lease.release(); + await authority.close(); + + await writeFile(join(lease.dependencyRoot, 'index.js'), 'malicious\n', 'utf8'); + await writeFile( + join(artifactRoot, 'environment-receipt.json'), + `${JSON.stringify({ environmentId: identity.environmentId, contentTreeSha256: 'forged' })}\n`, + 'utf8', + ); + + const reopened = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + await assert.rejects( + reopened.acquire(identity, source), + /artifact contains an unowned entry|content does not match its receipt/u, + ); + await reopened.close(); +}); + +test('rejects an environment id that is not the digest of the requested identity', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-identity-forgery-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision() {}, + }; + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + await assert.rejects( + authority.acquire({ ...identity, environmentId: `sha256:${'f'.repeat(64)}` }, source), + /identity is not canonical/u, + ); + await assert.rejects( + authority.acquire( + { ...identity, environmentId: 'sha256:../../escaped' } as typeof identity, + source, + ), + /identity is not canonical/u, + ); + await assert.rejects(access(join(storageRoot, 'escaped')), { code: 'ENOENT' }); + await authority.close(); +}); + +test( + 'rejects an NTFS alternate stream created inside a dependency artifact', + { skip: process.platform !== 'win32' }, + async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-ads-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + const target = join(input.outputRoot, 'index.js'); + await writeFile(target, 'trusted\n', 'utf8'); + await writeFile(`${target}:unhashed`, 'malicious\n', 'utf8'); + }, + }; + const source = dependencySourceForName('ads'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + await assert.rejects(authority.acquire(identity, source), /alternate data stream/u); + await authority.close(); + }, +); + +test('publishes one Maka-owned artifact for concurrent equivalent acquisitions', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-environment-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let provisionCalls = 0; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input) { + provisionCalls += 1; + await mkdir(join(input.outputRoot, 'fixture-package'), { recursive: true }); + await writeFile( + join(input.outputRoot, 'fixture-package', 'index.js'), + 'export const source = "maka-owned";\n', + 'utf8', + ); + }, + }, + }); + const identityInput = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(identityInput); + + const [first, second] = await Promise.all([ + authority.acquire(identity, { + manifestBytes: identityInput.manifestBytes, + lockfileBytes: identityInput.lockfileBytes, + }), + authority.acquire(identity, { + manifestBytes: identityInput.manifestBytes, + lockfileBytes: identityInput.lockfileBytes, + }), + ]); + + assert.equal(provisionCalls, 1); + assert.equal(first.environmentId, second.environmentId); + assert.equal(first.dependencyRoot, second.dependencyRoot); + await first.release(); + await second.release(); + await authority.close(); +}); + +test('collects the least-recently-used unleased environment under the cache quota', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-gc-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let provisionCalls = 0; + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string; identity: { lockfileSha256: string } }) { + provisionCalls += 1; + await writeFile(input.outputRoot + '/payload', input.identity.lockfileSha256.slice(-8)); + }, + }; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + maxCacheBytes: 8, + }); + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3,"name":"first"}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const firstIdentity = computeManagedDependencyEnvironmentIdentity(source); + const first = await authority.acquire(firstIdentity, source); + await first.release(); + + const secondSource = { + ...source, + lockfileBytes: Buffer.from('{"lockfileVersion":3,"name":"second"}\n'), + }; + const secondIdentity = computeManagedDependencyEnvironmentIdentity(secondSource); + const second = await authority.acquire(secondIdentity, secondSource); + await second.release(); + + const firstAgain = await authority.acquire(firstIdentity, source); + assert.equal(provisionCalls, 3); + await firstAgain.release(); + await authority.close(); +}); + +test('does not collect a published environment while its acquisition is still pending', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-pending-gc-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let releasePendingPublish!: () => void; + const pendingPublish = new Promise((resolve) => { + releasePendingPublish = resolve; + }); + let acknowledgeReceiptDurable!: () => void; + const receiptDurable = new Promise((resolve) => { + acknowledgeReceiptDurable = resolve; + }); + let pendingDigest: string | undefined; + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string; identity: { lockfileSha256: string } }) { + await writeFile(join(input.outputRoot, 'payload'), input.identity.lockfileSha256, 'utf8'); + }, + }; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + maxCacheBytes: 0, + async failpoint(point) { + if (point === 'after_environment_receipt_durable' && pendingDigest) { + acknowledgeReceiptDurable(); + await pendingPublish; + } + }, + }); + const firstSource = dependencySourceForName('first'); + const firstIdentity = computeManagedDependencyEnvironmentIdentity(firstSource); + const first = await authority.acquire(firstIdentity, firstSource); + const secondSource = dependencySourceForName('second'); + const secondIdentity = computeManagedDependencyEnvironmentIdentity(secondSource); + pendingDigest = secondIdentity.environmentId; + const secondTask = authority.acquire(secondIdentity, secondSource); + await receiptDurable; + await first.release(); + releasePendingPublish(); + const second = await secondTask; + assert.equal(await readFile(join(second.dependencyRoot, 'payload'), 'utf8'), secondIdentity.lockfileSha256); + await second.release(); + await authority.close(); +}); + +function dependencySourceForName(name: string) { + return { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from(`{"lockfileVersion":3,"name":"${name}"}\n`), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; +} + +function fixtureNodeRuntime() { + return { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + } as const; +} diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index c53075142c..3678011ca9 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -2,13 +2,14 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; -import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; import { afterEach, before, test } from 'node:test'; import { + createManagedDependencyEnvironmentProducerCapability, ManagedWorkspaceOwnerError, openManagedWorkspaceOwner, type ManagedWorkspaceExecutionHandle, @@ -29,6 +30,9 @@ const { } = managedWorkspaceExecutionAuthorityTestSupport; let gitExecutablePath: string; let gitExecutableSha256: `sha256:${string}`; +const fixtureDependencyProducerCapability = createManagedDependencyEnvironmentProducerCapability( + `sha256:${'a'.repeat(64)}`, +); before(async () => { gitExecutablePath = await findGitExecutable(); @@ -208,6 +212,255 @@ test('publishes only a revocable execution scope through its accepted handle', a } }); +test('fails closed before scope issue when dependency provisioning is required but unavailable', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + let callbackEntered = false; + + await assert.rejects( + owner.withManagedWorkspaceExecution( + accepted.executionHandle, + async () => { + callbackEntered = true; + }, + { provisioning: 'dependency_environment_v1' }, + ), + isOwnerError('managed_dependency_producer_unavailable'), + ); + assert.equal(callbackEntered, false); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('provisions one Maka-owned dependency environment without reading source node_modules', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleDependencySource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + let provisionCalls = 0; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + dependencyEnvironmentProducer: { + capability: fixtureDependencyProducerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: process.versions.node, + abi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + provisionCalls += 1; + await writeFile( + join(dirname(input.outputRoot), 'producer-staging-only.txt'), + 'must never enter the managed worktree\n', + 'utf8', + ); + await mkdir(join(input.outputRoot, 'fixture-package'), { recursive: true }); + await writeFile( + join(input.outputRoot, 'fixture-package', 'index.js'), + 'export const source = "maka-owned";\n', + 'utf8', + ); + }, + }, + filesystemWorker: { + async execute(input) { + assert.equal(input.operation.kind, 'read'); + assert.equal(input.operation.path.startsWith(sourceRoot), false); + return { kind: 'read', content: await readFile(input.operation.path, 'utf8') }; + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + const managedWorktreePath = inspectManagedWorkspaceExecutionHandleInternal( + accepted.executionHandle, + ).binding.worktreePath; + + for (let index = 0; index < 2; index += 1) { + const content = await owner.withManagedWorkspaceExecution( + accepted.executionHandle, + async (scope) => { + const state = inspectManagedWorkspaceExecutionScopeInternal(scope); + assert.equal(state.provisioning, 'dependency_environment_v1'); + return await owner.executeReadOnlyFilesystemOperation(scope, { + kind: 'read', + path: 'node_modules/fixture-package/index.js', + }); + }, + { provisioning: 'dependency_environment_v1' }, + ); + assert.deepEqual(content, { + kind: 'read', + content: 'export const source = "maka-owned";\n', + }); + } + + assert.equal(provisionCalls, 1); + assert.equal( + await git(managedWorktreePath, 'status', '--porcelain=v1', '--untracked-files=all'), + '', + ); + assert.equal(existsSync(join(managedWorktreePath, 'producer-staging-only.txt')), false); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('revalidates managed worktree drift after dependency provisioning and before scope issue', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleDependencySource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + let managedWorktreePath = ''; + let callbackEntered = false; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + dependencyEnvironmentProducer: { + capability: fixtureDependencyProducerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: process.versions.node, + abi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + await writeFile(join(input.outputRoot, 'dependency.txt'), 'safe\n'); + await writeFile(join(managedWorktreePath, 'README.md'), 'external drift\n'); + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + managedWorktreePath = inspectManagedWorkspaceExecutionHandleInternal(accepted.executionHandle) + .binding.worktreePath; + + await assert.rejects( + owner.withManagedWorkspaceExecution( + accepted.executionHandle, + async () => { + callbackEntered = true; + }, + { provisioning: 'dependency_environment_v1' }, + ), + isOwnerError('managed_workspace_quarantined'), + ); + assert.equal(callbackEntered, false); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects dependency provisioning when the canonical tree tracks node_modules', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleDependencySource(join(root, 'source')); + await git(sourceRoot, 'add', '-f', 'node_modules/fixture-package/index.js'); + await git( + sourceRoot, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'track dependency path', + ); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + let provisionCalls = 0; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + dependencyEnvironmentProducer: { + capability: fixtureDependencyProducerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: process.versions.node, + abi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }, + async provision() { + provisionCalls += 1; + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + + await assert.rejects( + owner.withManagedWorkspaceExecution(accepted.executionHandle, async () => undefined, { + provisioning: 'dependency_environment_v1', + }), + isOwnerError('managed_dependency_manifest_unsupported'), + ); + assert.equal(provisionCalls, 0); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + test('does not let caller-mutated head state or a shadowed public reader forge execution authority', async () => { const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); @@ -933,6 +1186,44 @@ async function createEligibleSource(sourceRoot: string): Promise { return realpath(sourceRoot); } +async function createEligibleDependencySource(sourceRoot: string): Promise { + const canonicalSource = await createEligibleSource(sourceRoot); + await writeFile( + join(canonicalSource, 'package.json'), + '{"name":"fixture","packageManager":"npm@11.12.1"}\n', + 'utf8', + ); + await writeFile( + join(canonicalSource, 'package-lock.json'), + '{"name":"fixture","lockfileVersion":3,"packages":{}}\n', + 'utf8', + ); + await writeFile( + join(canonicalSource, '.gitignore'), + '.maka-workspace.json\nnode_modules/\n', + 'utf8', + ); + await git(canonicalSource, 'add', 'package.json', 'package-lock.json', '.gitignore'); + await git( + canonicalSource, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'add dependency manifest', + ); + await mkdir(join(canonicalSource, 'node_modules', 'fixture-package'), { recursive: true }); + await writeFile( + join(canonicalSource, 'node_modules', 'fixture-package', 'index.js'), + 'export const source = "attached-checkout";\n', + 'utf8', + ); + return canonicalSource; +} + function openRequest(sourceRoot: string) { return { repositoryId: 'repository_11111111111111111111111111111111', diff --git a/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts b/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts index 9b08defe88..23e4d46069 100644 --- a/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { join } from 'node:path'; import test from 'node:test'; import { createReadOnlyPermissionProfile } from '@maka/core'; import { @@ -20,6 +21,17 @@ function scopeFor(ownerToken: object) { }); } +function dependencyScopeFor(ownerToken: object, dependencyRoot: string) { + return issueManagedWorkspaceExecutionScopeInternal(ownerToken, { + provisioning: 'dependency_environment_v1', + workspaceEffect: 'none', + cwd: '/managed/worktree', + dependencyRoot, + binding: Object.freeze({}) as never, + head: Object.freeze({}) as never, + }); +} + test('injects the owner-bound cwd and read-only boundary for allowed operations', async () => { const ownerToken = {}; const calls: unknown[] = []; @@ -103,3 +115,77 @@ test('rejects foreign, expired, mutating, and unknown operations before worker d } assert.equal(dispatches, 0); }); + +test('routes relative and cwd-absolute dependency paths through the owner-bound environment', async () => { + const ownerToken = {}; + const dependencyRoot = join('/maka', 'dependency-environment', 'node_modules'); + const routedPaths: string[] = []; + const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + async execute(input) { + routedPaths.push(input.operation.path); + if (input.operation.kind === 'glob') { + return { kind: 'glob', files: ['fixture/index.js'] }; + } + return { kind: 'read', content: 'trusted' }; + }, + }); + const scope = dependencyScopeFor(ownerToken, dependencyRoot); + + await bridge.execute(scope, { kind: 'read', path: 'node_modules/fixture/index.js' }); + await bridge.execute(scope, { + kind: 'read', + path: join('/managed/worktree', 'node_modules', 'fixture', 'index.js'), + }); + const glob = await bridge.execute(scope, { + kind: 'glob', + path: 'node_modules', + pattern: '**/*.js', + }); + + assert.deepEqual(routedPaths, [ + join(dependencyRoot, 'fixture', 'index.js'), + join(dependencyRoot, 'fixture', 'index.js'), + dependencyRoot, + ]); + assert.deepEqual(glob, { kind: 'glob', files: ['node_modules/fixture/index.js'] }); +}); + +test('rejects dependency traversal before worker dispatch', async () => { + const ownerToken = {}; + let dispatches = 0; + const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + async execute() { + dispatches += 1; + return { kind: 'read', content: 'unexpected' }; + }, + }); + const scope = dependencyScopeFor(ownerToken, join('/maka', 'environment', 'node_modules')); + + await assert.rejects( + bridge.execute(scope, { kind: 'read', path: 'node_modules/../outside.txt' }), + (error) => + error instanceof ManagedWorkspaceWorkerBridgeError && + error.code === 'managed_workspace_operation_denied', + ); + assert.equal(dispatches, 0); +}); + +test('rejects Windows alternate-stream syntax before dependency worker dispatch', async () => { + const ownerToken = {}; + let dispatches = 0; + const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + async execute() { + dispatches += 1; + return { kind: 'read', content: 'unexpected' }; + }, + }); + const scope = dependencyScopeFor(ownerToken, join('/maka', 'environment', 'node_modules')); + + await assert.rejects( + bridge.execute(scope, { kind: 'read', path: 'node_modules/fixture/index.js:unhashed' }), + (error) => + error instanceof ManagedWorkspaceWorkerBridgeError && + error.code === 'managed_workspace_operation_denied', + ); + assert.equal(dispatches, 0); +}); diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index a1d07e2e6c..486ce1b55d 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -11,7 +11,7 @@ import { rm, stat, } from 'node:fs/promises'; -import { dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; import { withArtifactWriterLock } from './artifact-writer-lock.js'; import { bundledGitEnvironment } from './dugite-native-environment.js'; @@ -261,6 +261,8 @@ export interface GitWorkspaceService { input: ManagedWorkspaceIdentity, ): Promise; inspectManagedWorkspace(binding: ManagedWorkspaceBinding): Promise; + hasManagedWorkspaceBaselinePath(binding: ManagedWorkspaceBinding, path: string): Promise; + readManagedWorkspaceBaselineFile(binding: ManagedWorkspaceBinding, path: string): Promise; quarantineManagedWorkspace( binding: ManagedWorkspaceBinding, reason: string, @@ -570,6 +572,87 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } + async readManagedWorkspaceBaselineFile( + binding: ManagedWorkspaceBinding, + path: string, + ): Promise { + const runtime = await this.runtime.verify(); + assertBindingShape(binding); + assertOpenIdentity(binding); + const trackedPath = assertManagedTrackedPath(path); + return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + assertBindingPaths(binding, layout); + const quarantined = await this.resumePendingQuarantine(binding, layout, runtime.digest); + if (quarantined) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace instance was quarantined: ${binding.workspaceInstanceId}`, + ); + } + const stored = await readBinding(layout.bindingPath); + if (!stored || !sameBinding(stored, binding)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace binding is unavailable: ${binding.workspaceInstanceId}`, + ); + } + const repository = await this.requireRepository(binding, layout); + assertBindingRepository(binding, repository); + const epoch = await this.requireEpochArtifact(binding, repository, layout); + assertBindingEpoch(binding, epoch); + return await this.runtime.runBuffer( + [ + '--git-dir', + binding.repositoryPath, + 'show', + `${binding.baselineCommitOid}:${trackedPath}`, + ], + layout.homePath, + ); + }); + } + + async hasManagedWorkspaceBaselinePath( + binding: ManagedWorkspaceBinding, + path: string, + ): Promise { + const runtime = await this.runtime.verify(); + assertBindingShape(binding); + assertOpenIdentity(binding); + const trackedPath = assertManagedTrackedPath(path); + return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + assertBindingPaths(binding, layout); + const stored = await readBinding(layout.bindingPath); + if (!stored || !sameBinding(stored, binding)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace binding is unavailable: ${binding.workspaceInstanceId}`, + ); + } + const repository = await this.requireRepository(binding, layout); + assertBindingRepository(binding, repository); + const epoch = await this.requireEpochArtifact(binding, repository, layout); + assertBindingEpoch(binding, epoch); + const output = await this.runtime.runBuffer( + [ + '--git-dir', + binding.repositoryPath, + 'ls-tree', + '-z', + binding.baselineCommitOid, + '--', + trackedPath, + ], + layout.homePath, + ); + return output.byteLength > 0; + }); + } + async #requireManagedWorkspaceBaselineReceipt( input: CreateManagedWorkspaceFromSourceInput, ): Promise { @@ -2625,6 +2708,26 @@ function managedHeadRef(workspaceId: string, workspaceEpochId: string): string { return `refs/maka/workspaces/${workspaceId}/epochs/${workspaceEpochId}/head`; } +function assertManagedTrackedPath(path: string): string { + const normalized = path.replaceAll('\\', '/'); + if ( + !normalized || + normalized.includes('\0') || + normalized.includes(':') || + normalized.startsWith('/') || + normalized === '..' || + normalized.startsWith('../') || + normalized.includes('/../') || + posix.normalize(normalized) !== normalized + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed workspace tracked file path is invalid', + ); + } + return normalized; +} + function managedBaselineRef(workspaceEpochId: string): string { return `refs/maka/baselines/${workspaceEpochId}`; } diff --git a/packages/storage/src/managed-dependency-environment.ts b/packages/storage/src/managed-dependency-environment.ts new file mode 100644 index 0000000000..880716f8e0 --- /dev/null +++ b/packages/storage/src/managed-dependency-environment.ts @@ -0,0 +1,1078 @@ +import { execFile } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { createRequire } from 'node:module'; +import { + lstat, + mkdir, + open, + readFile, + readdir, + readlink, + realpath, + rename, + rm, + stat, + utimes, + writeFile, +} from 'node:fs/promises'; +import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; + +const MANAGED_DEPENDENCY_IDENTITY_DOMAIN = 'maka.managed_dependency_environment.v1\0'; +const MANAGED_DEPENDENCY_TREE_DOMAIN = 'maka.managed_dependency_environment.tree.v1\0'; +const AUTHORITY_DATABASE_NAME = 'dependency-environment-authority-v1.sqlite'; +const DEPENDENCY_ROOT_NAME = 'node_modules'; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const MANAGED_DEPENDENCY_PRODUCER_POLICY_DOMAIN = + 'maka.managed_dependency_environment.producer_policy.v1\0'; +const MANAGED_DEPENDENCY_PRODUCER_POLICY_V1 = Object.freeze({ + protocolVersion: 1 as const, + kind: 'hermetic_dependency_builder_v1' as const, + network: 'registry_https_only' as const, + filesystem: 'maka_owned_staging_only' as const, + secrets: 'none' as const, + childProcess: 'verified_runtime_only' as const, + lifecycleScripts: 'disabled' as const, +}); +const RECEIPT_KEYS = [ + 'protocolVersion', + 'environmentId', + 'manifestPath', + 'manifestSha256', + 'lockfilePath', + 'lockfileSha256', + 'packageManagerName', + 'packageManagerVersion', + 'nodeVersion', + 'nodeAbi', + 'platform', + 'arch', + 'producerRuntimeIdentitySha256', + 'producerPolicyIdentitySha256', + 'policyVersion', + 'dependencyRootName', + 'contentTreeSha256', + 'contentBytes', + 'contentEntries', +] as const; +const require = createRequire(import.meta.url); + +export type ManagedDependencyPackageManager = 'npm' | 'pnpm' | 'yarn'; + +export interface ComputeManagedDependencyEnvironmentIdentityInput { + readonly manifestPath: string; + readonly manifestBytes: Uint8Array; + readonly lockfilePath: string; + readonly lockfileBytes: Uint8Array; + readonly packageManagerName: ManagedDependencyPackageManager; + readonly packageManagerVersion: string; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly producerRuntimeIdentitySha256: `sha256:${string}`; + readonly producerPolicyIdentitySha256: `sha256:${string}`; + readonly policyVersion: 'managed_dependency_environment_v1'; +} + +export interface ManagedDependencyEnvironmentIdentityV1 { + readonly protocolVersion: 1; + readonly environmentId: `sha256:${string}`; + readonly manifestPath: string; + readonly manifestSha256: `sha256:${string}`; + readonly lockfilePath: string; + readonly lockfileSha256: `sha256:${string}`; + readonly packageManagerName: ManagedDependencyPackageManager; + readonly packageManagerVersion: string; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly producerRuntimeIdentitySha256: `sha256:${string}`; + readonly producerPolicyIdentitySha256: `sha256:${string}`; + readonly policyVersion: 'managed_dependency_environment_v1'; +} + +export interface ManagedDependencyEnvironmentProducerCapabilityV1 { + readonly protocolVersion: 1; + readonly kind: 'hermetic_dependency_builder_v1'; + readonly runtimeIdentitySha256: `sha256:${string}`; + readonly policyIdentitySha256: `sha256:${string}`; + readonly network: 'registry_https_only'; + readonly filesystem: 'maka_owned_staging_only'; + readonly secrets: 'none'; + readonly childProcess: 'verified_runtime_only'; + readonly lifecycleScripts: 'disabled'; +} + +export interface ManagedDependencyEnvironmentProducerInput { + readonly identity: ManagedDependencyEnvironmentIdentityV1; + readonly outputRoot: string; + readonly scratchRoot: string; + readonly manifestBytes: Uint8Array; + readonly lockfileBytes: Uint8Array; + readonly abortSignal?: AbortSignal; +} + +export interface ManagedDependencyEnvironmentProducer { + readonly capability: ManagedDependencyEnvironmentProducerCapabilityV1; + readonly packageManagerName: ManagedDependencyPackageManager; + readonly packageManagerVersion: string; + readonly nodeRuntime: { + readonly version: string; + readonly abi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + }; + provision(input: ManagedDependencyEnvironmentProducerInput): Promise; +} + +export function createManagedDependencyEnvironmentProducerCapability( + runtimeIdentitySha256: `sha256:${string}`, +): ManagedDependencyEnvironmentProducerCapabilityV1 { + if (!SHA256_PATTERN.test(runtimeIdentitySha256)) { + throw new TypeError('Managed dependency producer runtime identity must be a SHA-256 digest'); + } + return Object.freeze({ + ...MANAGED_DEPENDENCY_PRODUCER_POLICY_V1, + runtimeIdentitySha256, + policyIdentitySha256: managedDependencyProducerPolicyIdentity(), + }); +} + +export interface CreateManagedDependencyEnvironmentAuthorityInput { + readonly storageRoot: string; + readonly producer: ManagedDependencyEnvironmentProducer; + readonly maxCacheBytes?: number; + readonly failpoint?: (point: ManagedDependencyEnvironmentFailpoint) => void | Promise; +} + +export type ManagedDependencyEnvironmentFailpoint = + | 'after_environment_receipt_durable' + | 'after_environment_publish'; + +export interface AcquireManagedDependencyEnvironmentInput { + readonly manifestBytes: Uint8Array; + readonly lockfileBytes: Uint8Array; + readonly abortSignal?: AbortSignal; +} + +export interface ManagedDependencyEnvironmentLease { + readonly environmentId: `sha256:${string}`; + readonly dependencyRoot: string; + release(): Promise; +} + +export interface ManagedDependencyEnvironmentAuthority { + acquire( + identity: ManagedDependencyEnvironmentIdentityV1, + input: AcquireManagedDependencyEnvironmentInput, + ): Promise; + close(): Promise; +} + +interface ManagedDependencyEnvironmentReceiptV1 extends ManagedDependencyEnvironmentIdentityV1 { + readonly dependencyRootName: typeof DEPENDENCY_ROOT_NAME; + readonly contentTreeSha256: `sha256:${string}`; + readonly contentBytes: number; + readonly contentEntries: number; +} + +interface PublishedManagedDependencyEnvironment { + readonly receipt: ManagedDependencyEnvironmentReceiptV1; + readonly dependencyRoot: string; +} + +interface DependencyReceiptAuthority { + read(digest: string): ManagedDependencyEnvironmentReceiptV1 | undefined; + list(): readonly ManagedDependencyEnvironmentReceiptV1[]; + write(receipt: ManagedDependencyEnvironmentReceiptV1): void; + delete(digest: string): void; + close(): void; +} + +function openDependencyReceiptAuthority(path: string): DependencyReceiptAuthority { + const Database = (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; + const database: DatabaseSync = new Database(path); + database.exec('PRAGMA synchronous = FULL'); + const version = Number( + (database.prepare('PRAGMA user_version').get() as { user_version?: unknown }).user_version, + ); + if (version !== 0 && version !== 1) { + database.close(); + throw new Error(`Unsupported managed dependency receipt authority version ${version}`); + } + database.exec(` + BEGIN IMMEDIATE; + CREATE TABLE IF NOT EXISTS managed_dependency_environment_receipts ( + environment_digest TEXT PRIMARY KEY NOT NULL CHECK ( + length(environment_digest) = 64 AND + environment_digest NOT GLOB '*[^0-9a-f]*' + ), + receipt_json TEXT NOT NULL + ) STRICT; + PRAGMA user_version = 1; + COMMIT; + `); + let closed = false; + const assertOpen = () => { + if (closed) throw new Error('Managed dependency receipt authority is closed'); + }; + return Object.freeze({ + read(digest: string) { + assertOpen(); + requireDigest(digest); + const row = database + .prepare( + 'SELECT receipt_json FROM managed_dependency_environment_receipts WHERE environment_digest = ?', + ) + .get(digest) as { receipt_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.receipt_json !== 'string') { + throw new Error('Managed dependency receipt authority contains an invalid row'); + } + return decodeReceipt(JSON.parse(row.receipt_json)); + }, + list() { + assertOpen(); + return Object.freeze( + ( + database + .prepare( + 'SELECT environment_digest, receipt_json FROM managed_dependency_environment_receipts ORDER BY environment_digest', + ) + .all() as Array<{ environment_digest?: unknown; receipt_json?: unknown }> + ).map((row) => { + if ( + typeof row.environment_digest !== 'string' || + typeof row.receipt_json !== 'string' + ) { + throw new Error('Managed dependency receipt authority contains an invalid row'); + } + const receipt = decodeReceipt(JSON.parse(row.receipt_json)); + if (receipt.environmentId !== `sha256:${row.environment_digest}`) { + throw new Error('Managed dependency receipt row does not match its payload identity'); + } + return receipt; + }), + ); + }, + write(receipt: ManagedDependencyEnvironmentReceiptV1) { + assertOpen(); + const digest = receipt.environmentId.slice('sha256:'.length); + requireDigest(digest); + database.exec('BEGIN IMMEDIATE'); + try { + database + .prepare( + 'INSERT INTO managed_dependency_environment_receipts (environment_digest, receipt_json) VALUES (?, ?)', + ) + .run(digest, JSON.stringify(receipt)); + database.exec('COMMIT'); + } catch (error) { + database.exec('ROLLBACK'); + throw error; + } + }, + delete(digest: string) { + assertOpen(); + requireDigest(digest); + database + .prepare( + 'DELETE FROM managed_dependency_environment_receipts WHERE environment_digest = ?', + ) + .run(digest); + }, + close() { + if (closed) return; + closed = true; + database.close(); + }, + }); +} + +function requireDigest(value: string): void { + if (!/^[0-9a-f]{64}$/u.test(value)) { + throw new Error('Managed dependency receipt digest is invalid'); + } +} + +export function computeManagedDependencyEnvironmentIdentity( + input: ComputeManagedDependencyEnvironmentIdentityInput, +): ManagedDependencyEnvironmentIdentityV1 { + const manifestPath = normalizeTrackedPath(input.manifestPath, 'manifestPath'); + const lockfilePath = normalizeTrackedPath(input.lockfilePath, 'lockfilePath'); + const manifestSha256 = sha256(input.manifestBytes); + const lockfileSha256 = sha256(input.lockfileBytes); + assertIdentityText(input.packageManagerVersion, 'packageManagerVersion'); + assertIdentityText(input.nodeVersion, 'nodeVersion'); + assertIdentityText(input.nodeAbi, 'nodeAbi'); + assertIdentityText(input.platform, 'platform'); + assertIdentityText(input.arch, 'arch'); + assertSha256(input.producerRuntimeIdentitySha256, 'producerRuntimeIdentitySha256'); + assertSha256(input.producerPolicyIdentitySha256, 'producerPolicyIdentitySha256'); + + const canonicalIdentity = JSON.stringify({ + manifestPath, + manifestSha256, + lockfilePath, + lockfileSha256, + packageManagerName: input.packageManagerName, + packageManagerVersion: input.packageManagerVersion, + nodeVersion: input.nodeVersion, + nodeAbi: input.nodeAbi, + platform: input.platform, + arch: input.arch, + producerRuntimeIdentitySha256: input.producerRuntimeIdentitySha256, + producerPolicyIdentitySha256: input.producerPolicyIdentitySha256, + policyVersion: input.policyVersion, + }); + const environmentId = sha256( + Buffer.concat([ + Buffer.from(MANAGED_DEPENDENCY_IDENTITY_DOMAIN, 'utf8'), + Buffer.from(canonicalIdentity, 'utf8'), + ]), + ); + + return Object.freeze({ + protocolVersion: 1, + environmentId, + manifestPath, + manifestSha256, + lockfilePath, + lockfileSha256, + packageManagerName: input.packageManagerName, + packageManagerVersion: input.packageManagerVersion, + nodeVersion: input.nodeVersion, + nodeAbi: input.nodeAbi, + platform: input.platform, + arch: input.arch, + producerRuntimeIdentitySha256: input.producerRuntimeIdentitySha256, + producerPolicyIdentitySha256: input.producerPolicyIdentitySha256, + policyVersion: input.policyVersion, + }); +} + +export async function createManagedDependencyEnvironmentAuthority( + input: CreateManagedDependencyEnvironmentAuthorityInput, +): Promise { + assertProducerCapability(input.producer.capability); + const canonicalStorageRoot = await realpath(input.storageRoot).catch(async () => { + await mkdir(input.storageRoot, { recursive: true }); + return await realpath(input.storageRoot); + }); + const environmentsRoot = join( + canonicalStorageRoot, + 'managed-workspaces', + 'dependency-environments', + ); + const authorityDatabasePath = join( + canonicalStorageRoot, + 'managed-workspaces', + AUTHORITY_DATABASE_NAME, + ); + const stagingRoot = join(environmentsRoot, '.staging'); + const maxCacheBytes = input.maxCacheBytes ?? 2 * 1024 * 1024 * 1024; + if (!Number.isSafeInteger(maxCacheBytes) || maxCacheBytes < 0) { + throw new TypeError('Managed dependency cache quota must be a non-negative safe integer'); + } + await Promise.all([ + ensureOwnedDirectory(environmentsRoot, canonicalStorageRoot), + ]); + await ensureOwnedDirectory(stagingRoot, environmentsRoot); + await cleanupOrphanStaging(stagingRoot); + const receiptAuthority = openDependencyReceiptAuthority(authorityDatabasePath); + try { + await cleanupIncompletePublications(environmentsRoot, receiptAuthority); + } catch (error) { + receiptAuthority.close(); + throw error; + } + const inflight = new Map>(); + const leaseCounts = new Map(); + const pendingCounts = new Map(); + let closed = false; + let gcTask = Promise.resolve(); + + const authority: ManagedDependencyEnvironmentAuthority = { + async acquire(identity, source) { + if (closed) throw new Error('Managed dependency environment authority is closed'); + assertCanonicalIdentity(identity, source); + if ( + identity.packageManagerName !== input.producer.packageManagerName || + identity.packageManagerVersion !== input.producer.packageManagerVersion || + identity.nodeVersion !== input.producer.nodeRuntime.version || + identity.nodeAbi !== input.producer.nodeRuntime.abi || + identity.platform !== input.producer.nodeRuntime.platform || + identity.arch !== input.producer.nodeRuntime.arch || + identity.producerRuntimeIdentitySha256 !== + input.producer.capability.runtimeIdentitySha256 || + identity.producerPolicyIdentitySha256 !== input.producer.capability.policyIdentitySha256 + ) { + throw new Error('Managed dependency producer does not match the requested identity'); + } + assertSourceMatchesIdentity(identity, source); + const digest = identity.environmentId.slice('sha256:'.length); + pendingCounts.set(digest, (pendingCounts.get(digest) ?? 0) + 1); + let artifact: PublishedManagedDependencyEnvironment; + try { + let task = inflight.get(digest); + if (!task) { + task = openOrPublishEnvironment({ + environmentsRoot, + receiptAuthority, + stagingRoot, + identity, + source, + producer: input.producer, + failpoint: input.failpoint, + }).finally(() => inflight.delete(digest)); + inflight.set(digest, task); + } + artifact = await task; + const now = new Date(); + await utimes(dirname(artifact.dependencyRoot), now, now); + leaseCounts.set(digest, (leaseCounts.get(digest) ?? 0) + 1); + } finally { + decrementCount(pendingCounts, digest); + } + let released = false; + return Object.freeze({ + environmentId: identity.environmentId, + dependencyRoot: artifact.dependencyRoot, + async release() { + if (released) return; + released = true; + const remaining = (leaseCounts.get(digest) ?? 1) - 1; + if (remaining > 0) leaseCounts.set(digest, remaining); + else leaseCounts.delete(digest); + gcTask = gcTask.catch(() => undefined).then(() => + collectEnvironmentGarbage({ + environmentsRoot, + receiptAuthority, + maxCacheBytes, + leaseCounts, + pendingCounts, + protectedDigest: digest, + }), + ); + await gcTask; + }, + }); + }, + async close() { + if (closed) return; + closed = true; + await Promise.allSettled(inflight.values()); + let gcError: unknown; + try { + await gcTask; + } catch (error) { + gcError = error; + } + if (leaseCounts.size > 0) { + closed = false; + throw new Error('Managed dependency environment authority still has active leases'); + } + receiptAuthority.close(); + if (gcError) throw gcError; + }, + }; + return Object.freeze(authority); +} + +async function openOrPublishEnvironment(input: { + readonly environmentsRoot: string; + readonly receiptAuthority: DependencyReceiptAuthority; + readonly stagingRoot: string; + readonly identity: ManagedDependencyEnvironmentIdentityV1; + readonly source: AcquireManagedDependencyEnvironmentInput; + readonly producer: ManagedDependencyEnvironmentProducer; + readonly failpoint?: (point: ManagedDependencyEnvironmentFailpoint) => void | Promise; +}): Promise { + const digest = input.identity.environmentId.slice('sha256:'.length); + const artifactRoot = publicationPath(input.environmentsRoot, digest); + const existing = await openPublishedEnvironment( + artifactRoot, + input.environmentsRoot, + input.receiptAuthority, + input.identity, + ); + if (existing) return existing; + + const transactionRoot = join(input.stagingRoot, `${digest}-${randomUUID()}`); + const producerRoot = join(transactionRoot, 'producer'); + const projectRoot = join(producerRoot, 'project'); + const producerOutputRoot = join(projectRoot, DEPENDENCY_ROOT_NAME); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const artifactStagingRoot = join(transactionRoot, 'artifact'); + const dependencyRoot = join(artifactStagingRoot, DEPENDENCY_ROOT_NAME); + await Promise.all([ + mkdir(producerOutputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + mkdir(artifactStagingRoot, { recursive: true }), + ]); + try { + await input.producer.provision({ + identity: input.identity, + outputRoot: producerOutputRoot, + scratchRoot, + manifestBytes: input.source.manifestBytes, + lockfileBytes: input.source.lockfileBytes, + ...(input.source.abortSignal ? { abortSignal: input.source.abortSignal } : {}), + }); + const canonicalOutput = await realpath(producerOutputRoot); + if (!isPathWithin(canonicalOutput, producerRoot)) { + throw new Error('Managed dependency producer output escapes its staging authority'); + } + await rename(producerOutputRoot, dependencyRoot); + await rm(producerRoot, { recursive: true, force: true }); + const content = await hashDependencyTree(dependencyRoot); + const receipt: ManagedDependencyEnvironmentReceiptV1 = Object.freeze({ + ...input.identity, + dependencyRootName: DEPENDENCY_ROOT_NAME, + contentTreeSha256: content.sha256, + contentBytes: content.bytes, + contentEntries: content.entries, + }); + await syncDirectory(artifactStagingRoot); + await rename(artifactStagingRoot, artifactRoot); + await syncDirectory(input.environmentsRoot); + await input.failpoint?.('after_environment_publish'); + input.receiptAuthority.write(receipt); + await input.failpoint?.('after_environment_receipt_durable'); + await rm(transactionRoot, { recursive: true, force: true }); + return await requirePublishedEnvironment( + artifactRoot, + input.environmentsRoot, + input.receiptAuthority, + input.identity, + ); + } catch (error) { + await rm(transactionRoot, { recursive: true, force: true }).catch(() => undefined); + const raced = await openPublishedEnvironment( + artifactRoot, + input.environmentsRoot, + input.receiptAuthority, + input.identity, + ); + if (raced) return raced; + const receiptExists = input.receiptAuthority.read(digest) !== undefined; + if (!receiptExists) { + await rm(artifactRoot, { recursive: true, force: true }).catch(() => undefined); + } + throw error; + } +} + +async function cleanupOrphanStaging(stagingRoot: string): Promise { + const entries = await readdir(stagingRoot, { withFileTypes: true }); + for (const entry of entries) { + const path = join(stagingRoot, entry.name); + const info = await lstat(path); + if (!entry.isDirectory() || info.isSymbolicLink()) { + throw new Error('Managed dependency staging contains an unowned entry'); + } + await rm(path, { recursive: true, force: true }); + } +} + +async function ensureOwnedDirectory(path: string, parentRoot: string): Promise { + await mkdir(path, { recursive: true }); + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Managed dependency authority path is not an owned directory'); + } + const canonical = normalize(await realpath(path)); + const canonicalParent = normalize(await realpath(parentRoot)); + if (!isPathWithin(canonical, canonicalParent)) { + throw new Error('Managed dependency authority path escapes its storage root'); + } + return canonical; +} + +async function cleanupIncompletePublications( + environmentsRoot: string, + receiptAuthority: DependencyReceiptAuthority, +): Promise { + const receipts = new Set(); + for (const receipt of receiptAuthority.list()) { + const digest = receipt.environmentId.slice('sha256:'.length); + if (receipt.environmentId !== `sha256:${digest}`) { + throw new Error('Managed dependency authority receipt has the wrong identity'); + } + receipts.add(digest); + } + const artifacts = new Set(); + for (const entry of await readdir(environmentsRoot, { withFileTypes: true })) { + if (entry.name === '.staging') continue; + if (!entry.isDirectory() || !/^[0-9a-f]{64}$/u.test(entry.name)) { + throw new Error('Managed dependency cache contains an unowned entry'); + } + const info = await lstat(join(environmentsRoot, entry.name)); + if (info.isSymbolicLink()) { + throw new Error('Managed dependency cache contains a reparse point'); + } + artifacts.add(entry.name); + } + for (const digest of artifacts) { + if (!receipts.has(digest)) { + await rm(join(environmentsRoot, digest), { recursive: true, force: true }); + } + } + for (const digest of receipts) { + if (!artifacts.has(digest)) { + receiptAuthority.delete(digest); + } + } +} + +function publicationPath(environmentsRoot: string, digest: string) { + if (!/^[0-9a-f]{64}$/u.test(digest)) { + throw new Error('Managed dependency environment identity is not a canonical SHA-256 digest'); + } + const artifactRoot = join(environmentsRoot, digest); + if (!isPathWithin(artifactRoot, environmentsRoot)) { + throw new Error('Managed dependency publication path escapes its authority root'); + } + return artifactRoot; +} + +async function openPublishedEnvironment( + artifactRoot: string, + environmentsRoot: string, + receiptAuthority: DependencyReceiptAuthority, + identity: ManagedDependencyEnvironmentIdentityV1, +): Promise { + try { + return await requirePublishedEnvironment( + artifactRoot, + environmentsRoot, + receiptAuthority, + identity, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +async function requirePublishedEnvironment( + artifactRoot: string, + environmentsRoot: string, + receiptAuthority: DependencyReceiptAuthority, + identity: ManagedDependencyEnvironmentIdentityV1, +): Promise { + const artifactInfo = await lstat(artifactRoot); + if (!artifactInfo.isDirectory() || artifactInfo.isSymbolicLink()) { + throw new Error('Managed dependency environment artifact root is not an owned directory'); + } + const canonicalArtifactRoot = normalize(await realpath(artifactRoot)); + if (!isPathWithin(canonicalArtifactRoot, environmentsRoot)) { + throw new Error('Managed dependency environment artifact escapes its authority root'); + } + const artifactEntries = await readdir(artifactRoot); + if (artifactEntries.length !== 1 || artifactEntries[0] !== DEPENDENCY_ROOT_NAME) { + throw new Error('Managed dependency environment artifact contains an unowned entry'); + } + const receipt = receiptAuthority.read(identity.environmentId.slice('sha256:'.length)); + if (!receipt) throw Object.assign(new Error('Managed dependency receipt is unavailable'), { code: 'ENOENT' }); + if (!sameIdentity(receipt, identity)) { + throw new Error('Managed dependency environment receipt identity does not match the request'); + } + const dependencyRoot = join(artifactRoot, receipt.dependencyRootName); + const dependencyInfo = await lstat(dependencyRoot); + if (!dependencyInfo.isDirectory() || dependencyInfo.isSymbolicLink()) { + throw new Error('Managed dependency environment content root is unavailable'); + } + const content = await hashDependencyTree(dependencyRoot); + if ( + content.sha256 !== receipt.contentTreeSha256 || + content.bytes !== receipt.contentBytes || + content.entries !== receipt.contentEntries + ) { + throw new Error('Managed dependency environment content does not match its receipt'); + } + return Object.freeze({ receipt, dependencyRoot: await realpath(dependencyRoot) }); +} + +function assertSourceMatchesIdentity( + identity: ManagedDependencyEnvironmentIdentityV1, + input: AcquireManagedDependencyEnvironmentInput, +): void { + if ( + sha256(input.manifestBytes) !== identity.manifestSha256 || + sha256(input.lockfileBytes) !== identity.lockfileSha256 + ) { + throw new Error('Managed dependency source bytes do not match the requested identity'); + } +} + +function assertCanonicalIdentity( + identity: ManagedDependencyEnvironmentIdentityV1, + source: AcquireManagedDependencyEnvironmentInput, +): void { + const expected = computeManagedDependencyEnvironmentIdentity({ + manifestPath: identity.manifestPath, + manifestBytes: source.manifestBytes, + lockfilePath: identity.lockfilePath, + lockfileBytes: source.lockfileBytes, + packageManagerName: identity.packageManagerName, + packageManagerVersion: identity.packageManagerVersion, + nodeVersion: identity.nodeVersion, + nodeAbi: identity.nodeAbi, + platform: identity.platform, + arch: identity.arch, + producerRuntimeIdentitySha256: identity.producerRuntimeIdentitySha256, + producerPolicyIdentitySha256: identity.producerPolicyIdentitySha256, + policyVersion: identity.policyVersion, + }); + if (!sameEnvironmentIdentity(expected, identity)) { + throw new Error('Managed dependency environment identity is not canonical'); + } +} + +async function hashDependencyTree( + root: string, +): Promise<{ + readonly sha256: `sha256:${string}`; + readonly bytes: number; + readonly entries: number; +}> { + const hash = createHash('sha256'); + const counter = { bytes: 0, entries: 0 }; + hash.update(MANAGED_DEPENDENCY_TREE_DOMAIN); + await hashDirectory(root, '', hash, counter); + if (process.platform === 'win32') await assertNoWindowsAlternateStreams(root); + return Object.freeze({ + sha256: `sha256:${hash.digest('hex')}`, + bytes: counter.bytes, + entries: counter.entries, + }); +} + +async function hashDirectory( + root: string, + relativeRoot: string, + hash: ReturnType, + counter: { bytes: number; entries: number }, +) { + const directory = relativeRoot ? join(root, relativeRoot) : root; + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => Buffer.from(left.name).compare(Buffer.from(right.name))); + for (const entry of entries) { + if (entry.name.includes(':') || entry.name.includes('\0')) { + throw new Error('Managed dependency environment contains a non-portable path'); + } + counter.entries += 1; + const relativePath = relativeRoot ? join(relativeRoot, entry.name) : entry.name; + const portablePath = relativePath.replaceAll('\\', '/'); + const absolutePath = join(root, relativePath); + const info = await lstat(absolutePath); + const mode = process.platform === 'win32' ? 0 : info.mode & 0o777; + if (entry.isDirectory()) { + hash.update(`d\0${portablePath}\0${mode}\0`); + await hashDirectory(root, relativePath, hash, counter); + continue; + } + if (entry.isFile()) { + hash.update(`f\0${portablePath}\0${mode}\0${info.size}\0`); + counter.bytes += info.size; + for await (const chunk of createReadStream(absolutePath)) hash.update(chunk as Buffer); + hash.update('\0'); + continue; + } + if (entry.isSymbolicLink()) { + if (process.platform === 'win32') { + throw new Error('Managed dependency environment contains a Windows reparse point'); + } + const target = await readlink(absolutePath); + if (isAbsolute(target) || !isPathWithin(resolve(dirname(absolutePath), target), root)) { + throw new Error('Managed dependency environment contains an escaping symbolic link'); + } + hash.update(`l\0${portablePath}\0${target.replaceAll('\\', '/')}\0`); + continue; + } + throw new Error('Managed dependency environment contains an unsupported filesystem entry'); + } +} + +async function assertNoWindowsAlternateStreams(root: string): Promise { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (!systemRoot) { + throw new Error('Cannot verify Windows alternate streams without SystemRoot'); + } + const powershell = join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + const script = [ + '$ErrorActionPreference = "Stop"', + '$root = $env:MAKA_ADS_ROOT', + '$items = @((Get-Item -LiteralPath $root -Force)) + @(Get-ChildItem -LiteralPath $root -Force -Recurse)', + 'foreach ($item in $items) {', + ' $streams = @(Get-Item -LiteralPath $item.FullName -Stream * -ErrorAction SilentlyContinue)', + ' foreach ($stream in $streams) {', + " if ($stream.Stream -ne ':$DATA') { throw 'alternate data stream detected' }", + ' }', + '}', + ].join('; '); + await new Promise((resolvePromise, rejectPromise) => { + execFile( + powershell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + windowsHide: true, + timeout: 30_000, + env: { SystemRoot: systemRoot, WINDIR: systemRoot, MAKA_ADS_ROOT: root }, + }, + (error) => { + if (error) { + rejectPromise( + new Error('Managed dependency environment contains an alternate data stream', { + cause: error, + }), + ); + } else { + resolvePromise(); + } + }, + ); + }); +} + +function isPathWithin(candidate: string, root: string): boolean { + const path = relative(normalize(root), normalize(candidate)); + return path === '' || (!path.startsWith('..') && !isAbsolute(path)); +} + +function decodeReceipt(value: unknown): ManagedDependencyEnvironmentReceiptV1 { + if (!value || typeof value !== 'object') throw new Error('Invalid dependency receipt'); + const receipt = value as Partial; + const keys = Object.keys(value).sort(); + const expectedKeys = [...RECEIPT_KEYS].sort(); + if ( + keys.length !== expectedKeys.length || + keys.some((key, index) => key !== expectedKeys[index]) || + receipt.protocolVersion !== 1 || + receipt.dependencyRootName !== DEPENDENCY_ROOT_NAME || + typeof receipt.environmentId !== 'string' || + !SHA256_PATTERN.test(receipt.environmentId) || + typeof receipt.contentTreeSha256 !== 'string' || + !SHA256_PATTERN.test(receipt.contentTreeSha256) || + typeof receipt.contentBytes !== 'number' || + !Number.isSafeInteger(receipt.contentBytes) || + receipt.contentBytes < 0 || + typeof receipt.contentEntries !== 'number' || + !Number.isSafeInteger(receipt.contentEntries) || + receipt.contentEntries < 0 || + typeof receipt.manifestSha256 !== 'string' || + !SHA256_PATTERN.test(receipt.manifestSha256) || + typeof receipt.lockfileSha256 !== 'string' || + !SHA256_PATTERN.test(receipt.lockfileSha256) || + typeof receipt.manifestPath !== 'string' || + typeof receipt.lockfilePath !== 'string' || + (receipt.packageManagerName !== 'npm' && + receipt.packageManagerName !== 'pnpm' && + receipt.packageManagerName !== 'yarn') || + typeof receipt.packageManagerVersion !== 'string' || + typeof receipt.nodeVersion !== 'string' || + typeof receipt.nodeAbi !== 'string' || + typeof receipt.platform !== 'string' || + typeof receipt.arch !== 'string' || + typeof receipt.producerRuntimeIdentitySha256 !== 'string' || + !SHA256_PATTERN.test(receipt.producerRuntimeIdentitySha256) || + typeof receipt.producerPolicyIdentitySha256 !== 'string' || + !SHA256_PATTERN.test(receipt.producerPolicyIdentitySha256) || + receipt.policyVersion !== 'managed_dependency_environment_v1' + ) { + throw new Error('Invalid dependency receipt'); + } + return Object.freeze(receipt as ManagedDependencyEnvironmentReceiptV1); +} + +async function collectEnvironmentGarbage(input: { + readonly environmentsRoot: string; + readonly receiptAuthority: DependencyReceiptAuthority; + readonly maxCacheBytes: number; + readonly leaseCounts: ReadonlyMap; + readonly pendingCounts: ReadonlyMap; + readonly protectedDigest: string; +}): Promise { + const artifacts: Array<{ + readonly digest: string; + readonly root: string; + readonly bytes: number; + readonly lastUsedMs: number; + }> = []; + for (const entry of await readdir(input.environmentsRoot, { withFileTypes: true })) { + if (entry.name === '.staging') continue; + if (!entry.isDirectory() || !/^[0-9a-f]{64}$/u.test(entry.name)) { + throw new Error('Managed dependency cache contains an unowned entry'); + } + const root = join(input.environmentsRoot, entry.name); + const receipt = input.receiptAuthority.read(entry.name); + if (!receipt) throw new Error('Managed dependency cache is missing its authority receipt'); + if (receipt.environmentId !== `sha256:${entry.name}`) { + throw new Error('Managed dependency cache directory does not match its receipt'); + } + artifacts.push({ + digest: entry.name, + root, + // Empty files and directories consume filesystem metadata even when + // contentBytes is zero. Charge one conservative 4 KiB unit per entry so + // inode-only trees cannot bypass the cache quota. + bytes: receipt.contentBytes + receipt.contentEntries * 4_096, + lastUsedMs: (await stat(root)).mtimeMs, + }); + } + let totalBytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0); + artifacts.sort( + (left, right) => left.lastUsedMs - right.lastUsedMs || left.digest.localeCompare(right.digest), + ); + for (const artifact of artifacts) { + if (totalBytes <= input.maxCacheBytes) break; + if ( + artifact.digest === input.protectedDigest || + input.leaseCounts.has(artifact.digest) || + input.pendingCounts.has(artifact.digest) + ) + continue; + await rm(artifact.root, { recursive: true, force: true }); + input.receiptAuthority.delete(artifact.digest); + totalBytes -= artifact.bytes; + } +} + +function sameIdentity( + receipt: ManagedDependencyEnvironmentReceiptV1, + identity: ManagedDependencyEnvironmentIdentityV1, +): boolean { + return ( + receipt.environmentId === identity.environmentId && + receipt.manifestPath === identity.manifestPath && + receipt.manifestSha256 === identity.manifestSha256 && + receipt.lockfilePath === identity.lockfilePath && + receipt.lockfileSha256 === identity.lockfileSha256 && + receipt.packageManagerName === identity.packageManagerName && + receipt.packageManagerVersion === identity.packageManagerVersion && + receipt.nodeVersion === identity.nodeVersion && + receipt.nodeAbi === identity.nodeAbi && + receipt.platform === identity.platform && + receipt.arch === identity.arch && + receipt.producerRuntimeIdentitySha256 === identity.producerRuntimeIdentitySha256 && + receipt.producerPolicyIdentitySha256 === identity.producerPolicyIdentitySha256 && + receipt.policyVersion === identity.policyVersion + ); +} + +function sameEnvironmentIdentity( + left: ManagedDependencyEnvironmentIdentityV1, + right: ManagedDependencyEnvironmentIdentityV1, +): boolean { + return ( + left.protocolVersion === right.protocolVersion && + left.environmentId === right.environmentId && + left.manifestPath === right.manifestPath && + left.manifestSha256 === right.manifestSha256 && + left.lockfilePath === right.lockfilePath && + left.lockfileSha256 === right.lockfileSha256 && + left.packageManagerName === right.packageManagerName && + left.packageManagerVersion === right.packageManagerVersion && + left.nodeVersion === right.nodeVersion && + left.nodeAbi === right.nodeAbi && + left.platform === right.platform && + left.arch === right.arch && + left.producerRuntimeIdentitySha256 === right.producerRuntimeIdentitySha256 && + left.producerPolicyIdentitySha256 === right.producerPolicyIdentitySha256 && + left.policyVersion === right.policyVersion + ); +} + +function decrementCount(counts: Map, key: string): void { + const remaining = (counts.get(key) ?? 1) - 1; + if (remaining > 0) counts.set(key, remaining); + else counts.delete(key); +} + +async function syncFile(path: string): Promise { + // Windows rejects fsync on a read-only file handle. The receipt was just + // created by this owner, so reopen it read/write solely for durability. + const handle = await open(path, 'r+'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } catch (error) { + if (process.platform !== 'win32') throw error; + } finally { + await handle.close(); + } +} + +function sha256(value: Uint8Array): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function normalizeTrackedPath(value: string, field: string): string { + assertIdentityText(value, field); + const normalized = posix.normalize(value.replaceAll('\\', '/')); + if ( + normalized === '.' || + normalized.startsWith('/') || + normalized === '..' || + normalized.startsWith('../') + ) { + throw new TypeError(`${field} must be a workspace-relative tracked path`); + } + return normalized; +} + +function assertIdentityText(value: string, field: string): void { + if (!value || value.includes('\0')) { + throw new TypeError(`${field} must be non-empty text without NUL bytes`); + } +} + +function assertSha256(value: string, field: string): void { + if (!SHA256_PATTERN.test(value)) { + throw new TypeError(`${field} must be a SHA-256 digest`); + } +} + +function managedDependencyProducerPolicyIdentity(): `sha256:${string}` { + return sha256( + Buffer.concat([ + Buffer.from(MANAGED_DEPENDENCY_PRODUCER_POLICY_DOMAIN, 'utf8'), + Buffer.from(JSON.stringify(MANAGED_DEPENDENCY_PRODUCER_POLICY_V1), 'utf8'), + ]), + ); +} + +function assertProducerCapability( + capability: ManagedDependencyEnvironmentProducerCapabilityV1, +): void { + const expected = createManagedDependencyEnvironmentProducerCapability( + capability.runtimeIdentitySha256, + ); + if ( + Object.keys(capability).sort().join('\0') !== Object.keys(expected).sort().join('\0') || + Object.entries(expected).some( + ([key, value]) => + capability[key as keyof ManagedDependencyEnvironmentProducerCapabilityV1] !== value, + ) + ) { + throw new Error('Managed dependency producer capability is invalid'); + } +} diff --git a/packages/storage/src/managed-workspace-execution-authority-internal.ts b/packages/storage/src/managed-workspace-execution-authority-internal.ts index d82308ea33..f994d2b130 100644 --- a/packages/storage/src/managed-workspace-execution-authority-internal.ts +++ b/packages/storage/src/managed-workspace-execution-authority-internal.ts @@ -41,9 +41,10 @@ const states = new WeakMap< >(); export interface ManagedWorkspaceExecutionScopeStateInternal { - readonly provisioning: 'canonical_tree_only_v1'; + readonly provisioning: 'canonical_tree_only_v1' | 'dependency_environment_v1'; readonly workspaceEffect: 'none'; readonly cwd: string; + readonly dependencyRoot?: string; readonly binding: Readonly; readonly head: Readonly; } diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts index 37d7f25b19..e27e29eafb 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -28,6 +28,14 @@ import { type ManagedWorkspaceReadOnlyResult, type ManagedWorkspaceWorkerBridgeInternal, } from './managed-workspace-worker-bridge-internal.js'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentProducerCapability, + createManagedDependencyEnvironmentAuthority, + type ManagedDependencyEnvironmentAuthority, + type ManagedDependencyEnvironmentProducer, + type ManagedDependencyEnvironmentProducerInput, +} from './managed-dependency-environment.js'; import type { RuntimeWorkspaceVersionAuthorityStore, WorkspaceHeadRecordV1 } from '@maka/core'; import { assertInteractiveRootOwner, @@ -54,6 +62,9 @@ export type ManagedWorkspaceOwnerErrorCode = | 'managed_workspace_owner_closing' | 'managed_workspace_owner_reentrant_close' | 'managed_workspace_worker_unavailable' + | 'managed_dependency_producer_unavailable' + | 'managed_dependency_manifest_unsupported' + | 'managed_dependency_provision_failed' | 'managed_workspace_quarantined' | 'managed_workspace_execution_handle_invalid'; @@ -73,6 +84,7 @@ export interface OpenManagedWorkspaceOwnerInput { readonly gitRuntime: VerifiedGitRuntimeInput; readonly failpoint?: (point: ManagedWorkspaceOwnerFailpoint) => void | Promise; readonly filesystemWorker?: ManagedWorkspaceFilesystemWorker; + readonly dependencyEnvironmentProducer?: ManagedDependencyEnvironmentProducer; } export type ManagedWorkspaceOwnerFailpoint = @@ -90,6 +102,11 @@ export interface OpenManagedWorkspaceBaselineResult { readonly executionHandle: ManagedWorkspaceExecutionHandle; } +export interface ManagedWorkspaceExecutionOptions { + readonly provisioning?: 'canonical_tree_only_v1' | 'dependency_environment_v1'; + readonly abortSignal?: AbortSignal; +} + export interface ManagedWorkspaceOwner { readonly state: 'ready' | 'closing' | 'closed'; openManagedWorkspaceBaseline( @@ -99,6 +116,7 @@ export interface ManagedWorkspaceOwner { withManagedWorkspaceExecution( handle: ManagedWorkspaceExecutionHandle, operation: (scope: ManagedWorkspaceExecutionScope) => Promise, + options?: ManagedWorkspaceExecutionOptions, ): Promise; executeReadOnlyFilesystemOperation( scope: ManagedWorkspaceExecutionScope, @@ -109,6 +127,8 @@ export interface ManagedWorkspaceOwner { } export type { + ManagedDependencyEnvironmentProducer, + ManagedDependencyEnvironmentProducerInput, ManagedWorkspaceExecutionHandle, ManagedWorkspaceExecutionScope, ManagedWorkspaceFilesystemWorker, @@ -117,6 +137,8 @@ export type { VerifiedGitRuntimeInput, }; +export { createManagedDependencyEnvironmentProducerCapability }; + const owners = new WeakMap(); export async function openManagedWorkspaceOwner( @@ -146,12 +168,20 @@ export async function openManagedWorkspaceOwner( // flight. Revalidate after the lease-bound operation so a stale lifecycle // owner is never published as ready. await assertInteractiveRootOwner(rootOwner); + const dependencyAuthority = input.dependencyEnvironmentProducer + ? await createManagedDependencyEnvironmentAuthority({ + storageRoot: rootOwner.capability.canonicalPath, + producer: input.dependencyEnvironmentProducer, + }) + : undefined; const owner = new ManagedWorkspaceOwnerImpl( rootOwner, service, requireManagedBaselineReceiptAuthorityInternal(service), input.failpoint, input.filesystemWorker, + input.dependencyEnvironmentProducer, + dependencyAuthority, ); owners.set(rootOwner, owner); return owner; @@ -182,6 +212,8 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { private readonly receiptAuthority: ManagedBaselineReceiptAuthorityInternal, private readonly failpoint?: (point: ManagedWorkspaceOwnerFailpoint) => void | Promise, filesystemWorker?: ManagedWorkspaceFilesystemWorker, + private readonly dependencyProducer?: ManagedDependencyEnvironmentProducer, + private readonly dependencyAuthority?: ManagedDependencyEnvironmentAuthority, ) { // Capture the identity guard while the lease is active. Unlike a fresh // admission check, this guard remains valid for an already-admitted @@ -296,6 +328,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { async withManagedWorkspaceExecution( handle: ManagedWorkspaceExecutionHandle, operation: (scope: ManagedWorkspaceExecutionScope) => Promise, + options: ManagedWorkspaceExecutionOptions = {}, ): Promise { return this.#run(async () => { let accepted; @@ -311,6 +344,15 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { { cause: error }, ); } + if ( + options.provisioning === 'dependency_environment_v1' && + (!this.dependencyProducer || !this.dependencyAuthority) + ) { + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_producer_unavailable', + 'Managed dependency provisioning requires an explicitly configured producer', + ); + } assertExecutionCrossPlaneIdentity(accepted.binding, accepted.receipt, accepted.head); // Test builds with a failpoint retain a preliminary proof so crash tests // can stop after a real, completed artifact verification. The ordinary @@ -320,46 +362,56 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { await this.failpoint('after_execution_artifact_verification'); } - // The root write lease excludes cooperating Maka writers across this - // proof bundle. Verify the durable Git artifact first, then make the - // immutable workspace head the final durable reread before scope issue. - await this.#assertCurrentRootIdentity(); - await this.#verifyExecutionArtifactOrQuarantine(accepted.binding, accepted.receipt); - const currentHead = await readWorkspaceHeadInternal( - accepted.store, - accepted.binding.workspaceId, - accepted.binding.workspaceEpochId, - ); - // Rebind only after the head read. This catches a database pathname - // detach at the admission boundary without putting a mutable DB guard - // ahead of the durable head evidence it protects. - await assertWorkspaceBaselineAuthorityStoreRootInternal( - accepted.store, - this.rootOwner.capability.canonicalPath, - ); - bindWorkspaceBaselineAuthorityStoreRootInternal( - accepted.store, - this.rootOwner.capability.rootId, - ); - if (!currentHead || !sameWorkspaceHead(currentHead, accepted.head)) { - throw new ManagedWorkspaceOwnerError( - 'managed_workspace_owner_unavailable', - 'Managed workspace execution handle no longer matches the canonical workspace head', - ); - } - assertExecutionCrossPlaneIdentity(accepted.binding, accepted.receipt, currentHead); const binding = accepted.binding; - const scope = issueManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, { - provisioning: 'canonical_tree_only_v1', - workspaceEffect: 'none', - cwd: binding.worktreePath, - binding: Object.freeze({ ...binding }), - head: freezeWorkspaceHead(currentHead), - }); + const provisioning = options.provisioning ?? 'canonical_tree_only_v1'; + const dependencyLease = + provisioning === 'dependency_environment_v1' + ? await this.#acquireDependencyEnvironment(binding, options.abortSignal) + : undefined; try { - return await this.#executionContext.run(this.#executionOwnerToken, () => operation(scope)); + // Provisioning can take minutes. The root write lease excludes + // cooperating Maka writers, but not an editor or other external + // process, so the durable Git artifact and workspace head must be the + // final proof bundle immediately before scope issue. + await this.#assertCurrentRootIdentity(); + await this.#verifyExecutionArtifactOrQuarantine(accepted.binding, accepted.receipt); + const currentHead = await readWorkspaceHeadInternal( + accepted.store, + accepted.binding.workspaceId, + accepted.binding.workspaceEpochId, + ); + await assertWorkspaceBaselineAuthorityStoreRootInternal( + accepted.store, + this.rootOwner.capability.canonicalPath, + ); + bindWorkspaceBaselineAuthorityStoreRootInternal( + accepted.store, + this.rootOwner.capability.rootId, + ); + if (!currentHead || !sameWorkspaceHead(currentHead, accepted.head)) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed workspace execution handle no longer matches the canonical workspace head', + ); + } + assertExecutionCrossPlaneIdentity(accepted.binding, accepted.receipt, currentHead); + const scope = issueManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, { + provisioning, + workspaceEffect: 'none', + cwd: binding.worktreePath, + ...(dependencyLease ? { dependencyRoot: dependencyLease.dependencyRoot } : {}), + binding: Object.freeze({ ...binding }), + head: freezeWorkspaceHead(currentHead), + }); + try { + return await this.#executionContext.run(this.#executionOwnerToken, () => + operation(scope), + ); + } finally { + revokeManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, scope); + } } finally { - revokeManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, scope); + await dependencyLease?.release(); } }); } @@ -390,6 +442,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { this.#closeTask ??= (async () => { this.#state = 'closing'; await this.#waitForDrain(); + await this.dependencyAuthority?.close(); this.#state = 'closed'; })(); return this.#closeTask; @@ -482,6 +535,95 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ); } } + + async #acquireDependencyEnvironment( + binding: ManagedWorkspaceBinding, + abortSignal?: AbortSignal, + ) { + if (!this.dependencyProducer || !this.dependencyAuthority) { + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_producer_unavailable', + 'Managed dependency provisioning requires an explicitly configured producer', + ); + } + try { + if (await this.service.hasManagedWorkspaceBaselinePath(binding, 'node_modules')) { + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_manifest_unsupported', + 'Managed dependency provisioning requires node_modules to be absent from the canonical tree', + ); + } + const [manifestBytes, lockfileBytes] = await Promise.all([ + this.service.readManagedWorkspaceBaselineFile(binding, 'package.json'), + this.service.readManagedWorkspaceBaselineFile(binding, 'package-lock.json'), + ]); + const packageManager = readExactPackageManager(manifestBytes); + if ( + packageManager.name !== this.dependencyProducer.packageManagerName || + packageManager.version !== this.dependencyProducer.packageManagerVersion + ) { + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_manifest_unsupported', + 'Managed dependency producer does not match the exact packageManager declaration', + ); + } + const identity = computeManagedDependencyEnvironmentIdentity({ + manifestPath: 'package.json', + manifestBytes, + lockfilePath: 'package-lock.json', + lockfileBytes, + packageManagerName: packageManager.name, + packageManagerVersion: packageManager.version, + nodeVersion: this.dependencyProducer.nodeRuntime.version, + nodeAbi: this.dependencyProducer.nodeRuntime.abi, + platform: this.dependencyProducer.nodeRuntime.platform, + arch: this.dependencyProducer.nodeRuntime.arch, + producerRuntimeIdentitySha256: this.dependencyProducer.capability.runtimeIdentitySha256, + producerPolicyIdentitySha256: this.dependencyProducer.capability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1', + }); + return await this.dependencyAuthority.acquire(identity, { + manifestBytes, + lockfileBytes, + ...(abortSignal ? { abortSignal } : {}), + }); + } catch (error) { + if (error instanceof ManagedWorkspaceOwnerError) throw error; + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_provision_failed', + 'Unable to provision the managed dependency environment', + { cause: error }, + ); + } + } +} + +function readExactPackageManager(bytes: Uint8Array): { + readonly name: 'npm'; + readonly version: string; +} { + let value: unknown; + try { + value = JSON.parse(Buffer.from(bytes).toString('utf8')); + } catch (error) { + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_manifest_unsupported', + 'Managed dependency package.json is not valid JSON', + { cause: error }, + ); + } + const packageManager = + value && typeof value === 'object' + ? (value as { packageManager?: unknown }).packageManager + : undefined; + const match = typeof packageManager === 'string' ? /^npm@([^\s]+)$/u.exec(packageManager) : null; + if (!match?.[1]) { + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_manifest_unsupported', + 'Managed dependency v1 requires an exact npm packageManager declaration', + ); + } + return Object.freeze({ name: 'npm', version: match[1] }); } function sameWorkspaceHead(left: WorkspaceHeadRecordV1, right: WorkspaceHeadRecordV1): boolean { diff --git a/packages/storage/src/managed-workspace-worker-bridge-internal.ts b/packages/storage/src/managed-workspace-worker-bridge-internal.ts index 1ac225a0ee..21fd130e08 100644 --- a/packages/storage/src/managed-workspace-worker-bridge-internal.ts +++ b/packages/storage/src/managed-workspace-worker-bridge-internal.ts @@ -3,6 +3,7 @@ import { createReadOnlyPermissionProfile, type ExecutionBoundary, } from '@maka/core'; +import { isAbsolute, join, normalize, relative, sep } from 'node:path'; import { requireManagedWorkspaceExecutionScopeInternal, type ManagedWorkspaceExecutionScope, @@ -101,23 +102,131 @@ export function createManagedWorkspaceWorkerBridgeInternal( ); } const state = requireManagedWorkspaceExecutionScopeInternal(ownerToken, scope); - if (state.workspaceEffect !== 'none' || state.provisioning !== 'canonical_tree_only_v1') { + if (state.workspaceEffect !== 'none') { throw new ManagedWorkspaceWorkerBridgeError( 'managed_workspace_operation_denied', 'Managed workspace execution scope does not permit filesystem mutation', ); } - return await worker.execute({ - operation, + const route = routeDependencyOperation(operation, state.cwd, state.dependencyRoot); + const baseProfile = createReadOnlyPermissionProfile(); + const profile = state.dependencyRoot + ? { + ...baseProfile, + name: 'custom' as const, + fileSystem: { + ...baseProfile.fileSystem, + entries: [ + ...baseProfile.fileSystem.entries, + { + kind: 'path' as const, + access: 'read' as const, + path: state.dependencyRoot, + match: 'subtree' as const, + }, + ], + }, + } + : baseProfile; + const result = await worker.execute({ + operation: route.operation, cwd: state.cwd, - executionBoundary: createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0), + executionBoundary: createManagedExecutionBoundary(profile, 0), ...(abortSignal ? { abortSignal } : {}), }); + return remapDependencyResult(result, state.dependencyRoot, route.provenance); }, }; return Object.freeze(bridge); } +function routeDependencyOperation( + operation: ManagedWorkspaceReadOnlyOperation, + cwd: string, + dependencyRoot: string | undefined, +): { + readonly operation: ManagedWorkspaceReadOnlyOperation; + readonly provenance: 'workspace' | 'dependency'; +} { + if (!dependencyRoot) return Object.freeze({ operation, provenance: 'workspace' }); + let segments: string[]; + if (isAbsolute(operation.path)) { + const logicalDependencyRoot = join(cwd, 'node_modules'); + const suffix = relative(logicalDependencyRoot, operation.path); + if (suffix.startsWith('..') || isAbsolute(suffix)) { + return Object.freeze({ operation, provenance: 'workspace' }); + } + segments = suffix === '' ? [] : suffix.split(/[\\/]/u); + } else { + const portable = operation.path.replaceAll('\\', '/'); + const portableSegments = portable.split('/'); + if (portableSegments[0] !== 'node_modules') { + return Object.freeze({ operation, provenance: 'workspace' }); + } + segments = portableSegments.slice(1); + } + if ( + segments.some( + (segment) => + segment === '..' || + segment === '' || + segment.includes(':') || + segment.includes('\0'), + ) + ) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed dependency path is invalid', + ); + } + const routedPath = join(dependencyRoot, ...segments); + if (!isPathWithin(routedPath, dependencyRoot)) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed dependency path escapes its environment', + ); + } + return Object.freeze({ + operation: Object.freeze({ ...operation, path: routedPath }), + provenance: 'dependency' as const, + }); +} + +function remapDependencyResult( + result: ManagedWorkspaceReadOnlyResult, + dependencyRoot: string | undefined, + provenance: 'workspace' | 'dependency', +): ManagedWorkspaceReadOnlyResult { + if (!dependencyRoot || provenance !== 'dependency') return result; + if (result.kind === 'grep') { + return Object.freeze({ + kind: 'grep', + matches: result.matches.map((match) => remapDependencyPath(match, dependencyRoot)), + }); + } + if (result.kind === 'glob') { + return Object.freeze({ + kind: 'glob', + files: result.files.map((path) => remapDependencyPath(path, dependencyRoot)), + }); + } + return result; +} + +function remapDependencyPath(path: string, dependencyRoot: string): string { + if (!isAbsolute(path)) { + return `node_modules/${path.replaceAll('\\', '/')}`; + } + const prefix = normalize(dependencyRoot); + if (path !== prefix && !path.startsWith(`${prefix}${sep}`)) return path; + return `node_modules${path.slice(prefix.length)}`.replaceAll('\\', '/'); +} + +function isPathWithin(candidate: string, root: string): boolean { + const path = relative(normalize(root), normalize(candidate)); + return path === '' || (!path.startsWith('..') && !isAbsolute(path)); +} + function isReadOnlyOperation(input: unknown): input is ManagedWorkspaceReadOnlyOperation { if (!input || typeof input !== 'object') return false; const kind = (input as { kind?: unknown }).kind; diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index f5add9714c..f516c5db20 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -33,6 +33,7 @@ const WORKSPACE_PREFIX = '@maka/'; const ALLOWED_LICENSES = new Set([ '0BSD', 'Apache-2.0', + 'Artistic-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BlueOak-1.0.0', @@ -148,6 +149,40 @@ function collectDesktopClosure() { ); } +function collectBundledNpmClosure() { + const tree = JSON.parse( + execFileSync( + 'npm', + ['ls', 'npm', '--all', '--json'], + npmSpawnOptions({ + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }), + ), + ); + const bundledNpm = tree.dependencies?.npm; + if (!bundledNpm || bundledNpm.version !== '12.0.2') { + throw new Error('npm ls did not return the pinned bundled npm 12.0.2 runtime'); + } + const packages = new Map(); + const visit = (name, dependency) => { + if (!dependency || typeof dependency !== 'object') return; + if (!name.startsWith(WORKSPACE_PREFIX) && typeof dependency.version === 'string') { + packages.set(`${name}@${dependency.version}`, { name, version: dependency.version }); + } + for (const [childName, child] of Object.entries(dependency.dependencies ?? {})) { + visit(childName, child); + } + }; + visit('npm', bundledNpm); + // The release runtime replaces npm's bundled tar 7.5.19 with the pinned + // 7.5.22 security update during prepare:bundled-npm. + packages.delete('tar@7.5.19'); + packages.set('tar@7.5.22', { name: 'tar', version: '7.5.22' }); + return [...packages.values()]; +} + function packageNameFromLockPath(lockPath) { const marker = 'node_modules/'; const index = lockPath.lastIndexOf(marker); @@ -212,7 +247,14 @@ function overrideLicenseText(packageKey, selectedLicense) { function renderNotice() { const lockIndex = buildLockIndex(); const sections = []; - for (const dependency of collectDesktopClosure()) { + const closure = new Map(); + for (const dependency of [...collectDesktopClosure(), ...collectBundledNpmClosure()]) { + closure.set(`${dependency.name}@${dependency.version}`, dependency); + } + for (const dependency of [...closure.values()].sort( + (left, right) => + left.name.localeCompare(right.name) || left.version.localeCompare(right.version), + )) { const packageKey = `${dependency.name}@${dependency.version}`; const candidates = lockIndex.get(packageKey); if (!candidates?.length) throw new Error(`${packageKey}: missing from package-lock.json`); @@ -260,7 +302,8 @@ function renderNotice() { ==================================================== Generated by scripts/generate-third-party-notices.mjs from the exact -@maka/desktop production dependency closure and package-lock.json. +@maka/desktop production dependency closure, the pinned bundled npm runtime, +and package-lock.json. Do not edit this file by hand. Policy: every package must resolve to an ASF-compatible SPDX license. Compound diff --git a/scripts/macos-arm64-release.test.mjs b/scripts/macos-arm64-release.test.mjs index a0be9714b2..e25a189f2b 100644 --- a/scripts/macos-arm64-release.test.mjs +++ b/scripts/macos-arm64-release.test.mjs @@ -44,6 +44,7 @@ test('release tooling fails closed on unsupported hosts, signing, and architectu requirePath: async () => {}, forbidPath: async () => {}, smokeRenderer: async () => {}, + smokeBundledNpm: async () => {}, }), /arm64/, ); @@ -81,6 +82,7 @@ test('the packaged app is checked for every unsigned helper that could still be forbidden.push(path); }, smokeRenderer: async () => {}, + smokeBundledNpm: async () => {}, }), /arm64/, ); diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 5f08206447..16fc683a3a 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -73,6 +73,9 @@ export async function packageMacosArm64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); await run('npm', ['run', 'prepare:bundled-git']); + await run('npm', ['run', 'prepare:bundled-npm']); + await run('npm', ['run', 'audit:bundled-npm']); + await run('npm', ['run', 'verify:bundled-npm']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:macos-arm64']); diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 4ea84a30c4..b53feb077f 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -67,6 +67,9 @@ export async function packageWindowsX64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); await run('npm', ['run', 'prepare:bundled-git']); + await run('npm', ['run', 'prepare:bundled-npm']); + await run('npm', ['run', 'audit:bundled-npm']); + await run('npm', ['run', 'verify:bundled-npm']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:windows-x64']); diff --git a/scripts/prepare-bundled-npm.mjs b/scripts/prepare-bundled-npm.mjs new file mode 100644 index 0000000000..fe6b0ea376 --- /dev/null +++ b/scripts/prepare-bundled-npm.mjs @@ -0,0 +1,248 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { cp, lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const EXPECTED_NPM_VERSION = '12.0.2'; +const EXPECTED_APPROVED_RUNTIME_TREE_SHA256 = + 'sha256:930e2370422a82f7650387d37a4c6148bbf185e5def442ba8669550252b468e8'; +const SECURITY_PATCHES = Object.freeze([ + Object.freeze({ + packageName: 'tar', + fromVersion: '7.5.19', + toVersion: '7.5.22', + advisories: Object.freeze(['GHSA-r292-9mhp-454m']), + }), + Object.freeze({ + packageName: 'brace-expansion', + fromVersion: '5.0.7', + toVersion: '5.0.9', + advisories: Object.freeze(['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895']), + }), + Object.freeze({ + packageName: 'ip-address', + fromVersion: '10.2.0', + toVersion: '10.4.0', + advisories: Object.freeze([ + 'GHSA-mwp4-54f8-5fhr', + 'GHSA-4xrf-jv44-h6hh', + 'GHSA-22jq-vg5j-6vgg', + ]), + }), + Object.freeze({ + packageName: 'undici', + fromVersion: '6.27.0', + toVersion: '6.28.0', + advisories: Object.freeze(['GHSA-8xcm-r25x-g524', 'GHSA-m8rv-5g2x-5cg5', 'GHSA-v3r7-h72x-cjcm']), + }), +]); + +export async function prepareBundledNpm({ + sourceNpmRoot = join(repoRoot, 'node_modules', 'npm'), + patchedPackagesRoot = join(repoRoot, 'node_modules'), + runtimeOutputRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm', 'npm'), + outputPath = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm', 'bundled-npm.json'), + auditRoot = join(dirname(outputPath), 'audit'), + sourceLockPath = join(repoRoot, 'package-lock.json'), + platform = process.platform, + arch = process.arch, +} = {}) { + const packageManifest = JSON.parse(await readFile(join(sourceNpmRoot, 'package.json'), 'utf8')); + if ( + packageManifest.name !== 'npm' || + packageManifest.version !== EXPECTED_NPM_VERSION || + packageManifest.license !== 'Artistic-2.0' + ) { + throw new Error( + `Bundled npm preparation requires npm ${EXPECTED_NPM_VERSION} under Artistic-2.0.`, + ); + } + for (const patch of SECURITY_PATCHES) { + await requirePackageVersion( + join(sourceNpmRoot, 'node_modules', patch.packageName, 'package.json'), + patch.packageName, + patch.fromVersion, + `npm source ${patch.packageName}`, + ); + await requirePackageVersion( + join(patchedPackagesRoot, patch.packageName, 'package.json'), + patch.packageName, + patch.toVersion, + `patched ${patch.packageName}`, + ); + } + // Validate the immutable inputs before copying so symlink/junction failures + // have one stable policy error on every platform. + await inventoryFiles(sourceNpmRoot); + for (const patch of SECURITY_PATCHES) { + await inventoryFiles(join(patchedPackagesRoot, patch.packageName)); + } + await rm(runtimeOutputRoot, { recursive: true, force: true }); + await mkdir(dirname(runtimeOutputRoot), { recursive: true }); + await cp(sourceNpmRoot, runtimeOutputRoot, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + for (const patch of SECURITY_PATCHES) { + const runtimePackageRoot = join(runtimeOutputRoot, 'node_modules', patch.packageName); + await rm(runtimePackageRoot, { recursive: true, force: true }); + await cp(join(patchedPackagesRoot, patch.packageName), runtimePackageRoot, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + } + await requireRegularFile(join(runtimeOutputRoot, 'LICENSE'), 'npm license'); + await requireRegularFile(join(runtimeOutputRoot, 'bin', 'npm-cli.js'), 'npm CLI'); + const files = await inventoryFiles(runtimeOutputRoot); + const runtimeTreeSha256 = sha256(Buffer.from(JSON.stringify(files))); + const usesRepositoryRuntimeInputs = + sourceNpmRoot === join(repoRoot, 'node_modules', 'npm') && + patchedPackagesRoot === join(repoRoot, 'node_modules'); + if ( + usesRepositoryRuntimeInputs && + runtimeTreeSha256 !== EXPECTED_APPROVED_RUNTIME_TREE_SHA256 + ) { + throw new Error( + `Bundled npm approved runtime tree mismatch: expected ${EXPECTED_APPROVED_RUNTIME_TREE_SHA256}, received ${runtimeTreeSha256}.`, + ); + } + const identity = JSON.stringify({ + protocol: 'maka_bundled_npm_runtime_identity_v1', + npmVersion: EXPECTED_NPM_VERSION, + platform, + arch, + securityPatches: SECURITY_PATCHES, + files, + }); + const manifest = { + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: EXPECTED_NPM_VERSION, + platform, + arch, + securityPatches: SECURITY_PATCHES, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + files, + runtimeIdentitySha256: sha256(Buffer.from(identity)), + distributionReady: true, + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + await writeBundledRuntimeAuditLock({ auditRoot, sourceLockPath }); + return manifest; +} + +async function writeBundledRuntimeAuditLock({ auditRoot, sourceLockPath }) { + const sourceLock = JSON.parse(await readFile(sourceLockPath, 'utf8')); + const packages = Object.fromEntries( + Object.entries(sourceLock.packages ?? {}) + .filter(([path]) => path === 'node_modules/npm' || path.startsWith('node_modules/npm/')) + .map(([path, value]) => [path, { ...value, dev: false }]), + ); + for (const patch of SECURITY_PATCHES) { + const patchedEntry = sourceLock.packages?.[`node_modules/${patch.packageName}`]; + if (!patchedEntry || patchedEntry.version !== patch.toVersion) { + throw new Error( + `Bundled npm audit requires ${patch.packageName} ${patch.toVersion} in the root lockfile.`, + ); + } + packages[`node_modules/npm/node_modules/${patch.packageName}`] = { + ...patchedEntry, + dev: false, + }; + } + packages[''] = { + name: 'maka-bundled-npm-audit', + version: '1.0.0', + dependencies: { npm: EXPECTED_NPM_VERSION }, + }; + const auditPackage = { + name: 'maka-bundled-npm-audit', + version: '1.0.0', + private: true, + dependencies: { npm: EXPECTED_NPM_VERSION }, + }; + const auditLock = { + name: auditPackage.name, + version: auditPackage.version, + lockfileVersion: 3, + requires: true, + packages, + }; + await rm(auditRoot, { recursive: true, force: true }); + await mkdir(auditRoot, { recursive: true }); + await Promise.all([ + writeFile(join(auditRoot, 'package.json'), `${JSON.stringify(auditPackage, null, 2)}\n`, 'utf8'), + writeFile( + join(auditRoot, 'package-lock.json'), + `${JSON.stringify(auditLock, null, 2)}\n`, + 'utf8', + ), + ]); +} + +async function requirePackageVersion(path, name, version, label) { + const manifest = JSON.parse(await readFile(path, 'utf8')); + if (manifest.name !== name || manifest.version !== version) { + throw new Error(`${label} must be ${name}@${version}.`); + } +} + +async function inventoryFiles(root) { + const files = []; + await walk(root, root, files); + files.sort((left, right) => Buffer.from(left.path).compare(Buffer.from(right.path))); + return files; +} + +async function walk(root, directory, files) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + const info = await lstat(absolutePath); + if (entry.isDirectory() && !info.isSymbolicLink()) { + await walk(root, absolutePath, files); + continue; + } + if (!entry.isFile() || info.isSymbolicLink()) { + throw new Error('Bundled npm runtime may contain only regular files and directories.'); + } + files.push({ + path: relative(root, absolutePath).replaceAll('\\', '/'), + bytes: info.size, + sha256: await sha256File(absolutePath), + }); + } +} + +async function requireRegularFile(path, label) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error(`${label} must be a regular non-symlink file: ${path}`); + } +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} + +function sha256(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const manifest = await prepareBundledNpm(); + console.log( + `Prepared bundled npm ${manifest.npmVersion} for ${manifest.platform}-${manifest.arch}.`, + ); +} diff --git a/scripts/prepare-bundled-npm.test.mjs b/scripts/prepare-bundled-npm.test.mjs new file mode 100644 index 0000000000..162181ad24 --- /dev/null +++ b/scripts/prepare-bundled-npm.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { prepareBundledNpm } from './prepare-bundled-npm.mjs'; + +const patches = [ + ['tar', '7.5.19', '7.5.22'], + ['brace-expansion', '5.0.7', '5.0.9'], + ['ip-address', '10.2.0', '10.4.0'], + ['undici', '6.27.0', '6.28.0'], +]; + +test('prepares an exact file manifest for the bundled npm runtime', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceNpmRoot = join(root, 'npm'); + const patchedPackagesRoot = join(root, 'patched-packages'); + const runtimeOutputRoot = join(root, 'runtime', 'npm'); + const outputPath = join(root, 'bundled-npm.json'); + await mkdir(join(sourceNpmRoot, 'bin'), { recursive: true }); + await mkdir(join(sourceNpmRoot, 'lib'), { recursive: true }); + await createPatchFixtures(root, sourceNpmRoot, patchedPackagesRoot); + await writeFile( + join(sourceNpmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ); + await writeFile(join(sourceNpmRoot, 'LICENSE'), 'fixture license\n'); + await writeFile(join(sourceNpmRoot, 'bin', 'npm-cli.js'), 'import "../lib/cli.js";\n'); + await writeFile(join(sourceNpmRoot, 'lib', 'cli.js'), 'export default true;\n'); + const manifest = await prepareBundledNpm({ + sourceNpmRoot, + patchedPackagesRoot, + runtimeOutputRoot, + outputPath, + sourceLockPath: join(root, 'package-lock.json'), + platform: 'linux', + arch: 'x64', + }); + + assert.equal(manifest.npmVersion, '12.0.2'); + assert.equal(manifest.securityPatches.length, 4); + assert.deepEqual( + manifest.securityPatches.map(({ packageName, fromVersion, toVersion }) => [ + packageName, + fromVersion, + toVersion, + ]), + patches, + ); + assert.equal(manifest.cliRelativePath, 'npm/bin/npm-cli.js'); + assert.deepEqual( + manifest.files.map((file) => file.path), + [ + 'LICENSE', + 'bin/npm-cli.js', + 'lib/cli.js', + 'node_modules/brace-expansion/index.js', + 'node_modules/brace-expansion/package.json', + 'node_modules/ip-address/index.js', + 'node_modules/ip-address/package.json', + 'node_modules/tar/index.js', + 'node_modules/tar/package.json', + 'node_modules/undici/index.js', + 'node_modules/undici/package.json', + 'package.json', + ], + ); + assert.match(manifest.runtimeIdentitySha256, /^sha256:[0-9a-f]{64}$/u); + assert.deepEqual(JSON.parse(await readFile(outputPath, 'utf8')), manifest); +}); + +test('rejects a bundled npm tree containing symbolic links', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceNpmRoot = join(root, 'npm'); + const patchedPackagesRoot = join(root, 'patched-packages'); + await mkdir(join(sourceNpmRoot, 'bin'), { recursive: true }); + await createPatchFixtures(root, sourceNpmRoot, patchedPackagesRoot); + await writeFile( + join(sourceNpmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ); + await writeFile(join(sourceNpmRoot, 'LICENSE'), 'fixture license\n'); + await writeFile(join(sourceNpmRoot, 'bin', 'npm-cli.js'), 'console.log("npm");\n'); + const { symlink } = await import('node:fs/promises'); + await symlink( + process.platform === 'win32' ? sourceNpmRoot : join(sourceNpmRoot, 'LICENSE'), + join(sourceNpmRoot, 'linked-license'), + process.platform === 'win32' ? 'junction' : undefined, + ); + + await assert.rejects( + prepareBundledNpm({ + sourceNpmRoot, + patchedPackagesRoot, + runtimeOutputRoot: join(root, 'runtime', 'npm'), + outputPath: join(root, 'manifest.json'), + sourceLockPath: join(root, 'package-lock.json'), + }), + /regular files and directories/u, + ); +}); + +async function createPatchFixtures(root, sourceNpmRoot, patchedPackagesRoot) { + const lockPackages = {}; + for (const [name, fromVersion, toVersion] of patches) { + const sourceRoot = join(sourceNpmRoot, 'node_modules', name); + const patchedRoot = join(patchedPackagesRoot, name); + await Promise.all([ + mkdir(sourceRoot, { recursive: true }), + mkdir(patchedRoot, { recursive: true }), + ]); + await writeFile( + join(sourceRoot, 'package.json'), + `${JSON.stringify({ name, version: fromVersion })}\n`, + ); + await writeFile( + join(patchedRoot, 'package.json'), + `${JSON.stringify({ name, version: toVersion })}\n`, + ); + await writeFile(join(patchedRoot, 'index.js'), 'export const patched = true;\n'); + lockPackages[`node_modules/${name}`] = { + version: toVersion, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${toVersion}.tgz`, + integrity: 'sha512-Zml4dHVyZQ==', + }; + } + lockPackages['node_modules/npm'] = { version: '12.0.2' }; + for (const [name, fromVersion] of patches) { + lockPackages[`node_modules/npm/node_modules/${name}`] = { version: fromVersion }; + } + await writeFile( + join(root, 'package-lock.json'), + `${JSON.stringify({ lockfileVersion: 3, packages: lockPackages })}\n`, + ); +} diff --git a/scripts/verify-bundled-npm-runtime.mjs b/scripts/verify-bundled-npm-runtime.mjs new file mode 100644 index 0000000000..5e94177617 --- /dev/null +++ b/scripts/verify-bundled-npm-runtime.mjs @@ -0,0 +1,55 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveBundledNpmDependencyProducer } from '../packages/runtime-host/dist/server/bundled-npm-dependency-producer.js'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const resourcesRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm'); +const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-smoke-')); + +try { + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot, + nodeExecutablePath: process.execPath, + }); + const projectRoot = join(temporaryRoot, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + const manifestBytes = Buffer.from( + '{"name":"maka-bundled-npm-smoke","version":"1.0.0","packageManager":"npm@12.0.2"}\n', + ); + const lockfileBytes = Buffer.from( + '{"name":"maka-bundled-npm-smoke","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"maka-bundled-npm-smoke","version":"1.0.0"}}}\n', + ); + await producer.provision({ + identity: { + protocolVersion: 1, + environmentId: `sha256:${'1'.repeat(64)}`, + manifestPath: 'package.json', + manifestSha256: `sha256:${'2'.repeat(64)}`, + lockfilePath: 'package-lock.json', + lockfileSha256: `sha256:${'3'.repeat(64)}`, + packageManagerName: 'npm', + packageManagerVersion: producer.packageManagerVersion, + nodeVersion: producer.nodeRuntime.version, + nodeAbi: producer.nodeRuntime.abi, + platform: producer.nodeRuntime.platform, + arch: producer.nodeRuntime.arch, + policyVersion: 'managed_dependency_environment_v1', + }, + outputRoot, + scratchRoot, + manifestBytes, + lockfileBytes, + }); + console.log( + `[bundled-npm] verified npm ${producer.packageManagerVersion} with Node ${producer.nodeRuntime.version}`, + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index 7af93fc7d6..306117ef5f 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -19,6 +19,7 @@ import { runCommand, sha256File, smokePackagedRenderer, + smokePackagedBundledNpm, } from './verify-packaged-app.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -101,6 +102,7 @@ export async function verifyPackagedMacApp( forbidPath = assertMissing, smokeRenderer = smokePackagedRenderer, smokeFilesystemWorker = smokePackagedFilesystemWorker, + smokeBundledNpm = smokePackagedBundledNpm, workingDirectory = dirname(appPath), } = {}, ) { @@ -128,6 +130,7 @@ export async function verifyPackagedMacApp( const executableArchitectures = await run('lipo', ['-archs', executable]); assertSingleArchitecture(executableArchitectures.stdout, 'Maka executable'); + await smokeBundledNpm(resources, executable); await run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]); await run('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]); await run('xcrun', ['stapler', 'validate', appPath]); diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index ed630db397..099561a0bf 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { access, mkdir } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; // `timeoutMs` is opt-in, for the commands that have actually hung: node-pty @@ -301,6 +302,8 @@ export async function assertPackagedResources( 'app.asar', 'bundled-tools.json', 'bundled-git.json', + 'bundled-npm.json', + join('npm', 'bin', 'npm-cli.js'), join('licenses', 'git', 'LICENSE.txt'), join('licenses', 'git', 'SOURCE_OFFER.txt'), join('workers', 'filesystem-worker.js'), @@ -311,6 +314,7 @@ export async function assertPackagedResources( join('licenses', 'electron', 'LICENSE'), join('licenses', 'electron', 'LICENSES.chromium.html'), join('licenses', 'npm', 'THIRD_PARTY_NOTICES.txt'), + join('licenses', 'npm-cli', 'LICENSE'), join('licenses', 'renderer', 'THIRD_PARTY_LICENSES.txt'), join('licenses', 'renderer', 'GEIST_LICENSE.txt'), join('licenses', 'renderer', 'GEIST_MONO_LICENSE.txt'), @@ -344,6 +348,58 @@ export async function assertPackagedResources( } } +export async function smokePackagedBundledNpm(resourcesPath, nodeExecutablePath) { + const [{ resolveBundledNpmDependencyProducer }, { computeManagedDependencyEnvironmentIdentity }] = + await Promise.all([ + import('../packages/runtime-host/dist/server/bundled-npm-dependency-producer.js'), + import('../packages/storage/dist/managed-dependency-environment.js'), + ]); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-packaged-npm-')); + try { + const producer = await resolveBundledNpmDependencyProducer({ + resourcesRoot: resourcesPath, + nodeExecutablePath, + }); + const manifestBytes = Buffer.from( + `{"name":"maka-packaged-npm-smoke","version":"1.0.0","packageManager":"npm@${producer.packageManagerVersion}"}\n`, + ); + const lockfileBytes = Buffer.from( + '{"name":"maka-packaged-npm-smoke","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"maka-packaged-npm-smoke","version":"1.0.0"}}}\n', + ); + const identity = computeManagedDependencyEnvironmentIdentity({ + manifestPath: 'package.json', + manifestBytes, + lockfilePath: 'package-lock.json', + lockfileBytes, + packageManagerName: producer.packageManagerName, + packageManagerVersion: producer.packageManagerVersion, + nodeVersion: producer.nodeRuntime.version, + nodeAbi: producer.nodeRuntime.abi, + platform: producer.nodeRuntime.platform, + arch: producer.nodeRuntime.arch, + producerRuntimeIdentitySha256: producer.capability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producer.capability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1', + }); + const projectRoot = join(temporaryRoot, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await producer.provision({ + identity, + outputRoot, + scratchRoot, + manifestBytes, + lockfileBytes, + }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + export async function sha256File(path) { const hash = createHash('sha256'); const file = createReadStream(path); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index b41939fc93..c7caea457d 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -10,6 +10,7 @@ import { runCommand, sha256File, smokePackagedRenderer, + smokePackagedBundledNpm, } from './verify-packaged-app.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -84,6 +85,7 @@ export async function verifyPackagedWindowsApp( forbidPath = assertMissing, readMachine = readPeMachine, smokeRenderer = smokePackagedRenderer, + smokeBundledNpm = smokePackagedBundledNpm, workingDirectory = appDirectory, } = {}, ) { @@ -103,6 +105,9 @@ export async function verifyPackagedWindowsApp( throw new Error(`${executableName} must be x64, found PE machine 0x${machine.toString(16)}.`); } + step('verifying the packaged npm runtime and CLI'); + await smokeBundledNpm(resources, executable); + step('reading the product version resource'); const { stdout } = await runPowerShell( run, diff --git a/scripts/windows-x64-release.test.mjs b/scripts/windows-x64-release.test.mjs index b9f934d326..9ab38562b8 100644 --- a/scripts/windows-x64-release.test.mjs +++ b/scripts/windows-x64-release.test.mjs @@ -43,6 +43,7 @@ function packagedAppOptions(overrides = {}) { forbidPath: async () => {}, readMachine: async () => 0x8664, smokeRenderer: async () => {}, + smokeBundledNpm: async () => {}, ...overrides, }; } @@ -86,6 +87,9 @@ test('Windows packaging regenerates bundled Git evidence before electron-builder 'npm run clean', 'npm run build', 'npm run prepare:bundled-git', + 'npm run prepare:bundled-npm', + 'npm run audit:bundled-npm', + 'npm run verify:bundled-npm', 'npm run check:release', 'npm --workspace @maka/desktop run package:windows-x64', ]); @@ -164,7 +168,7 @@ test('the packaged Windows app is checked for every unsigned helper that could s // The exact set, not "some path ends with each name": a helper is forbidden in // both the directories it could be staged from, and matching either one would // let the other be dropped without a test noticing. - assert.deepEqual(forbidden.map((path) => path.slice('C:/app/resources/'.length)).sort(), [ + assert.deepEqual(forbidden.map((path) => path.replaceAll('\\', '/').slice('C:/app/resources/'.length)).sort(), [ 'bin/cua-driver', 'bin/maka-cu', 'licenses/officecli',