Files
omnigent-ai--omnigent/tests/test_qwen_native_bridge.py
Serena Ruan 2ae6b36be2 feat(qwen-native): expose Omnigent MCP tools to the qwen TUI (#1559)
* feat(qwen-native): expose Omnigent MCP tools to the qwen TUI

Register the shared Omnigent MCP relay (omnigent.claude_native_bridge
serve-mcp) in <workspace>/.qwen/settings.json before launch so qwen
connects to it on boot, /mcp lists it, and the model can call Omnigent's
builtin tools (sys_*, load_skill, web_fetch, ...). Mirrors the
cursor-/claude-/opencode-native pattern.

A project-scoped MCP server is gated behind qwen's "Untrusted MCP server"
startup prompt, so the runner pre-approves it non-interactively via
`qwen mcp approve omnigent` (qwen's own hash-exact command, the analog of
cursor's `cursor mcp enable`), writing to a per-session approvals store
isolated via QWEN_CODE_MCP_APPROVALS_PATH to avoid polluting ~/.qwen and a
same-workspace concurrency race.

Co-authored-by: Isaac

* style: apply ruff format to qwen-native bridge test

Co-authored-by: Isaac

* fix(qwen-native): write dedicated .mcp.json, JSONC-aware fail-safe merge

Address Polly review: writing into the shared .qwen/settings.json could
silently clobber a user's auth/theme/gateway config (settings.json is JSONC;
plain json.loads on a commented file fell into except -> {} -> overwrite).

- Register the relay in qwen's dedicated <workspace>/.mcp.json instead (the
  true analog of cursor's .cursor/mcp.json), so we never touch settings.json.
- Parse an existing .mcp.json as JSONC (strip comments) and fail safe: a
  non-empty file we can't parse (or that isn't a JSON object) is left untouched
  and MCP wiring is skipped, never overwritten. Returns Path | None.
- Unique temp filename for the atomic replace (same-workspace concurrency).
- Fix docstrings/comments: ensure_comment_relay writes tool_relay.json, not
  bridge.json (which only holds {token}).

Co-authored-by: Isaac

* refactor(qwen-native): pass MCP via --mcp-config, drop workspace file

