fix(mcp): enforce profile ownership throughout OAuth sessions
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Credential-free live OAuth/MCP persistence probe (no production mocks).
|
||||
|
||||
Run with the repository's Python environment:
|
||||
python evals/desktop_mcp_oauth/backend_http_fixture.py --repo . --output receipt.json
|
||||
|
||||
The parent creates an ephemeral HOME and re-execs with an environment allowlist.
|
||||
Only the redacted receipt survives; token files are verified then deleted with HOME.
|
||||
This exercises production session handlers, not Electron or gateway transport.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
|
||||
class Provider(ThreadingHTTPServer):
|
||||
def __init__(self):
|
||||
super().__init__(("127.0.0.1", 0), ProviderHandler)
|
||||
self.origin = f"http://127.0.0.1:{self.server_port}"
|
||||
self.events = []
|
||||
self.clients = {}
|
||||
self.codes = {}
|
||||
self.tokens = set()
|
||||
self.pkce_verified = 0
|
||||
|
||||
|
||||
class ProviderHandler(BaseHTTPRequestHandler):
|
||||
server: Provider
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Never log callback queries, codes or bearer headers.
|
||||
|
||||
def reply(self, status, payload=None, headers=None):
|
||||
body = json.dumps(payload).encode() if payload is not None else b""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for key, value in (headers or {}).items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
path = urlsplit(self.path).path
|
||||
self.server.events.append(["GET", path])
|
||||
if path.startswith("/.well-known/oauth-protected-resource"):
|
||||
return self.reply(200, {"resource": self.server.origin + "/mcp",
|
||||
"authorization_servers": [self.server.origin],
|
||||
"scopes_supported": ["tools:read"]})
|
||||
if path.startswith("/.well-known/oauth-authorization-server"):
|
||||
return self.reply(200, {
|
||||
"issuer": self.server.origin,
|
||||
"authorization_endpoint": self.server.origin + "/authorize",
|
||||
"token_endpoint": self.server.origin + "/token",
|
||||
"registration_endpoint": self.server.origin + "/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"scopes_supported": ["tools:read"],
|
||||
})
|
||||
if path == "/authorize":
|
||||
query = {k: v[0] for k, v in parse_qs(urlsplit(self.path).query).items()}
|
||||
client = self.server.clients.get(query.get("client_id"))
|
||||
if (not client or query.get("redirect_uri") not in client["redirect_uris"]
|
||||
or query.get("code_challenge_method") != "S256"):
|
||||
return self.reply(400, {"error": "invalid_request"})
|
||||
code = secrets.token_urlsafe(24)
|
||||
self.server.codes[code] = query
|
||||
target = query["redirect_uri"] + "?" + urlencode({"code": code, "state": query["state"]})
|
||||
return self.reply(302, headers={"Location": target})
|
||||
self.reply(405 if path == "/mcp" else 404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
path = urlsplit(self.path).path
|
||||
self.server.events.append(["POST", path])
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
if path == "/register":
|
||||
client = json.loads(body)
|
||||
client["client_id"] = secrets.token_urlsafe(16)
|
||||
client["token_endpoint_auth_method"] = "none"
|
||||
self.server.clients[client["client_id"]] = client
|
||||
return self.reply(201, client)
|
||||
if path == "/token":
|
||||
form = {k: v[0] for k, v in parse_qs(body.decode()).items()}
|
||||
auth = self.server.codes.pop(form.get("code"), None)
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(
|
||||
form.get("code_verifier", "").encode()).digest()).rstrip(b"=").decode()
|
||||
if (not auth or challenge != auth["code_challenge"]
|
||||
or form.get("redirect_uri") != auth["redirect_uri"]
|
||||
or form.get("client_id") != auth["client_id"]):
|
||||
return self.reply(400, {"error": "invalid_grant"})
|
||||
self.server.pkce_verified += 1
|
||||
token = secrets.token_urlsafe(32)
|
||||
self.server.tokens.add(token)
|
||||
return self.reply(200, {"access_token": token, "token_type": "Bearer",
|
||||
"expires_in": 3600, "scope": "tools:read",
|
||||
"refresh_token": secrets.token_urlsafe(32)})
|
||||
if path != "/mcp":
|
||||
return self.reply(404)
|
||||
if self.headers.get("Authorization", "").removeprefix("Bearer ") not in self.server.tokens:
|
||||
return self.reply(401, {"error": "unauthorized"}, {"WWW-Authenticate":
|
||||
f'Bearer resource_metadata="{self.server.origin}/.well-known/oauth-protected-resource"'})
|
||||
request = json.loads(body)
|
||||
method = request["method"]
|
||||
self.server.events.append(["MCP", method])
|
||||
if "id" not in request:
|
||||
return self.reply(202)
|
||||
results = {
|
||||
"initialize": {"protocolVersion": request.get("params", {}).get("protocolVersion", "2025-03-26"),
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "local-oauth-fixture", "version": "1"}},
|
||||
"tools/list": {"tools": [{"name": "fixture_ping", "description": "Local fixture ping",
|
||||
"inputSchema": {"type": "object", "properties": {}}}]},
|
||||
"ping": {},
|
||||
}
|
||||
if method not in results:
|
||||
return self.reply(200, {"jsonrpc": "2.0", "id": request["id"],
|
||||
"error": {"code": -32601, "message": "Method not found"}})
|
||||
self.reply(200, {"jsonrpc": "2.0", "id": request["id"], "result": results[method]})
|
||||
|
||||
def do_DELETE(self): # noqa: N802
|
||||
self.reply(200)
|
||||
|
||||
|
||||
class CallbackServer(ThreadingHTTPServer):
|
||||
callback: dict[str, str]
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
server: CallbackServer
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
self.server.callback = {k: v[0] for k, v in parse_qs(urlsplit(self.path).query).items()}
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Local fixture callback captured")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def serving(server):
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(5)
|
||||
|
||||
|
||||
def eventually(predicate, label, timeout=25):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = predicate()
|
||||
if result:
|
||||
return result
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(label)
|
||||
|
||||
|
||||
def run_probe(repo, receipt):
|
||||
sys.path.insert(0, str(repo))
|
||||
logging.disable(logging.CRITICAL)
|
||||
import httpx
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
from tui_gateway import mcp_oauth_sessions as sessions
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
|
||||
owner = Path(os.environ["HERMES_HOME"])
|
||||
other = owner.parent / "other-profile"
|
||||
other.mkdir()
|
||||
checks = receipt["checks"]
|
||||
|
||||
def check(name, result):
|
||||
checks[name] = bool(result)
|
||||
if not result:
|
||||
raise AssertionError(name)
|
||||
|
||||
with serving(Provider()) as provider, serving(CallbackServer(
|
||||
("127.0.0.1", 0), CallbackHandler)) as callback, httpx.Client(trust_env=False) as browser:
|
||||
cfg = {"url": provider.origin + "/mcp", "auth": "oauth",
|
||||
"oauth": {"cimd": False, "scope": "tools:read", "timeout": 30}}
|
||||
redirect = f"http://127.0.0.1:{callback.server_port}/callback"
|
||||
|
||||
def begin(name, remote=True):
|
||||
return sessions.start_flow(str(owner), name, cfg, url_timeout=25,
|
||||
client_redirect_uri=redirect if remote else None)
|
||||
|
||||
def done(flow, name):
|
||||
sid = flow["session_id"]
|
||||
eventually(lambda: sessions._sessions[sid]["flow"].worker_done, "worker completed")
|
||||
return sessions.poll_flow(sid, name)
|
||||
|
||||
flow = begin("positive")
|
||||
sid = flow["session_id"]
|
||||
check("remote_does_not_bind_backend_listener", sessions._sessions[sid]["httpd"] is None)
|
||||
check("initial_poll_pending", sessions.poll_flow(sid, "positive")["status"] == "pending")
|
||||
check("wrong_state_rejected", not sessions.deliver_callback_flow(
|
||||
sid, "positive", code="invalid", state="invalid")["ok"])
|
||||
check("wrong_state_keeps_pending", sessions.poll_flow(sid, "positive")["status"] == "pending")
|
||||
check("wrong_server_poll_rejected", sessions.poll_flow(sid, "wrong")["status"] == "error")
|
||||
check("wrong_server_callback_rejected", not sessions.deliver_callback_flow(
|
||||
sid, "wrong", code="invalid", state="invalid")["ok"])
|
||||
check("wrong_owner_cancel_rejected", not sessions.cancel_flow(sid, "positive", str(other))["ok"])
|
||||
check("wrong_owner_did_not_cancel", sessions.poll_flow(sid, "positive")["status"] == "pending")
|
||||
response = browser.get(flow["auth_url"], follow_redirects=True)
|
||||
check("real_http_callback_received", response.status_code == 200 and bool(callback.callback.get("code")))
|
||||
captured = callback.callback
|
||||
check("correct_callback_accepted", sessions.deliver_callback_flow(sid, "positive", **captured)["ok"])
|
||||
result = done(flow, "positive")
|
||||
receipt["positive_status"] = result["status"]
|
||||
check("positive_approved", result["status"] == "approved")
|
||||
check("real_mcp_tools_discovered", any(t["name"] == "fixture_ping" for t in result["tools"]))
|
||||
check("callback_replay_rejected", not sessions.deliver_callback_flow(sid, "positive", **captured)["ok"])
|
||||
token_path = owner / "mcp-tokens" / "positive.json"
|
||||
stored = json.loads(token_path.read_text(encoding="utf-8"))
|
||||
check("disk_token_matches_provider", stored["access_token"] in provider.tokens)
|
||||
check("disk_refresh_token_and_absolute_expiry", bool(stored.get("refresh_token")) and stored["expires_at"] > time.time())
|
||||
check("token_file_private", token_path.stat().st_mode & 0o777 == 0o600)
|
||||
reloaded = asyncio.run(HermesTokenStorage("positive", hermes_home=owner).get_tokens())
|
||||
check("fresh_storage_reload", reloaded.access_token in provider.tokens)
|
||||
check("wrong_profile_has_no_token", not (other / "mcp-tokens" / "positive.json").exists())
|
||||
check("config_saved_to_owner", "positive" in (owner / "config.yaml").read_text(encoding="utf-8"))
|
||||
exchanges = provider.pkce_verified
|
||||
cold = subprocess.run([sys.executable, str(Path(__file__).resolve()),
|
||||
"--repo", str(repo), "--output", os.devnull, "--cold-probe"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=40, check=False)
|
||||
check("fresh_process_authenticated_mcp_from_disk", cold.returncode == 0)
|
||||
check("fresh_process_did_not_reauthorize", provider.pkce_verified == exchanges)
|
||||
|
||||
flow = begin("cancelled")
|
||||
sid = flow["session_id"]
|
||||
exchanges = provider.pkce_verified
|
||||
check("owner_cancel_accepted", sessions.cancel_flow(sid, "cancelled", str(owner))["ok"])
|
||||
check("cancel_worker_terminated", done(flow, "cancelled")["status"] == "error")
|
||||
check("cancel_did_not_exchange_or_persist", provider.pkce_verified == exchanges and
|
||||
not (owner / "mcp-tokens" / "cancelled.json").exists())
|
||||
check("callback_after_cancel_rejected", not sessions.deliver_callback_flow(
|
||||
sid, "cancelled", code="invalid", state="invalid")["ok"])
|
||||
|
||||
flow = begin("loopback", remote=False)
|
||||
check("backend_listener_http_success", browser.get(flow["auth_url"], follow_redirects=True).status_code == 200)
|
||||
check("backend_listener_approved", done(flow, "loopback")["status"] == "approved")
|
||||
check("backend_listener_closed", eventually(
|
||||
lambda: sessions._sessions[flow["session_id"]]["httpd"] is None,
|
||||
"backend listener shutdown completed"))
|
||||
|
||||
# Even possession of valid session/state cannot cross the resolved home.
|
||||
flow = begin("owner_boundary")
|
||||
browser.get(flow["auth_url"], follow_redirects=True)
|
||||
override = set_hermes_home_override(other)
|
||||
try:
|
||||
foreign_poll = sessions.poll_flow(flow["session_id"], "owner_boundary")
|
||||
foreign_callback = sessions.deliver_callback_flow(flow["session_id"], "owner_boundary", **callback.callback)
|
||||
finally:
|
||||
reset_hermes_home_override(override)
|
||||
receipt["ownership_boundary"] = {
|
||||
"cancel_owner_enforced": checks["wrong_owner_cancel_rejected"],
|
||||
"cross_profile_poll_rejected": foreign_poll["status"] == "error",
|
||||
"cross_profile_callback_rejected_with_session_and_state": not foreign_callback["ok"],
|
||||
}
|
||||
check("wrong_owner_poll_rejected", foreign_poll["status"] == "error" and "auth_url" not in foreign_poll)
|
||||
check("wrong_owner_callback_rejected", not foreign_callback["ok"])
|
||||
check("owner_callback_after_foreign_attempt_accepted", sessions.deliver_callback_flow(
|
||||
flow["session_id"], "owner_boundary", **callback.callback)["ok"])
|
||||
check("boundary_probe_worker_completed", done(flow, "owner_boundary")["status"] == "approved")
|
||||
check("boundary_exchange_still_persists_only_to_owner", (owner / "mcp-tokens" / "owner_boundary.json").exists()
|
||||
and not (other / "mcp-tokens" / "owner_boundary.json").exists())
|
||||
receipt["http_events"] = provider.events
|
||||
receipt["pkce_exchanges_verified"] = provider.pkce_verified
|
||||
check("real_discovery_dcr_token_http", all(any(path == target for _, path in provider.events)
|
||||
for target in ["/.well-known/oauth-protected-resource", "/register", "/token"]))
|
||||
receipt["persisted_files_verified_then_removed"] = sorted(p.name for p in (owner / "mcp-tokens").iterdir())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--isolated-worker", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--cold-probe", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
repo, output = args.repo.resolve(), args.output.resolve()
|
||||
if args.cold_probe:
|
||||
sys.path.insert(0, str(repo))
|
||||
logging.disable(logging.CRITICAL)
|
||||
from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server
|
||||
from tools.mcp_oauth import suppress_interactive_oauth
|
||||
with suppress_interactive_oauth():
|
||||
tools = _probe_single_server("positive", _get_mcp_servers()["positive"])
|
||||
return 0 if any(name == "fixture_ping" for name, _ in tools) else 1
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not args.isolated_worker:
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-oauth-http-") as temp:
|
||||
home = Path(temp)
|
||||
(home / ".hermes").mkdir()
|
||||
env = {"PATH": os.environ.get("PATH", ""), "HOME": temp,
|
||||
"HERMES_HOME": str(home / ".hermes"), "LANG": "C.UTF-8", "TZ": "UTC",
|
||||
"PYTHONNOUSERSITE": "1"}
|
||||
completed = subprocess.run([sys.executable, str(Path(__file__).resolve()),
|
||||
"--repo", str(repo), "--output", str(output), "--isolated-worker"],
|
||||
env=env, cwd=temp, stdin=subprocess.DEVNULL, timeout=180, check=False)
|
||||
print(json.dumps({"receipt": str(output), "exit_code": completed.returncode, "isolated_home_removed": True}))
|
||||
return completed.returncode
|
||||
receipt = {"checks": {}, "repo": str(repo), "fidelity":
|
||||
"Real production OAuth session functions + real HTTP discovery/DCR/PKCE/token/MCP/callback + real disk persistence; no production mocks; not gateway RPC transport or Electron",
|
||||
"isolated_home": True, "credentials": "ephemeral local fixture only; no token material in receipt"}
|
||||
try:
|
||||
with open(os.devnull, "w", encoding="utf-8") as sink, contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
||||
run_probe(repo, receipt)
|
||||
receipt["status"] = "passed"
|
||||
except Exception as exc:
|
||||
receipt["status"] = "failed"
|
||||
receipt["error_type"] = type(exc).__name__
|
||||
receipt["failed_check"] = next((k for k, v in receipt["checks"].items() if not v), None)
|
||||
# Assertions are our fixed labels, not provider bodies or secret-bearing URLs.
|
||||
if isinstance(exc, AssertionError):
|
||||
receipt["assertion"] = str(exc)
|
||||
output.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
|
||||
return 0 if receipt["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import fs from 'node:fs/promises'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
const repo = process.argv[2],
|
||||
out = process.argv[3],
|
||||
scenario = process.argv[4] || 'approved'
|
||||
const require = createRequire(path.join(repo, 'package.json'))
|
||||
const { build } = require('esbuild')
|
||||
const { chromium } = require('playwright')
|
||||
const requests = []
|
||||
const rpcCalls = []
|
||||
const nativeCalls = []
|
||||
const handlers = new Map()
|
||||
const nativeBuild = await build({
|
||||
entryPoints: [repo + '/apps/desktop/electron/mcp-oauth-callback-ipc.ts'],
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['electron']
|
||||
})
|
||||
const nativeModule = { exports: {} }
|
||||
new Function('require', 'module', 'exports', nativeBuild.outputFiles[0].text)(
|
||||
id => (id === 'electron' ? { ipcMain: { handle: (name, fn) => handlers.set(name, fn) } } : require(id)),
|
||||
nativeModule,
|
||||
nativeModule.exports
|
||||
)
|
||||
nativeModule.exports.registerMcpOauthCallbackIpc()
|
||||
const { WebSocketServer } = require('ws')
|
||||
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' })
|
||||
await new Promise(r => wss.once('listening', r))
|
||||
let approved = false
|
||||
let redirectUri
|
||||
wss.on('connection', ws =>
|
||||
ws.on('message', raw => {
|
||||
const m = JSON.parse(raw)
|
||||
rpcCalls.push(m)
|
||||
let result = { ok: true }
|
||||
if (m.method === 'mcp.servers.oauth.start') {
|
||||
redirectUri = m.params.client_redirect_uri
|
||||
result = {
|
||||
ok: true,
|
||||
session_id: 'fixture-session',
|
||||
auth_url:
|
||||
'http://127.0.0.1/authorize?state=fixture-state&redirect_uri=' +
|
||||
encodeURIComponent(scenario === 'legacy' ? 'http://remote.invalid/callback' : redirectUri)
|
||||
}
|
||||
}
|
||||
if (m.method === 'mcp.servers.oauth.callback') {
|
||||
approved = m.params.state === 'fixture-state' && m.params.code === 'fixture-code'
|
||||
result = { ok: approved }
|
||||
}
|
||||
if (m.method === 'mcp.servers.oauth.poll')
|
||||
result = { ok: true, status: approved ? 'approved' : 'pending', tools: [] }
|
||||
ws.send(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }))
|
||||
})
|
||||
)
|
||||
const entry = `import React from 'react'; import {createRoot} from 'react-dom/client';
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {McpTab} from '${repo}/apps/desktop/src/app/skills/mcp-tab.tsx';
|
||||
import {setApiRequestProfile,setApiRequestConnection} from '${repo}/apps/desktop/src/api/client.ts';
|
||||
setApiRequestProfile('profile-b');setApiRequestConnection('fixture-remote');
|
||||
const queryClient=new QueryClient({defaultOptions:{queries:{retry:false}}});function Probe(){const [profile,setProfile]=React.useState('profile-b');const [shown,setShown]=React.useState(true);window.cancelFlow=()=>setProfile('profile-c');window.unmountFlow=()=>setShown(false);return <QueryClientProvider client={queryClient}>{shown && <McpTab gateway={null} profile={{connectionId:'fixture-remote',profile}} />}</QueryClientProvider>};createRoot(document.getElementById('root')).render(<Probe/>);`
|
||||
const bundle = await build({
|
||||
stdin: { contents: entry, resolveDir: repo, loader: 'tsx' },
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
jsx: 'automatic',
|
||||
alias: { '@': repo + '/apps/desktop/src', '@hermes/shared': repo + '/apps/shared/src' },
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'import.meta.env': '{}',
|
||||
'import.meta.env.DEV': 'false',
|
||||
'import.meta.env.PROD': 'true'
|
||||
},
|
||||
loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.svg': 'dataurl' },
|
||||
logLevel: 'warning'
|
||||
})
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader('Content-Type', req.url === '/app.js' ? 'text/javascript' : 'text/html')
|
||||
res.end(
|
||||
req.url === '/app.js'
|
||||
? bundle.outputFiles[0].text
|
||||
: '<div id="root"></div><script type="module" src="/app.js"></script>'
|
||||
)
|
||||
})
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox'],
|
||||
...(process.env.CHROMIUM_EXECUTABLE ? { executablePath: process.env.CHROMIUM_EXECUTABLE } : {})
|
||||
})
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', e => {
|
||||
errors.push(e.message)
|
||||
console.error(e.stack)
|
||||
})
|
||||
page.on('console', m => console.log(m.type(), m.text()))
|
||||
await page.exposeFunction('recordRequest', r => {
|
||||
requests.push(r)
|
||||
console.log('request', JSON.stringify(r))
|
||||
if (r.path === '/api/config')
|
||||
return { mcp_servers: { reports: { url: 'https://fixture.invalid/mcp', auth: 'oauth' } } }
|
||||
if (r.path.endsWith('/test')) return { ok: false, tools: [], auth_required: true, error: 'OAuth required' }
|
||||
if (r.path.includes('/logs')) return { lines: [] }
|
||||
if (r.path.includes('/catalog')) return { entries: [] }
|
||||
if (r.path.endsWith('/auth'))
|
||||
return { flow_id: 'f', status: 'error', error: 'fixture remote HTTP redirect rejected' }
|
||||
return { ok: true, providers: [] }
|
||||
})
|
||||
await page.exposeFunction('nativeOAuth', async (action, id) => {
|
||||
nativeCalls.push({ action, id })
|
||||
return handlers.get('hermes:mcp-oauth:' + action)({}, id)
|
||||
})
|
||||
await page.exposeFunction('fixtureConnection', async scope => {
|
||||
nativeCalls.push({ action: 'connection', scope })
|
||||
return { wsUrl: 'ws://127.0.0.1:' + wss.address().port, connectionId: scope.connectionId, authMode: 'token' }
|
||||
})
|
||||
await page.exposeFunction('openAuthorization', async url => {
|
||||
nativeCalls.push({ action: 'open', url })
|
||||
if (scenario === 'cancel' || scenario === 'unmount') {
|
||||
await page.evaluate(s => (s === 'unmount' ? window.unmountFlow() : window.cancelFlow()), scenario)
|
||||
return
|
||||
}
|
||||
const u = new URL(url)
|
||||
await fetch(u.searchParams.get('redirect_uri') + '?code=fixture-code&state=' + u.searchParams.get('state'))
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
window.hermesDesktop = {
|
||||
api: r => window.recordRequest(r),
|
||||
getConnectionFor: r => window.fixtureConnection(r),
|
||||
mcpOauth: {
|
||||
listen: () => {
|
||||
window.nativeListen = true
|
||||
return window.nativeOAuth('listen')
|
||||
},
|
||||
wait: id => window.nativeOAuth('wait', id),
|
||||
cancel: id => window.nativeOAuth('cancel', id)
|
||||
},
|
||||
openExternal: url => window.openAuthorization(url)
|
||||
}
|
||||
})
|
||||
await page.goto('http://127.0.0.1:' + server.address().port)
|
||||
await page.waitForTimeout(1500)
|
||||
console.log('BODY', await page.locator('body').innerText())
|
||||
await page.getByText('Reports', { exact: true }).first().click()
|
||||
await page.waitForTimeout(300)
|
||||
console.log('SELECTED', await page.locator('body').innerText())
|
||||
await page
|
||||
.getByRole('button', { name: /Authenticate|Sign in/ })
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForTimeout(2500)
|
||||
const hasScope = await page.evaluate(() => !!window.nativeListen)
|
||||
const result = {
|
||||
repo,
|
||||
scenario,
|
||||
requests,
|
||||
rpcCalls,
|
||||
nativeCalls,
|
||||
approved,
|
||||
hasScope,
|
||||
errors,
|
||||
text: await page.locator('body').innerText(),
|
||||
fidelity: 'production McpTab + all real renderer imports in headless Chromium; fixture native API transport'
|
||||
}
|
||||
await fs.writeFile(out, JSON.stringify(result, null, 2))
|
||||
console.log(JSON.stringify(result))
|
||||
if (scenario === 'cancel' || scenario === 'unmount') {
|
||||
assert(
|
||||
rpcCalls.some(m => m.method === 'mcp.servers.oauth.cancel'),
|
||||
'abandoned component must cancel backend'
|
||||
)
|
||||
assert(
|
||||
nativeCalls.some(m => m.action === 'cancel'),
|
||||
'abandoned component must close listener'
|
||||
)
|
||||
assert(
|
||||
nativeCalls.filter(m => m.action === 'connection').every(m => m.scope.profile === 'profile-b'),
|
||||
'cleanup must retain original owner'
|
||||
)
|
||||
assert(!rpcCalls.some(m => m.method === 'mcp.servers.oauth.callback'), 'no callback after abandonment')
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
for (const c of wss.clients) c.terminate()
|
||||
wss.close()
|
||||
}
|
||||
@@ -34,6 +34,7 @@ def test_cancel_is_scoped_idempotent_and_releases_worker(
|
||||
|
||||
monkeypatch.setattr(sessions, "_worker", worker)
|
||||
home = str(tmp_path / "origin")
|
||||
monkeypatch.setenv("HERMES_HOME", home)
|
||||
result = sessions.start_flow(
|
||||
home,
|
||||
"reports",
|
||||
@@ -79,6 +80,43 @@ def test_cancel_is_scoped_idempotent_and_releases_worker(
|
||||
finished.wait(5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["poll", "callback", "cancel"])
|
||||
def test_session_operations_require_resolved_owner(tmp_path, monkeypatch, operation):
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
home = str(tmp_path / "owner")
|
||||
flow = DashboardOAuthFlow(
|
||||
"owned", "reports", None, home, "http://127.0.0.1:49152/callback"
|
||||
)
|
||||
asyncio.run(flow.publish_authorization_url("https://idp.example/authorize?state=test"))
|
||||
monkeypatch.setattr(sessions, "_sessions", {
|
||||
"owned": {"flow": flow, "server_name": "reports", "hermes_home": home, "httpd": None}
|
||||
})
|
||||
|
||||
def invoke():
|
||||
if operation == "poll":
|
||||
return sessions.poll_flow("owned", "reports")
|
||||
if operation == "callback":
|
||||
return sessions.deliver_callback_flow("owned", "reports", code="valid", state="test")
|
||||
from hermes_constants import get_hermes_home
|
||||
return sessions.cancel_flow("owned", "reports", str(get_hermes_home()))
|
||||
|
||||
token = set_hermes_home_override(tmp_path / "other")
|
||||
try:
|
||||
rejected = invoke()
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
assert "profile mismatch" in (rejected.get("error_message") or "")
|
||||
assert "auth_url" not in rejected
|
||||
assert flow.snapshot()["status"] == "authorization_required"
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
accepted = invoke()
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
assert accepted.get("ok", accepted.get("status") == "pending") is True
|
||||
|
||||
|
||||
def test_cancel_does_not_revoke_an_approved_flow(tmp_path, monkeypatch):
|
||||
home = str(tmp_path)
|
||||
flow = DashboardOAuthFlow(
|
||||
|
||||
@@ -16,6 +16,7 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
|
||||
from tui_gateway import mcp_oauth_sessions
|
||||
from tui_gateway.mcp_oauth_sessions import (
|
||||
@@ -106,7 +107,7 @@ def test_start_flow_client_redirect_skips_gateway_listener(monkeypatch):
|
||||
)
|
||||
|
||||
result = mcp_oauth_sessions.start_flow(
|
||||
"/tmp/hermes-test-home",
|
||||
str(get_hermes_home()),
|
||||
"clicky",
|
||||
{"url": "https://mcp.example.com/mcp", "auth": "oauth"},
|
||||
client_redirect_uri="http://127.0.0.1:8412/callback",
|
||||
@@ -131,7 +132,7 @@ def test_start_flow_rejects_bad_client_redirect(monkeypatch):
|
||||
_fake_worker_publishes_url(monkeypatch)
|
||||
with pytest.raises(ValueError):
|
||||
mcp_oauth_sessions.start_flow(
|
||||
"/tmp/hermes-test-home",
|
||||
str(get_hermes_home()),
|
||||
"clicky2",
|
||||
{"url": "https://mcp.example.com/mcp", "auth": "oauth"},
|
||||
client_redirect_uri="https://evil.example.com/callback",
|
||||
@@ -152,7 +153,7 @@ def _make_session(session_id="sess-relay-1", server="hosp", state="s3cr3tstate")
|
||||
flow_id=session_id,
|
||||
server_name=server,
|
||||
profile=None,
|
||||
hermes_home="/tmp/hermes-test-home",
|
||||
hermes_home=str(get_hermes_home()),
|
||||
redirect_uri="http://127.0.0.1:9000/callback",
|
||||
)
|
||||
# Pin the expected state the way publish_authorization_url does.
|
||||
@@ -166,7 +167,7 @@ def _make_session(session_id="sess-relay-1", server="hosp", state="s3cr3tstate")
|
||||
rec = {
|
||||
"session_id": session_id,
|
||||
"server_name": server,
|
||||
"hermes_home": "/tmp/hermes-test-home",
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"flow": flow,
|
||||
"httpd": None,
|
||||
"created_at": __import__("time").time(),
|
||||
|
||||
@@ -207,14 +207,19 @@ def start_flow(
|
||||
return {"session_id": session_id, "auth_url": auth_url, "flow": "pkce"}
|
||||
|
||||
|
||||
def _lookup(session_id: str, server_name: str) -> "tuple[Dict[str, Any] | None, str | None]":
|
||||
"""Find a session record; returns ``(rec, None)`` or ``(None, error_message)``."""
|
||||
def _lookup(
|
||||
session_id: str, server_name: str, hermes_home: Optional[str] = None,
|
||||
) -> "tuple[Dict[str, Any] | None, str | None]":
|
||||
"""Find a session belonging to the caller's resolved profile."""
|
||||
from hermes_constants import hermes_home_key
|
||||
with _sessions_lock:
|
||||
rec = _sessions.get(session_id)
|
||||
if rec is None:
|
||||
return None, "OAuth session not found or expired"
|
||||
if rec["server_name"] != server_name:
|
||||
return None, "server name mismatch for session"
|
||||
if hermes_home_key(rec["hermes_home"]) != hermes_home_key(hermes_home):
|
||||
return None, "profile mismatch for session"
|
||||
return rec, None
|
||||
|
||||
|
||||
@@ -239,11 +244,9 @@ def poll_flow(session_id: str, server_name: str) -> Dict[str, Any]:
|
||||
|
||||
def cancel_flow(session_id: str, server_name: str, hermes_home: str) -> Dict[str, Any]:
|
||||
"""Cancel only the owning profile's flow and release its callback waiter."""
|
||||
rec, err = _lookup(session_id, server_name)
|
||||
rec, err = _lookup(session_id, server_name, hermes_home)
|
||||
if rec is None:
|
||||
return {"ok": False, "error_message": err}
|
||||
if rec["hermes_home"] != hermes_home:
|
||||
return {"ok": False, "error_message": "profile mismatch for session"}
|
||||
flow = rec["flow"]
|
||||
flow.mark_error("OAuth cancelled by user")
|
||||
_shutdown_listener(rec)
|
||||
|
||||
@@ -44,6 +44,14 @@ If your provider isn't in the table, you don't need a tunnel.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
**Desktop Skills → MCP:** the native app receives the callback on your computer
|
||||
and relays it to the selected connection and profile, so this flow does not need
|
||||
an SSH callback tunnel or `dashboard.public_url`. Tokens stay on the owning
|
||||
backend profile. Leaving the MCP tab or changing its scope cancels pending
|
||||
sign-in. If Desktop asks you to update the backend, update it before retrying;
|
||||
it does not fall back to a remote HTTP callback. The terminal workflows below
|
||||
are unchanged.
|
||||
|
||||
Remote MCP servers (Linear, Sentry, Atlassian, Asana, Figma, etc.) use the same loopback redirect flow. Hermes auto-picks a free port per server and prints the authorize URL when the OAuth flow kicks off — either at startup (when a new server appears in `mcp_servers:`) or when you run `hermes mcp login <server>`.
|
||||
|
||||
You have two ways to complete it from a remote host:
|
||||
|
||||
Reference in New Issue
Block a user