# xlings 运行时底座 —— 实施计划

> **For agentic workers:** 逐 task 执行,每个 task 自带 red→green→commit 循环。

**Goal:** 让 mcpp 把 xlings 当作可查询的运行时底座:补上 libc 轴的分发契约(`c_runtime`)、消费 subos 的运行时身份与环境声明、修掉 `self-contained` 打包模式自产的 `/proc/self/exe` 陷阱。

**Architecture:** 三条缝。S2 在既有 `mcpp.build.distribution` 的三层模型(Role→Contract→Mechanism)上**复用词汇、新增 libc 机制表**,由 `linkmodel` 单点渲染;S3 新增 `mcpp.xlings.subos_info` 读取 xlings 的 `subos_info` 块,`run`/`test` 应用其 env;S1 让 runtime 身份成为显式轴。P2(视图寻址)不做。

**Tech Stack:** C++23 modules,gtest(`tests/unit/`),shell e2e(`tests/e2e/`),nlohmann::json(`mcpp.libs.json`)。

设计文档:`.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`

## Global Constraints

- **词汇不新造**:契约三值 `self-contained` | `toolchain-coupled` | `host-coupled`,与 `[build] cxx_runtime` 逐字相同。runtime binding 拼写 `<name>@<version>`,与 xlings `subos_info.runtime` 逐字相同。
- **默认值不变**:`c_runtime` 缺省 = `toolchain-coupled` = 今天的行为。引入这条轴本身**不改变任何现有产物**。
- **Mechanism 必须是 total function**:每格要么给 flags,要么 `degraded=true` + 非空 `diagnostic`。静默无操作是禁止的结果。
- **不引入新的宿主探测**:mcpp 里不得出现探测 GPU / 图形能力的代码。
- **schema 缺失/未知版本必须出声**:一行 stderr,构建继续。
- **P2 不做**:`toolchain-coupled` 保持载荷寻址,不切视图寻址。
- **平台特殊处理下沉**:平台差异放 `src/platform/` 对应模块,不在 `build/` 里写 `#ifdef`。

---

### Task 1: `self-contained` 打包模式的 `/proc/self/exe` 陷阱

**Files:**
- Modify: `src/pack/pack.cppm:431-446`(`write_bundle_all_wrappers`)
- Modify: `docs/02-pack-and-release.md`(§ Mode `self-contained` 之后)
- Test: `tests/e2e/30_pack_modes.sh`

**Interfaces:**
- Consumes: 无
- Produces: 分发包契约新增环境变量 `MCPP_BUNDLE_DIR`(bundle 根目录绝对路径),由 `run.sh` / 顶层同名脚本导出。

**背景**:ELF 规范禁止 `PT_INTERP` 用 `$ORIGIN`,所以 `self-contained` 只能经 `ld.so --library-path` 启动;代价是内核把 `/proc/self/exe` 指向 loader、`/proc/self/cmdline` 混入 `--library-path`。本 task 不消除这个约束(那需要安装期改写 PT_INTERP,见设计文档 Q6 选项 i),而是**把它变成一个有声明、可编程的契约**,并写进文档。

- [ ] **Step 1: 写失败的 e2e 断言**

在 `tests/e2e/30_pack_modes.sh` 的 Mode B(bundle-all)段落后追加:

```sh
# self-contained: the wrapper must export MCPP_BUNDLE_DIR so an application
# can resolve resources despite /proc/self/exe pointing at the loader.
grep -q 'MCPP_BUNDLE_DIR' "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" || {
    echo "Mode B: run.sh does not export MCPP_BUNDLE_DIR"; exit 1; }
out=$("$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" 2>&1) || true
echo "$out" | grep -q 'Hello' || { echo "Mode B: wrapper broke execution: $out"; exit 1; }
```

- [ ] **Step 2: 跑,确认失败**

```bash
bash tests/e2e/30_pack_modes.sh
```
Expected: FAIL,`run.sh does not export MCPP_BUNDLE_DIR`

- [ ] **Step 3: 改 wrapper**

`src/pack/pack.cppm` 的 `write_bundle_all_wrappers` body 改为:

