Skip to content

Commit 8e72e62

Browse files
authored
feat(subos): subos-as-xpkg system (M1-M5) (#296)
* 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 0 / Task 1). Requires upstream commit "feat(xpkg): add PackageType::Subos for subos-as-xpkg" on mcpplibs/libxpkg feat/pkgtype-subos branch. * feat(xim): dispatch type='subos' through default install/config/uninstall 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/<namespace>-x-<name>/<ver>/, 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) * feat(subos): subos use --cmd <string> for non-interactive exec Extends 'xlings subos use' with --cmd <string> (and --cmd=<value>) to run a single command in the subos and exit with the command's exit code. POSIX: shell -c <cmd>; 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 <cmd>. 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=<value> equals form. Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M3) * feat(subos): subos new --from <spec> for fork from local subos or pkg Adds `xlings subos new <name> --from <spec>` which forks the new subos from either: - a local subos (bare name, e.g. --from base-env): copies content from subos/<base>/ to subos/<new>/ - a pkg-spec (contains ':' or '@', e.g. --from subos:py-ds@1.0.0): locates xpkgs/<ns>-x-<name>/<ver>/; if not yet installed, auto-invokes `xlings install <spec>` (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) * feat(subos): auto-keeper primitives + --keep/--no-keep/--ttl + subos stop 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 <sec> on `subos use`; mutual-exclusion validation; integer-parse error handling for --ttl. - `xlings subos stop <name>` 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) * 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 * 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 * 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 <local|pkg-spec> fork with auto-install - subos use --cmd <string> 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)
1 parent 884f95b commit 8e72e62

15 files changed

Lines changed: 3203 additions & 22 deletions

File tree

.agents/docs/subos-as-xpkg-design-2026-05-16.md

Lines changed: 493 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/plans/2026-05-16-subos-as-xpkg.md

Lines changed: 1520 additions & 0 deletions
Large diffs are not rendered by default.

