Skip to content

fix(memory): fall back to a writable session dir instead of losing it - #3266

Merged
kongche-jbw merged 1 commit into
mainfrom
fix/AGE-7763-memory-session-dir-fallback
Sep 16, 2026
Merged

kongche-jbw merged 1 commit into
mainfrom
fix/AGE-7763-memory-session-dir-fallback

Conversation

@Forrest-ly

Copy link
Copy Markdown
Collaborator

What

start_session used the configured session directory unconditionally and propagated the first create_dir_all error. The shipped default that lands there — /run/anolisa/sessions, which is also the exact value the OpenClaw plugin forwards as MEMORY_SESSION_DIR on every spawn — is only writable by root, while the server always runs unprivileged. So on a stock install the session log never existed.

Resolve the base through a preference chain instead and take the first candidate that can be created and written to.

Why this is a bug, not a configuration preference

Install path /run/anolisa/sessions state Non-root server
RPM created 0700 root:root by config/systemd/anolisa-memory-tmpfiles.conf (system instance, %tmpfiles_create in %post) cannot traverse the parent (no x for others), cannot create <sid>
make install / container / dev box absent — no tmpfiles snippet is installed, and /run is drwxr-xr-x root root create_dir_all fails on the first component with EACCES
systemctl --user anolisa-memory@$USER as above unit declares RuntimeDirectory=anolisa/sessions/%i but never tells the server about it, and ReadOnlyPaths=/ makes it unwritable from inside the namespace anyway

Failure was silent: MemoryService::new degraded to session = None behind a single warn! in the child's stderr, and every mem_promote returned SessionUnavailable while every mem_session_log returned NotImplemented. Session-scoped consolidation had no log to read either. mem_promote is the capability the plugin's sessionId pinning exists to support (see a3e641a3), so this removes the one property the user guide says a fixed session id is for.

Fix

resolve_session_base tries, in order, and takes the first usable candidate:

  1. the configured directory (resolved_session_dir()), unchanged;
  2. $XDG_RUNTIME_DIR/anolisa/sessions — already 0700 and user-owned, and what the user unit's RuntimeDirectory= creates;
  3. <$TMPDIR|/tmp>/anolisa-sessions-<uid> — uid-suffixed so two users on one host never share a base.

A fallback is reported with warn! naming both directories; only when every candidate fails does the service degrade, and the aggregated error then lists each one with its reason. Treating the configured value as a preference rather than a requirement is what makes this work for every caller — including the plugin, which always sends an explicit MEMORY_SESSION_DIR, so changing only the compiled-in default would not have reached it.

Two details worth calling out:

  • Existence is not a usable probe. A pre-existing read-only directory passes create_dir_all and then fails on the first session, so each candidate gets a real create-and-remove.
  • Directories we pick are hardened; the operator's are not. Chosen candidates must be non-symlinks owned by us and are created 0700. Without that, any local user could plant /tmp/anolisa-sessions-<victim uid> as a symlink and redirect the probe, every session root, log.jsonl and the mem_promote source tree into a directory of their choosing. The configured directory is exempt from those two checks so a symlink or a group-shared mount there keeps working.

The user unit is fixed alongside: Environment=MEMORY_SESSION_DIR=%t/anolisa/sessions/%i makes the RuntimeDirectory= it already creates live, and %t/anolisa is added to ReadWritePaths (ReadOnlyPaths=/ would otherwise make it read-only inside the namespace). /run/anolisa is left listed for operators who point MEMORY_SESSION_DIR back at it.

Verification

Driven over stdio exactly as the plugin does (initialize → notifications/initialized → tools/call mem_session_log), non-root uid 1359527, no RPM installed, /run/anolisa absent, MEMORY_SESSION_DIR=/run/anolisa/sessions:

