feat(mcp): harden remote assets and job execution
Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage. Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
This commit is contained in:
+171
-8
@@ -1,4 +1,5 @@
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
@@ -28,6 +29,7 @@ class McpQueryTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.manager.close()
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def test_check_opencode_readiness_static_success(self):
|
||||
@@ -71,7 +73,7 @@ class McpQueryTests(unittest.TestCase):
|
||||
self.assertEqual(result["runtime_smoke_test"], "passed")
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
smoke_args = run_mock.call_args_list[-1].args[0]
|
||||
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "default"])
|
||||
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "json"])
|
||||
self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "minimal")
|
||||
self.assertIn("--auto", smoke_args)
|
||||
@@ -86,6 +88,18 @@ class McpQueryTests(unittest.TestCase):
|
||||
with self.assertRaises(QueryError):
|
||||
self.manager.start_query_job("what do I know?", mode="full")
|
||||
|
||||
def test_close_still_closes_supervisor_when_queued_job_write_fails(self):
|
||||
queued = {"state": "queued"}
|
||||
with (
|
||||
patch.object(self.manager, "_iter_jobs", return_value=[queued]),
|
||||
patch.object(self.manager, "_write_job", side_effect=OSError("disk full")),
|
||||
patch.object(self.manager._supervisor, "close") as supervisor_close,
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
self.manager.close()
|
||||
|
||||
supervisor_close.assert_called_once_with()
|
||||
|
||||
def test_start_query_job_constructs_restricted_opencode_command(self):
|
||||
self.manager.opencode_model = "openai/gpt-5.5"
|
||||
self.manager.opencode_variant = "minimal"
|
||||
@@ -103,7 +117,7 @@ class McpQueryTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
args = popen_mock.call_args.args[0]
|
||||
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "default", "--auto"])
|
||||
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "json", "--auto"])
|
||||
self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(args[args.index("--variant") + 1], "minimal")
|
||||
self.assertIn("--dir", args)
|
||||
@@ -144,7 +158,10 @@ class McpQueryTests(unittest.TestCase):
|
||||
status = self.manager.get_query_job_status(job["job_id"])
|
||||
self.assertNotIn("log_path", status)
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text("Based on the wiki:\nAnswer body\n", encoding="utf-8")
|
||||
log_path.write_text(
|
||||
self._json_text_event("MCP_QUERY_OK\nBased on the wiki:\nAnswer body"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
@@ -170,9 +187,12 @@ class McpQueryTests(unittest.TestCase):
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
"Based on [[Mini FAKRA]] and [[BJD]].\n"
|
||||
"Business facts used. Data through 2026-06-02.\n"
|
||||
"Answer body\n",
|
||||
self._json_text_event(
|
||||
"MCP_QUERY_OK\n"
|
||||
"Based on [[Mini FAKRA]] and [[BJD]].\n"
|
||||
"Business facts used. Data through 2026-06-02.\n"
|
||||
"Answer body"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
@@ -195,13 +215,67 @@ class McpQueryTests(unittest.TestCase):
|
||||
job = self.manager.start_query_job("what failed?")
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text("Traceback: failed\n", encoding="utf-8")
|
||||
log_path.write_text(self._json_text_event("MCP_QUERY_FAILED\nTraceback: failed"), encoding="utf-8")
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertEqual(result["confidence"], "low")
|
||||
self.assertTrue(any("failed" in warning.lower() for warning in result["warnings"]))
|
||||
|
||||
def test_query_result_rejects_skill_failure_with_zero_exit_code(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_query_job("what failed?")
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(self._json_text_event("MCP_QUERY_FAILED\nSKILL_LOAD_FAILED: missing"), encoding="utf-8")
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertEqual(result["confidence"], "low")
|
||||
self.assertIn("SKILL_LOAD_FAILED", result["error"])
|
||||
|
||||
def test_query_result_accepts_only_final_answer_event_and_hides_paths(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_query_job("safe answer?")
|
||||
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
'{"type":"tool_use","part":{"state":{"output":"/home/private/secret"}}}\n'
|
||||
+ self._json_text_event("MCP_QUERY_OK\nBased on [[BJD]]."),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_query_job_result(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "succeeded")
|
||||
self.assertEqual(result["answer_markdown"], "Based on [[BJD]].")
|
||||
self.assertNotIn("/home/private", result["opencode_output_tail"])
|
||||
status = self.manager.get_query_job_status(job["job_id"])
|
||||
self.assertNotIn("pid", status)
|
||||
self.assertNotIn("checked_paths", status["preflight"])
|
||||
|
||||
internal = self.manager._load_job(job["job_id"])
|
||||
internal["error"] = f"failed while reading {self.project / 'secret.txt'}"
|
||||
internal["preflight"]["warnings"] = [f"check {self.runtime / 'private.log'}"]
|
||||
self.manager._write_job(internal)
|
||||
redacted = self.manager.get_query_job_status(job["job_id"])
|
||||
serialized = json.dumps(redacted)
|
||||
self.assertNotIn(str(self.project), serialized)
|
||||
self.assertNotIn(str(self.runtime), serialized)
|
||||
self.assertIn("<project>", serialized)
|
||||
self.assertIn("<runtime>", serialized)
|
||||
|
||||
def test_ask_wiki_returns_timeout_payload_with_job_id(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
|
||||
@@ -237,6 +311,76 @@ class McpQueryTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(cancelled["state"], "cancelled")
|
||||
|
||||
def test_close_cancels_running_and_queued_jobs_and_releases_runtime(self):
|
||||
popen = FakePopen(returncode=None)
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
first = self.manager.start_query_job("first query")
|
||||
second = self.manager.start_query_job("second query")
|
||||
self.manager.close()
|
||||
|
||||
self.assertEqual(self.manager._load_job(first["job_id"])["state"], "cancelled")
|
||||
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "cancelled")
|
||||
replacement = QueryManager(
|
||||
vault_path=self.vault,
|
||||
project_root=self.project,
|
||||
runtime_root=self.runtime,
|
||||
opencode_path=Path("/usr/bin/opencode"),
|
||||
)
|
||||
replacement.close()
|
||||
|
||||
with self.assertRaises(QueryError):
|
||||
self.manager.start_query_job("after close")
|
||||
|
||||
def test_completed_job_advances_queue_without_status_polling(self):
|
||||
first_process = FakePopen(returncode=None)
|
||||
second_process = FakePopen(returncode=None)
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", side_effect=[first_process, second_process]),
|
||||
):
|
||||
first = self.manager.start_query_job("first query")
|
||||
second = self.manager.start_query_job("second query")
|
||||
first_log = Path(self.manager._load_job(first["job_id"])["log_path"])
|
||||
first_log.write_text(self._json_text_event("MCP_QUERY_OK\nfirst answer"), encoding="utf-8")
|
||||
first_process.returncode = 0
|
||||
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline and self.manager._load_job(second["job_id"])["state"] != "running":
|
||||
time.sleep(0.01)
|
||||
|
||||
self.assertEqual(self.manager._load_job(first["job_id"])["state"], "succeeded")
|
||||
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "running")
|
||||
|
||||
def test_failed_preflight_does_not_block_later_queued_job(self):
|
||||
first_process = FakePopen(returncode=None)
|
||||
third_process = FakePopen(returncode=None)
|
||||
failed_readiness = {"ok": False, "errors": ["skill missing"]}
|
||||
successful_readiness = {"ok": True, "errors": []}
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_query.subprocess.Popen", side_effect=[first_process, third_process]),
|
||||
):
|
||||
first = self.manager.start_query_job("first query")
|
||||
second = self.manager.start_query_job("second query")
|
||||
third = self.manager.start_query_job("third query")
|
||||
first_log = Path(self.manager._load_job(first["job_id"])["log_path"])
|
||||
first_log.write_text(self._json_text_event("MCP_QUERY_OK\nfirst answer"), encoding="utf-8")
|
||||
with patch.object(
|
||||
self.manager,
|
||||
"check_opencode_readiness",
|
||||
side_effect=[failed_readiness, successful_readiness],
|
||||
):
|
||||
completed = self.manager._supervisor._complete_naturally(first["job_id"], 0)
|
||||
|
||||
self.assertTrue(completed)
|
||||
self.assertEqual(self.manager._load_job(second["job_id"])["state"], "failed")
|
||||
self.assertEqual(self.manager._load_job(third["job_id"])["state"], "running")
|
||||
|
||||
def test_cleanup_runtime_prunes_old_terminal_query_jobs(self):
|
||||
popen = FakePopen(returncode=0)
|
||||
with (
|
||||
@@ -290,9 +434,28 @@ class McpQueryTests(unittest.TestCase):
|
||||
if args in (["/usr/bin/opencode", "--version"], ["/usr/bin/opencode", "run", "--help"]):
|
||||
return self._fake_static_readiness_run(args, **kwargs)
|
||||
if args[:3] == ["/usr/bin/opencode", "run", "--format"]:
|
||||
return subprocess.CompletedProcess(args, 0, stdout="WIKI_QUERY_SKILL_LOADED\n", stderr="")
|
||||
return subprocess.CompletedProcess(
|
||||
args,
|
||||
0,
|
||||
stdout=self._json_text_event("WIKI_QUERY_SKILL_LOADED"),
|
||||
stderr="",
|
||||
)
|
||||
raise AssertionError(f"unexpected subprocess.run args: {args}")
|
||||
|
||||
def _json_text_event(self, text):
|
||||
import json
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "text",
|
||||
"part": {
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"metadata": {"openai": {"phase": "final_answer"}},
|
||||
},
|
||||
}
|
||||
) + "\n"
|
||||
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, returncode=None):
|
||||
|
||||
Reference in New Issue
Block a user