diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index a0b1a370baabc..f6ec890f67c7f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -20,6 +20,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' +import { isImeComposing } from '@/lib/ime' import { cn } from '@/lib/utils' import { $composerAttachments, @@ -555,6 +556,10 @@ export function ChatBar({ return } + if (isImeComposing(event)) { + return + } + if (trigger && triggerItems.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 13134915d7b70..82dfbe4ddc2ab 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -17,6 +17,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Input } from '@/components/ui/input' import { renameSession } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' +import { isImeComposing } from '@/lib/ime' import { exportSession } from '@/lib/session-export' import { notify, notifyError } from '@/store/notifications' import { setSessions } from '@/store/session' @@ -224,7 +225,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re disabled={submitting} onChange={event => setValue(event.target.value)} onKeyDown={event => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !isImeComposing(event)) { event.preventDefault() void submit() } else if (event.key === 'Escape') { diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 0c90d29903da2..2b06c63def913 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -76,6 +76,7 @@ import type { HermesGateway } from '@/hermes' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' +import { isImeComposing } from '@/lib/ime' import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -1195,6 +1196,10 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } ) const handleKeyDown = (event: KeyboardEvent) => { + if (isImeComposing(event)) { + return + } + if (trigger && triggerItems.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault() diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index efe81769e48b5..771759ad893ca 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -17,6 +17,7 @@ import { Sparkles, Terminal } from '@/lib/icons' +import { isImeComposing } from '@/lib/ime' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { cn } from '@/lib/utils' import { $desktopBoot, type DesktopBootState } from '@/store/boot' @@ -483,7 +484,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon autoFocus className="font-mono" onChange={e => setValue(e.target.value)} - onKeyDown={e => e.key === 'Enter' && void submit()} + onKeyDown={e => e.key === 'Enter' && !isImeComposing(e) && void submit()} placeholder={option.placeholder || 'Paste API key'} type={isLocal ? 'text' : 'password'} value={value} @@ -551,7 +552,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow setOnboardingCode(e.target.value)} - onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)} + onKeyDown={e => e.key === 'Enter' && !isImeComposing(e) && void submitOnboardingCode(ctx)} placeholder="Paste authorization code" value={flow.code} /> @@ -672,9 +673,11 @@ function ConfirmingModelPanel({ queryKey: ['onboarding-model-options', flow.providerSlug], queryFn: () => getGlobalModelOptions() }) + const providerRow = options.data?.providers?.find( p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase() ) + const price = providerRow?.pricing?.[flow.currentModel] const freeTier = providerRow?.free_tier diff --git a/apps/desktop/src/lib/ime.test.ts b/apps/desktop/src/lib/ime.test.ts new file mode 100644 index 0000000000000..4ea9ec925685e --- /dev/null +++ b/apps/desktop/src/lib/ime.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { isImeComposing } from './ime' + +describe('isImeComposing', () => { + it('detects active IME composition on native keyboard events', () => { + expect(isImeComposing({ nativeEvent: { isComposing: true } })).toBe(true) + }) + + it('detects legacy process-key IME events by keyCode 229', () => { + expect(isImeComposing({ nativeEvent: { keyCode: 229 } })).toBe(true) + }) + + it('does not treat ordinary Enter events as composition', () => { + expect(isImeComposing({ nativeEvent: { isComposing: false, keyCode: 13 } })).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/ime.ts b/apps/desktop/src/lib/ime.ts new file mode 100644 index 0000000000000..4f70b8d937832 --- /dev/null +++ b/apps/desktop/src/lib/ime.ts @@ -0,0 +1,14 @@ +type ImeKeyboardLikeEvent = { + isComposing?: boolean + keyCode?: number + nativeEvent?: { + isComposing?: boolean + keyCode?: number + } +} + +export const isImeComposing = (event: ImeKeyboardLikeEvent): boolean => { + const nativeEvent = event.nativeEvent + + return Boolean(nativeEvent?.isComposing || event.isComposing || nativeEvent?.keyCode === 229 || event.keyCode === 229) +} diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f477019cbf531..3a06a86746b17 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2381,7 +2381,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) RestartMaxDelaySec=300 RestartSteps=5 RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE} -KillMode=mixed +KillMode=control-group KillSignal=SIGTERM ExecReload=/bin/kill -USR1 $MAINPID TimeoutStopSec={restart_timeout} @@ -2416,7 +2416,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) RestartMaxDelaySec=300 RestartSteps=5 RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE} -KillMode=mixed +KillMode=control-group KillSignal=SIGTERM ExecReload=/bin/kill -USR1 $MAINPID TimeoutStopSec={restart_timeout} diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index c6baa71563240..448809c1c2d85 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -327,6 +327,8 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self assert "ExecStop=" not in unit assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit + assert "KillMode=control-group" in unit + assert "KillMode=mixed" not in unit # TimeoutStopSec must exceed the default drain_timeout (60s) so # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup # (tool subprocess kill, adapter disconnect) runs — issue #8202. @@ -388,6 +390,8 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se assert "ExecStop=" not in unit assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit + assert "KillMode=control-group" in unit + assert "KillMode=mixed" not in unit # TimeoutStopSec must exceed the default drain_timeout (60s) so # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup # (tool subprocess kill, adapter disconnect) runs — issue #8202. diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index ef5358e678938..1158ab4e6bbc9 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -1525,3 +1525,79 @@ def _capture(event_name, **kwargs): assert last_post.get("choice") == "timeout", ( f"hook choice should be 'timeout' on no-response, got {last_post.get('choice')!r}" ) + + +class TestGatewaySystemctlRestartGuard: + """Raw systemctl gateway restarts from inside the gateway must hard-block (#37453).""" + + SESSION_KEY = "test-gateway-systemctl-restart" + + def setup_method(self): + from tools import approval as mod + + mod._gateway_queues.clear() + mod._gateway_notify_cbs.clear() + mod._session_approved.clear() + self._saved_env = { + k: os.environ.get(k) + for k in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION", "HERMES_YOLO_MODE", "HERMES_SESSION_KEY", "HERMES_INTERACTIVE") + } + os.environ.pop("HERMES_CRON_SESSION", None) + os.environ.pop("HERMES_INTERACTIVE", None) + os.environ["HERMES_GATEWAY_SESSION"] = "1" + os.environ["HERMES_SESSION_KEY"] = self.SESSION_KEY + + def teardown_method(self): + from tools import approval as mod + + mod._gateway_queues.clear() + mod._gateway_notify_cbs.clear() + for key, value in self._saved_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def test_blocks_raw_systemctl_restart_without_asking_for_approval(self, monkeypatch): + from tools import approval as mod + + monkeypatch.setattr(mod, "_get_approval_config", lambda: {"mode": "manual", "gateway_timeout": 1}) + notified = [] + mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data)) + + result = mod.check_all_command_guards("systemctl --user restart hermes-gateway", "local") + + assert result["approved"] is False + assert result.get("hardline") is True + assert "systemctl" in result["message"] + assert "hermes-gateway" in result["message"] + assert notified == [] + + def test_yolo_does_not_bypass_gateway_systemctl_restart_guard(self, monkeypatch): + from tools import approval as mod + + os.environ["HERMES_YOLO_MODE"] = "1" + monkeypatch.setattr(mod, "_get_approval_config", lambda: {"mode": "off", "gateway_timeout": 1}) + + result = mod.check_all_command_guards("sudo systemctl restart hermes-gateway.service", "local") + + assert result["approved"] is False + assert result.get("hardline") is True + + def test_blocks_realistic_systemctl_variants(self, monkeypatch): + from tools import approval as mod + + monkeypatch.setattr(mod, "_get_approval_config", lambda: {"mode": "manual", "gateway_timeout": 1}) + + commands = [ + "/usr/bin/systemctl --user restart hermes-gateway", + "systemctl --user --no-block restart hermes-gateway.service", + "sudo systemctl --system stop 'hermes-gateway-coder.service'", + "env FOO=bar /bin/systemctl restart hermes-gateway-coder", + ] + + for command in commands: + result = mod.check_all_command_guards(command, "local") + + assert result["approved"] is False, command + assert result.get("hardline") is True, command diff --git a/tools/approval.py b/tools/approval.py index 47f4a5f443160..f7319b1be96fe 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -329,6 +329,38 @@ def _sudo_stdin_block_result(description: str) -> dict: } +_GATEWAY_SYSTEMCTL_RESTART_RE = re.compile( + _CMDPOS + + r'(?:[^\s;&|`]+/)?systemctl\s+' + + r'(?:(?:--[a-zA-Z0-9][a-zA-Z0-9-]*(?:=\S+)?|-[a-zA-Z0-9-]+)\s+)*' + + r'(?:restart|stop)\s+' + + r'(?:(?:--[a-zA-Z0-9][a-zA-Z0-9-]*(?:=\S+)?|-[a-zA-Z0-9-]+)\s+)*' + + r'["\']?hermes-gateway(?:-[A-Za-z0-9_.-]+)?(?:\.service)?["\']?' + + r'(?=$|\s|[;&|])', + re.IGNORECASE | re.DOTALL, +) + + +def _is_gateway_systemctl_restart_command(command: str) -> bool: + """Return True for raw systemctl commands that stop/restart this gateway.""" + normalized = _normalize_command_for_detection(command) + return bool(_GATEWAY_SYSTEMCTL_RESTART_RE.search(normalized)) + + +def _gateway_systemctl_restart_block_result() -> dict: + return { + "approved": False, + "hardline": True, + "message": ( + "BLOCKED (hardline): raw systemctl stop/restart of hermes-gateway services " + "from inside a gateway session can kill the child systemctl process " + "mid-restart and leave the gateway offline. Do NOT retry or " + "rephrase this command. Use the gateway restart flow outside the " + "active gateway process." + ), + } + + # ========================================================================= # Dangerous command patterns # ========================================================================= @@ -971,6 +1003,10 @@ def check_dangerous_command(command: str, env_type: str, logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200]) return _hardline_block_result(hardline_desc) + if _is_gateway_approval_context() and _is_gateway_systemctl_restart_command(command): + logger.warning("Gateway self-restart block: %s", command[:200]) + return _gateway_systemctl_restart_block_result() + # --yolo: bypass all approval prompts. Gateway /yolo is session-scoped; # CLI --yolo remains process-scoped via the env var for local use. if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled(): @@ -1212,6 +1248,10 @@ def check_all_command_guards(command: str, env_type: str, sudo_guess_desc, command[:200]) return _sudo_stdin_block_result(sudo_guess_desc) + if _is_gateway_approval_context() and _is_gateway_systemctl_restart_command(command): + logger.warning("Gateway self-restart block: %s", command[:200]) + return _gateway_systemctl_restart_block_result() + # --yolo or approvals.mode=off: bypass all approval prompts. # Gateway /yolo is session-scoped; CLI --yolo remains process-scoped. approval_mode = _get_approval_mode()