build mem_session_log server stderr
main @ 69b6869a isError: true — session log unavailable; check MEMORY_SESSION_DIR / /run/anolisa permissions WARN session log unavailable (io: Permission denied (os error 13)); mem_promote / mem_session_log will return errors
this branch isError: false — (session log is empty) WARN session dir /run/anolisa/sessions is not usable by uid 1359527; using /run/user/1359527/anolisa/sessions instead (set MEMORY_SESSION_DIR to override)

With XDG_RUNTIME_DIR unset the same fixed build falls through to $TMPDIR/anolisa-sessions-1359527 and still answers isError: false.

Suites:

Gate Result
cargo test --locked 353 passed / 0 failed, 16 suites (lib 191 → 198)
cargo fmt --all --check clean
cargo clippy --all-targets --locked -- -D warnings clean
systemd-analyze verify anolisa-memory@.service identical warning set before and after (only line numbers shift); %t/anolisa accepted as absolute

New service::session_base_tests (7): preference order, uncreatable configured dir, existing read-only dir, symlink refused for chosen dirs but honoured for configured ones, 0700 on a created fallback, all-candidates-failed error text, and the shape of the candidate chain.

tests/session_test.rs::session_log_degrades_gracefully_when_session_dir_unavailable pinned the old behaviour with assert!(svc.session.is_none()) and now fails by design. It is rewritten as unusable_session_dir_falls_back_instead_of_losing_the_session, which keeps the "service still builds" half of the old contract and adds: session root is outside the unusable path, and mem_promote + mem_session_log work end to end through the fallback. Reverting only src/service/mod.rs makes it fail, so it reproduces the bug rather than describing it.

Scope

Three files, no overlap with the in-flight component:memory PRs — #3228 (anolisa-core adapter), #3238 (plugin config.ts / index.ts / manifest / user guide), #3256 (Makefile), #3222 (adapter install.sh).

Deliberately left for follow-ups:

  • config/systemd/anolisa-memory-tmpfiles.conf still creates /run/anolisa{,/sessions} root-only. That is now unused rather than broken; changing its mode is a security-relevant packaging decision that should not ride along here.
  • docs/user-guide/{en,zh}/token-saving/agent-memory.md and the plugin's DEFAULT_SESSION_DIR / sessionDir descriptions still present /run/anolisa/sessions as the effective location. Both files are being edited by fix(memory): refuse expert profile at plugin boot #3238, so touching them here would only create a conflict.

Related to Multica issue AGE-7763.

@github-actions github-actions Bot added the component:memory src/memory label Sep 15, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e334264dd5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agent-memory/src/service/mod.rs Outdated
Comment thread src/agent-memory/src/service/mod.rs Outdated
Comment thread src/agent-memory/src/service/mod.rs Outdated
Forrest-ly added a commit that referenced this pull request Sep 15, 2026
…wners

Review follow-up on #3266 (chatgpt-codex-connector, 2xP1 + 1xP2). The
fallback that recovers the session log also widened what a local
neighbour could do to it:

- The probe file was written with `std::fs::write`, which follows
  symlinks and truncates the target. Its name is predictable (pid plus a
  process-wide counter) and the operator-configured base is explicitly
  allowed to be group- or world-writable, so any local user could plant
  `.anolisa-probe-<pid>-0` as a link and turn the writability check into
  a file-clobber primitive against everything the server uid can write.
  `probe_writable` now opens with `create_new` (O_CREAT|O_EXCL, mode
  0600), which refuses to open -- let alone follow -- a squatted name. A
  symlink there rejects the candidate instead of unlinking someone
  else's file; a stale regular file left by a killed server just moves on
  to the next reserved name.

- The owner check read `md.uid() != me && !me.is_root()`, so a root
  server accepted a fallback owned by anybody. `/tmp/anolisa-sessions-0`
  is a predictable name in a world-writable directory, which handed a
  local user control over every session root a root server builds there,
  including swapping a known MEMORY_SESSION_ID entry for a symlink that
  the server's own create_dir_all / chmod / metadata writes then follow.
  Ownership by the effective uid is now required, root included.

