Skip to content
Open
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
192 changes: 192 additions & 0 deletions _test_hermes_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Test profile-aware path resolution (scripts/hermes_env.py + icarus/state.py mirrors).

Covers the resolution logic behind the profile-isolated memory feature:
default (no HERMES_HOME) layout, modern (<root>/profiles/<name>) layout,
legacy (~/.hermes-<name>) layout, and the FABRIC_DIR override that lets
profiles deliberately share a fabric.
"""
import os
import subprocess
import sys

sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts"))

import hermes_env # noqa: E402

all_ok = True


def check(name, cond):
global all_ok
if not cond:
print(f"FAIL: {name}")
all_ok = False


class env:
"""Context manager that patches os.environ for the block, then restores it."""

def __init__(self, **kw):
self.kw = kw
self.saved = {}

def __enter__(self):
self.saved = {k: os.environ.get(k) for k in self.kw}
for k, v in self.kw.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v

def __exit__(self, *exc):
for k, v in self.saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v


HOME = os.path.expanduser("~")

# ── hermes_env.hermes_home() ─────────────────────────────────────────────

with env(HERMES_HOME=None):
check("hermes_home: default falls back to ~/.hermes",
str(hermes_env.hermes_home()) == f"{HOME}/.hermes")

with env(HERMES_HOME="/tmp/hh/profiles/coder"):
check("hermes_home: honors HERMES_HOME when set",
str(hermes_env.hermes_home()) == "/tmp/hh/profiles/coder")

with env(HERMES_HOME=" "):
check("hermes_home: blank/whitespace HERMES_HOME treated as unset",
str(hermes_env.hermes_home()) == f"{HOME}/.hermes")

with env(HERMES_HOME="~/.hermes/profiles/coder"):
check("hermes_home: expands ~ in HERMES_HOME",
str(hermes_env.hermes_home()) == f"{HOME}/.hermes/profiles/coder")

# ── hermes_env.profile_name() / is_profile() ─────────────────────────────

with env(HERMES_HOME=None):
check("profile_name: default profile is ''", hermes_env.profile_name() == "")
check("is_profile: False for default profile", hermes_env.is_profile() is False)

with env(HERMES_HOME="/home/u/.hermes/profiles/coder"):
check("profile_name: modern layout <root>/profiles/<name>",
hermes_env.profile_name() == "coder")
check("is_profile: True for modern profile layout", hermes_env.is_profile() is True)

with env(HERMES_HOME="/home/u/.hermes-reviewer"):
check("profile_name: legacy .hermes-<name> layout",
hermes_env.profile_name() == "reviewer")
check("is_profile: True for legacy profile layout", hermes_env.is_profile() is True)

with env(HERMES_HOME="/home/u/.hermes-reviewer/"):
check("profile_name: legacy layout strips trailing slash",
hermes_env.profile_name() == "reviewer")

with env(HERMES_HOME="/some/custom/hermes-dir"):
check("profile_name: non-profile-shaped HERMES_HOME is treated as default ('')",
hermes_env.profile_name() == "")
check("is_profile: False for non-profile-shaped HERMES_HOME",
hermes_env.is_profile() is False)

# ── hermes_env.fabric_dir() ───────────────────────────────────────────────

with env(HERMES_HOME=None, FABRIC_DIR=None):
check("fabric_dir: default profile falls back to ~/fabric",
str(hermes_env.fabric_dir()) == f"{HOME}/fabric")

with env(HERMES_HOME="/home/u/.hermes/profiles/coder", FABRIC_DIR=None):
check("fabric_dir: profile without override gets its own <home>/fabric",
str(hermes_env.fabric_dir()) == "/home/u/.hermes/profiles/coder/fabric")

with env(HERMES_HOME="/home/u/.hermes/profiles/reviewer", FABRIC_DIR=None):
check("fabric_dir: a second profile gets a *different* fabric than the first",
str(hermes_env.fabric_dir()) != "/home/u/.hermes/profiles/coder/fabric")

with env(HERMES_HOME="/home/u/.hermes/profiles/coder", FABRIC_DIR="/shared/fabric"):
check("fabric_dir: explicit FABRIC_DIR always wins over profile default",
str(hermes_env.fabric_dir()) == "/shared/fabric")

with env(HERMES_HOME=None, FABRIC_DIR="/shared/fabric"):
check("fabric_dir: explicit FABRIC_DIR wins for default profile too",
str(hermes_env.fabric_dir()) == "/shared/fabric")

# ── hermes_env.*_db() / logs / wiki / dlq path composition ───────────────

with env(HERMES_HOME="/home/u/.hermes/profiles/coder"):
hh = hermes_env.hermes_home()
check("state_db: under profile home", hermes_env.state_db() == hh / "state.db")
check("memory_store_db: under profile home",
hermes_env.memory_store_db() == hh / "memory_store.db")
check("logs_dir: under profile home", hermes_env.logs_dir() == hh / "logs")
check("soul_path: under profile home", hermes_env.soul_path() == hh / "SOUL.md")
check("wiki_state_file: under profile home",
hermes_env.wiki_state_file() == hh / "wiki_ingest_state.json")
check("wiki_failures_file: under profile home",
hermes_env.wiki_failures_file() == hh / "wiki_ingest_failures.json")
check("dlq_report_log: under profile home",
hermes_env.dlq_report_log() == hh / "cron" / "output" / "dlq_reports.jsonl")
check("dlq_report_dir: under profile home",
hermes_env.dlq_report_dir() == hh / "cron" / "output" / "quality_report")
check("query_telemetry_log: under profile home/logs",
hermes_env.query_telemetry_log() == hh / "logs" / "query-telemetry.jsonl")
check("reflection_log: under profile home/logs",
hermes_env.reflection_log() == hh / "logs" / "reflection_trigger.log")


# ── icarus/state.py mirrors the same resolution (module-level constants,
# so exercised via subprocess — each scenario needs a fresh import) ────

def state_py(env_overrides, expr):
"""Run `expr` against a freshly-imported icarus.state under env_overrides."""
child_env = dict(os.environ)
for k, v in env_overrides.items():
if v is None:
child_env.pop(k, None)
else:
child_env[k] = v
proc = subprocess.run(
[sys.executable, "-c", f"import icarus.state as s; print({expr})"],
cwd=os.path.dirname(__file__) or ".",
env=child_env,
capture_output=True,
text=True,
)
check(f"state.py subprocess ok ({expr} under {env_overrides})", proc.returncode == 0)
return proc.stdout.strip()


check("icarus.state: default FABRIC_DIR == ~/fabric",
state_py({"HERMES_HOME": None}, "s.FABRIC_DIR") == f"{HOME}/fabric")

check("icarus.state: profile FABRIC_DIR == <home>/fabric",
state_py({"HERMES_HOME": "/tmp/hh/profiles/coder"}, "s.FABRIC_DIR")
== "/tmp/hh/profiles/coder/fabric")

check("icarus.state: profile AGENT_NAME auto-detected from HERMES_HOME",
state_py({"HERMES_HOME": "/tmp/hh/profiles/coder", "HERMES_AGENT_NAME": None},
"s.AGENT_NAME") == "coder")

check("icarus.state: explicit HERMES_AGENT_NAME wins over auto-detection",
state_py({"HERMES_HOME": "/tmp/hh/profiles/coder", "HERMES_AGENT_NAME": "custom"},
"s.AGENT_NAME") == "custom")

check("icarus.state: legacy .hermes-<name> AGENT_NAME auto-detected",
state_py({"HERMES_HOME": "/home/u/.hermes-reviewer", "HERMES_AGENT_NAME": None},
"s.AGENT_NAME") == "reviewer")

with env(HERMES_HOME="/tmp/hh/profiles/coder", FABRIC_DIR=None):
check("icarus.state and hermes_env agree on FABRIC_DIR for the same profile",
state_py({"HERMES_HOME": "/tmp/hh/profiles/coder", "FABRIC_DIR": None},
"s.FABRIC_DIR") == str(hermes_env.fabric_dir()))

if all_ok:
print("=== ALL HERMES_ENV TESTS PASS ===")
sys.exit(0)
else:
print("=== HERMES_ENV TESTS FAILED ===")
sys.exit(1)
137 changes: 137 additions & 0 deletions _test_setup_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Test the --profile argument handling in setup.sh in isolation.

setup.sh itself needs Docker/pip/network to run end-to-end, so these tests
extract just the self-contained pieces this PR touches (profile-name
parsing/validation, HERMES_HOME export, per-profile cron marker) and run
them under bash directly, without executing the rest of the installer.
"""
import os
import re
import subprocess
import sys

