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
- 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.
Next step: read #3671, then check whether your report is one of the 1 still open.
Read #3671Pick up a Lab taskThese 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
Fixes on record
-
#3671 feat: mount skill credential files into remote terminal backends (Modal/Docker)
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.pyShow 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
files:
skills/productivity/google-workspace/SKILL.md,tests/skills/test_google_workspace_credential_files.pyShow 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
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
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.00means 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