- The `0700` tightening was `let _ = set_permissions(...)` and only ran
  when the probe had to create the directory. A chmod failure was
  swallowed and the candidate used anyway, so the base could stay at its
  umask-derived mode while holding session data; a base created before
  this hardening was never tightened at all. Both now reject the
  candidate, and the mode is re-checked after the chmod because some
  FUSE and network mounts report success without applying it.

The operator-configured directory keeps its exemption from the symlink,
owner and mode rules -- those are the operator's own choices -- but not
from the O_EXCL probe.

Tests: 6 new in `session_base_tests` (359 total, all green), each pinned
by reverting the fix and watching it fail. `cargo fmt --check` and
`cargo clippy --all-targets --locked -- -D warnings` are clean.

Co-authored-by: multica-agent <github@multica.ai>
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

Round 1 review handled — all three findings confirmed valid, fixed in a13fdab5, and each thread answered in place and resolved.

Finding Fix Test that fails without it
P1 · owner check exempted root fallback_owner_is_us(owner, me) = owner == me, no root branch fallback_owner_check_has_no_root_exemption, rejects_a_foreign_owned_fallback_even_as_root
P1 · probe followed planted symlinks probe_writable opens create_new (O_CREAT|O_EXCL) mode 0600; symlink ⇒ reject candidate, stale regular file ⇒ next of PROBE_ATTEMPTS = 8 reserved names probe_refuses_to_follow_a_planted_symlink, probe_steps_over_a_stale_file_and_removes_only_its_own
P2 · chmod result swallowed tighten to 0700 after the owner check, propagate failure as a candidate rejection, re-stat and reject if still loose tightens_a_pre_existing_fallback_to_0700, leaves_an_operator_configured_dir_mode_alone

Two things worth calling out beyond the literal asks:

  • The O_EXCL probe applies to the operator-configured base too, not just the fallbacks. A group-writable MEMORY_SESSION_DIR is explicitly allowed, which is the case where a neighbour can plant the predictable probe name; only the symlink / owner / mode rules on the base itself remain the operator's business.
  • The 0700 tightening is re-verified after chmod, because some FUSE and network mounts return success without applying it — the same "silently listable base" outcome, reached with no error left to propagate.

Every fix was mutation-checked: reverting it individually turns the corresponding test red (493 != 448 for the mode case, Ok(()) where an Err was expected for the symlink case, and the predicate assertion for the owner case).

Scope is unchanged — still src/agent-memory/src/service/mod.rs, tests/session_test.rs and the systemd user unit. Verified on the branch: cargo test --locked 359 passed / 0 failed (6 new), cargo fmt --all -- --check clean, cargo clippy --all-targets --locked -- -D warnings clean.

Ready for re-review.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full merge-base-to-head diff, the e334264 -> a13fdab follow-up, affected callers, tests, and all existing review threads and replies. The three previously reported issues have direct fixes, but three additional findings remain below. All three were already present in e334264; they are gaps in the previous review, not regressions introduced by a13fdab.

Baseline: declared base 2e1ee65; merge base 69b6869; reviewed head a13fdab. The current agent-memory CI check passed.

Validation was primarily static. git diff --check passed. A bounded Python simulation in a private temporary directory confirmed the filesystem-operation interleaving described in the first finding; this was not a cross-user exploit or an end-to-end execution of the Rust server. No services, namespaces, mounts, or network/security configuration changes were performed. The temporary directory was removed and its absence verified.

Comment thread src/agent-memory/src/service/mod.rs Outdated
Comment thread src/agent-memory/src/service/mod.rs Outdated
Comment thread src/agent-memory/tests/session_test.rs Outdated
@Forrest-ly

Copy link
Copy Markdown
Collaborator Author

Thanks for the second pass, @kongche-jbw — all three findings confirmed and fixed in d259a8e2. Each thread has the detail; the short version:

