Temp path and credential file issues in non-standard environments

NousResearch/hermes-agent · cluster 3665 · agent

Data as of

4 open items. Fix merged in #3671. Check it covers your case.

The short answer

1open issue
3open PRs
14items grouped
Known?
Yes. 14 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #3671. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #3671. That is separate from the fix event.
Still open
1 issue and 3 PRs.

These issues and PRs address two adjacent problems: hardcoded /tmp paths breaking tools on Termux/Android, and credential files not being accessible in remote terminal backends like Modal and Docker. Both themes involve environment-specific file path and mounting concerns.

Open issues1

  • Issue#47264[Bug] Modal sandbox tools (write_file/terminal) fail with "Token ID is malformed" on Termux.

Open PRs3

  • PR#52420fix: use TMPDIR env var instead of hardcoded /tmp for Termux/Android
  • PR#117737fix(terminal): diagnose Modal auth failures instead of surfacing the raw SDK error
  • PR#141512fix: respect Termux/TMPDIR env vars in temp-dir fallbacks

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3671 feat: mount skill credential files into remote terminal backends (Modal/Docker)

    merged to main

    files: skills/productivity/google-workspace/SKILL.md, tests/tools/test_credential_files.py, tools/credential_files.py, tools/environments/docker.py, tools/environments/modal.py, tools/skills_tool.py

    Show the diff
    • skills/productivity/google-workspace/SKILL.md
      @@ -4,6 +4,11 @@ description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via
       version: 1.0.0
       author: Nous Research
       license: MIT
      +required_credential_files:
      +  - path: google_token.json
      +    description: Google OAuth2 token (created by setup script)
      +  - path: google_client_secret.json
      +    description: Google OAuth2 client credentials (downloaded from Google Cloud Console)
       metadata:
         hermes:
           tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]
    • tests/tools/test_credential_files.py
      @@ -0,0 +1,158 @@
      +"""Tests for credential file passthrough registry (tools/credential_files.py)."""
      +
      +import os
      +from pathlib import Path
      +
      +import pytest
      +
      +from tools.credential_files import (
      +    clear_credential_files,
      +    get_credential_file_mounts,
      +    register_credential_file,
      +    register_credential_files,
      +    reset_config_cache,
      +)
      +
      +
      [redacted:email](autouse=True)
      +def _clean_registry():
      +    """Reset registry between tests."""
      +    clear_credential_files()
      +    reset_config_cache()
      +    yield
      +    clear_credential_files()
      +    reset_config_cache()
      +
      +
      +class TestRegisterCredentialFile:
      +    def test_registers_existing_file(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "token.json").write_text('{"token": "abc"}')
      +
      +        result = register_credential_file("token.json")
      +
      +        assert result is True
      +        mounts = get_credential_file_mounts()
      +        assert len(mounts) == 1
      +        assert mounts[0]["host_path"] == str(tmp_path / "token.json")
      +        assert mounts[0]["container_path"] == "/root/.hermes/token.json"
      +
      +    def test_skips_missing_file(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +
      +        result = register_credential_file("nonexistent.json")
      +
      +        assert result is False
      +        assert get_credential_file_mounts() == []
      +
      +    def test_custom_container_base(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "cred.json").write_text("{}")
      +
      +        register_credential_file("cred.json", container_base="/home/user/.hermes")
      +
      +        mounts = get_credential_file_mounts()
      +        assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json"
      +
      +    def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "token.json").write_text("{}")
      +
      +        register_credential_file("token.json")
      +        register_credential_file("token.json")
      +
      +        mounts = get_credential_file_mounts()
      +        assert len(mounts) == 1
      +
      +
      +class TestRegisterCredentialFiles:
      +    def test_string_entries(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "a.json").write_text("{}")
      +        (tmp_path / "b.json").write_text("{}")
      +
      +        missing = register_credential_files(["a.json", "b.json"])
      +
      +        assert missing == []
      +        assert len(get_credential_file_mounts()) == 2
      +
      +    def test_dict_entries(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "token.json").write_text("{}")
      +
      +        missing = register_credential_files([
      +            {"path": "token.json", "description": "OAuth token"},
      +        ])
      +
      +        assert missing == []
      +        assert len(get_credential_file_mounts()) == 1
      +
      +    def test_returns_missing_files(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "exists.json").write_text("{}")
      +
      +        missing = register_credential_files([
      +            "exists.json",
      +            "missing.json",
      +            {"path": "also_missing.json"},
      +        ])
      +
      +        assert missing == ["missing.json", "also_missing.json"]
      +        assert len(get_credential_file_mounts()) == 1
      +
      +    def test_empty_list(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        assert register_credential_files([]) == []
      +
      +
      +class TestConfigCredentialFiles:
      +    def test_loads_from_config(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "oauth.json").write_text("{}")
      +        (tmp_path / "config.yaml").write_text(
      +            "terminal:\n  credential_files:\n    - oauth.json\n"
      +        )
      +
      +        mounts = get_credential_file_mounts()
      +
      +        assert len(mounts) == 1
      +        assert mounts[0]["host_path"] == str(tmp_path / "oauth.json")
      +
      +    def test_config_skips_missing_files(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "config.yaml").write_text(
      +            "terminal:\n  credential_files:\n    - nonexistent.json\n"
      +        )
      +
      +        mounts = get_credential_file_mounts()
      +        assert mounts == []
      +
      +    def test_combines_skill_and_config(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skill_token.json").write_text("{}")
      +        (tmp_path / "config_token.json").write_text("{}")
      +        (tmp_path / "config.yaml").write_text(
      +            "terminal:\n  credential_files:\n    - config_token.json\n"
      +        )
      +
      +        register_credential_file("skill_token.json")
      +        mounts = get_credential_file_mounts()
      +
      +        assert len(mounts) == 2
      +        paths = {m["container_path"] for m in mounts}
      +        assert "/root/.hermes/skill_token.json" in paths
      +        assert "/root/.hermes/config_token.json" in paths
      +
      +
      +class TestGetMountsRechecksExistence:
      +    def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        token = [redacted:secret-assignment] / "token.json"
      +        token.write_text("{}")
      +
      +        register_credential_file("token.json")
      +        assert len(get_credential_file_mounts()) == 1
      +
      +        # Delete the file after registration
      +        token.unlink()
      +        assert get_credential_file_mounts() == []
    • tools/credential_files.py
      @@ -0,0 +1,163 @@
      +"""Credential file passthrough registry for remote terminal backends.
      +
      +Skills that declare ``required_credential_files`` in their frontmatter need
      +those files available inside sandboxed execution environments (Modal, Docker).
      +By default remote backends create bare containers with no host files.
      +
      +This module provides a session-scoped registry so skill-declared credential
      +files (and user-configured overrides) are mounted into remote sandboxes.
      +
      +Two sources feed the registry:
      +
      +1. **Skill declarations** — when a skill is loaded via ``skill_view``, its
      +   ``required_credential_files`` entries are registered here if the files
      +   exist on the host.
      +2. **User config** — ``terminal.credential_files`` in config.yaml lets users
      +   explicitly list additional files to mount.
      +
      +Remote backends (``tools/environments/modal.py``, ``docker.py``) call
      +:func:`get_credential_file_mounts` at sandbox creation time.
      +
      +Each registered entry is a dict::
      +
      +    {
      +        "host_path": "/home/user/.hermes/google_token.json",
      +        "container_path": "/root/.hermes/google_token.json",
      +    }
      +"""
      +
      +from __future__ import annotations
      +
      +import logging
      +import os
      +from pathlib import Path
      +from typing import Dict, List
      +
      +logger = logging.getLogger(__name__)
      +
      +# Session-scoped list of credential files to mount.
      +# Key: container_path (deduplicated), Value: host_path
      +_registered_files: Dict[str, str] = {}
      +
      +# Cache for config-based file list (loaded once per process).
      +_config_files: List[Dict[str, str]] | None = None
      +
      +
      +def _resolve_hermes_home() -> Path:
      +    return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
      +
      +
      +def register_credential_file(
      +    relative_path: str,
      +    container_base: str = "/root/.hermes",
      +) -> bool:
      +    """Register a credential file for mounting into remote sandboxes.
      +
      +    *relative_path* is relative to ``HERMES_HOME`` (e.g. ``google_token.json``).
      +    Returns True if the file exists on the host and was registered.
      +    """
      +    hermes_home = _resolve_hermes_home()
      +    host_path = hermes_home / relative_path
      +    if not host_path.is_file():
      +        logger.debug("credential_files: skipping %s (not found)", host_path)
      +        return False
      +
      +    container_path = f"{container_base.rstrip('/')}/{relative_path}"
      +    _registered_files[container_path] = str(host_path)
      +    logger.debug("credential_files: registered %s -> %s", host_path, container_path)
      +    return True
      +
      +
      +def register_credential_files(
      +    entries: list,
      +    container_base: str = "/root/.hermes",
      +) -> List[str]:
      +    """Register multiple credential files from skill frontmatter entries.
      +
      +    Each entry is either a string (relative path) or a dict with a ``path``
      +    key.  Returns the list of relative paths that were NOT found on the host
      +    (i.e. missing files).
      +    """
      +    missing = []
      +    for entry in entries:
      +        if isinstance(entry, str):
      +            rel_path = entry.strip()
      +        elif isinstance(entry, dict):
      +            rel_path = (entry.get("path") or "").strip()
      +        else:
      +            continue
      +        if not rel_path:
      +            continue
      +        if not register_credential_file(rel_path, container_base):
      +            missing.append(rel_path)
      +    return missing
      +
      +
      +def _load_config_files() -> List[Dict[str, str]]:
      +    """Load ``terminal.credential_files`` from config.yaml (cached)."""
      +    global _config_files
      +    if _config_files is not None:
      +        return _config_files
      +
      +    result: List[Dict[str, str]] = []
      +    try:
      +        hermes_home = _resolve_hermes_home()
      +        config_path = hermes_home / "config.yaml"
      +        if config_path.exists():
      +            import yaml
      +
      +            with open(config_path) as f:
      +                cfg = yaml.safe_load(f) or {}
      +            cred_files = cfg.get("terminal", {}).get("credential_files")
      +            if isinstance(cred_files, list):
      +                for item in cred_files:
      +                    if isinstance(item, str) and item.strip():
      +                        host_path = hermes_home / item.strip()
      +                        if host_path.is_file():
      +                            container_path = f"/root/.hermes/{item.strip()}"
      +                            result.append({
      +                                "host_path": str(host_path),
      +                                "container_path": container_path,
      +                            })
      +    except Exception as e:
      +        logger.debug("Could not read terminal.credential_files from config: %s", e)
      +
      +    _config_files = result
      +    return _config_files
      +
      +
      +def get_credential_file_mounts() -> List[Dict[str, str]]:
      +    """Return all credential files that should be mounted into remote sandboxes.
      +
      +    Each item has ``host_path`` and ``container_path`` keys.
      +    Combines skill-registered files and user config.
      +    """
      +    mounts: Dict[str, str] = {}
      +
      +    # Skill-registered files
      +    for container_path, host_path in _registered_files.items():
      +        # Re-check existence (file may have been deleted since registration)
      +        if Path(host_path).is_file():
      +            mounts[container_path] = host_path
      +
      +    # Config-based files
      +    for entry in _load_config_files():
      +        cp = entry["container_path"]
      +        if cp not in mounts and Path(entry["host_path"]).is_file():
      +            mounts[cp] = entry["host_path"]
      +
      +    return [
      +        {"host_path": hp, "container_path": cp}
      +        for cp, hp in mounts.items()
      +    ]
      +
      +
      +def clear_credential_files() -> None:
      +    """Reset the skill-scoped registry (e.g. on session reset)."""
      +    _registered_files.clear()
      +
      +
      +def reset_config_cache() -> None:
      +    """Force re-read of config on next access (for testing)."""
      +    global _config_files
      +    _config_files = None
    • tools/environments/docker.py
      @@ -312,6 +312,24 @@ def __init__(
               elif workspace_explicitly_mounted:
                   logger.debug("Skipping docker cwd mount: /workspace already mounted by user config")
       
      +        # Mount credential files (OAuth tokens, etc.) declared by skills.
      +        # Read-only so the container can authenticate but not modify host creds.
      +        try:
      +            from tools.credential_files import get_credential_file_mounts
      +
      +            for mount_entry in get_credential_file_mounts():
      +                volume_args.extend([
      +                    "-v",
      +                    f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro",
      +                ])
      +                logger.info(
      +                    "Docker: mounting credential %s -> %s",
      +                    mount_entry["host_path"],
      +                    mount_entry["container_path"],
      +                )
      +        except Exception as e:
      +            logger.debug("Docker: could not load credential file mounts: %s", e)
      +
               logger.info(f"Docker volume_args: {volume_args}")
               all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args
               logger.info(f"Docker run_args: {all_run_args}")
      @@ -406,8 +424,17 @@ def execute(self, command: str, cwd: str = "", *,
               if effective_stdin is not None:
                   cmd.append("-i")
               cmd.extend(["-w", work_dir])
      -        hermes_env = _load_hermes_env_vars() if self._forward_env else {}
      -        for key in self._forward_env:
      +        # Combine explicit docker_forward_env with skill-declared env_passthrough
      +        # vars so skills that declare required_environment_variables (e.g. Notion)
      +        # have their keys forwarded into the container automatically.
      +        forward_keys = set(self._forward_env)
      +        try:
      +            from tools.env_passthrough import get_all_passthrough
      +            forward_keys |= get_all_passthrough()
      +        except Exception:
      +            pass
      +        hermes_env = _load_hermes_env_vars() if forward_keys else {}
      +        for key in sorted(forward_keys):
                   value = os.getenv(key)
                   if value is None:
                       value = hermes_env.get(key)
    • tools/environments/modal.py
      @@ -137,6 +137,28 @@ def __init__(
                       ],
                   )
       
      +        # Mount credential files (OAuth tokens, etc.) declared by skills.
      +        # These are read-only copies so the sandbox can authenticate with
      +        # external services but can't modify the host's credentials.
      +        cred_mounts = []
      +        try:
      +            from tools.credential_files import get_credential_file_mounts
      +
      +            for mount_entry in get_credential_file_mounts():
      +                cred_mounts.append(
      +                    _modal.Mount.from_local_file(
      +                        mount_entry["host_path"],
      +                        remote_path=mount_entry["container_path"],
      +                    )
      +                )
      +                logger.info(
      +                    "Modal: mounting credential %s -> %s",
      +                    mount_entry["host_path"],
      +                    mount_entry["container_path"],
      +                )
      +        except Exception as e:
      +            logger.debug("Modal: could not load credential file mounts: %s", e)
      +
               # Start the async worker thread and create sandbox on it
               # so all gRPC channels are bound to the worker's event loop.
               self._worker.start()
      @@ -145,23 +167,90 @@ async def _create_sandbox():
                   app = await _modal.App.lookup.aio(
                       "hermes-agent", create_if_missing=True
                   )
      +            create_kwargs = dict(sandbox_kwargs)
      +            if cred_mounts:
      +                existing_mounts = list(create_kwargs.pop("mounts", []))
      +                existing_mounts.extend(cred_mounts)
      +                create_kwargs["mounts"] = existing_mounts
                   sandbox = await _modal.Sandbox.create.aio(
                       "sleep", "infinity",
                       image=effective_image,
                       app=app,
      -                timeout=int(sandbox_kwargs.pop("timeout", 3600)),
      -                **sandbox_kwargs,
      +                timeout=int(create_kwargs.pop("timeout", 3600)),
      +                **create_kwargs,
                   )
                   return app, sandbox
       
               self._app, self._sandbox = self._worker.run_coroutine(
                   _create_sandbox(), timeout=300
               )
      +        # Track synced credential files to avoid redundant pushes.
      +        # Key: container_path, Value: (mtime, size) of last synced version.
      +        self._synced_creds: Dict[str, tuple] = {}
               logger.info("Modal: sandbox created (task=%s)", self._task_id)
       
      +    def _sync_credential_files(self) -> None:
      +        """Push credential files into the running sandbox.
      +
      +        Mounts are set at sandbox creation, but credentials may be created
      +        later (e.g. OAuth setup mid-session).  This writes the current file
      +        content into the sandbox via exec(), so new/updated credentials are
      +        available without recreating the sandbox.
      +        """
      +        try:
      +            from tools.credential_files import get_credential_file_mounts
      +
      +            mounts = get_credential_file_mounts()
      +            if not mounts:
      +                return
      +
      +            for entry in mounts:
      +                host_path = entry["host_path"]
      +                container_path = entry["container_path"]
      +                hp = Path(host_path)
      +                try:
      +                    stat = hp.stat()
      +                    file_key = (stat.st_mtime, stat.st_size)
      +                except OSError:
      +                    continue
      +
      +                # Skip if already synced with same mtime+size
      +                if self._synced_creds.get(container_path) == file_key:
      +                    continue
      +
      +                try:
      +                    content = hp.read_text(encoding="utf-8")
      +                except Exception:
      +                    continue
      +
      +                # Write via base64 to avoid shell escaping issues with JSON
      +                import base64
      +                b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
      +                container_dir = str(Path(container_path).parent)
      +                cmd = (
      +                    f"mkdir -p {shlex.quote(container_dir)} && "
      +                    f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
      +                )
      +
      +                _cp = container_path  # capture for closure
      +
      +                async def _write():
      +                    proc = await self._sandbox.exec.aio("bash", "-c", cmd)
      +                    await proc.wait.aio()
      +
      +                self._worker.run_coroutine(_write(), timeout=15)
      +                self._synced_creds[container_path] = file_key
      +                logger.debug("Modal: synced credential %s -> %s", host_path, container_path)
      +        except Exception as e:
      +            logger.debug("Modal: credential file sync failed: %s", e)
      +
           def execute(self, command: str, cwd: str = "", *,
                       timeout: int | None = None,
                       stdin_data: str | None = None) -> dict:
      +        # Sync credential files before each command so mid-session
      +        # OAuth setups are picked up without requiring a restart.
      +        self._sync_credential_files()
      +
               if stdin_data is not None:
                   marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
                   while marker in stdin_data:
    • tools/skills_tool.py
      @@ -1106,6 +1106,27 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
                           exc_info=True,
                       )
       
      +        # Register credential files for mounting into remote sandboxes
      +        # (Modal, Docker).  Files that exist on the host are registered;
      +        # missing ones are added to the setup_needed indicators.
      +        required_cred_files_raw = frontmatter.get("required_credential_files", [])
      +        if not isinstance(required_cred_files_raw, list):
      +            required_cred_files_raw = []
      +        missing_cred_files: list = []
      +        if required_cred_files_raw:
      +            try:
      +                from tools.credential_files import register_credential_files
      +
      +                missing_cred_files = register_credential_files(required_cred_files_raw)
      +                if missing_cred_files:
      +                    setup_needed = True
      +            except Exception:
      +                logger.debug(
      +                    "Could not register credential files for skill %s",
      +                    skill_name,
      +                    exc_info=True,
      +                )
      +
               result = {
                   "success": True,
                   "name": skill_name,
      @@ -1121,6 +1142,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
                   "required_environment_variables": required_env_vars,
                   "required_commands": [],
                   "missing_required_environment_variables": remaining_missing_required_envs,
      +            "missing_credential_files": missing_cred_files,
                   "missing_required_commands": [],
                   "setup_needed": setup_needed,
                   "setup_skipped": capture_result["setup_skipped"],
      @@ -1139,6 +1161,8 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
               if setup_needed:
                   missing_items = [
                       f"env ${env_name}" for env_name in remaining_missing_required_envs
      +            ] + [
      +                f"file {path}" for path in missing_cred_files
                   ]
                   setup_note = _build_setup_note(
                       SkillReadinessStatus.SETUP_NEEDED,
  • #19886 fix(google-workspace): restore required_credential_files in SKILL.md

    merged to main

    files: skills/productivity/google-workspace/SKILL.md, tests/skills/test_google_workspace_credential_files.py

    Show the diff
    • skills/productivity/google-workspace/SKILL.md
      @@ -1,9 +1,14 @@
       ---
       name: google-workspace
       description: "Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python."
      -version: 1.0.0
      +version: 1.0.1
       author: Nous Research
       license: MIT
      +required_credential_files:
      +  - path: google_token.json
      +    description: Google OAuth2 token (created by setup script)
      +  - path: google_client_secret.json
      +    description: Google OAuth2 client credentials (downloaded from Google Cloud Console)
       metadata:
         hermes:
           tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]
    • tests/skills/test_google_workspace_credential_files.py
      @@ -0,0 +1,102 @@
      +"""Regression test: google-workspace SKILL.md must declare required_credential_files.
      +
      +PR #9931 accidentally removed the required_credential_files header, which broke
      +credential file mounting in Docker/Modal remote backends (#16452). This test
      +prevents the regression from silently reappearing.
      +"""
      +
      +from __future__ import annotations
      +
      +import os
      +from pathlib import Path
      +from unittest.mock import patch
      +
      +import pytest
      +
      +SKILL_MD = (
      +    Path(__file__).resolve().parents[2]
      +    / "skills/productivity/google-workspace/SKILL.md"
      +)
      +
      +_EXPECTED_PATHS = {"google_token.json", "google_client_secret.json"}
      +
      +
      +def _parse_frontmatter(content: str) -> dict:
      +    from agent.skill_utils import parse_frontmatter
      +
      +    fm, _ = parse_frontmatter(content)
      +    return fm
      +
      +
      +class TestGoogleWorkspaceCredentialFiles:
      +    def test_required_credential_files_present_in_skill_md(self):
      +        content = SKILL_MD.read_text(encoding="utf-8")
      +        fm = _parse_frontmatter(content)
      +        entries = fm.get("required_credential_files")
      +        assert entries, "required_credential_files missing from google-workspace SKILL.md"
      +        assert isinstance(entries, list), "required_credential_files must be a list"
      +        paths = {
      +            (e["path"] if isinstance(e, dict) else e)
      +            for e in entries
      +        }
      +        assert _EXPECTED_PATHS <= paths, (
      +            f"Missing entries in required_credential_files: [redacted:secret-assignment] - paths}"
      +        )
      +
      +    def test_entries_are_registered_when_files_exist(self, tmp_path):
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "google_token.json").write_text("{}")
      +        (hermes_home / "google_client_secret.json").write_text("{}")
      +
      +        from tools.credential_files import (
      +            clear_credential_files,
      +            get_credential_file_mounts,
      +            register_credential_files,
      +        )
      +
      +        clear_credential_files()
      +        try:
      +            content = SKILL_MD.read_text(encoding="utf-8")
      +            fm = _parse_frontmatter(content)
      +            entries = fm.get("required_credential_files", [])
      +
      +            with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
      +                missing = register_credential_files(entries)
      +
      +            assert missing == [], f"Unexpected missing files: {missing}"
      +            mounts = get_credential_file_mounts()
      +            container_paths = {m["container_path"] for m in mounts}
      +            assert "/root/.hermes/google_token.json" in container_paths
      +            assert "/root/.hermes/google_client_secret.json" in container_paths
      +        finally:
      +            clear_credential_files()
      +
      +    def test_missing_token_is_reported(self, tmp_path):
      +        """google_token.json absent (first-time setup) — reported as missing, client secret still mounts."""
      +        hermes_home = tmp_path / ".hermes"
      +        hermes_home.mkdir()
      +        (hermes_home / "google_client_secret.json").write_text("{}")
      +
      +        from tools.credential_files import (
      +            clear_credential_files,
      +            get_credential_file_mounts,
      +            register_credential_files,
      +        )
      +
      +        clear_credential_files()
      +        try:
      +            content = SKILL_MD.read_text(encoding="utf-8")
      +            fm = _parse_frontmatter(content)
      +            entries = fm.get("required_credential_files", [])
      +
      +            with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
      +                missing = register_credential_files(entries)
      +
      +            assert "google_token.json" in missing
      +            mounts = get_credential_file_mounts()
      +            container_paths = {m["container_path"] for m in mounts}
      +            assert "/root/.hermes/google_client_secret.json" in container_paths
      +            assert "/root/.hermes/google_token.json" not in container_paths
      +        finally:
      +            clear_credential_files()
  • #115939 Hermes stops using /tmp: owned scratch dir, TMPDIR at boot, prompt line, CI lint

    merged to main

    files: .github/workflows/lint.yml, CONTRIBUTING.md, acp_adapter/server.py, agent/copilot_acp_client.py, agent/prompt_builder.py, apps/desktop/README.md, apps/desktop/e2e/group-chat-code-block-and-media.spec.ts, apps/desktop/e2e/group-create-gate-remote-roster.spec.ts, apps/desktop/e2e/group-room-member-editing.spec.ts, apps/desktop/e2e/group-transcript-mentions-reply-to.spec.ts, apps/desktop/e2e/group-transcript-owner-meta.spec.ts, apps/desktop/e2e/group-turn-integrity.spec.ts, apps/desktop/scripts/perf/README.md, apps/desktop/scripts/perf/gateway_attach_bench.py, apps/desktop/scripts/perf/scenarios/multitab.mjs, apps/desktop/scripts/profile-model-picker.mjs, apps/desktop/scripts/profile-typing-lag.md, apps/desktop/src/app/chat/short-session-hang-repro.tsx, evals/botmode-dm-delivery/probe-cron-root.spec.ts, evals/botmode-dm-delivery/probe-dm-delivery.spec.ts (+180 more)

4 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · partial fix 1 · other 1

  • #16470 fix(google-workspace): restore required_credential_files in SKILL.md (#16452) · merged via another change → #19886
  • #65668 fix: replace hardcoded /tmp with tempfile.gettempdir() (fixes #52415) · done elsewhere → 51dd197, #115939, #52415
  • #100820 feat: add get_effective_temp_dir() helper, route hardcoded /tmp paths through it · other
  • #100832 feat: add get_effective_temp_dir() helper for platform-aware temp paths · partial fix → #52415
Closed issues · 3
  • Closed#3665feat: mount skill credential files into remote terminal backends (Modal/Docker)
  • Closed#16452[Bug]: google-workspace can't read oauth file in remote terminals
  • Closed#52415[Bug]: hardcoded /tmp paths break terminal tool on Termux/Android (7 sites bypass get_temp_dir())
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#3671, #19886, #115939
Closed issues
3
Attempts
4 PRs closed without merging
Fix link checked
19 Sep 2026

For your agent

The same facts, typed.