```cpp
    auto body = std::format(
        "#!/bin/sh\n"
        "# Auto-generated by `mcpp pack --mode self-contained`. Launches the\n"
        "# bundled binary through the bundled dynamic linker so the package\n"
        "# is portable across glibc versions.\n"
        "#\n"
        "# TRAP, and why this variable exists: launching through the loader\n"
        "# makes the kernel set /proc/self/exe to the LOADER, not to the\n"
        "# program, and /proc/self/cmdline carries --library-path. Any\n"
        "# \"find my resources next to the executable\" logic silently\n"
        "# resolves against the loader's directory instead. MCPP_BUNDLE_DIR\n"
        "# is the answer that survives: resolve against it first and fall\n"
        "# back to /proc/self/exe only when it is unset.\n"
        "here=$(cd \"$(dirname \"$0\")\" && pwd)\n"
        "MCPP_BUNDLE_DIR=\"$here\"; export MCPP_BUNDLE_DIR\n"
        "exec \"$here/lib/{}\" --library-path \"$here/lib\" \"$here/bin/{}\" \"$@\"\n",
        loaderName, binaryName);
```

- [ ] **Step 4: 跑,确认通过**

```bash
bash tests/e2e/30_pack_modes.sh
```
Expected: PASS

- [ ] **Step 5: 文档**

在 `docs/02-pack-and-release.md` 的 self-contained loader 说明之后追加一节:

```markdown
#### Trap: `/proc/self/exe` under the bundled loader

Launching through the loader means the kernel sets `/proc/self/exe` to the
**loader**, not to your program, and `/proc/self/cmdline` carries the
`--library-path` argument. Every "find my resources next to the executable"
path silently resolves against the wrong directory — GUI toolkits looking for
fonts or `assets/`, and helper binaries shipped alongside the program.

The wrapper exports `MCPP_BUNDLE_DIR` (the bundle root) for this. Resolve
against it first:

```c
const char *base = getenv("MCPP_BUNDLE_DIR");   /* set by run.sh */
/* fall back to /proc/self/exe only when unset */
```

If your application cannot be changed, use `--mode vendored` instead: it
repoints `PT_INTERP` at the host loader, so `/proc/self/exe` is correct — at
the cost of requiring the host's glibc to be at least as new as the one the
artifact was built against.
```

同一节的中文版加到 `docs/zh/` 下对应文件(若存在)。

- [ ] **Step 6: Commit**

```bash
git add src/pack/pack.cppm docs/02-pack-and-release.md tests/e2e/30_pack_modes.sh
git commit -m "fix(pack): the self-contained wrapper broke /proc/self/exe and never said so"
```

---

### ~~Task 2–4: `c_runtime` 契约轴~~ —— 已撤销

**做过,全绿,然后整条 reset。** 设计文档 §3-S2 与 §5.2 记录了理由;摘要:

- 它是照着 #375 提报者**提出的解法**做的,不是解他的**问题**(问题是「产物没法分发」)
- 它唯一的新能力是 `host-coupled` = 把产物链到**宿主 libc**,而 hermetic 策略禁止穿越的第一条就是 `/lib*` 下的 libc。**mcpp 能不用 host 就不用 host**
- 去掉那个值之后这条轴不剩任何能力(`self-contained` 已由 `--target ...-musl` 表达)
- 它会造出「可不可以用宿主 libc」的**第二个回答者**(既有的是 `[build] allow_host_libs`)

**代替它的是 Task 4'(下)**:#375 的真问题有三条 hermetic 答案,全都已存在;缺口在「其中一条自己是坏的」(Task 1 已修)加「三条都不可发现」。

---

### Task 4': 让三条 hermetic 分发路径可发现

**Files:**
- Modify: `docs/02-pack-and-release.md` + `docs/zh/02-pack-and-release.md`
- Modify: `docs/00-getting-started.md` 或 `10-publishing-a-library.md`(择一放「怎么分发」的入口)
- Test: 文档改动,无自动化断言;判据是 §10 的 V1(三条路径各自在干净容器里跑通)

**要写清的三条,并列、等价、都不用宿主 libc:**

| 路径 | 命令 | libc 从哪来 | 什么时候选它 |
|---|---|---|---|
| A. 生态闭环 | `mcpp emit xpkg` → `xlings install` | 目标机自己的 xlings 载荷,**elfpatch 装机期重指 PT_INTERP/RUNPATH** | 目标机在生态内 |
| B. 静态单文件 | `--target x86_64-linux-musl` | 自带,静态 | 任何 Linux,零运行期依赖 |
| C. 自带运行时 | `mcpp pack --mode self-contained` | 自带这套工具链的 glibc + loader | 任何 Linux,含比构建机更老的 |

**A 要特别写明一件容易误读的事**:产物里烙的 `PT_INTERP` 指向构建机路径**不构成分发障碍** —— 走 A 时目标机的 xlings 会重写它。#375 观察到的「路径不存在所以起不来」,前提是绕开生态直接拷贝二进制。

