Add -e/--exec to join a running -r/--run session

Validates the given pid against the same tracked-session liveness
check --list-processes/--clean-processes already use, then joins its
namespaces via nsenter and runs a command there in the foreground.

Two things discovered only by testing against a live session, not
assumed up front:

- The tracked pid is bwrap's own outer process. It sets up the
  mount/user namespaces itself, then clone()s the actual sandboxed
  command into fresh pid/uts/ipc/cgroup namespaces -- clone()'s
  namespace flags only ever affect the new child, never the caller,
  so the outer process itself never enters those namespaces at all.
  exec_in_session() resolves that real child via
  /proc/<pid>/task/<pid>/children and joins its namespaces instead,
  falling back to the outer pid if that can't be read.

- Rather than nsenter -a (which would hit a known "Invalid argument"
  failure re-entering an identical namespace -- this project already
  worked around exactly that once, for the containers-storage mount
  path), each namespace type is only joined if
  /proc/<pid>/ns/<type> actually differs from this process's own.
  nsenter also needs --preserve-credentials, or it tries to
  setuid/setgid/setgroups to the target's identity, which fails
  outright against the setgroups-denied unprivileged user namespace
  bwrap creates whenever -r/--run isn't root.

Verified end-to-end: joined shell gets the container's own hostname,
process tree (ps shows only container processes), and root
filesystem; untracked/stale pids error out cleanly without touching
nsenter; Ctrl-C during the joined command doesn't disturb the
original session; no leftover mounts after either exits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-22 09:41:32 +00:00
parent d4c0e8f0a1
commit 00482b6b42
6 changed files with 275 additions and 7 deletions
+46 -2
View File
@@ -29,7 +29,14 @@ Source layout (all under `src/`):
(`pid_file.{h,cpp}`) and prints one `removed stale pid file for '<name>' (pid
<pid>)` line per file actually removed — nothing is printed for sessions still
running, and an empty result (nothing stale) is silent success, same
convention as the rest of this file's list/delete commands.
convention as the rest of this file's list/delete commands. `-e/--exec <pid>`
(has a short form, unlike the rest of the process-tracking flags) dispatches
straight to `exec_in_session()` (`exec_session.{h,cpp}`, see below): `pid`
lands in `mode_arg` (parsed as a positive integer, erroring out otherwise) the
same way `-r`'s image path does, and the trailing command
(`argv[optind:]`, required — errors out if empty) is collected the same way
`-r`'s own command is, sharing that mode's exemption from the "no leftover
positional args" check.
`inspect_image_command()` implements `-i/--inspect
<image.tar>`: prints every `OciImageConfig` field (user/group, exposed ports, env,
volumes, default command) without mounting or running the image — extend it
@@ -222,6 +229,43 @@ Source layout (all under `src/`):
a check and a later removal. Only files it actually removes are reported back
(as `SessionInfo`s with `running=false`); still-locked (running) files are
left untouched and not reported.
- `exec_session.{h,cpp}` — implements `-e/--exec <pid>`: joins an already-running
`-r/--run` session's namespaces via `nsenter` and runs a command inside it in
the foreground. `exec_in_session()` first confirms `pid` is a tracked, running
session via `list_sessions()` (`pid_file.h`) — same liveness check
`--list-processes`/`--clean-processes` already use, no new logic needed there.
**Key discovery, confirmed by direct testing, not assumed**: `pid` (the one
`run_process_foreground()` captured and pid-file-tracked when `-r` launched
`bwrap`) is bwrap's own *outer* process — it sets up the mount and user
namespaces itself, then `clone()`s the actual sandboxed command into fresh
pid/uts/ipc/cgroup namespaces, and `clone()`'s namespace-creation flags only
ever affect the newly created child, never the caller. So the outer process
itself never actually enters those namespaces — comparing
`/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
reads `/proc/<pid>/task/<pid>/children` (the direct-children list `procfs`
exposes) to find that real inner process and joins *its* namespaces instead —
falls back to `pid` itself (best-effort, not fatal) if that file can't be
read. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
cgroup→--cgroup, user→--user}` (`net` deliberately excluded — this project
never isolates networking, see `bwrap.cpp` below), `readlink()`s both
`/proc/<ns_pid>/ns/<type>` and `/proc/self/ns/<type>` and only passes
nsenter's corresponding `--type=/proc/<ns_pid>/ns/<type>` flag when they
differ — an identical-namespace re-entry attempt can fail outright
(`setns()`'s own `EINVAL` restriction on re-entering a namespace you're
already in), so skipping is deliberate, not just an optimization. `mnt` is the
one type where a *read* failure (permission denied, or the process vanished)
is treated as fatal, since without it "joining the container" is meaningless;
every other type just degrades to a skip. Always appends
`--preserve-credentials`: without it, `nsenter --user` also tries to
`setuid()`/`setgid()`/`setgroups()` to the target's identity within the new
user namespace, which fails outright (`setgroups failed: Operation not
permitted`) against the `setgroups`-denied unprivileged user namespace bwrap
creates whenever `-r/--run` isn't root — confirmed by hitting this exact
failure during manual testing before adding the flag. Runs the final
`nsenter ... -- <command>` via the existing `run_process_foreground()`
(`process.h`) — same inherited stdio and SIGINT/SIGTERM forwarding as every
other foreground external command, no new process-running logic needed.
- `config_file.{h,cpp}``load_config_file()` reads and parses (via libyaml's
document API, `<yaml.h>`) the `global` and `volumes` sections of the local YAML
config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
@@ -298,7 +342,7 @@ Build directory is `buildDir/` (already configured).
(see `priv_drop_helper.cpp` in "Project state")
- Run the executable: `./buildDir/slocker-lite -m <image.tar>` (see `--help` for the
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
`-l/--list-images`, `-i/--inspect`, `-n/--no-nsenter`, `--user`, `--group`,
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `--user`, `--group`,
`--hostname`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
`--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`,
`-h/--help`, `-V/--version`)