Files
multica/server/internal/daemon/execenv/hermes_home.go
Multica Eve 07538e9928 fix(execenv): isolate Hermes SQLite state (#5560)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:07:33 +08:00

889 lines
36 KiB
Go

package execenv
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"gopkg.in/yaml.v3"
)
// Hermes discovers skills from exactly two places (verified against the bundled
// Hermes agent source, agent/skill_utils.py get_all_skills_dirs): its home
// skills dir `<HERMES_HOME>/skills/` first, then the directories listed under
// `skills.external_dirs` in `<HERMES_HOME>/config.yaml`, in config order. It has
// NO workspace-relative discovery, so the generic `.agent_context/skills/`
// fallback the daemon used before was never scanned and workspace-assigned
// skills silently never took effect (issue #5242).
//
// Rather than replace HERMES_HOME with a home rebuilt from a fixed allowlist —
// which would silently change behavior for tasks that don't even use skills and
// would drop any home state not on the list (plugins, OAuth state, hooks, SOUL,
// scripts, and whatever Hermes adds next) — this builds a minimal compatibility
// overlay, and only when the agent actually has skills bound (gated at the call
// site in Prepare/Reuse; a skill-less Hermes task keeps HERMES_HOME untouched).
//
// The overlay:
// - mirrors every top-level entry of the shared ~/.hermes/ into the per-task
// home via symlink, EXCEPT the entries it overrides — so the denylist stays
// tiny and future home state is inherited automatically instead of being
// missed by an allowlist;
// - derives a task-local config.yaml whose `skills.external_dirs` points at
// the shared skills dir plus the user's existing external_dirs, expanded
// against the agent's effective env and normalized to absolute paths (Hermes
// resolves relative external_dirs against HERMES_HOME, so leaving them
// relative after the redirect would silently repoint them) — this exposes
// the user's global/builtin skills read-only without copying them;
// - populates the task-local `skills/` dir with ONLY the Multica-bound skills,
// which take precedence because Hermes lists the home skills dir first;
// - keeps `memories/` overlay-owned (a fresh per-task dir), NOT symlinked to
// the shared home: Hermes loads and writes back MEMORY.md/USER.md there, and
// per-agent memory is a Multica product concern — the host's local Hermes
// memory must not bleed into a task, nor task memory back out to the host;
// - keeps the state.db SQLite session store and its journal sidecars
// overlay-owned too: Hermes creates them lazily, Reuse preserves them for
// the task, and host conversation history is never linked or copied;
// - disables the external `memory.provider` in the derived config so a
// host-configured Supermemory/Hindsight/etc. backend isn't shared across
// tasks. This is the on-disk + external-backend memory isolation; a managed,
// agent-scoped memory backend is a separate future product decision.
//
// The shared ~/.hermes/ is never modified by this setup. Note however that
// mirrored entries are writable symlinks, so if Hermes writes through one at
// runtime (e.g. refreshing token state under a mirrored auth/OAuth path) that
// write does reach the shared home — that propagation is intentional.
// hermesOverriddenEntries are the fixed top-level entries of the shared
// ~/.hermes/ that the overlay supplies its own task-local version of, so they
// are NOT mirrored from the shared home and are preserved across reuse
// reconciliation:
// - skills/ task-local, only the bound skills
// - config.yaml derived config with absolutized external_dirs
// - memories/ fresh per-task dir, isolated from the host's memory
// - marker below records that legacy shared SQLite state was detached
//
// The state.db SQLite family is classified dynamically by
// isHermesOverlayOwnedEntry so every journal sidecar stays task-local too.
// Everything else in the shared home is mirrored generically.
//
// active_profile and profiles are also overlay-owned (never mirrored): Hermes
// reads <HERMES_HOME>/active_profile at startup and, if it names a non-default
// profile, redirects HERMES_HOME to that real profile — which would bypass the
// overlay's skills and memory isolation. Keeping active_profile out of the
// overlay means Hermes finds none and stays put. The overlay is already seeded
// from the correct (possibly profile) source home.
//
// .env is overlay-owned too, but unlike the others it is DERIVED, not just
// omitted (see writeDerivedHermesEnv): Hermes runs _apply_profile_override()
// and then load_hermes_dotenv(), which loads <HERMES_HOME>/.env with
// override=True. A source .env carrying an out-of-band HERMES_HOME= would
// overwrite the overlay's HERMES_HOME after the argv/sticky-profile protections
// ran, repointing skill discovery and memory back at the source home. The
// derived overlay .env preserves the source's credentials/settings but pins
// HERMES_HOME to the overlay, and is written even when the source has none so
// Hermes' project-.env fallback (loaded with override=True only when no user
// .env loaded) can't relocate the home either.
const hermesTaskLocalStateMarker = ".multica-task-local-state-v1"
var hermesOverriddenEntries = map[string]struct{}{
"skills": {},
"config.yaml": {},
"memories": {},
"active_profile": {},
"profiles": {},
".env": {},
hermesTaskLocalStateMarker: {},
}
// isHermesOverlayOwnedEntry reports whether name belongs to the per-task
// overlay rather than the shared Hermes home. Hermes' state.db is the canonical
// session store and uses WAL mode; mirroring its main file and journal sidecars
// separately can produce an inconsistent snapshot, exposes host conversation
// history, and fails on Windows when SQLite byte-range-locks state.db-shm.
func isHermesOverlayOwnedEntry(name string) bool {
if _, owned := hermesOverriddenEntries[name]; owned {
return true
}
return isHermesTaskLocalStateEntry(name)
}
func isHermesTaskLocalStateEntry(name string) bool {
return name == "state.db" || strings.HasPrefix(name, "state.db-")
}
// platformDefaultHermesHome returns Hermes' platform-native default home:
// %LOCALAPPDATA%\hermes on native Windows, ~/.hermes elsewhere — matching
// hermes_constants._get_platform_default_hermes_home. Without the Windows branch
// a Windows user with no explicit HERMES_HOME would seed the overlay from an
// empty ~/.hermes and lose their real config/auth/global skills.
func platformDefaultHermesHome() string {
la, _ := os.LookupEnv("LOCALAPPDATA")
home, _ := os.UserHomeDir()
return platformDefaultHermesHomeFor(runtime.GOOS, la, home)
}
// platformDefaultHermesHomeFor is the pure core of platformDefaultHermesHome,
// split out so the Windows branch is testable off a Windows host. It matches
// hermes_constants._get_platform_default_hermes_home: on Windows the base is
// %LOCALAPPDATA%, or %USERPROFILE%\AppData\Local when LOCALAPPDATA is unset,
// with `hermes` appended; POSIX uses ~/.hermes.
func platformDefaultHermesHomeFor(goos, localAppData, userHome string) string {
if goos == "windows" {
base := strings.TrimSpace(localAppData)
if base == "" && userHome != "" {
base = filepath.Join(userHome, "AppData", "Local")
}
if base != "" {
return filepath.Join(base, "hermes")
}
}
if userHome != "" {
return filepath.Join(userHome, ".hermes")
}
return filepath.Join(os.TempDir(), ".hermes") // last-resort fallback
}
// hermesProfileNameRe mirrors Hermes' hermes_cli.profiles._PROFILE_ID_RE — the
// shape a profile identifier must have on disk and in argv.
var hermesProfileNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
// hermesReservedProfileNames mirrors hermes_cli.profiles._RESERVED_NAMES: names
// Hermes' validate_profile_name rejects (they would collide with the install
// itself or a common system binary). "default" is in Hermes' set too but is a
// special pass-through there — it names the root home — so it is handled before
// this check, not listed here.
var hermesReservedProfileNames = map[string]struct{}{
"hermes": {}, "test": {}, "tmp": {}, "root": {}, "sudo": {},
}
// HermesProfileResolution is the single authoritative result of resolving a
// Hermes profile selection: the source home to seed the overlay from (and to
// expand ${HERMES_HOME} against), whether that home must already exist, and a
// non-nil Err when the selection is one Hermes would refuse to start under.
type HermesProfileResolution struct {
// SourceHome is the resolved HERMES_HOME the overlay is built from. It is
// also the value ${HERMES_HOME} in a profile's skills.external_dirs expands
// to, matching native Hermes applying the profile override before it loads
// config.yaml.
SourceHome string
// MustExist fails the overlay closed when SourceHome is absent — set for a
// named/profile-scoped source so a typo doesn't silently seed from an empty
// dir and drop the user's auth/config, matching Hermes' own FileNotFoundError
// sys.exit on a missing profile.
MustExist bool
// Err is set when the selection names a reserved or otherwise invalid
// profile (including the empty inline `--profile=` value). Hermes sys.exit(1)s
// in these cases, so the daemon must fail the task closed rather than start
// it under the default profile.
Err error
}
// ResolveHermesProfile is the one resolver contract for Hermes profile
// selection. Given the agent's custom_env HERMES_HOME and the profile selection
// already parsed from custom_args (agent.ParseHermesProfileArgs), it reproduces
// hermes_cli.main._apply_profile_override + hermes_cli.profiles semantics:
//
// - The Hermes root is derived exactly like get_default_hermes_root: an
// explicit custom_env HERMES_HOME, else the process HERMES_HOME, else the
// platform default; if that home is itself <root>/profiles/<name>, the root
// is <root> (profiles are always resolved against the root, never nested).
// - An explicit -p/--profile wins. Otherwise an already-profile-scoped
// HERMES_HOME is trusted as-is (step 1.5), and only failing that is the
// sticky <root>/active_profile consulted (step 2).
// - The chosen name is normalized + validated like normalize_profile_name /
// validate_profile_name: "default" (case-insensitively) means the root home;
// an empty, malformed, or reserved name is a hard error (Err set).
// - A valid named profile resolves to <root>/profiles/<name> and MustExist.
//
// found/inline come from the parsed selection: found means an explicit flag with
// a value matched; inline distinguishes the `--profile=<value>` form, whose empty
// value must hard-fail rather than fall back to the default.
func ResolveHermesProfile(customEnvHome, name string, found, inline bool) HermesProfileResolution {
base := strings.TrimSpace(customEnvHome)
if base == "" {
base = strings.TrimSpace(os.Getenv("HERMES_HOME"))
}
if base == "" {
base = platformDefaultHermesHome()
}
if abs, err := filepath.Abs(base); err == nil {
base = abs
}
root := hermesRootFromHome(base)
profile := name
if !found {
// Step 1.5: trust an already-profile-scoped HERMES_HOME (immediate parent
// dir named "profiles") without consulting active_profile.
if base != "" && filepath.Base(filepath.Dir(base)) == "profiles" {
return HermesProfileResolution{SourceHome: base, MustExist: true}
}
// Step 2: honor the sticky <root>/active_profile. (The container-only
// HERMES_S6_SUPERVISED_CHILD exception in Hermes does not apply to a
// daemon task spawn.) When no sticky applies, the base home (the
// root/default) is the source.
profile = readHermesActiveProfile(root)
if profile == "" {
return HermesProfileResolution{SourceHome: base}
}
}
// An explicit selection (found) is always validated — an empty inline value
// (`--profile=`) is a hard error, not a fall-back to the default — as is a
// sticky name, matching Hermes calling resolve_profile_env on both.
home, mustExist, err := hermesProfileDir(root, profile)
if err != nil {
return HermesProfileResolution{Err: err}
}
return HermesProfileResolution{SourceHome: home, MustExist: mustExist}
}
// hermesRootFromHome reproduces hermes_constants.get_default_hermes_root: the
// root for profile-level operations. If base is the platform default or lives
// under it (normal or profile mode) the root is the platform default; otherwise
// (Docker/custom home) a <...>/profiles/<name> layout roots at the grandparent,
// and any other path is its own root.
func hermesRootFromHome(base string) string {
return hermesRootFromHomeFor(base, platformDefaultHermesHome())
}
// hermesRootFromHomeFor is the pure core of hermesRootFromHome with the native
// home injected for testability. The containment test resolves symlinks on both
// sides (like get_default_hermes_root's env_path.resolve().relative_to(
// native_home.resolve())), so a HERMES_HOME symlinked into <native>/profiles/<x>
// still roots at native. The RETURNED value stays unresolved, matching Hermes,
// which returns native_home / the lexical grandparent of the original env_path.
func hermesRootFromHomeFor(base, native string) string {
if base == "" {
return native
}
if isPathUnder(resolvePathBestEffort(native), resolvePathBestEffort(base)) {
return native
}
if filepath.Base(filepath.Dir(base)) == "profiles" {
return filepath.Dir(filepath.Dir(base))
}
return base
}
// resolvePathBestEffort resolves symlinks like Python's Path.resolve(strict=False):
// it follows every symlink in the existing prefix of p and appends the remaining
// non-existent tail unchanged, rather than failing (as filepath.EvalSymlinks does)
// when p doesn't fully exist. The result is absolute.
func resolvePathBestEffort(p string) string {
if p == "" {
return p
}
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
if resolved, err := filepath.EvalSymlinks(p); err == nil {
return resolved
}
dir := p
var tail []string
for {
parent := filepath.Dir(dir)
if parent == dir {
return p // reached the root without an existing ancestor
}
tail = append([]string{filepath.Base(dir)}, tail...)
dir = parent
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
return filepath.Join(append([]string{resolved}, tail...)...)
}
}
}
// isPathUnder reports whether child is parent or nested under it.
func isPathUnder(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// readHermesActiveProfile returns the sticky profile name from
// <root>/active_profile, or "" when absent, unreadable, empty, or "default"
// (matching _apply_profile_override step 2, which ignores a "default" sticky).
func readHermesActiveProfile(root string) string {
data, err := os.ReadFile(filepath.Join(root, "active_profile"))
if err != nil {
return ""
}
name := strings.TrimSpace(string(data))
if name == "default" {
return ""
}
return name
}
// hermesProfileDir resolves a profile name against root, reproducing
// normalize_profile_name + validate_profile_name + get_profile_dir. It returns
// the home dir, whether that home must already exist (true for a named profile),
// or an error for an empty/malformed/reserved name (which Hermes sys.exit(1)s on).
func hermesProfileDir(root, name string) (home string, mustExist bool, err error) {
stripped := strings.TrimSpace(name)
if stripped == "" {
return "", false, fmt.Errorf("hermes profile name cannot be empty")
}
var canon string
if strings.EqualFold(stripped, "default") {
canon = "default"
} else {
canon = strings.ToLower(stripped)
}
if canon == "default" {
return root, false, nil // the default profile IS the root home
}
if !hermesProfileNameRe.MatchString(canon) {
return "", false, fmt.Errorf("invalid hermes profile name %q", canon)
}
if _, reserved := hermesReservedProfileNames[canon]; reserved {
return "", false, fmt.Errorf("hermes profile name %q is reserved", canon)
}
return filepath.Join(root, "profiles", canon), true, nil
}
// prepareHermesHome builds the per-task HERMES_HOME compatibility overlay
// described above. The daemon exports the given path as HERMES_HOME on the
// hermes subprocess so the CLI discovers the bound skills natively.
//
// Callers gate this on the agent having skills bound; it is a full rebuild each
// time (mirror reconciled, config re-derived, bound skills rewritten) so a Reuse
// after a skill/config change lands cleanly. It fails CLOSED: if the mirror, the
// derived config, or the bound skills can't be established the whole overlay is
// unusable, so the error propagates and the caller must not start Hermes against
// a half-built home.
// sourceHome is the shared home to seed from (resolved by the daemon via
// ResolveHermesProfile, honoring the agent's HERMES_HOME/profile); empty
// falls back to the platform default. sourceMustExist fails closed when the
// source home is absent — set for an explicitly named profile so a typo doesn't
// silently seed from an empty dir and drop the user's auth/config. env is the
// sanitized effective env used to expand ${VAR} in external_dirs so it matches
// what the Hermes child sees.
func prepareHermesHome(hermesHome, sourceHome string, sourceMustExist bool, workspaceSkills []SkillContextForEnv, env map[string]string, logger *slog.Logger) error {
sharedHome := strings.TrimSpace(sourceHome)
if sharedHome == "" {
sharedHome = platformDefaultHermesHome()
}
if sourceMustExist {
if fi, err := os.Stat(sharedHome); err != nil || !fi.IsDir() {
return fmt.Errorf("hermes profile home %q not found (create it with `hermes profile create`)", sharedHome)
}
}
if err := os.MkdirAll(hermesHome, 0o700); err != nil {
return fmt.Errorf("create hermes-home dir: %w", err)
}
// Tighten perms on reuse too — MkdirAll leaves an existing dir's mode alone,
// and the derived config below can hold inline api_key secrets.
if err := os.Chmod(hermesHome, 0o700); err != nil {
return fmt.Errorf("chmod hermes-home dir: %w", err)
}
if err := prepareHermesTaskLocalState(hermesHome); err != nil {
return fmt.Errorf("prepare task-local state: %w", err)
}
// Fresh, isolated per-task memories dir (idempotent — preserved across reuse
// so the task/issue lifecycle keeps its own memory).
if err := os.MkdirAll(filepath.Join(hermesHome, "memories"), 0o700); err != nil {
return fmt.Errorf("create task memories dir: %w", err)
}
if err := mirrorSharedHermesHome(sharedHome, hermesHome, logger); err != nil {
return fmt.Errorf("mirror shared hermes home: %w", err)
}
if err := writeDerivedHermesConfig(sharedHome, hermesHome, env, logger); err != nil {
return fmt.Errorf("derive hermes config: %w", err)
}
if err := writeDerivedHermesEnv(sharedHome, hermesHome); err != nil {
return fmt.Errorf("derive hermes .env: %w", err)
}
return writeHermesBoundSkills(hermesHome, workspaceSkills, logger)
}
// writeDerivedHermesEnv writes the task-local .env: the source home's .env
// contents (credentials/settings preserved) with any HERMES_HOME assignment
// removed, then a pinned HERMES_HOME pointing at the overlay appended last so it
// wins. Hermes loads <HERMES_HOME>/.env with override=True right after profile
// resolution, so without this an out-of-band HERMES_HOME= in the source .env
// would relocate the home past the overlay (dropping bound skills and memory
// isolation). We always write the file — even when the source has none — so the
// overlay .env "loads" and Hermes' project-.env fallback (override=True only when
// no user .env loaded) can't relocate the home either. Written 0600 via atomic
// replace since it can hold API-key secrets; reuse also repairs prior perms.
func writeDerivedHermesEnv(sharedHome, hermesHome string) error {
dst := filepath.Join(hermesHome, ".env")
var body []byte
src, err := os.ReadFile(filepath.Join(sharedHome, ".env"))
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("read shared .env: %w", err)
}
} else {
body = stripDotenvAssignment(src, "HERMES_HOME")
}
var buf strings.Builder
if len(body) > 0 {
buf.Write(body)
if body[len(body)-1] != '\n' {
buf.WriteByte('\n')
}
}
// Pin HERMES_HOME to the overlay. Single-quote the value so python-dotenv
// treats it literally (no escaping / var expansion) — task home paths can
// contain spaces or other characters under the workspaces root.
fmt.Fprintf(&buf, "HERMES_HOME='%s'\n", hermesHome)
return writeFileAtomic(dst, []byte(buf.String()), 0o600)
}
// stripDotenvAssignment drops every line of a .env file that assigns key,
// tolerating a leading `export ` and surrounding whitespace the way python-dotenv
// does. Other lines (including comments and blanks) are preserved verbatim.
func stripDotenvAssignment(content []byte, key string) []byte {
lines := strings.Split(string(content), "\n")
out := lines[:0]
for _, line := range lines {
if dotenvLineKey(line) == key {
continue
}
out = append(out, line)
}
return []byte(strings.Join(out, "\n"))
}
// dotenvLineKey returns the variable name a .env line assigns, or "" for a
// comment/blank/non-assignment line.
func dotenvLineKey(line string) string {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
return ""
}
if rest := strings.TrimPrefix(s, "export"); rest != s && rest != "" &&
(rest[0] == ' ' || rest[0] == '\t') {
s = strings.TrimSpace(rest)
}
eq := strings.IndexByte(s, '=')
if eq <= 0 {
return ""
}
return strings.TrimSpace(s[:eq])
}
// mirrorSharedHermesHome symlinks every top-level entry of the shared ~/.hermes/
// into the per-task home except the overlay-owned entries, then reconciles the
// destination so entries removed from the shared home (or left over from a prior
// reuse) don't linger as readable stale state. Symlinks share state with the
// user's real home (auth/OAuth refreshes propagate, no credential copy lingers
// in task scratch). The shared home itself is never written — we only read it
// and create links pointing into it.
func mirrorSharedHermesHome(sharedHome, hermesHome string, logger *slog.Logger) error {
entries, err := os.ReadDir(sharedHome)
if err != nil {
if os.IsNotExist(err) {
// No shared home to mirror. The derived config + bound skills still
// give Hermes a working home, so this is not fatal on its own.
return reconcileMirroredEntries(hermesHome, nil)
}
return fmt.Errorf("read shared home: %w", err)
}
mirrored := make(map[string]struct{}, len(entries))
for _, entry := range entries {
name := entry.Name()
if isHermesOverlayOwnedEntry(name) {
continue
}
src := filepath.Join(sharedHome, name)
dst := filepath.Join(hermesHome, name)
if err := linkSharedHermesEntry(src, dst); err != nil {
return fmt.Errorf("mirror %s: %w", name, err)
}
mirrored[name] = struct{}{}
}
return reconcileMirroredEntries(hermesHome, mirrored)
}
// reconcileMirroredEntries removes overlay entries that are neither overlay-owned
// nor currently mirrored from the shared home, so a shared entry deleted between
// runs (or a Windows copy-fallback left behind) doesn't survive as stale state.
func reconcileMirroredEntries(hermesHome string, mirrored map[string]struct{}) error {
entries, err := os.ReadDir(hermesHome)
if err != nil {
return fmt.Errorf("read overlay home: %w", err)
}
for _, entry := range entries {
name := entry.Name()
if isHermesOverlayOwnedEntry(name) {
continue
}
if _, keep := mirrored[name]; keep {
continue
}
if err := os.RemoveAll(filepath.Join(hermesHome, name)); err != nil {
return fmt.Errorf("reconcile stale %s: %w", name, err)
}
}
return nil
}
// prepareHermesTaskLocalState migrates an overlay built by an older daemon away
// from the shared Hermes SQLite session store. Without the marker, state.db and
// its sidecars may be symlinks or independently copied files; neither is safe to
// reuse as task-local state. Remove only those entries inside the generated
// overlay, then record the migration atomically. Hermes lazily creates a fresh
// database, and later prepares preserve it because the marker is present.
func prepareHermesTaskLocalState(hermesHome string) error {
marker := filepath.Join(hermesHome, hermesTaskLocalStateMarker)
if fi, err := os.Lstat(marker); err == nil {
if !fi.Mode().IsRegular() {
return fmt.Errorf("state marker is not a regular file: %s", marker)
}
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat state marker: %w", err)
}
entries, err := os.ReadDir(hermesHome)
if err != nil {
return fmt.Errorf("read overlay home: %w", err)
}
for _, entry := range entries {
if !isHermesTaskLocalStateEntry(entry.Name()) {
continue
}
path := filepath.Join(hermesHome, entry.Name())
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("remove legacy task state %s: %w", path, err)
}
}
return writeFileAtomic(marker, []byte("task-local Hermes state\n"), 0o600)
}
// linkSharedHermesEntry symlinks dst → src, idempotent across Reuse: an existing
// link already pointing at src is left alone; anything else is removed and
// recreated so the overlay never drifts from the shared home. A dangling source
// (a broken symlink in the user's home) is skipped, not failed. Directories use
// createDirLink and files createFileLink so the Windows copy fallbacks match the
// entry kind.
func linkSharedHermesEntry(src, dst string) error {
if fi, err := os.Lstat(dst); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(dst); err == nil && target == src {
return nil
}
}
if err := os.RemoveAll(dst); err != nil {
return fmt.Errorf("remove stale %s: %w", dst, err)
}
}
info, err := os.Stat(src) // follow the link to decide dir vs file
if err != nil {
if os.IsNotExist(err) {
return nil // dangling source in the user's home — nothing to link
}
return fmt.Errorf("stat %s: %w", src, err)
}
if info.IsDir() {
return createDirLink(src, dst)
}
return createFileLink(src, dst)
}
// writeDerivedHermesConfig writes the task-local config.yaml: the user's config
// with `skills.external_dirs` set to their existing external dirs plus the shared
// ~/.hermes/skills, all as absolute paths. When the user has no config we still
// write a minimal one so their global skills stay reachable via the external
// root. If the config can't be parsed we copy it verbatim so auth/model settings
// survive — the bound skills still load from the task-local skills/ dir, which is
// the point of the fix; only the user's global skills would be missing. The file
// is written 0600 (it can hold inline api_key secrets) via atomic replace, so
// reuse also repairs a prior file's permissions.
func writeDerivedHermesConfig(sharedHome, hermesHome string, env map[string]string, logger *slog.Logger) error {
srcConfig := filepath.Join(sharedHome, "config.yaml")
dstConfig := filepath.Join(hermesHome, "config.yaml")
data, err := os.ReadFile(srcConfig)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("read shared config: %w", err)
}
doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}}
if err := setHermesExternalDirs(doc, computeHermesExternalDirs(sharedHome, nil, env)); err != nil {
return err
}
return marshalYAMLToFile(doc, dstConfig)
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
logger.Warn("execenv: hermes-home config parse failed; copying verbatim", "error", err)
return writeFileAtomic(dstConfig, data, 0o600)
}
dirs := computeHermesExternalDirs(sharedHome, existingHermesExternalDirs(&doc), env)
if err := setHermesExternalDirs(&doc, dirs); err != nil {
logger.Warn("execenv: hermes-home set external_dirs failed; copying verbatim", "error", err)
return writeFileAtomic(dstConfig, data, 0o600)
}
// Disable any host-configured external memory backend (memory.provider) so a
// Supermemory/Hindsight/etc. bank isn't shared across managed tasks; the
// built-in per-task memories/ dir is already isolated above.
disableHermesMemoryProvider(&doc)
return marshalYAMLToFile(&doc, dstConfig)
}
// disableHermesMemoryProvider forces skills-adjacent `memory.provider` to empty
// in the derived config. Hermes activates an external memory plugin only when
// memory.provider is a non-blank string (agent/agent_init.py), so "" is the
// explicit off switch. The built-in note/user-profile memory is unaffected — it
// writes to the isolated per-task memories/ dir.
func disableHermesMemoryProvider(doc *yaml.Node) {
top := yamlDocumentRoot(doc)
if top == nil {
return
}
memory := yamlMapValue(top, "memory")
if memory == nil || memory.Kind != yaml.MappingNode {
memory = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
yamlSetMapValue(top, "memory", memory)
}
yamlSetMapValue(memory, "provider", &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""})
}
// computeHermesExternalDirs normalizes the user's existing external_dirs to
// absolute paths and appends the shared skills dir as a read-only external root.
// Variable/`~` expansion uses the sanitized effective env (the same map layered
// onto the Hermes child), falling back to the daemon process env — so a `${VAR}`
// configured on the agent resolves to what Hermes will actually see, and a var
// the daemon blocklists (e.g. HOME) resolves to the process value rather than
// the dropped custom one. Unknown variables are PRESERVED as `${VAR}` (matching
// Hermes/Python expandvars) rather than collapsed to empty, so a path meant to
// be resolved at runtime isn't silently rewritten. An entry still containing an
// unresolved `${` is left as-is (Hermes expands it later); otherwise relative
// paths resolve against the shared home, matching pre-redirect behavior. Order
// preserved; duplicates dropped.
func computeHermesExternalDirs(sharedHome string, existing []string, env map[string]string) []string {
expand := func(s string) string {
return os.Expand(s, func(k string) string {
if v, ok := env[k]; ok {
return v
}
if v, ok := os.LookupEnv(k); ok {
return v
}
return "${" + k + "}" // preserve unknown vars for runtime expansion
})
}
out := make([]string, 0, len(existing)+1)
seen := make(map[string]struct{}, len(existing)+1)
add := func(p string) {
if p == "" {
return
}
if _, ok := seen[p]; ok {
return
}
seen[p] = struct{}{}
out = append(out, p)
}
for _, raw := range existing {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
entry = strings.TrimSpace(expand(entry))
if entry == "" {
continue
}
// An unresolved ${VAR} remains (unknown var preserved above) — leave the
// entry untouched so Hermes expands and resolves it at runtime; we can't
// safely decide abs-vs-relative here.
if strings.Contains(entry, "${") {
add(entry)
continue
}
if entry == "~" || strings.HasPrefix(entry, "~/") {
if home, err := os.UserHomeDir(); err == nil {
entry = filepath.Join(home, strings.TrimPrefix(entry, "~"))
}
}
if !filepath.IsAbs(entry) {
entry = filepath.Join(sharedHome, entry)
}
add(filepath.Clean(entry))
}
// The shared skills dir, referenced (not copied) so the user's global and
// builtin skills stay visible. It differs from the task-local skills dir,
// so Hermes won't fold it into the local root, and being last it yields to
// the bound skills on a name collision.
add(filepath.Join(sharedHome, "skills"))
return out
}
// writeHermesBoundSkills rebuilds the task-local skills/ dir from scratch so a
// skill removed since the last run can't linger, then writes only the
// Multica-bound skills. They keep their natural slug (no user skills share this
// dir) and therefore take precedence over any same-named external skill.
func writeHermesBoundSkills(hermesHome string, workspaceSkills []SkillContextForEnv, logger *slog.Logger) error {
skillsDir := filepath.Join(hermesHome, "skills")
if err := os.RemoveAll(skillsDir); err != nil {
return fmt.Errorf("clear hermes skills dir: %w", err)
}
if len(workspaceSkills) == 0 {
// Defensive: callers gate on a non-empty set, but stay correct if that
// ever changes — an empty local dir just means the external root is the
// only source, matching un-redirected behavior.
return os.MkdirAll(skillsDir, 0o700)
}
// Skills live under env.RootDir/hermes-home, which the GC loop (cloud) or
// env teardown (local_directory) wipes wholesale — no sidecar manifest.
return writeSkillFiles(skillsDir, workspaceSkills, nil)
}
// existingHermesExternalDirs reads the raw skills.external_dirs entries from a
// parsed config document, accepting either a single string or a list (both of
// which Hermes accepts). Returns nil when absent.
func existingHermesExternalDirs(doc *yaml.Node) []string {
top := yamlDocumentRoot(doc)
if top == nil {
return nil
}
skills := yamlMapValue(top, "skills")
ed := yamlMapValue(skills, "external_dirs")
if ed == nil {
return nil
}
if ed.Kind == yaml.ScalarNode {
return []string{ed.Value}
}
if ed.Kind != yaml.SequenceNode {
return nil
}
out := make([]string, 0, len(ed.Content))
for _, c := range ed.Content {
if c.Kind == yaml.ScalarNode {
out = append(out, c.Value)
}
}
return out
}
// setHermesExternalDirs sets skills.external_dirs on the config document,
// creating the skills mapping if needed and preserving every other setting.
func setHermesExternalDirs(doc *yaml.Node, dirs []string) error {
top := yamlDocumentRoot(doc)
if top == nil {
return fmt.Errorf("hermes config: unexpected root node")
}
skills := yamlMapValue(top, "skills")
if skills == nil || skills.Kind != yaml.MappingNode {
skills = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
yamlSetMapValue(top, "skills", skills)
}
yamlSetMapValue(skills, "external_dirs", yamlStringSeq(dirs))
return nil
}
// yamlDocumentRoot returns the top-level mapping node of a parsed document, or
// nil if the shape isn't a mapping.
func yamlDocumentRoot(doc *yaml.Node) *yaml.Node {
if doc == nil {
return nil
}
node := doc
if node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return nil
}
node = node.Content[0]
}
if node.Kind != yaml.MappingNode {
return nil
}
return node
}
// yamlMapValue returns the value node for key in a mapping node, or nil.
func yamlMapValue(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// yamlSetMapValue sets key to val in a mapping node, replacing in place if the
// key exists or appending otherwise.
func yamlSetMapValue(m *yaml.Node, key string, val *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content[i+1] = val
return
}
}
m.Content = append(m.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
val,
)
}
// yamlStringSeq builds a YAML sequence node of string scalars.
func yamlStringSeq(vals []string) *yaml.Node {
seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, v := range vals {
seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v})
}
return seq
}
// marshalYAMLToFile renders a YAML node to dst as a 0600 file (it can hold
// inline secrets) via atomic replace.
func marshalYAMLToFile(doc *yaml.Node, dst string) error {
out, err := yaml.Marshal(doc)
if err != nil {
return fmt.Errorf("marshal hermes config: %w", err)
}
return writeFileAtomic(dst, out, 0o600)
}
// writeFileAtomic writes data to a temp file in the destination directory with
// the given perms, then renames it over dst — so readers never see a partial
// file and a prior file's looser permissions are replaced.
func writeFileAtomic(dst string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(dst)
tmp, err := os.CreateTemp(dir, ".hermes-tmp-*")
if err != nil {
return fmt.Errorf("create temp for %s: %w", dst, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp for %s: %w", dst, err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return fmt.Errorf("chmod temp for %s: %w", dst, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp for %s: %w", dst, err)
}
if err := os.Rename(tmpName, dst); err != nil {
return fmt.Errorf("rename temp to %s: %w", dst, err)
}
return nil
}