Skip to content

SubOS architecture: isolation, roots, carriers and Luban (#640) - #641

Merged
Sunrisepeak merged 170 commits into
mainfrom
feat/subos-architecture-640
Oct 9, 2026
Merged

Sunrisepeak merged 170 commits into
mainfrom
feat/subos-architecture-640

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Implement the SubOS architecture in Part 1 and Part 2: shared policy/provider/session/broker/audit contracts, owned prefix domains, checked source scopes, exact root views, atomic generations and rollback, root export/boot, and a unified xdev acceptance runner. Preserve user data and fail closed when ownership, state, or isolation cannot be established.

Plans and evidence:

Current status: Draft; not ready to merge. Head 24cb3ae9b2fa4cd8deccb78cb3b86fe1ec783c31 adds only F12 test/CI evidence wiring and its progress record; C++ product source is unchanged from b3dd5009. All five workflow groups are rerunning on this head.

The preceding complete product head b3dd5009 passed Linux main, ASAN, macOS, Windows, ARM64, Linux root, legacy E2E, Arch, isolation and all three distro jobs. Linux main: 107 test programs pass; XTEST 216 pass/0 fail/5 skip. All three additional static domain/source/export cases and all six static performance cases actually pass, with no skips and every hard-gate outcome success. The originally reported DomainProducer case passes in 16416ms, including unconfirmed removal exit 2 and host data preservation. ASAN: all 109 programs pass; 1905.30s total. macOS XTEST154 pass/0 fail/2 skip; Windows111 pass/0 fail/45 skip.

The full rootfs fixture passes all nine stages, including core/cache, 144 executable closures, no-shell shim, fetch=layer and desktop rendering. The full image fixture passes all seven stages, including multi layout, actual HTTP self-update from published .1 to candidate .2, generation 2 to 3, rollback to .1/generation2, and unchanged static stage0 bytes/inode. Root UID0 controller trials retained capabilities and were rejected; the original UID1000 boundary is retained.

The requirement report verified 143 of 146 behaviors. Windows quoting is covered by its mandatory Windows gate; WSL1 is the explicit best-effort exception. The only remaining hard blocker was F12: the dedicated repair script really ran and passed, but lacked coverage metadata. The appended fix adds F12/DOC-ISOLATION coverage, requires metadata in that CI suite, refuses an unsupported prerequisite instead of reporting exit-zero success, and always checks the raw probe error. Replaying real execution evidence proves the missing linkage before the fix and a passing F12 linkage after it; the new head must still pass CI.

Dependency: libxpkg #47 removes the installing SubOS path from shared payload RPATH. Its regression fails before the fix and passes afterward. The 0.0.62 version candidate also passes upstream CI. The client pins immutable commit 7c202104625a072b5e6553603cc18859c3e29b47 for reproducible candidate validation. The upstream PR/source-tag release/mirror/index update remain pending review; nothing has been merged or released.

Keep all 142 PR commits: append commits and ordinary pushes only. Once the technical acceptance criteria are met, report to the maintainer for review and a merge decision. Do not merge or release automatically.

Refs: #640


Part 3 (2026-10-09): content × view × carrier — C43–C53

Design: .agents/docs/2026-10-09-subos-architecture-design-part3.md (§16 plan, §19 record). Self-review: .agents/docs/2026-10-09-pr-641-self-review.md.

C What Verified by
C43 Layout by responsibility; interface units include no system headers; platform #if → if constexpr; layer / branch lints, every tools/lint_*.sh in CI three-platform builds, LAYOUT-DEPS
C44 modules/store: a retained generation is a GC root; keep 5 + release (fixes H1, M1) ROOT-GC-ROOT, ROOT-GEN-RETAIN (CLI: remove → rollback finds its files)
C45 Durable generations (fsync order traced); switch checks change stamps, not every link (fixes H2, M2: 300 payloads 0.2 ms) ROOT-GEN-DURABLE, ROOT-SWITCH-SCALE, static perf lane
C46 One tool table, one elevation door (audited); in-process tarballs; std::system gone TOOL-RESOLVE, distro lane (docker import, qemu boot)
C47 Intent IR + modules/confine registry; core no longer dispatches backends; matrix from the implementations INTENT-EQ: 872 recorded cases byte-identical; GATE-CONFORM
C48 modules/carrier: subos new --carrier --abi, chosen by what the SubOS is, forwarded through one launcher + the NDJSON interface CARRIER-LOCAL, CARRIER-UNAVAILABLE
C49 wsl2 carrier: one WSL2 distribution per home, the user never enters WSL; interop/automount off; drvfs grants stand-in wsl.exe lifecycle on Linux; Windows E2E-08 on a real host
C50 macOS sessions (framed SOCK_STREAM transport): join/start/stop/timeout/exit codes as on Linux; Windows commands in a Job Object macOS + Windows workflows; SESSION-NATIVE-SUPERVISED
C51 vz carrier through its VM helper's contract stand-in helper lifecycle; the signed helper itself is deferred (CARRIER-VZ-HELPER)
C52 luban/ (boot, stage0, machine) beside modules/; luban-init its own static binary (0 frontend symbols, 4.7 MB) LUBAN-INIT on the release tarball
C53 openkal: coexists with glibc + static musl (CI probe); handle transfer is outside openkal 0.15 → Transport stays in platform OPENKAL-COEXIST step

Also: libxpkg #47 reviewed and merged (912720f), client pinned to it. Release version 2026.10.9.1.

Not done, said so: the signed xlings-vm VZ helper; joining a running session on Windows; openkal in product code (first meaningful use is with the Luban kernel).

@Sunrisepeak Sunrisepeak added the ci:asan Run the ASan/UBSan unit suite on this PR (xlings-ci-linux unit-asan) label Oct 5, 2026
Sunrisepeak added a commit to openxlings/xim-pkgindex that referenced this pull request Oct 6, 2026
* feat: Luban editions and the packages a SubOS root is made of

For xlings SubOS design part 2 (openxlings/xlings#641): a SubOS presented
as /, exported as an image, booted by a kernel -- Luban.

- subos:luban-tiny / luban-core / luban-desktop: edition templates (no
  download; install() writes them): the packages a root is made of, its
  init, its factory /etc, sysusers. core is `from` tiny, desktop from core.
- bash 5.2.37, coreutils 9.5: new, built against xim:glibc 2.44.3 with
  xim:gcc 16.1.0 (form X; libc.so.6 is their only NEEDED).
- linux-kernel 6.8.0-71: Ubuntu noble's generic vmlinuz, repackaged at
  lib/modules/<ver>/vmlinuz; virtio, ext4 and the serial console are built
  in, so a root on /dev/vda boots with no initramfs.
- busybox (revision 1): the payload's bin/ carries a relative link per
  applet, which is what a root's /usr/bin is made of. Only `busybox` is
  registered, so a home's PATH is unchanged.
- perl (revision 1): 29 scripts (perldoc, pod2man, prove, cpan...) said
  `#!/build/stage/bin/perl` and started on no machine but the build's.
- binutils 2.42.1 (revision 1): the ld wrapper takes its directory with
  ${0%/*} instead of dirname, so it runs in a root without coreutils.

* luban-tiny: init's restart re-execs stage-0 (subos boot --now)

* luban-core: no curl (it has no linux build); xlings fetches

* fix: spec -- subos-type packages are registered by xlings' default config; linux-kernel registers its name; the ld wrapper's note moves out of its lines

* revert the index cache a local run rewrote
xlings-ci added 27 commits October 8, 2026 08:06
The design consolidates the earlier SubOS isolation documents into one:
deployment and run modes, policy presets and the decide function, enter
and exec, the human/agent contract, the platform abstraction layer,
observability, module layout, and the test/CI architecture.

The implementation plan cuts it into the checkpoints this PR lands as
commits, with the dependency graph and the scope decisions.

docs/design/subos-isolation.md: image is a storage mode, not an isolation
level; bwrap runs in user-namespace mode (the setuid chmod silently fails
without sudo); the known #640 issues are listed.

AGENTS.md: core and interaction surfaces are separate; the audience is
declared with --agent / XLINGS_AGENT_MODE.
modules/testkit answers the four questions every end-to-end test used to
answer for itself, differently each time:

- which binary: XLINGS_BIN when it names a file, else the newest
  target/**/bin/xlings (the shell profile exports XLINGS_BIN as a
  directory, so a developer's shell already gives it another meaning);
- which home: Home::isolated() under the system temp dir, removed unless
  the test failed, in which case its config/logs/state are kept as
  artefacts;
- which environment: run() starts from nothing and adds what the test
  names; inheriting XLINGS_ACTIVE_SUBOS is what made a unit test see the
  developer's subos;
- why it did not run: XTEST metadata declares required capabilities;
  missing is a skip on a developer machine and a failure on a lane that
  declared it in XDEV_LANE_CAPS.

The runner covers POSIX (pipes, process groups, pty with nothing typed)
and Windows (CreateProcess, job object). XTEST_META_OUT and
XTEST_RESULTS_OUT write NDJSON for the report.

The proof case ports subos_cmd_contract_test.sh and .ps1 to one C++ test.
The macOS shell copy never asserted anything: bash 3.2 does not trip
set -e on a failing [[ ]], and the last main run printed 'No such file'
for the path it checked and then 'ok'.
apps/xdev is the project's development tool, its own workspace member:
the root build does not build it, so the product is unchanged
(mcpp build -p xdev).

- xdev test runs mcpp test --message-format json with the XTEST outputs
  wired, then the legacy suites from tests/suites.toml through an adapter
  (one record per command: exit code, duration, log tail);
- xdev report renders one report from any number of run directories:
  pass/fail/skip per test binary, XTEST case and script, failures with
  output, skips grouped by reason, slowest tests, lane capabilities; to
  the terminal, report.{md,json}, and the GitHub step summary;
- xdev doctor lists what this machine can test.

tests/suites.toml carries the contract scripts and lint checks CI runs
inline today. tests/README.md is rewritten (it still described
test_main.cpp and linux_usability_test.sh).

test_interface_protocol no longer inherits XLINGS_ACTIVE_SUBOS from the
shell that runs it; inside a subos it failed locally and never in CI.
xlings-ci-linux-e2e.yml built the same commit a second time from source
(E2E-00) and a third time as a release, to run a 3.6-minute suite. It is
now the e2e job of xlings-ci-linux.yml and takes build-and-test's release
artifact; the binary under test is the one extracted from it.

- unit-asan (the slowest job, ~27 min) runs on push to main/release and
  on PRs labelled ci:asan; E2E-00 (fresh mcpp home builds xlings) moved
  there too, on push.
- Tests run through xdev on Linux, macOS and Windows. Each lane declares
  its capabilities (XDEV_LANE_CAPS); the Linux lane installs bubblewrap
  and lifts the AppArmor userns restriction for itself, so its isolation
  tests run instead of skipping.
- run_all.sh writes one record per test when XDEV_RECORDS is set; a report
  job merges the Linux lanes into the step summary. xdev is built static
  (musl) so the e2e and report jobs can run the same binary.

BMIs stay uncached: restored BMI sets fail with 'CRC mismatch', as the
cache step documents.
tests/requirements.toml declares every behaviour the SubOS design
promises, by ID: the findings it fixes (F1-F16), the exit codes, the
agent contract, policy, isolation, sessions, permissions, observability,
home and compatibility. Each carries a status:

- required: built, must be covered;
- planned: its checkpoint has not landed; the commit that lands it flips
  the status, so the gate grows with the PR;
- deferred: not in this PR, with the reason (net=proxy, rootfs).

An isolation ID is only covered by a test that runs a real sandbox
(proves = "isolation"): the fake provider proves a path is wired, not
that anything is isolated.

xdev report --requirements ... --fail-uncovered fails on an uncovered
required ID and on a test naming an undeclared ID; the Linux report job
runs it over every lane's metadata.

check_requirements() takes the lane's declarations explicitly too, and
the lane rule that replaces skip-when-missing (F13) is tested with it.
Two things both the xlings core and the SubOS core need, and neither may
own, become their own packages:

- xlings.guard: the UserConfirmed token and the Asker port. ask() takes
  an Asker; the core never decides how a question is put. The token can
  still only come from ask(), so the rule that only a confirmed deletion
  may remove user data stays a compile error to break.
- xlings.observe: the event model (ops, lifecycle, perm, exec, net, fs,
  destructive, trace), a journal that rotates by size and never fails
  its caller, redaction (names, never values) and XLINGS_TRACE.

src/core/confirm and src/core/destructive_log keep their names and APIs
as adapters (the EventStream is an Asker; the destructive record is bound
to this home), so none of the 28 call sites changes. The destructive
record is never rotated: it exists to attribute a loss after the fact.

Behaviour is unchanged; the full unit suite passes.
The macOS leg's member output layout differs from Linux's, the path
pattern matched nothing, and an empty $xdev ran as exit 127 after a
successful build.
… in (C2)

The SubOS core gets its own package. It depends on json, platform, guard
and observe, and may not import xlings.core.*: what it needs from the
package manager arrives through ports (next checkpoint), so a build that
reaches for xim from here fails to compile.

gpu, graphics and manifest already depended on nothing but std, json and
platform; they move with history (git mv) and their modules are renamed
xlings.core.subos.* -> xlings.subos.*. Namespaces are unchanged, so the
only edits outside the move are import lines.

No behaviour change.
…re (C3)

- xlings.subos.home_view: where a SubOS's things live -- the instance,
  and, outside it, its policy (config/subos/<n>), its audit
  (logs/subos/<n>), its sockets (run/subos/<n>) and the host capability
  cache (state/). The SubOS core gets this from xlings core instead of
  reading Config.
- xlings.subos.ports: what the SubOS core needs from xlings (install a
  backend, recognise a shim's owner), received as functions.
- src/core/subos/ports: the adapter that builds both from this home and
  xim / xvm.
- userdata moves with history; delete_subos takes the HomeView and a
  guard::UserConfirmed, and records through observe.

The remove_all lint now covers modules/ and treats all of modules/subos
as lifecycle code. keeper is not moved: the session model replaces it
(C9), and moving dead code first would only move it twice.

No behaviour change: unit tests and subos_user_data_test.sh pass.
The rules that turn a typed name into an instance -- exact, unique
case-insensitive, unique prefix, ambiguous, not_found with suggestions
ranked by relation and edit distance -- are pure functions over names in
xlings.subos.model. src/core/subos.cpp keeps the adapter half: reading
the registry, counting commands and packages through xvm, and mapping
the model's answer back to instances.

Every surface that resolves a name (use, and next exec / start / the
interface) shares one implementation. No behaviour change:
subos_use_candidates_test.sh passes; the rules are pinned by unit tests
without a home.
src/core/home (xlings.core.home) is the one answer to which home, in
which deployment mode, at which layout:

- Config records how it chose the home (anchored, XLINGS_HOME,
  self-contained, default); Config::home_context() adds what the marker
  declares.
- .xlings-home gains mode (user, custom, portable, system, multi) and
  layout. self init declares both at creation; a marker that predates
  modes is inferred once from where the home is and then declared.
  Writes keep every key this client does not know; the layout only rises.
- layout 2 adds config/subos, logs/subos, run/subos and state -- only
  directories, so older clients are unaffected.
- A home at a layout this client does not know is read and never
  written: mutating commands refuse and name the home's own client.

Tests: unit tests for describe/declare/read-only commands, and e2e that
a first command declares the home and that a newer layout refuses
'subos new' while 'subos list' works.
… it (C6)

home::read_json_for_update is the writer rule in one place: a missing
file is a new, empty document; a file that exists and does not parse as
an object is an error, and the writer does not write. Writers update the
object they read, so keys they do not know survive.

Two writers broke it:

- Config::save_versions turned an unparseable home .xlings.json into
  {versions}, erasing the mirror, the subos registry and known projects;
- ensure_subos_info_ (subos new on an existing directory) rebuilt an
  unparseable manifest from {}, writing a blank workspace over the
  subos's real one.

Both now leave the file untouched and say so, as the workspace writer
already did. The SubOS policy file gets its place outside the instance
(HomeView::policy_file, config/subos/<n>/policy.json) for the same
reason: a document the sandbox could rewrite would govern nothing.
Policy + HomeView + Caps -> SandboxSpec -> provider (design §16-§17):

- xlings.subos.policy: the policy model as data (presets, net, fetch,
  observe, identity, env pass-list, mounts, named grants). Legacy is the
  policy of an instance nobody declared one for.
- xlings.subos.caps: backends located and probed, with the probe's raw
  output kept as evidence; the SubOS core finds them through HomeView
  and asks Ports whether a host binary is another home's shim.
- xlings.subos.spec: SandboxSpec is pure data -- mounts in order,
  namespaces, the whole environment, the command -- and compile() is the
  only place a security default lives. A requirement the host cannot
  meet is a structured refusal (dimension, reason, fix, must/should).
- xlings.subos.provider: bwrap and proot argv and the process
  environment, translated from the spec and deciding nothing.
- xlings.subos.gates: the eight platform interfaces' probes (FsGate,
  ProcessScope, NetGate, DeviceGate, IdentityShim, ExecTracer,
  SessionHost, RootfsRuntime): supported, kernel or advisory, and the
  route where not. The platform matrix becomes a measurement.

src/core/subos/sandbox.cpp keeps what belongs to xlings (directory
setup, image mounts, the index check, backend auto-install) and builds
the sandbox from the compiled spec. The backend is now exec'd with an
explicit environment.

No behaviour change: goldens pin the Legacy bwrap and proot argv to what
the old builders produced, byte for byte.
Five workflows queued every push's full matrix behind the last one's; a
PR with several checkpoint commits waited on runs nobody would read.
main and release branches keep every run.
… terminal, probe text (C11)

Applied by the compiler to every bwrap sandbox, declared or not (the S0
fixes are not opt-in):

- F3: the environment is an allow-list. A base set (TERM, LANG, LC_*,
  TZ, XLINGS_AGENT_MODE, ...) plus, for dev and undeclared instances,
  proxies, editor, pager and CA paths. Tokens, SSH_AUTH_SOCK, the D-Bus
  address and XAUTHORITY no longer enter; a policy can name more.
- F4: pid, ipc and uts namespaces; --die-with-parent.
- F8: a non-interactive command runs without a controlling terminal
  (--new-session); an interactive shell keeps job control and carries a
  seccomp filter refusing ioctl(TIOCSTI/TIOCLINUX) with EPERM, compared
  on the low 32 bits so a high-bit spelling does not pass. The filter is
  hand-written classic BPF (x86_64 incl. x32 and i386, aarch64).
- F12: a failed bwrap probe quotes bwrap, names the restriction in force
  from the kernel's own sysctl, and lists remedies least-privilege first
  (self doctor --isolation --fix, then proot). It no longer advises
  turning the restriction off for the whole machine.

proot cannot do any of this; the spec records it as degraded.

Verified from inside real sandboxes (proves = isolation): no token in
env, under ten pids in /proc, no controlling terminal for a redirected
command, TIOCSTI -> EPERM on a terminal. The filter is also checked in a
plain child process, including the high-bit bypass.
…oins with fd passing (C9)

Entering a sandbox no longer replaces xlings with bwrap (F15). The xlings
process that entered stays outside as the supervisor:

- it forks the backend with session-init (this binary, read-only at
  /run/xlings/xlings, plus its loader and RUNPATH for a dynamic build)
  as the sandbox's first process, and waits;
- it owns <home>/run/subos/<n>/exec.sock (owner-only). A later command
  for the same instance JOINS the session: it passes stdin/stdout/stderr
  with SCM_RIGHTS, the supervisor records the request and hands request
  and descriptors to session-init over a private SOCK_SEQPACKET pair, and
  the process inside uses the caller's terminal or pipes directly --
  nothing relays the bytes. The exit status comes back the same way.
  Clients never talk to the sandbox, so it cannot answer for the
  supervisor;
- a join with different isolation is refused (spec digest);
- like system(), it ignores SIGINT while waiting and forwards
  SIGTERM/SIGHUP; a client's Ctrl-C is forwarded to its command;
- it writes the audit to <home>/logs/subos/<n>/events.ndjson: lifecycle
  (session-start with the compiled spec, session-end with exit and
  duration, stop requests) and ops (each joined exec: program, argc,
  cwd, environment NAMES, exit, duration).

Exit codes follow design §12.2: the command's own, 126/127 for cannot
run / not found, 128+n for a signal, 125 for setup failures.

New: subos ps [--json], subos log <n> [--kind] [--session] [-n] [-f]
[--json]; subos stop ends the session (and clears keeper files left by
older clients). macOS and Windows report SessionHost as not implemented
yet.

Tests: the supervisor's lifecycle records and exit passthrough, a second
command joining (shared /tmp, exec audit), ps and stop, log filtering --
all against real sandboxes.
testkit writes XTEST metadata from a static destructor; on libc++ the
function-local mutex it locked was destroyed first and every test binary
aborted at exit with 'mutex lock failed: Invalid argument' (macOS CI).
The registry, testkit's output mutex and the journal mutex are now
allocated once and never freed.
…(C12)

The way to run commands in an instance from outside it (design §12),
for scripts, CI and agents:

- subos exec <name> [--sandbox] [--cwd] [--env K=V] [--timeout T]
  [--json] -- <argv...>: argv, nothing to shell-escape. It joins the
  instance's running session; otherwise it starts one for the command
  (--sandbox) or runs it with the instance's environment. Exit codes:
  the command's own, 125 before it starts, 126/127, 124 on timeout,
  128+n for a signal. --json prints the result on stderr.
- exec --temp [--from SRC]: a throwaway instance, removed through the
  one deletion entry point with --temp as the confirmation; its audit
  stays in logs/.
- subos start <name> [--ttl T]: a session without a terminal that later
  execs join (a hot environment, no startup per command). use --keep /
  --ttl now mean the same thing: the session outlives the shell.
- subos cp <src> <name>:<dst> (and back): only the instance's own trees
  (its home and /tmp).
- interface: subos_exec (output as subos_exec_output events, the exit
  code as the result), subos_start, subos_stop, subos_events.

Also: platform::run_argv on every platform; '--' ends option parsing in
the CLI spec; a sandbox keeps the variables its instance declares
(#352's GL paths) through the environment allow-list; POSIX specs use
POSIX paths when compiled on Windows; two shell e2e tests read
manifest.cppm from its new place.
…othing waits (C13, T4)

The audience is declared, never inferred (design §13): --agent for one
command, XLINGS_AGENT_MODE=1 for a process tree, =0 for a human. The CLI
exports it, and the sandbox's environment allow-list carries it, so the
xlings an agent runs inside a SubOS keeps the same contract.

Agent mode already refused to prompt (no confirmation and no selection
without an explicit answer). What remained was the one entry that waits
by design: subos use <name> without --cmd opens an interactive shell. In
agent mode it now refuses with exit 2 and names subos exec / subos start
instead -- decided at the interaction surface, not in the core.

T4: test_agent_contract walks the CLI's own spec and runs every local
command on a pseudo-terminal with nobody typing, in agent mode; each must
finish. Plus: a missing confirmation exits 2 and deletes nothing; the
environment declares the audience as --agent does; an unknown name is an
error that lists the candidates; the declaration reaches a sandbox.
…subos config/status (C14, C20)

What an instance may do is declared, compiled and reported (design §7,
§10, §14, §19):

- presets: dev (host files and other instances invisible, network as
  usual, missing items reported), private (+ a private network through
  pasta, neutral identity, fetch=ask; its isolation is required), locked
  (+ no network, fetch=deny, full observation, nested user namespaces
  refused, mounts read-only by default);
- <home>/config/subos/<n>/policy.json, outside the instance. Parsing
  fails closed: a field or value this version cannot enforce refuses
  entry instead of being ignored. Writers keep x-* and comment keys;
- a call names a stricter preset (--sandbox=private|locked) or
  tighten-only overrides (--net, --fetch, --allow within
  grants_allowed, --no-degrade); loosening is refused with the owner's
  command. A declared instance is entered under its policy however it is
  entered, with or without --sandbox;
- policy::decide() is the one answer for fetch (ordered rules: glob,
  index, size), index updates, grants and policy changes; the policy
  changes only from outside the sandbox (E_PERMISSION, exit 13);
- the compiler honours net=none (a namespace with only lo, no ARP
  neighbours), a neutral identity (user, the instance as host name, UTC,
  C.UTF-8, etc-neutral/ passwd, no host zone file), disable_userns, and
  must/should: a missing must-item refuses (125) in one shared format, a
  missing should-item enters and says what is not in effect;
- subos config <n> [--sandbox=P --net --fetch --index-update --observe
  --allow --disallow --grants-allowed --env-pass --no-degrade --reset]
  writes the file and audits the diff; subos status <n> [--json] shows
  requested vs effective and the eight platform interfaces' probes.

net=nat needs pasta; it is reported as missing until the network
checkpoint. Verified in real sandboxes: a locked instance entered
without --sandbox has user=user host=box tz=UTC, only lo, no ARP
entries, and cannot create a user namespace.
…t sockets out of reach (C23)

private's network (design §19): a namespace of its own with egress
through pasta (passt), the host's interfaces, ARP neighbours and
abstract unix sockets out of sight.

pasta cannot join a namespace bwrap made (bwrap is not dumpable and owns
its netns from an inner user namespace). So for net=nat the
supervisor's child first unshares a user namespace mapping this user to
itself and a network namespace, says it is ready, pasta attaches to it,
and only then does the child exec bwrap, which runs in that network
instead of making one. pasta's pid goes to run/subos/<n>/pasta.pid and
is ended with the session; a pasta that fails is a setup failure (125).

Safe defaults: nothing published (-t none -u none), no path from the
sandbox to the host's own loopback (-T none -U none --no-map-gw).
--publish HOST:SANDBOX publishes a TCP port; the host-loopback grant
opens the host's local services. --publish without nat is reported.

pasta is found as a payload, then at /usr/bin or /usr/local/bin, and is
usable only with /dev/net/tun; missing, private refuses with the reason
and the fix (install passt, or --net none). The Linux CI lane installs
passt and declares the capability.

F2 verified in real sandboxes: a host process listening on an abstract
socket (what an X server does) is reachable from dev, which shares the
host network by design, and not from locked.
…it (C10, C15, C7)

#640 F1 and F6, the S0 escape: the whole xlings home was bound
read-write into every sandbox -- the shims, the shell profile, the
payloads and the home config, all code the host runs, plus every other
instance's files.

Now (design §16):
  --ro-bind  , --tmpfs /subos, --bind /subos/<self>,
  --tmpfs /logs, /run, /state
config/ stays readable, so the policy is readable inside and not
writable. proot cannot bind read-only and reports it as degraded fs.

The inventory (C7) of what the xlings inside then needs: reads work
as they were (--version, list, info, use <pkg>, subos list); what writes
the home goes to the broker (design §8, §9):

- inside, install / update / remove / use <pkg> <ver> are sent as argv
  with the caller's stdio to run/subos/<n>/broker.sock, bound at
  /run/xlings/broker.sock;
- the supervisor decides with the same policy::decide (the strictest
  target wins) and runs an allowed command on the host for this
  instance, writing to the caller's terminal directly; ask queues it
  (exit 75 with the request id); deny is E_PERMISSION (13) with the
  owner's command;
- the owner's things -- self, subos changes, config, other instances
  (--subos other) -- are refused inside before anything is sent;
- every decision, approval and result is a perm event in the audit.

New: install --subos <name> (remove had it); subos requests / approve /
deny for fetch=ask. A state lock that cannot be opened for writing now
fails at once instead of waiting 600 s for a holder that does not exist.
/run/xlings (the session's client) is the last PATH entry inside, so an
instance always has an xlings.

Verified in real sandboxes: bin/, .xlings.json, data/ and the policy
file are read-only inside, the instance's own tree is writable, another
instance and the audit are invisible; brokered remove returns the host
command's code; deny/ask/requests/deny-request flow; with the network,
install from inside (fetch=auto) and install --subos from outside.
clang deduces an unnamed stream's format string as an argument (the
file's own header says so); a const char* loop variable fed to
std::format instantiated the wchar_t formatter; kill/setpgid needed
<signal.h> on macOS.
Nothing a sandbox withholds by default is a dead end; each is one
explicit grant away (design §11, §21.2).

- --mount <host>[:<inside>][:ro|rw] on use / exec / start, and
  subos config --mount / --unmount to keep it in the policy. docker -v
  syntax: a second segment that is exactly ro or rw is the mode
  (~/.gitconfig:ro). Default rw, read-only under locked (which refuses
  an explicit rw). Refused: a missing host path, the xlings home or a
  directory above it, the system's trees and the sandbox's own paths.
- --allow display | audio | camera | ssh-agent | dbus | gpu |
  host-loopback, within the policy's grants_allowed. Each opens one
  thing: X11's socket directory and a read-only copy of the
  Xauthority, or the Wayland socket; the Pulse / PipeWire socket; the
  /dev/video* nodes; the agent socket; the session bus socket -- each
  bound to a fixed path with its variable pointed at it, never the
  host's whole runtime directory. A grant this host cannot satisfy (no
  display, an abstract-only bus) is reported, not silent.

Verified in a real sandbox: a rw mount writes through to the host, a ro
mount refuses writes, the ssh-agent grant brings in exactly the socket,
and mapping the xlings home is refused (125).
… from the last line

The static musl builds (release, aarch64 cross) do not get offsetof
through <sys/un.h>. With the release binary, xlings' own notice precedes
the command's output, so the isolation test takes the count from the
last line. Every sandbox test passes against the static release binary.
…, then payload bwrap (C21)

#640's comment: on Ubuntu 24.04 the xim bwrap fails its probe because
AppArmor denies unconfined programs user namespaces, the recipe's setuid
chmod silently does nothing without sudo, and the old hint told people
to turn the restriction off for the whole machine.

- Lookup order (design §20, F10): /usr/lib/xlings/bwrap when it is
  root-owned and not writable by others; then the system's bwrap when
  its probe passes (used, and reported as the host's); then the xim
  payload. xlings neither creates nor relies on setuid.
- self doctor --isolation [--json]: the user-namespace sysctls, every
  bwrap found with its probe result, the chosen backend, pasta, and the
  eight platform interfaces.
- --fix, only for the case it repairs (AppArmor restricting user
  namespaces): installs a root-owned copy of a bwrap at
  /usr/lib/xlings/bwrap and /etc/apparmor.d/xlings-bwrap, a profile that
  grants that one binary user namespaces and nothing else, and loads it.
  It prints every command it will run as root, asks (agent mode: -y or
  exit 2), and leaves the kernel setting alone.

CI: a job on a stock ubuntu-24.04 runner (restriction left on) installs
the payload bwrap, sees the doctor name the restriction without
advising sysctl, runs --fix -y, and enters a sandbox through the
root-owned bwrap. The bwrap recipe's setuid step is a separate
xim-pkgindex change.
…t every link (C45)

Fixes H2 and M2 from the #641 review (SubOS design part 3 §7.2).

H2: commit and switch_to were atomic for readers but not durable -- after a
power loss the pointer could name a generation whose records never reached
the disk. Now the records and every directory the commit created are flushed
(deepest first) before the generation is placed, root.gen after it, and the
SubOS directory after the pointer moves. XLINGS_TRACE=durability prints the
order; ROOT-GEN-DURABLE asserts it.

M2: switch_to walked the whole generation and read every link (9.6 ms of a
10 ms budget at 300 payloads). A placed generation now has an inventory
beside it (root.gen/.<k>.inventory): the change stamp -- inode and ctime,
which nobody can set back -- of each of its directories and record files, and
the payloads it links into. Equal stamps mean "as placed"; anything else
falls back to the full walk, which remains the only proof that authorizes
pruning. Measured locally: 0.9 ms at 300 payloads, 3.0 ms at 3000.

A switch also refuses a generation whose payload is gone (the last line of
defence behind C44), naming it. platform::change_stamp is the new primitive;
Flush::Deferred lets the perf lane time the check apart from the device's
flush latency (durable switch reported beside it) -- nothing in the product
passes it.
… rights (C46)

SubOS design part 3 §6.4. Before this, where a program came from was decided
in four places (caps' three locators and root_cmd's PATH walk), several ran
as shell lines (`sudo mount ...`, `cp -a '...'`, `truncate`, `mountpoint`),
and an export ran the host's tar inside a user namespace only so the entries
would read as root's.

- xlings.subos.tools: the table -- root-owned / payload / runtimedir / system
  paths per tool, never another home's shim. caps' bwrap/proot/pasta, image
  mkfs, the fork's cp and the export's mkfs.ext4 resolve through it;
  `self doctor --isolation --json` reports each tool and its source (and the
  package that brings a missing one).
- In-process: xim::write_tar_gz (libarchive) writes export and pack
  tarballs, root-owned for an image without a user namespace; image sizing is
  std::filesystem::resize_file; "is it mounted" reads mountinfo.
- platform::run_elevated / is_elevated (sudo, or UAC "runas" on Windows) and
  xlings.subos.elevation, which records every elevated argv in
  logs/elevation.ndjson. Image mount/chown/umount and the isolation fix go
  through it; platform::priv_prefix (a "sudo " shell prefix) is gone.
- Headers: the extract interface unit no longer includes libarchive.
- tools/lint_tool_resolve.sh (TOOL-RESOLVE): no std::system, no argv that
  starts with a literal tool name, administrator argv only via elevation::run.
…(INTENT-EQ baseline)

872 cases (every host shape, backend preference, preset, network mode,
storage, grant set, root or not) through spec::compile and the providers,
recorded by the compiler as it is BEFORE C47 replaces its insides: the
description, the backend argv, pasta's arguments and the process
environment, hashed per case. C47 must reproduce every one byte for byte.
…nger dispatches backends (C47)

SubOS design part 3 §6.1-6.2. spec::compile was one 700-line function that
mixed what a policy asks for with how each backend does it, and
src/core/subos/sandbox.cpp chose the argv builder by backend.

- xlings.subos.intent: the policy and the call lowered into backend-free
  decisions -- identity, the network wished for, host paths mapped in (an
  ordered list in the shape of an openkal preopen, each with its refusal),
  named grants, the environment allow-list, storage and root.
- modules/confine (new package): one Implementation per backend --
  linux-bwrap, linux-proot, linux-landlock, home-redirect, fake -- each
  stating whether it runs here, its view, its process isolation, how a
  socket grant and a --mount reach it, its environment and command, and its
  rows of the platform matrix. The selector, the compile skeleton (the
  policy's Must/Should semantics, kept in one place), launch_argv and the
  matrix (strongest claim per interface, the route when none) live here.
- xlings.subos.spec keeps only the types; provider and gates moved to
  xlings.confine.provider / xlings.confine.gates.
- INTENT-EQ: all 872 recorded cases (previous commit) compile byte for byte
  to the same description, argv, pasta arguments and environment.
  GATE-CONFORM: the matrix is the implementations' claims.
macOS's bsdtar prints 'nlink uid gid' where GNU tar prints 'uid/gid'; the
tarball itself was root-owned on both (C46).
…ugh one launcher and the NDJSON interface (C48)

SubOS design part 3 §5.1, §5.2, §5.6.

- modules/carrier (new package): a Carrier probes this machine, ensures an
  endpoint (a launcher prefix and the xlings there), stops it, and grants a
  host directory into it. No new protocol: `carrier::control` is one
  `xlings interface <capability>` call there, `carrier::terminal` is
  `xlings <args>` there with this terminal attached. `local` is this
  machine's kernel.
- carrier::choose (§5.6): Linux has one kernel; on Windows / macOS a SubOS of
  the host's own programs stays local and a Linux one, a root, or one whose
  policy needs a boundary this kernel cannot give goes to the platform's
  guest carrier (wsl2 / vz) -- or is refused with the route that brings it.
  A carrier asked for by name is honoured or refused, never replaced.
- `subos new --carrier --abi`: recorded additively in instance.json
  (`carrier`, `abi`; absent = local/native, which older clients assume).
  A SubOS on another carrier: `new` and every name-taking subcommand run
  there; `remove` then drops the name here.
- `subos list` and the interface's `list_subos` are one emitter now (the
  capability had its own copy that lacked `kind`); entries carry carrier,
  abi and view (overlay / root / machine).
- CARRIER-LOCAL, CARRIER-UNAVAILABLE.
…and stats payloads once

The Linux-root lane (a dev build) measured the 3000-payload check at 11.1 ms
of 10. string_view + from_chars for the inventory, one lstat per payload
root: 3.0 ms unoptimized, 1.9 ms optimized, 0.2 ms at 300. The static lane
counts three generation cases now (ROOT-SWITCH-SCALE).
…the user entering WSL (C49)

SubOS design part 3 §5.3. On Windows, `xlings subos new dev --abi linux`
(or --rootfs, or --carrier wsl2) makes the SubOS in a WSL2 distribution of
this home's own and every later `xlings subos ...` for it runs there.

- One distribution per home ("xlings-" + a hash of the home's path, under
  <home>\carriers\wsl2), imported once from an image this xlings writes
  in-process: the Linux build of this release at /xlings/bin/xlings (static:
  it runs before anything else is there; self-contained, so /xlings is its
  home) and the machine files the carrier declares -- /etc/wsl.conf with no
  automount and no interop, root's passwd, mount.drvfs -> /init. Written from
  a description (xim::write_tar_gz_entries), so Windows needs no symlinks.
- The Linux build comes from the index's xim:xlings linux artifact at this
  version (the latest published one for a build not in the index yet), or
  XLINGS_CARRIER_GUEST_XLINGS.
- `wsl.exe -d <name> -u root --exec /xlings/bin/xlings __carrier-env K=V --
  <args>`: the guest has no env(1), so its xlings carries the variables.
  `__carrier-grant` mounts one granted Windows directory under /grant/.
- A carrier SubOS is registered here by name (home config `subos`, and
  instance.json `carrier`), so list/use/remove find it; its content is there.
- `self doctor --isolation --json` reports the carriers this platform has.
- Tests: a stand-in wsl.exe (XLINGS_WSL_EXE) runs the whole lifecycle on
  Linux CI -- real image, real guest xlings -- and found the env(1)
  assumption. tests/e2e/windows_carrier_wsl2_test.ps1 runs on the Windows
  lane: WSL2's lifecycle and interop-off where it exists, the refusal with
  its route (exit 125, native SubOS unaffected) where it does not.
  VIEW-CARRIER-EQ is deferred: it needs a real guest kernel.
…loads exist here

The static lane's RootExport case: an exported image's generation links name
payloads at the image home's (logical) path, which exists where the image is
used, not at export time -- and C45's 'a payload this generation links into
is gone' refused the commit's own switch. Choosing an existing generation
(rollback) still proves its payloads; commit and the restore of a prior
generation after a failure prove the tree (rootfs::Verify).
…Windows in a Job Object (C50)

SubOS design part 3 §6.3. Until now a sandboxed native SubOS on macOS and
Windows bypassed the supervisor entirely: environment variables set, a shell
run -- no join, no --timeout, no exit-code table, no audit.

- Transport: macOS has no SOCK_SEQPACKET for AF_UNIX; xlings.platform's
  message sockets are SOCK_STREAM there, each message framed by a 4-byte
  length with its descriptors on the frame's header. A socket path longer
  than macOS's sun_path goes through a per-user /tmp link to its directory
  (Linux keeps /proc/self/fd). Linux is unchanged.
- Session host: kSessions is Linux and macOS; the home redirect launches as
  this binary's __session-init (as Landlock does), its default command the
  user's shell. The macOS path is the Linux one -- join, start/stop,
  --timeout (124), signals (128+n), 125/126/127, the audit.
- Windows (no fork, no descriptor-passing sockets yet): run_argv_with_timeout
  is the process scope -- a Job Object with kill-on-close, the whole tree
  ended at the deadline (124), the command's own exit code. Joining a running
  session there needs a CreateProcess supervisor: stated in
  SESSION-NATIVE-SUPERVISED, not claimed.
- tests/e2e/test_subos_native_session.cpp runs on macOS and Windows; the
  Linux report counts that requirement as gated by those workflows.
…act (C51)

SubOS design part 3 §5.4: the wsl2 carrier's shape on a Mac -- one Linux VM
per home, a Luban machine with /xlings as its home, the same NDJSON
interface and __carrier-env launcher.

Virtualization.framework needs a binary signed with its entitlement, which
xlings is not; the carrier drives a helper, `xlings-vm` (a payload in the
tool table), through one contract: probe, status (0 running, 3 stopped,
4 absent), create from the carrier image, start, stop, share (virtiofs; the
path inside), exec (vsock). The VM is created on first need, started when
stopped, reused while running.

Verified against a stand-in helper (tests/fixtures/carrier/fake-vz-helper.sh)
on Linux CI: created once, started twice across a stop, a grant answered by
the helper, remove there. The helper itself is a separate signed package --
CARRIER-VZ-HELPER is deferred with that reason; until it is published the
carrier is refused with `xlings install xlings-vm` as its route.
…-init (C52)

SubOS design part 3 §8.

- luban/ (package luban, modules luban.*): luban.boot (boot.json: default,
  fallback, a trial), luban.stage0 (a machine's first process), luban.machine
  (the machine's /etc: factory files, sysusers -- taken out of
  xlings.subos.rootfs). It depends on modules/ and never on the frontend; the
  layer lint enforces both directions.
- apps/luban-init: stage-0 as its own package and binary. A package's
  binaries link every source of that package, so as a target of the root
  package it was the whole client (6812 frontend symbols); as its own it
  links luban and modules/ only (0). The Linux release builds and ships
  bin/luban-init (static, checked); an exported root and the projection carry
  boot/luban-init and /usr/bin/luban-init beside xlings-init, which machines
  already booting init=<home>/boot/xlings-init keep using.
- Linking modules/ without the frontend found a latent defect:
  manifest's DEFAULT_RUNTIME_FALLBACK was `inline constexpr` in a module
  interface, which GCC emits only where it is used -- every binary until now
  got it from the frontend.
- LUBAN-INIT: tests/e2e/luban_init_test.sh against the release tarball
  (shipped, static, a fraction of the client's size, refuses outside PID 1).
…isolation design map the new layout

C53 (SubOS design part 3 §4.3): both questions answered with evidence.
- openkal-linux 0.16.1 links and runs beside libstdc++ under glibc and the
  static musl release target (tests/openkal; the Linux CI builds and runs both
  on every PR). kal::write bypasses stdio: flush before mixing.
- Passing a handle between processes is outside openkal 0.15 (SPEC §11/9):
  Transport stays in modules/platform.
- Nothing of xlings links openkal in this PR: no portable piece behaves
  better as kal_*, and the first adoption that does -- an openkal program
  started with exactly its grants -- belongs with the Luban kernel.

Part 3 §19 records C43-C53 with what verified each. AGENTS.md: the layout
(store, confine, carrier, luban/, apps/luban-init) and the rules that came
with part 3 (one tool table, one elevation door, layers import downward, a
retained generation is a GC root, the INTENT-EQ golden).
macOS's home redirect starts its session-init as this binary (C50), and
host_exe_ read /proc/self/exe -- Linux's alone -- falling back to a bare
'xlings' that exec could not find (125: 'cannot run xlings').
…dependent of SubOS scopes)

The client was pinned to #47's fix commit on its PR branch; that branch is
merged and gone, so the pin moves to the squash commit on libxpkg's main.
Same code (the version bump to 0.0.62 is the only addition).
mcpp test compiles every tests/**/*.cpp as a test of the root package, which
does not depend on openkal; the macOS lane failed compiling the probe.
2026.10.8.2 was a candidate and never published; its policy-schema floor
(kPolicyMinClient) and the interface 1.6 note move to the release that
carries them.
A route that names a package the index does not have is a dead end that
looks like a step.
…t taken as local

Self-review against AGENTS.md's "could not read is not empty": a WSL2 SubOS
with a damaged instance.json was treated as local, so `subos remove` would
drop its name here while its content lived on in the carrier.
…session host

The macOS lane ran C50's exit-code table under the supervisor (3, 127, 124,
128+15 all correct); `subos start` was still refused by a "needs Linux"
check that predates macOS sessions.
The check stats each payload root once so a rollback cannot land on a
deleted payload; that grows with payloads. A loaded root-lane runner measured
11.9 ms (1.9 ms optimized, 3.0 ms unoptimized locally). The 300-payload budget
(PERF-GEN-SWITCH, 10 ms) is unchanged -- 0.2 ms now. The 3000 case is a
budget this PR introduced: ten times the payloads within twice the budget.

.agents/docs/2026-10-09-pr-641-self-review.md: part 1/2/3 together, by
angle; the fourteen defects this round found and what fixed each; what is
not done and why (the signed VZ helper, Windows join, openkal in product).
…mance lane

Lanes that run the suite in parallel measure their own contention: the
same binary read 11.9 ms and 25.3 ms on two runs of the Linux-root lane. The
static performance step runs each case alone and now sets
XLINGS_PERF_BUDGETS=1; elsewhere the case validates its workload, prints its
timing and skips the assertion, saying why.
tests/scripts/test_release_candidate_gate.py: a PowerShell test that runs a
command it expects to fail must not leak that $LASTEXITCODE into the step.
…all.sh

run_all.sh's orphan guard: a test nobody runs reports what a passing one
does. It passed in its own line of the suite (the release tarball).
The Linux report found SESSION-NATIVE-SUPERVISED undeclared: every case in
its file needed macOS or Windows, so xdev never ran the binary on Linux. A
platform-independent case now checks what the macOS supervisor stands on --
a home redirect starts as this binary's __session-init with its command after
it. The platform behaviour stays gated by the macOS and Windows workflows.
@Sunrisepeak Sunrisepeak changed the title SubOS architecture: scoped isolation, private domains and rootfs delivery (#640) SubOS architecture: isolation, roots, carriers and Luban (#640) Oct 9, 2026
@Sunrisepeak
Sunrisepeak marked this pull request as ready for review October 9, 2026 03:15
@Sunrisepeak
Sunrisepeak merged commit af5c2dc into main Oct 9, 2026
17 checks passed
Sunrisepeak added a commit that referenced this pull request Oct 9, 2026
…used install (2026.10.9.2) (#649)

* fix(xvm): an asset the payload does not ship is not placed, not a refused install (2026.10.9.2)

The fresh-install gcc suite on the published 2026.10.9.1: `xlings install
gcc@15.1.0` failed on Linux and CentOS 7 with "sysroot update refused before
metadata changed: .../gcc/15.1.0/lib64/libasan.so: asset source is missing or
unreadable". gcc's recipe declares its library assets across versions; the
15.1.0 build ships no libasan.so. Before #641 such an asset was skipped
("asset source missing, not placed"); #641's materializer refused the whole
update over it.

An absent source (not found, a dangling link included) is skipped again with
the same debug line; a source that exists and cannot be read still refuses.
Regression: XvmMaterialize.AnAssetThePayloadDoesNotShipIsNotPlacedAndDoesNot
RefuseTheRest.

* fix(xdev): a lane that does not name its execution is reported, never sampled

The hand-written lane.json of the linux-xdev lane carried no "run", so the
report used the lane's directory as the execution -- the same string in
every CI run. The first PR after main saved trend history read main's sample
of LoopbackHttp.DownloadsExactBytesAndAttributesADestinationFailure as the
same execution with another duration and refused the report.

Every hand-written lane now names its execution (run id and attempt), and a
lane that still does not is kept out of the trend history. macos-xdev also
declared platform "macosx", which the history does not accept.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:asan Run the ASan/UBSan unit suite on this PR (xlings-ci-linux unit-asan)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants