diff --git a/.gitignore b/.gitignore index 0f6f738912..1257eb342f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ apps/desktop/resources/bin/ # Rebuilt from experiments/windows-sandbox by scripts/package-windows-x64.mjs. apps/desktop/resources/windows-sandbox/ apps/desktop/bundled-git.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 a39a10d8c4..45e70c0f82 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -66,6 +66,14 @@ export default { 'dist/renderer/computer-use-overlay/**', ], extraResources: [ + { + 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', @@ -105,6 +113,10 @@ export default { from: '../../LICENSE', to: 'licenses/maka/LICENSE', }, + { + from: '../../node_modules/npm/LICENSE', + to: 'licenses/npm-cli/LICENSE', + }, { from: '../../NOTICE', to: 'licenses/maka/NOTICE', diff --git a/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md new file mode 100644 index 0000000000..21caa7aa41 --- /dev/null +++ b/docs/architecture/bundled-npm-runtime-attestation-v1.zh-CN.md @@ -0,0 +1,129 @@ +--- +document_status: implementation-contract +status: draft-stacked-foundation +date: 2026-08-08 +milestone: M1.3 +stack_base: managed-dependency-producer-boundary-v1 +--- + +# Bundled npm Runtime Attestation v1 + +## 1. 本 PR 只证明一个主要不变量 + +> 在调用方已经取得“来自 Maka 已签名发布物”的 resources-root authority 后,固定 npm producer 只能使用其中完整清单验证通过、且绑定当前受支持 Host Node 的 npm 运行闭包;调用者不能通过伪造结构体、传入任意 executable 或在签发后替换 npm 文件来取得执行权。 + +本 PR 的 owner 是 Runtime Host package 内部的 bundled npm attestation 模块。它拥有 npm 运行树 manifest 的解码、完整文件清单校验、Host Node 版本与 executable identity、不可伪造 capability 的签发和每次调用前的重新验证。 + +这里必须区分两层证明:外层应用签名与平台发布链提供 **provenance trust**,本模块的 manifest 提供 **runtime integrity**。manifest 与 npm tree 位于同一资源目录,攻击者若能同时替换二者并重算摘要,本模块本身无法识别;它绝不是自足的密码学信任根。PR 3 的 API 合同因此有一个显式前置条件:`resourcesRoot` 必须已经由后续 packaged-process owner 认证。本 PR 单独只能证明“受权目录在 admission 与每次 invocation 时没有发生未声明变化”,不能证明任意目录来自 Maka。 + +本 PR 不包含 Desktop/CLI/Runtime Host composition 的生产 consumer。attestation resolver、capability issuer 和固定 npm provision 入口不通过 `@maka/runtime-host/server` 公共 barrel 暴露;PR 4 必须在一个固定 `resourcesPath` 的 composition owner 中把三者接通。因此本 PR 保持 Draft,不能单独宣称 M1.3 已可用。 + +## 2. 为什么只 bundled npm,不再 bundled 一份 Node + +Maka 已经由 Electron 或当前受控 Runtime Host 携带 Node。再打包第二份 Node 会增加包体、补丁与许可证维护面,并制造“两套 Node authority”。v1 直接绑定当前 Host runtime: + +```text +process.execPath canonical path + SHA-256 ++ process.versions.node ++ process.versions.modules (ABI) ++ platform / arch ++ 完整 npm tree manifest += ManagedNpmRuntimeCapability +``` + +Node 支持范围采用有限 allowlist;未知未来 major 默认拒绝,必须经过 permission-model 兼容验证后显式加入。当前允许: + +- Node 22.22.2 及同 major 后续版本; +- Node 24.15.0 及同 major 后续版本; +- Node 26.x; +- 其他 major 全部拒绝。 + +## 3. 发布闭包与供应链 + +发布准备从锁定的 `npm@12.0.2` 生成一个 Maka-owned runtime tree,并替换 npm 自带闭包中的四个已知脆弱版本: + +| package | npm 原版本 | 发布版本 | 证据 | +| --- | --- | --- | --- | +| `tar` | 7.5.19 | 7.5.22 | GHSA-r292-9mhp-454m | +| `brace-expansion` | 5.0.7 | 5.0.9 | GHSA-mh99-v99m-4gvg;GHSA-rgw5-rvv9-x895 | +| `ip-address` | 10.2.0 | 10.4.0 | 三条 manifest 中固定的 GHSA | +| `undici` | 6.27.0 | 6.28.0 | 三条 manifest 中固定的 GHSA | + +准备过程拒绝 symlink/junction,只接受 regular file/directory,输出: + +```text +apps/desktop/.generated/bundled-npm/ + npm/** 完整 npm runtime tree + bundled-npm.json 每个文件的 path、bytes、sha256 + audit/package-lock.json 独立 production audit 视图 +``` + +release gate 对独立 audit lock 执行 `npm audit --omit=dev --audit-level=high`。当前实际生成闭包约 14.6 MB,audit 为 0 vulnerabilities。生成目录不进入 Git;每次打包重新生成并验证。 + +## 4. 权限边界 + +`ManagedNpmRuntimeCapability` 的 TypeScript 形状不是权限。真实权限由 Runtime Host 模块内的 `WeakMap` 记录:只有 internal issuer 产生的对象才能通过消费 gate。结构相同的普通对象必须被拒绝。 + +签发器同样不属于公共 package API。否则任意调用者可以为自建目录生成“合法” capability,变成自认证。PR 4 的 composition owner 只能以打包应用的固定 resources root 调用 internal resolver,不能接受用户或 operation 传入的路径。 + +每次 npm invocation 前必须重新验证: + +1. Host executable canonical path 未变; +2. Host executable digest 未变; +3. npm runtime 仍只含 regular files/directories; +4. 实际文件集合、大小与 SHA-256 完全匹配 manifest; +5. npm `package.json` 仍是 `npm@12.0.2`、`Artistic-2.0`。 + +任一项失败都在 spawn 前 fail closed。 + +## 5. 原子性、失败状态与回滚 + +本 PR 不写用户 workspace,也不产生 durable T1/T2。它的原子边界是“通过全部验证后签发 capability”;验证中途失败不产生 capability。 + +稳定失败分类: + +- `bundled_npm_unavailable`:资源或 Host executable 不可读; +- `bundled_npm_manifest_invalid`:manifest 形状、路径或范围非法; +- `bundled_npm_platform_mismatch`:platform/arch 不匹配; +- `bundled_npm_integrity_mismatch`:文件集合、内容、版本或许可证不匹配; +- `bundled_npm_node_unsupported`:Host Node 不在验证 allowlist。 + +回滚本 PR 只需移除 npm release resources、manifest preparation 与 internal attestation 模块;PR 1 storage authority 和 PR 2 producer lifecycle 不需要回滚。没有兼容旧 manifest 的承诺:本能力尚无生产 consumer,格式变化应明确断代而不是建设迁移层。 + +## 6. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| regular-file tree inventory | 支持 | 支持 | 支持 | +| symlink/reparse input | 拒绝 | 拒绝 | 拒绝 junction/reparse | +| Host executable digest binding | 支持 | 支持;签名仍由外层 app 发布链保证 | 支持;Authenticode 仍由外层 app 发布链保证 | +| 每 invocation tree revalidation | 支持 | 支持 | 支持 | +| npm producer permission profile | Node permission model | Node permission model | Node permission model | + +manifest 与 npm tree 一起受最终应用签名/发布物保护。macOS 的 trust root 是通过 Gatekeeper/代码签名发布的 app bundle;Windows 的 trust root 是 Authenticode 签名的安装包与已安装应用。Linux v1 没有统一的平台签名验证 API,因此只承诺由官方发布/更新链安装后的完整性检查,不把任意本机目录提升为可信发布物。manifest hash 本身不是独立信任根;如果恶意本机进程已经能替换已安装应用资源、伪造父进程或直接运行修改后的 Maka 代码,本层不声称独立抵抗该攻击。 + +同理,后续父子进程 bootstrap 只负责把已经取得的 application authority 传给 detached Host,防止普通 CLI 参数或 ambient path 被误当成发布资源;它不是 macOS code-signing/Windows Authenticode 的替代物,也不抵御能够任意创建 Electron 父进程和 fd channel 的同用户恶意进程。若产品威胁模型将该攻击者纳入边界,必须另行引入平台签名验证 owner,不能继续给 bootstrap 增加可伪造字段。 + +## 7. Crash / tamper matrix + +| 时点 | 结果 | +| --- | --- | +| 准备 runtime tree 中途退出 | 生成目录不进入发布物;下一次 preparation 全量重建 | +| manifest 写入前退出 | release verifier 因 manifest 缺失失败 | +| manifest 与 tree 不一致 | runtime admission 拒绝 | +| capability 签发后 npm 文件被修改 | 下一次 invocation 在 spawn 前拒绝 | +| capability 被结构化伪造 | WeakMap gate 拒绝 | +| Host Node 被替换 | canonical path/digest revalidation 拒绝 | +| platform/arch 不一致 | admission 拒绝 | + +## 8. PR 4 的硬前置 + +PR 4 才能增加首个生产 consumer,并必须同时证明: + +1. Desktop/Runtime Host 只从固定 packaged `resourcesPath` 解析 npm; +2. storage authority、producer owner、runtime capability 由同一 composition 生命周期持有; +3. production-shaped 测试使用实际生成的 npm tree,从 hermetic loopback registry 安装一个真实 tarball package,验证解包与 `.bin` 生成后再完成依赖环境 acquire; +4. runtime identity 写入 dependency environment identity,不能由调用者自报; +5. shutdown 顺序先停止新 acquire,再 drain producer,最后关闭 storage authority。 + +在这五项完成以前,PR 1–3 都只是可独立审查的 stacked foundation,不是用户能力。 diff --git a/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md new file mode 100644 index 0000000000..544adb9066 --- /dev/null +++ b/docs/architecture/managed-dependency-producer-boundary-v1.zh-CN.md @@ -0,0 +1,157 @@ +--- +document_status: implementation-contract +status: draft-stacked-foundation +date: 2026-08-08 +milestone: M1.3 +stack_base: managed-dependency-storage-authority-v1 +--- + +# Managed Dependency Producer Boundary v1 + +## 1. 本 PR 只证明一个主要不变量 + +> 在 storage authority 复制 producer 输出以前,固定 npm producer 必须只在一次性 Maka-owned staging 中运行;它不能继承 host secrets、不能创建 child process、不能执行 lifecycle script,并且只有在根进程退出、输出 drain、最终 inventory 与 observed-limit 验证全部完成后才允许 `provision()` resolve。 + +本 PR 的候选 owner 是 Runtime Host 模块内部的 `runManagedNpmDependencyProvision()`。它拥有固定 npm argv、hermetic environment、Node permission profile、staging project layout、manifest/lockfile admission、timeout/abort、process lifecycle、bounded diagnostics,以及运行中和终态 filesystem inventory。 + +`runManagedNpmDependencyProvision()` 与低层 `runManagedDependencyProducerProcessInternal()` 均不通过 Runtime Host server barrel 暴露。PR 2 没有 runtime attestation owner,因此任何 raw executable path 都不能成为 production 输入。PR 3 必须先把经过 manifest/digest 验证的、有限 Node major allowlist 内的 bundled runtime capability 与该模块共同落地,之后才能公开固定 npm 入口。后续 PR 不能绕过该入口自行 spawn package manager。 + +本 PR 不包含: + +- bundled npm/Node 文件树、manifest、digest、license 或 release packaging; +- Desktop、CLI、ManagedWorkspaceOwner 或 Runtime Host composition 接线; +- dependency lease 到 Read/Glob/Grep worker 的 logical binding; +- Shell/Build、Write/Edit 或 workspace mutation; +- 用户 PATH 上的 npm fallback; +- production network broker。 + +因此本 PR 必须保持 Draft。它与 PR 1、PR 3 一样没有独立用户能力;PR 3 完成 runtime attestation 前,当前固定 npm 入口也不能成为生产 API。只有 PR 4 的 production consumer 和端到端测试成立后,整个 stack 才能按顺序转 Ready。 + +## 2. 固定 producer 协议 + +v1 只接受: + +```text +package manager: npm 12.0.2 +manifest: packageManager == npm@12.0.2 +lockfile: non-workspace package-lock v3 +resolved URL: https://registry.npmjs.org/** +integrity: sha1/sha256/sha384/sha512 SRI +link dependency: rejected +hasInstallScript: rejected +package entries: <= 25,000 +manifest bytes: <= 1 MiB +lockfile bytes: <= 64 MiB +``` + +固定 invocation: + +```text +verified-node + --permission + --allow-fs-read= + --allow-fs-read= + --allow-fs-write= + + ci + --ignore-scripts + --no-audit + --no-fund + --package-lock=true + --cache= + --userconfig= + --globalconfig= +``` + +没有 `--allow-child-process`,因此 production npm root process 不能创建 descendant。`PATH`、`NODE_OPTIONS`、proxy、credential、registry token 和任意 host environment 都不继承;HOME、npm config、temp 与 Node compile cache 全部指向同一次 staging 的 scratch。 + +PR 3 必须提供经过完整 manifest/digest 验证的 Node executable、npm runtime root 与 npm CLI,并使用有限 Node major allowlist。它必须在同一 Runtime Host package 内完成不可伪造 capability 的发行与消费,再由 server barrel 公开组合后的入口;不能仅把 raw path 验证留给调用者。它也不能覆盖 argv、env、timeout 或 observed limit。 + +## 3. Owner、时序和失败状态 + +```text +validate identity + manifest + lockfile + -> canonicalize exact output/scratch/runtime paths + -> create owned scratch children with exclusive creation + -> write exact npm configs + manifest + lockfile + -> spawn one detached root process with no child-process capability + -> monitor whole staging project every 100 ms + -> abort / timeout / invalid tree / observed limit: terminate process tree and await exit + I/O drain + -> normal root exit + -> await stdout/stderr drain + -> final complete inventory + -> exit code == 0 + -> provision resolves + -> PR 1 storage authority may deep-copy producer output +``` + +稳定失败原因: + +```text +aborted +timeout +filesystem_limit_exceeded +filesystem_invalid +output_drain_incomplete +process_failed +``` + +失败不会发布 artifact 或 receipt。transaction root 仍由 PR 1 storage authority 拥有并在 producer rejection 后删除;PR 2 不新增第二个 durable owner、数据库或 cleanup journal。 + +## 4. Filesystem inventory 与 `.bin` + +默认 soft observed limit 固定为 2 GiB 和 250,000 entries,不能由 PR 3/4 调高。它通过 100 ms polling 与最终 inventory 保证“超限结果绝不进入 artifact publication”,但不是 OS/filesystem 强制的峰值磁盘 quota;producer 可在相邻 observation 之间短暂超写。若产品要求防止磁盘被瞬时写满,必须另建 OS quota、受控写入 broker 或等价平台 owner,不能把 polling 描述为 disk-safety boundary。 + +空文件、目录、普通文件和合法 symlink 都计入 observed entry limit;普通文件 size 与 symlink target bytes 计入 observed byte limit。进程退出后必须再做一次完整终检,避免短命 producer 在 monitor tick 前超限后退出。 + +Linux/macOS 允许 npm 的典型相对 `.bin` symlink,例如: + +```text +node_modules/.bin/tool -> ../package/bin/tool.js +``` + +target 必须按 link 所在目录解析后仍位于 staging project 内。absolute/escaping symlink fail closed。Windows 的 npm shim 应为普通 `.cmd/.ps1` 文件;symlink、junction/reparse point 在 inventory 或后续 PR 1 artifact seal 中拒绝。 + +## 5. 平台能力矩阵 + +| 平台 | child process | timeout/abort | `.bin` | 保证 | +|---|---|---|---|---| +| Linux | PR 3 attested Node permission 禁止;异常终止用 process group + descendant scan | root 存活时先收割树,再 reject | contained relative symlink | production-shaped POSIX 测试必须执行 | +| macOS | PR 3 attested Node permission 禁止;异常终止用 process group + descendant scan | root 存活时先收割树,再 reject | contained relative symlink | `/var` alias 由 canonical path 处理 | +| Windows | PR 3 attested Node permission 禁止;异常终止用 `taskkill /T /F` | root 存活时先收割树,再 reject | 普通 npm shim;reparse 拒绝 | 无 Job Object;禁止任意可生 descendant 的 producer | + +Node permission model 与 PR 3 runtime attestation 必须共同存在,才构成 production root-only 证明;二者都不是可选 hardening。内部任意 argv primitive 不承诺在 root 已退出后回收 detached descendant,且不得成为 production consumer。若未来 package manager 必须启动 child process,必须定义新的 capability/policy identity,并为 POSIX 引入 cgroup/subreaper 或为 Windows 引入 Job Object 等真实平台 owner;不能在 `hermetic_dependency_builder_v1` 下静默加入 `--allow-child-process`。 + +## 6. Network 边界与尚未闭环的证明 + +本 PR 固定官方 registry config,并在 spawn 前拒绝非官方 `resolved` URL;无 lifecycle script、无 child process、无 host proxy/credential 环境。它没有单独提供一个 OS 级 host allowlist。 + +因此 `registry_https_only` 的完整证明依赖 PR 3 对 bundled npm runtime tree 的不可变验证,以及 PR 4 的 production composition/egress 决策。本 PR 不能单独被描述为已经提供强网络 sandbox。若 PR 4 要求网络层也成为强制 host allowlist,应增加 host-owned registry fetch broker 或等价执行边界,并产生新的 production-shaped 网络对抗测试;不能仅靠文案把 npm config 当作 OS enforcement。 + +## 7. Crash 与对抗矩阵 + +| 场景 | 唯一合法结果 | +|---|---| +| manifest/lockfile 不满足固定 policy | spawn 前拒绝 | +| scratch child 被 symlink/junction 预占 | spawn 前拒绝;outside 不写入 | +| npm 尝试创建 child process | Node permission 拒绝;不产生 descendant side effect | +| caller abort | tree 完全退出且 I/O drain 后返回 `aborted` | +| timeout | tree 完全退出且 I/O drain 后返回 `timeout` | +| observed byte/entry limit 超限 | tree 完全退出后返回 `filesystem_limit_exceeded`;超限内容不发布,但不承诺峰值磁盘占用 | +| escaping/unsupported entry | tree 完全退出后返回 `filesystem_invalid` | +| root exit 非零 | bounded stderr/stdout tail 随 `process_failed` 返回 | +| output 在 deadline 前未 drain | 返回 `output_drain_incomplete`;该状态禁止发布。PR 3 通过 attested no-child runtime 使 descendant 形状不可达 | +| producer 成功后 storage deep-copy 前 host 崩溃 | PR 1 启动清理 staging,无 artifact/receipt | + +child-process crash test 只能证明进程生命周期,不是断电测试。PR 2 不写 durable fact,所以没有 schema migration 或数据库 recovery 语义。 + +## 8. Extraction ledger + +| 旧集成提交 | PR 2 处理 | +|---|---| +| `89c9e0a3c feat(runtime-host): ship verified bundled npm environments` | 只提取固定 npm argv/env、input validation 与 process-owner 轮廓;runtime manifest/release 归 PR 3 | +| `8150e90a7 fix(runtime-host): constrain bundled npm provisioning` | 重写 timeout/abort、quota、scratch 与 `.bin` policy;不迁移仅 `child.kill()` 的旧生命周期 | +| `27f8f6b8e fix(release): verify shipped bundled npm closure` | 不属于 PR 2;全部留给 PR 3 | +| `9a42a761c fix(runtime-host): transport packaged dependency authority` | 不属于 PR 2;production composition 留给 PR 4 | + +本 PR 从 PR 1 head 平铺增加 producer owner,没有 cherry-pick 上述跨边界提交。 diff --git a/docs/architecture/managed-dependency-storage-authority-v1.zh-CN.md b/docs/architecture/managed-dependency-storage-authority-v1.zh-CN.md index 4c9974c8c6..6b1ea8d71c 100644 --- a/docs/architecture/managed-dependency-storage-authority-v1.zh-CN.md +++ b/docs/architecture/managed-dependency-storage-authority-v1.zh-CN.md @@ -156,8 +156,8 @@ Windows 上只读 dependency file 是合法输入。authority 只在自己的 un 后续平铺顺序固定为: 1. PR 1:本文件定义的 storage authority; -2. PR 2:producer boundary,必须证明 producer 进程树退出后 `provision()` 才 resolve,并包含真实 npm `.bin` symlink 的配额与取消/超时测试; -3. PR 3:bundled npm runtime 供应链、发布审计与许可证材料; +2. PR 2:producer boundary,必须证明 producer 进程树退出后 `provision()` 才 resolve,并用 npm 形态的 `.bin` symlink 覆盖配额与取消/超时语义; +3. PR 3:bundled npm runtime 供应链、发布审计与许可证材料,并使用实际打包的 npm runtime 补齐真实 `.bin` production-shaped 测试; 4. PR 4:唯一生产 consumer,把真实 baseline、environment lease 和 Read/Glob/Grep worker 串成闭环。 PR 1–3 只是堆叠地基;PR 4 合并前不能把 M1.3 描述为用户可用。PR 2 不得通过拒绝所有 POSIX symlink 规避 `.bin`:producer inventory 应计量合法 link,最终 storage authority 与 worker 仍负责 target containment;Windows 继续拒绝 reparse point。 diff --git a/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md index c8a2cb7b34..1fc5d1f04e 100644 --- a/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md @@ -110,6 +110,7 @@ v1 receipt 严格包含: - durable binding 与调用 identity 完全相同; - repository record、epoch artifact、baseline ref、head ref、commit/tree 与 pinned Git capability 一致; +- reopening 已存在 binding 时重新观测 source repository identity、HEAD commit 与 tree;任一项相对已接受边界发生漂移都 fail closed,不得静默复用旧 baseline; - worktree registration 仍被 Maka ownership lock 锁定; - HEAD/tree 等于 baseline,worktree clean; - policy version/hash 等于实现实际执行的 canonical M0 policy; diff --git a/package-lock.json b/package-lock.json index 4c656ee2f9..28a2a3d49d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,14 @@ "@biomejs/biome": "2.5.6", "@electron/asar": "3.4.1", "@types/node": "^26.1.2", + "brace-expansion": "5.0.9", + "ip-address": "10.4.0", "knip": "^6.26.0", + "npm": "12.0.2", "patch-package": "8.0.1", + "tar": "7.5.22", "typescript": "^7.0.2", + "undici": "6.28.0", "yaml": "2.9.0" }, "engines": { @@ -1040,9 +1045,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1060,9 +1062,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1080,9 +1079,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1100,9 +1096,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -5531,16 +5524,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": { @@ -10520,6 +10513,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/nth-check": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", diff --git a/package.json b/package.json index f85b5ec41c..a6300e0a78 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,10 @@ "windows:inventory": "node --test scripts/windows-test-inventory.test.mjs && node scripts/windows-test-inventory.mjs --check", "windows:inventory:write": "node scripts/windows-test-inventory.mjs --write", "smoke:windows": "npm run build && npm run smoke:windows:dist", - "smoke:windows:dist": "node scripts/windows-smoke.mjs" + "smoke:windows:dist": "node scripts/windows-smoke.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" }, "devDependencies": { "@ai-sdk/provider-utils": "5.0.27", @@ -88,9 +91,14 @@ "@astryxdesign/core": "0.4.0", "@biomejs/biome": "2.5.6", "@types/node": "^26.1.2", + "brace-expansion": "5.0.9", + "ip-address": "10.4.0", "knip": "^6.26.0", + "npm": "12.0.2", "patch-package": "8.0.1", + "tar": "7.5.22", "typescript": "^7.0.2", + "undici": "6.28.0", "yaml": "2.9.0" }, "allowScripts": { diff --git a/packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts b/packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts new file mode 100644 index 0000000000..c1c5d5d821 --- /dev/null +++ b/packages/runtime-host/src/__tests__/bundled-npm-runtime.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { resolveBundledNpmRuntime } from '../server/bundled-npm-runtime.js'; +import { runManagedNpmDependencyProvision } from '../server/managed-dependency-producer-process.js'; + +test('attests a strict packaged npm tree against every declared file', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + + const capability = await resolveBundledNpmRuntime({ + resourcesRoot: fixture.root, + }); + + assert.equal(capability.npmVersion, '12.0.2'); + assert.equal(capability.nodeVersion, process.versions.node); + assert.equal(capability.nodeAbi, process.versions.modules); + assert.equal(capability.platform, process.platform); + assert.equal(capability.arch, process.arch); + assert.equal(capability.npmRuntimeRoot, await realpath(join(fixture.root, 'npm'))); + assert.equal( + capability.npmCliPath, + await realpath(join(fixture.root, 'npm', 'bin', 'npm-cli.js')), + ); + assert.match(capability.runtimeIdentitySha256, /^sha256:[a-f0-9]{64}$/u); +}); + +test('rejects a bundled npm file changed before attestation', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await writeFile(join(fixture.root, 'npm', 'bin', 'npm-cli.js'), 'tampered\n'); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_integrity_mismatch', + ); +}); + +test('rejects undeclared files in the bundled npm tree', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await writeFile(join(fixture.root, 'npm', 'undeclared.js'), 'unexpected\n'); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_integrity_mismatch', + ); +}); + +test('revalidates the full npm tree before every managed invocation', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + const capability = await resolveBundledNpmRuntime({ resourcesRoot: fixture.root }); + await writeFile(join(fixture.root, 'npm', 'LICENSE'), 'changed after attestation\n'); + + await assert.rejects( + runManagedNpmDependencyProvision({ + runtime: capability, + producerInput: undefined as never, + }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_integrity_mismatch', + ); +}); + +test('rejects a manifest for another platform before issuing capability', async (t) => { + const fixture = await createFixture({ + platform: process.platform === 'win32' ? 'linux' : 'win32', + }); + t.after(fixture.remove); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_platform_mismatch', + ); +}); + +test('classifies malformed manifest JSON as invalid evidence', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await writeFile(join(fixture.root, 'bundled-npm.json'), '{not-json'); + + await assert.rejects( + resolveBundledNpmRuntime({ resourcesRoot: fixture.root }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'bundled_npm_manifest_invalid', + ); +}); + +async function createFixture(options: { readonly platform?: NodeJS.Platform } = {}) { + const root = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-runtime-')); + const npmRoot = join(root, 'npm'); + await mkdir(join(npmRoot, 'bin'), { recursive: true }); + await Promise.all([ + writeFile( + join(npmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ), + writeFile(join(npmRoot, 'bin', 'npm-cli.js'), "console.log('fixture npm');\n"), + writeFile(join(npmRoot, 'LICENSE'), 'Artistic License fixture\n'), + ]); + await writeFile( + join(root, 'bundled-npm.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: '12.0.2', + platform: options.platform ?? process.platform, + arch: process.arch, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + securityPatches: approvedSecurityPatches, + files: [ + { + path: 'LICENSE', + bytes: 25, + sha256: 'sha256:871a16ed3b8cf5ceaee50e01124761c4be8310cc9908820b8fde5953f4034f83', + }, + { + path: 'bin/npm-cli.js', + bytes: 28, + sha256: 'sha256:d104584f0a4ea5ead632bdbb4bb3aa6999600e33c92808bda4ce642aa53d5d6b', + }, + { + path: 'package.json', + bytes: 59, + sha256: 'sha256:1c0973dc9bec7dab061b42e164446693459ff4e192e233a33b6b2a86809e6e22', + }, + ], + distributionReady: true, + })}\n`, + ); + return { + root, + remove: () => rm(root, { recursive: true, force: true }), + }; +} + +const approvedSecurityPatches = [ + { + 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'], + }, +] as const; diff --git a/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts new file mode 100644 index 0000000000..f3e4a38e7e --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-dependency-producer-process.test.ts @@ -0,0 +1,615 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import * as runtimeHostServer from '../server/index.js'; +import { resolveBundledNpmRuntime } from '../server/bundled-npm-runtime.js'; +import { + isManagedNpmNodeVersionSupported, + runManagedDependencyProducerProcessInternal, + runManagedNpmDependencyProvision, +} from '../server/managed-dependency-producer-process.js'; + +const productionProfileSkip = isManagedNpmNodeVersionSupported(process.versions.node) + ? false + : `Host Node ${process.versions.node} is outside the attested managed npm profile`; + +test('keeps npm attestation and provisioning package-internal until composition owns both', () => { + assert.equal('resolveBundledNpmRuntime' in runtimeHostServer, false); + assert.equal('runManagedNpmDependencyProvision' in runtimeHostServer, false); +}); + +test('rejects a structurally valid but unissued npm runtime capability', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-forged-runtime-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, '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 assert.rejects( + runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: Object.freeze({ + npmVersion: '12.0.2', + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + nodeExecutablePath: process.execPath, + npmRuntimeRoot: root, + npmCliPath: join(root, 'npm-cli.js'), + runtimeIdentitySha256: `sha256:${'6'.repeat(64)}`, + }), + } as never), + /attested runtime capability/u, + ); +}); + +test('admits only Node versions compatible with the fixed npm execution profile', () => { + assert.equal(isManagedNpmNodeVersionSupported('22.22.1'), false); + assert.equal(isManagedNpmNodeVersionSupported('22.22.2'), true); + assert.equal(isManagedNpmNodeVersionSupported('23.99.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('24.14.9'), false); + assert.equal(isManagedNpmNodeVersionSupported('24.15.0'), true); + assert.equal(isManagedNpmNodeVersionSupported('25.0.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('26.0.0'), true); + assert.equal(isManagedNpmNodeVersionSupported('27.0.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('999.0.0'), false); + assert.equal(isManagedNpmNodeVersionSupported('invalid'), false); +}); + +test('runs the fixed npm install protocol with a hermetic environment', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-provision-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'fixture-npm-cli.cjs'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + [ + "const fs = require('node:fs');", + "const path = require('node:path');", + 'const args = process.argv.slice(2);', + "fs.mkdirSync(path.join(process.cwd(), 'node_modules', 'fixture'), { recursive: true });", + "fs.writeFileSync(path.join(process.cwd(), 'node_modules', 'fixture', 'index.js'), 'safe\\n');", + "fs.writeFileSync(path.join(process.cwd(), 'invocation.json'), JSON.stringify({ args, env: process.env }));", + ].join(''), + 'utf8', + ); + const previousSecret = process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST; + process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST = 'must-not-cross'; + t.after(() => { + if (previousSecret === undefined) delete process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST; + else process.env.MAKA_DEPENDENCY_SECRET_FOR_TEST = previousSecret; + }); + + await runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: await attestFixtureRuntime(root, npmCliPath), + }); + + const invocation = JSON.parse(await readFile(join(projectRoot, 'invocation.json'), 'utf8')) as { + args: string[]; + env: Record; + }; + assert.deepEqual(invocation.args.slice(0, 6), [ + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=true', + `--cache=${join(scratchRoot, 'cache')}`, + ]); + assert.equal(invocation.env.npm_config_registry, 'https://registry.npmjs.org/'); + assert.equal(invocation.env.npm_config_ignore_scripts, 'true'); + assert.equal(invocation.env.MAKA_DEPENDENCY_SECRET_FOR_TEST, undefined); + if (process.platform === 'win32') { + const relativeHome = join('.maka-runtime', 'home'); + assert.equal(invocation.env.HOME, relativeHome); + assert.equal(invocation.env.USERPROFILE, relativeHome); + assert.equal(invocation.env.APPDATA, join(relativeHome, 'AppData', 'Roaming')); + assert.equal(invocation.env.LOCALAPPDATA, join(relativeHome, 'AppData', 'Local')); + } + assert.equal(await readFile(join(outputRoot, 'fixture', 'index.js'), 'utf8'), 'safe\n'); +}); + +test('rejects lifecycle-script lock entries before starting npm', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-unsafe-lock-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'must-not-run.cjs'); + const marker = join(root, 'spawned'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned')`, + 'utf8', + ); + const producerInput = fixtureProducerInput(outputRoot, scratchRoot); + producerInput.lockfileBytes = Buffer.from( + '{"lockfileVersion":3,"packages":{"":{"name":"fixture"},"node_modules/unsafe":{"resolved":"https://registry.npmjs.org/unsafe/-/unsafe-1.0.0.tgz","integrity":"sha512-YQ==","hasInstallScript":true}}}\n', + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput, + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /unsafe dependency entry/u, + ); + await assert.rejects(readFile(marker, 'utf8'), { code: 'ENOENT' }); +}); + +test('rejects a pre-positioned scratch redirect before starting npm', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-scratch-redirect-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const outsideRoot = join(root, 'outside'); + const npmCliPath = join(root, 'must-not-run.cjs'); + const marker = join(root, 'spawned'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + mkdir(outsideRoot, { recursive: true }), + ]); + await symlink( + outsideRoot, + join(scratchRoot, 'home'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await writeFile( + npmCliPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned')`, + 'utf8', + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /scratch entry was not created/u, + ); + await assert.rejects(readFile(join(outsideRoot, 'npmrc'), 'utf8'), { code: 'ENOENT' }); + await assert.rejects(readFile(marker, 'utf8'), { code: 'ENOENT' }); +}); + +test('denies child-process creation inside the fixed npm execution profile', { + skip: productionProfileSkip, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-npm-child-denied-')); + t.after(() => rm(root, { recursive: true, force: true })); + const projectRoot = join(root, 'project'); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const npmCliPath = join(root, 'spawning-npm-cli.cjs'); + const descendantMarker = join(root, 'descendant-ran'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + await writeFile( + npmCliPath, + [ + "const { spawn } = require('node:child_process');", + `spawn(process.execPath, ['-e', ${JSON.stringify(`require('node:fs').writeFileSync(${JSON.stringify(descendantMarker)}, 'ran')`)}]);`, + ].join(''), + 'utf8', + ); + + await assert.rejects( + runManagedNpmDependencyProvision({ + producerInput: fixtureProducerInput(outputRoot, scratchRoot), + runtime: await attestFixtureRuntime(root, npmCliPath), + }), + /child_process|permission|access denied/iu, + ); + await assert.rejects(readFile(descendantMarker, 'utf8'), { code: 'ENOENT' }); +}); + +test('waits for the producer process output tree to close before returning', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-drain-')); + t.after(() => rm(root, { recursive: true, force: true })); + const marker = join(root, 'descendant-finished'); + const descendant = [ + "const { writeFileSync } = require('node:fs');", + `setTimeout(() => { writeFileSync(${JSON.stringify(marker)}, 'done'); process.stdout.write('descendant done\\n'); }, 150);`, + ].join(''); + const producer = [ + "const { spawn } = require('node:child_process');", + `spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + "process.stdout.write('producer done\\n');", + ].join(''); + + const result = await runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + + assert.equal(result.exitCode, 0); + assert.match(result.outputTail, /producer done/u); + assert.match(result.outputTail, /descendant done/u); + assert.equal(await readFile(marker, 'utf8'), 'done'); +}); + +test('aborts and reaps the complete producer process tree before rejecting', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-abort-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + "process.on('SIGTERM', () => {});", + 'setInterval(() => {}, 1000);', + ].join(''); + const abort = new AbortController(); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + abortSignal: abort.signal, + timeoutMs: 5_000, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + abort.abort(); + + await assert.rejects(task, /aborted/u); + await waitForProcessExit(childPid); +}); + +test('reaps a surviving descendant after the direct producer accepts SIGTERM', { + skip: process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-root-exit-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + 'setInterval(() => {}, 1000);', + ].join(''); + const abort = new AbortController(); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + abortSignal: abort.signal, + timeoutMs: 5_000, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + abort.abort(); + + await assert.rejects(task, /aborted/u); + await waitForProcessExit(childPid); +}); + +test('times out and reaps the complete producer process tree before rejecting', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-timeout-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + "process.on('SIGTERM', () => {});", + 'setInterval(() => {}, 1000);', + ].join(''); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 100, + maxObservedBytes: 1024 * 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + await assert.rejects(task, /timed out/u); + await waitForProcessExit(childPid); +}); + +test('enforces observed filesystem limits and reaps the producer tree before rejecting', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-quota-')); + t.after(() => rm(root, { recursive: true, force: true })); + const childPidPath = join(root, 'child.pid'); + const oversizedPath = join(root, 'oversized.bin'); + const descendant = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const producer = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { stdio: ['ignore', process.stdout, process.stderr] });`, + `writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`, + `writeFileSync(${JSON.stringify(oversizedPath)}, Buffer.alloc(4096));`, + "process.on('SIGTERM', () => {});", + 'setInterval(() => {}, 1000);', + ].join(''); + const task = runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 500, + maxObservedBytes: 1024, + maxObservedEntries: 100, + }); + const childPid = Number.parseInt(await waitForFile(childPidPath), 10); + + await assert.rejects( + task, + (error: unknown) => + error instanceof Error && + (error as { readonly reason?: unknown }).reason === 'filesystem_limit_exceeded', + ); + await waitForProcessExit(childPid); +}); + +test('accepts and accounts for a contained npm bin symlink on POSIX', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-bin-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const producer = [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "const bin = path.join(process.cwd(), 'node_modules', 'package', 'bin');", + "const links = path.join(process.cwd(), 'node_modules', '.bin');", + 'fs.mkdirSync(bin, { recursive: true });', + 'fs.mkdirSync(links, { recursive: true });', + "fs.writeFileSync(path.join(bin, 'cli.js'), 'module.exports = 1;\\n');", + "fs.symlinkSync('../package/bin/cli.js', path.join(links, 'fixture-cli'));", + ].join(''); + + const result = await runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }); + + assert.equal(result.exitCode, 0); +}); + +test('rejects an escaping producer symlink as invalid output instead of a quota failure', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-escape-link-')); + t.after(() => rm(root, { recursive: true, force: true })); + const producer = [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "fs.symlinkSync('../../outside', path.join(process.cwd(), 'escape'));", + 'setInterval(() => {}, 1000);', + ].join(''); + + await assert.rejects( + runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 1_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }), + /escaping symbolic link/u, + ); +}); + +test('counts empty files toward the producer entry quota', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-entry-quota-')); + t.after(() => rm(root, { recursive: true, force: true })); + const producer = [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "const files = path.join(process.cwd(), 'many-empty-files');", + 'fs.mkdirSync(files);', + "for (let index = 0; index < 20; index += 1) fs.writeFileSync(path.join(files, String(index)), '');", + ].join(''); + + await assert.rejects( + runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', producer], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }), + /observed filesystem limit/u, + ); +}); + +test('rejects a non-zero producer exit with its bounded diagnostic tail', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-dependency-producer-failure-')); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects( + runManagedDependencyProducerProcessInternal({ + argv: [process.execPath, '-e', "process.stderr.write('fixture failed\\n'); process.exit(7)"], + cwd: root, + env: process.env, + monitorRoot: root, + timeoutMs: 5_000, + maxObservedBytes: 1024, + maxObservedEntries: 10, + }), + /exit code 7: fixture failed/u, + ); +}); + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + return await readFile(path, 'utf8'); + } 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}`); +} + +async function attestFixtureRuntime(root: string, fixtureCliPath: string) { + const resourcesRoot = join(root, 'runtime-resources'); + const npmRoot = join(resourcesRoot, 'npm'); + const npmCliPath = join(npmRoot, 'bin', 'npm-cli.js'); + const packageJson = '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n'; + const license = 'Artistic License fixture\n'; + const cli = await readFile(fixtureCliPath); + await mkdir(join(npmRoot, 'bin'), { recursive: true }); + await Promise.all([ + writeFile(join(npmRoot, 'package.json'), packageJson), + writeFile(join(npmRoot, 'LICENSE'), license), + writeFile(npmCliPath, cli), + ]); + const files = [ + manifestFile('LICENSE', Buffer.from(license)), + manifestFile('bin/npm-cli.js', cli), + manifestFile('package.json', Buffer.from(packageJson)), + ]; + 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, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + securityPatches: approvedSecurityPatches, + files, + distributionReady: true, + })}\n`, + ); + return await resolveBundledNpmRuntime({ resourcesRoot }); +} + +const approvedSecurityPatches = [ + { + 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'], + }, +] as const; + +function manifestFile(path: string, bytes: Buffer) { + return { + path, + bytes: bytes.byteLength, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + }; +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Producer descendant ${pid} survived cancellation`); +} + +function fixtureProducerInput(outputRoot: string, scratchRoot: string) { + return { + identity: { + 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, + }, + outputRoot, + scratchRoot, + manifestBytes: Buffer.from('{"name":"fixture","packageManager":"npm@12.0.2"}\n'), + lockfileBytes: Buffer.from( + '{"name":"fixture","lockfileVersion":3,"packages":{"":{"name":"fixture"}}}\n', + ), + }; +} 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 a9e958f016..28e0dfb2ab 100644 --- a/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts @@ -156,6 +156,9 @@ function fakeManagedOwner(input: { async openManagedWorkspaceBaseline() { throw new Error('not used'); }, + async openManagedWorkspaceBaselineFromExecutionStores() { + throw new Error('not used'); + }, async withManagedWorkspaceExecution(handle, operation) { assert.equal(handle, input.handle); input.calls.push('managed:admit'); diff --git a/packages/runtime-host/src/server/bundled-npm-runtime.ts b/packages/runtime-host/src/server/bundled-npm-runtime.ts new file mode 100644 index 0000000000..ae64022111 --- /dev/null +++ b/packages/runtime-host/src/server/bundled-npm-runtime.ts @@ -0,0 +1,425 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, normalize, relative } from 'node:path'; + +import { + isManagedNpmNodeVersionSupported, + issueManagedNpmRuntimeCapabilityInternal, + type ManagedNpmRuntimeCapability, +} from './managed-dependency-producer-process.js'; + +const MANIFEST_KEYS = [ + 'arch', + 'cliRelativePath', + 'distributionReady', + 'files', + 'npmVersion', + 'platform', + 'protocol', + 'provider', + 'runtimeRootRelativePath', + 'schemaVersion', + 'securityPatches', +] as const; +const FILE_KEYS = ['bytes', 'path', 'sha256'] as const; +const SECURITY_PATCH_KEYS = ['advisories', 'fromVersion', 'packageName', 'toVersion'] as const; +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; +const MAX_RUNTIME_FILES = 100_000; +const MAX_RUNTIME_BYTES = 128 * 1024 * 1024; + +export type BundledNpmRuntimeErrorCode = + | 'bundled_npm_unavailable' + | 'bundled_npm_manifest_invalid' + | 'bundled_npm_platform_mismatch' + | 'bundled_npm_integrity_mismatch' + | 'bundled_npm_node_unsupported'; + +export class BundledNpmRuntimeError extends Error { + constructor( + readonly code: BundledNpmRuntimeErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'BundledNpmRuntimeError'; + } +} + +export interface ResolveBundledNpmRuntimeInput { + readonly resourcesRoot: string; +} + +export async function resolveBundledNpmRuntime( + input: ResolveBundledNpmRuntimeInput, +): Promise { + const platform = process.platform; + const arch = process.arch; + try { + if (!isManagedNpmNodeVersionSupported(process.versions.node)) { + throw new BundledNpmRuntimeError( + 'bundled_npm_node_unsupported', + `Host Node ${process.versions.node} is outside the attested npm execution profile`, + ); + } + const nodeExecutablePath = await canonicalRegularFile(process.execPath, 'Host Node executable'); + const nodeExecutableSha256 = await sha256File(nodeExecutablePath); + const resourcesRoot = normalize(await realpath(input.resourcesRoot)); + const manifestPath = normalize(await realpath(join(resourcesRoot, 'bundled-npm.json'))); + assertWithinRoot(resourcesRoot, manifestPath, 'Bundled npm manifest'); + const manifestInfo = await lstat(manifestPath); + if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink()) { + throw invalidManifest('Bundled npm manifest must be a regular non-symlink file'); + } + if (manifestInfo.size > MAX_MANIFEST_BYTES) { + throw invalidManifest('Bundled npm manifest exceeds its size limit'); + } + const manifest = decodeManifest(parseManifestJson(await readFile(manifestPath, 'utf8'))); + if (manifest.platform !== platform || manifest.arch !== arch) { + throw new BundledNpmRuntimeError( + 'bundled_npm_platform_mismatch', + `Bundled npm targets ${manifest.platform}-${manifest.arch}, not ${platform}-${arch}`, + ); + } + const npmRuntimeRoot = normalize( + await realpath(join(resourcesRoot, ...manifest.runtimeRootRelativePath.split('/'))), + ); + assertWithinRoot(resourcesRoot, npmRuntimeRoot, 'Bundled npm runtime'); + const runtimeInfo = await lstat(npmRuntimeRoot); + if (!runtimeInfo.isDirectory() || runtimeInfo.isSymbolicLink()) { + throw invalidManifest('Bundled npm runtime root must be a regular directory'); + } + const npmCliPath = normalize( + await realpath(join(resourcesRoot, ...manifest.cliRelativePath.split('/'))), + ); + assertWithinRoot(npmRuntimeRoot, npmCliPath, 'Bundled npm CLI'); + const cliInfo = await lstat(npmCliPath); + if (!cliInfo.isFile() || cliInfo.isSymbolicLink()) { + throw invalidManifest('Bundled npm CLI must be a regular non-symlink file'); + } + await assertRuntimeTreeMatchesManifest(manifest, npmRuntimeRoot); + const capability = issueManagedNpmRuntimeCapabilityInternal( + { + npmVersion: manifest.npmVersion, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform, + arch, + nodeExecutablePath, + npmRuntimeRoot, + npmCliPath, + runtimeIdentitySha256: runtimeIdentity(manifest, nodeExecutableSha256), + }, + async () => { + const currentNodeExecutable = await canonicalRegularFile( + process.execPath, + 'Host Node executable', + ); + if ( + currentNodeExecutable !== nodeExecutablePath || + (await sha256File(currentNodeExecutable)) !== nodeExecutableSha256 + ) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Host Node executable changed after npm runtime attestation', + ); + } + await assertRuntimeTreeMatchesManifest(manifest, npmRuntimeRoot); + }, + ); + return capability; + } catch (error) { + if (error instanceof BundledNpmRuntimeError) throw error; + throw new BundledNpmRuntimeError( + 'bundled_npm_unavailable', + 'Bundled npm runtime is unavailable', + { cause: error }, + ); + } +} + +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: '12.0.2'; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly runtimeRootRelativePath: 'npm'; + readonly cliRelativePath: 'npm/bin/npm-cli.js'; + readonly files: readonly BundledNpmManifestFileV1[]; + readonly securityPatches: readonly BundledNpmSecurityPatchV1[]; + readonly distributionReady: true; +} + +interface BundledNpmSecurityPatchV1 { + readonly packageName: string; + readonly fromVersion: string; + readonly toVersion: string; + readonly advisories: readonly string[]; +} + +function decodeManifest(input: unknown): BundledNpmManifestV1 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw invalidManifest('Bundled npm manifest must be an object'); + } + const value = input as Record; + if ( + !hasExactKeys(value, MANIFEST_KEYS) || + value.schemaVersion !== 1 || + value.protocol !== 'maka_bundled_npm_runtime_v1' || + value.provider !== 'desktop/npm-cli' || + value.npmVersion !== '12.0.2' || + (value.platform !== 'win32' && value.platform !== 'darwin' && value.platform !== 'linux') || + typeof value.arch !== 'string' || + !/^[a-z0-9_]+$/u.test(value.arch) || + value.runtimeRootRelativePath !== 'npm' || + value.cliRelativePath !== 'npm/bin/npm-cli.js' || + !Array.isArray(value.files) || + value.files.length === 0 || + value.files.length > MAX_RUNTIME_FILES || + !Array.isArray(value.securityPatches) || + !matchesApprovedSecurityPatches(value.securityPatches) || + value.distributionReady !== true + ) { + throw invalidManifest('Bundled npm manifest is invalid'); + } + let previousPath = ''; + for (const entry of value.files) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw invalidManifest('Bundled npm file entry must be an object'); + } + const file = entry as Record; + if ( + !hasExactKeys(file, FILE_KEYS) || + typeof file.path !== 'string' || + !isSafeRelativePath(file.path) || + (previousPath !== '' && + Buffer.compare(Buffer.from(file.path), Buffer.from(previousPath)) <= 0) || + !Number.isSafeInteger(file.bytes) || + (file.bytes as number) < 0 || + typeof file.sha256 !== 'string' || + !SHA256_PATTERN.test(file.sha256) + ) { + throw invalidManifest('Bundled npm file entry is invalid'); + } + previousPath = file.path; + } + return value as unknown as BundledNpmManifestV1; +} + +function decodeNpmPackageManifest(input: unknown): { readonly version: string } { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package manifest is invalid', + ); + } + const value = input as Record; + if ( + value.name !== 'npm' || + typeof value.version !== 'string' || + value.license !== 'Artistic-2.0' + ) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package manifest is not the approved npm distribution', + ); + } + return { version: value.version }; +} + +async function inventoryRegularFiles(root: string): Promise { + const files: BundledNpmManifestFileV1[] = []; + let totalBytes = 0; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + if (!directory) break; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const path = join(directory, entry.name); + const info = await lstat(path); + if (entry.isDirectory() && !info.isSymbolicLink()) { + pending.push(path); + continue; + } + if (!entry.isFile() || info.isSymbolicLink()) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm runtime may contain only regular files and directories', + ); + } + totalBytes += info.size; + if (files.length >= MAX_RUNTIME_FILES || totalBytes > MAX_RUNTIME_BYTES) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm runtime exceeds its bounded inventory policy', + ); + } + files.push({ + path: relative(root, path).replaceAll('\\', '/'), + bytes: info.size, + sha256: await sha256File(path), + }); + } + } + files.sort((left, right) => Buffer.from(left.path).compare(Buffer.from(right.path))); + return files; +} + +async function assertRuntimeTreeMatchesManifest( + manifest: BundledNpmManifestV1, + npmRuntimeRoot: string, +): Promise { + const actualFiles = await inventoryRegularFiles(npmRuntimeRoot); + if (JSON.stringify(actualFiles) !== JSON.stringify(manifest.files)) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm runtime tree does not match its manifest', + ); + } + let packageManifestInput: unknown; + try { + packageManifestInput = JSON.parse(await readFile(join(npmRuntimeRoot, 'package.json'), 'utf8')); + } catch (error) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package manifest is unreadable', + { cause: error }, + ); + } + const packageManifest = decodeNpmPackageManifest(packageManifestInput); + if (packageManifest.version !== manifest.npmVersion) { + throw new BundledNpmRuntimeError( + 'bundled_npm_integrity_mismatch', + 'Bundled npm package version does not match its runtime manifest', + ); + } +} + +function runtimeIdentity( + manifest: BundledNpmManifestV1, + nodeExecutableSha256: `sha256:${string}`, +): `sha256:${string}` { + const identity = JSON.stringify({ + protocol: 'maka_bundled_npm_runtime_identity_v1', + manifest: { + schemaVersion: manifest.schemaVersion, + protocol: manifest.protocol, + provider: manifest.provider, + npmVersion: manifest.npmVersion, + platform: manifest.platform, + arch: manifest.arch, + runtimeRootRelativePath: manifest.runtimeRootRelativePath, + cliRelativePath: manifest.cliRelativePath, + files: manifest.files.map((file) => ({ + path: file.path, + bytes: file.bytes, + sha256: file.sha256, + })), + securityPatches: manifest.securityPatches.map((patch) => ({ + packageName: patch.packageName, + fromVersion: patch.fromVersion, + toVersion: patch.toVersion, + advisories: [...patch.advisories], + })), + distributionReady: manifest.distributionReady, + }, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + electronVersion: process.versions.electron ?? null, + nodeExecutableSha256, + }); + return `sha256:${createHash('sha256').update(identity).digest('hex')}`; +} + +async function canonicalRegularFile(path: string, label: string): Promise { + const sourceInfo = await lstat(path); + if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) { + throw new BundledNpmRuntimeError('bundled_npm_unavailable', `${label} is unavailable`); + } + const canonical = normalize(await realpath(path)); + const info = await lstat(canonical); + if (!info.isFile() || info.isSymbolicLink()) { + throw new BundledNpmRuntimeError('bundled_npm_unavailable', `${label} is unavailable`); + } + return canonical; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + return Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); +} + +function matchesApprovedSecurityPatches(input: readonly unknown[]): boolean { + const approved = [ + ['tar', '7.5.19', '7.5.22', ['GHSA-r292-9mhp-454m']], + ['brace-expansion', '5.0.7', '5.0.9', ['GHSA-mh99-v99m-4gvg', 'GHSA-rgw5-rvv9-x895']], + [ + 'ip-address', + '10.2.0', + '10.4.0', + ['GHSA-mwp4-54f8-5fhr', 'GHSA-4xrf-jv44-h6hh', 'GHSA-22jq-vg5j-6vgg'], + ], + [ + 'undici', + '6.27.0', + '6.28.0', + ['GHSA-8xcm-r25x-g524', 'GHSA-m8rv-5g2x-5cg5', 'GHSA-v3r7-h72x-cjcm'], + ], + ] as const; + return ( + input.every((candidate, index) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; + const value = candidate as Record; + const expected = approved[index]; + return ( + expected !== undefined && + hasExactKeys(value, SECURITY_PATCH_KEYS) && + value.packageName === expected[0] && + value.fromVersion === expected[1] && + value.toVersion === expected[2] && + Array.isArray(value.advisories) && + JSON.stringify(value.advisories) === JSON.stringify(expected[3]) + ); + }) && input.length === approved.length + ); +} + +function isSafeRelativePath(value: string): boolean { + if (!value || isAbsolute(value) || value.includes('\\')) return false; + return value + .split('/') + .every((segment) => segment.length > 0 && segment !== '.' && segment !== '..'); +} + +function assertWithinRoot(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw invalidManifest(`${label} escapes its packaged authority root`); +} + +function invalidManifest(message: string): BundledNpmRuntimeError { + return new BundledNpmRuntimeError('bundled_npm_manifest_invalid', message); +} + +function parseManifestJson(input: string): unknown { + try { + return JSON.parse(input); + } catch (error) { + throw invalidManifest(`Bundled npm manifest is not valid JSON: ${String(error)}`); + } +} + +async function sha256File(path: string): Promise<`sha256:${string}`> { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} diff --git a/packages/runtime-host/src/server/managed-dependency-producer-process.ts b/packages/runtime-host/src/server/managed-dependency-producer-process.ts new file mode 100644 index 0000000000..4dc76c9b57 --- /dev/null +++ b/packages/runtime-host/src/server/managed-dependency-producer-process.ts @@ -0,0 +1,588 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { lstat, mkdir, readdir, readlink, realpath, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { + DEFAULT_PROCESS_IO_DRAIN_TIMEOUT_MS, + manageChildProcessLifecycle, +} from '@maka/runtime/child-process-lifecycle'; +import type { ManagedDependencyEnvironmentProducerInput } from '@maka/storage/managed-dependency-environment'; + +const DEFAULT_PRODUCER_TIMEOUT_MS = 10 * 60 * 1_000; +const DEFAULT_KILL_GRACE_MS = 2_000; +const MAX_OUTPUT_TAIL_BYTES = 1024 * 1024; +const QUOTA_MONITOR_INTERVAL_MS = 100; +export const MANAGED_NPM_PACKAGE_MANAGER_VERSION = '12.0.2'; +const MANAGED_NPM_MAX_OBSERVED_BYTES = 2 * 1024 * 1024 * 1024; +const MANAGED_NPM_MAX_OBSERVED_ENTRIES = 250_000; + +export function isManagedNpmNodeVersionSupported(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; +} + +export interface RunManagedNpmDependencyProvisionInput { + readonly producerInput: ManagedDependencyEnvironmentProducerInput; + readonly runtime: ManagedNpmRuntimeCapability; +} + +export interface ManagedNpmRuntimeCapability { + readonly npmVersion: typeof MANAGED_NPM_PACKAGE_MANAGER_VERSION; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly nodeExecutablePath: string; + readonly npmRuntimeRoot: string; + readonly npmCliPath: string; + readonly runtimeIdentitySha256: `sha256:${string}`; +} + +const attestedNpmRuntimeCapabilities = new WeakMap< + ManagedNpmRuntimeCapability, + () => Promise +>(); + +/** @internal Only the bundled runtime attestation owner may issue this capability. */ +export function issueManagedNpmRuntimeCapabilityInternal( + input: ManagedNpmRuntimeCapability, + revalidate: () => Promise, +): ManagedNpmRuntimeCapability { + const capability = Object.freeze({ ...input }); + attestedNpmRuntimeCapabilities.set(capability, revalidate); + return capability; +} + +export async function runManagedNpmDependencyProvision( + input: RunManagedNpmDependencyProvisionInput, +): Promise { + const runtime = await requireAttestedNpmRuntimeCapability(input.runtime); + assertSafeNpmInputs(input.producerInput); + const outputRoot = normalize(await realpath(input.producerInput.outputRoot)); + const scratchRoot = normalize(await realpath(input.producerInput.scratchRoot)); + const projectRoot = dirname(outputRoot); + if ( + basename(outputRoot) !== 'node_modules' || + basename(scratchRoot) !== '.maka-runtime' || + dirname(scratchRoot) !== projectRoot + ) { + throw new TypeError('Managed npm producer requires one exact owned staging project'); + } + const nodeExecutablePath = await canonicalRegularFile( + runtime.nodeExecutablePath, + 'Managed npm Node runtime', + ); + const npmRuntimeRoot = await canonicalDirectory(runtime.npmRuntimeRoot, 'Managed npm runtime'); + const npmCliPath = await canonicalRegularFile(runtime.npmCliPath, 'Managed npm CLI'); + if (!isPathWithin(npmCliPath, npmRuntimeRoot)) { + throw new Error('Managed npm CLI escapes its verified runtime 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 Promise.all([ + writeFile(userConfig, exactConfig, { encoding: 'utf8', flag: 'wx' }), + writeFile(globalConfig, exactConfig, { encoding: 'utf8', flag: 'wx' }), + writeFile(join(projectRoot, 'package.json'), input.producerInput.manifestBytes, { + flag: 'wx', + }), + writeFile(join(projectRoot, 'package-lock.json'), input.producerInput.lockfileBytes, { + flag: 'wx', + }), + ]); + await runManagedDependencyProducerProcessInternal({ + argv: [ + nodeExecutablePath, + '--permission', + ...(requiresExplicitNetworkPermission(runtime.nodeVersion) ? ['--allow-net'] : []), + `--allow-fs-read=${npmRuntimeRoot}`, + `--allow-fs-read=${projectRoot}`, + `--allow-fs-write=${projectRoot}`, + npmCliPath, + '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, + ), + monitorRoot: projectRoot, + ...(input.producerInput.abortSignal ? { abortSignal: input.producerInput.abortSignal } : {}), + timeoutMs: DEFAULT_PRODUCER_TIMEOUT_MS, + maxObservedBytes: MANAGED_NPM_MAX_OBSERVED_BYTES, + maxObservedEntries: MANAGED_NPM_MAX_OBSERVED_ENTRIES, + }); +} + +function requiresExplicitNetworkPermission(nodeVersion: string): boolean { + const major = Number.parseInt(nodeVersion.split('.')[0] ?? '', 10); + return Number.isSafeInteger(major) && major >= 26; +} + +async function requireAttestedNpmRuntimeCapability( + capability: ManagedNpmRuntimeCapability, +): Promise { + const revalidate = attestedNpmRuntimeCapabilities.get(capability); + if (!revalidate) throw new Error('Managed npm producer requires an attested runtime capability'); + await revalidate(); + return capability; +} + +function assertSafeNpmInputs(input: ManagedDependencyEnvironmentProducerInput): void { + if ( + input.identity.packageManagerName !== 'npm' || + input.identity.packageManagerVersion !== MANAGED_NPM_PACKAGE_MANAGER_VERSION || + !isManagedNpmNodeVersionSupported(input.identity.nodeVersion) || + input.identity.platform !== process.platform || + input.identity.arch !== process.arch + ) { + throw new Error('Managed npm producer identity mismatch'); + } + if ( + input.manifestBytes.byteLength > 1024 * 1024 || + input.lockfileBytes.byteLength > 64 * 1024 * 1024 + ) { + throw new Error('Managed npm producer input exceeds its bounded size policy'); + } + const manifest = decodeJsonObject(input.manifestBytes, 'manifest'); + const lockfile = decodeJsonObject(input.lockfileBytes, 'lockfile'); + if ( + manifest.packageManager !== `npm@${MANAGED_NPM_PACKAGE_MANAGER_VERSION}` || + manifest.workspaces !== undefined || + lockfile.lockfileVersion !== 3 || + !lockfile.packages || + typeof lockfile.packages !== 'object' || + Array.isArray(lockfile.packages) + ) { + throw new Error('Managed 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('Managed npm producer lockfile exceeds its package-count policy'); + } + for (const [packagePath, value] of packageEntries) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed npm producer rejected an unsafe dependency entry'); + } + const entry = value as Record; + if (packagePath === '') continue; + if ( + !packagePath.startsWith('node_modules/') || + entry.link === true || + entry.hasInstallScript === true || + typeof entry.resolved !== 'string' || + !entry.resolved.startsWith('https://registry.npmjs.org/') || + typeof entry.integrity !== 'string' || + !/^sha(?:1|256|384|512)-[A-Za-z0-9+/=]+$/u.test(entry.integrity) + ) { + throw new Error('Managed npm producer rejected an unsafe dependency entry'); + } + } +} + +function decodeJsonObject(bytes: Uint8Array, label: string): Record { + let value: unknown; + try { + value = JSON.parse(Buffer.from(bytes).toString('utf8')); + } catch (cause) { + throw new Error(`Managed npm producer ${label} is invalid JSON`, { cause }); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Managed npm producer ${label} must be an object`); + } + return value as Record; +} + +async function canonicalRegularFile(path: string, label: string): Promise { + const sourceInfo = await lstat(path); + if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) { + throw new Error(`${label} is unavailable`); + } + const canonical = normalize(await realpath(path)); + const info = await lstat(canonical); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} is unavailable`); + return canonical; +} + +async function canonicalDirectory(path: string, label: string): Promise { + const sourceInfo = await lstat(path); + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + throw new Error(`${label} is unavailable`); + } + const canonical = normalize(await realpath(path)); + const info = await lstat(canonical); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} is unavailable`); + return canonical; +} + +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('Managed npm scratch entry was not created by this provision', { + cause: error, + }); + } + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Managed npm scratch entry is not an owned directory'); + } + const canonical = normalize(await realpath(path)); + if (!isPathWithin(canonical, root)) { + throw new Error('Managed npm scratch entry escapes its authority root'); + } + return canonical; +} + +function hermeticNpmEnvironment( + homeRoot: string, + userConfig: string, + globalConfig: string, + temporaryRoot: string, + compileCacheRoot: string, +): NodeJS.ProcessEnv { + // libuv's Windows home lookup rejects USERPROFILE values at MAX_PATH even + // though Node's filesystem APIs can access the owned long path. The cwd is + // the exact staging project, so a relative home preserves the same authority + // boundary without depending on that fixed-size OS lookup buffer. + const effectiveHomeRoot = process.platform === 'win32' ? join('.maka-runtime', 'home') : homeRoot; + return { + HOME: effectiveHomeRoot, + USERPROFILE: effectiveHomeRoot, + 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' + ? { + APPDATA: join(effectiveHomeRoot, 'AppData', 'Roaming'), + LOCALAPPDATA: join(effectiveHomeRoot, 'AppData', 'Local'), + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + } + : {}), + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), + }; +} + +export interface ManagedDependencyProducerProcessInput { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Readonly>; + readonly monitorRoot: string; + readonly abortSignal?: AbortSignal; + readonly timeoutMs?: number; + /** Soft observation limit, not an OS-enforced peak disk quota. */ + readonly maxObservedBytes: number; + /** Soft observation limit, not an OS-enforced peak inode quota. */ + readonly maxObservedEntries: number; +} + +export interface ManagedDependencyProducerProcessResult { + readonly exitCode: number; + readonly outputTail: string; +} + +export type ManagedDependencyProducerProcessFailureReason = + | 'aborted' + | 'timeout' + | 'filesystem_limit_exceeded' + | 'filesystem_invalid' + | 'output_drain_incomplete' + | 'process_failed'; + +export class ManagedDependencyProducerProcessError extends Error { + readonly name = 'ManagedDependencyProducerProcessError'; + + constructor( + readonly reason: ManagedDependencyProducerProcessFailureReason, + message?: string, + options?: ErrorOptions, + ) { + super(message ?? defaultFailureMessage(reason), options); + } +} + +/** @internal Not a package export; PR3 must bind it to an attested runtime. */ +export async function runManagedDependencyProducerProcessInternal( + input: ManagedDependencyProducerProcessInput, +): Promise { + const executable = input.argv[0]; + if (!executable) throw new TypeError('Managed dependency producer argv must include a program'); + assertPositiveLimit(input.maxObservedBytes, 'observed byte limit'); + assertPositiveLimit(input.maxObservedEntries, 'observed entry limit'); + if (input.abortSignal?.aborted) throw new ManagedDependencyProducerProcessError('aborted'); + const cwd = normalize(await realpath(input.cwd)); + const monitorRoot = normalize(await realpath(input.monitorRoot)); + if (cwd !== monitorRoot) { + throw new TypeError('Managed dependency producer monitor root must equal its owned cwd'); + } + const child = spawn(executable, input.argv.slice(1), { + cwd, + env: input.env as NodeJS.ProcessEnv, + detached: process.platform !== 'win32', + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) as ChildProcessByStdio; + return await observeProducerProcess(child, { ...input, cwd, monitorRoot }); +} + +async function observeProducerProcess( + child: ChildProcessByStdio, + input: ManagedDependencyProducerProcessInput, +): Promise { + const output = createBoundedTail(MAX_OUTPUT_TAIL_BYTES); + child.stdout.on('data', (chunk: Buffer) => output.append(chunk)); + child.stderr.on('data', (chunk: Buffer) => output.append(chunk)); + const lifecycle = manageChildProcessLifecycle( + child, + [ + { key: 'stdout', stream: child.stdout }, + { key: 'stderr', stream: child.stderr }, + ], + { + killGraceMs: DEFAULT_KILL_GRACE_MS, + ioDrainTimeoutMs: DEFAULT_PROCESS_IO_DRAIN_TIMEOUT_MS, + }, + ); + let termination: ManagedDependencyProducerProcessFailureReason | undefined; + let monitorFailure: Error | undefined; + const terminate = (reason: ManagedDependencyProducerProcessFailureReason) => { + if (termination) return; + termination = reason; + lifecycle.terminate(); + }; + const timeout = setTimeout( + () => terminate('timeout'), + input.timeoutMs ?? DEFAULT_PRODUCER_TIMEOUT_MS, + ); + const abort = () => terminate('aborted'); + let quotaCheck: Promise | undefined; + const monitor = setInterval(() => { + if (quotaCheck || termination) return; + const current = enforceFilesystemLimit( + input.monitorRoot, + input.maxObservedBytes, + input.maxObservedEntries, + ) + .catch((error: unknown) => { + monitorFailure = asError(error); + terminate( + error instanceof ManagedDependencyProducerProcessError && + error.reason === 'filesystem_limit_exceeded' + ? 'filesystem_limit_exceeded' + : 'filesystem_invalid', + ); + }) + .finally(() => { + if (quotaCheck === current) quotaCheck = undefined; + }); + quotaCheck = current; + }, QUOTA_MONITOR_INTERVAL_MS); + if (input.abortSignal?.aborted) abort(); + else input.abortSignal?.addEventListener('abort', abort, { once: true }); + try { + const result = await lifecycle.completion; + clearInterval(monitor); + await quotaCheck; + if (termination) { + if (termination === 'filesystem_limit_exceeded') { + throw new ManagedDependencyProducerProcessError( + termination, + 'Managed dependency producer exceeded its observed filesystem limit', + monitorFailure ? { cause: monitorFailure } : undefined, + ); + } + if (termination === 'filesystem_invalid') { + throw new ManagedDependencyProducerProcessError( + termination, + `Managed dependency producer output is invalid${monitorFailure ? `: ${monitorFailure.message}` : ''}`, + monitorFailure ? { cause: monitorFailure } : undefined, + ); + } + throw new ManagedDependencyProducerProcessError(termination); + } + if (!result.ioDrained) { + throw new ManagedDependencyProducerProcessError( + 'output_drain_incomplete', + 'Managed dependency producer output did not drain before its deadline', + ); + } + try { + await enforceFilesystemLimit( + input.monitorRoot, + input.maxObservedBytes, + input.maxObservedEntries, + ); + } catch (error) { + if (error instanceof ManagedDependencyProducerProcessError) throw error; + throw new ManagedDependencyProducerProcessError( + 'filesystem_invalid', + `Managed dependency producer output is invalid: ${asError(error).message}`, + { cause: error }, + ); + } + const exitCode = result.exitCode ?? 1; + if (exitCode !== 0) { + throw new ManagedDependencyProducerProcessError( + 'process_failed', + `Managed dependency producer failed with exit code ${exitCode}${output.text ? `: ${output.text}` : ''}`, + ); + } + return Object.freeze({ exitCode, outputTail: output.text }); + } finally { + clearTimeout(timeout); + clearInterval(monitor); + input.abortSignal?.removeEventListener('abort', abort); + } +} + +async function enforceFilesystemLimit( + root: string, + maxBytes: number, + maxEntries: number, +): Promise { + const inventory = await measureProducerTree(root); + if (inventory.bytes > maxBytes || inventory.entries > maxEntries) { + throw new ManagedDependencyProducerProcessError( + 'filesystem_limit_exceeded', + 'Managed dependency producer exceeded its observed filesystem limit', + ); + } +} + +async function measureProducerTree( + 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(); + if (!directory) break; + let names: string[]; + try { + names = await readdir(directory); + } catch (error) { + if (isMissingPathError(error)) continue; + throw error; + } + for (const name of names) { + const path = resolve(directory, name); + let info; + try { + info = await lstat(path); + } catch (error) { + if (isMissingPathError(error)) continue; + throw error; + } + entries += 1; + if (info.isDirectory() && !info.isSymbolicLink()) { + pending.push(path); + } else if (info.isFile() && !info.isSymbolicLink()) { + bytes += info.size; + } else if (info.isSymbolicLink() && process.platform !== 'win32') { + const target = await readlink(path); + if (isAbsolute(target) || !isPathWithin(resolve(dirname(path), target), root)) { + throw new Error('Managed dependency producer created an escaping symbolic link'); + } + bytes += Buffer.byteLength(target, 'utf8'); + } else { + throw new Error('Managed dependency producer created an unsupported filesystem entry'); + } + if (bytes > Number.MAX_SAFE_INTEGER || entries > Number.MAX_SAFE_INTEGER) { + throw new Error('Managed dependency producer inventory overflowed'); + } + } + } + return Object.freeze({ bytes, entries }); +} + +function isPathWithin(path: string, root: string): boolean { + const value = relative(root, path); + return value === '' || (!value.startsWith('..') && !isAbsolute(value)); +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function assertPositiveLimit(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`Managed dependency producer ${label} must be a positive safe integer`); + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function defaultFailureMessage(reason: ManagedDependencyProducerProcessFailureReason): string { + switch (reason) { + case 'aborted': + return 'Managed dependency producer process was aborted'; + case 'timeout': + return 'Managed dependency producer process timed out'; + case 'filesystem_limit_exceeded': + return 'Managed dependency producer exceeded its observed filesystem limit'; + case 'filesystem_invalid': + return 'Managed dependency producer output is invalid'; + case 'output_drain_incomplete': + return 'Managed dependency producer output did not drain before its deadline'; + case 'process_failed': + return 'Managed dependency producer process failed'; + } +} + +function createBoundedTail(maxBytes: number) { + let tail: Buffer = Buffer.alloc(0); + return { + append(chunk: Buffer) { + tail = appendBoundedTail(tail, chunk, maxBytes); + }, + get text() { + return tail.toString('utf8').trim(); + }, + }; +} + +function appendBoundedTail(current: Buffer, chunk: Buffer, limit: number): Buffer { + if (chunk.length >= limit) return Buffer.from(chunk.subarray(chunk.length - limit)); + if (current.length + chunk.length <= limit) return Buffer.concat([current, chunk]); + return Buffer.concat([current.subarray(current.length - (limit - chunk.length)), chunk]); +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ad2df56c7f..9564aaf55e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -20,6 +20,9 @@ "./test-connection": "./dist/test-connection.js", "./model-fetcher": "./dist/model-fetcher.js", "./materializer": "./dist/materializer.js", + "./async-queue": "./dist/async-queue.js", + "./child-process-lifecycle": "./dist/child-process-lifecycle.js", + "./file-write-lock": "./dist/file-write-lock.js", "./session-manager": "./dist/session-manager.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", diff --git a/packages/runtime/src/__tests__/child-process-lifecycle.test.ts b/packages/runtime/src/__tests__/child-process-lifecycle.test.ts index 970ad3e80b..5ad5049675 100644 --- a/packages/runtime/src/__tests__/child-process-lifecycle.test.ts +++ b/packages/runtime/src/__tests__/child-process-lifecycle.test.ts @@ -80,6 +80,35 @@ test('completion waits for the bounded process-tree signal attempt after root ex }); }); +test('incomplete descendant output forces a tree kill after the direct root exits', async () => { + const child = new EventEmitter() as ChildProcess; + const inheritedOutput = new PassThrough(); + const signals: string[] = []; + const lifecycle = manageChildProcessLifecycle( + child, + [{ key: 'stdout', stream: inheritedOutput }], + { + killGraceMs: 100, + ioDrainTimeoutMs: 10, + async signalProcessTree(signal) { + signals.push(signal); + return true; + }, + }, + ); + + lifecycle.terminate(); + child.emit('exit', 0, 'SIGTERM'); + + assert.deepEqual(await lifecycle.completion, { + exitCode: 0, + signal: 'SIGTERM', + ioDrained: false, + incompleteOutputs: new Set(['stdout']), + }); + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); +}); + test('forced termination rejects boundedly when the direct root never acknowledges exit', async () => { const child = new EventEmitter() as ChildProcess; const signals: string[] = []; diff --git a/packages/runtime/src/child-process-lifecycle.ts b/packages/runtime/src/child-process-lifecycle.ts index 9ce9037eff..a25a6c3916 100644 --- a/packages/runtime/src/child-process-lifecycle.ts +++ b/packages/runtime/src/child-process-lifecycle.ts @@ -263,6 +263,10 @@ export function manageChildProcessLifecycle( function maybeFinish(): void { if (!rootExited || !outputDrainResult || signalsInFlight > 0) return; + if (terminationStarted && !killSent) { + if (outputDrainResult.incomplete.size > 0) forceKill(); + return; + } finish(); } diff --git a/packages/runtime/src/process-tree-terminator.ts b/packages/runtime/src/process-tree-terminator.ts index 95284230fd..6c662bcfb9 100644 --- a/packages/runtime/src/process-tree-terminator.ts +++ b/packages/runtime/src/process-tree-terminator.ts @@ -67,8 +67,8 @@ export async function terminateProcessTree( options: ProcessTreeTerminationOptions, ): Promise { const { pid, signal, fallback, hasExited, beforeSignal } = options; - if (hasExited?.()) return false; if (process.platform === 'win32') { + if (hasExited?.()) return false; if (beforeSignal && !beforeSignal()) return false; if (await killWindowsTree(pid)) return true; if (hasExited?.()) return false; @@ -76,12 +76,14 @@ export async function terminateProcessTree( } const processes = await readPosixProcesses(); - if (hasExited?.()) return false; if (beforeSignal && !beforeSignal()) return false; const escapedDescendantSignaled = forceKillEscapedDescendants(pid, processes); - if (hasExited?.()) return escapedDescendantSignaled; try { + // All production callers spawn a detached POSIX group whose PGID is the + // direct root PID. The group survives the root, so signal it even after + // Node has observed root exit; inherited descendants may still own output + // handles and otherwise escape lifecycle completion. process.kill(-pid, signal); return true; } catch (error) { diff --git a/packages/storage/package.json b/packages/storage/package.json index f5b58c7537..f534f06e14 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -22,6 +22,7 @@ "./interaction-store": "./dist/interaction-store-public.js", "./git-worktree-child-executor": "./dist/git-worktree-child-executor.js", "./memory-bundle-store": "./dist/memory-bundle-store.js", + "./managed-dependency-environment": "./dist/managed-dependency-environment.js", "./managed-workspace-owner": "./dist/managed-workspace-owner.js", "./managed-secret-store": "./dist/managed-secret-store.js", "./activation-secret-injector": "./dist/activation-secret-injector.js", diff --git a/packages/storage/src/__tests__/artifact-writer-lock.test.ts b/packages/storage/src/__tests__/artifact-writer-lock.test.ts index 3fd8da2449..7ce763b589 100644 --- a/packages/storage/src/__tests__/artifact-writer-lock.test.ts +++ b/packages/storage/src/__tests__/artifact-writer-lock.test.ts @@ -115,6 +115,33 @@ test('public Store mutation waits for a child-held writer lock and preserves met }); }); +test('an aborted artifact writer admission stops waiting for a child-held lock', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const holder = await spawnLockHolder(stateRoot); + const abort = new AbortController(); + let entered = false; + try { + const admission = withArtifactWriterLock( + stateRoot, + async () => { + entered = true; + }, + abort.signal, + ); + await assertPending(admission, 'abortable artifact writer admission'); + abort.abort(new DOMException('Baseline admission cancelled', 'AbortError')); + + await assert.rejects(admission, { name: 'AbortError' }); + assert.equal(entered, false); + } finally { + await stopHolder(holder); + } + }); +}); + test('public Store mutations in separate processes reload and publish under one OS lock', { timeout: TEST_TIMEOUT_MS, }, async () => { diff --git a/packages/storage/src/__tests__/git-workspace-service.test.ts b/packages/storage/src/__tests__/git-workspace-service.test.ts index 831b88d97f..b5fb8b7139 100644 --- a/packages/storage/src/__tests__/git-workspace-service.test.ts +++ b/packages/storage/src/__tests__/git-workspace-service.test.ts @@ -176,7 +176,7 @@ describe('Git workspace service', () => { assert.equal((await restarted.inspectManagedWorkspace(adopted)).state, 'ready'); }); - test('reopens an existing managed epoch after the source checkout advances', async () => { + test('rejects reopening an existing managed epoch after the source checkout advances', async () => { const root = await temporaryRoot(); const sourceRoot = await createEligibleSource(join(root, 'source')); const storageRoot = join(root, 'storage'); @@ -199,11 +199,10 @@ describe('Git workspace service', () => { ); const restarted = await serviceAt(storageRoot); - const reopened = await restarted.openManagedWorkspaceFromBinding(created); - - assert.deepEqual(reopened, created); - assert.equal((await restarted.inspectManagedWorkspace(reopened)).state, 'ready'); - assert.equal(await readFile(join(reopened.worktreePath, 'tracked.txt'), 'utf8'), 'tracked\n'); + await assert.rejects( + restarted.openManagedWorkspaceFromBinding(created), + isWorkspaceError('managed_workspace_source_drifted'), + ); }); test('creates a second epoch with a different baseline in the same managed repository', async () => { diff --git a/packages/storage/src/__tests__/managed-dependency-environment.test.ts b/packages/storage/src/__tests__/managed-dependency-environment.test.ts index d154404ecc..8fde21b886 100644 --- a/packages/storage/src/__tests__/managed-dependency-environment.test.ts +++ b/packages/storage/src/__tests__/managed-dependency-environment.test.ts @@ -142,6 +142,37 @@ test('rejects a second authority for the same storage root in one process', asyn await first.close(); }); +test('rejects a pre-aborted acquisition before invoking the producer', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-pre-abort-')); + 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() { + provisionCalls += 1; + }, + }, + }); + const source = dependencySourceForName('pre-abort'); + const abort = new AbortController(); + abort.abort(new DOMException('Cancelled before acquisition', 'AbortError')); + + await assert.rejects( + authority.acquire(computeManagedDependencyEnvironmentIdentity(source), { + ...source, + abortSignal: abort.signal, + }), + { name: 'AbortError' }, + ); + assert.equal(provisionCalls, 0); + await authority.close(); +}); + 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 })); @@ -595,6 +626,128 @@ test('publishes one Maka-owned artifact for concurrent equivalent acquisitions', await authority.close(); }); +test('does not cancel shared provisioning while another acquisition still needs it', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-shared-abort-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let acknowledgeProvision!: () => void; + const provisionStarted = new Promise((resolve) => { + acknowledgeProvision = resolve; + }); + let finishProvision!: () => void; + const provisionAllowed = new Promise((resolve) => { + finishProvision = resolve; + }); + let producerSignal: AbortSignal | undefined; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input) { + producerSignal = input.abortSignal; + acknowledgeProvision(); + await Promise.race([ + provisionAllowed, + new Promise((_resolve, reject) => { + const abort = () => + reject( + input.abortSignal?.reason ?? + new DOMException('Shared provision cancelled', 'AbortError'), + ); + if (input.abortSignal?.aborted) abort(); + else input.abortSignal?.addEventListener('abort', abort, { once: true }); + }), + ]); + input.abortSignal?.throwIfAborted(); + await writeFile(join(input.outputRoot, 'payload'), 'shared\n', 'utf8'); + }, + }, + }); + const source = dependencySourceForName('shared-abort'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const firstAbort = new AbortController(); + const first = authority.acquire(identity, { ...source, abortSignal: firstAbort.signal }); + await provisionStarted; + const second = authority.acquire(identity, source); + + firstAbort.abort(new DOMException('First waiter cancelled', 'AbortError')); + + await assert.rejects(first, { name: 'AbortError' }); + assert.equal(producerSignal?.aborted, false); + finishProvision(); + const lease = await second; + assert.equal(await readFile(join(lease.dependencyRoot, 'payload'), 'utf8'), 'shared\n'); + await lease.release(); + await authority.close(); +}); + +test('starts a fresh publication when a new caller arrives during aborted publication cleanup', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-abort-handoff-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let provisionCalls = 0; + let acknowledgeFirstStart!: () => void; + const firstStarted = new Promise((resolve) => { + acknowledgeFirstStart = resolve; + }); + let acknowledgeFirstAbort!: () => void; + const firstAborted = new Promise((resolve) => { + acknowledgeFirstAbort = resolve; + }); + let finishFirstCleanup!: () => void; + const firstCleanupAllowed = new Promise((resolve) => { + finishFirstCleanup = resolve; + }); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input) { + provisionCalls += 1; + if (provisionCalls === 1) { + acknowledgeFirstStart(); + await new Promise((resolve) => { + const onAbort = () => { + acknowledgeFirstAbort(); + resolve(); + }; + if (input.abortSignal?.aborted) onAbort(); + else input.abortSignal?.addEventListener('abort', onAbort, { once: true }); + }); + await firstCleanupAllowed; + input.abortSignal?.throwIfAborted(); + } + await writeFile(join(input.outputRoot, 'payload'), 'fresh\n', 'utf8'); + }, + }, + }); + t.after(() => authority.close().catch(() => undefined)); + const source = dependencySourceForName('abort-handoff'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const firstAbort = new AbortController(); + const first = authority.acquire(identity, { ...source, abortSignal: firstAbort.signal }); + await firstStarted; + + firstAbort.abort(new DOMException('First caller cancelled', 'AbortError')); + await assert.rejects(first, { name: 'AbortError' }); + await firstAborted; + + const second = authority.acquire(identity, source); + await Promise.resolve(); + assert.equal(provisionCalls, 1, 'fresh publication started before aborted cleanup settled'); + finishFirstCleanup(); + + const lease = await second; + assert.equal(provisionCalls, 2); + assert.equal(await readFile(join(lease.dependencyRoot, 'payload'), 'utf8'), 'fresh\n'); + await lease.release(); + await authority.close(); +}); + test('close drains an acquisition through lease installation before deciding its outcome', async (t) => { const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-close-drain-')); t.after(() => rm(storageRoot, { recursive: true, force: true })); diff --git a/packages/storage/src/__tests__/managed-workspace-baseline.test.ts b/packages/storage/src/__tests__/managed-workspace-baseline.test.ts index 8adbb57c39..f043ebc71c 100644 --- a/packages/storage/src/__tests__/managed-workspace-baseline.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-baseline.test.ts @@ -1041,6 +1041,50 @@ test('reissues execution authority after a real process crash during admission v } }); +test('rejects reopening an accepted baseline after the source HEAD advances', 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 request = openRequest(sourceRoot); + await owner.openManagedWorkspaceBaseline(runtimeStore, request); + + await writeFile(join(sourceRoot, 'tracked.txt'), 'advanced\n', 'utf8'); + await git(sourceRoot, 'add', 'tracked.txt'); + await git( + sourceRoot, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'advance source', + ); + + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, request), + /source no longer matches its accepted Git boundary/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + test('rejects a source tree containing a non-UTF-8 Git path', { skip: process.platform === 'win32', }, async () => { diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index 20a2b24243..bcd50ab051 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -21,7 +21,7 @@ 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 { setTimeout as delay } from 'node:timers/promises'; @@ -39,6 +39,8 @@ import { } from '../managed-workspace-execution-authority-internal.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; +import { createManagedDependencyEnvironmentProducerCapability } from '../managed-dependency-environment.js'; +import { withArtifactWriterLock } from '../artifact-writer-lock.js'; const execFileAsync = promisify(execFile); const cleanup: string[] = []; @@ -227,6 +229,329 @@ test('publishes only a revocable execution scope through its accepted handle', a } }); +test('fails closed before scope issue when dependency provisioning is 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: 'unknown' } as never, + ), + isOwnerError('managed_workspace_execution_options_invalid'), + ); + + 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('binds one Maka-owned dependency environment to the managed execution scope', 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 runtimeIdentity = `sha256:${'9'.repeat(64)}` as const; + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + dependencyEnvironmentProducer: { + capability: createManagedDependencyEnvironmentProducerCapability(runtimeIdentity), + packageManagerName: 'npm', + packageManagerVersion: '12.0.2', + nodeRuntime: { + version: process.versions.node, + abi: process.versions.modules ?? 'unknown', + 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'), + 'export const source = "maka-owned";\n', + ); + }, + }, + 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), + ); + + for (let index = 0; index < 2; index += 1) { + const result = await owner.withManagedWorkspaceExecution( + accepted.executionHandle, + (scope) => + owner.executeReadOnlyFilesystemOperation(scope, { + kind: 'read', + path: 'node_modules/fixture-package/index.js', + }), + { provisioning: 'dependency_environment_v1' }, + ); + assert.deepEqual(result, { + kind: 'read', + content: 'export const source = "maka-owned";\n', + }); + } + assert.equal(provisionCalls, 1); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('cancels dependency provisioning before issuing an execution scope', 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 acknowledgeProvision!: () => void; + const provisionStarted = new Promise((resolve) => { + acknowledgeProvision = resolve; + }); + try { + const runtimeIdentity = `sha256:${'8'.repeat(64)}` as const; + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + dependencyEnvironmentProducer: { + capability: createManagedDependencyEnvironmentProducerCapability(runtimeIdentity), + packageManagerName: 'npm', + packageManagerVersion: '12.0.2', + nodeRuntime: { + version: process.versions.node, + abi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + acknowledgeProvision(); + await new Promise((_resolve, reject) => { + const abort = () => + reject( + input.abortSignal?.reason ?? + new DOMException('Dependency provision cancelled', 'AbortError'), + ); + if (input.abortSignal?.aborted) abort(); + else input.abortSignal?.addEventListener('abort', abort, { once: true }); + }); + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + const abort = new AbortController(); + let callbackEntered = false; + const execution = owner.withManagedWorkspaceExecution( + accepted.executionHandle, + async () => { + callbackEntered = true; + }, + { provisioning: 'dependency_environment_v1', abortSignal: abort.signal }, + ); + await provisionStarted; + + abort.abort(new DOMException('User cancelled managed execution', 'AbortError')); + + await assert.rejects(execution, { name: 'AbortError' }); + assert.equal(callbackEntered, false); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('cancels dependency baseline reads while artifact admission is blocked', 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 releaseLock!: () => void; + const lockMayRelease = new Promise((resolve) => { + releaseLock = resolve; + }); + let lockAcquired!: () => void; + const lockIsHeld = new Promise((resolve) => { + lockAcquired = resolve; + }); + let lockTask: Promise | undefined; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + dependencyEnvironmentProducer: { + capability: createManagedDependencyEnvironmentProducerCapability( + `sha256:${'9'.repeat(64)}`, + ), + packageManagerName: 'npm', + packageManagerVersion: '12.0.2', + nodeRuntime: { + version: process.versions.node, + abi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + }, + async provision() { + throw new Error('Provisioning must not start after cancellation'); + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + lockTask = withArtifactWriterLock(storageRoot, async () => { + lockAcquired(); + await lockMayRelease; + }); + await lockIsHeld; + + const abort = new AbortController(); + let callbackEntered = false; + const execution = owner.withManagedWorkspaceExecution( + accepted.executionHandle, + async () => { + callbackEntered = true; + }, + { provisioning: 'dependency_environment_v1', abortSignal: abort.signal }, + ); + await delay(25); + abort.abort(new DOMException('User cancelled baseline admission', 'AbortError')); + + await assert.rejects( + Promise.race([ + execution, + delay(2_000).then(() => { + throw new Error('Cancellation did not interrupt dependency baseline admission'); + }), + ]), + { name: 'AbortError' }, + ); + assert.equal(callbackEntered, false); + releaseLock(); + await lockTask; + await owner.close(); + } finally { + releaseLock(); + await lockTask?.catch(() => undefined); + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rechecks cancellation immediately before issuing an execution scope', 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')); + const abort = new AbortController(); + let callbackEntered = false; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'before_execution_scope_issue') { + abort.abort(new DOMException('User cancelled before scope issue', 'AbortError')); + } + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + + await assert.rejects( + owner.withManagedWorkspaceExecution( + accepted.executionHandle, + async () => { + callbackEntered = true; + }, + { abortSignal: abort.signal }, + ), + { name: 'AbortError' }, + ); + assert.equal(callbackEntered, false); + 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'); @@ -952,6 +1277,39 @@ async function createEligibleSource(sourceRoot: string): Promise { return realpath(sourceRoot); } +async function createEligibleDependencySource(sourceRoot: string): Promise { + const canonicalSource = await createEligibleSource(sourceRoot); + await Promise.all([ + writeFile( + join(canonicalSource, 'package.json'), + '{"name":"fixture","packageManager":"npm@12.0.2"}\n', + ), + writeFile( + join(canonicalSource, 'package-lock.json'), + '{"name":"fixture","lockfileVersion":3,"packages":{"":{"name":"fixture"}}}\n', + ), + writeFile(join(canonicalSource, '.gitignore'), '.maka-workspace.json\nnode_modules/\n'), + ]); + 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', + ); + 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 188271d2ae..57d224e003 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 @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { join } from 'node:path'; import test from 'node:test'; import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { @@ -27,6 +28,7 @@ import { import { createManagedWorkspaceWorkerBridgeInternal, ManagedWorkspaceWorkerBridgeError, + type ManagedWorkspaceReadOnlyOperation, } from '../managed-workspace-worker-bridge-internal.js'; function scopeFor(ownerToken: object) { @@ -39,6 +41,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[] = []; @@ -122,3 +135,135 @@ test('rejects foreign, expired, mutating, and unknown operations before worker d } assert.equal(dispatches, 0); }); + +test('routes logical node_modules reads through the leased dependency root', async () => { + const ownerToken = {}; + const dependencyRoot = join('/maka', 'dependencies', 'environment', 'node_modules'); + const calls: Array<{ operation: ManagedWorkspaceReadOnlyOperation; cwd: string }> = []; + const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + async execute(input) { + calls.push({ operation: input.operation, cwd: input.cwd }); + return input.operation.kind === 'glob' + ? { kind: 'glob', files: [join('fixture-package', 'index.js')] } + : { kind: 'read', content: 'maka-owned' }; + }, + }); + const scope = dependencyScopeFor(ownerToken, dependencyRoot); + + assert.deepEqual( + await bridge.execute(scope, { + kind: 'read', + path: 'node_modules/fixture-package/index.js', + }), + { kind: 'read', content: 'maka-owned' }, + ); + assert.deepEqual( + await bridge.execute(scope, { + kind: 'glob', + path: 'node_modules', + pattern: '**/*.js', + }), + { kind: 'glob', files: ['node_modules/fixture-package/index.js'] }, + ); + assert.equal(calls[0]?.cwd, '/managed/worktree'); + assert.equal( + calls[0]?.operation.kind === 'read' ? calls[0].operation.path : undefined, + join(dependencyRoot, 'fixture-package', 'index.js'), + ); + assert.equal( + calls[1]?.operation.kind === 'glob' ? calls[1].operation.path : undefined, + dependencyRoot, + ); +}); + +test('rejects non-canonical dependency paths 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', 'dependencies', 'node_modules')); + + for (const path of [ + 'node_modules/../outside.txt', + 'node_modules/./fixture.js', + 'foo/../node_modules/rogue.js', + 'node_modules/fixture.js:alternate-stream', + 'node_modules/fixture\0.js', + ]) { + await assert.rejects( + () => bridge.execute(scope, { kind: 'read', path }), + (error) => + error instanceof ManagedWorkspaceWorkerBridgeError && + error.code === 'managed_workspace_operation_denied', + ); + } + assert.equal(dispatches, 0); +}); + +test('routes Windows node_modules casing through the dependency lease', async (t) => { + if (process.platform !== 'win32') { + t.skip('Windows path identity is case insensitive'); + return; + } + const ownerToken = {}; + const dependencyRoot = join('C:\\maka', 'dependencies', 'node_modules'); + let dispatchedPath: string | undefined; + const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + async execute(input) { + dispatchedPath = input.operation.path; + return { kind: 'read', content: 'maka-owned' }; + }, + }); + const scope = issueManagedWorkspaceExecutionScopeInternal(ownerToken, { + provisioning: 'dependency_environment_v1', + workspaceEffect: 'none', + cwd: 'C:\\managed\\worktree', + dependencyRoot, + binding: Object.freeze({}) as never, + head: Object.freeze({}) as never, + }); + + await bridge.execute(scope, { kind: 'read', path: 'NODE_MODULES/fixture/index.js' }); + + assert.equal(dispatchedPath, join(dependencyRoot, 'fixture', 'index.js')); +}); + +test('never falls back a dependency scope without an exact leased root', async () => { + const ownerToken = {}; + let dispatches = 0; + const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + async execute() { + dispatches += 1; + return { kind: 'read', content: 'unexpected' }; + }, + }); + const missingRoot = issueManagedWorkspaceExecutionScopeInternal(ownerToken, { + provisioning: 'dependency_environment_v1', + workspaceEffect: 'none', + cwd: '/managed/worktree', + binding: Object.freeze({}) as never, + head: Object.freeze({}) as never, + }); + const unexpectedRoot = issueManagedWorkspaceExecutionScopeInternal(ownerToken, { + provisioning: 'canonical_tree_only_v1', + workspaceEffect: 'none', + cwd: '/managed/worktree', + dependencyRoot: '/maka/dependencies/node_modules', + binding: Object.freeze({}) as never, + head: Object.freeze({}) as never, + }); + + for (const scope of [missingRoot, unexpectedRoot]) { + await assert.rejects( + () => bridge.execute(scope, { kind: 'read', path: 'node_modules/a.js' }), + (error) => + error instanceof ManagedWorkspaceWorkerBridgeError && + error.code === 'managed_workspace_operation_denied', + ); + } + assert.equal(dispatches, 0); +}); diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 82ceabf2a7..cf9d7d9910 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -202,6 +202,33 @@ describe('workspace version persistence authority', () => { } }); + it('does not mistake mutated authority control rows for empty schema bootstrap state', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-unbound-authority-state-')); + const dbPath = join(root, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + try { + const raw = new DatabaseSync(dbPath); + try { + raw.exec(` + CREATE TABLE automation_authority_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + ); + INSERT INTO automation_authority_state(singleton, revision) VALUES (1, 1); + `); + } finally { + raw.close(); + } + assert.throws( + () => bindWorkspaceBaselineAuthorityStoreRootInternal(store, TEST_STORAGE_ROOT_ID), + /unbound operational data require explicit storage-root adoption/iu, + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + for (const failpoint of [ 'after_workspace_epoch_event_insert', 'after_workspace_version_event_insert', diff --git a/packages/storage/src/artifact-writer-bootstrap-lock.ts b/packages/storage/src/artifact-writer-bootstrap-lock.ts index 3eec8899bc..5551ea5c8b 100644 --- a/packages/storage/src/artifact-writer-bootstrap-lock.ts +++ b/packages/storage/src/artifact-writer-bootstrap-lock.ts @@ -21,13 +21,15 @@ import { constants as fsConstants } from 'node:fs'; import { lstat, open, type FileHandle } from 'node:fs/promises'; -import { unlock, waitForLock } from 'fs-native-extensions'; +import { setTimeout as delay } from 'node:timers/promises'; +import { tryLock, unlock, waitForLock } from 'fs-native-extensions'; const lockGates = new Map>(); export async function withArtifactWriterBootstrapLock( lockPath: string, operation: () => Promise, + abortSignal?: AbortSignal, ): Promise { const previous = lockGates.get(lockPath); let releaseGate!: () => void; @@ -35,8 +37,9 @@ export async function withArtifactWriterBootstrapLock( releaseGate = resolve; }); lockGates.set(lockPath, current); - await previous?.catch(() => {}); try { + if (previous) await waitForGate(previous, abortSignal); + abortSignal?.throwIfAborted(); const handle = await open( lockPath, fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, @@ -46,9 +49,10 @@ export async function withArtifactWriterBootstrapLock( try { await assertStableRegularFile(handle, lockPath); if (process.platform !== 'win32') await handle.chmod(0o600); - await waitForLock(handle.fd); + await acquireLock(handle.fd, abortSignal); locked = true; await assertStableRegularFile(handle, lockPath); + abortSignal?.throwIfAborted(); return await operation(); } finally { if (locked) releaseLock(handle); @@ -60,6 +64,41 @@ export async function withArtifactWriterBootstrapLock( } } +async function acquireLock(fd: number, abortSignal: AbortSignal | undefined): Promise { + if (!abortSignal) { + await waitForLock(fd); + return; + } + abortSignal.throwIfAborted(); + while (!tryLock(fd)) { + await delay(25, undefined, { signal: abortSignal }); + } +} + +async function waitForGate( + gate: Promise, + abortSignal: AbortSignal | undefined, +): Promise { + if (!abortSignal) { + await gate.catch(() => {}); + return; + } + abortSignal.throwIfAborted(); + await Promise.race([ + gate.catch(() => {}), + new Promise((_resolve, reject) => { + const onAbort = () => + reject( + abortSignal.reason instanceof Error + ? abortSignal.reason + : new DOMException('Artifact writer admission was aborted', 'AbortError'), + ); + abortSignal.addEventListener('abort', onAbort, { once: true }); + gate.finally(() => abortSignal.removeEventListener('abort', onAbort)).catch(() => {}); + }), + ]); +} + async function assertStableRegularFile(handle: FileHandle, lockPath: string): Promise { const [handleStat, pathStat] = await Promise.all([ handle.stat({ bigint: true }), diff --git a/packages/storage/src/artifact-writer-lock.ts b/packages/storage/src/artifact-writer-lock.ts index 20869b83a3..0b7adac6b0 100644 --- a/packages/storage/src/artifact-writer-lock.ts +++ b/packages/storage/src/artifact-writer-lock.ts @@ -22,7 +22,8 @@ import { constants as fsConstants } from 'node:fs'; import { lstat, mkdir, open, realpath, type FileHandle } from 'node:fs/promises'; import { join } from 'node:path'; -import { unlock, waitForLock } from 'fs-native-extensions'; +import { setTimeout as delay } from 'node:timers/promises'; +import { tryLock, unlock, waitForLock } from 'fs-native-extensions'; import { ARTIFACT_WRITER_LOCK_FILE } from './artifact-storage-layout.js'; import { withArtifactWriterBootstrapLock } from './artifact-writer-bootstrap-lock.js'; import { @@ -37,21 +38,32 @@ const lockGates = new Map>(); export async function withArtifactWriterLock( workspaceRoot: string, operation: (canonicalRoot: string) => Promise, + abortSignal?: AbortSignal, ): Promise { + abortSignal?.throwIfAborted(); await mkdir(workspaceRoot, { recursive: true }); const requestedCanonicalRoot = await realpath(workspaceRoot); const bootstrap = await prepareArtifactWriterBootstrapAuthority(requestedCanonicalRoot); - return withArtifactWriterBootstrapLock(bootstrap.lockPath, async () => { - await bootstrap.assertCurrentRoot(); - const authority = await prepareArtifactWriterLockAuthorityForMarkedRoot( - bootstrap.canonicalPath, - ); - if (!authority) return operation(bootstrap.canonicalPath); - if (authority.bootstrapLockPath !== bootstrap.lockPath) { - throw new Error('Storage root identity changed while acquiring its Artifact writer lock'); - } - return withAuthorityArtifactWriterLock(authority, () => operation(bootstrap.canonicalPath)); - }); + return withArtifactWriterBootstrapLock( + bootstrap.lockPath, + async () => { + abortSignal?.throwIfAborted(); + await bootstrap.assertCurrentRoot(); + const authority = await prepareArtifactWriterLockAuthorityForMarkedRoot( + bootstrap.canonicalPath, + ); + if (!authority) return operation(bootstrap.canonicalPath); + if (authority.bootstrapLockPath !== bootstrap.lockPath) { + throw new Error('Storage root identity changed while acquiring its Artifact writer lock'); + } + return withAuthorityArtifactWriterLock( + authority, + () => operation(bootstrap.canonicalPath), + abortSignal, + ); + }, + abortSignal, + ); } export async function withLeaseBoundArtifactWriterLock( @@ -66,6 +78,7 @@ export async function withLeaseBoundArtifactWriterLock( async function withAuthorityArtifactWriterLock( authority: ArtifactWriterLockAuthority, operation: () => Promise, + abortSignal?: AbortSignal, ): Promise { return withArtifactWriterLockPath( join(authority.controlDirectory, ARTIFACT_WRITER_LOCK_FILE), @@ -73,38 +86,51 @@ async function withAuthorityArtifactWriterLock( await authority.assertCurrentRoot(); return operation(); }, + abortSignal, ); } async function withArtifactWriterLockPath( lockPath: string, operation: () => Promise, + abortSignal?: AbortSignal, ): Promise { - return runWithLockGate(lockPath, async () => { - const handle = await openArtifactWriterLock(lockPath); - let acquired = false; - try { - await assertStableRegularFile(handle, lockPath); - await waitForLock(handle.fd); - acquired = true; - await assertStableRegularFile(handle, lockPath); - return await operation(); - } finally { - if (acquired) releaseLock(handle); - await handle.close(); - } - }); + return runWithLockGate( + lockPath, + async () => { + abortSignal?.throwIfAborted(); + const handle = await openArtifactWriterLock(lockPath); + let acquired = false; + try { + await assertStableRegularFile(handle, lockPath); + await acquireLock(handle.fd, abortSignal); + acquired = true; + await assertStableRegularFile(handle, lockPath); + abortSignal?.throwIfAborted(); + return await operation(); + } finally { + if (acquired) releaseLock(handle); + await handle.close(); + } + }, + abortSignal, + ); } -async function runWithLockGate(lockPath: string, operation: () => Promise): Promise { +async function runWithLockGate( + lockPath: string, + operation: () => Promise, + abortSignal?: AbortSignal, +): Promise { const previous = lockGates.get(lockPath); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); lockGates.set(lockPath, current); - await previous?.catch(() => {}); try { + if (previous) await waitForGate(previous, abortSignal); + abortSignal?.throwIfAborted(); return await operation(); } finally { release(); @@ -112,6 +138,41 @@ async function runWithLockGate(lockPath: string, operation: () => Promise) } } +async function acquireLock(fd: number, abortSignal: AbortSignal | undefined): Promise { + if (!abortSignal) { + await waitForLock(fd); + return; + } + abortSignal.throwIfAborted(); + while (!tryLock(fd)) { + await delay(25, undefined, { signal: abortSignal }); + } +} + +async function waitForGate( + gate: Promise, + abortSignal: AbortSignal | undefined, +): Promise { + if (!abortSignal) { + await gate.catch(() => {}); + return; + } + abortSignal.throwIfAborted(); + await Promise.race([ + gate.catch(() => {}), + new Promise((_resolve, reject) => { + const onAbort = () => + reject( + abortSignal.reason instanceof Error + ? abortSignal.reason + : new DOMException('Artifact writer admission was aborted', 'AbortError'), + ); + abortSignal.addEventListener('abort', onAbort, { once: true }); + gate.finally(() => abortSignal.removeEventListener('abort', onAbort)).catch(() => {}); + }), + ]); +} + async function openArtifactWriterLock(lockPath: string): Promise { const handle = await open( lockPath, diff --git a/packages/storage/src/execution-stores-workspace-authority-internal.ts b/packages/storage/src/execution-stores-workspace-authority-internal.ts new file mode 100644 index 0000000000..68182790d0 --- /dev/null +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -0,0 +1,21 @@ +import type { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; + +const workspaceAuthorities = new WeakMap(); + +export function registerExecutionStoresWorkspaceAuthorityInternal( + stores: object, + authority: RuntimeWorkspaceVersionAuthorityStore, +): void { + if (workspaceAuthorities.has(stores)) { + throw new Error('Execution stores workspace authority is already registered'); + } + workspaceAuthorities.set(stores, authority); +} + +export function requireExecutionStoresWorkspaceAuthorityInternal( + stores: object, +): RuntimeWorkspaceVersionAuthorityStore { + const authority = workspaceAuthorities.get(stores); + if (!authority) throw new Error('Execution stores workspace authority is unavailable'); + return authority; +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index ea8e49bcda..7d809128eb 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -76,6 +76,11 @@ import type { ToolCommitResult, ToolOperationRecord, } from './sqlite-runtime-store.js'; +import { registerExecutionStoresWorkspaceAuthorityInternal } from './execution-stores-workspace-authority-internal.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + WorkspaceStorageRootAdoptionRequiredError, +} from './workspace-version-authority-internal.js'; const executionStoresWriterBrand: unique symbol = Symbol('ExecutionStoresWriter'); const executionStoresReaderBrand: unique symbol = Symbol('ExecutionStoresReader'); @@ -258,12 +263,30 @@ export function authenticateExecutionStoresReader( export async function openInteractiveExecutionStoresForWrite( lease: StorageRootLease<'interactive', 'write'>, ): Promise> { + // Establish root identity before any other operational authority creates + // singleton control rows. Existing unbound logical data remain explicitly + // unadopted and therefore unavailable to managed workspace admission. + await initializeWorkspaceStorageRootBinding(lease); const interactionStore = await openSqliteInteractiveInteractionStoreForWrite(lease); return openExecutionStoresForWrite(lease, 'interactive', { interactionStore, }); } +async function initializeWorkspaceStorageRootBinding( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const persistence = await openRuntimeEventPersistence({ workspaceRoot: lease.canonicalPath }); + try { + bindWorkspaceBaselineAuthorityStoreRootInternal(persistence.runtimeCommitStore, lease.rootId); + } catch (error) { + if (!(error instanceof WorkspaceStorageRootAdoptionRequiredError)) throw error; + } finally { + persistence.close(); + } +} + async function openExecutionStoresForWrite( lease: StorageRootLease, kind: K, @@ -312,6 +335,22 @@ async function createExecutionStoresForWrite {}); + throw error; + } + } let conversationOperationalStateStore: ConversationOperationalStateStore; try { conversationOperationalStateStore = createConversationOperationalStateStore( @@ -558,6 +597,7 @@ async function createExecutionStoresForWrite; createManagedWorkspaceFromSource( input: CreateManagedWorkspaceFromSourceInput, + options?: GitWorkspaceOperationOptions, ): Promise; openManagedWorkspaceFromBinding( input: ManagedWorkspaceIdentity, + options?: GitWorkspaceOperationOptions, ): Promise; - inspectManagedWorkspace(binding: ManagedWorkspaceBinding): Promise; + inspectManagedWorkspace( + binding: ManagedWorkspaceBinding, + options?: GitWorkspaceOperationOptions, + ): Promise; + hasManagedWorkspaceBaselinePath( + binding: ManagedWorkspaceBinding, + path: string, + options?: GitWorkspaceOperationOptions, + ): Promise; + readManagedWorkspaceBaselineFile( + binding: ManagedWorkspaceBinding, + path: string, + options?: GitWorkspaceOperationOptions, + ): Promise; quarantineManagedWorkspace( binding: ManagedWorkspaceBinding, reason: string, @@ -388,9 +409,12 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { } this.runtime = new VerifiedGitRuntime(input.gitRuntime); registerManagedBaselineReceiptAuthorityInternal(this, { - issue: (binding) => this.#createOrReuseManagedWorkspaceBaselineReceipt(binding), - require: (request) => this.#requireManagedWorkspaceBaselineReceipt(request), - verify: (receipt) => this.#verifyManagedWorkspaceBaselineReceipt(receipt), + issue: (binding, abortSignal) => + this.#createOrReuseManagedWorkspaceBaselineReceipt(binding, abortSignal), + require: (request, abortSignal) => + this.#requireManagedWorkspaceBaselineReceipt(request, abortSignal), + verify: (receipt, abortSignal) => + this.#verifyManagedWorkspaceBaselineReceipt(receipt, abortSignal), }); } @@ -401,133 +425,147 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { async createManagedWorkspaceFromSource( input: CreateManagedWorkspaceFromSourceInput, + options: GitWorkspaceOperationOptions = {}, ): Promise { - assertOpenIdentity(input); - const runtime = await this.runtime.verify(); - - return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { - const layout = workspaceLayout(canonicalStorageRoot, input); - await ensureOwnedDirectory(layout.managedRoot, canonicalStorageRoot); - await ensureOwnedDirectory(layout.quarantineRoot, layout.managedRoot); - await ensureOwnedDirectory(layout.homePath, layout.managedRoot); - await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); - - const quarantined = await this.resumePendingQuarantine(input, layout, runtime.digest); - if (quarantined) { - throw new GitWorkspaceServiceError( - 'managed_workspace_unavailable', - `Managed workspace instance was quarantined: ${input.workspaceInstanceId}`, - ); - } - - const existingBinding = await readBinding(layout.bindingPath); - if (existingBinding) { - assertBindingMatches(existingBinding, input, layout, runtime.digest); - return this.adoptStoredBinding(input, existingBinding, layout); - } - - const sourceRoot = await canonicalDirectory(input.sourceRoot, 'repository_ineligible'); - const source = await this.inspectSourceRepository(sourceRoot); - const repository = await this.openRepository(input, source, layout, runtime.digest); - const epoch = await this.openEpochArtifact(input, source, repository, layout); - await this.clearIncompleteInstance(input, epoch, layout); - await ensureOwnedDirectory(layout.instanceRoot, layout.managedRoot); - await this.runtime.run( - [ - '--git-dir', - repository.repositoryPath, - 'worktree', - 'add', - '--quiet', - '--detach', - layout.worktreePath, - epoch.baselineCommitOid, - ], - layout.homePath, - ); - await this.input.failpoint?.('after_worktree_materialized'); + return this.runtime.runWithAbortSignal(options.abortSignal, async () => { + assertOpenIdentity(input); + const runtime = await this.runtime.verify(); + + return withArtifactWriterLock( + this.input.storageRoot, + async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, input); + await ensureOwnedDirectory(layout.managedRoot, canonicalStorageRoot); + await ensureOwnedDirectory(layout.quarantineRoot, layout.managedRoot); + await ensureOwnedDirectory(layout.homePath, layout.managedRoot); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + + const quarantined = await this.resumePendingQuarantine(input, layout, runtime.digest); + if (quarantined) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace instance was quarantined: ${input.workspaceInstanceId}`, + ); + } + + const existingBinding = await readBinding(layout.bindingPath); + if (existingBinding) { + assertBindingMatches(existingBinding, input, layout, runtime.digest); + return this.adoptStoredBinding(input, existingBinding, layout); + } + + const sourceRoot = await canonicalDirectory(input.sourceRoot, 'repository_ineligible'); + const source = await this.inspectSourceRepository(sourceRoot); + const repository = await this.openRepository(input, source, layout, runtime.digest); + const epoch = await this.openEpochArtifact(input, source, repository, layout); + await this.clearIncompleteInstance(input, epoch, layout); + await ensureOwnedDirectory(layout.instanceRoot, layout.managedRoot); + await this.runtime.run( + [ + '--git-dir', + repository.repositoryPath, + 'worktree', + 'add', + '--quiet', + '--detach', + layout.worktreePath, + epoch.baselineCommitOid, + ], + layout.homePath, + ); + await this.input.failpoint?.('after_worktree_materialized'); - await this.runtime.run( - [ - '--git-dir', - repository.repositoryPath, - 'worktree', - 'lock', - '--reason', - worktreeLockReason(input), - layout.worktreePath, - ], - layout.homePath, - ); - await this.input.failpoint?.('after_worktree_locked'); - await this.updateRefCas( - repository.repositoryPath, - epoch.headRef, - epoch.baselineCommitOid, - repository.objectFormat, - layout.homePath, + await this.runtime.run( + [ + '--git-dir', + repository.repositoryPath, + 'worktree', + 'lock', + '--reason', + worktreeLockReason(input), + layout.worktreePath, + ], + layout.homePath, + ); + await this.input.failpoint?.('after_worktree_locked'); + await this.updateRefCas( + repository.repositoryPath, + epoch.headRef, + epoch.baselineCommitOid, + repository.objectFormat, + layout.homePath, + ); + await this.input.failpoint?.('after_head_ref_updated'); + + const binding: ManagedWorkspaceBinding = { + schemaVersion: BINDING_SCHEMA_VERSION, + protocol: 'git_managed_workspace_v1', + repositoryId: input.repositoryId, + workspaceId: input.workspaceId, + workspaceEpochId: input.workspaceEpochId, + workspaceInstanceId: input.workspaceInstanceId, + sourceRoot: epoch.sourceRoot, + sourceGitCommonDir: epoch.sourceGitCommonDir, + sourceHeadCommitOid: epoch.sourceHeadCommitOid, + sourceTreeOid: epoch.sourceTreeOid, + repositoryPath: repository.repositoryPath, + worktreePath: normalize(layout.worktreePath), + hooksPath: repository.hooksPath, + baselineCommitOid: epoch.baselineCommitOid, + baselineTreeOid: epoch.baselineTreeOid, + headRef: epoch.headRef, + gitRuntimeSha256: runtime.digest, + objectFormat: repository.objectFormat, + materializationProfileDigest: epoch.materializationProfileDigest, + materializationSemantics: MATERIALIZATION_SEMANTICS, + }; + const inspection = await this.inspectBinding(binding, layout); + if (inspection.state !== 'ready') { + await this.beginQuarantine(binding, layout, 'initial_materialization_drift'); + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Git materialization did not produce a clean managed workspace', + ); + } + await atomicWriteJson(layout.bindingPath, binding); + return binding; + }, + options.abortSignal, ); - await this.input.failpoint?.('after_head_ref_updated'); - - const binding: ManagedWorkspaceBinding = { - schemaVersion: BINDING_SCHEMA_VERSION, - protocol: 'git_managed_workspace_v1', - repositoryId: input.repositoryId, - workspaceId: input.workspaceId, - workspaceEpochId: input.workspaceEpochId, - workspaceInstanceId: input.workspaceInstanceId, - sourceRoot: epoch.sourceRoot, - sourceGitCommonDir: epoch.sourceGitCommonDir, - sourceHeadCommitOid: epoch.sourceHeadCommitOid, - sourceTreeOid: epoch.sourceTreeOid, - repositoryPath: repository.repositoryPath, - worktreePath: normalize(layout.worktreePath), - hooksPath: repository.hooksPath, - baselineCommitOid: epoch.baselineCommitOid, - baselineTreeOid: epoch.baselineTreeOid, - headRef: epoch.headRef, - gitRuntimeSha256: runtime.digest, - objectFormat: repository.objectFormat, - materializationProfileDigest: epoch.materializationProfileDigest, - materializationSemantics: MATERIALIZATION_SEMANTICS, - }; - const inspection = await this.inspectBinding(binding, layout); - if (inspection.state !== 'ready') { - await this.beginQuarantine(binding, layout, 'initial_materialization_drift'); - throw new GitWorkspaceServiceError( - 'managed_workspace_drifted', - 'Git materialization did not produce a clean managed workspace', - ); - } - await atomicWriteJson(layout.bindingPath, binding); - return binding; }); } async openManagedWorkspaceFromBinding( input: ManagedWorkspaceIdentity, + options: GitWorkspaceOperationOptions = {}, ): Promise { - assertOpenIdentity(input); - const runtime = await this.runtime.verify(); - return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { - const layout = workspaceLayout(canonicalStorageRoot, input); - await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); - const quarantined = await this.resumePendingQuarantine(input, layout, runtime.digest); - if (quarantined) { - throw new GitWorkspaceServiceError( - 'managed_workspace_unavailable', - `Managed workspace instance was quarantined: ${input.workspaceInstanceId}`, - ); - } - const binding = await readBinding(layout.bindingPath); - if (!binding) { - throw new GitWorkspaceServiceError( - 'managed_workspace_unavailable', - `Managed workspace binding is unavailable: ${input.workspaceInstanceId}`, - ); - } - assertBindingIdentity(binding, input, layout, runtime.digest); - return this.adoptStoredBinding(input, binding, layout); + return this.runtime.runWithAbortSignal(options.abortSignal, async () => { + assertOpenIdentity(input); + const runtime = await this.runtime.verify(); + return withArtifactWriterLock( + this.input.storageRoot, + async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, input); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + const quarantined = await this.resumePendingQuarantine(input, layout, runtime.digest); + if (quarantined) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace instance was quarantined: ${input.workspaceInstanceId}`, + ); + } + const binding = await readBinding(layout.bindingPath); + if (!binding) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace binding is unavailable: ${input.workspaceInstanceId}`, + ); + } + assertBindingIdentity(binding, input, layout, runtime.digest); + return this.adoptStoredBinding(input, binding, layout); + }, + options.abortSignal, + ); }); } @@ -536,6 +574,10 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { binding: ManagedWorkspaceBinding, layout: WorkspaceLayout, ): Promise { + const currentSource = await this.inspectSourceRepository( + await canonicalDirectory(binding.sourceRoot, 'repository_ineligible'), + ); + assertBindingSourceObservation(binding, currentSource); const repository = await this.requireRepository(input, layout); assertBindingRepository(binding, repository); const epoch = await this.requireEpochArtifact(input, repository, layout); @@ -552,132 +594,274 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { async inspectManagedWorkspace( binding: ManagedWorkspaceBinding, + options: GitWorkspaceOperationOptions = {}, ): Promise { + return this.runtime.runWithAbortSignal(options.abortSignal, async () => { + const runtime = await this.runtime.verify(); + assertBindingShape(binding); + assertOpenIdentity(binding); + 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 readRepositoryRecord(layout.repositoryRecordPath); + if (!repository) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed Git repository record is unavailable: ${binding.repositoryId}`, + ); + } + assertBindingRepository(binding, repository); + await this.assertRepositoryArtifact(repository); + const epoch = await this.requireEpochArtifact(binding, repository, layout); + assertBindingEpoch(binding, epoch); + return this.inspectBinding(binding, layout); + }, + options.abortSignal, + ); + }); + } + + async readManagedWorkspaceBaselineFile( + binding: ManagedWorkspaceBinding, + path: string, + options: GitWorkspaceOperationOptions = {}, + ): Promise { + options.abortSignal?.throwIfAborted(); const runtime = await this.runtime.verify(); + options.abortSignal?.throwIfAborted(); assertBindingShape(binding); assertOpenIdentity(binding); - 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 readRepositoryRecord(layout.repositoryRecordPath); - if (!repository) { - throw new GitWorkspaceServiceError( - 'managed_workspace_unavailable', - `Managed Git repository record is unavailable: ${binding.repositoryId}`, - ); - } - assertBindingRepository(binding, repository); - await this.assertRepositoryArtifact(repository); - const epoch = await this.requireEpochArtifact(binding, repository, layout); - assertBindingEpoch(binding, epoch); - return this.inspectBinding(binding, layout); - }); + const trackedPath = assertManagedTrackedPath(path); + return this.runtime.runWithAbortSignal(options.abortSignal, () => + 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); + await this.assertRepositoryArtifact(repository); + const epoch = await this.requireEpochArtifact(binding, repository, layout); + assertBindingEpoch(binding, epoch); + options.abortSignal?.throwIfAborted(); + return await this.runtime.runBuffer( + [ + '--git-dir', + binding.repositoryPath, + 'show', + `${binding.baselineCommitOid}:${trackedPath}`, + ], + layout.homePath, + ); + }, + options.abortSignal, + ), + ); + } + + async hasManagedWorkspaceBaselinePath( + binding: ManagedWorkspaceBinding, + path: string, + options: GitWorkspaceOperationOptions = {}, + ): Promise { + options.abortSignal?.throwIfAborted(); + const runtime = await this.runtime.verify(); + options.abortSignal?.throwIfAborted(); + assertBindingShape(binding); + assertOpenIdentity(binding); + const trackedPath = assertManagedTrackedPath(path); + return this.runtime.runWithAbortSignal(options.abortSignal, () => + 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); + await this.assertRepositoryArtifact(repository); + const epoch = await this.requireEpochArtifact(binding, repository, layout); + assertBindingEpoch(binding, epoch); + options.abortSignal?.throwIfAborted(); + const output = await this.runtime.runBuffer( + [ + '--git-dir', + binding.repositoryPath, + 'ls-tree', + '-z', + binding.baselineCommitOid, + '--', + trackedPath, + ], + layout.homePath, + ); + return output.byteLength > 0; + }, + options.abortSignal, + ), + ); } async #requireManagedWorkspaceBaselineReceipt( input: CreateManagedWorkspaceFromSourceInput, + abortSignal?: AbortSignal, ): Promise { - const runtime = await this.runtime.verify(); - assertOpenIdentity(input); - return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { - const layout = workspaceLayout(canonicalStorageRoot, input); - await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); - const receipt = await readBaselineReceipt(layout.baselineReceiptPath); - if (!receipt) { - throw new GitWorkspaceServiceError( - 'managed_workspace_unavailable', - 'Canonical workspace baseline receipt is unavailable', - ); - } - assertBindingMatches(receipt.binding, input, layout, runtime.digest); - const summary = await this.requireVerifiedBaselineContext( - receipt.binding, - layout, - runtime.digest, + return this.runtime.runWithAbortSignal(abortSignal, async () => { + const runtime = await this.runtime.verify(); + assertOpenIdentity(input); + return withArtifactWriterLock( + this.input.storageRoot, + async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, input); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + const receipt = await readBaselineReceipt(layout.baselineReceiptPath); + if (!receipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Canonical workspace baseline receipt is unavailable', + ); + } + assertBindingMatches(receipt.binding, input, layout, runtime.digest); + const summary = await this.requireVerifiedBaselineContext( + receipt.binding, + layout, + runtime.digest, + ); + assertBaselineReceiptMatches(receipt, receipt.binding, summary); + return receipt; + }, + abortSignal, ); - assertBaselineReceiptMatches(receipt, receipt.binding, summary); - return receipt; }); } async #createOrReuseManagedWorkspaceBaselineReceipt( binding: ManagedWorkspaceBinding, + abortSignal?: AbortSignal, ): Promise { - const runtime = await this.runtime.verify(); - assertBindingShape(binding); - assertOpenIdentity(binding); - return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { - const layout = workspaceLayout(canonicalStorageRoot, binding); - const summary = await this.requireVerifiedBaselineContext(binding, layout, runtime.digest); - const existing = await readBaselineReceipt(layout.baselineReceiptPath); - if (existing) { - assertBaselineReceiptMatches(existing, binding, summary); - return existing; - } - const identities = deriveBaselineReceiptIdentities(binding); - const receipt: ManagedWorkspaceBaselineReceiptV1 = { - schemaVersion: BASELINE_RECEIPT_SCHEMA_VERSION, - protocol: 'maka_managed_workspace_baseline_receipt_v1', - binding, - workspaceVersionId: identities.workspaceVersionId, - policyVersion: 1, - policyHash: MANAGED_BASELINE_POLICY_HASH_V1, - epochOpenedEventId: identities.epochOpenedEventId, - baselineAcceptedEventId: identities.baselineAcceptedEventId, - treeDeltaDigest: summary.treeDeltaDigest, - changedFileCount: summary.changedFileCount, - deletedFileCount: 0, - }; - await atomicWriteJson(layout.baselineReceiptPath, receipt); - await this.input.failpoint?.('after_baseline_receipt'); - const durable = await readBaselineReceipt(layout.baselineReceiptPath); - if (!durable) { - throw new GitWorkspaceServiceError( - 'managed_workspace_unavailable', - 'Managed workspace baseline receipt was not durable', - ); - } - assertBaselineReceiptMatches(durable, binding, summary); - return durable; + return this.runtime.runWithAbortSignal(abortSignal, async () => { + const runtime = await this.runtime.verify(); + assertBindingShape(binding); + assertOpenIdentity(binding); + return withArtifactWriterLock( + this.input.storageRoot, + async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + const summary = await this.requireVerifiedBaselineContext( + binding, + layout, + runtime.digest, + ); + const existing = await readBaselineReceipt(layout.baselineReceiptPath); + if (existing) { + assertBaselineReceiptMatches(existing, binding, summary); + return existing; + } + const identities = deriveBaselineReceiptIdentities(binding); + const receipt: ManagedWorkspaceBaselineReceiptV1 = { + schemaVersion: BASELINE_RECEIPT_SCHEMA_VERSION, + protocol: 'maka_managed_workspace_baseline_receipt_v1', + binding, + workspaceVersionId: identities.workspaceVersionId, + policyVersion: 1, + policyHash: MANAGED_BASELINE_POLICY_HASH_V1, + epochOpenedEventId: identities.epochOpenedEventId, + baselineAcceptedEventId: identities.baselineAcceptedEventId, + treeDeltaDigest: summary.treeDeltaDigest, + changedFileCount: summary.changedFileCount, + deletedFileCount: 0, + }; + await atomicWriteJson(layout.baselineReceiptPath, receipt); + await this.input.failpoint?.('after_baseline_receipt'); + const durable = await readBaselineReceipt(layout.baselineReceiptPath); + if (!durable) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Managed workspace baseline receipt was not durable', + ); + } + assertBaselineReceiptMatches(durable, binding, summary); + return durable; + }, + abortSignal, + ); }); } async #verifyManagedWorkspaceBaselineReceipt( receipt: ManagedWorkspaceBaselineReceiptV1, + abortSignal?: AbortSignal, ): Promise { - const runtime = await this.runtime.verify(); - assertBaselineReceiptShape(receipt); - assertOpenIdentity(receipt.binding); - await withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { - const layout = workspaceLayout(canonicalStorageRoot, receipt.binding); - const summary = await this.requireVerifiedBaselineContext( - receipt.binding, - layout, - runtime.digest, + await this.runtime.runWithAbortSignal(abortSignal, async () => { + const runtime = await this.runtime.verify(); + assertBaselineReceiptShape(receipt); + assertOpenIdentity(receipt.binding); + await withArtifactWriterLock( + this.input.storageRoot, + async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, receipt.binding); + const summary = await this.requireVerifiedBaselineContext( + receipt.binding, + layout, + runtime.digest, + ); + const durable = await readBaselineReceipt(layout.baselineReceiptPath); + if (!durable || !sameBaselineReceipt(durable, receipt)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed workspace baseline receipt does not match its durable artifact', + ); + } + assertBaselineReceiptMatches(durable, receipt.binding, summary); + }, + abortSignal, ); - const durable = await readBaselineReceipt(layout.baselineReceiptPath); - if (!durable || !sameBaselineReceipt(durable, receipt)) { - throw new GitWorkspaceServiceError( - 'managed_workspace_identity_conflict', - 'Managed workspace baseline receipt does not match its durable artifact', - ); - } - assertBaselineReceiptMatches(durable, receipt.binding, summary); }); } @@ -1628,6 +1812,8 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { } class VerifiedGitRuntime { + readonly #abortContext = new AsyncLocalStorage(); + constructor(private readonly input: VerifiedGitRuntimeInput) { if ( !isAbsolute(input.executablePath) || @@ -1661,12 +1847,27 @@ class VerifiedGitRuntime { return this.verifyOnce(); } + runWithAbortSignal( + abortSignal: AbortSignal | undefined, + operation: () => Promise, + ): Promise { + abortSignal?.throwIfAborted(); + if (!abortSignal) return operation(); + return this.#abortContext.run(abortSignal, async () => { + const result = await operation(); + abortSignal.throwIfAborted(); + return result; + }); + } + async run( args: readonly string[], homePath?: string, extraEnv?: NodeJS.ProcessEnv, acceptedExitCodes: readonly number[] = [], ): Promise { + const abortSignal = this.#abortContext.getStore(); + abortSignal?.throwIfAborted(); const runtime = await this.verify(); const hooksPath = homePath ? join(homePath, 'empty-hooks') : dirname(runtime.executablePath); if (homePath) { @@ -1686,6 +1887,7 @@ class VerifiedGitRuntime { encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER_BYTES, timeout: GIT_TIMEOUT_MS, + ...(abortSignal ? { signal: abortSignal } : {}), windowsHide: true, }, ); @@ -1701,6 +1903,8 @@ class VerifiedGitRuntime { homePath?: string, extraEnv?: NodeJS.ProcessEnv, ): Promise { + const abortSignal = this.#abortContext.getStore(); + abortSignal?.throwIfAborted(); const runtime = await this.verify(); const hooksPath = homePath ? join(homePath, 'empty-hooks') : dirname(runtime.executablePath); if (homePath) { @@ -1717,6 +1921,7 @@ class VerifiedGitRuntime { encoding: 'buffer', maxBuffer: GIT_MAX_BUFFER_BYTES, timeout: GIT_TIMEOUT_MS, + ...(abortSignal ? { signal: abortSignal } : {}), windowsHide: true, }, ); @@ -1741,6 +1946,8 @@ class VerifiedGitRuntime { repositoryPath: string, homePath: string, ): Promise { + const abortSignal = this.#abortContext.getStore(); + abortSignal?.throwIfAborted(); const packRuntime = await this.verify(); const hooksPath = join(homePath, 'empty-hooks'); await mkdir(hooksPath, { recursive: true }); @@ -1752,9 +1959,11 @@ class VerifiedGitRuntime { cwd: homePath, env: isolatedGitEnvironment(this.input, homePath), stdio: ['pipe', 'pipe', 'pipe'], + ...(abortSignal ? { signal: abortSignal } : {}), windowsHide: true, }, ); + const packExit = waitForChildExit(pack); let indexRuntime: Awaited>; try { // Each Git process is an independent trust decision. Re-check the @@ -1763,7 +1972,7 @@ class VerifiedGitRuntime { indexRuntime = await this.verify(); } catch (error) { pack.kill('SIGKILL'); - await waitForChildExit(pack).catch(() => undefined); + await packExit.catch(() => undefined); throw error; } const index = spawn( @@ -1773,12 +1982,15 @@ class VerifiedGitRuntime { cwd: homePath, env: isolatedGitEnvironment(this.input, homePath), stdio: ['pipe', 'ignore', 'pipe'], + ...(abortSignal ? { signal: abortSignal } : {}), windowsHide: true, }, ); + const indexExit = waitForChildExit(index); if (!pack.stdin || !pack.stdout || !index.stdin) { pack.kill('SIGKILL'); index.kill('SIGKILL'); + await Promise.all([packExit.catch(() => undefined), indexExit.catch(() => undefined)]); throw new Error('Git tree import did not create the required process pipes'); } const packStderr = collectBoundedStderr(pack.stderr); @@ -1793,10 +2005,7 @@ class VerifiedGitRuntime { index.kill('SIGKILL'); }, GIT_TIMEOUT_MS); try { - const [packCode, indexCode] = await Promise.all([ - waitForChildExit(pack), - waitForChildExit(index), - ]); + const [packCode, indexCode] = await Promise.all([packExit, indexExit]); if (packCode !== 0 || indexCode !== 0) { throw new Error( `Git tree import failed: pack=${packCode} index=${indexCode} ` + @@ -2097,6 +2306,24 @@ function assertSameSourceObservation( } } +function assertBindingSourceObservation( + binding: ManagedWorkspaceBinding, + current: SourceRepositoryInspection, +): void { + if ( + !samePath(binding.sourceRoot, current.sourceRoot) || + !samePath(binding.sourceGitCommonDir, current.gitCommonDir) || + binding.sourceHeadCommitOid !== current.headCommitOid || + binding.sourceTreeOid !== current.treeOid || + binding.objectFormat !== current.objectFormat + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_source_drifted', + 'Managed workspace source no longer matches its accepted Git boundary', + ); + } +} + async function readEpochArtifact(path: string): Promise { const value = await readJson(path); if (value === undefined) return undefined; @@ -2640,6 +2867,26 @@ function compactIdentity(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 20); } +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 managedHeadRef(workspaceId: string, workspaceEpochId: string): string { return `refs/maka/workspaces/${workspaceId}/epochs/${workspaceEpochId}/head`; } diff --git a/packages/storage/src/managed-baseline-receipt-authority-internal.ts b/packages/storage/src/managed-baseline-receipt-authority-internal.ts index e1c18ee458..c35fa36a96 100644 --- a/packages/storage/src/managed-baseline-receipt-authority-internal.ts +++ b/packages/storage/src/managed-baseline-receipt-authority-internal.ts @@ -25,9 +25,15 @@ import type { } from './git-workspace-service.js'; export interface ManagedBaselineReceiptAuthorityInternal { - issue(binding: ManagedWorkspaceBinding): Promise; - require(input: CreateManagedWorkspaceFromSourceInput): Promise; - verify(receipt: ManagedWorkspaceBaselineReceiptV1): Promise; + issue( + binding: ManagedWorkspaceBinding, + abortSignal?: AbortSignal, + ): Promise; + require( + input: CreateManagedWorkspaceFromSourceInput, + abortSignal?: AbortSignal, + ): Promise; + verify(receipt: ManagedWorkspaceBaselineReceiptV1, abortSignal?: AbortSignal): Promise; } const receiptAuthorities = new WeakMap< diff --git a/packages/storage/src/managed-dependency-environment.ts b/packages/storage/src/managed-dependency-environment.ts index 85065dde3c..140825ee01 100644 --- a/packages/storage/src/managed-dependency-environment.ts +++ b/packages/storage/src/managed-dependency-environment.ts @@ -208,6 +208,13 @@ interface PublishedManagedDependencyEnvironment { readonly dependencyRoot: string; } +interface InflightManagedDependencyPublication { + readonly controller: AbortController; + readonly task: Promise; + waiters: number; + settled: boolean; +} + interface DependencyReceiptAuthority { read(digest: string): ManagedDependencyEnvironmentReceiptV1 | undefined; list(): readonly ManagedDependencyEnvironmentReceiptV1[]; @@ -456,7 +463,7 @@ async function createManagedDependencyEnvironmentAuthorityForOwner( receiptAuthority.close(); throw error; } - const inflight = new Map>(); + const inflight = new Map(); const leaseCounts = new Map(); const pendingCounts = new Map(); let state: 'open' | 'draining' | 'closed' = 'open'; @@ -489,6 +496,7 @@ async function createManagedDependencyEnvironmentAuthorityForOwner( async acquire(identity, source) { const finishAcquisition = beginAcquisition(); try { + source.abortSignal?.throwIfAborted(); assertCanonicalIdentity(identity, source); if ( identity.packageManagerName !== input.producer.packageManagerName || @@ -508,23 +516,71 @@ async function createManagedDependencyEnvironmentAuthorityForOwner( pendingCounts.set(digest, (pendingCounts.get(digest) ?? 0) + 1); let artifact: PublishedManagedDependencyEnvironment; try { - let task = inflight.get(digest); - if (!task) { - task = openOrPublishEnvironment({ + let publication: InflightManagedDependencyPublication; + for (;;) { + const current = inflight.get(digest); + if (current?.controller.signal.aborted) { + // The last waiter revoked this publication, but its producer may + // still be draining. A later caller must not inherit that abort. + // Wait for owner cleanup, then re-observe the canonical slot. + await waitForPublication( + current.task.then( + () => undefined, + () => undefined, + ), + source.abortSignal, + ); + source.abortSignal?.throwIfAborted(); + continue; + } + if (current) { + publication = current; + break; + } + const controller = new AbortController(); + let pending!: InflightManagedDependencyPublication; + const task = openOrPublishEnvironment({ environmentsRoot, receiptAuthority, stagingRoot, identity, - source, + source: { ...source, abortSignal: controller.signal }, producer: input.producer, failpoint: input.failpoint, - }).finally(() => inflight.delete(digest)); - inflight.set(digest, task); + }).finally(() => { + pending.settled = true; + if (inflight.get(digest) === pending) inflight.delete(digest); + }); + pending = { + controller, + task, + waiters: 0, + settled: false, + }; + publication = pending; + inflight.set(digest, publication); + break; } - artifact = await task; + publication.waiters += 1; + try { + artifact = await waitForPublication(publication.task, source.abortSignal); + } finally { + publication.waiters -= 1; + if ( + publication.waiters === 0 && + !publication.settled && + !publication.controller.signal.aborted + ) { + publication.controller.abort( + new DOMException('Managed dependency acquisition was abandoned', 'AbortError'), + ); + } + } + source.abortSignal?.throwIfAborted(); const now = new Date(); await utimes(dirname(artifact.dependencyRoot), now, now); await input.failpoint?.('before_environment_lease'); + source.abortSignal?.throwIfAborted(); leaseCounts.set(digest, (leaseCounts.get(digest) ?? 0) + 1); } finally { decrementCount(pendingCounts, digest); @@ -564,6 +620,7 @@ async function createManagedDependencyEnvironmentAuthorityForOwner( state = 'draining'; closeTask = (async () => { await waitForAcquisitions(); + await Promise.allSettled([...inflight.values()].map(({ task }) => task)); let gcError: unknown; try { await gcTask; @@ -1229,6 +1286,33 @@ function decrementCount(counts: Map, key: string): void { else counts.delete(key); } +function waitForPublication(task: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return task; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (operation: () => void) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + operation(); + }; + const onAbort = () => + finish(() => + reject( + signal.reason instanceof Error + ? signal.reason + : new DOMException('Managed dependency acquisition was aborted', 'AbortError'), + ), + ); + signal.addEventListener('abort', onAbort, { once: true }); + task.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + async function syncDirectory(path: string): Promise { const handle = await open(path, 'r'); try { diff --git a/packages/storage/src/managed-workspace-execution-authority-internal.ts b/packages/storage/src/managed-workspace-execution-authority-internal.ts index 19676b9cde..d0e4f3d967 100644 --- a/packages/storage/src/managed-workspace-execution-authority-internal.ts +++ b/packages/storage/src/managed-workspace-execution-authority-internal.ts @@ -61,9 +61,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 5ba63d7293..555ffa172d 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -49,6 +49,14 @@ import { } from './managed-workspace-worker-bridge-internal.js'; import type { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + type ManagedDependencyEnvironmentAuthority, + type ManagedDependencyEnvironmentFailpoint, + type ManagedDependencyEnvironmentProducer, + type ManagedDependencyEnvironmentProducerInput, +} from './managed-dependency-environment.js'; import { assertInteractiveRootOwner, authenticateInteractiveRootOwner, @@ -62,6 +70,8 @@ import { commitWorkspaceBaselineInternal, readWorkspaceHeadInternal, } from './workspace-version-authority-internal.js'; +import type { InteractiveExecutionStoresWriter } from './execution-stores.js'; +import { requireExecutionStoresWorkspaceAuthorityInternal } from './execution-stores-workspace-authority-internal.js'; // RuntimeEvent order is assigned by the SQLite authority spine. M0 therefore // uses a protocol-fixed logical timestamp and keeps unauthenticated wall-clock @@ -74,8 +84,12 @@ 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'; + | 'managed_workspace_execution_handle_invalid' + | 'managed_workspace_execution_options_invalid'; export class ManagedWorkspaceOwnerError extends Error { constructor( @@ -93,14 +107,17 @@ export interface OpenManagedWorkspaceOwnerInput { readonly gitRuntime: VerifiedGitRuntimeInput; readonly failpoint?: (point: ManagedWorkspaceOwnerFailpoint) => void | Promise; readonly filesystemWorker?: ManagedWorkspaceFilesystemWorker; + readonly dependencyEnvironmentProducer?: ManagedDependencyEnvironmentProducer; } export type ManagedWorkspaceOwnerFailpoint = | GitWorkspaceServiceFailpoint + | ManagedDependencyEnvironmentFailpoint | 'after_initial_store_root_validation' | 'after_baseline_authority_commit' | 'after_post_commit_artifact_verification' - | 'after_execution_artifact_verification'; + | 'after_execution_artifact_verification' + | 'before_execution_scope_issue'; export type OpenManagedWorkspaceBaselineInput = CreateManagedWorkspaceFromSourceInput; @@ -110,15 +127,31 @@ export interface OpenManagedWorkspaceBaselineResult { readonly executionHandle: ManagedWorkspaceExecutionHandle; } +export interface ManagedWorkspaceExecutionOptions { + readonly provisioning?: 'canonical_tree_only_v1' | 'dependency_environment_v1'; + readonly abortSignal?: AbortSignal; +} + +export interface ManagedWorkspaceAdmissionOptions { + readonly abortSignal?: AbortSignal; +} + export interface ManagedWorkspaceOwner { readonly state: 'ready' | 'closing' | 'closed'; openManagedWorkspaceBaseline( store: RuntimeWorkspaceVersionAuthorityStore, input: OpenManagedWorkspaceBaselineInput, + options?: ManagedWorkspaceAdmissionOptions, + ): Promise; + openManagedWorkspaceBaselineFromExecutionStores( + stores: InteractiveExecutionStoresWriter, + input: OpenManagedWorkspaceBaselineInput, + options?: ManagedWorkspaceAdmissionOptions, ): Promise; withManagedWorkspaceExecution( handle: ManagedWorkspaceExecutionHandle, operation: (scope: ManagedWorkspaceExecutionScope) => Promise, + options?: ManagedWorkspaceExecutionOptions, ): Promise; executeReadOnlyFilesystemOperation( scope: ManagedWorkspaceExecutionScope, @@ -129,6 +162,8 @@ export interface ManagedWorkspaceOwner { } export type { + ManagedDependencyEnvironmentProducer, + ManagedDependencyEnvironmentProducerInput, ManagedWorkspaceExecutionHandle, ManagedWorkspaceExecutionScope, ManagedWorkspaceFilesystemWorker, @@ -166,12 +201,21 @@ 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, + ...(input.failpoint ? { failpoint: (point) => input.failpoint?.(point) } : {}), + }) + : undefined; const owner = new ManagedWorkspaceOwnerImpl( rootOwner, service, requireManagedBaselineReceiptAuthorityInternal(service), input.failpoint, input.filesystemWorker, + input.dependencyEnvironmentProducer, + dependencyAuthority, ); owners.set(rootOwner, owner); return owner; @@ -202,6 +246,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 @@ -223,8 +269,10 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { async openManagedWorkspaceBaseline( store: RuntimeWorkspaceVersionAuthorityStore, input: OpenManagedWorkspaceBaselineInput, + options: ManagedWorkspaceAdmissionOptions = {}, ): Promise { return this.#run(async () => { + options.abortSignal?.throwIfAborted(); await assertWorkspaceBaselineAuthorityStoreRootInternal( store, this.rootOwner.capability.canonicalPath, @@ -236,11 +284,17 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { input.workspaceId, input.workspaceEpochId, ); - const receipt = existingHead ? await this.receiptAuthority.require(input) : undefined; + const receipt = existingHead + ? await this.receiptAuthority.require(input, options.abortSignal) + : undefined; const binding = receipt - ? await this.#openReadyBinding(receipt.binding) - : await this.#requireReady(await this.service.createManagedWorkspaceFromSource(input)); - const durableReceipt = receipt ?? (await this.receiptAuthority.issue(binding)); + ? await this.#openReadyBinding(receipt.binding, options.abortSignal) + : await this.#requireReady( + await this.service.createManagedWorkspaceFromSource(input, options), + options.abortSignal, + ); + const durableReceipt = + receipt ?? (await this.receiptAuthority.issue(binding, options.abortSignal)); if ( existingHead && (existingHead.workspaceVersionId !== durableReceipt.workspaceVersionId || @@ -291,7 +345,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { // Canonical acceptance never makes a missing Git artifact acceptable. // Reverify after the SQLite transaction so post-accept artifact loss is // reported fail-closed instead of returning a usable workspace head. - await this.receiptAuthority.verify(durableReceipt); + await this.receiptAuthority.verify(durableReceipt, options.abortSignal); await this.failpoint?.('after_post_commit_artifact_verification'); // The root marker is mutable host state and is not covered by receipt // verification. Revalidate its identity at the final return gate without @@ -313,11 +367,25 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { }); } + openManagedWorkspaceBaselineFromExecutionStores( + stores: InteractiveExecutionStoresWriter, + input: OpenManagedWorkspaceBaselineInput, + options: ManagedWorkspaceAdmissionOptions = {}, + ): Promise { + return this.openManagedWorkspaceBaseline( + requireExecutionStoresWorkspaceAuthorityInternal(stores), + input, + options, + ); + } + async withManagedWorkspaceExecution( handle: ManagedWorkspaceExecutionHandle, operation: (scope: ManagedWorkspaceExecutionScope) => Promise, + options: ManagedWorkspaceExecutionOptions = {}, ): Promise { return this.#run(async () => { + options.abortSignal?.throwIfAborted(); let accepted; try { accepted = requireManagedWorkspaceExecutionHandleInternal( @@ -336,50 +404,71 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { // can stop after a real, completed artifact verification. The ordinary // production path performs only the final proof below. if (this.failpoint) { - await this.#verifyExecutionArtifactOrQuarantine(accepted.binding, accepted.receipt); + await this.#verifyExecutionArtifactOrQuarantine( + accepted.binding, + accepted.receipt, + options.abortSignal, + ); 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 = requireExecutionProvisioning(options); + const dependencyLease = + provisioning === 'dependency_environment_v1' + ? await this.#acquireDependencyEnvironment(binding, options.abortSignal) + : undefined; try { - return await this.#executionContext.run(this.#executionOwnerToken, () => operation(scope)); + options.abortSignal?.throwIfAborted(); + // Provisioning may take minutes. External editors do not cooperate + // with Maka's root lease, so the durable Git artifact and workspace + // head are revalidated only after provisioning and immediately before + // the scoped capability is issued. + await this.#assertCurrentRootIdentity(); + await this.#verifyExecutionArtifactOrQuarantine( + accepted.binding, + accepted.receipt, + options.abortSignal, + ); + 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); + await this.failpoint?.('before_execution_scope_issue'); + options.abortSignal?.throwIfAborted(); + 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(); } }); } @@ -410,6 +499,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { this.#closeTask ??= (async () => { this.#state = 'closing'; await this.#waitForDrain(); + await this.dependencyAuthority?.close(); this.#state = 'closed'; })(); return this.#closeTask; @@ -443,8 +533,11 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { return new Promise((resolve) => this.#drainWaiters.add(resolve)); } - async #requireReady(binding: ManagedWorkspaceBinding): Promise { - const inspection = await this.service.inspectManagedWorkspace(binding); + async #requireReady( + binding: ManagedWorkspaceBinding, + abortSignal?: AbortSignal, + ): Promise { + const inspection = await this.service.inspectManagedWorkspace(binding, { abortSignal }); if (inspection.state === 'ready') return binding; const quarantine = await this.service.quarantineManagedWorkspace( binding, @@ -456,9 +549,15 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ); } - async #openReadyBinding(binding: ManagedWorkspaceBinding): Promise { + async #openReadyBinding( + binding: ManagedWorkspaceBinding, + abortSignal?: AbortSignal, + ): Promise { try { - return await this.#requireReady(await this.service.openManagedWorkspaceFromBinding(binding)); + return await this.#requireReady( + await this.service.openManagedWorkspaceFromBinding(binding, { abortSignal }), + abortSignal, + ); } catch (error) { if ( !(error instanceof GitWorkspaceServiceError) || @@ -481,9 +580,10 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { async #verifyExecutionArtifactOrQuarantine( binding: ManagedWorkspaceBinding, receipt: ManagedWorkspaceBaselineReceiptV1, + abortSignal?: AbortSignal, ): Promise { try { - await this.receiptAuthority.verify(receipt); + await this.receiptAuthority.verify(receipt, abortSignal); } catch (error) { if ( !(error instanceof GitWorkspaceServiceError) || @@ -502,6 +602,126 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ); } } + + async #acquireDependencyEnvironment( + binding: ManagedWorkspaceBinding, + abortSignal: AbortSignal | undefined, + ) { + 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', { + ...(abortSignal ? { abortSignal } : {}), + }) + ) { + 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', { + ...(abortSignal ? { abortSignal } : {}), + }), + this.service.readManagedWorkspaceBaselineFile(binding, 'package-lock.json', { + ...(abortSignal ? { abortSignal } : {}), + }), + ]); + 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; + if (isAbortError(error)) throw error; + if (abortSignal?.aborted) { + throw abortSignal.reason instanceof Error + ? abortSignal.reason + : new DOMException('Managed dependency provisioning was aborted', 'AbortError'); + } + throw new ManagedWorkspaceOwnerError( + 'managed_dependency_provision_failed', + 'Unable to provision the managed dependency environment', + { cause: error }, + ); + } + } +} + +function isAbortError(error: unknown): error is Error { + return error instanceof Error && error.name === 'AbortError'; +} + +function requireExecutionProvisioning( + options: ManagedWorkspaceExecutionOptions, +): NonNullable { + const provisioning = options.provisioning ?? 'canonical_tree_only_v1'; + if (provisioning !== 'canonical_tree_only_v1' && provisioning !== 'dependency_environment_v1') { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_execution_options_invalid', + 'Managed workspace execution provisioning profile is invalid', + ); + } + return provisioning; +} + +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 bf07b53e29..cdcd8173c1 100644 --- a/packages/storage/src/managed-workspace-worker-bridge-internal.ts +++ b/packages/storage/src/managed-workspace-worker-bridge-internal.ts @@ -22,6 +22,7 @@ import { type ExecutionBoundary, } from '@maka/core/sandbox-boundary'; import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; +import { isAbsolute, join, normalize, parse, relative, sep } from 'node:path'; import { requireManagedWorkspaceExecutionScopeInternal, type ManagedWorkspaceExecutionScope, @@ -120,23 +121,163 @@ export function createManagedWorkspaceWorkerBridgeInternal( ); } const state = requireManagedWorkspaceExecutionScopeInternal(ownerToken, scope); - if (state.workspaceEffect !== 'none' || state.provisioning !== 'canonical_tree_only_v1') { + const hasDependencyRoot = typeof state.dependencyRoot === 'string'; + if ( + state.workspaceEffect !== 'none' || + (state.provisioning === 'dependency_environment_v1') !== hasDependencyRoot + ) { throw new ManagedWorkspaceWorkerBridgeError( 'managed_workspace_operation_denied', 'Managed workspace execution scope does not permit filesystem mutation', ); } - return await worker.execute({ - operation, + const routedOperation = 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: routedOperation, cwd: state.cwd, - executionBoundary: createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0), + executionBoundary: createManagedExecutionBoundary(profile, 0), ...(abortSignal ? { abortSignal } : {}), }); + return remapDependencyResult(result, state.dependencyRoot, routedOperation); }, }; return Object.freeze(bridge); } +function routeDependencyOperation( + operation: ManagedWorkspaceReadOnlyOperation, + cwd: string, + dependencyRoot: string | undefined, +): ManagedWorkspaceReadOnlyOperation { + if (!dependencyRoot) return operation; + let segments: string[]; + if (isAbsolute(operation.path)) { + assertCanonicalPathSegments(operation.path.slice(parse(operation.path).root.length)); + const logicalDependencyRoot = join(cwd, 'node_modules'); + const suffix = relative(logicalDependencyRoot, operation.path); + if (suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return operation; + segments = suffix === '' ? [] : suffix.split(/[\\/]/u); + } else { + const portableSegments = operation.path.replaceAll('\\', '/').split('/'); + assertCanonicalSegments(portableSegments); + if (!isNodeModulesSegment(portableSegments[0])) return operation; + segments = portableSegments.slice(1); + } + assertCanonicalSegments(segments, { allowEmpty: true }); + 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, path: routedPath }); +} + +function assertCanonicalPathSegments(path: string): void { + assertCanonicalSegments(path.replaceAll('\\', '/').split('/'), { allowEmpty: true }); +} + +function assertCanonicalSegments( + segments: readonly string[], + options: { readonly allowEmpty?: boolean } = {}, +): void { + if ( + (!options.allowEmpty && segments.length === 0) || + segments.some( + (segment) => + segment === '..' || + segment === '.' || + segment === '' || + segment.includes('\0') || + segment.includes(':'), + ) + ) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed dependency path is invalid', + ); + } +} + +function isNodeModulesSegment(segment: string | undefined): boolean { + if (segment === undefined) return false; + return process.platform === 'win32' + ? segment.toLowerCase() === 'node_modules' + : segment === 'node_modules'; +} + +function remapDependencyResult( + result: ManagedWorkspaceReadOnlyResult, + dependencyRoot: string | undefined, + routedOperation: ManagedWorkspaceReadOnlyOperation, +): ManagedWorkspaceReadOnlyResult { + if (!dependencyRoot) 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) => + remapDependencyGlobPath(path, dependencyRoot, routedOperation), + ), + }); + } + return result; +} + +function remapDependencyGlobPath( + path: string, + dependencyRoot: string, + routedOperation: ManagedWorkspaceReadOnlyOperation, +): string { + const absolute = remapDependencyPath(path, dependencyRoot); + if (absolute !== path || isAbsolute(path) || routedOperation.kind !== 'glob') return absolute; + if (!isPathWithin(routedOperation.path, dependencyRoot)) return path; + + const relativeOperationRoot = relative(dependencyRoot, routedOperation.path); + const operationSegments = + relativeOperationRoot === '' ? [] : relativeOperationRoot.split(/[\\/]/u); + const resultSegments = path.replaceAll('\\', '/').split('/'); + assertCanonicalSegments(operationSegments, { allowEmpty: true }); + assertCanonicalSegments(resultSegments); + return ['node_modules', ...operationSegments, ...resultSegments].join('/'); +} + +function remapDependencyPath(path: string, dependencyRoot: string): string { + 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/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 6a406734ee..e20e697123 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -90,7 +90,10 @@ import { RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION, SQLITE_RUNTIME_SCHEMA_VERSION, } from './sqlite-runtime-schema.js'; -import { registerWorkspaceBaselineAuthorityWriterInternal } from './workspace-version-authority-internal.js'; +import { + registerWorkspaceBaselineAuthorityWriterInternal, + WorkspaceStorageRootAdoptionRequiredError, +} from './workspace-version-authority-internal.js'; import type { ConversationCopyRuntimeEventBatch, ImmutableSteeringMessageProof, @@ -1251,7 +1254,7 @@ export class SqliteRuntimeStore return; } if (this.#databaseHasLogicalStateBeforeRootBinding()) { - throw new Error('Unbound operational data require explicit storage-root adoption'); + throw new WorkspaceStorageRootAdoptionRequiredError(); } this.db .prepare(` @@ -1280,10 +1283,11 @@ export class SqliteRuntimeStore } #databaseHasLogicalStateBeforeRootBinding(): boolean { - const metadataTables = new Set([ + const schemaMetadataTables = new Set([ 'operational_schema_migrations', 'runtime_capabilities', 'runtime_storage_root_binding', + 'session_metadata_schema', ]); const tables = this.db .prepare(` @@ -1294,7 +1298,36 @@ export class SqliteRuntimeStore `) .all() as Array<{ name: string }>; for (const { name } of tables) { - if (metadataTables.has(name)) continue; + if (schemaMetadataTables.has(name)) continue; + // These authority rows are installed by schema bootstrap before the + // storage root is bound. Only their exact empty-state values are + // metadata; a non-zero revision/generation is already logical data and + // must require the explicit whole-root adoption flow. + if (name === 'automation_authority_state' || name === 'usage_pricing_authority') { + if ( + !this.db + .prepare(`SELECT 1 FROM "${name}" WHERE singleton <> 1 OR revision <> 0 LIMIT 1`) + .get() + ) { + continue; + } + return true; + } + if (name === 'session_catalog_state') { + if ( + !this.db + .prepare(` + SELECT 1 + FROM session_catalog_state + WHERE scope <> 'catalog' OR generation <> 0 OR pending_writes <> 0 + LIMIT 1 + `) + .get() + ) { + continue; + } + return true; + } const quotedName = `"${name.replaceAll('"', '""')}"`; if (this.db.prepare(`SELECT 1 FROM ${quotedName} LIMIT 1`).get()) return true; } diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index ed77104ee1..2d3478c248 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -46,6 +46,13 @@ interface WorkspaceBaselineAuthorityRegistration { boundRootId?: string; } +export class WorkspaceStorageRootAdoptionRequiredError extends Error { + constructor() { + super('Unbound operational data require explicit storage-root adoption'); + this.name = 'WorkspaceStorageRootAdoptionRequiredError'; + } +} + const workspaceBaselineAuthorityWriters = new WeakMap< object, WorkspaceBaselineAuthorityRegistration diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 34779367bc..23f2592abd 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -91,6 +91,9 @@ export async function packageMacosArm64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); + await run('npm', ['run', 'prepare:bundled-npm']); + await run('npm', ['run', 'verify:bundled-npm']); + await run('npm', ['run', 'audit: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 46cb20441d..e7841e5611 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -107,6 +107,9 @@ export async function packageWindowsX64({ await run('npm', ['run', 'check:windows-cargo-notices']); await mkdir(sandboxResourceDirectory, { recursive: true }); await copyFile(sandboxBinaryPath, sandboxResourcePath); + await run('npm', ['run', 'prepare:bundled-npm']); + await run('npm', ['run', 'verify:bundled-npm']); + await run('npm', ['run', 'audit: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..0fba759680 --- /dev/null +++ b/scripts/prepare-bundled-npm.mjs @@ -0,0 +1,243 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { cp, lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { basename, 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'; +export const BUNDLED_NPM_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 BUNDLED_NPM_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}`, + ); + } + + await inventoryFiles(sourceNpmRoot, { ignoreGeneratedBinDirectories: true }); + for (const patch of BUNDLED_NPM_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 BUNDLED_NPM_SECURITY_PATCHES) { + const destination = join(runtimeOutputRoot, 'node_modules', patch.packageName); + await rm(destination, { recursive: true, force: true }); + await cp(join(patchedPackagesRoot, patch.packageName), destination, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + } + await removeGeneratedBinDirectories(runtimeOutputRoot); + await requireRegularFile(join(runtimeOutputRoot, 'LICENSE'), 'npm license'); + await requireRegularFile(join(runtimeOutputRoot, 'bin', 'npm-cli.js'), 'npm CLI'); + const files = await inventoryFiles(runtimeOutputRoot); + const manifest = { + schemaVersion: 1, + protocol: 'maka_bundled_npm_runtime_v1', + provider: 'desktop/npm-cli', + npmVersion: EXPECTED_NPM_VERSION, + platform, + arch, + runtimeRootRelativePath: 'npm', + cliRelativePath: 'npm/bin/npm-cli.js', + securityPatches: BUNDLED_NPM_SECURITY_PATCHES, + files, + 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 BUNDLED_NPM_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`), + writeFile(join(auditRoot, 'package-lock.json'), `${JSON.stringify(auditLock, null, 2)}\n`), + ]); +} + +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, { ignoreGeneratedBinDirectories = false } = {}) { + const files = []; + await walk(root, root, files, { ignoreGeneratedBinDirectories }); + files.sort((left, right) => Buffer.from(left.path).compare(Buffer.from(right.path))); + return files; +} + +async function walk(root, directory, files, options) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + if ( + options.ignoreGeneratedBinDirectories && + entry.name === '.bin' && + basename(directory) === 'node_modules' + ) { + continue; + } + const info = await lstat(absolutePath); + if (entry.isDirectory() && !info.isSymbolicLink()) { + await walk(root, absolutePath, files, options); + 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 removeGeneratedBinDirectories(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + if (entry.name === '.bin' && basename(directory) === 'node_modules') { + await rm(absolutePath, { recursive: true, force: true }); + continue; + } + const info = await lstat(absolutePath); + if (entry.isDirectory() && !info.isSymbolicLink()) { + await removeGeneratedBinDirectories(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')}`; +} + +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..c7d1d3bddc --- /dev/null +++ b/scripts/prepare-bundled-npm.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { access, mkdtemp, mkdir, readFile, rm, symlink, 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 patched manifest and audit lock for bundled npm', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + const runtimeOutputRoot = join(fixture.root, 'runtime', 'npm'); + const outputPath = join(fixture.root, 'runtime', 'bundled-npm.json'); + const auditRoot = join(fixture.root, 'runtime', 'audit'); + + const manifest = await prepareBundledNpm({ + ...fixture.inputs, + runtimeOutputRoot, + outputPath, + auditRoot, + platform: 'linux', + arch: 'x64', + }); + + assert.equal(manifest.npmVersion, '12.0.2'); + assert.deepEqual( + manifest.securityPatches.map(({ packageName, fromVersion, toVersion }) => [ + packageName, + fromVersion, + toVersion, + ]), + patches, + ); + assert.deepEqual(JSON.parse(await readFile(outputPath, 'utf8')), manifest); + for (const [name, , version] of patches) { + const packageManifest = JSON.parse( + await readFile(join(runtimeOutputRoot, 'node_modules', name, 'package.json'), 'utf8'), + ); + assert.equal(packageManifest.version, version); + } + const auditLock = JSON.parse(await readFile(join(auditRoot, 'package-lock.json'), 'utf8')); + for (const [name, , version] of patches) { + assert.equal(auditLock.packages[`node_modules/npm/node_modules/${name}`].version, version); + } +}); + +test('rejects symlink or junction input before publication', async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + await symlink( + process.platform === 'win32' + ? fixture.inputs.sourceNpmRoot + : join(fixture.inputs.sourceNpmRoot, 'LICENSE'), + join(fixture.inputs.sourceNpmRoot, 'redirect'), + process.platform === 'win32' ? 'junction' : undefined, + ); + + await assert.rejects( + prepareBundledNpm({ + ...fixture.inputs, + runtimeOutputRoot: join(fixture.root, 'runtime', 'npm'), + outputPath: join(fixture.root, 'runtime', 'bundled-npm.json'), + }), + /regular files and directories/u, + ); +}); + +test('excludes install-generated internal bin links from the published runtime', { + skip: process.platform === 'win32', +}, async (t) => { + const fixture = await createFixture(); + t.after(fixture.remove); + const generatedBinRoot = join(fixture.inputs.sourceNpmRoot, 'node_modules', '.bin'); + await mkdir(generatedBinRoot, { recursive: true }); + await symlink( + join('..', '..', 'bin', 'npm-cli.js'), + join(generatedBinRoot, 'npm-internal'), + 'file', + ); + const runtimeOutputRoot = join(fixture.root, 'runtime', 'npm'); + + await prepareBundledNpm({ + ...fixture.inputs, + runtimeOutputRoot, + outputPath: join(fixture.root, 'runtime', 'bundled-npm.json'), + }); + + await assert.rejects(access(join(runtimeOutputRoot, 'node_modules', '.bin'))); +}); + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), 'maka-prepare-bundled-npm-')); + const sourceNpmRoot = join(root, 'npm'); + const patchedPackagesRoot = join(root, 'patched'); + const sourceLockPath = join(root, 'package-lock.json'); + await mkdir(join(sourceNpmRoot, 'bin'), { recursive: true }); + await Promise.all([ + writeFile( + join(sourceNpmRoot, 'package.json'), + '{"name":"npm","version":"12.0.2","license":"Artistic-2.0"}\n', + ), + writeFile(join(sourceNpmRoot, 'LICENSE'), 'fixture license\n'), + writeFile(join(sourceNpmRoot, 'bin', 'npm-cli.js'), 'console.log("npm");\n'), + ]); + const lockPackages = { 'node_modules/npm': { version: '12.0.2' } }; + 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 Promise.all([ + writeFile( + join(sourceRoot, 'package.json'), + `${JSON.stringify({ name, version: fromVersion })}\n`, + ), + writeFile( + join(patchedRoot, 'package.json'), + `${JSON.stringify({ name, version: toVersion })}\n`, + ), + writeFile(join(patchedRoot, 'index.js'), 'export const patched = true;\n'), + ]); + lockPackages[`node_modules/npm/node_modules/${name}`] = { version: fromVersion }; + lockPackages[`node_modules/${name}`] = { + version: toVersion, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${toVersion}.tgz`, + integrity: 'sha512-Zml4dHVyZQ==', + }; + } + await writeFile( + sourceLockPath, + `${JSON.stringify({ lockfileVersion: 3, packages: lockPackages })}\n`, + ); + return { + root, + inputs: { sourceNpmRoot, patchedPackagesRoot, sourceLockPath }, + remove: () => rm(root, { recursive: true, force: true }), + }; +} diff --git a/scripts/verify-bundled-npm-runtime.mjs b/scripts/verify-bundled-npm-runtime.mjs new file mode 100644 index 0000000000..3788612c65 --- /dev/null +++ b/scripts/verify-bundled-npm-runtime.mjs @@ -0,0 +1,271 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { create as createTar } from 'tar'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export async function verifyPreparedBundledNpm({ + resourcesRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'bundled-npm'), +} = {}) { + const { resolveBundledNpmRuntime } = await import( + new URL('../packages/runtime-host/dist/server/bundled-npm-runtime.js', import.meta.url) + ); + const { runManagedDependencyProducerProcessInternal, runManagedNpmDependencyProvision } = + await import( + new URL( + '../packages/runtime-host/dist/server/managed-dependency-producer-process.js', + import.meta.url, + ) + ); + const capability = await resolveBundledNpmRuntime({ resourcesRoot }); + if (capability.npmVersion !== '12.0.2') { + throw new Error(`Expected bundled npm 12.0.2, found ${capability.npmVersion}.`); + } + const scratchProject = await mkdtemp(join(tmpdir(), 'maka-bundled-npm-smoke-')); + try { + const outputRoot = join(scratchProject, 'node_modules'); + const scratchRoot = join(scratchProject, '.maka-runtime'); + await Promise.all([ + mkdir(outputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + ]); + const manifestBytes = Buffer.from( + '{"name":"maka-bundled-npm-smoke","private":true,"packageManager":"npm@12.0.2"}\n', + ); + const lockfileBytes = Buffer.from( + '{"name":"maka-bundled-npm-smoke","lockfileVersion":3,"requires":true,"packages":{"":{"name":"maka-bundled-npm-smoke"}}}\n', + ); + await runManagedNpmDependencyProvision({ + runtime: capability, + producerInput: { + identity: { + protocolVersion: 1, + environmentId: digest(Buffer.concat([manifestBytes, lockfileBytes])), + manifestPath: 'package.json', + manifestSha256: digest(manifestBytes), + lockfilePath: 'package-lock.json', + lockfileSha256: digest(lockfileBytes), + packageManagerName: 'npm', + packageManagerVersion: capability.npmVersion, + nodeVersion: capability.nodeVersion, + nodeAbi: capability.nodeAbi, + platform: capability.platform, + arch: capability.arch, + producerRuntimeIdentitySha256: capability.runtimeIdentitySha256, + producerPolicyIdentitySha256: digest(Buffer.from('hermetic_dependency_builder_v1')), + policyVersion: 'managed_dependency_environment_v1', + }, + outputRoot, + scratchRoot, + manifestBytes, + lockfileBytes, + }, + }); + await verifyRealDependencyInstall({ + capability, + scratchProject: join(scratchProject, 'real-install'), + runManagedDependencyProducerProcessInternal, + }); + } finally { + await rm(scratchProject, { recursive: true, force: true }); + } + return capability; +} + +async function verifyRealDependencyInstall({ + capability, + scratchProject, + runManagedDependencyProducerProcessInternal, +}) { + const fixtureRoot = join(scratchProject, 'registry-fixture'); + const packageRoot = join(fixtureRoot, 'package'); + const tarballPath = join(fixtureRoot, 'maka-fixture-bin-1.0.0.tgz'); + const requestedProjectRoot = join(scratchProject, 'project'); + await Promise.all([ + mkdir(join(packageRoot, 'bin'), { recursive: true }), + mkdir(join(requestedProjectRoot, 'node_modules'), { recursive: true }), + mkdir(join(requestedProjectRoot, '.maka-runtime', 'home'), { recursive: true }), + mkdir(join(requestedProjectRoot, '.maka-runtime', 'cache'), { recursive: true }), + mkdir(join(requestedProjectRoot, '.maka-runtime', 'temp'), { recursive: true }), + ]); + // Windows hosted runners commonly expose TEMP through an 8.3 alias such as + // RUNNER~1. The producer owner canonicalizes cwd before spawn, so the + // verifier must build its Permission Model allowlist from the same identity. + const projectRoot = await realpath(requestedProjectRoot); + const outputRoot = join(projectRoot, 'node_modules'); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const homeRoot = join(scratchRoot, 'home'); + const cacheRoot = join(scratchRoot, 'cache'); + const tempRoot = join(scratchRoot, 'temp'); + await Promise.all([ + writeFile( + join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: 'maka-fixture-bin', + version: '1.0.0', + bin: { 'maka-fixture': 'bin/cli.js' }, + })}\n`, + ), + writeFile(join(packageRoot, 'index.js'), 'export const installed = true;\n'), + writeFile(join(packageRoot, 'bin', 'cli.js'), '#!/usr/bin/env node\nconsole.log("fixture");\n'), + ]); + if (process.platform !== 'win32') await chmod(join(packageRoot, 'bin', 'cli.js'), 0o755); + await createTar({ cwd: fixtureRoot, file: tarballPath, gzip: true }, ['package']); + const tarball = await readFile(tarballPath); + const integrity = `sha512-${createHash('sha512').update(tarball).digest('base64')}`; + const registry = await startFixtureRegistry(tarball, integrity); + try { + const manifest = { + name: 'maka-bundled-npm-real-smoke', + private: true, + packageManager: 'npm@12.0.2', + dependencies: { 'maka-fixture-bin': '1.0.0' }, + }; + const resolved = `${registry.origin}maka-fixture-bin/-/maka-fixture-bin-1.0.0.tgz`; + const lockfile = { + name: manifest.name, + lockfileVersion: 3, + requires: true, + packages: { + '': { name: manifest.name, dependencies: manifest.dependencies }, + 'node_modules/maka-fixture-bin': { + version: '1.0.0', + resolved, + integrity, + bin: { 'maka-fixture': 'bin/cli.js' }, + }, + }, + }; + const userConfig = join(homeRoot, 'npmrc'); + const globalConfig = join(homeRoot, 'global-npmrc'); + const config = `registry=${registry.origin}\n`; + await Promise.all([ + writeFile(join(projectRoot, 'package.json'), `${JSON.stringify(manifest)}\n`), + writeFile(join(projectRoot, 'package-lock.json'), `${JSON.stringify(lockfile)}\n`), + writeFile(userConfig, config), + writeFile(globalConfig, config), + ]); + await runManagedDependencyProducerProcessInternal({ + argv: [ + capability.nodeExecutablePath, + '--permission', + ...(Number.parseInt(capability.nodeVersion.split('.')[0] ?? '', 10) >= 26 + ? ['--allow-net'] + : []), + `--allow-fs-read=${capability.npmRuntimeRoot}`, + `--allow-fs-read=${projectRoot}`, + `--allow-fs-write=${projectRoot}`, + capability.npmCliPath, + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=true', + `--registry=${registry.origin}`, + `--cache=${cacheRoot}`, + `--userconfig=${userConfig}`, + `--globalconfig=${globalConfig}`, + ], + cwd: projectRoot, + monitorRoot: projectRoot, + env: { + HOME: homeRoot, + USERPROFILE: homeRoot, + npm_config_registry: registry.origin, + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + TEMP: tempRoot, + TMP: tempRoot, + TMPDIR: tempRoot, + ...(process.platform === 'win32' + ? { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR } + : {}), + }, + timeoutMs: 60_000, + maxObservedBytes: 64 * 1024 * 1024, + maxObservedEntries: 10_000, + }); + const installed = JSON.parse( + await readFile(join(outputRoot, 'maka-fixture-bin', 'package.json'), 'utf8'), + ); + if (installed.name !== 'maka-fixture-bin' || installed.version !== '1.0.0') { + throw new Error('Bundled npm did not install the hermetic fixture package'); + } + const binPath = join( + outputRoot, + '.bin', + process.platform === 'win32' ? 'maka-fixture.cmd' : 'maka-fixture', + ); + const binInfo = await lstat(binPath); + if (process.platform === 'win32' ? !binInfo.isFile() : !binInfo.isSymbolicLink()) { + throw new Error('Bundled npm did not create the expected platform .bin entry'); + } + } finally { + await registry.close(); + } +} + +async function startFixtureRegistry(tarball, integrity) { + let origin = ''; + const server = createServer((request, response) => { + if (request.url === '/maka-fixture-bin') { + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + name: 'maka-fixture-bin', + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + name: 'maka-fixture-bin', + version: '1.0.0', + bin: { 'maka-fixture': 'bin/cli.js' }, + dist: { + integrity, + tarball: `${origin}maka-fixture-bin/-/maka-fixture-bin-1.0.0.tgz`, + }, + }, + }, + }), + ); + return; + } + if (request.url === '/maka-fixture-bin/-/maka-fixture-bin-1.0.0.tgz') { + response.setHeader('content-type', 'application/octet-stream'); + response.end(tarball); + return; + } + response.statusCode = 404; + response.end('not found'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture registry did not bind TCP'); + origin = `http://127.0.0.1:${address.port}/`; + return { + origin, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const capability = await verifyPreparedBundledNpm(); + console.log( + `Verified bundled npm ${capability.npmVersion} for ${capability.platform}-${capability.arch}.`, + ); +} diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index feb68c45a2..7d2b8d4651 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -738,6 +738,10 @@ export async function assertPackagedResources( // reads it at runtime, so current builds must carry it — but a // previously released baseline predates it. requireCanonicalIcon = true, + // The pinned Windows upgrade baseline predates bundled npm. Current + // artifacts must carry it, while that historical baseline is verified + // against the release contract that applied when it shipped. + requireBundledNpm = true, } = {}, ) { if (bundledGitContract !== 'forbidden' && bundledGitContract !== 'legacy-required') { @@ -747,6 +751,13 @@ export async function assertPackagedResources( const required = [ 'app.asar', 'bundled-tools.json', + ...(requireBundledNpm + ? [ + 'bundled-npm.json', + join('npm', 'bin', 'npm-cli.js'), + join('licenses', 'npm-cli', 'LICENSE'), + ] + : []), ...(requiresLegacyBundledGit ? [ 'bundled-git.json', diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 1f7596d19d..5d38e5c590 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -49,6 +49,29 @@ test('packaged resources forbid the retired bundled Git distribution', async () } }); +test('requires bundled npm only for the current release contract', async () => { + const currentPaths = []; + await assertPackagedResources('resources', { + requirePath: async (path) => currentPaths.push(path), + forbidPath: async () => {}, + requireWindowsSandbox: false, + }); + assert.ok(currentPaths.includes(join('resources', 'bundled-npm.json'))); + assert.ok(currentPaths.includes(join('resources', 'npm', 'bin', 'npm-cli.js'))); + assert.ok(currentPaths.includes(join('resources', 'licenses', 'npm-cli', 'LICENSE'))); + + const legacyPaths = []; + await assertPackagedResources('resources', { + requirePath: async (path) => legacyPaths.push(path), + forbidPath: async () => {}, + requireWindowsSandbox: false, + requireBundledNpm: false, + }); + assert.equal(legacyPaths.includes(join('resources', 'bundled-npm.json')), false); + assert.equal(legacyPaths.includes(join('resources', 'npm', 'bin', 'npm-cli.js')), false); + assert.equal(legacyPaths.includes(join('resources', 'licenses', 'npm-cli', 'LICENSE')), false); +}); + test('legacy packaged resources require the historical bundled Git contract', async () => { const required = []; const forbidden = []; diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 426b4a43c5..fa119cf896 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -130,6 +130,7 @@ export async function verifyPackagedWindowsApp( requireDisclaimer: requiresCurrentContract, bundledGitContract: requiresCurrentContract ? 'forbidden' : 'legacy-required', requireCanonicalIcon: requiresCurrentContract, + requireBundledNpm: requiresCurrentContract, }); if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); else await requirePath(join(resources, 'git', 'cmd', 'git.exe'));