multica-ai/multica · error

stat %s: %w

Error message

stat %s: %w

What it means

linkSharedHermesEntry could not os.Stat the shared-home source entry, and the failure was not 'does not exist' (a dangling symlink in the user's home is deliberately skipped with nil). Stat follows symlinks to decide dir-vs-file linking, so errors like EACCES on a path component or ELOOP on a symlink cycle propagate.

Source

Thrown at server/internal/daemon/execenv/hermes_home.go:624

// 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")

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Reproduce as the daemon user: `stat <sharedHome>/<entry>`; fix the permission on the failing path component.
  2. Find and break symlink cycles: `namei -l <path>` or `readlink` chain inspection.
  3. Grant the daemon's account read access to the whole Hermes home, or run the daemon as the home's owner.
  4. Remove the offending entry from the shared home if it is not needed by Hermes.
Defensive patterns

Strategy: validation

Validate before calling

if _, err := os.Stat(src); err != nil && !errors.Is(err, fs.ErrNotExist) {
	return fmt.Errorf("shared entry unstatable, skipping mirror of %s: %w", src, err)
}

Try / catch

if err := linkSharedHermesEntry(src, dst); err != nil {
	var pe *os.PathError
	if errors.As(err, &pe) {
		switch {
		case errors.Is(pe.Err, syscall.ELOOP):
			return nil // symlink cycle in user's home: skip, do not fail the overlay
		case errors.Is(pe.Err, syscall.EACCES):
			log.Printf("no permission to stat %s — fix shared home perms", pe.Path)
		}
	}
	return err
}

Prevention

When it happens

Trigger: A component of the source path lacks execute/search permission for the daemon user; the entry is a symlink loop (a→b→a); the path exceeds NAME_MAX; I/O error on the volume holding the shared home.

Common situations: Shared Hermes home on restrictive storage (mode-600 directories from another user); manually-created cyclic symlinks inside ~/.hermes; enterprise ACL'd network shares; multi-user hosts where the daemon account is not the home owner.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/72a96cd48ce594b5. Report an issue: GitHub.