[P1] service/mod.rs — validation and use are now bound to one descriptor. probe_session_base resolves the candidate exactly once (O_RDONLY|O_DIRECTORY|O_CLOEXEC[|O_NOFOLLOW]) and does every check with fstat/fchmod on that descriptor; the probe uses openat/unlinkat/fstatat. The winner is returned as a new SessionBase (src/agent-memory/src/session/base.rs) that owns the descriptor and hands out /proc/self/fd/<n> — the kernel resolves that magic symlink to the descriptor's inode rather than re-walking the pathname, so the rename-then-symlink swap you reproduced cannot redirect the session any more. SessionLogService::start_in keeps the handle alive next to the paths derived from it, and creates <sid> with mkdirat + an AT_SYMLINK_NOFOLLOW verification. New display_root() carries the operator-facing pathname for logs.

[P2] service/mod.rs — the tmp suffix uses the host uid. New crate::host captures the launching uid into a OnceLock before any unshare (from main, from LinuxUserNsMount::enter, and from the top of MemoryService::new), and falls back to recovering it from /proc/self/uid_map when nothing was captured. The ownership check deliberately keeps using the current uid, since that is the namespace st_uid is reported in — the split is now "host uid for the name, namespace uid for the metadata".

[P2] tests/session_test.rs — the fallback case runs in an isolated child. Private XDG_RUNTIME_DIR, private TMPDIR, test-owned MEMORY_SESSION_ID, everything else passed in as ANOLISA_TEST_*. The parent plants a decoy live session in the fallback base and asserts it survives the child's cleanup, that the child's own session is gone, and that the tmp fallback was never needed. The child must print a completion marker, so a child that matched no test cannot pass by exiting 0.

Verification:

check result
cargo fmt --all --check clean
cargo clippy --all-targets -- -D warnings clean
cargo test --all-targets 368 passed, 0 failed (lib 213, up from 208)
agent-memory serve, non-root, inside userns, XDG_RUNTIME_DIR unset, MEMORY_SESSION_DIR=/run/anolisa/sessions falls back to <$TMPDIR>/anolisa-sessions-1359527 (host uid, not 0), base 0700, session tree written, log reports the operator-facing path

One deliberate behaviour change to flag: a directory can only be opened read-only, so the base is opened O_RDONLY|O_DIRECTORY. A session base the server cannot even list is now rejected where it previously might have scraped through; write access — the property that actually matters — is still proven by the O_EXCL probe.

Ball back with you and @ikunkun-sys for re-review.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Re-reviewed a13fdab -> d259a8e, including the author replies, affected callers, descriptor lifetime, and session cleanup. The three findings from my previous review are addressed:

  • Fallback validation, probing, and subsequent session operations stay anchored to the validated directory descriptor.
  • Temporary-directory naming uses the UID captured before namespace entry, while ownership validation uses the current namespace's UID.
  • The fallback integration test runs in a child process with test-owned runtime/tmp directories and a pinned test session ID, and verifies that a neighbouring session survives cleanup.

No new actionable findings in this revision. git diff --check passed, and a bounded local filesystem simulation confirmed that probing, writes, and cleanup remain in the original directory after its pathname is replaced. The current agent-memory CI check passed. Local validation was primarily static; I did not run the full Rust suite or namespace/mount tests. The documented requirement for usable /proc/self/fd and a readable session base was considered in this review.

kongche-jbw pushed a commit that referenced this pull request Sep 16, 2026
- Preserve writable session fallback and descriptor-based validation.
- Retain host UID naming and isolated fallback regression coverage.
- Preserve the original author messages below in chronological order.

fix(memory): fall back to a writable session dir instead of losing it

`start_session` used `config.resolved_session_dir()` unconditionally and
propagated the first `create_dir_all` error, so `MemoryService::new`
degraded to `session = None` behind a single `warn!`. The shipped default
that lands there — `/run/anolisa/sessions`, and the exact value the
OpenClaw plugin forwards as `MEMORY_SESSION_DIR` on every spawn — is only
writable by root, while the server always runs unprivileged:

