2ae6b36be2
* 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
114 lines
4.9 KiB
Python
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()
|