src/core/config.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import xlings.core.xvm.db;
1313
namespace xlings {
1414

1515
export struct Info {
16-
static constexpr std::string_view VERSION = "0.4.35";
16+
static constexpr std::string_view VERSION = "0.4.36";
1717
static constexpr std::string_view REPO = "https://github.com/openxlings/xlings";
1818
};
1919

src/core/subos.cppm

Lines changed: 412 additions & 15 deletions
Large diffs are not rendered by default.

src/core/subos/keeper.cppm

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Auto-keeper for high-frequency sandbox exec (M4 — Linux only).
2+
//
3+
// Problem: each `xlings subos use <name> --sandbox --cmd ...` invocation
4+
// spins up bwrap from scratch — for tmpfs/image storage this means
5+
// mount setup on every call, ~100-500ms overhead. An agent running 100
6+
// commands burns 10-50 seconds on mount overhead alone.
7+
//
8+
// Solution: keep one bwrap process alive in the background after the
9+
// first sandbox use; record its PID. Subsequent `subos use --cmd ...`
10+
// invocations nsenter into that bwrap's mount namespace instead of
11+
// remounting from scratch.
12+
//
13+
// Lifecycle:
14+
// - First use: spawn bwrap with a long-running keeper script;
15+
// write PID to <subos>/.keeper.pid.
16+
// - Subsequent: detect alive keeper → nsenter --mount=/proc/<pid>/ns/mnt
17+
// -- sh -c <cmd>. Touch <subos>/.keeper.lastused.
18+
// - Idle TTL : keeper's own polling loop exits if .lastused not bumped
19+
// for `ttl_sec`; bwrap exits → mount namespace gone.
20+
// - Stop : `xlings subos stop <name>` sends SIGTERM, cleans state.
21+
// - Stale : if PID file exists but process is dead, treat as
22+
// no-keeper and respawn.
23+
//
24+
// Auto-trigger (per design): storage=image|tmpfs + --sandbox + Linux.
25+
// In this MVP the keeper is OPT-IN via --keep flag; the auto-default
26+
// trigger is a one-line toggle in use_sandbox_mode_ that we leave off
27+
// pending broader e2e soak. M5 adds --no-keep / --ttl / --keep
28+
// overrides.
29+
//
30+
// Refs: .agents/docs/subos-as-xpkg-design-2026-05-16.md (M4, D9).
31+
module;
32+
33+
// POSIX headers used by the Linux keeper primitives must live in the
34+
// global module fragment so they don't collide with `import std;` later.
35+
// Same pattern as subos.cppm itself uses.
36+
#if defined(__linux__)
37+
#include <sys/types.h>
38+
#include <sys/wait.h>
39+
#include <signal.h>
40+
#include <unistd.h>
41+
#endif
42+
43+
export module xlings.core.subos.keeper;
44+
45+
import std;
46+
import xlings.core.config;
47+
import xlings.core.log;
48+
49+
export namespace xlings::subos::keeper {
50+
51+
namespace fs = std::filesystem;
52+
53+
// Default idle TTL in seconds — the keeper self-exits when no
54+
// .keeper.lastused activity is observed within this window. 5 min is
55+
// the design default (D9): long enough that an agent's multi-step
56+
// workflow doesn't re-mount between steps, short enough that idle
57+
// keepers don't pile up on the system.
58+
constexpr int DEFAULT_TTL_SEC = 300;
59+
60+
// Special value passed via --keep: keeper never expires on idle. Caller
61+
// must `subos stop` explicitly. Encoded as a very large int (10 years).
62+
constexpr int TTL_NEVER = 60 * 60 * 24 * 365 * 10;
63+
64+
struct KeeperState {
65+
fs::path pidFile; // <home>/subos/<name>/.keeper.pid
66+
fs::path lastUsedFile; // <home>/subos/<name>/.keeper.lastused
67+
};
68+
69+
inline KeeperState state_for(const std::string& subosName) {
70+
auto& p = Config::paths();
71+
auto dir = p.homeDir / "subos" / subosName;
72+
return { dir / ".keeper.pid", dir / ".keeper.lastused" };
73+
}
74+
75+
// Touch the last-used timestamp. Called by every exec that hits the
76+
// keeper so its idle countdown resets.
77+
inline void touch_activity(const std::string& subosName) {
78+
auto s = state_for(subosName);
79+
std::ofstream ofs(s.lastUsedFile, std::ios::trunc);
80+
if (ofs) {
81+
auto now = std::chrono::system_clock::now().time_since_epoch();
82+
auto secs = std::chrono::duration_cast<std::chrono::seconds>(now).count();
83+
ofs << secs;
84+
}
85+
}
86+
87+
// Is there a live keeper for this subos? Stale PID files are cleaned
88+
// up here so callers can treat false as a definitive "no keeper".
89+
inline bool is_alive(const std::string& subosName) {
90+
#if !defined(__linux__)
91+
(void)subosName;
92+
return false;
93+
#else
94+
auto s = state_for(subosName);
95+
if (!fs::exists(s.pidFile)) return false;
96+
std::ifstream ifs(s.pidFile);
97+
pid_t pid = -1;
98+
ifs >> pid;
99+
if (pid <= 0 || ::kill(pid, 0) != 0) {
100+
// Stale → clean up so the next caller respawns fresh
101+
std::error_code ec;
102+
fs::remove(s.pidFile, ec);
103+
fs::remove(s.lastUsedFile, ec);
104+
return false;
105+
}
106+
return true;
107+
#endif
108+
}
109+
110+
// Record a freshly-spawned keeper's PID. Called by the sandbox-entry
111+
// path after fork()+exec(bwrap, ...) returns the child PID.
112+
inline void register_pid(const std::string& subosName, int pid) {
113+
auto s = state_for(subosName);
114+
std::error_code ec;
115+
fs::create_directories(s.pidFile.parent_path(), ec);
116+
{
117+
std::ofstream ofs(s.pidFile, std::ios::trunc);
118+
if (ofs) ofs << pid;
119+
}
120+
touch_activity(subosName);
121+
}
122+
123+
// nsenter into the keeper's mount namespace and run cmd via sh -c.
124+
// Returns the wrapped command's exit code, or -1 on failure to launch.
125+
// On non-Linux: returns -1 (caller falls back to fresh-sandbox path).
126+
inline int nsenter_and_exec(const std::string& subosName,
127+
const std::string& cmd) {
128+
#if !defined(__linux__)
129+
(void)subosName; (void)cmd;
130+
return -1;
131+
#else
132+
auto s = state_for(subosName);
133+
std::ifstream ifs(s.pidFile);
134+
pid_t pid = -1;
135+
ifs >> pid;
136+
if (pid <= 0) return -1;
137+
138+
touch_activity(subosName);
139+
140+
// Build nsenter cmdline. We can't pass cmd directly to /bin/sh -c
141+
// through system() without escaping its single quotes — wrap and
142+
// escape so embedded ' chars survive.
143+
std::string escaped;
144+
escaped.reserve(cmd.size() + 8);
145+
for (char c : cmd) {
146+
if (c == '\'') escaped += "'\\''"; // close, escape, reopen
147+
else escaped += c;
148+
}
149+
auto full = std::format("nsenter --mount=/proc/{}/ns/mnt -- /bin/sh -c '{}'",
150+
pid, escaped);
151+
auto rc = std::system(full.c_str());
152+
if (rc == -1) return -1;
153+
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
154+
return rc;
155+
#endif
156+
}
157+
158+
// Stop the keeper (sent by `xlings subos stop <name>` or by lifecycle
159+
// hooks like subos remove). SIGTERM gives the keeper a chance to clean
160+
// up; if it doesn't die in 2s we follow with SIGKILL.
161+
inline int stop_keeper(const std::string& subosName) {
162+
auto s = state_for(subosName);
163+
if (!fs::exists(s.pidFile)) return 0;
164+
#if defined(__linux__)
165+
std::ifstream ifs(s.pidFile);
166+
pid_t pid = -1;
167+
ifs >> pid;
168+
if (pid > 0 && ::kill(pid, 0) == 0) {
169+
::kill(pid, SIGTERM);
170+
// Best-effort wait
171+
for (int i = 0; i < 20; ++i) {
172+
if (::kill(pid, 0) != 0) break;
173+
::usleep(100'000); // 100ms
174+
}
175+
if (::kill(pid, 0) == 0) ::kill(pid, SIGKILL);
176+
}
177+
#endif
178+
std::error_code ec;
179+
fs::remove(s.pidFile, ec);
180+
fs::remove(s.lastUsedFile, ec);
181+
return 0;
182+
}
183+
184+
// Trigger predicate for auto-spawn — defined per D9: only meaningful
185+
// for image/tmpfs storage in sandbox mode on Linux.
186+
inline bool should_auto_keeper(const std::string& storage, bool sandbox) {
187+
#if !defined(__linux__)
188+
(void)storage; (void)sandbox;
189+
return false;
190+
#else
191+
if (!sandbox) return false;
192+
return storage == "image" || storage == "tmpfs";
193+
#endif
194+
}
195+
196+
} // namespace xlings::subos::keeper

src/core/xim/index.cppm

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ xpkg::PackageType int_to_type(int v) {
2525
case 1: return xpkg::PackageType::Script;
2626
case 2: return xpkg::PackageType::Template;
2727
case 3: return xpkg::PackageType::Config;
28+
case 4: return xpkg::PackageType::Subos;
2829
default: return xpkg::PackageType::Package;
2930
}
3031
}

src/core/xim/installer.cppm

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import xlings.core.xvm.db;
2020
import xlings.core.xvm.commands;
2121
import xlings.core.xvm.shim;
2222
import xlings.core.xim.libxpkg.types.script;
23+
import xlings.core.xim.libxpkg.types.subos;
2324
import xlings.runtime.cancellation;
2425

2526
export namespace xlings::xim {
@@ -1361,6 +1362,15 @@ public:
13611362
}
13621363
continue;
13631364
}
1365+
} else if (!payloadInstalled && node.pkgType == 4 /* Subos */) {
1366+
log::debug("installing subos base {}...", node.name);
1367+
if (!subos::default_install(node, ctx)) {
1368+
if (onStatus) {
1369+
onStatus({ node.name, InstallPhase::Failed, 0.0f,
1370+
"default subos install failed" });
1371+
}
1372+
continue;
1373+
}
13641374
}
13651375