**不写**:任何「让产物链到系统 libc」的做法。已有的那个决定只有一个入口(`[build] allow_host_libs`),文档不给它第二个说法。

- [ ] **Step 1** 在 `docs/02-pack-and-release.md` 顶部「Two axes」之前加一节 "Three ways to ship, none of which use the host's libc",内容如上表
- [ ] **Step 2** 中文版同步
- [ ] **Step 3** Commit

---

### Task 5: 读 xlings 的 subos 自描述

**Files:**
- Create: `src/xlings/subos_info.cppm`(模块 `mcpp.xlings.subos_info`)
- Test: `tests/unit/test_subos_info.cpp`

**Interfaces:**
- Consumes: `mcpp.libs.json`
- Produces:
  ```cpp
  namespace mcpp::xlings::subos {
    inline constexpr int kSupportedSchema = 1;
    struct EnvDecl   { std::string var, op, value; };
    struct Provider  { std::string binding; std::vector<EnvDecl> decls; };
    struct Info {
        int schema = 0;
        std::string runtime;              // "glibc@2.39"
        std::vector<Provider> providers;  // sorted by binding
        bool present = false;             // the block existed at all
        std::string note;                 // non-empty ⇒ caller MUST print it
    };
    Info read(const std::filesystem::path& subosDir);
    std::string family_of(std::string_view runtime, std::string_view arch = "x86_64");
    std::vector<std::pair<std::string,std::string>>
    resolve_env(const Info&, const std::filesystem::path& subosDir);
  }
  ```

- [ ] **Step 1: 写失败的单测**

创建 `tests/unit/test_subos_info.cpp`:

```cpp
#include <gtest/gtest.h>

import std;
import mcpp.xlings.subos_info;

namespace {

namespace su = mcpp::xlings::subos;

struct Tmp {
    std::filesystem::path dir;
    Tmp() {
        dir = std::filesystem::temp_directory_path()
            / ("mcpp_subos_" + std::to_string(std::random_device{}()));
        std::filesystem::create_directories(dir);
    }
    ~Tmp() { std::error_code ec; std::filesystem::remove_all(dir, ec); }
    void write(std::string_view body) {
        std::ofstream(dir / ".xlings.json") << body;
    }
};

TEST(SubosInfo, ReadsRuntimeAndEnvs) {
    Tmp t;
    t.write(R"({
      "workspace": {},
      "subos_info": {
        "schema_version": 1,
        "runtime": "glibc@2.39",
        "envs": [
          { "binding": "mesa@25.0.7.1", "decls": [
            { "var": "LIBGL_DRIVERS_PATH", "op": "prepend",
              "value": "${subosdir}/usr/lib/dri" }
          ]}
        ]
      }
    })");
    auto info = su::read(t.dir);
    EXPECT_TRUE(info.present);
    EXPECT_EQ(info.schema, 1);
    EXPECT_EQ(info.runtime, "glibc@2.39");
    ASSERT_EQ(info.providers.size(), 1u);
    ASSERT_EQ(info.providers[0].decls.size(), 1u);
    EXPECT_EQ(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH");
    EXPECT_TRUE(info.note.empty());
}

// ${subosdir} is expanded against the subos this block was read from.
TEST(SubosInfo, ResolvesSubosdirPlaceholder) {
    Tmp t;
    t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39",
      "envs":[{"binding":"mesa@1","decls":[
        {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})");
    auto env = su::resolve_env(su::read(t.dir), t.dir);
    ASSERT_EQ(env.size(), 1u);
    EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH");
    EXPECT_EQ(env[0].second, (t.dir / "usr/lib/dri").string());
}

// A subos made before xlings grew the block: degrade, and SAY SO. Silence
// here is what made mcpp#352 hard to find in the first place.
TEST(SubosInfo, MissingBlockDegradesWithANote) {
    Tmp t;
    t.write(R"({"workspace":{}})");
    auto info = su::read(t.dir);
    EXPECT_FALSE(info.present);
    EXPECT_TRUE(info.runtime.empty());
    EXPECT_FALSE(info.note.empty());
}

// A schema newer than we understand: use what we can, and say we are behind.
TEST(SubosInfo, NewerSchemaDegradesWithANote) {
    Tmp t;
    t.write(R"({"subos_info":{"schema_version":99,"runtime":"glibc@2.44","envs":[]}})");
    auto info = su::read(t.dir);
    EXPECT_TRUE(info.present);
    EXPECT_EQ(info.runtime, "glibc@2.44");
    EXPECT_FALSE(info.note.empty());
}

TEST(SubosInfo, NoFileAtAllIsNotACrash) {
    Tmp t;   // nothing written
    auto info = su::read(t.dir);
    EXPECT_FALSE(info.present);
    EXPECT_FALSE(info.note.empty());
}

// The family mapping must agree with xlings's own, verbatim.
TEST(SubosInfo, FamilyOfMirrorsXlings) {
    EXPECT_EQ(su::family_of("glibc@2.39"), "linux-x86_64-glibc");
    EXPECT_EQ(su::family_of("musl@1.2.5"), "linux-x86_64-musl");
    EXPECT_EQ(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc");
    EXPECT_EQ(su::family_of("wasi-libc@1"), "wasm32-wasi");
    EXPECT_EQ(su::family_of("nonsense@1"), "unknown");
}

// Malformed JSON must not take the build down.
TEST(SubosInfo, MalformedJsonDegrades) {
    Tmp t;
    t.write("{ this is not json");
    auto info = su::read(t.dir);
    EXPECT_FALSE(info.present);
    EXPECT_FALSE(info.note.empty());
}

}  // namespace
```