- the RPM's `config/systemd/anolisa-memory-tmpfiles.conf` creates
  `/run/anolisa` and `/run/anolisa/sessions` `0700 root:root`, so a
  non-root server can neither traverse the parent nor create `<sid>`;
- `make install`, containers and dev boxes ship no tmpfiles snippet, and
  `/run` itself is `drwxr-xr-x root root`, so even the first
  `create_dir_all` component fails with EACCES;
- the shipped unit is a *user* template, and MCP clients spawn
  `agent-memory serve` directly as the logged-in user.

So on a stock install the session log never existed: `mem_promote` always
returned `SessionUnavailable`, `mem_session_log` always `NotImplemented`,
and session-scoped consolidation had no log to read. `mem_promote` is the
capability the plugin's `sessionId` pinning exists to support, which makes
this the one property the user guide flags as needing a fixed session id.

Resolve the base through a preference chain instead and take the first
candidate that can be created *and* written to: the configured directory,
then `$XDG_RUNTIME_DIR/anolisa/sessions` (already 0700 and user-owned),
then `<$TMPDIR|/tmp>/anolisa-sessions-<uid>`. A fallback is reported with
`warn!` naming both directories; only when every candidate fails does the
service degrade, and the aggregated error then lists each one with its
reason. Treating the configured value as a preference rather than a
requirement is what makes the fix work for every caller — including the
plugin, which always sends an explicit `MEMORY_SESSION_DIR`.

Existence alone is not a usable probe: a pre-existing read-only directory
passes `create_dir_all` and fails on the first session, so each candidate
gets a real create-and-remove. Directories *we* picked are additionally
required to be non-symlinks owned by us and are created 0700 — otherwise
any local user could plant `/tmp/anolisa-sessions-<victim uid>` as a
symlink and redirect the probe, every session root, `log.jsonl` and the
`mem_promote` source tree into a directory of their choosing. The
operator-configured directory is exempt from those two checks so a symlink
or a group-shared mount there keeps working.

The user unit declared `RuntimeDirectory=anolisa/sessions/%i` but never
told the server about it, and `ReadOnlyPaths=/` made it unwritable from
inside the unit's namespace anyway — dead config on both counts. Point
`MEMORY_SESSION_DIR` at `%t/anolisa/sessions/%i` and add `%t/anolisa` to
`ReadWritePaths` so the directory systemd creates is the one used.

Verified on this host (non-root, no RPM, `/run/anolisa` absent) by driving
`agent-memory serve` over stdio exactly as the plugin does, with
`MEMORY_SESSION_DIR=/run/anolisa/sessions`:

| build | `mem_session_log` | server stderr |
| --- | --- | --- |
| `main` @ `69b6869a` | `isError: true`, "session log unavailable; check MEMORY_SESSION_DIR / /run/anolisa permissions" | `WARN session log unavailable (io: Permission denied (os error 13))` |
| this branch | `isError: false`, "(session log is empty)" | `WARN session dir /run/anolisa/sessions is not usable by uid 1359527; using /run/user/1359527/anolisa/sessions instead` |

With `XDG_RUNTIME_DIR` unset the same fixed build falls through to
`$TMPDIR/anolisa-sessions-1359527` and still answers `isError: false`.

- `cargo test --locked`: 353 passed / 0 failed across 16 suites (lib 191 ->
  198). New `service::session_base_tests` cover preference order, an
  uncreatable configured dir, a read-only dir, symlink refusal for chosen
  dirs vs acceptance for configured ones, 0700 on a created fallback, and
  the all-candidates-failed error text.
- `tests/session_test.rs`: `session_log_degrades_gracefully_when_session_dir_
  unavailable` pinned the old behaviour (`assert!(svc.session.is_none())`)
  and now fails by design; rewritten as
  `unusable_session_dir_falls_back_instead_of_losing_the_session`, which
  asserts the service still builds, the session root is outside the
  unusable path, and `mem_promote` + `mem_session_log` work through it.
  Reverting only `src/service/mod.rs` makes it fail, so it reproduces the
  bug rather than describing it.
