Commit Graph

27 Commits

Author SHA1 Message Date
ceamac 69a18924b4 Add bridge name column to --list-networks
list_networks_command() now includes each network's bridge name
(recomputed via bridge_name(), not stored -- it's already a pure
deterministic function of the network name), tab-aligned the same way
as the existing name/kind/subnet columns, positioned before the
trailing unaligned IPv6 column. Useful for correlating a network entry
with its live host-side state (`ip link show <bridge>`,
`iptables -t nat -L`) without recomputing the hash by hand.

Verified as root via the doas rule against a fresh extern and intern
network.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-30 17:15:31 +00:00
ceamac 53b859b7bf Document the tap+relay veth fallback in the design doc
Adds a dedicated section to docs/networking-design.md covering the
tap+relay fallback for veth-less kernels: the trigger (real target
device supports tun/tap but not veth), why tap can't 1:1 replace veth,
the confirmed design (two tap devices + a relay reusing the existing
bridge, replacing veth's earlier N-way-switch-daemon sketch once
bridge support was confirmed available), the four-commit
implementation sequence with what testing actually found (the fd-leak
deadlock, the reverted cgroup fix), and an honest writeup of the
unresolved gateway/outside-reachability gap.

README.md's -n/--network row now also flags that gap directly, next
to the existing NAT-hairpinning limitation note for -p.

This closes out the tap+relay fallback work for now: peer-to-peer
connectivity through it is solid and dev-verified; gateway/outside
reachability needs re-verification on the actual veth-less target
device before being relied on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-30 16:36:43 +00:00
ceamac 08895297a9 Add veth capability probe + --no-veth network flag
Prep work for a tap+userspace-relay fallback: the real target device
supports tun/tap but not veth (CONFIG_VETH stripped from its kernel),
so -n/--network joins there can't use the veth-pair mechanism as-is.

probe_veth_support() (network_bridge.{h,cpp}) detects kernel veth
support the same way bwrap.cpp's kernel_supports_namespace() probes
namespace types: fork, unshare(CLONE_NEWNET) into a throwaway
namespace, try `ip link add ... type veth ...` there. should_use_veth()
combines that with a new per-network NetworkEntry::veth policy flag
(default true, config_file.h), mirroring how namespace_policy_enabled()
already combines kernel capability with policy for --unshare-xxx.

--no-veth at network-creation time (-n <name> --extern|--intern
--no-veth) sets veth: false, forcing the not-yet-built tap+relay
fallback even on a veth-capable kernel like this dev machine -- lets
that path be exercised here without the actual veth-less hardware.

Verified as root via the doas rule: probe_veth_support() returns true
on this dev machine (a real veth pair is created successfully), and
--no-veth correctly persists veth: false while should_use_veth() still
returns false regardless of kernel support.

The fallback itself (network_tap_relay.{h,cpp}) isn't wired in yet --
a --no-veth network simply has no way to join a container until that
lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-30 15:59:40 +00:00
ceamac 7605831269 Add crash-orphan sweep for stale -p port-forward rules
Commit 6/6 (final) of the network isolation feature
(docs/networking-design.md). Landed narrower in scope than originally
planned once the actual orphan surface was worked out: veths need no
sweep at all (the kernel tears down an entire pair once either end's
namespace is destroyed, so nothing survives a crash), and bridges/
persistent namespaces are deliberately meant to always outlive any one
session (the whole point of the reboot-reconciliation design already
built in commit 3). Only -p's iptables rules are host-global state
with no automatic teardown, so that's the entire sweep.

port_forward.{h,cpp}: record_port_forwards()/remove_port_forward_record()
persist a session's active mappings to $XDG_STATE_HOME/slocker-lite/
port-forwards/<container_name>-<pid> -- the exact same naming scheme
as session_pid_file_path() (pid_file.h), so clean_stale_port_forwards()
can cross-reference filenames directly against list_sessions()'s own
liveness check rather than re-deriving it. --clean-processes
(commands.cpp) now also runs this sweep alongside its existing
stale-pid-file one.