- [ ] **Step 2: 跑,确认失败**

```bash
mcpp test --filter SubosInfo
```
Expected: FAIL,模块不存在

- [ ] **Step 3: 实现**

创建 `src/xlings/subos_info.cppm`:

```cpp
// mcpp.xlings.subos_info — read the `subos_info` block xlings writes into a
// subos's own `.xlings.json`.
//
// WHAT THIS IS FOR
//
// A program needs three things: bootstrap (PT_INTERP + CRT + libc), discovery
// (PATH + RPATH), and configuration (env vars). xlings had the first two —
// glibc + elfpatch, xvm + shims — and until it grew this block, nothing for
// the third. Its own module comment names the consequence: mcpp#352, a GLFW
// binary that links fine and exits 255 because nothing told it where the GL
// drivers are.
//
// mcpp is the consumer of the third one. This module ONLY reads and resolves;
// it never writes the block and never manages subos lifecycle — that is
// xlings's layer, and mcpp reaching into it is the layering inversion the
// ecosystem design calls out by name.
//
// A note on silence: every degradation here fills `note`, and callers are
// required to print it. "It did not happen" and "it succeeded" producing the
// same output is the property that made #352 expensive to find.
//
// Design: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md §3-S3

export module mcpp.xlings.subos_info;

import std;
import mcpp.libs.json;

export namespace mcpp::xlings::subos {

// The schema this build understands. A higher one on disk is usable — we read
// the fields we know — but the caller is told it is reading a newer format.
inline constexpr int kSupportedSchema = 1;

inline constexpr std::string_view kBlock = "subos_info";

struct EnvDecl {
    std::string var;
    std::string op;      // "set" | "prepend"
    std::string value;   // may contain ${subosdir}
};

struct Provider {
    std::string          binding;   // "<name>@<version>"
    std::vector<EnvDecl> decls;
};

struct Info {
    int                   schema = 0;
    std::string           runtime;    // "glibc@2.39"
    std::vector<Provider> providers;  // sorted by binding
    bool                  present = false;
    // Non-empty ⇒ the caller MUST surface it. Never a hard error: a missing
    // or newer block degrades the experience, it does not invalidate a build.
    std::string           note;
};

// The runtime string is self-describing: "glibc@2.39" says Linux/glibc
// without a second field that could disagree with it. Mirrors xlings's
// `subos::manifest::family_of` — the mapping is small, stable and part of the
// cross-repo contract, so it is asserted in tests rather than left implicit.
std::string family_of(std::string_view runtime,
                      std::string_view arch = "x86_64") {
    const auto at   = runtime.find('@');
    const auto name = runtime.substr(0, at == std::string_view::npos
                                            ? runtime.size() : at);
    if (name == "glibc")     return std::format("linux-{}-glibc", arch);
    if (name == "musl")      return std::format("linux-{}-musl", arch);
    if (name == "wasi-libc") return "wasm32-wasi";
    if (name == "macos_sdk") return std::format("darwin-{}", arch);
    if (name == "ucrt")      return std::format("windows-{}-ucrt", arch);
    return "unknown";
}

Info read(const std::filesystem::path& subosDir) {
    Info info;
    auto path = subosDir / ".xlings.json";
    std::error_code ec;
    if (!std::filesystem::exists(path, ec)) {
        info.note = std::format(
            "subos '{}' has no .xlings.json, so it cannot say which runtime it "
            "is or what environment its programs need", subosDir.string());
        return info;
    }
    std::ifstream is(path);
    auto doc = nlohmann::json::parse(is, nullptr, /*allow_exceptions=*/false);
    if (doc.is_discarded() || !doc.is_object()) {
        info.note = std::format("subos manifest {} is not readable JSON",
                                path.string());
        return info;
    }
    auto it = doc.find(std::string(kBlock));
    if (it == doc.end() || !it->is_object()) {
        info.note = std::format(
            "subos '{}' does not describe itself (no `{}` block): its runtime "
            "identity and environment declarations are unavailable. A newer "
            "xlings writes this block; `xlings self update` adds it",
            subosDir.string(), kBlock);
        return info;
    }
    info.present = true;
    if (auto v = it->find("schema_version");
        v != it->end() && v->is_number_integer())
        info.schema = v->get<int>();
    if (auto v = it->find("runtime"); v != it->end() && v->is_string())
        info.runtime = v->get<std::string>();

    if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) {
        for (auto& p : *envs) {
            if (!p.is_object()) continue;
            Provider prov;
            if (auto b = p.find("binding"); b != p.end() && b->is_string())
                prov.binding = b->get<std::string>();
            if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) {
                for (auto& d : *ds) {
                    if (!d.is_object()) continue;
                    EnvDecl e;
                    if (auto x = d.find("var");   x != d.end() && x->is_string())
                        e.var = x->get<std::string>();
                    if (auto x = d.find("op");    x != d.end() && x->is_string())
                        e.op = x->get<std::string>();
                    if (auto x = d.find("value"); x != d.end() && x->is_string())
                        e.value = x->get<std::string>();
                    if (!e.var.empty()) prov.decls.push_back(std::move(e));
                }
            }
            info.providers.push_back(std::move(prov));
        }
    }
    // Sorted by binding, matching xlings's own ordering, so two reads of the
    // same subos produce the same environment in the same order.
    std::ranges::sort(info.providers,
                      [](auto const& a, auto const& b) { return a.binding < b.binding; });

    if (info.schema > kSupportedSchema)
        info.note = std::format(
            "subos '{}' declares schema {}, newer than the {} this mcpp "
            "understands; reading the fields it knows and ignoring the rest",
            subosDir.string(), info.schema, kSupportedSchema);
    return info;
}

// Resolve the declarations into concrete (var, value) pairs, `${subosdir}`
// expanded. `prepend` entries for the same variable are joined with ':' in
// provider order; a `set` replaces whatever came before it, which is xlings's
// own precedence.
std::vector<std::pair<std::string, std::string>>
resolve_env(const Info& info, const std::filesystem::path& subosDir) {
    std::vector<std::pair<std::string, std::string>> out;
    auto expand = [&](std::string v) {
        constexpr std::string_view kPh = "${subosdir}";
        for (auto pos = v.find(kPh); pos != std::string::npos;
             pos = v.find(kPh, pos))
            v.replace(pos, kPh.size(), subosDir.string());
        return v;
    };
    for (auto const& p : info.providers) {
        for (auto const& d : p.decls) {
            auto value = expand(d.value);
            auto hit = std::ranges::find(out, d.var, &std::pair<std::string,std::string>::first);
            if (hit == out.end()) { out.emplace_back(d.var, value); continue; }
            if (d.op == "set") { hit->second = value; continue; }
            // prepend, de-duplicated: a doubled entry must not accumulate
            // across nested invocations.
            if (hit->second != value
                && !hit->second.starts_with(value + ":")
                && hit->second.find(":" + value) == std::string::npos)
                hit->second = value + ":" + hit->second;
        }
    }
    return out;
}

}  // namespace mcpp::xlings::subos
```