- `cargo fmt --all --check`: clean. `cargo clippy --all-targets --locked
  -- -D warnings`: clean. `systemd-analyze verify` on the unit reports the
  same warnings before and after (the pre-existing `~/.anolisa` in
  `ReadWritePaths=` is left alone).

Left for follow-ups, deliberately: the tmpfiles snippet still creates
`/run/anolisa{,/sessions}` root-only, which is now unused rather than
broken — changing its mode is a security-relevant packaging decision; and
`docs/user-guide/*/token-saving/agent-memory.md` plus the plugin's
`DEFAULT_SESSION_DIR` still document `/run/anolisa/sessions` as the
effective location. Both files are being edited by #3238, so touching them
here would only create a conflict.

Co-authored-by: multica-agent <github@multica.ai>

Fixes: d33e450 ("feat(memory): introduce agent-memory MCP server v0.1.0")
Assisted-by: Codex:0.154.0
Signed-off-by: 林生 <linyan.lin@alibaba-inc.com>
Co-authored-by: multica-agent <github@multica.ai>

fix(memory): harden the session-dir probe against planted names and owners

Review follow-up on #3266 (chatgpt-codex-connector, 2xP1 + 1xP2). The
fallback that recovers the session log also widened what a local
neighbour could do to it:

- The probe file was written with `std::fs::write`, which follows
  symlinks and truncates the target. Its name is predictable (pid plus a
  process-wide counter) and the operator-configured base is explicitly
  allowed to be group- or world-writable, so any local user could plant
  `.anolisa-probe-<pid>-0` as a link and turn the writability check into
  a file-clobber primitive against everything the server uid can write.
  `probe_writable` now opens with `create_new` (O_CREAT|O_EXCL, mode
  0600), which refuses to open -- let alone follow -- a squatted name. A
  symlink there rejects the candidate instead of unlinking someone
  else's file; a stale regular file left by a killed server just moves on
  to the next reserved name.

- The owner check read `md.uid() != me && !me.is_root()`, so a root
  server accepted a fallback owned by anybody. `/tmp/anolisa-sessions-0`
  is a predictable name in a world-writable directory, which handed a
  local user control over every session root a root server builds there,
  including swapping a known MEMORY_SESSION_ID entry for a symlink that
  the server's own create_dir_all / chmod / metadata writes then follow.
  Ownership by the effective uid is now required, root included.

- The `0700` tightening was `let _ = set_permissions(...)` and only ran
  when the probe had to create the directory. A chmod failure was
  swallowed and the candidate used anyway, so the base could stay at its
  umask-derived mode while holding session data; a base created before
  this hardening was never tightened at all. Both now reject the
  candidate, and the mode is re-checked after the chmod because some
  FUSE and network mounts report success without applying it.

The operator-configured directory keeps its exemption from the symlink,
owner and mode rules -- those are the operator's own choices -- but not
from the O_EXCL probe.

Tests: 6 new in `session_base_tests` (359 total, all green), each pinned
by reverting the fix and watching it fail. `cargo fmt --check` and
`cargo clippy --all-targets --locked -- -D warnings` are clean.

Co-authored-by: multica-agent <github@multica.ai>

fix(memory): anchor the session base to one descriptor, name it by host uid

Second-round review (kongche-jbw, review 5220543745) found three problems
that predate `a13fdab5` — they were in `e334264d` and the first review
round missed them. All three are fixed here.

[P1] `probe_session_base` looked at the pathname twice: `symlink_metadata`
to reject a symlink, then `metadata` for the ownership and mode checks.
Each call resolves the name from scratch, so a local user could pre-create
the predictable `/tmp/anolisa-sessions-<uid>`, let the first lookup see an
ordinary directory, rename it away, and leave a symlink to a directory the
server already owns. The second lookup followed it, both checks passed
against the wrong inode, and `SessionLogService::start` went on to build
the session under the redirect target. `create_new(true)` on the probe name
never protected the directory components.

