Skip to content

Commit a41011d

Browse files
authored
fix(ohmo): gate gateway-scoped provider and model commands (#281)
/provider and /model are registered with remote_invocable=False and remote_admin_opt_in=True in src/openharness/commands/registry.py, but OhmoSessionRuntimePool.stream_message intercepted them with _handle_gateway_scoped_command before the remote-allowed gate ran, so the contract was silently skipped for both commands. Move the gateway-scoped intercept after the gating block so the existing remote_admin_opt_in / allow_remote_admin_commands + allowed_remote_admin_commands path governs them like every other admin command. Add a regression test that asserts /provider and /model are rejected unless the operator has opted in, and update the existing positive test to set the opt-in so it continues to exercise the success path. Refs #280 Co-authored-by: glitch-ux <glitch-ux@users.noreply.github.com>
1 parent bf5931e commit a41011d

2 files changed

Lines changed: 107 additions & 14 deletions

File tree

ohmo/gateway/runtime.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -260,19 +260,6 @@ def get_command_context() -> CommandContext:
260260
if parsed is not None and not message.media:
261261
command, args = parsed
262262
command_name = str(getattr(command, "name", "") or "")
263-
gateway_result = self._handle_gateway_scoped_command(command_name, args)
264-
if gateway_result is not None:
265-
message_text, refresh_runtime = gateway_result
266-
result = CommandResult(message=message_text, refresh_runtime=refresh_runtime)
267-
async for update in self._stream_command_result(
268-
bundle=bundle,
269-
message=message,
270-
session_key=session_key,
271-
user_prompt=user_prompt,
272-
result=result,
273-
):
274-
yield update
275-
return
276263
remote_allowed = getattr(command, "remote_invocable", True)
277264
if not remote_allowed and self._remote_admin_allowed(command):
278265
remote_allowed = True
@@ -296,6 +283,19 @@ def get_command_context() -> CommandContext:
296283
):
297284
yield update
298285
return
286+
gateway_result = self._handle_gateway_scoped_command(command_name, args)
287+
if gateway_result is not None:
288+
message_text, refresh_runtime = gateway_result
289+
result = CommandResult(message=message_text, refresh_runtime=refresh_runtime)
290+
async for update in self._stream_command_result(
291+
bundle=bundle,
292+
message=message,
293+
session_key=session_key,
294+
user_prompt=user_prompt,
295+
result=result,
296+
):
297+
yield update
298+
return
299299
result = await command.handler(
300300
args,
301301
get_command_context(),

tests/test_ohmo/test_gateway.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2654,7 +2654,14 @@ def test_runtime_pool_sanitizes_internal_group_prompt_metadata():
26542654
async def test_runtime_pool_provider_command_refresh_uses_gateway_profile(tmp_path, monkeypatch):
26552655
workspace = tmp_path / ".ohmo-home"
26562656
initialize_workspace(workspace)
2657-
save_gateway_config(GatewayConfig(provider_profile="kimi-anthropic"), workspace)
2657+
save_gateway_config(
2658+
GatewayConfig(
2659+
provider_profile="kimi-anthropic",
2660+
allow_remote_admin_commands=True,
2661+
allowed_remote_admin_commands=["provider", "model"],
2662+
),
2663+
workspace,
2664+
)
26582665
build_calls: list[dict[str, object]] = []
26592666

26602667
statuses = {
@@ -2732,6 +2739,92 @@ async def fake_close_runtime(bundle):
27322739
assert build_calls[1]["active_profile"] == "codex"
27332740

27342741

2742+
@pytest.mark.asyncio
2743+
@pytest.mark.parametrize(
2744+
"command_text,command_name",
2745+
[("/provider codex", "provider"), ("/model gpt-5.5", "model")],
2746+
)
2747+
async def test_runtime_pool_rejects_gateway_scoped_command_without_admin_opt_in(
2748+
tmp_path,
2749+
monkeypatch,
2750+
command_text,
2751+
command_name,
2752+
):
2753+
workspace = tmp_path / ".ohmo-home"
2754+
initialize_workspace(workspace)
2755+
save_gateway_config(GatewayConfig(provider_profile="kimi-anthropic"), workspace)
2756+
build_calls: list[dict[str, object]] = []
2757+
handler_invocations: list[tuple[str, str]] = []
2758+
2759+
def fake_provider_handler(args, **_kwargs):
2760+
handler_invocations.append(("provider", args))
2761+
return ("provider switched", True)
2762+
2763+
def fake_model_handler(args, **_kwargs):
2764+
handler_invocations.append(("model", args))
2765+
return ("model switched", True)
2766+
2767+
class FakeEngine:
2768+
def __init__(self):
2769+
self.messages = [ConversationMessage.from_user_text("before")]
2770+
self.total_usage = UsageSnapshot()
2771+
2772+
def set_system_prompt(self, prompt):
2773+
del prompt
2774+
2775+
async def submit_message(self, content):
2776+
del content
2777+
if False:
2778+
yield None
2779+
2780+
async def fake_build_runtime(**kwargs):
2781+
build_calls.append(kwargs)
2782+
return SimpleNamespace(
2783+
engine=FakeEngine(),
2784+
session_id="sess123",
2785+
current_settings=lambda: SimpleNamespace(model="gpt-5.4"),
2786+
commands=create_default_command_registry(),
2787+
hook_summary=lambda: "",
2788+
mcp_summary=lambda: "",
2789+
plugin_summary=lambda: "",
2790+
cwd=str(tmp_path),
2791+
tool_registry=None,
2792+
app_state=None,
2793+
session_backend=None,
2794+
extra_skill_dirs=(),
2795+
extra_plugin_roots=(),
2796+
enforce_max_turns=False,
2797+
)
2798+
2799+
async def fake_start_runtime(bundle):
2800+
del bundle
2801+
2802+
async def fake_close_runtime(bundle):
2803+
del bundle
2804+
2805+
monkeypatch.setattr(
2806+
"ohmo.gateway.runtime.handle_gateway_provider_command", fake_provider_handler
2807+
)
2808+
monkeypatch.setattr(
2809+
"ohmo.gateway.runtime.handle_gateway_model_command", fake_model_handler
2810+
)
2811+
monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime)
2812+
monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime)
2813+
monkeypatch.setattr("ohmo.gateway.runtime.close_runtime", fake_close_runtime)
2814+
2815+
pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="kimi-anthropic")
2816+
message = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content=command_text)
2817+
updates = [u async for u in pool.stream_message(message, "feishu:c1")]
2818+
2819+
assert handler_invocations == []
2820+
assert any(
2821+
f"/{command_name} is only available in the local OpenHarness UI." in update.text
2822+
for update in updates
2823+
)
2824+
assert load_gateway_config(workspace).provider_profile == "kimi-anthropic"
2825+
assert len(build_calls) == 1
2826+
2827+
27352828
@pytest.mark.asyncio
27362829
async def test_runtime_pool_stream_message_handles_slash_command_and_refresh_runtime(tmp_path, monkeypatch):
27372830
workspace = tmp_path / ".ohmo-home"

0 commit comments

Comments
 (0)