Skip to content
Closed
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
138 changes: 138 additions & 0 deletions apps/desktop/src/app/chat/composer/ime-submit-race.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useCallback, useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

afterEach(cleanup)

// Minimal harness that mirrors the IME composition + submitDraft flow from
// index.tsx. The key difference from ime-composition-dom-repro.test.tsx is
// that this harness exercises a `submitDraft`-equivalent function and
// verifies the *submitted* text (not just `hasPayload`).
//
// Regression repro for #40633: pressing Enter immediately after IME
// compositionend used to submit stale React state because
// `aui.composer().setText()` is async — the re-render hadn't committed yet.
function Harness({ onSubmit }: { onSubmit: (text: string) => void }) {
const editorRef = useRef<HTMLDivElement>(null)
const composingRef = useRef(false)
const draftRef = useRef('')
const [draft, setDraft] = useState('')

const flushEditorToDraft = useCallback((editor: HTMLDivElement) => {
const next = editor.textContent ?? ''
if (next !== draftRef.current) {
draftRef.current = next
setDraft(next)
}
}, [])

// Mirrors the fixed submitDraft: flush DOM → read draftRef, NOT React state.
const submitDraft = useCallback(() => {
if (editorRef.current) {
flushEditorToDraft(editorRef.current)
}
const currentDraft = draftRef.current
if (currentDraft.trim()) {
onSubmit(currentDraft)
}
}, [flushEditorToDraft, onSubmit])

return (
<div>
<div
contentEditable
data-testid="editor"
onCompositionEnd={event => {
composingRef.current = false
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onInput={event => {
if (composingRef.current) return
flushEditorToDraft(event.currentTarget)
}}
onKeyDown={event => {
if (composingRef.current || event.nativeEvent.isComposing) return
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitDraft()
}
}}
ref={editorRef}
suppressContentEditableWarning
/>
<span data-testid="draft">{draft}</span>
</div>
)
}

describe('IME composition — submitDraft race condition (#40633)', () => {
it('submits the full composed text when Enter is pressed right after compositionend', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} />)
const editor = getByTestId('editor')

// Simulate Korean IME: compositionstart → intermediate input →
// compositionend → immediate Enter (no trailing input event).
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '안녕'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
// Press Enter immediately — before React re-renders with the new draft.
fireEvent.keyDown(editor, { key: 'Enter' })
})

// Before the fix, submitted text was stale ("안" or "") because
// React state hadn't updated. After the fix, the full text is submitted.
expect(onSubmit).toHaveBeenCalledWith('안녕')
})

it('submits full Chinese text on immediate Enter after composition', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} />)
const editor = getByTestId('editor')

await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '你好世界'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})

expect(onSubmit).toHaveBeenCalledWith('你好世界')
})

it('submits full Japanese text on immediate Enter after composition', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} />)
const editor = getByTestId('editor')

await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = 'こんにちは'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})

expect(onSubmit).toHaveBeenCalledWith('こんにちは')
})