13661376
if (!payloadInstalled && extractedRoot && !detail_::has_directory_entries_(ctx.install_dir)) {
@@ -1445,6 +1455,14 @@ public:
14451455
}
14461456
continue;
14471457
}
1458+
} else if (!executor.has_hook(mcpplibs::xpkg::HookType::Config) && node.pkgType == 4 /* Subos */) {
1459+
if (!subos::default_config(node, dataDir)) {
1460+
if (onStatus) {
1461+
onStatus({ node.name, InstallPhase::Failed, 0.0f,
1462+
"default subos config failed" });
1463+
}
1464+
continue;
1465+
}
14481466
} else if (!detail_::run_config_hook_(node, dataDir, executor, ctx,
14491467
onStatus, useAfterInstall)) {
14501468
if (onStatus) {
@@ -1598,18 +1616,27 @@ public:
15981616
std::format("uninstall hook failed: {}", result.error));
15991617
}
16001618
} else {
1601-
// Check if this is a script-type package and run default uninstall
1619+
// Check if this is a script-type or subos-type package and run default uninstall
16021620
bool isScriptType = false;
1621+
bool isSubosType = false;
16031622
if (catalog_ && resolvedMatch) {
16041623
auto pkg = catalog_->load_package(*resolvedMatch);
1605-
if (pkg) isScriptType = (pkg->type == mcpplibs::xpkg::PackageType::Script);
1624+
if (pkg) {
1625+
isScriptType = (pkg->type == mcpplibs::xpkg::PackageType::Script);
1626+
isSubosType = (pkg->type == mcpplibs::xpkg::PackageType::Subos);
1627+
}
16061628
} else if (index_) {
16071629
auto* entry = index_->find_entry(targetName);
1608-
if (entry) isScriptType = (entry->type == mcpplibs::xpkg::PackageType::Script);
1630+
if (entry) {
1631+
isScriptType = (entry->type == mcpplibs::xpkg::PackageType::Script);
1632+
isSubosType = (entry->type == mcpplibs::xpkg::PackageType::Subos);
1633+
}
16091634
}
1635+
std::string ver = resolvedMatch ? resolvedMatch->version : std::string{};
16101636
if (isScriptType) {
1611-
std::string ver = resolvedMatch ? resolvedMatch->version : std::string{};
16121637
script::default_uninstall(ctx.pkg_name, ver);
1638+
} else if (isSubosType) {
1639+
subos::default_uninstall(ctx.pkg_name, ver);
16131640
}
16141641
}
16151642

0 commit comments

Comments
 (0)