Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -555,6 +556,10 @@ export function ChatBar({
return
}

if (isImeComposing(event)) {
return
}

if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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') {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1195,6 +1196,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
)

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (isImeComposing(event)) {
return
}

if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/components/desktop-onboarding-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -551,7 +552,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
<Input
autoFocus
onChange={e => 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}
/>
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/lib/ime.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 14 additions & 0 deletions apps/desktop/src/lib/ime.ts
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 2 additions & 2 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2381,7 +2381,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
RestartMaxDelaySec=300

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not change this to KillMode=control-group. Current main intentionally preserves KillMode=mixed and adds ExecStopPost=-...gateway.cgroup_cleanup to reap orphaned per-PID processes after shutdown; see hermes_cli/gateway.py:2764-2768 and gateway/cgroup_cleanup.py:1-12 (commit e551da6ddb39a712c93e6c48a16aa748e2c7bc95).

RestartSteps=5
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillMode=control-group
KillSignal=SIGTERM
ExecReload=/bin/kill -USR1 $MAINPID
TimeoutStopSec={restart_timeout}
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions tests/tools/test_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =========================================================================
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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()
Expand Down