NousResearch/hermes-agent

OpenViking integration updates and end-user isolation

Cluster 48464 · agent. Data as of

Nothing open on GitHub. Fix merged in #48206 on 18 Jun 2026. Check it covers your case.

The short answer

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

These members cover adapting the memory provider to the current OpenViking API and adding configurable end-user isolation using the peer_id feature introduced in OpenViking v0.4.1.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #48206 fix(openviking): adapt memory provider for current api

    merged to main

    files: plugins/memory/openviking/README.md, plugins/memory/openviking/__init__.py, scripts/release.py, tests/openviking_plugin/test_openviking.py, tests/plugins/memory/test_openviking_provider.py, website/docs/user-guide/features/memory-providers.md

    Show the diff
    • plugins/memory/openviking/README.md
      @@ -31,10 +31,14 @@ All config via environment variables in `.env`:
       | Env Var | Default | Description |
       |---------|---------|-------------|
       | `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
      -| `OPENVIKING_API_KEY` | (none) | API key (optional) |
      -| `OPENVIKING_ACCOUNT` | (none) | Tenant account override |
      -| `OPENVIKING_USER` | (none) | Tenant user override |
      -| `OPENVIKING_AGENT` | `hermes` | Tenant agent namespace |
      +| `OPENVIKING_API_KEY` | (none) | User/admin API key for authenticated servers |
      +| `OPENVIKING_ACCOUNT` | `default` | Tenant account for local/trusted mode |
      +| `OPENVIKING_USER` | `default` | Tenant user for local/trusted mode |
      +| `OPENVIKING_AGENT` | `hermes` | Hermes peer ID in OpenViking, used for peer-scoped memories |
      +
      +When `OPENVIKING_API_KEY` is set, Hermes lets OpenViking derive account/user
      +identity from the key. In local or trusted deployments without an API key,
      +Hermes sends `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` as identity headers.
       
       ## Tools
       
    • plugins/memory/openviking/__init__.py
      @@ -11,9 +11,9 @@
       or a linked OpenViking CLI config:
         OPENVIKING_ENDPOINT  — Server URL (default: http://127.0.0.1:1933)
         OPENVIKING_API_KEY   — API key (required for authenticated servers)
      -  OPENVIKING_ACCOUNT   — Optional tenant account override
      -  OPENVIKING_USER      — Optional tenant user override
      -  OPENVIKING_AGENT     — Tenant agent (default: hermes)
      +  OPENVIKING_ACCOUNT   — Tenant account for local/trusted mode (default: default)
      +  OPENVIKING_USER      — Tenant user for local/trusted mode (default: default)
      +  OPENVIKING_AGENT     — Hermes peer ID in OpenViking (default: hermes)
       
       Capabilities:
         - Automatic memory extraction on session commit (6 categories)
      @@ -55,6 +55,7 @@
       _DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
       _OPENVIKING_SERVICE_ENDPOINT = "https://api.vikingdb.cn-beijing.volces.com/openviking"
       _DEFAULT_AGENT = "hermes"
      +_AGENT_PROMPT_LABEL = "Hermes peer ID in OpenViking"
       _OVCLI_CONFIG_ENV = "OPENVIKING_CLI_CONFIG_FILE"
       _OVCLI_DEFAULT_RELATIVE_PATH = ".openviking/ovcli.conf"
       _OVCLI_SAVED_PREFIX = "ovcli.conf."
      @@ -200,10 +201,9 @@ def __init__(self, endpoint: str, api_key: str = "",
                        agent: Optional[str] = None):
               self._endpoint = endpoint.rstrip("/")
               self._api_key = [redacted:secret-assignment]
      -        # Empty account/user fall back to "default" and the tenant headers are
      -        # always sent — ROOT API keys require them (preserves the merged
      -        # contract from #22414/#21232; an empty string must NOT omit the
      -        # header). Use `or` (not `is not None`) so "" also falls back.
      +        # Account/user are local/trusted-mode tenant identity. API-key requests
      +        # omit these headers by default; trusted-mode retry may send them only
      +        # after OpenViking explicitly asks for asserted tenant identity.
               self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default")
               self._user = user or os.environ.get("OPENVIKING_USER", "default")
               self._agent = agent if agent is not None else os.environ.get("OPENVIKING_AGENT", _DEFAULT_AGENT)
      @@ -211,15 +211,18 @@ def __init__(self, endpoint: str, api_key: str = "",
               if self._httpx is None:
                   raise ImportError("httpx is required for OpenViking: pip install httpx")
       
      -    def _headers(self) -> dict:
      +    def _headers(self, *, include_tenant: bool | None = None) -> dict:
      +        if include_tenant is None:
      +            include_tenant = not bool(self._api_key)
      +
               h = {"Content-Type": "application/json"}
               if self._agent:
                   h["X-OpenViking-Actor-Peer"] = self._agent
      -            h["X-OpenViking-Agent"] = self._agent
      -        if self._account:
      -            h["X-OpenViking-Account"] = self._account
      -        if self._user:
      -            h["X-OpenViking-User"] = self._user
      +        if include_tenant:
      +            if self._account:
      +                h["X-OpenViking-Account"] = self._account
      +            if self._user:
      +                h["X-OpenViking-User"] = self._user
               if self._api_key:
                   h["X-API-Key"] = self._api_key
                   h["Authorization"] = "Bearer " + self._api_key
      @@ -228,11 +231,33 @@ def _headers(self) -> dict:
           def _url(self, path: str) -> str:
               return f"{self._endpoint}{path}"
       
      -    def _multipart_headers(self) -> dict:
      -        headers = self._headers()
      +    def _multipart_headers(self, *, include_tenant: bool | None = None) -> dict:
      +        headers = self._headers(include_tenant=include_tenant)
               headers.pop("Content-Type", None)
               return headers
       
      +    @staticmethod
      +    def _needs_trusted_identity_retry(exc: Exception) -> bool:
      +        message = str(exc)
      +        return (
      +            "Trusted mode requests must include X-OpenViking-Account" in message
      +            or "Trusted mode requests must include X-OpenViking-User" in message
      +            or "Trusted mode requests must include X-OpenViking-Account or explicit account_id" in message
      +        )
      +
      +    def _send_with_trusted_identity_retry(self, send, *, multipart: bool = False) -> dict:
      +        try:
      +            headers = self._multipart_headers() if multipart else self._headers()
      +            return self._parse_response(send(headers))
      +        except Exception as exc:
      +            if not self._api_key or not self._needs_trusted_identity_retry(exc):
      +                raise
      +            headers = (
      +                self._multipart_headers(include_tenant=True)
      +                if multipart else self._headers(include_tenant=True)
      +            )
      +            return self._parse_response(send(headers))
      +
           def _parse_response(self, resp) -> dict:
               try:
                   data = resp.json()
      @@ -267,28 +292,33 @@ def _parse_response(self, resp) -> dict:
               return data
       
           def get(self, path: str, **kwargs) -> dict:
      -        resp = self._httpx.get(
      -            self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs
      +        return self._send_with_trusted_identity_retry(
      +            lambda headers: self._httpx.get(
      +                self._url(path), headers=headers, timeout=_TIMEOUT, **kwargs
      +            )
               )
      -        return self._parse_response(resp)
       
           def post(self, path: str, payload: dict = None, **kwargs) -> dict:
      -        resp = self._httpx.post(
      -            self._url(path), json=payload or {}, headers=self._headers(),
      -            timeout=_TIMEOUT, **kwargs
      +        return self._send_with_trusted_identity_retry(
      +            lambda headers: self._httpx.post(
      +                self._url(path), json=payload or {}, headers=headers,
      +                timeout=_TIMEOUT, **kwargs
      +            )
               )
      -        return self._parse_response(resp)
       
           def upload_temp_file(self, file_path: Path) -> str:
               mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
      -        with file_path.open("rb") as f:
      -            resp = self._httpx.post(
      -                self._url("/api/v1/resources/temp_upload"),
      -                files={"file": (file_path.name, f, mime_type)},
      -                headers=self._multipart_headers(),
      -                timeout=_TIMEOUT,
      -            )
      -        data = self._parse_response(resp)
      +
      +        def _send(headers):
      +            with file_path.open("rb") as f:
      +                return self._httpx.post(
      +                    self._url("/api/v1/resources/temp_upload"),
      +                    files={"file": (file_path.name, f, mime_type)},
      +                    headers=headers,
      +                    timeout=_TIMEOUT,
      +                )
      +
      +        data = self._send_with_trusted_identity_retry(_send, multipart=True)
               result = data.get("result", {})
               temp_file_id = result.get("temp_file_id", "")
               if not temp_file_id:
      @@ -1219,7 +1249,7 @@ def _prompt_manual_connection_values(prompt, select, cancelled, *, service: bool
                       return _SETUP_CANCELLED
                   if credential_choice == 0:
                       values["agent"] = _clean_config_value(
      -                    prompt("OpenViking agent", default=_DEFAULT_AGENT)
      +                    prompt(_AGENT_PROMPT_LABEL, default=_DEFAULT_AGENT)
                       ) or _DEFAULT_AGENT
                       _print_validation_progress("Validating OpenViking local dev access...")
                       valid, message, _role = _validate_openviking_setup_values(values)
      @@ -1339,7 +1369,7 @@ def _prompt_manual_connection_values(prompt, select, cancelled, *, service: bool
                   prefilled_agent = ""
               else:
                   values["agent"] = _clean_config_value(
      -                prompt("OpenViking agent", default=_DEFAULT_AGENT)
      +                prompt(_AGENT_PROMPT_LABEL, default=_DEFAULT_AGENT)
                   ) or _DEFAULT_AGENT
               _print_validation_progress("Validating OpenViking API access...")
               valid, message, role = _validate_openviking_setup_values(
      @@ -1697,7 +1727,10 @@ def get_config_schema(self):
                   },
                   {
                       "key": "agent",
      -                "description": "OpenViking agent ID within the account ([hermes], useful in multi-agent mode)",
      +                "description": (
      +                    "Hermes peer ID in OpenViking, sent as the actor peer and "
      +                    "used for peer-scoped memories"
      +                ),
                       "default": "hermes",
                       "env_var": "OPENVIKING_AGENT",
                   },
      @@ -2129,18 +2162,22 @@ def _new_client(self) -> _VikingClient:
           def _text_part(content: str) -> Dict[str, str]:
               return {"type": "text", "text": content}
       
      -    @classmethod
      -    def _turn_batch_payload(cls, user_content: str, assistant_content: str) -> Dict[str, Any]:
      +    def _turn_batch_payload(self, user_content: str, assistant_content: str) -> Dict[str, Any]:
      +        assistant_message: Dict[str, Any] = {
      +            "role": "assistant",
      +            "parts": [self._text_part(assistant_content)],
      +        }
      +        if self._agent:
      +            assistant_message["peer_id"] = self._agent
               return {
                   "messages": [
      -                {"role": "user", "parts": [cls._text_part(user_content)]},
      -                {"role": "assistant", "parts": [cls._text_part(assistant_content)]},
      +                {"role": "user", "parts": [self._text_part(user_content)]},
      +                assistant_message,
                   ]
               }
       
      -    @classmethod
           def _post_session_turn(
      -        cls,
      +        self,
               client: _VikingClient,
               sid: str,
               user_content: str,
      @@ -2148,7 +2185,7 @@ def _post_session_turn(
           ) -> None:
               client.post(
                   f"/api/v1/sessions/{sid}/messages/batch",
      -            cls._turn_batch_payload(user_content, assistant_content),
      +            self._turn_batch_payload(user_content, assistant_content),
               )
       
           def _session_has_pending_tokens(self, sid: str) -> bool:
      @@ -2402,9 +2439,9 @@ def on_session_switch(
               )
       
           def _build_memory_uri(self, subdir: str) -> str:
      -        """Build a viking:// memory URI under the configured user/agent/subdir."""
      +        """Build a viking:// memory URI under the configured peer namespace."""
               slug = uuid.uuid4().hex[:12]
      -        return f"viking://user/{self._user}/agent/{self._agent}/memories/{subdir}/mem_{slug}.md"
      +        return f"viking://user/peers/{self._agent}/memories/{subdir}/mem_{slug}.md"
       
           def on_memory_write(
               self,
      @@ -2535,14 +2572,16 @@ def _tool_search(self, args: dict) -> str:
       
               payload: Dict[str, Any] = {"query": query}
               mode = args.get("mode", "auto")
      -        if mode != "auto":
      -            payload["mode"] = mode
               if args.get("scope"):
                   payload["target_uri"] = args["scope"]
               if args.get("limit"):
                   payload["limit"] = args["limit"]
       
      -        resp = self._client.post("/api/v1/search/find", payload)
      +        endpoint = "/api/v1/search/search" if mode == "deep" else "/api/v1/search/find"
      +        if endpoint == "/api/v1/search/search" and self._session_id:
      +            payload["session_id"] = self._session_id
      +
      +        resp = self._client.post(endpoint, payload)
               result = resp.get("result", {})
       
               # Format results for the model — keep it concise
    • scripts/release.py
      @@ -91,6 +91,7 @@
           "[redacted:email]": "randomsnowflake",
           "[redacted:email]": "zakame",
           "[email protected]": "jiangkoumo",
      +    "[redacted:email]": "qin-ctx",
           "[redacted:email]": "ViewWay",
           "[redacted:email]": "m4dni5",
           "[redacted:email]": "harjothkhara",
    • tests/openviking_plugin/test_openviking.py
      @@ -239,6 +239,7 @@ def test_sync_turn_stores_only_slash_skill_user_instruction(self, monkeypatch):
                               {
                                   "role": "assistant",
                                   "parts": [{"type": "text", "text": "Done."}],
      +                            "peer_id": "hermes",
                               },
                           ]
                       },
      @@ -474,8 +475,8 @@ def test_list_browse_unwraps_and_normalizes_entry_shapes(self):
       class TestOpenVikingMemoryUriBuilder:
           """Regression tests for _build_memory_uri — fixes #36969.
       
      -    Before the fix the URI omitted /agent/{agent}/, causing all agents
      -    under the same user to share the same memory namespace.
      +    OpenViking's current memory layout stores peer-scoped memories under
      +    viking://user/peers/{peer_id}/...
           """
       
           def _make_provider(self, user="alice", agent="coder"):
      @@ -484,19 +485,19 @@ def _make_provider(self, user="alice", agent="coder"):
               p._agent = agent
               return p
       
      -    def test_uri_layout_includes_agent_segment(self):
      -        """URI must contain /agent/{agent}/ between user and memories."""
      +    def test_uri_layout_includes_peer_segment(self):
      +        """URI must contain /peers/{peer_id}/ between user and memories."""
               p = self._make_provider(user="alice", agent="coder")
               uri = p._build_memory_uri("preferences")
      -        assert uri.startswith("viking://user/alice/agent/coder/memories/preferences/mem_")
      +        assert uri.startswith("viking://user/peers/coder/memories/preferences/mem_")
               assert uri.endswith(".md")
       
      -    def test_uri_uses_configured_agent_not_default(self):
      -        """_agent value must be interpolated — not hardcoded to 'hermes'."""
      +    def test_uri_uses_configured_peer_not_default(self):
      +        """_agent value is the OpenViking actor peer ID, not hardcoded to 'hermes'."""
               p = self._make_provider(user="alice", agent="research-bot")
               uri = p._build_memory_uri("entities")
      -        assert "/agent/research-bot/" in uri
      -        assert "/agent/hermes/" not in uri
      +        assert "/peers/research-bot/" in uri
      +        assert "/peers/hermes/" not in uri
       
           def test_uri_slug_is_twelve_hex_chars_and_unique(self):
               """Slug must be 12 hex chars and differ between calls."""
    • tests/plugins/memory/test_openviking_provider.py
      @@ -369,7 +369,7 @@ def test_post_setup_create_remote_user_profile_can_mirror_to_openviking_store(tm
               _prompt_from_values({
                   "OpenViking server URL": "https://openviking.example",
                   "OpenViking user API key": "[redacted:secret-assignment]",
      -            "OpenViking agent": "hermes",
      +            "Hermes peer ID in OpenViking": "hermes",
                   "OpenViking profile name": "VPS",
               }),
           )
      @@ -411,7 +411,7 @@ def test_post_setup_create_remote_user_can_keep_hermes_only(tmp_path, monkeypatc
               _prompt_from_values({
                   "OpenViking server URL": "https://openviking.example",
                   "OpenViking user API key": "[redacted:secret-assignment]",
      -            "OpenViking agent": "agent",
      +            "Hermes peer ID in OpenViking": "agent",
               }),
           )
           config = {"memory": {}}
      @@ -455,7 +455,7 @@ def validate_values(values, *, require_api_key=[redacted:secret-assignment]
               _prompt_from_values(
                   {
                       "OpenViking API key": "[redacted:secret-assignment]",
      -                "OpenViking agent": "agent",
      +                "Hermes peer ID in OpenViking": "agent",
                   },
                   forbidden={"OpenViking server URL", "OpenViking user API key", "OpenViking root API key"},
               ),
      @@ -540,7 +540,7 @@ def fake_prompt(label, default=None, secret=[redacted:secret-assignment]
                   "OpenViking user API key": "[redacted:secret-assignment]",
                   "OpenViking account": "acct",
                   "OpenViking user": "alice",
      -            "OpenViking agent": "agent",
      +            "Hermes peer ID in OpenViking": "agent",
               }
               return values.get(label, default or "")
       
      @@ -549,7 +549,7 @@ def fake_prompt(label, default=None, secret=[redacted:secret-assignment]
       
           OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
       
      -    assert prompt_events.count("OpenViking agent") == 1
      +    assert prompt_events.count("Hermes peer ID in OpenViking") == 1
           env_text = (hermes_home / ".env").read_text(encoding="utf-8")
           assert "OPENVIKING_API_KEY=[redacted:secret-assignment]" in env_text
           assert "OPENVIKING_ACCOUNT=acct" in env_text
      @@ -580,7 +580,7 @@ def validate_values(values, *, require_api_key=[redacted:secret-assignment]
                   {
                       "OpenViking server URL": "https://openviking.example",
                       "OpenViking root API key": "[redacted:secret-assignment]",
      -                "OpenViking agent": "agent",
      +                "Hermes peer ID in OpenViking": "agent",
                   },
                   forbidden={"OpenViking user API key", "OpenViking account", "OpenViking user"},
               ),
      @@ -616,7 +616,7 @@ def validate_values(values, *, require_api_key=[redacted:secret-assignment]
                   "OpenViking root API key": "[redacted:secret-assignment]",
                   "OpenViking account": "acct",
                   "OpenViking user": "alice",
      -            "OpenViking agent": "agent",
      +            "Hermes peer ID in OpenViking": "agent",
               }),
               lambda *args, **kwargs: next(choices),
               -1,
      @@ -1091,7 +1091,7 @@ def validate_reachability(endpoint):
               "_prompt",
               _prompt_from_values({
                   "OpenViking server URL": "localhost",
      -            "OpenViking agent": "agent",
      +            "Hermes peer ID in OpenViking": "agent",
               }),
           )
           config = {"memory": {}}
      @@ -1126,7 +1126,7 @@ def test_post_setup_invalid_env_profile_can_create_new_config(tmp_path, monkeypa
               _prompt_from_values({
                   "OpenViking server URL": "https://openviking.example",
                   "OpenViking user API key": "[redacted:secret-assignment]",
      -            "OpenViking agent": "agent",
      +            "Hermes peer ID in OpenViking": "agent",
               }),
           )
           config = {"memory": {}}
      @@ -1210,6 +1210,36 @@ def test_tool_search_sends_limit_not_legacy_top_k():
           assert "top_k" not in payload
       
       
      +def test_tool_search_uses_find_for_normal_search():
      +    provider = OpenVikingMemoryProvider()
      +    provider._client = MagicMock()
      +    provider._client.post.return_value = {
      +        "result": {"memories": [], "resources": [], "skills": [], "total": 0}
      +    }
      +
      +    provider._tool_search({"query": "simple lookup", "mode": "fast"})
      +
      +    provider._client.post.assert_called_once_with("/api/v1/search/find", {
      +        "query": "simple lookup",
      +    })
      +
      +
      +def test_tool_search_uses_session_search_for_deep_search():
      +    provider = OpenVikingMemoryProvider()
      +    provider._client = MagicMock()
      +    provider._session_id = "session-123"
      +    provider._client.post.return_value = {
      +        "result": {"memories": [], "resources": [], "skills": [], "total": 0}
      +    }
      +
      +    provider._tool_search({"query": "connect facts", "mode": "deep"})
      +
      +    provider._client.post.assert_called_once_with("/api/v1/search/search", {
      +        "query": "connect facts",
      +        "session_id": "session-123",
      +    })
      +
      +
       def test_tool_add_resource_uploads_existing_local_file(tmp_path):
           sample = tmp_path / "sample.md"
           sample.write_text("# Local resource\n", encoding="utf-8")
      @@ -1457,10 +1487,10 @@ def capture_httpx_post(url, **kwargs):
           assert "files" in captured_kwargs
           assert "json" not in captured_kwargs
           headers = captured_kwargs["headers"]
      -    assert headers["X-OpenViking-Account"] == "test-account"
      -    assert headers["X-OpenViking-User"] == "test-user"
      +    assert "X-OpenViking-Account" not in headers
      +    assert "X-OpenViking-User" not in headers
           assert headers["X-OpenViking-Actor-Peer"] == "test-agent"
      -    assert headers["X-OpenViking-Agent"] == "test-agent"
      +    assert "X-OpenViking-Agent" not in headers
           assert headers["X-API-Key"] == "test-key"
           assert "Content-Type" not in headers
       
      @@ -1517,16 +1547,17 @@ def test_viking_client_headers_include_bearer_when_api_key_set():
           headers = client._headers()
           assert headers["X-API-Key"] == "test-key"
           assert headers["Authorization"] == "Bearer test-key"
      +    assert headers["X-OpenViking-Actor-Peer"] == "hermes"
      +    assert "X-OpenViking-Agent" not in headers
      +    assert "X-OpenViking-Account" not in headers
      +    assert "X-OpenViking-User" not in headers
       
       
      -def test_viking_client_headers_send_tenant_when_default():
      -    # account/user set to the literal string "default". OpenViking 0.3.x
      -    # requires X-OpenViking-Account and X-OpenViking-User for ROOT API key
      -    # requests to tenant-scoped APIs — omitting them causes
      -    # INVALID_ARGUMENT errors even when account="default".
      +def test_viking_client_headers_send_tenant_in_local_mode():
      +    # Local/trusted mode needs explicit tenant identity headers.
           client = _VikingClient(
               "https://example.com",
      -        api_key="[redacted:secret-assignment]",
      +        api_key="",
               account="default",
               user="default",
               agent="hermes",
      @@ -1535,14 +1566,13 @@ def test_viking_client_headers_send_tenant_when_default():
           assert headers["X-OpenViking-Account"] == "default"
           assert headers["X-OpenViking-User"] == "default"
           assert headers["X-OpenViking-Actor-Peer"] == "hermes"
      -    assert headers["X-OpenViking-Agent"] == "hermes"
      -    assert headers["Authorization"] == "Bearer test-key"
      +    assert "X-OpenViking-Agent" not in headers
      +    assert "Authorization" not in headers
       
       
       def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(monkeypatch):
           _clear_openviking_tenant_env(monkeypatch)
      -    # Empty account/user strings fall back to "default" via the constructor.
      -    # Headers are sent even for the default value — ROOT API keys need them.
      +    # Empty account/user strings fall back to "default" in local mode.
           client = _VikingClient(
               "https://example.com",
               api_key="",
      @@ -1553,11 +1583,13 @@ def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(monk
           headers = client._headers()
           assert headers["X-OpenViking-Account"] == "default"
           assert headers["X-OpenViking-User"] == "default"
      +    assert headers["X-OpenViking-Actor-Peer"] == "hermes"
      +    assert "X-OpenViking-Agent" not in headers
           assert "Authorization" not in headers
           assert "X-API-Key" not in headers
       
       
      -def test_viking_client_headers_sent_with_real_tenant_values():
      +def test_viking_client_headers_can_include_tenant_for_trusted_retry():
           client = _VikingClient(
               "https://example.com",
               api_key="[redacted:secret-assignment]",
      @@ -1565,9 +1597,54 @@ def test_viking_client_headers_sent_with_real_tenant_values():
               user="real-user",
               agent="hermes",
           )
      -    headers = client._headers()
      +    headers = client._headers(include_tenant=True)
           assert headers["X-OpenViking-Account"] == "real-account"
           assert headers["X-OpenViking-User"] == "real-user"
      +    assert headers["Authorization"] == "Bearer test-key"
      +
      +
      +def test_viking_client_retries_with_tenant_headers_for_trusted_mode(monkeypatch):
      +    client = _VikingClient(
      +        "https://example.com",
      +        api_key="[redacted:secret-assignment]",
      +        account="acct",
      +        user="usr",
      +        agent="hermes",
      +    )
      +    captured_headers = []
      +
      +    def capture_get(url, **kwargs):
      +        captured_headers.append(kwargs.get("headers") or {})
      +        if len(captured_headers) == 1:
      +            return SimpleNamespace(
      +                status_code=400,
      +                text="",
      +                json=lambda: {
      +                    "status": "error",
      +                    "error": {
      +                        "code": "INVALID_ARGUMENT",
      +                        "message": "Trusted mode requests must include X-OpenViking-Account.",
      +                    },
      +                },
      +                raise_for_status=lambda: None,
      +            )
      +        return SimpleNamespace(
      +            status_code=200,
      +            text="",
      +            json=lambda: {"status": "ok", "result": {"ok": True}},
      +            raise_for_status=lambda: None,
      +        )
      +
      +    monkeypatch.setattr(client._httpx, "get", capture_get)
      +
      +    assert client.get("/api/v1/system/status") == {
      +        "status": "ok",
      +        "result": {"ok": True},
      +    }
      +    assert "X-OpenViking-Account" not in captured_headers[0]
      +    assert "X-OpenViking-User" not in captured_headers[0]
      +    assert captured_headers[1]["X-OpenViking-Account"] == "acct"
      +    assert captured_headers[1]["X-OpenViking-User"] == "usr"
       
       
       def test_viking_client_health_sends_auth_headers(monkeypatch):
      @@ -1590,6 +1667,10 @@ def capture_get(url, **kwargs):
           assert client.health() is True
           assert captured["url"] == "https://example.com/health"
           assert captured["headers"]["Authorization"] == "Bearer test-key"
      +    assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
      +    assert "X-OpenViking-Agent" not in captured["headers"]
      +    assert "X-OpenViking-Account" not in captured["headers"]
      +    assert "X-OpenViking-User" not in captured["headers"]
       
       
       def test_viking_client_validate_auth_uses_authenticated_system_status(monkeypatch):
      @@ -1620,8 +1701,9 @@ def capture_get(url, **kwargs):
           }
           assert captured["url"] == "https://example.com/api/v1/system/status"
           assert captured["headers"]["Authorization"] == "Bearer test-key"
      -    assert captured["headers"]["X-OpenViking-Account"] == "acct"
      -    assert captured["headers"]["X-OpenViking-User"] == "alice"
      +    assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
      +    assert "X-OpenViking-Account" not in captured["headers"]
      +    assert "X-OpenViking-User" not in captured["headers"]
       
       
       def test_viking_client_validate_root_access_uses_admin_accounts(monkeypatch):
      @@ -1650,10 +1732,9 @@ def capture_get(url, **kwargs):
           assert client.validate_root_access() == {"status": "ok", "result": []}
           assert captured["url"] == "https://example.com/api/v1/admin/accounts"
           assert captured["headers"]["Authorization"] == "Bearer root-key"
      -    # Empty account/user fall back to "default" and the tenant headers are
      -    # always sent — ROOT API keys require them (#22414/#21232 contract).
      -    assert captured["headers"]["X-OpenViking-Account"] == "default"
      -    assert captured["headers"]["X-OpenViking-User"] == "default"
      +    assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
      +    assert "X-OpenViking-Account" not in captured["headers"]
      +    assert "X-OpenViking-User" not in captured["headers"]
       
       
       def test_validate_openviking_reachability_uses_health_only(monkeypatch):
      @@ -2055,7 +2136,7 @@ def post(self, path, payload=None, **kwargs):
           assert captured_payloads == [{
               "messages": [
                   {"role": "user", "parts": [{"type": "text", "text": "u"}]},
      -            {"role": "assistant", "parts": [{"type": "text", "text": "a"}]},
      +            {"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
               ]
           }]
       
      @@ -2099,7 +2180,7 @@ def post(self, path, payload=None, **kwargs):
               {
                   "messages": [
                       {"role": "user", "parts": [{"type": "text", "text": "u"}]},
      -                {"role": "assistant", "parts": [{"type": "text", "text": "a"}]},
      +                {"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
                   ]
               },
           )]
      @@ -2453,7 +2534,7 @@ def post(self, path, payload=None, **kwargs):
           assert captured_payloads[0]["content"] == "remember this"
           assert captured_payloads[0]["mode"] == "create"
           assert captured_payloads[0]["uri"].startswith(
      -        "viking://user/usr/agent/hermes/memories/preferences/mem_"
      +        "viking://user/peers/hermes/memories/preferences/mem_"
           )
       
       
    • website/docs/user-guide/features/memory-providers.md
      @@ -299,6 +299,8 @@ hermes memory setup    # select "openviking"
       # Or manually:
       hermes config set memory.provider openviking
       echo "OPENVIKING_ENDPOINT=http://localhost:1933" >> ~/.hermes/.env
      +# Authenticated servers should use a user/admin API key:
      +echo "OPENVIKING_API_KEY=..." >> ~/.hermes/.env
       ```
       
       **Key features:**
      @@ -306,6 +308,9 @@ echo "OPENVIKING_ENDPOINT=http://localhost:1933" >> ~/.hermes/.env
       - Automatic memory extraction on session commit (profile, preferences, entities, events, cases, patterns)
       - `viking://` URI scheme for hierarchical knowledge browsing
       
      +`OPENVIKING_ACCOUNT` and `OPENVIKING_USER` are used for local/trusted mode.
      +`OPENVIKING_AGENT` is Hermes' peer ID in OpenViking for peer-scoped memories.
      +
       ---
       
       ### Mem0

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #48465 feat(openviking): add configurable end-user isolation via OpenViking v0.4.1 peer_id · superseded by another PR → #48206
Closed issues · 1
  • Closed#48464feat(openviking): add configurable end-user isolation via OpenViking v0.4.1 peer_id
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

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

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#48206
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
18 Jun 2026

For your agent

The same facts, typed.