- [ ] **Step 4: 跑,确认通过**

```bash
mcpp test --filter SubosInfo
```
Expected: PASS(7 组全绿)

- [ ] **Step 5: Commit**

```bash
git add src/xlings/subos_info.cppm tests/unit/test_subos_info.cpp
git commit -m "feat(xlings): read the subos's own description instead of inferring it"
```

---

### Task 6: `mcpp run` / `mcpp test` 应用 subos 环境

**Files:**
- Modify: `src/build/plan.cppm`(把解析好的 env 放进 plan)
- Modify: `src/build/execute.cppm:279-300, 1210-1230`(应用到子进程)
- Test: `tests/e2e/196_subos_env_reaches_program.sh`(新建)

**Interfaces:**
- Consumes: `mcpp::xlings::subos::{read, resolve_env}`
- Produces: `BuildPlan` 新增 `std::vector<std::pair<std::string,std::string>> subosEnv;`

**为什么不是自己拼图形变量**:`LIBGL_DRIVERS_PATH` / `__EGL_VENDOR_LIBRARY_DIRS` / `XDG_DATA_DIRS` 的值由 xlings 的图形包声明,mcpp 只是把它们传下去。mcpp 里出现任何图形相关的字面量都是这条设计的违反。

- [ ] **Step 1: 写失败的 e2e**

