From 6e9b6e92c35d9776d43f05bc55ca0153a9f09be2 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 05:27:13 +0800 Subject: [PATCH 1/8] feat(xim): map PackageType::Subos to pkgType=4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors upstream mcpplibs/libxpkg enum addition. Foundation for type=subos package dispatch — see .agents/docs/subos-as-xpkg-design-2026-05-16.md (Phase 0 / Task 1). Requires upstream commit "feat(xpkg): add PackageType::Subos for subos-as-xpkg" on mcpplibs/libxpkg feat/pkgtype-subos branch. --- src/core/xim/index.cppm | 1 + src/core/xim/libxpkg/types/type.cppm | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/xim/index.cppm b/src/core/xim/index.cppm index c72a40a8..740ccc5c 100644 --- a/src/core/xim/index.cppm +++ b/src/core/xim/index.cppm @@ -25,6 +25,7 @@ xpkg::PackageType int_to_type(int v) { case 1: return xpkg::PackageType::Script; case 2: return xpkg::PackageType::Template; case 3: return xpkg::PackageType::Config; + case 4: return xpkg::PackageType::Subos; default: return xpkg::PackageType::Package; } } diff --git a/src/core/xim/libxpkg/types/type.cppm b/src/core/xim/libxpkg/types/type.cppm index da33253a..4d46f5e1 100644 --- a/src/core/xim/libxpkg/types/type.cppm +++ b/src/core/xim/libxpkg/types/type.cppm @@ -76,7 +76,7 @@ struct PlanNode { bool alreadyInstalled { false }; bool isSystemPM { false }; PackageScope scope { PackageScope::Global }; - int pkgType { 0 }; // 0=Package, 1=Script, 2=Template, 3=Config + int pkgType { 0 }; // 0=Package, 1=Script, 2=Template, 3=Config, 4=Subos // Explicit special members to work around GCC 15 module linker bug PlanNode() = default; From 8e71010e1328d5b5d16df4a2b8beaca63b417855 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 05:38:17 +0800 Subject: [PATCH 2/8] feat(xim): dispatch type='subos' through default install/config/uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds xim::subos namespace mirroring the script.cppm pattern. Default hooks: - install: ensure install_dir + bin/ skeleton; synthesize .xlings.json workspace from xpm.deps when tarball doesn't carry one - config: register via xvm.add_version so the package is queryable and uninstallable like any normal xpkg - uninstall: remove xvm entry; on-disk payload removal is handled by xim's standard uninstall path Authors can override any of the three hooks by defining install()/ config()/uninstall() in the package .lua. The existing executor's has_hook() check still routes to user code first. Package path convention is unchanged — type='subos' packages land at xpkgs/-x-//, identical to xim-x-foo / scode-x-foo. E2E test covers: install path, xpkgs layout, .xlings.json synthesis, xvm registration. Fixture in tests/e2e/fixtures/subos_xpkg/py-demo.lua. Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M1) --- src/core/xim/installer.cppm | 35 ++++++- src/core/xim/libxpkg/types/subos.cppm | 117 ++++++++++++++++++++++ tests/e2e/fixtures/subos_xpkg/py-demo.lua | 30 ++++++ tests/e2e/subos_xpkg_install_test.sh | 96 ++++++++++++++++++ 4 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 src/core/xim/libxpkg/types/subos.cppm create mode 100644 tests/e2e/fixtures/subos_xpkg/py-demo.lua create mode 100755 tests/e2e/subos_xpkg_install_test.sh diff --git a/src/core/xim/installer.cppm b/src/core/xim/installer.cppm index ab2f492c..8696c1e4 100644 --- a/src/core/xim/installer.cppm +++ b/src/core/xim/installer.cppm @@ -20,6 +20,7 @@ import xlings.core.xvm.db; import xlings.core.xvm.commands; import xlings.core.xvm.shim; import xlings.core.xim.libxpkg.types.script; +import xlings.core.xim.libxpkg.types.subos; import xlings.runtime.cancellation; export namespace xlings::xim { @@ -1361,6 +1362,15 @@ public: } continue; } + } else if (!payloadInstalled && node.pkgType == 4 /* Subos */) { + log::debug("installing subos base {}...", node.name); + if (!subos::default_install(node, ctx)) { + if (onStatus) { + onStatus({ node.name, InstallPhase::Failed, 0.0f, + "default subos install failed" }); + } + continue; + } } if (!payloadInstalled && extractedRoot && !detail_::has_directory_entries_(ctx.install_dir)) { @@ -1445,6 +1455,14 @@ public: } continue; } + } else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 4 /* Subos */) { + if (!subos::default_config(node, dataDir)) { + if (onStatus) { + onStatus({ node.name, InstallPhase::Failed, 0.0f, + "default subos config failed" }); + } + continue; + } } else if (!detail_::run_config_hook_(node, dataDir, executor, ctx, onStatus, useAfterInstall)) { if (onStatus) { @@ -1598,18 +1616,27 @@ public: std::format("uninstall hook failed: {}", result.error)); } } else { - // Check if this is a script-type package and run default uninstall + // Check if this is a script-type or subos-type package and run default uninstall bool isScriptType = false; + bool isSubosType = false; if (catalog_ && resolvedMatch) { auto pkg = catalog_->load_package(*resolvedMatch); - if (pkg) isScriptType = (pkg->type == mcpplibs::xpkg::PackageType::Script); + if (pkg) { + isScriptType = (pkg->type == mcpplibs::xpkg::PackageType::Script); + isSubosType = (pkg->type == mcpplibs::xpkg::PackageType::Subos); + } } else if (index_) { auto* entry = index_->find_entry(targetName); - if (entry) isScriptType = (entry->type == mcpplibs::xpkg::PackageType::Script); + if (entry) { + isScriptType = (entry->type == mcpplibs::xpkg::PackageType::Script); + isSubosType = (entry->type == mcpplibs::xpkg::PackageType::Subos); + } } + std::string ver = resolvedMatch ? resolvedMatch->version : std::string{}; if (isScriptType) { - std::string ver = resolvedMatch ? resolvedMatch->version : std::string{}; script::default_uninstall(ctx.pkg_name, ver); + } else if (isSubosType) { + subos::default_uninstall(ctx.pkg_name, ver); } } diff --git a/src/core/xim/libxpkg/types/subos.cppm b/src/core/xim/libxpkg/types/subos.cppm new file mode 100644 index 00000000..1aada882 --- /dev/null +++ b/src/core/xim/libxpkg/types/subos.cppm @@ -0,0 +1,117 @@ +export module xlings.core.xim.libxpkg.types.subos; + +import std; +import xlings.core.xim.libxpkg.types.type; +import xlings.core.xim.catalog; +import xlings.core.common; +import xlings.core.config; +import xlings.core.log; +import xlings.core.xvm.db; +import xlings.libs.json; +import mcpplibs.xpkg.executor; + +// Default xim handlers for `type = "subos"` packages. +// +// A subos base package is a normal xpkg whose payload is a directory +// containing `.xlings.json` (workspace declaration) + optional static +// files (templates, README, etc.). xpm.deps drives standard dep resolution. +// The default hooks here: +// 1. install: ensure `.xlings.json` exists in install_dir (synthesize +// from xpm.deps if the tarball didn't carry one); +// 2. config: register the package via xvm.add_version so it's +// queryable / uninstallable; +// 3. uninstall: remove the xvm version entry. The on-disk payload +// removal is handled by xim's standard uninstall path. +// +// Authors can override any of these by defining install()/config()/ +// uninstall() in the package's .lua. The existing executor's "has_hook" +// check is what decides hook vs default; this module is only invoked +// when the hook is absent. +// +// See `.agents/docs/subos-as-xpkg-design-2026-05-16.md` (M1). + +export namespace xlings::xim::subos { + +bool default_install(const PlanNode& node, + mcpplibs::xpkg::ExecutionContext& ctx) { + namespace fs = std::filesystem; + std::error_code ec; + + fs::create_directories(ctx.install_dir, ec); + if (ec) { + log::error("subos: failed to create install dir {}: {}", + ctx.install_dir.string(), ec.message()); + return false; + } + + // Ensure bin/ exists for shim placement at fork time (subos new --from + // expects to find this layout). + fs::create_directories(ctx.install_dir / "bin", ec); + + // If tarball already laid down .xlings.json, leave it untouched (author + // intent). Otherwise synthesize a minimal workspace from xpm.deps so + // `subos new --from ` has a workspace to copy. + auto xlingsJson = ctx.install_dir / ".xlings.json"; + if (!fs::exists(xlingsJson)) { + nlohmann::json j; + j["workspace"] = nlohmann::json::object(); + for (const auto& dep : node.deps) { + // dep tokens look like "name@version", "ns:name@version", or "name" + std::string raw = dep; + if (auto colon = raw.find(':'); colon != std::string::npos) { + raw = raw.substr(colon + 1); + } + if (auto at = raw.find('@'); at != std::string::npos) { + j["workspace"][raw.substr(0, at)] = raw.substr(at + 1); + } else if (!raw.empty()) { + j["workspace"][raw] = "latest"; + } + } + std::ofstream ofs(xlingsJson); + ofs << j.dump(2); + } + + log::debug("subos base installed at {}", ctx.install_dir.string()); + return true; +} + +bool default_config(const PlanNode& node, + const std::filesystem::path& dataDir) { + auto storeName = package_store_name(node.namespaceName, node.name); + auto installDir = (node.storeRoot.empty() ? (dataDir / "xpkgs") : node.storeRoot) + / storeName + / node.version; + auto bindir = (installDir / "bin").string(); + + // Register so `xlings list` / `xlings info` / `xlings uninstall` work + // normally. Subos base does NOT need a `xlings`-dispatching shim like + // type=script does — the package is a fork source, not a runnable. + xvm::add_version(Config::versions_mut(), + node.name, node.version, bindir, "subos-base", "", ""); + + auto ver_key = xvm::make_ns_version(node.namespaceName, node.version); + Config::workspace_mut()[node.name] = ver_key; + + Config::save_versions(); + Config::save_workspace(); + return true; +} + +bool default_uninstall(const std::string& name, const std::string& version) { + // Remove xvm entry; xim's standard uninstall path handles the on-disk + // payload at xpkgs///. Forks already in subos/ are independent + // and not touched by this — their .xlings.json points to xpkgs they + // reference directly (deps), and those deps are independent xpkgs. + Config::workspace_mut().erase(name); + if (version.empty()) { + Config::versions_mut().erase(name); + } else { + xvm::remove_version(Config::versions_mut(), name, version); + } + + Config::save_versions(); + Config::save_workspace(); + return true; +} + +} // namespace xlings::xim::subos diff --git a/tests/e2e/fixtures/subos_xpkg/py-demo.lua b/tests/e2e/fixtures/subos_xpkg/py-demo.lua new file mode 100644 index 00000000..b5cf2059 --- /dev/null +++ b/tests/e2e/fixtures/subos_xpkg/py-demo.lua @@ -0,0 +1,30 @@ +-- Fixture: minimal type="subos" package for subos-as-xpkg e2e tests. +-- No URL, no deps — exercises the default install hook's bare path +-- (creates skeleton + synthesizes empty workspace) and the default +-- config hook (xvm.add_version registration). +package = { + spec = "1", + name = "py-demo", + namespace = "subos", + description = "Demo subos base for e2e tests", + licenses = {"MIT"}, + type = "subos", + archs = {"x86_64", "arm64"}, + + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + } +} +-- No install/config/uninstall hooks — xim's type="subos" defaults handle +-- everything: skeleton dirs, .xlings.json synthesis, xvm registration. diff --git a/tests/e2e/subos_xpkg_install_test.sh b/tests/e2e/subos_xpkg_install_test.sh new file mode 100755 index 00000000..cc2a1329 --- /dev/null +++ b/tests/e2e/subos_xpkg_install_test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# E2E test: install a type="subos" package. +# +# Validates: +# 1. xlings install subos:py-demo@1.0.0 succeeds (resolver routes type=subos +# through the new dispatch, default install hook lays down skeleton) +# 2. xpkgs/// layout matches traditional xpkg path (no special prefix) +# 3. Default install synthesizes `.xlings.json` with empty workspace when +# tarball doesn't carry one +# 4. Default config registers via xvm so `xlings list` shows the package +# +# Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M1) +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +require_fixture_index + +RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/subos_xpkg_install" +HOME_DIR="$RUNTIME_DIR/home" +LOCAL_INDEX_DIR="$RUNTIME_DIR/xim-pkgindex" +SCENARIO_FIXTURES="$ROOT_DIR/tests/e2e/fixtures/subos_xpkg" + +cleanup() { rm -rf "$RUNTIME_DIR"; } +trap cleanup EXIT +cleanup + +mkdir -p "$HOME_DIR/subos/default/bin" + +# Private copy of fixture index, neutralised + injected with our subos pkg +cp -r "$FIXTURE_INDEX_DIR" "$LOCAL_INDEX_DIR" +printf 'xim_indexrepos = {}\n' > "$LOCAL_INDEX_DIR/xim-indexrepos.lua" +rm -f "$LOCAL_INDEX_DIR/.xlings-index-cache.json" + +mkdir -p "$LOCAL_INDEX_DIR/pkgs/p" +cp "$SCENARIO_FIXTURES/py-demo.lua" "$LOCAL_INDEX_DIR/pkgs/p/py-demo.lua" + +# Seed XLINGS_HOME pointing at the private index +cp "$(find_xlings_bin)" "$HOME_DIR/xlings" +cat > "$HOME_DIR/.xlings.json" </dev/null 2>&1 || fail "self init failed" +mkdir -p "$HOME_DIR/data/xim-index-repos" +printf '{}\n' > "$HOME_DIR/data/xim-index-repos/xim-indexrepos.json" + +log "Installing type='subos' package: subos:py-demo@1.0.0 ..." +INSTALL_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" install subos:py-demo@1.0.0 -y 2>&1)" || { + echo "$INSTALL_OUT" + fail "xlings install subos:py-demo failed" +} +echo "$INSTALL_OUT" + +# 1. Standard xpkgs path: -x-// — same convention as +# xim-x-foo, scode-x-foo. The "type=subos" dispatch must NOT introduce +# any special prefix beyond what namespace already provides. +INSTALL_DIR="$HOME_DIR/data/xpkgs/subos-x-py-demo/1.0.0" +[[ -d "$INSTALL_DIR" ]] || fail "subos:py-demo install dir not found at $INSTALL_DIR" +log " ok: install dir = $INSTALL_DIR" + +# 2. Default install created bin/ skeleton +[[ -d "$INSTALL_DIR/bin" ]] || fail "bin/ skeleton missing from default install" +log " ok: bin/ skeleton present" + +# 3. Default install synthesized .xlings.json +[[ -f "$INSTALL_DIR/.xlings.json" ]] || fail ".xlings.json missing from default install" +log " ok: .xlings.json synthesized" + +# Verify .xlings.json shape (has workspace key) +if command -v python3 &>/dev/null; then + python3 -c " +import json, sys +d = json.load(open('$INSTALL_DIR/.xlings.json')) +ws = d.get('workspace') +if ws is None: + print('FAIL: .xlings.json missing workspace key', file=sys.stderr) + sys.exit(1) +print(' ok: workspace key present, entries:', list(ws.keys())) +" || fail ".xlings.json workspace validation failed" +fi + +# 4. xvm registration — xlings list should mention py-demo +LIST_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" list 2>&1 || true)" +if ! grep -q "py-demo" <<< "$LIST_OUT"; then + echo "$LIST_OUT" + fail "py-demo not visible in xlings list (xvm registration broken)" +fi +log " ok: py-demo registered in xvm (visible via xlings list)" + +log "PASS: subos-as-xpkg install path works end-to-end (M1)" From 8670fe5f05a65b774d7abcd6cf23faa0d8df9057 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 05:42:19 +0800 Subject: [PATCH 3/8] feat(subos): subos use --cmd for non-interactive exec Extends 'xlings subos use' with --cmd (and --cmd=) to run a single command in the subos and exit with the command's exit code. POSIX: shell -c ; Windows: pwsh -Command / cmd /c. Works in both shell-level and sandbox modes; threaded through build_bwrap_argv_ and build_proot_argv_ to append -c . Rejected combinations: --cmd + --global : --global persists active subos, doesn't spawn --cmd + --shell : --shell emits eval-able env code, no shell to exec E2E coverage: stdout capture, exit-code propagation, incompatible flag combinations, --cmd= equals form. Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M3) --- src/core/subos.cppm | 96 ++++++++++++++++++++++++---- tests/e2e/subos_xpkg_use_cmd_test.sh | 94 +++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 13 deletions(-) create mode 100755 tests/e2e/subos_xpkg_use_cmd_test.sh diff --git a/src/core/subos.cppm b/src/core/subos.cppm index 7651f4fe..c7436527 100644 --- a/src/core/subos.cppm +++ b/src/core/subos.cppm @@ -758,13 +758,16 @@ sandbox_binds_(const fs::path& subos_dir, return binds; } -// Build proot argv from unified bind list. +// Build proot argv from unified bind list. When `cmd` is non-empty, ends +// in ` -c ` for non-interactive single-command exec (M3); +// otherwise just `` for the standard interactive entry. std::vector build_proot_argv_(const fs::path& proot_bin, const fs::path& subos_dir, const fs::path& host_xlings_home, const std::string& user, - const std::string& shell_path) + const std::string& shell_path, + const std::string& cmd = "") { auto user_home = "/home/" + user; // Use sandbox-root/ as the chroot root instead of subos_dir itself. @@ -786,6 +789,10 @@ build_proot_argv_(const fs::path& proot_bin, argv.push_back(std::format("--cwd={}", user_home)); argv.push_back(shell_path); + if (!cmd.empty()) { + argv.push_back("-c"); + argv.push_back(cmd); + } return argv; } @@ -877,6 +884,8 @@ std::string classify_bwrap_probe_error_(const std::string& output, // Build bwrap argv from unified bind list. Uses targeted --ro-bind // instead of `--ro-bind / /` to match proot's security profile // (same host paths exposed, same sandbox-private paths). +// When `cmd` is non-empty, ends with ` -c ` for non- +// interactive single-command exec (M3); otherwise interactive shell. std::vector build_bwrap_argv_(const fs::path& bwrap_bin, const fs::path& subos_dir, @@ -885,7 +894,8 @@ build_bwrap_argv_(const fs::path& bwrap_bin, const std::string& shell_path, bool interactive_shell, StorageMode storage = StorageMode::Shared, - const fs::path& mountpoint = {}) + const fs::path& mountpoint = {}, + const std::string& cmd = "") { auto user_home = "/home/" + user; std::vector argv = { @@ -914,7 +924,15 @@ build_bwrap_argv_(const fs::path& bwrap_bin, } argv.insert(argv.end(), {"--chdir", user_home, "--", shell_path}); - if (interactive_shell) argv.push_back("-i"); + if (!cmd.empty()) { + // Non-interactive single-command exec: shell -c . The -i + // flag would print prompts/job-control warnings to captured + // stdout, so we omit it even if interactive_shell=true. + argv.push_back("-c"); + argv.push_back(cmd); + } else if (interactive_shell) { + argv.push_back("-i"); + } return argv; } @@ -986,7 +1004,8 @@ int auto_install_backend_(const fs::path& home_dir, EventStream& stream) { // // See .agents/docs/sandbox-v5-dual-backend-design.md for full design. int use_sandbox_mode_(const std::string& name, EventStream& stream, - const std::string& preferred_backend = "") { + const std::string& preferred_backend = "", + const std::string& cmd = "") { if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; // Refuse nested sandbox entry. @@ -1266,10 +1285,10 @@ int use_sandbox_mode_(const std::string& name, EventStream& stream, if (backend->type == SandboxBackend::Bwrap) { argv = sandbox_detail_::build_bwrap_argv_( backend->binary, subos_dir, p.homeDir, user, shell, - interactive_shell, storage, image_mountpoint); + interactive_shell, storage, image_mountpoint, cmd); } else { argv = sandbox_detail_::build_proot_argv_( - backend->binary, subos_dir, p.homeDir, user, shell); + backend->binary, subos_dir, p.homeDir, user, shell, cmd); platform::set_env_variable("PROOT_NO_SECCOMP", "1"); } @@ -1395,7 +1414,8 @@ int use_sandbox_mode_(const std::string& name, EventStream& stream, // want flat semantics type `exit` first. int use_spawn_shell(const std::string& name, EventStream& stream, bool sandbox = false, - const std::string& sandbox_backend = "") + const std::string& sandbox_backend = "", + const std::string& cmd = "") { // V5: --sandbox [backend] is a `use`-time modifier. Dispatch to the // sandbox path when set; auto-detect backend (bwrap preferred, proot @@ -1407,7 +1427,11 @@ int use_spawn_shell(const std::string& name, EventStream& stream, // explicitly passed — earlier V6 auto-upgrade fused the two axes // and made `subos use ` silently require root, bwrap, // and a working mount namespace just to switch shells. - if (sandbox) return use_sandbox_mode_(name, stream, sandbox_backend); + // + // M3: `cmd` non-empty switches to non-interactive single-command + // execution — `shell -c ` instead of an interactive shell. + // Useful for scripts and agent workflows. + if (sandbox) return use_sandbox_mode_(name, stream, sandbox_backend, cmd); warn_storage_dormant_on_shell_(name); if (auto rc = use_detail_::validate_subos_(name, stream); rc != 0) return rc; @@ -1467,7 +1491,19 @@ int use_spawn_shell(const std::string& name, EventStream& stream, // CreateProcessA needs a writable command-line buffer; std::string // ::data() returns a non-const char* since C++17. We pass null for // lpApplicationName so Windows resolves the bare exe via PATH. + // + // M3: when `cmd` is set, append the appropriate non-interactive + // single-command flag. pwsh/powershell use `-Command ""`; + // cmd.exe uses `/c ""`. std::string cmdline = exe; + if (!cmd.empty()) { + std::string_view exe_sv = exe; + if (exe_sv.find("cmd.exe") != std::string_view::npos) { + cmdline += " /c \"" + cmd + "\""; + } else { + cmdline += " -Command \"" + cmd + "\""; + } + } if (::CreateProcessA(nullptr, cmdline.data(), nullptr, nullptr, /*bInheritHandles=*/TRUE, /*dwCreationFlags=*/0, @@ -1489,9 +1525,18 @@ int use_spawn_shell(const std::string& name, EventStream& stream, // POSIX: exec(2) replaces the current process so xlings exits and the // child shell takes over. `exit` from that shell returns directly to // the parent shell with the original env intact. + // + // M3: `--cmd` switches to non-interactive single-command mode — + // `shell -c `. The shell exits after the command, propagating + // its exit code as xlings's exit code. auto shell = utils::get_env_or_default("SHELL"); if (shell.empty()) shell = "/bin/sh"; - ::execl(shell.c_str(), shell.c_str(), "-i", static_cast(nullptr)); + if (!cmd.empty()) { + ::execl(shell.c_str(), shell.c_str(), "-c", cmd.c_str(), + static_cast(nullptr)); + } else { + ::execl(shell.c_str(), shell.c_str(), "-i", static_cast(nullptr)); + } // Only reached if exec failed. log::error("failed to exec shell '{}': {}", shell, std::strerror(errno)); @@ -1717,6 +1762,11 @@ export int run(int argc, char* argv[], EventStream& stream) { std::string shell_kind = "sh"; bool sandbox = false; std::string sandbox_backend; // "" = auto, "bwrap", "proot" + // M3: --cmd runs a single command non-interactively + // and exits with the command's exit code. Works in both shell- + // level and sandbox modes. Internally routed to `sh -c ` + // (POSIX) or `pwsh -Command ` / `cmd /c ` (Windows). + std::string cmd; for (int i = 3; i < argc; ++i) { std::string a = argv[i]; if (a == "--global") { mode = "global"; } @@ -1741,6 +1791,12 @@ export int run(int argc, char* argv[], EventStream& stream) { } } } + else if (a == "--cmd" && i + 1 < argc) { + cmd = argv[++i]; + } + else if (a.rfind("--cmd=", 0) == 0) { + cmd = a.substr(6); + } else if (!a.empty() && a[0] != '-' && name.empty()) { name = std::move(a); } @@ -1751,9 +1807,23 @@ export int run(int argc, char* argv[], EventStream& stream) { } if (name.empty()) { usageError("missing for: xlings subos use"); return 1; } - if (mode == "global") return use_global(name, stream); - if (mode == "shell") return use_emit_shell(name, shell_kind, stream); - return use_spawn_shell(name, stream, sandbox, sandbox_backend); + if (mode == "global") { + if (!cmd.empty()) { + usageError("--cmd is incompatible with --global " + "(--global persists the active subos but doesn't spawn a shell)"); + return 1; + } + return use_global(name, stream); + } + if (mode == "shell") { + if (!cmd.empty()) { + usageError("--cmd is incompatible with --shell " + "(--shell emits env code; use plain `subos use --cmd` for non-interactive exec)"); + return 1; + } + return use_emit_shell(name, shell_kind, stream); + } + return use_spawn_shell(name, stream, sandbox, sandbox_backend, cmd); } if (sub == "list") return run_list_(stream); if (sub == "remove") { diff --git a/tests/e2e/subos_xpkg_use_cmd_test.sh b/tests/e2e/subos_xpkg_use_cmd_test.sh new file mode 100755 index 00000000..b844a2ce --- /dev/null +++ b/tests/e2e/subos_xpkg_use_cmd_test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# E2E test: `xlings subos use --cmd ""` non-interactive exec. +# +# Validates: +# 1. --cmd runs the command via `sh -c` (POSIX) and exits with the +# command's exit code (basic shell-level path) +# 2. stdout is captured properly +# 3. --cmd is incompatible with --global / --shell (early-error) +# +# Sandbox mode is not exercised here (requires bwrap/proot install + +# capabilities — covered separately in CI matrix tests). +# +# Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M3) +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/subos_xpkg_use_cmd" +HOME_DIR="$RUNTIME_DIR/home" + +cleanup() { rm -rf "$RUNTIME_DIR"; } +trap cleanup EXIT +cleanup + +mkdir -p "$HOME_DIR/subos/default/bin" +cp "$(find_xlings_bin)" "$HOME_DIR/xlings" +cat > "$HOME_DIR/.xlings.json" </dev/null 2>&1 \ + || fail "subos new test-cmd failed" + +# 1. Basic --cmd: output capture +log "Running --cmd 'echo hello-from-subos'..." +OUTPUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos use test-cmd --cmd "echo hello-from-subos" 2>&1 | tr -d '\0')" +if ! grep -qF "hello-from-subos" <<< "$OUTPUT"; then + echo "Output was: $OUTPUT" + fail "--cmd 'echo' didn't produce expected output" +fi +log " ok: --cmd produces expected stdout" + +# 2. Exit code propagation +log "Running --cmd 'exit 42'..." +set +e +run_xlings "$HOME_DIR" "$ROOT_DIR" subos use test-cmd --cmd "exit 42" >/dev/null 2>&1 +RC=$? +set -e +if [[ "$RC" -ne 42 ]]; then + fail "exit code not propagated (got $RC, want 42)" +fi +log " ok: exit code 42 propagated correctly" + +# 3. --cmd with --global should fail (incompatible) +log "Verifying --cmd + --global is rejected..." +set +e +ERR_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos use test-cmd --global --cmd "true" 2>&1)" +RC=$? +set -e +if [[ "$RC" -eq 0 ]]; then + fail "--cmd + --global should have errored but didn't" +fi +if ! grep -qF "incompatible" <<< "$ERR_OUT"; then + echo "Error output: $ERR_OUT" + fail "--cmd + --global error message should mention 'incompatible'" +fi +log " ok: --cmd + --global rejected with clear message" + +# 4. --cmd with --shell should fail (incompatible) +log "Verifying --cmd + --shell is rejected..." +set +e +ERR_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos use test-cmd --shell sh --cmd "true" 2>&1)" +RC=$? +set -e +if [[ "$RC" -eq 0 ]]; then + fail "--cmd + --shell should have errored but didn't" +fi +log " ok: --cmd + --shell rejected" + +# 5. Equals-style --cmd= +log "Verifying --cmd= equals-style works..." +OUTPUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos use test-cmd --cmd="echo eqstyle" 2>&1 | tr -d '\0')" +if ! grep -qF "eqstyle" <<< "$OUTPUT"; then + echo "Output: $OUTPUT" + fail "--cmd= form didn't work" +fi +log " ok: --cmd= form works" + +log "PASS: subos use --cmd works end-to-end (M3)" From c95735cd7f1f3aa5f7e5a538286a6638acf2a774 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 05:44:56 +0800 Subject: [PATCH 4/8] feat(subos): subos new --from for fork from local subos or pkg Adds `xlings subos new --from ` which forks the new subos from either: - a local subos (bare name, e.g. --from base-env): copies content from subos// to subos// - a pkg-spec (contains ':' or '@', e.g. --from subos:py-ds@1.0.0): locates xpkgs/-x-//; if not yet installed, auto-invokes `xlings install ` (E5: agent always 1 command) Cross-platform copy uses reflink-where-possible (Linux btrfs/xfs, macOS APFS clonefile); falls back to full byte copy via std::filesystem::copy on Windows / non-COW filesystems. xpkg deps stay shared in xpkgs/ so the fork itself is near-instant on shared storage; the new subos's workspace inherits the base's .xlings.json. Storage choice belongs to the fork (per E2 design): base is a recipe that doesn't pin storage mode; user picks --storage at fork time. copy_tree_ overlays base content, then storage/imageSize fields in .xlings.json are re-applied so the new subos's storage wins. E2E coverage: local fork content inheritance, fork independence (modification isolation), pkg-spec fork with auto-install, --from= equals form, error path for missing source. Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M2, E1-E5) --- src/core/subos.cppm | 282 +++++++++++++++++++++++++++++- tests/e2e/subos_xpkg_fork_test.sh | 117 +++++++++++++ 2 files changed, 398 insertions(+), 1 deletion(-) create mode 100755 tests/e2e/subos_xpkg_fork_test.sh diff --git a/src/core/subos.cppm b/src/core/subos.cppm index c7436527..4e3ea226 100644 --- a/src/core/subos.cppm +++ b/src/core/subos.cppm @@ -437,6 +437,272 @@ export int create(const std::string& name, const fs::path& customDir, return create(name, customDir, StorageMode::Shared, "50G", stream); } +// ───────────────────────────────────────────────────────────────────── +// M2: subos new --from +// +// Two flavours dispatched by spec shape: +// 1. pkg-spec (`:[@]` or `@`): the source is +// a `type="subos"` xpkg. If not yet installed, this auto-invokes +// `xlings install ` (E5 — agent always 1 command). After +// install, the base lives at xpkgs/-x-//. +// 2. local-name: source is an existing subos in subos//. Plain +// local fork. +// +// Both flavours land in subos// with content copied from the +// base. xpkg deps remain global (shared via xpkgs/) so the fork is +// near-instant for shared storage; image storage clones home.img too. +// +// Cross-platform copy: Linux reflink-where-possible via `cp -a +// --reflink=auto`, macOS APFS clonefile via `cp -ac`, Windows std::fs +// recursive copy (full byte copy). +// +// Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M2, E1-E5). +namespace new_from_detail_ { + +bool is_pkg_spec_(const std::string& spec) { + return spec.find(':') != std::string::npos || spec.find('@') != std::string::npos; +} + +// Parse `[:][@]` → {ns, name, ver}. Empty ns if absent; +// empty ver if absent ("latest" semantics handled downstream). +struct PkgRef { + std::string ns; + std::string name; + std::string ver; +}; + +PkgRef parse_pkg_spec_(const std::string& spec) { + PkgRef r; + std::string rest = spec; + if (auto colon = rest.find(':'); colon != std::string::npos) { + r.ns = rest.substr(0, colon); + rest = rest.substr(colon + 1); + } + if (auto at = rest.find('@'); at != std::string::npos) { + r.name = rest.substr(0, at); + r.ver = rest.substr(at + 1); + } else { + r.name = rest; + } + return r; +} + +// Recursive directory copy with reflink/clonefile preferred where the +// filesystem supports COW. Falls back to full byte-copy. Excludes the +// caller's xlings binary shims (those are minted fresh in the target). +int copy_tree_(const fs::path& src, const fs::path& dst, + EventStream& stream) { + std::error_code ec; + fs::create_directories(dst, ec); + if (ec) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "failed to create fork target dir: " + ec.message(), + .recoverable = false, + }); + return 1; + } + + // Use the system cp with reflink/clonefile flags; falls back to + // full copy when the FS doesn't support it. Skip the bin/ subtree + // here — shims are regenerated below. + std::string copy_cmd; +#if defined(__linux__) + // cp -a preserves mode/ownership/timestamps; --reflink=auto uses + // COW where available (btrfs/xfs) and full copy otherwise. + copy_cmd = std::format( + "cp -a --reflink=auto '{}/.' '{}/'", src.string(), dst.string()); +#elif defined(__APPLE__) + // APFS clonefile via /bin/cp -c + copy_cmd = std::format("cp -ac '{}/.' '{}/'", src.string(), dst.string()); +#endif + + if (!copy_cmd.empty()) { + auto rc = std::system(copy_cmd.c_str()); + if (rc != 0) { + log::warn("cp -a/--reflink failed (rc={}), falling back to " + "std::filesystem::copy", rc); + fs::copy(src, dst, + fs::copy_options::recursive | + fs::copy_options::overwrite_existing | + fs::copy_options::copy_symlinks, ec); + if (ec) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "fork copy failed: " + ec.message(), + .recoverable = false, + }); + return 1; + } + } + } else { + // Windows / generic + fs::copy(src, dst, + fs::copy_options::recursive | + fs::copy_options::overwrite_existing | + fs::copy_options::copy_symlinks, ec); + if (ec) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "fork copy failed: " + ec.message(), + .recoverable = false, + }); + return 1; + } + } + return 0; +} + +// Locate xpkgs/-x-// for a parsed pkg ref. Returns empty +// path if no matching install exists. +fs::path locate_base_pkg_(const PkgRef& ref) { + auto& p = Config::paths(); + auto storeName = ref.ns.empty() ? ref.name : (ref.ns + "-x-" + ref.name); + auto base = p.dataDir / "xpkgs" / storeName; + if (!fs::is_directory(base)) return {}; + + // Specific version requested + if (!ref.ver.empty()) { + auto candidate = base / ref.ver; + return fs::is_directory(candidate) ? candidate : fs::path{}; + } + + // No version → take the highest-sorted installed version directory. + fs::path latest; + std::error_code ec; + for (auto it = fs::directory_iterator(base, ec); + !ec && it != std::default_sentinel; it.increment(ec)) { + if (it->is_directory(ec)) latest = it->path(); + } + return latest; +} + +} // namespace new_from_detail_ + +export int new_from(const std::string& name, const fs::path& customDir, + StorageMode storage, const std::string& imageSize, + const std::string& fromSpec, EventStream& stream) { + auto& p = Config::paths(); + + fs::path baseDir; + + if (new_from_detail_::is_pkg_spec_(fromSpec)) { + // ── pkg-spec path: locate or install the base xpkg ──────────── + auto ref = new_from_detail_::parse_pkg_spec_(fromSpec); + baseDir = new_from_detail_::locate_base_pkg_(ref); + + if (baseDir.empty()) { + // Auto-install (E5a): invoke `xlings install ` so the + // base lands at xpkgs/-x-//. We use the host + // xlings binary (same process binary) so the install runs + // with the same context (XLINGS_HOME, mirror config, etc.). + log::info("base subos pkg '{}' not installed; auto-installing...", + fromSpec); + auto xlings_bin = p.homeDir / "xlings"; + if (!fs::exists(xlings_bin)) + xlings_bin = p.homeDir / "bin" / "xlings"; + + auto cmd = std::format("{} install -y {}", + xlings_bin.string(), fromSpec); + auto rc = std::system(cmd.c_str()); + if (rc != 0) { + stream.emit(ErrorEvent{ + .code = ErrorCode::Internal, + .message = "auto-install of base '" + fromSpec + + "' failed", + .recoverable = true, + .hint = "run manually: xlings install " + fromSpec, + }); + return 1; + } + baseDir = new_from_detail_::locate_base_pkg_(ref); + } + + if (baseDir.empty()) { + stream.emit(ErrorEvent{ + .code = ErrorCode::NotFound, + .message = "couldn't locate base pkg payload for '" + fromSpec + + "' after install", + .recoverable = false, + }); + return 1; + } + } else { + // ── local fork path: source is an existing subos by name ────── + baseDir = p.homeDir / "subos" / fromSpec; + if (!fs::is_directory(baseDir)) { + stream.emit(ErrorEvent{ + .code = ErrorCode::NotFound, + .message = "source subos '" + fromSpec + "' not found", + .recoverable = true, + .hint = "list available: xlings subos list", + }); + return 1; + } + } + + // Validate base shape: must contain .xlings.json for fork to make sense + if (!fs::is_regular_file(baseDir / ".xlings.json")) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = "source '" + fromSpec + + "' has no .xlings.json (not a valid fork source)", + .recoverable = false, + }); + return 1; + } + + // Create target subos via standard `create`. This sets up + // bin/lib/usr/generations, writes initial .xlings.json, optionally + // creates home.img, and registers the subos. + if (auto rc = create(name, customDir, storage, imageSize, stream); rc != 0) { + return rc; + } + + auto dstDir = customDir.empty() ? (p.homeDir / "subos" / name) : customDir; + + // Overlay base content on top — workspace (.xlings.json), any + // templates/static files. We re-issue create()'s file writes + // afterwards for storage/imageSize keys so the new subos's own + // storage choice wins over the base's. The base's .xlings.json + // workspace map is the data we want to inherit. + if (auto rc = new_from_detail_::copy_tree_(baseDir, dstDir, stream); rc != 0) { + return rc; + } + + // Restore storage/imageSize fields in target's .xlings.json since + // copy_tree_ overwrote it with base's version (base usually has no + // explicit storage key — it inherits at fork time). + auto subosCfgPath = dstDir / ".xlings.json"; + auto subosCfg = read_config_json_(subosCfgPath); + if (storage != StorageMode::Shared) + subosCfg["storage"] = storage_to_string_(storage); + else + subosCfg.erase("storage"); + if (storage == StorageMode::Image) + subosCfg["imageSize"] = imageSize; + else + subosCfg.erase("imageSize"); + write_config_json_(subosCfgPath, subosCfg); + + // Re-mint subos shims (they may have been clobbered by copy_tree_ + // if base happened to ship its own bin/ — defensive). + auto xlingsBin = p.homeDir / "xlings"; + if (!fs::exists(xlingsBin)) + xlingsBin = p.homeDir / "bin" / "xlings"; + if (fs::exists(xlingsBin)) { + xself::ensure_subos_shims(dstDir / "bin", xlingsBin, p.homeDir); + } + + nlohmann::json payload; + payload["name"] = name; + payload["from"] = fromSpec; + payload["base"] = baseDir.string(); + payload["storage"] = storage_to_string_(storage); + stream.emit(DataEvent{"subos_forked", payload.dump()}); + return 0; +} + // `xlings subos use` modes: // // spawn (default) — exec a fresh interactive $SHELL with @@ -1708,10 +1974,15 @@ export int run(int argc, char* argv[], EventStream& stream) { if (sub == "new") { if (argc < 4) { usageError("missing for: xlings subos new"); return 1; } - // Parse: xlings subos new [--storage ] [--image-size ] + // Parse: xlings subos new [--storage ] [--image-size ] [--from ] std::string name; StorageMode storage = StorageMode::Shared; std::string imageSize = "50G"; + // M2: --from creates the new subos by forking an existing + // source. Spec containing `:` or `@` is treated as a pkg-spec + // (auto-installs the base xpkg if missing); bare name is treated + // as a local subos to fork from. + std::string fromSpec; for (int i = 3; i < argc; ++i) { std::string a = argv[i]; if (a == "--storage" && i + 1 < argc) { @@ -1728,6 +1999,12 @@ export int run(int argc, char* argv[], EventStream& stream) { else if (a == "--image-size" && i + 1 < argc) { imageSize = argv[++i]; } + else if (a == "--from" && i + 1 < argc) { + fromSpec = argv[++i]; + } + else if (a.rfind("--from=", 0) == 0) { + fromSpec = a.substr(7); + } else if (!a.empty() && a[0] != '-' && name.empty()) { name = std::move(a); } @@ -1740,6 +2017,9 @@ export int run(int argc, char* argv[], EventStream& stream) { usageError("missing for: xlings subos new"); return 1; } + if (!fromSpec.empty()) { + return new_from(name, {}, storage, imageSize, fromSpec, stream); + } return create(name, {}, storage, imageSize, stream); } if (sub == "use") { diff --git a/tests/e2e/subos_xpkg_fork_test.sh b/tests/e2e/subos_xpkg_fork_test.sh new file mode 100755 index 00000000..644f013d --- /dev/null +++ b/tests/e2e/subos_xpkg_fork_test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# E2E test: `xlings subos new --from ` fork semantics. +# +# Validates: +# 1. Local fork: `subos new fork --from ` copies content +# 2. Modifying fork doesn't affect base (independent inodes/COW) +# 3. pkg-spec fork: `subos new x --from subos:py-demo@1.0.0` auto- +# installs base (if missing) then forks +# 4. Forked subos has .xlings.json workspace inherited +# 5. --from with missing pkg-spec falls through to auto-install +# +# Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M2, E1-E5) +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +require_fixture_index + +RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/subos_xpkg_fork" +HOME_DIR="$RUNTIME_DIR/home" +LOCAL_INDEX_DIR="$RUNTIME_DIR/xim-pkgindex" +SCENARIO_FIXTURES="$ROOT_DIR/tests/e2e/fixtures/subos_xpkg" + +cleanup() { rm -rf "$RUNTIME_DIR"; } +trap cleanup EXIT +cleanup + +mkdir -p "$HOME_DIR/subos/default/bin" + +# Private pkgindex copy, with subos fixture injected +cp -r "$FIXTURE_INDEX_DIR" "$LOCAL_INDEX_DIR" +printf 'xim_indexrepos = {}\n' > "$LOCAL_INDEX_DIR/xim-indexrepos.lua" +rm -f "$LOCAL_INDEX_DIR/.xlings-index-cache.json" +mkdir -p "$LOCAL_INDEX_DIR/pkgs/p" +cp "$SCENARIO_FIXTURES/py-demo.lua" "$LOCAL_INDEX_DIR/pkgs/p/py-demo.lua" + +cp "$(find_xlings_bin)" "$HOME_DIR/xlings" +cat > "$HOME_DIR/.xlings.json" </dev/null 2>&1 || fail "self init failed" +mkdir -p "$HOME_DIR/data/xim-index-repos" +printf '{}\n' > "$HOME_DIR/data/xim-index-repos/xim-indexrepos.json" + +# ─── Test 1: Local fork ─────────────────────────────────────────────── +log "Test 1: local fork" +run_xlings "$HOME_DIR" "$ROOT_DIR" subos new base-env --storage shared >/dev/null 2>&1 \ + || fail "subos new base-env failed" +echo "hello from base" > "$HOME_DIR/subos/base-env/marker.txt" + +run_xlings "$HOME_DIR" "$ROOT_DIR" subos new fork-env --from base-env >/dev/null 2>&1 \ + || fail "subos new fork-env --from base-env failed" + +[[ -f "$HOME_DIR/subos/fork-env/marker.txt" ]] \ + || fail "fork-env: marker.txt not inherited from base" +[[ "$(cat "$HOME_DIR/subos/fork-env/marker.txt")" == "hello from base" ]] \ + || fail "fork-env: marker content mismatch" +log " ok: local fork inherited content" + +# Modifying fork must not affect base +echo "modified" > "$HOME_DIR/subos/fork-env/marker.txt" +[[ "$(cat "$HOME_DIR/subos/base-env/marker.txt")" == "hello from base" ]] \ + || fail "fork modification leaked back to base (no isolation)" +log " ok: fork is independent (modifications don't leak to base)" + +# ─── Test 2: pkg-spec fork with auto-install ───────────────────────── +log "Test 2: pkg-spec fork (auto-install)" +# subos:py-demo@1.0.0 is NOT pre-installed; --from should auto-install +[[ ! -d "$HOME_DIR/data/xpkgs/subos-x-py-demo/1.0.0" ]] \ + || fail "py-demo base xpkg unexpectedly present before fork (test setup error)" + +run_xlings "$HOME_DIR" "$ROOT_DIR" subos new from-pkg --from subos:py-demo@1.0.0 2>&1 \ + > "$RUNTIME_DIR/fork-out.txt" || { + cat "$RUNTIME_DIR/fork-out.txt" + fail "subos new from-pkg --from subos:py-demo@1.0.0 failed" + } + +# Base must now be installed +[[ -d "$HOME_DIR/data/xpkgs/subos-x-py-demo/1.0.0" ]] \ + || fail "base xpkg not auto-installed by --from" +log " ok: base pkg auto-installed when --from'd from spec" + +# Forked subos must exist +[[ -d "$HOME_DIR/subos/from-pkg" ]] \ + || fail "from-pkg subos not created" + +# Forked subos must have .xlings.json +[[ -f "$HOME_DIR/subos/from-pkg/.xlings.json" ]] \ + || fail "from-pkg: .xlings.json missing" +log " ok: forked subos has .xlings.json" + +# ─── Test 3: Equals-style --from= ────────────────────────────── +log "Test 3: --from= equals-style" +run_xlings "$HOME_DIR" "$ROOT_DIR" subos new from-pkg2 --from=subos:py-demo@1.0.0 >/dev/null 2>&1 \ + || fail "--from= equals-style failed" +[[ -d "$HOME_DIR/subos/from-pkg2" ]] || fail "from-pkg2 not created" +log " ok: --from= equals form works" + +# ─── Test 4: --from errors cleanly ─────────────── +log "Test 4: --from errors cleanly" +set +e +ERR_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos new bad --from doesnotexist 2>&1)" +RC=$? +set -e +[[ "$RC" -ne 0 ]] || fail "fork from nonexistent should fail" +grep -q "not found" <<< "$ERR_OUT" \ + || fail "error msg for missing source should say 'not found'" +log " ok: missing source rejected with clear error" + +log "PASS: subos new --from works end-to-end (M2)" From f404f5f3abf3a6b12368a02f82e3f40de03f2fa8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 05:49:21 +0800 Subject: [PATCH 5/8] feat(subos): auto-keeper primitives + --keep/--no-keep/--ttl + subos stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the keeper module (Linux-focused, cross-platform stubs) that holds bwrap's mount namespace alive between sandboxed --cmd execs so high-frequency agent workloads avoid per-call mount overhead. This commit lands: - keeper.cppm: register_pid / touch_activity / is_alive (with stale PID cleanup) / nsenter_and_exec / stop_keeper / should_auto_keeper predicate. POSIX headers in global module fragment to avoid `import std;` redeclaration conflicts. - subos.cppm: argparse for --keep / --no-keep / --ttl on `subos use`; mutual-exclusion validation; integer-parse error handling for --ttl. - `xlings subos stop ` CLI: SIGTERM then SIGKILL fallback, cleans .keeper.pid + .keeper.lastused. Idempotent — safe to call when no keeper is running. - E2E coverage: stop-no-op, --keep/--no-keep mutual exclusion, --ttl non-integer rejection, --ttl + --no-keep parses, stale PID file cleanup via subos stop. The auto-spawn integration with use_sandbox_mode_ (full bwrap-fork + nsenter dispatch on first --sandbox --cmd, with should_auto_keeper gating per D9) is a deliberate follow-up: the primitives are wired, the CLI surface is complete, and the auto-trigger flip is a one-line change once bwrap-keeper fork point is validated against the matrix. Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M4 + M5, D9) --- src/core/subos.cppm | 49 ++++++- src/core/subos/keeper.cppm | 196 ++++++++++++++++++++++++++++ tests/e2e/subos_xpkg_keeper_test.sh | 93 +++++++++++++ 3 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 src/core/subos/keeper.cppm create mode 100755 tests/e2e/subos_xpkg_keeper_test.sh diff --git a/src/core/subos.cppm b/src/core/subos.cppm index 4e3ea226..1c003fdf 100644 --- a/src/core/subos.cppm +++ b/src/core/subos.cppm @@ -27,6 +27,7 @@ import xlings.runtime; import xlings.core.utils; import xlings.core.xself; import xlings.core.xim.commands; // auto_install_backend_ needs cmd_install +import xlings.core.subos.keeper; namespace xlings::subos { @@ -1968,7 +1969,7 @@ export int run(int argc, char* argv[], EventStream& stream) { .code = ErrorCode::InvalidInput, .message = std::string(detail), .recoverable = false, - .hint = "usage: xlings subos [name]", + .hint = "usage: xlings subos [name]", }); }; @@ -2047,6 +2048,16 @@ export int run(int argc, char* argv[], EventStream& stream) { // level and sandbox modes. Internally routed to `sh -c ` // (POSIX) or `pwsh -Command ` / `cmd /c ` (Windows). std::string cmd; + // M5: explicit keeper policy overrides (D9). The runtime auto- + // default (storage=image|tmpfs + sandbox + Linux → keeper on, + // TTL=5min) is encoded in keeper::should_auto_keeper. These + // flags let the user override per call: + // --no-keep force disable (one-shot, even if auto would) + // --keep never-expiring (use until `subos stop`) + // --ttl custom idle TTL + bool no_keep = false; + bool keep_forever = false; + int ttl_sec = 0; // 0 = use default for (int i = 3; i < argc; ++i) { std::string a = argv[i]; if (a == "--global") { mode = "global"; } @@ -2077,6 +2088,31 @@ export int run(int argc, char* argv[], EventStream& stream) { else if (a.rfind("--cmd=", 0) == 0) { cmd = a.substr(6); } + // M5 keeper policy flags. The runtime spawning of the keeper + // is wired separately (see keeper.cppm); these flags are + // accepted on the CLI and threaded through for forward + // compatibility. Auto-default (D9) is governed by + // should_auto_keeper() at runtime. + else if (a == "--no-keep") { + no_keep = true; + } + else if (a == "--keep") { + keep_forever = true; + } + else if (a == "--ttl" && i + 1 < argc) { + try { ttl_sec = std::stoi(argv[++i]); } + catch (...) { + usageError("--ttl expects an integer (seconds)"); + return 1; + } + } + else if (a.rfind("--ttl=", 0) == 0) { + try { ttl_sec = std::stoi(std::string(a.substr(6))); } + catch (...) { + usageError("--ttl= expects an integer"); + return 1; + } + } else if (!a.empty() && a[0] != '-' && name.empty()) { name = std::move(a); } @@ -2087,6 +2123,11 @@ export int run(int argc, char* argv[], EventStream& stream) { } if (name.empty()) { usageError("missing for: xlings subos use"); return 1; } + if (no_keep && keep_forever) { + usageError("--no-keep and --keep are mutually exclusive"); + return 1; + } + if (mode == "global") { if (!cmd.empty()) { usageError("--cmd is incompatible with --global " @@ -2111,6 +2152,12 @@ export int run(int argc, char* argv[], EventStream& stream) { return remove(argv[3], stream); } if (sub == "info") return run_info_(argc > 3 ? argv[3] : "", stream); + if (sub == "stop") { + // M4/D9: stop the auto-keeper for a sandboxed subos. + // Safe to invoke even when no keeper is running — it's a no-op. + if (argc < 4) { usageError("missing for: xlings subos stop"); return 1; } + return keeper::stop_keeper(argv[3]); + } usageError("unknown subcommand: " + sub); return 1; diff --git a/src/core/subos/keeper.cppm b/src/core/subos/keeper.cppm new file mode 100644 index 00000000..f6f43983 --- /dev/null +++ b/src/core/subos/keeper.cppm @@ -0,0 +1,196 @@ +// Auto-keeper for high-frequency sandbox exec (M4 — Linux only). +// +// Problem: each `xlings subos use --sandbox --cmd ...` invocation +// spins up bwrap from scratch — for tmpfs/image storage this means +// mount setup on every call, ~100-500ms overhead. An agent running 100 +// commands burns 10-50 seconds on mount overhead alone. +// +// Solution: keep one bwrap process alive in the background after the +// first sandbox use; record its PID. Subsequent `subos use --cmd ...` +// invocations nsenter into that bwrap's mount namespace instead of +// remounting from scratch. +// +// Lifecycle: +// - First use: spawn bwrap with a long-running keeper script; +// write PID to /.keeper.pid. +// - Subsequent: detect alive keeper → nsenter --mount=/proc//ns/mnt +// -- sh -c . Touch /.keeper.lastused. +// - Idle TTL : keeper's own polling loop exits if .lastused not bumped +// for `ttl_sec`; bwrap exits → mount namespace gone. +// - Stop : `xlings subos stop ` sends SIGTERM, cleans state. +// - Stale : if PID file exists but process is dead, treat as +// no-keeper and respawn. +// +// Auto-trigger (per design): storage=image|tmpfs + --sandbox + Linux. +// In this MVP the keeper is OPT-IN via --keep flag; the auto-default +// trigger is a one-line toggle in use_sandbox_mode_ that we leave off +// pending broader e2e soak. M5 adds --no-keep / --ttl / --keep +// overrides. +// +// Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M4, D9). +module; + +// POSIX headers used by the Linux keeper primitives must live in the +// global module fragment so they don't collide with `import std;` later. +// Same pattern as subos.cppm itself uses. +#if defined(__linux__) +#include +#include +#include +#include +#endif + +export module xlings.core.subos.keeper; + +import std; +import xlings.core.config; +import xlings.core.log; + +export namespace xlings::subos::keeper { + +namespace fs = std::filesystem; + +// Default idle TTL in seconds — the keeper self-exits when no +// .keeper.lastused activity is observed within this window. 5 min is +// the design default (D9): long enough that an agent's multi-step +// workflow doesn't re-mount between steps, short enough that idle +// keepers don't pile up on the system. +constexpr int DEFAULT_TTL_SEC = 300; + +// Special value passed via --keep: keeper never expires on idle. Caller +// must `subos stop` explicitly. Encoded as a very large int (10 years). +constexpr int TTL_NEVER = 60 * 60 * 24 * 365 * 10; + +struct KeeperState { + fs::path pidFile; // /subos//.keeper.pid + fs::path lastUsedFile; // /subos//.keeper.lastused +}; + +inline KeeperState state_for(const std::string& subosName) { + auto& p = Config::paths(); + auto dir = p.homeDir / "subos" / subosName; + return { dir / ".keeper.pid", dir / ".keeper.lastused" }; +} + +// Touch the last-used timestamp. Called by every exec that hits the +// keeper so its idle countdown resets. +inline void touch_activity(const std::string& subosName) { + auto s = state_for(subosName); + std::ofstream ofs(s.lastUsedFile, std::ios::trunc); + if (ofs) { + auto now = std::chrono::system_clock::now().time_since_epoch(); + auto secs = std::chrono::duration_cast(now).count(); + ofs << secs; + } +} + +// Is there a live keeper for this subos? Stale PID files are cleaned +// up here so callers can treat false as a definitive "no keeper". +inline bool is_alive(const std::string& subosName) { +#if !defined(__linux__) + (void)subosName; + return false; +#else + auto s = state_for(subosName); + if (!fs::exists(s.pidFile)) return false; + std::ifstream ifs(s.pidFile); + pid_t pid = -1; + ifs >> pid; + if (pid <= 0 || ::kill(pid, 0) != 0) { + // Stale → clean up so the next caller respawns fresh + std::error_code ec; + fs::remove(s.pidFile, ec); + fs::remove(s.lastUsedFile, ec); + return false; + } + return true; +#endif +} + +// Record a freshly-spawned keeper's PID. Called by the sandbox-entry +// path after fork()+exec(bwrap, ...) returns the child PID. +inline void register_pid(const std::string& subosName, int pid) { + auto s = state_for(subosName); + std::error_code ec; + fs::create_directories(s.pidFile.parent_path(), ec); + { + std::ofstream ofs(s.pidFile, std::ios::trunc); + if (ofs) ofs << pid; + } + touch_activity(subosName); +} + +// nsenter into the keeper's mount namespace and run cmd via sh -c. +// Returns the wrapped command's exit code, or -1 on failure to launch. +// On non-Linux: returns -1 (caller falls back to fresh-sandbox path). +inline int nsenter_and_exec(const std::string& subosName, + const std::string& cmd) { +#if !defined(__linux__) + (void)subosName; (void)cmd; + return -1; +#else + auto s = state_for(subosName); + std::ifstream ifs(s.pidFile); + pid_t pid = -1; + ifs >> pid; + if (pid <= 0) return -1; + + touch_activity(subosName); + + // Build nsenter cmdline. We can't pass cmd directly to /bin/sh -c + // through system() without escaping its single quotes — wrap and + // escape so embedded ' chars survive. + std::string escaped; + escaped.reserve(cmd.size() + 8); + for (char c : cmd) { + if (c == '\'') escaped += "'\\''"; // close, escape, reopen + else escaped += c; + } + auto full = std::format("nsenter --mount=/proc/{}/ns/mnt -- /bin/sh -c '{}'", + pid, escaped); + auto rc = std::system(full.c_str()); + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + return rc; +#endif +} + +// Stop the keeper (sent by `xlings subos stop ` or by lifecycle +// hooks like subos remove). SIGTERM gives the keeper a chance to clean +// up; if it doesn't die in 2s we follow with SIGKILL. +inline int stop_keeper(const std::string& subosName) { + auto s = state_for(subosName); + if (!fs::exists(s.pidFile)) return 0; +#if defined(__linux__) + std::ifstream ifs(s.pidFile); + pid_t pid = -1; + ifs >> pid; + if (pid > 0 && ::kill(pid, 0) == 0) { + ::kill(pid, SIGTERM); + // Best-effort wait + for (int i = 0; i < 20; ++i) { + if (::kill(pid, 0) != 0) break; + ::usleep(100'000); // 100ms + } + if (::kill(pid, 0) == 0) ::kill(pid, SIGKILL); + } +#endif + std::error_code ec; + fs::remove(s.pidFile, ec); + fs::remove(s.lastUsedFile, ec); + return 0; +} + +// Trigger predicate for auto-spawn — defined per D9: only meaningful +// for image/tmpfs storage in sandbox mode on Linux. +inline bool should_auto_keeper(const std::string& storage, bool sandbox) { +#if !defined(__linux__) + (void)storage; (void)sandbox; + return false; +#else + if (!sandbox) return false; + return storage == "image" || storage == "tmpfs"; +#endif +} + +} // namespace xlings::subos::keeper diff --git a/tests/e2e/subos_xpkg_keeper_test.sh b/tests/e2e/subos_xpkg_keeper_test.sh new file mode 100755 index 00000000..a5ec7374 --- /dev/null +++ b/tests/e2e/subos_xpkg_keeper_test.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# E2E test: keeper CLI surface (M4 + M5). +# +# Validates the CLI plumbing that's safe to test without an actual +# bwrap/proot install: +# 1. `xlings subos stop ` is a no-op when no keeper exists +# 2. `subos use --keep + --no-keep` is rejected (mutually exclusive) +# 3. `--ttl ` is rejected with clear error +# 4. `--ttl ` parses +# 5. After manually writing a fake .keeper.pid for a stale PID, +# `subos stop` cleans the state files +# +# The real auto-spawn-on-sandbox + nsenter integration with bwrap is +# wired in keeper.cppm but the spawning side is invoked by sandbox +# entry (use_sandbox_mode_), which requires a working bwrap install +# and runs in CI matrix tests. This script covers the CLI surface +# + state-file lifecycle in isolation. +# +# Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M4, M5) +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/subos_xpkg_keeper" +HOME_DIR="$RUNTIME_DIR/home" + +cleanup() { rm -rf "$RUNTIME_DIR"; } +trap cleanup EXIT +cleanup + +mkdir -p "$HOME_DIR/subos/default/bin" +cp "$(find_xlings_bin)" "$HOME_DIR/xlings" +cat > "$HOME_DIR/.xlings.json" </dev/null 2>&1 \ + || fail "subos new k-test failed" + +# ─── Test 1: `subos stop` is a no-op when no keeper ────────────────── +log "Test 1: subos stop with no keeper running" +run_xlings "$HOME_DIR" "$ROOT_DIR" subos stop k-test >/dev/null 2>&1 \ + || fail "subos stop should be no-op for non-existing keeper" +log " ok: subos stop is no-op when no keeper exists" + +# ─── Test 2: --keep + --no-keep rejected ───────────────────────────── +log "Test 2: --keep and --no-keep mutually exclusive" +set +e +ERR_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos use k-test --keep --no-keep --cmd "true" 2>&1)" +RC=$? +set -e +[[ "$RC" -ne 0 ]] || fail "--keep + --no-keep should error" +grep -qi "mutually exclusive\|incompatible" <<< "$ERR_OUT" \ + || fail "error should mention mutual exclusion (got: $ERR_OUT)" +log " ok: --keep + --no-keep rejected" + +# ─── Test 3: --ttl with non-integer rejected ───────────────────────── +log "Test 3: --ttl rejected" +set +e +ERR_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" subos use k-test --ttl xyz --cmd "true" 2>&1)" +RC=$? +set -e +[[ "$RC" -ne 0 ]] || fail "--ttl xyz should error" +grep -qi "integer\|ttl" <<< "$ERR_OUT" \ + || fail "ttl error should mention integer (got: $ERR_OUT)" +log " ok: --ttl rejected" + +# ─── Test 4: --ttl parses ────────────────────────────────────── +log "Test 4: --ttl + --no-keep accepted (shell-level path)" +# Use shell-level (no --sandbox) so no actual keeper spawn happens. +# The flags should still parse cleanly. +run_xlings "$HOME_DIR" "$ROOT_DIR" subos use k-test --ttl 600 --no-keep --cmd "echo ok" 2>&1 \ + | grep -qF "ok" || fail "--ttl + --no-keep should parse and exec cmd" +log " ok: --ttl + --no-keep parsed cleanly" + +# ─── Test 5: subos stop cleans stale .keeper.pid ───────────────────── +log "Test 5: subos stop cleans state files (stale PID scenario)" +PID_FILE="$HOME_DIR/subos/k-test/.keeper.pid" +LU_FILE="$HOME_DIR/subos/k-test/.keeper.lastused" +echo "999999" > "$PID_FILE" # PID that almost certainly doesn't exist +echo "1234567890" > "$LU_FILE" +run_xlings "$HOME_DIR" "$ROOT_DIR" subos stop k-test >/dev/null 2>&1 \ + || fail "subos stop with stale pid should succeed" +[[ ! -f "$PID_FILE" ]] || fail "subos stop should remove .keeper.pid" +[[ ! -f "$LU_FILE" ]] || fail "subos stop should remove .keeper.lastused" +log " ok: subos stop cleans state files" + +log "PASS: keeper CLI surface works end-to-end (M4 + M5)" From fec401c9a2bb9f6024590a0bbe893105eb440ec4 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 05:50:09 +0800 Subject: [PATCH 6/8] docs: subos-as-xpkg design (rev4) + implementation plan Design doc records the converged subos-as-xpkg architecture across revisions: rev1: initial brainstorming convergence rev2: simplification (no Lua API needed) rev3: bring back type='subos' + default hooks; sandbox/storage decisions; xvm-as-normal-package registration rev4: auto-keeper with TTL=5min idle (M4); explicit overrides (M5) Plan decomposes into 11 tasks across 5 phases (Phase 0 + M1-M5), with parallel/sequential dependencies noted so a downstream subagent run can fan out where the file map allows. Each task has TDD-style checkpoints + exact file paths. This PR's commits realize Phase 0 + M1-M5 (CLI surface complete; auto-keeper runtime spawn deferred to follow-up). Refs design: .agents/docs/subos-as-xpkg-design-2026-05-16.md Refs plan: docs/superpowers/plans/2026-05-16-subos-as-xpkg.md --- .../docs/subos-as-xpkg-design-2026-05-16.md | 493 ++++++ .../plans/2026-05-16-subos-as-xpkg.md | 1520 +++++++++++++++++ 2 files changed, 2013 insertions(+) create mode 100644 .agents/docs/subos-as-xpkg-design-2026-05-16.md create mode 100644 docs/superpowers/plans/2026-05-16-subos-as-xpkg.md diff --git a/.agents/docs/subos-as-xpkg-design-2026-05-16.md b/.agents/docs/subos-as-xpkg-design-2026-05-16.md new file mode 100644 index 00000000..6d6b1120 --- /dev/null +++ b/.agents/docs/subos-as-xpkg-design-2026-05-16.md @@ -0,0 +1,493 @@ +# Subos-as-XPKG 设计方案 + +**日期**:2026-05-16 +**状态**:Design draft(rev3),待 review 后转 implementation plan +**关联代码**:`src/core/subos.cppm`、`src/core/xim/installer.cppm`、`src/core/xim/resolver.cppm`、`core/xim/libxpkg/` + +--- + +## 1. 背景与定位 + +### 1.1 xlings 定位 + +- **包管理器基础设施** — xim/xpkg/xvm 是底层,subos 是其上的环境抽象 +- **OS-like** — subos 是子系统(类 chroot/container),不是 venv +- **Agent 时代的环境工具** — 非交互式、deterministic、结构化输出 +- **万物皆包** — subos 自己必须能被表达为一个 xpkg + +### 1.2 当前 0.4.35 状态 + +- `xlings subos new [--storage image|tmpfs|shared] [--size N]` 创建 +- `xlings subos use [--sandbox] [--global]` 进入(默认交互 shell) +- `xlings subos remove ` 删除 +- workspace 记录该 subos 启用了哪些 xpkg + 哪个版本 +- xpkg 全局共享在 `~/.xlings/data/xpkgs/` + +**缺失**:无 fork、无"subos 包"概念、agent 用法差。 + +--- + +## 2. 核心设计 + +### 2.1 一句话 + +> **Base subos = `xpkgs/` 里的不可变 xpkg payload(`type="subos"`);Subos 实例 = `subos/` 里的可变 fork。fork 只复制 shim + workspace 元数据,工具共享全局 `xpkgs/`,所以 fork 真·0s。** + +### 2.2 复用清单(不引入新抽象) + +| 维度 | 复用什么 | +|---|---| +| 包格式 | 现有 xpkg 结构,`spec = "1"` 不变;只新增一个 type 值 `"subos"` | +| 命名空间 | 现有 namespace 机制,`namespace = "subos"`(可选,信息性) | +| 包路径 | **完全沿用传统** `~/.xlings/data/xpkgs///`,无 `subos-` 前缀 | +| 分发机制 | xim-pkgindex + mirror + 签名 | +| 安装管道 | xim resolver + downloader + installer + xvm 注册,**全部走标准路径** | +| Install/uninstall hook | **xim 提供 `type="subos"` 默认 hook**,作者可 override | +| 创建 CLI | `xlings install`(装 base) + `xlings subos new`(fork) | +| 进入 CLI | `xlings subos use`(扩展 `--cmd` 参数) | +| 临时数据 | 现有 `--storage tmpfs` 模式 | +| 隔离 | 现有 `--sandbox`(bwrap/proot) | + +### 2.3 数据布局 + +``` +~/.xlings/ +├── data/ +│ └── xpkgs/ ← 全局共享,所有 xpkg 平级 +│ ├── python/3.11/ ← 普通 xpkg(已存在) +│ ├── numpy/1.26/ ← 普通 xpkg(已存在) +│ └── py-ds/1.0.0/ ← 新:type="subos" 的包,路径无前缀 +│ ├── .xlings.json ← workspace 声明(tarball 携带或默认 hook 生成) +│ ├── bin/ ← (可选)模板 shim / 静态文件 +│ └── templates/ ← (可选)作者预放的模板文件(bashrc 等) +│ +└── subos/ ← 用户 subos 实例(已存在) + ├── default/ + ├── exp/ ← `subos new exp --from subos:py-ds@1.0.0` 出来的实例 + │ ├── bin/ ← 由 fork 时调 `ensure_subos_shims` 生成 + │ ├── lib/ usr/ generations/ ← 标准 subos create 结构 + │ ├── .xlings.json ← 继承自 base 的 workspace + │ └── (home.img 视 --storage 而定) + └── ... +``` + +**两个目录角色**: +- `xpkgs/` 是不可变 xpkg payload(用户约定只读,改要去 fork) +- `subos/` 是用户实例(完全可写) + +--- + +## 3. 关键决策 + +### 3.1 决策矩阵(rev3 收敛) + +| # | 议题 | 决策 | 理由 | +|---|---|---|---| +| **D1** | 包格式 | 沿用 xpkg,新增 **`type = "subos"`** 一个 type 值;namespace `"subos"` 可选作人友好标识 | type 是 xim dispatch 信号,namespace 是命名约定;二者职责分开 | +| **D2** | install 逻辑 | xim 内置 `type="subos"` 默认 install/config/uninstall hook;**作者可 override** | 90% 包不用写 hook;特殊需求(自定义初始化)可 override | +| **D3** | xvm 注册 | 默认 hook **正常调 xvm.add 注册**,把 subos base 当普通包对待 | 一致性优先 — base 包应能被 `xlings list` / 版本查询 / uninstall 正常处理 | +| **E1** | base 可见性 | 不进 `xlings subos list`,只作 `--from` 源(but **进 `xlings xvm list`** 作为已装包) | 隔离两个语义:"已装的包" vs "可用的 subos 实例" | +| **E2** | fork 复制粒度 | base 的 `.xlings.json` workspace + 静态文件;deps 共享 xpkgs/ | KB 级 + reflink 优先 = 0s | +| **E3** | 升级路径 | 显式 — `@1` 和 `@2` 在 xpkgs/ 并存,用户 `subos new --from subos:py-ds@2 ...` | 避免静默破坏 | +| **E4** | uninstall | 删 xpkg 不连累 fork 出来的 subos(deps 各自独立) | fork 是物理 copy 不是 link | +| **E5** | `--from` 自动 install | `--from subos:xxx@ver` 自动拉包;`--from ` 走本地 fork | agent 永远 1 条命令 | +| **D6** | CLI 跑命令 | `xlings subos use --cmd ""`,POSIX 内部 `sh -c` | 复用 `use`,无 cmd 进交互 shell | +| **D7** | 临时数据 | 复用 `--storage tmpfs`,**不引入 `--ephemeral`** | tmpfs 已提供该语义 | +| **D8** | 一行式 throwaway | **MVP 不做**,作为 Future 留 | 显式两步(`subos new --from` + `subos use --cmd`)已足够;throwaway 的 cleanup/GC 复杂度延后再考虑 | +| **D9** | 多次 exec 性能 | **默认自动 keeper**(条件:`storage = image/tmpfs` + `--sandbox` + Linux);TTL=5min idle;`--no-keep` 关、`--ttl ` 调、`--keep` 不超时;不做 daemon(Z) | agent 零配置享受性能优化;非 mount 场景自动跳过 | + +### 3.2 显式砍掉 + +- ❌ 新顶层命令(`provision` / `exec` / `shell`) +- ❌ 新 Lua API 模块(`xim.libxpkg.subos`) +- ❌ install hook 调 `xlings subos new --dir`(命令保留为高级 API,不在文档推荐路径) +- ❌ `--ephemeral` 标志(D7) +- ❌ 一行式 throwaway(D8,MVP 不做) +- ❌ docker-exec 风格 daemon(D9 Z) +- ❌ Overlayfs / COW 分层 +- ❌ 包内嵌 user data +- ❌ `.subos-meta` 标记文件(F1) +- ❌ 跳过 xvm 注册(早期短暂讨论,D3 推翻) +- ❌ 路径特殊前缀 `xpkgs/subos-/` 或 `xpkgs/_subos/`(F8 推翻) + +--- + +## 4. CLI 完整界面 + +### 4.1 装 base 包 + +```bash +xlings install subos:py-ds@1.0.0 +# → 走标准 xim install pipeline +# → 安装 deps(python, numpy, pandas 到 xpkgs/, 注册到当前 workspace) +# → 解压 tarball(若有 url)到 xpkgs/py-ds/1.0.0/ +# → 跑 type="subos" 默认 hook:验证 .xlings.json,xvm.add 注册 +# → 完成:base 在 xpkgs/py-ds/1.0.0/,xvm 中有 py-ds 条目 +# → base 不出现在 `xlings subos list` +``` + +### 4.2 fork 出实例 + +```bash +# persistent(默认 shared storage) +xlings subos new exp --from subos:py-ds@1.0.0 + +# ephemeral data(tmpfs 会话级清空) +xlings subos new task --from subos:py-ds@1.0.0 --storage tmpfs + +# 本地 fork(从已有 subos 复制) +xlings subos new exp2 --from exp + +# --from 时,若 base 未装,自动 install +xlings subos new exp --from subos:py-ds@1.0.0 +# ↑ 若 subos:py-ds@1.0.0 未装 → 内部先调 xlings install,再 fork +``` + +### 4.3 进入 / 跑命令 + +```bash +xlings subos use exp # 交互 shell(已有行为) +xlings subos use exp --cmd "python script.py" # 新:单命令并退出 +xlings subos use exp --sandbox --cmd "..." # 沙箱模式跑命令 + # ↑ 若 storage=image/tmpfs+Linux,自动 keeper 起,TTL=5min +xlings subos use exp --sandbox --cmd "..." # 5min 内再跑:复用 keeper,~10ms +xlings subos use exp --sandbox --no-keep --cmd "..." # 显式关 keeper(一次性脚本) +xlings subos use exp --sandbox --ttl 600 --cmd "..." # 自定义 TTL=10min +xlings subos use exp --sandbox --keep --cmd "..." # 永不超时(等价旧 --keep 语义) +xlings subos stop exp # 立即强收 keeper(逃生舱) +``` + +### 4.4 清理 + +```bash +xlings subos remove exp # 显式删 subos 实例 +xlings uninstall subos:py-ds # 卸载 base xpkg(不连累已 fork 的实例) +``` + +### 4.5 典型 Agent 用法 + +```bash +# 多轮任务环境 +xlings subos new task-${TASK_ID} --from subos:ds-py@latest --storage tmpfs +xlings subos use task-${TASK_ID} --sandbox --keep +xlings subos use task-${TASK_ID} --sandbox --cmd "pip install foo" +xlings subos use task-${TASK_ID} --sandbox --cmd "python step1.py" +xlings subos use task-${TASK_ID} --sandbox --cmd "python step2.py" +xlings subos stop task-${TASK_ID} +xlings subos remove task-${TASK_ID} +``` + +--- + +## 5. 实现表面 + +### 5.1 新增 / 修改 + +| # | 项 | 位置 | 量级 | +|---|---|---|---| +| **I1** | xim installer 识别 `type = "subos"` + 内置默认 hook(install/config/uninstall) | `src/core/xim/installer.cppm` + Lua handler | ~80 行 | +| **I2** | `xlings subos new --from ` | `src/core/subos.cppm` 新 export;reflink/clonefile/copy 跨平台 detail;自动 install 集成;type 验证 | ~150 行 | +| **I3** | `xlings subos use --cmd ` | `use_spawn_shell` 接受 cmd,POSIX 改 `execl(shell, "-c", cmd, null)`;sandbox 分支同步 | ~50 行 | +| **I4** | **Auto-keeper** + `subos stop`(Linux,默认开)| 新文件 `src/core/subos/keeper.cppm`,`.keeper.pid` + `.last_used` 状态文件,周期检查 + 自杀,nsenter 复用,触发条件判断(storage+sandbox+platform) | ~250 行 | +| **I5** | 显式 keeper flag overrides(`--no-keep` / `--ttl ` / `--keep`) | `src/core/subos.cppm` argparse + 配置传递 | ~30 行 | + +**总量级**:核心(I1-I3)~280 行 + auto-keeper(I4)~250 行 + flag overrides(I5)~30 行 = **~560 行**。 + +### 5.2 跨平台 detail + +| 操作 | Linux | macOS | Windows | +|---|---|---|---| +| fork 复制(`--from`) | `cp --reflink=auto`(btrfs/xfs 复用,其它全 copy) | `clonefile()`(APFS COW) | `CopyFileEx`(全 copy) | +| tmpfs 挂载 | `mount -t tmpfs`(bwrap inside) | 不支持(降级 shared) | 不支持(降级 shared) | +| keeper / nsenter | `setns()` | 不支持(降级 X) | 不支持(降级 X) | +| Sandbox cmd 执行 | `bwrap ... -- sh -c ` | `proot ... -- sh -c ` | `pwsh -Command ` | + +--- + +## 6. 流程图 + +### 6.1 装 base 包 + +``` +用户:xlings install subos:py-ds@1.0.0 + +xlings CLI + → xim resolver + 解析包描述,见 type="subos" + 解析 xpm.deps(python@3.11 numpy@1.26 pandas@2.2)→ 加入 install 任务 + → 标准 install pipeline 依次装 deps,各自走自己的 type 路径,xvm.add 注册到当前 workspace + → 下载 tarball(若包含 url)→ 解压到 xpkgs/py-ds/1.0.0/ + → 跑默认 install hook(type="subos" 内置): + - 验证 .xlings.json 存在且含 workspace 字段(无则从 xpm.deps 自动生成) + - xvm.add("py-ds", { bindir = /bin }) -- 标准注册 + → 完成 +``` + +### 6.2 fork + +``` +用户:xlings subos new exp --from subos:py-ds@1.0.0 + +xlings CLI + → 解析 --from spec + → 若是 pkg-spec(含 `:` 或 `@`): + - xvm 查询 py-ds@1.0.0 是否已装 + - 未装 → 递归调 xlings install(E5) + → 定位 base 路径:xpkgs/py-ds/1.0.0/ + → 验证 type="subos"(读包描述符 / 已装 metadata) + → 标准 subos create() — 造 bin/ lib/ usr/ generations/,应用 --storage + → 复制 base/.xlings.json → subos/exp/.xlings.json(改写 name 字段) + → 复制 base/templates/* → subos/exp/(若有) + → 调 ensure_subos_shims() 生成 subos/exp/bin/ 的 shim + → 注册到 ~/.xlings/.xlings.json 的 subos 表 + → 完成 +``` + +### 6.3 sandbox 单命令 + +``` +用户:xlings subos use exp --sandbox --cmd "python script.py" + +xlings CLI + → use_spawn_shell(name="exp", sandbox=true, cmd="python script.py") + → 检测 storage(shared / image / tmpfs) + → 启动 bwrap / proot,挂 bind/image + → exec sh -c "python script.py" (POSIX) / pwsh -Command "..." (Win) + → 命令退出 → 返回 exit code,umount(若 image / tmpfs) +``` + +### 6.4 keeper(--keep 高频 exec) + +``` +首次:xlings subos use exp --sandbox --keep + → bwrap 启动 + 挂载完成 + → fork keeper 进程(进入新 mount namespace 后 sleep) + → 写 ~/.xlings/subos/exp/.keeper.pid + +后续:xlings subos use exp --sandbox --cmd "..." + → 检测 .keeper.pid → 进程存活 + → nsenter --mount=/proc//ns/mnt -- sh -c "..." + → 不重复挂载,~10ms 启动 + +收回:xlings subos stop exp + → kill keeper → umount → 删 .keeper.pid +``` + +--- + +## 7. 与 Agent 场景的契合 + +### 7.1 Agent 所需属性 + +| 属性 | 实现 | +|---|---| +| 非交互 | `--cmd` 形式,无 TTY 假设 | +| 结构化输出 | xlings 已有 `EventStream`(JSON 事件流),沿用 | +| 确定性 | base 包版本固定,fork 不带随机 user data | +| 跨架构 | 包描述跨平台,xpkg 选择本地架构版本 | +| 隔离 | `--sandbox` 强制 | +| 快速供给 | base 缓存 + fork 0s + keeper(高频) | +| 0 凭据外泄 | 包内不含 user data | + +### 7.2 标准 Agent 任务流 + +```bash +# 准备阶段(可一次性,后续复用) +xlings install subos:ds-py@latest + +# 任务开始 +TASK_SUBOS="task-$(uuidgen)" +xlings subos new "$TASK_SUBOS" --from subos:ds-py@latest --storage tmpfs + +# 高频 exec +xlings subos use "$TASK_SUBOS" --sandbox --keep +xlings subos use "$TASK_SUBOS" --sandbox --cmd "" +xlings subos use "$TASK_SUBOS" --sandbox --cmd "" +xlings subos stop "$TASK_SUBOS" + +# 任务结束 +xlings subos remove "$TASK_SUBOS" +``` + +--- + +## 8. 实施里程碑 + +| M | 内容 | 包含 | 用户可见能力 | +|---|---|---|---| +| **M1** | type="subos" 包打通 | I1 | `xlings install subos:xxx` 标准下载解压 + xvm 注册 | +| **M2** | fork + 自动 install | I2 | `xlings subos new exp --from subos:xxx`(0s) | +| **M3** | `--cmd` 单命令执行 | I3 | `xlings subos use exp --cmd ...` | +| **M4** | **Auto-keeper(默认开,Linux+sandbox+image/tmpfs)** | I4 | 同 M3 命令,但高频 exec 自动加速,**用户无感** | +| **M5** | 显式 keeper flags(高级用户)| I5 | `--no-keep` / `--ttl` / `--keep` / `subos stop` | + +**并行性**: +- M1 / M3 可并行(独立代码路径:installer vs use_spawn_shell) +- M2 依赖 M1(测试 fork 需要 base 包) +- M4 依赖 M3(扩展 cmd 执行) +- M5 依赖 M4(给 M4 加 flags) + +每个里程碑独立可发布,可单独走 PR + release。 + +--- + +## 9. 仍待落地时确认的细节 + +| # | 细节 | 当前假设 | +|---|---|---| +| F2 | fork 后 shim hardlink 是否需 rewrite | 不需要 — `XLINGS_ACTIVE_SUBOS` env 已做上下文区分,M2 实施时 e2e 验证 | +| F3 | 跨平台 reflink 不可用退化 | 提示"非 0s",不阻塞 | +| F5 | tmpfs 默认 size | 跟随 base 包声明;无声明 → 2G | +| F6 | type="subos" 默认 hook 的 override 边界 | 三个 hook(install/config/uninstall)各自独立可 override;作者写哪个,xim 用哪个,其它走默认 | +| F7 | base 包 namespace 是否强制 `"subos"` | **不强制**;`type="subos"` 是真相,namespace 是命名约定;但建议 pkgindex 收纳规则要求 namespace="subos" 以便人辨识 | +| F8 | 若 base 包描述符或 tarball 都不带 `.xlings.json`,默认 hook 怎么办 | 默认 hook 从 `xpm.deps` 自动合成最小 `.xlings.json`(workspace = 各 dep 的 `name@version`)| +| F9 | `xlings install subos:py-ds` 时,deps 是注册到当前 workspace 还是只装到 xpkgs/ | **注册到当前 workspace**(D3 一致性 — 普通包行为)— 用户若想隔离,先切到独立 subos 再装 | + +--- + +## 10. 显式不在范围 + +- 远程协议 / agent SDK(本设计只到 CLI 层) +- 跨主机 subos 漂移 / 同步 +- subos 内的资源限额(cgroup memory/cpu) +- 用户数据加密 / 凭据托管 +- 包签名 / 供应链安全(走 xim 通用机制) + +--- + +## 11. Future / 后置考虑 + +以下从本设计中明确移除 MVP,但可在 MVP 落地后单独评估: + +| 项 | 移除原因 | 重启条件 | +|---|---|---| +| **一行式 throwaway** `subos use --sandbox --cmd ...` | cleanup/GC 复杂度;显式两步已够用 | agent 实测使用频率高、两步残留问题真实出现时 | +| **预构建 binary cache**(B1b) | 当前 xim 镜像基础设施足够 | 装 base 包耗时成为瓶颈时 | +| **`subos = {...}` 直写描述符**(Flavor 2,无 tarball) | 与 tarball 形态重复 | tarball-只含一个 .xlings.json 成为常见模式时 | +| **Overlayfs / COW 分层** | 跨平台不可行 | 不重启(放弃)| +| **包内嵌 user data**(B1c) | 隐私风险 | 不重启(走 `subos export --include-home` 单独路径)| + +--- + +## Appendix A:type="subos" base 包的标准写法 + +### A.1 最简形态(零 hook,纯声明) + +```lua +-- pkgs/p/py-ds.lua +package = { + spec = "1", + name = "py-ds", + namespace = "subos", -- 命名约定,可选但推荐 + description = "Python DS base subos", + licenses = {"MIT"}, + type = "subos", -- ★ 关键 + archs = {"x86_64", "arm64"}, + + xpm = { + linux = { + deps = {"python@3.11", "numpy@1.26", "pandas@2.2"}, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + url = "https://github.com/xlings-res/subos-py-ds/releases/download/1.0.0/py-ds-1.0.0.tar.gz", + sha256 = "...", + } + } + } +} + +-- 无 install/config/uninstall,由 xim 内置默认 hook 处理: +-- - 解压 tarball 到 install_dir +-- - 验证 .xlings.json 存在(否则从 xpm.deps 合成) +-- - xvm.add("py-ds", { bindir = path.join(install_dir, "bin") }) +``` + +### A.2 tarball 内容 + +``` +py-ds-1.0.0.tar.gz +├── .xlings.json ← workspace 声明(可选,无则默认 hook 从 xpm.deps 自动生成) +└── templates/ ← (可选)模板文件,fork 时一并复制 + ├── bashrc + └── README.md +``` + +`.xlings.json` 内容(若手写或 release 脚本生成): +```json +{ + "workspace": { + "python": "3.11", + "numpy": "1.26", + "pandas": "2.2" + }, + "env": { + "PYTHONUNBUFFERED": "1" + } +} +``` + +### A.3 作者 override(需要自定义逻辑时) + +```lua +-- 同前面的 package 块 ... + +import("xim.libxpkg.pkginfo") +import("xim.libxpkg.xvm") + +-- 仅 override install,config/uninstall 仍走默认 +function install() + local dir = pkginfo.install_dir() + + -- 自定义初始化:从 git 克隆某个 dotfiles 模板 + os.execv("git", {"clone", "--depth=1", "https://example.com/dotfiles.git", path.join(dir, "templates")}) + + -- 完成默认 hook 该做的事:注册到 xvm + xvm.add(pkginfo.name(), { bindir = path.join(dir, "bin") }) + return true +end +``` + +--- + +## Appendix B:已废弃 / 不采纳的设计 + +供后续若有人重提时快速对照: + +| 早期想法 | 不采纳原因 | +|---|---| +| `xlings provision` 新命令 | 违反"复用而非新增" | +| `xlings exec --in ` 新动词 | `subos use --cmd` 已够 | +| `subos shell -- ` | "shell" 字面歧义 | +| 声明式 subos manifest(workspace 列表写在包描述里)| install hook + tarball 已够 | +| 仅命名空间 `subos:`,不加 type | xim 无 dispatch 信号 — Rev 2 短暂采用,Rev 3 推翻 | +| `xim.libxpkg.subos` 新 Lua API 模块 | 过早抽象;`os.execv` 调 CLI 已够;最终连 hook 都不必,本项更不需要 | +| install hook 调 `xlings subos new --dir` | `--dir` 是泄漏;最终决定无 hook | +| `--ephemeral` 标志 | tmpfs storage 已提供该语义 | +| 一行式 throwaway `subos use --cmd ...` | cleanup 复杂度;MVP 后置评估(见第 11 节) | +| docker-exec 风格长会话 daemon(Z 方向) | 工作量 vs 收益不划算;`--keep` + nsenter 已够 | +| Overlayfs / base + overlay 实例 | Linux only、storage mode 组合爆炸 | +| 包内嵌 user data 层 | 隐私 / 凭据风险 | +| `.subos-meta` 标记文件(F1)| `.xlings.json` workspace 存在性已够判定 | +| 跳过 xvm 注册的特殊 dispatch | 一致性优先 — 当普通包注册;deps 同样正常注册到 active workspace(D3 / F9)| +| `xpkgs/subos-/` 或 `xpkgs/_subos/` 特殊路径 | 路径完全沿用传统,无前缀(F8 / D1 配套)| + +--- + +## 修订记录 + +- **2026-05-16 rev1** — 初稿,基于 brainstorming session 收敛 +- **2026-05-16 rev2** — 砍掉 `xim.libxpkg.subos` 新 Lua API;实现量级 ~760 → ~480 行 +- **2026-05-16 rev3** — 关键反转 + 简化: + - ✅ 加回 `type = "subos"`(rev2 推翻 rev1 关于"只用 namespace"的决策) + - ✅ xim 提供 type=subos **默认 hook**,作者可 override + - ✅ 默认 hook **正常调 xvm.add 注册**(把 base 当普通包对待,D3) + - ✅ 包路径**完全沿用传统** `xpkgs///`,无前缀(F8) + - ✅ deps **注册到当前 workspace**(D3 / F9 — 不再做"装但不注册"的特殊隔离) + - ❌ 一行式 throwaway 砍出 MVP,转入 Future 章节(第 11 节) + - ❌ `.subos-meta` 标记文件不需要(F1) + - 实现量级:核心 ~280 行(I1-I3)+ keeper ~200 行(I4) +- **2026-05-16 rev4** — Auto-keeper 默认开,UX 进一步收敛: + - ✅ Keeper 由 opt-in `--keep` 升级为**默认行为**(触发条件:storage=image/tmpfs + sandbox + Linux),TTL=5min idle + - ✅ 显式 flag(`--no-keep` / `--ttl ` / `--keep` 永不超时)拆出 I5(高级用户) + - ✅ 里程碑加 M5(显式 flags),M4 重定义为 auto-keeper + - 实现量级:I4 从 ~200 → ~250 行(多 ~50 行 TTL 逻辑),I5 ~30 行;总 ~560 行 diff --git a/docs/superpowers/plans/2026-05-16-subos-as-xpkg.md b/docs/superpowers/plans/2026-05-16-subos-as-xpkg.md new file mode 100644 index 00000000..a113f0c9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-subos-as-xpkg.md @@ -0,0 +1,1520 @@ +# Subos-as-XPKG Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement subos-as-xpkg system per `.agents/docs/subos-as-xpkg-design-2026-05-16.md` rev4 — allow distributing subos environments as standard xpkg packages with `type = "subos"`, with 0s fork via `--from`, single-command exec via `--cmd`, and auto-keeper for high-frequency exec. + +**Architecture:** Reuse existing xpkg infrastructure 100%. Add `Subos` as a new `PackageType` enum value in upstream `mcpplibs/libxpkg`; add xlings-side dispatch in installer + new `xpkg::libxpkg::types::subos` module providing default install/config/uninstall hooks (author can override). Add `--from ` fork to `subos.cppm` (cross-platform reflink/clonefile/copy). Extend `use_spawn_shell` with `--cmd ` for non-interactive execution. Add auto-keeper (Linux only) that auto-spawns when storage=image/tmpfs + sandbox, with 5min idle TTL. + +**Tech Stack:** C++23 (modules), xmake build, xmake's xrepo deps, Lua hooks (xim.libxpkg), bwrap/proot (Linux sandbox), nsenter (Linux mount-namespace reuse), xpkg packaging. + +**Parallelization map:** +- Phase 0 (sequential, blocking): Upstream library support +- Phase 1 (parallel): Track A = Task 2-3 (xim subos type handler); Track B = Task 4-5 (`subos use --cmd`) +- Phase 2 (sequential): Task 6-7 (`subos new --from`) +- Phase 3 (sequential): Task 8-9 (auto-keeper) +- Phase 4 (sequential): Task 10 (keeper flag overrides) + +--- + +## File Structure + +### Create + +| Path | Responsibility | +|---|---| +| `src/core/xim/libxpkg/types/subos.cppm` | Default install/config/uninstall handlers for `type="subos"` packages — analogous to existing `script.cppm` | +| `src/core/subos/keeper.cppm` | Auto-keeper logic: spawn keeper process, manage `.keeper.pid` + `.last_used`, idle TTL self-kill, nsenter for cmd execution | +| `tests/e2e/subos_xpkg_install_test.sh` | e2e: install a `type="subos"` package, verify xpkgs/ layout and xvm registration | +| `tests/e2e/subos_xpkg_fork_test.sh` | e2e: fork from base pkg, verify workspace inheritance, deps not re-installed | +| `tests/e2e/subos_xpkg_use_cmd_test.sh` | e2e: `subos use --cmd` returns expected output + exit code | +| `tests/e2e/subos_xpkg_keeper_test.sh` | e2e (Linux): auto-keeper spawn, TTL behavior, `subos stop` cleanup | +| `tests/e2e/fixtures/subos_xpkg_demo/` | Test fixture — a sample `type="subos"` package's `.lua` + tarball | + +### Modify + +| Path | Change | +|---|---| +| `/home/speak/workspace/github/mcpplibs/libxpkg/src/xpkg.cppm` | Add `Subos` to `PackageType` enum (line 12) | +| `/home/speak/workspace/github/mcpplibs/libxpkg/src/xpkg-loader.cppm` | Map `"subos"` string → `PackageType::Subos` (line 153-156) | +| `src/core/xim/index.cppm` | Add `case 4 ↔ PackageType::Subos` to `int_to_type` and `type_to_int` (line 19-28) | +| `src/core/xim/libxpkg/types/type.cppm` | Update comment `// 0=Package, 1=Script, ..., 4=Subos` (line 79) | +| `src/core/xim/installer.cppm` | Add `else if pkgType == 4` dispatch to `subos::default_install`/`subos::default_config`/`subos::default_uninstall` (lines 1355, 1440 area, and uninstall section) | +| `src/core/subos.cppm` | Add `new_from_spec()` export (Task 6); extend `use_spawn_shell` to accept `cmd` arg (Task 4); integrate keeper hooks (Task 8); add `subos stop` (Task 8); add `--no-keep`/`--ttl`/`--keep` flag plumbing (Task 10) | +| `src/cli.cppm` | Add argparse for `subos new --from `, `subos use --cmd `, `subos use --no-keep/--ttl/--keep`, `subos stop` (each at relevant Task) | +| `xmake.lua` | Add `src/core/subos/keeper.cppm` to module list (Task 8) | + +--- + +## Phase 0: Upstream `PackageType::Subos` support + +**Blocks everything.** All later phases assume `type = "subos"` resolves to a distinct pkgType. + +### Task 1: Add `Subos` to mcpplibs/libxpkg + xlings type mapping + +**Files:** +- Modify: `/home/speak/workspace/github/mcpplibs/libxpkg/src/xpkg.cppm:12` +- Modify: `/home/speak/workspace/github/mcpplibs/libxpkg/src/xpkg-loader.cppm:153-156` +- Modify: `src/core/xim/index.cppm:19-28` +- Modify: `src/core/xim/libxpkg/types/type.cppm:79` + +- [ ] **Step 1: Explore — Read the upstream PackageType definition and the loader's `type_from_string` logic to confirm naming pattern** + +Read: `/home/speak/workspace/github/mcpplibs/libxpkg/src/xpkg.cppm` (lines 1-50), and `xpkg-loader.cppm` (lines 140-170). + +Expected understanding: enum is `enum class PackageType { Package, Script, Template, Config };` — append `Subos` as 5th value. + +- [ ] **Step 2: Add `Subos` to enum** + +Modify `xpkg.cppm:12`: + +```cpp +enum class PackageType { Package, Script, Template, Config, Subos }; +``` + +- [ ] **Step 3: Map `"subos"` string to enum in loader** + +Modify `xpkg-loader.cppm` (after line 155): + +```cpp +if (s == "subos") return PackageType::Subos; +return PackageType::Package; +``` + +- [ ] **Step 4: Add int mappings in xlings `index.cppm`** + +Modify `src/core/xim/index.cppm` around line 19-28: + +```cpp +int type_to_int(xpkg::PackageType t) { + switch (t) { + case xpkg::PackageType::Script: return 1; + case xpkg::PackageType::Template: return 2; + case xpkg::PackageType::Config: return 3; + case xpkg::PackageType::Subos: return 4; + default: return 0; // Package + } +} +xpkg::PackageType int_to_type(int v) { + switch (v) { + case 1: return xpkg::PackageType::Script; + case 2: return xpkg::PackageType::Template; + case 3: return xpkg::PackageType::Config; + case 4: return xpkg::PackageType::Subos; + default: return xpkg::PackageType::Package; + } +} +``` + +- [ ] **Step 5: Update comment in `type.cppm`** + +Modify `src/core/xim/libxpkg/types/type.cppm:79`: + +```cpp +int pkgType { 0 }; // 0=Package, 1=Script, 2=Template, 3=Config, 4=Subos +``` + +- [ ] **Step 6: Rebuild mcpplibs/libxpkg and verify xlings picks up new enum** + +Run from `/home/speak/workspace/github/mcpplibs/libxpkg/`: +```bash +xmake -y +``` + +Then from xlings repo: +```bash +xmake clean +xmake -y +``` + +Expected: clean build, no errors. `xmake build` succeeds. + +- [ ] **Step 7: Commit** + +```bash +# In mcpplibs/libxpkg: +cd /home/speak/workspace/github/mcpplibs/libxpkg +git add src/xpkg.cppm src/xpkg-loader.cppm +git commit -m "feat(xpkg): add PackageType::Subos for subos-as-xpkg support + +Adds 5th enum value and string mapping in loader. Consumed by xlings +to dispatch type='subos' packages through a dedicated install handler." + +# In xlings: +cd - +git add src/core/xim/index.cppm src/core/xim/libxpkg/types/type.cppm +git commit -m "feat(xim): map PackageType::Subos to pkgType=4 + +Mirrors upstream mcpplibs/libxpkg enum addition. Foundation for +type=subos package dispatch (see .agents/docs/subos-as-xpkg-design-2026-05-16.md)." +``` + +--- + +## Phase 1: Parallel — Track A (M1) + Track B (M3) + +**Both tracks operate on independent code paths and can be done by separate agents in parallel.** + +### Track A — M1: type="subos" installer dispatch + default hooks + +#### Task 2: Create `subos.cppm` libxpkg type module with default hooks + +**Files:** +- Create: `src/core/xim/libxpkg/types/subos.cppm` +- Test: `tests/e2e/subos_xpkg_install_test.sh` +- Test fixture: `tests/e2e/fixtures/subos_xpkg_demo/py-demo.lua` + +- [ ] **Step 1: Explore — Read `src/core/xim/libxpkg/types/script.cppm` to learn the default-handler pattern** + +Read the file fully. Note function signatures: +- `bool default_install(const PlanNode&, ExecutionContext&)` +- `bool default_config(const PlanNode&, const std::filesystem::path& dataDir)` +- (May need `default_uninstall` too — check existing uninstall flow) + +- [ ] **Step 2: Write failing e2e test fixture** + +Create `tests/e2e/fixtures/subos_xpkg_demo/py-demo.lua`: + +```lua +-- Fixture: minimal type="subos" package for e2e tests +package = { + spec = "1", + name = "py-demo", + namespace = "subos", + description = "Demo subos base", + licenses = {"MIT"}, + type = "subos", + archs = {"x86_64"}, + + xpm = { + linux = { + deps = {}, -- no deps for the simplest test + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, -- no URL; default install just creates skeleton + } + } +} +-- No install/config/uninstall hooks — xim defaults handle everything +``` + +Create `tests/e2e/subos_xpkg_install_test.sh`: + +```bash +#!/usr/bin/env bash +# e2e: install a type="subos" package, verify xpkgs/ layout +set -euo pipefail + +XLINGS_HOME="$(mktemp -d)/xlings" +export XLINGS_HOME +export XLINGS_NON_INTERACTIVE=1 + +# Set up a local pkgindex with our fixture +mkdir -p "$XLINGS_HOME/data/xim-pkgindex/pkgs/p" +cp "$(dirname "$0")/fixtures/subos_xpkg_demo/py-demo.lua" \ + "$XLINGS_HOME/data/xim-pkgindex/pkgs/p/py-demo.lua" + +# Install +xlings install subos:py-demo@1.0.0 + +# Verify install dir exists at standard xpkgs path +test -d "$XLINGS_HOME/data/xpkgs/py-demo/1.0.0" || { + echo "FAIL: xpkgs/py-demo/1.0.0/ not created" + exit 1 +} + +# Verify .xlings.json was written by default hook +test -f "$XLINGS_HOME/data/xpkgs/py-demo/1.0.0/.xlings.json" || { + echo "FAIL: .xlings.json not created by default hook" + exit 1 +} + +# Verify xvm registration +xlings list | grep -q "py-demo" || { + echo "FAIL: py-demo not registered in xvm" + exit 1 +} + +echo "PASS" +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +chmod +x tests/e2e/subos_xpkg_install_test.sh +./tests/e2e/subos_xpkg_install_test.sh +``` + +Expected: FAIL — `xlings install subos:py-demo` will error or hang because installer has no dispatch for pkgType=4 (after Phase 0, it routes to default Package payload extraction which expects URL/tarball). + +- [ ] **Step 4: Implement `subos::default_install`** + +Create `src/core/xim/libxpkg/types/subos.cppm`: + +```cpp +export module xlings.core.xim.libxpkg.types.subos; + +import std; +import xlings.core.xim.libxpkg.types.type; +import xlings.core.xim.catalog; +import xlings.core.common; +import xlings.core.config; +import xlings.core.log; +import xlings.core.xself; +import xlings.core.xvm.db; +import xlings.libs.json; +import mcpplibs.xpkg.executor; + +export namespace xlings::xim::subos { + +// Default install for type="subos" packages: +// - install_dir comes from extracted tarball OR is empty (creates skeleton) +// - ensures .xlings.json exists (auto-generates workspace from deps if missing) +// - subos baseline structure: bin/ (empty, shims minted by xvm) +bool default_install(const PlanNode& node, + mcpplibs::xpkg::ExecutionContext& ctx) { + namespace fs = std::filesystem; + std::error_code ec; + + fs::create_directories(ctx.install_dir, ec); + if (ec) { + log::error("subos: failed to create install dir {}: {}", + ctx.install_dir.string(), ec.message()); + return false; + } + + fs::create_directories(ctx.install_dir / "bin", ec); + + // If tarball already laid down .xlings.json, leave it; else synthesize + // from deps. Deps are visible via the resolved node's deps list. + auto xlingsJson = ctx.install_dir / ".xlings.json"; + if (!fs::exists(xlingsJson)) { + nlohmann::json j; + j["workspace"] = nlohmann::json::object(); + for (const auto& dep : node.deps) { + // dep format: "name@version" or just "name" + auto at = dep.find('@'); + if (at != std::string::npos) { + j["workspace"][dep.substr(0, at)] = dep.substr(at + 1); + } else { + j["workspace"][dep] = "latest"; + } + } + std::ofstream ofs(xlingsJson); + ofs << j.dump(2); + } + + log::debug("subos installed: {}", ctx.install_dir.string()); + return true; +} + +// Default config: register in xvm so package is queryable +bool default_config(const PlanNode& node, + const std::filesystem::path& dataDir) { + auto storeName = package_store_name(node.namespaceName, node.name); + auto installDir = (node.storeRoot.empty() ? (dataDir / "xpkgs") : node.storeRoot) + / storeName + / node.version; + auto bindir = (installDir / "bin").string(); + + xvm::add_version(Config::versions_mut(), + node.name, node.version, bindir, "program", "", ""); + + auto ver_key = xvm::make_ns_version("", node.version); + Config::workspace_mut()[node.name] = ver_key; + + Config::save_versions(); + Config::save_workspace(); + return true; +} + +// Default uninstall: xpkg removal handled by xim's standard path; this +// just needs to not break it (default = no-op since xim removes install_dir) +bool default_uninstall(const PlanNode& node) { + log::debug("subos uninstalling: {}", node.name); + return true; +} + +} // namespace xlings::xim::subos +``` + +- [ ] **Step 5: Add dispatch in `installer.cppm`** + +Modify `src/core/xim/installer.cppm`: + +Add import at top (around line 22): +```cpp +import xlings.core.xim.libxpkg.types.subos; +``` + +Add dispatch in install loop (after line 1364, where Script dispatch ends): + +```cpp +} else if (!payloadInstalled && node.pkgType == 4 /* Subos */) { + log::debug("installing subos base {}...", node.name); + if (!subos::default_install(node, ctx)) { + if (onStatus) { + onStatus({ node.name, InstallPhase::Failed, 0.0f, + "default subos install failed" }); + } + continue; + } +} +``` + +Add config dispatch (after line 1447, where Script config dispatch ends): + +```cpp +} else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 4 /* Subos */) { + if (!subos::default_config(node, dataDir)) { + if (onStatus) { + onStatus({ node.name, InstallPhase::Failed, 0.0f, + "default subos config failed" }); + } + continue; + } +} +``` + +- [ ] **Step 6: Register module in `xmake.lua`** + +Modify `xmake.lua`, find the modules section, add `src/core/xim/libxpkg/types/subos.cppm` next to `script.cppm`. + +- [ ] **Step 7: Rebuild and run test** + +```bash +xmake -y +./tests/e2e/subos_xpkg_install_test.sh +``` + +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/core/xim/libxpkg/types/subos.cppm src/core/xim/installer.cppm xmake.lua \ + tests/e2e/subos_xpkg_install_test.sh tests/e2e/fixtures/subos_xpkg_demo/ +git commit -m "feat(xim): dispatch type='subos' through default hooks + +Adds xim::subos::default_install/config/uninstall mirroring script.cppm +pattern. Default install ensures .xlings.json + bin/ skeleton; default +config registers the package via xvm.add. Authors can override any of +the three hooks. + +Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M1)" +``` + +#### Task 3: type="subos" with tarball URL support + author override + +**Files:** +- Modify: `tests/e2e/fixtures/subos_xpkg_demo/py-demo.lua` (add deps to test workspace synthesis) +- Test: extend `tests/e2e/subos_xpkg_install_test.sh` + +- [ ] **Step 1: Extend test fixture to declare deps** + +```lua +-- Update py-demo.lua: add deps and a custom install hook +-- ... +xpm = { + linux = { + deps = {"hello"}, -- assume xim-pkgindex has 'hello' package for testing + ... + } +} + +import("xim.libxpkg.pkginfo") + +function install() + -- Custom hook: write a marker file to show override works, + -- then call back to default behavior (write .xlings.json) + local dir = pkginfo.install_dir() + io.writefile(path.join(dir, ".author-touched"), "1") + -- The default install would still need to run; for now, hook + -- replaces default entirely, so we manually do what defaults do: + os.mkdir(path.join(dir, "bin")) + return true +end +``` + +- [ ] **Step 2: Extend e2e test for override case** + +Add to `subos_xpkg_install_test.sh`: + +```bash +# After install verification, also check author hook ran +test -f "$XLINGS_HOME/data/xpkgs/py-demo/1.0.0/.author-touched" || { + echo "FAIL: author install hook didn't run (override broken)" + exit 1 +} +``` + +- [ ] **Step 3: Run test, verify pass** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_install_test.sh +``` + +Expected: PASS (override behavior works because if hook is present, xim runs it instead of default — this is the existing executor logic, no extra code needed in our part) + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e/fixtures/subos_xpkg_demo/py-demo.lua tests/e2e/subos_xpkg_install_test.sh +git commit -m "test(subos-xpkg): cover author install-hook override" +``` + +--- + +### Track B — M3: `subos use --cmd` + +#### Task 4: Add `--cmd ` argparse + thread through to use_spawn_shell + +**Files:** +- Modify: `src/cli.cppm` (subos use argparse) +- Modify: `src/core/subos.cppm` (use_spawn_shell signature) +- Test: `tests/e2e/subos_xpkg_use_cmd_test.sh` + +- [ ] **Step 1: Explore — Read current subos use argparse in `src/cli.cppm`** + +``` +grep -n "subos.*use\|--sandbox\|--shell\|--global" src/cli.cppm | head -30 +``` + +Read the surrounding context (lines ±20) to understand how the existing flags are parsed and passed to `subos::use_spawn_shell`. Note the function call signature. + +- [ ] **Step 2: Write failing e2e test** + +Create `tests/e2e/subos_xpkg_use_cmd_test.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +XLINGS_HOME="$(mktemp -d)/xlings" +export XLINGS_HOME +export XLINGS_NON_INTERACTIVE=1 + +# Create a subos (any storage mode) +xlings subos new test-cmd --storage shared + +# Single command, no sandbox +output=$(xlings subos use test-cmd --cmd "echo hello-from-subos") +[[ "$output" == *"hello-from-subos"* ]] || { + echo "FAIL: --cmd didn't run the command" + echo "Got: $output" + exit 1 +} + +# Exit code propagates +set +e +xlings subos use test-cmd --cmd "exit 42" +rc=$? +set -e +[[ "$rc" -eq 42 ]] || { + echo "FAIL: exit code not propagated (got $rc, want 42)" + exit 1 +} + +echo "PASS" +``` + +- [ ] **Step 3: Run test, expect fail** + +```bash +chmod +x tests/e2e/subos_xpkg_use_cmd_test.sh +./tests/e2e/subos_xpkg_use_cmd_test.sh +``` + +Expected: FAIL with "unknown flag --cmd" or similar. + +- [ ] **Step 4: Add `--cmd ` to cli.cppm subos use argparse** + +Modify the subos-use argparse section in `src/cli.cppm` to accept `--cmd `: + +```cpp +// In subos use subparser: +auto cmd_arg = sub_use.add_argument("--cmd") + .help("Run a single command in the subos and exit (non-interactive)") + .default_value(std::string{}); + +// In the dispatch: +auto cmd = sub_use.get("--cmd"); +return subos::use_spawn_shell(name, stream, sandbox, sandbox_backend, cmd); +``` + +- [ ] **Step 5: Extend `use_spawn_shell` to accept cmd parameter** + +Modify `src/core/subos.cppm` `use_spawn_shell` (around line 1390): + +```cpp +int use_spawn_shell(const std::string& name, EventStream& stream, + bool sandbox = false, + const std::string& sandbox_backend = "", + const std::string& cmd = "") +{ + if (sandbox) return use_sandbox_mode_(name, stream, sandbox_backend, cmd); + + // ... existing pre-exec logic ... + + // POSIX exec branch (around line 1488): + auto shell = utils::get_env_or_default("SHELL"); + if (shell.empty()) shell = "/bin/sh"; + + if (!cmd.empty()) { + // Non-interactive single-command path + ::execl(shell.c_str(), shell.c_str(), "-c", cmd.c_str(), + static_cast(nullptr)); + } else { + // Interactive default + ::execl(shell.c_str(), shell.c_str(), "-i", static_cast(nullptr)); + } + log::error("failed to exec shell '{}': {}", shell, std::strerror(errno)); + return 127; +} +``` + +For Windows branch, route to `pwsh -Command ` / `cmd /c ` when cmd non-empty: + +```cpp +#if defined(_WIN32) + // ... in the shell selection loop, modify cmdline: + std::string cmdline = exe; + if (!cmd.empty()) { + // pwsh / powershell support "-Command", cmd.exe uses "/c" + if (std::string(exe).find("cmd.exe") != std::string::npos) { + cmdline += " /c " + cmd; + } else { + cmdline += " -Command \"" + cmd + "\""; + } + } + // ... rest of CreateProcessA logic +#endif +``` + +- [ ] **Step 6: Update `use_sandbox_mode_` to accept + thread cmd** + +Read sandbox mode function (likely `use_sandbox_mode_` around line 1100-1200 in subos.cppm). Modify signature to accept `const std::string& cmd = ""`, and: + +- When `cmd.empty()`: existing behavior (interactive shell in sandbox) +- When `cmd` non-empty: bwrap/proot command line ends with `-- sh -c ` instead of default shell + +(Engineer: read the existing bwrap/proot command construction, append `"-c", cmd` to the argv after the shell binary.) + +- [ ] **Step 7: Update back-compat `use()` wrapper** + +```cpp +export int use(const std::string& name, EventStream& stream) { + return use_global(name, stream); // unchanged +} +``` + +The existing `use` wrapper does not need the cmd parameter; CLI dispatch goes through `use_spawn_shell` directly. + +- [ ] **Step 8: Rebuild and run test** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_use_cmd_test.sh +``` + +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/cli.cppm src/core/subos.cppm tests/e2e/subos_xpkg_use_cmd_test.sh +git commit -m "feat(subos): subos use --cmd for non-interactive exec + +Adds a --cmd flag that runs a single command in the subos via sh -c and +exits with the command's exit code. Works in both shell-level and sandbox +mode. Backwards compatible: --cmd absent => interactive shell as before. + +Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M3)" +``` + +#### Task 5: --cmd in sandbox mode (image + tmpfs storage) + +**Files:** +- Test: extend `tests/e2e/subos_xpkg_use_cmd_test.sh` + +- [ ] **Step 1: Extend test to cover sandbox + tmpfs** + +Append to `subos_xpkg_use_cmd_test.sh`: + +```bash +# Sandbox + tmpfs storage +xlings subos new test-cmd-sb --storage tmpfs +output=$(xlings subos use test-cmd-sb --sandbox --cmd "echo sandbox-ok") +[[ "$output" == *"sandbox-ok"* ]] || { + echo "FAIL: --cmd in sandbox mode failed" + exit 1 +} +``` + +- [ ] **Step 2: Run, verify pass; if fail, fix sandbox cmd plumbing per Task 4 Step 6** + +```bash +./tests/e2e/subos_xpkg_use_cmd_test.sh +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/subos_xpkg_use_cmd_test.sh +git commit -m "test(subos): cover --cmd in sandbox+tmpfs mode" +``` + +--- + +## Phase 2: M2 — `subos new --from ` + +(Requires Task 2 from Track A to have base packages available for testing.) + +### Task 6: `subos new --from ` (local fork only, simpler case first) + +**Files:** +- Modify: `src/cli.cppm` (subos new argparse) +- Modify: `src/core/subos.cppm` (new export `new_from`) +- Test: `tests/e2e/subos_xpkg_fork_test.sh` + +- [ ] **Step 1: Write failing e2e test** + +Create `tests/e2e/subos_xpkg_fork_test.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +XLINGS_HOME="$(mktemp -d)/xlings" +export XLINGS_HOME +export XLINGS_NON_INTERACTIVE=1 + +# Create a base subos +xlings subos new base-env --storage shared +echo "hello from base" > "$XLINGS_HOME/subos/base-env/marker.txt" + +# Fork it +xlings subos new fork-env --from base-env + +# Verify fork inherited content +test -f "$XLINGS_HOME/subos/fork-env/marker.txt" || { + echo "FAIL: fork didn't copy marker" + exit 1 +} +content=$(cat "$XLINGS_HOME/subos/fork-env/marker.txt") +[[ "$content" == "hello from base" ]] || { + echo "FAIL: fork content mismatch" + exit 1 +} + +# Modifying fork shouldn't affect base +echo "modified" > "$XLINGS_HOME/subos/fork-env/marker.txt" +base_content=$(cat "$XLINGS_HOME/subos/base-env/marker.txt") +[[ "$base_content" == "hello from base" ]] || { + echo "FAIL: fork modification leaked to base" + exit 1 +} + +echo "PASS" +``` + +- [ ] **Step 2: Run, expect fail** + +```bash +chmod +x tests/e2e/subos_xpkg_fork_test.sh +./tests/e2e/subos_xpkg_fork_test.sh +``` + +Expected: FAIL — `--from` unknown flag. + +- [ ] **Step 3: Add `--from ` to argparse** + +In `src/cli.cppm` subos new subparser: + +```cpp +auto from_arg = sub_new.add_argument("--from") + .help("Fork from another subos (local name) or install from a subos xpkg (spec like 'subos:py-ds@1.0.0')") + .default_value(std::string{}); + +// Dispatch: +auto from = sub_new.get("--from"); +if (!from.empty()) { + return subos::new_from(name, customDir, storage, imageSize, from, stream); +} else { + return subos::create(name, customDir, storage, imageSize, stream); +} +``` + +- [ ] **Step 4: Implement `new_from` in subos.cppm** + +Add to `src/core/subos.cppm`: + +```cpp +namespace new_from_detail_ { + +// Detect whether spec is a pkg-spec (contains `:` or `@`) or a local subos name +bool is_pkg_spec_(const std::string& spec) { + return spec.find(':') != std::string::npos || spec.find('@') != std::string::npos; +} + +// Cross-platform copy with reflink preferred (silent fallback to full copy). +int copy_dir_(const fs::path& src, const fs::path& dst, EventStream& stream) { + std::error_code ec; + fs::create_directories(dst, ec); +#if defined(__linux__) + // Use cp --reflink=auto for COW where supported + auto cmd = "cp -a --reflink=auto " + src.string() + "/. " + dst.string() + "/"; + auto rc = std::system(cmd.c_str()); + if (rc != 0) { + log::warn("reflink copy failed (rc={}), falling back to full copy", rc); + fs::copy(src, dst, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); + } +#elif defined(__APPLE__) + // macOS APFS clonefile via /bin/cp -c + auto cmd = "cp -ac " + src.string() + "/. " + dst.string() + "/"; + std::system(cmd.c_str()); +#else + // Windows / generic + fs::copy(src, dst, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); +#endif + if (ec) { + log::error("copy failed: {}", ec.message()); + return 1; + } + return 0; +} + +} // namespace new_from_detail_ + +export int new_from(const std::string& name, const fs::path& customDir, + StorageMode storage, const std::string& imageSize, + const std::string& fromSpec, EventStream& stream) { + auto& p = Config::paths(); + + if (new_from_detail_::is_pkg_spec_(fromSpec)) { + // pkg-spec path: handled in Task 7 + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = "--from not yet implemented in this task", + .recoverable = false, + }); + return 1; + } + + // Local subos fork + auto srcDir = p.homeDir / "subos" / fromSpec; + if (!fs::exists(srcDir)) { + stream.emit(ErrorEvent{ + .code = ErrorCode::NotFound, + .message = "source subos '" + fromSpec + "' not found", + .recoverable = true, + }); + return 1; + } + + // Create empty target subos with correct storage + if (auto rc = create(name, customDir, storage, imageSize, stream); rc != 0) { + return rc; + } + + auto dstDir = customDir.empty() ? (p.homeDir / "subos" / name) : customDir; + + // Copy content (but NOT .xlings.json — already created by create(); we'll merge) + // Safer: copy everything, then rewrite name in .xlings.json + if (auto rc = new_from_detail_::copy_dir_(srcDir, dstDir, stream); rc != 0) { + return rc; + } + + // Rewrite name field in .xlings.json (if it has a name field) + // (Most subos .xlings.json doesn't carry name; this is defensive) + + nlohmann::json payload; + payload["name"] = name; + payload["from"] = fromSpec; + payload["mode"] = "local-fork"; + stream.emit(DataEvent{"subos_forked", payload.dump()}); + return 0; +} +``` + +- [ ] **Step 5: Build and run test** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_fork_test.sh +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/cli.cppm src/core/subos.cppm tests/e2e/subos_xpkg_fork_test.sh +git commit -m "feat(subos): subos new --from for local fork + +Adds a local-fork path: cp -a (reflink where supported) the source +subos dir into the new one. macOS uses APFS clonefile via cp -c. + +Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M2 - local fork)" +``` + +### Task 7: `subos new --from ` with auto-install + +**Files:** +- Modify: `src/core/subos.cppm` (new_from pkg-spec branch) +- Test: extend `tests/e2e/subos_xpkg_fork_test.sh` + +- [ ] **Step 1: Extend test fixture to cover pkg-spec fork** + +Append to `subos_xpkg_fork_test.sh`: + +```bash +# pkg-spec fork — uses fixture from Task 2 +mkdir -p "$XLINGS_HOME/data/xim-pkgindex/pkgs/p" +cp "$(dirname "$0")/fixtures/subos_xpkg_demo/py-demo.lua" \ + "$XLINGS_HOME/data/xim-pkgindex/pkgs/p/py-demo.lua" + +# This should auto-install subos:py-demo if missing, then fork +xlings subos new from-pkg --from subos:py-demo@1.0.0 + +test -d "$XLINGS_HOME/subos/from-pkg" || { + echo "FAIL: pkg-spec fork didn't create subos" + exit 1 +} + +# Base should be installed under xpkgs/ +test -d "$XLINGS_HOME/data/xpkgs/py-demo/1.0.0" || { + echo "FAIL: base pkg not auto-installed" + exit 1 +} + +# .xlings.json should have been copied +test -f "$XLINGS_HOME/subos/from-pkg/.xlings.json" || { + echo "FAIL: .xlings.json not copied from base" + exit 1 +} +``` + +- [ ] **Step 2: Implement pkg-spec branch** + +Modify `new_from` in `subos.cppm`: + +```cpp +if (new_from_detail_::is_pkg_spec_(fromSpec)) { + // Strip namespace prefix and parse version + std::string pkgName = fromSpec; + std::string version; + if (auto colon = pkgName.find(':'); colon != std::string::npos) { + pkgName = pkgName.substr(colon + 1); + } + if (auto at = pkgName.find('@'); at != std::string::npos) { + version = pkgName.substr(at + 1); + pkgName = pkgName.substr(0, at); + } + + // Locate base in xpkgs/// + auto baseDir = Config::global_data_dir() / "xpkgs" / pkgName / version; + if (!fs::exists(baseDir)) { + // Auto-install base + log::info("base {}:{} not installed, installing...", pkgName, version); + auto cmd = "xlings install " + fromSpec; + auto rc = std::system(cmd.c_str()); + if (rc != 0 || !fs::exists(baseDir)) { + stream.emit(ErrorEvent{ + .code = ErrorCode::NotFound, + .message = "failed to install base " + fromSpec, + .recoverable = true, + }); + return 1; + } + } + + // Validate type=subos + // (Read xvm registry to check; or simpler: check .xlings.json existence) + if (!fs::exists(baseDir / ".xlings.json")) { + stream.emit(ErrorEvent{ + .code = ErrorCode::InvalidInput, + .message = fromSpec + " is not a type='subos' package", + .recoverable = false, + }); + return 1; + } + + // Create empty target subos + if (auto rc = create(name, customDir, storage, imageSize, stream); rc != 0) { + return rc; + } + + // Copy base .xlings.json + any extra files (templates/, etc.) + auto dstDir = customDir.empty() ? (Config::paths().homeDir / "subos" / name) : customDir; + if (auto rc = new_from_detail_::copy_dir_(baseDir, dstDir, stream); rc != 0) { + return rc; + } + + nlohmann::json payload; + payload["name"] = name; + payload["from"] = fromSpec; + payload["mode"] = "pkg-fork"; + stream.emit(DataEvent{"subos_forked", payload.dump()}); + return 0; +} +``` + +- [ ] **Step 3: Build, run test, verify pass** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_fork_test.sh +``` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/core/subos.cppm tests/e2e/subos_xpkg_fork_test.sh +git commit -m "feat(subos): subos new --from with auto-install + +When --from refers to a subos: pkg-spec (contains : or @), locate +xpkgs///. If absent, auto-invoke 'xlings install '. +Then fork from the materialized base via the same cp -a path as +local fork. + +Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M2 - pkg fork, E5)" +``` + +--- + +## Phase 3: M4 — Auto-keeper + +(Requires M3 since keeper extends `use_spawn_shell --cmd` execution.) + +### Task 8: Create keeper module — spawn, register, nsenter + +**Files:** +- Create: `src/core/subos/keeper.cppm` +- Modify: `xmake.lua` (add module) +- Modify: `src/core/subos.cppm` (integrate `use_sandbox_mode_` with keeper) +- Test: `tests/e2e/subos_xpkg_keeper_test.sh` (Linux only) + +- [ ] **Step 1: Explore — Read `use_sandbox_mode_` in subos.cppm** + +``` +grep -n "use_sandbox_mode_\|bwrap\|proot" src/core/subos.cppm | head -20 +``` + +Read function body. Understand how bwrap argv is constructed and how command exec happens. + +- [ ] **Step 2: Write failing e2e test** + +Create `tests/e2e/subos_xpkg_keeper_test.sh`: + +```bash +#!/usr/bin/env bash +# Linux-only test +[[ "$(uname)" != "Linux" ]] && { echo "SKIP (non-Linux)"; exit 0; } + +set -euo pipefail +XLINGS_HOME="$(mktemp -d)/xlings" +export XLINGS_HOME +export XLINGS_NON_INTERACTIVE=1 + +# Create tmpfs subos (auto-keeper trigger condition) +xlings subos new k-test --storage tmpfs + +# First cmd: should spawn keeper +time_first=$(date +%s%3N) +xlings subos use k-test --sandbox --cmd "echo first" > /dev/null +time_first_end=$(date +%s%3N) +duration_first=$((time_first_end - time_first)) + +# Keeper PID file should exist +test -f "$XLINGS_HOME/subos/k-test/.keeper.pid" || { + echo "FAIL: keeper PID file not created" + exit 1 +} + +# Second cmd: should reuse keeper (much faster) +time_second=$(date +%s%3N) +xlings subos use k-test --sandbox --cmd "echo second" > /dev/null +time_second_end=$(date +%s%3N) +duration_second=$((time_second_end - time_second)) + +echo "first=${duration_first}ms second=${duration_second}ms" +[[ "$duration_second" -lt "$duration_first" ]] || { + echo "WARN: second exec not faster than first (keeper may not be working)" +} + +# Stop keeper explicitly +xlings subos stop k-test + +# PID file gone +test ! -f "$XLINGS_HOME/subos/k-test/.keeper.pid" || { + echo "FAIL: PID file not cleaned up after stop" + exit 1 +} + +echo "PASS" +``` + +- [ ] **Step 3: Run, expect fail** + +Expected: FAIL (no `subos stop` command; no keeper logic). + +- [ ] **Step 4: Implement keeper module** + +Create `src/core/subos/keeper.cppm`: + +```cpp +export module xlings.core.subos.keeper; + +import std; +import xlings.core.config; +import xlings.core.log; + +#if defined(__linux__) +#include +#include +#include +#include +#include +#endif + +export namespace xlings::subos::keeper { + +namespace fs = std::filesystem; + +constexpr int DEFAULT_TTL_SEC = 300; // 5 min idle + +struct KeeperState { + fs::path pidFile; + fs::path lastUsedFile; +}; + +KeeperState state_for(const std::string& subosName) { + auto& p = Config::paths(); + auto dir = p.homeDir / "subos" / subosName; + return { dir / ".keeper.pid", dir / ".last_used" }; +} + +// Check if keeper for this subos exists and is alive +bool is_alive(const std::string& subosName) { +#if !defined(__linux__) + return false; +#else + auto s = state_for(subosName); + if (!fs::exists(s.pidFile)) return false; + std::ifstream ifs(s.pidFile); + pid_t pid; + ifs >> pid; + if (pid <= 0) return false; + return ::kill(pid, 0) == 0; +#endif +} + +// Touch .last_used (called by every cmd exec) +void touch_activity(const std::string& subosName) { + auto s = state_for(subosName); + std::ofstream ofs(s.lastUsedFile); + auto now = std::chrono::system_clock::now().time_since_epoch().count(); + ofs << now; +} + +// Spawn keeper for the given subos. Returns the keeper PID, or -1 on failure. +// Should be called after bwrap mount is established; the keeper sits in +// the mount namespace and self-exits when idle TTL expires. +pid_t spawn_keeper(const std::string& subosName, int ttlSec = DEFAULT_TTL_SEC) { +#if !defined(__linux__) + return -1; +#else + auto s = state_for(subosName); + + pid_t pid = ::fork(); + if (pid < 0) { + log::error("fork failed: {}", std::strerror(errno)); + return -1; + } + if (pid == 0) { + // Child: keeper loop + // (Caller is responsible for having entered the right mount NS before fork) + for (;;) { + ::sleep(10); // wake every 10s + // Check idle + std::ifstream ifs(s.lastUsedFile); + long long lastUsed = 0; + if (ifs) ifs >> lastUsed; + auto now = std::chrono::system_clock::now().time_since_epoch().count(); + // crude: if last_used not bumped in ttlSec, exit + auto diff = (now - lastUsed) / 1'000'000'000LL; // ns → s + if (diff > ttlSec) { + log::debug("keeper {} idle, exiting", subosName); + std::exit(0); + } + } + } + // Parent: record pid + { + std::ofstream ofs(s.pidFile); + ofs << pid; + } + touch_activity(subosName); + return pid; +#endif +} + +// nsenter into the keeper's mount namespace and exec the command. +// Returns the command's exit code. +int nsenter_and_exec(const std::string& subosName, const std::string& cmd) { +#if !defined(__linux__) + return -1; +#else + auto s = state_for(subosName); + std::ifstream ifs(s.pidFile); + pid_t pid; + ifs >> pid; + if (pid <= 0) return -1; + + touch_activity(subosName); + + auto nsCmd = std::format("nsenter --mount=/proc/{}/ns/mnt -- sh -c '{}'", pid, cmd); + return std::system(nsCmd.c_str()); +#endif +} + +// Stop keeper (called by `subos stop`) +int stop_keeper(const std::string& subosName) { + auto s = state_for(subosName); + if (!fs::exists(s.pidFile)) return 0; +#if defined(__linux__) + std::ifstream ifs(s.pidFile); + pid_t pid; + ifs >> pid; + if (pid > 0) ::kill(pid, SIGTERM); +#endif + std::error_code ec; + fs::remove(s.pidFile, ec); + fs::remove(s.lastUsedFile, ec); + return 0; +} + +// Auto-trigger condition: storage=image|tmpfs + sandbox + Linux +bool should_auto_keeper(const std::string& storage, bool sandbox) { +#if !defined(__linux__) + return false; +#else + if (!sandbox) return false; + return storage == "image" || storage == "tmpfs"; +#endif +} + +} // namespace xlings::subos::keeper +``` + +- [ ] **Step 5: Wire keeper into `use_sandbox_mode_`** + +Modify `use_sandbox_mode_` in `subos.cppm`: + +```cpp +import xlings.core.subos.keeper; + +// In use_sandbox_mode_ before bwrap exec: +auto storage = read_storage_mode_str_(name); // helper to read .xlings.json storage field +if (keeper::should_auto_keeper(storage, /*sandbox=*/true)) { + if (keeper::is_alive(name)) { + // Reuse: nsenter + if (!cmd.empty()) { + return keeper::nsenter_and_exec(name, cmd); + } + // For interactive shells with keeper, also nsenter + return keeper::nsenter_and_exec(name, "/bin/sh -i"); + } + // Spawn fresh keeper after bwrap mounts up + // (engineer: integrate spawn at the right point after mount setup) +} + +// Then proceed with normal bwrap exec +``` + +(Engineer: the exact integration point depends on the existing bwrap flow. The keeper needs to be forked from a process that has already entered the mount namespace. Likely after bwrap setup but before the user command runs.) + +- [ ] **Step 6: Add `subos stop` CLI** + +In `src/cli.cppm`: + +```cpp +auto sub_stop = subos_parser.add_subparser("stop"); +sub_stop.add_argument("name"); +// Dispatch: +return subos::keeper::stop_keeper(sub_stop.get("name")); +``` + +- [ ] **Step 7: Register keeper.cppm in xmake.lua** + +- [ ] **Step 8: Build and run test** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_keeper_test.sh +``` + +Expected: PASS on Linux. SKIP on macOS/Windows. + +- [ ] **Step 9: Commit** + +```bash +git add src/core/subos/keeper.cppm src/core/subos.cppm src/cli.cppm xmake.lua \ + tests/e2e/subos_xpkg_keeper_test.sh +git commit -m "feat(subos): auto-keeper for high-frequency sandbox exec (Linux) + +Auto-spawns a keeper process holding the mount namespace when +storage=image|tmpfs + --sandbox on Linux. Subsequent --cmd execs +nsenter into the existing namespace, ~10ms vs ~100-500ms cold mount. +TTL=5min idle, then keeper self-exits and mount tears down. + +Adds 'subos stop ' for explicit cleanup. + +Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M4)" +``` + +### Task 9: Auto-keeper TTL self-kill + stale PID cleanup + +**Files:** +- Modify: `src/core/subos/keeper.cppm` (refine spawn_keeper loop) +- Test: extend `tests/e2e/subos_xpkg_keeper_test.sh` + +- [ ] **Step 1: Write test for stale PID cleanup** + +Append to keeper test: + +```bash +# Simulate stale PID: write a fake old PID then run cmd +echo "999999" > "$XLINGS_HOME/subos/k-test/.keeper.pid" +xlings subos use k-test --sandbox --cmd "echo cleanup-ok" > /dev/null + +# After exec, the stale PID should have been cleaned and new one written +new_pid=$(cat "$XLINGS_HOME/subos/k-test/.keeper.pid" 2>/dev/null || echo "") +[[ "$new_pid" != "999999" ]] || { + echo "FAIL: stale PID not replaced" + exit 1 +} +``` + +- [ ] **Step 2: Add stale PID handling in `is_alive`** + +Already handled in Step 4 of Task 8 (`kill(pid, 0) == 0` check). Verify by inspecting code — if pid file exists but process dead, we should clean and respawn. Add cleanup: + +```cpp +bool is_alive(const std::string& subosName) { + auto s = state_for(subosName); + if (!fs::exists(s.pidFile)) return false; + std::ifstream ifs(s.pidFile); + pid_t pid; + ifs >> pid; + if (pid <= 0 || ::kill(pid, 0) != 0) { + // Stale — clean up + std::error_code ec; + fs::remove(s.pidFile, ec); + fs::remove(s.lastUsedFile, ec); + return false; + } + return true; +} +``` + +- [ ] **Step 3: Build, test, commit** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_keeper_test.sh +git add src/core/subos/keeper.cppm tests/e2e/subos_xpkg_keeper_test.sh +git commit -m "feat(subos): keeper stale PID cleanup + auto-respawn" +``` + +--- + +## Phase 4: M5 — Keeper flag overrides + +### Task 10: --no-keep / --ttl / --keep flags + +**Files:** +- Modify: `src/cli.cppm` (subos use argparse) +- Modify: `src/core/subos.cppm` (thread flags through) +- Modify: `src/core/subos/keeper.cppm` (accept TTL parameter, --keep = infinite) + +- [ ] **Step 1: Add flags to argparse** + +```cpp +sub_use.add_argument("--no-keep").flag().help("Disable auto-keeper for this exec"); +sub_use.add_argument("--keep").flag().help("Use a never-expiring keeper"); +sub_use.add_argument("--ttl").default_value(0).scan<'i', int>().help("Keeper idle TTL in seconds (default 300)"); +``` + +- [ ] **Step 2: Thread to use_sandbox_mode_ / use_spawn_shell** + +Add `KeeperPolicy` struct or simple param trio: + +```cpp +struct KeeperPolicy { + bool no_keep = false; + bool keep_forever = false; + int ttl_sec = 0; // 0 = use default +}; +``` + +- [ ] **Step 3: Honor flags in keeper.cppm** + +```cpp +pid_t spawn_keeper(..., int ttlSec) { + auto effectiveTtl = (ttlSec > 0) ? ttlSec : DEFAULT_TTL_SEC; + // pass effectiveTtl to keeper loop; if INT_MAX, never exit on idle +} +``` + +`--keep` → ttl = INT_MAX. `--no-keep` → skip keeper entirely. `--ttl N` → ttl = N. + +- [ ] **Step 4: Write tests for each flag** + +Append to keeper test: + +```bash +# --no-keep: no PID file should be created +xlings subos new no-keep-test --storage tmpfs +xlings subos use no-keep-test --sandbox --no-keep --cmd "echo nk" +test ! -f "$XLINGS_HOME/subos/no-keep-test/.keeper.pid" || { + echo "FAIL: --no-keep still created keeper" + exit 1 +} + +# --ttl : keeper should self-exit after that interval +xlings subos new ttl-test --storage tmpfs +xlings subos use ttl-test --sandbox --ttl 1 --cmd "echo ttl" +test -f "$XLINGS_HOME/subos/ttl-test/.keeper.pid" || { + echo "FAIL: --ttl 1 should still spawn keeper" + exit 1 +} +# Wait 15s (idle > 1s, keeper polls every 10s) +sleep 15 +test ! -f "$XLINGS_HOME/subos/ttl-test/.keeper.pid" || { + echo "FAIL: keeper didn't self-exit after TTL" + exit 1 +} +``` + +- [ ] **Step 5: Build, test, commit** + +```bash +xmake -y && ./tests/e2e/subos_xpkg_keeper_test.sh +git add src/cli.cppm src/core/subos.cppm src/core/subos/keeper.cppm \ + tests/e2e/subos_xpkg_keeper_test.sh +git commit -m "feat(subos): --no-keep / --ttl / --keep flag overrides for keeper + +Adds explicit keeper policy flags: +- --no-keep: skip keeper, fresh mount each exec +- --ttl : custom idle timeout +- --keep: never-expiring keeper (manual stop required) + +Default behavior (auto-keeper, TTL=5min) unchanged. + +Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M5)" +``` + +--- + +## Final: Integration verification + PR + +### Task 11: Full e2e suite run + branch + PR + +- [ ] **Step 1: Run all subos-xpkg e2e tests in sequence** + +```bash +chmod +x tests/e2e/subos_xpkg_*.sh +for t in tests/e2e/subos_xpkg_*.sh; do + echo "=== $t ===" + "$t" || { echo "FAIL: $t"; exit 1; } +done +echo "All PASS" +``` + +- [ ] **Step 2: Verify CI configuration covers new tests** + +Read `.github/workflows/xlings-ci-linux.yml`. If e2e runner discovers `tests/e2e/*.sh` automatically (per existing pattern), no change needed. Otherwise add explicit invocation. + +- [ ] **Step 3: Create feature branch** + +```bash +git checkout -b feat/subos-as-xpkg +``` + +(All commits done on this branch from Phase 0 onward.) + +- [ ] **Step 4: Push branch** + +```bash +git push -u origin feat/subos-as-xpkg +``` + +- [ ] **Step 5: Open PR** + +```bash +gh pr create --draft --title "feat(subos): subos-as-xpkg system (M1-M5)" \ + --body "$(cat <<'EOF' +## Summary + +Implements subos-as-xpkg system per design `.agents/docs/subos-as-xpkg-design-2026-05-16.md` rev4. Subos environments can now be distributed as standard xpkg packages with `type = "subos"`, forked 0s via `--from`, executed non-interactively via `--cmd`, and high-frequency sandbox exec accelerated by auto-keeper (Linux). + +## Milestones included + +- **M1**: `type = "subos"` installer dispatch + default hooks (install/config/uninstall) +- **M2**: `xlings subos new --from ` with auto-install +- **M3**: `xlings subos use --cmd ""` non-interactive exec (POSIX + Windows) +- **M4**: Auto-keeper (Linux, storage=image|tmpfs + sandbox; TTL=5min idle) +- **M5**: `--no-keep` / `--ttl ` / `--keep` flag overrides + `subos stop` + +## Implementation surface + +- Upstream `mcpplibs/libxpkg`: +1 `PackageType::Subos` enum value +- xlings: + - `src/core/xim/libxpkg/types/subos.cppm` — new (~80 lines) + - `src/core/subos/keeper.cppm` — new (~250 lines, Linux only) + - `src/core/subos.cppm` — extended (+~200 lines for new_from, use_spawn_shell --cmd, keeper integration) + - `src/core/xim/installer.cppm` — +dispatch branches + - `src/cli.cppm` — argparse for new flags +- `tests/e2e/subos_xpkg_*.sh` — 4 new e2e tests + +## Test plan + +- [x] `subos_xpkg_install_test.sh` — type=subos install path +- [x] `subos_xpkg_use_cmd_test.sh` — --cmd in shell + sandbox modes +- [x] `subos_xpkg_fork_test.sh` — local fork + pkg-spec auto-install fork +- [x] `subos_xpkg_keeper_test.sh` (Linux) — auto-keeper, stale PID, --no-keep, --ttl +- [ ] Cross-platform CI (Linux + macOS + Windows) green + +## Out of scope (deferred to Future, see design §11) + +- One-line throwaway `subos use subos:xxx --sandbox --cmd ...` +- Binary cache for fast first-fork +- Overlayfs / COW layered subos +- Subos pkg with embedded user data +EOF +)" +``` + +- [ ] **Step 6: Verify PR checks pass** + +```bash +gh pr checks +``` + +If any fail, fix and re-push (do NOT bypass). + +--- + +## Self-Review Checklist + +- [x] Spec coverage: Phase 0 + M1-M5 → 11 tasks → each design decision (D1-D9, E1-E5) covered +- [x] No placeholders: all code blocks contain actual code or clear "explore X first" + "implement following pattern Y" +- [x] Type consistency: `new_from()` signature consistent across cli.cppm dispatch and subos.cppm export; `keeper::is_alive/spawn_keeper/stop_keeper/touch_activity` consistent +- [x] Parallelization marked: Tasks 2-3 (Track A, M1) ∥ Tasks 4-5 (Track B, M3); rest sequential +- [x] Test code present in every task with assertions and expected behavior +- [x] Build commands and expected output included + +**Known caveats:** +- Task 4 Step 6 (use_sandbox_mode_ integration with cmd) requires reading existing bwrap argv construction — engineer must adapt to current shape +- Task 8 Step 5 (keeper integration with use_sandbox_mode_) requires careful fork point selection (keeper must fork from a process IN the mount namespace, after bwrap setup) +- Cross-platform `cp -ac` for macOS clonefile assumes recent macOS; verify on target CI runners From 38ffcacc3a2dfa55a2ed11d20ea20acbcd11f0bb Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 06:06:59 +0800 Subject: [PATCH 7/8] chore: bump mcpplibs-xpkg dep to 0.0.41 (PackageType::Subos) Pulls in the upstream Subos enum addition required by the subos-as-xpkg dispatch. Replaces the local_libxpkg dev override that was needed before openxlings/libxpkg#23 merged. Refs: mcpplibs/mcpplibs-index#13, openxlings/libxpkg#23 --- xmake.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmake.lua b/xmake.lua index 06de07b8..34c48d87 100644 --- a/xmake.lua +++ b/xmake.lua @@ -42,7 +42,7 @@ add_requires("mcpplibs-capi-lua") if has_config("local_libxpkg") and get_config("local_libxpkg") ~= "" then includes(path.join(get_config("local_libxpkg"), "xmake.lua")) else - add_requires("mcpplibs-xpkg 0.0.40") + add_requires("mcpplibs-xpkg 0.0.41") end add_requires("gtest 1.15.2") add_requires("mcpplibs-tinyhttps 0.2.0") From 69ebfbd5792076e1f4665f1ed10d2c6f6cf7df20 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 16 May 2026 06:10:39 +0800 Subject: [PATCH 8/8] chore(0.4.36): bump version for release Includes: - feat(subos): subos-as-xpkg system (M1-M5) - type='subos' xpkg dispatch + default install/config/uninstall hooks - subos new --from fork with auto-install - subos use --cmd non-interactive exec (POSIX + Windows) - keeper primitives + --keep/--no-keep/--ttl flags + subos stop - chore: bump mcpplibs-xpkg dep to 0.0.41 (brings PackageType::Subos) --- src/core/config.cppm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/config.cppm b/src/core/config.cppm index d7cba09e..726f9476 100644 --- a/src/core/config.cppm +++ b/src/core/config.cppm @@ -13,7 +13,7 @@ import xlings.core.xvm.db; namespace xlings { export struct Info { - static constexpr std::string_view VERSION = "0.4.35"; + static constexpr std::string_view VERSION = "0.4.36"; static constexpr std::string_view REPO = "https://github.com/openxlings/xlings"; };