324 lines
19 KiB
Python
324 lines
19 KiB
Python
import io
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import sys
|
|
import time
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from framebridge_expanded import tunnel
|
|
|
|
ID = "tunnel_0123456789abcdef0123456789abcdef"
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class ParserTests(unittest.TestCase):
|
|
def test_id_and_documented_commands(self):
|
|
commands = [
|
|
ID,
|
|
"'" + ID + "'",
|
|
"tunnel-client run --control-plane.tunnel-id " + ID,
|
|
"/opt/client/tunnel-client run --control-plane.tunnel-id=" + ID + " --control-plane.api-key env:OLD_KEY --mcp.server-url url=http://127.0.0.1:8898/mcp",
|
|
"tunnel-client init --profile demo --sample sample_mcp_remote_no_auth --tunnel-id " + ID + " --mcp-server-url http://127.0.0.1:8898/mcp",
|
|
"tunnel-client runtimes connect --alias demo --tunnel-id " + ID + " --runtime-api-key env:OLD_KEY --json",
|
|
"framebridge-original tunnel -- run --control-plane.tunnel-id " + ID,
|
|
]
|
|
for command in commands:
|
|
with self.subTest(command=command):
|
|
self.assertEqual(tunnel.parse_tunnel_input(command).tunnel_id, ID)
|
|
|
|
def test_rejections_never_echo_hidden_input(self):
|
|
secret = "sk-test-DO_NOT_DISCLOSE-0123456789"
|
|
bad = [
|
|
"sudo tunnel-client run --control-plane.tunnel-id " + ID,
|
|
"env OPENAI_API_KEY=" + secret + " tunnel-client run --control-plane.tunnel-id " + ID,
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + "; echo bad",
|
|
"tunnel-client run --control-plane.tunnel-id $(" + secret + ")",
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + " | cat",
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + " --config /private/profile.yaml",
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + " --control-plane.api-key " + secret,
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + " --control-plane.api-key file:/private/key",
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + " --control-plane.base-url https://example.invalid",
|
|
"tunnel-client run --control-plane.tunnel-id " + ID + " --tunnel-id " + ID,
|
|
"tunnel-client run --control-plane.tunnel-id",
|
|
"tunnel-client run --control-plane.tunnel-id '" + secret,
|
|
]
|
|
for command in bad:
|
|
with self.subTest(command=command):
|
|
with self.assertRaises(tunnel.TunnelInputError) as error:
|
|
tunnel.parse_tunnel_input(command)
|
|
self.assertNotIn(secret, str(error.exception))
|
|
self.assertNotIn(command, str(error.exception))
|
|
|
|
def test_rebuild_uses_packaged_binary_and_profile_endpoint(self):
|
|
spec = tunnel.parse_tunnel_input("tunnel-client run --control-plane.tunnel-id " + ID + " --mcp.server-url https://mcp.example.com/mcp")
|
|
args = tunnel.build_command("/packaged/tunnel-client", "/own/state", "http://127.0.0.1:20865/mcp", spec)
|
|
self.assertEqual(args[0], "/packaged/tunnel-client")
|
|
self.assertIn("url=http://127.0.0.1:20865/mcp", args)
|
|
self.assertNotIn("https://mcp.example.com/mcp", args)
|
|
self.assertIn("env:" + tunnel.KEY_ENV, args)
|
|
|
|
def test_inherited_runtime_settings_removed_without_global_mutation(self):
|
|
original = {"PATH": "/usr/bin", "DISPLAY": ":77", "HTTPS_PROXY": "http://proxy.example", "CA_BUNDLE": "/ca.pem",
|
|
"TUNNEL_CLIENT_CONFIG": "/production/config", "CONTROL_PLANE_TUNNEL_ID": "production",
|
|
"MCP_SERVER_URL": "https://production", "MCP_COMMAND": "production-command",
|
|
"HEALTH_URL_FILE": "/production/health", "LOG_HTTP_RAW_UNSAFE": "true",
|
|
"CLOUDFLARED_TUNNEL_TOKEN": "not-used", "OPENAI_API_KEY": "not-used"}
|
|
snapshot = dict(original)
|
|
env = tunnel.child_environment("FAKE_RUNTIME_KEY", original)
|
|
self.assertEqual(original, snapshot)
|
|
for key in snapshot:
|
|
if key.startswith(tunnel._RUNTIME_PREFIXES):
|
|
self.assertNotIn(key, env)
|
|
self.assertEqual(env["HTTPS_PROXY"], original["HTTPS_PROXY"])
|
|
self.assertEqual(env[tunnel.KEY_ENV], "FAKE_RUNTIME_KEY")
|
|
|
|
def test_child_default_profile_paths_are_private_and_scoped(self):
|
|
with tempfile.TemporaryDirectory(prefix="tunnel-test-", dir=ROOT / "qa") as td:
|
|
env = tunnel.child_environment("FAKE_ONLY", {"HOME": "/old/home", "XDG_CONFIG_HOME": "/old/config"}, state=Path(td))
|
|
self.assertEqual(env["HOME"], str(Path(td) / "chatgpt-home"))
|
|
self.assertEqual(env["XDG_CONFIG_HOME"], str(Path(td) / "chatgpt-home/.config"))
|
|
self.assertEqual(Path(env["TMPDIR"]).stat().st_mode & 0o777, 0o700)
|
|
self.assertFalse(any(p.is_file() for p in Path(td).rglob("*")))
|
|
|
|
def test_saved_public_context_is_reused(self):
|
|
public = {"tunnel_id": ID, "control_plane_organization_id": "org_test", "control_plane_url_path": "/v1/tunnel"}
|
|
with mock.patch.object(tunnel, "_hidden", side_effect=["", "FAKE_ONLY"]), mock.patch("framebridge_expanded.connection_background.run_foreground", return_value=0) as run:
|
|
rc = tunnel.connect_chatgpt("/binary", "/state", "http://127.0.0.1:20865/mcp", public, output=io.StringIO(), foreground=True)
|
|
self.assertEqual(rc, 0)
|
|
self.assertIn("org_test", run.call_args.args[0])
|
|
self.assertIn("/v1/tunnel", run.call_args.args[0])
|
|
|
|
def test_non_tty_fails_before_secret_prompt(self):
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=False), mock.patch.object(tunnel.getpass, "getpass") as prompt:
|
|
with self.assertRaises(tunnel.TunnelInputError):
|
|
tunnel._hidden("Hidden: ")
|
|
prompt.assert_not_called()
|
|
|
|
|
|
class ForegroundTests(unittest.TestCase):
|
|
def command(self, code):
|
|
return [sys.executable, "-u", "-c", code,
|
|
"--control-plane.tunnel-id", ID,
|
|
"--mcp.server-url", "url=http://127.0.0.1:20865/mcp"]
|
|
|
|
def test_fake_key_only_in_child_environment_and_private_log(self):
|
|
secret = "FAKE_RUNTIME_ONLY_0123456789"
|
|
seen = {}
|
|
real_popen = subprocess.Popen
|
|
def spawn(args, **kwargs):
|
|
seen["args"] = list(args)
|
|
seen["env"] = dict(kwargs["env"])
|
|
return real_popen(args, **kwargs)
|
|
code = "import os,time;print('TECHNICAL_DEBUG '+os.environ['" + tunnel.KEY_ENV + "'],flush=True);time.sleep(.3)"
|
|
with tempfile.TemporaryDirectory(prefix="tunnel-output-") as td:
|
|
output = io.StringIO()
|
|
with mock.patch("framebridge_expanded.connection_monitor.probe", return_value=("ready", "Verified synthetic test state")):
|
|
result = tunnel.run_foreground(self.command(code), secret, state=td, output=output, popen=spawn)
|
|
data = (Path(td) / "logs/chatgpt/connection.log").read_text()
|
|
self.assertEqual(result, 0)
|
|
self.assertEqual(seen["env"][tunnel.KEY_ENV], secret)
|
|
self.assertNotIn(secret, repr(seen["args"]))
|
|
self.assertNotIn(secret, output.getvalue())
|
|
self.assertNotIn("TECHNICAL_DEBUG", output.getvalue())
|
|
self.assertIn("TECHNICAL_DEBUG [REDACTED]", data)
|
|
self.assertNotIn(secret, data)
|
|
self.assertIn("[OK]", output.getvalue())
|
|
self.assertIn("successful tool call", output.getvalue())
|
|
self.assertEqual((Path(td) / "logs/chatgpt/connection.log").stat().st_mode & 0o777, 0o600)
|
|
|
|
def test_ctrl_c_stops_only_owned_child(self):
|
|
real_popen = subprocess.Popen
|
|
seen = {}
|
|
def spawn(args, **kwargs):
|
|
seen["process"] = real_popen(args, **kwargs)
|
|
return seen["process"]
|
|
with tempfile.TemporaryDirectory(prefix="tunnel-stop-") as td:
|
|
with mock.patch("framebridge_expanded.connection_monitor.probe", side_effect=KeyboardInterrupt):
|
|
rc = tunnel.run_foreground(self.command("import time;time.sleep(60)"), "FAKE_ONLY", state=td,
|
|
output=io.StringIO(), popen=spawn)
|
|
self.assertEqual(rc, 130)
|
|
self.assertIsNotNone(seen["process"].poll())
|
|
self.assertFalse(any((Path(td) / "connection-runtime").iterdir()))
|
|
|
|
def test_interactive_flow_saves_only_public_values(self):
|
|
secret = "FAKE_RUNTIME_ONLY_0123456789"
|
|
saved = []
|
|
output = io.StringIO()
|
|
with mock.patch.object(tunnel, "_hidden", side_effect=[ID, secret]), mock.patch("framebridge_expanded.connection_background.run_foreground", return_value=0) as run:
|
|
rc = tunnel.connect_chatgpt("/binary", "/state", "http://127.0.0.1:20865/mcp", {"account_label": "test@example.com"}, saved.append, output=output, foreground=True)
|
|
self.assertEqual(rc, 0)
|
|
self.assertEqual(saved[0]["tunnel_id"], ID)
|
|
self.assertNotIn(secret, repr(saved))
|
|
self.assertNotIn(secret, output.getvalue())
|
|
self.assertEqual(run.call_args.args[1], secret)
|
|
self.assertNotIn(secret, repr(run.call_args.args[0]))
|
|
|
|
|
|
class ManagedWizardTests(unittest.TestCase):
|
|
@staticmethod
|
|
def ready():
|
|
return {"configured": True, "enabled": True, "service_active": True,
|
|
"service_state": "active", "runtime": {"running": True, "ready": True},
|
|
"connection": {"alive": True, "verified": True, "state": "ready"}}
|
|
|
|
def invoke(self, configure, *, public=None, output=None, wait_timeout=0):
|
|
return tunnel.connect_chatgpt("/binary", "/state", "http://127.0.0.1:20865/mcp", public,
|
|
output=output or io.StringIO(), configure_connection=configure,
|
|
wait_timeout=wait_timeout)
|
|
|
|
def test_three_fields_are_ordered_and_only_public_settings_are_committed(self):
|
|
events = []
|
|
def label(prompt):
|
|
events.append(prompt)
|
|
return "test@example.com"
|
|
answers = iter([ID, "SYNTHETIC-MCP-ACCESS-KEY"])
|
|
def hidden(prompt):
|
|
events.append(prompt)
|
|
return next(answers)
|
|
seen = []
|
|
def configure(state, public, credential):
|
|
events.append("commit")
|
|
seen.append((state, public, credential))
|
|
return self.ready()
|
|
output = io.StringIO()
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", side_effect=label), mock.patch.object(tunnel, "_hidden", side_effect=hidden):
|
|
rc = self.invoke(configure, output=output)
|
|
self.assertEqual(0, rc)
|
|
self.assertTrue(events[0].startswith("ChatGPT account (email label)"))
|
|
self.assertTrue(events[1].startswith("Tunnel ID (hidden"))
|
|
self.assertEqual("MCP server access key (hidden): ", events[2])
|
|
self.assertEqual("commit", events[3])
|
|
self.assertEqual("test@example.com", seen[0][1]["account_label"])
|
|
self.assertEqual(ID, seen[0][1]["tunnel_id"])
|
|
self.assertNotIn("SYNTHETIC-MCP-ACCESS-KEY", repr(seen[0][1]))
|
|
self.assertNotIn("SYNTHETIC-MCP-ACCESS-KEY", output.getvalue())
|
|
self.assertEqual("Connecting...\n[OK] Connected.\n", output.getvalue())
|
|
|
|
def test_cancel_at_each_field_never_calls_committer(self):
|
|
for field in range(3):
|
|
with self.subTest(field=field):
|
|
configure = mock.Mock()
|
|
label = mock.Mock(return_value="test@example.com")
|
|
hidden = mock.Mock(side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"])
|
|
if field == 0:
|
|
label.side_effect = KeyboardInterrupt
|
|
elif field == 1:
|
|
hidden.side_effect = KeyboardInterrupt
|
|
else:
|
|
hidden.side_effect = [ID, KeyboardInterrupt]
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", label), mock.patch.object(tunnel, "_hidden", hidden):
|
|
rc = self.invoke(configure)
|
|
self.assertEqual(130, rc)
|
|
configure.assert_not_called()
|
|
|
|
def test_full_command_in_normal_hidden_id_field_is_rejected_without_echo(self):
|
|
configure = mock.Mock()
|
|
pasted = "tunnel-client run --tunnel-id " + ID + " --api-key SYNTHETIC-PASTED-SECRET"
|
|
output = io.StringIO()
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", return_value=pasted) as hidden:
|
|
with self.assertRaises(tunnel.TunnelInputError) as error:
|
|
self.invoke(configure, output=output)
|
|
self.assertNotIn("SYNTHETIC-PASTED-SECRET", str(error.exception) + output.getvalue())
|
|
self.assertEqual(1, hidden.call_count)
|
|
configure.assert_not_called()
|
|
|
|
def test_invalid_key_does_not_commit_and_is_not_echoed(self):
|
|
configure = mock.Mock()
|
|
output = io.StringIO()
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC INVALID KEY"]):
|
|
with self.assertRaises(tunnel.TunnelInputError) as error:
|
|
self.invoke(configure, output=output)
|
|
self.assertNotIn("SYNTHETIC INVALID KEY", str(error.exception) + output.getvalue())
|
|
configure.assert_not_called()
|
|
|
|
def test_saved_public_defaults_preserve_tunnel_context_but_key_is_requested(self):
|
|
public = {"account_label": "test@example.com", "tunnel_id": ID,
|
|
"control_plane_organization_id": "org_test", "control_plane_url_path": "/v1/tunnel"}
|
|
configure = mock.Mock(return_value=self.ready())
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value=""), mock.patch.object(tunnel, "_hidden", side_effect=["", "SYNTHETIC-MCP-ACCESS-KEY"]) as hidden:
|
|
self.assertEqual(0, self.invoke(configure, public=public))
|
|
self.assertEqual(2, hidden.call_count)
|
|
self.assertEqual(public, configure.call_args.args[1])
|
|
|
|
def test_pending_returns_prompt_nonzero_without_false_success_or_reboot(self):
|
|
pending = self.ready()
|
|
pending["connection"] = {"alive": True, "verified": False, "state": "connecting"}
|
|
output = io.StringIO()
|
|
self.assertEqual(1, tunnel._wait_managed_connection("/state", pending, output, wait_timeout=0))
|
|
self.assertIn("[PENDING]", output.getvalue())
|
|
self.assertNotIn("[OK]", output.getvalue())
|
|
self.assertNotIn("reboot", output.getvalue().lower())
|
|
|
|
def test_failed_service_cannot_use_stale_verified_connection_as_success(self):
|
|
for failure in ({"service_state": "failed", "service_active": False}, {"service_state": "failed", "service_active": True}, {"worker": {"state": "failed"}}):
|
|
with self.subTest(failure=failure):
|
|
state = self.ready()
|
|
state.update(failure)
|
|
output = io.StringIO()
|
|
self.assertEqual(1, tunnel._wait_managed_connection("/state", state, output, wait_timeout=0))
|
|
self.assertIn("[ERROR]", output.getvalue())
|
|
self.assertNotIn("[OK]", output.getvalue())
|
|
|
|
def test_readiness_is_polled_and_key_is_requested_only_once(self):
|
|
from framebridge_expanded import autostart
|
|
starting = self.ready()
|
|
starting["connection"] = {"alive": False, "verified": False, "state": "connecting"}
|
|
configure = mock.Mock(return_value=starting)
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"]) as hidden, mock.patch.object(tunnel.time, "sleep"), mock.patch.object(autostart, "status", return_value=self.ready()) as status:
|
|
self.assertEqual(0, self.invoke(configure, wait_timeout=2))
|
|
self.assertEqual(2, hidden.call_count)
|
|
self.assertEqual("MCP server access key (hidden): ", hidden.call_args.args[0])
|
|
status.assert_called_once_with("/state")
|
|
|
|
def test_interrupt_during_wait_reports_saved_background_connection(self):
|
|
from framebridge_expanded import autostart
|
|
starting = self.ready()
|
|
starting["connection"] = {"alive": False, "verified": False, "state": "connecting"}
|
|
output = io.StringIO()
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"]), mock.patch.object(tunnel.time, "sleep"), mock.patch.object(autostart, "status", side_effect=KeyboardInterrupt):
|
|
self.assertEqual(130, self.invoke(mock.Mock(return_value=starting), output=output, wait_timeout=2))
|
|
self.assertIn("continues in the background", output.getvalue())
|
|
self.assertNotIn("Connection cancelled", output.getvalue())
|
|
|
|
def test_interrupt_after_commit_does_not_report_connection_cancelled(self):
|
|
marker = {"configured": True, "enabled": True, "interrupted_after_commit": True}
|
|
configure = mock.Mock(return_value=marker)
|
|
output = io.StringIO()
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=True), mock.patch("builtins.input", return_value="test@example.com"), mock.patch.object(tunnel, "_hidden", side_effect=[ID, "SYNTHETIC-MCP-ACCESS-KEY"]):
|
|
self.assertEqual(130, self.invoke(configure, output=output))
|
|
self.assertIn("continues in the background", output.getvalue())
|
|
self.assertNotIn("cancelled", output.getvalue().lower())
|
|
self.assertNotIn("[OK]", output.getvalue())
|
|
|
|
def test_resume_status_interrupted_after_commit_is_not_reported_cancelled(self):
|
|
marker = {"configured": True, "enabled": True, "interrupted_after_commit": True}
|
|
output = io.StringIO()
|
|
self.assertEqual(130, tunnel.resume_managed_connection("/state", mock.Mock(return_value=marker), output=output, wait_timeout=0))
|
|
self.assertIn("continues in the background", output.getvalue())
|
|
self.assertNotIn("cancelled", output.getvalue().lower())
|
|
|
|
def test_non_tty_fails_before_any_input_or_commit(self):
|
|
configure = mock.Mock()
|
|
with mock.patch.object(tunnel.sys.stdin, "isatty", return_value=False), mock.patch("builtins.input") as label, mock.patch.object(tunnel, "_hidden") as hidden:
|
|
with self.assertRaises(tunnel.TunnelInputError):
|
|
self.invoke(configure)
|
|
label.assert_not_called()
|
|
hidden.assert_not_called()
|
|
configure.assert_not_called()
|
|
|
|
def test_resume_uses_no_setup_prompts(self):
|
|
resume = mock.Mock(return_value=self.ready())
|
|
with mock.patch("builtins.input") as label, mock.patch.object(tunnel, "_hidden") as hidden:
|
|
self.assertEqual(0, tunnel.resume_managed_connection("/state", resume, output=io.StringIO(), wait_timeout=0))
|
|
label.assert_not_called()
|
|
hidden.assert_not_called()
|
|
resume.assert_called_once_with("/state")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|