fix(memory): fall back to a writable session dir instead of losing it - #3266
Conversation
There was a problem hiding this comment.
💡 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".
…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>
|
Round 1 review handled — all three findings confirmed valid, fixed in
Two things worth calling out beyond the literal asks:
Every fix was mutation-checked: reverting it individually turns the corresponding test red ( Scope is unchanged — still Ready for re-review. |
kongche-jbw
left a comment
There was a problem hiding this comment.
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.
|
Thanks for the second pass, @kongche-jbw — all three findings confirmed and fixed in [P1] [P2] [P2] Verification:
One deliberate behaviour change to flag: a directory can only be opened read-only, so the base is opened Ball back with you and @ikunkun-sys for re-review. |
kongche-jbw
left a comment
There was a problem hiding this comment.
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.
- 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>
d259a8e to
92fac00
Compare
- 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>
92fac00 to
3341089
Compare
What
start_sessionused the configured session directory unconditionally and propagated the firstcreate_dir_allerror. The shipped default that lands there —/run/anolisa/sessions, which is also the exact value the OpenClaw plugin forwards asMEMORY_SESSION_DIRon 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
/run/anolisa/sessionsstate0700 root:rootbyconfig/systemd/anolisa-memory-tmpfiles.conf(system instance,%tmpfiles_createin%post)xfor others), cannot create<sid>make install/ container / dev box/runisdrwxr-xr-x root rootcreate_dir_allfails on the first component with EACCESsystemctl --user anolisa-memory@$USERRuntimeDirectory=anolisa/sessions/%ibut never tells the server about it, andReadOnlyPaths=/makes it unwritable from inside the namespace anywayFailure was silent:
MemoryService::newdegraded tosession = Nonebehind a singlewarn!in the child's stderr, and everymem_promotereturnedSessionUnavailablewhile everymem_session_logreturnedNotImplemented. Session-scoped consolidation had no log to read either.mem_promoteis the capability the plugin'ssessionIdpinning exists to support (seea3e641a3), so this removes the one property the user guide says a fixed session id is for.Fix
resolve_session_basetries, in order, and takes the first usable candidate:resolved_session_dir()), unchanged;$XDG_RUNTIME_DIR/anolisa/sessions— already0700and user-owned, and what the user unit'sRuntimeDirectory=creates;<$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 explicitMEMORY_SESSION_DIR, so changing only the compiled-in default would not have reached it.Two details worth calling out:
create_dir_alland then fails on the first session, so each candidate gets a real create-and-remove.0700. Without that, any local user could plant/tmp/anolisa-sessions-<victim uid>as a symlink and redirect the probe, every session root,log.jsonland themem_promotesource 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/%imakes theRuntimeDirectory=it already creates live, and%t/anolisais added toReadWritePaths(ReadOnlyPaths=/would otherwise make it read-only inside the namespace)./run/anolisais left listed for operators who pointMEMORY_SESSION_DIRback 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/anolisaabsent,MEMORY_SESSION_DIR=/run/anolisa/sessions:mem_session_logmain@69b6869aisError: true—session log unavailable; check MEMORY_SESSION_DIR / /run/anolisa permissionsWARN session log unavailable (io: Permission denied (os error 13)); mem_promote / mem_session_log will return errorsisError: 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_DIRunset the same fixed build falls through to$TMPDIR/anolisa-sessions-1359527and still answersisError: false.Suites:
cargo test --lockedcargo fmt --all --checkcargo clippy --all-targets --locked -- -D warningssystemd-analyze verify anolisa-memory@.service%t/anolisaaccepted as absoluteNew
service::session_base_tests(7): preference order, uncreatable configured dir, existing read-only dir, symlink refused for chosen dirs but honoured for configured ones,0700on 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_unavailablepinned the old behaviour withassert!(svc.session.is_none())and now fails by design. It is rewritten asunusable_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, andmem_promote+mem_session_logwork end to end through the fallback. Reverting onlysrc/service/mod.rsmakes it fail, so it reproduces the bug rather than describing it.Scope
Three files, no overlap with the in-flight
component:memoryPRs — #3228 (anolisa-coreadapter), #3238 (pluginconfig.ts/index.ts/ manifest / user guide), #3256 (Makefile), #3222 (adapterinstall.sh).Deliberately left for follow-ups:
config/systemd/anolisa-memory-tmpfiles.confstill 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.mdand the plugin'sDEFAULT_SESSION_DIR/sessionDirdescriptions still present/run/anolisa/sessionsas 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.