创建 `tests/e2e/196_subos_env_reaches_program.sh`:

```sh
#!/usr/bin/env bash
# requires: linux
# A variable a package declared into the subos must reach the program `mcpp
# run` launches. Until this landed, subos env declarations were applied only
# by `xlings subos use`, so a program started by mcpp saw none of them — the
# reason a GL binary could link fine and exit 255 (mcpp#352).
set -euo pipefail
. "$(dirname "$0")/_common.sh"

proj=$(mktemp -d)
trap 'rm -rf "$proj"' EXIT

# A subos that declares one variable, in xlings's own schema.
subos="$proj/subos"
mkdir -p "$subos/usr/lib/dri"
cat > "$subos/.xlings.json" <<'EOF'
{ "workspace": {},
  "subos_info": { "schema_version": 1, "runtime": "glibc@2.39",
    "envs": [ { "binding": "probe@1", "decls": [
      { "var": "MCPP_E2E_PROBE", "op": "prepend",
        "value": "${subosdir}/usr/lib/dri" } ] } ] } }
EOF

cd "$proj"
"$MCPP" new hello >/dev/null
cd hello
cat > src/main.cpp <<'EOF'
#include <cstdlib>
#include <cstdio>
int main() {
    const char* v = std::getenv("MCPP_E2E_PROBE");
    std::printf("PROBE=%s\n", v ? v : "(unset)");
}
EOF

out=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1)
echo "$out" | grep -q "PROBE=$subos/usr/lib/dri" || {
    echo "subos env did not reach the program:"; echo "$out"; exit 1; }
echo "PASS: subos env reaches the program"
```

`chmod +x tests/e2e/196_subos_env_reaches_program.sh`

> `MCPP_SUBOS_DIR` 是本 task 引入的**测试与覆盖用**入口:默认解析仍是活动 subos。它让这条 e2e 不需要改动用户真实环境——记住 memory 里那条教训:破坏性脚本不在真机上试。

- [ ] **Step 2: 跑,确认失败**

```bash
bash tests/e2e/196_subos_env_reaches_program.sh
```
Expected: FAIL,`PROBE=(unset)`

- [ ] **Step 3: 实现**

`src/build/plan.cppm`,在 `runtimeLibraryDirs` 那段之后:

```cpp
    // The subos's own environment declarations. mcpp reads them and passes
    // them on; it does not author them and does not know what they mean. That
    // is the whole point: when xlings adds a Vulkan loader or a new driver
    // bridge, this code does not change.
    {
        auto subosDir = mcpp::xlings::subos_dir_for_build(cfg);
        auto info     = mcpp::xlings::subos::read(subosDir);
        if (!info.note.empty()) mcpp::ui::warning(info.note);
        plan.subosEnv = mcpp::xlings::subos::resolve_env(info, subosDir);
    }
```

`src/xlings.cppm` 加一个解析器(单点,不让每个消费者各猜一次):

```cpp
    // The subos whose environment a program built here should run under.
    //
    // MCPP_SUBOS_DIR overrides it outright — that exists so tests can exercise
    // this path without touching the developer's real environment, and so a
    // user can point a run at another subos without switching the active one.
    std::filesystem::path subos_dir_for_build(const config::GlobalConfig& cfg);
