Skip to content

Commit c688fca

Browse files
authored
A verified Web run that asked the host for node (2026.9.12.1) (#617)
The payload names the program that runs what it produces: .mcpp-toolchain.json gains `runner`, written by xim:emsdk (openxlings/xim-pkgindex#823). See the PR description and .agents/docs/2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md.
1 parent 0e0aaee commit c688fca

14 files changed

Lines changed: 520 additions & 32 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
---
2+
subject: targets
3+
status: landed
4+
---
5+
6+
# A verified Web run that asked the host for node
7+
8+
**Status:** implemented in mcpp 2026.9.12.1 and openxlings/xim-pkgindex#823.
9+
10+
## What was measured
11+
12+
The sandbox verification of the published mcpp 2026.9.11.4 ran 27 checks in an
13+
`xlings subos use <name> --sandbox` environment: a fresh `$HOME`, a fresh
14+
`/tmp`, and no `mcpp` or `node` on PATH. Twenty-six held. The one that did not
15+
was the Web run:
16+
17+
```
18+
mcpp run --target wasm32-emscripten
19+
Compiling w v0.1.0 (.)
20+
Finished dev [unoptimized + debuginfo] in 0.84s
21+
Running `target/wasm32-emscripten/.../bin/w`
22+
/usr/bin/env: 'node': No such file or directory
23+
```
24+
25+
The build was correct. The run depended on the host.
26+
27+
## Why the development host could not see it
28+
29+
An Emscripten link produces a JavaScript launcher whose first line is
30+
`#!/usr/bin/env node`. When neither the project nor its dependency graph
31+
declares a runner, `mcpp run` executes the artefact, and the kernel resolves the
32+
interpreter from the PATH mcpp inherited. `choose_device_action` consults the
33+
manifest and the graph and nothing else, and the spawned artefact receives no
34+
toolchain directory on its PATH.
35+
36+
The ecosystem had already installed the right program. `xim:emsdk` declares
37+
`xim:node` as a runtime dependency and writes that payload's `bin/node` into its
38+
own `.emscripten`, deliberately so that linking does not depend on PATH. Running
39+
the result was the one step that still did.
40+
41+
The row's `verified` record states how it was measured: `node bin/<name>`, with
42+
a `node` the measuring machine had. The development host has one on PATH through
43+
its default xlings environment. So every measurement on that host, including the
44+
dry run of the verification script itself, read the host's `node` and passed.
45+
Only an environment without one could distinguish "the ecosystem runs this" from
46+
"this machine runs this".
47+
48+
## Where the answer belongs
49+
50+
Three placements were considered and rejected.
51+
52+
- **The engine reads `NODE_JS` from emsdk's `.emscripten`.** This would put an
53+
emsdk layout fact into the engine, which is the coupling the payload
54+
descriptor was introduced to remove (2026-09-11 record, item C).
55+
- **The recipe places a `node` link inside the emsdk payload's `bin/`.** Recipe
56+
hooks in this ecosystem have no reliable symlink or permission primitive, and
57+
a link would restate a fact the recipe already records as a path.
58+
- **Projects declare `[target.wasm32-emscripten] runner` and `xim:node`.** A
59+
`verified` row that needs two lines of vocabulary to run is not the row the
60+
2026.9.11.3 release described, where changing a flag was the whole cost.
61+
62+
The payload knows which program runs what its compiler produces, so the payload
63+
states it. `.mcpp-toolchain.json` gains a fourth key.
64+
65+
## The contract
66+
67+
```json
68+
{
69+
"schema": 1,
70+
"frontend": "emscripten/em++",
71+
"runner": "<store>/xim-x-node/26.7.0/bin/node"
72+
}
73+
```
74+
75+
- **One program, no arguments.** The artefact path is appended. The key cannot
76+
carry flags, which keeps the descriptor what it was: a set of answers to
77+
questions the engine already asks, and not a flag channel.
78+
- **Relative or absolute.** A relative runner obeys the rules `frontend` obeys
79+
and resolves against the directory the descriptor was read from. An absolute
80+
runner exists because the program is usually a dependency's, and it is
81+
honoured only inside the package store that holds the payload
82+
(`<store>/<package>/<version>`), compared on canonical paths so that a store
83+
reached through a symbolic link is still that store.
84+
- **Outside the store is ignored, malformed is refused.** Where a store lives is
85+
a property of the machine, so a runner outside it is not honoured and the run
86+
proceeds as it did before the key existed. A structurally wrong value (not a
87+
string, empty, a backslash, a `.` or `..` component) is refused by name at
88+
read time, like every other key.
89+
- **Last in precedence, run slot only.** A project's `[target.<triple>] runner`
90+
and a dependency's `mcpp::runner(...)` are statements about this program and
91+
outrank a statement about everything a compiler produces. `--no-runner` still
92+
executes the artefact directly. `flash`, `monitor` and `debug` name actions a
93+
board package owns, and a compiler has no opinion about them.
94+
95+
## Compatibility
96+
97+
Both directions hold without coordination. An mcpp that predates the key ignores
98+
it, so the recipe ships first. A payload installed before the recipe wrote the
99+
descriptor has none, and its artefact runs through its own shebang exactly as
100+
before; reinstalling the payload adds the descriptor.
101+
102+
## Criteria
103+
104+
| claim | criterion | measured |
105+
|---|---|---|
106+
| a runner in the payload's store is the default for its compiler | unit test with the emsdk shape: compiler at `emscripten/em++`, runner a sibling payload's `bin/node` | yes |
107+
| a runner outside the store is not honoured | unit test with `/usr/bin/node`; and the same test with the store rule removed | yes, and the probe turned it red |
108+
| malformed runners are refused by name and by key | seven shapes, each refusal naming the file and `"runner"` | yes |
109+
| the wiring reaches `mcpp run` | in the sandbox, no `node` on PATH, with a descriptor in the sandbox's own emsdk payload | `1-2-3` |
110+
| the runner came from the descriptor and not from somewhere else | the same run with the descriptor removed | fails at `/usr/bin/env: 'node'` again |
111+
| CI holds the claim against the published recipe | `scan (linux-x86_64)` runs a Web program with a fresh `MCPP_HOME`, so the emsdk payload is the one the index publishes rather than one restored from cache, and with a decoy `node` first on PATH that exits 97 with a marker; the step fails if the payload has no descriptor, if the decoy ran, or if `1-2-3` is not a line of the output | on this PR, after openxlings/xim-pkgindex#823 was published |
112+
113+
The first attempt at the sandbox reading measured nothing. The development binary
114+
was dynamically linked, and its loader lives in the host's `~/.mcpp`, which the
115+
sandbox replaces with its own; both readings were "cannot execute: required file
116+
not found" and said nothing about runners. The reading above used a
117+
`x86_64-linux-musl` static build, the form a release ships.
118+
119+
## What remains
120+
121+
An emsdk payload already installed on a machine keeps running through PATH until
122+
it is reinstalled. The published-artefact check is the sandbox verification
123+
script's Web section, run again after this release.

.agents/docs/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
1818
---
1919
```
2020

21-
278 records.
21+
279 records.
2222

2323
## By subject
2424

@@ -46,6 +46,7 @@ Records that declare one. Everything else is listed by date below.
4646

4747
### targets
4848

49+
- [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed
4950
- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed
5051
- [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active
5152

@@ -57,6 +58,7 @@ Records that declare one. Everything else is listed by date below.
5758

5859
### 2026-09
5960

61+
- [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed
6062
- [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active
6163
- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed
6264
- [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active

.github/workflows/ci-target-matrix.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,64 @@ jobs:
348348
tests/matrix/expected.tsv ${{ matrix.host }} graph || fail=1
349349
[ "$fail" = 0 ] || exit 1
350350
351+
# A WEB ARTEFACT RUNS THROUGH THE INTERPRETER ITS PAYLOAD NAMES, NOT
352+
# THROUGH PATH. That is what `wasm32-emscripten`'s `verified` tier
353+
# claims, and no step measured it until a sandbox with no `node` on PATH
354+
# showed that the run depended on the host:
355+
#
356+
# /usr/bin/env: 'node': No such file or directory
357+
#
358+
# A FRESH MCPP_HOME, BECAUSE THE CACHE IS NOT THE PUBLISHED FORM.
359+
# `~/.mcpp` is restored by key prefix, so the emsdk payload in it may
360+
# predate the recipe that writes `.mcpp-toolchain.json`, and that older
361+
# payload would be the object measured. A fresh home installs what the
362+
# index publishes today.
363+
#
364+
# AND A DECOY `node` FIRST ON PATH, rather than `node` removed from PATH.
365+
# Removing it would also remove whatever else those directories hold,
366+
# and an image that ships a real `node` would pass for the wrong reason.
367+
# A decoy that exits 97 with a marker separates the two outcomes on any
368+
# image: the payload's runner never reaches it.
369+
- name: "wasm32-emscripten runs through its payload's runner, not PATH"
370+
if: ${{ !cancelled() && matrix.host == 'linux-x86_64' }}
371+
run: |
372+
set -uo pipefail
373+
home=$(mktemp -d); work=$(mktemp -d); decoy=$(mktemp -d)
374+
printf '#!/bin/sh\necho "DECOY-NODE-WAS-RUN" >&2\nexit 97\n' > "$decoy/node"
375+
chmod +x "$decoy/node"
376+
export MCPP_HOME="$home" MCPP_VENDORED_XLINGS="$XLINGS_BIN"
377+
"$MCPP_UNDER_TEST" self config --mirror GLOBAL >/dev/null 2>&1 || true
378+
mkdir -p "$work/src"
379+
printf '[package]\nname = "w"\nversion = "0.1.0"\n' > "$work/mcpp.toml"
380+
cat > "$work/src/main.cpp" <<'CPP'
381+
import std;
382+
int main() {
383+
std::vector<int> v{3, 1, 2};
384+
std::ranges::sort(v);
385+
std::print("{}-{}-{}\n", v[0], v[1], v[2]);
386+
}
387+
CPP
388+
out=$(cd "$work" && PATH="$decoy:$PATH" "$MCPP_UNDER_TEST" run --target wasm32-emscripten 2>&1) && rc=0 || rc=$?
389+
printf '%s\n' "$out" | tail -12
390+
desc="$home/registry/data/xpkgs/xim-x-emsdk/6.0.9/.mcpp-toolchain.json"
391+
if [ ! -f "$desc" ]; then
392+
echo "::error::the freshly installed emsdk payload has no .mcpp-toolchain.json; the published recipe does not name its runner"
393+
exit 1
394+
fi
395+
if grep -q 'DECOY-NODE-WAS-RUN' <<<"$out"; then
396+
echo "::error::the artefact ran through the node on PATH, not through the payload's runner"
397+
exit 1
398+
fi
399+
if [ "$rc" -ne 0 ]; then
400+
echo "::error::mcpp run --target wasm32-emscripten exited $rc"
401+
exit 1
402+
fi
403+
if ! grep -qx '1-2-3' <<<"$out"; then
404+
echo "::error::the program's output line 1-2-3 is absent"
405+
exit 1
406+
fi
407+
echo "ok: wasm32-emscripten ran through the payload's runner; the decoy node was never run"
408+
351409
coverage:
352410
# THE DENOMINATOR. Every check above is per host, and no per-host check
353411
# can notice a host that never ran.

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@
55
66
## [Unreleased]
77

8+
## [2026.9.12.1] - 2026-09-12
9+
10+
### Web 产物的运行不再依赖宿主的 `node`
11+
12+
2026.9.11.4 发布后在 xlings 沙箱里核验发布物:`mcpp build --target wasm32-emscripten`
13+
成功,而 `mcpp run` 停在
14+
15+
```
16+
/usr/bin/env: 'node': No such file or directory
17+
```
18+
19+
Emscripten 链接产出的是首行为 `#!/usr/bin/env node` 的 JavaScript 启动器。项目什么都不
20+
声明时 mcpp 直接执行它,于是解释器取自机器的 PATH。而生态早已装好了它:`xim:emsdk` 声明
21+
依赖 `xim:node`,并把那份载荷的 `bin/node` 写进自己的 `.emscripten`。开发机的 PATH 上恰好
22+
`node`,所以此前那次 `verified` 测量看不出这条宿主依赖;沙箱没有,于是看出来了。
23+
24+
- 载荷描述文件 `.mcpp-toolchain.json` 增加第四个键 `runner`:运行这个工具链产物的程序,
25+
一个程序、不带参数,产物路径追加在后,因此不构成 flag 通道。它可以是载荷内的相对路径
26+
(与 `frontend` 同一套规则),也可以是绝对路径;绝对路径只在持有该载荷的包存储
27+
(`<store>/<package>/<version>`)之内才被采用,按规范化路径比较,存储之外的一律忽略
28+
—— 载荷不能选择宿主解释器。
29+
- 优先级:项目的 `[target.<triple>] runner` 与依赖图提供的 runner 在前,载荷的 runner
30+
在后;`--no-runner` 仍然直接执行产物。只作用于 run 槽位,即 `mcpp run``mcpp test`
31+
- 结构错误的 `runner`(非字符串、为空、含反斜杠、含 `.``..` 分量)按描述文件的既有
32+
规则拒绝,并点名该文件。
33+
- `xim:emsdk` 的配方写出 `runner`(openxlings/xim-pkgindex#823)。早于这个键的 mcpp
34+
忽略它,所以配方可以先发;在此之前安装的 emsdk 载荷没有描述文件,行为与之前相同,重新
35+
安装后获得。
36+
837
## [2026.9.11.4] - 2026-09-11
938

1039
### iOS 三行:生态编译器与定位到的 SDK

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ list` reports for this machine):
419419
| `aarch64-none-elf` · `x86_64-none-elf` | llvm 22 — bare metal, no C library by default ² | preview |
420420
| `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 22 — Cortex-M4/M7 soft float, M23, M33F/M55F ² | preview |
421421
| `riscv64-linux-musl` · `aarch64-linux-gnu` · `x86_64-macos` || planned |
422-
| `wasm32-emscripten` | `emsdk@6.0.9` — Emscripten ships its own sysroot and its own libc++ module surface; `mcpp run` executes the module with `node` | verified |
422+
| `wasm32-emscripten` | `emsdk@6.0.9` — Emscripten ships its own sysroot and its own libc++ module surface; `mcpp run` executes the module with the `node` the payload declares (`xim:node`), not one found on PATH | verified |
423423
| `x86_64-linux-android` | `android-ndk@30.0.16248370` — bionic from the NDK, one payload for both ABIs; ran on an API 24 x86_64 emulator image | verified |
424424
| `aarch64-linux-android` | the same payload and the same build; ran under qemu-user over the system image's own bionic, which the platform emulator cannot do from an x86_64 host | verified |
425425
| `aarch64-ios-sim` | llvm 22 plus the machine's iPhoneSimulator SDK, which mcpp locates rather than installs; ran on a simulator through `simctl-run` ³ | verified |

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family
406406
| `aarch64-none-elf` · `x86_64-none-elf` | llvm 22——裸机,默认不带 C 库 ² | preview |
407407
| `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 22——Cortex-M4/M7 软浮点、M23、M33F/M55F ² | preview |
408408
| `riscv64-linux-musl` · `aarch64-linux-gnu` · `x86_64-macos` || planned |
409-
| `wasm32-emscripten` | `emsdk@6.0.9` —— Emscripten 自带 sysroot 和它自己的 libc++ 模块面;`mcpp run` `node` 把模块跑起来 | verified |
409+
| `wasm32-emscripten` | `emsdk@6.0.9` —— Emscripten 自带 sysroot 和它自己的 libc++ 模块面;`mcpp run` 用载荷声明的 `node`(`xim:node`)把模块跑起来,不取 PATH 上的 | verified |
410410
| `x86_64-linux-android` | `android-ndk@30.0.16248370` —— bionic 来自 NDK,一个载荷服务两个 ABI;在 API 24 的 x86_64 模拟器镜像上跑过 | verified |
411411
| `aarch64-linux-android` | 同一个载荷、同样的构建;在 qemu-user 上、配系统镜像自带的 bionic 跑过 —— 这是平台模拟器从 x86_64 宿主做不到的 | verified |
412412
| `aarch64-ios-sim` | llvm 22 加上机器自己的 iPhoneSimulator SDK,mcpp 定位而不安装它;经 `simctl-run` 在模拟器上跑过 ³ | verified |

docs/20-toolchains.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -587,10 +587,19 @@ the NDK itself declares in `meta/platforms.json`.
587587

588588
### Running what they produce
589589

590-
Neither the emulator nor a device is part of the toolchain axis. `mcpp run`
591-
executes a wasm module directly, because Emscripten's output is a program
592-
`node` can run. For a target whose artifact runs elsewhere, the `runner` key is
593-
an argv prefix and the session belongs to a package rather than to the engine:
590+
Neither the emulator nor a device is part of the toolchain axis. A wasm module
591+
needs an interpreter rather than an emulator, and the payload names it:
592+
`xim:emsdk` writes `.mcpp-toolchain.json` beside itself, and its `runner` key is
593+
the `node` of the `xim:node` payload it depends on. `mcpp run` and `mcpp test`
594+
use that program when neither the project nor a dependency declares a runner,
595+
so no `node` on PATH is involved. A payload's runner is one program with the
596+
artefact path appended; it is either relative to the payload or absolute inside
597+
the package store that holds the payload, and a path outside that store is
598+
ignored. A payload installed before its recipe wrote the key has no descriptor,
599+
and its artefact runs through its own `#!/usr/bin/env node` line as before.
600+
601+
For a target whose artifact runs elsewhere, the `runner` key is an argv prefix
602+
and the session belongs to a package rather than to the engine:
594603

595604
```toml
596605
[target.x86_64-linux-android]

docs/zh/20-toolchains.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,9 +535,15 @@ macos_deployment_target = "14.0" # Apple
535535

536536
### 产物的运行方式
537537

538-
模拟器和真机都不属于工具链这根轴。`mcpp run` 直接执行一个 wasm 模块,因为
539-
Emscripten 的产物就是一个 `node` 能跑的程序。对于产物在别处运行的目标,`runner`
540-
键是一个 argv 前缀,而那个会话属于一个****而不属于引擎:
538+
模拟器和真机都不属于工具链这根轴。wasm 模块需要的是解释器而不是模拟器,由载荷说出它:
539+
`xim:emsdk` 在自身旁边写出 `.mcpp-toolchain.json`,其中的 `runner` 键就是它所依赖的
540+
`xim:node` 载荷里的 `node`。项目与依赖都没有声明 runner 时,`mcpp run``mcpp test`
541+
使用这个程序,因此不涉及 PATH 上的 `node`。载荷的 runner 是一个程序,产物路径追加在后;
542+
它或是载荷内的相对路径,或是持有该载荷的包存储之内的绝对路径,存储之外的路径一律忽略。
543+
在配方写出这个键之前安装的载荷没有描述文件,其产物仍按自身的 `#!/usr/bin/env node`
544+
一行运行,与之前相同。
545+
546+
对于产物在别处运行的目标,`runner` 键是一个 argv 前缀,而那个会话属于一个****而不属于引擎:
541547

542548
```toml
543549
[target.x86_64-linux-android]

examples/13-platform-targets/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ bin/platform-targets.wasm 447183 bytes the module
2323
node bin/platform-targets -> 1-2-3
2424
```
2525

26+
运行用的 `node` 取自 `xim:emsdk` 声明的依赖 `xim:node`,而不是 PATH 上的某一个。载荷在
27+
`.mcpp-toolchain.json` 里用 `runner` 写出它,项目与依赖图都没有声明 runner 时 mcpp 使用
28+
它。这需要 mcpp 2026.9.12.1,以及在配方更新之后安装的 emsdk 载荷;更早安装的载荷没有
29+
描述文件,仍按产物首行的 `#!/usr/bin/env node` 取 PATH 上的 `node`
30+
2631
`mcpp run` 会用 `node` 跑它,所以不需要额外的一步。工程侧**一个新词汇都不需要**
2732
`wasm32-emscripten` 这一行自己命名了它的载荷(`emsdk@6.0.9`),载荷自带 sysroot,
2833
而 Emscripten 自己就发布一份 libc++ 的模块面。
@@ -175,7 +180,7 @@ error: target aarch64-ios needs the iphoneos SDK, which this machine does not pr
175180

176181
| target | tier | pin | 运行过? |
177182
|---|---|---|---|
178-
| `wasm32-emscripten` | verified | `emsdk@6.0.9` | 是,`node` |
183+
| `wasm32-emscripten` | verified | `emsdk@6.0.9` | 是,载荷声明的 `node` |
179184
| `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | 是,平台模拟器 |
180185
| `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | 是,`qemu-aarch64-static` + 从镜像取出的 bionic |
181186
| `aarch64-ios` | preview | `llvm@22.1.8` | 否 —— 真机需要开发者自己的签名 |

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.9.11.4"
3+
version = "2026.9.12.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

0 commit comments

Comments
 (0)