Address review findings 2 & 3: writing a shared, workspace-rooted file had a
last-writer-wins race for concurrent same-workspace sessions (the .mcp.json
mcpServers.omnigent entry carried each session's bridge_dir) and polluted the
user's repo with a file that could be committed or left pointing at a dead
bridge dir.

Switch to qwen's --mcp-config <path> flag (the claude-native model). The config
now lives in the per-session bridge dir, never the workspace:
- no file dropped in the user's repo; nothing to commit or clean up;
- per-session by construction, so concurrent same-workspace sessions can't
  collide;
- CLI-provided MCP servers are ungated, so the whole pre-approval dance
  (qwen mcp approve + QWEN_CODE_MCP_APPROVALS_PATH isolation) and the JSONC
  merge/fail-safe are deleted.

Verified end-to-end: qwen spawns the omnigent serve-mcp relay from --mcp-config
on boot with no trust prompt, and the workspace stays clean.

Also drops the stale .qwen/settings.json references (finding 1).

Co-authored-by: Isaac

* fix(qwen-native): harden bridge.json token dir; drop stale doc

Address Polly review:

- Security: bridge.json is a bearer token, but it was written via the weak
  _ensure_dir (mkdir + suppressed chmod) which trusts pre-existing ancestors —
  on a shared host an attacker could pre-create $TMPDIR/omnigent-<uid> as a
  symlink and redirect the token. Route the token write through
  _ensure_secure_bridge_dir, delegating to claude-native's _ensure_secure_dir
  (the same owner-only ancestor validation the shared relay already applies;
  the qwen-native root is in its allowlist). On validation failure the runner
  degrades to no-MCP rather than crashing the session.
- Docs: drop the stale QWEN_FOLLOWUPS paragraph describing the deleted
  approve_mcp_server / qwen mcp approve / QWEN_CODE_MCP_APPROVALS_PATH approach.

Adds a symlinked-ancestor rejection test.

Co-authored-by: Isaac
2026-06-29 15:31:17 +08:00

114 lines
4.9 KiB
Python

"""Unit tests for qwen-native MCP bridge config wiring."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from omnigent import qwen_native_bridge
@pytest.fixture
def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A bridge dir under a production-shaped qwen root (passes secure validation).
``write_mcp_bridge_config`` now hardens the bridge tree via
``_ensure_secure_dir``, which requires the dir to live below a known bridge
root. Mirror the real layout (``<uid-scoped temp>/qwen-native/<digest>``) so
the owner-only ancestor walk anchors at ``tmp_path``.
"""
root = tmp_path / "omnigent-test" / "qwen-native"
monkeypatch.setattr(qwen_native_bridge, "_BRIDGE_ROOT", root)
return qwen_native_bridge.bridge_dir_for_session_id("sess")
def test_write_mcp_config_writes_into_bridge_dir_not_workspace(bridge_dir: Path) -> None:
"""``write_mcp_config`` writes the ``--mcp-config`` file inside the bridge dir."""
path = qwen_native_bridge.write_mcp_config(bridge_dir)
# The config lives in the bridge dir — never the workspace (no repo pollution).
assert path == bridge_dir / "mcp_config.json"
assert path.parent == bridge_dir
data = json.loads(path.read_text(encoding="utf-8"))
server = data["mcpServers"]["omnigent"]
# Points at the shared stdio relay implemented in claude_native_bridge.
assert server["args"][:4] == ["-I", "-m", "omnigent.claude_native_bridge", "serve-mcp"]
assert str(bridge_dir) in server["args"]
# trust:true auto-approves qwen's own MCP gate (Omnigent gates separately).
assert server["trust"] is True
# The relay's bearer token was written for ``serve-mcp`` to read at startup.
assert (bridge_dir / "bridge.json").is_file()
token = json.loads((bridge_dir / "bridge.json").read_text())["token"]
assert isinstance(token, str) and token
def test_write_mcp_config_is_valid_for_qwen_mcp_config_flag(bridge_dir: Path) -> None:
"""The payload is the ``{"mcpServers": {...}}`` shape qwen's --mcp-config expects."""
path = qwen_native_bridge.write_mcp_config(bridge_dir)
data = json.loads(path.read_text(encoding="utf-8"))
assert set(data) == {"mcpServers"}
assert set(data["mcpServers"]) == {"omnigent"}
def test_write_mcp_config_path_is_per_session(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Two sessions get independent config files carrying their own bridge dir."""
root = tmp_path / "omnigent-test" / "qwen-native"
monkeypatch.setattr(qwen_native_bridge, "_BRIDGE_ROOT", root)
bridge_a = qwen_native_bridge.bridge_dir_for_session_id("a")
bridge_b = qwen_native_bridge.bridge_dir_for_session_id("b")
path_a = qwen_native_bridge.write_mcp_config(bridge_a)
path_b = qwen_native_bridge.write_mcp_config(bridge_b)
assert path_a != path_b
args_a = json.loads(path_a.read_text())["mcpServers"]["omnigent"]["args"]
args_b = json.loads(path_b.read_text())["mcpServers"]["omnigent"]["args"]
assert str(bridge_a) in args_a
assert str(bridge_b) in args_b
# No cross-contamination: A's config never points at B's bridge dir.
assert str(bridge_b) not in args_a
def test_mcp_config_path_matches_written_path(bridge_dir: Path) -> None:
"""``mcp_config_path`` reports the same path ``write_mcp_config`` writes."""
assert qwen_native_bridge.write_mcp_config(bridge_dir) == (
qwen_native_bridge.mcp_config_path(bridge_dir)
)
def test_write_mcp_bridge_config_is_idempotent(bridge_dir: Path) -> None:
"""The relay token is generated once and preserved across re-launches."""
qwen_native_bridge.write_mcp_bridge_config(bridge_dir)
first = (bridge_dir / "bridge.json").read_text()
qwen_native_bridge.write_mcp_bridge_config(bridge_dir)
assert (bridge_dir / "bridge.json").read_text() == first
def test_write_mcp_bridge_config_rejects_symlinked_ancestor(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A symlinked bridge-tree ancestor is refused — the token is never written.
bridge.json holds a bearer token, so the dir must pass owner-only ancestor
validation. If an attacker pre-creates an ancestor as a symlink, writing the
token must fail loudly rather than land it in attacker-redirectable storage.
"""
real_root = tmp_path / "omnigent-test"
qwen_root = real_root / "qwen-native"
monkeypatch.setattr(qwen_native_bridge, "_BRIDGE_ROOT", qwen_root)
bridge_dir = qwen_native_bridge.bridge_dir_for_session_id("sess")
# Redirect an ancestor (the uid-scoped dir) through a symlink.
elsewhere = tmp_path / "attacker"
elsewhere.mkdir()
real_root.symlink_to(elsewhere, target_is_directory=True)
with pytest.raises(RuntimeError):
qwen_native_bridge.write_mcp_bridge_config(bridge_dir)
# No token leaked into the redirected location.
assert not (elsewhere / "qwen-native").exists()