Validation is now a single `open(O_RDONLY|O_DIRECTORY|O_CLOEXEC[|O_NOFOLLOW])`
and every check is an `fstat`/`fchmod` on the descriptor it returned; the
probe uses `openat`/`unlinkat`/`fstatat` against that descriptor. The
winner is returned as a new `SessionBase` that owns the descriptor and
hands out `/proc/self/fd/<n>` as the path to use — the kernel resolves that
magic symlink to the descriptor's inode rather than re-walking the
pathname, so a later rename or swap cannot redirect anything built through
it. `<sid>` itself is created with `mkdirat` on the descriptor and then
verified with `AT_SYMLINK_NOFOLLOW`, so a planted name cannot smuggle in a
symlink either. `SessionLogService` keeps the `SessionBase` alive next to
the paths derived from it, and gains `display_root()` for the operator-
facing pathname used in logs.

[P2] The tmp fallback suffix read `geteuid()` after `main::early_enter_userns`
had installed the mapping `0 <host uid> 1`, so every user on the box
computed `/tmp/anolisa-sessions-0` whenever the configured base was
unusable, `XDG_RUNTIME_DIR` was absent and `TMPDIR` was unset or shared.
Whoever created it first then owned a directory every other user's
namespace reports as foreign-owned, so the ownership check rejected the
last candidate and they lost the session anyway. New `crate::host` records
the launching uid before any `unshare` (in `main`, in
`LinuxUserNsMount::enter`, and at the top of `MemoryService::new`) and
recovers it from `/proc/self/uid_map` when nothing was captured. The suffix
uses that host uid; the ownership check deliberately keeps using the
current one, because that is the namespace `st_uid` is reported in.

[P2] The `unusable_session_dir_falls_back_...` integration test inherited
the real `XDG_RUNTIME_DIR`/`TMPDIR` and the inherited `MEMORY_SESSION_ID`,
so it probed — and could chmod 0700 — the user's real fallback base, and,
if that id already had a session there, reopened it, wrote scratch into it
and let cleanup recursively delete the lot. It now runs in a child process
with a test-owned runtime dir, tmp dir and session id, and the parent
plants a decoy session next to it to prove cleanup cannot reach a
neighbour.

Verified: `cargo fmt --all --check`, `cargo clippy --all-targets --
-D warnings`, `cargo test --all-targets` (368 passed) all clean, plus an
end-to-end `agent-memory serve` as a non-root user inside the userns with
`MEMORY_SESSION_DIR=/run/anolisa/sessions` — the fallback is named after
the host uid (1359527, not 0), the base is created 0700, and the session
log line reports the operator-facing path.

Related to AGE-7969

Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: kongche-jbw <kongche.jbw@alibaba-inc.com>
@kongche-jbw
kongche-jbw force-pushed the fix/AGE-7763-memory-session-dir-fallback branch from d259a8e to 92fac00 Compare September 16, 2026 10:00
- Fall back to a writable per-user session directory.
- Anchor session paths to validated descriptors and use the host UID.
- Isolate fallback tests and configure the systemd runtime path.

Fixes: d33e450 ("feat(memory): introduce agent-memory MCP server v0.1.0")
Signed-off-by: kongche-jbw <kongche.jbw@alibaba-inc.com>
@kongche-jbw
kongche-jbw force-pushed the fix/AGE-7763-memory-session-dir-fallback branch from 92fac00 to 3341089 Compare September 16, 2026 10:02
@kongche-jbw
kongche-jbw merged commit ca7f870 into main Sep 16, 2026
21 checks passed
@kongche-jbw
kongche-jbw deleted the fix/AGE-7763-memory-session-dir-fallback branch September 19, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants