Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/agent-memory/config/systemd/anolisa-memory@.service
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ Environment=MEMORY_MOUNT_STRATEGY=auto
Environment=USER_ID=%i
RuntimeDirectory=anolisa/sessions/%i
RuntimeDirectoryMode=0700
# Point the server at the runtime directory the two lines above create.
# Without this it used the compiled-in default /run/anolisa/sessions, which
# the RPM's tmpfiles.d snippet creates 0700 root:root and this *user* unit
# can therefore neither traverse nor write — so the RuntimeDirectory= was
# dead config and the session log (mem_promote / mem_session_log) was lost.
# %t is $XDG_RUNTIME_DIR for a user unit.
Environment=MEMORY_SESSION_DIR=%t/anolisa/sessions/%i

# user namespace + mount namespace are both unprivileged, so no
# AmbientCapabilities= are required. ProtectSystem/Home are off because the
Expand Down Expand Up @@ -45,12 +52,18 @@ RestrictNamespaces=user mnt
# to our own scope.

# Read-only root filesystem with explicit write paths for the memory
# store (~/.anolisa), session scratch (/run/anolisa), and cgroupfs
# (the Delegate=memory subtree below requires cgroup.subtree_control
# and memory.max writes; ReadOnlyPaths=/ would otherwise mount
# /sys/fs/cgroup read-only inside our namespace and EROFS those writes).
# store (~/.anolisa), session scratch (the per-user runtime dir created by
# RuntimeDirectory= above, plus the legacy /run/anolisa for operators who
# point MEMORY_SESSION_DIR back at it), and cgroupfs (the Delegate=memory
# subtree below requires cgroup.subtree_control and memory.max writes;
# ReadOnlyPaths=/ would otherwise mount /sys/fs/cgroup read-only inside our
# namespace and EROFS those writes).
#
# %t/anolisa has to be listed explicitly: ReadOnlyPaths=/ makes the whole
# tree read-only inside the unit's namespace, so a RuntimeDirectory= that
# systemd creates outside it is still unwritable from inside.
ReadOnlyPaths=/
ReadWritePaths=~/.anolisa /run/anolisa /sys/fs/cgroup
ReadWritePaths=~/.anolisa /run/anolisa %t/anolisa /sys/fs/cgroup

# P6.4: delegate cgroup controllers so [memory.cgroup].enabled=true can
# create a child cgroup under our scope and apply memory.max. Without
Expand Down
105 changes: 105 additions & 0 deletions src/agent-memory/src/host.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Host identity, captured before the process switches namespaces.
//!
//! `main` enters the user namespace before it constructs anything else, and
//! the default unprivileged mapping is `0 <real uid> 1`. From that point on
//! `geteuid()` reports 0 for *every* user on the box, so anything that has
//! to tell one host user from another — a per-user directory name, say —
//! must use the uid recorded here rather than the one the kernel reports
//! now.
//!
//! The converse matters just as much: anything that has to match filesystem
//! metadata (`st_uid`) must keep using the *current* uid, because that is
//! the namespace the metadata is reported in. Inside our own user
//! namespace a directory we own on `/tmp` shows up as uid 0, and one a
//! neighbour owns shows up as the overflow uid — comparing either of those
//! against the host uid would be wrong.

use std::sync::OnceLock;

/// The uid this process was launched with. Set once, before any `unshare`.
static HOST_UID: OnceLock<u32> = OnceLock::new();

/// Record the calling uid while it is still the host uid.
///
/// Idempotent — the first call wins — so it is safe (and intended) to call
/// it both from `main`, before `early_enter_userns`, and from
/// `LinuxUserNsMount::enter`, before the `unshare` itself.
pub fn capture_host_uid() -> u32 {
*HOST_UID.get_or_init(|| nix::unistd::Uid::current().as_raw())
}

/// The uid this process was launched with, even after `unshare(CLONE_NEWUSER)`.
///
/// When nothing was captured — a library consumer that built `MemoryService`
/// without going through `main` — the answer is recovered from
/// `/proc/self/uid_map` instead, so a namespaced process still gets a
/// per-host-user value rather than the 0 every one of its neighbours shares.
pub fn host_uid() -> u32 {
if let Some(uid) = HOST_UID.get() {
return *uid;
}
match std::fs::read_to_string("/proc/self/uid_map") {
Ok(map) => host_uid_from_uid_map(nix::unistd::Uid::current().as_raw(), &map)
.unwrap_or_else(capture_host_uid),
Err(_) => capture_host_uid(),
}
}

/// Map `current`, a uid in this process's user namespace, back to the host
/// uid using the contents of `/proc/self/uid_map`.
///
/// Each line is `<inside> <outside> <count>`. Returns `None` when `current`
/// is not covered by any mapping (an unmapped uid, reported by the kernel as
/// the overflow id), in which case there is no host uid to recover.
fn host_uid_from_uid_map(current: u32, uid_map: &str) -> Option<u32> {
uid_map.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let inside = fields.next()?.parse::<u32>().ok()?;
let outside = fields.next()?.parse::<u32>().ok()?;
let count = fields.next()?.parse::<u32>().ok()?;
let offset = current.checked_sub(inside)?;
(offset < count).then_some(outside + offset)
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn recovers_the_host_uid_from_a_single_id_mapping() {
// `LinuxUserNsMount::enter` writes exactly this: inside-0 is the
// launching user, and every other host uid is unmapped.
let map = " 0 1000 1\n";
assert_eq!(host_uid_from_uid_map(0, map), Some(1000));
assert_eq!(
host_uid_from_uid_map(1, map),
None,
"an unmapped uid has no host uid"
);
}

#[test]
fn is_the_identity_outside_a_user_namespace() {
let map = " 0 0 4294967295\n";
assert_eq!(host_uid_from_uid_map(1000, map), Some(1000));
assert_eq!(host_uid_from_uid_map(0, map), Some(0));
}

#[test]
fn handles_a_multi_range_map_and_ignores_junk() {
let map = "garbage\n0 1000 10\n10 2000 5\n";
assert_eq!(host_uid_from_uid_map(0, map), Some(1000));
assert_eq!(host_uid_from_uid_map(9, map), Some(1009));
assert_eq!(host_uid_from_uid_map(12, map), Some(2002));
assert_eq!(host_uid_from_uid_map(15, map), None);
}

#[test]
fn host_uid_never_reports_the_namespace_uid_after_capture() {
// Whatever namespace we happen to be in, the captured value is the
// one callers get; this is the property the tmp-dir suffix relies on.
let captured = capture_host_uid();
assert_eq!(host_uid(), captured);
}
}
1 change: 1 addition & 0 deletions src/agent-memory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod consolidation;
pub mod embedding;
pub mod error;
pub mod git_repo;
pub mod host;
pub mod index;
pub mod mcp_server;
pub mod mount;
Expand Down
8 changes: 7 additions & 1 deletion src/agent-memory/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ enum Commands {
fn main() -> Result<()> {
let cli = Cli::parse();

// Before anything below can change the answer: `early_enter_userns`
// maps our uid to 0, and the per-user session fallback has to be named
// after the *host* uid or every user on the box collides on
// `/tmp/anolisa-sessions-0`.
agent_memory::host::capture_host_uid();

tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
Expand Down Expand Up @@ -130,7 +136,7 @@ async fn run_mcp_server(config: AppConfig) -> Result<()> {
let svc = Arc::new(MemoryService::new(config)?);
tracing::info!("mount: {}", svc.mount.root.display());
if let Some(s) = &svc.session {
tracing::info!("session: {} ({})", s.sid(), s.root().display());
tracing::info!("session: {} ({})", s.sid(), s.display_root().display());
}

let server = MemoryMcpServer::new(Arc::clone(&svc));
Expand Down
7 changes: 7 additions & 0 deletions src/agent-memory/src/mount/linux_userns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ impl LinuxUserNsMount {
// running under userland (which would silently produce wrong
// ownership / permissions on every home-dir syscall).
if !UNSHARED.load(Ordering::Acquire) {
// Record the launching uid *before* the unshare below makes
// `geteuid()` report the mapped one, so the per-user session
// fallback name can still tell host users apart. `main` already
// did this; it is repeated here because `enter` is also reached
// from `MemoryService::new` in library consumers.
crate::host::capture_host_uid();

let real_uid = geteuid().as_raw();
let real_gid = getegid().as_raw();

Expand Down
Loading
Loading