SETUP_SH = os.path.join(os.path.dirname(__file__), "setup.sh")

all_ok = True


def check(name, cond):
global all_ok
if not cond:
print(f"FAIL: {name}")
all_ok = False


def extract(text, start_marker, end_marker):
start = text.index(start_marker)
end = text.index(end_marker, start)
return text[start:end]


with open(SETUP_SH) as f:
SCRIPT = f.read()

PROFILE_BLOCK = extract(SCRIPT, 'PROFILE_NAME=""', 'VAULT_PATH="${VAULT_PATH:-${HOME}/vault}"')
CRON_MARKER_BLOCK = extract(SCRIPT, "CRON_ENTRY=", '\n\nif crontab -l')

check("extracted profile block looks right",
"export HERMES_HOME" in PROFILE_BLOCK and "PROFILE_NAME" in PROFILE_BLOCK)
check("extracted cron marker block looks right",
"CRON_MARKER=" in CRON_MARKER_BLOCK)


def run_profile_block(args, extra_prelude=""):
"""Run the profile-parsing block with $@ set to `args`; return (rc, stdout, stderr)."""
script = f"""#!/usr/bin/env bash
set -euo pipefail
FAIL=0
fail() {{ printf "FAIL_CALL: %s\\n" "$1" >&2; FAIL=$((FAIL + 1)); }}
HOME="{os.environ.get('HOME', '/home/testuser')}"
{extra_prelude}
{PROFILE_BLOCK}
echo "PROFILE_NAME=${{PROFILE_NAME}}"
echo "HERMES_HOME=${{HERMES_HOME}}"
python3 -c 'import os; print("HERMES_HOME_EXPORTED=" + os.environ.get("HERMES_HOME", "<unset>"))'
"""
proc = subprocess.run(
["bash", "-c", script, "--"] + args,
capture_output=True, text=True,
)
return proc.returncode, proc.stdout, proc.stderr