Also fixed a real gap in commands.cpp caught while wiring this up:
on_bwrap_pid_known was only set when daemonize_flag ||
!network_specs.empty(), so a bare "-p ... " with no -n or -D would
silently never even attempt to run -- no error, nothing logged, the
whole flag just quietly did nothing.

Verified via a controlled scratch test rather than a literal kill -9
on a root-owned slocker-lite process (not achievable through this
session's scoped doas rule, which only permits running slocker-lite
itself): a fabricated stale port-forward record was correctly
detected, its rule-removal attempted, and its file cleaned up, while a
record matching a real running session was left completely untouched.
2026-08-30 13:33:47 +00:00
ceamac 5c87cac430 Add -p/--port-forward: iptables DNAT into extern-joined containers
Commit 5/6 of the network isolation feature (docs/networking-design.md).
port_forward.{h,cpp}: parse_port_forward_spec() parses
"[<network>:]<host-port>:<container-port>"; add_port_forward()
resolves the network (by name, or the container's sole extern network)
against join_networks()'s result and adds the DNAT/FORWARD rules;
remove_port_forward() undoes them. join_networks() (network_join.{h,cpp})
now returns the joined networks with their assigned IPs (was a bare
bool) so port-forward setup knows where to send traffic. -p requires
-r, is repeatable, network names may no longer contain ':' (needed to
keep the spec syntax unambiguous -- is_valid_network_name(),
network_subnet.h).

Two real corrections from testing, not assumed:
- The DNAT rule needs both nat PREROUTING and nat OUTPUT -- PREROUTING
  never sees locally-generated packets (e.g. curl run on the same
  host), only OUTPUT does. PREROUTING-only left the host's own real IP
  connection-refused despite the container being directly reachable.
- curl localhost:<port> still doesn't work even with both chains --
  a separate problem, NAT hairpinning: the container sees an inbound
  packet claiming a loopback source arriving on a non-loopback
  interface and drops it as martian. A net.ipv4.conf.*.route_localnet
  sysctl was tried and confirmed not to fix this alone, then removed
  rather than left in as dead code. Not solved here (would need scoped
  source masquerading or a userland proxy); curl <host's real IP> is
  the actually-relevant, verified-working path for real clients.

Also surfaced (unrelated to -p, found while testing it, not fixed
here): -x/--exec doesn't join the net namespace -- written when this
project never isolated networking at all -- so it currently sees the
host's own network stack instead of a network-isolated session's own.

Verified end-to-end as root (via a scoped doas rule): a container
serving HTTP on an extern network with -p 8080:80 was reachable via
curl <host's real IP>:8080; the rule was confirmed gone after the
session was killed.
2026-08-30 13:28:23 +00:00
ceamac 8cc967e748 Join -r/--run containers to networks: veth creation, IPs, routes
Commit 4/6 of the network isolation feature (docs/networking-design.md).
network_join.{h,cpp}: join_networks() waits (bounded, polling) for the
session's own isolated net namespace to exist -- bwrap's outer pid
never enters it, and the on_bwrap_pid_known callback fires before
bwrap has even started its own setup -- then per network: ensures it's
provisioned, creates a veth pair where the bridge lives, attaches the
bridge side, moves the container side into the session's namespace as
eth<N>, assigns it a free address, brings it up, and (extern only)
replaces the default route.

sandbox_process.{h,cpp}: generalized pid_namespace_isolated() into
namespace_isolated(outer_pid, ns_pid, ns_type) so this can reuse it for
"net" instead of "pid".

network_subnet.{h,cpp}: gateway-address logic generalized into
host_address(af, cidr, n) shared by the existing gateway functions
(n=1) and new ipv4/ipv6_host_address() (n=2, 3, ... for containers).

network_bridge.{h,cpp}: bridge_name()/wrap_for_network() exported so
network_join.cpp can attach to the exact bridge/namespace
network_bridge.cpp provisioned.

commands.cpp: run_container() validates network namespace isolation is
actually available before ever starting bwrap (can't be degraded the
way --hostname is), then joins networks from on_bwrap_pid_known,
before the -D/--daemonize report is sent.

Real bug caught by testing, fixed before landing: address allocation
first tried to detect in-use IPs via `ip addr show master <bridge>`,
but a container's address lives on its own interface inside its own
private namespace, invisible from the bridge's namespace -- two
concurrent containers on the same network both got 10.168.0.2. Fixed
with a flock-based per-address lease file (same technique pid_file.h's
SessionLock already uses), verified with two containers running
simultaneously getting distinct addresses.

Known, documented limitation: a very short-lived sandboxed command can
exit before the namespace-wait polling catches up (bwrap execs
straight into the target with no hook point in between namespace
creation and exec); real long-running networked services are
unaffected.

Verified end-to-end as root (via a scoped doas rule): two containers
on the same intern network got distinct addresses and could ping each
other; an intern-joined container could not reach the outside; an
extern-joined container reached the real internet through NAT; a
container joining both simultaneously got two working interfaces.
2026-08-30 13:11:28 +00:00
ceamac 24b8ddcce7 Add bridge provisioning for networks (root-only)
Commit 3/6 of the network isolation feature (docs/networking-design.md).
network_bridge.{h,cpp}: ensure_network_provisioned() stands up a
network's real bridge -- idempotent (checks `ip link show` first), so
this doubles as the reboot-reconciliation mechanism, no separate code
path. extern's bridge lives in the host's own root namespace with
net.ipv4.ip_forward + an iptables MASQUERADE rule for the subnet (+
IPv6 equivalents if enabled); intern's bridge lives inside its own
dedicated persistent namespace (persistent_netns.h) with no forwarding
or NAT at all -- a structural isolation boundary, not just a missing
rule. Bridge names are a deterministic FNV-1a hash of the network name
(not std::hash, whose value isn't guaranteed stable across a rebuild),
kept under Linux's 15-char interface name limit.

network_subnet.{h,cpp} gains ipv4_gateway_address()/
ipv6_gateway_address() (mask a CIDR to its network address, +1 for the
bridge's own ".1"). create_network_command() now calls
ensure_network_provisioned() before persisting the config entry -- a
network that fails to provision isn't saved.

Verified end-to-end as root (via a scoped doas rule): a real extern
network's bridge/gateway IPs/forwarding/NAT rule, and a real intern
network's isolated bridge with neither, both came up correctly; test
networks removed via --delete-network afterward.
2026-08-30 12:50:55 +00:00
ceamac ec09b96b56 Add -n/--network config CRUD: schema, subnet/IPv6 allocation, CLI
Commit 1/6 of the network isolation feature (docs/networking-design.md):
config-only, no host-side effects yet. Adds NetworkEntry {name, kind,
subnet, ipv6, subnet6} and a networks config-file section parallel to
volumes; network_subnet.{h,cpp} for CIDR validation, overlap checking,
and auto-allocation (10.168.<n>.0/24 / fd00:168:0:<n>::/64, paired,
--subnet/--subnet6 overrides); -n/--network create/list/delete CLI,
dual-purpose like -v/--volume (alone creates, repeatable with -r to
join -- joining isn't wired up yet, just accepted).

-n was already taken by --no-nsenter; moved that to long-option-only
(--no-nsenter), matching --kill's "rare flag, no real loss" precedent,
since --network will be far more heavily used.
2026-08-30 12:39:28 +00:00
ceamac ace71e0474 Add -w/--write-config to bootstrap a fully-populated config file
Writes every supported config option explicitly -- the six
unshare-* bools defaulted via value_or(true), log-level captured
from the actually active spdlog level (so an explicit --log-level
passed alongside -w is persisted too) -- creating the file and its
parent directory if missing, and prints its full path. Existing
values (including volumes) are preserved untouched.

Also fixes a stale README row left over from unplugging the
namespace probe out of -t/--test.
2026-08-30 09:44:03 +00:00
ceamac c70abb56a9 Make bwrap's --unshare-xxx namespaces configurable via global.unshare-*
Adds six global.unshare-{user,ipc,pid,net,uts,cgroup} config-file keys
(1/on/yes/true or 0/off/no/false, case-insensitive, default on) that
gate whether -r/--run requests each bwrap --unshare-xxx flag when the
kernel also supports it. Replaces the previous hardcoded skip of
--unshare-net, which is now policy-driven like the other five types
and defaults to enabled -- preparation for real network namespace
isolation (slirp4netns) next, on this same branch.
2026-08-30 09:15:39 +00:00
ceamac 33cd97fdc8 Make --kill long-option-only, no -k short form
Per the user's request: --kill is destructive enough (stops every process
a session started) that typo-prone brevity isn't worth it -- dropping the
short option makes it harder to invoke by mistake.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-29 10:05:23 +00:00
ceamac 28332ac5c2 Change -e/--exec's short option to -x
-e was going to collide with the shape of other single-letter destructive-
ish operational flags; -x frees up the letter and reads fine as
--exec's short form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-29 10:02:31 +00:00
ceamac 8c4afb82e8 Add -k/--kill <pid> to fully stop a running session
A plain `kill <tracked_bwrap_pid>` doesn't kill everything a container
started: on a kernel without pid namespace support, a service that
daemonizes (double-forks and detaches) before the entrypoint execs into its
main command reparents all the way to the *host's* own pid 1, completely
disconnected from the sandboxed session -- confirmed via a real session log
from the user's own Android target device, where php-fpm and caddy both
kept running as orphans after killing the tracked pid.

kill_session() (src/kill_session.{h,cpp}) picks between three
independently-named strategies per session, based on what's actually
available for it:
- kill_via_cgroup(): preferred when the session has a dedicated cgroup
  (src/session_cgroup.{h,cpp}, set up at -r/--run time in run_bwrap()'s
  on_start callback). Reaches every process the session ever started,
  daemonized/reparented or not, regardless of pid namespace support.
- kill_via_pid_namespace(): used when --unshare-pid was genuinely in effect
  for the session (src/sandbox_process.{h,cpp}, shared with exec_session.cpp,
  which already needed resolve_namespace_pid()). Relies on the kernel's own
  guarantee that killing a pid namespace's pid 1 tears down everything in it.
- kill_via_tracked_pid(): fallback, signals the tracked pid directly -- no
  worse than today's manual kill. This is what the user's real target
  device currently falls back to (no pid namespace support there).

Each strategy sends SIGTERM, waits up to a 10s grace period, then forces a
SIGKILL. Verified locally (rootless dev machine, which does support pid
namespaces): a daemonizing test session was fully cleaned up via
kill_via_pid_namespace(), including the forced-SIGKILL escalation path,
with no leftover processes, mounts, or layers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-29 09:43:12 +00:00
ceamac f904d33a11 Add -D/--daemonize to run -r/--run sessions in the background
Single-fork daemonize: the child calls setsid() itself rather than
re-enabling bwrap's own --new-session, which was previously removed
(and stays that way) because it only detaches the deeply-nested
sandboxed command, leaving bwrap/nsenter/slocker-lite itself still
attached to the original session -- not real daemonization. Calling
setsid() in slocker-lite's own forked child, before it execs into
nsenter/bwrap, detaches the whole chain at once, since exec() never
changes session membership -- confirmed via ps -o sid,pgid,tty against
a running daemonized session.

The child also ignores SIGHUP (confirmed to survive exec() into bwrap,
unlike a real handler, which exec() resets) and redirects stdin to
/dev/null and stdout/stderr to a log file under
$XDG_STATE_HOME/slocker-lite/logs/ (session_log_file_path(), new
sibling to the existing session_pid_file_path() in pid_file.{h,cpp}).
The original process blocks briefly on a pipe until the child reports
the real bwrap pid (or exits without doing so), then prints it and
exits -- keeping "pid" meaning the same thing everywhere in this
codebase (the same one --list-processes/-e/--exec already use), rather
than introducing a separate daemon-supervisor pid. run_bwrap() gained
an on_bwrap_pid_known callback (bwrap.{h,cpp}) for this, invoked
alongside the existing session-lock creation at the same instant.

The daemonized child is what runs run_container()'s entire existing
body afterward, including the unmount/cleanup that already ran once
bwrap exits -- no separate watcher/reaper process.

Testing caught a real bug before this was correct: the log file gets
renamed from its initial (daemon-pid-named) filename to the final
<container_name>-<bwrap-pid>.log once the real pid is known, but the
parent had already been told the pre-rename path and was never updated
-- fixed by re-reporting the path over the same pipe after the rename.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-23 16:41:07 +00:00
ceamac e7dac86eee Add --env/--env-file to set environment variables under -r/--run
Both flags are repeatable and share a single ordered EnvSpec list
(env_spec.{h,cpp}) so a later --env or --env-file always overrides an
earlier one for the same name, regardless of which flag set it.
--env-file reads one VAR=VALUE per line, skipping blank lines and
#-comments. resolve_env_specs()'s result is appended after
build_sandbox_env()'s own PATH/HOME/PWD/TERM defaults, letting an
explicit --env override any of them too.

Testing this surfaced a real bug in the environment-at-exec-time
mechanism from the previous change (dropping bwrap's own --clearenv):
since bwrap/nsenter are now exec'd with the same explicitly-built
environment the sandbox sees, a --env PATH=... override broke
execvp()'s ability to even locate bwrap/nsenter themselves (bare-name
PATH lookup happens in the child, using the already-overridden PATH).
Fixed by resolving both to absolute paths via find_in_path(), called
from this process's own unmodified environment before fork() --
confirmed by testing that only the sandboxed command's own lookup is
now affected by a PATH override, not bwrap/nsenter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-23 15:12:22 +00:00
ceamac 00482b6b42 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
2026-08-22 09:41:32 +00:00
ceamac d4c0e8f0a1 Add --clean-processes to remove stale session pid files
Scans the same $XDG_STATE_HOME/slocker-lite/run/ directory as
--list-processes and removes every pid file that's genuinely stale.
The liveness check and the removal happen atomically per file (the
same non-blocking flock() used to test it is held across the
remove() call itself), rather than reusing a separate earlier scan,
closing the race window where a new session could start in between.
Only files actually removed are reported, one line each; sessions
still running are left untouched and silently skipped.

Refactored list_sessions() and the new clean_stale_sessions() to
share a private open_session_file() helper for the open/read-pid/
recover-name step they both need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-22 09:16:57 +00:00
ceamac 8c55e5c288 Add --list-processes to list running -r/--run sessions
Lists every session pid file found under
$XDG_STATE_HOME/slocker-lite/run/, showing pid, container name, and
status (running or exited). Status is determined the same way any
external tool could check it: a non-blocking exclusive flock() on the
file that succeeds means it's actually stale (nothing holds it), so
the session is reported as exited in that case; the lock is always
released again immediately, never left held by the check itself.

list_sessions() (pid_file.{h,cpp}) reads the real pid from each file's
own contents rather than parsing it out of the filename, which would
be ambiguous for container names that themselves contain '-'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-22 09:05:27 +00:00
ceamac 23f380e180 Track running -r/--run sessions with a locked PID file
Each bwrap session is now recorded under
$XDG_STATE_HOME/slocker-lite/run/<container-name>-<pid> (falling back
to $HOME/.local/state/...), holding an exclusive advisory flock() for
as long as it's running -- so any tool can tell a stale leftover file
apart from a live session by attempting the same non-blocking flock().
The file is removed once the run ends, on every exit path including a
forwarded Ctrl-C.

run_process_foreground() gained an optional on_start(pid) callback,
fired right after fork() succeeds -- the only point the real bwrap pid
is knowable, since exec() (including nsenter handing off to bwrap)
never changes it. run_bwrap() uses this to create/release the session
lock. The container name comes from read_image_ref(), promoted from a
list_oci_images()-only helper to public API in oci_image.h so
run_container() can reuse the same name/tag derivation for a single
image tar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-22 08:56:12 +00:00
ceamac 9877ffe7f1 Add --hostname to set the sandbox's hostname under -r/--run
Long-option only. Threaded through run_container() into
build_bwrap_args(), which passes it as bwrap's own --hostname only
when --unshare-uts is actually among the flags being given to bwrap
(bwrap itself refuses --hostname without it) -- otherwise logs a
warning and leaves the hostname alone, since a stock Android kernel
in degraded mode may not support a UTS namespace at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-22 07:13:22 +00:00
ceamac 545762d6de Reuse -v/--volume to mount named/host volumes into -r/--run
Volume names now reject '/', which lets a -v spec used with -r be told
apart as either an existing named volume or a host directory path. -v
becomes repeatable with -r, each mounting a volume at an absolute
container path; if the host directory is empty and the image already
has content there, it's copied in first (preserving numeric
ownership/permissions/links/xattrs-ACLs, degrading gracefully with a
warning if the host filesystem doesn't support xattrs). The
existence-check and copy run through the same nsenter-wrapped
namespace bwrap itself needs, since a rootless containers-storage
mount's content isn't otherwise visible to this process at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 17:08:16 +00:00
ceamac d69b408d14 Add -i/--inspect to display an image's declared config
Prints user/group, exposed ports, env, volumes, and default command
from OciImageConfig without mounting or running the image. Extend
inspect_image_command() whenever OciImageConfig gains a new field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 13:39:29 +00:00
ceamac abf268e950 Add --delete-volume and --delete-volume-full
--delete-volume <name> removes a volume's config entry only.
--delete-volume-full <name> also recursively deletes its host
directory -- fails before touching the config if that deletion errors,
so a failed directory delete never silently drops the config entry;
warns instead of failing if the directory was already gone.

Both are long-only, following the --list-volumes precedent, and slot
into the existing multi-mode getopt dispatch the same way -v/--volume
already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 13:21:46 +00:00
ceamac afca0f7ce3 Add --list-volumes to list named volumes
Long-only (no free short letter left), lists each volume's name and
host directory, tab-aligned the same way -l/--list-images already
aligns name:tag output -- list_volumes_command() reuses that scheme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 13:15:28 +00:00
ceamac aac656be31 Add -v/--volume to create a named volume mapped to a host directory
Creates the host directory if missing (warning if it already exists,
another if it's non-empty) and records name -> directory in the config
file's new "volumes" section. Fails if the name or directory is already
used by an existing volume.

This is a distinct concept from OciImageConfig::volumes (an image's own
declared mount points, still unconsumed) -- a user-defined volume, meant
to be referenced by name once -r/--run starts actually mounting volumes.

config_file.{h,cpp} gains write_config_file(), symmetric to the existing
load_config_file(), built on libyaml's document-building/emitter API.
Rewrites the whole file each time; global.log-level round-trips
untouched alongside the new volumes section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 12:59:37 +00:00
ceamac be5a0b8202 Add a local YAML config file for persistent settings
Reads $XDG_CONFIG_HOME/slocker-lite/config.yaml (falling back to
$HOME/.config/slocker-lite/config.yaml), organized into sections. Only
the "global" section's log-level is supported for now -- other options
are one-shot flags, not standing preferences. An explicit --log-level
on the command line always overrides the config file, the same way
SPDLOG_LEVEL already does.

Uses libyaml directly (yaml_dep was already declared in meson.build but
unused). A missing config file isn't an error; unknown sections/keys
are ignored for forward-compatibility; malformed YAML syntax is a hard
error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 12:31:40 +00:00
ceamac 38fac22612 Add README.md
Human-facing overview (what/why/status, build/usage instructions, brief
architecture summary) alongside CLAUDE.md, which stays the dense
agent-facing reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-21 12:10:16 +00:00