Merge remote-tracking branch 'refs/remotes/pr/1023' into resolution/main-2026-10-01

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AkitaOnRails
2026-10-01 12:14:06 -03:00
7 changed files with 154 additions and 9 deletions
+4
View File
@@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
whether the TOML parses), so it also catches a `[capture]` table that
parses fine but is still rejected at compile time, e.g. an unsupported glob
character in `ignore_paths`. (#1021)
- Fixed the shell and PowerShell hook scripts mishandling a `$HOME` that ends
in a separator: the shell walks missed a marker between the checkout and
home, and the PowerShell walks went past home and read a marker above it.
Both now match the native `ai-memory hook` walk. (#1023)
## [2.5.2] - 2026-10-01
+26
View File
@@ -484,6 +484,32 @@ mod tests {
assert!(outer_marker.exists());
}
/// A `$HOME` ending in a separator is the same boundary. The hook scripts
/// mirror this.
#[test]
fn a_trailing_separator_on_home_keeps_the_boundary() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let repo = home.join("org").join("repo");
let plain = home.join("plain");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::create_dir_all(&plain).unwrap();
let org_marker = write_marker(&home.join("org"), "workspace = \"org\"\n");
write_marker(tmp.path(), "workspace = \"above\"\nserver = \"x\"\n");
let slashed = PathBuf::from(format!("{}/", home.display()));
assert_eq!(
find_marker_with_home(repo.to_str().unwrap(), Some(&slashed)),
Some(org_marker)
);
assert_eq!(
find_marker_with_home(plain.to_str().unwrap(), Some(&slashed)),
None,
"a marker above home must not leak in"
);
assert!(find_server_selection(plain.to_str().unwrap(), Some(&slashed)).is_none());
}
#[test]
fn marker_walk_outside_home_checks_only_cwd_without_a_checkout() {
let tmp = TempDir::new().unwrap();
@@ -53,3 +53,74 @@ fn marker_lookup_does_not_assign_to_powershell_home() {
);
assert_eq!(output.stdout, b"ok");
}
/// Per case: the marker found from `org\repo` (`-` for none), whether `plain`
/// is routed, then `|`. The cases are `$HOME` as-is, with a trailing `\`, and
/// with a trailing `/`, then a control whose home is the fixture root.
const TRAILING_SEPARATOR_PROGRAM: &str = r#". '__HELPER__'; $Error.Clear()
$r = $env:AI_MEMORY_TEST_ROOT
$h = Join-Path $r 'home'
$repo = Join-Path (Join-Path $h 'org') 'repo'
$plain = Join-Path $h 'plain'
$out = foreach ($hm in @($h, ($h + '\'), ($h + '/'), $r)) {
$env:HOME = $hm; $env:USERPROFILE = $hm
$marker = Get-AiMemoryMarkerToml -Cwd $repo
$found = if ($marker) { Split-Path (Split-Path $marker -Parent) -Leaf } else { '-' }
$routed = if (Test-AiMemoryServerRouted -Cwd $plain) { 'T' } else { 'F' }
"$found,$routed|"
}
if ($Error.Count -ne 0) { [Console]::Error.Write(($Error | Out-String)); exit 17 }
[Console]::Out.Write(($out -join ''))
"#;
/// A trailing separator on `$HOME` must not move the walk boundary: a marker
/// above home stays out of reach, and its `server` key routes nothing.
#[test]
fn a_trailing_separator_on_home_keeps_the_walk_boundary() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let write = |relative: &str, bytes: &[u8]| {
let path = root.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
};
// Above home: out of reach unless the control widens home to the root.
write(
".ai-memory.toml",
b"workspace = \"above\"\nserver = \"x\"\n",
);
// Inside home, above the checkout root: always in reach.
write("home/org/.ai-memory.toml", b"workspace = \"org\"\n");
std::fs::create_dir_all(root.join("home/org/repo/.git")).unwrap();
std::fs::create_dir_all(root.join("home/plain")).unwrap();
let helper = repo_root()
.join("hooks")
.join("lib")
.join("ai-memory-hook.ps1");
let helper = helper.to_string_lossy().replace('\'', "''");
let program = TRAILING_SEPARATOR_PROGRAM.replace("__HELPER__", &helper);
let output = Command::new(ai_memory_test_support::powershell_exe())
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
&program,
])
.env("AI_MEMORY_TEST_ROOT", root)
.output()
.expect("run PowerShell marker walks");
assert!(
output.status.success(),
"PowerShell marker walks failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"org,F|org,F|org,F|org,T|",
"home as-is, trailing `\\`, trailing `/`, then the root-home control"
);
}
+1 -1
View File
@@ -54,7 +54,7 @@ boundary not yet built.
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded` | STRONG |
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`; argv elements matched whole and tokenized); an invalid marker makes file and shell calls metadata-only, and the server admits a metadata-only shell body only under an invalid marker (`router.rs` `metadata_protocol_is_legal`); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`). Candidate/argument flavor (`Flavor::Posix` vs `Flavor::Windows`, used to pick which `ignore_paths` patterns even apply) is derived from the **host** (the cwd), never from the candidate string alone — a POSIX-host candidate spelled with a leading `//` is collapsed to a single `/` before flavor detection (`normalize_candidate`; TS `captureNormalize`'s `windowsHost` parameter, sourced from `captureHostWindows(cwd)`), fixing GHSA-vh98 (a `//`-prefixed candidate used to self-classify as `Flavor::Windows` regardless of host, matching zero POSIX patterns and being captured instead of dropped); a genuine Windows/UNC host (cwd itself windows-flavored) is unaffected | `capture_policy.rs` per-agent `…honors_exclusions` tests, `shell_fixture_vectors` (shared `capture-policy.json` `shell` drop/keep vectors: tool aliases incl. OpenClaw/Devin `exec`, `workdir`, glob directories, Windows paths, and a POSIX-host leading-`//` command via `/{root}/docs/adr/x.md`), `shell_tool_shapes_of_every_adapter_honor_exclusions`, `fixture_vectors` (incl. TS-adapter `bash`/`exec` vectors, and `normalization`'s leading-`//` POSIX vectors alongside the kept Windows-cwd UNC vectors as the no-regression control); `shell_matching_is_off_when_inactive_and_fails_closed_when_invalid_or_over_budget`; `router.rs` `capture_protocol_shell_decisions_survive_server_reinspection`, `capture_protocol_invalid_marker_shell_is_metadata_only` (active metadata-only shell refused, older client's invalid-marker keep stripped, commandless control kept), `capture_protocol_unparseable_marker_strips_shell_events` (server fallback for an unparseable marker), `capture_protocol_invalid_shell_metadata_claim_must_be_canonical` (a stripped shell claim with a path count or non-`extracted` state is refused); `capture_policy.rs` `invalid_marker_strips_shell_calls_with_unparseable_commands`, `long_bash_lc_script_in_argv_is_not_dropped_by_the_match_budget`, **`a_leading_double_slash_candidate_does_not_escape_posix_ignore_paths_via_flavor_mismatch`** (GHSA-vh98 adversarial: attempts the `//repo/secret/...` violation on a POSIX host, proves it now drops, with a plain-single-slash control and a Windows-hosted genuine-UNC control both still correct — fails on the pre-fix code); `hook.rs` `shell_command_reading_an_ignored_path_is_dropped_before_spool`; `render_shared.rs` `generated_capture_policy_v1_node_runtime_evidence` (runs the same fixture sections, including the GHSA-vh98 vectors, against the emitted TypeScript; `#[ignore]` locally, run with `--ignored` under Node 24 by the Linux CI test job) | STRONG |
| 11c | Capture hook ≤200ms budget (invariant #5) | `hooks/_lib.sh` capture path `curl --max-time 0.2` (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) | none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH |
| 11d | Hook server-profile routing: a marker-selected server gets only its own capture and its own token (#992) | `ai-memory-cli/src/server_profiles.rs` `resolve` (validated `ProfileName`, strict `servers.toml` parse, `roots` required once two profiles exist, component-wise root match) and `marker.rs` `find_server_selection` (inherited down the tree, any value shape counts); `commands/hook.rs` `resolve_hook_route` drops a `Rejected` route before spool, handoff and backfill, and hands the drainer no live token for a profile route; `commands/hook_spool.rs` `static_retry_token` (a profile entry retries only with its own stored token), the loopback reroot skip, and chunk splitting on `profile`; generated TS `captureServerRouted` drops a routed repository and gates `fetchHandoff`; `hooks/_lib.sh` `ai_memory_server_routed` (flag refused by `ai_memory_post_hook`/`ai_memory_get_handoff`) and `hooks/lib/ai-memory-hook.ps1` `Test-AiMemoryServerRouted` drop it in the script hooks | `hook.rs` `each_repository_spools_to_its_own_profile_with_its_own_token`, `a_selection_that_does_not_resolve_emits_nothing` (unknown / tokenless / outside roots / roots required / invalid name, plus a resolving control), `session_start_handoff_comes_from_the_profile_server_only`, `a_repository_without_a_server_key_keeps_the_install_default`; `hook_spool.rs` `a_profile_entry_is_never_retried_with_the_install_live_token` (server B accepts exactly the install's live token and must still not get it), `a_profile_entry_recovers_with_its_own_rotated_token` (control), `a_profile_entry_on_a_dead_loopback_port_is_not_rerouted_to_the_default`, `profile_and_default_entries_at_one_address_ride_separate_batches`; `server_profiles.rs` roots/registry/name tests; `marker.rs` `nested_markers_without_server_inherit_the_ancestor_selection`; `install_hooks.rs` `generated_integrations_fail_closed_on_a_server_profile_marker`, `openclaw_plugin.rs` `openclaw_plugin_fails_closed_on_a_server_profile_marker`, and the `server-routed-*` checks in `generated_capture_policy_v1_node_runtime_evidence`; `hook.rs` `an_event_without_a_payload_cwd_routes_by_the_process_cwd`, `a_refused_route_prints_nothing_for_kimi_user_prompts`; `hook_spool.rs` `a_profile_entry_is_not_retried_with_a_token_issued_for_a_new_url`; `server_profiles.rs` `changing_the_url_without_a_token_discards_the_old_token`, `omitted_roots_keep_the_registered_ones`; `marker.rs` `outside_home_the_walk_reaches_a_marker_above_the_checkout_root`, `encoding_noise_cannot_hide_a_server_key`, `an_unreadable_marker_is_a_refused_selection`; `backfill.rs` `a_spawned_backfill_authenticates_like_the_hook_that_spawned_it`; `tests/hooks/test_lib.sh` "server profiles (#992)" section; `hook.rs` `a_mixed_spool_drains_each_event_only_to_its_own_server` (two token-gated servers, one spool, one drain: each server receives exactly its own event with exactly its own bearer); `tests/suite/server_profiles.rs` (the built binary: `server add` → `hook` spools to the profile with its token, unknown profile spools nothing, `uninstall` removes the tokens; `two_real_servers_each_receive_only_their_own_repository` runs two real `ai-memory serve` children with different root tokens and checks on each server which repository landed there); `ai-memory-hooks` `powershell_server_routed.rs` `server_routed_guard_mirrors_the_native_walk` (runs `Test-AiMemoryServerRouted` under real PowerShell: inherited, BOM, look-alike keys, the `$HOME` boundary with its control, past a checkout root outside home, current directory) | STRONG for native hooks, generated TS, `.sh` and `.ps1` hooks. Known gap: an older binary draining a shared spool ignores `profile` |
| 11d | Hook server-profile routing: a marker-selected server gets only its own capture and its own token (#992) | `ai-memory-cli/src/server_profiles.rs` `resolve` (validated `ProfileName`, strict `servers.toml` parse, `roots` required once two profiles exist, component-wise root match) and `marker.rs` `find_server_selection` (inherited down the tree, any value shape counts); `commands/hook.rs` `resolve_hook_route` drops a `Rejected` route before spool, handoff and backfill, and hands the drainer no live token for a profile route; `commands/hook_spool.rs` `static_retry_token` (a profile entry retries only with its own stored token), the loopback reroot skip, and chunk splitting on `profile`; generated TS `captureServerRouted` drops a routed repository and gates `fetchHandoff`; `hooks/_lib.sh` `ai_memory_server_routed` (flag refused by `ai_memory_post_hook`/`ai_memory_get_handoff`) and `hooks/lib/ai-memory-hook.ps1` `Test-AiMemoryServerRouted` drop it in the script hooks | `hook.rs` `each_repository_spools_to_its_own_profile_with_its_own_token`, `a_selection_that_does_not_resolve_emits_nothing` (unknown / tokenless / outside roots / roots required / invalid name, plus a resolving control), `session_start_handoff_comes_from_the_profile_server_only`, `a_repository_without_a_server_key_keeps_the_install_default`; `hook_spool.rs` `a_profile_entry_is_never_retried_with_the_install_live_token` (server B accepts exactly the install's live token and must still not get it), `a_profile_entry_recovers_with_its_own_rotated_token` (control), `a_profile_entry_on_a_dead_loopback_port_is_not_rerouted_to_the_default`, `profile_and_default_entries_at_one_address_ride_separate_batches`; `server_profiles.rs` roots/registry/name tests; `marker.rs` `nested_markers_without_server_inherit_the_ancestor_selection`; `install_hooks.rs` `generated_integrations_fail_closed_on_a_server_profile_marker`, `openclaw_plugin.rs` `openclaw_plugin_fails_closed_on_a_server_profile_marker`, and the `server-routed-*` checks in `generated_capture_policy_v1_node_runtime_evidence`; `hook.rs` `an_event_without_a_payload_cwd_routes_by_the_process_cwd`, `a_refused_route_prints_nothing_for_kimi_user_prompts`; `hook_spool.rs` `a_profile_entry_is_not_retried_with_a_token_issued_for_a_new_url`; `server_profiles.rs` `changing_the_url_without_a_token_discards_the_old_token`, `omitted_roots_keep_the_registered_ones`; `marker.rs` `outside_home_the_walk_reaches_a_marker_above_the_checkout_root`, `encoding_noise_cannot_hide_a_server_key`, `an_unreadable_marker_is_a_refused_selection`; `backfill.rs` `a_spawned_backfill_authenticates_like_the_hook_that_spawned_it`; `tests/hooks/test_lib.sh` "server profiles (#992)" section; `hook.rs` `a_mixed_spool_drains_each_event_only_to_its_own_server` (two token-gated servers, one spool, one drain: each server receives exactly its own event with exactly its own bearer); `tests/suite/server_profiles.rs` (the built binary: `server add` → `hook` spools to the profile with its token, unknown profile spools nothing, `uninstall` removes the tokens; `two_real_servers_each_receive_only_their_own_repository` runs two real `ai-memory serve` children with different root tokens and checks on each server which repository landed there); `ai-memory-hooks` `powershell_server_routed.rs` `server_routed_guard_mirrors_the_native_walk` (runs `Test-AiMemoryServerRouted` under real PowerShell: inherited, BOM, look-alike keys, the `$HOME` boundary with its control, past a checkout root outside home, current directory); `tests/hooks/test_lib.sh` and `powershell_home.rs` `a_trailing_separator_on_home_keeps_the_walk_boundary` (a `$HOME` ending in `/` or `\` keeps both script walks at home: a `server` marker above it routes nothing, with a root-home control that does) | STRONG for native hooks, generated TS, `.sh` and `.ps1` hooks. Known gap: an older binary draining a shared spool ignores `profile` |
| 12 | Network/auth posture | `config.rs` loopback `DEFAULT_BIND`; `serve.rs` `validate_http_exposure`, `require_allowed_host`; `auth.rs` `require_bearer` | `serve.rs` host-guard (missing→400 / forged→403), non-loopback-requires-token; `auth.rs` wrong-token 401 | STRONG |
| 13 | Managed-run transcript attribution (concurrent launches in one checkout, invariant #16) | `ai-memory-store/src/workstream.rs` `link_native_session` stamps `native_session_linked_at` on its own run only, both `finish` updates drop the stamp when the session changes, `run_status` reports it; `ai-memory-cli/src/commands/run.rs` `resolve_native_session_after_run` takes a linked session only when `ai-memory-workstream` `native_session_in_checkout` holds it for this checkout (OpenCode by recorded directory), never falls back to a link it set aside, and turns an `AmbiguousNativeSession` into a warning with nothing imported; `ai-memory-workstream/src/transcript.rs` `discover_crush` claims only the one top-level session created (or, with `--continue`, touched) during the run, and in a data directory outside the project only one that edited a file in it | `multi_session.rs` `a_session_linked_by_one_managed_run_is_not_another_runs`; `run.rs` `a_session_linked_during_the_run_wins_over_discovery` (concurrent newer session, another checkout's link refused, no fallback to it, unlinked control); `transcript.rs` `native_session_in_checkout_checks_the_opencode_directory`; `store/src/lib.rs` `managed_run_status_reports_a_link_made_during_the_run`; `run.rs` `ambiguous_crush_discovery_keeps_the_run`; `transcript.rs` `crush_discovery_claims_only_the_session_the_run_created`, `crush_discovery_in_a_shared_store_claims_only_an_edit_here` | PARTIAL: a run whose child links nothing still falls back to discovering the newest session in the checkout; Crush, which has no hooks, relies on that discovery alone and claims nothing when it is ambiguous |
| 13b | Managed-run lease exclusivity (one active run per workstream, invariant #16) | `ai-memory-store/src/workstream.rs` `prepare_run` expires lapsed leases and refuses any other `active` run on the workstream inside one transaction (`StoreError::WorkstreamBusy`), regardless of the `lease_owner` label; `heartbeat` renews only `active` rows. `ai-memory-cli/src/commands/run.rs` `wait_out_held_lease` (interactive relaunch) only waits for a reported expiry and retries — it never cancels or claims another run, so the server's busy check stays the sole arbiter | `store/src/lib.rs` `managed_workstream_batches_are_idempotent_and_release_the_lease` (second prepare refused while active); `run.rs` `a_renewed_lease_is_reported_as_a_live_owner_not_taken_over` (renewing holder is reported, never displaced), with controls `interactive_launch_waits_out_a_lapsing_lease_then_proceeds` and `ctrl_c_aborts_the_held_lease_wait_immediately` | STRONG for exclusivity. The `lease_owner` label (`host:pid`) is informational only and not unique inside ai-jail (every jailed launcher reports `ai-sandbox:<ns-pid>`), so it must never become an ownership key |
+18 -6
View File
@@ -5,6 +5,15 @@
# cursor, gemini-cli, kimi-code, kiro-cli, antigravity-cli, opencode,
# omp, pool) sources this same file.
# Set `_amhome` to $HOME without trailing slashes ("/" stays "/"), so the
# string patterns below treat `HOME=/home/u/` as the native walk does.
ai_memory_home() {
_amhome="${HOME:-}"
while [ "${_amhome%/}" != "$_amhome" ] && [ -n "${_amhome%/}" ]; do
_amhome="${_amhome%/}"
done
}
# Walk up from "$1" toward $HOME (or /) looking for `.ai-memory.toml`.
# Prints the absolute path of the first marker found, or nothing.
# Stops at $HOME to avoid leaking declarations from a shared system
@@ -15,9 +24,10 @@ ai_memory_find_marker() {
dir="$1"
[ -z "$dir" ] && return 0
boundary=""
if [ -n "${HOME:-}" ]; then
ai_memory_home
if [ -n "$_amhome" ]; then
case "$dir" in
"$HOME"|"$HOME"/*) boundary="$HOME" ;;
"$_amhome"|"${_amhome%/}"/*) boundary="$_amhome" ;;
*)
probe="$dir"
while [ -n "$probe" ] && [ "$probe" != "/" ]; do
@@ -109,9 +119,10 @@ ai_memory_server_routed() {
_amsr_dir="${1:-${PWD:-}}"
[ -z "$_amsr_dir" ] && return 1
_amsr_boundary=""
if [ -n "${HOME:-}" ]; then
ai_memory_home
if [ -n "$_amhome" ]; then
case "$_amsr_dir" in
"$HOME"|"$HOME"/*) _amsr_boundary="$HOME" ;;
"$_amhome"|"${_amhome%/}"/*) _amsr_boundary="$_amhome" ;;
esac
fi
while [ -n "$_amsr_dir" ]; do
@@ -150,9 +161,10 @@ ai_memory_find_settings_marker() {
dir="$1"
[ -z "$dir" ] && return 0
boundary=""
if [ -n "${HOME:-}" ]; then
ai_memory_home
if [ -n "$_amhome" ]; then
case "$dir" in
"$HOME"|"$HOME"/*) boundary="$HOME" ;;
"$_amhome"|"${_amhome%/}"/*) boundary="$_amhome" ;;
*)
probe="$dir"
while [ -n "$probe" ] && [ "$probe" != "/" ]; do
+12 -2
View File
@@ -42,11 +42,21 @@ function Resolve-AiMemoryCwd {
return $null
}
# $HOME (else USERPROFILE) without trailing separators, so the walks can
# compare it to `Split-Path` output; a root such as `C:\` keeps its own.
function Get-AiMemoryUserHome {
$userHome = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
if (-not $userHome) { return $userHome }
$trimmed = $userHome.TrimEnd([char[]]@('/', '\'))
if (-not $trimmed -or $trimmed.EndsWith(':')) { return $userHome }
return $trimmed
}
function Get-AiMemoryMarkerToml {
param([string] $Cwd)
if (-not $Cwd) { return $null }
$dir = $Cwd
$userHome = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$userHome = Get-AiMemoryUserHome
$boundary = $null
if ($userHome) {
$userHomePrefix = $userHome.TrimEnd([char[]]@('/', '\')) + [IO.Path]::DirectorySeparatorChar
@@ -90,7 +100,7 @@ function Get-AiMemoryMarkerToml {
function Test-AiMemoryServerRouted {
param([string] $Cwd)
$dir = if ($Cwd) { $Cwd } else { (Get-Location).Path }
$userHome = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$userHome = Get-AiMemoryUserHome
$boundary = $null
if ($userHome) {
$userHomePrefix = $userHome.TrimEnd([char[]]@('/', '\')) + [IO.Path]::DirectorySeparatorChar
+22
View File
@@ -88,6 +88,28 @@ assert_eq "outside HOME plain dir rejects parent marker" "" \
HOME="$TMP"
export HOME
# A trailing slash on $HOME does not move the boundary.
mkdir -p "$TMP/slash-home/org/repo/.git" "$TMP/slash-home/plain/src"
printf 'workspace = "org"\n' >"$TMP/slash-home/org/.ai-memory.toml"
printf 'workspace = "above-home"\nserver = "team-b"\n' >"$TMP/.ai-memory.toml"
for slash_home in "$TMP/slash-home" "$TMP/slash-home/" "$TMP/slash-home//"; do
HOME="$slash_home"
export HOME
assert_eq "HOME=$slash_home: marker above the checkout root" \
"$TMP/slash-home/org/.ai-memory.toml" \
"$(ai_memory_find_marker "$TMP/slash-home/org/repo")"
assert_eq "HOME=$slash_home: settings marker above the checkout root" \
"$TMP/slash-home/org/.ai-memory.toml" \
"$(ai_memory_find_settings_marker "$TMP/slash-home/org/repo")"
assert_eq "HOME=$slash_home: walk stops at HOME" "" \
"$(ai_memory_find_marker "$TMP/slash-home/plain/src")"
assert_eq "HOME=$slash_home: server walk stops at HOME" "no" \
"$(ai_memory_server_routed "$TMP/slash-home/plain/src" && echo yes || echo no)"
done
rm -f "$TMP/.ai-memory.toml"
HOME="$TMP"
export HOME
# --- extract_cwd ------------------------------------------------------
PAYLOAD='{"session_id":"x","cwd":"/home/u/foo","tool":"Read"}'
assert_eq "extract cwd from payload" "/home/u/foo" "$(ai_memory_extract_cwd "$PAYLOAD")"