# ── No --profile: default HERMES_HOME, still exported ────────────────────

rc, out, err = run_profile_block([])
check("no --profile: exits 0", rc == 0)
check("no --profile: HERMES_HOME defaults to $HOME/.hermes",
re.search(r"^HERMES_HOME=.*/\.hermes$", out, re.M) is not None)
check("no --profile: HERMES_HOME is exported to subprocesses",
"HERMES_HOME_EXPORTED=<unset>" not in out)

# ── --profile <name> (space form) ─────────────────────────────────────────

rc, out, err = run_profile_block(["--profile", "coder"])
check("--profile coder: exits 0", rc == 0)
check("--profile coder: PROFILE_NAME parsed", "PROFILE_NAME=coder" in out)
check("--profile coder: HERMES_HOME under profiles/coder",
re.search(r"^HERMES_HOME=.*/\.hermes/profiles/coder$", out, re.M) is not None)
check("--profile coder: HERMES_HOME exported to subprocess (regression: setup_db.py bug)",
"HERMES_HOME_EXPORTED=<unset>" not in out
and re.search(r"HERMES_HOME_EXPORTED=.*/profiles/coder$", out, re.M) is not None)

# ── --profile=<name> (equals form) ────────────────────────────────────────

rc, out, err = run_profile_block(["--profile=reviewer"])
check("--profile=reviewer: exits 0", rc == 0)
check("--profile=reviewer: PROFILE_NAME parsed", "PROFILE_NAME=reviewer" in out)
check("--profile=reviewer: HERMES_HOME under profiles/reviewer",
re.search(r"^HERMES_HOME=.*/\.hermes/profiles/reviewer$", out, re.M) is not None)

# ── Invalid profile names are rejected (path-traversal hardening) ────────

for bad in ["../../etc", "a/b", "a b", "coder;rm -rf /"]:
rc, out, err = run_profile_block(["--profile", bad])
check(f"invalid profile name rejected: {bad!r}", rc != 0 and "FAIL_CALL:" in err)

# ── Valid charset (letters, digits, _, -) is accepted ─────────────────────