```

`src/build/execute.cppm`:在构造子进程环境的两处(`run` 的 279-300 与 ninja 侧 1210-1230)把 `plan.subosEnv` 合并进去,`prepend` 语义与既有 `prepend_path_list` 一致。

- [ ] **Step 4: 跑,确认通过**

```bash
bash tests/e2e/196_subos_env_reaches_program.sh
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/build src/xlings.cppm tests/e2e/196_subos_env_reaches_program.sh
git commit -m "feat(run): a program mcpp launches gets its subos's environment"
```

---

### Task 7: 运行时身份成为显式轴

**Files:**
- Modify: `src/toolchain/abi.cppm`(libc 维度带版本)
- Modify: `src/toolchain/model.cppm`(`Toolchain` 加 `runtimeBinding`)
- Modify: `src/build/prepare.cppm`(解析优先级)
- Test: `tests/unit/test_abi.cpp`

**Interfaces:**
- Consumes: `subos::read(...).runtime`、`subos::family_of`
- Produces: `AbiProfile` 新增 `std::string libcVersion;`(空 = 未知,**不参与匹配**),`Toolchain::runtimeBinding`

**收窄的实现范围与理由**:`abi.cppm` 的 `libc` 维度参与依赖解析,给它加一个**参与匹配**的版本轴会立刻改变全索引的解析结果(memory 里 `index-floor-must-degrade` 是同一族事故)。所以本 task 只做两件事:①把版本**记录**下来并在 `mcpp doctor` / `--verbose` 里可见;②`runtimeBinding` 的解析优先级落地。**匹配语义不变**,留给 platform manifest 那一轮。

- [ ] **Step 1: 写失败的单测**

在 `tests/unit/test_abi.cpp` 追加:

```cpp
// The libc dimension gains a version, and it is RECORDED, not matched. A
// dependency that says `abi:glibc` must keep matching a glibc@2.39 toolchain
// exactly as it did before — changing that would re-resolve the whole index.
TEST(Abi, LibcVersionIsRecordedButNotMatched) {
    mcpp::toolchain::Toolchain tc;
    tc.targetTriple   = "x86_64-linux-gnu";
    tc.runtimeBinding = "glibc@2.39";
    auto p = mcpp::toolchain::abi_profile(tc);
    EXPECT_EQ(p.libc, "glibc");
    EXPECT_EQ(p.libcVersion, "2.39");

    auto c = mcpp::toolchain::parse_abi_capability("abi:glibc");
    ASSERT_TRUE(c.has_value());
    EXPECT_TRUE(mcpp::toolchain::satisfies(p, *c));
}

// No binding resolved: the dimension still answers, without a version.
TEST(Abi, LibcVersionEmptyWithoutABinding) {
    mcpp::toolchain::Toolchain tc;
    tc.targetTriple = "x86_64-linux-gnu";
    auto p = mcpp::toolchain::abi_profile(tc);
    EXPECT_EQ(p.libc, "glibc");
    EXPECT_TRUE(p.libcVersion.empty());
}
```

- [ ] **Step 2: 跑,确认失败**

```bash
mcpp test --filter Abi
```
Expected: FAIL,`runtimeBinding` / `libcVersion` 不存在

- [ ] **Step 3: 实现**

`model.cppm` 的 `Toolchain` 加:

```cpp
    // The runtime this toolchain's output is built against, in xlings's own
    // spelling ("glibc@2.39"). Resolved in prepare, in this order, every step
    // explicit — a "default is the convention" step is what grows a second
    // answerer (see the design doc §S1):
    //   1. --runtime
    //   2. [target.<triple>].runtime / [build].runtime
    //   3. the active subos's `subos_info.runtime`
    //   4. the payload actually resolved, with a note
    std::string runtimeBinding;
```

`abi.cppm` 的 `AbiProfile` 加 `std::string libcVersion;`,并在 `abi_profile()` 的两条路径里从 `tc.runtimeBinding` 的 `@` 之后取值。**`satisfies()` 不动。**

`prepare.cppm` 按上述四级解析并写进 `tc.runtimeBinding`;第 4 级发一条 `ui::info`。

- [ ] **Step 4: 跑,确认通过**

```bash
mcpp test --filter Abi
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/toolchain src/build/prepare.cppm tests/unit/test_abi.cpp
git commit -m "feat(toolchain): the runtime a build targets is read, not inferred"
```

---

### Task 8: 文档

**Files:**
- Modify: `docs/05-mcpp-toml.md`(`c_runtime`)
- Modify: `docs/02-pack-and-release.md`(contract × mode 的关系)
- Modify: `docs/03-toolchains.md`(runtime binding)
- Modify: `docs/zh/` 下的对应文件
- Modify: `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(标注已实施)

- [ ] **Step 1: `05-mcpp-toml.md` 加 `c_runtime`**

紧邻既有 `cxx_runtime` 一节,写明三值、默认值、以及与 `mcpp pack --mode` 的函数关系表(设计文档 §3-S2 那张)。**必须写清 `host-coupled` 的语义是「宿主 glibc ≥ 构建时的那份」**,不是「任何 Linux」。

- [ ] **Step 2: `02-pack-and-release.md` 加一节 "Contract vs mode"**

说明 mode 管「带多少东西」、contract 管「承诺什么」,并给出不可分发契约在 pack 时会被拒绝的行为。

- [ ] **Step 3: `03-toolchains.md` 加 runtime binding**

说明四级解析顺序与 `--runtime`。

- [ ] **Step 4: 校验中文版同步**

```bash
ls docs/zh/
```
对每个改过的英文文档,同步中文版。

- [ ] **Step 5: Commit**

```bash
git add docs .agents/docs
git commit -m "docs: the two runtime contracts, and what each promises"
```

---

### Task 9: 版本 + xlings pin + PR

**Files:**
- Modify: `src/version.cppm`
- Modify: `src/xlings.cppm`(`kXlingsVersion`)

- [ ] **Step 1: 查最新 xlings 版本**

```bash
gh api repos/openxlings/xlings/releases/latest --jq .tag_name
```

- [ ] **Step 2: bump 两个常量**

`MCPP_VERSION` → `2026.8.8.1`;`kXlingsVersion` → 上一步的值。

> **只动这两个。** bootstrap pin(`.xlings.json` 那组)是自举起点,不随发布走 —— 一起 bump 会让全部 CI 去装一个还不存在的版本。

- [ ] **Step 3: 机器校验 pin 一致**

```bash
.github/tools/check_version_pins.sh
```
Expected: 通过

- [ ] **Step 4: 全量本地验证**

```bash
mcpp build && mcpp test
for t in 30_pack_modes 195_c_runtime_host_coupled 196_subos_env_reaches_program; do
    bash "tests/e2e/$t.sh" || echo "FAIL: $t"
done
```

- [ ] **Step 5: 开 PR**

```bash
git checkout -b feat/xlings-runtime-substrate
git push -u origin feat/xlings-runtime-substrate
gh pr create --title "feat: xlings 作为运行时底座 —— libc 分发契约 + subos 环境 (#375, #352)" --body-file .agents/docs/pr-body.md
```

---

### Task 10: mcpp-index 图形栈迁移(独立仓、独立 PR)

**Files(`/home/speak/workspace/github/mcpplibs/mcpp-index`):**
- Modify: `pkgs/c/compat.glfw.lua`, `pkgs/c/compat.glx-headers.lua`, `pkgs/c/compat.vulkan-runtime.lua`
- Deprecate: `pkgs/c/compat.glx-runtime.lua`

- [ ] **Step 1: 确认下游只有三个**

```bash
cd /home/speak/workspace/github/mcpplibs/mcpp-index && grep -rln "glx-runtime" pkgs/
```
Expected: 恰好 4 个文件(含它自己)

- [ ] **Step 2: 三个消费者改为依赖 `xim:graphics`**

`compat.glx-runtime` 保留为**空壳 provider**(仍声明 `provides = {"opengl.glx.driver","x11.display"}`,但不再 symlink 宿主库),一个版本之后再删——下游包的 manifest 已发布,不能立刻失效(与 `index-floor-must-degrade` 同一条教训)。

- [ ] **Step 3: 验**

```bash
bash tests/smoke.sh 2>/dev/null || ls tests/
```
按该仓既有验证入口跑。**判据是渲染器身份,不是「窗口出现了」。**

- [ ] **Step 4: PR**

---

## Self-Review

**Spec coverage** — 设计文档各节 → task 映射:

| 设计文档 | Task |
|---|---|
| §1.6 / Q6 `self-contained` wrapper | T1 |
| §3-S2 `c_runtime` 契约 | T2 + T3 + T4 |
| §3-S3 subos 环境 | T5 + T6 |
| §3-S1 运行时身份 | T7 |
| §1.5 图形栈迁移 | T10 |
| §6 P0 文档 | T1 Step5 + T8 |
| §7 跨仓契约(xlings 落盘 exports) | **不在本 PR** —— 需 xlings 侧改动,设计文档已排 P1 尾;本 PR 的 §5.1「A 降级路径」足以工作 |
| §5.3 / P2 视图寻址 | **不做**(设计文档明确) |

**未覆盖且是有意的**:§2.1 六个回答者里,本 PR 收敛的是 #2/#3/#5(loader 选择统一到 `link_tokens` + `distro_loader_path`)。#1/#4/#6 依赖 xlings 侧落盘 exports,留 P1。V5 的静态计数守的是「不再增加」,不是「已经归零」。

**Type consistency** — 跨 task 一致性已核:`dist::Contract` / `dist::Role` / `dist::Format` 三者在 T2/T4 同名;`libcdist::Addressing` 与 `tc::LibcAddressing` 是**有意的镜像对**(层次不倒置),T3 的注释与 T2 的测试都点明了这一点,并由 T4 的 `static_cast` 单点转换。`subos::Info` / `resolve_env` 在 T5 定义、T6 消费,签名一致。
