{"repo":"NousResearch/hermes-agent","public_id":"3665","display_label":"Temp path and credential file issues in non-standard environments","summary":"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.","subsystem":"agent","counts":{"members":14,"open_issues":1,"open_issues_distinct":1,"open_prs":3,"closed_issues":3,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3671},"resolution":{"state":"partially_resolved","by":"pr-3671"},"freshness":{"state":"verified","last_verified_at":"2026-09-19T17:44:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12175824175824174},"fix_edges":[{"issue_number":3665,"pr_number":3671,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":16452,"pr_number":19886,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":52415,"pr_number":115939,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":61043,"pr_number":115939,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3665,"title":"feat: mount skill credential files into remote terminal backends (Modal/Docker)","url":null,"state":"closed"},{"kind":"issue","number":16452,"title":"[Bug]: google-workspace can't read oauth file in remote terminals","url":null,"state":"closed"},{"kind":"issue","number":47264,"title":"[Bug] Modal sandbox tools (write_file/terminal) fail with \"Token ID is malformed\" on Termux.","url":null,"state":"open"},{"kind":"issue","number":52415,"title":"[Bug]: hardcoded /tmp paths break terminal tool on Termux/Android (7 sites bypass get_temp_dir())","url":null,"state":"closed"},{"kind":"pr","number":3671,"title":"feat: mount skill credential files into remote terminal backends (Modal/Docker)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"skills/productivity/google-workspace/SKILL.md","hunks":["@@ -4,6 +4,11 @@ description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via\n version: 1.0.0\n author: Nous Research\n license: MIT\n+required_credential_files:\n+  - path: google_token.json\n+    description: Google OAuth2 token (created by setup script)\n+  - path: google_client_secret.json\n+    description: Google OAuth2 client credentials (downloaded from Google Cloud Console)\n metadata:\n   hermes:\n     tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]"]},{"file":"tests/tools/test_credential_files.py","hunks":["@@ -0,0 +1,158 @@\n+\"\"\"Tests for credential file passthrough registry (tools/credential_files.py).\"\"\"\n+\n+import os\n+from pathlib import Path\n+\n+import pytest\n+\n+from tools.credential_files import (\n+    clear_credential_files,\n+    get_credential_file_mounts,\n+    register_credential_file,\n+    register_credential_files,\n+    reset_config_cache,\n+)\n+\n+\n[redacted:email](autouse=True)\n+def _clean_registry():\n+    \"\"\"Reset registry between tests.\"\"\"\n+    clear_credential_files()\n+    reset_config_cache()\n+    yield\n+    clear_credential_files()\n+    reset_config_cache()\n+\n+\n+class TestRegisterCredentialFile:\n+    def test_registers_existing_file(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"token.json\").write_text('{\"token\": \"abc\"}')\n+\n+        result = register_credential_file(\"token.json\")\n+\n+        assert result is True\n+        mounts = get_credential_file_mounts()\n+        assert len(mounts) == 1\n+        assert mounts[0][\"host_path\"] == str(tmp_path / \"token.json\")\n+        assert mounts[0][\"container_path\"] == \"/root/.hermes/token.json\"\n+\n+    def test_skips_missing_file(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        result = register_credential_file(\"nonexistent.json\")\n+\n+        assert result is False\n+        assert get_credential_file_mounts() == []\n+\n+    def test_custom_container_base(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"cred.json\").write_text(\"{}\")\n+\n+        register_credential_file(\"cred.json\", container_base=\"/home/user/.hermes\")\n+\n+        mounts = get_credential_file_mounts()\n+        assert mounts[0][\"container_path\"] == \"/home/user/.hermes/cred.json\"\n+\n+    def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"token.json\").write_text(\"{}\")\n+\n+        register_credential_file(\"token.json\")\n+        register_credential_file(\"token.json\")\n+\n+        mounts = get_credential_file_mounts()\n+        assert len(mounts) == 1\n+\n+\n+class TestRegisterCredentialFiles:\n+    def test_string_entries(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"a.json\").write_text(\"{}\")\n+        (tmp_path / \"b.json\").write_text(\"{}\")\n+\n+        missing = register_credential_files([\"a.json\", \"b.json\"])\n+\n+        assert missing == []\n+        assert len(get_credential_file_mounts()) == 2\n+\n+    def test_dict_entries(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"token.json\").write_text(\"{}\")\n+\n+        missing = register_credential_files([\n+            {\"path\": \"token.json\", \"description\": \"OAuth token\"},\n+        ])\n+\n+        assert missing == []\n+        assert len(get_credential_file_mounts()) == 1\n+\n+    def test_returns_missing_files(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"exists.json\").write_text(\"{}\")\n+\n+        missing = register_credential_files([\n+            \"exists.json\",\n+            \"missing.json\",\n+            {\"path\": \"also_missing.json\"},\n+        ])\n+\n+        assert missing == [\"missing.json\", \"also_missing.json\"]\n+        assert len(get_credential_file_mounts()) == 1\n+\n+    def test_empty_list(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        assert register_credential_files([]) == []\n+\n+\n+class TestConfigCredentialFiles:\n+    def test_loads_from_config(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"oauth.json\").write_text(\"{}\")\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"terminal:\\n  credential_files:\\n    - oauth.json\\n\"\n+        )\n+\n+        mounts = get_credential_file_mounts()\n+\n+        assert len(mounts) == 1\n+        assert mounts[0][\"host_path\"] == str(tmp_path / \"oauth.json\")\n+\n+    def test_config_skips_missing_files(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"terminal:\\n  credential_files:\\n    - nonexistent.json\\n\"\n+        )\n+\n+        mounts = get_credential_file_mounts()\n+        assert mounts == []\n+\n+    def test_combines_skill_and_config(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skill_token.json\").write_text(\"{}\")\n+        (tmp_path / \"config_token.json\").write_text(\"{}\")\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"terminal:\\n  credential_files:\\n    - config_token.json\\n\"\n+        )\n+\n+        register_credential_file(\"skill_token.json\")\n+        mounts = get_credential_file_mounts()\n+\n+        assert len(mounts) == 2\n+        paths = {m[\"container_path\"] for m in mounts}\n+        assert \"/root/.hermes/skill_token.json\" in paths\n+        assert \"/root/.hermes/config_token.json\" in paths\n+\n+\n+class TestGetMountsRechecksExistence:\n+    def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        token = [redacted:secret-assignment] / \"token.json\"\n+        token.write_text(\"{}\")\n+\n+        register_credential_file(\"token.json\")\n+        assert len(get_credential_file_mounts()) == 1\n+\n+        # Delete the file after registration\n+        token.unlink()\n+        assert get_credential_file_mounts() == []"]},{"file":"tools/credential_files.py","hunks":["@@ -0,0 +1,163 @@\n+\"\"\"Credential file passthrough registry for remote terminal backends.\n+\n+Skills that declare ``required_credential_files`` in their frontmatter need\n+those files available inside sandboxed execution environments (Modal, Docker).\n+By default remote backends create bare containers with no host files.\n+\n+This module provides a session-scoped registry so skill-declared credential\n+files (and user-configured overrides) are mounted into remote sandboxes.\n+\n+Two sources feed the registry:\n+\n+1. **Skill declarations** — when a skill is loaded via ``skill_view``, its\n+   ``required_credential_files`` entries are registered here if the files\n+   exist on the host.\n+2. **User config** — ``terminal.credential_files`` in config.yaml lets users\n+   explicitly list additional files to mount.\n+\n+Remote backends (``tools/environments/modal.py``, ``docker.py``) call\n+:func:`get_credential_file_mounts` at sandbox creation time.\n+\n+Each registered entry is a dict::\n+\n+    {\n+        \"host_path\": \"/home/user/.hermes/google_token.json\",\n+        \"container_path\": \"/root/.hermes/google_token.json\",\n+    }\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+import os\n+from pathlib import Path\n+from typing import Dict, List\n+\n+logger = logging.getLogger(__name__)\n+\n+# Session-scoped list of credential files to mount.\n+# Key: container_path (deduplicated), Value: host_path\n+_registered_files: Dict[str, str] = {}\n+\n+# Cache for config-based file list (loaded once per process).\n+_config_files: List[Dict[str, str]] | None = None\n+\n+\n+def _resolve_hermes_home() -> Path:\n+    return Path(os.environ.get(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+\n+\n+def register_credential_file(\n+    relative_path: str,\n+    container_base: str = \"/root/.hermes\",\n+) -> bool:\n+    \"\"\"Register a credential file for mounting into remote sandboxes.\n+\n+    *relative_path* is relative to ``HERMES_HOME`` (e.g. ``google_token.json``).\n+    Returns True if the file exists on the host and was registered.\n+    \"\"\"\n+    hermes_home = _resolve_hermes_home()\n+    host_path = hermes_home / relative_path\n+    if not host_path.is_file():\n+        logger.debug(\"credential_files: skipping %s (not found)\", host_path)\n+        return False\n+\n+    container_path = f\"{container_base.rstrip('/')}/{relative_path}\"\n+    _registered_files[container_path] = str(host_path)\n+    logger.debug(\"credential_files: registered %s -> %s\", host_path, container_path)\n+    return True\n+\n+\n+def register_credential_files(\n+    entries: list,\n+    container_base: str = \"/root/.hermes\",\n+) -> List[str]:\n+    \"\"\"Register multiple credential files from skill frontmatter entries.\n+\n+    Each entry is either a string (relative path) or a dict with a ``path``\n+    key.  Returns the list of relative paths that were NOT found on the host\n+    (i.e. missing files).\n+    \"\"\"\n+    missing = []\n+    for entry in entries:\n+        if isinstance(entry, str):\n+            rel_path = entry.strip()\n+        elif isinstance(entry, dict):\n+            rel_path = (entry.get(\"path\") or \"\").strip()\n+        else:\n+            continue\n+        if not rel_path:\n+            continue\n+        if not register_credential_file(rel_path, container_base):\n+            missing.append(rel_path)\n+    return missing\n+\n+\n+def _load_config_files() -> List[Dict[str, str]]:\n+    \"\"\"Load ``terminal.credential_files`` from config.yaml (cached).\"\"\"\n+    global _config_files\n+    if _config_files is not None:\n+        return _config_files\n+\n+    result: List[Dict[str, str]] = []\n+    try:\n+        hermes_home = _resolve_hermes_home()\n+        config_path = hermes_home / \"config.yaml\"\n+        if config_path.exists():\n+            import yaml\n+\n+            with open(config_path) as f:\n+                cfg = yaml.safe_load(f) or {}\n+            cred_files = cfg.get(\"terminal\", {}).get(\"credential_files\")\n+            if isinstance(cred_files, list):\n+                for item in cred_files:\n+                    if isinstance(item, str) and item.strip():\n+                        host_path = hermes_home / item.strip()\n+                        if host_path.is_file():\n+                            container_path = f\"/root/.hermes/{item.strip()}\"\n+                            result.append({\n+                                \"host_path\": str(host_path),\n+                                \"container_path\": container_path,\n+                            })\n+    except Exception as e:\n+        logger.debug(\"Could not read terminal.credential_files from config: %s\", e)\n+\n+    _config_files = result\n+    return _config_files\n+\n+\n+def get_credential_file_mounts() -> List[Dict[str, str]]:\n+    \"\"\"Return all credential files that should be mounted into remote sandboxes.\n+\n+    Each item has ``host_path`` and ``container_path`` keys.\n+    Combines skill-registered files and user config.\n+    \"\"\"\n+    mounts: Dict[str, str] = {}\n+\n+    # Skill-registered files\n+    for container_path, host_path in _registered_files.items():\n+        # Re-check existence (file may have been deleted since registration)\n+        if Path(host_path).is_file():\n+            mounts[container_path] = host_path\n+\n+    # Config-based files\n+    for entry in _load_config_files():\n+        cp = entry[\"container_path\"]\n+        if cp not in mounts and Path(entry[\"host_path\"]).is_file():\n+            mounts[cp] = entry[\"host_path\"]\n+\n+    return [\n+        {\"host_path\": hp, \"container_path\": cp}\n+        for cp, hp in mounts.items()\n+    ]\n+\n+\n+def clear_credential_files() -> None:\n+    \"\"\"Reset the skill-scoped registry (e.g. on session reset).\"\"\"\n+    _registered_files.clear()\n+\n+\n+def reset_config_cache() -> None:\n+    \"\"\"Force re-read of config on next access (for testing).\"\"\"\n+    global _config_files\n+    _config_files = None"]},{"file":"tools/environments/docker.py","hunks":["@@ -312,6 +312,24 @@ def __init__(\n         elif workspace_explicitly_mounted:\n             logger.debug(\"Skipping docker cwd mount: /workspace already mounted by user config\")\n \n+        # Mount credential files (OAuth tokens, etc.) declared by skills.\n+        # Read-only so the container can authenticate but not modify host creds.\n+        try:\n+            from tools.credential_files import get_credential_file_mounts\n+\n+            for mount_entry in get_credential_file_mounts():\n+                volume_args.extend([\n+                    \"-v\",\n+                    f\"{mount_entry['host_path']}:{mount_entry['container_path']}:ro\",\n+                ])\n+                logger.info(\n+                    \"Docker: mounting credential %s -> %s\",\n+                    mount_entry[\"host_path\"],\n+                    mount_entry[\"container_path\"],\n+                )\n+        except Exception as e:\n+            logger.debug(\"Docker: could not load credential file mounts: %s\", e)\n+\n         logger.info(f\"Docker volume_args: {volume_args}\")\n         all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args\n         logger.info(f\"Docker run_args: {all_run_args}\")","@@ -406,8 +424,17 @@ def execute(self, command: str, cwd: str = \"\", *,\n         if effective_stdin is not None:\n             cmd.append(\"-i\")\n         cmd.extend([\"-w\", work_dir])\n-        hermes_env = _load_hermes_env_vars() if self._forward_env else {}\n-        for key in self._forward_env:\n+        # Combine explicit docker_forward_env with skill-declared env_passthrough\n+        # vars so skills that declare required_environment_variables (e.g. Notion)\n+        # have their keys forwarded into the container automatically.\n+        forward_keys = set(self._forward_env)\n+        try:\n+            from tools.env_passthrough import get_all_passthrough\n+            forward_keys |= get_all_passthrough()\n+        except Exception:\n+            pass\n+        hermes_env = _load_hermes_env_vars() if forward_keys else {}\n+        for key in sorted(forward_keys):\n             value = os.getenv(key)\n             if value is None:\n                 value = hermes_env.get(key)"]},{"file":"tools/environments/modal.py","hunks":["@@ -137,6 +137,28 @@ def __init__(\n                 ],\n             )\n \n+        # Mount credential files (OAuth tokens, etc.) declared by skills.\n+        # These are read-only copies so the sandbox can authenticate with\n+        # external services but can't modify the host's credentials.\n+        cred_mounts = []\n+        try:\n+            from tools.credential_files import get_credential_file_mounts\n+\n+            for mount_entry in get_credential_file_mounts():\n+                cred_mounts.append(\n+                    _modal.Mount.from_local_file(\n+                        mount_entry[\"host_path\"],\n+                        remote_path=mount_entry[\"container_path\"],\n+                    )\n+                )\n+                logger.info(\n+                    \"Modal: mounting credential %s -> %s\",\n+                    mount_entry[\"host_path\"],\n+                    mount_entry[\"container_path\"],\n+                )\n+        except Exception as e:\n+            logger.debug(\"Modal: could not load credential file mounts: %s\", e)\n+\n         # Start the async worker thread and create sandbox on it\n         # so all gRPC channels are bound to the worker's event loop.\n         self._worker.start()","@@ -145,23 +167,90 @@ async def _create_sandbox():\n             app = await _modal.App.lookup.aio(\n                 \"hermes-agent\", create_if_missing=True\n             )\n+            create_kwargs = dict(sandbox_kwargs)\n+            if cred_mounts:\n+                existing_mounts = list(create_kwargs.pop(\"mounts\", []))\n+                existing_mounts.extend(cred_mounts)\n+                create_kwargs[\"mounts\"] = existing_mounts\n             sandbox = await _modal.Sandbox.create.aio(\n                 \"sleep\", \"infinity\",\n                 image=effective_image,\n                 app=app,\n-                timeout=int(sandbox_kwargs.pop(\"timeout\", 3600)),\n-                **sandbox_kwargs,\n+                timeout=int(create_kwargs.pop(\"timeout\", 3600)),\n+                **create_kwargs,\n             )\n             return app, sandbox\n \n         self._app, self._sandbox = self._worker.run_coroutine(\n             _create_sandbox(), timeout=300\n         )\n+        # Track synced credential files to avoid redundant pushes.\n+        # Key: container_path, Value: (mtime, size) of last synced version.\n+        self._synced_creds: Dict[str, tuple] = {}\n         logger.info(\"Modal: sandbox created (task=%s)\", self._task_id)\n \n+    def _sync_credential_files(self) -> None:\n+        \"\"\"Push credential files into the running sandbox.\n+\n+        Mounts are set at sandbox creation, but credentials may be created\n+        later (e.g. OAuth setup mid-session).  This writes the current file\n+        content into the sandbox via exec(), so new/updated credentials are\n+        available without recreating the sandbox.\n+        \"\"\"\n+        try:\n+            from tools.credential_files import get_credential_file_mounts\n+\n+            mounts = get_credential_file_mounts()\n+            if not mounts:\n+                return\n+\n+            for entry in mounts:\n+                host_path = entry[\"host_path\"]\n+                container_path = entry[\"container_path\"]\n+                hp = Path(host_path)\n+                try:\n+                    stat = hp.stat()\n+                    file_key = (stat.st_mtime, stat.st_size)\n+                except OSError:\n+                    continue\n+\n+                # Skip if already synced with same mtime+size\n+                if self._synced_creds.get(container_path) == file_key:\n+                    continue\n+\n+                try:\n+                    content = hp.read_text(encoding=\"utf-8\")\n+                except Exception:\n+                    continue\n+\n+                # Write via base64 to avoid shell escaping issues with JSON\n+                import base64\n+                b64 = base64.b64encode(content.encode(\"utf-8\")).decode(\"ascii\")\n+                container_dir = str(Path(container_path).parent)\n+                cmd = (\n+                    f\"mkdir -p {shlex.quote(container_dir)} && \"\n+                    f\"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}\"\n+                )\n+\n+                _cp = container_path  # capture for closure\n+\n+                async def _write():\n+                    proc = await self._sandbox.exec.aio(\"bash\", \"-c\", cmd)\n+                    await proc.wait.aio()\n+\n+                self._worker.run_coroutine(_write(), timeout=15)\n+                self._synced_creds[container_path] = file_key\n+                logger.debug(\"Modal: synced credential %s -> %s\", host_path, container_path)\n+        except Exception as e:\n+            logger.debug(\"Modal: credential file sync failed: %s\", e)\n+\n     def execute(self, command: str, cwd: str = \"\", *,\n                 timeout: int | None = None,\n                 stdin_data: str | None = None) -> dict:\n+        # Sync credential files before each command so mid-session\n+        # OAuth setups are picked up without requiring a restart.\n+        self._sync_credential_files()\n+\n         if stdin_data is not None:\n             marker = f\"HERMES_EOF_{uuid.uuid4().hex[:8]}\"\n             while marker in stdin_data:"]},{"file":"tools/skills_tool.py","hunks":["@@ -1106,6 +1106,27 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:\n                     exc_info=True,\n                 )\n \n+        # Register credential files for mounting into remote sandboxes\n+        # (Modal, Docker).  Files that exist on the host are registered;\n+        # missing ones are added to the setup_needed indicators.\n+        required_cred_files_raw = frontmatter.get(\"required_credential_files\", [])\n+        if not isinstance(required_cred_files_raw, list):\n+            required_cred_files_raw = []\n+        missing_cred_files: list = []\n+        if required_cred_files_raw:\n+            try:\n+                from tools.credential_files import register_credential_files\n+\n+                missing_cred_files = register_credential_files(required_cred_files_raw)\n+                if missing_cred_files:\n+                    setup_needed = True\n+            except Exception:\n+                logger.debug(\n+                    \"Could not register credential files for skill %s\",\n+                    skill_name,\n+                    exc_info=True,\n+                )\n+\n         result = {\n             \"success\": True,\n             \"name\": skill_name,","@@ -1121,6 +1142,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:\n             \"required_environment_variables\": required_env_vars,\n             \"required_commands\": [],\n             \"missing_required_environment_variables\": remaining_missing_required_envs,\n+            \"missing_credential_files\": missing_cred_files,\n             \"missing_required_commands\": [],\n             \"setup_needed\": setup_needed,\n             \"setup_skipped\": capture_result[\"setup_skipped\"],","@@ -1139,6 +1161,8 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:\n         if setup_needed:\n             missing_items = [\n                 f\"env ${env_name}\" for env_name in remaining_missing_required_envs\n+            ] + [\n+                f\"file {path}\" for path in missing_cred_files\n             ]\n             setup_note = _build_setup_note(\n                 SkillReadinessStatus.SETUP_NEEDED,\n"]}]},{"kind":"pr","number":16470,"title":"fix(google-workspace): restore required_credential_files in SKILL.md (#16452)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19886","number":19886}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19886,"title":"fix(google-workspace): restore required_credential_files in SKILL.md","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["skills/productivity/google-workspace/SKILL.md","tests/skills/test_google_workspace_credential_files.py"],"fix_hunks":[{"file":"skills/productivity/google-workspace/SKILL.md","hunks":["@@ -1,9 +1,14 @@\n ---\n name: google-workspace\n description: \"Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python.\"\n-version: 1.0.0\n+version: 1.0.1\n author: Nous Research\n license: MIT\n+required_credential_files:\n+  - path: google_token.json\n+    description: Google OAuth2 token (created by setup script)\n+  - path: google_client_secret.json\n+    description: Google OAuth2 client credentials (downloaded from Google Cloud Console)\n metadata:\n   hermes:\n     tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]"]},{"file":"tests/skills/test_google_workspace_credential_files.py","hunks":["@@ -0,0 +1,102 @@\n+\"\"\"Regression test: google-workspace SKILL.md must declare required_credential_files.\n+\n+PR #9931 accidentally removed the required_credential_files header, which broke\n+credential file mounting in Docker/Modal remote backends (#16452). This test\n+prevents the regression from silently reappearing.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+import pytest\n+\n+SKILL_MD = (\n+    Path(__file__).resolve().parents[2]\n+    / \"skills/productivity/google-workspace/SKILL.md\"\n+)\n+\n+_EXPECTED_PATHS = {\"google_token.json\", \"google_client_secret.json\"}\n+\n+\n+def _parse_frontmatter(content: str) -> dict:\n+    from agent.skill_utils import parse_frontmatter\n+\n+    fm, _ = parse_frontmatter(content)\n+    return fm\n+\n+\n+class TestGoogleWorkspaceCredentialFiles:\n+    def test_required_credential_files_present_in_skill_md(self):\n+        content = SKILL_MD.read_text(encoding=\"utf-8\")\n+        fm = _parse_frontmatter(content)\n+        entries = fm.get(\"required_credential_files\")\n+        assert entries, \"required_credential_files missing from google-workspace SKILL.md\"\n+        assert isinstance(entries, list), \"required_credential_files must be a list\"\n+        paths = {\n+            (e[\"path\"] if isinstance(e, dict) else e)\n+            for e in entries\n+        }\n+        assert _EXPECTED_PATHS <= paths, (\n+            f\"Missing entries in required_credential_files: [redacted:secret-assignment] - paths}\"\n+        )\n+\n+    def test_entries_are_registered_when_files_exist(self, tmp_path):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"google_token.json\").write_text(\"{}\")\n+        (hermes_home / \"google_client_secret.json\").write_text(\"{}\")\n+\n+        from tools.credential_files import (\n+            clear_credential_files,\n+            get_credential_file_mounts,\n+            register_credential_files,\n+        )\n+\n+        clear_credential_files()\n+        try:\n+            content = SKILL_MD.read_text(encoding=\"utf-8\")\n+            fm = _parse_frontmatter(content)\n+            entries = fm.get(\"required_credential_files\", [])\n+\n+            with patch.dict(os.environ, {\"HERMES_HOME\": str(hermes_home)}):\n+                missing = register_credential_files(entries)\n+\n+            assert missing == [], f\"Unexpected missing files: {missing}\"\n+            mounts = get_credential_file_mounts()\n+            container_paths = {m[\"container_path\"] for m in mounts}\n+            assert \"/root/.hermes/google_token.json\" in container_paths\n+            assert \"/root/.hermes/google_client_secret.json\" in container_paths\n+        finally:\n+            clear_credential_files()\n+\n+    def test_missing_token_is_reported(self, tmp_path):\n+        \"\"\"google_token.json absent (first-time setup) — reported as missing, client secret still mounts.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"google_client_secret.json\").write_text(\"{}\")\n+\n+        from tools.credential_files import (\n+            clear_credential_files,\n+            get_credential_file_mounts,\n+            register_credential_files,\n+        )\n+\n+        clear_credential_files()\n+        try:\n+            content = SKILL_MD.read_text(encoding=\"utf-8\")\n+            fm = _parse_frontmatter(content)\n+            entries = fm.get(\"required_credential_files\", [])\n+\n+            with patch.dict(os.environ, {\"HERMES_HOME\": str(hermes_home)}):\n+                missing = register_credential_files(entries)\n+\n+            assert \"google_token.json\" in missing\n+            mounts = get_credential_file_mounts()\n+            container_paths = {m[\"container_path\"] for m in mounts}\n+            assert \"/root/.hermes/google_client_secret.json\" in container_paths\n+            assert \"/root/.hermes/google_token.json\" not in container_paths\n+        finally:\n+            clear_credential_files()\n"]}]},{"kind":"pr","number":52420,"title":"fix: use TMPDIR env var instead of hardcoded /tmp for Termux/Android","url":null,"state":"open","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":65668,"title":"fix: replace hardcoded /tmp with tempfile.gettempdir() (fixes #52415)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"51dd1970afa","number":0},{"kind":"pr","value":"#115939","number":115939},{"kind":"pr","value":"#52415","number":52415}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":100820,"title":"feat: add get_effective_temp_dir() helper, route hardcoded /tmp paths through it","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":100832,"title":"feat: add get_effective_temp_dir() helper for platform-aware temp paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52415","number":52415}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":115939,"title":"Hermes stops using /tmp: owned scratch dir, TMPDIR at boot, prompt line, CI lint","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".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","evals/botmode-dm-matrix/probe-dm-matrix.spec.ts","evals/browser_use/orchestrate.py","evals/delegation_group_schema/probe.py","evals/heartbeat_idle_wire.py","evals/memory/honcho_current_query.py","evals/native_compaction/ab_checkpoint_preflight.py","evals/postmortem/live_ab/cache_concurrency_probe.py","evals/postmortem/live_ab/cache_prefix_live.py","evals/postmortem/live_ab/cache_prefix_wire.py","evals/postmortem/review_probes/goal_repaste_probe.py","evals/providers/reasoning_shapes.py","evals/readtool/fixtures.py","evals/subagent_process_handoff/stress_handoff_live.py","evals/token_accounting/ab_image_cost_calibration.py","evals/token_accounting/replay_gates.py","evals/tool_search/tool_search_livetest.py","evals/tool_search/tool_search_livetest2.py","evals/tool_search/tool_search_livetest_ue.py","evals/tool_search/tool_search_livetest_ue_hard.py","evals/update_pipeline/post_swap_handoff_ab.sh","gateway/control_socket.py","hermes_bootstrap.py","hermes_cli/codex_runtime_plugin_migration.py","hermes_cli/doctor_state.py","hermes_cli/gateway.py","hermes_cli/hooks.py","hermes_cli/main.py","hermes_cli/send_cmd.py","hermes_cli/subcommands/approvals.py","hermes_constants.py","mini_swe_runner.py","optional-skills/autonomous-ai-agents/blackbox/SKILL.md","optional-skills/autonomous-ai-agents/dynamic-workflow/SKILL.md","optional-skills/autonomous-ai-agents/grok/SKILL.md","optional-skills/autonomous-ai-agents/openhands/SKILL.md","optional-skills/creative/ai-presenter-video/scripts/finalize_delivery.sh","optional-skills/creative/ascii-art/SKILL.md","optional-skills/creative/comfyui/scripts/comfyui_setup.sh","optional-skills/creative/hyperframes/references/troubleshooting.md","optional-skills/creative/meme-generation/EXAMPLES.md","optional-skills/creative/meme-generation/SKILL.md","optional-skills/creative/meme-generation/scripts/generate_meme.py","optional-skills/creative/pixel-art/SKILL.md","optional-skills/creative/pretext/SKILL.md","optional-skills/data-science/jupyter-notebook/SKILL.md","optional-skills/devops/pinggy-tunnel/SKILL.md","optional-skills/gaming/pokemon-player/SKILL.md","optional-skills/mcp/mcp-oauth-remote-gateway/SKILL.md","optional-skills/mlops/pytorch-fsdp/references/common-patterns.md","optional-skills/mlops/pytorch-fsdp/references/other.md","optional-skills/mlops/training/axolotl/references/other.md","optional-skills/payments/stripe-link-cli/SKILL.md","optional-skills/research/bioinformatics/SKILL.md","optional-skills/research/darwinian-evolver/SKILL.md","optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py","optional-skills/research/darwinian-evolver/templates/custom_problem_template.py","optional-skills/research/parallel-cli/SKILL.md","optional-skills/research/qmd/SKILL.md","optional-skills/security/1password/SKILL.md","optional-skills/smart-home/openhue/SKILL.md","optional-skills/software-development/ast-grep/references/install.md","optional-skills/software-development/pr-lens/SKILL.md","optional-skills/web-development/har-derived-api-client/SKILL.md","optional-skills/yuanbao/SKILL.md","plugins/disk-cleanup/README.md","plugins/disk-cleanup/__init__.py","plugins/google_meet/meet_bot.py","plugins/platforms/line/adapter.py","scripts/check_no_tmp_literals.py","scripts/ci/live_comment.py","scripts/desktop-update/repro.sh","scripts/generate_conformance_vectors.py","scripts/install.sh","scripts/launch_capture_probe.sh","scripts/profile-tui.py","scripts/run_tests_parallel.py","setup-hermes.sh","skills/AGENTS.md","skills/apple/findmy/SKILL.md","skills/autonomous-ai-agents/claude-code/SKILL.md","skills/autonomous-ai-agents/codex/SKILL.md","skills/autonomous-ai-agents/hermes-agent/references/native-mcp.md","skills/autonomous-ai-agents/opencode/SKILL.md","skills/productivity/pdf/references/ocr-extraction.md","skills/software-development/github/references/ci-troubleshooting.md","skills/software-development/github/references/pr-workflow.md","skills/software-development/github/references/repo-management.md","skills/software-development/hermes-agent-skill-authoring/SKILL.md","skills/software-development/inspecting-hermes-desktop-dom/SKILL.md","skills/software-development/node-inspect-debugger/SKILL.md","skills/software-development/python-debugpy/SKILL.md","skills/web/blocked-page-recovery/SKILL.md","tests/gateway/test_status_command.py","tests/scripts/test_check_no_tmp_literals.py","tests/test_scratch_dir.py","tests/tools/test_tool_result_storage.py","tools/browser_tool.py","tools/code_execution_env.py","tools/code_execution_tool.py","tools/code_kernel.py","tools/environments/base.py","tools/environments/daytona.py","tools/environments/docker.py","tools/environments/local.py","tools/environments/vercel_sandbox.py","tools/kanban_tools_schemas.py","tools/process_registry.py","tools/read_extract.py","tools/self_repo_guard.py","tools/send_message_tool.py","tools/skills_guard.py","tools/terminal_tool_background.py","tools/tool_result_storage.py","tools/voice_mode.py","website/docs/developer-guide/egress-internals.md","website/docs/developer-guide/gateway-monitoring.md","website/docs/developer-guide/image-gen-provider-plugin.md","website/docs/developer-guide/middleware.md","website/docs/developer-guide/model-provider-plugin.md","website/docs/developer-guide/session-storage.md","website/docs/getting-started/nix-setup.md","website/docs/guides/delegation-patterns.md","website/docs/guides/local-ollama-setup.md","website/docs/guides/pipe-script-output.md","website/docs/reference/cli-commands.md","website/docs/reference/faq.md","website/docs/user-guide/bot-mode.md","website/docs/user-guide/configuration.md","website/docs/user-guide/desktop.md","website/docs/user-guide/docker.md","website/docs/user-guide/features/built-in-plugins.md","website/docs/user-guide/features/codex-app-server-runtime.md","website/docs/user-guide/features/context-files.md","website/docs/user-guide/features/cron.md","website/docs/user-guide/features/deliverable-mode.md","website/docs/user-guide/features/document-extraction.md","website/docs/user-guide/features/goals.md","website/docs/user-guide/features/mcp.md","website/docs/user-guide/features/skills.md","website/docs/user-guide/messaging/slack.md","website/docs/user-guide/security.md","website/docs/user-guide/skills/bundled/apple/apple-findmy.md","website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code.md","website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex.md","website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode.md","website/docs/user-guide/skills/bundled/creative/creative-ascii-art.md","website/docs/user-guide/skills/bundled/creative/creative-pretext.md","website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md","website/docs/user-guide/skills/bundled/github/github-github-repo-management.md","website/docs/user-guide/skills/bundled/productivity/productivity-ocr-and-documents.md","website/docs/user-guide/skills/bundled/research/research-blocked-page-recovery.md","website/docs/user-guide/skills/bundled/smart-home/smart-home-openhue.md","website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md","website/docs/user-guide/skills/bundled/software-development/software-development-inspecting-hermes-desktop-dom.md","website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md","website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md","website/docs/user-guide/skills/bundled/web/web-blocked-page-recovery.md","website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox.md","website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-dynamic-workflow.md","website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok.md","website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md","website/docs/user-guide/skills/optional/creative/creative-ascii-art.md","website/docs/user-guide/skills/optional/creative/creative-meme-generation.md","website/docs/user-guide/skills/optional/creative/creative-pixel-art.md","website/docs/user-guide/skills/optional/creative/creative-pretext.md","website/docs/user-guide/skills/optional/data-science/data-science-jupyter-notebook.md","website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md","website/docs/user-guide/skills/optional/gaming/gaming-pokemon-player.md","website/docs/user-guide/skills/optional/mcp/mcp-mcp-oauth-remote-gateway.md","website/docs/user-guide/skills/optional/payments/payments-stripe-link-cli.md","website/docs/user-guide/skills/optional/research/research-bioinformatics.md","website/docs/user-guide/skills/optional/research/research-darwinian-evolver.md","website/docs/user-guide/skills/optional/research/research-parallel-cli.md","website/docs/user-guide/skills/optional/research/research-qmd.md","website/docs/user-guide/skills/optional/smart-home/smart-home-openhue.md","website/docs/user-guide/skills/optional/software-development/software-development-pr-lens.md","website/docs/user-guide/skills/optional/web-development/web-development-har-derived-api-client.md","website/docs/user-guide/skills/optional/yuanbao/yuanbao-yuanbao.md","website/docs/user-guide/windows-wsl-quickstart.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/bot-mode.md"]},{"kind":"pr","number":117737,"title":"fix(terminal): diagnose Modal auth failures instead of surfacing the raw SDK error","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":141512,"title":"fix: respect Termux/TMPDIR env vars in temp-dir fallbacks","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}