it('does not submit when composer is empty after composition', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(<Harness onSubmit={onSubmit} />)
const editor = getByTestId('editor')

await act(async () => {
fireEvent.compositionStart(editor)
fireEvent.compositionEnd(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})

expect(onSubmit).not.toHaveBeenCalled()
})
})
22 changes: 16 additions & 6 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,16 @@ export function ChatBar({
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps

const submitDraft = () => {
// Flush the live DOM content into draftRef before reading it. Without
// this, a fast Enter after IME compositionend can read stale React state
// (the async `aui.composer().setText` from compositionend hasn't committed
// yet), dropping the final composed character (#40633).
if (editorRef.current) {
flushEditorToDraft(editorRef.current)
}

const currentDraft = draftRef.current

if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
Expand All @@ -1222,23 +1232,23 @@ export function ChatBar({
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
if (!attachments.length && SLASH_COMMAND_RE.test(currentDraft.trim())) {
const submitted = currentDraft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (hasComposerPayload) {
} else if (currentDraft.trim() || attachments.length > 0) {
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
} else if (!currentDraft.trim() && !attachments.length && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
} else if (currentDraft.trim() || attachments.length > 0) {
const submitted = currentDraft
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
Expand Down
72 changes: 51 additions & 21 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ async def get_status():
# Module not importable yet (early startup) — leave as [].
pass

return {
result: dict[str, Any] = {
"version": __version__,
"release_date": __release_date__,
"hermes_home": str(get_hermes_home()),
Expand All @@ -849,6 +849,17 @@ async def get_status():
"auth_required": auth_required,
"auth_providers": auth_providers,
}
# Expose the ephemeral session token so remote Desktop clients (SSH
# tunnel, LAN) can auto-discover it instead of requiring manual
# copy-paste. Only surface when the OAuth gate is NOT active — gated
# gateways use ticket-based auth instead. The token is already sent
# on every /api/ request that passes auth_middleware; including it in
# the public status response simply avoids the chicken-and-egg problem
# where the Desktop needs the token to connect but can't fetch it
# without already having it. See GH #40391.
if not auth_required:
result["session_token"] = _SESSION_TOKEN
return result


@app.get("/api/system/stats")
Expand Down Expand Up @@ -7758,13 +7769,29 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
return _ws_host_origin_reason(ws) is None


def _ws_request_reason(ws: "WebSocket") -> Optional[str]:
"""First Host/Origin or peer-IP rejection reason, or None when allowed."""
def _ws_request_reason(ws: "WebSocket", *, token_ok: bool = False) -> Optional[str]:
"""First Host/Origin or peer-IP rejection reason, or None when allowed.

When *token_ok* is ``True`` the credential gate has already accepted the
request — the Host/Origin and peer-IP guards are DNS-rebinding defences
that protect the *credential* itself, but a valid token (32-byte random,
constant-time compared) is already unguessable, so the extra network-layer
checks are redundant and actively break remote-gateway / SSH-tunnel
scenarios where the Electron client can't satisfy both guards
simultaneously (see GH #38412, #40391).
"""
if token_ok:
return None
return _ws_host_origin_reason(ws) or _ws_client_reason(ws)


def _ws_request_is_allowed(ws: "WebSocket") -> bool:
"""Return True when the WebSocket upgrade matches dashboard boundaries."""
def _ws_request_is_allowed(ws: "WebSocket", *, token_ok: bool = False) -> bool:
"""Return True when the WebSocket upgrade matches dashboard boundaries.

See :func:`_ws_request_reason` for the *token_ok* bypass rationale.
"""
if token_ok:
return True
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)


Expand Down Expand Up @@ -8054,16 +8081,16 @@ async def pty_ws(ws: WebSocket) -> None:
await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
return

host_origin_reason = _ws_host_origin_reason(ws)
if host_origin_reason is not None:
_log.warning("pty refused: %s peer=%s", host_origin_reason, peer)
await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
return

client_reason = _ws_client_reason(ws)
if client_reason is not None:
_log.warning("pty refused: %s", client_reason)
await ws.close(code=4408, reason=_ws_close_reason(client_reason))
# A valid credential (token / ticket / internal) proves identity; the
# Host/Origin and peer-IP guards defend against DNS rebinding *stealing*
# that credential, but a 32-byte random token is already unguessable.
# Bypassing these guards when auth succeeded unblocks remote-gateway and
# SSH-tunnel setups where the Electron client can't satisfy both checks
# simultaneously (GH #38412, #40391).
request_reason = _ws_request_reason(ws, token_ok=True)
if request_reason is not None:
_log.warning("pty refused: %s peer=%s", request_reason, peer)
await ws.close(code=4403, reason=_ws_close_reason(request_reason))
return

await ws.accept()
Expand Down Expand Up @@ -8177,11 +8204,12 @@ async def gateway_ws(ws: WebSocket) -> None:
await ws.close(code=4403)
return

if not _ws_auth_ok(ws):
auth_reason, _cred = _ws_auth_reason(ws)
if auth_reason is not None:
await ws.close(code=4401)
return

if not _ws_request_is_allowed(ws):
if not _ws_request_is_allowed(ws, token_ok=True):
await ws.close(code=4403)
return

Expand All @@ -8208,11 +8236,12 @@ async def pub_ws(ws: WebSocket) -> None:
await ws.close(code=4403)
return

if not _ws_auth_ok(ws):
auth_reason, _cred = _ws_auth_reason(ws)
if auth_reason is not None:
await ws.close(code=4401)
return

if not _ws_request_is_allowed(ws):
if not _ws_request_is_allowed(ws, token_ok=True):
await ws.close(code=4403)
return

Expand All @@ -8236,11 +8265,12 @@ async def events_ws(ws: WebSocket) -> None:
await ws.close(code=4403)
return

if not _ws_auth_ok(ws):
auth_reason, _cred = _ws_auth_reason(ws)
if auth_reason is not None:
await ws.close(code=4401)
return

if not _ws_request_is_allowed(ws):
if not _ws_request_is_allowed(ws, token_ok=True):
await ws.close(code=4403)
return

Expand Down
Loading