# NousResearch/hermes-agent · Temp path and credential file issues in non-standard environments

## Open now

- 1 issue, 3 PRs open on GitHub.
- #47264: \[Bug\] Modal sandbox tools (write_file/terminal) fail with "Token ID is malformed" on Termux.
- PR #52420: fix: use TMPDIR env var instead of hardcoded /tmp for Termux/Android
- PR #117737: fix(terminal): diagnose Modal auth failures instead of surfacing the raw SDK error
- PR #141512: fix: respect Termux/TMPDIR env vars in temp-dir fallbacks

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.

## TL;DR

- 14 member(s): 1 open issue(s), 3 closed issue(s), 3 open PR(s), 3 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #3671. Verify it addresses your case.
- **Recommended:** read PR #3671 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-3671`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #3665: feat: mount skill credential files into remote terminal backends (Modal/Docker)
- #16452: \[Bug\]: google-workspace can't read oauth file in remote terminals
- #52415: \[Bug\]: hardcoded /tmp paths break terminal tool on Termux/Android (7 sites bypass get_temp_dir())

## Prior & competing PRs

- **#3671: feat: mount skill credential files into remote terminal backends (Modal/Docker)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `skills/productivity/google-workspace/SKILL.md`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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}")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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"],
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `skills/productivity/google-workspace/SKILL.md`, `tests/skills/test_google_workspace_credential_files.py`
  - fix hunks:
  - `skills/productivity/google-workspace/SKILL.md`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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)
- **#16470: fix(google-workspace): restore required_credential_files in SKILL.md (#16452)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#19886`
- **#52420: fix: use TMPDIR env var instead of hardcoded /tmp for Termux/Android**. open
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`
  - files: `agent/copilot_acp_client.py`, `hermes_constants.py`, `tools/code_execution_tool.py`, `tools/process_registry.py`
- **#65668: fix: replace hardcoded /tmp with tempfile.gettempdir() (fixes #52415)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `51dd1970afa`, `#115939`, `#52415`
- **#100820: feat: add get_effective_temp_dir() helper, route hardcoded /tmp paths through it**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#100832: feat: add get_effective_temp_dir() helper for platform-aware temp paths**. closed without merge
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `#52415`
- **#117737: fix(terminal): diagnose Modal auth failures instead of surfacing the raw SDK error**. open
  - maintainer direction: category `silent_close`; implication `active`
  - files: `tests/tools/test_modal_environment_auth_errors.py`, `tools/environments/modal.py`
- **#141512: fix: respect Termux/TMPDIR env vars in temp-dir fallbacks**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/coding_context.py`, `agent/copilot_acp_client.py`, `agent/verification_evidence.py`, `agent/verification_stop.py`, `hermes_constants.py`, `tests/agent/test_verification_stop.py`, `tools/approval_detection.py`, `tools/bot_mode_dm.py`, `tools/code_execution_tool.py`, `tools/code_kernel.py`, `tools/computer_use/cua_backend_daemon.py`, `tools/environments/docker.py`, `tools/environments/file_sync.py`, `tools/environments/ssh.py`, `tools/process_registry.py`, `tools/tool_result_storage.py`, `tools/voice_mode.py`

## Fix status

- Merged to main in PR #3671 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-19`.
- Subsystem: `agent`