rc, out, err = run_profile_block(["--profile", "my-agent_2"])
check("valid profile name with _ and - accepted", rc == 0 and "PROFILE_NAME=my-agent_2" in out)


# ── Cron marker is per-profile (regression: second profile install being
# silently skipped because it matched the first profile's marker) ──────

def run_cron_marker(profile_name):
script = f"""#!/usr/bin/env bash
set -euo pipefail
PROFILE_NAME="{profile_name}"
REPO_DIR="/tmp/repo"
HERMES_HOME="/tmp/hh"
{CRON_MARKER_BLOCK}
echo "CRON_MARKER=${{CRON_MARKER}}"
"""
proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
check(f"cron marker block runs cleanly for profile={profile_name!r}", proc.returncode == 0)
m = re.search(r"^CRON_MARKER=(.*)$", proc.stdout, re.M)
return m.group(1) if m else None


default_marker = run_cron_marker("")
coder_marker = run_cron_marker("coder")
reviewer_marker = run_cron_marker("reviewer")

check("default install marker unchanged (upgrade compat)",
default_marker == "# memory-os wiki watcher")
check("profile markers differ from the default marker",
coder_marker not in (None, default_marker) and reviewer_marker not in (None, default_marker))
check("two different profiles get two different markers",
coder_marker != reviewer_marker)

if all_ok:
print("=== ALL SETUP.SH PROFILE TESTS PASS ===")
sys.exit(0)
else:
print("=== SETUP.SH PROFILE TESTS FAILED ===")
sys.exit(1)
29 changes: 24 additions & 5 deletions icarus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,18 +256,37 @@ Export modes:
- `normal` -- excludes low-value and skips noisy unstructured session notes unless grounded
- `high-volume` -- everything

## Profiles (Hermes v0.6.0)
## Profiles (Hermes v0.6.0+)

Hermes supports multiple profiles, and Memory OS is profile-aware. When a
non-default profile is active, Hermes sets `HERMES_HOME` to
`<root>/profiles/<name>` (e.g. `~/.hermes/profiles/coder`), and Memory OS
resolves every Hermes-owned path from it — fabric, `state.db`,
`memory_store.db`, logs, wiki state, DLQ, SOUL.md. Each profile gets its
own memory automatically.

```bash
hermes profile create coder
hermes profile create reviewer --clone
mkdir -p ~/.hermes-coder/plugins/icarus ~/.hermes-reviewer/plugins/icarus
cp -r icarus-plugin/* ~/.hermes-coder/plugins/icarus/
cp -r icarus-plugin/* ~/.hermes-reviewer/plugins/icarus/

# Install Memory OS / Icarus into each profile
bash setup.sh --profile coder
bash setup.sh --profile reviewer

hermes -p coder chat
```

Both profiles write to the same `FABRIC_DIR`, so the reviewer sees the coder's work.
By default each profile writes to its own `FABRIC_DIR`
(`<profile-home>/fabric`), so coder and reviewer memories stay isolated.

To deliberately **share** a fabric between profiles (e.g. the reviewer reads
the coder's work), set `FABRIC_DIR` explicitly to the same absolute path in
each profile's `.env`:

```bash
# ~/.hermes/profiles/coder/.env and ~/.hermes/profiles/reviewer/.env
FABRIC_DIR=/home/you/vault/fabric
```

## Fallback models

Expand Down
15 changes: 13 additions & 2 deletions icarus/fabric-retrieve.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,19 @@
from datetime import datetime, timezone
from pathlib import Path

FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", Path.home() / "fabric"))
STATE_DB = Path(os.environ.get("STATE_DB_PATH", Path.home() / ".hermes" / "state.db"))
# Profile-aware paths: resolve through scripts/hermes_env when run standalone,
# else fall back to ~/.hermes for the default profile.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
try:
from hermes_env import hermes_home, fabric_dir
_HH = hermes_home
_FD = fabric_dir
except ImportError:
_HH = lambda: Path.home() / ".hermes"
_FD = lambda: Path.home() / "fabric"

FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", str(_FD())))
STATE_DB = Path(os.environ.get("STATE_DB_PATH", str(_HH() / "state.db")))

# ── SQLite fabric index (avoids glob+parse on every retrieval) ──────────

Expand Down
Loading
Loading